@ran-sh/dsh-crew 1.1.1 → 1.2.0-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +16 -17
- package/.claude-plugin/plugin.json +7 -1
- package/README.md +44 -15
- package/README.zh.md +37 -12
- package/agents/ds-flash.md +16 -18
- package/agents/ds-pro.md +16 -24
- package/agents/ds-reviewer.md +15 -15
- package/agents/ds-worker.md +16 -16
- package/bin/dsh-crew.mjs +16 -12
- package/codex/AGENTS.md +37 -85
- package/codex/agents/ds-flash.toml +18 -14
- package/codex/agents/ds-pro.toml +18 -15
- package/codex/agents/ds-reviewer.toml +17 -13
- package/codex/agents/ds-worker.toml +17 -13
- package/codex/prompts/dsh-config.md +17 -1
- package/codex/prompts/dsh-status.md +5 -1
- package/commands/config.md +16 -4
- package/commands/status.md +6 -2
- package/docs/installation.md +54 -9
- package/docs/readiness-matrix.md +14 -6
- package/docs/ui-surfaces.md +32 -17
- package/lib/client.js +5434 -4844
- package/official-web-bridge/lib/client.js +567 -479
- package/official-web-bridge/overlay-entry.mjs +59 -0
- package/package.json +6 -3
- package/scripts/build-client.mjs +2 -0
- package/scripts/setup.mjs +8 -7
- package/scripts/verify-crew-ui-polish.mjs +145 -0
- package/scripts/verify-history-ui.mjs +97 -0
- package/scripts/verify-npm-install.mjs +1 -1
- package/src/client/history-panel.tsx +108 -0
- package/src/client/host-readiness.mjs +15 -6
- package/src/client/index.tsx +86 -64
- package/src/client/model-callability-view.mjs +17 -0
- package/src/client/panel-chrome.tsx +40 -0
- package/src/client/quick-panel.tsx +88 -73
- package/src/client/readiness-envelope.mjs +34 -0
- package/src/config-readiness.mjs +181 -115
- package/src/extension-contract.mjs +44 -26
- package/src/history/admission-gate.mjs +51 -0
- package/src/history/archive-store.mjs +261 -0
- package/src/history/cleanup-plan.mjs +64 -0
- package/src/history/http.mjs +32 -0
- package/src/history/operation.mjs +86 -0
- package/src/history/runner.mjs +51 -0
- package/src/history/runtime.mjs +28 -0
- package/src/history/service.mjs +137 -0
- package/src/history/state.mjs +27 -0
- package/src/hub/index.mjs +141 -24
- package/src/hub-client.mjs +17 -15
- package/src/image-output.mjs +71 -0
- package/src/install/install-legacy.mjs +126 -23
- package/src/install/npx-lifecycle.mjs +373 -64
- package/src/install/official-frontend-assets.mjs +78 -0
- package/src/install/payload-content.mjs +88 -0
- package/src/install/windows-startup.mjs +107 -19
- package/src/install/windows-supervisor-adapter.mjs +443 -0
- package/src/install/windows-supervisor-lifecycle.mjs +782 -0
- package/src/install/zcode.mjs +53 -14
- package/src/job-contracts.mjs +37 -31
- package/src/jobs.mjs +2 -2
- package/src/mcp-runtime.mjs +74 -9
- package/src/model-callability-contract.mjs +79 -0
- package/src/multimodal.mjs +0 -0
- package/src/official-web-bridge.mjs +0 -2
- package/src/provider-health.mjs +130 -124
- package/src/readiness-matrix.mjs +20 -15
- package/src/runtime-identity-contract.mjs +34 -0
- package/src/runtime-identity.mjs +14 -4
- package/src/runtime-readiness-snapshot.mjs +285 -87
- package/src/server.mjs +86 -76
- package/src/standalone-sdk.mjs +23 -0
- package/src/supervisor/restart-request.mjs +64 -10
- package/src/workflow-runtime.mjs +28 -13
- package/windows/start-dsh-crew.cmd +57 -57
- package/windows/start-dsh-crew.ps1 +314 -51
- package/windows/supervisor-control.ps1 +467 -0
- package/zcode/AGENTS.md +4 -1
- package/zcode/agents/ds-reviewer.md +31 -36
- package/zcode/agents/ds-worker.md +31 -39
- package/zcode/commands/dsh-config.md +17 -6
- package/zcode/commands/dsh-status.md +5 -4
- package/.mcp.json +0 -8
|
@@ -0,0 +1,28 @@
|
|
|
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 { createHistoryService } from './service.mjs';
|
|
7
|
+
import { registerHistoryHttp } from './http.mjs';
|
|
8
|
+
|
|
9
|
+
export function registerRuntimeHistory(ctx) {
|
|
10
|
+
const crewRoot = join(homedir(), '.config', 'dsh-crew');
|
|
11
|
+
const runtime = getHubRuntimeIdentity();
|
|
12
|
+
const owned = process.platform === 'win32' && runtime.dsh_version === '0.1.2-rc.1'
|
|
13
|
+
&& resolve(process.env.DSH_HOME ?? '').toLowerCase() === resolve(join(crewRoot, 'harness')).toLowerCase();
|
|
14
|
+
if (!owned) return () => {};
|
|
15
|
+
return ctx.inject(['webServer', 'sessionPersistence', 'agents'], host => {
|
|
16
|
+
const service = createHistoryService({ crewRoot, agents: host.agents, persistence: host.sessionPersistence,
|
|
17
|
+
runtimeId: runtime.runtime_id, launch(id, recover = false) {
|
|
18
|
+
return new Promise((accept, reject) => {
|
|
19
|
+
const child = spawn(process.execPath, [fileURLToPath(new URL('./runner.mjs', import.meta.url)), id, ...(recover ? ['--recover'] : [])], {
|
|
20
|
+
detached: true, windowsHide: true, stdio: 'ignore', env: { ...process.env },
|
|
21
|
+
});
|
|
22
|
+
child.once('error', reject); child.once('spawn', () => { child.unref(); accept(); });
|
|
23
|
+
});
|
|
24
|
+
} });
|
|
25
|
+
const disposeHttp = registerHistoryHttp(host.webServer, service);
|
|
26
|
+
return () => { disposeHttp?.(); service.dispose(); };
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
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 } from './archive-store.mjs';
|
|
7
|
+
import { readHistoryState, writeHistoryState, historyPending, publicHistoryState } from './state.mjs';
|
|
8
|
+
|
|
9
|
+
export function createHistoryService({ crewRoot, agents, persistence, runtimeId, launch, now = Date.now }) {
|
|
10
|
+
const gate = installHistoryAdmissionGate(agents, () => historyPending(crewRoot));
|
|
11
|
+
const plans = new Map();
|
|
12
|
+
let entering = false;
|
|
13
|
+
async function snapshot(options) {
|
|
14
|
+
if (typeof persistence?.listSnapshots !== 'function' || typeof persistence?.locate !== 'function'
|
|
15
|
+
|| persistence.supportsRawArtifacts !== true) throw Error('HISTORY_STORAGE_UNSUPPORTED');
|
|
16
|
+
const bytes = readHistoryBytes(historyPath(crewRoot, 'harness/storages/workspace.json'));
|
|
17
|
+
const store = decodeWorkspaceStore(bytes);
|
|
18
|
+
const listed = await persistence.listSnapshots();
|
|
19
|
+
if (!Array.isArray(listed) || listed.length > 10000) throw Error('HISTORY_INVENTORY_TOO_LARGE');
|
|
20
|
+
let total = 0;
|
|
21
|
+
const sessions = listed.map(({ header, revision }) => {
|
|
22
|
+
if (!header || typeof header.id !== 'string') throw Error('HISTORY_INVENTORY_INVALID');
|
|
23
|
+
const location = persistence.locate(header);
|
|
24
|
+
if (location?.kind !== 'jsonl') throw Error('HISTORY_STORAGE_UNSUPPORTED');
|
|
25
|
+
let path = relative(crewRoot, location.path).replaceAll('\\', '/');
|
|
26
|
+
if (!/^harness\/sessions\/[^/]+\/[^/]+\/session\.jsonl(?:\.zstd)?$/.test(path)
|
|
27
|
+
|| path.split('/')[3] !== header.id) throw Error('HISTORY_STORAGE_UNSUPPORTED');
|
|
28
|
+
const plain = path.replace(/\.zstd$/, '');
|
|
29
|
+
const existing = [plain, `${plain}.zstd`].filter(candidate => existsSync(historyPath(crewRoot, candidate)));
|
|
30
|
+
if (existing.length !== 1) throw Error('HISTORY_ARTIFACT_AMBIGUOUS');
|
|
31
|
+
path = existing[0];
|
|
32
|
+
const raw = readHistoryBytes(historyPath(crewRoot, path)); total += raw.length;
|
|
33
|
+
if (total > 512 * 1024 * 1024) throw Error('HISTORY_INVENTORY_TOO_LARGE');
|
|
34
|
+
return { id: header.id, createdAt: header.createdAt, parentSession: header.parentSession,
|
|
35
|
+
revision: JSON.stringify([revision, historyHash(raw)]), artifact: { sessionId: header.id, relativePath: path, sha256: historyHash(raw) } };
|
|
36
|
+
});
|
|
37
|
+
// A retained child keeps its ancestor chain; do not leave a newer fork orphaned.
|
|
38
|
+
const workspaces = Object.entries(store.tables.workspaces).map(([id, row]) => ({ id, ...row }));
|
|
39
|
+
const plan = planHistoryCleanup({ workspaces, sessions, activeSessionIds: gate.idle() ? [] : ['active-agent'] }, options);
|
|
40
|
+
const selected = new Set(plan.sessionIds);
|
|
41
|
+
const byId = new Map(sessions.map(row => [row.id, row]));
|
|
42
|
+
const queue = sessions.filter(row => !selected.has(row.id));
|
|
43
|
+
for (let i = 0; i < queue.length; i++) {
|
|
44
|
+
const parent = byId.get(queue[i].parentSession);
|
|
45
|
+
if (parent && selected.delete(parent.id)) queue.push(parent);
|
|
46
|
+
}
|
|
47
|
+
plan.sessionIds = plan.sessionIds.filter(id => selected.has(id));
|
|
48
|
+
plan.workspaceIds = plan.workspaceIds.filter(id => store.tables.workspaces[id].sessionIds.every(sid => selected.has(sid)));
|
|
49
|
+
plan.counts = { workspaces: plan.workspaceIds.length, sessions: plan.sessionIds.length };
|
|
50
|
+
plan.executable = gate.idle() && plan.counts.workspaces + plan.counts.sessions > 0;
|
|
51
|
+
if (!plan.executable && !plan.blockedReason) plan.blockedReason = 'EMPTY_SELECTION';
|
|
52
|
+
const request = { operation: plan.operation, workspaceHash: historyHash(bytes), workspaceIds: plan.workspaceIds,
|
|
53
|
+
sessionIds: plan.sessionIds, artifacts: sessions.filter(row => selected.has(row.id)).map(row => row.artifact) };
|
|
54
|
+
const revision = historyHash(JSON.stringify([runtimeId, plan.revision, request]));
|
|
55
|
+
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 };
|
|
56
|
+
}
|
|
57
|
+
async function preview(options = {}) {
|
|
58
|
+
if (historyPending(crewRoot)) throw Error('HISTORY_MAINTENANCE_PENDING');
|
|
59
|
+
const result = await snapshot(options); const planId = randomUUID();
|
|
60
|
+
const expiresAt = now() + 600000;
|
|
61
|
+
plans.set(planId, { ...result, options, expiresAt });
|
|
62
|
+
while (plans.size > 16) plans.delete(plans.keys().next().value);
|
|
63
|
+
return { ...result.plan, planId, expiresAt };
|
|
64
|
+
}
|
|
65
|
+
async function submit(operation, build) {
|
|
66
|
+
if (entering || historyPending(crewRoot)) throw Error('HISTORY_MAINTENANCE_PENDING');
|
|
67
|
+
entering = true;
|
|
68
|
+
let state;
|
|
69
|
+
try {
|
|
70
|
+
state = { schemaVersion: 1, id: randomUUID(), operation, phase: 'QUEUED', lease: randomUUID(), runtimeId };
|
|
71
|
+
writeHistoryState(crewRoot, state); // Fence BEFORE the last idle/snapshot check.
|
|
72
|
+
if (!gate.idle()) throw Error('HISTORY_ACTIVE_SESSIONS');
|
|
73
|
+
Object.assign(state, await build(), { phase: 'QUEUED' });
|
|
74
|
+
writeHistoryState(crewRoot, state);
|
|
75
|
+
await launch(state.id);
|
|
76
|
+
return publicHistoryState(state);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (state) writeHistoryState(crewRoot, { ...state, phase: 'FAILED', code: safeHistoryError(error) });
|
|
79
|
+
throw error;
|
|
80
|
+
} finally { entering = false; }
|
|
81
|
+
}
|
|
82
|
+
async function execute({ planId, confirm, acknowledgement } = {}) {
|
|
83
|
+
const saved = plans.get(planId);
|
|
84
|
+
if (!saved || saved.expiresAt < now()) throw Error('HISTORY_PLAN_EXPIRED');
|
|
85
|
+
if (confirm !== true || (saved.plan.operation === 'delete' && acknowledgement !== 'DELETE')) throw Error('HISTORY_CONFIRMATION_REQUIRED');
|
|
86
|
+
return submit(saved.plan.operation, async () => {
|
|
87
|
+
const fresh = await snapshot(saved.options);
|
|
88
|
+
if (fresh.plan.revision !== saved.plan.revision) throw Error('HISTORY_PREVIEW_CHANGED');
|
|
89
|
+
if (!fresh.plan.executable) throw Error('HISTORY_EMPTY_SELECTION');
|
|
90
|
+
plans.delete(planId);
|
|
91
|
+
return { request: fresh.request, options: saved.options, revision: fresh.plan.revision, counts: fresh.plan.counts };
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
function archives() {
|
|
95
|
+
const directory = historyPath(crewRoot, 'history/transactions');
|
|
96
|
+
if (!existsSync(directory)) return [];
|
|
97
|
+
const ids = readdirSync(directory).filter(id => /^[a-f0-9-]{36}$/.test(id));
|
|
98
|
+
if (ids.length > 10000) throw Error('HISTORY_ARCHIVE_LIMIT');
|
|
99
|
+
return ids.flatMap(id => {
|
|
100
|
+
try {
|
|
101
|
+
const m = readHistoryManifest(crewRoot, id);
|
|
102
|
+
return m.operation === 'archive' && m.state === 'APPLIED' ? [{ id: m.id, createdAt: m.createdAt, sessions: m.files.length,
|
|
103
|
+
workspaces: m.before.global.workspaceIds.filter(id => !m.after.global.workspaceIds.includes(id)).length }] : [];
|
|
104
|
+
} catch {
|
|
105
|
+
return [{ id, invalid: true, code: 'HISTORY_INVALID_MANIFEST', sessions: 0, workspaces: 0, createdAt: null }];
|
|
106
|
+
}
|
|
107
|
+
}).reverse();
|
|
108
|
+
}
|
|
109
|
+
async function restore({ archiveId, confirm } = {}) {
|
|
110
|
+
if (confirm !== true) throw Error('HISTORY_CONFIRMATION_REQUIRED');
|
|
111
|
+
return submit('restore', async () => {
|
|
112
|
+
const archive = archives().find(a => a.id === archiveId && !a.invalid);
|
|
113
|
+
if (!archive) throw Error('HISTORY_ARCHIVE_NOT_RESTORABLE');
|
|
114
|
+
return { archiveId, counts: { sessions: archive.sessions, workspaces: archive.workspaces } };
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
async function fencedCheck() {
|
|
118
|
+
const state = readHistoryState(crewRoot);
|
|
119
|
+
if (!state || !historyPending(crewRoot) || state.runtimeId !== runtimeId || !gate.idle()) throw Error('HISTORY_FENCE_NOT_IDLE');
|
|
120
|
+
if (state.operation !== 'restore') {
|
|
121
|
+
const fresh = await snapshot(state.options);
|
|
122
|
+
if (fresh.plan.revision !== state.revision) throw Error('HISTORY_PREVIEW_CHANGED');
|
|
123
|
+
}
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
async function recover({ confirm } = {}) {
|
|
127
|
+
const state = readHistoryState(crewRoot);
|
|
128
|
+
if (confirm !== true) throw Error('HISTORY_CONFIRMATION_REQUIRED');
|
|
129
|
+
if (!state || !historyPending(crewRoot)) throw Error('HISTORY_RECOVERY_UNAVAILABLE');
|
|
130
|
+
await launch(state.id, true);
|
|
131
|
+
return publicHistoryState(state);
|
|
132
|
+
}
|
|
133
|
+
return { preview, execute, restore, recover, archives, fencedCheck, status: () => publicHistoryState(readHistoryState(crewRoot)), dispose: () => gate.dispose() };
|
|
134
|
+
}
|
|
135
|
+
export function safeHistoryError(error) {
|
|
136
|
+
return /^HISTORY_[A-Z_]+$/.test(error?.message ?? '') ? error.message : 'HISTORY_OPERATION_FAILED';
|
|
137
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
package/src/hub/index.mjs
CHANGED
|
@@ -30,7 +30,7 @@ import { addContextReferences, buildWorkspaceTask, isSafeBranchName, loadWorkspa
|
|
|
30
30
|
import { buildExtensionContract } from '../extension-contract.mjs';
|
|
31
31
|
import { cleanupIsolatedWorkspace, createIsolatedWorkspace } from '../workspace-isolation.mjs';
|
|
32
32
|
import { assessWorkspaceReadiness } from '../workspace-readiness.mjs';
|
|
33
|
-
import {
|
|
33
|
+
import { buildConfigReadinessMatrix } from '../config-readiness.mjs';
|
|
34
34
|
import { localRequestCore, originLoopback } from '../local-request-guard.mjs';
|
|
35
35
|
import { raceWaiters } from '../removable-waiter.mjs';
|
|
36
36
|
import { buildProviderInventory } from '../provider-inventory.mjs';
|
|
@@ -273,6 +273,38 @@ export function buildProviderMigrationDeclarations(profileDeclarations = [], set
|
|
|
273
273
|
];
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
+
export function projectCatalogHealth(health, { limit = 32 } = {}) {
|
|
277
|
+
const boundedLimit = Number.isSafeInteger(limit) && limit > 0 ? Math.min(limit, 128) : 32;
|
|
278
|
+
const hints = (Array.isArray(health?.hints) ? health.hints : []).map((hint) => ({
|
|
279
|
+
code: typeof hint?.code === 'string' && hint.code.trim() ? hint.code.slice(0, 128) : 'UNKNOWN',
|
|
280
|
+
level: hint?.level === 'warning' ? 'warning' : 'info',
|
|
281
|
+
}));
|
|
282
|
+
const warnings = hints.filter((hint) => hint.level === 'warning');
|
|
283
|
+
const informational = hints.filter((hint) => hint.level !== 'warning');
|
|
284
|
+
return { hints: [...warnings, ...informational].slice(0, boundedLimit) };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function projectCurrentProviderHealth(healthStore, currentSelections = {}, { limit = 128 } = {}) {
|
|
288
|
+
const boundedLimit = Number.isSafeInteger(limit) && limit > 0 ? Math.min(limit, 256) : 128;
|
|
289
|
+
const selected = [];
|
|
290
|
+
const selectedKeys = new Set();
|
|
291
|
+
for (const route of Object.values(currentSelections ?? {})) {
|
|
292
|
+
if (route?.ok !== true || typeof route.provider !== 'string' || typeof route.model !== 'string') continue;
|
|
293
|
+
const key = `${route.provider}\u0000${route.model}`;
|
|
294
|
+
if (selectedKeys.has(key)) continue;
|
|
295
|
+
const observation = healthStore?.get?.(route.provider, route.model);
|
|
296
|
+
if (observation?.fresh !== true) continue;
|
|
297
|
+
selectedKeys.add(key);
|
|
298
|
+
selected.push(observation);
|
|
299
|
+
}
|
|
300
|
+
const listed = healthStore?.list?.();
|
|
301
|
+
const remaining = (Array.isArray(listed) ? listed : []).filter((entry) => {
|
|
302
|
+
const key = `${entry?.provider ?? ''}\u0000${entry?.model ?? ''}`;
|
|
303
|
+
return !selectedKeys.has(key);
|
|
304
|
+
});
|
|
305
|
+
return [...selected, ...remaining].slice(0, boundedLimit);
|
|
306
|
+
}
|
|
307
|
+
|
|
276
308
|
async function readProviderInventorySnapshot(hub, ctx, config) {
|
|
277
309
|
let catalog = { providers: [], harness_default: null };
|
|
278
310
|
let catalogEvidence = { ok: false, code: 'MODEL_CATALOG_UNAVAILABLE' };
|
|
@@ -384,10 +416,12 @@ async function readProviderInventorySnapshot(hub, ctx, config) {
|
|
|
384
416
|
lifecycleEvidence,
|
|
385
417
|
defaultEvidence,
|
|
386
418
|
});
|
|
419
|
+
const catalogHealth = projectCatalogHealth(catalog?.health);
|
|
387
420
|
return {
|
|
388
421
|
...inventory,
|
|
389
422
|
migration,
|
|
390
423
|
catalog_evidence: catalogEvidence,
|
|
424
|
+
catalog_health: catalogHealth,
|
|
391
425
|
declaration_evidence: declarationEvidence,
|
|
392
426
|
default_evidence: defaultEvidence,
|
|
393
427
|
lifecycle_evidence: lifecycleEvidence.ok ? { ok: true } : { ok: false, code: lifecycleEvidence.code },
|
|
@@ -1262,6 +1296,8 @@ export async function apply(ctx) {
|
|
|
1262
1296
|
// calls resolveHubSpawnPayload (above) to stamp the effective tier onto
|
|
1263
1297
|
// the spawn payload.
|
|
1264
1298
|
const disposers = [];
|
|
1299
|
+
const { registerRuntimeHistory } = await import('../history/runtime.mjs');
|
|
1300
|
+
disposers.push(registerRuntimeHistory(ctx));
|
|
1265
1301
|
const PROVIDER_DELETE_PLAN_TTL_MS = 10 * 60 * 1000;
|
|
1266
1302
|
const providerDeletePlans = new Map();
|
|
1267
1303
|
const providerMigrationPlans = new Map();
|
|
@@ -1427,20 +1463,82 @@ export async function apply(ctx) {
|
|
|
1427
1463
|
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
|
|
1428
1464
|
if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'GET only' }, { allow: 'GET' });
|
|
1429
1465
|
const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1466
|
+
const workerProviderMode = normalizeWorkerProviderMode(config.worker_provider_mode);
|
|
1467
|
+
const runtime = getHubRuntimeIdentity();
|
|
1468
|
+
// One authoritative snapshot drives both catalog and provider-lifecycle
|
|
1469
|
+
// readiness. Keep only bounded, secret-free projections in the matrix.
|
|
1470
|
+
let providerInventoryBody = { ok: false, code: 'PROVIDER_INVENTORY_UNAVAILABLE' };
|
|
1471
|
+
let providerCatalogBody = { ok: false, code: 'PROVIDER_CATALOG_UNAVAILABLE' };
|
|
1472
|
+
let providerInventorySnapshot = null;
|
|
1473
|
+
try {
|
|
1474
|
+
providerInventorySnapshot = await readProviderInventorySnapshot(hub, ctx, config);
|
|
1475
|
+
const records = Array.isArray(providerInventorySnapshot?.records) ? providerInventorySnapshot.records : [];
|
|
1476
|
+
const recoveryTransactions = Array.isArray(providerInventorySnapshot?.recovery_transactions)
|
|
1477
|
+
? providerInventorySnapshot.recovery_transactions
|
|
1478
|
+
: [];
|
|
1479
|
+
providerInventoryBody = records.length <= 256 && recoveryTransactions.length <= 256
|
|
1480
|
+
? {
|
|
1481
|
+
ok: true,
|
|
1482
|
+
records,
|
|
1483
|
+
lifecycle_evidence: providerInventorySnapshot.lifecycle_evidence ?? null,
|
|
1484
|
+
declaration_evidence: providerInventorySnapshot.declaration_evidence ?? null,
|
|
1485
|
+
default_evidence: providerInventorySnapshot.default_evidence ?? null,
|
|
1486
|
+
recovery_transactions: recoveryTransactions.map((transaction) => ({
|
|
1487
|
+
provider_id: transaction?.provider_id ?? null,
|
|
1488
|
+
unresolved: transaction?.unresolved === true,
|
|
1489
|
+
})),
|
|
1490
|
+
}
|
|
1491
|
+
: { ok: false, code: 'PROVIDER_INVENTORY_INCOMPLETE' };
|
|
1492
|
+
providerCatalogBody = providerInventorySnapshot.catalog_evidence?.ok === true
|
|
1493
|
+
? { ok: true, health: providerInventorySnapshot.catalog_health ?? { hints: [] } }
|
|
1494
|
+
: { ok: false, code: providerInventorySnapshot.catalog_evidence?.code ?? 'PROVIDER_CATALOG_UNAVAILABLE' };
|
|
1495
|
+
} catch {
|
|
1496
|
+
providerInventoryBody = { ok: false, code: 'PROVIDER_INVENTORY_UNAVAILABLE' };
|
|
1497
|
+
}
|
|
1498
|
+
const liveJobs = typeof hub.list === 'function' ? hub.list() : [];
|
|
1499
|
+
const boundedJobs = Array.isArray(liveJobs) ? liveJobs.slice(-128) : [];
|
|
1500
|
+
const currentSelections = {};
|
|
1501
|
+
if (workerProviderMode === 'deepseek-official') {
|
|
1502
|
+
currentSelections.worker = { ok: true, provider: 'deepseek-official', model: 'deepseek-v4-flash', source: 'legacy-strict' };
|
|
1503
|
+
currentSelections.worker_escalation = { ok: true, provider: 'deepseek-official', model: 'deepseek-v4-pro', source: 'legacy-strict' };
|
|
1504
|
+
currentSelections.reviewer = { ok: true, provider: 'deepseek-official', model: 'deepseek-v4-pro', source: 'legacy-strict' };
|
|
1505
|
+
} else if (providerInventorySnapshot) {
|
|
1506
|
+
const routingCatalog = {
|
|
1507
|
+
providers: providerInventorySnapshot.records
|
|
1508
|
+
.filter((record) => record?.lifecycle?.catalogued === true)
|
|
1509
|
+
.map((record) => ({
|
|
1510
|
+
id: record.id,
|
|
1511
|
+
name: record.display_name ?? record.id,
|
|
1512
|
+
models: (Array.isArray(record.models) ? record.models : []).map((id) => ({ id })),
|
|
1513
|
+
})),
|
|
1514
|
+
harness_default: providerInventorySnapshot.harness_default ?? null,
|
|
1515
|
+
};
|
|
1516
|
+
const lifecycleState = readProviderLifecycleState();
|
|
1517
|
+
for (const route of [
|
|
1518
|
+
{ key: 'worker', role: 'worker', attempt: 0 },
|
|
1519
|
+
{ key: 'worker_escalation', role: 'worker', attempt: 1 },
|
|
1520
|
+
{ key: 'reviewer', role: 'reviewer', attempt: 0 },
|
|
1521
|
+
]) {
|
|
1522
|
+
const policy = resolveModelPolicy(config, route.role, { attempt: route.attempt });
|
|
1523
|
+
currentSelections[route.key] = resolveModel({
|
|
1524
|
+
role: route.role,
|
|
1525
|
+
attempt: route.attempt,
|
|
1526
|
+
policy,
|
|
1527
|
+
catalog: routingCatalog,
|
|
1528
|
+
harnessDefault: routingCatalog.harness_default,
|
|
1529
|
+
healthStore: hub.healthStore,
|
|
1530
|
+
healthGate: policy.health_gate ?? config.health_gate ?? config.worker?.model_policy?.health_gate,
|
|
1531
|
+
allowFallback: config.allow_fallback !== false,
|
|
1532
|
+
tombstones: lifecycleState.tombstones,
|
|
1438
1533
|
});
|
|
1439
|
-
catalogStatus = { id: 'provider_catalog', status: 'PASS', reason_code: 'PROVIDER_CATALOG_RESOLVED' };
|
|
1440
|
-
} catch {
|
|
1441
|
-
catalogStatus = { id: 'provider_catalog', status: 'FAIL', reason_code: 'PROVIDER_CATALOG_UNAVAILABLE' };
|
|
1442
1534
|
}
|
|
1443
1535
|
}
|
|
1536
|
+
let providerHealthBody = { ok: false, code: 'PROVIDER_HEALTH_UNAVAILABLE' };
|
|
1537
|
+
try {
|
|
1538
|
+
providerHealthBody = { ok: true, health: projectCurrentProviderHealth(hub.healthStore, currentSelections) };
|
|
1539
|
+
} catch {
|
|
1540
|
+
providerHealthBody = { ok: false, code: 'PROVIDER_HEALTH_UNAVAILABLE' };
|
|
1541
|
+
}
|
|
1444
1542
|
const profiles = loadRoleProfiles();
|
|
1445
1543
|
const requestUrl = new URL(req.url, 'http://localhost');
|
|
1446
1544
|
const requestedWorkspaceId = requestUrl.searchParams.get('workspace_id');
|
|
@@ -1449,20 +1547,39 @@ export async function apply(ctx) {
|
|
|
1449
1547
|
const workspaceReadiness = requestedWorkspaceId && !requestedContext
|
|
1450
1548
|
? { status: 'UNAVAILABLE', reason_code: 'WORKSPACE_CONTEXT_NOT_FOUND' }
|
|
1451
1549
|
: await assessWorkspaceReadiness({ cwd: requestedContext?.repo_root ?? null });
|
|
1452
|
-
const
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1550
|
+
const readinessMatrix = buildConfigReadinessMatrix({
|
|
1551
|
+
hubCompatibility: {
|
|
1552
|
+
reachable: true,
|
|
1553
|
+
compatible: true,
|
|
1554
|
+
runtime_version: runtime.runtime_version ?? null,
|
|
1555
|
+
protocol_version: runtime.protocol_version ?? null,
|
|
1556
|
+
},
|
|
1557
|
+
workerProviderMode,
|
|
1558
|
+
providerCatalogChecked: true,
|
|
1559
|
+
providerCatalogBody,
|
|
1560
|
+
providerInventoryChecked: true,
|
|
1561
|
+
providerInventoryBody,
|
|
1562
|
+
providerHealthChecked: true,
|
|
1563
|
+
providerHealthBody,
|
|
1564
|
+
currentSelections,
|
|
1565
|
+
hubJobsChecked: true,
|
|
1566
|
+
hubJobsBody: { ok: true, jobs: boundedJobs },
|
|
1567
|
+
});
|
|
1459
1568
|
const readinessSnapshot = buildRuntimeReadinessSnapshot({
|
|
1460
|
-
runtime
|
|
1569
|
+
runtime,
|
|
1461
1570
|
readinessMatrix,
|
|
1462
|
-
selections: {
|
|
1463
|
-
|
|
1464
|
-
|
|
1571
|
+
selections: {
|
|
1572
|
+
worker: currentSelections.worker ?? null,
|
|
1573
|
+
reviewer: currentSelections.reviewer ?? null,
|
|
1574
|
+
},
|
|
1575
|
+
health: Array.isArray(providerHealthBody?.health) ? providerHealthBody.health.slice(0, 128) : [],
|
|
1576
|
+
health_status: providerHealthBody.ok === true ? 'AVAILABLE' : 'UNAVAILABLE',
|
|
1577
|
+
jobs: boundedJobs,
|
|
1465
1578
|
workspace: workspaceReadiness,
|
|
1579
|
+
enabled_roles: {
|
|
1580
|
+
worker: config.subagents_enabled !== false && config.worker_state !== 'disabled',
|
|
1581
|
+
reviewer: config.subagents_enabled !== false && config.review_state !== 'disabled',
|
|
1582
|
+
},
|
|
1466
1583
|
});
|
|
1467
1584
|
const contract = buildExtensionContract({
|
|
1468
1585
|
config,
|
|
@@ -1470,7 +1587,7 @@ export async function apply(ctx) {
|
|
|
1470
1587
|
readinessSnapshot,
|
|
1471
1588
|
workspace: workspaceReadiness,
|
|
1472
1589
|
profiles,
|
|
1473
|
-
runtime
|
|
1590
|
+
runtime,
|
|
1474
1591
|
});
|
|
1475
1592
|
return sendJson(res, 200, { ok: true, extension: contract });
|
|
1476
1593
|
},
|
package/src/hub-client.mjs
CHANGED
|
@@ -23,7 +23,7 @@ const EMPTY_STATUS = Object.freeze({
|
|
|
23
23
|
code: HUB_COMPATIBILITY_CODES.UNREACHABLE,
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
-
let lastProbe = { at: 0, status: EMPTY_STATUS };
|
|
26
|
+
let lastProbe = { at: 0, base: null, status: EMPTY_STATUS };
|
|
27
27
|
|
|
28
28
|
function withReadiness(status) {
|
|
29
29
|
return {
|
|
@@ -32,14 +32,15 @@ function withReadiness(status) {
|
|
|
32
32
|
};
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
function cacheStatus(status) {
|
|
35
|
+
function cacheStatus(status, base) {
|
|
36
36
|
const observed = withReadiness(status);
|
|
37
|
-
lastProbe = { at: Date.now(), status: observed };
|
|
37
|
+
lastProbe = { at: Date.now(), base, status: observed };
|
|
38
38
|
return observed;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
async function fetchJson(path) {
|
|
42
|
-
const
|
|
41
|
+
async function fetchJson(path, base = BASE) {
|
|
42
|
+
const api = `${String(base).replace(/\/$/, '')}/_dsh/dsh-crew`;
|
|
43
|
+
const res = await fetch(`${api}${path}`, { signal: AbortSignal.timeout(800) });
|
|
43
44
|
let body;
|
|
44
45
|
try { body = await res.json(); } catch { body = null; }
|
|
45
46
|
return { res, body };
|
|
@@ -53,10 +54,11 @@ async function fetchJson(path) {
|
|
|
53
54
|
* lacks /runtime is therefore reachable-but-incompatible instead of looking
|
|
54
55
|
* offline or being silently treated as current.
|
|
55
56
|
*/
|
|
56
|
-
export async function hubStatus({ force = false } = {}) {
|
|
57
|
-
|
|
57
|
+
export async function hubStatus({ force = false, base = BASE } = {}) {
|
|
58
|
+
const normalizedBase = String(base).replace(/\/$/, '');
|
|
59
|
+
if (!force && lastProbe.base === normalizedBase && Date.now() - lastProbe.at < 10_000) return lastProbe.status;
|
|
58
60
|
try {
|
|
59
|
-
const ping = await fetchJson('/ping');
|
|
61
|
+
const ping = await fetchJson('/ping', normalizedBase);
|
|
60
62
|
if (!ping.res.ok) {
|
|
61
63
|
return cacheStatus({
|
|
62
64
|
...EMPTY_STATUS,
|
|
@@ -64,13 +66,13 @@ export async function hubStatus({ force = false } = {}) {
|
|
|
64
66
|
code: HUB_COMPATIBILITY_CODES.HTTP_ERROR,
|
|
65
67
|
http_status: ping.res.status,
|
|
66
68
|
endpoint: 'ping',
|
|
67
|
-
});
|
|
69
|
+
}, normalizedBase);
|
|
68
70
|
}
|
|
69
71
|
if (ping.body?.service !== 'dsh-crew-hub') {
|
|
70
|
-
return cacheStatus(evaluateHubHandshake(ping.body));
|
|
72
|
+
return cacheStatus(evaluateHubHandshake(ping.body), normalizedBase);
|
|
71
73
|
}
|
|
72
74
|
|
|
73
|
-
const runtime = await fetchJson('/runtime');
|
|
75
|
+
const runtime = await fetchJson('/runtime', normalizedBase);
|
|
74
76
|
if (runtime.res.status === 404) {
|
|
75
77
|
return cacheStatus({
|
|
76
78
|
...EMPTY_STATUS,
|
|
@@ -78,7 +80,7 @@ export async function hubStatus({ force = false } = {}) {
|
|
|
78
80
|
service: 'dsh-crew-hub',
|
|
79
81
|
code: HUB_COMPATIBILITY_CODES.PROTOCOL_MISSING,
|
|
80
82
|
endpoint: 'runtime',
|
|
81
|
-
});
|
|
83
|
+
}, normalizedBase);
|
|
82
84
|
}
|
|
83
85
|
if (!runtime.res.ok) {
|
|
84
86
|
return cacheStatus({
|
|
@@ -88,11 +90,11 @@ export async function hubStatus({ force = false } = {}) {
|
|
|
88
90
|
code: HUB_COMPATIBILITY_CODES.HTTP_ERROR,
|
|
89
91
|
http_status: runtime.res.status,
|
|
90
92
|
endpoint: 'runtime',
|
|
91
|
-
});
|
|
93
|
+
}, normalizedBase);
|
|
92
94
|
}
|
|
93
|
-
return cacheStatus(evaluateHubHandshake(runtime.body, { strictProduction: true }));
|
|
95
|
+
return cacheStatus(evaluateHubHandshake(runtime.body, { strictProduction: true }), normalizedBase);
|
|
94
96
|
} catch {
|
|
95
|
-
return cacheStatus(EMPTY_STATUS);
|
|
97
|
+
return cacheStatus(EMPTY_STATUS, normalizedBase);
|
|
96
98
|
}
|
|
97
99
|
}
|
|
98
100
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { mkdtempSync, mkdirSync, lstatSync, openSync, readSync, closeSync, renameSync, rmSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, isAbsolute, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export async function downloadImageBytes(url, { fetchImpl = globalThis.fetch, timeoutMs = 60_000, maxBytes = 64 * 1024 * 1024 } = {}) {
|
|
5
|
+
if (!['http:', 'https:'].includes(new URL(url).protocol)) throw new Error('unsupported image URL protocol');
|
|
6
|
+
const controller = new AbortController();
|
|
7
|
+
let reader;
|
|
8
|
+
let timer;
|
|
9
|
+
const timeout = new Promise((_, reject) => {
|
|
10
|
+
timer = setTimeout(() => {
|
|
11
|
+
controller.abort();
|
|
12
|
+
reject(new Error('image download timed out'));
|
|
13
|
+
}, timeoutMs);
|
|
14
|
+
});
|
|
15
|
+
const download = async () => {
|
|
16
|
+
const response = await fetchImpl(url, { signal: controller.signal });
|
|
17
|
+
controller.signal.throwIfAborted();
|
|
18
|
+
if (!response.ok) throw new Error(`image download failed: HTTP ${response.status}`);
|
|
19
|
+
if (Number(response.headers.get('content-length')) > maxBytes) throw new Error('image download exceeds size limit');
|
|
20
|
+
if (!response.body) throw new Error('image download has no body');
|
|
21
|
+
reader = response.body.getReader();
|
|
22
|
+
const chunks = [];
|
|
23
|
+
let bytes = 0;
|
|
24
|
+
while (true) {
|
|
25
|
+
const { done, value } = await reader.read();
|
|
26
|
+
controller.signal.throwIfAborted();
|
|
27
|
+
if (done) break;
|
|
28
|
+
bytes += value.byteLength;
|
|
29
|
+
if (bytes > maxBytes) throw new Error('image download exceeds size limit');
|
|
30
|
+
chunks.push(Buffer.from(value));
|
|
31
|
+
}
|
|
32
|
+
return Buffer.concat(chunks, bytes);
|
|
33
|
+
};
|
|
34
|
+
try { return await Promise.race([download(), timeout]); }
|
|
35
|
+
finally {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
controller.abort();
|
|
38
|
+
if (reader) void reader.cancel().catch(() => {});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function validateImage(file) {
|
|
43
|
+
const info = lstatSync(file);
|
|
44
|
+
if (!info.isFile() || info.size === 0 || info.size > 64 * 1024 * 1024) throw new Error('generated image has invalid type or size');
|
|
45
|
+
const head = Buffer.alloc(32);
|
|
46
|
+
const fd = openSync(file, 'r');
|
|
47
|
+
let length;
|
|
48
|
+
try { length = readSync(fd, head, 0, head.length, 0); } finally { closeSync(fd); }
|
|
49
|
+
const bytes = head.subarray(0, length);
|
|
50
|
+
const png = bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
|
|
51
|
+
const jpeg = bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255;
|
|
52
|
+
const gif = ['GIF87a', 'GIF89a'].includes(bytes.subarray(0, 6).toString('ascii'));
|
|
53
|
+
const webp = bytes.subarray(0, 4).toString('ascii') === 'RIFF' && bytes.subarray(8, 12).toString('ascii') === 'WEBP';
|
|
54
|
+
if (!png && !jpeg && !gif && !webp) throw new Error('generated output does not have a supported image signature');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Stage each request separately; an existing destination never proves success. */
|
|
58
|
+
export async function withGeneratedImageOutput(outputPath, generate) {
|
|
59
|
+
if (!isAbsolute(outputPath)) throw new Error('output_path must be absolute');
|
|
60
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
61
|
+
const stage = mkdtempSync(join(dirname(outputPath), '.dsh-image-'));
|
|
62
|
+
const stagedFile = join(stage, basename(outputPath));
|
|
63
|
+
try {
|
|
64
|
+
await generate(stagedFile);
|
|
65
|
+
validateImage(stagedFile);
|
|
66
|
+
renameSync(stagedFile, outputPath);
|
|
67
|
+
return outputPath;
|
|
68
|
+
} finally {
|
|
69
|
+
rmSync(stage, { recursive: true, force: true });
|
|
70
|
+
}
|
|
71
|
+
}
|