@ran-sh/dsh-crew 1.1.0 → 1.2.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/.claude-plugin/marketplace.json +16 -17
  2. package/.claude-plugin/plugin.json +7 -1
  3. package/README.md +44 -15
  4. package/README.zh.md +37 -12
  5. package/agents/ds-flash.md +16 -18
  6. package/agents/ds-pro.md +16 -24
  7. package/agents/ds-reviewer.md +15 -15
  8. package/agents/ds-worker.md +16 -16
  9. package/bin/dsh-crew.mjs +16 -12
  10. package/codex/AGENTS.md +37 -85
  11. package/codex/agents/ds-flash.toml +18 -14
  12. package/codex/agents/ds-pro.toml +18 -15
  13. package/codex/agents/ds-reviewer.toml +17 -13
  14. package/codex/agents/ds-worker.toml +17 -13
  15. package/codex/prompts/dsh-config.md +17 -1
  16. package/codex/prompts/dsh-status.md +5 -1
  17. package/commands/config.md +16 -4
  18. package/commands/status.md +6 -2
  19. package/docs/installation.md +54 -9
  20. package/docs/readiness-matrix.md +14 -6
  21. package/docs/ui-surfaces.md +32 -17
  22. package/lib/client.js +5434 -4844
  23. package/official-web-bridge/lib/client.js +567 -479
  24. package/official-web-bridge/overlay-entry.mjs +59 -0
  25. package/package.json +6 -3
  26. package/scripts/build-client.mjs +2 -0
  27. package/scripts/setup.mjs +8 -7
  28. package/scripts/verify-crew-ui-polish.mjs +145 -0
  29. package/scripts/verify-history-ui.mjs +97 -0
  30. package/scripts/verify-npm-install.mjs +1 -1
  31. package/src/client/history-panel.tsx +108 -0
  32. package/src/client/host-readiness.mjs +15 -6
  33. package/src/client/index.tsx +86 -64
  34. package/src/client/model-callability-view.mjs +17 -0
  35. package/src/client/panel-chrome.tsx +40 -0
  36. package/src/client/quick-panel.tsx +88 -73
  37. package/src/client/readiness-envelope.mjs +34 -0
  38. package/src/config-readiness.mjs +181 -115
  39. package/src/extension-contract.mjs +44 -26
  40. package/src/history/admission-gate.mjs +51 -0
  41. package/src/history/archive-store.mjs +261 -0
  42. package/src/history/cleanup-plan.mjs +64 -0
  43. package/src/history/http.mjs +32 -0
  44. package/src/history/operation.mjs +86 -0
  45. package/src/history/runner.mjs +51 -0
  46. package/src/history/runtime.mjs +28 -0
  47. package/src/history/service.mjs +137 -0
  48. package/src/history/state.mjs +27 -0
  49. package/src/hub/index.mjs +141 -24
  50. package/src/hub-client.mjs +17 -15
  51. package/src/image-output.mjs +71 -0
  52. package/src/install/install-legacy.mjs +126 -23
  53. package/src/install/npx-lifecycle.mjs +373 -64
  54. package/src/install/official-frontend-assets.mjs +78 -0
  55. package/src/install/payload-content.mjs +88 -0
  56. package/src/install/windows-startup.mjs +107 -19
  57. package/src/install/windows-supervisor-adapter.mjs +443 -0
  58. package/src/install/windows-supervisor-lifecycle.mjs +782 -0
  59. package/src/install/zcode.mjs +53 -14
  60. package/src/job-contracts.mjs +37 -31
  61. package/src/jobs.mjs +2 -2
  62. package/src/mcp-runtime.mjs +74 -9
  63. package/src/model-callability-contract.mjs +79 -0
  64. package/src/multimodal.mjs +0 -0
  65. package/src/official-web-bridge.mjs +0 -2
  66. package/src/provider-health.mjs +130 -124
  67. package/src/provider-profile-store.mjs +12 -0
  68. package/src/provider-settings-store.mjs +347 -5
  69. package/src/readiness-matrix.mjs +20 -15
  70. package/src/runtime-identity-contract.mjs +34 -0
  71. package/src/runtime-identity.mjs +14 -4
  72. package/src/runtime-readiness-snapshot.mjs +285 -87
  73. package/src/server.mjs +86 -76
  74. package/src/standalone-sdk.mjs +23 -0
  75. package/src/supervisor/restart-request.mjs +64 -10
  76. package/src/workflow-runtime.mjs +28 -13
  77. package/windows/start-dsh-crew.cmd +57 -57
  78. package/windows/start-dsh-crew.ps1 +327 -55
  79. package/windows/supervisor-control.ps1 +467 -0
  80. package/zcode/AGENTS.md +4 -1
  81. package/zcode/agents/ds-reviewer.md +31 -36
  82. package/zcode/agents/ds-worker.md +31 -39
  83. package/zcode/commands/dsh-config.md +17 -6
  84. package/zcode/commands/dsh-status.md +5 -4
  85. package/.mcp.json +0 -8
@@ -2,7 +2,9 @@
2
2
  // describes only DSH Crew and deliberately never advertises itself as a top-
3
3
  // level Executor or control plane.
4
4
 
5
- export const EXTENSION_CONTRACT_SCHEMA_VERSION = 1;
5
+ import { validateModelCallabilityV2 } from './model-callability-contract.mjs';
6
+
7
+ export const EXTENSION_CONTRACT_SCHEMA_VERSION = 1;
6
8
 
