@ran-sh/dsh-crew 1.9.0 → 1.10.0

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 +3 -2
  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 +463 -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,36 +1,36 @@
1
- import { homedir } from 'node:os';
2
- import { join, resolve } from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
- import { spawn } from 'node:child_process';
5
- import { getHubRuntimeIdentity } from '../runtime-identity.mjs';
6
- import { TARGET_DSH_VERSION } from '../dsh-cohort.mjs';
7
- import { createHistoryService } from './service.mjs';
8
- import { registerHistoryHttp } from './http.mjs';
9
-
10
- export function registerRuntimeHistory(ctx) {
11
- const crewRoot = join(homedir(), '.config', 'dsh-crew');
12
- const runtime = getHubRuntimeIdentity();
13
- const owned = process.platform === 'win32' && runtime.dsh_version === TARGET_DSH_VERSION
14
- && resolve(process.env.DSH_HOME ?? '').toLowerCase() === resolve(join(crewRoot, 'harness')).toLowerCase();
15
- if (!owned) return () => {};
16
- return ctx.inject(['webServer', 'sessionPersistence', 'agents'], host => {
17
- const service = createHistoryService({ crewRoot, agents: host.agents, persistence: host.sessionPersistence,
18
- runtimeId: runtime.runtime_id, launch(id, recover = false) {
19
- return new Promise((accept, reject) => {
20
- // Spawn through a launcher that exits immediately: a runner parented by
21
- // this hub would sit inside the hub's tracked process tree and be killed
22
- // by the stop it requested. See runner-detach.mjs.
23
- const child = spawn(process.execPath, [
24
- fileURLToPath(new URL('./runner-detach.mjs', import.meta.url)),
25
- fileURLToPath(new URL('./runner.mjs', import.meta.url)),
26
- id, ...(recover ? ['--recover'] : []),
27
- ], {
28
- windowsHide: true, stdio: 'ignore', env: { ...process.env },
29
- });
30
- child.once('error', reject); child.once('spawn', () => { child.unref(); accept(); });
31
- });
32
- } });
33
- const disposeHttp = registerHistoryHttp(host.webServer, service);
34
- return () => { disposeHttp?.(); service.dispose(); };
35
- });
36
- }
1
+ import { homedir } from 'node:os';
2
+ import { join, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { spawn } from 'node:child_process';
5
+ import { getHubRuntimeIdentity } from '../runtime-identity.mjs';
6
+ import { TARGET_DSH_VERSION } from '../dsh-cohort.mjs';
7
+ import { createHistoryService } from './service.mjs';
8
+ import { registerHistoryHttp } from './http.mjs';
9
+
10
+ export function registerRuntimeHistory(ctx) {
11
+ const crewRoot = join(homedir(), '.config', 'dsh-crew');
12
+ const runtime = getHubRuntimeIdentity();
13
+ const owned = process.platform === 'win32' && runtime.dsh_version === TARGET_DSH_VERSION
14
+ && resolve(process.env.DSH_HOME ?? '').toLowerCase() === resolve(join(crewRoot, 'harness')).toLowerCase();
15
+ if (!owned) return () => {};
16
+ return ctx.inject(['webServer', 'sessionPersistence', 'agents'], host => {
17
+ const service = createHistoryService({ crewRoot, agents: host.agents, persistence: host.sessionPersistence,
18
+ runtimeId: runtime.runtime_id, launch(id, recover = false) {
19
+ return new Promise((accept, reject) => {
20
+ // Spawn through a launcher that exits immediately: a runner parented by
21
+ // this hub would sit inside the hub's tracked process tree and be killed
22
+ // by the stop it requested. See runner-detach.mjs.
23
+ const child = spawn(process.execPath, [
24
+ fileURLToPath(new URL('./runner-detach.mjs', import.meta.url)),
25
+ fileURLToPath(new URL('./runner.mjs', import.meta.url)),
26
+ id, ...(recover ? ['--recover'] : []),
27
+ ], {
28
+ windowsHide: true, stdio: 'ignore', env: { ...process.env },
29
+ });
30
+ child.once('error', reject); child.once('spawn', () => { child.unref(); accept(); });
31
+ });
32
+ } });
33
+ const disposeHttp = registerHistoryHttp(host.webServer, service);
34
+ return () => { disposeHttp?.(); service.dispose(); };
35
+ });
36
+ }
@@ -1,177 +1,177 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { existsSync, readdirSync } from 'node:fs';
3
- import { relative } from 'node:path';
4
- import { planHistoryCleanup } from './cleanup-plan.mjs';
5
- import { installHistoryAdmissionGate } from './admission-gate.mjs';
6
- import { historyHash, historyPath, readHistoryBytes, decodeWorkspaceStore, readHistoryManifest, isSessionArtifactPath } from './archive-store.mjs';
7
- import { readHistoryState, writeHistoryState, historyPending, publicHistoryState } from './state.mjs';
8
- import { readSessionOrigins } from '../session-origins.mjs';
9
- import { defaultWorktreeRoot } from '../workspace-isolation.mjs';
10
-
11
- export function createHistoryService({ crewRoot, agents, persistence, runtimeId, launch, now = Date.now }) {
12
- const gate = installHistoryAdmissionGate(agents, () => historyPending(crewRoot));
13
- const plans = new Map();
14
- let entering = false;
15
- // One row per materialized session: the header to archive and the artifact
16
- // location the backend reported. The 0.1.5 jsonl backend resolves each
17
- // session's current immutable generation itself and hands back a concrete
18
- // path through `listArtifacts()`. The earlier backend listed snapshots and
19
- // only hinted at a location, leaving the encoding to be probed below.
20
- async function readInventory() {
21
- if (typeof persistence?.listArtifacts === 'function') {
22
- const artifacts = await persistence.listArtifacts();
23
- if (!Array.isArray(artifacts)) throw Error('HISTORY_INVENTORY_INVALID');
24
- return artifacts.map(({ header, path }) => {
25
- if (typeof path !== 'string') throw Error('HISTORY_STORAGE_UNSUPPORTED');
26
- return { header, located: path, resolved: true };
27
- });
28
- }
29
- if (typeof persistence?.listSnapshots === 'function') {
30
- const rows = await persistence.listSnapshots();
31
- if (!Array.isArray(rows)) throw Error('HISTORY_INVENTORY_INVALID');
32
- return rows.map(({ header }) => {
33
- const location = persistence.locate(header);
34
- if (location?.kind !== 'jsonl') throw Error('HISTORY_STORAGE_UNSUPPORTED');
35
- return { header, located: location.path, resolved: false };
36
- });
37
- }
38
- throw Error('HISTORY_STORAGE_UNSUPPORTED');
39
- }
40
- async function snapshot(options) {
41
- if (typeof persistence?.locate !== 'function') throw Error('HISTORY_STORAGE_UNSUPPORTED');
42
- // Refuse only a backend that explicitly declares it exposes no per-session
43
- // raw artifact. The 0.1.5 backend dropped the flag rather than setting it
44
- // false, and the artifact path itself is validated below either way.
45
- if (persistence.supportsRawArtifacts === false) throw Error('HISTORY_STORAGE_UNSUPPORTED');
46
- const bytes = readHistoryBytes(historyPath(crewRoot, 'harness/storages/workspace.json'));
47
- const store = decodeWorkspaceStore(bytes);
48
- const listed = await readInventory();
49
- if (listed.length > 10000) throw Error('HISTORY_INVENTORY_TOO_LARGE');
50
- let total = 0;
51
- const sessions = listed.map(({ header, located, resolved }) => {
52
- if (!header || typeof header.id !== 'string') throw Error('HISTORY_INVENTORY_INVALID');
53
- let path = relative(crewRoot, located).replaceAll('\\', '/');
54
- if (!isSessionArtifactPath(path, header.id)) throw Error('HISTORY_STORAGE_UNSUPPORTED');
55
- if (!resolved) {
56
- // An unresolved location is a hint that may not name the encoding that
57
- // actually exists, so exactly one candidate must be present on disk.
58
- const plain = path.replace(/\.zstd$/, '');
59
- const existing = [plain, `${plain}.zstd`].filter(candidate => existsSync(historyPath(crewRoot, candidate)));
60
- if (existing.length !== 1) throw Error('HISTORY_ARTIFACT_AMBIGUOUS');
61
- path = existing[0];
62
- }
63
- const raw = readHistoryBytes(historyPath(crewRoot, path)); total += raw.length;
64
- if (total > 512 * 1024 * 1024) throw Error('HISTORY_INVENTORY_TOO_LARGE');
65
- return { id: header.id, createdAt: header.createdAt, parentSession: header.parentSession, cwd: header.cwd,
66
- revision: JSON.stringify([header.revision, historyHash(raw)]), artifact: { sessionId: header.id, relativePath: path, sha256: historyHash(raw) } };
67
- });
68
- // A retained child keeps its ancestor chain; do not leave a newer fork orphaned.
69
- const workspaces = Object.entries(store.tables.workspaces).map(([id, row]) => ({ id, ...row }));
70
- const crewSessionIds = [...readSessionOrigins()];
71
- const crewSet = new Set(crewSessionIds);
72
- for (const row of sessions) if (crewSet.has(row.id)) row.crew = true;
73
- // The isolated-workspace marker: the hub stamps every worktree session with a
74
- // cwd under the Crew worktree root, so that subset is scopeable on its own.
75
- const worktreeRoot = defaultWorktreeRoot().replaceAll('\\', '/').toLowerCase();
76
- for (const row of sessions) {
77
- if (row.crew === true && typeof row.cwd === 'string' && row.cwd.replaceAll('\\', '/').toLowerCase().startsWith(worktreeRoot)) row.worktree = true;
78
- }
79
- const plan = planHistoryCleanup({ workspaces, sessions, crewSessionIds, activeSessionIds: gate.idle() ? [] : ['active-agent'] }, options);
80
- const selected = new Set(plan.sessionIds);
81
- const byId = new Map(sessions.map(row => [row.id, row]));
82
- const queue = sessions.filter(row => !selected.has(row.id));
83
- for (let i = 0; i < queue.length; i++) {
84
- const parent = byId.get(queue[i].parentSession);
85
- if (parent && selected.delete(parent.id)) queue.push(parent);
86
- }
87
- plan.sessionIds = plan.sessionIds.filter(id => selected.has(id));
88
- plan.workspaceIds = plan.workspaceIds.filter(id => store.tables.workspaces[id].sessionIds.every(sid => selected.has(sid)));
89
- plan.counts = { workspaces: plan.workspaceIds.length, sessions: plan.sessionIds.length };
90
- plan.executable = gate.idle() && plan.counts.workspaces + plan.counts.sessions > 0;
91
- if (!plan.executable && !plan.blockedReason) plan.blockedReason = 'EMPTY_SELECTION';
92
- const request = { operation: plan.operation, workspaceHash: historyHash(bytes), workspaceIds: plan.workspaceIds,
93
- sessionIds: plan.sessionIds, artifacts: sessions.filter(row => selected.has(row.id)).map(row => row.artifact) };
94
- const revision = historyHash(JSON.stringify([runtimeId, plan.revision, request]));
95
- return { plan: { ...plan, revision, items: workspaces.filter(row => plan.workspaceIds.includes(row.id)).slice(0, 100).map(row => ({ id: row.id, title: row.title })) }, request };
96
- }
97
- async function preview(options = {}) {
98
- if (historyPending(crewRoot)) throw Error('HISTORY_MAINTENANCE_PENDING');
99
- const result = await snapshot(options); const planId = randomUUID();
100
- const expiresAt = now() + 600000;
101
- plans.set(planId, { ...result, options, expiresAt });
102
- while (plans.size > 16) plans.delete(plans.keys().next().value);
103
- return { ...result.plan, planId, expiresAt };
104
- }
105
- async function submit(operation, build) {
106
- if (entering || historyPending(crewRoot)) throw Error('HISTORY_MAINTENANCE_PENDING');
107
- entering = true;
108
- let state;
109
- try {
110
- state = { schemaVersion: 1, id: randomUUID(), operation, phase: 'QUEUED', lease: randomUUID(), runtimeId };
111
- writeHistoryState(crewRoot, state); // Fence BEFORE the last idle/snapshot check.
112
- if (!gate.idle()) throw Error('HISTORY_ACTIVE_SESSIONS');
113
- Object.assign(state, await build(), { phase: 'QUEUED' });
114
- writeHistoryState(crewRoot, state);
115
- await launch(state.id);
116
- return publicHistoryState(state);
117
- } catch (error) {
118
- if (state) writeHistoryState(crewRoot, { ...state, phase: 'FAILED', code: safeHistoryError(error) });
119
- throw error;
120
- } finally { entering = false; }
121
- }
122
- async function execute({ planId, confirm, acknowledgement } = {}) {
123
- const saved = plans.get(planId);
124
- if (!saved || saved.expiresAt < now()) throw Error('HISTORY_PLAN_EXPIRED');
125
- if (confirm !== true || (saved.plan.operation === 'delete' && acknowledgement !== 'DELETE')) throw Error('HISTORY_CONFIRMATION_REQUIRED');
126
- return submit(saved.plan.operation, async () => {
127
- const fresh = await snapshot(saved.options);
128
- if (fresh.plan.revision !== saved.plan.revision) throw Error('HISTORY_PREVIEW_CHANGED');
129
- if (!fresh.plan.executable) throw Error('HISTORY_EMPTY_SELECTION');
130
- plans.delete(planId);
131
- return { request: fresh.request, options: saved.options, revision: fresh.plan.revision, counts: fresh.plan.counts };
132
- });
133
- }
134
- function archives() {
135
- const directory = historyPath(crewRoot, 'history/transactions');
136
- if (!existsSync(directory)) return [];
137
- const ids = readdirSync(directory).filter(id => /^[a-f0-9-]{36}$/.test(id));
138
- if (ids.length > 10000) throw Error('HISTORY_ARCHIVE_LIMIT');
139
- return ids.flatMap(id => {
140
- try {
141
- const m = readHistoryManifest(crewRoot, id);
142
- return m.operation === 'archive' && m.state === 'APPLIED' ? [{ id: m.id, createdAt: m.createdAt, sessions: m.files.length,
143
- workspaces: m.before.global.workspaceIds.filter(id => !m.after.global.workspaceIds.includes(id)).length }] : [];
144
- } catch {
145
- return [{ id, invalid: true, code: 'HISTORY_INVALID_MANIFEST', sessions: 0, workspaces: 0, createdAt: null }];
146
- }
147
- }).reverse();
148
- }
149
- async function restore({ archiveId, confirm } = {}) {
150
- if (confirm !== true) throw Error('HISTORY_CONFIRMATION_REQUIRED');
151
- return submit('restore', async () => {
152
- const archive = archives().find(a => a.id === archiveId && !a.invalid);
153
- if (!archive) throw Error('HISTORY_ARCHIVE_NOT_RESTORABLE');
154
- return { archiveId, counts: { sessions: archive.sessions, workspaces: archive.workspaces } };
155
- });
156
- }
157
- async function fencedCheck() {
158
- const state = readHistoryState(crewRoot);
159
- if (!state || !historyPending(crewRoot) || state.runtimeId !== runtimeId || !gate.idle()) throw Error('HISTORY_FENCE_NOT_IDLE');
160
- if (state.operation !== 'restore') {
161
- const fresh = await snapshot(state.options);
162
- if (fresh.plan.revision !== state.revision) throw Error('HISTORY_PREVIEW_CHANGED');
163
- }
164
- return true;
165
- }
166
- async function recover({ confirm } = {}) {
167
- const state = readHistoryState(crewRoot);
168
- if (confirm !== true) throw Error('HISTORY_CONFIRMATION_REQUIRED');
169
- if (!state || !historyPending(crewRoot)) throw Error('HISTORY_RECOVERY_UNAVAILABLE');
170
- await launch(state.id, true);
171
- return publicHistoryState(state);
172
- }
173
- return { preview, execute, restore, recover, archives, fencedCheck, status: () => publicHistoryState(readHistoryState(crewRoot)), dispose: () => gate.dispose() };
174
- }
175
- export function safeHistoryError(error) {
176
- return /^HISTORY_[A-Z_]+$/.test(error?.message ?? '') ? error.message : 'HISTORY_OPERATION_FAILED';
177
- }
1
+ import { randomUUID } from 'node:crypto';
2
+ import { existsSync, readdirSync } from 'node:fs';
3
+ import { relative } from 'node:path';
4
+ import { planHistoryCleanup } from './cleanup-plan.mjs';
5
+ import { installHistoryAdmissionGate } from './admission-gate.mjs';
6
+ import { historyHash, historyPath, readHistoryBytes, decodeWorkspaceStore, readHistoryManifest, isSessionArtifactPath } from './archive-store.mjs';
7
+ import { readHistoryState, writeHistoryState, historyPending, publicHistoryState } from './state.mjs';
8
+ import { readSessionOrigins } from '../session-origins.mjs';
9
+ import { defaultWorktreeRoot } from '../workspace-isolation.mjs';
10
+
11
+ export function createHistoryService({ crewRoot, agents, persistence, runtimeId, launch, now = Date.now }) {
12
+ const gate = installHistoryAdmissionGate(agents, () => historyPending(crewRoot));
13
+ const plans = new Map();
14
+ let entering = false;
15
+ // One row per materialized session: the header to archive and the artifact
16
+ // location the backend reported. The 0.1.5 jsonl backend resolves each
17
+ // session's current immutable generation itself and hands back a concrete
18
+ // path through `listArtifacts()`. The earlier backend listed snapshots and
19
+ // only hinted at a location, leaving the encoding to be probed below.
20
+ async function readInventory() {
21
+ if (typeof persistence?.listArtifacts === 'function') {
22
+ const artifacts = await persistence.listArtifacts();
23
+ if (!Array.isArray(artifacts)) throw Error('HISTORY_INVENTORY_INVALID');
24
+ return artifacts.map(({ header, path }) => {
25
+ if (typeof path !== 'string') throw Error('HISTORY_STORAGE_UNSUPPORTED');
26
+ return { header, located: path, resolved: true };
27
+ });
28
+ }
29
+ if (typeof persistence?.listSnapshots === 'function') {
30
+ const rows = await persistence.listSnapshots();
31
+ if (!Array.isArray(rows)) throw Error('HISTORY_INVENTORY_INVALID');
32
+ return rows.map(({ header }) => {
33
+ const location = persistence.locate(header);
34
+ if (location?.kind !== 'jsonl') throw Error('HISTORY_STORAGE_UNSUPPORTED');
35
+ return { header, located: location.path, resolved: false };
36
+ });
37
+ }
38
+ throw Error('HISTORY_STORAGE_UNSUPPORTED');
39
+ }
40
+ async function snapshot(options) {
41
+ if (typeof persistence?.locate !== 'function') throw Error('HISTORY_STORAGE_UNSUPPORTED');
42
+ // Refuse only a backend that explicitly declares it exposes no per-session
43
+ // raw artifact. The 0.1.5 backend dropped the flag rather than setting it
44
+ // false, and the artifact path itself is validated below either way.
45
+ if (persistence.supportsRawArtifacts === false) throw Error('HISTORY_STORAGE_UNSUPPORTED');
46
+ const bytes = readHistoryBytes(historyPath(crewRoot, 'harness/storages/workspace.json'));
47
+ const store = decodeWorkspaceStore(bytes);
48
+ const listed = await readInventory();
49
+ if (listed.length > 10000) throw Error('HISTORY_INVENTORY_TOO_LARGE');
50
+ let total = 0;
51
+ const sessions = listed.map(({ header, located, resolved }) => {
52
+ if (!header || typeof header.id !== 'string') throw Error('HISTORY_INVENTORY_INVALID');
53
+ let path = relative(crewRoot, located).replaceAll('\\', '/');
54
+ if (!isSessionArtifactPath(path, header.id)) throw Error('HISTORY_STORAGE_UNSUPPORTED');
55
+ if (!resolved) {
56
+ // An unresolved location is a hint that may not name the encoding that
57
+ // actually exists, so exactly one candidate must be present on disk.
58
+ const plain = path.replace(/\.zstd$/, '');
59
+ const existing = [plain, `${plain}.zstd`].filter(candidate => existsSync(historyPath(crewRoot, candidate)));
60
+ if (existing.length !== 1) throw Error('HISTORY_ARTIFACT_AMBIGUOUS');
61
+ path = existing[0];
62
+ }
63
+ const raw = readHistoryBytes(historyPath(crewRoot, path)); total += raw.length;
64
+ if (total > 512 * 1024 * 1024) throw Error('HISTORY_INVENTORY_TOO_LARGE');
65
+ return { id: header.id, createdAt: header.createdAt, parentSession: header.parentSession, cwd: header.cwd,
66
+ revision: JSON.stringify([header.revision, historyHash(raw)]), artifact: { sessionId: header.id, relativePath: path, sha256: historyHash(raw) } };
67
+ });
68
+ // A retained child keeps its ancestor chain; do not leave a newer fork orphaned.
69
+ const workspaces = Object.entries(store.tables.workspaces).map(([id, row]) => ({ id, ...row }));
70
+ const crewSessionIds = [...readSessionOrigins()];
71
+ const crewSet = new Set(crewSessionIds);
72
+ for (const row of sessions) if (crewSet.has(row.id)) row.crew = true;
73
+ // The isolated-workspace marker: the hub stamps every worktree session with a
74
+ // cwd under the Crew worktree root, so that subset is scopeable on its own.
75
+ const worktreeRoot = defaultWorktreeRoot().replaceAll('\\', '/').toLowerCase();
76
+ for (const row of sessions) {
77
+ if (row.crew === true && typeof row.cwd === 'string' && row.cwd.replaceAll('\\', '/').toLowerCase().startsWith(worktreeRoot)) row.worktree = true;
78
+ }
79
+ const plan = planHistoryCleanup({ workspaces, sessions, crewSessionIds, activeSessionIds: gate.idle() ? [] : ['active-agent'] }, options);
80
+ const selected = new Set(plan.sessionIds);
81
+ const byId = new Map(sessions.map(row => [row.id, row]));
82
+ const queue = sessions.filter(row => !selected.has(row.id));
83
+ for (let i = 0; i < queue.length; i++) {
84
+ const parent = byId.get(queue[i].parentSession);
85
+ if (parent && selected.delete(parent.id)) queue.push(parent);
86
+ }
87
+ plan.sessionIds = plan.sessionIds.filter(id => selected.has(id));
88
+ plan.workspaceIds = plan.workspaceIds.filter(id => store.tables.workspaces[id].sessionIds.every(sid => selected.has(sid)));
89
+ plan.counts = { workspaces: plan.workspaceIds.length, sessions: plan.sessionIds.length };
90
+ plan.executable = gate.idle() && plan.counts.workspaces + plan.counts.sessions > 0;
91
+ if (!plan.executable && !plan.blockedReason) plan.blockedReason = 'EMPTY_SELECTION';
92
+ const request = { operation: plan.operation, workspaceHash: historyHash(bytes), workspaceIds: plan.workspaceIds,
93
+ sessionIds: plan.sessionIds, artifacts: sessions.filter(row => selected.has(row.id)).map(row => row.artifact) };
94
+ const revision = historyHash(JSON.stringify([runtimeId, plan.revision, request]));
95
+ return { plan: { ...plan, revision, items: workspaces.filter(row => plan.workspaceIds.includes(row.id)).slice(0, 100).map(row => ({ id: row.id, title: row.title })) }, request };
96
+ }
97
+ async function preview(options = {}) {
98
+ if (historyPending(crewRoot)) throw Error('HISTORY_MAINTENANCE_PENDING');
99
+ const result = await snapshot(options); const planId = randomUUID();
100
+ const expiresAt = now() + 600000;
101
+ plans.set(planId, { ...result, options, expiresAt });
102
+ while (plans.size > 16) plans.delete(plans.keys().next().value);
103
+ return { ...result.plan, planId, expiresAt };
104
+ }
105
+ async function submit(operation, build) {
106
+ if (entering || historyPending(crewRoot)) throw Error('HISTORY_MAINTENANCE_PENDING');
107
+ entering = true;
108
+ let state;
109
+ try {
110
+ state = { schemaVersion: 1, id: randomUUID(), operation, phase: 'QUEUED', lease: randomUUID(), runtimeId };
111
+ writeHistoryState(crewRoot, state); // Fence BEFORE the last idle/snapshot check.
112
+ if (!gate.idle()) throw Error('HISTORY_ACTIVE_SESSIONS');
113
+ Object.assign(state, await build(), { phase: 'QUEUED' });
114
+ writeHistoryState(crewRoot, state);
115
+ await launch(state.id);
116
+ return publicHistoryState(state);
117
+ } catch (error) {
118
+ if (state) writeHistoryState(crewRoot, { ...state, phase: 'FAILED', code: safeHistoryError(error) });
119
+ throw error;
120
+ } finally { entering = false; }
121
+ }
122
+ async function execute({ planId, confirm, acknowledgement } = {}) {
123
+ const saved = plans.get(planId);
124
+ if (!saved || saved.expiresAt < now()) throw Error('HISTORY_PLAN_EXPIRED');
125
+ if (confirm !== true || (saved.plan.operation === 'delete' && acknowledgement !== 'DELETE')) throw Error('HISTORY_CONFIRMATION_REQUIRED');
126
+ return submit(saved.plan.operation, async () => {
127
+ const fresh = await snapshot(saved.options);
128
+ if (fresh.plan.revision !== saved.plan.revision) throw Error('HISTORY_PREVIEW_CHANGED');
129
+ if (!fresh.plan.executable) throw Error('HISTORY_EMPTY_SELECTION');
130
+ plans.delete(planId);
131
+ return { request: fresh.request, options: saved.options, revision: fresh.plan.revision, counts: fresh.plan.counts };
132
+ });
133
+ }
134
+ function archives() {
135
+ const directory = historyPath(crewRoot, 'history/transactions');
136
+ if (!existsSync(directory)) return [];
137
+ const ids = readdirSync(directory).filter(id => /^[a-f0-9-]{36}$/.test(id));
138
+ if (ids.length > 10000) throw Error('HISTORY_ARCHIVE_LIMIT');
139
+ return ids.flatMap(id => {
140
+ try {
141
+ const m = readHistoryManifest(crewRoot, id);
142
+ return m.operation === 'archive' && m.state === 'APPLIED' ? [{ id: m.id, createdAt: m.createdAt, sessions: m.files.length,
143
+ workspaces: m.before.global.workspaceIds.filter(id => !m.after.global.workspaceIds.includes(id)).length }] : [];
144
+ } catch {
145
+ return [{ id, invalid: true, code: 'HISTORY_INVALID_MANIFEST', sessions: 0, workspaces: 0, createdAt: null }];
146
+ }
147
+ }).reverse();
148
+ }
149
+ async function restore({ archiveId, confirm } = {}) {
150
+ if (confirm !== true) throw Error('HISTORY_CONFIRMATION_REQUIRED');
151
+ return submit('restore', async () => {
152
+ const archive = archives().find(a => a.id === archiveId && !a.invalid);
153
+ if (!archive) throw Error('HISTORY_ARCHIVE_NOT_RESTORABLE');
154
+ return { archiveId, counts: { sessions: archive.sessions, workspaces: archive.workspaces } };
155
+ });
156
+ }
157
+ async function fencedCheck() {
158
+ const state = readHistoryState(crewRoot);
159
+ if (!state || !historyPending(crewRoot) || state.runtimeId !== runtimeId || !gate.idle()) throw Error('HISTORY_FENCE_NOT_IDLE');
160
+ if (state.operation !== 'restore') {
161
+ const fresh = await snapshot(state.options);
162
+ if (fresh.plan.revision !== state.revision) throw Error('HISTORY_PREVIEW_CHANGED');
163
+ }
164
+ return true;
165
+ }
166
+ async function recover({ confirm } = {}) {
167
+ const state = readHistoryState(crewRoot);
168
+ if (confirm !== true) throw Error('HISTORY_CONFIRMATION_REQUIRED');
169
+ if (!state || !historyPending(crewRoot)) throw Error('HISTORY_RECOVERY_UNAVAILABLE');
170
+ await launch(state.id, true);
171
+ return publicHistoryState(state);
172
+ }
173
+ return { preview, execute, restore, recover, archives, fencedCheck, status: () => publicHistoryState(readHistoryState(crewRoot)), dispose: () => gate.dispose() };
174
+ }
175
+ export function safeHistoryError(error) {
176
+ return /^HISTORY_[A-Z_]+$/.test(error?.message ?? '') ? error.message : 'HISTORY_OPERATION_FAILED';
177
+ }
@@ -1,27 +1,27 @@
1
- import { existsSync } from 'node:fs';
2
- import { historyPath, readHistoryBytes, writeHistoryBytes } from './archive-store.mjs';
3
-
4
- export const TERMINAL_HISTORY_PHASES = ['DONE', 'FAILED', 'ROLLED_BACK'];
5
- const phases = ['QUEUED', 'STOPPING', 'APPLYING', 'STARTING', 'VERIFYING', 'RECOVERY_REQUIRED', ...TERMINAL_HISTORY_PHASES];
6
- export function readHistoryState(root) {
7
- const file = historyPath(root, 'history/active.json');
8
- if (!existsSync(file)) return null;
9
- let value;
10
- try { value = JSON.parse(readHistoryBytes(file)); } catch { throw Error('HISTORY_STATE_INVALID'); }
11
- if (value?.schemaVersion !== 1 || !/^[a-f0-9-]{36}$/.test(value.id) || !phases.includes(value.phase)
12
- || !['archive', 'delete', 'restore'].includes(value.operation) || typeof value.runtimeId !== 'string'
13
- || !/^[a-f0-9-]{36}$/.test(value.lease)) throw Error('HISTORY_STATE_INVALID');
14
- return value;
15
- }
16
- export function writeHistoryState(root, value) {
17
- writeHistoryBytes(root, 'history/active.json', JSON.stringify({ ...value, updatedAt: Date.now() }));
18
- }
19
- export function historyPending(root) {
20
- const state = readHistoryState(root);
21
- return state !== null && !TERMINAL_HISTORY_PHASES.includes(state.phase);
22
- }
23
- export function publicHistoryState(state) {
24
- if (!state) return { phase: 'IDLE' };
25
- return { id: state.id, phase: state.phase, operation: state.operation, counts: state.counts,
26
- archiveId: state.archiveId, code: state.code, updatedAt: state.updatedAt };
27
- }
1
+ import { existsSync } from 'node:fs';
2
+ import { historyPath, readHistoryBytes, writeHistoryBytes } from './archive-store.mjs';
3
+
4
+ export const TERMINAL_HISTORY_PHASES = ['DONE', 'FAILED', 'ROLLED_BACK'];
5
+ const phases = ['QUEUED', 'STOPPING', 'APPLYING', 'STARTING', 'VERIFYING', 'RECOVERY_REQUIRED', ...TERMINAL_HISTORY_PHASES];
6
+ export function readHistoryState(root) {
7
+ const file = historyPath(root, 'history/active.json');
8
+ if (!existsSync(file)) return null;
9
+ let value;
10
+ try { value = JSON.parse(readHistoryBytes(file)); } catch { throw Error('HISTORY_STATE_INVALID'); }
11
+ if (value?.schemaVersion !== 1 || !/^[a-f0-9-]{36}$/.test(value.id) || !phases.includes(value.phase)
12
+ || !['archive', 'delete', 'restore'].includes(value.operation) || typeof value.runtimeId !== 'string'
13
+ || !/^[a-f0-9-]{36}$/.test(value.lease)) throw Error('HISTORY_STATE_INVALID');
14
+ return value;
15
+ }
16
+ export function writeHistoryState(root, value) {
17
+ writeHistoryBytes(root, 'history/active.json', JSON.stringify({ ...value, updatedAt: Date.now() }));
18
+ }
19
+ export function historyPending(root) {
20
+ const state = readHistoryState(root);
21
+ return state !== null && !TERMINAL_HISTORY_PHASES.includes(state.phase);
22
+ }
23
+ export function publicHistoryState(state) {
24
+ if (!state) return { phase: 'IDLE' };
25
+ return { id: state.id, phase: state.phase, operation: state.operation, counts: state.counts,
26
+ archiveId: state.archiveId, code: state.code, updatedAt: state.updatedAt };
27
+ }