Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/client/project.ts
5738 views
1
/*
2
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
/*
7
Functionality that mainly involves working with a specific project.
8
*/
9
10
import { throttle } from "lodash";
11
import { join } from "path";
12
13
import { readFile, type ReadFileOptions } from "@cocalc/conat/files/read";
14
import { writeFile, type WriteFileOptions } from "@cocalc/conat/files/write";
15
import { projectSubject, EXEC_STREAM_SERVICE } from "@cocalc/conat/names";
16
import { redux } from "@cocalc/frontend/app-framework";
17
import { appBasePath } from "@cocalc/frontend/customize/app-base-path";
18
import { dialogs } from "@cocalc/frontend/i18n";
19
import { getIntl } from "@cocalc/frontend/i18n/get-intl";
20
import { allow_project_to_run } from "@cocalc/frontend/project/client-side-throttle";
21
import { ensure_project_running } from "@cocalc/frontend/project/project-start-warning";
22
import { API } from "@cocalc/frontend/project/websocket/api";
23
import { connection_to_project } from "@cocalc/frontend/project/websocket/connect";
24
import {
25
Configuration,
26
ConfigurationAspect,
27
} from "@cocalc/frontend/project_configuration";
28
import { HOME_ROOT } from "@cocalc/util/consts/files";
29
import type { ApiKey } from "@cocalc/util/db-schema/api-keys";
30
import {
31
ExecOptsBlocking,
32
isExecOptsBlocking,
33
type ExecOpts,
34
type ExecOutput,
35
} from "@cocalc/util/db-schema/projects";
36
import {
37
coerce_codomain_to_numbers,
38
copy_without,
39
defaults,
40
encode_path,
41
is_valid_uuid_string,
42
required,
43
} from "@cocalc/util/misc";
44
import { reuseInFlight } from "@cocalc/util/reuse-in-flight";
45
import { DirectoryListingEntry } from "@cocalc/util/types";
46
import { WebappClient } from "./client";
47
import { ExecStream } from "./types";
48
49
export class ProjectClient {
50
private client: WebappClient;
51
private touch_throttle: { [project_id: string]: number } = {};
52
53
constructor(client: WebappClient) {
54
this.client = client;
55
}
56
57
private conatApi = (project_id: string) => {
58
return this.client.conat_client.projectApi({ project_id });
59
};
60
61
// This can write small text files in one message.
62
write_text_file = async (opts): Promise<void> => {
63
await this.writeFile(opts);
64
};
65
66
// writeFile -- easily write **arbitrarily large text or binary files**
67
// to a project from a readable stream or a string!
68
writeFile = async (
69
opts: WriteFileOptions & { content?: string },
70
): Promise<{ bytes: number; chunks: number }> => {
71
if (opts.content != null) {
72
// @ts-ignore -- typescript doesn't like this at all, but it works fine.
73
opts.stream = new Blob([opts.content], { type: "text/plain" }).stream();
74
}
75
return await writeFile(opts);
76
};
77
78
// readFile -- read **arbitrarily large text or binary files**
79
// from a project via a readable stream.
80
// Look at the code below if you want to stream a file for memory
81
// efficiency...
82
readFile = async (opts: ReadFileOptions): Promise<Buffer> => {
83
const chunks: Uint8Array[] = [];
84
for await (const chunk of await readFile(opts)) {
85
chunks.push(chunk);
86
}
87
return Buffer.concat(chunks);
88
};
89
90
read_text_file = async ({
91
project_id,
92
path,
93
}: {
94
project_id: string; // string or array of strings
95
path: string; // string or array of strings
96
}): Promise<string> => {
97
return await this.conatApi(project_id).system.readTextFileFromProject({
98
path,
99
});
100
};
101
102
// Like "read_text_file" above, except the callback
103
// message gives a url from which the file can be
104
// downloaded using standard AJAX.
105
read_file = (opts: {
106
project_id: string; // string or array of strings
107
path: string; // string or array of strings
108
compute_server_id?: number;
109
}): string => {
110
const base_path = appBasePath;
111
if (opts.path[0] === "/") {
112
// absolute path to the root
113
opts.path = HOME_ROOT + opts.path; // use root symlink, which is created by start_smc
114
}
115
let url = join(
116
base_path,
117
`${opts.project_id}/files/${encode_path(opts.path)}`,
118
);
119
if (opts.compute_server_id) {
120
url += `?id=${opts.compute_server_id}`;
121
}
122
return url;
123
};
124
125
copy_path_between_projects = async (opts: {
126
src_project_id: string; // id of source project
127
src_path: string; // relative path of director or file in the source project
128
target_project_id: string; // if of target project
129
target_path?: string; // defaults to src_path
130
overwrite_newer?: boolean; // overwrite newer versions of file at destination (destructive)
131
delete_missing?: boolean; // delete files in dest that are missing from source (destructive)
132
backup?: boolean; // make ~ backup files instead of overwriting changed files
133
timeout?: number; // **timeout in milliseconds** -- how long to wait for the copy to complete before reporting "error" (though it could still succeed)
134
exclude?: string[]; // list of patterns to exclude; this uses exactly the (confusing) rsync patterns
135
}): Promise<void> => {
136
await this.client.conat_client.hub.projects.copyPathBetweenProjects(opts);
137
};
138
139
// Set a quota parameter for a given project.
140
// As of now, only user in the admin group can make these changes.
141
set_quotas = async (opts: {
142
project_id: string;
143
memory?: number;
144
memory_request?: number;
145
cpu_shares?: number;
146
cores?: number;
147
disk_quota?: number;
148
mintime?: number;
149
network?: number;
150
member_host?: number;
151
always_running?: number;
152
}): Promise<void> => {
153
// we do some extra work to ensure all the quotas are numbers (typescript isn't
154
// enough; sometimes client code provides strings, which can cause lots of trouble).
155
const x = coerce_codomain_to_numbers(copy_without(opts, ["project_id"]));
156
await this.client.conat_client.hub.projects.setQuotas({
157
...x,
158
project_id: opts.project_id,
159
});
160
};
161
162
websocket = async (project_id: string): Promise<any> => {
163
const store = redux.getStore("projects");
164
// Wait until project is running (or admin and not on project)
165
await store.async_wait({
166
until: () => {
167
const state = store.get_state(project_id);
168
if (state == null && redux.getStore("account")?.get("is_admin")) {
169
// is admin so doesn't know project state -- just immediately
170
// try, which will cause project to run
171
return true;
172
}
173
return state == "running";
174
},
175
});
176
177
// get_my_group returns undefined when the various info to
178
// determine this isn't yet loaded. For some connections
179
// this websocket function gets called before that info is
180
// loaded, which can cause trouble.
181
let group: string | undefined;
182
await store.async_wait({
183
until: () => (group = store.get_my_group(project_id)) != null,
184
});
185
if (group == "public") {
186
throw Error("no access to project websocket");
187
}
188
return await connection_to_project(project_id);
189
};
190
191
api = async (project_id: string): Promise<API> => {
192
return (await this.websocket(project_id)).api;
193
};
194
195
/*
196
Execute code in a given project or associated compute server with streaming output.
197
This streams stdout and stderr back to the client in real-time, similar to how
198
LLM chat streaming works.
199
200
Example usage:
201
202
const stream = cc.client.project_client.execStream({
203
project_id: cc.current().project_id,
204
bash: true,
205
command: "for i in {1..15}; do echo \"Processing step $i...\"; python3 -c \"sum(x*x for x in range(10000000))\"; sleep 0.5; done;",
206
path: "."
207
});
208
209
stream.on("job", (data) => console.log("Job started:", data));
210
stream.on("stdout", (data) => console.log("Real-time stdout:", data));
211
stream.on("stderr", (data) => console.log("Real-time stderr:", data));
212
stream.on("stats", (data) => console.log("Stats update:", data));
213
stream.on("done", (result) => console.log("Final result:", result));
214
stream.on("error", (err) => console.log("Error:", err));
215
*/
216
execStream = (
217
opts: ExecOptsBlocking & {
218
startStreamExplicitly?: boolean;
219
debug?: string;
220
},
221
startExplicitly = false,
222
): ExecStream => {
223
const execStream = new ExecStream();
224
(async () => {
225
try {
226
await this.execStreamAsync({ ...opts, execStream });
227
if (!startExplicitly) {
228
execStream.emit("start");
229
}
230
} catch (err) {
231
execStream.emit("error", err);
232
}
233
})();
234
return execStream;
235
};
236
237
private async execStreamAsync({
238
execStream,
239
debug,
240
...opts
241
}: ExecOptsBlocking & {
242
execStream: ExecStream;
243
debug?: string;
244
}): Promise<void> {
245
if (
246
!(await ensure_project_running(opts.project_id, "Streaming execution"))
247
) {
248
execStream.emit(
249
"error",
250
new Error("Project must be running to stream execution"),
251
);
252
return;
253
}
254
255
execStream.once("start", async () => {
256
try {
257
// Use conat streaming similar to LLM streaming
258
await this.streamExecViaConat({ opts, execStream, debug });
259
} catch (err) {
260
execStream.emit("error", err);
261
}
262
});
263
}
264
265
private async streamExecViaConat({
266
opts,
267
execStream,
268
debug,
269
}: {
270
opts: ExecOptsBlocking;
271
execStream: ExecStream;
272
debug?: string;
273
}): Promise<void> {
274
try {
275
// Use conat to connect to the project exec-stream service
276
const cn = await this.client.conat_client.conat();
277
const subject = projectSubject({
278
project_id: opts.project_id,
279
compute_server_id: opts.compute_server_id ?? 0,
280
service: EXEC_STREAM_SERVICE,
281
});
282
let lastSeq = -1;
283
284
const req = cn.requestMany(
285
subject,
286
{ ...opts, debug },
287
{
288
maxWait: (opts.timeout ?? 300) * 1000,
289
waitForInterest: true,
290
},
291
);
292
for await (const resp of await req) {
293
if (resp.data == null) {
294
// Stream ended
295
execStream.emit("end");
296
break;
297
}
298
299
const { error, type, data, seq } = resp.data;
300
301
if (error) {
302
execStream.emit("error", new Error(error));
303
break;
304
}
305
306
if (seq != null && lastSeq + 1 != seq) {
307
execStream.emit("error", new Error("Missed response in stream"));
308
break;
309
}
310
311
if (seq != null) {
312
lastSeq = seq;
313
}
314
315
// Handle different types of streaming data
316
switch (type) {
317
case "job":
318
execStream.job_id = data.job_id;
319
execStream.emit("job", data);
320
break;
321
case "stdout":
322
execStream.emit("stdout", data);
323
break;
324
case "stderr":
325
execStream.emit("stderr", data);
326
break;
327
case "stats":
328
execStream.emit("stats", data);
329
break;
330
case "done":
331
execStream.emit("done", data);
332
execStream.emit("end");
333
break;
334
default:
335
console.warn("Unknown execStream response type:", type);
336
}
337
}
338
} catch (err) {
339
execStream.emit("error", err);
340
}
341
}
342
343
/*
344
Execute code in a given project or associated compute server.
345
346
Aggregate option -- use like this:
347
348
webapp.exec
349
aggregate: timestamp (or something else sequential)
350
351
means: if there are multiple attempts to run the given command with the same
352
time, they are all aggregated and run only one time by the project. If requests
353
comes in with a newer time, they all run in another group after the first
354
one finishes. The timestamp will usually come from something like the "last save
355
time" (which is stored in the db), which they client will know. This is used, e.g.,
356
for operations like "run rst2html on this file whenever it is saved."
357
*/
358
exec = async (opts: ExecOpts & { post?: boolean }): Promise<ExecOutput> => {
359
if ("async_get" in opts) {
360
opts = defaults(opts, {
361
project_id: required,
362
compute_server_id: undefined,
363
async_get: required,
364
async_stats: undefined,
365
async_await: undefined,
366
post: false, // if true, uses the POST api through nextjs instead of the websocket api.
367
timeout: 30,
368
cb: undefined,
369
});
370
} else {
371
opts = defaults(opts, {
372
project_id: required,
373
compute_server_id: undefined,
374
filesystem: undefined,
375
path: "",
376
command: required,
377
args: [],
378
max_output: undefined,
379
bash: false,
380
aggregate: undefined,
381
err_on_exit: true,
382
env: undefined,
383
post: false, // if true, uses the POST api through nextjs instead of the websocket api.
384
async_call: undefined, // if given use a callback interface instead of async
385
timeout: 30,
386
cb: undefined,
387
});
388
}
389
390
const intl = await getIntl();
391
const msg = intl.formatMessage(dialogs.client_project_exec_msg, {
392
blocking: isExecOptsBlocking(opts),
393
arg: isExecOptsBlocking(opts) ? opts.command : opts.async_get,
394
});
395
396
if (!(await ensure_project_running(opts.project_id, msg))) {
397
return {
398
type: "blocking",
399
stdout: "",
400
stderr: intl.formatMessage(dialogs.client_project_exec_start_first),
401
exit_code: 1,
402
time: 0,
403
};
404
}
405
406
try {
407
const ws = await this.websocket(opts.project_id);
408
const exec_opts = copy_without(opts, ["project_id", "cb"]);
409
const msg = await ws.api.exec(exec_opts);
410
if (msg.status && msg.status == "error") {
411
throw new Error(msg.error);
412
}
413
if (msg.type === "blocking") {
414
delete msg.status;
415
}
416
delete msg.error;
417
if (opts.cb == null) {
418
return msg;
419
} else {
420
opts.cb(undefined, msg);
421
return msg;
422
}
423
} catch (err) {
424
if (opts.cb == null) {
425
throw err;
426
} else {
427
if (!err.message) {
428
// Important since err.message can be falsey, e.g., for Error(''), but toString will never be falsey.
429
opts.cb(err.toString());
430
} else {
431
opts.cb(err.message);
432
}
433
return {
434
type: "blocking",
435
stdout: "",
436
stderr: err.message,
437
exit_code: 1,
438
time: 0, // should be ignored; this is just to make typescript happy.
439
};
440
}
441
}
442
};
443
444
// Directly compute the directory listing. No caching or other information
445
// is used -- this just sends a message over the websocket requesting
446
// the backend node.js project process to compute the listing.
447
directory_listing = async (opts: {
448
project_id: string;
449
path: string;
450
compute_server_id: number;
451
timeout?: number;
452
hidden?: boolean;
453
}): Promise<{ files: DirectoryListingEntry[] }> => {
454
if (opts.timeout == null) opts.timeout = 15;
455
const api = await this.api(opts.project_id);
456
const listing = await api.listing(
457
opts.path,
458
opts.hidden,
459
opts.timeout * 1000,
460
opts.compute_server_id,
461
);
462
return { files: listing };
463
};
464
465
find_directories = async (opts: {
466
project_id: string;
467
query?: string; // see the -iwholename option to the UNIX find command.
468
path?: string; // Root path to find directories from
469
exclusions?: string[]; // paths relative to `opts.path`. Skips whole sub-trees
470
include_hidden?: boolean;
471
}): Promise<{
472
query: string;
473
path: string;
474
project_id: string;
475
directories: string[];
476
}> => {
477
opts = defaults(opts, {
478
project_id: required,
479
query: "*", // see the -iwholename option to the UNIX find command.
480
path: ".", // Root path to find directories from
481
exclusions: undefined, // Array<String> Paths relative to `opts.path`. Skips whole sub-trees
482
include_hidden: false,
483
});
484
if (opts.path == null || opts.query == null)
485
throw Error("bug -- cannot happen");
486
487
const args: string[] = [
488
opts.path,
489
"-xdev",
490
"!",
491
"-readable",
492
"-prune",
493
"-o",
494
"-type",
495
"d",
496
"-iwholename", // See https://github.com/sagemathinc/cocalc/issues/5502
497
`'${opts.query}'`,
498
"-readable",
499
];
500
if (opts.exclusions != null) {
501
for (const excluded_path of opts.exclusions) {
502
args.push(
503
`-a -not \\( -path '${opts.path}/${excluded_path}' -prune \\)`,
504
);
505
}
506
}
507
508
args.push("-print");
509
const command = `find ${args.join(" ")}`;
510
511
const result = await this.exec({
512
// err_on_exit = false: because want this to still work even if there's a nonzero exit code,
513
// which might happen if find hits a directory it can't read, e.g., a broken ~/.snapshots.
514
err_on_exit: false,
515
project_id: opts.project_id,
516
command,
517
timeout: 60,
518
aggregate: Math.round(Date.now() / 5000), // aggregate calls into 5s windows, in case multiple clients ask for same find at once...
519
});
520
const n = opts.path.length + 1;
521
let v = result.stdout.split("\n");
522
if (!opts.include_hidden) {
523
v = v.filter((x) => x.indexOf("/.") === -1);
524
}
525
v = v.filter((x) => x.length > n).map((x) => x.slice(n));
526
return {
527
query: opts.query,
528
path: opts.path,
529
project_id: opts.project_id,
530
directories: v,
531
};
532
};
533
534
// This is async, so do "await smc_webapp.configuration(...project_id...)".
535
// for reuseInFlight, see https://github.com/sagemathinc/cocalc/issues/7806
536
configuration = reuseInFlight(
537
async (
538
project_id: string,
539
aspect: ConfigurationAspect,
540
no_cache: boolean,
541
): Promise<Configuration> => {
542
if (!is_valid_uuid_string(project_id)) {
543
throw Error("project_id must be a valid uuid");
544
}
545
return (await this.api(project_id)).configuration(aspect, no_cache);
546
},
547
);
548
549
touch_project = async (
550
// project_id where activity occurred
551
project_id: string,
552
// optional global id of a compute server (in the given project), in which case we also mark
553
// that compute server as active, which keeps it running in case it has idle timeout configured.
554
compute_server_id?: number,
555
): Promise<void> => {
556
if (!is_valid_uuid_string(project_id)) {
557
console.warn("WARNING -- touch_project takes a project_id, but got ", {
558
project_id,
559
});
560
}
561
if (compute_server_id) {
562
// this is throttled, etc. and is independent of everything below.
563
touchComputeServer({
564
project_id,
565
compute_server_id,
566
client: this.client,
567
});
568
// that said, we do still touch the project, since if a user is actively
569
// using a compute server, the project should also be considered active.
570
}
571
572
const state = redux.getStore("projects")?.get_state(project_id);
573
if (!(state == null && redux.getStore("account")?.get("is_admin"))) {
574
// not trying to view project as admin so do some checks
575
if (!(await allow_project_to_run(project_id))) return;
576
if (!this.client.is_signed_in()) {
577
// silently ignore if not signed in
578
return;
579
}
580
if (state != "running") {
581
// not running so don't touch (user must explicitly start first)
582
return;
583
}
584
}
585
586
// Throttle -- so if this function is called with the same project_id
587
// twice in 3s, it's ignored (to avoid unnecessary network traffic).
588
// Do not make the timeout long, since that can mess up
589
// getting the hub-websocket to connect to the project.
590
const last = this.touch_throttle[project_id];
591
if (last != null && Date.now() - last <= 3000) {
592
return;
593
}
594
this.touch_throttle[project_id] = Date.now();
595
try {
596
await this.client.conat_client.hub.db.touch({ project_id });
597
} catch (err) {
598
// silently ignore; this happens, e.g., if you touch too frequently,
599
// and shouldn't be fatal and break other things.
600
// NOTE: this is a bit ugly for now -- basically the
601
// hub returns an error regarding actually touching
602
// the project (updating the db), but it still *does*
603
// ensure there is a TCP connection to the project.
604
}
605
};
606
607
// Print sagews to pdf
608
// The printed version of the file will be created in the same directory
609
// as path, but with extension replaced by ".pdf".
610
// Only used for sagews.
611
print_to_pdf = async ({
612
project_id,
613
path,
614
options,
615
timeout,
616
}: {
617
project_id: string;
618
path: string;
619
timeout?: number; // client timeout -- some things can take a long time to print!
620
options?: any; // optional options that get passed to the specific backend for this file type
621
}): Promise<string> => {
622
return await this.client.conat_client
623
.projectApi({ project_id })
624
.editor.printSageWS({ path, timeout, options });
625
};
626
627
create = async (opts: {
628
title: string;
629
description: string;
630
image?: string;
631
start?: boolean;
632
// "license_id1,license_id2,..." -- if given, create project with these licenses applied
633
license?: string;
634
// never use pool
635
noPool?: boolean;
636
}): Promise<string> => {
637
const project_id =
638
await this.client.conat_client.hub.projects.createProject(opts);
639
this.client.tracking_client.user_tracking("create_project", {
640
project_id,
641
title: opts.title,
642
});
643
return project_id;
644
};
645
646
realpath = async (opts: {
647
project_id: string;
648
path: string;
649
}): Promise<string> => {
650
return (await this.api(opts.project_id)).realpath(opts.path);
651
};
652
653
isdir = async ({
654
project_id,
655
path,
656
}: {
657
project_id: string;
658
path: string;
659
}): Promise<boolean> => {
660
const { stdout, exit_code } = await this.exec({
661
project_id,
662
command: "file",
663
args: ["-Eb", path],
664
err_on_exit: false,
665
});
666
return !exit_code && stdout.trim() == "directory";
667
};
668
669
ipywidgetsGetBuffer = reuseInFlight(
670
async (
671
project_id: string,
672
path: string,
673
model_id: string,
674
buffer_path: string,
675
): Promise<ArrayBuffer> => {
676
const actions = redux.getEditorActions(project_id, path);
677
return await actions.jupyter_actions.ipywidgetsGetBuffer(
678
model_id,
679
buffer_path,
680
);
681
},
682
);
683
684
// getting, setting, editing, deleting, etc., the api keys for a project
685
api_keys = async (opts: {
686
project_id: string;
687
action: "get" | "delete" | "create" | "edit";
688
password?: string;
689
name?: string;
690
id?: number;
691
expire?: Date;
692
}): Promise<ApiKey[] | undefined> => {
693
return await this.client.conat_client.hub.system.manageApiKeys(opts);
694
};
695
696
computeServers = (project_id) => {
697
const cs = redux.getProjectActions(project_id)?.computeServers();
698
if (cs == null) {
699
// this happens if something tries to access the compute server info after the project
700
// tab is closed. It shouldn't do that.
701
throw Error("compute server information not available");
702
}
703
return cs;
704
};
705
706
getServerIdForPath = async ({
707
project_id,
708
path,
709
}): Promise<number | undefined> => {
710
return await this.computeServers(project_id)?.getServerIdForPath(path);
711
};
712
713
// will return undefined if compute servers not yet initialized
714
getServerIdForPathSync = ({ project_id, path }): number | undefined => {
715
const cs = this.computeServers(project_id);
716
if (cs?.state != "connected") {
717
return undefined;
718
}
719
return cs.get(path);
720
};
721
}
722
723
// (NOTE: this won't throw an exception)
724
const touchComputeServer = throttle(
725
async ({ project_id, compute_server_id, client }) => {
726
if (!compute_server_id) {
727
// nothing to do
728
return;
729
}
730
try {
731
await client.async_query({
732
query: {
733
compute_servers: {
734
project_id,
735
id: compute_server_id,
736
last_edited_user: client.server_time(),
737
},
738
},
739
});
740
} catch (err) {
741
// just a warning -- if we can't connect then touching isn't something we should be doing anyways.
742
console.log(
743
"WARNING: failed to touch compute server -- ",
744
{ compute_server_id },
745
err,
746
);
747
}
748
},
749
30000,
750
);
751
752
// Polyfill for Safari: Add async iterator support to ReadableStream if missing.
753
// E.g., this is missing in all versions of Safari as of May 2025 according to
754
// https://caniuse.com/?search=ReadableStream%20async
755
// This breaks reading and writing files to projects, which is why this
756
// is here (e.g., the writeFile and readFile functions above).
757
// This might also matter for Jupyter.
758
// https://chatgpt.com/share/6827a476-dbe8-800e-9156-3326eb41baae
759
if (
760
typeof ReadableStream !== "undefined" &&
761
!ReadableStream.prototype[Symbol.asyncIterator]
762
) {
763
ReadableStream.prototype[Symbol.asyncIterator] = function () {
764
const reader = this.getReader();
765
return {
766
async next() {
767
return reader.read();
768
},
769
async return() {
770
reader.releaseLock();
771
return { done: true };
772
},
773
[Symbol.asyncIterator]() {
774
return this;
775
},
776
};
777
};
778
}
779
780