diff --git a/notebook-kernel-lease-guard/README.md b/notebook-kernel-lease-guard/README.md new file mode 100644 index 00000000..4c3e8cd1 --- /dev/null +++ b/notebook-kernel-lease-guard/README.md @@ -0,0 +1,18 @@ +# Notebook Kernel Lease Guard + +This module adds a focused notebook kernel lease and execution safety guard for the Real-Time Collaborative Editor. + +It evaluates whether Jupyter-style notebook cells can be run or published safely in a collaborative document by checking active kernel leases, ownership handoffs, collaborator status, resource limits, stale outputs after kernel restarts, changed source hashes, and unresolved inline cell comments. + +## Run + +```sh +node notebook-kernel-lease-guard/test.js +node notebook-kernel-lease-guard/demo.js +``` + +The demo writes JSON and Markdown reviewer artifacts to `notebook-kernel-lease-guard/reports/`. + +## Review Surface + +The implementation is dependency-free, uses synthetic data only, and does not call external APIs or read credentials. diff --git a/notebook-kernel-lease-guard/acceptance-notes.md b/notebook-kernel-lease-guard/acceptance-notes.md new file mode 100644 index 00000000..e323d83a --- /dev/null +++ b/notebook-kernel-lease-guard/acceptance-notes.md @@ -0,0 +1,26 @@ +# Acceptance Notes + +## What Changed + +Added `notebook-kernel-lease-guard/`, a self-contained module for notebook kernel lease, handoff, and execution readiness in collaborative research documents. + +## How To Validate + +Run: + +```sh +node notebook-kernel-lease-guard/test.js +node notebook-kernel-lease-guard/demo.js +``` + +Optional syntax check: + +```sh +node --check notebook-kernel-lease-guard/index.js +node --check notebook-kernel-lease-guard/test.js +node --check notebook-kernel-lease-guard/demo.js +``` + +## Why This Is Issue-Specific + +Issue #12 calls for embedded Jupyter notebooks, kernel management, real-time execution, inline cell comments, collaborative locks, autosave, and version tracking. This guard makes those requirements concrete by blocking unsafe notebook execution and publication when leases expire, source/output hashes drift, kernel restarts stale outputs, or unresolved cell comments remain. diff --git a/notebook-kernel-lease-guard/demo.js b/notebook-kernel-lease-guard/demo.js new file mode 100644 index 00000000..69b85144 --- /dev/null +++ b/notebook-kernel-lease-guard/demo.js @@ -0,0 +1,82 @@ +const fs = require("fs"); +const path = require("path"); +const { evaluateNotebookKernelLeases } = require("./index"); + +const outputDir = path.join(__dirname, "reports"); +fs.mkdirSync(outputDir, { recursive: true }); + +const document = { + documentId: "doc-materials-manuscript", + projectId: "project-materials", + now: "2026-06-01T12:00:00Z", + collaborators: [ + { id: "ada", role: "author" }, + { id: "lin", role: "reviewer" }, + ], + kernels: [ + { id: "python-main", language: "python", restartId: "restart-5", memoryGb: 11 }, + { id: "r-stats", language: "r", restartId: "restart-1", memoryGb: 3 }, + ], + leases: [ + { + kernelId: "python-main", + ownerId: "ada", + expiresAt: "2026-06-01T13:00:00Z", + memoryLimitGb: 8, + handoffRequestedBy: "lin", + }, + { + kernelId: "r-stats", + ownerId: "lin", + expiresAt: "2026-06-01T11:30:00Z", + memoryLimitGb: 6, + }, + ], + cells: [ + { + id: "cell-model-fit", + kernelId: "python-main", + releaseState: "publication-ready", + currentSourceHash: "sha256:model-fit-v2", + lastExecutionHash: "sha256:model-fit-v1", + outputKernelRestartId: "restart-4", + }, + { + id: "cell-stat-table", + kernelId: "r-stats", + releaseState: "publication-ready", + currentSourceHash: "sha256:stat-table", + lastExecutionHash: "sha256:stat-table", + outputKernelRestartId: "restart-1", + }, + ], + comments: [{ id: "comment-7", cellId: "cell-model-fit", status: "open" }], +}; + +const report = evaluateNotebookKernelLeases(document); +const jsonPath = path.join(outputDir, "notebook-kernel-lease-report.json"); +const markdownPath = path.join(outputDir, "notebook-kernel-lease-report.md"); + +fs.writeFileSync(jsonPath, JSON.stringify(report, null, 2)); +fs.writeFileSync( + markdownPath, + [ + "# Notebook Kernel Lease Guard Demo", + "", + `Decision: ${report.decision}`, + `Audit digest: ${report.auditDigest}`, + "", + "## Findings", + "", + ...report.findings.map((finding) => `- ${finding.severity}: ${finding.code} - ${finding.message}`), + "", + "## Execution Queue", + "", + ...report.executionQueue.map((item) => `- ${item.cellId} on ${item.kernelId} for ${item.ownerId} (${item.priority})`), + "", + ].join("\n"), +); + +console.log(`Wrote ${jsonPath}`); +console.log(`Wrote ${markdownPath}`); +console.log(`${report.decision}: ${report.findings.length} finding(s), ${report.auditDigest}`); diff --git a/notebook-kernel-lease-guard/demo.mp4 b/notebook-kernel-lease-guard/demo.mp4 new file mode 100644 index 00000000..0c9693bb Binary files /dev/null and b/notebook-kernel-lease-guard/demo.mp4 differ diff --git a/notebook-kernel-lease-guard/demo.svg b/notebook-kernel-lease-guard/demo.svg new file mode 100644 index 00000000..3cae9c0f --- /dev/null +++ b/notebook-kernel-lease-guard/demo.svg @@ -0,0 +1,25 @@ + + + + Notebook Kernel Lease Guard + Real-time collaborative editor slice for issue #12 + + Kernel Lease + owner + expiry + handoff + resource limit + + Notebook Cells + source hash + restart id + inline comments + publication state + + Execution Safety + hold blockers + rerun queue + owner digest + audit digest + Decision: hold execution when leases expire or publication cells have unresolved comments. + diff --git a/notebook-kernel-lease-guard/index.js b/notebook-kernel-lease-guard/index.js new file mode 100644 index 00000000..d42a124f --- /dev/null +++ b/notebook-kernel-lease-guard/index.js @@ -0,0 +1,221 @@ +const crypto = require("crypto"); + +function asArray(value) { + if (!value) return []; + return Array.isArray(value) ? value : [value]; +} + +function stableStringify(value) { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function digest(value) { + return crypto.createHash("sha256").update(stableStringify(value)).digest("hex"); +} + +function parseTime(value) { + const time = Date.parse(value || ""); + return Number.isNaN(time) ? 0 : time; +} + +function normalize(value) { + return String(value || "").trim().toLowerCase(); +} + +function addFinding(findings, severity, code, target, message, remediation) { + findings.push({ severity, code, target, message, remediation }); +} + +function indexBy(items, key) { + return asArray(items).reduce((acc, item) => { + if (item && item[key]) acc[item[key]] = item; + return acc; + }, {}); +} + +function evaluateNotebookKernelLeases(packet) { + const document = packet || {}; + const now = parseTime(document.now || new Date().toISOString()); + const cells = asArray(document.cells); + const kernels = indexBy(document.kernels, "id"); + const leases = indexBy(document.leases, "kernelId"); + const comments = asArray(document.comments); + const collaborators = indexBy(document.collaborators, "id"); + const findings = []; + const executionQueue = []; + + if (!document.documentId || !document.projectId) { + addFinding( + findings, + "blocker", + "DOCUMENT_CONTEXT_MISSING", + "document", + "Document id and project id are required before kernel lease evaluation.", + "Attach stable document and project ids so execution leases are audit-safe.", + ); + } + + for (const kernel of asArray(document.kernels)) { + const lease = leases[kernel.id]; + const target = `kernel:${kernel.id}`; + if (!lease) { + addFinding( + findings, + "blocker", + "KERNEL_WITHOUT_LEASE", + target, + "A notebook kernel is available without an active lease record.", + "Create an owner-scoped lease with expiry, handoff status, and resource limits before allowing execution.", + ); + continue; + } + + if (!collaborators[lease.ownerId]) { + addFinding( + findings, + "blocker", + "LEASE_OWNER_NOT_COLLABORATOR", + target, + `Kernel lease owner ${lease.ownerId} is not an active collaborator.`, + "Release or transfer the lease to an active collaborator before execution.", + ); + } + + if (parseTime(lease.expiresAt) <= now) { + addFinding( + findings, + "blocker", + "KERNEL_LEASE_EXPIRED", + target, + "Kernel lease has expired.", + "Renew, transfer, or shut down the kernel before running more notebook cells.", + ); + } + + if (lease.handoffRequestedBy && !lease.handoffAcceptedAt) { + addFinding( + findings, + "warning", + "KERNEL_HANDOFF_PENDING", + target, + "Kernel handoff is requested but not accepted.", + "Pause execution until the new owner accepts the handoff and autosave snapshot.", + ); + } + + if (kernel.memoryGb && lease.memoryLimitGb && kernel.memoryGb > lease.memoryLimitGb) { + addFinding( + findings, + "warning", + "KERNEL_MEMORY_LIMIT_EXCEEDED", + target, + "Kernel memory usage exceeds the lease limit.", + "Queue execution or increase the approved resource lease before reruns.", + ); + } + } + + for (const cell of cells) { + const kernel = kernels[cell.kernelId]; + const lease = leases[cell.kernelId]; + const target = `cell:${cell.id || "unknown"}`; + + if (!kernel) { + addFinding( + findings, + "blocker", + "CELL_KERNEL_MISSING", + target, + "Notebook cell references a missing kernel.", + "Assign the cell to a valid project kernel before collaborative execution.", + ); + continue; + } + + if (!lease || parseTime(lease.expiresAt) <= now) { + addFinding( + findings, + "blocker", + "CELL_EXECUTION_BLOCKED_BY_LEASE", + target, + "Cell execution is blocked because its kernel lease is missing or expired.", + "Renew the lease or queue the cell for a valid owner.", + ); + } + + if (cell.outputKernelRestartId && cell.outputKernelRestartId !== kernel.restartId) { + addFinding( + findings, + "warning", + "CELL_OUTPUT_STALE_AFTER_RESTART", + target, + "Cell output was produced before the current kernel restart.", + "Rerun the cell after the latest restart before freezing the manuscript.", + ); + } + + if (cell.lastExecutionHash && cell.currentSourceHash && cell.lastExecutionHash !== cell.currentSourceHash) { + addFinding( + findings, + "warning", + "CELL_SOURCE_CHANGED_AFTER_OUTPUT", + target, + "Cell source changed after the last recorded output.", + "Queue the cell for rerun and refresh linked manuscript outputs.", + ); + } + + const unresolved = comments.filter((comment) => comment.cellId === cell.id && normalize(comment.status) !== "resolved"); + if (unresolved.length > 0 && normalize(cell.releaseState) === "publication-ready") { + addFinding( + findings, + "blocker", + "PUBLICATION_CELL_HAS_UNRESOLVED_COMMENTS", + target, + "Publication-ready notebook cell still has unresolved comments.", + "Resolve inline reviewer comments or move the cell out of publication-ready state.", + ); + } + + if (lease && parseTime(lease.expiresAt) > now && findings.every((finding) => finding.target !== target || finding.severity !== "blocker")) { + executionQueue.push({ + cellId: cell.id, + kernelId: cell.kernelId, + ownerId: lease.ownerId, + priority: normalize(cell.releaseState) === "publication-ready" ? "high" : "normal", + queueDigest: digest({ cellId: cell.id, kernelId: cell.kernelId, ownerId: lease.ownerId, source: cell.currentSourceHash }), + }); + } + } + + const blockers = findings.filter((finding) => finding.severity === "blocker"); + const warnings = findings.filter((finding) => finding.severity === "warning"); + const packetOut = { + documentId: document.documentId, + decision: blockers.length > 0 ? "hold-execution" : warnings.length > 0 ? "queue-with-warnings" : "ready-to-run", + counts: { + blocker: blockers.length, + warning: warnings.length, + info: findings.filter((finding) => finding.severity === "info").length, + }, + executionQueue, + findings, + }; + + return { + ...packetOut, + auditDigest: digest(packetOut), + }; +} + +module.exports = { + evaluateNotebookKernelLeases, + stableStringify, +}; diff --git a/notebook-kernel-lease-guard/reports/notebook-kernel-lease-report.json b/notebook-kernel-lease-guard/reports/notebook-kernel-lease-report.json new file mode 100644 index 00000000..b9563081 --- /dev/null +++ b/notebook-kernel-lease-guard/reports/notebook-kernel-lease-report.json @@ -0,0 +1,62 @@ +{ + "documentId": "doc-materials-manuscript", + "decision": "hold-execution", + "counts": { + "blocker": 3, + "warning": 4, + "info": 0 + }, + "executionQueue": [], + "findings": [ + { + "severity": "warning", + "code": "KERNEL_HANDOFF_PENDING", + "target": "kernel:python-main", + "message": "Kernel handoff is requested but not accepted.", + "remediation": "Pause execution until the new owner accepts the handoff and autosave snapshot." + }, + { + "severity": "warning", + "code": "KERNEL_MEMORY_LIMIT_EXCEEDED", + "target": "kernel:python-main", + "message": "Kernel memory usage exceeds the lease limit.", + "remediation": "Queue execution or increase the approved resource lease before reruns." + }, + { + "severity": "blocker", + "code": "KERNEL_LEASE_EXPIRED", + "target": "kernel:r-stats", + "message": "Kernel lease has expired.", + "remediation": "Renew, transfer, or shut down the kernel before running more notebook cells." + }, + { + "severity": "warning", + "code": "CELL_OUTPUT_STALE_AFTER_RESTART", + "target": "cell:cell-model-fit", + "message": "Cell output was produced before the current kernel restart.", + "remediation": "Rerun the cell after the latest restart before freezing the manuscript." + }, + { + "severity": "warning", + "code": "CELL_SOURCE_CHANGED_AFTER_OUTPUT", + "target": "cell:cell-model-fit", + "message": "Cell source changed after the last recorded output.", + "remediation": "Queue the cell for rerun and refresh linked manuscript outputs." + }, + { + "severity": "blocker", + "code": "PUBLICATION_CELL_HAS_UNRESOLVED_COMMENTS", + "target": "cell:cell-model-fit", + "message": "Publication-ready notebook cell still has unresolved comments.", + "remediation": "Resolve inline reviewer comments or move the cell out of publication-ready state." + }, + { + "severity": "blocker", + "code": "CELL_EXECUTION_BLOCKED_BY_LEASE", + "target": "cell:cell-stat-table", + "message": "Cell execution is blocked because its kernel lease is missing or expired.", + "remediation": "Renew the lease or queue the cell for a valid owner." + } + ], + "auditDigest": "582e7bc4f37e4f9501eec3d3124ade4f7462eeb229ba7aa2b84b4ed9e752f415" +} \ No newline at end of file diff --git a/notebook-kernel-lease-guard/reports/notebook-kernel-lease-report.md b/notebook-kernel-lease-guard/reports/notebook-kernel-lease-report.md new file mode 100644 index 00000000..849cd66b --- /dev/null +++ b/notebook-kernel-lease-guard/reports/notebook-kernel-lease-report.md @@ -0,0 +1,17 @@ +# Notebook Kernel Lease Guard Demo + +Decision: hold-execution +Audit digest: 582e7bc4f37e4f9501eec3d3124ade4f7462eeb229ba7aa2b84b4ed9e752f415 + +## Findings + +- warning: KERNEL_HANDOFF_PENDING - Kernel handoff is requested but not accepted. +- warning: KERNEL_MEMORY_LIMIT_EXCEEDED - Kernel memory usage exceeds the lease limit. +- blocker: KERNEL_LEASE_EXPIRED - Kernel lease has expired. +- warning: CELL_OUTPUT_STALE_AFTER_RESTART - Cell output was produced before the current kernel restart. +- warning: CELL_SOURCE_CHANGED_AFTER_OUTPUT - Cell source changed after the last recorded output. +- blocker: PUBLICATION_CELL_HAS_UNRESOLVED_COMMENTS - Publication-ready notebook cell still has unresolved comments. +- blocker: CELL_EXECUTION_BLOCKED_BY_LEASE - Cell execution is blocked because its kernel lease is missing or expired. + +## Execution Queue + diff --git a/notebook-kernel-lease-guard/requirements-map.md b/notebook-kernel-lease-guard/requirements-map.md new file mode 100644 index 00000000..8f944e2a --- /dev/null +++ b/notebook-kernel-lease-guard/requirements-map.md @@ -0,0 +1,14 @@ +# Requirements Map + +Issue #12 asks for a real-time collaborative scientific editor with Jupyter notebook integration, live collaboration, comments, locks, autosave, and version safety. + +| Issue requirement | Implementation coverage | +| --- | --- | +| Embedded Jupyter notebooks | Evaluates notebook cells linked to project kernels. | +| Real-time rendering and execution | Builds an execution queue only for cells with valid leases. | +| Kernel management per project or user session | Requires owner-scoped kernel leases with expiry, handoff state, and resource limits. | +| Inline cell commenting and annotation | Blocks publication-ready cells with unresolved inline comments. | +| Live collaboration and controlled locks | Detects pending kernel handoffs and inactive lease owners. | +| Version history and autosave | Flags source changes after output and stale outputs after kernel restarts before publication. | + +This slice is distinct from existing submissions because it focuses on notebook kernel ownership and execution safety, not autosave recovery, review freeze lanes, round-trip formatting, task dependencies, decision ledgers, figure/table review, equation references, or broad collaborative editor foundations. diff --git a/notebook-kernel-lease-guard/test.js b/notebook-kernel-lease-guard/test.js new file mode 100644 index 00000000..6915fd75 --- /dev/null +++ b/notebook-kernel-lease-guard/test.js @@ -0,0 +1,109 @@ +const assert = require("assert"); +const { evaluateNotebookKernelLeases } = require("./index"); + +function readyPacket(overrides = {}) { + return { + documentId: "doc-neuro-editor", + projectId: "project-neuro", + now: "2026-06-01T12:00:00Z", + collaborators: [ + { id: "ada", role: "author" }, + { id: "lin", role: "reviewer" }, + ], + kernels: [{ id: "kernel-python", language: "python", restartId: "restart-2", memoryGb: 5 }], + leases: [ + { + kernelId: "kernel-python", + ownerId: "ada", + expiresAt: "2026-06-01T13:00:00Z", + memoryLimitGb: 8, + }, + ], + cells: [ + { + id: "cell-1", + kernelId: "kernel-python", + releaseState: "draft", + currentSourceHash: "sha256:source-1", + lastExecutionHash: "sha256:source-1", + outputKernelRestartId: "restart-2", + }, + ], + comments: [], + ...overrides, + }; +} + +function testReadyPacket() { + const result = evaluateNotebookKernelLeases(readyPacket()); + assert.equal(result.decision, "ready-to-run"); + assert.equal(result.counts.blocker, 0); + assert.equal(result.executionQueue.length, 1); +} + +function testExpiredLeaseBlocksExecution() { + const result = evaluateNotebookKernelLeases( + readyPacket({ + leases: [{ kernelId: "kernel-python", ownerId: "ada", expiresAt: "2026-06-01T11:00:00Z", memoryLimitGb: 8 }], + }), + ); + + assert.equal(result.decision, "hold-execution"); + assert.ok(result.findings.some((finding) => finding.code === "KERNEL_LEASE_EXPIRED")); + assert.ok(result.findings.some((finding) => finding.code === "CELL_EXECUTION_BLOCKED_BY_LEASE")); +} + +function testStaleOutputWarns() { + const result = evaluateNotebookKernelLeases( + readyPacket({ + cells: [ + { + id: "cell-stale", + kernelId: "kernel-python", + releaseState: "draft", + currentSourceHash: "sha256:source-1", + lastExecutionHash: "sha256:source-1", + outputKernelRestartId: "restart-1", + }, + ], + }), + ); + + assert.equal(result.decision, "queue-with-warnings"); + assert.ok(result.findings.some((finding) => finding.code === "CELL_OUTPUT_STALE_AFTER_RESTART")); +} + +function testUnresolvedPublicationCommentBlocks() { + const result = evaluateNotebookKernelLeases( + readyPacket({ + cells: [ + { + id: "cell-publication", + kernelId: "kernel-python", + releaseState: "publication-ready", + currentSourceHash: "sha256:source-1", + lastExecutionHash: "sha256:source-1", + outputKernelRestartId: "restart-2", + }, + ], + comments: [{ id: "comment-1", cellId: "cell-publication", status: "open" }], + }), + ); + + assert.equal(result.decision, "hold-execution"); + assert.ok(result.findings.some((finding) => finding.code === "PUBLICATION_CELL_HAS_UNRESOLVED_COMMENTS")); +} + +function testDeterministicDigest() { + const first = evaluateNotebookKernelLeases(readyPacket()); + const second = evaluateNotebookKernelLeases(readyPacket()); + assert.equal(first.auditDigest, second.auditDigest); +} + +testReadyPacket(); +testExpiredLeaseBlocksExecution(); +testStaleOutputWarns(); +testUnresolvedPublicationCommentBlocks(); +testDeterministicDigest(); + +console.log("notebook-kernel-lease-guard tests passed");