@cmetech/otto 1.1.1 → 1.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/coworker/persona-commands.d.ts +1 -0
- package/dist/coworker/persona-commands.js +5 -0
- package/dist/coworker/persona-commands.test.d.ts +1 -0
- package/dist/coworker/persona-commands.test.js +45 -0
- package/dist/resources/.managed-resources-content-hash +1 -1
- package/dist/resources/extensions/_coworker-paths.js +8 -0
- package/dist/resources/extensions/coworker-artifacts/artifacts-command.js +31 -0
- package/dist/resources/extensions/coworker-artifacts/artifacts-singleton.js +17 -0
- package/dist/resources/extensions/coworker-artifacts/extension-manifest.json +13 -0
- package/dist/resources/extensions/coworker-artifacts/index.js +125 -0
- package/dist/resources/extensions/coworker-artifacts/list-tool.js +27 -0
- package/dist/resources/extensions/coworker-artifacts/open-tool.js +25 -0
- package/dist/resources/extensions/coworker-memory/extension-manifest.json +13 -0
- package/dist/resources/extensions/coworker-memory/index.js +219 -0
- package/dist/resources/extensions/coworker-memory/memorize-tool.js +10 -0
- package/dist/resources/extensions/coworker-memory/memory-command.js +157 -0
- package/dist/resources/extensions/coworker-memory/memory-singleton.js +55 -0
- package/dist/resources/extensions/coworker-memory/recall-tool.js +18 -0
- package/dist/resources/extensions/coworker-memory/session-hooks.js +45 -0
- package/dist/resources/extensions/coworker-scratchpad/attach-banners.js +53 -0
- package/dist/resources/extensions/coworker-scratchpad/extension-manifest.json +13 -0
- package/dist/resources/extensions/coworker-scratchpad/format-age.js +9 -0
- package/dist/resources/extensions/coworker-scratchpad/helpers.js +38 -0
- package/dist/resources/extensions/coworker-scratchpad/index.js +199 -0
- package/dist/resources/extensions/coworker-scratchpad/mime-bundle.js +20 -0
- package/dist/resources/extensions/coworker-scratchpad/scratchpad-tool.js +118 -0
- package/dist/resources/extensions/coworker-scratchpad/session-sidecar.js +60 -0
- package/dist/resources/extensions/coworker-scratchpad/sp-command.js +597 -0
- package/dist/resources/extensions/coworker-scratchpad/workspace-pointer.js +41 -0
- package/dist/resources/extensions/coworker-scratchpad/workspace-root.js +17 -0
- package/dist/resources/extensions/coworker-vault/audit-command.js +35 -0
- package/dist/resources/extensions/coworker-vault/connect-command.js +42 -0
- package/dist/resources/extensions/coworker-vault/datasource-command.js +50 -0
- package/dist/resources/extensions/coworker-vault/extension-manifest.json +12 -0
- package/dist/resources/extensions/coworker-vault/index.js +171 -0
- package/dist/resources/extensions/coworker-vault/test-helpers.js +86 -0
- package/dist/resources/extensions/coworker-vault/vault-singleton.js +24 -0
- package/dist/resources/extensions/otto/commands/release-notes/_data.js +71 -0
- package/dist/resources/extensions/otto/commands/release-notes/command.js +15 -4
- package/dist/resources/extensions/subagent/index.js +8 -1
- package/dist/resources/extensions/subagent/launch.js +37 -5
- package/dist/resources/extensions/subagent/run-store.js +1 -0
- package/dist/resources/extensions/workflow/bootstrap/register-extension.js +2 -0
- package/dist/resources/extensions/workflow/bootstrap/register-hooks.js +10 -0
- package/dist/resources/extensions/workflow/persona-status.js +87 -0
- package/package.json +25 -10
- package/packages/contracts/package.json +1 -1
- package/packages/coworker-artifacts/dist/artifact-store.d.ts +25 -0
- package/packages/coworker-artifacts/dist/artifact-store.js +187 -0
- package/packages/coworker-artifacts/dist/dir-snapshot.d.ts +7 -0
- package/packages/coworker-artifacts/dist/dir-snapshot.js +54 -0
- package/packages/coworker-artifacts/dist/errors.d.ts +18 -0
- package/packages/coworker-artifacts/dist/errors.js +37 -0
- package/packages/coworker-artifacts/dist/index.d.ts +7 -0
- package/packages/coworker-artifacts/dist/index.js +7 -0
- package/packages/coworker-artifacts/dist/readme-renderer.d.ts +5 -0
- package/packages/coworker-artifacts/dist/readme-renderer.js +47 -0
- package/packages/coworker-artifacts/dist/resolve-uri.d.ts +3 -0
- package/packages/coworker-artifacts/dist/resolve-uri.js +29 -0
- package/packages/coworker-artifacts/dist/slug.d.ts +4 -0
- package/packages/coworker-artifacts/dist/slug.js +32 -0
- package/packages/coworker-artifacts/dist/types.d.ts +52 -0
- package/packages/coworker-artifacts/dist/types.js +1 -0
- package/packages/coworker-artifacts/package.json +20 -0
- package/packages/coworker-artifacts/src/artifact-store.test.ts +188 -0
- package/packages/coworker-artifacts/src/artifact-store.ts +206 -0
- package/packages/coworker-artifacts/src/artifacts-integration.test.ts +109 -0
- package/packages/coworker-artifacts/src/dir-snapshot.test.ts +71 -0
- package/packages/coworker-artifacts/src/dir-snapshot.ts +52 -0
- package/packages/coworker-artifacts/src/errors.test.ts +37 -0
- package/packages/coworker-artifacts/src/errors.ts +28 -0
- package/packages/coworker-artifacts/src/index.test.ts +22 -0
- package/packages/coworker-artifacts/src/index.ts +7 -0
- package/packages/coworker-artifacts/src/readme-renderer.test.ts +72 -0
- package/packages/coworker-artifacts/src/readme-renderer.ts +56 -0
- package/packages/coworker-artifacts/src/resolve-uri.test.ts +46 -0
- package/packages/coworker-artifacts/src/resolve-uri.ts +29 -0
- package/packages/coworker-artifacts/src/slug.test.ts +47 -0
- package/packages/coworker-artifacts/src/slug.ts +31 -0
- package/packages/coworker-artifacts/src/types.ts +61 -0
- package/packages/coworker-artifacts/tsconfig.json +15 -0
- package/packages/coworker-artifacts/tsconfig.publish.json +4 -0
- package/packages/coworker-memory/dist/context-injection.d.ts +9 -0
- package/packages/coworker-memory/dist/context-injection.js +41 -0
- package/packages/coworker-memory/dist/errors.d.ts +25 -0
- package/packages/coworker-memory/dist/errors.js +51 -0
- package/packages/coworker-memory/dist/index.d.ts +12 -0
- package/packages/coworker-memory/dist/index.js +12 -0
- package/packages/coworker-memory/dist/layer-a-store.d.ts +16 -0
- package/packages/coworker-memory/dist/layer-a-store.js +78 -0
- package/packages/coworker-memory/dist/local-sqlite-backend.d.ts +28 -0
- package/packages/coworker-memory/dist/local-sqlite-backend.js +167 -0
- package/packages/coworker-memory/dist/memory-backend.d.ts +14 -0
- package/packages/coworker-memory/dist/memory-backend.js +1 -0
- package/packages/coworker-memory/dist/memory-recorder.d.ts +50 -0
- package/packages/coworker-memory/dist/memory-recorder.js +69 -0
- package/packages/coworker-memory/dist/migrations/001-init.sql +38 -0
- package/packages/coworker-memory/dist/migrations/002-artifact-kind.sql +50 -0
- package/packages/coworker-memory/dist/paste-detector.d.ts +5 -0
- package/packages/coworker-memory/dist/paste-detector.js +14 -0
- package/packages/coworker-memory/dist/persona-seed.d.ts +10 -0
- package/packages/coworker-memory/dist/persona-seed.js +38 -0
- package/packages/coworker-memory/dist/recall-formatter.d.ts +2 -0
- package/packages/coworker-memory/dist/recall-formatter.js +14 -0
- package/packages/coworker-memory/dist/scope-resolver.d.ts +9 -0
- package/packages/coworker-memory/dist/scope-resolver.js +10 -0
- package/packages/coworker-memory/dist/types.d.ts +51 -0
- package/packages/coworker-memory/dist/types.js +2 -0
- package/packages/coworker-memory/dist/workspace-id.d.ts +3 -0
- package/packages/coworker-memory/dist/workspace-id.js +54 -0
- package/packages/coworker-memory/package.json +35 -0
- package/packages/coworker-memory/src/activator-integration.test.ts +141 -0
- package/packages/coworker-memory/src/context-injection.test.ts +72 -0
- package/packages/coworker-memory/src/context-injection.ts +57 -0
- package/packages/coworker-memory/src/errors.test.ts +45 -0
- package/packages/coworker-memory/src/errors.ts +42 -0
- package/packages/coworker-memory/src/index.test.ts +21 -0
- package/packages/coworker-memory/src/index.ts +12 -0
- package/packages/coworker-memory/src/layer-a-store.test.ts +85 -0
- package/packages/coworker-memory/src/layer-a-store.ts +88 -0
- package/packages/coworker-memory/src/local-sqlite-backend.test.ts +110 -0
- package/packages/coworker-memory/src/local-sqlite-backend.ts +185 -0
- package/packages/coworker-memory/src/memory-backend.ts +10 -0
- package/packages/coworker-memory/src/memory-integration.test.ts +89 -0
- package/packages/coworker-memory/src/memory-recorder.test.ts +101 -0
- package/packages/coworker-memory/src/memory-recorder.ts +95 -0
- package/packages/coworker-memory/src/migrations/001-init.sql +38 -0
- package/packages/coworker-memory/src/migrations/002-artifact-kind.sql +50 -0
- package/packages/coworker-memory/src/paste-detector.test.ts +23 -0
- package/packages/coworker-memory/src/paste-detector.ts +18 -0
- package/packages/coworker-memory/src/persona-seed.test.ts +57 -0
- package/packages/coworker-memory/src/persona-seed.ts +46 -0
- package/packages/coworker-memory/src/recall-formatter.test.ts +34 -0
- package/packages/coworker-memory/src/recall-formatter.ts +15 -0
- package/packages/coworker-memory/src/scope-resolver.test.ts +23 -0
- package/packages/coworker-memory/src/scope-resolver.ts +18 -0
- package/packages/coworker-memory/src/types.ts +61 -0
- package/packages/coworker-memory/src/workspace-id.test.ts +48 -0
- package/packages/coworker-memory/src/workspace-id.ts +56 -0
- package/packages/coworker-memory/tsconfig.json +15 -0
- package/packages/coworker-memory/tsconfig.publish.json +4 -0
- package/packages/coworker-persona/dist/commands.d.ts +7 -0
- package/packages/coworker-persona/dist/commands.js +35 -0
- package/packages/coworker-persona/dist/defaults/manifest.yaml +12 -0
- package/packages/coworker-persona/dist/defaults/steering/identity.md +3 -0
- package/packages/coworker-persona/dist/index.d.ts +3 -0
- package/packages/coworker-persona/dist/index.js +3 -0
- package/packages/coworker-persona/dist/manifest.d.ts +24 -0
- package/packages/coworker-persona/dist/manifest.js +21 -0
- package/packages/coworker-persona/dist/registry.d.ts +22 -0
- package/packages/coworker-persona/dist/registry.js +142 -0
- package/packages/coworker-persona/package.json +28 -0
- package/packages/coworker-persona/scripts/copy-defaults.cjs +17 -0
- package/packages/coworker-persona/src/commands.ts +47 -0
- package/packages/coworker-persona/src/defaults/manifest.yaml +12 -0
- package/packages/coworker-persona/src/defaults/steering/identity.md +3 -0
- package/packages/coworker-persona/src/index.ts +3 -0
- package/packages/coworker-persona/src/manifest.test.ts +67 -0
- package/packages/coworker-persona/src/manifest.ts +49 -0
- package/packages/coworker-persona/src/registry.test.ts +89 -0
- package/packages/coworker-persona/src/registry.ts +147 -0
- package/packages/coworker-persona/tsconfig.json +15 -0
- package/packages/coworker-persona/tsconfig.publish.json +4 -0
- package/packages/coworker-scratchpad/dist/cell-archive.d.ts +39 -0
- package/packages/coworker-scratchpad/dist/cell-archive.js +77 -0
- package/packages/coworker-scratchpad/dist/cell-tree.d.ts +14 -0
- package/packages/coworker-scratchpad/dist/cell-tree.js +72 -0
- package/packages/coworker-scratchpad/dist/child-process-runtime.d.ts +129 -0
- package/packages/coworker-scratchpad/dist/child-process-runtime.js +427 -0
- package/packages/coworker-scratchpad/dist/collector-registry.d.ts +12 -0
- package/packages/coworker-scratchpad/dist/collector-registry.js +29 -0
- package/packages/coworker-scratchpad/dist/detect-kind.d.ts +3 -0
- package/packages/coworker-scratchpad/dist/detect-kind.js +19 -0
- package/packages/coworker-scratchpad/dist/file-collector.d.ts +15 -0
- package/packages/coworker-scratchpad/dist/file-collector.js +99 -0
- package/packages/coworker-scratchpad/dist/index.d.ts +13 -0
- package/packages/coworker-scratchpad/dist/index.js +13 -0
- package/packages/coworker-scratchpad/dist/kernel-bindings.d.ts +49 -0
- package/packages/coworker-scratchpad/dist/kernel-bindings.js +220 -0
- package/packages/coworker-scratchpad/dist/kernel-entry.d.ts +1 -0
- package/packages/coworker-scratchpad/dist/kernel-entry.js +355 -0
- package/packages/coworker-scratchpad/dist/kernel-protocol.d.ts +171 -0
- package/packages/coworker-scratchpad/dist/kernel-protocol.js +48 -0
- package/packages/coworker-scratchpad/dist/kernel-spawn.d.ts +3 -0
- package/packages/coworker-scratchpad/dist/kernel-spawn.js +54 -0
- package/packages/coworker-scratchpad/dist/namespace-codec.d.ts +22 -0
- package/packages/coworker-scratchpad/dist/namespace-codec.js +61 -0
- package/packages/coworker-scratchpad/dist/scratchpad-lock.d.ts +24 -0
- package/packages/coworker-scratchpad/dist/scratchpad-lock.js +86 -0
- package/packages/coworker-scratchpad/dist/scratchpad-manager.d.ts +193 -0
- package/packages/coworker-scratchpad/dist/scratchpad-manager.js +866 -0
- package/packages/coworker-scratchpad/dist/staleness-banner.d.ts +12 -0
- package/packages/coworker-scratchpad/dist/staleness-banner.js +27 -0
- package/packages/coworker-scratchpad/package.json +31 -0
- package/packages/coworker-scratchpad/src/cell-archive.test.ts +150 -0
- package/packages/coworker-scratchpad/src/cell-archive.ts +97 -0
- package/packages/coworker-scratchpad/src/cell-tree.test.ts +105 -0
- package/packages/coworker-scratchpad/src/cell-tree.ts +90 -0
- package/packages/coworker-scratchpad/src/child-process-runtime.test.ts +413 -0
- package/packages/coworker-scratchpad/src/child-process-runtime.ts +493 -0
- package/packages/coworker-scratchpad/src/collector-registry.test.ts +69 -0
- package/packages/coworker-scratchpad/src/collector-registry.ts +33 -0
- package/packages/coworker-scratchpad/src/detect-kind.test.ts +33 -0
- package/packages/coworker-scratchpad/src/detect-kind.ts +22 -0
- package/packages/coworker-scratchpad/src/file-collector.test.ts +109 -0
- package/packages/coworker-scratchpad/src/file-collector.ts +114 -0
- package/packages/coworker-scratchpad/src/index.ts +74 -0
- package/packages/coworker-scratchpad/src/kernel-bindings.test.ts +188 -0
- package/packages/coworker-scratchpad/src/kernel-bindings.ts +279 -0
- package/packages/coworker-scratchpad/src/kernel-entry.test.ts +123 -0
- package/packages/coworker-scratchpad/src/kernel-entry.ts +390 -0
- package/packages/coworker-scratchpad/src/kernel-protocol.test.ts +105 -0
- package/packages/coworker-scratchpad/src/kernel-protocol.ts +230 -0
- package/packages/coworker-scratchpad/src/kernel-spawn.test.ts +60 -0
- package/packages/coworker-scratchpad/src/kernel-spawn.ts +54 -0
- package/packages/coworker-scratchpad/src/namespace-codec.test.ts +102 -0
- package/packages/coworker-scratchpad/src/namespace-codec.ts +90 -0
- package/packages/coworker-scratchpad/src/scratchpad-lock.test.ts +98 -0
- package/packages/coworker-scratchpad/src/scratchpad-lock.ts +102 -0
- package/packages/coworker-scratchpad/src/scratchpad-manager.test.ts +1343 -0
- package/packages/coworker-scratchpad/src/scratchpad-manager.ts +891 -0
- package/packages/coworker-scratchpad/src/staleness-banner.test.ts +53 -0
- package/packages/coworker-scratchpad/src/staleness-banner.ts +33 -0
- package/packages/coworker-scratchpad/src/vault-integration.test.ts +221 -0
- package/packages/coworker-scratchpad/tsconfig.json +15 -0
- package/packages/coworker-scratchpad/tsconfig.publish.json +4 -0
- package/packages/coworker-types/dist/artifacts.d.ts +31 -0
- package/packages/coworker-types/dist/artifacts.js +2 -0
- package/packages/coworker-types/dist/contracts.d.ts +32 -0
- package/packages/coworker-types/dist/contracts.js +1 -0
- package/packages/coworker-types/dist/index.d.ts +5 -0
- package/packages/coworker-types/dist/index.js +5 -0
- package/packages/coworker-types/dist/memory.d.ts +61 -0
- package/packages/coworker-types/dist/memory.js +3 -0
- package/packages/coworker-types/dist/scratchpad.d.ts +43 -0
- package/packages/coworker-types/dist/scratchpad.js +2 -0
- package/packages/coworker-types/dist/vault.d.ts +34 -0
- package/packages/coworker-types/dist/vault.js +2 -0
- package/packages/coworker-types/package.json +24 -0
- package/packages/coworker-types/src/artifacts.test.ts +52 -0
- package/packages/coworker-types/src/artifacts.ts +35 -0
- package/packages/coworker-types/src/contracts.test.ts +43 -0
- package/packages/coworker-types/src/contracts.ts +36 -0
- package/packages/coworker-types/src/index.ts +5 -0
- package/packages/coworker-types/src/memory.test.ts +50 -0
- package/packages/coworker-types/src/memory.ts +79 -0
- package/packages/coworker-types/src/scratchpad.test.ts +46 -0
- package/packages/coworker-types/src/scratchpad.ts +51 -0
- package/packages/coworker-types/src/smoke.test.ts +34 -0
- package/packages/coworker-types/src/vault.test.ts +49 -0
- package/packages/coworker-types/src/vault.ts +40 -0
- package/packages/coworker-types/tsconfig.json +15 -0
- package/packages/coworker-types/tsconfig.publish.json +4 -0
- package/packages/coworker-utils/dist/audit-log.d.ts +34 -0
- package/packages/coworker-utils/dist/audit-log.js +88 -0
- package/packages/coworker-utils/dist/index.d.ts +6 -0
- package/packages/coworker-utils/dist/index.js +6 -0
- package/packages/coworker-utils/dist/lease.d.ts +7 -0
- package/packages/coworker-utils/dist/lease.js +67 -0
- package/packages/coworker-utils/dist/logger.d.ts +13 -0
- package/packages/coworker-utils/dist/logger.js +26 -0
- package/packages/coworker-utils/dist/migration-runner.d.ts +7 -0
- package/packages/coworker-utils/dist/migration-runner.js +36 -0
- package/packages/coworker-utils/dist/ndjson-channel.d.ts +3 -0
- package/packages/coworker-utils/dist/ndjson-channel.js +38 -0
- package/packages/coworker-utils/dist/secret-scanner.d.ts +10 -0
- package/packages/coworker-utils/dist/secret-scanner.js +42 -0
- package/packages/coworker-utils/package.json +24 -0
- package/packages/coworker-utils/src/audit-log.test.ts +140 -0
- package/packages/coworker-utils/src/audit-log.ts +107 -0
- package/packages/coworker-utils/src/index.ts +6 -0
- package/packages/coworker-utils/src/lease.test.ts +64 -0
- package/packages/coworker-utils/src/lease.ts +76 -0
- package/packages/coworker-utils/src/logger.test.ts +50 -0
- package/packages/coworker-utils/src/logger.ts +45 -0
- package/packages/coworker-utils/src/migration-runner.test.ts +65 -0
- package/packages/coworker-utils/src/migration-runner.ts +50 -0
- package/packages/coworker-utils/src/ndjson-channel.test.ts +76 -0
- package/packages/coworker-utils/src/ndjson-channel.ts +41 -0
- package/packages/coworker-utils/src/secret-scanner.test.ts +61 -0
- package/packages/coworker-utils/src/secret-scanner.ts +56 -0
- package/packages/coworker-utils/tsconfig.json +15 -0
- package/packages/coworker-utils/tsconfig.publish.json +4 -0
- package/packages/coworker-vault/dist/data-vault.d.ts +41 -0
- package/packages/coworker-vault/dist/data-vault.js +223 -0
- package/packages/coworker-vault/dist/engine-registry.d.ts +34 -0
- package/packages/coworker-vault/dist/engine-registry.js +90 -0
- package/packages/coworker-vault/dist/engines/jira.yaml +17 -0
- package/packages/coworker-vault/dist/errors.d.ts +28 -0
- package/packages/coworker-vault/dist/errors.js +57 -0
- package/packages/coworker-vault/dist/index.d.ts +6 -0
- package/packages/coworker-vault/dist/index.js +6 -0
- package/packages/coworker-vault/dist/injector.d.ts +19 -0
- package/packages/coworker-vault/dist/injector.js +77 -0
- package/packages/coworker-vault/dist/types.d.ts +28 -0
- package/packages/coworker-vault/dist/types.js +1 -0
- package/packages/coworker-vault/dist/vault-keep.d.ts +4 -0
- package/packages/coworker-vault/dist/vault-keep.js +21 -0
- package/packages/coworker-vault/package.json +29 -0
- package/packages/coworker-vault/src/data-vault.test.ts +199 -0
- package/packages/coworker-vault/src/data-vault.ts +257 -0
- package/packages/coworker-vault/src/engine-registry.test.ts +120 -0
- package/packages/coworker-vault/src/engine-registry.ts +107 -0
- package/packages/coworker-vault/src/engines/jira.yaml +17 -0
- package/packages/coworker-vault/src/errors.test.ts +58 -0
- package/packages/coworker-vault/src/errors.ts +50 -0
- package/packages/coworker-vault/src/index.test.ts +24 -0
- package/packages/coworker-vault/src/index.ts +6 -0
- package/packages/coworker-vault/src/injector.test.ts +109 -0
- package/packages/coworker-vault/src/injector.ts +98 -0
- package/packages/coworker-vault/src/types.ts +33 -0
- package/packages/coworker-vault/src/vault-keep.test.ts +49 -0
- package/packages/coworker-vault/src/vault-keep.ts +31 -0
- package/packages/coworker-vault/tsconfig.json +15 -0
- package/packages/coworker-vault/tsconfig.publish.json +4 -0
- package/packages/daemon/package.json +3 -3
- package/packages/mcp-server/package.json +3 -3
- package/packages/mcp-server/tsconfig.tsbuildinfo +1 -1
- package/packages/native/package.json +1 -1
- package/packages/native/tsconfig.tsbuildinfo +1 -1
- package/packages/pi-agent-core/package.json +1 -1
- package/packages/pi-agent-core/tsconfig.tsbuildinfo +1 -1
- package/packages/pi-ai/package.json +1 -1
- package/packages/pi-ai/tsconfig.tsbuildinfo +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts +6 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.js +22 -3
- package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/resolve-config-value.test.js +11 -0
- package/packages/pi-coding-agent/dist/core/resolve-config-value.test.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/rpc/raw-stdout.d.ts +47 -0
- package/packages/pi-coding-agent/dist/modes/rpc/raw-stdout.d.ts.map +1 -0
- package/packages/pi-coding-agent/dist/modes/rpc/raw-stdout.js +107 -0
- package/packages/pi-coding-agent/dist/modes/rpc/raw-stdout.js.map +1 -0
- package/packages/pi-coding-agent/dist/modes/rpc/raw-stdout.regression.test.d.ts +19 -0
- package/packages/pi-coding-agent/dist/modes/rpc/raw-stdout.regression.test.d.ts.map +1 -0
- package/packages/pi-coding-agent/dist/modes/rpc/raw-stdout.regression.test.js +121 -0
- package/packages/pi-coding-agent/dist/modes/rpc/raw-stdout.regression.test.js.map +1 -0
- package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js +17 -1
- package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js.map +1 -1
- package/packages/pi-coding-agent/package.json +2 -2
- package/packages/pi-coding-agent/src/core/extensions/runner.ts +22 -3
- package/packages/pi-coding-agent/src/core/resolve-config-value.test.ts +11 -0
- package/packages/pi-coding-agent/src/modes/rpc/raw-stdout.regression.test.ts +129 -0
- package/packages/pi-coding-agent/src/modes/rpc/raw-stdout.ts +117 -0
- package/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts +18 -1
- package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
- package/packages/pi-tui/package.json +1 -1
- package/packages/pi-tui/tsconfig.tsbuildinfo +1 -1
- package/packages/rpc-client/package.json +2 -2
- package/packages/rpc-client/tsconfig.tsbuildinfo +1 -1
- package/pkg/package.json +1 -1
- package/scripts/install.js +6 -5
- package/src/resources/extensions/_coworker-paths.test.ts +40 -0
- package/src/resources/extensions/_coworker-paths.ts +10 -0
- package/src/resources/extensions/coworker-artifacts/artifacts-command.test.ts +54 -0
- package/src/resources/extensions/coworker-artifacts/artifacts-command.ts +43 -0
- package/src/resources/extensions/coworker-artifacts/artifacts-singleton.test.ts +25 -0
- package/src/resources/extensions/coworker-artifacts/artifacts-singleton.ts +29 -0
- package/src/resources/extensions/coworker-artifacts/extension-manifest.json +13 -0
- package/src/resources/extensions/coworker-artifacts/index.test.ts +46 -0
- package/src/resources/extensions/coworker-artifacts/index.ts +154 -0
- package/src/resources/extensions/coworker-artifacts/list-tool.test.ts +29 -0
- package/src/resources/extensions/coworker-artifacts/list-tool.ts +53 -0
- package/src/resources/extensions/coworker-artifacts/open-tool.test.ts +30 -0
- package/src/resources/extensions/coworker-artifacts/open-tool.ts +43 -0
- package/src/resources/extensions/coworker-memory/extension-manifest.json +13 -0
- package/src/resources/extensions/coworker-memory/index.test.ts +137 -0
- package/src/resources/extensions/coworker-memory/index.ts +257 -0
- package/src/resources/extensions/coworker-memory/memorize-tool.test.ts +41 -0
- package/src/resources/extensions/coworker-memory/memorize-tool.ts +20 -0
- package/src/resources/extensions/coworker-memory/memory-command.test.ts +134 -0
- package/src/resources/extensions/coworker-memory/memory-command.ts +131 -0
- package/src/resources/extensions/coworker-memory/memory-singleton.test.ts +41 -0
- package/src/resources/extensions/coworker-memory/memory-singleton.ts +89 -0
- package/src/resources/extensions/coworker-memory/recall-tool.test.ts +50 -0
- package/src/resources/extensions/coworker-memory/recall-tool.ts +35 -0
- package/src/resources/extensions/coworker-memory/session-hooks.test.ts +77 -0
- package/src/resources/extensions/coworker-memory/session-hooks.ts +61 -0
- package/src/resources/extensions/coworker-scratchpad/attach-banners.test.ts +124 -0
- package/src/resources/extensions/coworker-scratchpad/attach-banners.ts +67 -0
- package/src/resources/extensions/coworker-scratchpad/extension-manifest.json +13 -0
- package/src/resources/extensions/coworker-scratchpad/format-age.test.ts +30 -0
- package/src/resources/extensions/coworker-scratchpad/format-age.ts +6 -0
- package/src/resources/extensions/coworker-scratchpad/helpers.test.ts +93 -0
- package/src/resources/extensions/coworker-scratchpad/helpers.ts +42 -0
- package/src/resources/extensions/coworker-scratchpad/index.test.ts +514 -0
- package/src/resources/extensions/coworker-scratchpad/index.ts +207 -0
- package/src/resources/extensions/coworker-scratchpad/mime-bundle.test.ts +61 -0
- package/src/resources/extensions/coworker-scratchpad/mime-bundle.ts +23 -0
- package/src/resources/extensions/coworker-scratchpad/scratchpad-tool.test.ts +137 -0
- package/src/resources/extensions/coworker-scratchpad/scratchpad-tool.ts +165 -0
- package/src/resources/extensions/coworker-scratchpad/session-sidecar.test.ts +133 -0
- package/src/resources/extensions/coworker-scratchpad/session-sidecar.ts +68 -0
- package/src/resources/extensions/coworker-scratchpad/sp-command.test.ts +836 -0
- package/src/resources/extensions/coworker-scratchpad/sp-command.ts +602 -0
- package/src/resources/extensions/coworker-scratchpad/workspace-pointer.test.ts +74 -0
- package/src/resources/extensions/coworker-scratchpad/workspace-pointer.ts +55 -0
- package/src/resources/extensions/coworker-scratchpad/workspace-root.test.ts +51 -0
- package/src/resources/extensions/coworker-scratchpad/workspace-root.ts +16 -0
- package/src/resources/extensions/coworker-vault/audit-command.test.ts +109 -0
- package/src/resources/extensions/coworker-vault/audit-command.ts +56 -0
- package/src/resources/extensions/coworker-vault/connect-command.test.ts +103 -0
- package/src/resources/extensions/coworker-vault/connect-command.ts +69 -0
- package/src/resources/extensions/coworker-vault/datasource-command.test.ts +80 -0
- package/src/resources/extensions/coworker-vault/datasource-command.ts +81 -0
- package/src/resources/extensions/coworker-vault/extension-manifest.json +12 -0
- package/src/resources/extensions/coworker-vault/index.test.ts +82 -0
- package/src/resources/extensions/coworker-vault/index.ts +181 -0
- package/src/resources/extensions/coworker-vault/test-helpers.ts +120 -0
- package/src/resources/extensions/coworker-vault/vault-singleton.test.ts +27 -0
- package/src/resources/extensions/coworker-vault/vault-singleton.ts +40 -0
- package/src/resources/extensions/otto/commands/release-notes/_data.ts +85 -0
- package/src/resources/extensions/otto/commands/release-notes/command.ts +16 -3
- package/src/resources/extensions/subagent/index.ts +9 -0
- package/src/resources/extensions/subagent/launch.test.ts +97 -0
- package/src/resources/extensions/subagent/launch.ts +42 -5
- package/src/resources/extensions/subagent/run-store.ts +3 -1
- package/src/resources/extensions/workflow/bootstrap/register-extension.ts +2 -0
- package/src/resources/extensions/workflow/bootstrap/register-hooks.ts +10 -0
- package/src/resources/extensions/workflow/persona-status.ts +109 -0
- package/src/resources/extensions/workflow/tests/auto-recovery.test.ts +34 -0
|
@@ -0,0 +1,1343 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
|
5
|
+
import { tmpdir, hostname } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import process from 'node:process';
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { pathToFileURL } from 'node:url';
|
|
10
|
+
import { ScratchpadManager, ForkKernelHangError } from './scratchpad-manager.js';
|
|
11
|
+
import type { ArtifactCreateDrawer, DataLoadDrawer } from './kernel-protocol.js';
|
|
12
|
+
import { ArtifactStore } from '@otto/coworker-artifacts';
|
|
13
|
+
|
|
14
|
+
let workspace: string;
|
|
15
|
+
let root: string;
|
|
16
|
+
let mgr: ScratchpadManager;
|
|
17
|
+
let mgr2: ScratchpadManager | undefined;
|
|
18
|
+
|
|
19
|
+
const liveOf = (m: ScratchpadManager, name: string): boolean =>
|
|
20
|
+
m.list().find((s) => s.name === name)!.live;
|
|
21
|
+
|
|
22
|
+
describe('ScratchpadManager (core + LRU)', () => {
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
workspace = await mkdtemp(join(tmpdir(), 'spm-ws-'));
|
|
25
|
+
await mkdir(join(workspace, '.otto', 'inputs'), { recursive: true });
|
|
26
|
+
root = await mkdtemp(join(tmpdir(), 'spm-root-'));
|
|
27
|
+
mgr2 = undefined;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(async () => {
|
|
31
|
+
await mgr?.disposeAll();
|
|
32
|
+
await mgr2?.disposeAll();
|
|
33
|
+
await rm(workspace, { recursive: true, force: true });
|
|
34
|
+
await rm(root, { recursive: true, force: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('getOrAttach creates a kernel that runs cells', async () => {
|
|
38
|
+
mgr = new ScratchpadManager({ workspace, root });
|
|
39
|
+
const rt = await mgr.getOrAttach('a');
|
|
40
|
+
const { value } = await rt.runCell('return 6 * 7;');
|
|
41
|
+
assert.equal(value, 42);
|
|
42
|
+
assert.equal(liveOf(mgr, 'a'), true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('getOrAttach is idempotent — same name returns the same runtime', async () => {
|
|
46
|
+
mgr = new ScratchpadManager({ workspace, root });
|
|
47
|
+
const first = await mgr.getOrAttach('a');
|
|
48
|
+
const second = await mgr.getOrAttach('a');
|
|
49
|
+
assert.equal(first, second);
|
|
50
|
+
assert.equal(mgr.list().length, 1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('create throws if the scratchpad already exists', async () => {
|
|
54
|
+
mgr = new ScratchpadManager({ workspace, root });
|
|
55
|
+
await mgr.create('a');
|
|
56
|
+
await assert.rejects(() => mgr.create('a'), /scratchpad a already exists/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('a second manager on the same root sees a live kernel as busy', async () => {
|
|
60
|
+
mgr = new ScratchpadManager({ workspace, root });
|
|
61
|
+
await mgr.getOrAttach('a');
|
|
62
|
+
mgr2 = new ScratchpadManager({ workspace, root });
|
|
63
|
+
await assert.rejects(() => mgr2.getOrAttach('a'), /scratchpad a is busy in another session/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('force-takeover steals a busy lock (after the prior runtime is cold)', async () => {
|
|
67
|
+
// Evict 'a' to cold (snapshotThenDispose releases DuckDB file, keeps lock.json).
|
|
68
|
+
mgr = new ScratchpadManager({ workspace, root, maxLiveKernels: 1 });
|
|
69
|
+
await mgr.getOrAttach('a');
|
|
70
|
+
await mgr.getOrAttach('b'); // LRU-evicts 'a' → runtime cold, lock retained
|
|
71
|
+
assert.equal(liveOf(mgr, 'a'), false);
|
|
72
|
+
mgr2 = new ScratchpadManager({ workspace, root });
|
|
73
|
+
const rt = await mgr2.getOrAttach('a', { forceTakeover: true, takeoverReason: 'test' });
|
|
74
|
+
assert.equal((await rt.runCell('return 1;')).value, 1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('auto-clears a stale lock (dead-pid holder) and attaches', async () => {
|
|
78
|
+
// Simulate a crashed prior session: a lock.json whose holder pid is dead.
|
|
79
|
+
const dir = join(root, 'a');
|
|
80
|
+
await mkdir(dir, { recursive: true });
|
|
81
|
+
const c = spawn(process.execPath, ['-e', '']);
|
|
82
|
+
const dead = c.pid as number;
|
|
83
|
+
await new Promise<void>((r) => c.on('exit', () => r()));
|
|
84
|
+
await writeFile(join(dir, 'lock.json'),
|
|
85
|
+
JSON.stringify({ pid: dead, host: hostname(), acquired_at: '2026-01-01T00:00:00.000Z' }));
|
|
86
|
+
mgr = new ScratchpadManager({ workspace, root });
|
|
87
|
+
const rt = await mgr.getOrAttach('a'); // stale lock cleared on acquire
|
|
88
|
+
assert.equal((await rt.runCell('return 2;')).value, 2);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('LRU-evicts the least-recently-used kernel when the pool overflows', async () => {
|
|
92
|
+
let t = 1000;
|
|
93
|
+
mgr = new ScratchpadManager({ workspace, root, maxLiveKernels: 2, now: () => t });
|
|
94
|
+
await mgr.getOrAttach('a'); t += 10;
|
|
95
|
+
await mgr.getOrAttach('b'); t += 10;
|
|
96
|
+
await mgr.getOrAttach('c'); // pool full -> evict LRU 'a'
|
|
97
|
+
assert.equal(liveOf(mgr, 'a'), false); // cold
|
|
98
|
+
assert.equal(liveOf(mgr, 'b'), true);
|
|
99
|
+
assert.equal(liveOf(mgr, 'c'), true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('re-warms a cold kernel and restores globalThis from the snapshot', async () => {
|
|
103
|
+
let t = 1000;
|
|
104
|
+
mgr = new ScratchpadManager({ workspace, root, maxLiveKernels: 1, now: () => t });
|
|
105
|
+
const a1 = await mgr.getOrAttach('a');
|
|
106
|
+
await a1.runCell('globalThis.x = 99;');
|
|
107
|
+
t += 10;
|
|
108
|
+
await mgr.getOrAttach('b'); // evicts 'a' -> cold (snapshot written)
|
|
109
|
+
assert.equal(liveOf(mgr, 'a'), false);
|
|
110
|
+
t += 10;
|
|
111
|
+
const a2 = await mgr.getOrAttach('a'); // cold -> re-warm (namespace restored)
|
|
112
|
+
assert.equal((await a2.runCell('return globalThis.x ?? null;')).value, 99);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('keeps the lock when a kernel is LRU-evicted (cold but still owned)', async () => {
|
|
116
|
+
let t = 1000;
|
|
117
|
+
mgr = new ScratchpadManager({ workspace, root, maxLiveKernels: 1, now: () => t });
|
|
118
|
+
await mgr.getOrAttach('a'); t += 10;
|
|
119
|
+
await mgr.getOrAttach('b'); // evicts 'a' -> cold, lock retained
|
|
120
|
+
assert.equal(liveOf(mgr, 'a'), false);
|
|
121
|
+
mgr2 = new ScratchpadManager({ workspace, root, now: () => t });
|
|
122
|
+
await assert.rejects(() => mgr2.getOrAttach('a'), /busy/); // lock survived eviction
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('remove deletes the scratchpad dir and frees the lock', async () => {
|
|
126
|
+
mgr = new ScratchpadManager({ workspace, root });
|
|
127
|
+
await mgr.getOrAttach('a');
|
|
128
|
+
await mgr.remove('a');
|
|
129
|
+
assert.equal(existsSync(join(root, 'a')), false);
|
|
130
|
+
assert.equal(mgr.list().length, 0);
|
|
131
|
+
mgr2 = new ScratchpadManager({ workspace, root });
|
|
132
|
+
const rt = await mgr2.getOrAttach('a'); // lock gone -> re-attach succeeds
|
|
133
|
+
assert.equal((await rt.runCell('return 3;')).value, 3);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('disposeAll tears down every kernel and rejects further attaches', async () => {
|
|
137
|
+
mgr = new ScratchpadManager({ workspace, root });
|
|
138
|
+
await mgr.getOrAttach('a');
|
|
139
|
+
await mgr.getOrAttach('b');
|
|
140
|
+
await mgr.disposeAll();
|
|
141
|
+
await assert.rejects(() => mgr.getOrAttach('c'), /disposed/);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('ScratchpadManager (idle eviction)', () => {
|
|
146
|
+
let workspace2: string;
|
|
147
|
+
let root2: string;
|
|
148
|
+
let m: ScratchpadManager;
|
|
149
|
+
let m2: ScratchpadManager | undefined;
|
|
150
|
+
|
|
151
|
+
const liveIn = (mm: ScratchpadManager, name: string): boolean =>
|
|
152
|
+
mm.list().find((s) => s.name === name)!.live;
|
|
153
|
+
|
|
154
|
+
beforeEach(async () => {
|
|
155
|
+
workspace2 = await mkdtemp(join(tmpdir(), 'spm2-ws-'));
|
|
156
|
+
await mkdir(join(workspace2, '.otto', 'inputs'), { recursive: true });
|
|
157
|
+
root2 = await mkdtemp(join(tmpdir(), 'spm2-root-'));
|
|
158
|
+
m2 = undefined;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
afterEach(async () => {
|
|
162
|
+
await m?.disposeAll();
|
|
163
|
+
await m2?.disposeAll();
|
|
164
|
+
await rm(workspace2, { recursive: true, force: true });
|
|
165
|
+
await rm(root2, { recursive: true, force: true });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('evicts a kernel idle past idleMs on sweep', async () => {
|
|
169
|
+
let t = 1000;
|
|
170
|
+
m = new ScratchpadManager({ workspace: workspace2, root: root2, idleMs: 1000, sweepIntervalMs: 1_000_000, now: () => t });
|
|
171
|
+
await m.getOrAttach('a'); // lastUsedAt = 1000
|
|
172
|
+
t = 2001; // 1001ms later, > idleMs
|
|
173
|
+
await m.evictIdle();
|
|
174
|
+
assert.equal(liveIn(m, 'a'), false);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('does not evict a kernel with an in-flight cell', async () => {
|
|
178
|
+
let t = 1000;
|
|
179
|
+
m = new ScratchpadManager({
|
|
180
|
+
workspace: workspace2, root: root2, idleMs: 1000, sweepIntervalMs: 1_000_000, now: () => t,
|
|
181
|
+
runtimeOptions: { inactivityTimeoutMs: 10_000, cellTimeoutMs: 10_000 },
|
|
182
|
+
});
|
|
183
|
+
const a = await m.getOrAttach('a');
|
|
184
|
+
const p = a.runCell('await new Promise((r) => setTimeout(r, 300)); return 1;');
|
|
185
|
+
assert.equal(a.hasActiveCell, true);
|
|
186
|
+
t = 5000; // way past idle
|
|
187
|
+
await m.evictIdle();
|
|
188
|
+
assert.equal(liveIn(m, 'a'), true); // busy -> not evicted
|
|
189
|
+
assert.equal((await p).value, 1);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('retains the lock across idle eviction (a second manager stays blocked)', async () => {
|
|
193
|
+
let t = 1000;
|
|
194
|
+
m = new ScratchpadManager({ workspace: workspace2, root: root2, idleMs: 1000, sweepIntervalMs: 1_000_000, now: () => t });
|
|
195
|
+
await m.getOrAttach('a');
|
|
196
|
+
t = 2001;
|
|
197
|
+
await m.evictIdle();
|
|
198
|
+
assert.equal(liveIn(m, 'a'), false);
|
|
199
|
+
m2 = new ScratchpadManager({ workspace: workspace2, root: root2, now: () => t });
|
|
200
|
+
await assert.rejects(() => m2.getOrAttach('a'), /busy/);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('ScratchpadManager (cells + meta)', () => {
|
|
205
|
+
let ws: string;
|
|
206
|
+
let rt: string;
|
|
207
|
+
let m: ScratchpadManager;
|
|
208
|
+
|
|
209
|
+
const cellsLines = (root: string, name: string): string[] =>
|
|
210
|
+
readFileSync(join(root, name, 'cells.jsonl'), 'utf8').split('\n').filter((l) => l.trim());
|
|
211
|
+
const readMeta = (root: string, name: string): any =>
|
|
212
|
+
JSON.parse(readFileSync(join(root, name, 'meta.json'), 'utf8'));
|
|
213
|
+
|
|
214
|
+
beforeEach(async () => {
|
|
215
|
+
ws = await mkdtemp(join(tmpdir(), 'spm3-ws-'));
|
|
216
|
+
await mkdir(join(ws, '.otto', 'inputs'), { recursive: true });
|
|
217
|
+
rt = await mkdtemp(join(tmpdir(), 'spm3-root-'));
|
|
218
|
+
});
|
|
219
|
+
afterEach(async () => {
|
|
220
|
+
await m?.disposeAll();
|
|
221
|
+
await rm(ws, { recursive: true, force: true });
|
|
222
|
+
await rm(rt, { recursive: true, force: true });
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('runCell runs the cell and records it to cells.jsonl', async () => {
|
|
226
|
+
m = new ScratchpadManager({ workspace: ws, root: rt });
|
|
227
|
+
const res = await m.runCell('a', 'return 6 * 7;');
|
|
228
|
+
assert.equal(res.value, 42);
|
|
229
|
+
const ls = cellsLines(rt, 'a');
|
|
230
|
+
assert.deepEqual(JSON.parse(ls[0]), { type: 'header', version: 1 });
|
|
231
|
+
const rec = JSON.parse(ls[1]);
|
|
232
|
+
assert.equal(rec.id, 1);
|
|
233
|
+
assert.equal(rec.parentId, null);
|
|
234
|
+
assert.equal(rec.ok, true);
|
|
235
|
+
assert.equal(rec.value, 42);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('chains a second cell as id 2 / parentId 1', async () => {
|
|
239
|
+
m = new ScratchpadManager({ workspace: ws, root: rt });
|
|
240
|
+
await m.runCell('a', 'return 1;');
|
|
241
|
+
await m.runCell('a', 'return 2;');
|
|
242
|
+
const recs = cellsLines(rt, 'a').filter((l) => l.includes('"id"')).map((l) => JSON.parse(l));
|
|
243
|
+
assert.equal(recs[1].id, 2);
|
|
244
|
+
assert.equal(recs[1].parentId, 1);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('records a failed cell (ok:false + error) and still rethrows', async () => {
|
|
248
|
+
m = new ScratchpadManager({ workspace: ws, root: rt });
|
|
249
|
+
await assert.rejects(() => m.runCell('a', 'throw new Error("boom");'), /boom/);
|
|
250
|
+
const rec = JSON.parse(cellsLines(rt, 'a').filter((l) => l.includes('"id"'))[0]);
|
|
251
|
+
assert.equal(rec.ok, false);
|
|
252
|
+
assert.match(rec.error.message, /boom/);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('writes a full meta.json with attached_sessions, last_used, size_bytes', async () => {
|
|
256
|
+
let t = 5000;
|
|
257
|
+
m = new ScratchpadManager({ workspace: ws, root: rt, sessionId: 'sess-1', now: () => t });
|
|
258
|
+
await m.runCell('a', 'return 1;');
|
|
259
|
+
const meta = readMeta(rt, 'a');
|
|
260
|
+
assert.equal(meta.name, 'a');
|
|
261
|
+
assert.ok(meta.created_at);
|
|
262
|
+
assert.equal(meta.last_used, new Date(5000).toISOString());
|
|
263
|
+
assert.deepEqual(meta.attached_sessions, ['sess-1']);
|
|
264
|
+
assert.ok(meta.size_bytes > 0);
|
|
265
|
+
assert.equal(meta.schema_version, 4);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('continues cell ids across a fresh manager on the same root', async () => {
|
|
269
|
+
m = new ScratchpadManager({ workspace: ws, root: rt });
|
|
270
|
+
await m.runCell('a', 'return 1;'); // id 1
|
|
271
|
+
await m.disposeAll();
|
|
272
|
+
m = new ScratchpadManager({ workspace: ws, root: rt });
|
|
273
|
+
await m.runCell('a', 'return 2;'); // id 2 (archive scanned the existing file)
|
|
274
|
+
const recs = cellsLines(rt, 'a').filter((l) => l.includes('"id"')).map((l) => JSON.parse(l));
|
|
275
|
+
assert.equal(recs.length, 2);
|
|
276
|
+
assert.equal(recs[1].id, 2);
|
|
277
|
+
assert.equal(recs[1].parentId, 1);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe('ScratchpadManager (kernel persistence — 1d2)', () => {
|
|
282
|
+
let ws: string;
|
|
283
|
+
let rt: string;
|
|
284
|
+
let m: ScratchpadManager;
|
|
285
|
+
|
|
286
|
+
const readMeta = (root: string, name: string): any =>
|
|
287
|
+
JSON.parse(readFileSync(join(root, name, 'meta.json'), 'utf8'));
|
|
288
|
+
const writeMeta = (root: string, name: string, patch: Record<string, unknown>): void => {
|
|
289
|
+
const path = join(root, name, 'meta.json');
|
|
290
|
+
const cur = JSON.parse(readFileSync(path, 'utf8'));
|
|
291
|
+
writeFileSync(path, JSON.stringify({ ...cur, ...patch }, null, 2));
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
beforeEach(async () => {
|
|
295
|
+
ws = await mkdtemp(join(tmpdir(), 'spm4-ws-'));
|
|
296
|
+
await mkdir(join(ws, '.otto', 'inputs'), { recursive: true });
|
|
297
|
+
rt = await mkdtemp(join(tmpdir(), 'spm4-root-'));
|
|
298
|
+
});
|
|
299
|
+
afterEach(async () => {
|
|
300
|
+
await m?.disposeAll();
|
|
301
|
+
await rm(ws, { recursive: true, force: true });
|
|
302
|
+
await rm(rt, { recursive: true, force: true });
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('cold→warm restores globalThis after disposeAll on the same root', async () => {
|
|
306
|
+
m = new ScratchpadManager({ workspace: ws, root: rt, runtimeOptions: { cellTimeoutMs: 30_000, inactivityTimeoutMs: 30_000 } });
|
|
307
|
+
await m.runCell('a', 'globalThis.x = 1; globalThis.y = { nested: true };');
|
|
308
|
+
await m.disposeAll();
|
|
309
|
+
|
|
310
|
+
m = new ScratchpadManager({ workspace: ws, root: rt, runtimeOptions: { cellTimeoutMs: 30_000, inactivityTimeoutMs: 30_000 } });
|
|
311
|
+
const res = await m.runCell('a', 'return [globalThis.x, globalThis.y?.nested];');
|
|
312
|
+
assert.deepEqual(res.value, [1, true]);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('cold→warm restores a DuckDB table after disposeAll', async () => {
|
|
316
|
+
m = new ScratchpadManager({ workspace: ws, root: rt, runtimeOptions: { cellTimeoutMs: 30_000, inactivityTimeoutMs: 30_000 } });
|
|
317
|
+
await m.runCell('a', 'const c = await otto.duckdb.connect(); await c.run("CREATE TABLE t(x INT)"); await c.run("INSERT INTO t VALUES (1),(2),(3)");');
|
|
318
|
+
await m.disposeAll();
|
|
319
|
+
|
|
320
|
+
m = new ScratchpadManager({ workspace: ws, root: rt, runtimeOptions: { cellTimeoutMs: 30_000, inactivityTimeoutMs: 30_000 } });
|
|
321
|
+
const res = await m.runCell('a', 'const c = await otto.duckdb.connect(); const r = await c.runAndReadAll("SELECT COUNT(*) AS n FROM t"); return Number(r.getRows()[0][0]);');
|
|
322
|
+
assert.equal(res.value, 3);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('stamps last_snapshot_cell_id == archive.lastId after eviction', async () => {
|
|
326
|
+
m = new ScratchpadManager({ workspace: ws, root: rt, runtimeOptions: { cellTimeoutMs: 30_000, inactivityTimeoutMs: 30_000 } });
|
|
327
|
+
await m.runCell('a', 'globalThis.x = 1;'); // id 1
|
|
328
|
+
await m.runCell('a', 'globalThis.x = 2;'); // id 2
|
|
329
|
+
await m.disposeAll(); // triggers snapshotThenDispose
|
|
330
|
+
const meta = readMeta(rt, 'a');
|
|
331
|
+
assert.equal(meta.schema_version, 4);
|
|
332
|
+
assert.equal(meta.last_snapshot_cell_id, 2);
|
|
333
|
+
assert.ok(typeof meta.last_snapshot_at === 'string');
|
|
334
|
+
assert.equal(meta.kernel_db.present, true);
|
|
335
|
+
assert.equal(meta.namespace.present, true);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('records namespace-absent when re-attaching to a dir whose namespace.json was deleted', async () => {
|
|
339
|
+
m = new ScratchpadManager({ workspace: ws, root: rt, runtimeOptions: { cellTimeoutMs: 30_000, inactivityTimeoutMs: 30_000 } });
|
|
340
|
+
await m.runCell('a', 'globalThis.x = 1;');
|
|
341
|
+
await m.disposeAll();
|
|
342
|
+
// Simulate corruption / loss between sessions.
|
|
343
|
+
rmSync(join(rt, 'a', 'namespace.json'));
|
|
344
|
+
|
|
345
|
+
m = new ScratchpadManager({ workspace: ws, root: rt, runtimeOptions: { cellTimeoutMs: 30_000, inactivityTimeoutMs: 30_000 } });
|
|
346
|
+
const res = await m.runCell('a', 'return typeof globalThis.x;');
|
|
347
|
+
assert.equal(res.value, 'undefined');
|
|
348
|
+
const meta = readMeta(rt, 'a');
|
|
349
|
+
assert.ok(Array.isArray(meta.recovery_notes));
|
|
350
|
+
assert.equal(meta.recovery_notes.some((n: { kind: string }) => n.kind === 'namespace-absent'), true);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('records cells-since-snapshot when the on-disk archive is ahead of last_snapshot_cell_id', async () => {
|
|
354
|
+
m = new ScratchpadManager({ workspace: ws, root: rt, runtimeOptions: { cellTimeoutMs: 30_000, inactivityTimeoutMs: 30_000 } });
|
|
355
|
+
await m.runCell('a', 'return 1;'); // id 1
|
|
356
|
+
await m.runCell('a', 'return 2;'); // id 2
|
|
357
|
+
await m.disposeAll(); // snapshot stamps last_snapshot_cell_id = 2
|
|
358
|
+
// Simulate two crash-survivor cells appended after the last snapshot.
|
|
359
|
+
writeMeta(rt, 'a', { last_snapshot_cell_id: 0 });
|
|
360
|
+
|
|
361
|
+
m = new ScratchpadManager({ workspace: ws, root: rt, runtimeOptions: { cellTimeoutMs: 30_000, inactivityTimeoutMs: 30_000 } });
|
|
362
|
+
await m.runCell('a', 'return 99;'); // forces attach → recovery_notes computed
|
|
363
|
+
const meta = readMeta(rt, 'a');
|
|
364
|
+
const note = meta.recovery_notes.find((n: { kind: string; n?: number }) => n.kind === 'cells-since-snapshot');
|
|
365
|
+
assert.ok(note);
|
|
366
|
+
assert.equal(note.n, 2);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('FIFO-caps recovery_notes at 20', async () => {
|
|
370
|
+
m = new ScratchpadManager({ workspace: ws, root: rt, runtimeOptions: { cellTimeoutMs: 30_000, inactivityTimeoutMs: 30_000 } });
|
|
371
|
+
await m.runCell('a', 'return 1;');
|
|
372
|
+
// Seed 25 prior notes.
|
|
373
|
+
const seeded = Array.from({ length: 25 }, (_, i) => ({ at: new Date(i).toISOString(), kind: 'namespace-absent' }));
|
|
374
|
+
writeMeta(rt, 'a', { recovery_notes: seeded });
|
|
375
|
+
await m.disposeAll();
|
|
376
|
+
|
|
377
|
+
m = new ScratchpadManager({ workspace: ws, root: rt, runtimeOptions: { cellTimeoutMs: 30_000, inactivityTimeoutMs: 30_000 } });
|
|
378
|
+
await m.runCell('a', 'return 1;'); // attach adds at least one new note (cells-since-snapshot may not fire)
|
|
379
|
+
const meta = readMeta(rt, 'a');
|
|
380
|
+
assert.ok(meta.recovery_notes.length <= 20, `expected <= 20, got ${meta.recovery_notes.length}`);
|
|
381
|
+
// Oldest dropped: the first seed (epoch 0) should be gone.
|
|
382
|
+
assert.equal(meta.recovery_notes.some((n: { at?: string }) => n.at === new Date(0).toISOString()), false);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('records snapshot-failed when the runtime snapshot returns ok:false', async () => {
|
|
386
|
+
m = new ScratchpadManager({ workspace: ws, root: rt, runtimeOptions: { cellTimeoutMs: 30_000, inactivityTimeoutMs: 30_000 } });
|
|
387
|
+
await m.runCell('a', 'return 1;');
|
|
388
|
+
// Kill the kernel out from under the manager before disposeAll calls snapshot().
|
|
389
|
+
// The runtime's child process is disposed externally, so snapshot() resolves ok:false.
|
|
390
|
+
const entries = (m as unknown as { entries: Map<string, { runtime: import('./child-process-runtime.js').ChildProcessRuntime | null }> }).entries;
|
|
391
|
+
const entry = entries.get('a')!;
|
|
392
|
+
// Directly dispose the child process (simulating an unexpected crash) WITHOUT
|
|
393
|
+
// setting entry.runtime = null — so snapshotThenDispose still attempts the snapshot.
|
|
394
|
+
const liveRuntime = entry.runtime!;
|
|
395
|
+
await liveRuntime.dispose(); // kills the child; snapshot() will resolve ok:false
|
|
396
|
+
// disposeAll will call snapshotThenDispose('a', entry) → snapshot() → ok:false → appendRecoveryNotes
|
|
397
|
+
await m.disposeAll(); // must not throw
|
|
398
|
+
const meta = readMeta(rt, 'a');
|
|
399
|
+
const note = meta.recovery_notes.find((n: { kind: string }) => n.kind === 'snapshot-failed');
|
|
400
|
+
assert.ok(note, 'expected a snapshot-failed recovery note');
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
describe('ScratchpadManager (tree + fork — 1f)', () => {
|
|
405
|
+
let ws: string;
|
|
406
|
+
let rt: string;
|
|
407
|
+
let m: ScratchpadManager;
|
|
408
|
+
|
|
409
|
+
const readMeta = (root: string, name: string): any =>
|
|
410
|
+
JSON.parse(readFileSync(join(root, name, 'meta.json'), 'utf8'));
|
|
411
|
+
|
|
412
|
+
beforeEach(async () => {
|
|
413
|
+
ws = await mkdtemp(join(tmpdir(), 'spm5-ws-'));
|
|
414
|
+
await mkdir(join(ws, '.otto', 'inputs'), { recursive: true });
|
|
415
|
+
rt = await mkdtemp(join(tmpdir(), 'spm5-root-'));
|
|
416
|
+
});
|
|
417
|
+
afterEach(async () => {
|
|
418
|
+
await m?.disposeAll();
|
|
419
|
+
await rm(ws, { recursive: true, force: true });
|
|
420
|
+
await rm(rt, { recursive: true, force: true });
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('writeMeta now persists cell_leaf_id and schema_version is 4', async () => {
|
|
424
|
+
m = new ScratchpadManager({ workspace: ws, root: rt, runtimeOptions: { cellTimeoutMs: 30_000, inactivityTimeoutMs: 30_000 } });
|
|
425
|
+
await m.runCell('a', 'return 1;');
|
|
426
|
+
const meta = readMeta(rt, 'a');
|
|
427
|
+
assert.equal(meta.schema_version, 4);
|
|
428
|
+
assert.equal(meta.cell_leaf_id, 1);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('setLeaf rejects an id not present in cells.jsonl', async () => {
|
|
432
|
+
m = new ScratchpadManager({ workspace: ws, root: rt, runtimeOptions: { cellTimeoutMs: 30_000, inactivityTimeoutMs: 30_000 } });
|
|
433
|
+
await m.runCell('a', 'return 1;');
|
|
434
|
+
await assert.rejects(() => m.setLeaf('a', 99), /cell id 99 not found/);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('setLeaf on a warm scratchpad updates archive.leafId AND meta.cell_leaf_id', async () => {
|
|
438
|
+
m = new ScratchpadManager({ workspace: ws, root: rt, runtimeOptions: { cellTimeoutMs: 30_000, inactivityTimeoutMs: 30_000 } });
|
|
439
|
+
await m.runCell('a', 'return 1;'); // id 1
|
|
440
|
+
await m.runCell('a', 'return 2;'); // id 2
|
|
441
|
+
await m.setLeaf('a', 1);
|
|
442
|
+
// The next runCell should chain from 1, not 2.
|
|
443
|
+
const res = await m.runCell('a', 'return 3;');
|
|
444
|
+
assert.equal(res.value, 3);
|
|
445
|
+
const meta = readMeta(rt, 'a');
|
|
446
|
+
assert.equal(meta.cell_leaf_id, 3); // the new cell becomes the leaf again
|
|
447
|
+
// Read cells.jsonl and confirm the third cell's parentId is 1 (not 2).
|
|
448
|
+
const lines = readFileSync(join(rt, 'a', 'cells.jsonl'), 'utf8').split('\n').filter((l) => l.includes('"id"'));
|
|
449
|
+
const recs = lines.map((l) => JSON.parse(l));
|
|
450
|
+
assert.equal(recs[2].id, 3);
|
|
451
|
+
assert.equal(recs[2].parentId, 1);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('setLeaf on a cold scratchpad updates meta directly (next attach restores)', async () => {
|
|
455
|
+
m = new ScratchpadManager({ workspace: ws, root: rt, runtimeOptions: { cellTimeoutMs: 30_000, inactivityTimeoutMs: 30_000 } });
|
|
456
|
+
await m.runCell('a', 'return 1;');
|
|
457
|
+
await m.runCell('a', 'return 2;');
|
|
458
|
+
await m.disposeAll();
|
|
459
|
+
m = new ScratchpadManager({ workspace: ws, root: rt, runtimeOptions: { cellTimeoutMs: 30_000, inactivityTimeoutMs: 30_000 } });
|
|
460
|
+
// Cold: 'a' is not in entries; setLeaf updates meta only.
|
|
461
|
+
await m.setLeaf('a', 1);
|
|
462
|
+
assert.equal(readMeta(rt, 'a').cell_leaf_id, 1);
|
|
463
|
+
// Attach + new cell branches from 1.
|
|
464
|
+
const res = await m.runCell('a', 'return 99;');
|
|
465
|
+
assert.equal(res.value, 99);
|
|
466
|
+
const lines = readFileSync(join(rt, 'a', 'cells.jsonl'), 'utf8').split('\n').filter((l) => l.includes('"id"'));
|
|
467
|
+
const last = JSON.parse(lines[lines.length - 1]);
|
|
468
|
+
assert.equal(last.parentId, 1);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it('fork copies kernel.db + namespace.json + cells.jsonl and writes fresh meta', async () => {
|
|
472
|
+
m = new ScratchpadManager({ workspace: ws, root: rt, runtimeOptions: { cellTimeoutMs: 30_000, inactivityTimeoutMs: 30_000 } });
|
|
473
|
+
await m.runCell('src', 'const c = await otto.duckdb.connect(); await c.run("CREATE TABLE t(x INT)"); await c.run("INSERT INTO t VALUES (1),(2)");');
|
|
474
|
+
await m.runCell('src', 'globalThis.x = 42;');
|
|
475
|
+
await m.fork('src', 'dst');
|
|
476
|
+
// Both dirs exist.
|
|
477
|
+
assert.equal(existsSync(join(rt, 'dst', 'kernel.db')), true);
|
|
478
|
+
assert.equal(existsSync(join(rt, 'dst', 'cells.jsonl')), true);
|
|
479
|
+
assert.equal(existsSync(join(rt, 'dst', 'meta.json')), true);
|
|
480
|
+
// Dst meta inherits cell_leaf_id from src (currently last cell id = 2).
|
|
481
|
+
const dstMeta = readMeta(rt, 'dst');
|
|
482
|
+
assert.equal(dstMeta.cell_leaf_id, 2);
|
|
483
|
+
assert.equal(dstMeta.name, 'dst');
|
|
484
|
+
assert.deepEqual(dstMeta.recovery_notes, []);
|
|
485
|
+
assert.deepEqual(dstMeta.namespace_skipped, []);
|
|
486
|
+
// Dst is functional: attach and continue.
|
|
487
|
+
const res = await m.runCell('dst', 'const c = await otto.duckdb.connect(); const r = await c.runAndReadAll("SELECT COUNT(*) AS n FROM t"); return Number(r.getRows()[0][0]);');
|
|
488
|
+
assert.equal(res.value, 2);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('fork rejects when dst already exists (entries or on disk)', async () => {
|
|
492
|
+
m = new ScratchpadManager({ workspace: ws, root: rt, runtimeOptions: { cellTimeoutMs: 30_000, inactivityTimeoutMs: 30_000 } });
|
|
493
|
+
await m.runCell('src', 'return 1;');
|
|
494
|
+
await m.runCell('dst', 'return 1;'); // creates dst on disk
|
|
495
|
+
await assert.rejects(() => m.fork('src', 'dst'), /scratchpad dst already exists/);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it('fork rejects when src has no meta on disk', async () => {
|
|
499
|
+
m = new ScratchpadManager({ workspace: ws, root: rt, runtimeOptions: { cellTimeoutMs: 30_000, inactivityTimeoutMs: 30_000 } });
|
|
500
|
+
await assert.rejects(() => m.fork('nope', 'dst'), /scratchpad not found: nope/);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('re-attach restores leaf from meta when persisted leaf differs from file-max', async () => {
|
|
504
|
+
m = new ScratchpadManager({ workspace: ws, root: rt, runtimeOptions: { cellTimeoutMs: 30_000, inactivityTimeoutMs: 30_000 } });
|
|
505
|
+
await m.runCell('a', 'return 1;');
|
|
506
|
+
await m.runCell('a', 'return 2;');
|
|
507
|
+
await m.runCell('a', 'return 3;');
|
|
508
|
+
await m.setLeaf('a', 1); // leaf=1, file-max=3
|
|
509
|
+
await m.disposeAll();
|
|
510
|
+
m = new ScratchpadManager({ workspace: ws, root: rt, runtimeOptions: { cellTimeoutMs: 30_000, inactivityTimeoutMs: 30_000 } });
|
|
511
|
+
const res = await m.runCell('a', 'return 99;'); // should branch from 1
|
|
512
|
+
assert.equal(res.value, 99);
|
|
513
|
+
const lines = readFileSync(join(rt, 'a', 'cells.jsonl'), 'utf8').split('\n').filter((l) => l.includes('"id"'));
|
|
514
|
+
const last = JSON.parse(lines[lines.length - 1]);
|
|
515
|
+
assert.equal(last.id, 4);
|
|
516
|
+
assert.equal(last.parentId, 1);
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
describe('ScratchpadManager (clearHistory — 1g)', () => {
|
|
521
|
+
let workspace: string;
|
|
522
|
+
let root: string;
|
|
523
|
+
let mgr: ScratchpadManager;
|
|
524
|
+
|
|
525
|
+
beforeEach(async () => {
|
|
526
|
+
workspace = await mkdtemp(join(tmpdir(), 'sp-ws-'));
|
|
527
|
+
root = await mkdtemp(join(tmpdir(), 'sp-root-'));
|
|
528
|
+
mgr = new ScratchpadManager({ workspace, root, sessionId: 'sess-1', sweepIntervalMs: 1_000_000 });
|
|
529
|
+
});
|
|
530
|
+
afterEach(async () => {
|
|
531
|
+
await mgr.disposeAll();
|
|
532
|
+
await rm(workspace, { recursive: true, force: true });
|
|
533
|
+
await rm(root, { recursive: true, force: true });
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it('clearHistory truncates cells.jsonl + resets archive + nulls meta pointers on a warm scratchpad', async () => {
|
|
537
|
+
await mgr.runCell('p1', 'globalThis.x = 1;');
|
|
538
|
+
await mgr.runCell('p1', 'globalThis.x = 2;');
|
|
539
|
+
const cellsPathP1 = join(root, 'p1', 'cells.jsonl');
|
|
540
|
+
const metaPathP1 = join(root, 'p1', 'meta.json');
|
|
541
|
+
// sanity: 2 data lines + 1 header
|
|
542
|
+
assert.equal(readFileSync(cellsPathP1, 'utf8').split('\n').filter((l) => l.includes('"id"')).length, 2);
|
|
543
|
+
|
|
544
|
+
await mgr.clearHistory('p1');
|
|
545
|
+
|
|
546
|
+
const remaining = readFileSync(cellsPathP1, 'utf8').split('\n').filter((l) => l.trim());
|
|
547
|
+
assert.equal(remaining.length, 1, 'only schema header remains');
|
|
548
|
+
assert.equal(JSON.parse(remaining[0]).type, 'header');
|
|
549
|
+
const meta = JSON.parse(readFileSync(metaPathP1, 'utf8')) as Record<string, unknown>;
|
|
550
|
+
assert.equal(meta.cell_leaf_id, null);
|
|
551
|
+
assert.equal(meta.last_snapshot_cell_id, null);
|
|
552
|
+
assert.equal(meta.last_snapshot_at, null);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it('clearHistory throws when a cell is currently running', async () => {
|
|
556
|
+
await mgr.runCell('p1', 'globalThis.x = 1;');
|
|
557
|
+
const entry = (mgr as unknown as { entries: Map<string, { runtime: { hasActiveCell: boolean } | null }> }).entries.get('p1')!;
|
|
558
|
+
// Simulate an active cell by stubbing the getter.
|
|
559
|
+
Object.defineProperty(entry.runtime!, 'hasActiveCell', { get: () => true, configurable: true });
|
|
560
|
+
await assert.rejects(() => mgr.clearHistory('p1'), /cell is running/);
|
|
561
|
+
});
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
describe('ScratchpadManager (save + detach — 1g)', () => {
|
|
565
|
+
let workspace: string;
|
|
566
|
+
let root: string;
|
|
567
|
+
let mgr: ScratchpadManager;
|
|
568
|
+
|
|
569
|
+
beforeEach(async () => {
|
|
570
|
+
workspace = await mkdtemp(join(tmpdir(), 'sp-ws-'));
|
|
571
|
+
root = await mkdtemp(join(tmpdir(), 'sp-root-'));
|
|
572
|
+
mgr = new ScratchpadManager({ workspace, root, sessionId: 'sess-1', sweepIntervalMs: 1_000_000 });
|
|
573
|
+
});
|
|
574
|
+
afterEach(async () => {
|
|
575
|
+
await mgr.disposeAll();
|
|
576
|
+
await rm(workspace, { recursive: true, force: true });
|
|
577
|
+
await rm(root, { recursive: true, force: true });
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it('save snapshots namespace.json and writes last_snapshot_cell_id + last_snapshot_at without disposing', async () => {
|
|
581
|
+
await mgr.runCell('p1', 'globalThis.x = 1;');
|
|
582
|
+
await mgr.runCell('p1', 'globalThis.x = 2;');
|
|
583
|
+
await mgr.save('p1');
|
|
584
|
+
const meta = JSON.parse(readFileSync(join(root, 'p1', 'meta.json'), 'utf8')) as Record<string, unknown>;
|
|
585
|
+
assert.equal(meta.last_snapshot_cell_id, 2);
|
|
586
|
+
assert.equal(typeof meta.last_snapshot_at, 'string');
|
|
587
|
+
// Still warm: another cell can be run without re-attach.
|
|
588
|
+
const r = await mgr.runCell('p1', 'return globalThis.x;');
|
|
589
|
+
assert.equal(r.value, 2);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it('save throws when the scratchpad is cold or unknown', async () => {
|
|
593
|
+
await assert.rejects(() => mgr.save('never-existed'), /not warm/);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it('detach removes this sessionId from attached_sessions; runtime untouched', async () => {
|
|
597
|
+
await mgr.runCell('p1', 'globalThis.x = 1;');
|
|
598
|
+
let meta = JSON.parse(readFileSync(join(root, 'p1', 'meta.json'), 'utf8')) as { attached_sessions: string[] };
|
|
599
|
+
assert.deepEqual(meta.attached_sessions, ['sess-1']);
|
|
600
|
+
|
|
601
|
+
await mgr.detach('p1', 'sess-1');
|
|
602
|
+
|
|
603
|
+
meta = JSON.parse(readFileSync(join(root, 'p1', 'meta.json'), 'utf8')) as { attached_sessions: string[] };
|
|
604
|
+
assert.deepEqual(meta.attached_sessions, []);
|
|
605
|
+
// Runtime intentionally still alive — pool LRU/idle eviction handles cleanup.
|
|
606
|
+
const entry = (mgr as unknown as { entries: Map<string, { runtime: unknown }> }).entries.get('p1')!;
|
|
607
|
+
assert.ok(entry.runtime, 'detach does not dispose the runtime');
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it('detach is a no-op on attached_sessions when sessionId is not in the list', async () => {
|
|
611
|
+
await mgr.runCell('p1', 'globalThis.x = 1;');
|
|
612
|
+
await mgr.detach('p1', 'some-other-session');
|
|
613
|
+
const meta = JSON.parse(readFileSync(join(root, 'p1', 'meta.json'), 'utf8')) as { attached_sessions: string[] };
|
|
614
|
+
assert.deepEqual(meta.attached_sessions, ['sess-1']);
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
describe('ScratchpadManager (kernel_at_cell_id — 1g2)', () => {
|
|
619
|
+
let workspace: string;
|
|
620
|
+
let root: string;
|
|
621
|
+
let mgr: ScratchpadManager;
|
|
622
|
+
|
|
623
|
+
beforeEach(async () => {
|
|
624
|
+
workspace = await mkdtemp(join(tmpdir(), 'sp-ws-'));
|
|
625
|
+
root = await mkdtemp(join(tmpdir(), 'sp-root-'));
|
|
626
|
+
mgr = new ScratchpadManager({ workspace, root, sessionId: 'sess-1', sweepIntervalMs: 1_000_000 });
|
|
627
|
+
});
|
|
628
|
+
afterEach(async () => {
|
|
629
|
+
await mgr.disposeAll();
|
|
630
|
+
await rm(workspace, { recursive: true, force: true });
|
|
631
|
+
await rm(root, { recursive: true, force: true });
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
it('runCell updates meta.kernel_at_cell_id to archive.lastId', async () => {
|
|
635
|
+
await mgr.runCell('p1', 'globalThis.x = 1;');
|
|
636
|
+
const meta1 = JSON.parse(readFileSync(join(root, 'p1', 'meta.json'), 'utf8')) as { kernel_at_cell_id?: unknown };
|
|
637
|
+
assert.equal(meta1.kernel_at_cell_id, 1);
|
|
638
|
+
await mgr.runCell('p1', 'globalThis.x = 2;');
|
|
639
|
+
const meta2 = JSON.parse(readFileSync(join(root, 'p1', 'meta.json'), 'utf8')) as { kernel_at_cell_id?: unknown };
|
|
640
|
+
assert.equal(meta2.kernel_at_cell_id, 2);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
it('clearHistory nulls meta.kernel_at_cell_id alongside the other pointers', async () => {
|
|
644
|
+
await mgr.runCell('p1', 'globalThis.x = 1;');
|
|
645
|
+
await mgr.clearHistory('p1');
|
|
646
|
+
const meta = JSON.parse(readFileSync(join(root, 'p1', 'meta.json'), 'utf8')) as { kernel_at_cell_id?: unknown };
|
|
647
|
+
assert.equal(meta.kernel_at_cell_id, null);
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it('fork inherits kernel_at_cell_id from source meta', async () => {
|
|
651
|
+
await mgr.runCell('src', 'globalThis.x = 1;');
|
|
652
|
+
await mgr.runCell('src', 'globalThis.x = 2;');
|
|
653
|
+
await mgr.fork('src', 'dst');
|
|
654
|
+
const dstMeta = JSON.parse(readFileSync(join(root, 'dst', 'meta.json'), 'utf8')) as { kernel_at_cell_id?: unknown };
|
|
655
|
+
assert.equal(dstMeta.kernel_at_cell_id, 2);
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it('cold->warm attach restores kernelAtCellId from last_snapshot_cell_id', async () => {
|
|
659
|
+
await mgr.runCell('p1', 'globalThis.x = 1;');
|
|
660
|
+
await mgr.runCell('p1', 'globalThis.x = 2;');
|
|
661
|
+
// Force a snapshot by disposing then re-attaching.
|
|
662
|
+
await mgr.disposeAll();
|
|
663
|
+
mgr = new ScratchpadManager({ workspace, root, sessionId: 'sess-1', sweepIntervalMs: 1_000_000 });
|
|
664
|
+
await mgr.getOrAttach('p1'); // cold -> warm
|
|
665
|
+
const meta = JSON.parse(readFileSync(join(root, 'p1', 'meta.json'), 'utf8')) as {
|
|
666
|
+
kernel_at_cell_id?: unknown;
|
|
667
|
+
last_snapshot_cell_id?: unknown;
|
|
668
|
+
};
|
|
669
|
+
// After dispose-then-attach: kernel restored from namespace.json which was at last_snapshot_cell_id.
|
|
670
|
+
assert.equal(meta.kernel_at_cell_id, meta.last_snapshot_cell_id);
|
|
671
|
+
assert.equal(meta.kernel_at_cell_id, 2);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
it('writeMeta preserves kernel_at_cell_id across cold meta writes (prevExtras)', async () => {
|
|
675
|
+
await mgr.runCell('p1', 'globalThis.x = 1;');
|
|
676
|
+
await mgr.disposeAll();
|
|
677
|
+
// Re-create manager. Cold writes via setLeaf would otherwise drop the field if not preserved.
|
|
678
|
+
mgr = new ScratchpadManager({ workspace, root, sessionId: 'sess-1', sweepIntervalMs: 1_000_000 });
|
|
679
|
+
// Don't attach. Trigger a cold meta write via setLeaf (which writes meta directly).
|
|
680
|
+
await mgr.setLeaf('p1', 1);
|
|
681
|
+
const meta = JSON.parse(readFileSync(join(root, 'p1', 'meta.json'), 'utf8')) as { kernel_at_cell_id?: unknown };
|
|
682
|
+
assert.equal(meta.kernel_at_cell_id, 1);
|
|
683
|
+
});
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
describe('ScratchpadManager (markRecoveryNotesSeen — 1g2)', () => {
|
|
687
|
+
let workspace: string;
|
|
688
|
+
let root: string;
|
|
689
|
+
let mgr: ScratchpadManager;
|
|
690
|
+
|
|
691
|
+
beforeEach(async () => {
|
|
692
|
+
workspace = await mkdtemp(join(tmpdir(), 'sp-ws-'));
|
|
693
|
+
root = await mkdtemp(join(tmpdir(), 'sp-root-'));
|
|
694
|
+
mgr = new ScratchpadManager({
|
|
695
|
+
workspace, root, sessionId: 'sess-1', sweepIntervalMs: 1_000_000,
|
|
696
|
+
now: () => Date.parse('2026-06-01T12:00:00.000Z'),
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
afterEach(async () => {
|
|
700
|
+
await mgr.disposeAll();
|
|
701
|
+
await rm(workspace, { recursive: true, force: true });
|
|
702
|
+
await rm(root, { recursive: true, force: true });
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it('markRecoveryNotesSeen stamps meta.recovery_notes_seen_at = nowIso', async () => {
|
|
706
|
+
await mgr.runCell('p1', 'globalThis.x = 1;');
|
|
707
|
+
await mgr.markRecoveryNotesSeen('p1');
|
|
708
|
+
const meta = JSON.parse(readFileSync(join(root, 'p1', 'meta.json'), 'utf8')) as { recovery_notes_seen_at?: unknown };
|
|
709
|
+
assert.equal(meta.recovery_notes_seen_at, '2026-06-01T12:00:00.000Z');
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it('markRecoveryNotesSeen is silent when meta is missing', async () => {
|
|
713
|
+
// No scratchpad created; method should not throw.
|
|
714
|
+
await mgr.markRecoveryNotesSeen('absent');
|
|
715
|
+
assert.ok(true);
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
describe('ScratchpadManager (fork exit escalation — 1g3)', () => {
|
|
720
|
+
let workspace: string;
|
|
721
|
+
let root: string;
|
|
722
|
+
let mgr: ScratchpadManager;
|
|
723
|
+
|
|
724
|
+
beforeEach(async () => {
|
|
725
|
+
workspace = await mkdtemp(join(tmpdir(), 'sp-ws-'));
|
|
726
|
+
root = await mkdtemp(join(tmpdir(), 'sp-root-'));
|
|
727
|
+
// Inject a TINY timeout so SIGKILL escalation + hang tests don't block CI.
|
|
728
|
+
mgr = new ScratchpadManager({
|
|
729
|
+
workspace, root, sessionId: 'sess-1', sweepIntervalMs: 1_000_000,
|
|
730
|
+
forkExitTimeoutMs: 50,
|
|
731
|
+
});
|
|
732
|
+
});
|
|
733
|
+
afterEach(async () => {
|
|
734
|
+
await mgr.disposeAll();
|
|
735
|
+
await rm(workspace, { recursive: true, force: true });
|
|
736
|
+
await rm(root, { recursive: true, force: true });
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it('fork succeeds when the source kernel exits cleanly within the timeout', async () => {
|
|
740
|
+
await mgr.runCell('src', 'globalThis.x = 1;');
|
|
741
|
+
// Default kernel obeys SIGTERM; happy path completes without escalation.
|
|
742
|
+
await mgr.fork('src', 'dst');
|
|
743
|
+
const dstMeta = JSON.parse(readFileSync(join(root, 'dst', 'meta.json'), 'utf8')) as { name: string };
|
|
744
|
+
assert.equal(dstMeta.name, 'dst');
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
it('fork escalates to SIGKILL when SIGTERM is ignored, then proceeds', async () => {
|
|
748
|
+
await mgr.runCell('src', 'globalThis.x = 1;');
|
|
749
|
+
// Monkey-patch the rawChild so SIGTERM is swallowed but SIGKILL passes through.
|
|
750
|
+
const entry = (mgr as unknown as { entries: Map<string, { runtime: unknown }> }).entries.get('src')!;
|
|
751
|
+
const realChild = (entry.runtime as unknown as { child: import('node:child_process').ChildProcess | null }).child!;
|
|
752
|
+
// Also stub stdin.end() so dispose() can't cause natural exit via closed stdin;
|
|
753
|
+
// we want SIGTERM to be the ONLY exit signal, so we can verify escalation.
|
|
754
|
+
(realChild.stdin as unknown as { end: () => void }).end = () => { /* no-op */ };
|
|
755
|
+
const origKill = realChild.kill.bind(realChild);
|
|
756
|
+
const signalsSeen: NodeJS.Signals[] = [];
|
|
757
|
+
realChild.kill = ((sig?: NodeJS.Signals) => {
|
|
758
|
+
const s = sig ?? 'SIGTERM';
|
|
759
|
+
signalsSeen.push(s);
|
|
760
|
+
if (s === 'SIGKILL') return origKill('SIGKILL');
|
|
761
|
+
return true; // swallow SIGTERM
|
|
762
|
+
}) as typeof realChild.kill;
|
|
763
|
+
|
|
764
|
+
await mgr.fork('src', 'dst');
|
|
765
|
+
assert.ok(signalsSeen.includes('SIGTERM'), 'SIGTERM attempted first');
|
|
766
|
+
assert.ok(signalsSeen.includes('SIGKILL'), 'SIGKILL fired after timeout');
|
|
767
|
+
const dstMeta = JSON.parse(readFileSync(join(root, 'dst', 'meta.json'), 'utf8')) as { name: string };
|
|
768
|
+
assert.equal(dstMeta.name, 'dst');
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
it('fork throws ForkKernelHangError when both SIGTERM and SIGKILL are ignored', async () => {
|
|
772
|
+
await mgr.runCell('src', 'globalThis.x = 1;');
|
|
773
|
+
const entry = (mgr as unknown as { entries: Map<string, { runtime: unknown }> }).entries.get('src')!;
|
|
774
|
+
const realChild = (entry.runtime as unknown as { child: import('node:child_process').ChildProcess | null }).child!;
|
|
775
|
+
// Stub stdin.end() so dispose() can't trigger natural exit via closed stdin.
|
|
776
|
+
(realChild.stdin as unknown as { end: () => void }).end = () => { /* no-op */ };
|
|
777
|
+
realChild.kill = (() => true) as typeof realChild.kill; // swallow EVERY signal
|
|
778
|
+
|
|
779
|
+
let caught: unknown = null;
|
|
780
|
+
try {
|
|
781
|
+
await mgr.fork('src', 'dst');
|
|
782
|
+
} catch (err) {
|
|
783
|
+
caught = err;
|
|
784
|
+
}
|
|
785
|
+
assert.ok(caught instanceof ForkKernelHangError, 'ForkKernelHangError thrown');
|
|
786
|
+
const e = caught as ForkKernelHangError;
|
|
787
|
+
assert.equal(e.srcName, 'src');
|
|
788
|
+
assert.ok(typeof e.pid === 'number' && e.pid > 0, 'pid attached');
|
|
789
|
+
|
|
790
|
+
// After-the-fact: forcibly kill the lingering kernel so afterEach can clean up.
|
|
791
|
+
const reallyKill = Object.getPrototypeOf(realChild).kill as (sig?: NodeJS.Signals) => boolean;
|
|
792
|
+
reallyKill.call(realChild, 'SIGKILL');
|
|
793
|
+
});
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
describe('ScratchpadManager (payloadSize whitelist — 1g3)', () => {
|
|
797
|
+
let workspace: string;
|
|
798
|
+
let root: string;
|
|
799
|
+
let mgr: ScratchpadManager;
|
|
800
|
+
|
|
801
|
+
beforeEach(async () => {
|
|
802
|
+
workspace = await mkdtemp(join(tmpdir(), 'sp-ws-'));
|
|
803
|
+
root = await mkdtemp(join(tmpdir(), 'sp-root-'));
|
|
804
|
+
mgr = new ScratchpadManager({ workspace, root, sessionId: 'sess-1', sweepIntervalMs: 1_000_000 });
|
|
805
|
+
});
|
|
806
|
+
afterEach(async () => {
|
|
807
|
+
await mgr.disposeAll();
|
|
808
|
+
await rm(workspace, { recursive: true, force: true });
|
|
809
|
+
await rm(root, { recursive: true, force: true });
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
it('size_bytes counts only payload files; excludes lock.json + meta.json', async () => {
|
|
813
|
+
await mgr.runCell('p1', 'globalThis.x = 1;');
|
|
814
|
+
const dir = join(root, 'p1');
|
|
815
|
+
|
|
816
|
+
// Sanity: lock.json + meta.json + cells.jsonl + kernel.db (+ maybe kernel.db.wal) + namespace.json all on disk
|
|
817
|
+
// after a single runCell + auto-snapshot? Actually only cells.jsonl + kernel.db + lock.json + meta.json
|
|
818
|
+
// exist after runCell — snapshot only fires on dispose/idle. namespace.json may not be present.
|
|
819
|
+
// Either way the math holds: size_bytes only counts whatever's in the whitelist.
|
|
820
|
+
const { statSync } = await import('node:fs');
|
|
821
|
+
const lockSize = statSync(join(dir, 'lock.json')).size;
|
|
822
|
+
const metaSize = statSync(join(dir, 'meta.json')).size;
|
|
823
|
+
|
|
824
|
+
const meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf8')) as { size_bytes?: unknown };
|
|
825
|
+
const payloadOnly =
|
|
826
|
+
(existsSync(join(dir, 'kernel.db')) ? statSync(join(dir, 'kernel.db')).size : 0) +
|
|
827
|
+
(existsSync(join(dir, 'kernel.db.wal')) ? statSync(join(dir, 'kernel.db.wal')).size : 0) +
|
|
828
|
+
(existsSync(join(dir, 'namespace.json')) ? statSync(join(dir, 'namespace.json')).size : 0) +
|
|
829
|
+
(existsSync(join(dir, 'cells.jsonl')) ? statSync(join(dir, 'cells.jsonl')).size : 0);
|
|
830
|
+
|
|
831
|
+
assert.equal(meta.size_bytes, payloadOnly, 'size_bytes equals payload sum');
|
|
832
|
+
// Negative-form: it does NOT include lock.json or meta.json
|
|
833
|
+
assert.notEqual(meta.size_bytes, payloadOnly + lockSize, 'size_bytes excludes lock.json');
|
|
834
|
+
assert.notEqual(meta.size_bytes, payloadOnly + metaSize, 'size_bytes excludes meta.json');
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
it('size_bytes is 0 when no payload files are present (lock + meta only)', async () => {
|
|
838
|
+
// attachUnmanaged writes meta + acquires lock BEFORE spawning the runtime.
|
|
839
|
+
// We need to inspect meta written by attachUnmanaged before any runCell.
|
|
840
|
+
// The simplest exercise: write meta directly via the public surface that triggers writeMeta with no payload.
|
|
841
|
+
// Use `manager.create()` (which calls attachUnmanaged) and check the meta written.
|
|
842
|
+
// But create spawns the kernel which writes kernel.db. Hmm.
|
|
843
|
+
// Workaround: read meta AFTER create but check it counted only existing payload files at that point.
|
|
844
|
+
// Since kernel.db is created during spawnRuntime which is called from attachUnmanaged, kernel.db
|
|
845
|
+
// will exist by the time writeMeta returns. So this test can't easily exercise the "0 payload files" case.
|
|
846
|
+
// Skip this test as a unit assertion; the whitelist-only-counts behavior is already verified above.
|
|
847
|
+
// Instead, verify payloadSize directly via the internal helper exposure (cast through unknown).
|
|
848
|
+
const dir = join(root, 'p-empty');
|
|
849
|
+
const { mkdirSync } = await import('node:fs');
|
|
850
|
+
mkdirSync(dir, { recursive: true });
|
|
851
|
+
// Drop a lock.json and meta.json but no payload.
|
|
852
|
+
const { writeFileSync } = await import('node:fs');
|
|
853
|
+
writeFileSync(join(dir, 'lock.json'), '{}');
|
|
854
|
+
writeFileSync(join(dir, 'meta.json'), '{}');
|
|
855
|
+
const size = (mgr as unknown as { payloadSize(d: string): number }).payloadSize(dir);
|
|
856
|
+
assert.equal(size, 0, 'payloadSize ignores lock.json + meta.json');
|
|
857
|
+
});
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
describe('ScratchpadManager (atomic meta writes — 1g3)', () => {
|
|
861
|
+
let workspace: string;
|
|
862
|
+
let root: string;
|
|
863
|
+
let mgr: ScratchpadManager;
|
|
864
|
+
|
|
865
|
+
beforeEach(async () => {
|
|
866
|
+
workspace = await mkdtemp(join(tmpdir(), 'sp-ws-'));
|
|
867
|
+
root = await mkdtemp(join(tmpdir(), 'sp-root-'));
|
|
868
|
+
mgr = new ScratchpadManager({ workspace, root, sessionId: 'sess-1', sweepIntervalMs: 1_000_000 });
|
|
869
|
+
});
|
|
870
|
+
afterEach(async () => {
|
|
871
|
+
await mgr.disposeAll();
|
|
872
|
+
await rm(workspace, { recursive: true, force: true });
|
|
873
|
+
await rm(root, { recursive: true, force: true });
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
it('meta writes via writeMeta leave no .tmp artifact after success', async () => {
|
|
877
|
+
await mgr.runCell('p1', 'globalThis.x = 1;');
|
|
878
|
+
const dir = join(root, 'p1');
|
|
879
|
+
assert.ok(existsSync(join(dir, 'meta.json')), 'meta.json present');
|
|
880
|
+
assert.equal(existsSync(join(dir, 'meta.json.tmp')), false, 'no .tmp leak');
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
it('meta writes via clearHistory leave no .tmp artifact', async () => {
|
|
884
|
+
await mgr.runCell('p1', 'globalThis.x = 1;');
|
|
885
|
+
await mgr.clearHistory('p1');
|
|
886
|
+
const dir = join(root, 'p1');
|
|
887
|
+
assert.ok(existsSync(join(dir, 'meta.json')), 'meta.json present');
|
|
888
|
+
assert.equal(existsSync(join(dir, 'meta.json.tmp')), false, 'no .tmp leak');
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
it('writeMetaAtomic uses tmp + rename (no .tmp leak; survives concurrent reader)', async () => {
|
|
892
|
+
await mgr.runCell('p1', 'globalThis.x = 1;');
|
|
893
|
+
const path = join(root, 'p1', 'meta.json');
|
|
894
|
+
// Spy on rename to confirm it was called for this path.
|
|
895
|
+
// node:fs's renameSync is what the helper uses; we can verify by checking
|
|
896
|
+
// the helper's internal behavior directly through a cast.
|
|
897
|
+
const helper = (mgr as unknown as { writeMetaAtomic(path: string, payload: unknown): void });
|
|
898
|
+
// Write a known payload.
|
|
899
|
+
helper.writeMetaAtomic(path, { name: 'p1', schema_version: 3, custom_field: 'sentinel' });
|
|
900
|
+
const written = JSON.parse(readFileSync(path, 'utf8')) as { custom_field?: string };
|
|
901
|
+
assert.equal(written.custom_field, 'sentinel');
|
|
902
|
+
assert.equal(existsSync(`${path}.tmp`), false, 'no .tmp leak after writeMetaAtomic');
|
|
903
|
+
});
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
describe('ScratchpadManager.evict (Task D)', () => {
|
|
907
|
+
let workspace: string;
|
|
908
|
+
let root: string;
|
|
909
|
+
let mgr: ScratchpadManager;
|
|
910
|
+
|
|
911
|
+
beforeEach(async () => {
|
|
912
|
+
workspace = await mkdtemp(join(tmpdir(), 'sp-ws-'));
|
|
913
|
+
root = await mkdtemp(join(tmpdir(), 'sp-root-'));
|
|
914
|
+
mgr = new ScratchpadManager({ workspace, root, sessionId: 's', sweepIntervalMs: 1_000_000 });
|
|
915
|
+
});
|
|
916
|
+
afterEach(async () => {
|
|
917
|
+
await mgr.disposeAll();
|
|
918
|
+
await rm(workspace, { recursive: true, force: true });
|
|
919
|
+
await rm(root, { recursive: true, force: true });
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
it('evict snapshots + disposes warm entry; dir/meta/cells.jsonl remain', async () => {
|
|
923
|
+
await mgr.runCell('t', 'globalThis.x = 1;');
|
|
924
|
+
const { interrupted } = await mgr.evict('t');
|
|
925
|
+
assert.equal(interrupted, false);
|
|
926
|
+
assert.equal(mgr.list().find((i) => i.name === 't')?.live, false, 'should flip to cold');
|
|
927
|
+
assert.ok(existsSync(join(root, 't', 'kernel.db')), 'kernel.db remains on disk');
|
|
928
|
+
assert.ok(existsSync(join(root, 't', 'meta.json')), 'meta.json remains on disk');
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
it('evict refuses without --force when a cell is active', async () => {
|
|
932
|
+
await mgr.getOrAttach('t');
|
|
933
|
+
// Start a long-running cell but don't await it.
|
|
934
|
+
const pending = mgr.runCell('t', 'while (true) {}');
|
|
935
|
+
// Give the kernel a moment to start executing.
|
|
936
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
937
|
+
await assert.rejects(mgr.evict('t'), /cell is running.*--force to interrupt/);
|
|
938
|
+
// Clean up: cancel via --force so afterEach doesn't hang.
|
|
939
|
+
await mgr.evict('t', { force: true });
|
|
940
|
+
await assert.rejects(pending); // cell rejected via cancel
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
it('evict --force interrupts an active cell and skips snapshot', async () => {
|
|
944
|
+
await mgr.getOrAttach('t');
|
|
945
|
+
const pending = mgr.runCell('t', 'while (true) {}');
|
|
946
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
947
|
+
const { interrupted } = await mgr.evict('t', { force: true });
|
|
948
|
+
assert.equal(interrupted, true);
|
|
949
|
+
await assert.rejects(pending, /cancelled/);
|
|
950
|
+
// Entry should be cold; dir remains for cold-restart.
|
|
951
|
+
assert.equal(mgr.list().find((i) => i.name === 't')?.live, false);
|
|
952
|
+
assert.ok(existsSync(join(root, 't', 'meta.json')), 'on-disk state preserved');
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
it('evict on cold entry throws "not warm"', async () => {
|
|
956
|
+
await mgr.getOrAttach('t');
|
|
957
|
+
await mgr.evict('t'); // first evict makes it cold
|
|
958
|
+
await assert.rejects(mgr.evict('t'), /not warm \(already cold\)/);
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
it('list() ScratchpadInfo exposes hasActiveCell', async () => {
|
|
962
|
+
await mgr.runCell('t', 'globalThis.x = 1;');
|
|
963
|
+
const info = mgr.list().find((i) => i.name === 't')!;
|
|
964
|
+
assert.equal(info.hasActiveCell, false, 'idle warm entry');
|
|
965
|
+
const pending = mgr.runCell('t', 'while (true) {}');
|
|
966
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
967
|
+
const infoBusy = mgr.list().find((i) => i.name === 't')!;
|
|
968
|
+
assert.equal(infoBusy.hasActiveCell, true, 'mid-cell warm entry');
|
|
969
|
+
await mgr.evict('t', { force: true });
|
|
970
|
+
await assert.rejects(pending);
|
|
971
|
+
});
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
describe('ScratchpadManager attach meta freshness (Task E)', () => {
|
|
975
|
+
it('meta.json after /sp new reflects post-spawn disk state (kernel_db.present + size_bytes)', async () => {
|
|
976
|
+
const workspace = await mkdtemp(join(tmpdir(), 'sp-ws-'));
|
|
977
|
+
const root = await mkdtemp(join(tmpdir(), 'sp-root-'));
|
|
978
|
+
const mgr = new ScratchpadManager({ workspace, root, sessionId: 's', sweepIntervalMs: 1_000_000 });
|
|
979
|
+
try {
|
|
980
|
+
await mgr.getOrAttach('fresh'); // simulates /sp new path
|
|
981
|
+
const meta = JSON.parse(readFileSync(join(root, 'fresh', 'meta.json'), 'utf8')) as { kernel_db: { present: boolean }, size_bytes: number };
|
|
982
|
+
assert.equal(meta.kernel_db.present, true, 'kernel.db should be reflected after spawn');
|
|
983
|
+
assert.ok(meta.size_bytes > 0, `size_bytes should be > 0; got ${meta.size_bytes}`);
|
|
984
|
+
} finally {
|
|
985
|
+
await mgr.disposeAll();
|
|
986
|
+
await rm(workspace, { recursive: true, force: true });
|
|
987
|
+
await rm(root, { recursive: true, force: true });
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
describe('ScratchpadManager — bindings (Phase 2)', () => {
|
|
993
|
+
let workspace: string;
|
|
994
|
+
let root: string;
|
|
995
|
+
let mgr: ScratchpadManager;
|
|
996
|
+
|
|
997
|
+
beforeEach(async () => {
|
|
998
|
+
workspace = await mkdtemp(join(tmpdir(), 'sp-bind-ws-'));
|
|
999
|
+
await mkdir(join(workspace, '.otto', 'inputs'), { recursive: true });
|
|
1000
|
+
root = await mkdtemp(join(tmpdir(), 'sp-bind-root-'));
|
|
1001
|
+
mgr = new ScratchpadManager({ workspace, root, sessionId: 'sess-1', sweepIntervalMs: 1_000_000 });
|
|
1002
|
+
});
|
|
1003
|
+
afterEach(async () => {
|
|
1004
|
+
await mgr?.disposeAll();
|
|
1005
|
+
await rm(workspace, { recursive: true, force: true });
|
|
1006
|
+
await rm(root, { recursive: true, force: true });
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
it('create() persists bindings to meta.json', async () => {
|
|
1010
|
+
await mgr.create('p1', { bindings: ['jira:prod'] });
|
|
1011
|
+
const meta = JSON.parse(readFileSync(join(root, 'p1', 'meta.json'), 'utf8')) as {
|
|
1012
|
+
schema_version?: unknown;
|
|
1013
|
+
bindings?: unknown;
|
|
1014
|
+
};
|
|
1015
|
+
assert.equal(meta.schema_version, 4);
|
|
1016
|
+
assert.deepEqual(meta.bindings, ['jira:prod']);
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
it('addBinding() appends a ref and is idempotent', async () => {
|
|
1020
|
+
await mgr.create('p1');
|
|
1021
|
+
const res1 = await mgr.addBinding('p1', 'jira:prod');
|
|
1022
|
+
assert.equal(res1.added, true);
|
|
1023
|
+
const meta1 = JSON.parse(readFileSync(join(root, 'p1', 'meta.json'), 'utf8')) as { bindings?: unknown };
|
|
1024
|
+
assert.deepEqual(meta1.bindings, ['jira:prod']);
|
|
1025
|
+
// Same ref again is a no-op.
|
|
1026
|
+
const res2 = await mgr.addBinding('p1', 'jira:prod');
|
|
1027
|
+
assert.equal(res2.added, false);
|
|
1028
|
+
const meta2 = JSON.parse(readFileSync(join(root, 'p1', 'meta.json'), 'utf8')) as { bindings?: unknown };
|
|
1029
|
+
assert.deepEqual(meta2.bindings, ['jira:prod']);
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
it('removeBinding() drops a ref and returns false when absent', async () => {
|
|
1033
|
+
await mgr.create('p1', { bindings: ['jira:prod', 'foo:bar'] });
|
|
1034
|
+
const r1 = await mgr.removeBinding('p1', 'jira:prod');
|
|
1035
|
+
assert.equal(r1.removed, true);
|
|
1036
|
+
const meta = JSON.parse(readFileSync(join(root, 'p1', 'meta.json'), 'utf8')) as { bindings?: unknown };
|
|
1037
|
+
assert.deepEqual(meta.bindings, ['foo:bar']);
|
|
1038
|
+
const r2 = await mgr.removeBinding('p1', 'jira:prod');
|
|
1039
|
+
assert.equal(r2.removed, false);
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
it('readBindings() returns the on-disk binding list', async () => {
|
|
1043
|
+
await mgr.create('p1', { bindings: ['jira:prod'] });
|
|
1044
|
+
assert.deepEqual(mgr.readBindings('p1'), ['jira:prod']);
|
|
1045
|
+
assert.deepEqual(mgr.readBindings('does-not-exist'), []);
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
it('fork copies bindings from src to dst', async () => {
|
|
1049
|
+
await mgr.create('src-bind', { bindings: ['jira:prod'] });
|
|
1050
|
+
await mgr.fork('src-bind', 'dst-bind');
|
|
1051
|
+
const dstMeta = JSON.parse(readFileSync(join(root, 'dst-bind', 'meta.json'), 'utf8')) as { bindings?: unknown };
|
|
1052
|
+
assert.deepEqual(dstMeta.bindings, ['jira:prod']);
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
it('migrates v3 meta.json to v4 by adding empty bindings', async () => {
|
|
1056
|
+
// Create via the normal path, then forcibly rewrite meta.json to look like v3.
|
|
1057
|
+
await mgr.create('legacy');
|
|
1058
|
+
const metaPath = join(root, 'legacy', 'meta.json');
|
|
1059
|
+
const cur = JSON.parse(readFileSync(metaPath, 'utf8')) as Record<string, unknown>;
|
|
1060
|
+
cur.schema_version = 3;
|
|
1061
|
+
delete cur.bindings;
|
|
1062
|
+
writeFileSync(metaPath, JSON.stringify(cur, null, 2));
|
|
1063
|
+
|
|
1064
|
+
// Tear down + re-create on the same root so getOrAttach hits the cold path
|
|
1065
|
+
// (attachUnmanaged → writeMeta), which performs the migration.
|
|
1066
|
+
await mgr.disposeAll();
|
|
1067
|
+
mgr = new ScratchpadManager({ workspace, root, sessionId: 'sess-1', sweepIntervalMs: 1_000_000 });
|
|
1068
|
+
await mgr.getOrAttach('legacy');
|
|
1069
|
+
|
|
1070
|
+
const migrated = JSON.parse(readFileSync(metaPath, 'utf8')) as {
|
|
1071
|
+
schema_version?: unknown;
|
|
1072
|
+
bindings?: unknown;
|
|
1073
|
+
};
|
|
1074
|
+
assert.equal(migrated.schema_version, 4);
|
|
1075
|
+
assert.deepEqual(migrated.bindings, []);
|
|
1076
|
+
});
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
describe('ScratchpadManager — onDataLoad fan-out (Phase 3 Task 19)', () => {
|
|
1080
|
+
// Cross-pillar seam: the kernel emits a data_load event for every
|
|
1081
|
+
// otto.collectors.open(uri).load() call. ChildProcessRuntime surfaces it via
|
|
1082
|
+
// onDataLoad (per-spawn). The manager bridges that callback so callers see
|
|
1083
|
+
// {drawer, scratchpadName} — the shape MemoryRecorder.recordFileLoad needs.
|
|
1084
|
+
let workspace: string;
|
|
1085
|
+
let root: string;
|
|
1086
|
+
let mgr: ScratchpadManager;
|
|
1087
|
+
const delay = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
1088
|
+
|
|
1089
|
+
beforeEach(async () => {
|
|
1090
|
+
workspace = await mkdtemp(join(tmpdir(), 'sp-onload-ws-'));
|
|
1091
|
+
await mkdir(join(workspace, '.otto', 'inputs'), { recursive: true });
|
|
1092
|
+
root = await mkdtemp(join(tmpdir(), 'sp-onload-root-'));
|
|
1093
|
+
});
|
|
1094
|
+
afterEach(async () => {
|
|
1095
|
+
await mgr?.disposeAll();
|
|
1096
|
+
await rm(workspace, { recursive: true, force: true });
|
|
1097
|
+
await rm(root, { recursive: true, force: true });
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
it('forwards data_load drawers to onDataLoad with the scratchpad name', async () => {
|
|
1101
|
+
const inputs = join(workspace, '.otto', 'inputs');
|
|
1102
|
+
await writeFile(join(inputs, 'a.csv'), 'x,y\n1,2\n');
|
|
1103
|
+
const uri = pathToFileURL(join(inputs, 'a.csv')).href;
|
|
1104
|
+
|
|
1105
|
+
const events: Array<{ drawer: DataLoadDrawer; scratchpadName: string }> = [];
|
|
1106
|
+
mgr = new ScratchpadManager({
|
|
1107
|
+
workspace,
|
|
1108
|
+
root,
|
|
1109
|
+
sessionId: 'sess-onload',
|
|
1110
|
+
sweepIntervalMs: 1_000_000,
|
|
1111
|
+
onDataLoad: (drawer, scratchpadName) => events.push({ drawer, scratchpadName }),
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
await mgr.runCell(
|
|
1115
|
+
'pad-x',
|
|
1116
|
+
`return await (await otto.collectors.open(${JSON.stringify(uri)})).load();`,
|
|
1117
|
+
);
|
|
1118
|
+
|
|
1119
|
+
// data_load arrives as a kernel event; allow the IPC loop to drain.
|
|
1120
|
+
await delay(50);
|
|
1121
|
+
|
|
1122
|
+
assert.equal(events.length, 1, 'exactly one data_load event expected');
|
|
1123
|
+
const ev = events[0]!;
|
|
1124
|
+
assert.equal(ev.scratchpadName, 'pad-x', 'scratchpadName closure-bound at spawn time');
|
|
1125
|
+
assert.equal(ev.drawer.kind, 'data_load');
|
|
1126
|
+
assert.equal(ev.drawer.collector, 'file');
|
|
1127
|
+
assert.equal(ev.drawer.uri, uri);
|
|
1128
|
+
assert.equal(ev.drawer.bytes, 8);
|
|
1129
|
+
assert.equal(ev.drawer.schema, null);
|
|
1130
|
+
// rows_loaded is null for a buffer/string payload (not an array).
|
|
1131
|
+
assert.equal(ev.drawer.rows_loaded, null);
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
it('routes loads from different scratchpads to the correct name', async () => {
|
|
1135
|
+
const inputs = join(workspace, '.otto', 'inputs');
|
|
1136
|
+
await writeFile(join(inputs, 'one.csv'), 'a,b\n');
|
|
1137
|
+
await writeFile(join(inputs, 'two.csv'), 'c,d\n');
|
|
1138
|
+
const u1 = pathToFileURL(join(inputs, 'one.csv')).href;
|
|
1139
|
+
const u2 = pathToFileURL(join(inputs, 'two.csv')).href;
|
|
1140
|
+
|
|
1141
|
+
const events: Array<{ name: string; uri: string }> = [];
|
|
1142
|
+
mgr = new ScratchpadManager({
|
|
1143
|
+
workspace,
|
|
1144
|
+
root,
|
|
1145
|
+
sessionId: 'sess-onload-2',
|
|
1146
|
+
sweepIntervalMs: 1_000_000,
|
|
1147
|
+
onDataLoad: (drawer, scratchpadName) => events.push({ name: scratchpadName, uri: drawer.uri }),
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
await mgr.runCell('alpha', `await (await otto.collectors.open(${JSON.stringify(u1)})).load();`);
|
|
1151
|
+
await mgr.runCell('beta', `await (await otto.collectors.open(${JSON.stringify(u2)})).load();`);
|
|
1152
|
+
await delay(50);
|
|
1153
|
+
|
|
1154
|
+
assert.equal(events.length, 2);
|
|
1155
|
+
// Order isn't guaranteed across kernels but each event's name must match its URI.
|
|
1156
|
+
const alphaEv = events.find((e) => e.name === 'alpha');
|
|
1157
|
+
const betaEv = events.find((e) => e.name === 'beta');
|
|
1158
|
+
assert.ok(alphaEv, 'alpha event present');
|
|
1159
|
+
assert.ok(betaEv, 'beta event present');
|
|
1160
|
+
assert.equal(alphaEv!.uri, u1);
|
|
1161
|
+
assert.equal(betaEv!.uri, u2);
|
|
1162
|
+
});
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
describe('ScratchpadManager — onArtifactCreate fan-out + artifact RPC (Phase 4 Task 10)', () => {
|
|
1166
|
+
// Cross-pillar seam: the kernel calls otto.artifact.create(...) which fires
|
|
1167
|
+
// an RPC the manager services via ArtifactStore, and afterwards emits an
|
|
1168
|
+
// artifact_create event the manager bridges through onArtifactCreate. This
|
|
1169
|
+
// suite covers both the RPC plumbing (handleArtifactCreate/Update) and the
|
|
1170
|
+
// event fan-out path (onArtifactCreate).
|
|
1171
|
+
let workspace: string;
|
|
1172
|
+
let root: string;
|
|
1173
|
+
let mgr: ScratchpadManager;
|
|
1174
|
+
const delay = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
1175
|
+
|
|
1176
|
+
beforeEach(async () => {
|
|
1177
|
+
workspace = await mkdtemp(join(tmpdir(), 'sp-artifact-ws-'));
|
|
1178
|
+
await mkdir(join(workspace, '.otto', 'inputs'), { recursive: true });
|
|
1179
|
+
root = await mkdtemp(join(tmpdir(), 'sp-artifact-root-'));
|
|
1180
|
+
});
|
|
1181
|
+
afterEach(async () => {
|
|
1182
|
+
await mgr?.disposeAll();
|
|
1183
|
+
await rm(workspace, { recursive: true, force: true });
|
|
1184
|
+
await rm(root, { recursive: true, force: true });
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
it('forwards artifact_create drawers to onArtifactCreate with the scratchpad name', async () => {
|
|
1188
|
+
const store = new ArtifactStore({ workspaceDir: workspace });
|
|
1189
|
+
const events: Array<{ drawer: ArtifactCreateDrawer; scratchpadName: string }> = [];
|
|
1190
|
+
mgr = new ScratchpadManager({
|
|
1191
|
+
workspace,
|
|
1192
|
+
root,
|
|
1193
|
+
sessionId: 'sess-art',
|
|
1194
|
+
sweepIntervalMs: 1_000_000,
|
|
1195
|
+
onArtifactCreate: (drawer, scratchpadName) => events.push({ drawer, scratchpadName }),
|
|
1196
|
+
getArtifactStore: () => store,
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
await mgr.runCell(
|
|
1200
|
+
'pad-a',
|
|
1201
|
+
`const h = await otto.artifact.create('report', 'demo'); return h.slug;`,
|
|
1202
|
+
);
|
|
1203
|
+
|
|
1204
|
+
// artifact_create arrives as a kernel event AFTER the RPC roundtrip; allow drain.
|
|
1205
|
+
await delay(50);
|
|
1206
|
+
|
|
1207
|
+
assert.equal(events.length, 1, 'exactly one artifact_create event expected');
|
|
1208
|
+
const ev = events[0]!;
|
|
1209
|
+
assert.equal(ev.scratchpadName, 'pad-a', 'scratchpadName closure-bound at spawn time');
|
|
1210
|
+
assert.equal(ev.drawer.kind, 'artifact');
|
|
1211
|
+
assert.equal(ev.drawer.artifact_kind, 'report');
|
|
1212
|
+
assert.equal(typeof ev.drawer.slug, 'string');
|
|
1213
|
+
assert.ok(ev.drawer.uri.startsWith('artifact://'));
|
|
1214
|
+
assert.ok(ev.drawer.primary_path.endsWith('report.md'));
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
it('routes artifact creations from different scratchpads to the correct name', async () => {
|
|
1218
|
+
const store = new ArtifactStore({ workspaceDir: workspace });
|
|
1219
|
+
const events: Array<{ name: string; slug: string }> = [];
|
|
1220
|
+
mgr = new ScratchpadManager({
|
|
1221
|
+
workspace,
|
|
1222
|
+
root,
|
|
1223
|
+
sessionId: 'sess-art-2',
|
|
1224
|
+
sweepIntervalMs: 1_000_000,
|
|
1225
|
+
onArtifactCreate: (drawer, scratchpadName) =>
|
|
1226
|
+
events.push({ name: scratchpadName, slug: drawer.slug }),
|
|
1227
|
+
getArtifactStore: () => store,
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1230
|
+
await mgr.runCell('alpha', `await otto.artifact.create('report', 'one');`);
|
|
1231
|
+
await mgr.runCell('beta', `await otto.artifact.create('report', 'two');`);
|
|
1232
|
+
await delay(50);
|
|
1233
|
+
|
|
1234
|
+
assert.equal(events.length, 2);
|
|
1235
|
+
const alphaEv = events.find((e) => e.name === 'alpha');
|
|
1236
|
+
const betaEv = events.find((e) => e.name === 'beta');
|
|
1237
|
+
assert.ok(alphaEv, 'alpha event present');
|
|
1238
|
+
assert.ok(betaEv, 'beta event present');
|
|
1239
|
+
assert.ok(alphaEv!.slug.startsWith('one'));
|
|
1240
|
+
assert.ok(betaEv!.slug.startsWith('two'));
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
it('handleArtifactCreate calls store.create and returns slug+uri+primary_path', async () => {
|
|
1244
|
+
const store = new ArtifactStore({ workspaceDir: workspace });
|
|
1245
|
+
mgr = new ScratchpadManager({
|
|
1246
|
+
workspace,
|
|
1247
|
+
root,
|
|
1248
|
+
sessionId: 'sess-art-3',
|
|
1249
|
+
sweepIntervalMs: 1_000_000,
|
|
1250
|
+
getArtifactStore: () => store,
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
const { value } = await mgr.runCell(
|
|
1254
|
+
'pad-rpc',
|
|
1255
|
+
`const h = await otto.artifact.create('report', 'rpc-demo'); return { slug: h.slug, uri: h.uri, primary: h.primaryPath };`,
|
|
1256
|
+
);
|
|
1257
|
+
const res = value as { slug: string; uri: string; primary: string };
|
|
1258
|
+
assert.ok(res.slug.startsWith('rpc-demo'));
|
|
1259
|
+
assert.equal(res.uri, `artifact://${res.slug}`);
|
|
1260
|
+
assert.ok(res.primary.endsWith('report.md'));
|
|
1261
|
+
// And the store actually persisted it.
|
|
1262
|
+
const handle = await store.get(res.slug);
|
|
1263
|
+
assert.ok(handle, 'artifact persisted to store');
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
it('handleArtifactCreate rejects with "artifacts unavailable" if getArtifactStore returns null', async () => {
|
|
1267
|
+
mgr = new ScratchpadManager({
|
|
1268
|
+
workspace,
|
|
1269
|
+
root,
|
|
1270
|
+
sessionId: 'sess-art-4',
|
|
1271
|
+
sweepIntervalMs: 1_000_000,
|
|
1272
|
+
getArtifactStore: () => null,
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
await assert.rejects(
|
|
1276
|
+
() => mgr.runCell('pad-null', `await otto.artifact.create('report', 'nope');`),
|
|
1277
|
+
/artifacts unavailable/,
|
|
1278
|
+
);
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
it('handleArtifactCreate rejects with "artifacts unavailable" if no getArtifactStore wired', async () => {
|
|
1282
|
+
mgr = new ScratchpadManager({
|
|
1283
|
+
workspace,
|
|
1284
|
+
root,
|
|
1285
|
+
sessionId: 'sess-art-5',
|
|
1286
|
+
sweepIntervalMs: 1_000_000,
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
await assert.rejects(
|
|
1290
|
+
() => mgr.runCell('pad-noopt', `await otto.artifact.create('report', 'nope');`),
|
|
1291
|
+
/artifacts unavailable/,
|
|
1292
|
+
);
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
it('handleArtifactUpdate writes files via store.update and returns files_touched', async () => {
|
|
1296
|
+
const store = new ArtifactStore({ workspaceDir: workspace });
|
|
1297
|
+
mgr = new ScratchpadManager({
|
|
1298
|
+
workspace,
|
|
1299
|
+
root,
|
|
1300
|
+
sessionId: 'sess-art-6',
|
|
1301
|
+
sweepIntervalMs: 1_000_000,
|
|
1302
|
+
getArtifactStore: () => store,
|
|
1303
|
+
});
|
|
1304
|
+
|
|
1305
|
+
const { value } = await mgr.runCell(
|
|
1306
|
+
'pad-upd',
|
|
1307
|
+
`const h = await otto.artifact.create('report', 'updates');
|
|
1308
|
+
const r = await h.update([{ path: 'report.md', content: '# hi' }]);
|
|
1309
|
+
return r.files_touched;`,
|
|
1310
|
+
);
|
|
1311
|
+
const touched = value as string[];
|
|
1312
|
+
assert.deepEqual(touched, ['report.md']);
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
it('handleArtifactUpdate rejects when the underlying store cannot find the slug', async () => {
|
|
1316
|
+
// Drive a unit-level test against the spawnRuntime closure: spy a store that
|
|
1317
|
+
// returns null from get() so the handler throws "unknown artifact".
|
|
1318
|
+
const fakeStore = {
|
|
1319
|
+
create: async () => ({ slug: 'dummy', uri: 'artifact://dummy', primaryPath: '/tmp/x' }),
|
|
1320
|
+
get: async () => null,
|
|
1321
|
+
update: async () => ({ files_touched: [] }),
|
|
1322
|
+
} as unknown as ArtifactStore;
|
|
1323
|
+
mgr = new ScratchpadManager({
|
|
1324
|
+
workspace,
|
|
1325
|
+
root,
|
|
1326
|
+
sessionId: 'sess-art-7',
|
|
1327
|
+
sweepIntervalMs: 1_000_000,
|
|
1328
|
+
getArtifactStore: () => fakeStore,
|
|
1329
|
+
});
|
|
1330
|
+
|
|
1331
|
+
// Force the spawnRuntime path to expose its handlers via attaching one
|
|
1332
|
+
// runtime and then invoking the wired handler directly through the
|
|
1333
|
+
// ChildProcessRuntime options. We grab the runtime, then build a fake
|
|
1334
|
+
// request frame and round-trip it through the options object.
|
|
1335
|
+
const rt = await mgr.getOrAttach('pad-bad');
|
|
1336
|
+
const opts = (rt as unknown as { options: { handleArtifactUpdate?: (r: { slug: string; files: Array<{ path: string; content: string }> }) => Promise<{ files_touched: string[] }> } }).options;
|
|
1337
|
+
assert.ok(opts.handleArtifactUpdate, 'handleArtifactUpdate wired');
|
|
1338
|
+
await assert.rejects(
|
|
1339
|
+
() => opts.handleArtifactUpdate!({ slug: 'does-not-exist', files: [{ path: 'x.md', content: 'y' }] }),
|
|
1340
|
+
/unknown artifact: does-not-exist/,
|
|
1341
|
+
);
|
|
1342
|
+
});
|
|
1343
|
+
});
|