maka-agent 0.2.0-dev.26.20260909 → 0.2.0-dev.28.20260911

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.
Files changed (125) hide show
  1. package/LICENSE +106 -0
  2. package/dist/cli-core.js +28 -0
  3. package/dist/runtime-host-cli.js +43 -3
  4. package/dist/runtime-host-handoff-surface.js +10 -8
  5. package/dist/runtime-host-lifecycle-transaction.js +28 -3
  6. package/dist/runtime-host-local-source-retirement.js +1 -1
  7. package/dist/runtime-host-run-command.js +15 -7
  8. package/dist/runtime-host-setup-command.js +29 -6
  9. package/dist/runtime-host-update-command.js +49 -5
  10. package/dist/session-export-command.js +72 -0
  11. package/dist/session-import-command.js +69 -0
  12. package/native/runtime-host-peer/prebuilds/darwin-arm64/maka_runtime_host_peer.node +0 -0
  13. package/native/runtime-host-peer/prebuilds/linux-arm64/maka_runtime_host_peer.node +0 -0
  14. package/native/runtime-host-peer/prebuilds/linux-x64/maka_runtime_host_peer.node +0 -0
  15. package/native/runtime-host-peer/prebuilds/win32-x64/maka_runtime_host_peer.node +0 -0
  16. package/native/runtime-host-windows-task-launcher/prebuilds/win32-x64/maka-runtime-host-task-launcher.exe +0 -0
  17. package/node_modules/@earendil-works/pi-tui/dist/components/editor.js +9 -4
  18. package/node_modules/@maka/core/dist/diagnostic-log.js +18 -7
  19. package/node_modules/@maka/core/dist/text-sanitize.js +8 -2
  20. package/node_modules/@maka/core/dist/usage-stats/types.js +2 -0
  21. package/node_modules/@maka/core/dist/workhub-routing.js +126 -0
  22. package/node_modules/@maka/core/package.json +2 -1
  23. package/node_modules/@maka/runtime/dist/ai-sdk-turn.js +15 -11
  24. package/node_modules/@maka/runtime/dist/archive-read-tool.js +3 -1
  25. package/node_modules/@maka/runtime/dist/bots/bot-test.js +2 -1
  26. package/node_modules/@maka/runtime/dist/bots/feishu-bridge.js +24 -20
  27. package/node_modules/@maka/runtime/dist/bots/slack-bridge.js +5 -3
  28. package/node_modules/@maka/runtime/dist/bots/wechat-bridge.js +87 -29
  29. package/node_modules/@maka/runtime/dist/bots/wecom-bridge.js +40 -36
  30. package/node_modules/@maka/runtime/dist/builtin-tools.js +21 -2
  31. package/node_modules/@maka/runtime/dist/computer-use-tools.js +4 -1
  32. package/node_modules/@maka/runtime/dist/deep-research-tools.js +11 -5
  33. package/node_modules/@maka/runtime/dist/edit-replace.js +2 -2
  34. package/node_modules/@maka/runtime/dist/filesystem-worker/operations.js +5 -6
  35. package/node_modules/@maka/runtime/dist/memory-extraction-evidence.js +7 -1
  36. package/node_modules/@maka/runtime/dist/memory-extraction.js +12 -7
  37. package/node_modules/@maka/runtime/dist/openai-responses-websocket.js +2 -5
  38. package/node_modules/@maka/runtime/dist/plugin-agent-service.js +131 -0
  39. package/node_modules/@maka/runtime/dist/plugin-approval-service.js +33 -0
  40. package/node_modules/@maka/runtime/dist/plugin-attachment-service.js +53 -0
  41. package/node_modules/@maka/runtime/dist/plugin-command-service.js +100 -0
  42. package/node_modules/@maka/runtime/dist/plugin-composition-loader.js +11 -2
  43. package/node_modules/@maka/runtime/dist/plugin-data-services.js +332 -0
  44. package/node_modules/@maka/runtime/dist/plugin-fs-service.js +62 -0
  45. package/node_modules/@maka/runtime/dist/plugin-goal-service.js +61 -0
  46. package/node_modules/@maka/runtime/dist/plugin-invocation-signal.js +24 -0
  47. package/node_modules/@maka/runtime/dist/plugin-llm-service.js +85 -0
  48. package/node_modules/@maka/runtime/dist/plugin-lsp-service.js +94 -0
  49. package/node_modules/@maka/runtime/dist/plugin-scope-registry.js +91 -0
  50. package/node_modules/@maka/runtime/dist/plugin-session-query-service.js +71 -0
  51. package/node_modules/@maka/runtime/dist/plugin-shell-env-service.js +87 -0
  52. package/node_modules/@maka/runtime/dist/plugin-shell-service.js +74 -0
  53. package/node_modules/@maka/runtime/dist/plugin-skill-service.js +93 -0
  54. package/node_modules/@maka/runtime/dist/plugin-system-prompt-service.js +346 -0
  55. package/node_modules/@maka/runtime/dist/plugin-tool-service.js +18 -52
  56. package/node_modules/@maka/runtime/dist/plugin-user-question-service.js +43 -0
  57. package/node_modules/@maka/runtime/dist/plugin-web-service.js +69 -0
  58. package/node_modules/@maka/runtime/dist/request-customization-fetch.js +14 -7
  59. package/node_modules/@maka/runtime/dist/session-export.js +178 -0
  60. package/node_modules/@maka/runtime/dist/session-import.js +100 -0
  61. package/node_modules/@maka/runtime/dist/session-manager.js +6 -1
  62. package/node_modules/@maka/runtime/dist/shell-run-manager.js +9 -6
  63. package/node_modules/@maka/runtime/dist/shell-run-tool-result.js +13 -6
  64. package/node_modules/@maka/runtime/dist/stream-graph-coordinator.js +43 -10
  65. package/node_modules/@maka/runtime/dist/stream-graph-handoff.js +6 -1
  66. package/node_modules/@maka/runtime/dist/subscription-model-fetch.js +1 -4
  67. package/node_modules/@maka/runtime/dist/system-prompt/workspace-instructions.js +11 -6
  68. package/node_modules/@maka/runtime/dist/tavily-search.js +8 -2
  69. package/node_modules/@maka/runtime/dist/text-line-window.js +49 -0
  70. package/node_modules/@maka/runtime/dist/tool-output.js +35 -12
  71. package/node_modules/@maka/runtime/dist/unified-diff.js +40 -10
  72. package/node_modules/@maka/runtime/dist/web-fetch-tool.js +4 -2
  73. package/node_modules/@maka/runtime/dist/workers/filesystem-worker.js +82 -33
  74. package/node_modules/@maka/runtime/dist/workspace-executor.js +2 -6
  75. package/node_modules/@maka/runtime/package.json +20 -1
  76. package/node_modules/@maka/runtime-host/dist/client/host-handoff-copy.js +73 -24
  77. package/node_modules/@maka/runtime-host/dist/client/host-handoff.js +46 -12
  78. package/node_modules/@maka/runtime-host/dist/operator/setup-frame.js +31 -21
  79. package/node_modules/@maka/runtime-host/dist/peer-mesh/node.js +7 -4
  80. package/node_modules/@maka/runtime-host/dist/protocol/artifact.js +2 -2
  81. package/node_modules/@maka/runtime-host/dist/protocol/index.js +10 -1
  82. package/node_modules/@maka/runtime-host/dist/protocol/plugin-platform.js +30 -5
  83. package/node_modules/@maka/runtime-host/dist/protocol/session-transcript.js +3 -2
  84. package/node_modules/@maka/runtime-host/dist/protocol/skill-catalog.js +2 -1
  85. package/node_modules/@maka/runtime-host/dist/protocol/workhub-coordination.js +25 -23
  86. package/node_modules/@maka/runtime-host/dist/server/artifact-coordinator.js +2 -0
  87. package/node_modules/@maka/runtime-host/dist/server/client-capability-coordinator.js +62 -4
  88. package/node_modules/@maka/runtime-host/dist/server/execution-composition.js +481 -11
  89. package/node_modules/@maka/runtime-host/dist/server/execution-model-authority.js +74 -0
  90. package/node_modules/@maka/runtime-host/dist/server/goal-coordinator.js +6 -1
  91. package/node_modules/@maka/runtime-host/dist/server/hosted-execution-recovery.js +2 -2
  92. package/node_modules/@maka/runtime-host/dist/server/hosted-execution-tool-profile.js +30 -0
  93. package/node_modules/@maka/runtime-host/dist/server/interactive-run-composer.js +114 -20
  94. package/node_modules/@maka/runtime-host/dist/server/message-coordinator.js +49 -12
  95. package/node_modules/@maka/runtime-host/dist/server/plugin-data-runtime.js +234 -0
  96. package/node_modules/@maka/runtime-host/dist/server/plugin-platform-coordinator.js +6 -0
  97. package/node_modules/@maka/runtime-host/dist/server/plugin-platform.js +12 -0
  98. package/node_modules/@maka/runtime-host/dist/server/root-admission-owner.js +17 -8
  99. package/node_modules/@maka/runtime-host/dist/server/root-turn-coordinator.js +189 -92
  100. package/node_modules/@maka/runtime-host/dist/server/runtime-resource-projection.js +3 -9
  101. package/node_modules/@maka/runtime-host/dist/server/session-continuity-coordinator.js +20 -21
  102. package/node_modules/@maka/runtime-host/dist/server/session-transcript-pager.js +24 -2
  103. package/node_modules/@maka/runtime-host/dist/server/skill-catalog-coordinator.js +18 -2
  104. package/node_modules/@maka/runtime-host/dist/server/workhub-coordination-action-gate.js +44 -35
  105. package/node_modules/@maka/runtime-host/dist/server/workhub-coordination-coordinator.js +92 -10
  106. package/node_modules/@maka/runtime-host/dist/transport/framed-byte-stream-transport.js +4 -1
  107. package/node_modules/@maka/runtime-host/dist/transport/local-ipc-framing.js +4 -2
  108. package/node_modules/@maka/runtime-host/dist/transport/peer-native.js +9 -8
  109. package/node_modules/@maka/runtime-host/dist/transport/resumable-peer-stream.js +14 -9
  110. package/node_modules/@maka/storage/dist/agent-run-store.js +54 -10
  111. package/node_modules/@maka/storage/dist/context-offload-snapshot.js +32 -10
  112. package/node_modules/@maka/storage/dist/context-value-mutation-gate.js +59 -0
  113. package/node_modules/@maka/storage/dist/execution-stores.js +1 -1
  114. package/node_modules/@maka/storage/dist/session-bundle-policy.js +909 -34
  115. package/node_modules/@maka/storage/dist/session-message-projection.js +8 -4
  116. package/node_modules/@maka/storage/dist/session-store.js +2 -2
  117. package/node_modules/@maka/storage/dist/sqlite-context-offload-store.js +18 -4
  118. package/node_modules/@maka/storage/dist/sqlite-session-metadata-store.js +12 -5
  119. package/node_modules/@maka/storage/dist/stable-storage.js +2 -1
  120. package/node_modules/@maka/storage/package.json +2 -0
  121. package/node_modules/@modelcontextprotocol/client/dist/src-D_zzAWoS.mjs +22 -5
  122. package/node_modules/@modelcontextprotocol/client/dist/src-NAgB4Mp8.cjs +22 -5
  123. package/node_modules/zod/v4/core/memoizer.cjs +12 -16
  124. package/node_modules/zod/v4/core/memoizer.js +12 -16
  125. package/package.json +1 -1
