Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Path: blob/master/src/packages/jupyter/redux/project-actions.ts
Views: 687
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45/*6project-actions: additional actions that are only available in the7backend/project, which "manages" everything.89This code should not *explicitly* require anything that is only10available in the project or requires node to run, so that we can11fully unit test it via mocking of components.1213NOTE: this is also now the actions used by remote compute servers as well.14*/1516import { get_kernel_data } from "@cocalc/jupyter/kernel/kernel-data";17import * as immutable from "immutable";18import json_stable from "json-stable-stringify";19import { debounce } from "lodash";20import { JupyterActions as JupyterActions0 } from "@cocalc/jupyter/redux/actions";21import { callback2, once } from "@cocalc/util/async-utils";22import * as misc from "@cocalc/util/misc";23import { OutputHandler } from "@cocalc/jupyter/execute/output-handler";24import { RunAllLoop } from "./run-all-loop";25import nbconvertChange from "./handle-nbconvert-change";26import type { ClientFs } from "@cocalc/sync/client/types";27import { kernel as createJupyterKernel } from "@cocalc/jupyter/kernel";28import {29decodeUUIDtoNum,30isEncodedNumUUID,31} from "@cocalc/util/compute/manager";32import { handleApiRequest } from "@cocalc/jupyter/kernel/websocket-api";33import { callback } from "awaiting";34import { get_blob_store } from "@cocalc/jupyter/blobs";35import { removeJupyterRedux } from "@cocalc/jupyter/kernel";3637type BackendState = "init" | "ready" | "spawning" | "starting" | "running";3839export class JupyterActions extends JupyterActions0 {40private _backend_state: BackendState = "init";41private _initialize_manager_already_done: any;42private _kernel_state: any;43private _manager_run_cell_queue: any;44private _running_cells: { [id: string]: string };45private _throttled_ensure_positions_are_unique: any;46private run_all_loop?: RunAllLoop;47private clear_kernel_error?: any;48private running_manager_run_cell_process_queue: boolean = false;49private last_ipynb_save: number = 0;50protected _client: ClientFs; // this has filesystem access, etc.5152public run_cell(53id: string,54save: boolean = true,55no_halt: boolean = false,56): void {57if (this.store.get("read_only")) return;58const cell = this.store.getIn(["cells", id]);59if (cell == null) {60// it is trivial to run a cell that does not exist -- nothing needs to be done.61return;62}63const cell_type = cell.get("cell_type", "code");64if (cell_type == "code") {65// when the backend is running code, just don't worry about66// trying to parse things like "foo?" out. We can't do67// it without CodeMirror, and it isn't worth it for that68// application.69this.run_code_cell(id, save, no_halt);70}71if (save) {72this.save_asap();73}74}7576private set_backend_state(backend_state: BackendState): void {77this.dbg("set_backend_state")(backend_state);7879/*80The backend states, which are put in the syncdb so clients81can display this:8283- 'init' -- the backend is checking the file on disk, etc.84- 'ready' -- the backend is setup and ready to use; kernel isn't running though85- 'starting' -- the kernel itself is actived and currently starting up (e.g., Sage is starting up)86- 'running' -- the kernel is running and ready to evaluate code878889'init' --> 'ready' --> 'spawning' --> 'starting' --> 'running'90/|\ |91|-----------------------------------------|9293Going from ready to starting happens first when a code execution is requested.94*/9596// Check just in case Typescript doesn't catch something:97if (98["init", "ready", "spawning", "starting", "running"].indexOf(99backend_state,100) === -1101) {102throw Error(`invalid backend state '${backend_state}'`);103}104if (backend_state == "init" && this._backend_state != "init") {105// Do NOT allow changing the state to init from any other state.106throw Error(107`illegal state change '${this._backend_state}' --> '${backend_state}'`,108);109}110this._backend_state = backend_state;111112if (this.isCellRunner()) {113const stored_backend_state = this.syncdb114.get_one({ type: "settings" })115?.get("backend_state");116117if (stored_backend_state != backend_state) {118this._set({119type: "settings",120backend_state,121last_backend_state: Date.now(),122});123this.save_asap();124}125126// The following is to clear kernel_error if things are working only.127if (backend_state == "running") {128// clear kernel error if kernel successfully starts and stays129// in running state for a while.130this.clear_kernel_error = setTimeout(() => {131this._set({132type: "settings",133kernel_error: "",134});135}, 3000);136} else {137// change to a different state; cancel attempt to clear kernel error138if (this.clear_kernel_error) {139clearTimeout(this.clear_kernel_error);140delete this.clear_kernel_error;141}142}143}144}145146set_kernel_state = (state: any, save = false) => {147if (!this.isCellRunner()) return;148this._kernel_state = state;149this._set({ type: "settings", kernel_state: state }, save);150};151152// Called exactly once when the manager first starts up after the store is initialized.153// Here we ensure everything is in a consistent state so that we can react154// to changes later.155async initialize_manager() {156if (this._initialize_manager_already_done) {157return;158}159const dbg = this.dbg("initialize_manager");160dbg();161this._initialize_manager_already_done = true;162163this.sync_exec_state = debounce(this.sync_exec_state, 2000);164this._throttled_ensure_positions_are_unique = debounce(165this.ensure_positions_are_unique,1665000,167);168// Listen for changes...169this.syncdb.on("change", this._backend_syncdb_change.bind(this));170171this.setState({172// used by the kernel_info function of this.jupyter_kernel173start_time: this._client.server_time().valueOf(),174});175176// clear nbconvert start on init, since no nbconvert can be running yet177this.syncdb.delete({ type: "nbconvert" });178179// Initialize info about available kernels, which is used e.g., for180// saving to ipynb format.181this.init_kernel_info();182183// We try once to load from disk. If it fails, then184// a record with type:'fatal'185// is created in the database; if it succeeds, that record is deleted.186// Try again only when the file changes.187await this._first_load();188189// Listen for model state changes...190if (this.syncdb.ipywidgets_state == null) {191throw Error("syncdb's ipywidgets_state must be defined!");192}193this.syncdb.ipywidgets_state.on(194"change",195this.handle_ipywidgets_state_change.bind(this),196);197198this.syncdb.on("cursor_activity", this.checkForComputeServerStateChange);199200// initialize the websocket api201this.initWebsocketApi();202}203204private async _first_load() {205const dbg = this.dbg("_first_load");206dbg("doing load");207if (this.is_closed()) {208throw Error("actions must not be closed");209}210try {211await this.loadFromDiskIfNewer();212} catch (err) {213dbg(`load failed -- ${err}; wait for file change and try again`);214const path = this.store.get("path");215const watcher = this._client.watch_file({ path });216await once(watcher, "change");217dbg("file changed");218watcher.close();219await this._first_load();220return;221}222dbg("loading worked");223this._init_after_first_load();224}225226private _init_after_first_load() {227const dbg = this.dbg("_init_after_first_load");228229dbg("initializing");230this.ensure_backend_kernel_setup(); // this may change the syncdb.231232this.init_file_watcher();233234this._state = "ready";235this.ensure_there_is_a_cell();236}237238_backend_syncdb_change = (changes: any) => {239if (this.is_closed()) {240return;241}242const dbg = this.dbg("_backend_syncdb_change");243if (changes != null) {244changes.forEach((key) => {245switch (key.get("type")) {246case "settings":247dbg("settings change");248var record = this.syncdb.get_one(key);249if (record != null) {250// ensure kernel is properly configured251this.ensure_backend_kernel_setup();252// only the backend should change kernel and backend state;253// however, our security model allows otherwise (e.g., via TimeTravel).254if (255record.get("kernel_state") !== this._kernel_state &&256this._kernel_state != null257) {258this.set_kernel_state(this._kernel_state, true);259}260if (record.get("backend_state") !== this._backend_state) {261this.set_backend_state(this._backend_state);262}263264if (record.get("run_all_loop_s")) {265if (this.run_all_loop == null) {266this.run_all_loop = new RunAllLoop(267this,268record.get("run_all_loop_s"),269);270} else {271// ensure interval is correct272this.run_all_loop.set_interval(record.get("run_all_loop_s"));273}274} else if (275!record.get("run_all_loop_s") &&276this.run_all_loop != null277) {278// stop it.279this.run_all_loop.close();280delete this.run_all_loop;281}282}283break;284}285});286}287288this.ensure_there_is_a_cell();289this._throttled_ensure_positions_are_unique();290this.sync_exec_state();291};292293// ensure_backend_kernel_setup ensures that we have a connection294// to the proper type of kernel.295// If running is true, starts the kernel and waits until running.296ensure_backend_kernel_setup = () => {297const dbg = this.dbg("ensure_backend_kernel_setup");298if (this.isDeleted()) {299dbg("file is deleted");300return;301}302303const kernel = this.store.get("kernel");304305let current: string | undefined = undefined;306if (this.jupyter_kernel != null) {307current = this.jupyter_kernel.name;308if (current == kernel && this.jupyter_kernel.get_state() != "closed") {309dbg("everything is properly setup and working");310return;311}312}313314dbg(`kernel='${kernel}', current='${current}'`);315if (316this.jupyter_kernel != null &&317this.jupyter_kernel.get_state() != "closed"318) {319if (current != kernel) {320dbg("kernel changed -- kill running kernel to trigger switch");321this.jupyter_kernel.close();322return;323} else {324dbg("nothing to do");325return;326}327}328329dbg("make a new kernel");330331// No kernel wrapper object setup at all. Make one.332this.jupyter_kernel = createJupyterKernel({333name: kernel,334path: this.store.get("path"),335actions: this,336});337338if (this.syncdb.ipywidgets_state == null) {339throw Error("syncdb's ipywidgets_state must be defined!");340}341this.syncdb.ipywidgets_state.clear();342343if (this.jupyter_kernel == null) {344// to satisfy typescript.345throw Error("jupyter_kernel must be defined");346}347348// save so gets reported to frontend, and surfaced to user:349// https://github.com/sagemathinc/cocalc/issues/4847350this.jupyter_kernel.on("kernel_error", (error) => {351this.set_kernel_error(error);352});353354// Since we just made a new kernel, clearly no cells are running on the backend.355this._running_cells = {};356this.clear_all_cell_run_state();357358this.restartKernelOnClose = () => {359// When the kernel closes, make sure a new kernel gets setup.360if (this.store == null || this._state !== "ready") {361// This event can also happen when this actions is being closed,362// in which case obviously we shouldn't make a new kernel.363return;364}365dbg("kernel closed -- make new one.");366this.ensure_backend_kernel_setup();367};368369this.jupyter_kernel.once("closed", this.restartKernelOnClose);370371// Track backend state changes other than closing, so they372// are visible to user etc.373// TODO: Maybe all these need to move to ephemeral table?374// There's a good argument that recording these is useful though, so when375// looking at time travel or debugging, you know what was going on.376this.jupyter_kernel.on("state", (state) => {377dbg("jupyter_kernel state --> ", state);378switch (state) {379case "off":380case "closed":381// things went wrong.382this._running_cells = {};383this.clear_all_cell_run_state();384this.set_backend_state("ready");385this.jupyter_kernel?.close();386this.running_manager_run_cell_process_queue = false;387delete this.jupyter_kernel;388return;389case "spawning":390case "starting":391this.set_connection_file(); // yes, fall through392case "running":393this.set_backend_state(state);394}395});396397this.jupyter_kernel.on("execution_state", this.set_kernel_state);398399this.handle_all_cell_attachments();400this.set_backend_state("ready");401};402403set_connection_file = () => {404const connection_file = this.jupyter_kernel?.get_connection_file() ?? "";405this._set({406type: "settings",407connection_file,408});409};410411init_kernel_info = async () => {412let kernels0 = this.store.get("kernels");413if (kernels0 != null) {414return;415}416const dbg = this.dbg("init_kernel_info");417dbg("getting");418let kernels;419try {420kernels = await get_kernel_data();421dbg("success");422} catch (err) {423dbg(`FAILED to get kernel info: ${err}`);424// TODO: what to do?? Saving will be broken...425return;426}427this.setState({428kernels: immutable.fromJS(kernels),429});430};431432async ensure_backend_kernel_is_running() {433const dbg = this.dbg("ensure_backend_kernel_is_running");434if (this._backend_state == "ready") {435dbg("in state 'ready', so kick it into gear");436await this.set_backend_kernel_info();437dbg("done getting kernel info");438}439const is_running = (s): boolean => {440if (this._state === "closed") return true;441const t = s.get_one({ type: "settings" });442if (t == null) {443dbg("no settings");444return false;445} else {446const state = t.get("backend_state");447dbg(`state = ${state}`);448return state == "running";449}450};451await this.syncdb.wait(is_running, 60);452}453454// onCellChange is called after a cell change has been455// incorporated into the store after the syncdb change event.456// - If we are responsible for running cells, then it ensures457// that cell gets computed.458// - We also handle attachments for markdown cells.459protected onCellChange(id: string, new_cell: any, old_cell: any) {460const dbg = this.dbg(`onCellChange(id='${id}')`);461dbg();462// this logging could be expensive due to toJS, so only uncomment463// if really needed464// dbg("new_cell=", new_cell?.toJS(), "old_cell", old_cell?.toJS());465466if (467new_cell?.get("state") === "start" &&468old_cell?.get("state") !== "start" &&469this.isCellRunner()470) {471this.manager_run_cell_enqueue(id);472// attachments below only happen for markdown cells, which don't get run,473// we can return here:474return;475}476477const attachments = new_cell?.get("attachments");478if (attachments != null && attachments !== old_cell?.get("attachments")) {479this.handle_cell_attachments(new_cell);480}481}482483protected __syncdb_change_post_hook(doInit: boolean) {484if (doInit) {485if (this.isCellRunner()) {486// Since just opening the actions in the project, definitely the kernel487// isn't running so set this fact in the shared database. It will make488// things always be in the right initial state.489this.syncdb.set({490type: "settings",491backend_state: "init",492kernel_state: "idle",493kernel_usage: { memory: 0, cpu: 0 },494});495this.syncdb.commit();496}497498// Also initialize the execution manager, which runs cells that have been499// requested to run.500this.initialize_manager();501}502if (this.store.get("kernel")) {503this.manager_run_cell_process_queue();504}505}506507// Ensure that the cells listed as running *are* exactly the508// ones actually running or queued up to run.509sync_exec_state = () => {510// sync_exec_state is debounced, so it is *expected* to get called511// after actions have been closed.512if (this.store == null || this._state !== "ready") {513// not initialized, so we better not514// mess with cell state (that is somebody else's responsibility).515return;516}517// we are not the cell runner518if (!this.isCellRunner()) {519return;520}521522const dbg = this.dbg("sync_exec_state");523let change = false;524const cells = this.store.get("cells");525// First verify that all actual cells that are said to be running526// (according to the store) are in fact running.527if (cells != null) {528cells.forEach((cell, id) => {529const state = cell.get("state");530if (531state != null &&532state != "done" &&533state != "start" && // regarding "start", see https://github.com/sagemathinc/cocalc/issues/5467534!this._running_cells?.[id]535) {536dbg(`set cell ${id} with state "${state}" to done`);537this._set({ type: "cell", id, state: "done" }, false);538change = true;539}540});541}542if (this._running_cells != null) {543const cells = this.store.get("cells");544// Next verify that every cell actually running is still in the document545// and listed as running. TimeTravel, deleting cells, etc., can546// certainly lead to this being necessary.547for (const id in this._running_cells) {548const state = cells.getIn([id, "state"]);549if (state == null || state === "done") {550// cell no longer exists or isn't in a running state551dbg(`tell kernel to not run ${id}`);552this._cancel_run(id);553}554}555}556if (change) {557return this._sync();558}559};560561_cancel_run = (id: any) => {562const dbg = this.dbg(`_cancel_run ${id}`);563// All these checks are so we only cancel if it is actually running564// with the current kernel...565if (this._running_cells == null || this.jupyter_kernel == null) return;566const identity = this._running_cells[id];567if (identity == null) return;568if (this.jupyter_kernel.identity == identity) {569dbg("canceling");570this.jupyter_kernel.cancel_execute(id);571} else {572dbg("not canceling since wrong identity");573}574};575576// Note that there is a request to run a given cell.577// You must call manager_run_cell_process_queue for them to actually start running.578protected manager_run_cell_enqueue(id: string) {579if (this._running_cells?.[id]) {580return;581}582if (this._manager_run_cell_queue == null) {583this._manager_run_cell_queue = {};584}585this._manager_run_cell_queue[id] = true;586}587588// properly start running -- in order -- the cells that have been requested to run589protected async manager_run_cell_process_queue() {590if (this.running_manager_run_cell_process_queue) {591return;592}593this.running_manager_run_cell_process_queue = true;594try {595const dbg = this.dbg("manager_run_cell_process_queue");596const queue = this._manager_run_cell_queue;597if (queue == null) {598//dbg("queue is null");599return;600}601delete this._manager_run_cell_queue;602const v: any[] = [];603for (const id in queue) {604if (!this._running_cells?.[id]) {605v.push(this.store.getIn(["cells", id]));606}607}608609if (v.length == 0) {610dbg("no non-running cells");611return; // nothing to do612}613614v.sort((a, b) =>615misc.cmp(616a != null ? a.get("start") : undefined,617b != null ? b.get("start") : undefined,618),619);620621dbg(622`found ${v.length} non-running cell that should be running, so ensuring kernel is running...`,623);624this.ensure_backend_kernel_setup();625try {626await this.ensure_backend_kernel_is_running();627if (this._state == "closed") return;628} catch (err) {629// if this fails, give up on evaluation.630return;631}632633dbg(634`kernel is now running; requesting that each ${v.length} cell gets executed`,635);636for (const cell of v) {637if (cell != null) {638this.manager_run_cell(cell.get("id"));639}640}641642if (this._manager_run_cell_queue != null) {643// run it again to process additional entries.644setTimeout(this.manager_run_cell_process_queue, 1);645}646} finally {647this.running_manager_run_cell_process_queue = false;648}649}650651// returns new output handler for this cell.652protected _output_handler(cell: any) {653const dbg = this.dbg(`handler(id='${cell.id}')`);654if (655this.jupyter_kernel == null ||656this.jupyter_kernel.get_state() == "closed"657) {658throw Error("jupyter kernel must exist and not be closed");659}660this.reset_more_output(cell.id);661662const handler = new OutputHandler({663cell,664max_output_length: this.store.get("max_output_length"),665report_started_ms: 250,666dbg,667});668669dbg("setting up jupyter_kernel.once('closed', ...) handler");670const handleKernelClose = () => {671dbg("output handler -- closing due to jupyter kernel closed");672handler.close();673};674this.jupyter_kernel.once("closed", handleKernelClose);675// remove the "closed" handler we just defined above once676// we are done waiting for output from this cell.677// The output handler removes all listeners whenever it is678// finished, so we don't have to remove this listener for done.679handler.once("done", () =>680this.jupyter_kernel?.removeListener("closed", handleKernelClose),681);682683handler.on("more_output", (mesg, mesg_length) => {684this.set_more_output(cell.id, mesg, mesg_length);685});686687handler.on("process", (mesg) => {688// Do not enable -- mesg often very large!689// dbg("handler.on('process')", mesg);690if (691this.jupyter_kernel == null ||692this.jupyter_kernel.get_state() == "closed"693) {694return;695}696this.jupyter_kernel.process_output(mesg);697// dbg("handler -- after processing ", mesg);698});699700return handler;701}702703manager_run_cell = (id: string) => {704const dbg = this.dbg(`manager_run_cell(id='${id}')`);705dbg(JSON.stringify(misc.keys(this._running_cells)));706707if (this._running_cells == null) {708this._running_cells = {};709}710711if (this._running_cells[id]) {712dbg("cell already queued to run in kernel");713return;714}715716// It's important to set this._running_cells[id] to be true so that717// sync_exec_state doesn't declare this cell done. The kernel identity718// will get set properly below in case it changes.719this._running_cells[id] = this.jupyter_kernel?.identity ?? "none";720721const orig_cell = this.store.get("cells").get(id);722if (orig_cell == null) {723// nothing to do -- cell deleted724return;725}726727let input: string | undefined = orig_cell.get("input", "");728if (input == null) {729input = "";730} else {731input = input.trim();732}733734const halt_on_error: boolean = !orig_cell.get("no_halt", false);735736if (this.jupyter_kernel == null) {737throw Error("bug -- this is guaranteed by the above");738}739this._running_cells[id] = this.jupyter_kernel.identity;740741const cell: any = {742id,743type: "cell",744kernel: this.store.get("kernel"),745};746747dbg(`using max_output_length=${this.store.get("max_output_length")}`);748const handler = this._output_handler(cell);749750handler.on("change", (save) => {751if (!this.store.getIn(["cells", id])) {752// The cell was deleted, but we just got some output753// NOTE: client shouldn't allow deleting running or queued754// cells, but we still want to do something useful/sensible.755// We put cell back where it was with same input.756cell.input = orig_cell.get("input");757cell.pos = orig_cell.get("pos");758}759this.syncdb.set(cell);760// This is potentially very verbose -- don't due it unless761// doing low level debugging:762//dbg(`change (save=${save}): cell='${JSON.stringify(cell)}'`);763if (save) {764this.syncdb.save();765}766});767768handler.once("done", () => {769dbg("handler is done");770this.store.removeListener("cell_change", cell_change);771exec.close();772if (this._running_cells != null) {773delete this._running_cells[id];774}775this.syncdb.save();776setTimeout(() => this.syncdb?.save(), 100);777});778779if (this.jupyter_kernel == null) {780handler.error("Unable to start Jupyter");781return;782}783784const get_password = (): string => {785if (this.jupyter_kernel == null) {786dbg("get_password", id, "no kernel");787return "";788}789const password = this.jupyter_kernel.store.get(id);790dbg("get_password", id, password);791this.jupyter_kernel.store.delete(id);792return password;793};794795// This is used only for stdin right now.796const cell_change = (cell_id, new_cell) => {797if (id === cell_id) {798dbg("cell_change");799handler.cell_changed(new_cell, get_password);800}801};802this.store.on("cell_change", cell_change);803804const exec = this.jupyter_kernel.execute_code({805code: input,806id,807stdin: handler.stdin,808halt_on_error,809});810811exec.on("output", (mesg) => {812// uncomment only for specific low level debugging -- see https://github.com/sagemathinc/cocalc/issues/7022813// dbg(`got mesg='${JSON.stringify(mesg)}'`); // !!!☡ ☡ ☡ -- EXTREME DANGER ☡ ☡ ☡ !!!!814815if (mesg == null) {816// can't possibly happen, of course.817const err = "empty mesg";818dbg(`got error='${err}'`);819handler.error(err);820return;821}822if (mesg.done) {823// done is a special internal cocalc message.824handler.done();825return;826}827if (mesg.content?.transient?.display_id != null) {828// See https://github.com/sagemathinc/cocalc/issues/2132829// We find any other outputs in the document with830// the same transient.display_id, and set their output to831// this mesg's output.832this.handleTransientUpdate(mesg);833if (mesg.msg_type == "update_display_data") {834// don't also create a new output835return;836}837}838839if (mesg.msg_type === "clear_output") {840handler.clear(mesg.content.wait);841return;842}843844if (mesg.content.comm_id != null) {845// ignore any comm/widget related messages846return;847}848849if (mesg.content.execution_state === "idle") {850this.store.removeListener("cell_change", cell_change);851return;852}853if (mesg.content.execution_state === "busy") {854handler.start();855}856if (mesg.content.payload != null) {857if (mesg.content.payload.length > 0) {858// payload shell message:859// Despite https://ipython.org/ipython-doc/3/development/messaging.html#payloads saying860// ""Payloads are considered deprecated, though their replacement is not yet implemented."861// we fully have to implement them, since they are used to implement (crazy, IMHO)862// things like %load in the python2 kernel!863mesg.content.payload.map((p) => handler.payload(p));864return;865}866} else {867// Normal iopub output message868handler.message(mesg.content);869return;870}871});872873exec.on("error", (err) => {874dbg(`got error='${err}'`);875handler.error(err);876});877};878879reset_more_output = (id: any) => {880if (id == null) {881delete this.store._more_output;882}883if (884(this.store._more_output != null885? this.store._more_output[id]886: undefined) != null887) {888return delete this.store._more_output[id];889}890};891892set_more_output = (id: any, mesg: any, length: any): void => {893if (this.store._more_output == null) {894this.store._more_output = {};895}896const output =897this.store._more_output[id] != null898? this.store._more_output[id]899: (this.store._more_output[id] = {900length: 0,901messages: [],902lengths: [],903discarded: 0,904truncated: 0,905});906907output.length += length;908output.lengths.push(length);909output.messages.push(mesg);910911const goal_length = 10 * this.store.get("max_output_length");912while (output.length > goal_length) {913let need: any;914let did_truncate = false;915916// check if there is a text field, which we can truncate917let len =918output.messages[0].text != null919? output.messages[0].text.length920: undefined;921if (len != null) {922need = output.length - goal_length + 50;923if (len > need) {924// Instead of throwing this message away, let's truncate its text part. After925// doing this, the message is at least need shorter than it was before.926output.messages[0].text = misc.trunc(927output.messages[0].text,928len - need,929);930did_truncate = true;931}932}933934// check if there is a text/plain field, which we can thus also safely truncate935if (!did_truncate && output.messages[0].data != null) {936for (const field in output.messages[0].data) {937if (field === "text/plain") {938const val = output.messages[0].data[field];939len = val.length;940if (len != null) {941need = output.length - goal_length + 50;942if (len > need) {943// Instead of throwing this message away, let's truncate its text part. After944// doing this, the message is at least need shorter than it was before.945output.messages[0].data[field] = misc.trunc(val, len - need);946did_truncate = true;947}948}949}950}951}952953if (did_truncate) {954const new_len = JSON.stringify(output.messages[0]).length;955output.length -= output.lengths[0] - new_len; // how much we saved956output.lengths[0] = new_len;957output.truncated += 1;958break;959}960961const n = output.lengths.shift();962output.messages.shift();963output.length -= n;964output.discarded += 1;965}966};967968private init_file_watcher() {969const dbg = this.dbg("file_watcher");970dbg();971this._file_watcher = this._client.watch_file({972path: this.store.get("path"),973debounce: 1000,974});975976this._file_watcher.on("change", async () => {977if (!this.isCellRunner()) {978return;979}980dbg("change");981try {982await this.loadFromDiskIfNewer();983} catch (err) {984dbg("failed to load on change", err);985}986});987}988989/*990* Unfortunately, though I spent two hours on this approach... it just doesn't work,991* since, e.g., if the sync file doesn't already exist, it can't be created,992* which breaks everything. So disabling for now and re-opening the issue.993_sync_file_mode: =>994dbg = @dbg("_sync_file_mode"); dbg()995* Make the mode of the syncdb file the same as the mode of the .ipynb file.996* This is used for read-only status.997ipynb_file = @store.get('path')998locals =999ipynb_file_ro : undefined1000syncdb_file_ro : undefined1001syncdb_file = @syncdb.get_path()1002async.parallel([1003(cb) ->1004fs.access ipynb_file, fs.constants.W_OK, (err) ->1005* Also store in @_ipynb_file_ro to prevent starting kernel in this case.1006@_ipynb_file_ro = locals.ipynb_file_ro = !!err1007cb()1008(cb) ->1009fs.access syncdb_file, fs.constants.W_OK, (err) ->1010locals.syncdb_file_ro = !!err1011cb()1012], ->1013if locals.ipynb_file_ro == locals.syncdb_file_ro1014return1015dbg("mode change")1016async.parallel([1017(cb) ->1018fs.stat ipynb_file, (err, stats) ->1019locals.ipynb_stats = stats1020cb(err)1021(cb) ->1022* error if syncdb_file doesn't exist, which is GOOD, since1023* in that case we do not want to chmod which would create1024* that file as empty and blank it.1025fs.stat(syncdb_file, cb)1026], (err) ->1027if not err1028dbg("changing syncb mode to match ipynb mode")1029fs.chmod(syncdb_file, locals.ipynb_stats.mode)1030else1031dbg("error stating ipynb", err)1032)1033)1034*/10351036// Load file from disk if it is newer than1037// the last we saved to disk.1038private loadFromDiskIfNewer = async () => {1039const dbg = this.dbg("loadFromDiskIfNewer");1040// Get mtime of last .ipynb file that we explicitly saved.10411042// TODO: breaking the syncdb typescript data hiding. The1043// right fix will be to move1044// this info to a new ephemeral state table.1045const last_ipynb_save = await this.get_last_ipynb_save();1046dbg(`syncdb last_ipynb_save=${last_ipynb_save}`);1047let file_changed;1048if (last_ipynb_save == 0) {1049// we MUST load from file the first time, of course.1050file_changed = true;1051dbg("file changed because FIRST TIME");1052} else {1053const path = this.store.get("path");1054let stats;1055try {1056stats = await callback2(this._client.path_stat, { path });1057dbg(`stats.mtime = ${stats.mtime}`);1058} catch (err) {1059// This err just means the file doesn't exist.1060// We set the 'last load' to now in this case, since1061// the frontend clients need to know that we1062// have already scanned the disk.1063this.set_last_load();1064return;1065}1066const mtime = stats.mtime.getTime();1067file_changed = mtime > last_ipynb_save;1068dbg({ mtime, last_ipynb_save });1069}1070if (file_changed) {1071dbg(".ipynb disk file changed ==> loading state from disk");1072try {1073await this.load_ipynb_file();1074} catch (err) {1075dbg("failed to load on change", err);1076}1077} else {1078dbg("disk file NOT changed: NOT loading");1079}1080};10811082// if also set load is true, we also set the "last_ipynb_save" time.1083set_last_load = (alsoSetLoad: boolean = false) => {1084const last_load = new Date().getTime();1085this.syncdb.set({1086type: "file",1087last_load,1088});1089if (alsoSetLoad) {1090// yes, load v save is inconsistent!1091this.syncdb.set({ type: "settings", last_ipynb_save: last_load });1092}1093this.syncdb.commit();1094};10951096/* Determine timestamp of aux .ipynb file, and record it here,1097so we know that we do not have to load exactly that file1098back from disk. */1099private set_last_ipynb_save = async () => {1100let stats;1101try {1102stats = await callback2(this._client.path_stat, {1103path: this.store.get("path"),1104});1105} catch (err) {1106// no-op -- nothing to do.1107this.dbg("set_last_ipynb_save")(`WARNING -- issue in path_stat ${err}`);1108return;1109}11101111// This is ugly (i.e., how we get access), but I need to get this done.1112// This is the RIGHT place to save the info though.1113// TODO: move this state info to new ephemeral table.1114try {1115const last_ipynb_save = stats.mtime.getTime();1116this.last_ipynb_save = last_ipynb_save;1117this._set({1118type: "settings",1119last_ipynb_save,1120});1121this.dbg("stats.mtime.getTime()")(1122`set_last_ipynb_save = ${last_ipynb_save}`,1123);1124} catch (err) {1125this.dbg("set_last_ipynb_save")(1126`WARNING -- issue in set_last_ipynb_save ${err}`,1127);1128return;1129}1130};11311132private get_last_ipynb_save = async () => {1133const x =1134this.syncdb.get_one({ type: "settings" })?.get("last_ipynb_save") ?? 0;1135return Math.max(x, this.last_ipynb_save);1136};11371138load_ipynb_file = async () => {1139/*1140Read the ipynb file from disk. Fully use the ipynb file to1141set the syncdb's state. We do this when opening a new file, or when1142the file changes on disk (e.g., a git checkout or something).1143*/1144const dbg = this.dbg(`load_ipynb_file`);1145dbg("reading file");1146const path = this.store.get("path");1147let content: string;1148try {1149content = await callback2(this._client.path_read, {1150path,1151maxsize_MB: 50,1152});1153} catch (err) {1154// possibly file doesn't exist -- set notebook to empty.1155const exists = await callback2(this._client.path_exists, {1156path,1157});1158if (!exists) {1159content = "";1160} else {1161// It would be better to have a button to push instead of1162// suggesting running a command in the terminal, but1163// adding that took 1 second. Better than both would be1164// making it possible to edit huge files :-).1165const error = `Error reading ipynb file '${path}': ${err.toString()}. Fix this to continue. You can delete all output by typing cc-jupyter-no-output [filename].ipynb in a terminal.`;1166this.syncdb.set({ type: "fatal", error });1167throw Error(error);1168}1169}1170if (content.length === 0) {1171// Blank file, e.g., when creating in CoCalc.1172// This is good, works, etc. -- just clear state, including error.1173this.syncdb.delete();1174this.set_last_load(true);1175return;1176}11771178// File is nontrivial -- parse and load.1179let parsed_content;1180try {1181parsed_content = JSON.parse(content);1182} catch (err) {1183const error = `Error parsing the ipynb file '${path}': ${err}. You must fix the ipynb file somehow before continuing.`;1184dbg(error);1185this.syncdb.set({ type: "fatal", error });1186throw Error(error);1187}1188this.syncdb.delete({ type: "fatal" });1189await this.set_to_ipynb(parsed_content);1190this.set_last_load(true);1191};11921193save_ipynb_file = async () => {1194const dbg = this.dbg("save_ipynb_file");1195if (!this.isCellRunner()) {1196dbg("not cell runner, so NOT saving ipynb file to disk");1197return;1198}1199dbg("saving to file");12001201// Check first if file was deleted, in which case instead of saving to disk,1202// we should terminate and clean up everything.1203if (this.isDeleted()) {1204dbg("ipynb file is deleted, so NOT saving to disk and closing");1205this.close({ noSave: true });1206return;1207}12081209if (this.jupyter_kernel == null) {1210// The kernel is needed to get access to the blob store, which1211// may be needed to save to disk.1212this.ensure_backend_kernel_setup();1213if (this.jupyter_kernel == null) {1214// still not null? This would happen if no kernel is set at all,1215// in which case it's OK that saving isn't possible.1216throw Error("no kernel so cannot save");1217}1218}1219if (this.store.get("kernels") == null) {1220await this.init_kernel_info();1221if (this.store.get("kernels") == null) {1222// This should never happen, but maybe could in case of a very1223// messed up compute environment where the kernelspecs can't be listed.1224throw Error(1225"kernel info not known and can't be determined, so can't save",1226);1227}1228}1229dbg("going to try to save: getting ipynb object...");1230const blob_store = this.jupyter_kernel.get_blob_store();1231let ipynb = this.store.get_ipynb(blob_store);1232if (this.store.get("kernel")) {1233// if a kernel is set, check that it was sufficiently known that1234// we can fill in data about it -- see https://github.com/sagemathinc/cocalc/issues/72861235if (ipynb?.metadata?.kernelspec?.name == null) {1236dbg("kernelspec not known -- try loading kernels again");1237await this.fetch_jupyter_kernels();1238// and again grab the ipynb1239ipynb = this.store.get_ipynb(blob_store);1240if (ipynb?.metadata?.kernelspec?.name == null) {1241dbg("kernelspec STILL not known: metadata will be incomplete");1242}1243}1244}1245dbg("got ipynb object");1246// We use json_stable (and indent 1) to be more diff friendly to user,1247// and more consistent with official Jupyter.1248const data = json_stable(ipynb, { space: 1 });1249if (data == null) {1250dbg("failed -- ipynb not defined yet");1251throw Error("ipynb not defined yet; can't save");1252}1253dbg("converted ipynb to stable JSON string", data?.length);1254//dbg(`got string version '${data}'`)1255try {1256dbg("writing to disk...");1257await callback2(this._client.write_file, {1258path: this.store.get("path"),1259data,1260});1261dbg("succeeded at saving");1262await this.set_last_ipynb_save();1263} catch (err) {1264const e = `error writing file: ${err}`;1265dbg(e);1266throw Error(e);1267}1268};12691270ensure_there_is_a_cell = () => {1271if (this._state !== "ready") {1272return;1273}1274const cells = this.store.get("cells");1275if (cells == null || (cells.size === 0 && this.isCellRunner())) {1276this._set({1277type: "cell",1278id: this.new_id(),1279pos: 0,1280input: "",1281});1282// We are obviously contributing content to this (empty!) notebook.1283return this.set_trust_notebook(true);1284}1285};12861287private handle_all_cell_attachments() {1288// Check if any cell attachments need to be loaded.1289const cells = this.store.get("cells");1290cells?.forEach((cell) => {1291this.handle_cell_attachments(cell);1292});1293}12941295private handle_cell_attachments(cell) {1296if (this.jupyter_kernel == null) {1297// can't do anything1298return;1299}1300const dbg = this.dbg(`handle_cell_attachments(id=${cell.get("id")})`);1301dbg();13021303const attachments = cell.get("attachments");1304if (attachments == null) return; // nothing to do1305attachments.forEach(async (x, name) => {1306if (x == null) return;1307if (x.get("type") === "load") {1308if (this.jupyter_kernel == null) return; // try later1309// need to load from disk1310this.set_cell_attachment(cell.get("id"), name, {1311type: "loading",1312value: null,1313});1314let sha1: string;1315try {1316sha1 = await this.jupyter_kernel.load_attachment(x.get("value"));1317} catch (err) {1318this.set_cell_attachment(cell.get("id"), name, {1319type: "error",1320value: `${err}`,1321});1322return;1323}1324this.set_cell_attachment(cell.get("id"), name, {1325type: "sha1",1326value: sha1,1327});1328}1329});1330}13311332// handle_ipywidgets_state_change is called when the project ipywidgets_state1333// object changes, e.g., in response to a user moving a slider in the browser.1334// It crafts a comm message that is sent to the running Jupyter kernel telling1335// it about this change by calling send_comm_message_to_kernel.1336private handle_ipywidgets_state_change(keys): void {1337if (this.is_closed()) {1338return;1339}1340const dbg = this.dbg("handle_ipywidgets_state_change");1341dbg(keys);1342if (this.jupyter_kernel == null) {1343dbg("no kernel, so ignoring changes to ipywidgets");1344return;1345}1346if (this.syncdb.ipywidgets_state == null) {1347throw Error("syncdb's ipywidgets_state must be defined!");1348}1349for (const key of keys) {1350const [, model_id, type] = JSON.parse(key);1351dbg({ key, model_id, type });1352let data: any;1353if (type === "value") {1354const state = this.syncdb.ipywidgets_state.get_model_value(model_id);1355// Saving the buffers on change is critical since otherwise this breaks:1356// https://ipywidgets.readthedocs.io/en/latest/examples/Widget%20List.html#file-upload1357// Note that stupidly the buffer (e.g., image upload) gets sent to the kernel twice.1358// But it does work robustly, and the kernel and nodejs server processes next to each1359// other so this isn't so bad.1360const { buffer_paths, buffers } =1361this.syncdb.ipywidgets_state.getKnownBuffers(model_id);1362data = { method: "update", state, buffer_paths };1363this.jupyter_kernel.send_comm_message_to_kernel({1364msg_id: misc.uuid(),1365target_name: "jupyter.widget",1366comm_id: model_id,1367data,1368buffers,1369});1370} else if (type === "buffers") {1371// TODO: we MIGHT need implement this... but MAYBE NOT. An example where this seems like it might be1372// required is by the file upload widget, but actually that just uses the value type above, since1373// we explicitly fill in the widgets there; also there is an explicit comm upload message that1374// the widget sends out that updates the buffer, and in send_comm_message_to_kernel in jupyter/kernel/kernel.ts1375// when processing that message, we saves those buffers and make sure they are set in the1376// value case above (otherwise they would get removed).1377// https://ipywidgets.readthedocs.io/en/latest/examples/Widget%20List.html#file-upload1378// which creates a buffer from the content of the file, then sends it to the backend,1379// which sees a change and has to write that buffer to the kernel (here) so that1380// the running python process can actually do something with the file contents (e.g.,1381// process data, save file to disk, etc).1382// We need to be careful though to not send buffers to the kernel that the kernel sent us,1383// since that would be a waste.1384} else if (type === "state") {1385// TODO: currently ignoring this, since it seems chatty and pointless,1386// and could lead to race conditions probably with multiple users, etc.1387// It happens right when the widget is created.1388/*1389const state = this.syncdb.ipywidgets_state.getModelSerializedState(model_id);1390data = { method: "update", state };1391this.jupyter_kernel.send_comm_message_to_kernel(1392misc.uuid(),1393model_id,1394data1395);1396*/1397} else {1398throw Error(`invalid synctable state -- unknown type '${type}'`);1399}1400}1401}14021403public async process_comm_message_from_kernel(mesg: any): Promise<void> {1404const dbg = this.dbg("process_comm_message_from_kernel");1405// serializing the full message could cause enormous load on the server, since1406// the mesg may contain large buffers. Only do for low level debugging!1407// dbg(mesg); // EXTREME DANGER!1408// This should be safe:1409dbg(JSON.stringify(mesg.header));1410if (this.syncdb.ipywidgets_state == null) {1411throw Error("syncdb's ipywidgets_state must be defined!");1412}1413await this.syncdb.ipywidgets_state.process_comm_message_from_kernel(mesg);1414}14151416public capture_output_message(mesg: any): boolean {1417if (this.syncdb.ipywidgets_state == null) {1418throw Error("syncdb's ipywidgets_state must be defined!");1419}1420return this.syncdb.ipywidgets_state.capture_output_message(mesg);1421}14221423public close_project_only() {1424const dbg = this.dbg("close_project_only");1425dbg();1426if (this.run_all_loop) {1427this.run_all_loop.close();1428delete this.run_all_loop;1429}1430// this stops the kernel and cleans everything up1431// so no resources are wasted and next time starting1432// is clean1433(async () => {1434try {1435await removeJupyterRedux(this.store.get("path"), this.project_id);1436} catch (err) {1437dbg("WARNING -- issue removing jupyter redux", err);1438}1439})();1440}14411442// not actually async...1443public async signal(signal = "SIGINT"): Promise<void> {1444this.jupyter_kernel?.signal(signal);1445}14461447public handle_nbconvert_change(oldVal, newVal): void {1448nbconvertChange(this, oldVal?.toJS(), newVal?.toJS());1449}14501451protected isCellRunner = (): boolean => {1452if (this.is_closed()) {1453// it's closed, so obviously not the cell runner.1454return false;1455}1456const dbg = this.dbg("isCellRunner");1457let id;1458try {1459id = this.getComputeServerId();1460} catch (_) {1461// normal since debounced,1462// and anyways if anything like syncdb that getComputeServerId1463// depends on doesn't work, then we are clearly1464// not the cell runner1465return false;1466}1467dbg("id = ", id);1468if (id == 0 && this.is_project) {1469dbg("yes we are the cell runner (the project)");1470// when no remote compute servers are configured, the project is1471// responsible for evaluating code.1472return true;1473}1474if (this.is_compute_server) {1475// a remote compute server is supposed to be responsible. Are we it?1476try {1477const myId = decodeUUIDtoNum(this.syncdb.client_id());1478const isRunner = myId == id;1479dbg(isRunner ? "Yes, we are cell runner" : "NOT cell runner");1480return isRunner;1481} catch (err) {1482dbg(err);1483}1484}1485dbg("NO we are not the cell runner");1486return false;1487};14881489private lastComputeServerId = 0;1490private checkForComputeServerStateChange = (client_id) => {1491if (this.is_closed()) {1492return;1493}1494if (!isEncodedNumUUID(client_id)) {1495return;1496}1497const id = this.getComputeServerId();1498if (id != this.lastComputeServerId) {1499// reset all run state1500this.halt();1501this.clear_all_cell_run_state();1502}1503this.lastComputeServerId = id;1504};15051506/*1507WebSocket API150815091. Handles api requests from the user via the generic websocket message channel1510provided by the syncdb.151115122. In case a remote compute server connects and registers to handle api messages,1513then those are proxied to the remote server, handled there, and proxied back.1514*/15151516private initWebsocketApi = () => {1517if (this.is_project) {1518// only the project receives these messages from clients.1519this.syncdb.on("message", this.handleMessageFromClient);1520} else if (this.is_compute_server) {1521// compute servers receive messages from the project,1522// proxying an api request from a client.1523this.syncdb.on("message", this.handleMessageFromProject);1524}1525};15261527private remoteApiHandler: null | {1528spark: any; // the spark channel connection between project and compute server1529id: number; // this is a sequential id used for request/response pairing1530// when get response from computer server, one of these callbacks gets called:1531responseCallbacks: { [id: number]: (err: any, response: any) => void };1532} = null;15331534private handleMessageFromClient = async ({ data, spark }) => {1535// This is call in the project to handle api requests.1536// It either handles them directly, or if there is a remote1537// compute server, it forwards them to the remote compute server,1538// then proxies the response back to the client.15391540const dbg = this.dbg("handleMessageFromClient");1541dbg();1542// WARNING: potentially very verbose1543dbg(data);1544switch (data.event) {1545case "register-to-handle-api": {1546if (this.remoteApiHandler?.spark?.id == spark.id) {1547dbg(1548"register-to-handle-api -- it's the current one so nothing to do",1549);1550return;1551}1552if (this.remoteApiHandler?.spark != null) {1553dbg("register-to-handle-api -- remove existing handler");1554this.remoteApiHandler.spark.removeAllListeners();1555this.remoteApiHandler.spark.end();1556this.remoteApiHandler = null;1557}1558// a compute server client is volunteering to handle all api requests until they disconnect1559this.remoteApiHandler = { spark, id: 0, responseCallbacks: {} };1560dbg("register-to-handle-api -- spark.id = ", spark.id);1561spark.on("end", () => {1562dbg(1563"register-to-handle-api -- spark ended, spark.id = ",1564spark.id,1565" and this.remoteApiHandler?.spark.id=",1566this.remoteApiHandler?.spark.id,1567);1568if (this.remoteApiHandler?.spark.id == spark.id) {1569this.remoteApiHandler = null;1570}1571});1572return;1573}15741575case "api-request": {1576// browser client made an api request. This will get handled1577// either locally or via a remote compute server, depending on1578// whether this.remoteApiHandler is set (via the1579// register-to-handle-api event above).1580const response = await this.handleApiRequest(data);1581spark.write({1582event: "message",1583data: { event: "api-response", response, id: data.id },1584});1585return;1586}15871588case "api-response": {1589// handling api request that we proxied to a remote compute server.1590// We are handling the response from the remote compute server.1591if (this.remoteApiHandler == null) {1592dbg("WARNING: api-response event but there is no remote api handler");1593// api-response event can't be handled because no remote api handler is registered1594// This should only happen if the requesting spark just disconnected, so there's no way to1595// responsd anyways.1596return;1597}1598const cb = this.remoteApiHandler.responseCallbacks[data.id];1599if (cb != null) {1600delete this.remoteApiHandler.responseCallbacks[data.id];1601cb(undefined, data);1602} else {1603dbg("WARNING: api-response event for unknown id", data.id);1604}1605return;1606}16071608case "save-blob-to-project": {1609if (!this.is_project) {1610throw Error(1611"message save-blob-to-project should only be sent to the project",1612);1613}1614// A compute server sent the project a blob to store1615// in the local blob store.1616const blobStore = await get_blob_store();1617blobStore.save(data.data, data.type, data.ipynb);1618return;1619}16201621default: {1622// unknown event so send back error1623spark.write({1624event: "message",1625data: {1626event: "error",1627message: `unknown event ${data.event}`,1628id: data.id,1629},1630});1631}1632}1633};16341635// this should only be called on a compute server.1636public saveBlobToProject = (data: string, type: string, ipynb?: string) => {1637if (!this.is_compute_server) {1638throw Error(1639"saveBlobToProject should only be called on a compute server",1640);1641}1642const dbg = this.dbg("saveBlobToProject");1643if (this.is_closed()) {1644dbg("called AFTER closed");1645return;1646}1647// This is call on a compute server whenever something is1648// written to its local blob store. TODO: We do not wait for1649// confirmation that blob was sent yet though.1650dbg();1651this.syncdb.sendMessageToProject({1652event: "save-blob-to-project",1653data,1654type,1655ipynb,1656});1657};16581659private handleMessageFromProject = async (data) => {1660const dbg = this.dbg("handleMessageFromProject");1661if (this.is_closed()) {1662dbg("called AFTER closed");1663return;1664}1665// This is call on the remote compute server to handle api requests.1666dbg();1667// output could be very BIG:1668// dbg(data);1669if (data.event == "api-request") {1670const response = await this.handleApiRequest(data.request);1671try {1672await this.syncdb.sendMessageToProject({1673event: "api-response",1674id: data.id,1675response,1676});1677} catch (err) {1678// this happens when the websocket is disconnected1679dbg(`WARNING -- issue responding to message ${err}`);1680}1681return;1682}1683};16841685private handleApiRequest = async (data) => {1686if (this.remoteApiHandler != null) {1687return await this.handleApiRequestViaRemoteApiHandler(data);1688}1689const dbg = this.dbg("handleApiRequest");1690const { path, endpoint, query } = data;1691dbg("handling request in project", path);1692try {1693return await handleApiRequest(path, endpoint, query);1694} catch (err) {1695dbg("error -- ", err.message);1696return { event: "error", message: err.message };1697}1698};16991700private handleApiRequestViaRemoteApiHandler = async (data) => {1701const dbg = this.dbg("handleApiRequestViaRemoteApiHandler");1702dbg(data?.path);1703try {1704if (!this.is_project) {1705throw Error("BUG -- remote api requests only make sense in a project");1706}1707if (this.remoteApiHandler == null) {1708throw Error("BUG -- remote api handler not registered");1709}1710// Send a message to the remote asking it to handle this api request,1711// which calls the function handleMessageFromProject from above in that remote process.1712const { id, spark, responseCallbacks } = this.remoteApiHandler;1713spark.write({1714event: "message",1715data: { event: "api-request", request: data, id },1716});1717const waitForResponse = (cb) => {1718responseCallbacks[id] = cb;1719};1720this.remoteApiHandler.id += 1; // increment sequential protocol message tracker id1721return (await callback(waitForResponse)).response;1722} catch (err) {1723dbg("error -- ", err.message);1724return { event: "error", message: err.message };1725}1726};17271728// Handle transient cell messages.1729handleTransientUpdate = (mesg) => {1730const display_id = mesg.content?.transient?.display_id;1731if (!display_id) {1732return false;1733}17341735let matched = false;1736// are there any transient outputs in the entire document that1737// have this display_id? search to find them.1738// TODO: we could use a clever data structure to make1739// this faster and more likely to have bugs.1740const cells = this.syncdb.get({ type: "cell" });1741for (let cell of cells) {1742let output = cell.get("output");1743if (output != null) {1744for (const [n, val] of output) {1745if (val.getIn(["transient", "display_id"]) == display_id) {1746// found a match -- replace it1747output = output.set(n, immutable.fromJS(mesg.content));1748this.syncdb.set({ type: "cell", id: cell.get("id"), output });1749matched = true;1750}1751}1752}1753}1754if (matched) {1755this.syncdb.commit();1756}1757};1758// End Websocket API1759}176017611762