7
9
  function row(matrix, id) {
8
10
  return Array.isArray(matrix?.rows) ? matrix.rows.find((entry) => entry?.id === id) : undefined;
@@ -34,40 +36,56 @@ function readinessFromRow(entry, { pass = 'READY', notRun = 'DEGRADED' } = {}) {
34
36
  return component('UNAVAILABLE', entry.reason_code ?? 'CHECK_FAILED');
35
37
  }
36
38
 
37
- export function buildExtensionContract({ config = {}, readinessMatrix = {}, readinessSnapshot = null, workspace = null, profiles = null, runtime = null } = {}) {
38
- const matrix = readinessSnapshot?.readiness_matrix ?? readinessMatrix;
39
+ function modelReadinessFromSnapshot(readinessSnapshot, matrix, runtime, expectedEnabledRoles, now = Date.now()) {
40
+ const callability = readinessSnapshot?.model_callability;
41
+ const providerHealthEvidence = row(matrix, 'provider_health');
42
+ const catalogEvidence = row(matrix, 'provider_catalog');
43
+ if (callability && typeof callability === 'object') {
44
+ const validation = validateModelCallabilityV2({
45
+ projection: callability,
46
+ runtime,
47
+ expectedEnabledRoles,
48
+ expectedSelections: { worker: readinessSnapshot?.worker?.selected ?? null, reviewer: readinessSnapshot?.reviewer?.selected ?? null },
49
+ now,
50
+ });
51
+ if (validation.ok && validation.state === 'CALLABLE') return component('READY', 'CURRENT_MODEL_CALLABLE', { captured_at: callability.captured_at, expires_at: callability.expires_at, runtime_id: callability.current_runtime_id });
52
+ if (validation.ok && validation.state === 'NOT_CALLABLE') return component('UNAVAILABLE', callability.roles.worker?.state === 'NOT_CALLABLE' ? callability.roles.worker.reason_code : callability.roles.reviewer.reason_code ?? 'CURRENT_MODEL_NOT_CALLABLE');
53
+ if (validation.ok && validation.state === 'STALE') return component('DEGRADED', 'MODEL_EVIDENCE_STALE');
54
+ return component('DEGRADED', validation.reason_code ?? 'MODEL_CALLABILITY_UNKNOWN');
55
+ }
56
+ if (providerHealthEvidence?.status === 'FAIL') return readinessFromRow(providerHealthEvidence);
57
+ if (catalogEvidence?.status === 'FAIL') return readinessFromRow(catalogEvidence);
58
+ if (providerHealthEvidence || catalogEvidence) return component('DEGRADED', 'MODEL_CALLABILITY_NOT_PROJECTED');
59
+ return component('UNAVAILABLE', 'MODEL_CALLABILITY_NOT_PROJECTED');
60
+ }
61
+
62
+ export function buildExtensionContract({ config = {}, readinessMatrix = {}, readinessSnapshot = null, workspace = null, profiles = null, runtime = null } = {}) {
63
+ const matrix = readinessSnapshot?.readiness_matrix ?? readinessMatrix;
39
64
  const workerEnabled = config.subagents_enabled !== false && config.worker_state !== 'disabled';
40
65
  const reviewerEnabled = config.subagents_enabled !== false && config.review_state !== 'disabled';
41
- const realModelEvidence = ['worker_primary_callable', 'model_execution', 'worker_escalation_callable', 'deepseek_flash', 'deepseek_pro']
42
- .map((id) => row(matrix, id))
43
- .find((entry) => entry?.status === 'PASS');
44
- const catalogEvidence = row(matrix, 'provider_catalog');
45
- const lifecycleEvidence = row(matrix, 'provider_lifecycle_consistent');
46
- const modelReadiness = catalogEvidence?.status === 'FAIL'
47
- ? readinessFromRow(catalogEvidence)
48
- : realModelEvidence
49
- ? component('READY', realModelEvidence.reason_code ?? 'MODEL_EXECUTION_PASSED')
50
- : catalogEvidence?.status === 'PASS' || catalogEvidence?.status === 'SKIP'
51
- ? component('DEGRADED', 'MODEL_CATALOG_ONLY')
52
- : component('UNAVAILABLE', catalogEvidence?.reason_code ?? 'NO_EVIDENCE');
53
- const components = {
54
- harness: readinessFromRow(row(matrix, 'hub_compatibility')),
55
- provider_lifecycle: lifecycleEvidence
56
- ? readinessFromRow(lifecycleEvidence, { notRun: 'DEGRADED' })
57
- : component('DEGRADED', 'PROVIDER_LIFECYCLE_NOT_CHECKED'),
58
- model: modelReadiness,
66
+ const reviewerHealthEvidence = row(matrix, 'reviewer_health');
67
+ const lifecycleEvidence = row(matrix, 'provider_lifecycle_consistent');
68
+ const modelReadiness = modelReadinessFromSnapshot(readinessSnapshot, matrix, runtime, { worker: workerEnabled, reviewer: reviewerEnabled });
69
+ const components = {
70
+ harness: readinessFromRow(row(matrix, 'hub_compatibility')),
71
+ provider_lifecycle: lifecycleEvidence
72
+ ? readinessFromRow(lifecycleEvidence, { notRun: 'DEGRADED' })
73
+ : component('DEGRADED', 'PROVIDER_LIFECYCLE_NOT_CHECKED'),
74
+ model: modelReadiness,
59
75
  workspace: workspaceComponent(workspace),
60
76
  reviewer: reviewerEnabled
61
- ? readinessFromRow(row(matrix, 'reviewer_pipeline'))
62
- : component('DEGRADED', 'REVIEWER_DISABLED'),
77
+ ? reviewerHealthEvidence?.status === 'FAIL'
78
+ ? readinessFromRow(reviewerHealthEvidence)
79
+ : readinessFromRow(row(matrix, 'reviewer_pipeline'))
80
+ : component('NOT_APPLICABLE', 'REVIEWER_DISABLED'),
63
81
  };
64
- const states = Object.values(components).map((entry) => entry.status);
82
+ const states = Object.entries(components).filter(([key]) => key !== 'reviewer' || reviewerEnabled).map(([, entry]) => entry.status);
65
83
  const readiness = states.includes('UNAVAILABLE') ? 'UNAVAILABLE' : states.includes('DEGRADED') ? 'DEGRADED' : 'READY';
66
84
  return {
67
85
  schema_version: EXTENSION_CONTRACT_SCHEMA_VERSION,
68
86
  kind: 'dsh-crew-extension',
69
- runtime: runtime ?? null,
70
- ...(readinessSnapshot ? { readiness_snapshot: readinessSnapshot } : {}),
87
+ runtime: runtime ?? null,
88
+ ...(readinessSnapshot ? { readiness_snapshot: readinessSnapshot } : {}),
71
89
  capabilities: {
72
90
  'deepseek.worker': workerEnabled,
73
91
  'deepseek.reviewer': reviewerEnabled,
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Crew-profile-only runtime adapter. Fence the public agent creation entry,
3
+ * including creations still awaiting publication in the official agent list.
4
+ * Callers must persist their maintenance marker BEFORE asking idle().
5
+ * No original agent is cancelled or disposed by this adapter.
6
+ */
7
+ const installed = new WeakSet();
8
+ const guarded = Symbol.for('dsh-crew.history.admission');
9
+ function method(object, name) {
10
+ for (let current = object; current; current = Object.getPrototypeOf(current)) {
11
+ const descriptor = Object.getOwnPropertyDescriptor(current, name);
12
+ if (descriptor) return descriptor.value;
13
+ }
14
+ }
15
+
16
+ export function installHistoryAdmissionGate(agents, isMaintenancePending) {
17
+ if (typeof agents?.create !== 'function' || typeof agents?.list !== 'function'
18
+ || typeof isMaintenancePending !== 'function') throw new Error('HISTORY_ADMISSION_UNAVAILABLE');
19
+ if (installed.has(agents)) throw new Error('HISTORY_ADMISSION_ALREADY_INSTALLED');
20
+ const entries = ['create', 'resume'].filter(name => typeof method(agents, name) === 'function')
21
+ .map(name => ({ name, original: method(agents, name), wrapper: null }));
22
+ if (!entries.some(entry => entry.name === 'create')) throw new Error('HISTORY_ADMISSION_UNAVAILABLE');
23
+ if (entries.some(entry => entry.original[guarded])) throw new Error('HISTORY_ADMISSION_ALREADY_INSTALLED');
24
+ let creating = 0;
25
+ for (const entry of entries) {
26
+ entry.wrapper = async function(...args) {
27
+ if (isMaintenancePending()) throw new Error('HISTORY_MAINTENANCE_PENDING');
28
+ creating += 1;
29
+ try { return await entry.original.apply(this, args); }
30
+ finally { creating -= 1; }
31
+ };
32
+ entry.wrapper[guarded] = true;
33
+ agents[entry.name] = entry.wrapper;
34
+ }
35
+ installed.add(agents);
36
+ return {
37
+ idle() {
38
+ // Cordis returns fresh traceable callable proxies; compare descriptors,
39
+ // not the per-access wrapper returned by agents.create/resume.
40
+ if (entries.some(entry => method(agents, entry.name) !== entry.wrapper)) return false;
41
+ try {
42
+ const live = agents.list();
43
+ return Array.isArray(live) && creating === 0 && live.length === 0;
44
+ } catch { return false; }
45
+ },
46
+ dispose() {
47
+ for (const entry of entries) if (method(agents, entry.name) === entry.wrapper) agents[entry.name] = entry.original;
48
+ installed.delete(agents);
49
+ },
50
+ };
51
+ }
@@ -0,0 +1,261 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { closeSync, existsSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
4
+
5
+ const WORKSPACE = 'harness/storages/workspace.json';
6
+ const LIMIT = 512 * 1024 * 1024;
7
+ const hash = bytes => createHash('sha256').update(bytes).digest('hex');
8
+ const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object'
9
+ ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
10
+ const equal = (a, b) => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
11
+ const validId = id => typeof id === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/.test(id);
12
+ const fail = code => { throw new Error(`HISTORY_${code}`); };
13
+
14
+ function pathInside(root, relativePath) {
15
+ if (typeof relativePath !== 'string' || isAbsolute(relativePath) || relativePath.includes('\\')
16
+ || relativePath.includes(':') || relativePath.split('/').some(p => !p || p === '.' || p === '..')) fail('INVALID_PATH');
17
+ let current = resolve(root);
18
+ if (!lstatSync(current).isDirectory() || lstatSync(current).isSymbolicLink()) fail('LINKED_ROOT');
19
+ const canonical = realpathSync(current);
20
+ if ((process.platform === 'win32' ? canonical.toLowerCase() !== current.toLowerCase() : canonical !== current)) fail('LINKED_ROOT');
21
+ for (const part of relativePath.split('/')) {
22
+ current = join(current, part);
23
+ try { if (lstatSync(current).isSymbolicLink()) fail('LINKED_PATH'); }
24
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
25
+ }
26
+ return current;
27
+ }
28
+
29
+ function artifactPath(root, file) {
30
+ if (!validId(file?.sessionId) || typeof file.relativePath !== 'string'
31
+ || !/^harness\/sessions\/[^/]+\/[^/]+\/session\.jsonl(?:\.zstd)?$/.test(file.relativePath)
32
+ || file.relativePath.split('/')[3] !== file.sessionId) fail('INVALID_ARTIFACT');
33
+ return pathInside(root, file.relativePath);
34
+ }
35
+
36
+ function readBounded(file) {
37
+ const info = lstatSync(file);
38
+ if (!info.isFile() || info.isSymbolicLink() || info.size > LIMIT) fail('INVALID_FILE');
39
+ const bytes = readFileSync(file);
40
+ if (bytes.length > LIMIT || bytes.length !== info.size) fail('FILE_CHANGED');
41
+ return bytes;
42
+ }
43
+
44
+ function atomic(root, relativePath, bytes, exclusive = false) {
45
+ const file = pathInside(root, relativePath);
46
+ mkdirSync(dirname(file), { recursive: true });
47
+ pathInside(root, relativePath);
48
+ const temporary = `${file}.${randomUUID()}.tmp`;
49
+ let fd;
50
+ try {
51
+ fd = openSync(temporary, 'wx', 0o600); writeFileSync(fd, bytes); fsyncSync(fd); closeSync(fd); fd = undefined;
52
+ if (exclusive) {
53
+ try { linkSync(temporary, file); }
54
+ catch (error) { if (error.code === 'EEXIST') fail('RESTORE_CONFLICT'); throw error; }
55
+ } else renameSync(temporary, file);
56
+ } finally {
57
+ if (fd !== undefined) closeSync(fd);
58
+ if (existsSync(temporary)) unlinkSync(temporary);
59
+ }
60
+ }
61
+
62
+ function decodeStore(bytes) {
63
+ let store;
64
+ try { store = JSON.parse(bytes.toString('utf8')); } catch { fail('INVALID_STORAGE'); }
65
+ if (store?.unit?.name !== 'workspace' || store.unit.version !== 2 || store.global?.initialized !== true
66
+ || store.global.pendingMutation || !Array.isArray(store.global.workspaceIds) || !Array.isArray(store.global.archivedSessionIds)
67
+ || !store.tables?.workspaces || Array.isArray(store.tables.workspaces)) fail('UNSUPPORTED_STORAGE');
68
+ if (store.global.archivedSessionIds.some(id => !validId(id))
69
+ || new Set(store.global.archivedSessionIds).size !== store.global.archivedSessionIds.length) fail('INVALID_STORAGE');
70
+ const table = store.tables.workspaces;
71
+ if (Object.keys(table).length > 10000 || store.global.workspaceIds.length !== Object.keys(table).length
72
+ || new Set(store.global.workspaceIds).size !== store.global.workspaceIds.length
73
+ || store.global.workspaceIds.some(id => !validId(id) || !Object.hasOwn(table, id))) fail('INVALID_STORAGE');
74
+ for (const record of Object.values(table)) if (!record || !Array.isArray(record.sessionIds)
75
+ || record.sessionIds.some(id => !validId(id)) || typeof record.path !== 'string') fail('INVALID_STORAGE');
76
+ return store;
77
+ }
78
+
79
+ function batchPath(id) {
80
+ if (typeof id !== 'string' || !/^[a-f0-9-]{36}$/.test(id)) fail('INVALID_ARCHIVE_ID');
81
+ return `history/transactions/${id}`;
82
+ }
83
+
84
+ function saveManifest(root, manifest) {
85
+ atomic(root, `${batchPath(manifest.id)}/manifest.json`, JSON.stringify(manifest));
86
+ }
87
+
88
+ function loadManifest(root, id) {
89
+ let manifest;
90
+ try { manifest = JSON.parse(readBounded(pathInside(root, `${batchPath(id)}/manifest.json`))); }
91
+ catch { fail('INVALID_MANIFEST'); }
92
+ if (manifest.id !== id || manifest.schemaVersion !== 1 || !['archive', 'delete'].includes(manifest.operation)
93
+ || !Array.isArray(manifest.files) || manifest.files.length > 10000
94
+ || !['PREPARING', 'PREPARED', 'APPLYING', 'APPLIED', 'RESTORING', 'RESTORED', 'ROLLED_BACK', 'DELETING', 'DELETED'].includes(manifest.state)
95
+ || manifest.files.some(file => !file || !/^[a-f0-9]{64}$/.test(file.sha256) || !Number.isSafeInteger(file.size) || file.size < 0)
96
+ || new Set(manifest.files.map(file => file.sessionId)).size !== manifest.files.length
97
+ || new Set(manifest.files.map(file => file.relativePath)).size !== manifest.files.length
98
+ || manifest.files.reduce((total, file) => total + file.size, 0) > LIMIT) fail('INVALID_MANIFEST');
99
+ decodeStore(Buffer.from(JSON.stringify(manifest.before)));
100
+ decodeStore(Buffer.from(JSON.stringify(manifest.after)));
101
+ for (const file of manifest.files) artifactPath(root, file);
102
+ if (manifest.state === 'RESTORING') {
103
+ decodeStore(Buffer.from(JSON.stringify(manifest.restoreBefore ?? null)));
104
+ decodeStore(Buffer.from(JSON.stringify(manifest.restoreAfter ?? null)));
105
+ }
106
+ return manifest;
107
+ }
108
+
109
+ async function requireStopped(assertStopped) {
110
+ if (typeof assertStopped !== 'function' || await assertStopped() !== true) fail('BACKEND_NOT_STOPPED');
111
+ }
112
+
113
+ /** Internal offline primitive: caller must own the maintenance lease + update lock. */
114
+ export async function archiveHistory({ crewRoot, request, assertStopped, archiveId = randomUUID() }) {
115
+ await requireStopped(assertStopped);
116
+ if (!request || !['archive', 'delete'].includes(request.operation) || !Array.isArray(request.artifacts)
117
+ || !Array.isArray(request.sessionIds) || !Array.isArray(request.workspaceIds)
118
+ || request.artifacts.length > 10000 || request.workspaceIds.length > 10000) fail('INVALID_REQUEST');
119
+ const selectedSessions = new Set(request.sessionIds);
120
+ const selectedWorkspaces = new Set(request.workspaceIds);
121
+ if ([...selectedSessions, ...selectedWorkspaces].some(id => !validId(id))
122
+ || selectedSessions.size !== request.sessionIds.length || selectedWorkspaces.size !== request.workspaceIds.length
123
+ || request.artifacts.length !== selectedSessions.size
124
+ || new Set(request.artifacts.map(f => f.sessionId)).size !== selectedSessions.size
125
+ || request.artifacts.some(f => !selectedSessions.has(f.sessionId))) fail('INVALID_SELECTION');
126
+ const beforeBytes = readBounded(pathInside(crewRoot, WORKSPACE));
127
+ if (hash(beforeBytes) !== request.workspaceHash) fail('PREVIEW_CHANGED');
128
+ const before = decodeStore(beforeBytes);
129
+ const after = structuredClone(before);
130
+ for (const id of selectedWorkspaces) {
131
+ const record = before.tables.workspaces[id];
132
+ if (!record || record.sessionIds.some(sid => !selectedSessions.has(sid))) fail('PREVIEW_CHANGED');
133
+ delete after.tables.workspaces[id];
134
+ }
135
+ after.global.workspaceIds = after.global.workspaceIds.filter(id => !selectedWorkspaces.has(id));
136
+ after.global.archivedSessionIds = after.global.archivedSessionIds.filter(id => !selectedSessions.has(id));
137
+ for (const record of Object.values(after.tables.workspaces)) record.sessionIds = record.sessionIds.filter(id => !selectedSessions.has(id));
138
+ let size = 0;
139
+ const files = request.artifacts.map(file => {
140
+ const bytes = readBounded(artifactPath(crewRoot, file));
141
+ size += bytes.length;
142
+ if (size > LIMIT) fail('ARCHIVE_TOO_LARGE');
143
+ if (hash(bytes) !== file.sha256) fail('PREVIEW_CHANGED');
144
+ return { sessionId: file.sessionId, relativePath: file.relativePath, sha256: file.sha256, size: bytes.length };
145
+ });
146
+ if (existsSync(pathInside(crewRoot, `${batchPath(archiveId)}/manifest.json`))) fail('ARCHIVE_EXISTS');
147
+ const manifest = { schemaVersion: 1, id: archiveId, operation: request.operation,
148
+ createdAt: new Date().toISOString(), state: 'PREPARING', files, before, after };
149
+ saveManifest(crewRoot, manifest);
150
+ try {
151
+ for (const [index, file] of files.entries()) {
152
+ atomic(crewRoot, `${batchPath(manifest.id)}/files/${index}.bin`, readBounded(artifactPath(crewRoot, file)));
153
+ if (hash(readBounded(pathInside(crewRoot, `${batchPath(manifest.id)}/files/${index}.bin`))) !== file.sha256) fail('BACKUP_CHANGED');
154
+ }
155
+ manifest.state = 'PREPARED'; saveManifest(crewRoot, manifest);
156
+ await requireStopped(assertStopped);
157
+ if (hash(readBounded(pathInside(crewRoot, WORKSPACE))) !== request.workspaceHash) fail('PREVIEW_CHANGED');
158
+ for (const file of files) if (hash(readBounded(artifactPath(crewRoot, file))) !== file.sha256) fail('PREVIEW_CHANGED');
159
+ manifest.state = 'APPLYING'; saveManifest(crewRoot, manifest);
160
+ for (const file of files) unlinkSync(artifactPath(crewRoot, file));
161
+ atomic(crewRoot, WORKSPACE, JSON.stringify(after));
162
+ manifest.state = 'APPLIED'; saveManifest(crewRoot, manifest);
163
+ return { id: manifest.id, state: manifest.state, operation: manifest.operation, counts: { sessions: files.length, workspaces: selectedWorkspaces.size } };
164
+ } catch (error) {
165
+ // The caller retains the stopped lease. Never restart after this exception
166
+ // without explicitly recovering the referenced durable transaction.
167
+ error.archiveId = manifest.id;
168
+ throw error;
169
+ }
170
+ }
171
+
172
+ // Shared internal IO for the controller; no paths are accepted from HTTP clients.
173
+ export { pathInside as historyPath, readBounded as readHistoryBytes, atomic as writeHistoryBytes,
174
+ decodeStore as decodeWorkspaceStore, loadManifest as readHistoryManifest, hash as historyHash };
175
+
176
+ export async function restoreHistory({ crewRoot, archiveId, assertStopped }) {
177
+ await requireStopped(assertStopped);
178
+ const manifest = loadManifest(crewRoot, archiveId);
179
+ if (manifest.operation !== 'archive' || manifest.state !== 'APPLIED') fail('ARCHIVE_NOT_RESTORABLE');
180
+ const currentBytes = readBounded(pathInside(crewRoot, WORKSPACE));
181
+ const current = decodeStore(currentBytes);
182
+ const next = structuredClone(current);
183
+ const restoredIds = [];
184
+ for (const [id, old] of Object.entries(manifest.before.tables.workspaces)) {
185
+ const after = manifest.after.tables.workspaces[id];
186
+ if (equal(old, after)) continue;
187
+ if (!equal(current.tables.workspaces[id], after)) fail('RESTORE_CONFLICT');
188
+ if (Object.entries(current.tables.workspaces).some(([other, record]) => other !== id && record.path === old.path)) fail('RESTORE_CONFLICT');
189
+ next.tables.workspaces[id] = old;
190
+ if (after === undefined) restoredIds.push(id);
191
+ }
192
+ next.global.workspaceIds = [...manifest.before.global.workspaceIds.filter(id => restoredIds.includes(id)), ...current.global.workspaceIds];
193
+ const selected = new Set(manifest.files.map(file => file.sessionId));
194
+ next.global.archivedSessionIds = [...new Set([...current.global.archivedSessionIds, ...manifest.before.global.archivedSessionIds.filter(id => selected.has(id))])];
195
+ for (const [index, file] of manifest.files.entries()) {
196
+ if (existsSync(artifactPath(crewRoot, file))) fail('RESTORE_CONFLICT');
197
+ if (hash(readBounded(pathInside(crewRoot, `${batchPath(archiveId)}/files/${index}.bin`))) !== file.sha256) fail('BACKUP_CHANGED');
198
+ }
199
+ manifest.restoreBefore = current; manifest.restoreAfter = next; manifest.state = 'RESTORING'; saveManifest(crewRoot, manifest);
200
+ await requireStopped(assertStopped);
201
+ if (hash(readBounded(pathInside(crewRoot, WORKSPACE))) !== hash(currentBytes)) fail('RESTORE_CONFLICT');
202
+ for (const [index, file] of manifest.files.entries()) {
203
+ artifactPath(crewRoot, file);
204
+ // A durable temporary file is published exclusively. A crash cannot leave
205
+ // a partially written target that recovery mistakes for someone else's data.
206
+ atomic(crewRoot, file.relativePath, readBounded(pathInside(crewRoot, `${batchPath(archiveId)}/files/${index}.bin`)), true);
207
+ }
208
+ atomic(crewRoot, WORKSPACE, JSON.stringify(next));
209
+ manifest.state = 'RESTORED'; saveManifest(crewRoot, manifest);
210
+ return { id: archiveId, state: manifest.state };
211
+ }
212
+
213
+ /** Roll back an interrupted offline operation. The caller retains its stop lease. */
214
+ export async function recoverHistory({ crewRoot, archiveId, assertStopped }) {
215
+ await requireStopped(assertStopped);
216
+ const manifest = loadManifest(crewRoot, archiveId);
217
+ if (manifest.state === 'ROLLED_BACK') return { id: archiveId, state: manifest.state };
218
+ if (!['PREPARING', 'PREPARED', 'APPLYING', 'APPLIED', 'RESTORING'].includes(manifest.state)) fail('RECOVERY_UNAVAILABLE');
219
+ const restoring = manifest.state === 'RESTORING';
220
+ const desired = restoring ? manifest.restoreBefore : manifest.before;
221
+ const possible = restoring ? manifest.restoreAfter : manifest.after;
222
+ decodeStore(Buffer.from(JSON.stringify(desired)));
223
+ decodeStore(Buffer.from(JSON.stringify(possible)));
224
+ const currentBytes = readBounded(pathInside(crewRoot, WORKSPACE));
225
+ const current = decodeStore(currentBytes);
226
+ if (!equal(current, desired) && !equal(current, possible)) fail('RECOVERY_CONFLICT');
227
+ const touched = !['PREPARING', 'PREPARED'].includes(manifest.state);
228
+ for (const [index, file] of manifest.files.entries()) {
229
+ const target = artifactPath(crewRoot, file);
230
+ if (existsSync(target) && hash(readBounded(target)) !== file.sha256) fail('RECOVERY_CONFLICT');
231
+ if (!touched && !existsSync(target)) fail('RECOVERY_CONFLICT');
232
+ if (touched && hash(readBounded(pathInside(crewRoot, `${batchPath(archiveId)}/files/${index}.bin`))) !== file.sha256) fail('BACKUP_CHANGED');
233
+ }
234
+ await requireStopped(assertStopped);
235
+ if (hash(readBounded(pathInside(crewRoot, WORKSPACE))) !== hash(currentBytes)) fail('RECOVERY_CONFLICT');
236
+ for (const [index, file] of manifest.files.entries()) {
237
+ const target = artifactPath(crewRoot, file);
238
+ if (restoring) { if (existsSync(target)) unlinkSync(target); }
239
+ else if (!existsSync(target)) {
240
+ atomic(crewRoot, file.relativePath, readBounded(pathInside(crewRoot, `${batchPath(archiveId)}/files/${index}.bin`)), true);
241
+ }
242
+ }
243
+ atomic(crewRoot, WORKSPACE, JSON.stringify(desired));
244
+ manifest.state = restoring ? 'APPLIED' : 'ROLLED_BACK'; saveManifest(crewRoot, manifest);
245
+ return { id: archiveId, state: manifest.state };
246
+ }
247
+
248
+ /** The private rollback artifacts are purged only after the new runtime is verified. */
249
+ export async function finalizeHistoryDeletion({ crewRoot, archiveId, assertRestarted }) {
250
+ if (typeof assertRestarted !== 'function' || await assertRestarted() !== true) fail('RESTART_NOT_VERIFIED');
251
+ const manifest = loadManifest(crewRoot, archiveId);
252
+ if (manifest.operation !== 'delete' || !['APPLIED', 'DELETING', 'DELETED'].includes(manifest.state)) fail('DELETE_UNAVAILABLE');
253
+ if (manifest.state === 'DELETED') return { id: archiveId, state: manifest.state };
254
+ manifest.state = 'DELETING'; saveManifest(crewRoot, manifest);
255
+ for (const [index] of manifest.files.entries()) {
256
+ const backup = pathInside(crewRoot, `${batchPath(archiveId)}/files/${index}.bin`);
257
+ if (existsSync(backup)) unlinkSync(backup);
258
+ }
259
+ manifest.state = 'DELETED'; saveManifest(crewRoot, manifest);
260
+ return { id: archiveId, state: manifest.state };
261
+ }
@@ -0,0 +1,64 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ const MAX_RECORDS = 10_000;
4
+
5
+ function instant(value) {
6
+ if (typeof value === 'number') return Number.isFinite(value) && value >= 0 ? value : null;
7
+ if (typeof value !== 'string') return null;
8
+ const match = /^(\d{4})-(\d{2})-(\d{2})T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.exec(value);
9
+ if (!match) return null;
10
+ const [, y, m, d] = match.map(Number);
11
+ const calendar = new Date(0);
12
+ calendar.setUTCFullYear(y, m - 1, d);
13
+ if (calendar.getUTCFullYear() !== y || calendar.getUTCMonth() !== m - 1 || calendar.getUTCDate() !== d) return null;
14
+ const ms = Date.parse(value);
15
+ return Number.isFinite(ms) ? ms : null;
16
+ }
17
+
18
+ function ids(values) {
19
+ if (!Array.isArray(values) || values.length > MAX_RECORDS) throw new Error('HISTORY_INVALID_IDENTITIES');
20
+ if (values.some(id => typeof id !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/.test(id))) throw new Error('HISTORY_INVALID_IDENTITY');
21
+ if (new Set(values).size !== values.length) throw new Error('HISTORY_DUPLICATE_IDENTITY');
22
+ return [...values].sort();
23
+ }
24
+
25
+ function records(values) {
26
+ if (!Array.isArray(values) || values.some(row => !row || typeof row !== 'object')) throw new Error('HISTORY_INVALID_RECORDS');
27
+ ids(values.map(row => row.id));
28
+ return [...values].sort((a, b) => a.id.localeCompare(b.id, 'en'));
29
+ }
30
+
31
+ /** Pure preview only. No filesystem or project-path operations are performed. */
32
+ export function planHistoryCleanup(snapshot, { operation = 'archive', scope = 'all', before } = {}) {
33
+ if (!['archive', 'delete'].includes(operation) || !['all', 'before'].includes(scope)) throw new Error('HISTORY_INVALID_OPTIONS');
34
+ const cutoff = scope === 'before' ? instant(before) : null;
35
+ if (scope === 'before' && (typeof before !== 'string' || cutoff === null)) throw new Error('HISTORY_INVALID_CUTOFF');
36
+ const workspaces = records(snapshot.workspaces);
37
+ const sessions = records(snapshot.sessions);
38
+ const active = ids(snapshot.activeSessionIds);
39
+ const selected = new Set(sessions.filter(row => scope === 'all' || (instant(row.createdAt) !== null && instant(row.createdAt) < cutoff)).map(row => row.id));
40
+ const workspaceIds = workspaces.filter(row => {
41
+ const children = ids(row.sessionIds);
42
+ return (scope === 'all' || (instant(row.createdAt) !== null && instant(row.createdAt) < cutoff))
43
+ && children.every(id => selected.has(id));
44
+ }).map(row => row.id).sort();
45
+ const sessionIds = [...selected].sort();
46
+ const signature = {
47
+ operation, scope, cutoff,
48
+ workspaces: workspaces.map(row => ({ id: row.id, createdAt: row.createdAt, updatedAt: row.updatedAt, sessionIds: ids(row.sessionIds) })),
49
+ sessions: sessions.map(row => ({ id: row.id, createdAt: row.createdAt, revision: row.revision })),
50
+ active,
51
+ };
52
+ return {
53
+ schemaVersion: 1, operation, scope,
54
+ before: cutoff === null ? null : new Date(cutoff).toISOString(),
55
+ timeBasis: 'createdAt', workspaceIds, sessionIds,
56
+ counts: { workspaces: workspaceIds.length, sessions: sessionIds.length },
57
+ protectedCounts: { workspaces: workspaces.length - workspaceIds.length, sessions: sessions.length - sessionIds.length },
58
+ executable: active.length === 0 && (workspaceIds.length > 0 || sessionIds.length > 0),
59
+ blockedReason: active.length > 0 ? 'ACTIVE_SESSIONS' : workspaceIds.length + sessionIds.length === 0 ? 'EMPTY_SELECTION' : null,
60
+ activeSessionCount: active.length,
61
+ projectFilesAffected: false,
62
+ revision: createHash('sha256').update(JSON.stringify(signature)).digest('hex'),
63
+ };
64
+ }
@@ -0,0 +1,32 @@
1
+ import { localRequestCore, originAuthorityMatches } from '../local-request-guard.mjs';
2
+ import { safeHistoryError } from './service.mjs';
3
+
4
+ export function registerHistoryHttp(webServer, service) {
5
+ return webServer.register({ kind: 'prefix', path: '/_dsh/dsh-crew/history', async handler(req, res) {
6
+ const send = (status, body) => { res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); res.end(JSON.stringify(body)); };
7
+ if (!localRequestCore(req) || (req.headers.origin !== undefined && !originAuthorityMatches(req.headers.origin, req.headers.host))) return send(403, { ok: false, code: 'HISTORY_LOCAL_ORIGIN_REQUIRED' });
8
+ const action = new URL(req.url, 'http://127.0.0.1').pathname.split('/').pop();
9
+ try {
10
+ if (req.method === 'GET') {
11
+ if (action === 'status') return send(200, { ok: true, status: service.status() });
12
+ if (action === 'archives') return send(200, { ok: true, archives: service.archives() });
13
+ return send(405, { ok: false, code: 'HISTORY_METHOD_NOT_ALLOWED' });
14
+ }
15
+ if (req.method !== 'POST') return send(405, { ok: false, code: 'HISTORY_METHOD_NOT_ALLOWED' });
16
+ let bytes = 0; const chunks = [];
17
+ for await (const chunk of req) { bytes += chunk.length; if (bytes > 4096) return send(413, { ok: false, code: 'HISTORY_BODY_TOO_LARGE' }); chunks.push(Buffer.from(chunk)); }
18
+ let body;
19
+ try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { return send(400, { ok: false, code: 'HISTORY_INVALID_JSON' }); }
20
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return send(400, { ok: false, code: 'HISTORY_INVALID_JSON' });
21
+ if (action === 'preview') return send(200, { ok: true, preview: await service.preview(body) });
22
+ if (action === 'execute') return send(202, { ok: true, status: await service.execute(body) });
23
+ if (action === 'restore') return send(202, { ok: true, status: await service.restore(body) });
24
+ if (action === 'recover') return send(202, { ok: true, status: await service.recover(body) });
25
+ if (action === 'fenced-check') return send(200, { ok: await service.fencedCheck() });
26
+ return send(404, { ok: false, code: 'HISTORY_NOT_FOUND' });
27
+ } catch (error) {
28
+ const code = safeHistoryError(error);
29
+ return send(/INVALID|CONFIRMATION/.test(code) ? 400 : /UNSUPPORTED|UNAVAILABLE/.test(code) ? 503 : 409, { ok: false, code });
30
+ }
31
+ } });
32
+ }
@@ -0,0 +1,86 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { archiveHistory, restoreHistory, recoverHistory, finalizeHistoryDeletion, readHistoryManifest,
3
+ historyPath, readHistoryBytes, historyHash, decodeWorkspaceStore } from './archive-store.mjs';
4
+ import { readHistoryState, writeHistoryState, TERMINAL_HISTORY_PHASES } from './state.mjs';
5
+ import { safeHistoryError } from './service.mjs';
6
+
7
+ function verifyDisk(root, manifest) {
8
+ const restored = manifest.state === 'RESTORED';
9
+ const rolledBack = manifest.state === 'ROLLED_BACK';
10
+ if (!['APPLIED', 'RESTORED', 'ROLLED_BACK', 'DELETING', 'DELETED'].includes(manifest.state)) throw Error('HISTORY_DISK_NOT_VERIFIED');
11
+ const expected = restored ? manifest.restoreAfter : rolledBack ? manifest.before : manifest.after;
12
+ const current = decodeWorkspaceStore(readHistoryBytes(historyPath(root, 'harness/storages/workspace.json')));
13
+ if (JSON.stringify(current) !== JSON.stringify(expected)) throw Error('HISTORY_DISK_NOT_VERIFIED');
14
+ for (const file of manifest.files) {
15
+ const path = historyPath(root, file.relativePath);
16
+ if (restored || rolledBack) {
17
+ if (!existsSync(path) || historyHash(readHistoryBytes(path)) !== file.sha256) throw Error('HISTORY_DISK_NOT_VERIFIED');
18
+ } else if (existsSync(path)) throw Error('HISTORY_DISK_NOT_VERIFIED');
19
+ }
20
+ }
21
+
22
+ /** Detached executor core; injected boundaries make the real transaction testable. */
23
+ export async function runHistoryOperation({ crewRoot, id, acquire, release, supervisor, checkFence,
24
+ assertStopped, verifyRunning, recover = false }) {
25
+ let state = readHistoryState(crewRoot);
26
+ if (!state || state.id !== id) throw Error('HISTORY_OPERATION_CHANGED');
27
+ if (TERMINAL_HISTORY_PHASES.includes(state.phase)) return state;
28
+ if (!recover && state.phase !== 'QUEUED') throw Error('HISTORY_RECOVERY_REQUIRED');
29
+ const lock = await acquire();
30
+ if (!lock?.ok) throw Error('HISTORY_UPDATE_BUSY');
31
+ const originalPhase = state.phase === 'RECOVERY_REQUIRED' ? state.resumePhase : state.phase;
32
+ const save = phase => { state = { ...state, phase, code: undefined }; writeHistoryState(crewRoot, state); };
33
+ let result;
34
+ let ownsState = false;
35
+ try {
36
+ const currentState = readHistoryState(crewRoot);
37
+ if (currentState?.id !== state.id || currentState.phase !== state.phase) throw Error('HISTORY_OPERATION_CHANGED');
38
+ ownsState = true;
39
+ if (recover && originalPhase === 'QUEUED') {
40
+ state.code = 'HISTORY_CANCELLED_BEFORE_STOP';
41
+ state.phase = 'FAILED'; writeHistoryState(crewRoot, state);
42
+ return state;
43
+ }
44
+ // A process may have died after a successful restart but before finalization.
45
+ const alreadyStarted = recover && ['STARTING', 'VERIFYING'].includes(originalPhase) && await verifyRunning(state);
46
+ if (!alreadyStarted) {
47
+ if (!recover || await assertStopped(state) !== true) await checkFence(state);
48
+ save('STOPPING');
49
+ const stopped = await supervisor.stopOwnedBackend({ lease: state.lease, runtimeId: state.runtimeId });
50
+ if (!stopped?.ok || await assertStopped(state) !== true) throw Error('HISTORY_STOP_NOT_VERIFIED');
51
+ const archiveId = state.operation === 'restore' ? state.archiveId : state.id;
52
+ const manifestFile = historyPath(crewRoot, `history/transactions/${archiveId}/manifest.json`);
53
+ save('APPLYING');
54
+ const m = existsSync(manifestFile) ? readHistoryManifest(crewRoot, archiveId) : null;
55
+ if (recover && m && ['PREPARING', 'PREPARED', 'APPLYING', 'RESTORING'].includes(m.state)) {
56
+ result = await recoverHistory({ crewRoot, archiveId, assertStopped: () => assertStopped(state) });
57
+ state.rolledBack = true;
58
+ } else if (recover && m && ((state.operation !== 'restore' && ['APPLIED', 'DELETING', 'DELETED', 'ROLLED_BACK'].includes(m.state)) || m.state === 'RESTORED')) {
59
+ result = { id: archiveId, state: m.state };
60
+ state.rolledBack = m.state === 'ROLLED_BACK';
61
+ } else if (state.operation === 'restore') {
62
+ result = await restoreHistory({ crewRoot, archiveId, assertStopped: () => assertStopped(state) });
63
+ } else {
64
+ result = await archiveHistory({ crewRoot, archiveId, request: state.request, assertStopped: () => assertStopped(state) });
65
+ }
66
+ state.archiveId = result.id;
67
+ const manifest = readHistoryManifest(crewRoot, result.id);
68
+ // Recovery of a half-restored archive leaves the original archived state.
69
+ verifyDisk(crewRoot, manifest);
70
+ save('STARTING');
71
+ const started = await supervisor.startOwnedBackend({ lease: state.lease, runtimeId: state.runtimeId });
72
+ if (!started?.ok) throw Error('HISTORY_RESTART_NOT_VERIFIED');
73
+ }
74
+ save('VERIFYING');
75
+ if (await verifyRunning(state) !== true) throw Error('HISTORY_RESTART_NOT_VERIFIED');
76
+ if (state.operation === 'delete' && !state.rolledBack) {
77
+ await finalizeHistoryDeletion({ crewRoot, archiveId: state.archiveId ?? state.id, assertRestarted: () => verifyRunning(state) });
78
+ }
79
+ save(state.rolledBack ? 'ROLLED_BACK' : 'DONE');
80
+ return state;
81
+ } catch (error) {
82
+ state = { ...state, phase: 'RECOVERY_REQUIRED', resumePhase: state.phase, code: safeHistoryError(error) };
83
+ if (ownsState) writeHistoryState(crewRoot, state);
84
+ throw error;
85
+ } finally { await release(lock); }
86
+ }
@@ -0,0 +1,51 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { createConnection } from 'node:net';
5
+ import { acquireUpdateLock, releaseUpdateLock, createCrewSupervisor } from '../install/npx-lifecycle.mjs';
6
+ import { readMaintenanceSession } from '../supervisor/restart-request.mjs';
7
+ import { runHistoryOperation } from './operation.mjs';
8
+ import { readHistoryState } from './state.mjs';
9
+
10
+ async function portFree() {
11
+ return new Promise(resolve => {
12
+ const socket = createConnection({ host: '127.0.0.1', port: 3210 });
13
+ socket.setTimeout(2000);
14
+ socket.once('connect', () => { socket.destroy(); resolve(false); });
15
+ socket.once('timeout', () => { socket.destroy(); resolve(false); });
16
+ socket.once('error', error => { socket.destroy(); resolve(error.code === 'ECONNREFUSED'); });
17
+ });
18
+ }
19
+
20
+ export async function runProductionHistory({ id, recover = false } = {}) {
21
+ if (process.platform !== 'win32') throw Error('HISTORY_PLATFORM_UNSUPPORTED');
22
+ const home = homedir(); const crewRoot = join(home, '.config', 'dsh-crew');
23
+ const state = readHistoryState(crewRoot);
24
+ id ??= state?.id;
25
+ if (!state || id !== state.id) throw Error('HISTORY_OPERATION_CHANGED');
26
+ return runHistoryOperation({ crewRoot, id, recover,
27
+ acquire: () => acquireUpdateLock({ home }), release: lock => releaseUpdateLock({ home, nonce: lock.nonce }),
28
+ supervisor: createCrewSupervisor({ home }),
29
+ checkFence: async () => {
30
+ const response = await fetch('http://127.0.0.1:3210/_dsh/dsh-crew/history/fenced-check', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', signal: AbortSignal.timeout(30000) });
31
+ if (!response.ok || (await response.json()).ok !== true) throw Error('HISTORY_FENCE_NOT_IDLE');
32
+ },
33
+ assertStopped: async s => {
34
+ const lease = readMaintenanceSession(crewRoot);
35
+ return lease.ok && lease.state === 'present' && lease.session.lease === s.lease && lease.session.runtime_id === s.runtimeId && await portFree();
36
+ },
37
+ verifyRunning: async s => {
38
+ try {
39
+ const response = await fetch('http://127.0.0.1:3210/_dsh/dsh-crew/runtime', { signal: AbortSignal.timeout(3000) });
40
+ const runtime = await response.json();
41
+ return response.ok && runtime.ok === true && runtime.service === 'dsh-crew-hub' && runtime.profile === 'dsh-crew'
42
+ && runtime.execution_plane === 'hub-3210' && runtime.listen_port === 3210 && runtime.protocol_version === 1
43
+ && runtime.dsh_version === '0.1.2-rc.1' && typeof runtime.runtime_id === 'string' && runtime.runtime_id !== s.runtimeId;
44
+ } catch { return false; }
45
+ },
46
+ });
47
+ }
48
+
49
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
50
+ runProductionHistory({ id: process.argv[2], recover: process.argv.includes('--recover') }).catch(() => { process.exitCode = 1; });
51
+ }