@ran-sh/dsh-crew 1.9.0 → 1.9.1

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 (151) hide show
  1. package/.claude-plugin/marketplace.json +16 -16
  2. package/.claude-plugin/plugin.json +14 -14
  3. package/LICENSE +21 -21
  4. package/README.de.md +359 -359
  5. package/README.es.md +359 -359
  6. package/README.fr.md +359 -359
  7. package/README.hi.md +359 -359
  8. package/README.id.md +359 -359
  9. package/README.ja.md +359 -359
  10. package/README.ko.md +359 -359
  11. package/README.md +126 -126
  12. package/README.pt.md +359 -359
  13. package/README.ru.md +359 -359
  14. package/README.th.md +359 -359
  15. package/README.tr.md +359 -359
  16. package/README.vi.md +359 -359
  17. package/README.zh-TW.md +359 -359
  18. package/README.zh.md +116 -116
  19. package/agents/ds-flash.md +24 -24
  20. package/agents/ds-pro.md +24 -24
  21. package/agents/ds-reviewer.md +23 -23
  22. package/agents/ds-worker.md +23 -23
  23. package/bin/dsh-crew.mjs +16 -16
  24. package/codex/agents/ds-flash.toml +34 -34
  25. package/codex/agents/ds-pro.toml +34 -34
  26. package/codex/agents/ds-reviewer.toml +32 -32
  27. package/codex/agents/ds-worker.toml +32 -32
  28. package/codex/prompts/dsh-config.md +19 -19
  29. package/codex/prompts/dsh-status.md +5 -5
  30. package/commands/config.md +23 -23
  31. package/commands/off.md +5 -5
  32. package/commands/on.md +5 -5
  33. package/commands/status.md +9 -9
  34. package/cordis.patch.yml +4 -4
  35. package/docs/gpt-relay-extension.md +103 -103
  36. package/docs/installation.md +138 -138
  37. package/docs/job-contracts.md +103 -103
  38. package/docs/readiness-matrix.md +85 -85
  39. package/docs/ui-surfaces.md +107 -107
  40. package/official-web-bridge/cordis.patch.yml +4 -4
  41. package/official-web-bridge/entry.mjs +1 -1
  42. package/official-web-bridge/overlay-entry.mjs +59 -59
  43. package/official-web-bridge/package.json +25 -25
  44. package/package.json +1 -1
  45. package/scripts/build-client.mjs +49 -49
  46. package/scripts/live-crew-smoke.mjs +39 -39
  47. package/scripts/live-policy-matrix.mjs +177 -177
  48. package/scripts/policy-probe.mjs +101 -101
  49. package/scripts/remove-legacy-official-bridge.ps1 +89 -89
  50. package/scripts/setup.mjs +393 -393
  51. package/scripts/smoke-real.mjs +110 -110
  52. package/scripts/smoke.mjs +78 -78
  53. package/scripts/verify-crew-ui-polish.mjs +145 -145
  54. package/scripts/verify-history-ui.mjs +97 -97
  55. package/scripts/verify-installer-fix.mjs +26 -26
  56. package/scripts/verify-npm-install.mjs +311 -311
  57. package/scripts/verify-official-bridge-e2e.mjs +192 -192
  58. package/src/adaptive-routing.mjs +260 -260
  59. package/src/client/activation-summary.tsx +64 -64
  60. package/src/client/collapsible-sections.mjs +55 -55
  61. package/src/client/history-panel.tsx +108 -108
  62. package/src/client/host-readiness.mjs +71 -71
  63. package/src/client/index.tsx +1711 -1711
  64. package/src/client/model-callability-view.mjs +17 -17
  65. package/src/client/panel-chrome.tsx +40 -40
  66. package/src/client/quick-entry.tsx +10 -10
  67. package/src/client/quick-panel.tsx +275 -275
  68. package/src/client/readiness-envelope.mjs +34 -34
  69. package/src/client/surface-detection.mjs +43 -43
  70. package/src/config-readiness.mjs +226 -226
  71. package/src/credential-reference.mjs +38 -38
  72. package/src/delivery.mjs +205 -205
  73. package/src/dsh-cli-runtime.mjs +1021 -1021
  74. package/src/dsh-cohort.mjs +20 -20
  75. package/src/extension-contract.mjs +104 -104
  76. package/src/failure-classification.mjs +201 -201
  77. package/src/history/admission-gate.mjs +67 -67
  78. package/src/history/archive-store.mjs +272 -272
  79. package/src/history/cleanup-plan.mjs +89 -89
  80. package/src/history/http.mjs +32 -32
  81. package/src/history/operation.mjs +86 -86
  82. package/src/history/runner-detach.mjs +34 -34
  83. package/src/history/runner.mjs +52 -52
  84. package/src/history/runtime.mjs +36 -36
  85. package/src/history/service.mjs +177 -177
  86. package/src/history/state.mjs +27 -27
  87. package/src/hub/entry.mjs +104 -104
  88. package/src/hub/index.mjs +2694 -2694
  89. package/src/hub-client.mjs +154 -154
  90. package/src/hub-compatibility.mjs +42 -42
  91. package/src/i18n.mjs +19 -19
  92. package/src/information-flow.mjs +67 -67
  93. package/src/install/cli.mjs +28 -28
  94. package/src/install/install-legacy.mjs +711 -711
  95. package/src/install/install.mjs +483 -483
  96. package/src/install/npx-lifecycle.mjs +3456 -3456
  97. package/src/install/official-frontend-assets.mjs +78 -78
  98. package/src/install/official-web.mjs +95 -95
  99. package/src/install/payload-content.mjs +88 -88
  100. package/src/install/windows-startup.mjs +236 -236
  101. package/src/install/windows-supervisor-adapter.mjs +443 -443
  102. package/src/install/windows-supervisor-lifecycle.mjs +782 -782
  103. package/src/install/zcode.mjs +397 -397
  104. package/src/job-contracts.mjs +255 -255
  105. package/src/local-request-guard.mjs +60 -60
  106. package/src/mcp-runtime.mjs +340 -340
  107. package/src/model-callability-contract.mjs +79 -79
  108. package/src/model-catalog.mjs +180 -180
  109. package/src/model-routing.mjs +586 -586
  110. package/src/model-schedule.mjs +207 -207
  111. package/src/official-web-bridge.mjs +447 -447
  112. package/src/policy.mjs +235 -235
  113. package/src/provider-delete-adapters.mjs +1934 -1934
  114. package/src/provider-health.mjs +130 -130
  115. package/src/provider-inventory.mjs +182 -182
  116. package/src/provider-layer-migration-adapters.mjs +759 -759
  117. package/src/provider-layer-migration.mjs +198 -198
  118. package/src/provider-lifecycle-state.mjs +103 -103
  119. package/src/provider-lifecycle.mjs +252 -252
  120. package/src/provider-profile-store.mjs +390 -390
  121. package/src/provider-settings-store.mjs +633 -633
  122. package/src/provider-store-lock.mjs +67 -67
  123. package/src/readiness-matrix.mjs +181 -181
  124. package/src/removable-waiter.mjs +29 -29
  125. package/src/role-profiles.mjs +107 -107
  126. package/src/runtime-controls.mjs +84 -84
  127. package/src/runtime-identity-contract.mjs +34 -34
  128. package/src/runtime-identity.mjs +235 -235
  129. package/src/runtime-readiness-snapshot.mjs +285 -285
  130. package/src/server.mjs +581 -581
  131. package/src/session-origins.mjs +60 -60
  132. package/src/standalone-sdk.mjs +23 -23
  133. package/src/status-shard.mjs +63 -63
  134. package/src/structured-error-code.mjs +38 -38
  135. package/src/supervisor/restart-request.mjs +256 -256
  136. package/src/workflow-runtime.mjs +739 -739
  137. package/src/workflow.mjs +155 -155
  138. package/src/workspace-audit.mjs +231 -231
  139. package/src/workspace-context.mjs +146 -146
  140. package/src/workspace-isolation.mjs +455 -455
  141. package/src/workspace-readiness.mjs +32 -32
  142. package/statusline/statusline.sh +14 -14
  143. package/statusline/worker-segment.sh +35 -35
  144. package/windows/start-dsh-crew.cmd +57 -57
  145. package/windows/start-dsh-crew.ps1 +1302 -1302
  146. package/windows/supervisor-control.ps1 +467 -467
  147. package/worker.cordis.yml +67 -67
  148. package/zcode/agents/ds-reviewer.md +31 -31
  149. package/zcode/agents/ds-worker.md +31 -31
  150. package/zcode/commands/dsh-config.md +17 -17
  151. package/zcode/commands/dsh-status.md +5 -5