@@ -16,15 +16,36 @@
16
16
  * specific language governing permissions and limitations
17
17
  * under the License.
18
18
  */
19
- import { copyFile, lstat, mkdir, readdir, realpath, rename, rm } from 'node:fs/promises';
19
+ import { copyFile, link, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, unlink, writeFile, } from 'node:fs/promises';
20
+ import { constants } from 'node:fs';
20
21
  import { randomUUID } from 'node:crypto';
21
- import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
22
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
22
23
  import { DatabaseSync } from 'node:sqlite';
23
24
  import { decodeArtifactRecordJsons } from './artifact-metadata-codec.js';
24
- import { withArtifactWriterLock } from './artifact-writer-lock.js';
25
+ import { withArtifactWriterLock, withLeaseBoundArtifactWriterLock, } from './artifact-writer-lock.js';
26
+ import { syncDirectoryChain } from './stable-storage.js';
27
+ import { prepareArtifactWriterLockAuthorityForLease, } from './root-authority.js';
28
+ /**
29
+ * Holds the Artifact writer lock for the root the caller is authorised over.
30
+ *
31
+ * Without a lease the root is named by a path, and the lock is derived from
32
+ * that path again after the authority was checked. A lease says which root the
33
+ * authority covers, so deriving the lock from the lease keeps the two from
34
+ * drifting: an alias or a replaced directory between the two steps would
35
+ * otherwise bind the operation to one root while the lease names another --
36
+ * including an unmarked one, which takes no lock at all.
37
+ */
38
+ async function withBundleArtifactWriterLock(stateRoot, lease, operation) {
39
+ if (!lease)
40
+ return withArtifactWriterLock(stateRoot, operation);
41
+ const authority = await prepareArtifactWriterLockAuthorityForLease(lease, 'interactive');
42
+ return withLeaseBoundArtifactWriterLock(authority, () => operation(lease.canonicalPath));
43
+ }
44
+ import { runWithContextValueMutation } from './context-value-mutation-gate.js';
25
45
  import { withOfflineContextSnapshot, copyContextSnapshot, validateContextSnapshot, planContextSnapshotFiles, } from './context-offload-snapshot.js';
26
46
  import { CONTEXT_OFFLOAD_DATABASE_NAME, CONTEXT_OFFLOAD_VALUES_DIRECTORY_NAME, } from './sqlite-context-offload-store.js';
27
- import { acquireOperationalStateDatabase, OPERATIONAL_STATE_DATABASE_NAME, } from './operational-state-store.js';
47
+ import { acquireOperationalStateDatabase, inspectOperationalStateSchema, OPERATIONAL_STATE_DATABASE_NAME, OperationalStateMigrationBlockedError, } from './operational-state-store.js';
48
+ import { TERMINAL_RUNTIME_EVENT_SQL } from './runtime-transcript-query.js';
28
49
  import { isSafeStorageId } from './storage-id.js';
