@ran-sh/dsh-crew 0.5.6 → 1.0.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/README.md +20 -7
- package/README.zh.md +18 -7
- package/docs/installation.md +1 -1
- package/docs/ui-surfaces.md +65 -46
- package/lib/client.js +565 -28
- package/official-web-bridge/lib/client.js +565 -28
- package/package.json +1 -1
- package/src/client/collapsible-sections.mjs +3 -2
- package/src/client/host-readiness.mjs +16 -3
- package/src/client/index.tsx +331 -54
- package/src/config-readiness.mjs +66 -5
- package/src/credential-lifecycle.mjs +73 -0
- package/src/credential-purge-adapters.mjs +127 -0
- package/src/credential-purge-state.mjs +59 -0
- package/src/credential-reference-inventory.mjs +78 -0
- package/src/dsh-cli-runtime.mjs +36 -9
- package/src/extension-contract.mjs +16 -10
- package/src/hub/index.mjs +1004 -438
- package/src/hub-client.mjs +25 -5
- package/src/hub-compatibility.mjs +23 -7
- package/src/install/install.mjs +4 -4
- package/src/install/npx-lifecycle.mjs +495 -134
- package/src/job-contracts.mjs +27 -6
- package/src/jobs.mjs +2 -4
- package/src/local-request-guard.mjs +60 -0
- package/src/mcp-runtime.mjs +7 -5
- package/src/model-routing.mjs +141 -49
- package/src/multimodal.mjs +0 -0
- package/src/official-web-bridge.mjs +248 -95
- package/src/policy.mjs +62 -31
- package/src/provider-config-scrub.mjs +71 -0
- package/src/provider-delete-adapters.mjs +451 -0
- package/src/provider-desired-state.mjs +37 -0
- package/src/provider-health.mjs +124 -0
- package/src/provider-inventory.mjs +132 -0
- package/src/provider-lifecycle-state.mjs +95 -0
- package/src/provider-lifecycle.mjs +214 -0
- package/src/provider-profile-store.mjs +171 -0
- package/src/readiness-matrix.mjs +11 -5
- package/src/removable-waiter.mjs +29 -0
- package/src/runtime-identity.mjs +66 -21
- package/src/runtime-readiness-snapshot.mjs +87 -0
- package/src/server.mjs +64 -21
- package/src/status-shard.mjs +13 -2
- package/src/workflow-runtime.mjs +44 -10
- package/windows/start-dsh-crew.cmd +3 -3
- package/windows/start-dsh-crew.ps1 +49 -5
package/src/hub/index.mjs
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// Web UI session list), exposes a loopback jobs API for the CC/Codex MCP shim,
|
|
4
4
|
// and serves the one-click installer endpoints for the settings page.
|
|
5
5
|
|
|
6
|
-
import { randomUUID } from 'node:crypto';
|
|
7
|
-
import {
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { join, isAbsolute } from 'node:path';
|
|
8
9
|
import { homedir } from 'node:os';
|
|
9
10
|
import { createShardWriter, readMergedStatus } from '../status-shard.mjs';
|
|
10
11
|
import {
|
|
@@ -20,16 +21,29 @@ import { buildDirectSelectionTrace, resolveWorkerModel, resolveModel } from '../
|
|
|
20
21
|
import { readHarnessModelCatalog } from '../model-catalog.mjs';
|
|
21
22
|
import { appendDeliveryInstructions, parseDeliveryReport, formatDeliveryMetadata } from '../delivery.mjs';
|
|
22
23
|
import { captureWorkspaceBaseline, captureWorkspaceDiff, NOT_A_GIT_REPOSITORY } from '../workspace-audit.mjs';
|
|
23
|
-
import { applyWorkspaceEvidence, buildOutcome, JOB_PHASES } from '../workflow.mjs';
|
|
24
|
-
import { boundedMachineCodeFromError } from '../structured-error-code.mjs';
|
|
25
|
-
import { createCanonicalJobEvent, projectWorkflowView } from '../job-contracts.mjs';
|
|
26
|
-
import { getHubRuntimeIdentity } from '../runtime-identity.mjs';
|
|
27
|
-
import { loadRoleProfiles, resolveRoleProfile, saveRoleProfiles } from '../role-profiles.mjs';
|
|
28
|
-
import { addContextReferences, buildWorkspaceTask, isSafeBranchName, loadWorkspaceContexts, resolveWorkspaceContext, saveWorkspaceContexts } from '../workspace-context.mjs';
|
|
29
|
-
import { buildExtensionContract } from '../extension-contract.mjs';
|
|
30
|
-
import { cleanupIsolatedWorkspace, createIsolatedWorkspace } from '../workspace-isolation.mjs';
|
|
31
|
-
import { assessWorkspaceReadiness } from '../workspace-readiness.mjs';
|
|
24
|
+
import { applyWorkspaceEvidence, buildOutcome, JOB_PHASES } from '../workflow.mjs';
|
|
25
|
+
import { boundedMachineCodeFromError } from '../structured-error-code.mjs';
|
|
26
|
+
import { createCanonicalJobEvent, projectWorkflowView } from '../job-contracts.mjs';
|
|
27
|
+
import { getHubRuntimeIdentity } from '../runtime-identity.mjs';
|
|
28
|
+
import { loadRoleProfiles, resolveRoleProfile, saveRoleProfiles } from '../role-profiles.mjs';
|
|
29
|
+
import { addContextReferences, buildWorkspaceTask, isSafeBranchName, loadWorkspaceContexts, resolveWorkspaceContext, saveWorkspaceContexts } from '../workspace-context.mjs';
|
|
30
|
+
import { buildExtensionContract } from '../extension-contract.mjs';
|
|
31
|
+
import { cleanupIsolatedWorkspace, createIsolatedWorkspace } from '../workspace-isolation.mjs';
|
|
32
|
+
import { assessWorkspaceReadiness } from '../workspace-readiness.mjs';
|
|
32
33
|
import { buildHubExecutionRows } from '../config-readiness.mjs';
|
|
34
|
+
import { localRequestCore, originLoopback } from '../local-request-guard.mjs';
|
|
35
|
+
import { raceWaiters } from '../removable-waiter.mjs';
|
|
36
|
+
import { buildProviderInventory } from '../provider-inventory.mjs';
|
|
37
|
+
import { inspectProviderProfile, readProviderDeclarations } from '../provider-profile-store.mjs';
|
|
38
|
+
import { normalizeProviderLifecycleState } from '../provider-lifecycle-state.mjs';
|
|
39
|
+
import { createProviderHealthStore } from '../provider-health.mjs';
|
|
40
|
+
import { planProviderDelete } from '../provider-lifecycle.mjs';
|
|
41
|
+
import { createProviderDeleteFileHooks } from '../provider-delete-adapters.mjs';
|
|
42
|
+
import { buildCredentialReferenceInventory } from '../credential-reference-inventory.mjs';
|
|
43
|
+
import { executeCredentialPurge, planCredentialPurge } from '../credential-lifecycle.mjs';
|
|
44
|
+
import { createCredentialPurgeFileHooks } from '../credential-purge-adapters.mjs';
|
|
45
|
+
import { normalizeCredentialPurgeState, recordCredentialPurgeOutcome } from '../credential-purge-state.mjs';
|
|
46
|
+
import { buildRuntimeReadinessSnapshot } from '../runtime-readiness-snapshot.mjs';
|
|
33
47
|
|
|
34
48
|
// policy.mjs is pure (no @deepseek-ai imports, no ctx access), so importing it
|
|
35
49
|
// here is safe for the profile-realm discipline: it never pulls in package
|
|
@@ -91,63 +105,221 @@ const LEGACY_TIER_MODELS = { flash: 'deepseek-v4-flash', pro: 'deepseek-v4-pro'
|
|
|
91
105
|
// the profile realm): a valid dispatch role set.
|
|
92
106
|
const ROLES = { worker: true, reviewer: true };
|
|
93
107
|
const CONFIG_DIR = join(homedir(), '.config', 'dsh-crew');
|
|
108
|
+
const CREDENTIAL_PURGE_STATE_FILE = join(CONFIG_DIR, 'credential-purge-lifecycle.json');
|
|
94
109
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
110
|
+
function readProviderLifecycleState() {
|
|
111
|
+
try {
|
|
112
|
+
return normalizeProviderLifecycleState(JSON.parse(readFileSync(join(CONFIG_DIR, 'provider-lifecycle.json'), 'utf8')));
|
|
113
|
+
} catch {
|
|
114
|
+
return normalizeProviderLifecycleState();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function readCredentialPurgeState() {
|
|
119
|
+
try { return normalizeCredentialPurgeState(JSON.parse(readFileSync(CREDENTIAL_PURGE_STATE_FILE, 'utf8'))); } catch { return normalizeCredentialPurgeState(); }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function writeCredentialPurgeState(state) {
|
|
123
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
124
|
+
const temp = `${CREDENTIAL_PURGE_STATE_FILE}.${process.pid}.${Date.now()}.tmp`;
|
|
125
|
+
try {
|
|
126
|
+
writeFileSync(temp, JSON.stringify(normalizeCredentialPurgeState(state), null, 2) + '\n', 'utf8');
|
|
127
|
+
renameSync(temp, CREDENTIAL_PURGE_STATE_FILE);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
try { rmSync(temp, { force: true }); } catch {}
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function providerInventoryPolicy(config) {
|
|
135
|
+
return {
|
|
136
|
+
worker: config?.worker?.model_policy ?? {
|
|
137
|
+
priority: config?.flash_model_priority,
|
|
138
|
+
escalation_priority: config?.pro_model_priority,
|
|
139
|
+
},
|
|
140
|
+
reviewer: config?.review?.model_policy ?? {
|
|
141
|
+
priority: config?.pro_model_priority,
|
|
142
|
+
escalation_priority: [],
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function readProviderInventorySnapshot(hub, ctx, config) {
|
|
148
|
+
let catalog = { providers: [], harness_default: null };
|
|
149
|
+
try {
|
|
150
|
+
catalog = await readHarnessModelCatalog({
|
|
151
|
+
llm: ctx.llm ?? ctx.get('llm'),
|
|
152
|
+
getCurrentSelection: () => ctx.get('agentDefaultModel')?.currentSelection?.(),
|
|
153
|
+
});
|
|
154
|
+
} catch {}
|
|
155
|
+
const profileFile = join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml');
|
|
156
|
+
let declarations = [];
|
|
157
|
+
try {
|
|
158
|
+
if (existsSync(profileFile)) {
|
|
159
|
+
const source = readFileSync(profileFile, 'utf8');
|
|
160
|
+
const parsed = readProviderDeclarations(source, { file: 'harness/profiles/dsh-crew/cordis.patch.yml' });
|
|
161
|
+
if (parsed.ok) declarations = parsed.declarations;
|
|
162
|
+
}
|
|
163
|
+
} catch {}
|
|
164
|
+
const lifecycle = readProviderLifecycleState();
|
|
165
|
+
const credentialPurge = readCredentialPurgeState();
|
|
166
|
+
const inventory = buildProviderInventory({
|
|
167
|
+
catalog,
|
|
168
|
+
declarations,
|
|
169
|
+
policy: providerInventoryPolicy(config),
|
|
170
|
+
tombstones: lifecycle.tombstones,
|
|
171
|
+
activeJobs: hub.list(),
|
|
172
|
+
});
|
|
173
|
+
return {
|
|
174
|
+
...inventory,
|
|
175
|
+
credential_history_refs: Object.values(lifecycle.transactions ?? {})
|
|
176
|
+
.flatMap((transaction) => Array.isArray(transaction?.credential_refs) ? transaction.credential_refs : []),
|
|
177
|
+
credential_purged_refs: Object.keys(credentialPurge.purged),
|
|
178
|
+
credential_purge_unverified: Object.entries(credentialPurge.unverified).map(([referenceId, record]) => ({
|
|
179
|
+
reference_id: referenceId,
|
|
180
|
+
transaction_id: record.transaction_id,
|
|
181
|
+
state: record.state,
|
|
182
|
+
})),
|
|
183
|
+
lifecycle_transactions: Object.entries(lifecycle.transactions ?? {}).slice(-64).map(([transactionId, transaction]) => ({
|
|
184
|
+
transaction_id: transactionId,
|
|
185
|
+
provider_id: transaction.provider_id,
|
|
186
|
+
state: transaction.state,
|
|
187
|
+
...(transaction.expected_revision ? { expected_revision: transaction.expected_revision } : {}),
|
|
188
|
+
})),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function readProviderProfileRevision() {
|
|
193
|
+
const profileFile = join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml');
|
|
194
|
+
try {
|
|
195
|
+
if (!existsSync(profileFile)) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
|
|
196
|
+
const parsed = inspectProviderProfile(readFileSync(profileFile, 'utf8'));
|
|
197
|
+
return parsed.ok ? { ok: true, revision: parsed.revision } : { ok: false, code: parsed.code };
|
|
198
|
+
} catch {
|
|
199
|
+
return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function hubCanonicalEvents(job = {}) {
|
|
204
|
+
if (!job.id) return [];
|
|
205
|
+
const role = job.role === 'reviewer' ? 'reviewer' : 'worker';
|
|
206
|
+
const atStart = job.startedAt ?? null;
|
|
207
|
+
const atEnd = job.endedAt ?? atStart;
|
|
100
208
|
const definitions = [
|
|
101
209
|
['job.created', atStart, { client_job_id: job.client_job_id ?? null }],
|
|
102
210
|
['job.started', atStart, {}],
|
|
211
|
+
['runtime.bound', atStart, {
|
|
212
|
+
execution_plane: job.execution_context?.execution_plane ?? null,
|
|
213
|
+
profile: job.execution_context?.profile ?? null,
|
|
214
|
+
listen_port: Number.isInteger(job.execution_context?.listen_port) ? job.execution_context.listen_port : null,
|
|
215
|
+
runtime_id: job.execution_context?.runtime_id ?? null,
|
|
216
|
+
}],
|
|
103
217
|
['model.selected', atStart, { provider: job.provider ?? null, model: job.model ?? null, source: job.selection_source ?? null }],
|
|
218
|
+
['model.admitted', atStart, {
|
|
219
|
+
provider: job.provider ?? null,
|
|
220
|
+
model: job.model ?? null,
|
|
221
|
+
health_state: job.health_state ?? 'unprobed',
|
|
222
|
+
reason_code: job.health_reason_code ?? null,
|
|
223
|
+
observed_at: Number.isFinite(job.health_observed_at) ? job.health_observed_at : null,
|
|
224
|
+
}],
|
|
104
225
|
[role === 'reviewer' ? 'review.started' : 'worker.started', atStart, { run_id: job.id }],
|
|
226
|
+
['agent.created', atStart, { run_id: job.id }],
|
|
105
227
|
];
|
|
106
|
-
if (job.status !== 'running') {
|
|
107
|
-
definitions.push([
|
|
108
|
-
role === 'reviewer' ? 'review.completed' : 'worker.completed',
|
|
109
|
-
atEnd,
|
|
110
|
-
role === 'reviewer'
|
|
111
|
-
? { status: job.status ?? null, verdict: job.review?.verdict ?? null }
|
|
112
|
-
: { status: job.status ?? null, summary: job.outcome?.task_status ?? null },
|
|
113
|
-
]);
|
|
114
|
-
if (job.status === 'done') definitions.push(['job.completed', atEnd, { result_ref: job.id }]);
|
|
115
|
-
else if (job.status === 'cancelled') definitions.push(['job.cancelled', atEnd, { reason: 'cancelled' }]);
|
|
116
|
-
else definitions.push(['job.failed', atEnd, { error_code: job.error_code ?? null }]);
|
|
117
|
-
}
|
|
118
|
-
return definitions.map(([type, at, data], index) => createCanonicalJobEvent({
|
|
119
|
-
jobId: job.id, type, sequence: index + 1, at, role, attempt: job.attempt ?? 0, data,
|
|
120
|
-
}));
|
|
121
|
-
}
|
|
228
|
+
if (job.status !== 'running') {
|
|
229
|
+
definitions.push([
|
|
230
|
+
role === 'reviewer' ? 'review.completed' : 'worker.completed',
|
|
231
|
+
atEnd,
|
|
232
|
+
role === 'reviewer'
|
|
233
|
+
? { status: job.status ?? null, verdict: job.review?.verdict ?? null }
|
|
234
|
+
: { status: job.status ?? null, summary: job.outcome?.task_status ?? null },
|
|
235
|
+
]);
|
|
236
|
+
if (job.status === 'done') definitions.push(['job.completed', atEnd, { result_ref: job.id }]);
|
|
237
|
+
else if (job.status === 'cancelled') definitions.push(['job.cancelled', atEnd, { reason: 'cancelled' }]);
|
|
238
|
+
else definitions.push(['job.failed', atEnd, { error_code: job.error_code ?? null }]);
|
|
239
|
+
}
|
|
240
|
+
return definitions.map(([type, at, data], index) => createCanonicalJobEvent({
|
|
241
|
+
jobId: job.id, type, sequence: index + 1, at, role, attempt: job.attempt ?? 0, data,
|
|
242
|
+
}));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---------- job registry ----------
|
|
122
246
|
|
|
123
|
-
// ---------- job registry ----------
|
|
124
|
-
|
|
125
247
|
export function applyHubWorkspaceEvidence({ outcome, workspaceDiff, allowNoChanges = false, isolation = 'shared', role = 'worker' } = {}) {
|
|
126
|
-
const changes = workspaceDiff?.changes ?? {};
|
|
127
|
-
const hasChanges = ['modified', 'deleted', 'renamed', 'untracked']
|
|
128
|
-
.some((key) => Array.isArray(changes[key]) && changes[key].length > 0);
|
|
129
|
-
const evidenceAvailable = workspaceDiff?.kind === 'git' && workspaceDiff.dirtyBaseline !== true;
|
|
130
|
-
return applyWorkspaceEvidence(outcome, {
|
|
131
|
-
evidenceAvailable,
|
|
132
|
-
hasChanges,
|
|
133
|
-
allowNoChanges: allowNoChanges === true && isolation === 'worktree',
|
|
134
|
-
requireNoChangeAuthorization: role === 'worker',
|
|
248
|
+
const changes = workspaceDiff?.changes ?? {};
|
|
249
|
+
const hasChanges = ['modified', 'deleted', 'renamed', 'untracked']
|
|
250
|
+
.some((key) => Array.isArray(changes[key]) && changes[key].length > 0);
|
|
251
|
+
const evidenceAvailable = workspaceDiff?.kind === 'git' && workspaceDiff.dirtyBaseline !== true;
|
|
252
|
+
return applyWorkspaceEvidence(outcome, {
|
|
253
|
+
evidenceAvailable,
|
|
254
|
+
hasChanges,
|
|
255
|
+
allowNoChanges: allowNoChanges === true && isolation === 'worktree',
|
|
256
|
+
requireNoChangeAuthorization: role === 'worker',
|
|
135
257
|
});
|
|
136
258
|
}
|
|
137
|
-
|
|
138
|
-
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Build the explicit provider callability probe used by the 3210 lifecycle
|
|
262
|
+
* route. It performs one token-bounded Harness stream through the same llm
|
|
263
|
+
* runtime used by WorkerRegistry; no credential values are read or returned.
|
|
264
|
+
*/
|
|
265
|
+
export function createProviderProbe(ctx) {
|
|
266
|
+
const llm = ctx?.llm ?? ctx?.get?.('llm');
|
|
267
|
+
if (!llm || typeof llm.stream !== 'function') return null;
|
|
268
|
+
return async ({ provider, model, signal }) => {
|
|
269
|
+
try {
|
|
270
|
+
const stream = llm.stream({
|
|
271
|
+
provider,
|
|
272
|
+
model,
|
|
273
|
+
messages: [userMessage('dsh-crew provider probe')],
|
|
274
|
+
maxTokens: 1,
|
|
275
|
+
temperature: 0,
|
|
276
|
+
signal,
|
|
277
|
+
});
|
|
278
|
+
for await (const chunk of stream) {
|
|
279
|
+
if (chunk?.type !== 'finish') continue;
|
|
280
|
+
const reason = chunk.reason;
|
|
281
|
+
if (reason?.kind === 'error' || reason?.kind === 'aborted') {
|
|
282
|
+
return { ok: false, error: reason.failure ?? { code: `PROVIDER_PROBE_${String(reason.kind).toUpperCase()}` } };
|
|
283
|
+
}
|
|
284
|
+
return { ok: true };
|
|
285
|
+
}
|
|
286
|
+
return { ok: false, error: { code: 'PROVIDER_PROBE_NO_FINISH' } };
|
|
287
|
+
} catch (error) {
|
|
288
|
+
return { ok: false, error };
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Pick the first configured model for a provider, falling back to its live catalog. */
|
|
294
|
+
export function selectProviderProbeModel({ providerId, record, config } = {}) {
|
|
295
|
+
const advertised = new Set(Array.isArray(record?.models) ? record.models.filter((model) => typeof model === 'string') : []);
|
|
296
|
+
if (advertised.size === 0) return null;
|
|
297
|
+
const priorities = [
|
|
298
|
+
...(Array.isArray(config?.worker?.model_policy?.priority) ? config.worker.model_policy.priority : []),
|
|
299
|
+
...(Array.isArray(config?.flash_model_priority) ? config.flash_model_priority : []),
|
|
300
|
+
...(Array.isArray(config?.review?.model_policy?.priority) ? config.review.model_policy.priority : []),
|
|
301
|
+
...(Array.isArray(config?.pro_model_priority) ? config.pro_model_priority : []),
|
|
302
|
+
];
|
|
303
|
+
for (const ref of priorities) {
|
|
304
|
+
if (ref?.provider === providerId && advertised.has(ref.model)) return ref.model;
|
|
305
|
+
}
|
|
306
|
+
return [...advertised][0];
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Exported for the unit tests (test/hub-windows.test.mjs); instantiation
|
|
139
310
|
// needs only a duck-typed ctx, so spawn()'s path guard is testable without a
|
|
140
311
|
// live DSH host.
|
|
141
|
-
export class WorkerRegistry { constructor(ctx) {
|
|
142
|
-
this.ctx = ctx;
|
|
143
|
-
this.jobs = new Map();
|
|
144
|
-
this.nextId = 1;
|
|
145
|
-
this.shard = createShardWriter('hub');
|
|
146
|
-
|
|
312
|
+
export class WorkerRegistry { constructor(ctx) {
|
|
313
|
+
this.ctx = ctx;
|
|
314
|
+
this.jobs = new Map();
|
|
315
|
+
this.nextId = 1;
|
|
316
|
+
this.shard = createShardWriter('hub');
|
|
317
|
+
this.healthStore = createProviderHealthStore();
|
|
318
|
+
}
|
|
147
319
|
|
|
148
320
|
view(job, withResult = false) {
|
|
149
321
|
const v = {
|
|
150
|
-
id: job.id, client_job_id: job.client_job_id ?? null, sessionId: job.sessionId, role: job.role ?? 'worker', attempt: job.attempt ?? 0,
|
|
322
|
+
id: job.id, client_job_id: job.client_job_id ?? null, sessionId: job.sessionId, role: job.role ?? 'worker', attempt: job.attempt ?? 0,
|
|
151
323
|
tier: job.tier, provider: job.provider, model: job.model,
|
|
152
324
|
selection_source: job.selection_source,
|
|
153
325
|
selection_trace: job.selection_trace ?? null,
|
|
@@ -156,31 +328,32 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
156
328
|
cwd: job.cwd, turn: job.turn, step: job.step, currentTool: job.currentTool,
|
|
157
329
|
phase: job.phase ?? null,
|
|
158
330
|
toolCalls: job.toolCalls, tokens: job.tokens, mode: 'hub',
|
|
159
|
-
startedAt: job.startedAt, endedAt: job.endedAt,
|
|
160
|
-
isolation: job.isolation ?? 'shared', workspace_branch: job.workspace_branch ?? null,
|
|
161
|
-
delivery_complete: !!job.delivery_complete,
|
|
162
|
-
allow_no_changes: job.allow_no_changes === true,
|
|
163
|
-
task_status: job.outcome?.task_status ?? null,
|
|
164
|
-
workspace_evidence_ok: job.outcome?.workspace_evidence_ok ?? null,
|
|
165
|
-
review_verdict: job.review?.verdict ?? null,
|
|
331
|
+
startedAt: job.startedAt, endedAt: job.endedAt,
|
|
332
|
+
isolation: job.isolation ?? 'shared', workspace_branch: job.workspace_branch ?? null,
|
|
333
|
+
delivery_complete: !!job.delivery_complete,
|
|
334
|
+
allow_no_changes: job.allow_no_changes === true,
|
|
335
|
+
task_status: job.outcome?.task_status ?? null,
|
|
336
|
+
workspace_evidence_ok: job.outcome?.workspace_evidence_ok ?? null,
|
|
337
|
+
review_verdict: job.review?.verdict ?? null,
|
|
166
338
|
workspace_diff_available: !!job.workspaceDiff && job.workspaceDiff.kind === 'git',
|
|
167
339
|
workspace_retained: job.workspace_retained === true,
|
|
168
340
|
cleanup_warning: job.cleanup_warning ?? null,
|
|
169
341
|
profile_id: job.profile_id ?? null,
|
|
170
342
|
workspace_context: job.workspace_context ?? null,
|
|
343
|
+
execution_context: job.execution_context ?? null,
|
|
171
344
|
event_cursor: hubCanonicalEvents(job).at(-1)?.sequence ?? 0,
|
|
172
345
|
};
|
|
173
346
|
if (withResult) {
|
|
174
347
|
v.result = job.result; v.error = job.error; v.stopReason = job.stopReason;
|
|
175
348
|
v.reasonDetail = job.reasonDetail;
|
|
176
349
|
v.delivery = job.delivery_metadata ?? null;
|
|
177
|
-
v.delivery_missing = job.delivery_missing ?? [];
|
|
178
|
-
v.outcome = job.outcome ?? null;
|
|
179
|
-
v.review = job.review ?? null;
|
|
350
|
+
v.delivery_missing = job.delivery_missing ?? [];
|
|
351
|
+
v.outcome = job.outcome ?? null;
|
|
352
|
+
v.review = job.review ?? null;
|
|
180
353
|
v.workspace_diff = job.workspaceDiff ?? null;
|
|
181
|
-
v.workspace_baseline_dirty = !!job.workspaceDiff?.dirtyBaseline;
|
|
182
|
-
v.canonical_events = hubCanonicalEvents(job);
|
|
183
|
-
}
|
|
354
|
+
v.workspace_baseline_dirty = !!job.workspaceDiff?.dirtyBaseline;
|
|
355
|
+
v.canonical_events = hubCanonicalEvents(job);
|
|
356
|
+
}
|
|
184
357
|
return v;
|
|
185
358
|
}
|
|
186
359
|
|
|
@@ -200,7 +373,7 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
200
373
|
* `role` (worker | reviewer) records who does the work; `tier` remains the
|
|
201
374
|
* legacy model-class slot. Reviewer-role jobs always use the pro slot.
|
|
202
375
|
*/
|
|
203
|
-
async spawn({ task, tier = 'flash', role, attempt = 0, effort = 'max', cwd, source = 'api', preset, delivery = 'coding', client_job_id, requested_isolation, workspace_branch, timeout_seconds, profile_id, workspace_context, allow_no_changes }) {
|
|
376
|
+
async spawn({ task, tier = 'flash', role, attempt = 0, effort = 'max', cwd, source = 'api', preset, delivery = 'coding', client_job_id, requested_isolation, workspace_branch, timeout_seconds, profile_id, workspace_context, allow_no_changes, ingress = 'direct-3210' }) {
|
|
204
377
|
// role is only honored when the caller explicitly names it; a legacy
|
|
205
378
|
// tier-only spawn (role === undefined) keeps the exact v0.1 resolution.
|
|
206
379
|
const hasRole = role === 'worker' || role === 'reviewer';
|
|
@@ -219,7 +392,9 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
219
392
|
// Provider routing: follow-dsh reads the DSH Models selection live; the
|
|
220
393
|
// default deepseek-official keeps legacy setups unchanged. Re-resolved on
|
|
221
394
|
// every spawn so a Models change takes effect on the next worker.
|
|
222
|
-
const cfg = normalizeGlobalConfig(this.getConfig?.() ?? {});
|
|
395
|
+
const cfg = normalizeGlobalConfig(this.getConfig?.() ?? {});
|
|
396
|
+
const lifecycleState = readProviderLifecycleState();
|
|
397
|
+
const healthGate = cfg.health_gate ?? cfg.worker?.model_policy?.health_gate;
|
|
223
398
|
const workerProviderMode = normalizeWorkerProviderMode(cfg.worker_provider_mode);
|
|
224
399
|
const getCurrentSelection = () => this.ctx.get('agentDefaultModel')?.currentSelection?.();
|
|
225
400
|
let selection;
|
|
@@ -260,22 +435,30 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
260
435
|
// tier-only spawns resolve through the tier resolver exactly as before.
|
|
261
436
|
if (hasRole) {
|
|
262
437
|
const policy = resolveModelPolicy(cfg, effRole, { attempt });
|
|
263
|
-
selection = resolveModel({
|
|
264
|
-
role: effRole,
|
|
265
|
-
attempt,
|
|
266
|
-
policy,
|
|
267
|
-
catalog,
|
|
268
|
-
harnessDefault: catalog.harness_default ?? getCurrentSelection(),
|
|
269
|
-
|
|
438
|
+
selection = resolveModel({
|
|
439
|
+
role: effRole,
|
|
440
|
+
attempt,
|
|
441
|
+
policy,
|
|
442
|
+
catalog,
|
|
443
|
+
harnessDefault: catalog.harness_default ?? getCurrentSelection(),
|
|
444
|
+
healthStore: this.healthStore,
|
|
445
|
+
healthGate: policy.health_gate ?? healthGate,
|
|
446
|
+
allowFallback: cfg.allow_fallback !== false,
|
|
447
|
+
tombstones: lifecycleState.tombstones,
|
|
448
|
+
});
|
|
270
449
|
} else {
|
|
271
450
|
selection = resolveWorkerModel({
|
|
272
451
|
tier: effTier,
|
|
273
452
|
priority: cfg[`${effTier}_model_priority`],
|
|
274
453
|
priorityConfigured: cfg[`${effTier}_model_priority_configured`],
|
|
275
454
|
fallback: cfg[`${effTier}_model_fallback`],
|
|
276
|
-
catalog,
|
|
277
|
-
harnessDefault: catalog.harness_default ?? getCurrentSelection(),
|
|
278
|
-
|
|
455
|
+
catalog,
|
|
456
|
+
harnessDefault: catalog.harness_default ?? getCurrentSelection(),
|
|
457
|
+
healthStore: this.healthStore,
|
|
458
|
+
healthGate,
|
|
459
|
+
allowFallback: cfg.allow_fallback !== false,
|
|
460
|
+
tombstones: lifecycleState.tombstones,
|
|
461
|
+
traceContext: {
|
|
279
462
|
role: effRole,
|
|
280
463
|
logicalAttempt: attempt,
|
|
281
464
|
modelClassHint: effTier,
|
|
@@ -293,53 +476,80 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
293
476
|
const jobRole = hasRole ? role : (delivery === 'review' || role === 'reviewer' ? 'reviewer' : 'worker');
|
|
294
477
|
const workerPrompt = appendDeliveryInstructions(task, { tier: effTier, role: jobRole, isReview: delivery === 'review' || jobRole === 'reviewer' });
|
|
295
478
|
|
|
296
|
-
const id = `hub-${this.nextId++}-${Date.now().toString(36)}`;
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
479
|
+
const id = `hub-${this.nextId++}-${Date.now().toString(36)}`;
|
|
480
|
+
|
|
481
|
+
// Resolve presets before any side effect (registry entry, worktree): a
|
|
482
|
+
// rejected preset must fail spawn() cleanly instead of leaving a ghost
|
|
483
|
+
// "running" job and a leaked worktree behind.
|
|
484
|
+
const presets = this.ctx.get('agentPresets');
|
|
485
|
+
const wanted = preset ?? (tier === 'flash' ? cfg.preset_flash : cfg.preset_pro);
|
|
486
|
+
const presetId = presets === undefined
|
|
487
|
+
? undefined
|
|
488
|
+
: (await presets.resolve(!wanted || wanted === 'default' ? undefined : wanted)).id;
|
|
489
|
+
|
|
490
|
+
let executionCwd = cwd;
|
|
491
|
+
let isolatedWorkspace = null;
|
|
492
|
+
if ((requested_isolation === 'worktree' && jobRole === 'worker') || requested_isolation === 'readonly') {
|
|
493
|
+
const created = await createIsolatedWorkspace({ cwd, jobId: id, baseRevision: workspace_branch });
|
|
494
|
+
if (!created.ok) throw Object.assign(new Error(created.error ?? created.reason), { code: created.reason });
|
|
495
|
+
executionCwd = created.worktreePath;
|
|
496
|
+
isolatedWorkspace = { worktreePath: created.worktreePath, repoRoot: created.repoRoot };
|
|
497
|
+
}
|
|
498
|
+
const sessionId = `session-${randomUUID()}`;
|
|
499
|
+
const job = {
|
|
500
|
+
id, client_job_id: client_job_id ?? null, sessionId, role: jobRole, attempt, tier: effTier, provider: selection.provider, model: selection.model,
|
|
308
501
|
selection_source: selection.source, selection_trace: selection.selection_trace ?? null,
|
|
309
502
|
effort, reasoning_effort: selection.reasoningEffort,
|
|
310
503
|
task, source, cwd: executionCwd, requested_cwd: cwd,
|
|
311
504
|
isolation: isolatedWorkspace ? 'worktree' : 'shared',
|
|
505
|
+
execution_context: (() => {
|
|
506
|
+
const runtime = getHubRuntimeIdentity();
|
|
507
|
+
return {
|
|
508
|
+
execution_plane: runtime.execution_plane,
|
|
509
|
+
profile: runtime.profile,
|
|
510
|
+
listen_port: runtime.listen_port,
|
|
511
|
+
runtime_id: runtime.runtime_id,
|
|
512
|
+
ingress: ingress === 'official-3080' ? 'official-3080' : 'direct-3210',
|
|
513
|
+
};
|
|
514
|
+
})(),
|
|
312
515
|
allow_no_changes: allow_no_changes === true,
|
|
313
|
-
workspace_branch: workspace_branch ?? null, isolatedWorkspace,
|
|
314
|
-
profile_id: profile_id ?? null, workspace_context: workspace_context ?? null,
|
|
516
|
+
workspace_branch: workspace_branch ?? null, isolatedWorkspace,
|
|
517
|
+
profile_id: profile_id ?? null, workspace_context: workspace_context ?? null,
|
|
315
518
|
prompt: workerPrompt, delivery: delivery === 'review' ? 'review' : 'coding',
|
|
316
519
|
phase: JOB_PHASES.RUNNING,
|
|
317
520
|
status: 'running', turn: 0, step: 0, currentTool: null, toolCalls: 0,
|
|
318
521
|
tokens: { input: 0, output: 0, reasoning: 0 },
|
|
319
522
|
startedAt: new Date().toISOString(), endedAt: null,
|
|
320
523
|
result: null, error: null, stopReason: null, handle: null, waiters: [],
|
|
321
|
-
delivery_complete: false, delivery_missing: [], delivery_metadata: null,
|
|
322
|
-
outcome: null,
|
|
323
|
-
lastAssistantText: null, handle_dispose_promise: null, disposeHandle: null,
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
524
|
+
delivery_complete: false, delivery_missing: [], delivery_metadata: null,
|
|
525
|
+
outcome: null,
|
|
526
|
+
lastAssistantText: null, handle_dispose_promise: null, disposeHandle: null,
|
|
527
|
+
// Abort latch: cancel()/timeout set this the moment the job becomes
|
|
528
|
+
// terminal, even while agents.create() is still pending, so a handle
|
|
529
|
+
// acquired after that point is disposed instead of being driven.
|
|
530
|
+
abortRequested: false,
|
|
531
|
+
abortPromise: null,
|
|
532
|
+
resolveAbort: null,
|
|
533
|
+
};
|
|
534
|
+
job.abortPromise = new Promise((resolve) => { job.resolveAbort = resolve; });
|
|
535
|
+
const disposeJobHandle = async () => {
|
|
536
|
+
if (!job.handle) return;
|
|
537
|
+
if (!job.handle_dispose_promise) {
|
|
538
|
+
try {
|
|
539
|
+
job.handle_dispose_promise = Promise.resolve(job.handle.dispose());
|
|
540
|
+
} catch (error) {
|
|
541
|
+
job.handle_dispose_promise = Promise.reject(error);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
await job.handle_dispose_promise;
|
|
545
|
+
};
|
|
546
|
+
job.disposeHandle = disposeJobHandle;
|
|
331
547
|
// Read-only pre-run snapshot (async, never blocks dispatch): the audit
|
|
332
548
|
// only needs the before-state by the time the worker finishes. Non-repos
|
|
333
549
|
// degrade to { kind:'no-git' } instead of failing the job.
|
|
334
|
-
job.baseline = await captureWorkspaceBaseline({ cwd: executionCwd }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace audit failed' }));
|
|
550
|
+
job.baseline = await captureWorkspaceBaseline({ cwd: executionCwd }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace audit failed' }));
|
|
335
551
|
this.jobs.set(id, job);
|
|
336
552
|
|
|
337
|
-
const presets = this.ctx.get('agentPresets');
|
|
338
|
-
const wanted = preset ?? (tier === 'flash' ? cfg.preset_flash : cfg.preset_pro);
|
|
339
|
-
const presetId = presets === undefined
|
|
340
|
-
? undefined
|
|
341
|
-
: (await presets.resolve(!wanted || wanted === 'default' ? undefined : wanted)).id;
|
|
342
|
-
|
|
343
553
|
const onEvent = (session, event) => {
|
|
344
554
|
switch (event.type) {
|
|
345
555
|
case 'step/start':
|
|
@@ -355,9 +565,9 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
355
565
|
job.tokens.output += u.outputTokens ?? 0;
|
|
356
566
|
job.tokens.reasoning += u.reasoningTokens ?? 0;
|
|
357
567
|
}
|
|
358
|
-
const text = (event.data?.message?.content ?? [])
|
|
359
|
-
.filter((c) => c?.type === 'text').map((c) => c.text).join('');
|
|
360
|
-
if (text) job.lastAssistantText = text;
|
|
568
|
+
const text = (event.data?.message?.content ?? [])
|
|
569
|
+
.filter((c) => c?.type === 'text').map((c) => c.text).join('');
|
|
570
|
+
if (text) job.lastAssistantText = text;
|
|
361
571
|
break;
|
|
362
572
|
}
|
|
363
573
|
case 'turn/end':
|
|
@@ -369,9 +579,9 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
369
579
|
};
|
|
370
580
|
|
|
371
581
|
const run = async () => {
|
|
372
|
-
const
|
|
582
|
+
const createPromise = this.ctx.agents.create({
|
|
373
583
|
sessionId,
|
|
374
|
-
meta: { cwd: executionCwd, ...(presetId === undefined ? {} : { agentPreset: presetId }) },
|
|
584
|
+
meta: { cwd: executionCwd, ...(presetId === undefined ? {} : { agentPreset: presetId }) },
|
|
375
585
|
agentOptions: { provider: selection.provider, model: selection.model },
|
|
376
586
|
setup: async (agentCtx) => {
|
|
377
587
|
installModelSelection(agentCtx, { current: selection, assembled: undefined });
|
|
@@ -383,58 +593,90 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
383
593
|
});
|
|
384
594
|
},
|
|
385
595
|
});
|
|
596
|
+
const created = await Promise.race([
|
|
597
|
+
createPromise.then((handle) => ({ handle })),
|
|
598
|
+
job.abortPromise.then(() => ({ aborted: true })),
|
|
599
|
+
]);
|
|
600
|
+
if (created.aborted) {
|
|
601
|
+
createPromise.then(async (handle) => {
|
|
602
|
+
if (handle && (job.status !== 'running' || job.abortRequested)) {
|
|
603
|
+
job.handle = handle;
|
|
604
|
+
try { await job.disposeHandle(); } catch (error) {
|
|
605
|
+
job.cleanup_warning = `agent cleanup failed: ${error?.message ?? String(error)}`;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}).catch(() => {});
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
const handle = created.handle;
|
|
386
612
|
job.handle = handle;
|
|
613
|
+
// The job may have become terminal (cancel/timeout) while create() was
|
|
614
|
+
// pending; the old disposer was a no-op in that window. Dispose the
|
|
615
|
+
// freshly acquired handle and never deliver the prompt.
|
|
616
|
+
if (job.status !== 'running' || job.abortRequested) {
|
|
617
|
+
try { await job.disposeHandle(); } catch (error) {
|
|
618
|
+
job.cleanup_warning = `agent cleanup failed: ${error?.message ?? String(error)}`;
|
|
619
|
+
}
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
387
622
|
try {
|
|
388
623
|
// Group the worker session under the workspace of its cwd; create the
|
|
389
624
|
// workspace when none exists yet (resolveByPath is exact-match, so a
|
|
390
625
|
// job cwd never inherits a parent directory's workspace).
|
|
391
626
|
const registry = this.ctx.get('workspaceRegistry');
|
|
392
627
|
if (registry !== undefined) {
|
|
393
|
-
const ws = (await registry.resolveByPath(executionCwd)) ?? (await registry.create(executionCwd));
|
|
628
|
+
const ws = (await registry.resolveByPath(executionCwd)) ?? (await registry.create(executionCwd));
|
|
394
629
|
await ws.attachSession(sessionId);
|
|
395
630
|
}
|
|
396
631
|
} catch (err) {
|
|
397
|
-
this.ctx.logger?.warn?.(`dsh-crew: workspace attach failed for ${executionCwd}: ${err?.message ?? err}`);
|
|
632
|
+
this.ctx.logger?.warn?.(`dsh-crew: workspace attach failed for ${executionCwd}: ${err?.message ?? err}`);
|
|
398
633
|
}
|
|
399
634
|
await handle.agent.whenIdle();
|
|
635
|
+
// Cancel/timeout may have become terminal while waiting for the initial
|
|
636
|
+
// idle boundary; do not deliver the prompt to a job that is no longer
|
|
637
|
+
// running. The shared finally block still disposes the handle exactly once.
|
|
638
|
+
if (job.status !== 'running' || job.abortRequested) return;
|
|
400
639
|
handle.agent.followup(userMessage(job.prompt));
|
|
401
640
|
await handle.agent.whenIdle();
|
|
402
|
-
job.result = job.lastAssistantText ?? '';
|
|
403
|
-
if (job.status === 'running') job.status = job.stopReason === 'completed' ? 'done' : 'failed';
|
|
641
|
+
job.result = job.lastAssistantText ?? '';
|
|
642
|
+
if (job.status === 'running') job.status = job.stopReason === 'completed' ? 'done' : 'failed';
|
|
404
643
|
if (job.status === 'failed' && !job.error) job.error = `turn ended: ${job.stopReason ?? 'unknown'}`;
|
|
405
644
|
await this.ctx.sessions.flush(handle.agent.session);
|
|
406
645
|
};
|
|
407
646
|
|
|
408
|
-
const timeoutMs = Number.isInteger(timeout_seconds) && timeout_seconds > 0 ? timeout_seconds * 1000 : null;
|
|
409
|
-
if (timeoutMs) {
|
|
410
|
-
job.timeoutHandle = setTimeout(async () => {
|
|
411
|
-
if (job.status !== 'running') return;
|
|
412
|
-
job.
|
|
413
|
-
job.
|
|
414
|
-
job.
|
|
415
|
-
job.
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
647
|
+
const timeoutMs = Number.isInteger(timeout_seconds) && timeout_seconds > 0 ? timeout_seconds * 1000 : null;
|
|
648
|
+
if (timeoutMs) {
|
|
649
|
+
job.timeoutHandle = setTimeout(async () => {
|
|
650
|
+
if (job.status !== 'running') return;
|
|
651
|
+
job.abortRequested = true;
|
|
652
|
+
job.status = 'failed';
|
|
653
|
+
job.phase = JOB_PHASES.FAILED;
|
|
654
|
+
job.error = `job timed out after ${timeout_seconds}s`;
|
|
655
|
+
job.error_code = 'JOB_TIMEOUT';
|
|
656
|
+
job.endedAt = new Date().toISOString();
|
|
657
|
+
job.resolveAbort?.();
|
|
658
|
+
this.publish();
|
|
659
|
+
for (const waiter of job.waiters.splice(0)) waiter();
|
|
660
|
+
try { await disposeJobHandle(); } catch (error) {
|
|
661
|
+
job.cleanup_warning = `agent cleanup failed: ${error?.message ?? String(error)}`;
|
|
662
|
+
this.publish();
|
|
663
|
+
}
|
|
664
|
+
}, timeoutMs);
|
|
665
|
+
job.timeoutHandle.unref?.();
|
|
666
|
+
}
|
|
667
|
+
job.promise = run()
|
|
426
668
|
.catch((err) => {
|
|
427
669
|
if (job.status === 'running') job.status = 'failed';
|
|
428
670
|
job.error = job.error ?? (err?.message ?? String(err));
|
|
429
|
-
})
|
|
430
|
-
.finally(async () => {
|
|
431
|
-
if (job.timeoutHandle) clearTimeout(job.timeoutHandle);
|
|
432
|
-
job.endedAt = new Date().toISOString();
|
|
433
|
-
job.currentTool = null;
|
|
434
|
-
let handleCleanupWarning = job.cleanup_warning ?? null;
|
|
435
|
-
try { await disposeJobHandle(); } catch (error) {
|
|
436
|
-
handleCleanupWarning = `agent cleanup failed: ${error?.message ?? String(error)}`;
|
|
437
|
-
}
|
|
671
|
+
})
|
|
672
|
+
.finally(async () => {
|
|
673
|
+
if (job.timeoutHandle) clearTimeout(job.timeoutHandle);
|
|
674
|
+
job.endedAt = new Date().toISOString();
|
|
675
|
+
job.currentTool = null;
|
|
676
|
+
let handleCleanupWarning = job.cleanup_warning ?? null;
|
|
677
|
+
try { await disposeJobHandle(); } catch (error) {
|
|
678
|
+
handleCleanupWarning = `agent cleanup failed: ${error?.message ?? String(error)}`;
|
|
679
|
+
}
|
|
438
680
|
// Delivery completeness is separate from execution status: a job can
|
|
439
681
|
// be done yet fail to report Diff/Tests/Risks (or Review sections for
|
|
440
682
|
// an automatic review). Parse whatever final message the worker
|
|
@@ -442,21 +684,21 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
442
684
|
const parsed = parseDeliveryReport(job.result ?? '');
|
|
443
685
|
job.delivery_complete = parsed.complete;
|
|
444
686
|
job.delivery_missing = parsed.missing;
|
|
445
|
-
job.delivery_metadata = formatDeliveryMetadata(parsed);
|
|
446
|
-
if (job.role === 'reviewer') {
|
|
447
|
-
const verdictText = String(parsed.sections?.Verdict ?? '').trim().toLowerCase();
|
|
448
|
-
const verdict = /^(approve|approved|pass)\b/.test(verdictText)
|
|
449
|
-
? 'approve'
|
|
450
|
-
: /request.*chang|chang.*request|reject|needs changes/i.test(verdictText)
|
|
451
|
-
? 'request_changes'
|
|
452
|
-
: 'inconclusive';
|
|
453
|
-
const lines = (value) => String(value ?? '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean).slice(0, 40);
|
|
454
|
-
job.review = {
|
|
455
|
-
verdict, status: job.status, delivery_complete: parsed.complete === true,
|
|
456
|
-
findings: lines(parsed.sections?.['Review Findings']),
|
|
457
|
-
evidence: lines(parsed.sections?.Evidence), risks: lines(parsed.sections?.Risks),
|
|
458
|
-
};
|
|
459
|
-
}
|
|
687
|
+
job.delivery_metadata = formatDeliveryMetadata(parsed);
|
|
688
|
+
if (job.role === 'reviewer') {
|
|
689
|
+
const verdictText = String(parsed.sections?.Verdict ?? '').trim().toLowerCase();
|
|
690
|
+
const verdict = /^(approve|approved|pass)\b/.test(verdictText)
|
|
691
|
+
? 'approve'
|
|
692
|
+
: /request.*chang|chang.*request|reject|needs changes/i.test(verdictText)
|
|
693
|
+
? 'request_changes'
|
|
694
|
+
: 'inconclusive';
|
|
695
|
+
const lines = (value) => String(value ?? '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean).slice(0, 40);
|
|
696
|
+
job.review = {
|
|
697
|
+
verdict, status: job.status, delivery_complete: parsed.complete === true,
|
|
698
|
+
findings: lines(parsed.sections?.['Review Findings']),
|
|
699
|
+
evidence: lines(parsed.sections?.Evidence), risks: lines(parsed.sections?.Risks),
|
|
700
|
+
};
|
|
701
|
+
}
|
|
460
702
|
// Canonical structured outcome (shared workflow layer) + terminal phase.
|
|
461
703
|
job.outcome = buildOutcome({
|
|
462
704
|
result: job.result ?? '',
|
|
@@ -467,9 +709,9 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
467
709
|
});
|
|
468
710
|
job.phase = job.status === 'done' ? JOB_PHASES.COMPLETED : job.status === 'cancelled' ? JOB_PHASES.CANCELLED : JOB_PHASES.FAILED;
|
|
469
711
|
// Read-only after-snapshot of the workspace: bounded, redacted patch.
|
|
470
|
-
job.workspaceDiff = job.baseline.kind === 'git'
|
|
471
|
-
? await captureWorkspaceDiff({ cwd: executionCwd, baseline: job.baseline }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace diff failed' }))
|
|
472
|
-
: job.baseline;
|
|
712
|
+
job.workspaceDiff = job.baseline.kind === 'git'
|
|
713
|
+
? await captureWorkspaceDiff({ cwd: executionCwd, baseline: job.baseline }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace diff failed' }))
|
|
714
|
+
: job.baseline;
|
|
473
715
|
job.outcome = applyHubWorkspaceEvidence({
|
|
474
716
|
outcome: job.outcome,
|
|
475
717
|
workspaceDiff: job.workspaceDiff,
|
|
@@ -477,23 +719,32 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
477
719
|
isolation: job.isolation,
|
|
478
720
|
role: job.role,
|
|
479
721
|
});
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
job.
|
|
485
|
-
job.
|
|
486
|
-
job.
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
if (job.isolatedWorkspace) {
|
|
490
|
-
const cleanup = await cleanupIsolatedWorkspace(job.isolatedWorkspace).catch((error) => ({ ok: false, error: error?.message ?? String(error) }));
|
|
491
|
-
job.workspace_retained = cleanup.ok !== true;
|
|
492
|
-
const workspaceCleanupWarning = cleanup.ok === true ? null : cleanup.error ?? 'worktree cleanup failed';
|
|
493
|
-
job.cleanup_warning = [handleCleanupWarning, workspaceCleanupWarning].filter(Boolean).join('; ') || null;
|
|
494
|
-
} else {
|
|
495
|
-
job.cleanup_warning = handleCleanupWarning;
|
|
722
|
+
// Record only sanitized callability evidence. Cancelled jobs are
|
|
723
|
+
// operator actions, not provider health signals.
|
|
724
|
+
if (job.status !== 'cancelled') {
|
|
725
|
+
this.healthStore.record(job.provider, job.model, {
|
|
726
|
+
ok: job.status === 'done',
|
|
727
|
+
observed_at: Date.parse(job.endedAt ?? '') || Date.now(),
|
|
728
|
+
error: { code: job.error_code, message: job.error },
|
|
729
|
+
});
|
|
496
730
|
}
|
|
731
|
+
if (job.role === 'reviewer' && job.review && job.workspaceDiff?.kind === 'git') {
|
|
732
|
+
const changes = job.workspaceDiff.changes ?? {};
|
|
733
|
+
const mutated = ['modified', 'deleted', 'renamed', 'untracked'].some((key) => Array.isArray(changes[key]) && changes[key].length > 0);
|
|
734
|
+
if (mutated) {
|
|
735
|
+
job.review.mutated_candidate = true;
|
|
736
|
+
job.review.invalidated = true;
|
|
737
|
+
job.review.verdict = 'request_changes';
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
if (job.isolatedWorkspace) {
|
|
741
|
+
const cleanup = await cleanupIsolatedWorkspace(job.isolatedWorkspace).catch((error) => ({ ok: false, error: error?.message ?? String(error) }));
|
|
742
|
+
job.workspace_retained = cleanup.ok !== true;
|
|
743
|
+
const workspaceCleanupWarning = cleanup.ok === true ? null : cleanup.error ?? 'worktree cleanup failed';
|
|
744
|
+
job.cleanup_warning = [handleCleanupWarning, workspaceCleanupWarning].filter(Boolean).join('; ') || null;
|
|
745
|
+
} else {
|
|
746
|
+
job.cleanup_warning = handleCleanupWarning;
|
|
747
|
+
}
|
|
497
748
|
this.publish();
|
|
498
749
|
for (const w of job.waiters.splice(0)) w();
|
|
499
750
|
});
|
|
@@ -506,66 +757,49 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
506
757
|
const job = this.jobs.get(id);
|
|
507
758
|
if (!job) return undefined;
|
|
508
759
|
if (job.status !== 'running') return job;
|
|
509
|
-
await
|
|
510
|
-
new Promise((res) => job.waiters.push(res)),
|
|
511
|
-
timeoutMs > 0 ? new Promise((res) => setTimeout(res, timeoutMs)) : Promise.resolve(),
|
|
512
|
-
]);
|
|
760
|
+
await raceWaiters(job.waiters, { timeoutMs, immediateWithoutTimeout: true });
|
|
513
761
|
return job;
|
|
514
762
|
}
|
|
515
763
|
|
|
516
|
-
async cancel(id) {
|
|
764
|
+
async cancel(id) {
|
|
517
765
|
const job = this.jobs.get(id);
|
|
518
766
|
if (!job) return undefined;
|
|
519
767
|
if (job.status === 'running') {
|
|
768
|
+
job.abortRequested = true;
|
|
520
769
|
job.status = 'cancelled';
|
|
521
770
|
job.phase = JOB_PHASES.CANCELLED;
|
|
522
771
|
job.error = 'cancelled by request';
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
}
|
|
772
|
+
job.resolveAbort?.();
|
|
773
|
+
for (const waiter of job.waiters.splice(0)) waiter();
|
|
774
|
+
try { await job.disposeHandle?.(); } catch (error) {
|
|
775
|
+
job.cleanup_warning = `agent cleanup failed: ${error?.message ?? String(error)}`;
|
|
776
|
+
}
|
|
526
777
|
this.publish();
|
|
527
778
|
}
|
|
528
779
|
return job;
|
|
529
780
|
}
|
|
530
781
|
|
|
531
|
-
async dispose() {
|
|
532
|
-
for (const job of this.jobs.values()) {
|
|
533
|
-
if (job.status === 'running') {
|
|
534
|
-
try { await job.disposeHandle?.(); } catch (error) {
|
|
535
|
-
job.cleanup_warning = `agent cleanup failed: ${error?.message ?? String(error)}`;
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
}
|
|
782
|
+
async dispose() {
|
|
783
|
+
for (const job of this.jobs.values()) {
|
|
784
|
+
if (job.status === 'running') {
|
|
785
|
+
try { await job.disposeHandle?.(); } catch (error) {
|
|
786
|
+
job.cleanup_warning = `agent cleanup failed: ${error?.message ?? String(error)}`;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
540
791
|
}
|
|
541
792
|
|
|
542
793
|
// ---------- loopback route helpers (pattern from dsh-noema) ----------
|
|
543
794
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
if (
|
|
550
|
-
return
|
|
795
|
+
// The common trust core and its Origin policies live in one shared module so
|
|
796
|
+
// the hub and the 3080 bridge cannot drift apart (see local-request-guard.mjs).
|
|
797
|
+
export function isLoopbackRequest(req) {
|
|
798
|
+
if (!localRequestCore(req)) return false;
|
|
799
|
+
const origin = req.headers?.origin;
|
|
800
|
+
if (origin === undefined) return true;
|
|
801
|
+
return originLoopback(origin);
|
|
551
802
|
}
|
|
552
|
-
export function isLoopbackRequest(req) {
|
|
553
|
-
if (!isLoopbackAddress(req.socket?.remoteAddress)) return false;
|
|
554
|
-
let host;
|
|
555
|
-
try { host = new URL(`http://${String(req.headers.host ?? '')}`).hostname.toLowerCase(); } catch { return false; }
|
|
556
|
-
if (!(host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || isIpv4Loopback(host))) return false;
|
|
557
|
-
const fetchSite = String(req.headers?.['sec-fetch-site'] ?? '').toLowerCase();
|
|
558
|
-
if (fetchSite && fetchSite !== 'same-origin' && fetchSite !== 'none') return false;
|
|
559
|
-
const origin = req.headers?.origin;
|
|
560
|
-
if (origin !== undefined) {
|
|
561
|
-
if (typeof origin !== 'string') return false;
|
|
562
|
-
let parsed;
|
|
563
|
-
try { parsed = new URL(origin); } catch { return false; }
|
|
564
|
-
const originHost = parsed.hostname.toLowerCase();
|
|
565
|
-
if (!(originHost === 'localhost' || originHost === '127.0.0.1' || originHost === '::1' || originHost === '[::1]' || isIpv4Loopback(originHost))) return false;
|
|
566
|
-
}
|
|
567
|
-
return true;
|
|
568
|
-
}
|
|
569
803
|
function sendJson(res, status, value, headers = {}) {
|
|
570
804
|
const body = JSON.stringify(value);
|
|
571
805
|
res.writeHead(status, {
|
|
@@ -607,102 +841,103 @@ async function readBody(req, limit = 64 * 1024) {
|
|
|
607
841
|
* Returns { ok: true, payload } (payload.tier = effective tier) or
|
|
608
842
|
* { ok: false, code, error }.
|
|
609
843
|
*/
|
|
610
|
-
export function resolveHubSpawnPayload(payload, getConfig = () => ({}), dependencies = {}) {
|
|
611
|
-
const config = normalizeGlobalConfig(getConfig());
|
|
612
|
-
const raw = payload ?? {};
|
|
613
|
-
const advanced = raw.profile !== undefined || raw.workspace_id !== undefined || raw.workspace !== undefined
|
|
614
|
-
|| raw.constraints !== undefined || raw.context_refs !== undefined || raw.job_id !== undefined || raw.objective !== undefined;
|
|
615
|
-
let normalized = { ...raw };
|
|
616
|
-
if (advanced) {
|
|
617
|
-
if (raw.workspace !== undefined && (!raw.workspace || typeof raw.workspace !== 'object' || Array.isArray(raw.workspace))) {
|
|
618
|
-
return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'workspace must be an object' };
|
|
619
|
-
}
|
|
620
|
-
if (raw.constraints !== undefined && (!raw.constraints || typeof raw.constraints !== 'object' || Array.isArray(raw.constraints))) {
|
|
621
|
-
return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'constraints must be an object' };
|
|
622
|
-
}
|
|
623
|
-
if (raw.workspace?.repo_root !== undefined && typeof raw.workspace.repo_root !== 'string') {
|
|
624
|
-
return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'workspace repo_root must be a string' };
|
|
625
|
-
}
|
|
626
|
-
if (raw.workspace?.worktree !== undefined && !['auto', 'existing', 'none'].includes(raw.workspace.worktree)) {
|
|
627
|
-
return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'invalid worktree policy' };
|
|
628
|
-
}
|
|
629
|
-
if (raw.workspace?.branch !== undefined && !isSafeBranchName(raw.workspace.branch)) {
|
|
630
|
-
return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'invalid workspace branch' };
|
|
631
|
-
}
|
|
632
|
-
if (raw.constraints?.timeout_seconds !== undefined && (!Number.isInteger(raw.constraints.timeout_seconds) || raw.constraints.timeout_seconds < 1 || raw.constraints.timeout_seconds > 7200)) {
|
|
633
|
-
return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'invalid timeout_seconds' };
|
|
634
|
-
}
|
|
635
|
-
if (raw.constraints?.allow_fallback !== undefined && typeof raw.constraints.allow_fallback !== 'boolean') {
|
|
636
|
-
return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'allow_fallback must be boolean' };
|
|
637
|
-
}
|
|
638
|
-
if (raw.constraints?.allow_no_changes !== undefined && typeof raw.constraints.allow_no_changes !== 'boolean') {
|
|
639
|
-
return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'allow_no_changes must be boolean' };
|
|
640
|
-
}
|
|
641
|
-
const profileRegistry = dependencies.profileRegistry ?? loadRoleProfiles();
|
|
642
|
-
const workspaceRegistry = dependencies.workspaceRegistry ?? loadWorkspaceContexts();
|
|
643
|
-
if (!profileRegistry.ok) return { ok: false, code: 'PROFILE_FILE_INVALID', error: 'role profile registry is invalid' };
|
|
644
|
-
if (!workspaceRegistry.ok) return { ok: false, code: 'WORKSPACE_CONTEXT_FILE_INVALID', error: 'workspace registry is invalid' };
|
|
645
|
-
const profileRole = raw.profile ? profileRegistry.profiles?.[raw.profile]?.role : undefined;
|
|
646
|
-
const requestedRole = raw.role ?? profileRole ?? 'worker';
|
|
647
|
-
const resolvedProfile = resolveRoleProfile(profileRegistry, raw.profile, requestedRole);
|
|
648
|
-
if (!resolvedProfile.ok) return { ok: false, code: resolvedProfile.code, error: resolvedProfile.code };
|
|
649
|
-
const knownContext = raw.workspace_id ? workspaceRegistry.contexts?.[raw.workspace_id] : null;
|
|
650
|
-
const cwd = raw.workspace?.repo_root ?? raw.cwd ?? knownContext?.repo_root;
|
|
651
|
-
if (!cwd) return { ok: false, code: 'WORKSPACE_CONTEXT_NOT_FOUND', error: 'workspace repo_root or cwd is required' };
|
|
652
|
-
const workspace = resolveWorkspaceContext(workspaceRegistry, { workspace_id: raw.workspace_id, cwd });
|
|
653
|
-
if (!workspace.ok) return { ok: false, code: workspace.code, error: workspace.code };
|
|
654
|
-
const withRefs = addContextReferences(workspace.context, raw.context_refs, { cwd });
|
|
655
|
-
if (!withRefs.ok) return { ok: false, code: withRefs.code, error: withRefs.code };
|
|
656
|
-
const profile = resolvedProfile.profile;
|
|
657
|
-
const worktree = raw.workspace?.worktree;
|
|
658
|
-
const requestedIsolation = worktree === 'auto' ? 'worktree'
|
|
659
|
-
: worktree === 'existing' || worktree === 'none' ? 'shared'
|
|
660
|
-
: profile.isolation;
|
|
661
|
-
const objective = raw.objective ?? raw.task;
|
|
662
|
-
if (typeof objective !== 'string' || objective.trim() === '') return { ok: false, code: 'JOB_OBJECTIVE_REQUIRED', error: 'task or objective is required' };
|
|
663
|
-
if (raw.job_id !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(raw.job_id)) {
|
|
664
|
-
return { ok: false, code: 'JOB_ID_INVALID', error: 'invalid client job id' };
|
|
665
|
-
}
|
|
666
|
-
const contextTask = buildWorkspaceTask(objective, withRefs.context);
|
|
667
|
-
normalized = {
|
|
668
|
-
...raw,
|
|
669
|
-
task: profile.review_strictness === 'strict' && requestedRole === 'reviewer'
|
|
670
|
-
? `${contextTask}\n\nSTRICT REVIEW: fail closed on missing direct code or test evidence.`
|
|
671
|
-
: contextTask,
|
|
672
|
-
cwd,
|
|
673
|
-
role: requestedRole,
|
|
674
|
-
client_job_id: raw.job_id ?? null,
|
|
675
|
-
requested_isolation: requestedIsolation,
|
|
676
|
-
workspace_branch: raw.workspace?.branch ?? withRefs.context?.default_branch ?? null,
|
|
677
|
-
timeout_seconds: raw.constraints?.timeout_seconds ?? profile.timeout_seconds,
|
|
678
|
-
allow_fallback: raw.constraints?.allow_fallback ?? profile.fallback,
|
|
679
|
-
allow_no_changes: raw.constraints?.allow_no_changes === true,
|
|
680
|
-
routing: profile.routing,
|
|
681
|
-
review_strictness: profile.review_strictness,
|
|
682
|
-
profile_id: resolvedProfile.profile_id,
|
|
683
|
-
workspace_context: withRefs.context,
|
|
684
|
-
};
|
|
685
|
-
for (const key of ['objective', 'job_id', 'profile', 'workspace_id', 'workspace', 'constraints', 'context_refs']) delete normalized[key];
|
|
686
|
-
}
|
|
687
|
-
// v0.2 role-based dispatch: reviewer / worker are gated by their role state,
|
|
688
|
-
// and the tier slot is derived from the role (reviewer always → pro).
|
|
689
|
-
if (normalized.role === 'worker' || normalized.role === 'reviewer') {
|
|
690
|
-
const hint = resolveRoleTierHint(normalized.role, normalized.tier);
|
|
691
|
-
if (!hint.ok) return { ok: false, code: hint.code, error: hint.error };
|
|
692
|
-
const decision = canDispatchRole(config, normalized.role, true, {});
|
|
693
|
-
if (!decision.ok) return { ok: false, code: decision.error.policyCode, error: decision.error.message };
|
|
694
|
-
return { ok: true, payload: { ...normalized, role: normalized.role, tier: hint.tier } };
|
|
695
|
-
}
|
|
696
|
-
const decision = chooseDefaultTier(config, normalized.tier, {});
|
|
697
|
-
if (!decision.ok) return { ok: false, code: decision.error.policyCode, error: decision.error.message };
|
|
698
|
-
return { ok: true, payload: { ...normalized, tier: decision.tier } };
|
|
699
|
-
}
|
|
844
|
+
export function resolveHubSpawnPayload(payload, getConfig = () => ({}), dependencies = {}) {
|
|
845
|
+
const config = normalizeGlobalConfig(getConfig());
|
|
846
|
+
const raw = payload ?? {};
|
|
847
|
+
const advanced = raw.profile !== undefined || raw.workspace_id !== undefined || raw.workspace !== undefined
|
|
848
|
+
|| raw.constraints !== undefined || raw.context_refs !== undefined || raw.job_id !== undefined || raw.objective !== undefined;
|
|
849
|
+
let normalized = { ...raw };
|
|
850
|
+
if (advanced) {
|
|
851
|
+
if (raw.workspace !== undefined && (!raw.workspace || typeof raw.workspace !== 'object' || Array.isArray(raw.workspace))) {
|
|
852
|
+
return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'workspace must be an object' };
|
|
853
|
+
}
|
|
854
|
+
if (raw.constraints !== undefined && (!raw.constraints || typeof raw.constraints !== 'object' || Array.isArray(raw.constraints))) {
|
|
855
|
+
return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'constraints must be an object' };
|
|
856
|
+
}
|
|
857
|
+
if (raw.workspace?.repo_root !== undefined && typeof raw.workspace.repo_root !== 'string') {
|
|
858
|
+
return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'workspace repo_root must be a string' };
|
|
859
|
+
}
|
|
860
|
+
if (raw.workspace?.worktree !== undefined && !['auto', 'existing', 'none'].includes(raw.workspace.worktree)) {
|
|
861
|
+
return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'invalid worktree policy' };
|
|
862
|
+
}
|
|
863
|
+
if (raw.workspace?.branch !== undefined && !isSafeBranchName(raw.workspace.branch)) {
|
|
864
|
+
return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'invalid workspace branch' };
|
|
865
|
+
}
|
|
866
|
+
if (raw.constraints?.timeout_seconds !== undefined && (!Number.isInteger(raw.constraints.timeout_seconds) || raw.constraints.timeout_seconds < 1 || raw.constraints.timeout_seconds > 7200)) {
|
|
867
|
+
return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'invalid timeout_seconds' };
|
|
868
|
+
}
|
|
869
|
+
if (raw.constraints?.allow_fallback !== undefined && typeof raw.constraints.allow_fallback !== 'boolean') {
|
|
870
|
+
return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'allow_fallback must be boolean' };
|
|
871
|
+
}
|
|
872
|
+
if (raw.constraints?.allow_no_changes !== undefined && typeof raw.constraints.allow_no_changes !== 'boolean') {
|
|
873
|
+
return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'allow_no_changes must be boolean' };
|
|
874
|
+
}
|
|
875
|
+
const profileRegistry = dependencies.profileRegistry ?? loadRoleProfiles();
|
|
876
|
+
const workspaceRegistry = dependencies.workspaceRegistry ?? loadWorkspaceContexts();
|
|
877
|
+
if (!profileRegistry.ok) return { ok: false, code: 'PROFILE_FILE_INVALID', error: 'role profile registry is invalid' };
|
|
878
|
+
if (!workspaceRegistry.ok) return { ok: false, code: 'WORKSPACE_CONTEXT_FILE_INVALID', error: 'workspace registry is invalid' };
|
|
879
|
+
const profileRole = raw.profile ? profileRegistry.profiles?.[raw.profile]?.role : undefined;
|
|
880
|
+
const requestedRole = raw.role ?? profileRole ?? 'worker';
|
|
881
|
+
const resolvedProfile = resolveRoleProfile(profileRegistry, raw.profile, requestedRole);
|
|
882
|
+
if (!resolvedProfile.ok) return { ok: false, code: resolvedProfile.code, error: resolvedProfile.code };
|
|
883
|
+
const knownContext = raw.workspace_id ? workspaceRegistry.contexts?.[raw.workspace_id] : null;
|
|
884
|
+
const cwd = raw.workspace?.repo_root ?? raw.cwd ?? knownContext?.repo_root;
|
|
885
|
+
if (!cwd) return { ok: false, code: 'WORKSPACE_CONTEXT_NOT_FOUND', error: 'workspace repo_root or cwd is required' };
|
|
886
|
+
const workspace = resolveWorkspaceContext(workspaceRegistry, { workspace_id: raw.workspace_id, cwd });
|
|
887
|
+
if (!workspace.ok) return { ok: false, code: workspace.code, error: workspace.code };
|
|
888
|
+
const withRefs = addContextReferences(workspace.context, raw.context_refs, { cwd });
|
|
889
|
+
if (!withRefs.ok) return { ok: false, code: withRefs.code, error: withRefs.code };
|
|
890
|
+
const profile = resolvedProfile.profile;
|
|
891
|
+
const worktree = raw.workspace?.worktree;
|
|
892
|
+
const requestedIsolation = worktree === 'auto' ? 'worktree'
|
|
893
|
+
: worktree === 'existing' || worktree === 'none' ? 'shared'
|
|
894
|
+
: profile.isolation;
|
|
895
|
+
const objective = raw.objective ?? raw.task;
|
|
896
|
+
if (typeof objective !== 'string' || objective.trim() === '') return { ok: false, code: 'JOB_OBJECTIVE_REQUIRED', error: 'task or objective is required' };
|
|
897
|
+
if (raw.job_id !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(raw.job_id)) {
|
|
898
|
+
return { ok: false, code: 'JOB_ID_INVALID', error: 'invalid client job id' };
|
|
899
|
+
}
|
|
900
|
+
const contextTask = buildWorkspaceTask(objective, withRefs.context);
|
|
901
|
+
normalized = {
|
|
902
|
+
...raw,
|
|
903
|
+
task: profile.review_strictness === 'strict' && requestedRole === 'reviewer'
|
|
904
|
+
? `${contextTask}\n\nSTRICT REVIEW: fail closed on missing direct code or test evidence.`
|
|
905
|
+
: contextTask,
|
|
906
|
+
cwd,
|
|
907
|
+
role: requestedRole,
|
|
908
|
+
client_job_id: raw.job_id ?? null,
|
|
909
|
+
requested_isolation: requestedIsolation,
|
|
910
|
+
workspace_branch: raw.workspace?.branch ?? withRefs.context?.default_branch ?? null,
|
|
911
|
+
timeout_seconds: raw.constraints?.timeout_seconds ?? profile.timeout_seconds,
|
|
912
|
+
allow_fallback: raw.constraints?.allow_fallback ?? profile.fallback,
|
|
913
|
+
allow_no_changes: raw.constraints?.allow_no_changes === true,
|
|
914
|
+
routing: profile.routing,
|
|
915
|
+
review_strictness: profile.review_strictness,
|
|
916
|
+
profile_id: resolvedProfile.profile_id,
|
|
917
|
+
workspace_context: withRefs.context,
|
|
918
|
+
};
|
|
919
|
+
for (const key of ['objective', 'job_id', 'profile', 'workspace_id', 'workspace', 'constraints', 'context_refs']) delete normalized[key];
|
|
920
|
+
}
|
|
921
|
+
// v0.2 role-based dispatch: reviewer / worker are gated by their role state,
|
|
922
|
+
// and the tier slot is derived from the role (reviewer always → pro).
|
|
923
|
+
if (normalized.role === 'worker' || normalized.role === 'reviewer') {
|
|
924
|
+
const hint = resolveRoleTierHint(normalized.role, normalized.tier);
|
|
925
|
+
if (!hint.ok) return { ok: false, code: hint.code, error: hint.error };
|
|
926
|
+
const decision = canDispatchRole(config, normalized.role, true, {});
|
|
927
|
+
if (!decision.ok) return { ok: false, code: decision.error.policyCode, error: decision.error.message };
|
|
928
|
+
return { ok: true, payload: { ...normalized, role: normalized.role, tier: hint.tier } };
|
|
929
|
+
}
|
|
930
|
+
const decision = chooseDefaultTier(config, normalized.tier, {});
|
|
931
|
+
if (!decision.ok) return { ok: false, code: decision.error.policyCode, error: decision.error.message };
|
|
932
|
+
return { ok: true, payload: { ...normalized, tier: decision.tier } };
|
|
933
|
+
}
|
|
700
934
|
|
|
701
935
|
// ---------- plugin entry ----------
|
|
702
936
|
|
|
703
|
-
export async function apply(ctx) {
|
|
704
|
-
seedLangFromHost(ctx);
|
|
705
|
-
const hub = new WorkerRegistry(ctx);
|
|
937
|
+
export async function apply(ctx) {
|
|
938
|
+
seedLangFromHost(ctx);
|
|
939
|
+
const hub = new WorkerRegistry(ctx);
|
|
940
|
+
const builtInProviderProbe = createProviderProbe(ctx);
|
|
706
941
|
try {
|
|
707
942
|
const { readGlobalConfig } = await import('../install/install.mjs');
|
|
708
943
|
hub.getConfig = () => readGlobalConfig();
|
|
@@ -712,7 +947,18 @@ export async function apply(ctx) {
|
|
|
712
947
|
// enforced its own), so the check uses the global config only. The route
|
|
713
948
|
// calls resolveHubSpawnPayload (above) to stamp the effective tier onto
|
|
714
949
|
// the spawn payload.
|
|
715
|
-
const disposers = [];
|
|
950
|
+
const disposers = [];
|
|
951
|
+
const PROVIDER_DELETE_PLAN_TTL_MS = 10 * 60 * 1000;
|
|
952
|
+
const providerDeletePlans = new Map();
|
|
953
|
+
const credentialPurgePlans = new Map();
|
|
954
|
+
const rememberProviderDeletePlan = (plan) => {
|
|
955
|
+
providerDeletePlans.set(plan.plan_id, { plan, created_at: Date.now() });
|
|
956
|
+
while (providerDeletePlans.size > 32) providerDeletePlans.delete(providerDeletePlans.keys().next().value);
|
|
957
|
+
};
|
|
958
|
+
const rememberCredentialPurgePlan = (plan) => {
|
|
959
|
+
credentialPurgePlans.set(plan.plan_id, { plan, created_at: Date.now() });
|
|
960
|
+
while (credentialPurgePlans.size > 32) credentialPurgePlans.delete(credentialPurgePlans.keys().next().value);
|
|
961
|
+
};
|
|
716
962
|
|
|
717
963
|
// Multimodal bridge: register describe_image / generate_image for the DS
|
|
718
964
|
// model. Config is read per call so settings-page edits apply live; the
|
|
@@ -770,29 +1016,29 @@ export async function apply(ctx) {
|
|
|
770
1016
|
const foreign = readMergedStatus({ excludeWriter: hub.shard.writer });
|
|
771
1017
|
return sendJson(res, 200, { ok: true, jobs: [...own, ...foreign] });
|
|
772
1018
|
}
|
|
773
|
-
if (req.method === 'GET' && parts.length === 1) {
|
|
1019
|
+
if (req.method === 'GET' && parts.length === 1) {
|
|
774
1020
|
const wait = Number(url.searchParams.get('wait') ?? 0);
|
|
775
1021
|
const job = await hub.wait(parts[0], Math.min(wait, 600) * 1000);
|
|
776
1022
|
if (!job) return sendJson(res, 404, { ok: false, error: 'no such job' });
|
|
777
|
-
return sendJson(res, 200, { ok: true, job: hub.view(job, true) });
|
|
778
|
-
}
|
|
779
|
-
if (req.method === 'GET' && parts.length === 2 && parts[1] === 'events') {
|
|
780
|
-
const job = hub.jobs.get(parts[0]);
|
|
781
|
-
if (!job) return sendJson(res, 404, { ok: false, error: 'no such job' });
|
|
782
|
-
const after = Math.max(0, Number(url.searchParams.get('after') ?? 0) || 0);
|
|
783
|
-
const events = hubCanonicalEvents(job).filter((event) => event.sequence > after);
|
|
784
|
-
return sendJson(res, 200, {
|
|
785
|
-
ok: true, job_id: job.id, events,
|
|
786
|
-
event_cursor: hubCanonicalEvents(job).at(-1)?.sequence ?? 0,
|
|
787
|
-
});
|
|
788
|
-
}
|
|
789
|
-
if (req.method === 'GET' && parts.length === 2 && parts[1] === 'contract') {
|
|
790
|
-
const job = hub.jobs.get(parts[0]);
|
|
791
|
-
if (!job) return sendJson(res, 404, { ok: false, error: 'no such job' });
|
|
792
|
-
const detail = url.searchParams.get('detail') === 'full' ? 'full' : 'compact';
|
|
793
|
-
const afterSequence = Math.max(0, Number(url.searchParams.get('after') ?? 0) || 0);
|
|
794
|
-
return sendJson(res, 200, { ok: true, job: projectWorkflowView(hub.view(job, true), { detail, afterSequence }) });
|
|
795
|
-
}
|
|
1023
|
+
return sendJson(res, 200, { ok: true, job: hub.view(job, true) });
|
|
1024
|
+
}
|
|
1025
|
+
if (req.method === 'GET' && parts.length === 2 && parts[1] === 'events') {
|
|
1026
|
+
const job = hub.jobs.get(parts[0]);
|
|
1027
|
+
if (!job) return sendJson(res, 404, { ok: false, error: 'no such job' });
|
|
1028
|
+
const after = Math.max(0, Number(url.searchParams.get('after') ?? 0) || 0);
|
|
1029
|
+
const events = hubCanonicalEvents(job).filter((event) => event.sequence > after);
|
|
1030
|
+
return sendJson(res, 200, {
|
|
1031
|
+
ok: true, job_id: job.id, events,
|
|
1032
|
+
event_cursor: hubCanonicalEvents(job).at(-1)?.sequence ?? 0,
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
if (req.method === 'GET' && parts.length === 2 && parts[1] === 'contract') {
|
|
1036
|
+
const job = hub.jobs.get(parts[0]);
|
|
1037
|
+
if (!job) return sendJson(res, 404, { ok: false, error: 'no such job' });
|
|
1038
|
+
const detail = url.searchParams.get('detail') === 'full' ? 'full' : 'compact';
|
|
1039
|
+
const afterSequence = Math.max(0, Number(url.searchParams.get('after') ?? 0) || 0);
|
|
1040
|
+
return sendJson(res, 200, { ok: true, job: projectWorkflowView(hub.view(job, true), { detail, afterSequence }) });
|
|
1041
|
+
}
|
|
796
1042
|
if (req.method === 'POST' && parts.length === 0) {
|
|
797
1043
|
const payload = await readBody(req);
|
|
798
1044
|
// Same policy resolver as the MCP server (src/server.mjs), with the
|
|
@@ -801,7 +1047,8 @@ export async function apply(ctx) {
|
|
|
801
1047
|
// as its raw default (pro-only + no tier used to spawn flash).
|
|
802
1048
|
const resolved = resolveHubSpawnPayload(payload, () => hub.getConfig?.() ?? {});
|
|
803
1049
|
if (!resolved.ok) return sendJson(res, 400, { ok: false, error: resolved.error, code: resolved.code });
|
|
804
|
-
const
|
|
1050
|
+
const ingress = req.headers?.['x-dsh-crew-ingress'] === 'official-3080' ? 'official-3080' : 'direct-3210';
|
|
1051
|
+
const job = await hub.spawn({ ...resolved.payload, ingress });
|
|
805
1052
|
return sendJson(res, 200, { ok: true, job: hub.view(job) });
|
|
806
1053
|
}
|
|
807
1054
|
if (req.method === 'POST' && parts.length === 2 && parts[1] === 'cancel') {
|
|
@@ -816,90 +1063,101 @@ export async function apply(ctx) {
|
|
|
816
1063
|
if (code) body.code = code;
|
|
817
1064
|
return sendJson(res, 400, body);
|
|
818
1065
|
}
|
|
819
|
-
},
|
|
820
|
-
}));
|
|
821
|
-
|
|
822
|
-
disposers.push(webServer.register({
|
|
823
|
-
kind: 'exact', path: `${ROUTE_BASE}/profiles`,
|
|
824
|
-
handler: async (req, res) => {
|
|
825
|
-
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
|
|
826
|
-
if (req.method === 'GET') return sendJson(res, 200, { ok: true, ...loadRoleProfiles() });
|
|
827
|
-
if (req.method === 'POST') {
|
|
828
|
-
try {
|
|
829
|
-
const saved = saveRoleProfiles(await readBody(req));
|
|
830
|
-
return sendJson(res, saved.ok ? 200 : 400, { ...saved });
|
|
831
|
-
} catch {
|
|
832
|
-
return sendJson(res, 400, { ok: false, error: 'invalid JSON', code: 'PROFILE_FILE_INVALID' });
|
|
833
|
-
}
|
|
834
|
-
}
|
|
835
|
-
return sendJson(res, 405, { ok: false, error: 'GET or POST' }, { allow: 'GET, POST' });
|
|
836
|
-
},
|
|
837
|
-
}));
|
|
838
|
-
|
|
839
|
-
disposers.push(webServer.register({
|
|
840
|
-
kind: 'exact', path: `${ROUTE_BASE}/workspaces`,
|
|
841
|
-
handler: async (req, res) => {
|
|
842
|
-
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
|
|
843
|
-
if (req.method === 'GET') return sendJson(res, 200, { ok: true, ...loadWorkspaceContexts() });
|
|
844
|
-
if (req.method === 'POST') {
|
|
845
|
-
try {
|
|
846
|
-
const saved = saveWorkspaceContexts(await readBody(req));
|
|
847
|
-
return sendJson(res, saved.ok ? 200 : 400, { ...saved });
|
|
848
|
-
} catch {
|
|
849
|
-
return sendJson(res, 400, { ok: false, error: 'invalid JSON', code: 'WORKSPACE_CONTEXT_FILE_INVALID' });
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
return sendJson(res, 405, { ok: false, error: 'GET or POST' }, { allow: 'GET, POST' });
|
|
853
|
-
},
|
|
854
|
-
}));
|
|
855
|
-
|
|
856
|
-
disposers.push(webServer.register({
|
|
857
|
-
kind: 'exact', path: `${ROUTE_BASE}/extension`,
|
|
858
|
-
handler: async (req, res) => {
|
|
859
|
-
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
|
|
860
|
-
if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'GET only' }, { allow: 'GET' });
|
|
861
|
-
const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
|
|
862
|
-
let catalogStatus = { id: 'provider_catalog', status: 'NOT_RUN', reason_code: 'PROVIDER_MODE_UNKNOWN' };
|
|
863
|
-
if (normalizeWorkerProviderMode(config.worker_provider_mode) === 'deepseek-official') {
|
|
864
|
-
catalogStatus = { id: 'provider_catalog', status: 'SKIP', reason_code: 'PROVIDER_CATALOG_NOT_REQUIRED' };
|
|
865
|
-
} else {
|
|
866
|
-
try {
|
|
867
|
-
await readHarnessModelCatalog({
|
|
868
|
-
llm: ctx.llm ?? ctx.get('llm'),
|
|
869
|
-
getCurrentSelection: () => ctx.get('agentDefaultModel')?.currentSelection?.(),
|
|
870
|
-
});
|
|
871
|
-
catalogStatus = { id: 'provider_catalog', status: 'PASS', reason_code: 'PROVIDER_CATALOG_RESOLVED' };
|
|
872
|
-
} catch {
|
|
873
|
-
catalogStatus = { id: 'provider_catalog', status: 'FAIL', reason_code: 'PROVIDER_CATALOG_UNAVAILABLE' };
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
const profiles = loadRoleProfiles();
|
|
877
|
-
const requestUrl = new URL(req.url, 'http://localhost');
|
|
878
|
-
const requestedWorkspaceId = requestUrl.searchParams.get('workspace_id');
|
|
879
|
-
const workspaceRegistry = loadWorkspaceContexts();
|
|
880
|
-
const requestedContext = requestedWorkspaceId ? workspaceRegistry.contexts?.[requestedWorkspaceId] : null;
|
|
881
|
-
const workspaceReadiness = requestedWorkspaceId && !requestedContext
|
|
882
|
-
? { status: 'UNAVAILABLE', reason_code: 'WORKSPACE_CONTEXT_NOT_FOUND' }
|
|
883
|
-
: await assessWorkspaceReadiness({ cwd: requestedContext?.repo_root ?? null });
|
|
1066
|
+
},
|
|
1067
|
+
}));
|
|
1068
|
+
|
|
1069
|
+
disposers.push(webServer.register({
|
|
1070
|
+
kind: 'exact', path: `${ROUTE_BASE}/profiles`,
|
|
1071
|
+
handler: async (req, res) => {
|
|
1072
|
+
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
|
|
1073
|
+
if (req.method === 'GET') return sendJson(res, 200, { ok: true, ...loadRoleProfiles() });
|
|
1074
|
+
if (req.method === 'POST') {
|
|
1075
|
+
try {
|
|
1076
|
+
const saved = saveRoleProfiles(await readBody(req));
|
|
1077
|
+
return sendJson(res, saved.ok ? 200 : 400, { ...saved });
|
|
1078
|
+
} catch {
|
|
1079
|
+
return sendJson(res, 400, { ok: false, error: 'invalid JSON', code: 'PROFILE_FILE_INVALID' });
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
return sendJson(res, 405, { ok: false, error: 'GET or POST' }, { allow: 'GET, POST' });
|
|
1083
|
+
},
|
|
1084
|
+
}));
|
|
1085
|
+
|
|
1086
|
+
disposers.push(webServer.register({
|
|
1087
|
+
kind: 'exact', path: `${ROUTE_BASE}/workspaces`,
|
|
1088
|
+
handler: async (req, res) => {
|
|
1089
|
+
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
|
|
1090
|
+
if (req.method === 'GET') return sendJson(res, 200, { ok: true, ...loadWorkspaceContexts() });
|
|
1091
|
+
if (req.method === 'POST') {
|
|
1092
|
+
try {
|
|
1093
|
+
const saved = saveWorkspaceContexts(await readBody(req));
|
|
1094
|
+
return sendJson(res, saved.ok ? 200 : 400, { ...saved });
|
|
1095
|
+
} catch {
|
|
1096
|
+
return sendJson(res, 400, { ok: false, error: 'invalid JSON', code: 'WORKSPACE_CONTEXT_FILE_INVALID' });
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
return sendJson(res, 405, { ok: false, error: 'GET or POST' }, { allow: 'GET, POST' });
|
|
1100
|
+
},
|
|
1101
|
+
}));
|
|
1102
|
+
|
|
1103
|
+
disposers.push(webServer.register({
|
|
1104
|
+
kind: 'exact', path: `${ROUTE_BASE}/extension`,
|
|
1105
|
+
handler: async (req, res) => {
|
|
1106
|
+
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
|
|
1107
|
+
if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'GET only' }, { allow: 'GET' });
|
|
1108
|
+
const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
|
|
1109
|
+
let catalogStatus = { id: 'provider_catalog', status: 'NOT_RUN', reason_code: 'PROVIDER_MODE_UNKNOWN' };
|
|
1110
|
+
if (normalizeWorkerProviderMode(config.worker_provider_mode) === 'deepseek-official') {
|
|
1111
|
+
catalogStatus = { id: 'provider_catalog', status: 'SKIP', reason_code: 'PROVIDER_CATALOG_NOT_REQUIRED' };
|
|
1112
|
+
} else {
|
|
1113
|
+
try {
|
|
1114
|
+
await readHarnessModelCatalog({
|
|
1115
|
+
llm: ctx.llm ?? ctx.get('llm'),
|
|
1116
|
+
getCurrentSelection: () => ctx.get('agentDefaultModel')?.currentSelection?.(),
|
|
1117
|
+
});
|
|
1118
|
+
catalogStatus = { id: 'provider_catalog', status: 'PASS', reason_code: 'PROVIDER_CATALOG_RESOLVED' };
|
|
1119
|
+
} catch {
|
|
1120
|
+
catalogStatus = { id: 'provider_catalog', status: 'FAIL', reason_code: 'PROVIDER_CATALOG_UNAVAILABLE' };
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
const profiles = loadRoleProfiles();
|
|
1124
|
+
const requestUrl = new URL(req.url, 'http://localhost');
|
|
1125
|
+
const requestedWorkspaceId = requestUrl.searchParams.get('workspace_id');
|
|
1126
|
+
const workspaceRegistry = loadWorkspaceContexts();
|
|
1127
|
+
const requestedContext = requestedWorkspaceId ? workspaceRegistry.contexts?.[requestedWorkspaceId] : null;
|
|
1128
|
+
const workspaceReadiness = requestedWorkspaceId && !requestedContext
|
|
1129
|
+
? { status: 'UNAVAILABLE', reason_code: 'WORKSPACE_CONTEXT_NOT_FOUND' }
|
|
1130
|
+
: await assessWorkspaceReadiness({ cwd: requestedContext?.repo_root ?? null });
|
|
884
1131
|
const liveJobs = typeof hub.list === 'function' ? hub.list() : [];
|
|
885
1132
|
const [modelExecution, reviewerExecution] = buildHubExecutionRows(liveJobs);
|
|
886
|
-
const
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
workspace: workspaceReadiness,
|
|
895
|
-
profiles,
|
|
1133
|
+
const readinessMatrix = { rows: [
|
|
1134
|
+
{ id: 'hub_compatibility', status: 'PASS', reason_code: 'LIVE_CHECK_PASSED' },
|
|
1135
|
+
catalogStatus,
|
|
1136
|
+
modelExecution,
|
|
1137
|
+
reviewerExecution,
|
|
1138
|
+
{ id: 'provider_lifecycle_consistent', status: 'NOT_RUN', reason_code: 'NO_EXECUTION_EVIDENCE' },
|
|
1139
|
+
] };
|
|
1140
|
+
const readinessSnapshot = buildRuntimeReadinessSnapshot({
|
|
896
1141
|
runtime: getHubRuntimeIdentity(),
|
|
1142
|
+
readinessMatrix,
|
|
1143
|
+
selections: { worker: liveJobs.find((job) => job?.role === 'worker') ?? null, reviewer: liveJobs.find((job) => job?.role === 'reviewer') ?? null },
|
|
1144
|
+
health: hub.healthStore.list(),
|
|
1145
|
+
jobs: liveJobs,
|
|
1146
|
+
workspace: workspaceReadiness,
|
|
897
1147
|
});
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
1148
|
+
const contract = buildExtensionContract({
|
|
1149
|
+
config,
|
|
1150
|
+
readinessMatrix,
|
|
1151
|
+
readinessSnapshot,
|
|
1152
|
+
workspace: workspaceReadiness,
|
|
1153
|
+
profiles,
|
|
1154
|
+
runtime: getHubRuntimeIdentity(),
|
|
1155
|
+
});
|
|
1156
|
+
return sendJson(res, 200, { ok: true, extension: contract });
|
|
1157
|
+
},
|
|
1158
|
+
}));
|
|
1159
|
+
|
|
1160
|
+
disposers.push(webServer.register({
|
|
903
1161
|
kind: 'exact', path: `${ROUTE_BASE}/config`,
|
|
904
1162
|
handler: async (req, res) => {
|
|
905
1163
|
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
|
|
@@ -936,13 +1194,15 @@ export async function apply(ctx) {
|
|
|
936
1194
|
},
|
|
937
1195
|
}));
|
|
938
1196
|
|
|
939
|
-
disposers.push(webServer.register({
|
|
940
|
-
kind: 'exact', path: `${ROUTE_BASE}/provider`,
|
|
941
|
-
handler: async (req, res) => {
|
|
942
|
-
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
|
|
943
|
-
try {
|
|
944
|
-
const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
|
|
945
|
-
const
|
|
1197
|
+
disposers.push(webServer.register({
|
|
1198
|
+
kind: 'exact', path: `${ROUTE_BASE}/provider`,
|
|
1199
|
+
handler: async (req, res) => {
|
|
1200
|
+
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
|
|
1201
|
+
try {
|
|
1202
|
+
const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
|
|
1203
|
+
const lifecycleState = readProviderLifecycleState();
|
|
1204
|
+
const healthGate = config.health_gate ?? config.worker?.model_policy?.health_gate;
|
|
1205
|
+
const mode = normalizeWorkerProviderMode(config.worker_provider_mode);
|
|
946
1206
|
let selections;
|
|
947
1207
|
if (mode === 'deepseek-official') {
|
|
948
1208
|
selections = {
|
|
@@ -960,10 +1220,14 @@ export async function apply(ctx) {
|
|
|
960
1220
|
tier,
|
|
961
1221
|
priority: config[`${tier}_model_priority`],
|
|
962
1222
|
priorityConfigured: config[`${tier}_model_priority_configured`],
|
|
963
|
-
fallback: config[`${tier}_model_fallback`],
|
|
964
|
-
catalog,
|
|
965
|
-
harnessDefault: catalog.harness_default,
|
|
966
|
-
|
|
1223
|
+
fallback: config[`${tier}_model_fallback`],
|
|
1224
|
+
catalog,
|
|
1225
|
+
harnessDefault: catalog.harness_default,
|
|
1226
|
+
healthStore: hub.healthStore,
|
|
1227
|
+
healthGate,
|
|
1228
|
+
allowFallback: config.allow_fallback !== false,
|
|
1229
|
+
tombstones: lifecycleState.tombstones,
|
|
1230
|
+
});
|
|
967
1231
|
selections[tier] = selected.ok
|
|
968
1232
|
? { provider: selected.provider, model: selected.model, source: selected.source }
|
|
969
1233
|
: { code: selected.code, error: selected.message };
|
|
@@ -978,11 +1242,313 @@ export async function apply(ctx) {
|
|
|
978
1242
|
} catch (err) {
|
|
979
1243
|
return sendJson(res, 503, { ok: false, code: 'MODEL_CATALOG_UNAVAILABLE', error: 'Unable to resolve Harness worker models.' });
|
|
980
1244
|
}
|
|
981
|
-
},
|
|
982
|
-
}));
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
1245
|
+
},
|
|
1246
|
+
}));
|
|
1247
|
+
|
|
1248
|
+
// Secret-free Harness provider inventory. This is deliberately separate
|
|
1249
|
+
// from /models (catalog) and /provider (effective routing): it reports
|
|
1250
|
+
// declaration provenance, ownership, lifecycle state and policy refs.
|
|
1251
|
+
disposers.push(webServer.register({
|
|
1252
|
+
kind: 'exact', path: `${ROUTE_BASE}/providers`,
|
|
1253
|
+
handler: async (req, res) => {
|
|
1254
|
+
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
|
|
1255
|
+
if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'GET only' }, { allow: 'GET' });
|
|
1256
|
+
try {
|
|
1257
|
+
const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
|
|
1258
|
+
const inventory = await readProviderInventorySnapshot(hub, ctx, config);
|
|
1259
|
+
return sendJson(res, 200, {
|
|
1260
|
+
ok: true,
|
|
1261
|
+
...inventory,
|
|
1262
|
+
runtime: getHubRuntimeIdentity(),
|
|
1263
|
+
});
|
|
1264
|
+
} catch {
|
|
1265
|
+
return sendJson(res, 503, { ok: false, code: 'PROVIDER_INVENTORY_UNAVAILABLE', error: 'Unable to read Harness provider inventory.' });
|
|
1266
|
+
}
|
|
1267
|
+
},
|
|
1268
|
+
}));
|
|
1269
|
+
|
|
1270
|
+
// Credential references are a separate, secret-free inventory. Provider
|
|
1271
|
+
// deletion can report these references, but never implies their purge.
|
|
1272
|
+
disposers.push(webServer.register({
|
|
1273
|
+
kind: 'exact', path: `${ROUTE_BASE}/credential-references`,
|
|
1274
|
+
handler: async (req, res) => {
|
|
1275
|
+
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
|
|
1276
|
+
if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'GET only' }, { allow: 'GET' });
|
|
1277
|
+
try {
|
|
1278
|
+
const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
|
|
1279
|
+
const snapshot = await readProviderInventorySnapshot(hub, ctx, config);
|
|
1280
|
+
const credentials = buildCredentialReferenceInventory({ providers: snapshot.records, additional_refs: snapshot.credential_history_refs, purged_refs: snapshot.credential_purged_refs });
|
|
1281
|
+
return sendJson(res, 200, { ok: true, ...credentials, unverified_purges: snapshot.credential_purge_unverified, runtime: getHubRuntimeIdentity() });
|
|
1282
|
+
} catch {
|
|
1283
|
+
return sendJson(res, 503, { ok: false, code: 'CREDENTIAL_REFERENCE_INVENTORY_UNAVAILABLE' });
|
|
1284
|
+
}
|
|
1285
|
+
},
|
|
1286
|
+
}));
|
|
1287
|
+
|
|
1288
|
+
disposers.push(webServer.register({
|
|
1289
|
+
kind: 'prefix', path: `${ROUTE_BASE}/credential-references`,
|
|
1290
|
+
handler: async (req, res) => {
|
|
1291
|
+
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
|
|
1292
|
+
let url;
|
|
1293
|
+
try { url = new URL(req.url, 'http://localhost'); } catch { return sendJson(res, 400, { ok: false, code: 'CREDENTIAL_PURGE_PLAN_INVALID' }); }
|
|
1294
|
+
const parts = url.pathname.slice(`${ROUTE_BASE}/credential-references`.length).split('/').filter(Boolean);
|
|
1295
|
+
try {
|
|
1296
|
+
const referenceId = decodeURIComponent(parts[0] ?? '');
|
|
1297
|
+
const body = await readBody(req);
|
|
1298
|
+
const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
|
|
1299
|
+
const snapshot = await readProviderInventorySnapshot(hub, ctx, config);
|
|
1300
|
+
const credentials = buildCredentialReferenceInventory({ providers: snapshot.records, additional_refs: snapshot.credential_history_refs, purged_refs: snapshot.credential_purged_refs });
|
|
1301
|
+
if (req.method === 'POST' && parts.length === 2 && parts[1] === 'purge-plan') {
|
|
1302
|
+
const planned = planCredentialPurge({ inventory: credentials, referenceId, expectedRevision: body?.expected_revision });
|
|
1303
|
+
if (!planned.ok) return sendJson(res, 409, { ok: false, code: planned.code });
|
|
1304
|
+
rememberCredentialPurgePlan(planned.plan);
|
|
1305
|
+
return sendJson(res, 200, { ok: true, plan: planned.plan });
|
|
1306
|
+
}
|
|
1307
|
+
if (req.method === 'DELETE' && parts.length === 1) {
|
|
1308
|
+
if (body?.confirm !== true) return sendJson(res, 400, { ok: false, code: 'CREDENTIAL_PURGE_CONFIRM_REQUIRED' });
|
|
1309
|
+
const stored = credentialPurgePlans.get(body?.plan_id);
|
|
1310
|
+
if (!stored || Date.now() - stored.created_at > PROVIDER_DELETE_PLAN_TTL_MS || stored.plan.reference_id !== referenceId) {
|
|
1311
|
+
credentialPurgePlans.delete(body?.plan_id);
|
|
1312
|
+
return sendJson(res, 409, { ok: false, code: 'CREDENTIAL_PURGE_PLAN_EXPIRED' });
|
|
1313
|
+
}
|
|
1314
|
+
const current = planCredentialPurge({ inventory: credentials, referenceId, expectedRevision: stored.plan.expected_revision });
|
|
1315
|
+
if (!current.ok) return sendJson(res, 409, { ok: false, code: current.code });
|
|
1316
|
+
if (current.plan.expected_revision !== stored.plan.expected_revision) return sendJson(res, 409, { ok: false, code: 'CREDENTIAL_REFERENCE_CHANGED' });
|
|
1317
|
+
credentialPurgePlans.delete(body.plan_id);
|
|
1318
|
+
const adapter = createCredentialPurgeFileHooks({
|
|
1319
|
+
credentialsFile: join(CONFIG_DIR, 'harness', '.credentials.yaml'),
|
|
1320
|
+
crewHome: join(CONFIG_DIR, 'harness'),
|
|
1321
|
+
});
|
|
1322
|
+
const result = await executeCredentialPurge(current.plan, {
|
|
1323
|
+
recheck: async (id) => {
|
|
1324
|
+
const freshSnapshot = await readProviderInventorySnapshot(hub, ctx, config);
|
|
1325
|
+
const freshCredentials = buildCredentialReferenceInventory({ providers: freshSnapshot.records, additional_refs: freshSnapshot.credential_history_refs, purged_refs: freshSnapshot.credential_purged_refs });
|
|
1326
|
+
const freshPlan = planCredentialPurge({ inventory: freshCredentials, referenceId: id });
|
|
1327
|
+
if (!freshPlan.ok) return { ok: false };
|
|
1328
|
+
const freshRecord = freshCredentials.records.find((entry) => entry.reference_id === id);
|
|
1329
|
+
return {
|
|
1330
|
+
ok: true,
|
|
1331
|
+
revision: freshPlan.plan.expected_revision,
|
|
1332
|
+
orphan: freshRecord?.orphan === true,
|
|
1333
|
+
ownership: freshRecord?.ownership,
|
|
1334
|
+
purge_capability: freshRecord?.purge_capability,
|
|
1335
|
+
};
|
|
1336
|
+
},
|
|
1337
|
+
...(adapter.ok ? { purge: adapter.purge, verify: adapter.verify } : {}),
|
|
1338
|
+
});
|
|
1339
|
+
if (result.state === 'PURGED' || result.state === 'VERIFIED') {
|
|
1340
|
+
try {
|
|
1341
|
+
writeCredentialPurgeState(recordCredentialPurgeOutcome(readCredentialPurgeState(), current.plan, result));
|
|
1342
|
+
} catch {
|
|
1343
|
+
return sendJson(res, 503, { ok: false, code: 'CREDENTIAL_PURGE_AUDIT_FAILED', state: result.state, reference_id: referenceId });
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
return sendJson(res, result.ok ? 200 : 503, result);
|
|
1347
|
+
}
|
|
1348
|
+
return sendJson(res, 404, { ok: false, code: 'CREDENTIAL_REFERENCE_ENDPOINT_NOT_FOUND' });
|
|
1349
|
+
} catch (error) {
|
|
1350
|
+
return sendJson(res, 400, { ok: false, code: boundedMachineCodeFromError(error) ?? 'CREDENTIAL_PURGE_PLAN_INVALID' });
|
|
1351
|
+
}
|
|
1352
|
+
},
|
|
1353
|
+
}));
|
|
1354
|
+
|
|
1355
|
+
// Deletion is two-phase: this endpoint only computes a revision-bound,
|
|
1356
|
+
// secret-free impact plan. A later mutation endpoint must present that
|
|
1357
|
+
// plan and perform restart/verification through an owned supervisor.
|
|
1358
|
+
disposers.push(webServer.register({
|
|
1359
|
+
kind: 'prefix', path: `${ROUTE_BASE}/providers`,
|
|
1360
|
+
handler: async (req, res) => {
|
|
1361
|
+
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
|
|
1362
|
+
let url;
|
|
1363
|
+
try { url = new URL(req.url, 'http://localhost'); } catch { return sendJson(res, 400, { ok: false, code: 'PROVIDER_PLAN_INVALID' }); }
|
|
1364
|
+
const parts = url.pathname.slice(`${ROUTE_BASE}/providers`.length).split('/').filter(Boolean);
|
|
1365
|
+
try {
|
|
1366
|
+
const providerId = decodeURIComponent(parts[0]);
|
|
1367
|
+
const body = await readBody(req);
|
|
1368
|
+
const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
|
|
1369
|
+
const inventory = await readProviderInventorySnapshot(hub, ctx, config);
|
|
1370
|
+
if (req.method === 'POST' && parts.length === 2 && parts[1] === 'delete-plan') {
|
|
1371
|
+
const profile = readProviderProfileRevision();
|
|
1372
|
+
if (!profile.ok) return sendJson(res, 409, { ok: false, code: profile.code });
|
|
1373
|
+
const planned = planProviderDelete({
|
|
1374
|
+
providerId,
|
|
1375
|
+
inventory,
|
|
1376
|
+
activeJobs: hub.list(),
|
|
1377
|
+
replacementDefault: body?.replacement_default,
|
|
1378
|
+
expectedRevision: profile.revision,
|
|
1379
|
+
});
|
|
1380
|
+
if (!planned.ok) return sendJson(res, 409, { ok: false, code: planned.code });
|
|
1381
|
+
rememberProviderDeletePlan(planned.plan);
|
|
1382
|
+
return sendJson(res, 200, { ok: true, profile_revision: profile.revision, plan: planned.plan });
|
|
1383
|
+
}
|
|
1384
|
+
if (req.method === 'POST' && parts.length === 2 && parts[1] === 'probe') {
|
|
1385
|
+
const record = inventory.records.find((entry) => entry.id === providerId);
|
|
1386
|
+
if (!record) return sendJson(res, 404, { ok: false, code: 'PROVIDER_NOT_FOUND' });
|
|
1387
|
+
if (record.desired_state === 'absent') return sendJson(res, 409, { ok: false, code: 'PROVIDER_TOMBSTONED' });
|
|
1388
|
+
const model = selectProviderProbeModel({ providerId, record, config });
|
|
1389
|
+
if (!model) return sendJson(res, 409, { ok: false, code: 'PROVIDER_PROBE_MODEL_UNAVAILABLE' });
|
|
1390
|
+
let explicitProviderProbe;
|
|
1391
|
+
try { explicitProviderProbe = ctx?.providerProbe; } catch { explicitProviderProbe = undefined; }
|
|
1392
|
+
const providerProbe = typeof explicitProviderProbe === 'function' ? explicitProviderProbe : builtInProviderProbe;
|
|
1393
|
+
if (typeof providerProbe !== 'function') return sendJson(res, 503, { ok: false, code: 'PROVIDER_PROBE_UNAVAILABLE' });
|
|
1394
|
+
let observed;
|
|
1395
|
+
let timer;
|
|
1396
|
+
try {
|
|
1397
|
+
const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve({ error: { name: 'TimeoutError' } }), 15_000); });
|
|
1398
|
+
observed = await Promise.race([
|
|
1399
|
+
Promise.resolve().then(() => providerProbe({ provider: providerId, model, signal: AbortSignal.timeout(15_000) })),
|
|
1400
|
+
timeout,
|
|
1401
|
+
]);
|
|
1402
|
+
} catch (error) {
|
|
1403
|
+
// Keep probe transport failures as bounded health evidence so a
|
|
1404
|
+
// credential/quota/timeout result is visible to routing policy;
|
|
1405
|
+
// never copy the raw provider error into the response.
|
|
1406
|
+
observed = { error };
|
|
1407
|
+
} finally {
|
|
1408
|
+
clearTimeout(timer);
|
|
1409
|
+
}
|
|
1410
|
+
const health = hub.healthStore.record(providerId, model, observed?.ok === true ? { ok: true } : { error: observed?.error ?? observed });
|
|
1411
|
+
return sendJson(res, 200, { ok: true, provider_id: providerId, model, health });
|
|
1412
|
+
}
|
|
1413
|
+
if (req.method === 'POST' && parts.length === 2 && parts[1] === 'verify-delete') {
|
|
1414
|
+
if (body?.confirm !== true) return sendJson(res, 400, { ok: false, code: 'PROVIDER_DELETE_VERIFY_CONFIRM_REQUIRED' });
|
|
1415
|
+
const { transaction_id: transactionId } = body ?? {};
|
|
1416
|
+
const hooks = createProviderDeleteFileHooks({
|
|
1417
|
+
profileFile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
|
|
1418
|
+
configFile: join(CONFIG_DIR, 'config.json'),
|
|
1419
|
+
lifecycleFile: join(CONFIG_DIR, 'provider-lifecycle.json'),
|
|
1420
|
+
backupDir: join(CONFIG_DIR, 'provider-backups'),
|
|
1421
|
+
existingBackupId: transactionId,
|
|
1422
|
+
expectedProviderId: providerId,
|
|
1423
|
+
});
|
|
1424
|
+
const plan = hooks.backupPlan();
|
|
1425
|
+
if (!plan || plan.provider_id !== providerId) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_PLAN_PROVIDER_MISMATCH' });
|
|
1426
|
+
await hooks.acquireLock();
|
|
1427
|
+
try {
|
|
1428
|
+
const verification = await hooks.verify(plan);
|
|
1429
|
+
if (verification?.providerAbsent !== true || verification?.routingClear !== true || verification?.tombstonePresent !== true) {
|
|
1430
|
+
return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_VERIFY_FAILED', verification });
|
|
1431
|
+
}
|
|
1432
|
+
await hooks.recordTransaction({ transaction_id: transactionId, provider_id: providerId, state: 'VERIFIED' }, plan);
|
|
1433
|
+
return sendJson(res, 200, { ok: true, transaction_id: transactionId, provider_id: providerId, state: 'VERIFIED', verification });
|
|
1434
|
+
} finally {
|
|
1435
|
+
await hooks.release();
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
if (req.method === 'POST' && parts.length === 2 && parts[1] === 'rollback') {
|
|
1439
|
+
if (body?.confirm !== true) return sendJson(res, 400, { ok: false, code: 'PROVIDER_ROLLBACK_CONFIRM_REQUIRED' });
|
|
1440
|
+
const { transaction_id: transactionId } = body ?? {};
|
|
1441
|
+
const hooks = createProviderDeleteFileHooks({
|
|
1442
|
+
profileFile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
|
|
1443
|
+
configFile: join(CONFIG_DIR, 'config.json'),
|
|
1444
|
+
lifecycleFile: join(CONFIG_DIR, 'provider-lifecycle.json'),
|
|
1445
|
+
backupDir: join(CONFIG_DIR, 'provider-backups'),
|
|
1446
|
+
existingBackupId: transactionId,
|
|
1447
|
+
expectedProviderId: providerId,
|
|
1448
|
+
});
|
|
1449
|
+
await hooks.acquireLock();
|
|
1450
|
+
try {
|
|
1451
|
+
const plan = hooks.backupPlan();
|
|
1452
|
+
if (!plan || plan.provider_id !== providerId) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_PLAN_PROVIDER_MISMATCH' });
|
|
1453
|
+
await hooks.rollback(plan);
|
|
1454
|
+
try {
|
|
1455
|
+
await hooks.recordTransaction({ transaction_id: transactionId, provider_id: providerId, state: 'ROLLBACK_PENDING' }, plan);
|
|
1456
|
+
} catch (error) {
|
|
1457
|
+
return sendJson(res, 409, { ok: false, code: boundedMachineCodeFromError(error) ?? 'PROVIDER_LIFECYCLE_RECORD_FAILED' });
|
|
1458
|
+
}
|
|
1459
|
+
return sendJson(res, 202, { ok: true, restart_required: true, transaction_id: transactionId, provider_id: providerId, state: 'ROLLBACK_PENDING' });
|
|
1460
|
+
} finally {
|
|
1461
|
+
await hooks.release();
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
if (req.method === 'POST' && parts.length === 2 && parts[1] === 'verify-rollback') {
|
|
1465
|
+
if (body?.confirm !== true) return sendJson(res, 400, { ok: false, code: 'PROVIDER_ROLLBACK_VERIFY_CONFIRM_REQUIRED' });
|
|
1466
|
+
const { transaction_id: transactionId } = body ?? {};
|
|
1467
|
+
const hooks = createProviderDeleteFileHooks({
|
|
1468
|
+
profileFile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
|
|
1469
|
+
configFile: join(CONFIG_DIR, 'config.json'),
|
|
1470
|
+
lifecycleFile: join(CONFIG_DIR, 'provider-lifecycle.json'),
|
|
1471
|
+
backupDir: join(CONFIG_DIR, 'provider-backups'),
|
|
1472
|
+
existingBackupId: transactionId,
|
|
1473
|
+
expectedProviderId: providerId,
|
|
1474
|
+
});
|
|
1475
|
+
const plan = hooks.backupPlan();
|
|
1476
|
+
if (!plan || plan.provider_id !== providerId) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_PLAN_PROVIDER_MISMATCH' });
|
|
1477
|
+
await hooks.acquireLock();
|
|
1478
|
+
try {
|
|
1479
|
+
const verified = await hooks.verifyRollback(plan);
|
|
1480
|
+
if (verified?.ok !== true) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_ROLLBACK_VERIFY_FAILED' });
|
|
1481
|
+
await hooks.recordTransaction({ transaction_id: transactionId, provider_id: providerId, state: 'ROLLED_BACK' }, plan);
|
|
1482
|
+
return sendJson(res, 200, { ok: true, transaction_id: transactionId, provider_id: providerId, state: 'ROLLED_BACK' });
|
|
1483
|
+
} finally {
|
|
1484
|
+
await hooks.release();
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
if (req.method === 'DELETE' && parts.length === 1) {
|
|
1488
|
+
if (body?.confirm !== true) return sendJson(res, 400, { ok: false, code: 'PROVIDER_DELETE_CONFIRM_REQUIRED' });
|
|
1489
|
+
if (body?.purge_orphan_credentials === true) {
|
|
1490
|
+
return sendJson(res, 400, { ok: false, code: 'CREDENTIAL_PURGE_REQUIRES_EXPLICIT_CONFIRMATION' });
|
|
1491
|
+
}
|
|
1492
|
+
const stored = providerDeletePlans.get(body?.plan_id);
|
|
1493
|
+
if (!stored || Date.now() - stored.created_at > PROVIDER_DELETE_PLAN_TTL_MS || stored.plan.provider_id !== providerId) {
|
|
1494
|
+
providerDeletePlans.delete(body?.plan_id);
|
|
1495
|
+
return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_PLAN_EXPIRED' });
|
|
1496
|
+
}
|
|
1497
|
+
const profile = readProviderProfileRevision();
|
|
1498
|
+
if (!profile.ok) return sendJson(res, 409, { ok: false, code: profile.code });
|
|
1499
|
+
if (body?.expected_revision !== stored.plan.expected_revision || profile.revision !== stored.plan.expected_revision) {
|
|
1500
|
+
return sendJson(res, 409, { ok: false, code: 'PROVIDER_PROFILE_CHANGED' });
|
|
1501
|
+
}
|
|
1502
|
+
const refreshed = planProviderDelete({
|
|
1503
|
+
providerId,
|
|
1504
|
+
inventory,
|
|
1505
|
+
activeJobs: hub.list(),
|
|
1506
|
+
replacementDefault: stored.plan.replacement_default,
|
|
1507
|
+
expectedRevision: profile.revision,
|
|
1508
|
+
});
|
|
1509
|
+
if (!refreshed.ok) return sendJson(res, 409, { ok: false, code: refreshed.code });
|
|
1510
|
+
if (refreshed.plan.replacement_default_model !== stored.plan.replacement_default_model) {
|
|
1511
|
+
return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_PLAN_CHANGED' });
|
|
1512
|
+
}
|
|
1513
|
+
const executionPlan = { ...refreshed.plan, plan_id: stored.plan.plan_id, expected_revision: stored.plan.expected_revision };
|
|
1514
|
+
const hooks = createProviderDeleteFileHooks({
|
|
1515
|
+
profileFile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
|
|
1516
|
+
configFile: join(CONFIG_DIR, 'config.json'),
|
|
1517
|
+
lifecycleFile: join(CONFIG_DIR, 'provider-lifecycle.json'),
|
|
1518
|
+
backupDir: join(CONFIG_DIR, 'provider-backups'),
|
|
1519
|
+
});
|
|
1520
|
+
providerDeletePlans.delete(body.plan_id);
|
|
1521
|
+
const { executeProviderDelete } = await import('../provider-lifecycle.mjs');
|
|
1522
|
+
const result = await executeProviderDelete(executionPlan, hooks, { deferRestart: true });
|
|
1523
|
+
return sendJson(res, result.state === 'RESTART_PENDING' ? 202 : 409, { ok: result.state === 'RESTART_PENDING', restart_required: true, result });
|
|
1524
|
+
}
|
|
1525
|
+
return sendJson(res, 404, { ok: false, error: 'unknown providers endpoint' });
|
|
1526
|
+
} catch (err) {
|
|
1527
|
+
const isProbe = req.method === 'POST' && parts.length === 2 && parts[1] === 'probe';
|
|
1528
|
+
const code = boundedMachineCodeFromError(err) ?? (isProbe ? 'PROVIDER_PROBE_FAILED' : 'PROVIDER_PLAN_INVALID');
|
|
1529
|
+
const status = code === 'PROVIDER_DELETE_RESTART_SUPERVISOR_UNAVAILABLE' || code.startsWith('PROVIDER_PROBE') ? 503
|
|
1530
|
+
: code.startsWith('PROVIDER_') || code.startsWith('CREDENTIAL_') ? 409 : 400;
|
|
1531
|
+
return sendJson(res, status, { ok: false, code });
|
|
1532
|
+
}
|
|
1533
|
+
},
|
|
1534
|
+
}));
|
|
1535
|
+
|
|
1536
|
+
disposers.push(webServer.register({
|
|
1537
|
+
kind: 'exact', path: `${ROUTE_BASE}/provider-health`,
|
|
1538
|
+
handler: async (req, res) => {
|
|
1539
|
+
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
|
|
1540
|
+
if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'GET only' }, { allow: 'GET' });
|
|
1541
|
+
return sendJson(res, 200, {
|
|
1542
|
+
ok: true,
|
|
1543
|
+
schema_version: 1,
|
|
1544
|
+
health: hub.healthStore.list(),
|
|
1545
|
+
runtime: getHubRuntimeIdentity(),
|
|
1546
|
+
});
|
|
1547
|
+
},
|
|
1548
|
+
}));
|
|
1549
|
+
|
|
1550
|
+
disposers.push(webServer.register({
|
|
1551
|
+
kind: 'exact', path: `${ROUTE_BASE}/models`,
|
|
986
1552
|
handler: async (req, res) => {
|
|
987
1553
|
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
|
|
988
1554
|
if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'GET or POST' }, { allow: 'GET, POST' });
|
|
@@ -1058,8 +1624,8 @@ export async function apply(ctx) {
|
|
|
1058
1624
|
// Cache-busted import: the installer must always run the code
|
|
1059
1625
|
// currently on disk, not whatever this process first loaded —
|
|
1060
1626
|
// a stale cached copy once re-broke user settings after a fix.
|
|
1061
|
-
const { installClaudeCode, installCodex, installHudSegment, uninstallClaudeCode, uninstallCodex, installZCode, uninstallZCode } =
|
|
1062
|
-
await import(`../install/install.mjs?t=${Date.now()}`);
|
|
1627
|
+
const { installClaudeCode, installCodex, installHudSegment, uninstallClaudeCode, uninstallCodex, installZCode, uninstallZCode } =
|
|
1628
|
+
await import(`../install/install.mjs?t=${Date.now()}`);
|
|
1063
1629
|
if (target === 'claude') {
|
|
1064
1630
|
const base = await installClaudeCode({ statusline: !!statusline });
|
|
1065
1631
|
const hud = installHudSegment({});
|
|
@@ -1068,12 +1634,12 @@ export async function apply(ctx) {
|
|
|
1068
1634
|
actions: [...base.actions, ...hud.actions.map((a) => `hud: ${a}`)],
|
|
1069
1635
|
});
|
|
1070
1636
|
}
|
|
1071
|
-
if (target === 'codex') return sendJson(res, 200, installCodex({}));
|
|
1072
|
-
if (target === 'zcode') return sendJson(res, 200, installZCode({}));
|
|
1073
|
-
if (target === 'claude-uninstall') return sendJson(res, 200, uninstallClaudeCode({}));
|
|
1074
|
-
if (target === 'codex-uninstall') return sendJson(res, 200, uninstallCodex({}));
|
|
1075
|
-
if (target === 'zcode-uninstall') return sendJson(res, 200, uninstallZCode({}));
|
|
1076
|
-
return sendJson(res, 400, { ok: false, error: 'target must be claude | codex | zcode | claude-uninstall | codex-uninstall | zcode-uninstall' });
|
|
1637
|
+
if (target === 'codex') return sendJson(res, 200, installCodex({}));
|
|
1638
|
+
if (target === 'zcode') return sendJson(res, 200, installZCode({}));
|
|
1639
|
+
if (target === 'claude-uninstall') return sendJson(res, 200, uninstallClaudeCode({}));
|
|
1640
|
+
if (target === 'codex-uninstall') return sendJson(res, 200, uninstallCodex({}));
|
|
1641
|
+
if (target === 'zcode-uninstall') return sendJson(res, 200, uninstallZCode({}));
|
|
1642
|
+
return sendJson(res, 400, { ok: false, error: 'target must be claude | codex | zcode | claude-uninstall | codex-uninstall | zcode-uninstall' });
|
|
1077
1643
|
} catch (err) {
|
|
1078
1644
|
return sendJson(res, 500, { ok: false, error: err?.message ?? String(err) });
|
|
1079
1645
|
}
|