spexcode 0.6.7 → 0.6.8
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/README.md +12 -7
- package/node_modules/@spexcode/session-application/dist/index.d.ts +17 -0
- package/node_modules/@spexcode/session-application/dist/index.js +35 -0
- package/node_modules/@spexcode/session-application/dist/migration.d.ts +51 -0
- package/node_modules/@spexcode/session-application/dist/migration.js +694 -0
- package/node_modules/@spexcode/session-application/dist/production.d.ts +109 -0
- package/node_modules/@spexcode/session-application/dist/production.js +438 -0
- package/node_modules/@spexcode/session-application/dist/schema.d.ts +2 -0
- package/node_modules/@spexcode/session-application/dist/schema.js +39 -0
- package/node_modules/@spexcode/session-application/package.json +29 -0
- package/node_modules/@spexcode/session-events/dist/errors.d.ts +6 -0
- package/node_modules/@spexcode/session-events/dist/errors.js +11 -0
- package/node_modules/@spexcode/session-events/dist/index.d.ts +46 -0
- package/node_modules/@spexcode/session-events/dist/index.js +245 -0
- package/node_modules/@spexcode/session-events/dist/schema.d.ts +3 -0
- package/node_modules/@spexcode/session-events/dist/schema.js +44 -0
- package/node_modules/@spexcode/session-events/package.json +24 -0
- package/node_modules/@spexcode/session-protocol/dist/canonical.d.ts +36 -0
- package/node_modules/@spexcode/session-protocol/dist/canonical.js +139 -0
- package/node_modules/@spexcode/session-protocol/dist/engine.d.ts +22 -0
- package/node_modules/@spexcode/session-protocol/dist/engine.js +478 -0
- package/node_modules/@spexcode/session-protocol/dist/errors.d.ts +8 -0
- package/node_modules/@spexcode/session-protocol/dist/errors.js +39 -0
- package/node_modules/@spexcode/session-protocol/dist/index.d.ts +60 -0
- package/node_modules/@spexcode/session-protocol/dist/index.js +4 -0
- package/node_modules/@spexcode/session-protocol/dist/schema.d.ts +36 -0
- package/node_modules/@spexcode/session-protocol/dist/schema.js +192 -0
- package/node_modules/@spexcode/{session-core → session-protocol}/package.json +4 -8
- package/node_modules/@spexcode/session-runtime/dist/errors.d.ts +6 -0
- package/node_modules/@spexcode/session-runtime/dist/errors.js +11 -0
- package/node_modules/@spexcode/session-runtime/dist/index.d.ts +33 -0
- package/node_modules/@spexcode/session-runtime/dist/index.js +193 -0
- package/node_modules/@spexcode/session-runtime/dist/schema.d.ts +2 -0
- package/node_modules/@spexcode/session-runtime/dist/schema.js +37 -0
- package/node_modules/@spexcode/session-runtime/package.json +24 -0
- package/node_modules/@spexcode/session-selflaunch/bin/spex-session.mjs +4 -0
- package/node_modules/@spexcode/session-selflaunch/dist/cli.d.ts +25 -0
- package/node_modules/@spexcode/session-selflaunch/dist/cli.js +156 -0
- package/node_modules/@spexcode/session-selflaunch/dist/index.d.ts +28 -0
- package/node_modules/@spexcode/session-selflaunch/dist/index.js +25 -0
- package/node_modules/@spexcode/session-selflaunch/dist/locality.d.ts +20 -0
- package/node_modules/@spexcode/session-selflaunch/dist/locality.js +83 -0
- package/node_modules/@spexcode/session-selflaunch/dist/path.d.ts +13 -0
- package/node_modules/@spexcode/session-selflaunch/dist/path.js +45 -0
- package/node_modules/@spexcode/session-selflaunch/package.json +35 -0
- package/node_modules/@spexcode/session-topology/dist/errors.d.ts +6 -0
- package/node_modules/@spexcode/session-topology/dist/errors.js +11 -0
- package/node_modules/@spexcode/session-topology/dist/index.d.ts +24 -0
- package/node_modules/@spexcode/session-topology/dist/index.js +173 -0
- package/node_modules/@spexcode/session-topology/dist/schema.d.ts +3 -0
- package/node_modules/@spexcode/session-topology/dist/schema.js +27 -0
- package/node_modules/@spexcode/session-topology/package.json +24 -0
- package/node_modules/@spexcode/spec-cli/bin/spex.mjs +56 -19
- package/node_modules/@spexcode/spec-cli/dist/cli.js +102 -59
- package/node_modules/@spexcode/spec-cli/dist/client.d.ts +1 -3
- package/node_modules/@spexcode/spec-cli/dist/client.js +49 -30
- package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.d.ts +11 -0
- package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.js +46 -9
- package/node_modules/@spexcode/spec-cli/dist/delivery-lock.d.ts +2 -0
- package/node_modules/@spexcode/spec-cli/dist/delivery-lock.js +58 -0
- package/node_modules/@spexcode/spec-cli/dist/doctor.js +53 -11
- package/node_modules/@spexcode/spec-cli/dist/execution-trace.d.ts +1 -0
- package/node_modules/@spexcode/spec-cli/dist/execution-trace.js +2 -2
- package/node_modules/@spexcode/spec-cli/dist/gateway-hub.js +2 -1
- package/node_modules/@spexcode/spec-cli/dist/gateway.js +6 -3
- package/node_modules/@spexcode/spec-cli/dist/graphCache.js +32 -2
- package/node_modules/@spexcode/spec-cli/dist/graphSnapshot.js +57 -2
- package/node_modules/@spexcode/spec-cli/dist/graphStream.d.ts +2 -0
- package/node_modules/@spexcode/spec-cli/dist/graphStream.js +83 -3
- package/node_modules/@spexcode/spec-cli/dist/guide.js +20 -7
- package/node_modules/@spexcode/spec-cli/dist/harness-select.js +16 -3
- package/node_modules/@spexcode/spec-cli/dist/harness.d.ts +15 -3
- package/node_modules/@spexcode/spec-cli/dist/harness.js +331 -50
- package/node_modules/@spexcode/spec-cli/dist/help.js +11 -8
- package/node_modules/@spexcode/spec-cli/dist/hook-prompts.js +8 -0
- package/node_modules/@spexcode/spec-cli/dist/host-resources.js +29 -8
- package/node_modules/@spexcode/spec-cli/dist/host.d.ts +7 -0
- package/node_modules/@spexcode/spec-cli/dist/host.js +93 -0
- package/node_modules/@spexcode/spec-cli/dist/index.js +324 -22
- package/node_modules/@spexcode/spec-cli/dist/init.js +1 -1
- package/node_modules/@spexcode/spec-cli/dist/lint.js +70 -35
- package/node_modules/@spexcode/spec-cli/dist/listen.d.ts +3 -2
- package/node_modules/@spexcode/spec-cli/dist/listen.js +14 -2
- package/node_modules/@spexcode/spec-cli/dist/machine-peer.js +1 -1
- package/node_modules/@spexcode/spec-cli/dist/materialize.d.ts +2 -2
- package/node_modules/@spexcode/spec-cli/dist/materialize.js +176 -35
- package/node_modules/@spexcode/spec-cli/dist/pty-bridge.js +14 -14
- package/node_modules/@spexcode/spec-cli/dist/reviews.js +12 -7
- package/node_modules/@spexcode/spec-cli/dist/runtime-ownership.d.ts +11 -0
- package/node_modules/@spexcode/spec-cli/dist/runtime-ownership.js +79 -1
- package/node_modules/@spexcode/spec-cli/dist/session-application.d.ts +23 -0
- package/node_modules/@spexcode/spec-cli/dist/session-application.js +189 -0
- package/node_modules/@spexcode/spec-cli/dist/session-declarations.js +13 -1
- package/node_modules/@spexcode/spec-cli/dist/session-files.d.ts +6 -0
- package/node_modules/@spexcode/spec-cli/dist/session-files.js +13 -1
- package/node_modules/@spexcode/spec-cli/dist/session-follow.js +39 -22
- package/node_modules/@spexcode/{session-core/dist/record-lock.d.ts → spec-cli/dist/session-record-lock.d.ts} +0 -4
- package/node_modules/@spexcode/spec-cli/dist/session-record-lock.js +94 -0
- package/node_modules/@spexcode/spec-cli/dist/session-runtime-adapter.d.ts +44 -0
- package/node_modules/@spexcode/spec-cli/dist/session-runtime-adapter.js +37 -0
- package/node_modules/@spexcode/spec-cli/dist/session-timeline.d.ts +25 -2
- package/node_modules/@spexcode/spec-cli/dist/session-timeline.js +68 -11
- package/node_modules/@spexcode/spec-cli/dist/session-web.js +4 -4
- package/node_modules/@spexcode/spec-cli/dist/sessions.d.ts +108 -15
- package/node_modules/@spexcode/spec-cli/dist/sessions.js +1465 -744
- package/node_modules/@spexcode/spec-cli/dist/source-list.d.ts +13 -0
- package/node_modules/@spexcode/spec-cli/dist/source-list.js +99 -0
- package/node_modules/@spexcode/spec-cli/dist/source-read.d.ts +16 -0
- package/node_modules/@spexcode/spec-cli/dist/source-read.js +84 -0
- package/node_modules/@spexcode/spec-cli/dist/spec-attachments.d.ts +7 -0
- package/node_modules/@spexcode/spec-cli/dist/spec-attachments.js +89 -0
- package/node_modules/@spexcode/spec-cli/dist/spec-body-edit.d.ts +23 -0
- package/node_modules/@spexcode/spec-cli/dist/spec-body-edit.js +138 -0
- package/node_modules/@spexcode/spec-cli/dist/supervise.js +15 -6
- package/node_modules/@spexcode/spec-cli/dist/transcript-reader.d.ts +36 -0
- package/node_modules/@spexcode/spec-cli/dist/transcript-reader.js +251 -0
- package/node_modules/@spexcode/spec-cli/hooks/dispatch.sh +19 -31
- package/node_modules/@spexcode/spec-cli/hooks/harness.sh +6 -6
- package/node_modules/@spexcode/spec-cli/package.json +6 -6
- package/node_modules/@spexcode/spec-cli/templates/hooks/post-checkout +4 -2
- package/node_modules/@spexcode/spec-cli/templates/hooks/post-merge +2 -1
- package/node_modules/@spexcode/spec-cli/templates/hooks/pre-commit +5 -3
- package/node_modules/@spexcode/spec-cli/templates/hooks/reference-transaction +5 -3
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/commands/spec.md +2 -7
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/idle/idle.sh +4 -10
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +22 -24
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +10 -2
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-fail/fail.sh +8 -7
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +3 -1
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-listen/session-listen.sh +133 -0
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-listen/spec.md +36 -0
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -0
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +17 -20
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/skills/merge/spec.md +33 -0
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/skills/spec.md +2 -6
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/spec.md +7 -0
- package/node_modules/@spexcode/spec-core/dist/anchors.d.ts +13 -2
- package/node_modules/@spexcode/spec-core/dist/anchors.js +311 -15
- package/node_modules/@spexcode/spec-core/dist/git.d.ts +4 -0
- package/node_modules/@spexcode/spec-core/dist/git.js +9 -9
- package/node_modules/@spexcode/spec-core/dist/layout.d.ts +12 -0
- package/node_modules/@spexcode/spec-core/dist/layout.js +4 -2
- package/node_modules/@spexcode/spec-core/dist/resilience.d.ts +1 -0
- package/node_modules/@spexcode/spec-core/dist/resilience.js +8 -0
- package/node_modules/@spexcode/spec-core/dist/review/reviewFilters.d.ts +1 -0
- package/node_modules/@spexcode/spec-core/dist/review/reviewFilters.js +7 -3
- package/node_modules/@spexcode/spec-core/dist/review/reviewQuery.d.ts +1 -0
- package/node_modules/@spexcode/spec-core/dist/review/reviewQuery.js +4 -0
- package/node_modules/@spexcode/spec-core/dist/specs.d.ts +2 -0
- package/node_modules/@spexcode/spec-core/dist/specs.js +30 -0
- package/node_modules/@spexcode/spec-core/package.json +4 -1
- package/node_modules/@spexcode/spec-eval/dist/cli.js +1 -1
- package/node_modules/@spexcode/spec-eval/dist/evaltab.d.ts +1 -0
- package/node_modules/@spexcode/spec-eval/dist/evaltab.js +6 -1
- package/node_modules/@spexcode/spec-eval/dist/freshness.d.ts +1 -1
- package/node_modules/@spexcode/spec-eval/dist/freshness.js +12 -12
- package/node_modules/@spexcode/spec-eval/dist/host.d.ts +2 -1
- package/node_modules/@spexcode/spec-eval/dist/sessioneval.d.ts +2 -1
- package/node_modules/@spexcode/spec-eval/dist/sessioneval.js +20 -3
- package/node_modules/@spexcode/spec-eval/package.json +2 -2
- package/node_modules/@spexcode/spec-forge/package.json +2 -2
- package/node_modules/@vscode/tree-sitter-wasm/LICENSE +21 -0
- package/node_modules/@vscode/tree-sitter-wasm/README.md +36 -0
- package/node_modules/@vscode/tree-sitter-wasm/SECURITY.md +41 -0
- package/node_modules/@vscode/tree-sitter-wasm/cgmanifest.json +16 -0
- package/node_modules/@vscode/tree-sitter-wasm/package.json +42 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-bash.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-c-sharp.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-cpp.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-css.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-go.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ini.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-java.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-javascript.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-php.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-powershell.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-python.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-regex.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ruby.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-rust.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-tsx.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-typescript.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.js +4075 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/web-tree-sitter.d.ts +1027 -0
- package/package.json +9 -3
- package/node_modules/@spexcode/session-core/dist/delivery-queue.d.ts +0 -24
- package/node_modules/@spexcode/session-core/dist/delivery-queue.js +0 -206
- package/node_modules/@spexcode/session-core/dist/index.d.ts +0 -5
- package/node_modules/@spexcode/session-core/dist/index.js +0 -5
- package/node_modules/@spexcode/session-core/dist/internal.d.ts +0 -3
- package/node_modules/@spexcode/session-core/dist/internal.js +0 -3
- package/node_modules/@spexcode/session-core/dist/message.d.ts +0 -22
- package/node_modules/@spexcode/session-core/dist/message.js +0 -53
- package/node_modules/@spexcode/session-core/dist/record-lock.js +0 -152
- package/node_modules/@spexcode/session-core/dist/runtime-session.d.ts +0 -62
- package/node_modules/@spexcode/session-core/dist/runtime-session.js +0 -326
- package/node_modules/@spexcode/session-core/dist/session-cursors.d.ts +0 -14
- package/node_modules/@spexcode/session-core/dist/session-cursors.js +0 -82
- package/node_modules/@spexcode/session-core/dist/session-timeline.d.ts +0 -47
- package/node_modules/@spexcode/session-core/dist/session-timeline.js +0 -216
- package/node_modules/@spexcode/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +0 -53
- package/node_modules/@spexcode/spec-cli/hooks/compat/mark-active-sed-v0.fixture +0 -46
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmdirSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
4
|
+
import { openProjectSessionApplication } from './production.js';
|
|
5
|
+
import { encodeEventJson } from '@spexcode/session-events';
|
|
6
|
+
const SESSION_ID = /^(?!-)[0-9A-Za-z_-]{1,256}$/;
|
|
7
|
+
const STATUS = /^[0-9A-Za-z._:-]{1,64}$/;
|
|
8
|
+
const VERSION = 1;
|
|
9
|
+
/** Migrated legacy history keeps the live payload shapes under its own ignorable types: a timeline projection
|
|
10
|
+
* shows it beside live events, while state replay (which knows only the live types) skips it as history. */
|
|
11
|
+
export const MIGRATED_STATE_EVENT = 'session.state.migrated.v1';
|
|
12
|
+
export const MIGRATED_MESSAGE_EVENT = 'session.message.migrated.v1';
|
|
13
|
+
const LIVE_STATE_EVENT = 'session.state.changed.v1';
|
|
14
|
+
const LIVE_MESSAGE_EVENT = 'session.message.sent.v1';
|
|
15
|
+
// A follow cursor is a position in the projected timeline: these four types ordered by occurrence, then sequence.
|
|
16
|
+
const PROJECTED_EVENT_TYPES = new Set([LIVE_STATE_EVENT, LIVE_MESSAGE_EVENT, MIGRATED_STATE_EVENT, MIGRATED_MESSAGE_EVENT]);
|
|
17
|
+
const LEGACY_ENVELOPE = 'session.json';
|
|
18
|
+
const RETIRED_ENVELOPE = 'runtime.json';
|
|
19
|
+
const LEGACY_ARTIFACT_FILES = ['watchers.json', 'pending.json', 'timeline.ndjson', 'cursors.json'];
|
|
20
|
+
const LEGACY_TIMELINE_DIR = 'timeline';
|
|
21
|
+
/** The durable fence shared by the one-time importer and legacy JSON writers. */
|
|
22
|
+
export function jsonMigrationFencePath(recordsRoot) {
|
|
23
|
+
return join(recordsRoot, '.json-migration.lock');
|
|
24
|
+
}
|
|
25
|
+
class MigrationError extends Error {
|
|
26
|
+
constructor(message) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = 'JsonSessionMigrationError';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const fail = (message) => { throw new MigrationError(message); };
|
|
32
|
+
function parseJson(path) {
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
fail(`cannot read JSON migration input ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function readLegacyDir(recordsRoot, id) {
|
|
41
|
+
if (!SESSION_ID.test(id))
|
|
42
|
+
fail(`ambiguous session directory name: ${id}`);
|
|
43
|
+
const dir = join(recordsRoot, id);
|
|
44
|
+
const files = [];
|
|
45
|
+
let envelope = null;
|
|
46
|
+
let record = null;
|
|
47
|
+
let retired = null;
|
|
48
|
+
const recordPath = join(dir, LEGACY_ENVELOPE);
|
|
49
|
+
const retiredPath = join(dir, RETIRED_ENVELOPE);
|
|
50
|
+
if (existsSync(recordPath)) {
|
|
51
|
+
if (existsSync(retiredPath))
|
|
52
|
+
fail(`ambiguous session envelope: both ${recordPath} and ${retiredPath} exist`);
|
|
53
|
+
envelope = LEGACY_ENVELOPE;
|
|
54
|
+
const raw = parseJson(recordPath);
|
|
55
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
56
|
+
fail(`corrupt session record: ${recordPath}`);
|
|
57
|
+
const candidate = raw;
|
|
58
|
+
if (candidate.session_id !== id || typeof candidate.session_id !== 'string' || !SESSION_ID.test(candidate.session_id)) {
|
|
59
|
+
fail(`ambiguous session id in ${recordPath}`);
|
|
60
|
+
}
|
|
61
|
+
if (candidate.createdAt !== undefined && (!Number.isSafeInteger(candidate.createdAt) || candidate.createdAt < 0))
|
|
62
|
+
fail(`invalid createdAt in ${recordPath}`);
|
|
63
|
+
if (candidate.status === undefined) {
|
|
64
|
+
// A writer that already deferred lifecycle to the canonical store left only runtime metadata under the old name.
|
|
65
|
+
retired = candidate;
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
if (typeof candidate.status !== 'string' || !STATUS.test(candidate.status))
|
|
69
|
+
fail(`invalid status in ${recordPath}`);
|
|
70
|
+
if (candidate.parent !== undefined && candidate.parent !== null && candidate.parent !== '' && (typeof candidate.parent !== 'string' || !SESSION_ID.test(candidate.parent))) {
|
|
71
|
+
fail(`invalid parent in ${recordPath}`);
|
|
72
|
+
}
|
|
73
|
+
// Empty was the legacy root marker; normalize it without changing the source digest.
|
|
74
|
+
record = candidate.parent === '' ? { ...candidate, parent: null } : candidate;
|
|
75
|
+
}
|
|
76
|
+
files.push(recordPath);
|
|
77
|
+
}
|
|
78
|
+
else if (existsSync(retiredPath)) {
|
|
79
|
+
envelope = RETIRED_ENVELOPE;
|
|
80
|
+
const raw = parseJson(retiredPath);
|
|
81
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
82
|
+
fail(`corrupt retired envelope: ${retiredPath}`);
|
|
83
|
+
retired = raw;
|
|
84
|
+
}
|
|
85
|
+
const watches = [];
|
|
86
|
+
const watchPath = join(dir, 'watchers.json');
|
|
87
|
+
if (existsSync(watchPath)) {
|
|
88
|
+
const parsed = parseJson(watchPath);
|
|
89
|
+
if (!Array.isArray(parsed))
|
|
90
|
+
fail(`watchers file is not an array: ${watchPath}`);
|
|
91
|
+
const seen = new Set();
|
|
92
|
+
for (const [index, value] of parsed.entries()) {
|
|
93
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
94
|
+
fail(`invalid watcher row ${index} in ${watchPath}`);
|
|
95
|
+
const row = value;
|
|
96
|
+
if (typeof row.watcher !== 'string' || !SESSION_ID.test(row.watcher) || typeof row.createdAt !== 'string' || !Array.isArray(row.sources)) {
|
|
97
|
+
fail(`invalid watcher row ${index} in ${watchPath}`);
|
|
98
|
+
}
|
|
99
|
+
const sources = row.sources;
|
|
100
|
+
if (sources.some(source => source !== 'manual' && source !== 'parent') || new Set(sources).size !== sources.length) {
|
|
101
|
+
fail(`ambiguous watcher sources in ${watchPath}`);
|
|
102
|
+
}
|
|
103
|
+
const watcher = row.watcher;
|
|
104
|
+
const createdAt = row.createdAt;
|
|
105
|
+
if (seen.has(watcher))
|
|
106
|
+
fail(`duplicate watcher ${watcher} in ${watchPath}`);
|
|
107
|
+
seen.add(watcher);
|
|
108
|
+
watches.push({ watcher, createdAt, sources: sources, ...(row.snapshotPending === undefined ? {} : { snapshotPending: row.snapshotPending }) });
|
|
109
|
+
}
|
|
110
|
+
files.push(watchPath);
|
|
111
|
+
}
|
|
112
|
+
const timelineDir = join(dir, LEGACY_TIMELINE_DIR);
|
|
113
|
+
const timelineFiles = [
|
|
114
|
+
join(dir, 'timeline.ndjson'),
|
|
115
|
+
...(existsSync(timelineDir) && statSync(timelineDir).isDirectory()
|
|
116
|
+
? readdirSync(timelineDir).filter(name => /^\d+\.ndjson$/.test(name)).sort().map(name => join(timelineDir, name))
|
|
117
|
+
: []),
|
|
118
|
+
].filter(existsSync);
|
|
119
|
+
const timeline = [];
|
|
120
|
+
for (const file of timelineFiles) {
|
|
121
|
+
for (const line of readFileSync(file, 'utf8').split('\n').filter(Boolean)) {
|
|
122
|
+
let parsed;
|
|
123
|
+
try {
|
|
124
|
+
parsed = JSON.parse(line);
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
fail(`invalid legacy timeline line in ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
128
|
+
}
|
|
129
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
130
|
+
fail(`invalid legacy timeline event in ${file}`);
|
|
131
|
+
const event = parsed;
|
|
132
|
+
if (event.kind === 'dispatch-settled')
|
|
133
|
+
continue;
|
|
134
|
+
if (event.kind !== 'status' && event.kind !== 'sent')
|
|
135
|
+
fail(`unknown legacy timeline event kind in ${file}`);
|
|
136
|
+
timeline.push(event);
|
|
137
|
+
}
|
|
138
|
+
files.push(file);
|
|
139
|
+
}
|
|
140
|
+
const pending = [];
|
|
141
|
+
const pendingPath = join(dir, 'pending.json');
|
|
142
|
+
if (existsSync(pendingPath)) {
|
|
143
|
+
const parsed = parseJson(pendingPath);
|
|
144
|
+
if (!Array.isArray(parsed))
|
|
145
|
+
fail(`legacy pending file is not an array: ${pendingPath}`);
|
|
146
|
+
for (const [index, value] of parsed.entries()) {
|
|
147
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
148
|
+
fail(`invalid legacy pending row ${index} in ${pendingPath}`);
|
|
149
|
+
const row = value;
|
|
150
|
+
if (typeof row.mid !== 'string' || typeof row.text !== 'string' || (row.from !== null && typeof row.from !== 'string'))
|
|
151
|
+
fail(`invalid legacy pending row ${index} in ${pendingPath}`);
|
|
152
|
+
pending.push(row);
|
|
153
|
+
}
|
|
154
|
+
files.push(pendingPath);
|
|
155
|
+
}
|
|
156
|
+
// Follow cursors were positions in the retired ndjson projection; nothing in the canonical store maps to them,
|
|
157
|
+
// so they are backed up and retired without import.
|
|
158
|
+
const cursorsPath = join(dir, 'cursors.json');
|
|
159
|
+
if (existsSync(cursorsPath))
|
|
160
|
+
files.push(cursorsPath);
|
|
161
|
+
return { id, envelope, record, retired, watches, timeline, pending, files: files.sort() };
|
|
162
|
+
}
|
|
163
|
+
function readLegacyTree(recordsRoot) {
|
|
164
|
+
if (!isAbsolute(recordsRoot))
|
|
165
|
+
fail('recordsRoot must be an absolute directory');
|
|
166
|
+
if (!existsSync(recordsRoot))
|
|
167
|
+
return [];
|
|
168
|
+
if (!statSync(recordsRoot).isDirectory())
|
|
169
|
+
fail(`recordsRoot is not a directory: ${recordsRoot}`);
|
|
170
|
+
const dirs = readdirSync(recordsRoot, { withFileTypes: true }).filter(entry => entry.isDirectory()).map(entry => entry.name).sort();
|
|
171
|
+
return dirs.map(id => readLegacyDir(recordsRoot, id));
|
|
172
|
+
}
|
|
173
|
+
/** Whether the legacy tree still holds anything the canonical store must absorb before the tree is retired. */
|
|
174
|
+
export function legacyResidueExists(recordsRoot) {
|
|
175
|
+
if (!existsSync(recordsRoot))
|
|
176
|
+
return false;
|
|
177
|
+
for (const entry of readdirSync(recordsRoot, { withFileTypes: true })) {
|
|
178
|
+
if (!entry.isDirectory())
|
|
179
|
+
continue;
|
|
180
|
+
const dir = join(recordsRoot, entry.name);
|
|
181
|
+
if ([LEGACY_ENVELOPE, ...LEGACY_ARTIFACT_FILES, LEGACY_TIMELINE_DIR].some(name => existsSync(join(dir, name))))
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
function requireParentForest(records) {
|
|
187
|
+
// Parent pointers are a single rooted forest. Detect cycles before writing.
|
|
188
|
+
for (const record of records) {
|
|
189
|
+
const chain = new Set();
|
|
190
|
+
let current = record.session_id;
|
|
191
|
+
while (current) {
|
|
192
|
+
if (chain.has(current))
|
|
193
|
+
fail(`parent cycle includes ${current}`);
|
|
194
|
+
chain.add(current);
|
|
195
|
+
current = records.find(candidate => candidate.session_id === current)?.parent;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function readInputs(recordsRoot) {
|
|
200
|
+
const tree = readLegacyTree(recordsRoot);
|
|
201
|
+
for (const dir of tree) {
|
|
202
|
+
if (dir.envelope === LEGACY_ENVELOPE && !dir.record)
|
|
203
|
+
fail(`session record ${join(recordsRoot, dir.id, LEGACY_ENVELOPE)} carries no lifecycle; only a marked store may hold a retired envelope`);
|
|
204
|
+
}
|
|
205
|
+
const records = tree.flatMap(dir => dir.record ? [dir.record] : []);
|
|
206
|
+
const byId = new Set(records.map(record => record.session_id));
|
|
207
|
+
const orphanParents = [...new Set(records.flatMap(record => record.parent && !byId.has(record.parent) ? [record.parent] : []))].sort();
|
|
208
|
+
for (const dir of tree) {
|
|
209
|
+
if (!dir.record)
|
|
210
|
+
continue;
|
|
211
|
+
for (const entry of dir.watches)
|
|
212
|
+
if (!byId.has(entry.watcher))
|
|
213
|
+
fail(`session ${dir.id} names missing watcher ${entry.watcher}`);
|
|
214
|
+
}
|
|
215
|
+
requireParentForest(records);
|
|
216
|
+
return {
|
|
217
|
+
records: records.sort((a, b) => a.session_id.localeCompare(b.session_id)),
|
|
218
|
+
dirs: new Map(tree.map(dir => [dir.id, dir])),
|
|
219
|
+
files: tree.flatMap(dir => dir.files).sort(),
|
|
220
|
+
orphanParents,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
function digestInputs(recordsRoot, files) {
|
|
224
|
+
const hash = createHash('sha256');
|
|
225
|
+
for (const file of files)
|
|
226
|
+
hash.update(file.slice(recordsRoot.length)).update('\0').update(readFileSync(file)).update('\0');
|
|
227
|
+
return hash.digest('hex');
|
|
228
|
+
}
|
|
229
|
+
function backupInputs(recordsRoot, files, backupRoot, digest) {
|
|
230
|
+
mkdirSync(backupRoot, { recursive: true });
|
|
231
|
+
for (const file of files) {
|
|
232
|
+
const relative = file.slice(recordsRoot.length).replace(/^[/\\]/, '');
|
|
233
|
+
const destination = join(backupRoot, relative);
|
|
234
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
235
|
+
if (existsSync(destination) && readFileSync(destination).compare(readFileSync(file)) !== 0)
|
|
236
|
+
fail(`backup collision for ${relative}`);
|
|
237
|
+
if (!existsSync(destination))
|
|
238
|
+
copyFileSync(file, destination);
|
|
239
|
+
}
|
|
240
|
+
const digestPath = join(backupRoot, 'source.sha256');
|
|
241
|
+
if (existsSync(digestPath) && readFileSync(digestPath, 'utf8').trim() !== digest)
|
|
242
|
+
fail('existing migration backup has a different source digest');
|
|
243
|
+
if (!existsSync(digestPath))
|
|
244
|
+
writeFileSync(digestPath, `${digest}\n`, { flag: 'wx' });
|
|
245
|
+
}
|
|
246
|
+
function writeFence(path, state, sourceDigest) {
|
|
247
|
+
writeFileSync(path, JSON.stringify({ version: VERSION, state, sourceDigest, pid: process.pid }) + '\n', { flag: 'wx', mode: 0o600 });
|
|
248
|
+
}
|
|
249
|
+
function replaceFence(path, state, sourceDigest) {
|
|
250
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
251
|
+
writeFileSync(tmp, JSON.stringify({ version: VERSION, state, sourceDigest, pid: process.pid }) + '\n', { mode: 0o600 });
|
|
252
|
+
renameSync(tmp, path);
|
|
253
|
+
}
|
|
254
|
+
function assertInputsUnchanged(recordsRoot, expectedFiles, expectedDigest) {
|
|
255
|
+
const current = readInputs(recordsRoot);
|
|
256
|
+
const filesEqual = current.files.length === expectedFiles.length && current.files.every((file, index) => file === expectedFiles[index]);
|
|
257
|
+
const digest = digestInputs(recordsRoot, current.files);
|
|
258
|
+
if (!filesEqual || digest !== expectedDigest) {
|
|
259
|
+
fail(`JSON migration source changed while fenced; refusing cutover (expected ${expectedDigest}, found ${digest})`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function assertResidueInputsUnchanged(recordsRoot, expectedFiles, expectedDigest) {
|
|
263
|
+
const current = readLegacyTree(recordsRoot).flatMap(dir => dir.files).sort();
|
|
264
|
+
const filesEqual = current.length === expectedFiles.length && current.every((file, index) => file === expectedFiles[index]);
|
|
265
|
+
const digest = digestInputs(recordsRoot, current);
|
|
266
|
+
if (!filesEqual || digest !== expectedDigest) {
|
|
267
|
+
fail(`JSON migration residue changed while fenced; refusing retire (expected ${expectedDigest}, found ${digest})`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const eventIdFor = (seed, ...parts) => createHash('sha256').update([seed, ...parts].join('\0')).digest('hex').slice(0, 32);
|
|
271
|
+
const projectedOrder = (events) => events.filter(event => PROJECTED_EVENT_TYPES.has(event.type))
|
|
272
|
+
.sort((a, b) => a.occurredAtMs - b.occurredAtMs || a.eventSeq - b.eventSeq);
|
|
273
|
+
// One subject's legacy conversation lands in ONE transaction, keyed by its first deterministic event id: a
|
|
274
|
+
// re-run after an interrupted import finds that id and skips the whole subject, so no line is ever appended
|
|
275
|
+
// twice. Migrated history sorts before the live events already in the store, so every stored follow cursor on
|
|
276
|
+
// this subject moves forward by the number of history lines that now precede its position — a consumed event
|
|
277
|
+
// never becomes unread again.
|
|
278
|
+
function replayTimeline(app, subject, timeline, fallback, seed) {
|
|
279
|
+
if (timeline.length === 0)
|
|
280
|
+
return 0;
|
|
281
|
+
return app.protocol.withTransaction(tx => {
|
|
282
|
+
if (tx.query('SELECT 1 AS present FROM session_events WHERE event_id=?', eventIdFor(seed, 'timeline', '0')).length > 0)
|
|
283
|
+
return 0;
|
|
284
|
+
const before = projectedOrder(app.events.read(subject, {}, tx));
|
|
285
|
+
const cursors = tx.query('SELECT watcher_session_id AS watcher, event_seq AS position FROM session_follow_cursors WHERE subject_session_id=?', subject);
|
|
286
|
+
const appended = [];
|
|
287
|
+
for (const [index, event] of timeline.entries()) {
|
|
288
|
+
const occurredAtMs = typeof event.ts === 'string' && Number.isFinite(Date.parse(event.ts)) ? Date.parse(event.ts) : fallback.createdAtMs;
|
|
289
|
+
const eventId = eventIdFor(seed, 'timeline', String(index));
|
|
290
|
+
if (event.kind === 'status') {
|
|
291
|
+
app.events.append(tx, {
|
|
292
|
+
eventId,
|
|
293
|
+
type: MIGRATED_STATE_EVENT,
|
|
294
|
+
schemaVersion: 1,
|
|
295
|
+
subjectSessionId: subject,
|
|
296
|
+
ignorable: true,
|
|
297
|
+
payload: encodeEventJson({
|
|
298
|
+
eventId,
|
|
299
|
+
sessionId: subject,
|
|
300
|
+
status: event.status ?? fallback.status,
|
|
301
|
+
proposal: event.proposal ?? null,
|
|
302
|
+
note: event.note ?? null,
|
|
303
|
+
previousProposal: null,
|
|
304
|
+
previousNote: null,
|
|
305
|
+
parentSessionId: fallback.parent,
|
|
306
|
+
previousStatus: null,
|
|
307
|
+
previousParentSessionId: null,
|
|
308
|
+
reason: 'json-migration-history',
|
|
309
|
+
}),
|
|
310
|
+
occurredAtMs,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
else if (typeof event.text === 'string' && typeof event.mid === 'string') {
|
|
314
|
+
app.events.append(tx, {
|
|
315
|
+
eventId,
|
|
316
|
+
type: MIGRATED_MESSAGE_EVENT,
|
|
317
|
+
schemaVersion: 1,
|
|
318
|
+
subjectSessionId: subject,
|
|
319
|
+
ignorable: true,
|
|
320
|
+
payload: encodeEventJson({
|
|
321
|
+
messageId: event.mid,
|
|
322
|
+
text: event.text,
|
|
323
|
+
from: event.from ?? null,
|
|
324
|
+
...(event.replyVia === 'note' ? { replyVia: 'note' } : {}),
|
|
325
|
+
}),
|
|
326
|
+
occurredAtMs,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
else
|
|
330
|
+
continue;
|
|
331
|
+
appended.push(occurredAtMs);
|
|
332
|
+
}
|
|
333
|
+
for (const cursor of cursors) {
|
|
334
|
+
const position = Number(cursor.position);
|
|
335
|
+
if (position <= 0 || before.length === 0)
|
|
336
|
+
continue;
|
|
337
|
+
const boundary = before[Math.min(position, before.length) - 1];
|
|
338
|
+
const shift = appended.filter(occurredAtMs => occurredAtMs < boundary.occurredAtMs).length;
|
|
339
|
+
if (shift > 0)
|
|
340
|
+
tx.exec('UPDATE session_follow_cursors SET event_seq=? WHERE watcher_session_id=? AND subject_session_id=?', position + shift, cursor.watcher, subject);
|
|
341
|
+
}
|
|
342
|
+
return appended.length;
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
// Legacy debt re-enters the canonical queue under a stable idempotency key, so a repeated import returns the
|
|
346
|
+
// same queue row. Debt that never had a sent line gets one now, keyed like the queue row, so it stays visible
|
|
347
|
+
// in the history instead of becoming an unaccounted queue entry.
|
|
348
|
+
function importPending(app, subject, pending, timeline, seed) {
|
|
349
|
+
let events = 0;
|
|
350
|
+
for (const debt of pending) {
|
|
351
|
+
const message = app.enqueueMessage(subject, {
|
|
352
|
+
kind: 'session.prompt.v1',
|
|
353
|
+
body: Buffer.from(debt.text, 'utf8'),
|
|
354
|
+
senderSessionId: debt.from,
|
|
355
|
+
headers: debt.attributes,
|
|
356
|
+
idempotencyKey: `legacy:${debt.mid}`,
|
|
357
|
+
});
|
|
358
|
+
if (timeline.some(event => event.kind === 'sent' && event.mid === debt.mid))
|
|
359
|
+
continue;
|
|
360
|
+
const eventId = eventIdFor(seed, 'pending', debt.mid);
|
|
361
|
+
events += app.protocol.withTransaction(tx => {
|
|
362
|
+
if (app.events.hasMessageEvent(tx, subject, message.messageId))
|
|
363
|
+
return 0;
|
|
364
|
+
app.events.append(tx, {
|
|
365
|
+
eventId,
|
|
366
|
+
type: LIVE_MESSAGE_EVENT,
|
|
367
|
+
schemaVersion: 1,
|
|
368
|
+
subjectSessionId: subject,
|
|
369
|
+
payload: encodeEventJson({ messageId: message.messageId, text: debt.text, from: debt.from }),
|
|
370
|
+
occurredAtMs: message.enqueuedAtMs,
|
|
371
|
+
});
|
|
372
|
+
return 1;
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
return { queued: pending.length, events };
|
|
376
|
+
}
|
|
377
|
+
function attachWatches(app, subject, entries) {
|
|
378
|
+
let edges = 0, attached = 0;
|
|
379
|
+
for (const entry of entries) {
|
|
380
|
+
for (const source of entry.sources) {
|
|
381
|
+
const channel = source === 'parent' ? 'watch:parent' : 'watch:manual';
|
|
382
|
+
if (!app.topology.parents(subject, channel).some(edge => edge.fromSessionId === entry.watcher)) {
|
|
383
|
+
app.attachWatcher(entry.watcher, subject, channel);
|
|
384
|
+
attached++;
|
|
385
|
+
}
|
|
386
|
+
edges++;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return { edges, attached };
|
|
390
|
+
}
|
|
391
|
+
function attachParent(app, subject, parent) {
|
|
392
|
+
if (!parent || app.topology.parents(subject, 'parent').some(edge => edge.fromSessionId === parent))
|
|
393
|
+
return 0;
|
|
394
|
+
app.protocol.withTransaction(tx => app.topology.attach(tx, parent, subject, 'parent'));
|
|
395
|
+
return 1;
|
|
396
|
+
}
|
|
397
|
+
function createFromRecord(app, record, eventId) {
|
|
398
|
+
const nativeSessionId = typeof record.harness_session_id === 'string' && record.harness_session_id ? record.harness_session_id : null;
|
|
399
|
+
app.createSession({
|
|
400
|
+
sessionId: record.session_id,
|
|
401
|
+
status: record.status,
|
|
402
|
+
proposal: typeof record.proposal === 'string' ? record.proposal : null,
|
|
403
|
+
note: typeof record.note === 'string' ? record.note : null,
|
|
404
|
+
parentSessionId: record.parent ?? null,
|
|
405
|
+
updatedAtMs: record.createdAt ?? 0,
|
|
406
|
+
eventId,
|
|
407
|
+
...(nativeSessionId ? {
|
|
408
|
+
runtime: {
|
|
409
|
+
namespace: 'spex-governed',
|
|
410
|
+
runtimeKind: typeof record.harness === 'string' && record.harness ? record.harness : 'legacy',
|
|
411
|
+
nativeSessionId,
|
|
412
|
+
nativeStartToken: `migration:${eventId}`,
|
|
413
|
+
},
|
|
414
|
+
} : {}),
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
function tombstoneOrphans(app, orphanParents, seed) {
|
|
418
|
+
let events = 0;
|
|
419
|
+
for (const parent of orphanParents) {
|
|
420
|
+
if (app.readState(parent))
|
|
421
|
+
continue;
|
|
422
|
+
app.createSession({
|
|
423
|
+
sessionId: parent,
|
|
424
|
+
status: 'archived',
|
|
425
|
+
proposal: null,
|
|
426
|
+
note: null,
|
|
427
|
+
updatedAtMs: 0,
|
|
428
|
+
eventId: eventIdFor(seed, 'orphan-parent', parent),
|
|
429
|
+
});
|
|
430
|
+
events++;
|
|
431
|
+
}
|
|
432
|
+
return events;
|
|
433
|
+
}
|
|
434
|
+
function requireOrphanPolicy(policy, orphanParents) {
|
|
435
|
+
if (policy !== 'fail' && policy !== 'tombstone')
|
|
436
|
+
fail(`unknown orphan parent policy: ${policy}`);
|
|
437
|
+
if (orphanParents.length && policy === 'fail') {
|
|
438
|
+
fail(`sessions name retired parents ${orphanParents.join(', ')}; rerun the one-time migration with orphanParentPolicy=tombstone to preserve those edges as archived addresses`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// Once the SQLite marker is published, the JSON tree is no longer a protocol. Keep only runtime/worktree
|
|
442
|
+
// metadata in each envelope and remove the old communication artifacts. Every file removed here was imported
|
|
443
|
+
// and copied to a backup root first, so this is a reversible one-time cutover, not an in-place compatibility mode.
|
|
444
|
+
function retireLegacyArtifacts(recordsRoot) {
|
|
445
|
+
if (!existsSync(recordsRoot))
|
|
446
|
+
return;
|
|
447
|
+
const unlinkIfPresent = (path) => {
|
|
448
|
+
try {
|
|
449
|
+
unlinkSync(path);
|
|
450
|
+
}
|
|
451
|
+
catch (error) {
|
|
452
|
+
if (error.code !== 'ENOENT')
|
|
453
|
+
throw error;
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
for (const entry of readdirSync(recordsRoot, { withFileTypes: true })) {
|
|
457
|
+
if (!entry.isDirectory())
|
|
458
|
+
continue;
|
|
459
|
+
const dir = join(recordsRoot, entry.name);
|
|
460
|
+
const legacyPath = join(dir, LEGACY_ENVELOPE);
|
|
461
|
+
const recordPath = join(dir, RETIRED_ENVELOPE);
|
|
462
|
+
if (existsSync(legacyPath)) {
|
|
463
|
+
const raw = parseJson(legacyPath);
|
|
464
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
465
|
+
fail(`cannot retire non-object envelope: ${legacyPath}`);
|
|
466
|
+
const envelope = { ...raw };
|
|
467
|
+
for (const field of ['status', 'proposal', 'note', 'parent'])
|
|
468
|
+
delete envelope[field];
|
|
469
|
+
writeFileSync(recordPath, JSON.stringify(envelope, null, 2) + '\n');
|
|
470
|
+
unlinkIfPresent(legacyPath);
|
|
471
|
+
}
|
|
472
|
+
for (const name of LEGACY_ARTIFACT_FILES)
|
|
473
|
+
unlinkIfPresent(join(dir, name));
|
|
474
|
+
const timelineDir = join(dir, LEGACY_TIMELINE_DIR);
|
|
475
|
+
try {
|
|
476
|
+
for (const segment of readdirSync(timelineDir))
|
|
477
|
+
unlinkSync(join(timelineDir, segment));
|
|
478
|
+
// The directory itself is protocol state; remove it only after every segment is gone.
|
|
479
|
+
rmdirSync(timelineDir);
|
|
480
|
+
}
|
|
481
|
+
catch (error) {
|
|
482
|
+
if (error.code !== 'ENOENT' && error.code !== 'ENOTEMPTY')
|
|
483
|
+
throw error;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
// A marked store can still hold legacy residue: a tree cut over by an importer that did not replay history, or
|
|
488
|
+
// files a fenced-out writer left behind. Residue is absorbed by the same deterministic importer before the tree
|
|
489
|
+
// is retired. Canonical state stays authoritative for every session that already has a row; only a legacy
|
|
490
|
+
// envelope with no row creates one. Directories nothing claims (no envelope, no row) are backed up, reported by
|
|
491
|
+
// name, and retired: they are not sessions.
|
|
492
|
+
function migrateLegacyResidue(options, tree, backupRoot) {
|
|
493
|
+
const files = tree.flatMap(dir => dir.files).sort();
|
|
494
|
+
const sourceDigest = digestInputs(options.recordsRoot, files);
|
|
495
|
+
const residueBackupRoot = join(backupRoot, 'residue', sourceDigest.slice(0, 12));
|
|
496
|
+
const owned = options.application === undefined;
|
|
497
|
+
const app = options.application ?? openProjectSessionApplication({ databasePath: options.databasePath, locality: options.locality, now: options.now });
|
|
498
|
+
try {
|
|
499
|
+
const canonicalIds = new Set(app.protocol.withTransaction(tx => tx.query('SELECT session_id FROM session_application_state')).map(row => String(row.session_id)));
|
|
500
|
+
const records = tree.flatMap(dir => dir.record ? [dir.record] : []);
|
|
501
|
+
const known = new Set([...canonicalIds, ...records.map(record => record.session_id)]);
|
|
502
|
+
const orphanParents = [...new Set(records.flatMap(record => record.parent && !known.has(record.parent) ? [record.parent] : []))].sort();
|
|
503
|
+
const orphanParentPolicy = options.orphanParentPolicy ?? 'fail';
|
|
504
|
+
requireOrphanPolicy(orphanParentPolicy, orphanParents);
|
|
505
|
+
for (const dir of tree) {
|
|
506
|
+
for (const entry of dir.watches)
|
|
507
|
+
if (!known.has(entry.watcher))
|
|
508
|
+
fail(`session ${dir.id} names missing watcher ${entry.watcher}`);
|
|
509
|
+
if (dir.retired && !canonicalIds.has(dir.id))
|
|
510
|
+
fail(`retired envelope ${join(options.recordsRoot, dir.id, dir.envelope)} has no canonical application state`);
|
|
511
|
+
}
|
|
512
|
+
requireParentForest(records);
|
|
513
|
+
backupInputs(options.recordsRoot, files, residueBackupRoot, sourceDigest);
|
|
514
|
+
const seedFor = (id) => `migration\0residue\0${sourceDigest}\0${id}`;
|
|
515
|
+
let created = 0, events = 0, parentEdges = 0, watchEdges = 0, pending = 0;
|
|
516
|
+
const unclaimed = [];
|
|
517
|
+
if (orphanParentPolicy === 'tombstone')
|
|
518
|
+
events += tombstoneOrphans(app, orphanParents, `migration\0residue\0${sourceDigest}`);
|
|
519
|
+
for (const dir of tree) {
|
|
520
|
+
const seed = seedFor(dir.id);
|
|
521
|
+
let state = app.readState(dir.id);
|
|
522
|
+
let createdFromResidue = false;
|
|
523
|
+
if (!state && dir.record) {
|
|
524
|
+
createFromRecord(app, dir.record, eventIdFor(seed));
|
|
525
|
+
created++;
|
|
526
|
+
events++;
|
|
527
|
+
createdFromResidue = true;
|
|
528
|
+
state = app.readState(dir.id);
|
|
529
|
+
}
|
|
530
|
+
if (!state) {
|
|
531
|
+
unclaimed.push(dir.id);
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
// The canonical row is authoritative for an existing session; a stale legacy envelope must not rewrite
|
|
535
|
+
// its parent. For a newly created row this is the validated parent copied by createFromRecord.
|
|
536
|
+
parentEdges += createdFromResidue && state.parentSessionId !== null
|
|
537
|
+
? 1
|
|
538
|
+
: attachParent(app, dir.id, state.parentSessionId);
|
|
539
|
+
const retiredCreatedAt = typeof dir.retired?.createdAt === 'number' ? dir.retired.createdAt : null;
|
|
540
|
+
const fallback = {
|
|
541
|
+
status: state.status,
|
|
542
|
+
parent: state.parentSessionId,
|
|
543
|
+
createdAtMs: dir.record?.createdAt ?? retiredCreatedAt ?? state.updatedAtMs,
|
|
544
|
+
};
|
|
545
|
+
events += replayTimeline(app, dir.id, dir.timeline, fallback, seed);
|
|
546
|
+
const debt = importPending(app, dir.id, dir.pending, dir.timeline, seed);
|
|
547
|
+
pending += debt.queued;
|
|
548
|
+
events += debt.events;
|
|
549
|
+
watchEdges += attachWatches(app, dir.id, dir.watches).attached;
|
|
550
|
+
}
|
|
551
|
+
assertResidueInputsUnchanged(options.recordsRoot, files, sourceDigest);
|
|
552
|
+
return { sourceDigest, records: created, events, parentEdges, watchEdges, pending, unclaimed, backupRoot: residueBackupRoot };
|
|
553
|
+
}
|
|
554
|
+
finally {
|
|
555
|
+
if (owned)
|
|
556
|
+
app.close();
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
export function migrateJsonSessionRecords(options) {
|
|
560
|
+
if (!isAbsolute(options.databasePath))
|
|
561
|
+
fail('databasePath must be absolute');
|
|
562
|
+
if (isAbsolute(options.recordsRoot))
|
|
563
|
+
mkdirSync(options.recordsRoot, { recursive: true });
|
|
564
|
+
const backupRoot = options.backupRoot ?? `${options.databasePath}.json-migration-backup`;
|
|
565
|
+
const markerPath = `${options.databasePath}.json-migration.json`;
|
|
566
|
+
const fencePath = jsonMigrationFencePath(options.recordsRoot);
|
|
567
|
+
if (existsSync(markerPath)) {
|
|
568
|
+
const marker = parseJson(markerPath);
|
|
569
|
+
if (marker.version !== VERSION || typeof marker.sourceDigest !== 'string')
|
|
570
|
+
fail(`migration marker ${markerPath} is invalid`);
|
|
571
|
+
if (!existsSync(options.databasePath))
|
|
572
|
+
fail(`migration marker exists but database is missing: ${options.databasePath}`);
|
|
573
|
+
const markerDigest = String(marker.sourceDigest);
|
|
574
|
+
const report = {
|
|
575
|
+
version: VERSION,
|
|
576
|
+
sourceDigest: markerDigest,
|
|
577
|
+
records: Number(marker.records) || 0,
|
|
578
|
+
parentEdges: Number(marker.parentEdges) || 0,
|
|
579
|
+
watchEdges: Number(marker.watchEdges) || 0,
|
|
580
|
+
events: Number(marker.events) || 0,
|
|
581
|
+
orphanParents: Array.isArray(marker.orphanParents) ? marker.orphanParents.filter((value) => typeof value === 'string') : [],
|
|
582
|
+
backupRoot: options.backupRoot ?? (typeof marker.backupRoot === 'string' && isAbsolute(marker.backupRoot) ? marker.backupRoot : backupRoot),
|
|
583
|
+
markerPath,
|
|
584
|
+
replayed: true,
|
|
585
|
+
};
|
|
586
|
+
const residue = readLegacyTree(options.recordsRoot).filter(dir => dir.files.length > 0);
|
|
587
|
+
if (residue.length > 0)
|
|
588
|
+
report.residue = migrateLegacyResidue(options, residue, report.backupRoot);
|
|
589
|
+
retireLegacyArtifacts(options.recordsRoot);
|
|
590
|
+
if (existsSync(fencePath))
|
|
591
|
+
replaceFence(fencePath, 'retired', markerDigest);
|
|
592
|
+
else
|
|
593
|
+
writeFence(fencePath, 'retired', markerDigest);
|
|
594
|
+
return report;
|
|
595
|
+
}
|
|
596
|
+
const input = readInputs(options.recordsRoot);
|
|
597
|
+
const sourceDigest = digestInputs(options.recordsRoot, input.files);
|
|
598
|
+
if (existsSync(options.databasePath)) {
|
|
599
|
+
fail(`database exists without a migration marker: ${options.databasePath}; refusing to import into an ambiguous live database`);
|
|
600
|
+
}
|
|
601
|
+
if (existsSync(fencePath))
|
|
602
|
+
fail(`JSON migration fence already exists: ${fencePath}; refusing a concurrent or incomplete cutover`);
|
|
603
|
+
const orphanParentPolicy = options.orphanParentPolicy ?? 'fail';
|
|
604
|
+
requireOrphanPolicy(orphanParentPolicy, input.orphanParents);
|
|
605
|
+
writeFence(fencePath, 'migrating', sourceDigest);
|
|
606
|
+
let databaseInstalled = false;
|
|
607
|
+
try {
|
|
608
|
+
backupInputs(options.recordsRoot, input.files, backupRoot, sourceDigest);
|
|
609
|
+
const stagingDatabasePath = `${options.databasePath}.migration-${process.pid}.tmp`;
|
|
610
|
+
if (existsSync(stagingDatabasePath))
|
|
611
|
+
fail(`migration staging database already exists: ${stagingDatabasePath}`);
|
|
612
|
+
const app = openProjectSessionApplication({ databasePath: stagingDatabasePath, locality: options.locality, now: options.now });
|
|
613
|
+
let parentEdges = 0, watchEdges = 0, events = 0;
|
|
614
|
+
try {
|
|
615
|
+
if (orphanParentPolicy === 'tombstone')
|
|
616
|
+
events += tombstoneOrphans(app, input.orphanParents, `migration\0${sourceDigest}`);
|
|
617
|
+
for (const record of input.records) {
|
|
618
|
+
const state = app.readState(record.session_id);
|
|
619
|
+
const parent = record.parent ?? null;
|
|
620
|
+
if (!state) {
|
|
621
|
+
createFromRecord(app, record, eventIdFor(`migration\0${sourceDigest}\0${record.session_id}`));
|
|
622
|
+
events++;
|
|
623
|
+
}
|
|
624
|
+
else if (state.status !== record.status || state.parentSessionId !== parent) {
|
|
625
|
+
fail(`existing SQLite state for ${record.session_id} conflicts with JSON source`);
|
|
626
|
+
}
|
|
627
|
+
else if (app.events.read(record.session_id).length === 0) {
|
|
628
|
+
fail(`existing SQLite state for ${record.session_id} has no auditable event`);
|
|
629
|
+
}
|
|
630
|
+
if (parent)
|
|
631
|
+
parentEdges++;
|
|
632
|
+
}
|
|
633
|
+
// Replay the old append-only conversation before retiring its files. State rows above establish
|
|
634
|
+
// the protocol addresses; these events preserve the history and pending debt that the JSON tree held.
|
|
635
|
+
for (const record of input.records) {
|
|
636
|
+
const dir = input.dirs.get(record.session_id);
|
|
637
|
+
const seed = `migration\0${sourceDigest}\0${record.session_id}`;
|
|
638
|
+
events += replayTimeline(app, record.session_id, dir.timeline, { status: record.status, parent: record.parent ?? null, createdAtMs: record.createdAt ?? 0 }, seed);
|
|
639
|
+
events += importPending(app, record.session_id, dir.pending, dir.timeline, seed).events;
|
|
640
|
+
}
|
|
641
|
+
for (const record of input.records) {
|
|
642
|
+
const parent = record.parent ?? null;
|
|
643
|
+
if (parent && !app.topology.parents(record.session_id, 'parent').some(edge => edge.fromSessionId === parent)) {
|
|
644
|
+
app.protocol.withTransaction(tx => app.topology.attach(tx, parent, record.session_id, 'parent'));
|
|
645
|
+
}
|
|
646
|
+
watchEdges += attachWatches(app, record.session_id, input.dirs.get(record.session_id).watches).edges;
|
|
647
|
+
}
|
|
648
|
+
assertInputsUnchanged(options.recordsRoot, input.files, sourceDigest);
|
|
649
|
+
app.close();
|
|
650
|
+
renameSync(stagingDatabasePath, options.databasePath);
|
|
651
|
+
databaseInstalled = true;
|
|
652
|
+
}
|
|
653
|
+
catch (error) {
|
|
654
|
+
try {
|
|
655
|
+
app.close();
|
|
656
|
+
}
|
|
657
|
+
catch { /* preserve the import failure */ }
|
|
658
|
+
for (const sidecar of [`${stagingDatabasePath}-journal`, `${stagingDatabasePath}-wal`, `${stagingDatabasePath}-shm`, stagingDatabasePath]) {
|
|
659
|
+
try {
|
|
660
|
+
if (existsSync(sidecar))
|
|
661
|
+
unlinkSync(sidecar);
|
|
662
|
+
}
|
|
663
|
+
catch { /* cleanup is best effort; the original error wins */ }
|
|
664
|
+
}
|
|
665
|
+
throw error;
|
|
666
|
+
}
|
|
667
|
+
const report = {
|
|
668
|
+
version: VERSION,
|
|
669
|
+
sourceDigest,
|
|
670
|
+
records: input.records.length,
|
|
671
|
+
parentEdges,
|
|
672
|
+
watchEdges,
|
|
673
|
+
events,
|
|
674
|
+
orphanParents: input.orphanParents,
|
|
675
|
+
backupRoot,
|
|
676
|
+
markerPath,
|
|
677
|
+
replayed: false,
|
|
678
|
+
};
|
|
679
|
+
mkdirSync(dirname(markerPath), { recursive: true });
|
|
680
|
+
writeFileSync(markerPath, JSON.stringify(report, null, 2) + '\n', { flag: 'wx' });
|
|
681
|
+
retireLegacyArtifacts(options.recordsRoot);
|
|
682
|
+
replaceFence(fencePath, 'retired', sourceDigest);
|
|
683
|
+
return report;
|
|
684
|
+
}
|
|
685
|
+
catch (error) {
|
|
686
|
+
if (!databaseInstalled) {
|
|
687
|
+
try {
|
|
688
|
+
unlinkSync(fencePath);
|
|
689
|
+
}
|
|
690
|
+
catch { /* preserve the import failure */ }
|
|
691
|
+
}
|
|
692
|
+
throw error;
|
|
693
|
+
}
|
|
694
|
+
}
|