@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.
- package/.claude-plugin/marketplace.json +16 -16
- package/.claude-plugin/plugin.json +14 -14
- package/LICENSE +21 -21
- package/README.de.md +359 -359
- package/README.es.md +359 -359
- package/README.fr.md +359 -359
- package/README.hi.md +359 -359
- package/README.id.md +359 -359
- package/README.ja.md +359 -359
- package/README.ko.md +359 -359
- package/README.md +126 -126
- package/README.pt.md +359 -359
- package/README.ru.md +359 -359
- package/README.th.md +359 -359
- package/README.tr.md +359 -359
- package/README.vi.md +359 -359
- package/README.zh-TW.md +359 -359
- package/README.zh.md +116 -116
- package/agents/ds-flash.md +24 -24
- package/agents/ds-pro.md +24 -24
- package/agents/ds-reviewer.md +23 -23
- package/agents/ds-worker.md +23 -23
- package/bin/dsh-crew.mjs +16 -16
- package/codex/agents/ds-flash.toml +34 -34
- package/codex/agents/ds-pro.toml +34 -34
- package/codex/agents/ds-reviewer.toml +32 -32
- package/codex/agents/ds-worker.toml +32 -32
- package/codex/prompts/dsh-config.md +19 -19
- package/codex/prompts/dsh-status.md +5 -5
- package/commands/config.md +23 -23
- package/commands/off.md +5 -5
- package/commands/on.md +5 -5
- package/commands/status.md +9 -9
- package/cordis.patch.yml +4 -4
- package/docs/gpt-relay-extension.md +103 -103
- package/docs/installation.md +138 -138
- package/docs/job-contracts.md +103 -103
- package/docs/readiness-matrix.md +85 -85
- package/docs/ui-surfaces.md +107 -107
- package/official-web-bridge/cordis.patch.yml +4 -4
- package/official-web-bridge/entry.mjs +1 -1
- package/official-web-bridge/overlay-entry.mjs +59 -59
- package/official-web-bridge/package.json +25 -25
- package/package.json +3 -2
- package/scripts/build-client.mjs +49 -49
- package/scripts/live-crew-smoke.mjs +39 -39
- package/scripts/live-policy-matrix.mjs +177 -177
- package/scripts/policy-probe.mjs +101 -101
- package/scripts/remove-legacy-official-bridge.ps1 +89 -89
- package/scripts/setup.mjs +393 -393
- package/scripts/smoke-real.mjs +110 -110
- package/scripts/smoke.mjs +78 -78
- package/scripts/verify-crew-ui-polish.mjs +145 -145
- package/scripts/verify-history-ui.mjs +97 -97
- package/scripts/verify-installer-fix.mjs +26 -26
- package/scripts/verify-npm-install.mjs +311 -311
- package/scripts/verify-official-bridge-e2e.mjs +192 -192
- package/src/adaptive-routing.mjs +260 -260
- package/src/client/activation-summary.tsx +64 -64
- package/src/client/collapsible-sections.mjs +55 -55
- package/src/client/history-panel.tsx +108 -108
- package/src/client/host-readiness.mjs +71 -71
- package/src/client/index.tsx +1711 -1711
- package/src/client/model-callability-view.mjs +17 -17
- package/src/client/panel-chrome.tsx +40 -40
- package/src/client/quick-entry.tsx +10 -10
- package/src/client/quick-panel.tsx +275 -275
- package/src/client/readiness-envelope.mjs +34 -34
- package/src/client/surface-detection.mjs +43 -43
- package/src/config-readiness.mjs +226 -226
- package/src/credential-reference.mjs +38 -38
- package/src/delivery.mjs +205 -205
- package/src/dsh-cli-runtime.mjs +1021 -1021
- package/src/dsh-cohort.mjs +20 -20
- package/src/extension-contract.mjs +104 -104
- package/src/failure-classification.mjs +201 -201
- package/src/history/admission-gate.mjs +67 -67
- package/src/history/archive-store.mjs +272 -272
- package/src/history/cleanup-plan.mjs +89 -89
- package/src/history/http.mjs +32 -32
- package/src/history/operation.mjs +86 -86
- package/src/history/runner-detach.mjs +34 -34
- package/src/history/runner.mjs +52 -52
- package/src/history/runtime.mjs +36 -36
- package/src/history/service.mjs +177 -177
- package/src/history/state.mjs +27 -27
- package/src/hub/entry.mjs +104 -104
- package/src/hub/index.mjs +2694 -2694
- package/src/hub-client.mjs +154 -154
- package/src/hub-compatibility.mjs +42 -42
- package/src/i18n.mjs +19 -19
- package/src/information-flow.mjs +67 -67
- package/src/install/cli.mjs +28 -28
- package/src/install/install-legacy.mjs +711 -711
- package/src/install/install.mjs +483 -483
- package/src/install/npx-lifecycle.mjs +3456 -3456
- package/src/install/official-frontend-assets.mjs +78 -78
- package/src/install/official-web.mjs +95 -95
- package/src/install/payload-content.mjs +88 -88
- package/src/install/windows-startup.mjs +236 -236
- package/src/install/windows-supervisor-adapter.mjs +443 -443
- package/src/install/windows-supervisor-lifecycle.mjs +782 -782
- package/src/install/zcode.mjs +397 -397
- package/src/job-contracts.mjs +255 -255
- package/src/local-request-guard.mjs +60 -60
- package/src/mcp-runtime.mjs +340 -340
- package/src/model-callability-contract.mjs +79 -79
- package/src/model-catalog.mjs +180 -180
- package/src/model-routing.mjs +586 -586
- package/src/model-schedule.mjs +207 -207
- package/src/official-web-bridge.mjs +447 -447
- package/src/policy.mjs +235 -235
- package/src/provider-delete-adapters.mjs +1934 -1934
- package/src/provider-health.mjs +130 -130
- package/src/provider-inventory.mjs +182 -182
- package/src/provider-layer-migration-adapters.mjs +759 -759
- package/src/provider-layer-migration.mjs +198 -198
- package/src/provider-lifecycle-state.mjs +103 -103
- package/src/provider-lifecycle.mjs +252 -252
- package/src/provider-profile-store.mjs +390 -390
- package/src/provider-settings-store.mjs +633 -633
- package/src/provider-store-lock.mjs +67 -67
- package/src/readiness-matrix.mjs +181 -181
- package/src/removable-waiter.mjs +29 -29
- package/src/role-profiles.mjs +107 -107
- package/src/runtime-controls.mjs +84 -84
- package/src/runtime-identity-contract.mjs +34 -34
- package/src/runtime-identity.mjs +235 -235
- package/src/runtime-readiness-snapshot.mjs +285 -285
- package/src/server.mjs +581 -581
- package/src/session-origins.mjs +60 -60
- package/src/standalone-sdk.mjs +23 -23
- package/src/status-shard.mjs +63 -63
- package/src/structured-error-code.mjs +38 -38
- package/src/supervisor/restart-request.mjs +256 -256
- package/src/workflow-runtime.mjs +739 -739
- package/src/workflow.mjs +155 -155
- package/src/workspace-audit.mjs +231 -231
- package/src/workspace-context.mjs +146 -146
- package/src/workspace-isolation.mjs +463 -455
- package/src/workspace-readiness.mjs +32 -32
- package/statusline/statusline.sh +14 -14
- package/statusline/worker-segment.sh +35 -35
- package/windows/start-dsh-crew.cmd +57 -57
- package/windows/start-dsh-crew.ps1 +1302 -1302
- package/windows/supervisor-control.ps1 +467 -467
- package/worker.cordis.yml +67 -67
- package/zcode/agents/ds-reviewer.md +31 -31
- package/zcode/agents/ds-worker.md +31 -31
- package/zcode/commands/dsh-config.md +17 -17
- package/zcode/commands/dsh-status.md +5 -5
package/src/history/runtime.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/history/service.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/history/state.mjs
CHANGED
|
@@ -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
|
+
}
|