diff --git a/.changeset/notebook-create-action.md b/.changeset/notebook-create-action.md new file mode 100644 index 00000000000..50df0c170ba --- /dev/null +++ b/.changeset/notebook-create-action.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Let the notebook tools create a new empty notebook. diff --git a/packages/kilo-vscode/src/services/notebook/adapter.ts b/packages/kilo-vscode/src/services/notebook/adapter.ts index 1cad2e9eee3..5a242fcf5f1 100644 --- a/packages/kilo-vscode/src/services/notebook/adapter.ts +++ b/packages/kilo-vscode/src/services/notebook/adapter.ts @@ -1,7 +1,7 @@ import path from "node:path" import * as vscode from "vscode" import { normalizeOutputs, normalizeSource } from "./output" -import { NotebookError, resolveNotebookPath, type NotebookPathDeps } from "./path" +import { NotebookError, resolveNotebookCreatePath, resolveNotebookPath, type NotebookPathDeps } from "./path" import { cellFingerprint, fingerprint, notebookState, sameCell, type NotebookState } from "./revision" import { NOTEBOOK_LIMITS, @@ -21,6 +21,18 @@ const RETAINED_REVISIONS = 1_000 const revisions = new Map() const locks = new Map>() +// Minimal valid empty Jupyter notebook accepted by the built-in ipynb serializer. +const EMPTY_IPYNB = JSON.stringify( + { + cells: [], + metadata: {}, + nbformat: 4, + nbformat_minor: 5, + }, + null, + 1, +) + function revisionKey(target: string, revision: string): string { return `${target}\0${revision}` } @@ -60,6 +72,7 @@ function defaults(): NotebookAdapterDeps { return { documents: () => vscode.workspace.notebookDocuments, open: (uri) => Promise.resolve(vscode.workspace.openNotebookDocument(uri)), + write: (uri, content) => Promise.resolve(vscode.workspace.fs.writeFile(uri, content)), apply: (edit) => Promise.resolve(vscode.workspace.applyEdit(edit)), execute: (command, ...args) => Promise.resolve(vscode.commands.executeCommand(command, ...args)), change: (listener) => vscode.workspace.onDidChangeNotebookDocument(listener), @@ -167,12 +180,23 @@ export class NotebookAdapter { } async edit(request: NotebookEditRequest): Promise { + const edit = request.edit + if (edit.action === "create") { + return this.create(request) + } const loaded = await this.document(request.directory, request.path) return this.lock(loaded.target, async () => { const before = this.remember(loaded.document, loaded.target) - this.revision(before, request.expectedRevision, loaded.path, request.index) + const expectedRevision = request.expectedRevision + if (expectedRevision === undefined) { + throw new NotebookError("invalid_cell", "An expected revision is required for this edit", { + path: loaded.path, + index: request.index, + }) + } + this.revision(before, expectedRevision, loaded.path, request.index) const count = loaded.document.cellCount - const max = request.edit.action === "insert" ? count : count - 1 + const max = edit.action === "insert" ? count : count - 1 if (!Number.isInteger(request.index) || request.index < 0 || request.index > max) { throw new NotebookError("invalid_cell", `Cell index ${request.index} is out of range`, { path: loaded.path, @@ -182,25 +206,25 @@ export class NotebookAdapter { const expected = [...before.cells] const edits = (() => { - if (request.edit.action === "delete") { + if (edit.action === "delete") { expected.splice(request.index, 1) return [this.deps.delete(request.index)] } - const language = request.edit.language ?? (request.edit.kind === "code" ? "plaintext" : "markdown") + const language = edit.language ?? (edit.kind === "code" ? "plaintext" : "markdown") const cell = this.deps.cell({ - kind: request.edit.kind, - language: request.edit.language, - source: request.edit.source, + kind: edit.kind, + language: edit.language, + source: edit.source, }) - const value = fingerprint(request.edit.kind, language, request.edit.source) - if (request.edit.action === "insert") { + const value = fingerprint(edit.kind, language, edit.source) + if (edit.action === "insert") { expected.splice(request.index, 0, value) return [this.deps.insert(request.index, [cell])] } expected.splice(request.index, 1, value) return [this.deps.replace(request.index, [cell])] })() - this.revision(this.remember(loaded.document, loaded.target), request.expectedRevision, loaded.path, request.index) + this.revision(this.remember(loaded.document, loaded.target), expectedRevision, loaded.path, request.index) if (!(await this.deps.apply(this.deps.edit(loaded.document.uri, edits)))) { throw new NotebookError("unsupported", "VS Code rejected the notebook edit", { path: loaded.path, @@ -217,15 +241,37 @@ export class NotebookAdapter { requestPath: request.path, revision: after.revision, index: request.index, - action: request.edit.action, + action: edit.action, } - if (request.edit.action !== "delete" && request.index < loaded.document.cellCount) { + if (edit.action !== "delete" && request.index < loaded.document.cellCount) { result.cell = this.cell(loaded.document.cellAt(request.index), request.index) } return result }) } + private async create(request: NotebookEditRequest): Promise { + const resolved = await resolveNotebookCreatePath(request.directory, request.path, this.access, this.options.paths) + return this.lock(resolved.target, async () => { + await this.deps.write(this.deps.uri(resolved.target), new TextEncoder().encode(EMPTY_IPYNB)) + const document = await this.deps.open(this.deps.uri(resolved.target)) + if (document.isClosed) { + throw new NotebookError("closed", `Notebook ${JSON.stringify(resolved.relative)} is closed`, { + path: resolved.relative, + }) + } + const state = this.remember(document, resolved.target) + return { + operation: "edit", + path: resolved.relative, + requestPath: request.path, + revision: state.revision, + index: 0, + action: "create", + } + }) + } + async execute(request: NotebookExecuteRequest): Promise { const loaded = await this.document(request.directory, request.path) const state = this.remember(loaded.document, loaded.target) diff --git a/packages/kilo-vscode/src/services/notebook/bridge.ts b/packages/kilo-vscode/src/services/notebook/bridge.ts index 7b4f5644d04..b5841339c63 100644 --- a/packages/kilo-vscode/src/services/notebook/bridge.ts +++ b/packages/kilo-vscode/src/services/notebook/bridge.ts @@ -264,7 +264,7 @@ export class NotebookBridge { return adapter.edit({ path: request.path, directory, - expectedRevision: request.expectedRevision, + ...(request.expectedRevision !== undefined ? { expectedRevision: request.expectedRevision } : {}), index: request.index, edit: request.edit, }) diff --git a/packages/kilo-vscode/src/services/notebook/path.ts b/packages/kilo-vscode/src/services/notebook/path.ts index f165899dff5..4d2d1e2dc5e 100644 --- a/packages/kilo-vscode/src/services/notebook/path.ts +++ b/packages/kilo-vscode/src/services/notebook/path.ts @@ -84,3 +84,53 @@ export async function resolveNotebookPath( } return { target, relative: path.relative(root, target).split(path.sep).join("/") } } + +// Resolve a path for a notebook that does not exist yet. The file itself is not +// realpathed (it must not exist); instead the parent directory is realpathed and +// must be contained in the request root and pass access checks. +export async function resolveNotebookCreatePath( + directory: string, + input: string, + access: NotebookAccess, + deps: NotebookPathDeps = defaults, +): Promise { + if (!input || input.length > 4_096 || input.includes("\0")) { + throw invalid(input, "the path is empty, too long, or malformed") + } + if (WINDOWS_ABSOLUTE.test(input) && !path.win32.isAbsolute(directory)) { + throw invalid(input, "the absolute path uses a different platform format") + } + if (!input.toLowerCase().endsWith(".ipynb")) { + throw invalid(input, "only .ipynb notebooks can be created") + } + + const base = path.resolve(directory) + const root = await deps.realpath(base) + const candidate = path.resolve(base, input) + if (!path.isAbsolute(input) && !contained(base, candidate)) { + throw invalid(input, "it is outside the request directory") + } + + const parent = await deps.realpath(path.dirname(candidate)).catch((error: unknown) => { + const detail = error instanceof Error ? error.message : String(error) + throw new NotebookError("not_found", `Cannot resolve the parent directory of ${JSON.stringify(input)}: ${detail}`, { + path: input, + }) + }) + if (!contained(root, parent)) { + throw invalid(input, "its parent directory resolves outside the request directory") + } + + const target = path.join(parent, path.basename(candidate)) + const existing = await deps.realpath(target).then( + () => true, + () => false, + ) + if (existing) { + throw new NotebookError("already_exists", `Notebook ${JSON.stringify(input)} already exists`, { path: input }) + } + if (!(await access.validateAccess(target))) { + throw invalid(input, "it is excluded by workspace access or ignore rules") + } + return { target, relative: path.relative(root, target).split(path.sep).join("/") } +} diff --git a/packages/kilo-vscode/src/services/notebook/types.ts b/packages/kilo-vscode/src/services/notebook/types.ts index d195675994f..a30d6451d3d 100644 --- a/packages/kilo-vscode/src/services/notebook/types.ts +++ b/packages/kilo-vscode/src/services/notebook/types.ts @@ -57,7 +57,7 @@ export interface NotebookEditResult { requestPath: string revision: string index: number - action: "insert" | "replace" | "delete" + action: "insert" | "replace" | "delete" | "create" cell?: NotebookCell } @@ -82,6 +82,7 @@ export type NotebookEdit = | ({ action: "insert" } & NotebookCellInput) | ({ action: "replace" } & NotebookCellInput) | { action: "delete" } + | { action: "create" } export interface NotebookReadRequest { path: string @@ -92,7 +93,7 @@ export interface NotebookReadRequest { export interface NotebookEditRequest { path: string directory: string - expectedRevision: string + expectedRevision?: string index: number edit: NotebookEdit } @@ -113,6 +114,7 @@ export interface NotebookAccess { export interface NotebookAdapterDeps { documents(): readonly vscode.NotebookDocument[] open(uri: vscode.Uri): Promise + write(uri: vscode.Uri, content: Uint8Array): Promise apply(edit: vscode.WorkspaceEdit): Promise execute(command: string, ...args: unknown[]): Promise change(listener: (event: vscode.NotebookDocumentChangeEvent) => void): vscode.Disposable diff --git a/packages/kilo-vscode/tests/unit/notebook-core.test.ts b/packages/kilo-vscode/tests/unit/notebook-core.test.ts index e225d2ca266..fff4a8f40cf 100644 --- a/packages/kilo-vscode/tests/unit/notebook-core.test.ts +++ b/packages/kilo-vscode/tests/unit/notebook-core.test.ts @@ -65,13 +65,23 @@ function notebook(cells: vscode.NotebookCell[], file = "/repo/book.ipynb"): vsco function harness(document: vscode.NotebookDocument, cells: vscode.NotebookCell[]) { const changes = new Set<(event: vscode.NotebookDocumentChangeEvent) => void>() const closes = new Set<(document: vscode.NotebookDocument) => void>() - const calls = { open: 0, apply: 0, command: 0, commandArgs: [] as unknown[], edit: undefined as unknown } + const calls = { + open: 0, + apply: 0, + command: 0, + commandArgs: [] as unknown[], + edit: undefined as unknown, + write: [] as Array<{ uri: vscode.Uri; content: Uint8Array }>, + } const deps: NotebookAdapterDeps = { documents: () => [document], open: async () => { calls.open++ return document }, + write: async (uri, content) => { + calls.write.push({ uri, content }) + }, apply: async (edit) => { calls.apply++ const item = ( @@ -119,12 +129,12 @@ function harness(document: vscode.NotebookDocument, cells: vscode.NotebookCell[] const paths = { realpath: async (value: string) => value } const access = { validateAccess: mock(() => true) } -function adapter(items: ReturnType[], file = "/repo/book.ipynb") { +function adapter(items: ReturnType[], file = "/repo/book.ipynb", resolver = paths) { const cells = items.map((item) => item.value) const document = notebook(cells, file) const ctx = harness(document, cells) return { - adapter: new NotebookAdapter(access, { deps: ctx.deps, paths, timeout: 50 }), + adapter: new NotebookAdapter(access, { deps: ctx.deps, paths: resolver, timeout: 50 }), document, cells, ...ctx, @@ -467,3 +477,64 @@ describe("notebook adapter", () => { ]) }) }) + +describe("notebook create", () => { + // The new file must not resolve (it does not exist), but its parent directory must. + const creating = { + realpath: async (value: string) => (value.endsWith("fresh.ipynb") ? Promise.reject(new Error("ENOENT")) : value), + } + + it("writes a minimal empty .ipynb, opens it, and returns the initial revision", async () => { + const ctx = adapter([], "/repo/fresh.ipynb", creating) + const result = await ctx.adapter.edit({ + directory: "/repo", + path: "fresh.ipynb", + index: 0, + edit: { action: "create" }, + }) + expect(result).toMatchObject({ operation: "edit", action: "create", path: "fresh.ipynb", index: 0 }) + expect(result.revision).toContain("content:") + expect(ctx.calls.write).toHaveLength(1) + expect(ctx.calls.open).toBe(1) + const written = JSON.parse(new TextDecoder().decode(ctx.calls.write[0]!.content)) + expect(written).toMatchObject({ cells: [], nbformat: 4 }) + }) + + it("rejects creating a notebook that already exists", async () => { + const ctx = adapter([cell()], "/repo/book.ipynb") + await expect( + ctx.adapter.edit({ directory: "/repo", path: "book.ipynb", index: 0, edit: { action: "create" } }), + ).rejects.toMatchObject({ code: "already_exists", path: "book.ipynb" }) + expect(ctx.calls.write).toHaveLength(0) + }) + + it("rejects a missing parent directory with not_found", async () => { + const missing = { + realpath: async (value: string) => (value === "/repo" ? value : Promise.reject(new Error("ENOENT"))), + } + const ctx = adapter([], "/repo/missing/fresh.ipynb", missing) + await expect( + ctx.adapter.edit({ directory: "/repo", path: "missing/fresh.ipynb", index: 0, edit: { action: "create" } }), + ).rejects.toMatchObject({ code: "not_found" }) + expect(ctx.calls.write).toHaveLength(0) + }) + + it("rejects non-.ipynb create targets", async () => { + const ctx = adapter([], "/repo/notes.txt", creating) + await expect( + ctx.adapter.edit({ directory: "/repo", path: "notes.txt", index: 0, edit: { action: "create" } }), + ).rejects.toMatchObject({ code: "invalid_path" }) + expect(ctx.calls.write).toHaveLength(0) + }) + + it("rejects create targets excluded by access rules", async () => { + const guard = { validateAccess: mock(() => false) } + const document = notebook([], "/repo/fresh.ipynb") + const ctx = harness(document, []) + const core = new NotebookAdapter(guard, { deps: ctx.deps, paths: creating, timeout: 50 }) + await expect( + core.edit({ directory: "/repo", path: "fresh.ipynb", index: 0, edit: { action: "create" } }), + ).rejects.toMatchObject({ code: "invalid_path" }) + expect(ctx.calls.write).toHaveLength(0) + }) +}) diff --git a/packages/opencode/src/kilocode/notebook/protocol.ts b/packages/opencode/src/kilocode/notebook/protocol.ts index c893a57f89c..c202f6a6208 100644 --- a/packages/opencode/src/kilocode/notebook/protocol.ts +++ b/packages/opencode/src/kilocode/notebook/protocol.ts @@ -72,12 +72,15 @@ const CellEdit = { export const EditRequest = Schema.Struct({ ...Base, operation: Schema.Literal("edit"), - expectedRevision: Revision, + expectedRevision: Schema.optional(Revision).annotate({ + description: "Required for insert, replace, and delete; omitted for create, which has no prior revision", + }), index: Index, edit: Schema.Union([ Schema.Struct({ action: Schema.Literal("insert"), ...CellEdit }), Schema.Struct({ action: Schema.Literal("replace"), ...CellEdit }), Schema.Struct({ action: Schema.Literal("delete") }), + Schema.Struct({ action: Schema.Literal("create") }), ]), }).annotate({ identifier: "NotebookEditRequest" }) @@ -114,7 +117,7 @@ export const EditResult = Schema.Struct({ requestPath: Path, revision: Revision, index: Index, - action: Schema.Literals(["insert", "replace", "delete"]), + action: Schema.Literals(["insert", "replace", "delete", "create"]), cell: Schema.optional(Cell), }).annotate({ identifier: "NotebookEditResult" }) @@ -141,6 +144,7 @@ export const Result = Schema.Union([ReadResult, EditResult, ExecuteResult]).anno export type Result = Schema.Schema.Type export const ErrorCode = Schema.Literals([ + "already_exists", "cancelled", "closed", "disconnected", diff --git a/packages/opencode/src/kilocode/tool/notebook-host.ts b/packages/opencode/src/kilocode/tool/notebook-host.ts index f50f8e78c06..867c229b99d 100644 --- a/packages/opencode/src/kilocode/tool/notebook-host.ts +++ b/packages/opencode/src/kilocode/tool/notebook-host.ts @@ -58,7 +58,7 @@ export const NotebookReadTool = Tool.define< const notebook = yield* Notebook.Service return { description: - "Read a live, possibly unsaved VS Code notebook using a request-directory-relative path or a safe absolute path inside that directory. Returns an opaque content revision for edits; outputs are omitted unless include_outputs is true.", + "Read a live, possibly unsaved VS Code notebook using a request-directory-relative path or a safe absolute path inside that directory. Returns an opaque content revision for edits; outputs are omitted unless include_outputs is true. Use notebook_edit to create a notebook or change its cells, and notebook_execute to run a cell and generate its outputs.", parameters: ReadParams, execute: (params, ctx) => Effect.gen(function* () { @@ -91,10 +91,15 @@ export const NotebookReadTool = Tool.define< const EditParams = Schema.Struct({ path: Path, - expected_revision: Revision, - index: Index, - action: Schema.Literals(["insert", "replace", "delete"]).annotate({ - description: "insert and replace require kind and source; delete ignores cell fields", + expected_revision: Schema.optional(Revision).annotate({ + description: "Required for insert, replace, and delete. Omit for create, which has no prior revision.", + }), + index: Schema.optional(Index).annotate({ + description: "Zero-based cell index. Required for insert, replace, and delete. Ignored for create.", + }), + action: Schema.Literals(["insert", "replace", "delete", "create"]).annotate({ + description: + "insert and replace require kind and source; delete ignores cell fields; create makes a new empty .ipynb at path and ignores cell fields, index, and expected_revision", }), kind: Schema.optional(Schema.Literals(["code", "markdown"])).annotate({ description: "Cell kind. Required for insert and replace.", @@ -105,12 +110,27 @@ const EditParams = Schema.Struct({ type EditInput = Schema.Schema.Type function cellEdit(params: EditInput) { - if (params.action === "delete") return Effect.succeed({ action: params.action } as const) - if (params.kind === undefined || params.source === undefined) + if (params.action === "create") return Effect.succeed({ action: params.action } as const) + if (params.action === "delete") { + if (params.expected_revision === undefined || params.index === undefined) + return Effect.die( + new Tool.InvalidArgumentsError({ + tool: "notebook_edit", + detail: `the "delete" action requires both "expected_revision" and "index"`, + }), + ) + return Effect.succeed({ action: params.action } as const) + } + if ( + params.kind === undefined || + params.source === undefined || + params.expected_revision === undefined || + params.index === undefined + ) return Effect.die( new Tool.InvalidArgumentsError({ tool: "notebook_edit", - detail: `the "${params.action}" action requires both "kind" and "source"`, + detail: `the "${params.action}" action requires "kind", "source", "expected_revision", and "index"`, }), ) return Effect.succeed({ @@ -132,11 +152,12 @@ export const NotebookEditTool = Tool.define< const notebook = yield* Notebook.Service return { description: - "Insert, replace, or delete one cell in a live VS Code notebook. Paths may be request-directory-relative or safe absolute paths. Pass the latest opaque revision from notebook_read or the previous successful edit unchanged. A stale_revision error requires a fresh read; never blindly retry an index-based edit. Leaves the document dirty.", + "Insert, replace, delete, or create cells in a live VS Code notebook. To build a notebook from scratch, do not hand-write a .ipynb file: call this tool with action create, then insert cells one at a time, then run them with notebook_execute. Cell outputs are produced only by notebook_execute; never author outputs yourself. insert/replace/delete operate on one cell and require expected_revision and index. create makes a new empty .ipynb at path (the parent directory must exist) and returns its initial revision so you can then insert cells. Paths may be request-directory-relative or safe absolute paths. Pass the latest opaque revision from notebook_read or the previous successful edit unchanged. A stale_revision error requires a fresh read; never blindly retry an index-based edit. Leaves the document dirty.", parameters: EditParams, execute: (params, ctx) => Effect.gen(function* () { const edit = yield* cellEdit(params) + const index = params.action === "create" ? 0 : params.index! yield* ctx.ask({ permission: "notebook_edit", patterns: [params.path], @@ -144,7 +165,7 @@ export const NotebookEditTool = Tool.define< metadata: { path: params.path, action: params.action, - index: params.index, + index, expectedRevision: params.expected_revision, }, }) @@ -153,8 +174,8 @@ export const NotebookEditTool = Tool.define< operation: "edit", sessionID: ctx.sessionID, path: params.path, - expectedRevision: params.expected_revision, - index: params.index, + ...(params.expected_revision !== undefined ? { expectedRevision: params.expected_revision } : {}), + index, edit, }), ctx.abort, @@ -162,7 +183,10 @@ export const NotebookEditTool = Tool.define< if (result.operation !== "edit") return yield* Effect.die(new Error("Notebook host returned the wrong result type")) return { - title: `${result.action} notebook cell ${result.index}`, + title: + result.action === "create" + ? `created notebook ${result.path}` + : `${result.action} notebook cell ${result.index}`, output: render(result), metadata: { path: result.path, revision: result.revision, index: result.index }, } diff --git a/packages/opencode/test/kilocode/notebook-tools.test.ts b/packages/opencode/test/kilocode/notebook-tools.test.ts index 616349b4236..7bd89e3103b 100644 --- a/packages/opencode/test/kilocode/notebook-tools.test.ts +++ b/packages/opencode/test/kilocode/notebook-tools.test.ts @@ -27,7 +27,7 @@ const notebook = Layer.mock(Notebook.Service, { operation: "edit" as const, path: input.path, requestPath: input.path, - revision: "content:edit", + revision: input.edit.action === "create" ? "content:create" : "content:edit", index: input.index, action: input.edit.action, }) @@ -135,6 +135,26 @@ describe("native notebook tools", () => { }), { git: true }, ) + + it.instance( + "creates a new notebook through notebook_edit without a revision or cell fields", + () => + Effect.gen(function* () { + calls.length = 0 + const edit = yield* NotebookEditTool.pipe(Effect.flatMap(Tool.init)) + const asks: Parameters[0][] = [] + const ctx = context(asks) + const result = yield* edit.execute({ path: "fresh.ipynb", action: "create" }, ctx) + + expect(asks.map((item) => item.permission)).toEqual(["notebook_edit"]) + expect(calls).toEqual([ + { operation: "edit", sessionID: ctx.sessionID, path: "fresh.ipynb", index: 0, edit: { action: "create" } }, + ]) + expect(result.title).toContain("created notebook") + expect(result.metadata).toMatchObject({ path: "fresh.ipynb", revision: "content:create", index: 0 }) + }), + { git: true }, + ) }) test("uses dedicated VS Code notebook permission defaults only when enabled", () => { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 18badb5a81d..227b2487fad 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -212,7 +212,7 @@ export type NotebookEditRequest = { /** * Opaque notebook content revision; pass it back unchanged and do not parse or increment it */ - expectedRevision: string + expectedRevision?: string /** * Zero-based cell index */ @@ -233,6 +233,9 @@ export type NotebookEditRequest = { | { action: "delete" } + | { + action: "create" + } } export type NotebookExecuteRequest = { @@ -2539,7 +2542,7 @@ export type NotebookEditResult = { * Zero-based cell index */ index: number - action: "insert" | "replace" | "delete" + action: "insert" | "replace" | "delete" | "create" cell?: NotebookCell } @@ -2564,6 +2567,7 @@ export type NotebookResult = NotebookReadResult | NotebookEditResult | NotebookE export type NotebookFailure = { code: + | "already_exists" | "cancelled" | "closed" | "disconnected" diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 4f4e2b491d2..26f2721c8bb 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -17647,11 +17647,22 @@ }, "required": ["action"], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["create"] + } + }, + "required": ["action"], + "additionalProperties": false } ] } }, - "required": ["id", "sessionID", "path", "operation", "expectedRevision", "index", "edit"], + "required": ["id", "sessionID", "path", "operation", "index", "edit"], "additionalProperties": false }, "NotebookExecuteRequest": { @@ -24518,7 +24529,7 @@ }, "action": { "type": "string", - "enum": ["insert", "replace", "delete"] + "enum": ["insert", "replace", "delete", "create"] }, "cell": { "$ref": "#/components/schemas/NotebookCell" @@ -24592,6 +24603,7 @@ "code": { "type": "string", "enum": [ + "already_exists", "cancelled", "closed", "disconnected",