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