29
50
  export const SESSION_BUNDLE_STATE_ENTRIES = [
30
51
  'artifacts',
@@ -54,10 +75,18 @@ export async function planSessionBundleExport(input) {
54
75
  assertRootsSeparate(stateRoot, configRoot, input.allowShared === true);
55
76
  assertRootsSeparate(stateRoot, destinationRoot, false);
56
77
  assertRootsSeparate(configRoot, destinationRoot, false);
57
- const databasePath = resolve(stateRoot, OPERATIONAL_STATE_DATABASE_NAME);
78
+ // Everything below is derived from `input.databasePath` -- the private copy
79
+ // the caller has already taken -- and never from the live database. The
80
+ // artifact and context locks do not fence ordinary Session and runtime
81
+ // writers, so a subtree, an artifact list and a manifest read from the live
82
+ // file would describe a different moment than the database that ships.
83
+ const databasePath = input.databasePath ?? resolve(stateRoot, OPERATIONAL_STATE_DATABASE_NAME);
58
84
  await assertRegularFile(databasePath, OPERATIONAL_STATE_DATABASE_NAME);
59
85
  const database = new DatabaseSync(databasePath, { readOnly: true });
60
86
  let artifacts;
87
+ let sessionIds;
88
+ let sourceSchema;
89
+ let connection;
61
90
  try {
62
91
  const session = database
63
92
  .prepare('SELECT 1 AS present FROM session_metadata WHERE session_id = ?')
@@ -65,10 +94,23 @@ export async function planSessionBundleExport(input) {
65
94
  if (!session) {
66
95
  throw new SessionBundleExportError('invalid_root', `Session bundle session does not exist: ${input.sessionId}`);
67
96
  }
97
+ sourceSchema = assertPortableSourceSchema(database);
98
+ sessionIds =
99
+ input.includeSubtree === true
100
+ ? collectSubagentSessionTree(database, input.sessionId)
101
+ : [input.sessionId];
102
+ const placeholders = sessionIds.map(() => '?').join(', ');
68
103
  const rows = database
69
- .prepare('SELECT record_json FROM artifact_records WHERE session_id = ? ORDER BY created_at, artifact_id')
70
- .all(input.sessionId);
104
+ .prepare(`SELECT record_json FROM artifact_records WHERE session_id IN (${placeholders}) ORDER BY created_at, artifact_id`)
105
+ .all(...sessionIds);
71
106
  artifacts = decodeArtifactRecordJsons(rows.map((row) => row.record_json));
107
+ const route = database
108
+ .prepare('SELECT llm_connection_slug, model FROM session_metadata WHERE session_id = ?')
109
+ .get(input.sessionId);
110
+ connection = {
111
+ llmConnectionSlug: String(route?.llm_connection_slug ?? ''),
112
+ model: String(route?.model ?? ''),
113
+ };
72
114
  }
73
115
  finally {
74
116
  database.close();
@@ -84,16 +126,24 @@ export async function planSessionBundleExport(input) {
84
126
  if (artifacts.length > 0) {
85
127
  entries.push({ relativePath: 'artifacts', kind: 'directory', source: 'copy' });
86
128
  for (const artifact of artifacts) {
87
- if (!isArtifactPathForSession(artifact.relativePath, input.sessionId)) {
88
- throw new SessionBundleExportError('path_escape', `Artifact path does not belong to session ${input.sessionId}: ${artifact.relativePath}`);
129
+ if (!sessionIds.some((id) => isArtifactPathForSession(artifact.relativePath, id))) {
130
+ throw new SessionBundleExportError('path_escape', `Artifact path does not belong to the exported subtree: ${artifact.relativePath}`);
89
131
  }
90
132
  const relativePath = `artifacts/${artifact.relativePath}`;
91
- await assertRegularFile(resolve(stateRoot, relativePath), relativePath);
133
+ // A record naming bytes the workspace does not have would produce a
134
+ // bundle whose own metadata points at nothing. Reported apart from a
135
+ // missing state root, which is a different mistake entirely.
136
+ await assertRegularFile(resolve(stateRoot, relativePath), relativePath).catch((error) => {
137
+ if (error instanceof SessionBundleExportError && error.code === 'invalid_root') {
138
+ throw new SessionBundleExportError('missing_entry', error.message, { cause: error });
139
+ }
140
+ throw error;
141
+ });
92
142
  entries.push({ relativePath, kind: 'file', source: 'copy' });
93
143
  }
94
144
  includedEntries.push('artifacts');
95
145
  }
96
- const contextFiles = await planContextSnapshotFiles(stateRoot, input.sessionId);
146
+ const contextFiles = await planContextSnapshotFiles(stateRoot, sessionIds);
97
147
  for (const relativePath of contextFiles) {
98
148
  entries.push({ relativePath, kind: 'file', source: 'context_snapshot' });
99
149
  }
@@ -108,35 +158,45 @@ export async function planSessionBundleExport(input) {
108
158
  configRoot,
109
159
  destinationRoot,
110
160
  sessionId: input.sessionId,
161
+ sessionIds,
162
+ sourceSchema,
163
+ connection,
111
164
  includedEntries,
112
165
  excludedEntries,
113
166
  entries,
114
167
  };
115
168
  }
116
169
  export async function exportSessionBundleState(input) {
117
- return withOfflineContextSnapshot(input.stateRoot, (contextLocked) => withArtifactWriterLock(input.stateRoot, async (stateRoot) => {
118
- const plan = await planSessionBundleExport({ ...input, stateRoot });
119
- await assertDestinationMissing(plan.destinationRoot);
120
- const stagingRoot = `${plan.destinationRoot}.${process.pid}.${randomUUID()}.tmp`;
170
+ return withOfflineContextSnapshot(input.stateRoot, (contextLocked) => withBundleArtifactWriterLock(input.stateRoot, input.lease, async (stateRoot) => {
171
+ const destinationRoot = resolve(input.destinationRoot);
172
+ await assertDestinationMissing(destinationRoot);
173
+ const stagingRoot = `${destinationRoot}.${process.pid}.${randomUUID()}.tmp`;
121
174
  try {
122
175
  await mkdir(stagingRoot, { recursive: true, mode: 0o700 });
176
+ // Take the private copy BEFORE anything is read. `lease.backup()` is
177
+ // what freezes the content; every decision after this -- schema, the
178
+ // subtree, the artifact list, quiescence, the manifest -- is made
179
+ // against this one file, so the bundle cannot describe two moments.
180
+ const databasePath = resolveInside(stagingRoot, OPERATIONAL_STATE_DATABASE_NAME);
181
+ await backupOperationalState(stateRoot, databasePath);
182
+ const plan = await planSessionBundleExport({ ...input, stateRoot, databasePath });
123
183
  for (const entry of plan.entries) {
124
- if (entry.source === 'context_snapshot')
184
+ if (entry.source === 'context_snapshot' || entry.source === 'filtered_runtime_sqlite') {
125
185
  continue;
186
+ }
126
187
  const destination = resolveInside(stagingRoot, entry.relativePath);
127
188
  if (entry.kind === 'directory') {
128
189
  await mkdir(destination, { recursive: true });
129
190
  continue;
130
191
  }
131
192
  await mkdir(dirname(destination), { recursive: true });
132
- if (entry.source === 'copy') {
133
- await copyFile(resolveInside(plan.stateRoot, entry.relativePath), destination);
134
- }
135
- else {
136
- await exportFilteredDatabase(plan.stateRoot, destination, plan.sessionId);
137
- }
193
+ await copyArtifactFile(plan.stateRoot, entry.relativePath, destination);
138
194
  }
139
- await copyContextSnapshot(stateRoot, stagingRoot, contextLocked, plan.sessionId);
195
+ await filterBackedUpDatabase(databasePath, plan.sessionIds, {
196
+ omitDiagnostics: input.omitDiagnostics === true,
197
+ requireQuiescent: input.requireQuiescent === true,
198
+ });
199
+ await copyContextSnapshot(stateRoot, stagingRoot, contextLocked, plan.sessionIds);
140
200
  await validateContextSnapshot(stagingRoot);
141
201
  await mkdir(dirname(plan.destinationRoot), { recursive: true });
142
202
  await rename(stagingRoot, plan.destinationRoot);
@@ -146,18 +206,100 @@ export async function exportSessionBundleState(input) {
146
206
  await rm(stagingRoot, { recursive: true, force: true }).catch(() => { });
147
207
  throw error;
148
208
  }
149
- }));
209
+ }), input.lease ? { lease: input.lease } : {});
150
210
  }
151
- async function exportFilteredDatabase(stateRoot, destinationPath, sessionId) {
152
- const lease = acquireOperationalStateDatabase(stateRoot);
211
+ /**
212
+ * Take the private copy the whole export is derived from.
213
+ *
214
+ * `require_current` because an export must not migrate what it reads: opening
215
+ * the live database the ordinary way upgrades it in place, which turns a
216
+ * read-only operation into a write to someone else's workspace and leaves the
217
+ * manifest describing a version the source no longer has.
218
+ */
219
+ /**
220
+ * Copy one artifact without leaving the state root.
221
+ *
222
+ * Checking the final component is not enough: `artifacts/<sessionId>` can
223
+ * itself be a symlink, and `copyFile` follows ancestors — an artifact record
224
+ * that decodes perfectly can then pull in a file from outside the workspace.
225
+ * Every segment is checked, the final open refuses to follow a link, and the
226
+ * bytes are read from that descriptor rather than from the name.
227
+ *
228
+ * Node has no `openat`, so a segment swapped between its check and the open is
229
+ * not closed here. That window is narrowed, not eliminated; closing it needs a
230
+ * directory-relative open this runtime does not expose.
231
+ */
232
+ async function copyArtifactFile(stateRoot, relativePath, destination) {
233
+ const segments = relativePath.split('/').filter((segment) => segment.length > 0);
234
+ let walked = stateRoot;
235
+ for (const segment of segments) {
236
+ if (segment === '.' || segment === '..') {
237
+ throw new SessionBundleExportError('path_escape', `Artifact path segment is not safe`);
238
+ }
239
+ walked = resolveInside(walked, segment);
240
+ const metadata = await lstat(walked).catch((error) => {
241
+ if (error.code === 'ENOENT')
242
+ return undefined;
243
+ throw error;
244
+ });
245
+ if (!metadata) {
246
+ throw new SessionBundleExportError('missing_entry', `Missing ${relativePath}`);
247
+ }
248
+ if (metadata.isSymbolicLink()) {
249
+ throw new SessionBundleExportError('symlink', `Artifact path crosses a symlink at ${segment}`);
250
+ }
251
+ }
252
+ const handle = await open(walked, constants.O_RDONLY | constants.O_NOFOLLOW);
253
+ try {
254
+ const stats = await handle.stat();
255
+ if (!stats.isFile()) {
256
+ throw new SessionBundleExportError('unsupported_entry', `${relativePath} is not a regular file`);
257
+ }
258
+ await writeFile(destination, handle.createReadStream());
259
+ }
260
+ finally {
261
+ await handle.close().catch(() => { });
262
+ }
263
+ }
264
+ async function backupOperationalState(stateRoot, destinationPath) {
265
+ // A directory with no state database is not a workspace, which is a different
266
+ // mistake from a workspace whose schema this build cannot read.
267
+ await assertRegularFile(resolveInside(stateRoot, OPERATIONAL_STATE_DATABASE_NAME), OPERATIONAL_STATE_DATABASE_NAME);
268
+ let lease;
269
+ try {
270
+ lease = acquireOperationalStateDatabase(stateRoot, { schemaMigration: 'require_current' });
271
+ }
272
+ catch (error) {
273
+ // Only a blocked migration means "this build cannot read that schema".
274
+ // A permission, busy or I/O failure is the environment talking, and the
275
+ // operational store preserves it deliberately -- flattening those into a
276
+ // schema verdict tells the caller to upgrade when the real answer is that
277
+ // the file could not be opened.
278
+ if (error instanceof OperationalStateMigrationBlockedError) {
279
+ throw new SessionBundleExportError('schema_unsupported', 'Session bundle source is not at the current schema', { cause: error });
280
+ }
281
+ throw error;
282
+ }
153
283
  try {
154
284
  await lease.backup(destinationPath);
155
285
  }
156
286
  finally {
157
287
  lease.close();
158
288
  }
289
+ }
290
+ async function filterBackedUpDatabase(destinationPath, sessionIds, options) {
159
291
  const database = new DatabaseSync(destinationPath);
160
292
  try {
293
+ // Quiescence is asserted here, on the copy, not on the live database.
294
+ // `lease.backup()` is what freezes the content; a check made before it
295
+ // describes a state the bundle may no longer carry, and the artifact and
296
+ // context locks held around this do not keep a turn from starting. This is
297
+ // the only place where "what was checked" and "what ships" are the same
298
+ // bytes.
299
+ if (options.requireQuiescent) {
300
+ for (const sessionId of sessionIds)
301
+ assertSessionQuiescent(database, sessionId);
302
+ }
161
303
  database.exec('PRAGMA foreign_keys = OFF; BEGIN IMMEDIATE');
162
304
  const tables = database
163
305
  .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")
@@ -171,19 +313,47 @@ async function exportFilteredDatabase(stateRoot, destinationPath, sessionId) {
171
313
  const names = new Set(columns
172
314
  .map((column) => column.name)
173
315
  .filter((name) => typeof name === 'string'));
174
- const sessionColumns = ['session_id', 'source_session_id', 'target_session_id'].filter((name) => names.has(name));
316
+ const sessionColumns = names.has(SESSION_ROW_OWNER_COLUMN)
317
+ ? [SESSION_ROW_OWNER_COLUMN]
318
+ : SESSION_LINK_COLUMNS.filter((name) => names.has(name));
175
319
  if (sessionColumns.length > 0) {
320
+ // Delete what the subtree does not own, keeping the original
321
+ // predicate's shape: a row survives only when EVERY session column it
322
+ // has names an exported Session. A link table row with one end outside
323
+ // the bundle would otherwise arrive pointing at a Session that is not
324
+ // there -- the reference is what makes it a link.
325
+ const placeholders = sessionIds.map(() => '?').join(', ');
176
326
  const predicate = sessionColumns
177
- .map((name) => `${quoteIdentifier(name)} <> ?`)
327
+ .map((name) => name === SESSION_ROW_OWNER_COLUMN
328
+ ? // An owner that is NULL owns nothing. Such a row cannot be
329
+ // attributed to any Session, so it is not this bundle's to
330
+ // carry -- keeping it shipped an unattributed usage row.
331
+ `(${quoteIdentifier(name)} IS NULL OR ${quoteIdentifier(name)} NOT IN (${placeholders}))`
332
+ : // A link endpoint that is NULL names no counterpart, which is
333
+ // not the same as naming one outside the bundle.
334
+ `(${quoteIdentifier(name)} IS NOT NULL AND ${quoteIdentifier(name)} NOT IN (${placeholders}))`)
178
335
  .join(' OR ');
179
336
  database
180
337
  .prepare(`DELETE FROM ${quoteIdentifier(row.name)} WHERE ${predicate}`)
181
- .run(...sessionColumns.map(() => sessionId));
338
+ .run(...sessionColumns.flatMap(() => sessionIds));
182
339
  }
183
340
  else if (!PORTABLE_DERIVED_TABLES.has(row.name)) {
184
341
  database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`);
185
342
  }
186
343
  }
344
+ // Operational rows that describe a REQUEST rather than the conversation.
345
+ // None of them reach the model, and in a real Session they are the large
346
+ // majority of this table. The two record kinds that do decide what the
347
+ // model reads -- history_compact_checkpoint_recorded and
348
+ // model_projection_transition_recorded -- are deliberately not here.
349
+ if (options.omitDiagnostics) {
350
+ database
351
+ .prepare(`
352
+ DELETE FROM core_agent_run_events
353
+ WHERE event_type IN (${SESSION_BUNDLE_OMITTED_EVENT_TYPES.map(() => '?').join(', ')})
354
+ `)
355
+ .run(...SESSION_BUNDLE_OMITTED_EVENT_TYPES);
356
+ }
187
357
  database
188
358
  .prepare(`
189
359
  DELETE FROM tool_journal_events
@@ -224,11 +394,17 @@ async function exportFilteredDatabase(stateRoot, destinationPath, sessionId) {
224
394
  const foreignKeyViolation = database.prepare('PRAGMA foreign_key_check').get();
225
395
  if (foreignKeyViolation)
226
396
  throw new Error('Filtered session database has dangling references');
227
- const session = database
228
- .prepare('SELECT 1 AS present FROM session_metadata WHERE session_id = ?')
229
- .get(sessionId);
230
- if (!session)
231
- throw new Error(`Filtered session is missing: ${sessionId}`);
397
+ // DELETE frees pages, it does not erase them. Without this the bundle ships
398
+ // a file whose freelist still holds the excluded Sessions' bytes -- readable
399
+ // by anyone who opens it with something other than SQL.
400
+ database.exec('VACUUM');
401
+ for (const sessionId of sessionIds) {
402
+ const session = database
403
+ .prepare('SELECT 1 AS present FROM session_metadata WHERE session_id = ?')
404
+ .get(sessionId);
405
+ if (!session)
406
+ throw new Error(`Filtered session is missing: ${sessionId}`);
407
+ }
232
408
  database.exec('PRAGMA journal_mode = DELETE');
233
409
  }
234
410
  catch (error) {
@@ -242,6 +418,67 @@ async function exportFilteredDatabase(stateRoot, destinationPath, sessionId) {
242
418
  database.close();
243
419
  }
244
420
  }
421
+ /**
422
+ * `core_agent_run_events` types the bundle drops.
423
+ *
424
+ * They record how a request was shaped and how a stream behaved -- diagnostics
425
+ * for the machine that produced them, not the conversation. Any type absent
426
+ * from this list is carried, including one a later build introduces: an export
427
+ * moves rows, it does not interpret them.
428
+ */
429
+ export const SESSION_BUNDLE_OMITTED_EVENT_TYPES = [
430
+ 'provider_request_attempt_recorded',
431
+ 'provider_request_captured',
432
+ 'model_call_attempt_recorded',
433
+ 'model_stream_started',
434
+ 'model_stream_completed',
435
+ 'model_stream_failed',
436
+ 'send_diagnostics_recorded',
437
+ 'plan_context_resolved',
438
+ 'skill_catalog_built',
439
+ 'skill_searched',
440
+ 'skill_loaded',
441
+ 'skill_load_failed',
442
+ 'tool_searched',
443
+ 'request_composition_resolved',
444
+ 'trace_write_failed',
445
+ ];
446
+ /**
447
+ * Ownership, which is not the same thing as naming a Session.
448
+ *
449
+ * `session_id` says whose row this is. Everything else in this list is a
450
+ * Session column only on tables that have no `session_id` -- link tables, whose
451
+ * whole content is the pair they join, and which are meaningless when one end
452
+ * is outside the bundle.
453
+ *
454
+ * Keeping the two apart matters. `session_metadata.parent_session_id` is a
455
+ * lineage POINTER, not ownership: treating it as ownership deleted the very
456
+ * Session being exported whenever its branch source lay outside the subtree.
457
+ * And a link table whose columns are spelled `parent_session_id` /
458
+ * `child_session_id` -- `subagent_spawns`, the record of which tool call
459
+ * spawned each child -- looked Session-less and was emptied wholesale.
460
+ *
461
+ * Nullable columns count only when set, so a row that names no counterpart is
462
+ * not deleted for failing to name one.
463
+ */
464
+ const SESSION_ROW_OWNER_COLUMN = 'session_id';
465
+ const SESSION_LINK_COLUMNS = [
466
+ 'source_session_id',
467
+ 'target_session_id',
468
+ 'parent_session_id',
469
+ 'child_session_id',
470
+ 'root_session_id',
471
+ ];
472
+ /**
473
+ * Tables the target writes for itself.
474
+ *
475
+ * `session_metadata` carries triggers that maintain the catalog projection, so
476
+ * inserting the bundle's copy of it and then the Session row makes the trigger
477
+ * collide with what was just inserted. The projection is derived; letting the
478
+ * target derive it is both simpler and the only way it stays correct when the
479
+ * derivation changes.
480
+ */
481
+ const TRIGGER_MAINTAINED_TABLES = new Set(['session_catalog_projection']);
245
482
  const PORTABLE_GLOBAL_TABLES = new Set([
246
483
  'operational_schema_migrations',
247
484
  'session_metadata_schema',
@@ -254,6 +491,109 @@ const PORTABLE_DERIVED_TABLES = new Set([
254
491
  'runtime_partial_segments',
255
492
  'core_interaction_outcomes',
256
493
  ]);
494
+ /**
495
+ * The Session and its subagent descendants, root first, siblings by id.
496
+ *
497
+ * `subagent_parent_session_id` is an ordinary column, not a constrained tree:
498
+ * nothing stops a row naming itself or an ancestor. Membership is tracked
499
+ * rather than assumed, so a cycle ends the walk instead of hanging it, and a
500
+ * Session reachable twice is exported once.
501
+ */
502
+ /**
503
+ * Refuse a source whose schema this build does not read.
504
+ *
505
+ * The filter runs `DELETE` over whatever tables the database happens to have,
506
+ * so a schema this build cannot read produces a bundle whose shape will not
507
+ * match what its manifest claims. The operational store is the authority on
508
+ * what "current" means -- a private list here went stale the moment a scope was
509
+ * added, and reported versions the source did not have.
510
+ */
511
+ function assertPortableSourceSchema(database) {
512
+ // The inspector validates every scope and says whether a migration is owed.
513
+ // It reports only some of them, so the manifest's numbers come from the
514
+ // registry the database keeps -- validated by the authority, reported from
515
+ // the source, and neither of them this build's constants.
516
+ const inspection = inspectOperationalStateSchema(database);
517
+ if (inspection.status !== 'current') {
518
+ throw new SessionBundleExportError('schema_unsupported', 'Session bundle source schema is not current');
519
+ }
520
+ const registered = {};
521
+ for (const row of database
522
+ .prepare('SELECT scope, version FROM operational_schema_migrations ORDER BY scope')
523
+ .all()) {
524
+ if (typeof row.scope === 'string' && typeof row.version === 'number') {
525
+ registered[row.scope] = row.version;
526
+ }
527
+ }
528
+ return registered;
529
+ }
530
+ /**
531
+ * Refuse a Session that is mid-turn.
532
+ *
533
+ * The writer locks around this export keep other writers out from here on, but
534
+ * they say nothing about work that was already in flight when it started. A
535
+ * partial stream snapshot, a tool dispatched without a settled result, or an
536
+ * invocation that never reached a terminal event each mean the bundle would
537
+ * carry half of something -- and half a turn is not a Session.
538
+ */
539
+ function assertSessionQuiescent(database, sessionId) {
540
+ const partials = database
541
+ .prepare('SELECT COUNT(*) AS count FROM runtime_partial_snapshots WHERE session_id = ?')
542
+ .get(sessionId);
543
+ if (Number(partials.count ?? 0) > 0) {
544
+ throw new SessionBundleExportError('session_active', `Session has a partial stream snapshot: ${sessionId}`);
545
+ }
546
+ // A tool that crossed the dispatch boundary and never settled. Its
547
+ // invocation can carry a terminal event -- the run failed -- while the
548
+ // operation itself is still prepared, so an invocation check does not see it.
549
+ // Same predicate the runtime store uses, so "unsettled" means one thing.
550
+ const unsettledOperations = database
551
+ .prepare(`
552
+ SELECT COUNT(*) AS count FROM tool_operations
553
+ WHERE current_state = 'prepared'
554
+ AND result_event_id IS NULL
555
+ AND dispatch_event_id IS NOT NULL
556
+ AND call_event_id IN (SELECT event_id FROM runtime_events WHERE session_id = ?)
557
+ `)
558
+ .get(sessionId);
559
+ if (Number(unsettledOperations.count ?? 0) > 0) {
560
+ throw new SessionBundleExportError('session_active', `Session has an unsettled tool operation: ${sessionId}`);
561
+ }
562
+ const openInvocations = database
563
+ .prepare(`
564
+ SELECT COUNT(*) AS count FROM (
565
+ SELECT DISTINCT invocation_id FROM runtime_events AS invocations
566
+ WHERE session_id = ?
567
+ AND NOT EXISTS (
568
+ SELECT 1 FROM runtime_events AS terminal
569
+ WHERE terminal.invocation_id = invocations.invocation_id
570
+ AND ${TERMINAL_RUNTIME_EVENT_SQL}
571
+ )
572
+ )
573
+ `)
574
+ .get(sessionId);
575
+ if (Number(openInvocations.count ?? 0) > 0) {
576
+ throw new SessionBundleExportError('session_active', `Session has an invocation with no terminal event: ${sessionId}`);
577
+ }
578
+ }
579
+ function collectSubagentSessionTree(database, rootSessionId) {
580
+ const ordered = [];
581
+ const seen = new Set([rootSessionId]);
582
+ const queue = [rootSessionId];
583
+ const children = database.prepare('SELECT session_id FROM session_metadata WHERE subagent_parent_session_id = ? ORDER BY session_id');
584
+ while (queue.length > 0) {
585
+ const sessionId = queue.shift();
586
+ ordered.push(sessionId);
587
+ for (const row of children.all(sessionId)) {
588
+ const childId = row.session_id;
589
+ if (typeof childId !== 'string' || seen.has(childId))
590
+ continue;
591
+ seen.add(childId);
592
+ queue.push(childId);
593
+ }
594
+ }
595
+ return ordered;
596
+ }
257
597
  export function isArtifactPathForSession(relativePath, sessionId) {
258
598
  const parts = relativePath.split(/[\\/]+/);
259
599
  return (parts.length >= 2 &&
@@ -333,3 +673,538 @@ function assertSafeSessionId(sessionId) {
333
673
  function quoteIdentifier(value) {
334
674
  return `"${value.replaceAll('"', '""')}"`;
335
675
  }
676
+ export class SessionBundleImportError extends Error {
677
+ code;
678
+ constructor(code, message, options) {
679
+ super(message, options);
680
+ this.code = code;
681
+ this.name = 'SessionBundleImportError';
682
+ }
683
+ }
684
+ /**
685
+ * Merge a hydrated bundle into a workspace.
686
+ *
687
+ * The mirror of the export, and the asymmetry is the whole design: the export
688
+ * owns a private copy and can DELETE what is not the subtree, while the import
689
+ * writes into a live workspace holding other people's Sessions and can only
690
+ * ADD. What keeps that from needing a table list is that the bundle's database
691
+ * already contains nothing else -- so this copies every table it has, and a
692
+ * table added to the schema later travels in both directions without anyone
693
+ * updating a list.
694
+ *
695
+ * Write order is the safety argument. Artifact bytes land first and the
696
+ * database transaction commits last, so a failure between them leaves files
697
+ * nothing points at -- reclaimable -- rather than rows pointing at files that
698
+ * are not there.
699
+ */
700
+ export async function importSessionBundleState(input) {
701
+ const bundleStateRoot = await canonicalRoot(input.bundleStateRoot, 'bundle state');
702
+ const bundleDatabasePath = resolveInside(bundleStateRoot, OPERATIONAL_STATE_DATABASE_NAME);
703
+ await assertRegularFile(bundleDatabasePath, OPERATIONAL_STATE_DATABASE_NAME);
704
+ // The authority is decided by what is being written, not by what the target
705
+ // already has. A bundle carrying context needs it even for a fresh workspace
706
+ // with no context store yet -- which is exactly the case that would otherwise
707
+ // run unprotected, and the common one.
708
+ const bundleCarriesContext = await pathExists(resolveInside(bundleStateRoot, CONTEXT_OFFLOAD_DATABASE_NAME));
709
+ return withOfflineContextSnapshot(input.stateRoot, (contextLocked) => withBundleArtifactWriterLock(input.stateRoot, input.lease, async (stateRoot) => {
710
+ const sessionIds = readBundleSessionIds(bundleDatabasePath);
711
+ if (sessionIds.length === 0) {
712
+ throw new SessionBundleImportError('invalid_root', 'Bundle carries no Session');
713
+ }
714
+ // The export refuses to migrate its source because it only reads. An
715
+ // import is a write the user asked for, and the target is often a
716
+ // workspace with no database yet -- moving to a new machine is the whole
717
+ // point -- so this opens the ordinary way and lets it be initialised.
718
+ let lease;
719
+ try {
720
+ lease = acquireOperationalStateDatabase(stateRoot);
721
+ }
722
+ catch (error) {
723
+ // A target this build cannot open is a schema verdict, not an IO one;
724
+ // everything else the operational store raises is the environment.
725
+ if (error instanceof OperationalStateMigrationBlockedError) {
726
+ throw new SessionBundleImportError('schema_unsupported', 'Workspace schema cannot be opened by this build', { cause: error });
727
+ }
728
+ throw error;
729
+ }
730
+ try {
731
+ assertBundleSchemaMatches(lease.database, bundleDatabasePath);
732
+ assertImportableInto(lease.database, sessionIds);
733
+ // Everything the Session will reference lands first; the Session rows
734
+ // are the last thing written. A failure before that leaves artifacts
735
+ // and context nothing points at, which the store reclaims, rather than
736
+ // a Session already visible whose bytes never arrived -- and which a
737
+ // retry could not fix, because the ids are now taken.
738
+ const artifacts = await copyBundleArtifacts(bundleStateRoot, stateRoot);
739
+ try {
740
+ const contextRefs = await mergeBundleContext(bundleStateRoot, stateRoot, contextLocked, sessionIds);
741
+ const inserted = mergeBundleDatabase(lease, bundleDatabasePath);
742
+ return { sessionIds: inserted, artifactFiles: artifacts.copied, contextRefs };
743
+ }
744
+ catch (error) {
745
+ // Take back only what this attempt created. Anything already there
746
+ // belongs to someone else, or to an earlier attempt that the next
747
+ // one will recognise.
748
+ for (const path of artifacts.created)
749
+ await rm(path, { force: true }).catch(() => { });
750
+ throw error;
751
+ }
752
+ }
753
+ finally {
754
+ lease.close();
755
+ }
756
+ }), {
757
+ requireAuthority: bundleCarriesContext,
758
+ ...(input.lease ? { lease: input.lease } : {}),
759
+ });
760
+ }
761
+ /**
762
+ * Refuse a bundle written against a different schema.
763
+ *
764
+ * The merge copies rows with `INSERT ... SELECT *`, which maps by position. A
765
+ * bundle whose tables have a different column ORDER but the same count would
766
+ * be inserted silently transposed -- rows that read as data and are not. The
767
+ * export only ever writes a bundle at its own current schema, so any mismatch
768
+ * here means the two builds disagree, and the honest answer is to say so
769
+ * rather than to guess a mapping.
770
+ */
771
+ function assertBundleSchemaMatches(target, bundleDatabasePath) {
772
+ const bundle = new DatabaseSync(bundleDatabasePath, { readOnly: true });
773
+ try {
774
+ const bundleVersions = readSchemaRegistry(bundle);
775
+ const targetVersions = readSchemaRegistry(target);
776
+ for (const [scope, version] of Object.entries(bundleVersions)) {
777
+ if (targetVersions[scope] !== version) {
778
+ throw new SessionBundleImportError('schema_unsupported', `Bundle schema ${scope} is ${version}; this workspace is ${targetVersions[scope] ?? 'absent'}`);
779
+ }
780
+ }
781
+ const bundleUserVersion = readUserVersionPragma(bundle);
782
+ const targetUserVersion = readUserVersionPragma(target);
783
+ if (bundleUserVersion !== targetUserVersion) {
784
+ throw new SessionBundleImportError('schema_unsupported', `Bundle runtime schema is ${bundleUserVersion}; this workspace is ${targetUserVersion}`);
785
+ }
786
+ }
787
+ finally {
788
+ bundle.close();
789
+ }
790
+ }
791
+ function readSchemaRegistry(database) {
792
+ const versions = {};
793
+ for (const row of database
794
+ .prepare('SELECT scope, version FROM operational_schema_migrations')
795
+ .all()) {
796
+ if (typeof row.scope === 'string' && typeof row.version === 'number') {
797
+ versions[row.scope] = row.version;
798
+ }
799
+ }
800
+ return versions;
801
+ }
802
+ function readUserVersionPragma(database) {
803
+ const row = (database.prepare('PRAGMA user_version').get() ?? {});
804
+ return Number(Object.values(row)[0] ?? -1);
805
+ }
806
+ /** The same question the export asks before deleting a table wholesale. */
807
+ function describesASession(target, table) {
808
+ if (PORTABLE_DERIVED_TABLES.has(table))
809
+ return true;
810
+ const columns = new Set(target.prepare(`PRAGMA bundle.table_info(${quoteIdentifier(table)})`).all()
811
+ .map((column) => column.name)
812
+ .filter((name) => typeof name === 'string'));
813
+ if (columns.has(SESSION_ROW_OWNER_COLUMN))
814
+ return true;
815
+ return SESSION_LINK_COLUMNS.some((column) => columns.has(column));
816
+ }
817
+ function readBundleSessionIds(bundleDatabasePath) {
818
+ const database = new DatabaseSync(bundleDatabasePath, { readOnly: true });
819
+ try {
820
+ return database
821
+ .prepare('SELECT session_id FROM session_metadata ORDER BY session_id')
822
+ .all().map((row) => String(row.session_id));
823
+ }
824
+ finally {
825
+ database.close();
826
+ }
827
+ }
828
+ /**
829
+ * Refuse before writing anything.
830
+ *
831
+ * Session ids are generated, not chosen, so one already present means this
832
+ * Session is already here -- not that two of them collided. Importing over it
833
+ * would merge two histories that share ids and agree about nothing else.
834
+ */
835
+ function assertImportableInto(target, sessionIds) {
836
+ const existing = target.prepare('SELECT 1 FROM session_metadata WHERE session_id = ?');
837
+ const present = sessionIds.filter((sessionId) => existing.get(sessionId) !== undefined);
838
+ if (present.length > 0) {
839
+ throw new SessionBundleImportError('session_exists', `Session already present in this workspace: ${present.join(', ')}`);
840
+ }
841
+ }
842
+ /**
843
+ * Stage the bundle's artifact bytes, and be able to take them back.
844
+ *
845
+ * Publishing the Session last stops a broken Session from becoming visible, but
846
+ * it does not by itself make the import retryable: bytes written before a later
847
+ * failure stay behind, and a second attempt then trips over its own leftovers.
848
+ *
849
+ * Two rules make a retry work without overwriting anything. A destination that
850
+ * is byte-identical to what the bundle carries is this import's own leftover,
851
+ * or the same content by another route, and is accepted. A destination holding
852
+ * something else is a real conflict. Files this attempt actually created are
853
+ * remembered, so a failure can remove exactly those and nothing else.
854
+ */
855
+ async function copyBundleArtifacts(bundleStateRoot, stateRoot) {
856
+ const source = resolveInside(bundleStateRoot, 'artifacts');
857
+ const created = [];
858
+ if (!(await pathExists(source)))
859
+ return { copied: 0, created };
860
+ let copied = 0;
861
+ const walk = async (relative) => {
862
+ const absolute = relative ? resolveInside(source, relative) : source;
863
+ for (const entry of await readdir(absolute, { withFileTypes: true })) {
864
+ const next = relative ? `${relative}/${entry.name}` : entry.name;
865
+ if (entry.isDirectory()) {
866
+ await walk(next);
867
+ continue;
868
+ }
869
+ if (!entry.isFile()) {
870
+ throw new SessionBundleImportError('io_failed', `Bundle artifact is not a regular file: ${next}`);
871
+ }
872
+ const from = resolveInside(source, next);
873
+ const destination = resolveInside(resolveInside(stateRoot, 'artifacts'), next);
874
+ await mkdir(dirname(destination), { recursive: true });
875
+ try {
876
+ await copyFile(from, destination, constants.COPYFILE_EXCL);
877
+ created.push(destination);
878
+ }
879
+ catch (error) {
880
+ if (error.code !== 'EEXIST')
881
+ throw error;
882
+ if (!(await sameFileContent(from, destination))) {
883
+ throw new SessionBundleImportError('conflict', `Artifact already present: ${next}`);
884
+ }
885
+ }
886
+ copied += 1;
887
+ }
888
+ };
889
+ await walk('');
890
+ return { copied, created };
891
+ }
892
+ async function sameFileContent(left, right) {
893
+ const [a, b] = await Promise.all([readFile(left), readFile(right)]);
894
+ return a.equals(b);
895
+ }
896
+ /**
897
+ * Copy every table the bundle has, in one transaction.
898
+ *
899
+ * No allow-list: the bundle's database was already filtered down to its own
900
+ * Sessions, so "everything it has" is exactly what belongs. Only the tables
901
+ * describing the WORKSPACE rather than a Session are skipped -- the target has
902
+ * its own, and they are not the bundle's to bring.
903
+ */
904
+ function mergeBundleDatabase(lease, bundleDatabasePath) {
905
+ const target = lease.database;
906
+ // Read-only, so a bundle is never written by the act of reading it -- and so
907
+ // a hydrated staging tree cannot pick up a journal beside it.
908
+ const uri = `file:${encodeURI(bundleDatabasePath)}?mode=ro`;
909
+ target.exec(`ATTACH DATABASE '${uri.replaceAll("'", "''")}' AS bundle`);
910
+ try {
911
+ // The lease owns a shared, reference-counted connection with its own
912
+ // transaction depth. Driving BEGIN/COMMIT directly would step around that,
913
+ // and the foreign-key pragma it needs must be put back: leaving it off
914
+ // would silently disarm constraint checking for every later user of this
915
+ // connection.
916
+ const restoreForeignKeys = Number(Object.values((target.prepare('PRAGMA foreign_keys').get() ?? {}))[0] ?? 0) === 1;
917
+ target.exec('PRAGMA foreign_keys = OFF');
918
+ try {
919
+ return lease.transaction('write', () => mergeAttachedBundle(target));
920
+ }
921
+ finally {
922
+ if (restoreForeignKeys)
923
+ target.exec('PRAGMA foreign_keys = ON');
924
+ }
925
+ }
926
+ finally {
927
+ target.exec('DETACH DATABASE bundle');
928
+ }
929
+ }
930
+ function mergeAttachedBundle(target) {
931
+ {
932
+ {
933
+ const tables = target
934
+ .prepare("SELECT name FROM bundle.sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
935
+ .all();
936
+ for (const row of tables) {
937
+ const name = row.name;
938
+ if (typeof name !== 'string' ||
939
+ PORTABLE_GLOBAL_TABLES.has(name) ||
940
+ TRIGGER_MAINTAINED_TABLES.has(name)) {
941
+ continue;
942
+ }
943
+ // Mirror the export's own classification instead of trusting that it
944
+ // ran: a table with no Session column and no referential rule describes
945
+ // the WORKSPACE, and the target has its own. The export empties those,
946
+ // so in practice this inserts nothing -- but an import that depends on
947
+ // the other side having tidied up is one bundle away from writing a
948
+ // workspace singleton into somebody else's workspace.
949
+ if (!describesASession(target, name))
950
+ continue;
951
+ const quoted = quoteIdentifier(name);
952
+ target.exec(`INSERT INTO main.${quoted} SELECT * FROM bundle.${quoted}`);
953
+ }
954
+ const violation = target.prepare('PRAGMA foreign_key_check').get();
955
+ if (violation) {
956
+ throw new SessionBundleImportError('conflict', 'Imported Sessions would leave dangling references');
957
+ }
958
+ return target
959
+ .prepare('SELECT session_id FROM bundle.session_metadata ORDER BY session_id')
960
+ .all().map((entry) => String(entry.session_id));
961
+ }
962
+ }
963
+ }
964
+ async function pathExists(path) {
965
+ return lstat(path)
966
+ .then(() => true)
967
+ .catch(() => false);
968
+ }
969
+ /**
970
+ * Merge the bundle's offloaded context into the workspace.
971
+ *
972
+ * Without this an imported Session arrives with its read-image references
973
+ * intact and none of the bytes behind them, which is the same hole the export
974
+ * had before it learned to carry the closure.
975
+ *
976
+ * Blobs are content-addressed, so an id already present is the same bytes and
977
+ * the insert is skipped rather than treated as a conflict.
978
+ */
979
+ async function mergeBundleContext(bundleStateRoot, stateRoot, contextLocked, sessionIds) {
980
+ const bundleContext = resolveInside(bundleStateRoot, CONTEXT_OFFLOAD_DATABASE_NAME);
981
+ if (!(await pathExists(bundleContext)))
982
+ return 0;
983
+ if (!contextLocked) {
984
+ throw new SessionBundleImportError('io_failed', 'Context import requires an offline Storage Root; stop the Runtime Host first');
985
+ }
986
+ // An archive digest authenticates the archive, not the state inside it: it
987
+ // says the bytes arrived as sent, and nothing about whether a row claiming a
988
+ // hash names a file that actually hashes to it. Validated here, against the
989
+ // hydrated copy, before anything is written to the target -- a payload that
990
+ // fails is a bundle nobody can use, and importing it publishes a reference to
991
+ // content that cannot be read back.
992
+ await validateContextSnapshot(bundleStateRoot);
993
+ // Everything below is one turn in the Storage Root's context mutation queue,
994
+ // shared with the Context Store's own publication and collection. Those
995
+ // operations read database state, await, and only then act on files --
996
+ // collection decides a payload is unreferenced, awaits, unlinks it -- so an
997
+ // import that commits a reference inside that await leaves the reference
998
+ // pointing at a file that is about to disappear. The re-check collection does
999
+ // cannot see it, because the check and the unlink straddle the await.
1000
+ return runWithContextValueMutation(stateRoot, async () => {
1001
+ // Managed payloads live at `sha256/<prefix>/<hash>`, so a copy that visited
1002
+ // only immediate children saw one directory, skipped it, and reported a
1003
+ // successful import whose referenced bytes were all absent.
1004
+ const destination = resolveInside(stateRoot, CONTEXT_OFFLOAD_VALUES_DIRECTORY_NAME);
1005
+ await mkdir(destination, { recursive: true, mode: 0o700 });
1006
+ await copyContextValueTree(resolveInside(bundleStateRoot, CONTEXT_OFFLOAD_VALUES_DIRECTORY_NAME), destination, stateRoot);
1007
+ return mergeBundleContextDatabase(bundleContext, stateRoot, sessionIds);
1008
+ });
1009
+ }
1010
+ async function mergeBundleContextDatabase(bundleContext, stateRoot, sessionIds) {
1011
+ const targetContext = resolveInside(stateRoot, CONTEXT_OFFLOAD_DATABASE_NAME);
1012
+ if (!(await pathExists(targetContext))) {
1013
+ await copyFile(bundleContext, targetContext);
1014
+ return countContextRefs(targetContext, sessionIds);
1015
+ }
1016
+ const database = new DatabaseSync(targetContext);
1017
+ try {
1018
+ database.exec(`ATTACH DATABASE '${bundleContext.replaceAll("'", "''")}' AS bundle`);
1019
+ try {
1020
+ database.exec('BEGIN IMMEDIATE');
1021
+ try {
1022
+ database.exec('INSERT OR IGNORE INTO main.context_blobs SELECT * FROM bundle.context_blobs');
1023
+ // A retry re-inserts the same rows. Skipping an identical one is what
1024
+ // makes the second attempt work; skipping a DIFFERENT row that happens
1025
+ // to share an id would hide a real collision, so the two are separated.
1026
+ const colliding = database
1027
+ .prepare(`
1028
+ SELECT b.ref_id FROM bundle.context_refs b
1029
+ JOIN main.context_refs m USING(ref_id)
1030
+ WHERE m.session_id <> b.session_id OR m.blob_id <> b.blob_id
1031
+ `)
1032
+ .all();
1033
+ if (colliding.length > 0) {
1034
+ throw new SessionBundleImportError('conflict', `Context reference already names different content: ${String(colliding[0]?.ref_id)}`);
1035
+ }
1036
+ database.exec('INSERT OR IGNORE INTO main.context_refs SELECT * FROM bundle.context_refs');
1037
+ // A blob the target already held may have been queued for collection
1038
+ // while nothing referenced it. It is referenced again now, and leaving
1039
+ // the candidate behind makes the next collection fail outright --
1040
+ // `Context garbage candidate is still referenced or missing` -- and
1041
+ // keep failing. Ordinary insertion in the Store clears it the same way.
1042
+ database.exec(`
1043
+ DELETE FROM main.context_gc_candidates
1044
+ WHERE blob_id IN (SELECT blob_id FROM main.context_refs)
1045
+ `);
1046
+ // The context store maintains its usage tables explicitly -- no trigger
1047
+ // does it. Inserting blobs and refs without them leaves quotas and the
1048
+ // cleanup consistency checks reading numbers that describe a store that
1049
+ // no longer exists. Recomputed from what is actually there, which is
1050
+ // the same thing the export does when it filters.
1051
+ database.exec(`
1052
+ DELETE FROM context_session_usage;
1053
+ INSERT INTO context_session_usage
1054
+ SELECT r.session_id, count(*), sum(b.size_bytes)
1055
+ FROM context_refs r JOIN context_blobs b USING(blob_id) GROUP BY r.session_id;
1056
+ UPDATE context_store_usage SET
1057
+ blob_count = (SELECT count(*) FROM context_blobs),
1058
+ -- Bytes queued for deletion are still on disk and still charged:
1059
+ -- the store drops a blob row before draining its file and subtracts
1060
+ -- them when the drain completes. Recomputing from live blobs alone
1061
+ -- makes that later subtraction underflow. The export can use the
1062
+ -- simpler sum because it empties the queue on its private copy;
1063
+ -- a live target keeps it.
1064
+ physical_bytes =
1065
+ (SELECT coalesce(sum(size_bytes), 0) FROM context_blobs) +
1066
+ (SELECT coalesce(sum(size_bytes), 0) FROM context_file_deletions)
1067
+ WHERE singleton = 1;
1068
+ `);
1069
+ database.exec('COMMIT');
1070
+ }
1071
+ catch (error) {
1072
+ try {
1073
+ database.exec('ROLLBACK');
1074
+ }
1075
+ catch { }
1076
+ throw error;
1077
+ }
1078
+ }
1079
+ finally {
1080
+ database.exec('DETACH DATABASE bundle');
1081
+ }
1082
+ }
1083
+ finally {
1084
+ database.close();
1085
+ }
1086
+ return countContextRefs(targetContext, sessionIds);
1087
+ }
1088
+ /**
1089
+ * Copy a managed-payload tree, structure and all.
1090
+ *
1091
+ * Content-addressed names mean an existing file is the same file, so an
1092
+ * already-present payload is left alone rather than treated as a conflict.
1093
+ */
1094
+ async function copyContextValueTree(source, destination, stateRoot) {
1095
+ if (!(await pathExists(source)))
1096
+ return;
1097
+ await assertManagedDestinationDirectory(destination, stateRoot);
1098
+ for (const entry of await readdir(source, { withFileTypes: true })) {
1099
+ const from = resolveInside(source, entry.name);
1100
+ const to = resolveInside(destination, entry.name);
1101
+ if (entry.isDirectory()) {
1102
+ await mkdir(to, { recursive: true, mode: 0o700 });
1103
+ await copyContextValueTree(from, to, stateRoot);
1104
+ continue;
1105
+ }
1106
+ if (!entry.isFile()) {
1107
+ throw new SessionBundleImportError('io_failed', `Bundle context payload is not a regular file: ${entry.name}`);
1108
+ }
1109
+ await publishContextValue(from, to, stateRoot);
1110
+ }
1111
+ }
1112
+ /**
1113
+ * Refuses a destination directory that does not really live inside the Storage
1114
+ * Root.
1115
+ *
1116
+ * `resolveInside` compares strings, which says nothing about what the path
1117
+ * resolves to: a `context-offload-values` replaced by a symlink passes it and
1118
+ * then receives the payloads somewhere else entirely. The Context Store already
1119
+ * holds this invariant over its own managed directories, and a directory it
1120
+ * would refuse to publish into is not one an import may publish into either.
1121
+ */
1122
+ async function assertManagedDestinationDirectory(directory, stateRoot) {
1123
+ let entry;
1124
+ let resolved;
1125
+ try {
1126
+ [entry, resolved] = await Promise.all([lstat(directory), realpath(directory)]);
1127
+ }
1128
+ catch (error) {
1129
+ if (error.code === 'ENOTDIR') {
1130
+ throw new SessionBundleImportError('io_failed', `Context payload directory is not a directory: ${directory}`);
1131
+ }
1132
+ throw error;
1133
+ }
1134
+ const fromRoot = relative(stateRoot, resolved);
1135
+ if (!entry.isDirectory() ||
1136
+ entry.isSymbolicLink() ||
1137
+ fromRoot === '..' ||
1138
+ fromRoot.startsWith(`..${sep}`) ||
1139
+ isAbsolute(fromRoot)) {
1140
+ throw new SessionBundleImportError('io_failed', `Context payload directory escapes the Storage Root: ${directory}`);
1141
+ }
1142
+ }
1143
+ /**
1144
+ * Publishes one payload the way the Context Store publishes its own: the bytes
1145
+ * are assembled under a staging name and become visible at the final path by a
1146
+ * single `link`.
1147
+ *
1148
+ * `copyFile` fills its destination progressively, so the final path is
1149
+ * observable half-written -- measured at 38 distinct intermediate sizes while
1150
+ * copying 64 MiB. Payloads are content-addressed, so a bundle and its target
1151
+ * routinely name the same path, and that path is one another Session may be
1152
+ * reading. It also decides what a retry sees: a copy interrupted midway leaves
1153
+ * a truncated file AT the final path, and the previous `EEXIST`-is-fine rule
1154
+ * accepted it as already present.
1155
+ *
1156
+ * The staging name is the Store's with a different suffix. Two imports cannot
1157
+ * race -- both need the write authority, and it is exclusive -- but an import
1158
+ * and the Store publishing the same blob can, and they must not share a name.
1159
+ */
1160
+ async function publishContextValue(from, to, stateRoot) {
1161
+ const staging = join(dirname(to), `.${basename(to)}.import.tmp`);
1162
+ await rm(staging, { force: true });
1163
+ try {
1164
+ await copyFile(from, staging, constants.COPYFILE_EXCL);
1165
+ // 'r+' rather than 'r': Windows flushes through the handle and refuses a
1166
+ // read-only one, so a read handle would make this fail only on Windows.
1167
+ const handle = await open(staging, 'r+');
1168
+ try {
1169
+ await handle.sync();
1170
+ }
1171
+ finally {
1172
+ await handle.close();
1173
+ }
1174
+ try {
1175
+ await link(staging, to);
1176
+ // The bytes were synced; the directory entry naming them was not. A crash
1177
+ // here otherwise keeps the committed reference and loses the name, which
1178
+ // reads exactly like a payload that never arrived.
1179
+ await syncDirectoryChain(dirname(to), stateRoot);
1180
+ }
1181
+ catch (error) {
1182
+ if (error.code !== 'EEXIST')
1183
+ throw error;
1184
+ // Reaching the final path now means the payload was already there, not
1185
+ // that this import put it there. Content addressing says it should be
1186
+ // byte-identical; if it is not, the store holds something this bundle
1187
+ // cannot explain and overwriting it would destroy the other Session's
1188
+ // payload.
1189
+ if (!(await sameFileContent(from, to))) {
1190
+ throw new SessionBundleImportError('conflict', `Context payload already names different content: ${basename(to)}`);
1191
+ }
1192
+ }
1193
+ }
1194
+ finally {
1195
+ await unlink(staging).catch(() => { });
1196
+ }
1197
+ }
1198
+ function countContextRefs(databasePath, sessionIds) {
1199
+ const database = new DatabaseSync(databasePath, { readOnly: true });
1200
+ try {
1201
+ const placeholders = sessionIds.map(() => '?').join(', ');
1202
+ const row = database
1203
+ .prepare(`SELECT COUNT(*) AS count FROM context_refs WHERE session_id IN (${placeholders})`)
1204
+ .get(...sessionIds);
1205
+ return Number(row.count ?? 0);
1206
+ }
1207
+ finally {
1208
+ database.close();
1209
+ }
1210
+ }