Path: blob/master/src/packages/util/db-schema/projects.ts
5716 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45import { State } from "@cocalc/util/compute-states";6import { PurchaseInfo } from "@cocalc/util/licenses/purchase/types";7import { deep_copy } from "@cocalc/util/misc";8import {9ExecuteCodeOptions,10ExecuteCodeOptionsAsyncGet,11ExecuteCodeOutput,12} from "@cocalc/util/types/execute-code";13import { type RegistrationTokenCustomize } from "@cocalc/util/types/registration-token";14import { DEFAULT_QUOTAS } from "@cocalc/util/upgrade-spec";15import { isUserGroup } from "@cocalc/util/project-ownership";1617import { NOTES } from "./crm";18import { FALLBACK_COMPUTE_IMAGE } from "./defaults";19import { SCHEMA as schema } from "./index";20import { callback2 } from "@cocalc/util/async-utils";21import { Table } from "./types";2223export const MAX_FILENAME_SEARCH_RESULTS = 100;2425const PROJECTS_LIMIT = 300;26const PROJECTS_CUTOFF = "6 weeks";27const THROTTLE_CHANGES = 1000;2829Table({30name: "projects",31rules: {32primary_key: "project_id",33//# A lot depends on this being right at all times, e.g., restart state,34//# so do not use db_standby yet.35//# It is simply not robust enough.36//# db_standby : 'safer'3738pg_indexes: [39"last_edited",40"created", // TODO: this could have a fillfactor of 10041"USING GIN (users)", // so get_collaborator_ids is fast42"lti_id",43"USING GIN (state)", // so getting all running projects is fast (e.g. for site_license_usage_log... but also manage-state)44"((state #>> '{state}'))", // projecting the "state" (running, etc.) for its own index – the GIN index above still causes a scan, which we want to avoid.45"((state ->> 'state'))", // same reason as above. both syntaxes appear and we have to index both.46"((state IS NULL))", // not covered by the above47"((settings ->> 'always_running'))", // to quickly know which projects have this setting48"((run_quota ->> 'always_running'))", // same reason as above49"deleted", // in various queries we quickly fiter deleted projects50"site_license", // for queries across projects related to site_license#>>{license_id}51],5253crm_indexes: ["last_edited"],5455user_query: {56get: {57pg_where: [58`last_edited >= NOW() - interval '${PROJECTS_CUTOFF}'`,59"projects",60],61pg_where_load: ["last_edited >= NOW() - interval '7 days'", "projects"],62options: [{ limit: PROJECTS_LIMIT, order_by: "-last_edited" }],63options_load: [{ limit: 50, order_by: "-last_edited" }],64pg_changefeed: "projects",65throttle_changes: THROTTLE_CHANGES,66fields: {67project_id: null,68name: null,69title: "",70description: "",71users: {},72invite: null, // who has been invited to this project via email73invite_requests: null, // who has requested to be invited74deleted: null,75host: null,76settings: DEFAULT_QUOTAS,77run_quota: null,78site_license: null,79status: null,80manage_users_owner_only: null,81// security model is anybody with access to the project should be allowed to know this token.82secret_token: null,83state: null,84last_edited: null,85last_active: null,86action_request: null, // last requested action -- {action:?, time:?, started:?, finished:?, err:?}87course: null,88// if the value is not set, we have to use the old default prior to summer 2020 (Ubuntu 18.04, not 20.04!)89compute_image: FALLBACK_COMPUTE_IMAGE,90created: null,91ephemeral: null,92env: null,93sandbox: null,94avatar_image_tiny: null,95// do NOT add avatar_image_full here or it will get included in changefeeds, which we don't want.96// instead it gets its own virtual table.97color: null,98pay_as_you_go_quotas: null,99},100},101set: {102// NOTE: for security reasons users CANNOT set the course field via a user query;103// instead use the api/v2/projects/course/set-course-field api endpoint.104fields: {105project_id: "project_write",106title: true,107name: true,108description: true,109deleted: true,110invite_requests: true, // project collabs can modify this (e.g., to remove from it once user added or rejected)111users(obj, db, account_id) {112return db._user_set_query_project_users(obj, account_id);113},114manage_users_owner_only(obj, db, account_id) {115return db._user_set_query_project_manage_users_owner_only(116obj,117account_id,118);119},120action_request: true, // used to request that an action be performed, e.g., "save"; handled by before_change121compute_image: true,122site_license: true,123env: true,124sandbox: true,125avatar_image_tiny: true,126avatar_image_full: true,127color: true,128},129required_fields: {130project_id: true,131},132async check_hook(db, obj, account_id, _project_id, cb) {133// Validate manage_users_owner_only permission if it's being changed134if (obj.manage_users_owner_only !== undefined) {135try {136// Require actor identity before hitting the database137if (!account_id) {138throw Error(139"account_id is required to change manage_users_owner_only",140);141}142143const siteSettings =144(await callback2(db.get_server_settings_cached, {})) ?? {};145const siteEnforced = !!siteSettings.strict_collaborator_management;146if (siteEnforced && obj.manage_users_owner_only !== true) {147throw Error(148"Collaborator management is enforced by the site administrator and cannot be disabled.",149);150}151152const { rows } = await db.async_query({153query: "SELECT users FROM projects WHERE project_id = $1",154params: [obj.project_id],155});156const users = rows?.[0]?.users ?? {};157158// Check that the user making the change is an owner159const group = users?.[account_id]?.group;160if (!isUserGroup(group) || group !== "owner") {161throw Error(162"Only project owners can change collaborator management settings",163);164}165} catch (err) {166cb(err.toString());167return;168}169}170cb();171},172before_change(database, old_val, new_val, account_id, cb) {173database._user_set_query_project_change_before(174old_val,175new_val,176account_id,177cb,178);179},180181on_change(database, old_val, new_val, account_id, cb) {182database._user_set_query_project_change_after(183old_val,184new_val,185account_id,186cb,187);188},189},190},191192project_query: {193get: {194pg_where: [{ "project_id = $::UUID": "project_id" }],195fields: {196project_id: null,197title: null,198description: null,199status: null,200},201},202set: {203fields: {204project_id: "project_id",205title: true,206description: true,207status: true,208},209},210},211},212fields: {213project_id: {214type: "uuid",215desc: "The project id, which is the primary key that determines the project.",216},217name: {218type: "string",219pg_type: "VARCHAR(100)",220desc: "The optional name of this project. Must be globally unique (up to case) across all projects with a given *owner*. It can be between 1 and 100 characters from a-z A-Z 0-9 period and dash.",221render: { type: "text", maxLen: 100, editable: true },222},223title: {224type: "string",225desc: "The short title of the project. Should use no special formatting, except hashtags.",226render: { type: "project_link", project_id: "project_id" },227},228description: {229type: "string",230desc: "A longer textual description of the project. This can include hashtags and should be formatted using markdown.",231render: {232type: "markdown",233maxLen: 1024,234editable: true,235},236}, // markdown rendering possibly not implemented237users: {238title: "Collaborators",239type: "map",240desc: "This is a map from account_id's to {hide:bool, group:'owner'|'collaborator', upgrades:{memory:1000, ...}, ssh:{...}}.",241render: { type: "usersmap", editable: true },242},243manage_users_owner_only: {244type: "boolean",245desc: "If true, only project owners can add or remove collaborators. Collaborators can still remove themselves. Disabled by default (undefined or false means current behavior where collaborators can manage other collaborators).",246render: { type: "boolean", editable: true },247},248invite: {249type: "map",250desc: "Map from email addresses to {time:when invite sent, error:error message if there was one}",251date: ["time"],252},253invite_requests: {254type: "map",255desc: "This is a map from account_id's to {timestamp:?, message:'i want to join because...'}.",256date: ["timestamp"],257},258deleted: {259type: "boolean",260desc: "Whether or not this project is deleted.",261render: { type: "boolean", editable: true },262},263host: {264type: "map",265desc: "This is a map {host:'hostname_of_server', assigned:timestamp of when assigned to that server}.",266date: ["assigned"],267},268settings: {269type: "map",270desc: 'This is a map that defines the free base quotas that a project has. It is of the form {cores: 1.5, cpu_shares: 768, disk_quota: 1000, memory: 2000, mintime: 36000000, network: 0, ephemeral_state:0, ephemeral_disk:0, always_running:0}. WARNING: some of the values are strings not numbers in the database right now, e.g., disk_quota:"1000".',271},272site_license: {273type: "map",274desc: "This is a map that defines upgrades (just when running the project) that come from a site license, and also the licenses that are applied to this project. The format is {license_id:{memory:?, mintime:?, ...}} where the target of the license_id is the same as for the settings field. The license_id is the uuid of the license that contributed these upgrades. To tell cocalc to use a license for a project, a user sets site_license to {license_id:{}}, and when it is requested to start the project, the backend decides what allocation license_id provides and changes the field accordingly, i.e., changes {license_id:{},...} to {license_id:{memory:?,...},...}",275},276status: {277type: "map",278desc: "This is a map computed by the status command run inside a project, and slightly enhanced by the compute server, which gives extensive status information about a project. See the exported ProjectStatus interface defined in the code here.",279},280state: {281type: "map",282desc: 'Info about the state of this project of the form {error: "", state: "running" (etc), time: timestamp, ip?:"ip address where project is"}, where time is when the state was last computed. See COMPUTE_STATES in the compute-states file for state.state and the ProjectState interface defined below in code.',283date: ["time"],284},285last_edited: {286type: "timestamp",287desc: "The last time some file was edited in this project. This is the last time that the file_use table was updated for this project.",288},289last_started: {290type: "timestamp",291desc: "The last time the project started running.",292},293last_active: {294type: "map",295desc: "Map from account_id's to the timestamp of when the user with that account_id touched this project.",296date: "all",297},298created: {299type: "timestamp",300desc: "When the project was created.",301},302ephemeral: {303type: "number",304desc: "If set, number of milliseconds this project may exist after creation.",305},306action_request: {307type: "map",308desc: "Request state change action for project: {action:['start', 'stop'], started:timestamp, err:?, finished:timestamp}",309date: ["started", "finished"],310},311storage: {312type: "map",313desc: "(DEPRECATED) This is a map {host:'hostname_of_server', assigned:when first saved here, saved:when last saved here}.",314date: ["assigned", "saved"],315},316last_backup: {317type: "timestamp",318desc: "(DEPRECATED) Timestamp of last off-disk successful backup using bup to Google cloud storage",319},320storage_request: {321type: "map",322desc: "(DEPRECATED) {action:['save', 'close', 'move', 'open'], requested:timestap, pid:?, target:?, started:timestamp, finished:timestamp, err:?}",323date: ["started", "finished", "requested"],324},325course: {326type: "map",327desc: "{project_id:[id of project that contains .course file], path:[path to .course file], pay:?, payInfo:?, email_address:[optional email address of student -- used if account_id not known], account_id:[account id of student]}, where pay is either not set (or equals falseish) or is a timestamp by which the students must pay. If payInfo is set, it specifies the parameters of the license the students should purchase.",328date: ["pay"],329},330storage_server: {331type: "integer",332desc: "(DEPRECATED) Number of the Kubernetes storage server with the data for this project: one of 0, 1, 2, ...",333},334storage_ready: {335type: "boolean",336desc: "(DEPRECATED) Whether storage is ready to be used on the storage server. Do NOT try to start project until true; this gets set by storage daemon when it notices that run is true.",337},338disk_size: {339type: "integer",340desc: "Size in megabytes of the project disk.",341},342resources: {343type: "map",344desc: 'Object of the form {requests:{memory:"30Mi",cpu:"5m"}, limits:{memory:"100Mi",cpu:"300m"}} which is passed to the k8s resources section for this pod.',345},346preemptible: {347type: "boolean",348desc: "If true, allow to run on preemptible nodes.",349},350idle_timeout: {351type: "integer",352desc: "If given and nonzero, project will be killed if it is idle for this many **minutes**, where idle *means* that last_edited has not been updated.",353},354run_quota: {355type: "map",356desc: "If project is running, this is the quota that it is running with.",357},358compute_image: {359type: "string",360desc: "Specify the name of the underlying (kucalc) compute image.",361},362addons: {363type: "map",364desc: "Configure (kucalc specific) addons for projects. (e.g. academic software, license keys, ...)",365},366lti_id: {367type: "array",368pg_type: "TEXT[]",369desc: "This is a specific ID derived from an LTI context",370},371lti_data: {372type: "map",373desc: "extra information related to LTI",374},375env: {376type: "map",377desc: "Additional environment variables (TS: {[key:string]:string})",378render: { type: "json", editable: true },379},380sandbox: {381type: "boolean",382desc: "If set to true, then any user who attempts to access this project is automatically added as a collaborator to it. Only the project owner can change this setting.",383render: { type: "boolean", editable: true },384},385avatar_image_tiny: {386title: "Image",387type: "string",388desc: "tiny (32x32) visual image associated with the project. Suitable to include as part of changefeed, since about 3kb.",389render: { type: "image" },390},391avatar_image_full: {392title: "Image",393type: "string",394desc: "A visual image associated with the project. Could be 150kb. NOT include as part of changefeed of projects, since potentially big (e.g., 200kb x 1000 projects = 200MB!).",395render: { type: "image" },396},397color: {398title: "Color",399type: "string",400desc: "Optional color associated with the project, used for visual identification (e.g., border color in project list).",401render: { type: "text" },402},403pay_as_you_go_quotas: {404type: "map",405desc: "Pay as you go quotas that users set so that when they run this project, it gets upgraded to at least what is specified here, and user gets billed later for what is used. Any changes to this table could result in money being spent, so should only be done via the api. This is a map from the account_id of the user that set the quota to the value of the quota spec (which is purchase-quotas.ProjectQuota).",406render: { type: "json", editable: false },407},408notes: NOTES,409secret_token: {410type: "string",411pg_type: "VARCHAR(256)",412desc: "Random ephemeral secret token used temporarily by project to authenticate with hub.",413},414},415});416417export interface ApiKeyInfo {418name: string;419trunc: string;420hash?: string;421used?: number;422}423424// Same query above, but without the last_edited time constraint.425schema.projects_all = deep_copy(schema.projects);426if (427schema.projects_all.user_query?.get == null ||428schema.projects.user_query?.get == null429) {430throw Error("make typescript happy");431}432schema.projects_all.user_query.get.options = [];433schema.projects_all.user_query.get.options_load = [];434schema.projects_all.virtual = "projects";435schema.projects_all.user_query.get.pg_where = ["projects"];436schema.projects_all.user_query.get.pg_where_load = ["projects"];437438// Table that provides extended read info about a single project439// but *ONLY* for admin.440Table({441name: "projects_admin",442fields: schema.projects.fields,443rules: {444primary_key: schema.projects.primary_key,445virtual: "projects",446user_query: {447get: {448admin: true, // only admins can do get queries on this table449// (without this, users who have read access could read)450pg_where: [{ "project_id = $::UUID": "project_id" }],451fields: schema.projects.user_query.get.fields,452},453},454},455});456457/*458Table that enables set queries to the course field of a project. Only459project owners are allowed to use this table. The point is that this makes460it possible for the owner of the project to set things, but not for the461collaborators to set those things.462**wARNING:** right now we're not using this since when multiple people add463students to a course and the 'course' field doesn't get properly set,464much confusion and misery arises.... and it is very hard to fix.465In theory a malicous student could not pay via this. But if they could466mess with their client, they could easily not pay anyways.467*/468Table({469name: "projects_owner",470rules: {471virtual: "projects",472user_query: {473set: {474fields: {475project_id: "project_owner",476course: true,477},478},479},480},481fields: {482project_id: true,483course: true,484},485});486487/*488489Table that enables any signed-in user to set an invite request.490Later: we can make an index so that users can see all outstanding requests they have made easily.491How to test this from the browser console:492project_id = '4e0f5bfd-3f1b-4d7b-9dff-456dcf8725b8' // id of a project you have493invite_requests = {}; invite_requests[smc.client.account_id] = {timestamp:new Date(), message:'please invite me'}494smc.client.query({cb:console.log, query:{project_invite_requests:{project_id:project_id, invite_requests:invite_requests}}}) // set it495smc.redux.getStore('projects').get_project(project_id).invite_requests // see requests for this project496497CURRENTLY NOT USED, but probably will be...498499database._user_set_query_project_invite_requests(old_val, new_val, account_id, cb)500For now don't check anything -- this is how we will make it secure later.501This will:502- that user setting this is signed in503- ensure user only modifies their own entry (for their own id).504- enforce some hard limit on number of outstanding invites (say 30).505- enforce limit on size of invite message.506- sanity check on timestamp507- with an index as mentioned above we could limit the number of projects508to which a single user has requested to be invited.509510*/511Table({512name: "project_invite_requests",513rules: {514virtual: "projects",515primary_key: "project_id",516user_query: {517set: {518fields: {519project_id: true,520invite_requests: true,521},522before_change(_database, _old_val, _new_val, _account_id, cb) {523cb();524},525},526},527}, // actual function will be database._user...528fields: {529project_id: true,530invite_requests: true,531}, // {account_id:{timestamp:?, message:?}, ...}532});533534/*535Virtual table to get project avatar_images.536We don't put this in the main projects table,537since we don't want the avatar_image_full to be538the projects queries or changefeeds, since it539is big, and by default all get fields appear there.540*/541542Table({543name: "project_avatar_images",544rules: {545virtual: "projects",546primary_key: "project_id",547user_query: {548get: {549pg_where: ["projects"],550fields: {551project_id: null,552avatar_image_full: null,553},554},555},556},557fields: {558project_id: true,559avatar_image_full: true,560},561});562563/*564Table to get/set the datastore config in addons.565566The main idea is to set/update/delete entries in the dict addons.datastore.[key] = {...}567*/568Table({569name: "project_datastore",570rules: {571virtual: "projects",572primary_key: "project_id",573user_query: {574set: {575// this also deals with delete requests576fields: {577project_id: true,578addons: true,579},580async instead_of_change(581db,582_old_value,583new_val,584account_id,585cb,586): Promise<void> {587try {588// to delete an entry, pretend to set the datastore = {delete: [name]}589if (typeof new_val.addons.datastore.delete === "string") {590await db.project_datastore_del(591account_id,592new_val.project_id,593new_val.addons.datastore.delete,594);595cb(undefined);596} else {597// query should set addons.datastore.[new key] = config, such that we see here598// new_val = {"project_id":"...","addons":{"datastore":{"key3":{"type":"xxx", ...}}}}599// which will be merged into the existing addons.datastore dict600const res = await db.project_datastore_set(601account_id,602new_val.project_id,603new_val.addons.datastore,604);605cb(undefined, res);606}607} catch (err) {608cb(`${err}`);609}610},611},612get: {613fields: {614project_id: true,615addons: true,616},617async instead_of_query(db, opts, cb): Promise<void> {618if (opts.multi) {619throw Error("'multi' is not implemented");620}621try {622// important: the config dicts for each key must not expose secret credentials!623// check if opts.query.addons === null ?!624const data = await db.project_datastore_get(625opts.account_id,626opts.query.project_id,627);628cb(undefined, data);629} catch (err) {630cb(`${err}`);631}632},633},634},635},636fields: {637project_id: true,638addons: true,639},640});641642export interface ProjectStatus {643"project.pid"?: number; // pid of project server process644"hub-server.port"?: number; // port of tcp server that is listening for conn from hub645"browser-server.port"?: number; // port listening for http/websocket conn from browser client646"sage_server.port"?: number; // port where sage server is listening.647"sage_server.pid"?: number; // pid of sage server process648start_ts?: number; // timestamp, when project server started649session_id?: string; // unique identifyer650version?: number; // version number of project code651disk_MB?: number; // MB of used disk652installed?: boolean; // whether code is installed653memory?: {654count?: number;655pss?: number;656rss?: number;657swap?: number;658uss?: number;659}; // output by smem660}661662export interface ProjectState {663ip?: string; // where the project is running664error?: string;665state?: State; // running, stopped, etc.666time?: Date;667}668669Table({670name: "crm_projects",671fields: schema.projects.fields,672rules: {673primary_key: schema.projects.primary_key,674virtual: "projects",675user_query: {676get: {677admin: true, // only admins can do get queries on this table678// (without this, users who have read access could read)679pg_where: [],680fields: {681...schema.projects.user_query?.get?.fields,682notes: null,683},684},685set: {686admin: true,687fields: {688project_id: true,689name: true,690title: true,691description: true,692deleted: true,693notes: true,694},695},696},697},698});699700export type Datastore = boolean | string[] | undefined;701702// in the future, we might want to extend this to include custom environmment variables703export interface EnvVarsRecord {704inherit?: boolean;705}706export type EnvVars = EnvVarsRecord | undefined;707708export interface StudentProjectFunctionality {709disableActions?: boolean;710disableJupyterToggleReadonly?: boolean;711disableJupyterClassicServer?: boolean;712disableJupyterClassicMode?: boolean;713disableJupyterLabServer?: boolean;714disableRServer?: boolean;715disableVSCodeServer?: boolean;716disableLibrary?: boolean;717disableNetworkWarningBanner?: boolean;718disablePlutoServer?: boolean;719disableTerminals?: boolean;720disableUploads?: boolean;721disableNetwork?: boolean;722disableSSH?: boolean;723disableCollaborators?: boolean;724disableChatGPT?: boolean;725disableSharing?: boolean;726}727728export interface CourseInfo {729type: "student" | "shared" | "nbgrader";730account_id?: string; // account_id of the student that this project is for.731project_id: string; // the course project, i.e., project with the .course file732path: string; // path to the .course file in project_id733pay?: string; // iso timestamp or ""734paid?: string; // iso timestamp with *when* they paid.735purchase_id?: number; // id of purchase record in purchases table.736payInfo?: PurchaseInfo;737email_address?: string;738datastore: Datastore;739student_project_functionality?: StudentProjectFunctionality;740envvars?: EnvVars;741}742743type ExecOptsCommon = {744project_id: string;745cb?: Function; // if given use a callback interface *instead* of async.746};747748export type ExecOptsBlocking = ExecOptsCommon & {749compute_server_id?: number; // if true, run on the compute server (if available)750filesystem?: boolean; // run in fileserver container on compute server; otherwise, runs on main compute container.751path?: string;752command: string;753args?: string[];754timeout?: number;755max_output?: number;756bash?: boolean;757aggregate?: string | number | { value: string | number };758err_on_exit?: boolean;759env?: { [key: string]: string }; // custom environment variables.760async_call?: ExecuteCodeOptions["async_call"];761};762763export type ExecOptsAsync = ExecOptsCommon & {764async_get?: ExecuteCodeOptionsAsyncGet["async_get"];765async_stats?: ExecuteCodeOptionsAsyncGet["async_stats"];766async_await?: ExecuteCodeOptionsAsyncGet["async_await"];767};768769export type ExecOpts = ExecOptsBlocking | ExecOptsAsync;770771export function isExecOptsBlocking(opts: unknown): opts is ExecOptsBlocking {772return (773typeof opts === "object" &&774typeof (opts as any).project_id === "string" &&775typeof (opts as any).command === "string"776);777}778779export type ExecOutput = ExecuteCodeOutput & {780time: number; // time in ms, from user point of view.781};782783export interface CreateProjectOptions {784account_id?: string;785title?: string;786description?: string;787// (optional) image ID788image?: string;789// (optional) license id (or multiple ids separated by commas) -- if given, project will be created with this license790license?: string;791public_path_id?: string; // may imply use of a license792// noPool = do not allow using the pool (e.g., need this when creating projects to put in the pool);793// not a real issue since when creating for pool account_id is null, and then we wouldn't use the pool...794noPool?: boolean;795// start running the moment the project is created -- uses more resources, but possibly better user experience796start?: boolean;797798// admins can specify the project_id - nobody else can -- useful for debugging.799project_id?: string;800// if set, project should be treated as expiring after this many milliseconds since creation801ephemeral?: number;802// account customization settings to apply to project (e.g., disableInternet)803customize?: RegistrationTokenCustomize;804}805806interface BaseCopyOptions {807target_project_id?: string;808target_path?: string; // path into project; if not given, defaults to source path above.809overwrite_newer?: boolean; // if true, newer files in target are copied over (otherwise, uses rsync's --update)810delete_missing?: boolean; // if true, delete files in dest path not in source, **including** newer files811backup?: boolean; // make backup files812timeout?: number; // in **seconds**, not milliseconds813bwlimit?: number;814wait_until_done?: boolean; // by default, wait until done. false only gives the ID to query the status later815scheduled?: string | Date; // kucalc only: string (parseable by new Date()), or a Date816public?: boolean; // kucalc only: if true, may use the share server files rather than start the source project running817exclude?: string[]; // options passed to rsync via --exclude818}819export interface UserCopyOptions extends BaseCopyOptions {820account_id?: string;821src_project_id: string;822src_path: string;823// simulate copy taking at least this long -- useful for dev/debugging.824debug_delay_ms?: number;825}826827// for copying files within and between projects828export interface CopyOptions extends BaseCopyOptions {829path: string;830}831832833