@@ -1,89 +1,89 @@
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
- /**
32
- * Pure preview only. No filesystem or project-path operations are performed.
33
- *
34
- * `scope` defaults to `crew`: a caller that forgets to narrow the range gets the
35
- * one range that cannot remove sessions the operator opened themselves. Widening
36
- * to `all` is always an explicit choice.
37
- */
38
- export function planHistoryCleanup(snapshot, { operation = 'archive', scope = 'crew', before } = {}) {
39
- if (!['archive', 'delete'].includes(operation) || !['all', 'crew', 'worktree', 'before'].includes(scope)) throw new Error('HISTORY_INVALID_OPTIONS');
40
- const cutoff = scope === 'before' ? instant(before) : null;
41
- if (scope === 'before' && (typeof before !== 'string' || cutoff === null)) throw new Error('HISTORY_INVALID_CUTOFF');
42
- const workspaces = records(snapshot.workspaces);
43
- const sessions = records(snapshot.sessions);
44
- const active = ids(snapshot.activeSessionIds);
45
- // `crew` and `worktree` narrow by provenance, never by project path: a session
46
- // is Crew's own only when Crew recorded creating it, and worktree scope adds
47
- // the isolated-workspace marker the hub stamps at dispatch.
48
- const origin = new Set(Array.isArray(snapshot.crewSessionIds) ? snapshot.crewSessionIds : []);
49
- const admitted = (row) => {
50
- if (scope === 'crew') return origin.has(row.id);
51
- if (scope === 'worktree') return origin.has(row.id) && row.worktree === true;
52
- return true;
53
- };
54
- const withinTime = (row) => cutoff === null
55
- || (instant(row.createdAt) !== null && instant(row.createdAt) < cutoff);
56
- const selected = new Set(sessions.filter(row => admitted(row) && withinTime(row)).map(row => row.id));
57
- // A workspace carries no provenance of its own: it follows its sessions, and
58
- // only when every one of them is selected. Checking `admitted(row)` here would
59
- // test a workspace id against a session ledger and always fail. A provenance
60
- // scope additionally requires at least one selected child — an empty workspace
61
- // holds no Crew work, so it is not Crew's to remove; `all` keeps its historical
62
- // behaviour of following an empty workspace.
63
- const requiresOwnedChild = scope === 'crew' || scope === 'worktree';
64
- const workspaceIds = workspaces.filter(row => {
65
- const children = ids(row.sessionIds);
66
- return withinTime(row) && (!requiresOwnedChild || children.length > 0)
67
- && children.every(id => selected.has(id));
68
- }).map(row => row.id).sort();
69
- const sessionIds = [...selected].sort();
70
- const signature = {
71
- operation, scope, cutoff,
72
- workspaces: workspaces.map(row => ({ id: row.id, createdAt: row.createdAt, updatedAt: row.updatedAt, sessionIds: ids(row.sessionIds) })),
73
- sessions: sessions.map(row => ({ id: row.id, createdAt: row.createdAt, revision: row.revision })),
74
- active,
75
- crewSessionIds: [...origin].sort(),
76
- };
77
- return {
78
- schemaVersion: 1, operation, scope,
79
- before: cutoff === null ? null : new Date(cutoff).toISOString(),
80
- timeBasis: 'createdAt', workspaceIds, sessionIds,
81
- counts: { workspaces: workspaceIds.length, sessions: sessionIds.length },
82
- protectedCounts: { workspaces: workspaces.length - workspaceIds.length, sessions: sessions.length - sessionIds.length },
83
- executable: active.length === 0 && (workspaceIds.length > 0 || sessionIds.length > 0),
84
- blockedReason: active.length > 0 ? 'ACTIVE_SESSIONS' : workspaceIds.length + sessionIds.length === 0 ? 'EMPTY_SELECTION' : null,
85
- activeSessionCount: active.length,
86
- projectFilesAffected: false,
87
- revision: createHash('sha256').update(JSON.stringify(signature)).digest('hex'),
88
- };
89
- }
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
+ /**
32
+ * Pure preview only. No filesystem or project-path operations are performed.
33
+ *
34
+ * `scope` defaults to `crew`: a caller that forgets to narrow the range gets the
35
+ * one range that cannot remove sessions the operator opened themselves. Widening
36
+ * to `all` is always an explicit choice.
37
+ */
38
+ export function planHistoryCleanup(snapshot, { operation = 'archive', scope = 'crew', before } = {}) {
39
+ if (!['archive', 'delete'].includes(operation) || !['all', 'crew', 'worktree', 'before'].includes(scope)) throw new Error('HISTORY_INVALID_OPTIONS');
40
+ const cutoff = scope === 'before' ? instant(before) : null;
41
+ if (scope === 'before' && (typeof before !== 'string' || cutoff === null)) throw new Error('HISTORY_INVALID_CUTOFF');
42
+ const workspaces = records(snapshot.workspaces);
43
+ const sessions = records(snapshot.sessions);
44
+ const active = ids(snapshot.activeSessionIds);
45
+ // `crew` and `worktree` narrow by provenance, never by project path: a session
46
+ // is Crew's own only when Crew recorded creating it, and worktree scope adds
47
+ // the isolated-workspace marker the hub stamps at dispatch.
48
+ const origin = new Set(Array.isArray(snapshot.crewSessionIds) ? snapshot.crewSessionIds : []);
49
+ const admitted = (row) => {
50
+ if (scope === 'crew') return origin.has(row.id);
51
+ if (scope === 'worktree') return origin.has(row.id) && row.worktree === true;
52
+ return true;
53
+ };
54
+ const withinTime = (row) => cutoff === null
55
+ || (instant(row.createdAt) !== null && instant(row.createdAt) < cutoff);
56
+ const selected = new Set(sessions.filter(row => admitted(row) && withinTime(row)).map(row => row.id));
57
+ // A workspace carries no provenance of its own: it follows its sessions, and
58
+ // only when every one of them is selected. Checking `admitted(row)` here would
59
+ // test a workspace id against a session ledger and always fail. A provenance
60
+ // scope additionally requires at least one selected child — an empty workspace
61
+ // holds no Crew work, so it is not Crew's to remove; `all` keeps its historical
62
+ // behaviour of following an empty workspace.
63
+ const requiresOwnedChild = scope === 'crew' || scope === 'worktree';
64
+ const workspaceIds = workspaces.filter(row => {
65
+ const children = ids(row.sessionIds);
66
+ return withinTime(row) && (!requiresOwnedChild || children.length > 0)
67
+ && children.every(id => selected.has(id));
68
+ }).map(row => row.id).sort();
69
+ const sessionIds = [...selected].sort();
70
+ const signature = {
71
+ operation, scope, cutoff,
72
+ workspaces: workspaces.map(row => ({ id: row.id, createdAt: row.createdAt, updatedAt: row.updatedAt, sessionIds: ids(row.sessionIds) })),
73
+ sessions: sessions.map(row => ({ id: row.id, createdAt: row.createdAt, revision: row.revision })),
74
+ active,
75
+ crewSessionIds: [...origin].sort(),
76
+ };
77
+ return {
78
+ schemaVersion: 1, operation, scope,
79
+ before: cutoff === null ? null : new Date(cutoff).toISOString(),
80
+ timeBasis: 'createdAt', workspaceIds, sessionIds,
81
+ counts: { workspaces: workspaceIds.length, sessions: sessionIds.length },
82
+ protectedCounts: { workspaces: workspaces.length - workspaceIds.length, sessions: sessions.length - sessionIds.length },
83
+ executable: active.length === 0 && (workspaceIds.length > 0 || sessionIds.length > 0),
84
+ blockedReason: active.length > 0 ? 'ACTIVE_SESSIONS' : workspaceIds.length + sessionIds.length === 0 ? 'EMPTY_SELECTION' : null,
85
+ activeSessionCount: active.length,
86
+ projectFilesAffected: false,
87
+ revision: createHash('sha256').update(JSON.stringify(signature)).digest('hex'),
88
+ };
89
+ }
@@ -1,32 +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
- }
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
+ }
@@ -1,86 +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
- }
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
+ }
@@ -1,34 +1,34 @@
1
- // Detach the history maintenance runner from the hub's process tree.
2
- //
3
- // The runner must outlive the hub it is maintaining: its transaction stops 3210,
4
- // rewrites the session store while nothing holds it, then starts 3210 again. The
5
- // supervisor stops a service by killing that service's whole tracked process
6
- // tree, and the tree is walked through `ParentProcessId`. A runner spawned
7
- // directly by the hub is therefore inside the tree and is killed by the very stop
8
- // it requested, leaving the transaction stranded in STOPPING with a stale update
9
- // lock and no operator-side way to finish it.
10
- //
11
- // This launcher is the intermediate: it starts the runner, confirms the process
12
- // exists, and exits immediately. The runner's parent is then a process that no
13
- // longer exists, so the supervisor's tree walk cannot reach it and it survives.
14
- //
15
- // Usage: node runner-detach.mjs <runnerPath> [...runnerArgs]
16
- import { spawn } from 'node:child_process';
17
-
18
- const [runnerPath, ...runnerArgs] = process.argv.slice(2);
19
- if (!runnerPath) {
20
- process.stderr.write('runner-detach: missing runner path\n');
21
- process.exit(2);
22
- }
23
-
24
- const child = spawn(process.execPath, [runnerPath, ...runnerArgs], {
25
- detached: true,
26
- windowsHide: true,
27
- stdio: 'ignore',
28
- env: { ...process.env },
29
- });
30
- child.once('error', () => process.exit(1));
31
- child.once('spawn', () => {
32
- child.unref();
33
- process.exit(0);
34
- });
1
+ // Detach the history maintenance runner from the hub's process tree.
2
+ //
3
+ // The runner must outlive the hub it is maintaining: its transaction stops 3210,
4
+ // rewrites the session store while nothing holds it, then starts 3210 again. The
5
+ // supervisor stops a service by killing that service's whole tracked process
6
+ // tree, and the tree is walked through `ParentProcessId`. A runner spawned
7
+ // directly by the hub is therefore inside the tree and is killed by the very stop
8
+ // it requested, leaving the transaction stranded in STOPPING with a stale update
9
+ // lock and no operator-side way to finish it.
10
+ //
11
+ // This launcher is the intermediate: it starts the runner, confirms the process
12
+ // exists, and exits immediately. The runner's parent is then a process that no
13
+ // longer exists, so the supervisor's tree walk cannot reach it and it survives.
14
+ //
15
+ // Usage: node runner-detach.mjs <runnerPath> [...runnerArgs]
16
+ import { spawn } from 'node:child_process';
17
+
18
+ const [runnerPath, ...runnerArgs] = process.argv.slice(2);
19
+ if (!runnerPath) {
20
+ process.stderr.write('runner-detach: missing runner path\n');
21
+ process.exit(2);
22
+ }
23
+
24
+ const child = spawn(process.execPath, [runnerPath, ...runnerArgs], {
25
+ detached: true,
26
+ windowsHide: true,
27
+ stdio: 'ignore',
28
+ env: { ...process.env },
29
+ });
30
+ child.once('error', () => process.exit(1));
31
+ child.once('spawn', () => {
32
+ child.unref();
33
+ process.exit(0);
34
+ });
@@ -1,52 +1,52 @@
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
- import { TARGET_DSH_VERSION } from '../dsh-cohort.mjs';
10
-
11
- async function portFree() {
12
- return new Promise(resolve => {
13
- const socket = createConnection({ host: '127.0.0.1', port: 3210 });
14
- socket.setTimeout(2000);
15
- socket.once('connect', () => { socket.destroy(); resolve(false); });
16
- socket.once('timeout', () => { socket.destroy(); resolve(false); });
17
- socket.once('error', error => { socket.destroy(); resolve(error.code === 'ECONNREFUSED'); });
18
- });
19
- }
20
-
21
- export async function runProductionHistory({ id, recover = false } = {}) {
22
- if (process.platform !== 'win32') throw Error('HISTORY_PLATFORM_UNSUPPORTED');
23
- const home = homedir(); const crewRoot = join(home, '.config', 'dsh-crew');
24
- const state = readHistoryState(crewRoot);
25
- id ??= state?.id;
26
- if (!state || id !== state.id) throw Error('HISTORY_OPERATION_CHANGED');
27
- return runHistoryOperation({ crewRoot, id, recover,
28
- acquire: () => acquireUpdateLock({ home }), release: lock => releaseUpdateLock({ home, nonce: lock.nonce }),
29
- supervisor: createCrewSupervisor({ home }),
30
- checkFence: async () => {
31
- 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) });
32
- if (!response.ok || (await response.json()).ok !== true) throw Error('HISTORY_FENCE_NOT_IDLE');
33
- },
34
- assertStopped: async s => {
35
- const lease = readMaintenanceSession(crewRoot);
36
- return lease.ok && lease.state === 'present' && lease.session.lease === s.lease && lease.session.runtime_id === s.runtimeId && await portFree();
37
- },
38
- verifyRunning: async s => {
39
- try {
40
- const response = await fetch('http://127.0.0.1:3210/_dsh/dsh-crew/runtime', { signal: AbortSignal.timeout(3000) });
41
- const runtime = await response.json();
42
- return response.ok && runtime.ok === true && runtime.service === 'dsh-crew-hub' && runtime.profile === 'dsh-crew'
43
- && runtime.execution_plane === 'hub-3210' && runtime.listen_port === 3210 && runtime.protocol_version === 1
44
- && runtime.dsh_version === TARGET_DSH_VERSION && typeof runtime.runtime_id === 'string' && runtime.runtime_id !== s.runtimeId;
45
- } catch { return false; }
46
- },
47
- });
48
- }
49
-
50
- if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
51
- runProductionHistory({ id: process.argv[2], recover: process.argv.includes('--recover') }).catch(() => { process.exitCode = 1; });
52
- }
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
+ import { TARGET_DSH_VERSION } from '../dsh-cohort.mjs';
10
+
11
+ async function portFree() {
12
+ return new Promise(resolve => {
13
+ const socket = createConnection({ host: '127.0.0.1', port: 3210 });
14
+ socket.setTimeout(2000);
15
+ socket.once('connect', () => { socket.destroy(); resolve(false); });
16
+ socket.once('timeout', () => { socket.destroy(); resolve(false); });
17
+ socket.once('error', error => { socket.destroy(); resolve(error.code === 'ECONNREFUSED'); });
18
+ });
19
+ }
20
+
21
+ export async function runProductionHistory({ id, recover = false } = {}) {
22
+ if (process.platform !== 'win32') throw Error('HISTORY_PLATFORM_UNSUPPORTED');
23
+ const home = homedir(); const crewRoot = join(home, '.config', 'dsh-crew');
24
+ const state = readHistoryState(crewRoot);
25
+ id ??= state?.id;
26
+ if (!state || id !== state.id) throw Error('HISTORY_OPERATION_CHANGED');
27
+ return runHistoryOperation({ crewRoot, id, recover,
28
+ acquire: () => acquireUpdateLock({ home }), release: lock => releaseUpdateLock({ home, nonce: lock.nonce }),
29
+ supervisor: createCrewSupervisor({ home }),
30
+ checkFence: async () => {
31
+ 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) });
32
+ if (!response.ok || (await response.json()).ok !== true) throw Error('HISTORY_FENCE_NOT_IDLE');
33
+ },
34
+ assertStopped: async s => {
35
+ const lease = readMaintenanceSession(crewRoot);
36
+ return lease.ok && lease.state === 'present' && lease.session.lease === s.lease && lease.session.runtime_id === s.runtimeId && await portFree();
37
+ },
38
+ verifyRunning: async s => {
39
+ try {
40
+ const response = await fetch('http://127.0.0.1:3210/_dsh/dsh-crew/runtime', { signal: AbortSignal.timeout(3000) });
41
+ const runtime = await response.json();
42
+ return response.ok && runtime.ok === true && runtime.service === 'dsh-crew-hub' && runtime.profile === 'dsh-crew'
43
+ && runtime.execution_plane === 'hub-3210' && runtime.listen_port === 3210 && runtime.protocol_version === 1
44
+ && runtime.dsh_version === TARGET_DSH_VERSION && typeof runtime.runtime_id === 'string' && runtime.runtime_id !== s.runtimeId;
45
+ } catch { return false; }
46
+ },
47
+ });
48
+ }
49
+
50
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
51
+ runProductionHistory({ id: process.argv[2], recover: process.argv.includes('--recover') }).catch(() => { process.exitCode = 1; });
52
+ }