@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
|
@@ -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
|
+
}
|
package/src/history/http.mjs
CHANGED
|
@@ -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
|
+
});
|
package/src/history/runner.mjs
CHANGED
|
@@ -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
|
+
}
|