@spexcode/spec-cli 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/bin/spex.mjs +56 -19
- package/dist/cli.js +102 -59
- package/dist/client.d.ts +1 -3
- package/dist/client.js +49 -30
- package/dist/codex-runtime-generations.d.ts +11 -0
- package/dist/codex-runtime-generations.js +46 -9
- package/dist/delivery-lock.d.ts +2 -0
- package/dist/delivery-lock.js +58 -0
- package/dist/doctor.js +53 -11
- package/dist/execution-trace.d.ts +1 -0
- package/dist/execution-trace.js +2 -2
- package/dist/gateway-hub.js +2 -1
- package/dist/gateway.js +6 -3
- package/dist/graphCache.js +32 -2
- package/dist/graphSnapshot.js +57 -2
- package/dist/graphStream.d.ts +2 -0
- package/dist/graphStream.js +83 -3
- package/dist/guide.js +20 -7
- package/dist/harness-select.js +16 -3
- package/dist/harness.d.ts +15 -3
- package/dist/harness.js +331 -50
- package/dist/help.js +11 -8
- package/dist/hook-prompts.js +8 -0
- package/dist/host-resources.js +29 -8
- package/dist/host.d.ts +7 -0
- package/dist/host.js +93 -0
- package/dist/index.js +324 -22
- package/dist/init.js +1 -1
- package/dist/lint.js +70 -35
- package/dist/listen.d.ts +3 -2
- package/dist/listen.js +14 -2
- package/dist/machine-peer.js +1 -1
- package/dist/materialize.d.ts +2 -2
- package/dist/materialize.js +176 -35
- package/dist/pty-bridge.js +14 -14
- package/dist/reviews.js +12 -7
- package/dist/runtime-ownership.d.ts +11 -0
- package/dist/runtime-ownership.js +79 -1
- package/dist/session-application.d.ts +23 -0
- package/dist/session-application.js +189 -0
- package/dist/session-declarations.js +13 -1
- package/dist/session-files.d.ts +6 -0
- package/dist/session-files.js +13 -1
- package/dist/session-follow.js +39 -22
- package/dist/session-record-lock.d.ts +3 -0
- package/dist/session-record-lock.js +94 -0
- package/dist/session-runtime-adapter.d.ts +44 -0
- package/dist/session-runtime-adapter.js +37 -0
- package/dist/session-timeline.d.ts +25 -2
- package/dist/session-timeline.js +68 -11
- package/dist/session-web.js +4 -4
- package/dist/sessions.d.ts +108 -15
- package/dist/sessions.js +1465 -744
- package/dist/source-list.d.ts +13 -0
- package/dist/source-list.js +99 -0
- package/dist/source-read.d.ts +16 -0
- package/dist/source-read.js +84 -0
- package/dist/spec-attachments.d.ts +7 -0
- package/dist/spec-attachments.js +89 -0
- package/dist/spec-body-edit.d.ts +23 -0
- package/dist/spec-body-edit.js +138 -0
- package/dist/supervise.js +15 -6
- package/dist/transcript-reader.d.ts +36 -0
- package/dist/transcript-reader.js +251 -0
- package/hooks/dispatch.sh +19 -31
- package/hooks/harness.sh +6 -6
- package/package.json +6 -6
- package/templates/hooks/post-checkout +4 -2
- package/templates/hooks/post-merge +2 -1
- package/templates/hooks/pre-commit +5 -3
- package/templates/hooks/reference-transaction +5 -3
- package/templates/spec/project/.plugins/commands/spec.md +2 -7
- package/templates/spec/project/.plugins/core/idle/idle.sh +4 -10
- package/templates/spec/project/.plugins/core/idle/spec.md +1 -1
- package/templates/spec/project/.plugins/core/mark-active/mark-active.sh +22 -24
- package/templates/spec/project/.plugins/core/mark-active/spec.md +10 -2
- package/templates/spec/project/.plugins/core/session-fail/fail.sh +8 -7
- package/templates/spec/project/.plugins/core/session-fail/spec.md +3 -1
- package/templates/spec/project/.plugins/core/session-listen/session-listen.sh +133 -0
- package/templates/spec/project/.plugins/core/session-listen/spec.md +36 -0
- package/templates/spec/project/.plugins/core/spec.md +2 -0
- package/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
- package/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +17 -20
- package/templates/spec/project/.plugins/skills/merge/spec.md +33 -0
- package/templates/spec/project/.plugins/skills/spec.md +2 -6
- package/templates/spec/project/.plugins/spec.md +7 -0
- package/hooks/compat/mark-active-0.5.2-eef1.fixture +0 -53
- package/hooks/compat/mark-active-sed-v0.fixture +0 -46
package/dist/reviews.js
CHANGED
|
@@ -86,7 +86,9 @@ export async function issuesReview(query, requestedPage) {
|
|
|
86
86
|
}),
|
|
87
87
|
};
|
|
88
88
|
}
|
|
89
|
-
const
|
|
89
|
+
const orderableEval = (item) => item.filterKind === EVAL_FILTER_KIND.RESULT
|
|
90
|
+
|| item.filterKind === EVAL_FILTER_KIND.DEFERRED;
|
|
91
|
+
const byNewest = (a, b) => Number(orderableEval(b)) - Number(orderableEval(a))
|
|
90
92
|
|| String(b.ts ?? '').localeCompare(String(a.ts ?? ''))
|
|
91
93
|
|| String(a.node ?? '').localeCompare(String(b.node ?? ''))
|
|
92
94
|
|| String(a.scenario ?? '').localeCompare(String(b.scenario ?? ''));
|
|
@@ -114,10 +116,11 @@ export function trunkEvalReviewItems(nodes) {
|
|
|
114
116
|
...reading,
|
|
115
117
|
expected: scenario.expected ?? reading.expected,
|
|
116
118
|
tags: scenario.tags,
|
|
117
|
-
|
|
119
|
+
...(reading.freshnessDeferred
|
|
120
|
+
? { state: 'deferred', filterKind: EVAL_FILTER_KIND.DEFERRED }
|
|
121
|
+
: { state: evalReviewState(reading), filterKind: EVAL_FILTER_KIND.RESULT }),
|
|
118
122
|
node: node.id,
|
|
119
123
|
hue: node.hue,
|
|
120
|
-
filterKind: EVAL_FILTER_KIND.RESULT,
|
|
121
124
|
});
|
|
122
125
|
}
|
|
123
126
|
}
|
|
@@ -149,10 +152,11 @@ export function scopedEvalReviewItems(model) {
|
|
|
149
152
|
expected: scenario.expected ?? reading.expected,
|
|
150
153
|
tags: scenario.tags,
|
|
151
154
|
impact: scenario.impact,
|
|
152
|
-
|
|
155
|
+
...(reading.freshnessDeferred
|
|
156
|
+
? { state: 'deferred', filterKind: EVAL_FILTER_KIND.DEFERRED }
|
|
157
|
+
: { state: evalReviewState(reading), filterKind: EVAL_FILTER_KIND.RESULT }),
|
|
153
158
|
node: node.id,
|
|
154
159
|
hue: node.hue,
|
|
155
|
-
filterKind: EVAL_FILTER_KIND.RESULT,
|
|
156
160
|
};
|
|
157
161
|
items.push(item);
|
|
158
162
|
}
|
|
@@ -278,9 +282,10 @@ export function timelineEvalReviewItems(timeline, node) {
|
|
|
278
282
|
})),
|
|
279
283
|
...[...latest.values()].map((reading) => ({
|
|
280
284
|
...reading,
|
|
281
|
-
|
|
285
|
+
...(reading.freshnessDeferred
|
|
286
|
+
? { state: 'deferred', filterKind: EVAL_FILTER_KIND.DEFERRED }
|
|
287
|
+
: { state: evalReviewState(reading), filterKind: EVAL_FILTER_KIND.RESULT }),
|
|
282
288
|
node,
|
|
283
|
-
filterKind: EVAL_FILTER_KIND.RESULT,
|
|
284
289
|
filterKey: `${EVAL_FILTER_KIND.RESULT}:${reading.scenario}`,
|
|
285
290
|
})),
|
|
286
291
|
...(timeline.dangling ?? []).map((track) => ({
|
|
@@ -10,6 +10,16 @@ export type BackendInstanceRecord = {
|
|
|
10
10
|
export declare function readBackendInstanceRecords(root?: string): BackendInstanceRecord[];
|
|
11
11
|
export declare function registerBackendInstance(instanceId: string, pid?: number, root?: string): BackendInstanceRecord;
|
|
12
12
|
export declare function unregisterBackendInstance(instanceId: string, pid?: number): void;
|
|
13
|
+
type ProcBackendCandidate = BackendInstanceRecord & {
|
|
14
|
+
candidatePid: number;
|
|
15
|
+
command: string;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Reap only backend children whose supervisor identity is provably stale. A dead registry PID alone is not
|
|
19
|
+
* enough: the live process must carry the exact instance id and project root, run the backend entrypoint, and
|
|
20
|
+
* have been orphaned to PID 1. Ordinary launchers, shells, and app-server generations do not match.
|
|
21
|
+
*/
|
|
22
|
+
export declare function reapOrphanBackendInstances(root?: string): ProcBackendCandidate[];
|
|
13
23
|
export declare function spawnDetachedRuntime(opts: {
|
|
14
24
|
cwd: string;
|
|
15
25
|
logFile: string;
|
|
@@ -19,3 +29,4 @@ export declare function spawnDetachedRuntime(opts: {
|
|
|
19
29
|
args: string[];
|
|
20
30
|
env?: NodeJS.ProcessEnv;
|
|
21
31
|
}): ProcessIdentity;
|
|
32
|
+
export {};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
-
import { closeSync, mkdirSync, openSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { closeSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { repoRoot } from '@spexcode/spec-core';
|
|
6
6
|
import { readJsonConfig, runtimeRoot } from '@spexcode/spec-core';
|
|
@@ -47,6 +47,84 @@ export function unregisterBackendInstance(instanceId, pid = process.pid) {
|
|
|
47
47
|
}
|
|
48
48
|
catch { /* not ours / already removed */ }
|
|
49
49
|
}
|
|
50
|
+
const procRows = () => {
|
|
51
|
+
try {
|
|
52
|
+
return readdirSync('/proc').filter((name) => /^\d+$/.test(name)).map(Number);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const procEnv = (pid) => {
|
|
59
|
+
try {
|
|
60
|
+
return Object.fromEntries(readFileSync(`/proc/${pid}/environ`, 'utf8').split('\0').flatMap((entry) => {
|
|
61
|
+
const split = entry.indexOf('=');
|
|
62
|
+
return split > 0 ? [[entry.slice(0, split), entry.slice(split + 1)]] : [];
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const procCommand = (pid) => {
|
|
70
|
+
try {
|
|
71
|
+
return readFileSync(`/proc/${pid}/cmdline`, 'utf8').replaceAll('\0', ' ').trim();
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return '';
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const procParent = (pid) => {
|
|
78
|
+
try {
|
|
79
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
80
|
+
const close = stat.lastIndexOf(')');
|
|
81
|
+
const fields = stat.slice(close + 2).split(' ');
|
|
82
|
+
return Number(fields[1]) || null;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const isBackendCommand = (command) => /(?:^|\/)spec-cli\/(?:src\/index\.ts|dist\/index\.js)(?:\s|$)/.test(command);
|
|
89
|
+
/**
|
|
90
|
+
* Reap only backend children whose supervisor identity is provably stale. A dead registry PID alone is not
|
|
91
|
+
* enough: the live process must carry the exact instance id and project root, run the backend entrypoint, and
|
|
92
|
+
* have been orphaned to PID 1. Ordinary launchers, shells, and app-server generations do not match.
|
|
93
|
+
*/
|
|
94
|
+
export function reapOrphanBackendInstances(root = repoRoot()) {
|
|
95
|
+
const records = readBackendInstanceRecords(root);
|
|
96
|
+
const liveCurrent = records.filter((record) => processStartToken(record.pid) === record.startToken);
|
|
97
|
+
const currentIds = new Set(liveCurrent.map((record) => record.instanceId));
|
|
98
|
+
const candidates = [];
|
|
99
|
+
for (const record of records) {
|
|
100
|
+
if (currentIds.has(record.instanceId))
|
|
101
|
+
continue;
|
|
102
|
+
if (processStartToken(record.pid) === record.startToken)
|
|
103
|
+
continue;
|
|
104
|
+
for (const pid of procRows()) {
|
|
105
|
+
const env = procEnv(pid);
|
|
106
|
+
if (env.SPEXCODE_INSTANCE_ID !== record.instanceId || env.SPEXCODE_PROJECT_ROOT !== root)
|
|
107
|
+
continue;
|
|
108
|
+
if (procParent(pid) !== 1)
|
|
109
|
+
continue;
|
|
110
|
+
const command = procCommand(pid);
|
|
111
|
+
if (!isBackendCommand(command))
|
|
112
|
+
continue;
|
|
113
|
+
candidates.push({ ...record, candidatePid: pid, command });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
for (const candidate of candidates) {
|
|
117
|
+
try {
|
|
118
|
+
process.kill(candidate.candidatePid, 'SIGTERM');
|
|
119
|
+
}
|
|
120
|
+
catch { /* exact process already exited */ }
|
|
121
|
+
try {
|
|
122
|
+
rmSync(backendInstancePath(candidate.instanceId), { force: true });
|
|
123
|
+
}
|
|
124
|
+
catch { /* stale record is advisory */ }
|
|
125
|
+
}
|
|
126
|
+
return candidates;
|
|
127
|
+
}
|
|
50
128
|
export function spawnDetachedRuntime(opts) {
|
|
51
129
|
mkdirSync(dirname(opts.logFile), { recursive: true });
|
|
52
130
|
mkdirSync(dirname(opts.pidFile), { recursive: true });
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type CommittedSessionChange, type ProductionSessionApplication } from '@spexcode/session-application';
|
|
2
|
+
type SessionApplicationCommitWake = (recipients: readonly string[]) => void;
|
|
3
|
+
/** The application owns commit ordering; Spex supplies the adopter transport wake. */
|
|
4
|
+
export declare function setSessionApplicationCommitWake(wake: SessionApplicationCommitWake): void;
|
|
5
|
+
/** Allow the adopter to refresh its board stream after the canonical transaction commits. */
|
|
6
|
+
export declare function setSessionApplicationCommitObserver(observer: (change: Pick<CommittedSessionChange, 'recipients'>) => void): void;
|
|
7
|
+
/** The backend's sole session application composition. Path selection is shared with self-launch. */
|
|
8
|
+
export declare function configuredSessionApplication(): ProductionSessionApplication;
|
|
9
|
+
export type SessionApplicationCutoverState = 'ready' | 'residue' | 'fresh' | 'migration-required' | 'fenced' | 'ambiguous';
|
|
10
|
+
/** Inspect cutover state without initializing a fresh database as a side effect of a rejected request. */
|
|
11
|
+
export declare function sessionApplicationCutoverState(): SessionApplicationCutoverState;
|
|
12
|
+
/** Resolve the canonical application. A legacy tree is cut over at its first canonical access; runtime code
|
|
13
|
+
* never gets to choose between JSON and SQLite based on the caller or request path. */
|
|
14
|
+
export declare function configuredSessionApplicationIfCutover(): ProductionSessionApplication | undefined;
|
|
15
|
+
/** Initialize a fresh canonical store only after an accepted create has crossed its no-side-effect boundary. */
|
|
16
|
+
export declare function initializeFreshSessionApplication(): ProductionSessionApplication;
|
|
17
|
+
export declare function acquireFreshSessionApplicationForCreate(): {
|
|
18
|
+
application: ProductionSessionApplication;
|
|
19
|
+
owned: boolean;
|
|
20
|
+
};
|
|
21
|
+
export declare function releaseFreshSessionApplicationForCreate(owned: boolean, committed: boolean): void;
|
|
22
|
+
export declare function resetConfiguredSessionApplicationForTest(): void;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { existsSync, readdirSync, rmSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { openProjectSessionApplication, legacyResidueExists, migrateJsonSessionRecords, } from '@spexcode/session-application';
|
|
4
|
+
import { requireLocalDatabasePath, resolveDatabasePath } from '@spexcode/session-selflaunch';
|
|
5
|
+
import { runtimeRoot } from '@spexcode/spec-core';
|
|
6
|
+
import { jsonMigrationFencePath } from '@spexcode/session-application';
|
|
7
|
+
let cached;
|
|
8
|
+
let cachedPath;
|
|
9
|
+
let freshStoreOwned = false;
|
|
10
|
+
let freshStoreCommitted = false;
|
|
11
|
+
let freshStoreLeases = 0;
|
|
12
|
+
let commitWake = () => { };
|
|
13
|
+
let commitObserver;
|
|
14
|
+
/** The application owns commit ordering; Spex supplies the adopter transport wake. */
|
|
15
|
+
export function setSessionApplicationCommitWake(wake) {
|
|
16
|
+
commitWake = wake;
|
|
17
|
+
}
|
|
18
|
+
/** Allow the adopter to refresh its board stream after the canonical transaction commits. */
|
|
19
|
+
export function setSessionApplicationCommitObserver(observer) {
|
|
20
|
+
commitObserver = observer;
|
|
21
|
+
}
|
|
22
|
+
/** The backend's sole session application composition. Path selection is shared with self-launch. */
|
|
23
|
+
export function configuredSessionApplication() {
|
|
24
|
+
const databasePath = resolveDatabasePath();
|
|
25
|
+
if (cached !== undefined && cachedPath === databasePath)
|
|
26
|
+
return cached;
|
|
27
|
+
if (cached !== undefined)
|
|
28
|
+
cached.close();
|
|
29
|
+
cached = openProjectSessionApplication({
|
|
30
|
+
databasePath,
|
|
31
|
+
locality: path => { requireLocalDatabasePath(path); },
|
|
32
|
+
onCommitted: result => {
|
|
33
|
+
commitWake(result.recipients);
|
|
34
|
+
if (commitObserver)
|
|
35
|
+
setImmediate(() => commitObserver?.(result));
|
|
36
|
+
},
|
|
37
|
+
onRuntimeBound: sessionId => commitWake([sessionId]),
|
|
38
|
+
});
|
|
39
|
+
cachedPath = databasePath;
|
|
40
|
+
return cached;
|
|
41
|
+
}
|
|
42
|
+
// A marked store is `ready` only once its legacy tree holds nothing left to absorb. The scan runs once per process
|
|
43
|
+
// per store: after residue is migrated (or none was found) no legacy writer exists to create more, so later calls
|
|
44
|
+
// answer from memory instead of walking every session directory on the hot path.
|
|
45
|
+
const settledResidueStores = new Set();
|
|
46
|
+
/** Inspect cutover state without initializing a fresh database as a side effect of a rejected request. */
|
|
47
|
+
export function sessionApplicationCutoverState() {
|
|
48
|
+
const databasePath = resolveDatabasePath();
|
|
49
|
+
const recordsRoot = join(runtimeRoot(), 'sessions');
|
|
50
|
+
const storeKey = `${databasePath}\0${recordsRoot}`;
|
|
51
|
+
if (existsSync(`${databasePath}.json-migration.json`)) {
|
|
52
|
+
if (settledResidueStores.has(storeKey))
|
|
53
|
+
return 'ready';
|
|
54
|
+
if (legacyResidueExists(recordsRoot))
|
|
55
|
+
return 'residue';
|
|
56
|
+
settledResidueStores.add(storeKey);
|
|
57
|
+
return 'ready';
|
|
58
|
+
}
|
|
59
|
+
if (existsSync(jsonMigrationFencePath(recordsRoot)))
|
|
60
|
+
return 'fenced';
|
|
61
|
+
if (existsSync(recordsRoot) && readdirSync(recordsRoot, { withFileTypes: true }).some(entry => entry.isDirectory()))
|
|
62
|
+
return 'migration-required';
|
|
63
|
+
if (existsSync(databasePath))
|
|
64
|
+
return 'ambiguous';
|
|
65
|
+
return 'fresh';
|
|
66
|
+
}
|
|
67
|
+
/** Resolve the canonical application. A legacy tree is cut over at its first canonical access; runtime code
|
|
68
|
+
* never gets to choose between JSON and SQLite based on the caller or request path. */
|
|
69
|
+
export function configuredSessionApplicationIfCutover() {
|
|
70
|
+
const state = sessionApplicationCutoverState();
|
|
71
|
+
if (state === 'ready')
|
|
72
|
+
return configuredSessionApplication();
|
|
73
|
+
if (state === 'migration-required' || state === 'residue')
|
|
74
|
+
return initializeMigratedSessionApplication(state);
|
|
75
|
+
if (state === 'fresh')
|
|
76
|
+
return initializeFreshSessionApplication();
|
|
77
|
+
if (state === 'fenced' || state === 'ambiguous') {
|
|
78
|
+
throw new Error(`session application cutover is ${state}; refusing a legacy/runtime split`);
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
// One importer for both shapes of legacy tree. Before the marker it installs the canonical store; after the
|
|
83
|
+
// marker it absorbs whatever residue the tree still holds into the store this process already owns, then
|
|
84
|
+
// retires the tree. Either way the tree is gone when this returns, so the cutover state settles to `ready`.
|
|
85
|
+
function initializeMigratedSessionApplication(state) {
|
|
86
|
+
const databasePath = resolveDatabasePath();
|
|
87
|
+
const recordsRoot = join(runtimeRoot(), 'sessions');
|
|
88
|
+
const locality = (path) => { requireLocalDatabasePath(path); };
|
|
89
|
+
if (state === 'migration-required') {
|
|
90
|
+
migrateJsonSessionRecords({ databasePath, recordsRoot, locality });
|
|
91
|
+
return configuredSessionApplication();
|
|
92
|
+
}
|
|
93
|
+
const application = configuredSessionApplication();
|
|
94
|
+
const report = migrateJsonSessionRecords({ databasePath, recordsRoot, locality, application });
|
|
95
|
+
settledResidueStores.add(`${databasePath}\0${recordsRoot}`);
|
|
96
|
+
const residue = report.residue;
|
|
97
|
+
if (residue) {
|
|
98
|
+
console.log(`[session-application] migrated legacy residue from ${recordsRoot}: ${residue.records} record(s), ${residue.events} event(s), ${residue.parentEdges} parent edge(s), ${residue.watchEdges} watch edge(s), ${residue.pending} pending; ${residue.unclaimed.length} unclaimed dir(s)${residue.unclaimed.length ? ` (${residue.unclaimed.join(', ')})` : ''}; backup ${residue.backupRoot}`);
|
|
99
|
+
}
|
|
100
|
+
return application;
|
|
101
|
+
}
|
|
102
|
+
/** Initialize a fresh canonical store only after an accepted create has crossed its no-side-effect boundary. */
|
|
103
|
+
export function initializeFreshSessionApplication() {
|
|
104
|
+
const state = sessionApplicationCutoverState();
|
|
105
|
+
if (state === 'ready')
|
|
106
|
+
return configuredSessionApplication();
|
|
107
|
+
if (state === 'residue')
|
|
108
|
+
return initializeMigratedSessionApplication(state);
|
|
109
|
+
if (state !== 'fresh')
|
|
110
|
+
throw new Error(`cannot initialize a fresh session store from cutover state: ${state}`);
|
|
111
|
+
const databasePath = resolveDatabasePath();
|
|
112
|
+
const recordsRoot = join(runtimeRoot(), 'sessions');
|
|
113
|
+
migrateJsonSessionRecords({ databasePath, recordsRoot, locality: path => { requireLocalDatabasePath(path); } });
|
|
114
|
+
return configuredSessionApplication();
|
|
115
|
+
}
|
|
116
|
+
export function acquireFreshSessionApplicationForCreate() {
|
|
117
|
+
const state = sessionApplicationCutoverState();
|
|
118
|
+
if (state === 'fresh') {
|
|
119
|
+
const application = initializeFreshSessionApplication();
|
|
120
|
+
freshStoreOwned = true;
|
|
121
|
+
freshStoreCommitted = false;
|
|
122
|
+
freshStoreLeases++;
|
|
123
|
+
return { application, owned: true };
|
|
124
|
+
}
|
|
125
|
+
if (state === 'ready' && freshStoreOwned && !freshStoreCommitted) {
|
|
126
|
+
freshStoreLeases++;
|
|
127
|
+
return { application: configuredSessionApplication(), owned: true };
|
|
128
|
+
}
|
|
129
|
+
if (state === 'residue')
|
|
130
|
+
return { application: initializeMigratedSessionApplication(state), owned: false };
|
|
131
|
+
if (state !== 'ready')
|
|
132
|
+
throw new Error(`cannot initialize a session store from cutover state: ${state}`);
|
|
133
|
+
return { application: configuredSessionApplication(), owned: false };
|
|
134
|
+
}
|
|
135
|
+
export function releaseFreshSessionApplicationForCreate(owned, committed) {
|
|
136
|
+
if (!owned)
|
|
137
|
+
return;
|
|
138
|
+
if (committed)
|
|
139
|
+
freshStoreCommitted = true;
|
|
140
|
+
freshStoreLeases = Math.max(0, freshStoreLeases - 1);
|
|
141
|
+
if (freshStoreLeases !== 0)
|
|
142
|
+
return;
|
|
143
|
+
if (freshStoreCommitted) {
|
|
144
|
+
try {
|
|
145
|
+
unlinkSync(jsonMigrationFencePath(join(runtimeRoot(), 'sessions')));
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
if (error.code !== 'ENOENT')
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const application = cached;
|
|
154
|
+
const rows = application?.protocol.withTransaction(tx => Number(tx.query('SELECT COUNT(*) AS count FROM session_application_state')[0]?.count ?? 0)) ?? 0;
|
|
155
|
+
if (rows !== 0) {
|
|
156
|
+
freshStoreCommitted = true;
|
|
157
|
+
try {
|
|
158
|
+
unlinkSync(jsonMigrationFencePath(join(runtimeRoot(), 'sessions')));
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
if (error.code !== 'ENOENT')
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
application?.close();
|
|
167
|
+
cached = undefined;
|
|
168
|
+
const databasePath = resolveDatabasePath();
|
|
169
|
+
const recordsRoot = join(runtimeRoot(), 'sessions');
|
|
170
|
+
for (const path of [databasePath, `${databasePath}.json-migration.json`, jsonMigrationFencePath(recordsRoot)]) {
|
|
171
|
+
try {
|
|
172
|
+
unlinkSync(path);
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
if (error.code !== 'ENOENT')
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
rmSync(`${databasePath}.json-migration-backup`, { recursive: true, force: true });
|
|
180
|
+
freshStoreOwned = false;
|
|
181
|
+
}
|
|
182
|
+
export function resetConfiguredSessionApplicationForTest() {
|
|
183
|
+
cached?.close();
|
|
184
|
+
cached = undefined;
|
|
185
|
+
settledResidueStores.clear();
|
|
186
|
+
freshStoreOwned = false;
|
|
187
|
+
freshStoreCommitted = false;
|
|
188
|
+
freshStoreLeases = 0;
|
|
189
|
+
}
|
|
@@ -27,7 +27,11 @@ export async function sessionStateKit(sessionId) {
|
|
|
27
27
|
// the agent-authored state writers resolve WHICH session by id: a `--session <id>` flag (the lifecycle
|
|
28
28
|
// hooks pass it, parsed from the payload, since they no longer have a cwd `.session`) wins, else the
|
|
29
29
|
// harness env var (ownSessionId — the agent's own `spex session …` carries the harness session id).
|
|
30
|
-
|
|
30
|
+
// Hook payloads from Codex carry the native thread id, while the canonical
|
|
31
|
+
// application is keyed by the Spex session id. Resolve that alias once at
|
|
32
|
+
// the state-writer boundary; shell hooks stay transport-only and every
|
|
33
|
+
// lifecycle producer writes the same canonical record.
|
|
34
|
+
const sess = sessionId ? (l.readAliasedRawRecord(sessionId)?.session_id ?? sessionId) : undefined;
|
|
31
35
|
// @@@ no-record diagnosis ([[state]]) - the session store resolves from the CURRENT directory (runtimeRoot
|
|
32
36
|
// ← the cwd's git common dir), so the classic declaration failure is a cd OUTSIDE the session's project —
|
|
33
37
|
// and a bare "no session record" told the author none of that (field-reported). Name the actual cause and
|
|
@@ -99,6 +103,14 @@ export async function runSessionDeclaration(verb, argv = process.argv) {
|
|
|
99
103
|
if (proposal === 'nothing')
|
|
100
104
|
nothingProposalTrap();
|
|
101
105
|
const { s, sess, mark, noRecord, noteEcho } = await sessionStateKit(sessionId);
|
|
106
|
+
if (proposal === 'merge') {
|
|
107
|
+
const readiness = s.mergeReadiness('merge');
|
|
108
|
+
if (!readiness.ready) {
|
|
109
|
+
console.error(`review declaration refused: ${readiness.reason}`);
|
|
110
|
+
process.exitCode = 1;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
102
114
|
let closeNote = proposal === 'close' ? CLOSE_CLEANUP : '';
|
|
103
115
|
if (proposal === 'close') {
|
|
104
116
|
// The closeout nudge is advisory: a declaration must land even if the local issue store fails.
|
package/dist/session-files.d.ts
CHANGED
|
@@ -6,6 +6,12 @@ type SessionFileLock = <T>(id: string, body: () => T) => T;
|
|
|
6
6
|
export declare const SESSION_FILE_PREVIEW_MAX_BYTES: number;
|
|
7
7
|
export type SessionFilePreviewKind = 'text' | 'image' | 'html';
|
|
8
8
|
export declare function listSessionFiles(id: string): string[];
|
|
9
|
+
export type SessionFileStatus = {
|
|
10
|
+
path: string;
|
|
11
|
+
valid: boolean;
|
|
12
|
+
reason?: string;
|
|
13
|
+
};
|
|
14
|
+
export declare function inspectSessionFiles(id: string): SessionFileStatus[];
|
|
9
15
|
export declare function readSessionFiles(id: string): string[];
|
|
10
16
|
export declare function addSessionFile(id: string, input: string, lock: SessionFileLock, cwd?: string): {
|
|
11
17
|
path: string;
|
package/dist/session-files.js
CHANGED
|
@@ -71,7 +71,19 @@ export function listSessionFiles(id) {
|
|
|
71
71
|
requireSession(id);
|
|
72
72
|
return readSessionFiles(id);
|
|
73
73
|
}
|
|
74
|
-
|
|
74
|
+
export function inspectSessionFiles(id) {
|
|
75
|
+
requireSession(id);
|
|
76
|
+
return readFiles(id).map((path) => {
|
|
77
|
+
try {
|
|
78
|
+
currentFile(path);
|
|
79
|
+
return { path, valid: true };
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
return { path, valid: false, reason: error instanceof Error ? error.message : String(error) };
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
// Session projections already hold a parsed runtime envelope; re-checking it here would make an in-memory
|
|
75
87
|
// projection depend on the public route's existence guard. The route-facing list keeps that guard above.
|
|
76
88
|
export function readSessionFiles(id) {
|
|
77
89
|
return readFiles(id);
|
package/dist/session-follow.js
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { sessionStoreDir } from '@spexcode/spec-core';
|
|
3
|
-
import { advanceFollow, followCursor, unreadSince } from '@spexcode/session-core';
|
|
1
|
+
import { configuredSessionApplicationIfCutover } from './session-application.js';
|
|
4
2
|
import { timelineDisplay, timelineEvents, timelineStamp } from './session-timeline.js';
|
|
5
3
|
import { sessionTitle } from './sessions.js';
|
|
6
|
-
// @@@ session-follow - supervision
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// sessions cost the control plane zero, where the old poll cost one board build (a connect + a tmux spawn per
|
|
10
|
-
// live session) per follower per interval.
|
|
4
|
+
// @@@ session-follow - supervision follows canonical SQLite events past an application-owned cursor, never a
|
|
5
|
+
// derived board. The bounded observation tick reads the event sequence and cursor only; it never opens a backend,
|
|
6
|
+
// rendezvous socket, or tmux. M followers over N sessions therefore cost the control plane zero native probes.
|
|
11
7
|
// Actionable = a state whose arrival means "a human/supervisor must now act". `offline` is deliberately ABSENT
|
|
12
8
|
// where the old poll had it: liveness is a present-tense probe derivation, never authored, so it can never
|
|
13
9
|
// appear on a log ([[state]]). A follower learns what a session DECLARED and never that it died.
|
|
@@ -51,11 +47,19 @@ export async function followSessions(emit, opts) {
|
|
|
51
47
|
return f.path;
|
|
52
48
|
return [];
|
|
53
49
|
};
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
50
|
+
const application = configuredSessionApplicationIfCutover();
|
|
51
|
+
const canonicalSelf = self && application?.readState(self) ? self : null;
|
|
52
|
+
const readCursor = (id) => {
|
|
53
|
+
if (!self)
|
|
54
|
+
return memo.get(id) ?? null;
|
|
55
|
+
return canonicalSelf ? application.readFollowCursor(canonicalSelf, id) : null;
|
|
56
|
+
};
|
|
57
|
+
const writeCursor = (id, to) => {
|
|
58
|
+
if (!self)
|
|
59
|
+
memo.set(id, to);
|
|
60
|
+
else if (canonicalSelf)
|
|
61
|
+
application.advanceFollowCursor(canonicalSelf, id, to);
|
|
62
|
+
};
|
|
59
63
|
const line = (id, st, note, first) => {
|
|
60
64
|
const s = row?.(id, st, note);
|
|
61
65
|
if (!s)
|
|
@@ -74,7 +78,7 @@ export async function followSessions(emit, opts) {
|
|
|
74
78
|
if (id === self)
|
|
75
79
|
continue; // the follower's own log is its INBOX, followed below on its own cursor rule
|
|
76
80
|
seen.add(id);
|
|
77
|
-
if (!
|
|
81
|
+
if (!application?.readState(id)) {
|
|
78
82
|
if (state.delete(id))
|
|
79
83
|
emit(`${tag}[spex] closed · removed [id ${id}]`);
|
|
80
84
|
if (take)
|
|
@@ -111,7 +115,12 @@ export async function followSessions(emit, opts) {
|
|
|
111
115
|
continue; // THE cheap tick: nothing appended, nothing parsed
|
|
112
116
|
f.stamp = stamp;
|
|
113
117
|
const evs = timelineEvents(id);
|
|
114
|
-
const
|
|
118
|
+
const unread = evs.slice(f.pos);
|
|
119
|
+
const slice = {
|
|
120
|
+
events: unread,
|
|
121
|
+
at: unread.map((_, index) => f.pos + index),
|
|
122
|
+
next: evs.length,
|
|
123
|
+
};
|
|
115
124
|
let hit = null;
|
|
116
125
|
for (let k = 0; k < slice.events.length && !hit; k++) {
|
|
117
126
|
const e = slice.events[k];
|
|
@@ -151,18 +160,26 @@ export async function followSessions(emit, opts) {
|
|
|
151
160
|
state.delete(id);
|
|
152
161
|
emit(`${tag}[spex] closed · removed [id ${id}]`);
|
|
153
162
|
}
|
|
154
|
-
// THE FOLLOWER'S OWN LOG — watched exactly like any other target, on its own
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
if (self &&
|
|
159
|
-
const
|
|
163
|
+
// THE FOLLOWER'S OWN LOG — watched exactly like any other target, on its own canonical cursor row. In take
|
|
164
|
+
// mode the returned message is this wait's consumed result, so advance past it before returning; otherwise
|
|
165
|
+
// an empty cursor returns the same oldest inbox message forever. A turn-boundary delivery may still present
|
|
166
|
+
// the message through the adapter, but it does not own this read cursor.
|
|
167
|
+
if (self && application?.readState(self)) {
|
|
168
|
+
const ownEvents = timelineEvents(self);
|
|
169
|
+
const ownStart = readCursor(self) ?? 0;
|
|
170
|
+
const ownUnread = ownEvents.slice(ownStart);
|
|
171
|
+
const mine = {
|
|
172
|
+
events: ownUnread,
|
|
173
|
+
at: ownUnread.map((_, index) => ownStart + index),
|
|
174
|
+
};
|
|
160
175
|
for (let k = 0; k < mine.events.length; k++) {
|
|
161
176
|
const e = mine.events[k];
|
|
162
177
|
if (e.kind !== 'sent')
|
|
163
178
|
continue;
|
|
164
|
-
if (take)
|
|
179
|
+
if (take) {
|
|
180
|
+
writeCursor(self, mine.at[k] + 1);
|
|
165
181
|
return { mail: { from: e.from, text: e.text } };
|
|
182
|
+
}
|
|
166
183
|
// a stream has no turn boundary to advance that cursor, so its own high-water mark — process-local,
|
|
167
184
|
// never written — is what keeps an unread line from being re-printed on every tick.
|
|
168
185
|
if (mine.at[k] <= shown)
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare function withSessionRecordLock<T>(id: string, body: () => Promise<T>, signal?: AbortSignal): Promise<T>;
|
|
2
|
+
export declare function withSessionRecordLockSync<T>(id: string, body: () => T): T;
|
|
3
|
+
export declare function trySessionRecordLockSync(id: string): (() => void) | null;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { closeSync, mkdirSync, openSync, readFileSync, unlinkSync, writeSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { runtimeRoot } from '@spexcode/spec-core';
|
|
4
|
+
const root = () => join(runtimeRoot(), '.session-record-locks');
|
|
5
|
+
const pathFor = (id) => join(root(), `${id}.lock`);
|
|
6
|
+
const pause = (signal) => new Promise((resolve, reject) => {
|
|
7
|
+
if (signal?.aborted) {
|
|
8
|
+
reject(Object.assign(new Error('The operation was aborted'), { name: 'AbortError', code: 'ABORT_ERR' }));
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
const timer = setTimeout(resolve, 10);
|
|
12
|
+
if (signal)
|
|
13
|
+
signal.addEventListener('abort', () => { clearTimeout(timer); reject(Object.assign(new Error('The operation was aborted'), { name: 'AbortError', code: 'ABORT_ERR' })); }, { once: true });
|
|
14
|
+
});
|
|
15
|
+
async function acquire(id, timeoutMs = 30_000, signal) {
|
|
16
|
+
mkdirSync(root(), { recursive: true });
|
|
17
|
+
const path = pathFor(id), deadline = Date.now() + timeoutMs;
|
|
18
|
+
for (;;) {
|
|
19
|
+
if (signal?.aborted)
|
|
20
|
+
throw Object.assign(new Error('The operation was aborted'), { name: 'AbortError', code: 'ABORT_ERR' });
|
|
21
|
+
try {
|
|
22
|
+
const fd = openSync(path, 'wx');
|
|
23
|
+
writeSync(fd, String(process.pid));
|
|
24
|
+
closeSync(fd);
|
|
25
|
+
return () => { try {
|
|
26
|
+
unlinkSync(path);
|
|
27
|
+
}
|
|
28
|
+
catch { /* dead-owner recovery may have removed it */ } };
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (error.code !== 'EEXIST')
|
|
32
|
+
throw error;
|
|
33
|
+
let owner = 0;
|
|
34
|
+
try {
|
|
35
|
+
owner = Number(readFileSync(path, 'utf8').trim()) || 0;
|
|
36
|
+
}
|
|
37
|
+
catch { /* creator/releaser race */ }
|
|
38
|
+
if (owner && owner !== process.pid) {
|
|
39
|
+
try {
|
|
40
|
+
process.kill(owner, 0);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
try {
|
|
44
|
+
unlinkSync(path);
|
|
45
|
+
}
|
|
46
|
+
catch { /* race */ }
|
|
47
|
+
;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (Date.now() >= deadline)
|
|
52
|
+
throw new Error(`session record ${id}: timed out waiting for transaction lock`);
|
|
53
|
+
await pause(signal);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export async function withSessionRecordLock(id, body, signal) {
|
|
58
|
+
const release = await acquire(id, 30_000, signal);
|
|
59
|
+
try {
|
|
60
|
+
return await body();
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
release();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export function withSessionRecordLockSync(id, body) {
|
|
67
|
+
const release = trySessionRecordLockSync(id);
|
|
68
|
+
if (!release)
|
|
69
|
+
throw new Error(`session record ${id}: transaction lock is busy`);
|
|
70
|
+
try {
|
|
71
|
+
return body();
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
release();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function trySessionRecordLockSync(id) {
|
|
78
|
+
mkdirSync(root(), { recursive: true });
|
|
79
|
+
const path = pathFor(id);
|
|
80
|
+
try {
|
|
81
|
+
const fd = openSync(path, 'wx');
|
|
82
|
+
writeSync(fd, String(process.pid));
|
|
83
|
+
closeSync(fd);
|
|
84
|
+
return () => { try {
|
|
85
|
+
unlinkSync(path);
|
|
86
|
+
}
|
|
87
|
+
catch { /* dead-owner recovery may have removed it */ } };
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (error.code === 'EEXIST')
|
|
91
|
+
return null;
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
}
|