@ran-sh/dsh-crew 0.5.5 → 0.5.7
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 +14 -7
- package/README.zh.md +12 -7
- package/docs/installation.md +1 -1
- package/docs/ui-surfaces.md +65 -46
- package/lib/client.js +345 -22
- package/official-web-bridge/lib/client.js +345 -22
- package/package.json +1 -1
- package/src/client/collapsible-sections.mjs +3 -2
- package/src/client/index.tsx +227 -51
- package/src/config-readiness.mjs +56 -5
- package/src/extension-contract.mjs +9 -5
- package/src/hub/index.mjs +854 -445
- package/src/hub-client.mjs +24 -4
- package/src/hub-compatibility.mjs +23 -7
- package/src/install/install.mjs +4 -4
- package/src/install/npx-lifecycle.mjs +254 -87
- package/src/job-contracts.mjs +19 -3
- 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 +242 -84
- package/src/policy.mjs +62 -31
- package/src/provider-config-scrub.mjs +71 -0
- package/src/provider-delete-adapters.mjs +424 -0
- package/src/provider-health.mjs +124 -0
- package/src/provider-inventory.mjs +132 -0
- package/src/provider-lifecycle-state.mjs +81 -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 +43 -13
- package/src/server.mjs +34 -16
- 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, readFileSync } 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,24 @@ 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';
|
|
33
42
|
|
|
34
43
|
// policy.mjs is pure (no @deepseek-ai imports, no ctx access), so importing it
|
|
35
44
|
// here is safe for the profile-realm discipline: it never pulls in package
|
|
@@ -92,62 +101,171 @@ const LEGACY_TIER_MODELS = { flash: 'deepseek-v4-flash', pro: 'deepseek-v4-pro'
|
|
|
92
101
|
const ROLES = { worker: true, reviewer: true };
|
|
93
102
|
const CONFIG_DIR = join(homedir(), '.config', 'dsh-crew');
|
|
94
103
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const definitions = [
|
|
101
|
-
['job.created', atStart, { client_job_id: job.client_job_id ?? null }],
|
|
102
|
-
['job.started', atStart, {}],
|
|
103
|
-
['model.selected', atStart, { provider: job.provider ?? null, model: job.model ?? null, source: job.selection_source ?? null }],
|
|
104
|
-
[role === 'reviewer' ? 'review.started' : 'worker.started', atStart, { run_id: job.id }],
|
|
105
|
-
];
|
|
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 }]);
|
|
104
|
+
function readProviderLifecycleState() {
|
|
105
|
+
try {
|
|
106
|
+
return normalizeProviderLifecycleState(JSON.parse(readFileSync(join(CONFIG_DIR, 'provider-lifecycle.json'), 'utf8')));
|
|
107
|
+
} catch {
|
|
108
|
+
return normalizeProviderLifecycleState();
|
|
117
109
|
}
|
|
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
110
|
}
|
|
122
|
-
|
|
123
|
-
// ---------- job registry ----------
|
|
124
111
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
112
|
+
function providerInventoryPolicy(config) {
|
|
113
|
+
return {
|
|
114
|
+
worker: config?.worker?.model_policy ?? {
|
|
115
|
+
priority: config?.flash_model_priority,
|
|
116
|
+
escalation_priority: config?.pro_model_priority,
|
|
117
|
+
},
|
|
118
|
+
reviewer: config?.review?.model_policy ?? {
|
|
119
|
+
priority: config?.pro_model_priority,
|
|
120
|
+
escalation_priority: [],
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function readProviderInventorySnapshot(hub, ctx, config) {
|
|
126
|
+
let catalog = { providers: [], harness_default: null };
|
|
127
|
+
try {
|
|
128
|
+
catalog = await readHarnessModelCatalog({
|
|
129
|
+
llm: ctx.llm ?? ctx.get('llm'),
|
|
130
|
+
getCurrentSelection: () => ctx.get('agentDefaultModel')?.currentSelection?.(),
|
|
131
|
+
});
|
|
132
|
+
} catch {}
|
|
133
|
+
const profileFile = join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml');
|
|
134
|
+
let declarations = [];
|
|
135
|
+
try {
|
|
136
|
+
if (existsSync(profileFile)) {
|
|
137
|
+
const source = readFileSync(profileFile, 'utf8');
|
|
138
|
+
const parsed = readProviderDeclarations(source, { file: 'harness/profiles/dsh-crew/cordis.patch.yml' });
|
|
139
|
+
if (parsed.ok) declarations = parsed.declarations;
|
|
140
|
+
}
|
|
141
|
+
} catch {}
|
|
142
|
+
const lifecycle = readProviderLifecycleState();
|
|
143
|
+
return buildProviderInventory({
|
|
144
|
+
catalog,
|
|
145
|
+
declarations,
|
|
146
|
+
policy: providerInventoryPolicy(config),
|
|
147
|
+
tombstones: lifecycle.tombstones,
|
|
148
|
+
activeJobs: hub.list(),
|
|
135
149
|
});
|
|
136
150
|
}
|
|
151
|
+
|
|
152
|
+
function readProviderProfileRevision() {
|
|
153
|
+
const profileFile = join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml');
|
|
154
|
+
try {
|
|
155
|
+
if (!existsSync(profileFile)) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
|
|
156
|
+
const parsed = inspectProviderProfile(readFileSync(profileFile, 'utf8'));
|
|
157
|
+
return parsed.ok ? { ok: true, revision: parsed.revision } : { ok: false, code: parsed.code };
|
|
158
|
+
} catch {
|
|
159
|
+
return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
137
162
|
|
|
138
|
-
|
|
163
|
+
export function hubCanonicalEvents(job = {}) {
|
|
164
|
+
if (!job.id) return [];
|
|
165
|
+
const role = job.role === 'reviewer' ? 'reviewer' : 'worker';
|
|
166
|
+
const atStart = job.startedAt ?? null;
|
|
167
|
+
const atEnd = job.endedAt ?? atStart;
|
|
168
|
+
const definitions = [
|
|
169
|
+
['job.created', atStart, { client_job_id: job.client_job_id ?? null }],
|
|
170
|
+
['job.started', atStart, {}],
|
|
171
|
+
['model.selected', atStart, { provider: job.provider ?? null, model: job.model ?? null, source: job.selection_source ?? null }],
|
|
172
|
+
[role === 'reviewer' ? 'review.started' : 'worker.started', atStart, { run_id: job.id }],
|
|
173
|
+
];
|
|
174
|
+
if (job.status !== 'running') {
|
|
175
|
+
definitions.push([
|
|
176
|
+
role === 'reviewer' ? 'review.completed' : 'worker.completed',
|
|
177
|
+
atEnd,
|
|
178
|
+
role === 'reviewer'
|
|
179
|
+
? { status: job.status ?? null, verdict: job.review?.verdict ?? null }
|
|
180
|
+
: { status: job.status ?? null, summary: job.outcome?.task_status ?? null },
|
|
181
|
+
]);
|
|
182
|
+
if (job.status === 'done') definitions.push(['job.completed', atEnd, { result_ref: job.id }]);
|
|
183
|
+
else if (job.status === 'cancelled') definitions.push(['job.cancelled', atEnd, { reason: 'cancelled' }]);
|
|
184
|
+
else definitions.push(['job.failed', atEnd, { error_code: job.error_code ?? null }]);
|
|
185
|
+
}
|
|
186
|
+
return definitions.map(([type, at, data], index) => createCanonicalJobEvent({
|
|
187
|
+
jobId: job.id, type, sequence: index + 1, at, role, attempt: job.attempt ?? 0, data,
|
|
188
|
+
}));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------- job registry ----------
|
|
192
|
+
|
|
193
|
+
export function applyHubWorkspaceEvidence({ outcome, workspaceDiff, allowNoChanges = false, isolation = 'shared', role = 'worker' } = {}) {
|
|
194
|
+
const changes = workspaceDiff?.changes ?? {};
|
|
195
|
+
const hasChanges = ['modified', 'deleted', 'renamed', 'untracked']
|
|
196
|
+
.some((key) => Array.isArray(changes[key]) && changes[key].length > 0);
|
|
197
|
+
const evidenceAvailable = workspaceDiff?.kind === 'git' && workspaceDiff.dirtyBaseline !== true;
|
|
198
|
+
return applyWorkspaceEvidence(outcome, {
|
|
199
|
+
evidenceAvailable,
|
|
200
|
+
hasChanges,
|
|
201
|
+
allowNoChanges: allowNoChanges === true && isolation === 'worktree',
|
|
202
|
+
requireNoChangeAuthorization: role === 'worker',
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Build the explicit provider callability probe used by the 3210 lifecycle
|
|
208
|
+
* route. It performs one token-bounded Harness stream through the same llm
|
|
209
|
+
* runtime used by WorkerRegistry; no credential values are read or returned.
|
|
210
|
+
*/
|
|
211
|
+
export function createProviderProbe(ctx) {
|
|
212
|
+
const llm = ctx?.llm ?? ctx?.get?.('llm');
|
|
213
|
+
if (!llm || typeof llm.stream !== 'function') return null;
|
|
214
|
+
return async ({ provider, model, signal }) => {
|
|
215
|
+
try {
|
|
216
|
+
const stream = llm.stream({
|
|
217
|
+
provider,
|
|
218
|
+
model,
|
|
219
|
+
messages: [userMessage('dsh-crew provider probe')],
|
|
220
|
+
maxTokens: 1,
|
|
221
|
+
temperature: 0,
|
|
222
|
+
signal,
|
|
223
|
+
});
|
|
224
|
+
for await (const chunk of stream) {
|
|
225
|
+
if (chunk?.type !== 'finish') continue;
|
|
226
|
+
const reason = chunk.reason;
|
|
227
|
+
if (reason?.kind === 'error' || reason?.kind === 'aborted') {
|
|
228
|
+
return { ok: false, error: reason.failure ?? { code: `PROVIDER_PROBE_${String(reason.kind).toUpperCase()}` } };
|
|
229
|
+
}
|
|
230
|
+
return { ok: true };
|
|
231
|
+
}
|
|
232
|
+
return { ok: false, error: { code: 'PROVIDER_PROBE_NO_FINISH' } };
|
|
233
|
+
} catch (error) {
|
|
234
|
+
return { ok: false, error };
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Pick the first configured model for a provider, falling back to its live catalog. */
|
|
240
|
+
export function selectProviderProbeModel({ providerId, record, config } = {}) {
|
|
241
|
+
const advertised = new Set(Array.isArray(record?.models) ? record.models.filter((model) => typeof model === 'string') : []);
|
|
242
|
+
if (advertised.size === 0) return null;
|
|
243
|
+
const priorities = [
|
|
244
|
+
...(Array.isArray(config?.worker?.model_policy?.priority) ? config.worker.model_policy.priority : []),
|
|
245
|
+
...(Array.isArray(config?.flash_model_priority) ? config.flash_model_priority : []),
|
|
246
|
+
...(Array.isArray(config?.review?.model_policy?.priority) ? config.review.model_policy.priority : []),
|
|
247
|
+
...(Array.isArray(config?.pro_model_priority) ? config.pro_model_priority : []),
|
|
248
|
+
];
|
|
249
|
+
for (const ref of priorities) {
|
|
250
|
+
if (ref?.provider === providerId && advertised.has(ref.model)) return ref.model;
|
|
251
|
+
}
|
|
252
|
+
return [...advertised][0];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Exported for the unit tests (test/hub-windows.test.mjs); instantiation
|
|
139
256
|
// needs only a duck-typed ctx, so spawn()'s path guard is testable without a
|
|
140
257
|
// 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
|
-
|
|
258
|
+
export class WorkerRegistry { constructor(ctx) {
|
|
259
|
+
this.ctx = ctx;
|
|
260
|
+
this.jobs = new Map();
|
|
261
|
+
this.nextId = 1;
|
|
262
|
+
this.shard = createShardWriter('hub');
|
|
263
|
+
this.healthStore = createProviderHealthStore();
|
|
264
|
+
}
|
|
147
265
|
|
|
148
266
|
view(job, withResult = false) {
|
|
149
267
|
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,
|
|
268
|
+
id: job.id, client_job_id: job.client_job_id ?? null, sessionId: job.sessionId, role: job.role ?? 'worker', attempt: job.attempt ?? 0,
|
|
151
269
|
tier: job.tier, provider: job.provider, model: job.model,
|
|
152
270
|
selection_source: job.selection_source,
|
|
153
271
|
selection_trace: job.selection_trace ?? null,
|
|
@@ -156,31 +274,32 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
156
274
|
cwd: job.cwd, turn: job.turn, step: job.step, currentTool: job.currentTool,
|
|
157
275
|
phase: job.phase ?? null,
|
|
158
276
|
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,
|
|
277
|
+
startedAt: job.startedAt, endedAt: job.endedAt,
|
|
278
|
+
isolation: job.isolation ?? 'shared', workspace_branch: job.workspace_branch ?? null,
|
|
279
|
+
delivery_complete: !!job.delivery_complete,
|
|
280
|
+
allow_no_changes: job.allow_no_changes === true,
|
|
281
|
+
task_status: job.outcome?.task_status ?? null,
|
|
282
|
+
workspace_evidence_ok: job.outcome?.workspace_evidence_ok ?? null,
|
|
283
|
+
review_verdict: job.review?.verdict ?? null,
|
|
166
284
|
workspace_diff_available: !!job.workspaceDiff && job.workspaceDiff.kind === 'git',
|
|
167
285
|
workspace_retained: job.workspace_retained === true,
|
|
168
286
|
cleanup_warning: job.cleanup_warning ?? null,
|
|
169
287
|
profile_id: job.profile_id ?? null,
|
|
170
288
|
workspace_context: job.workspace_context ?? null,
|
|
289
|
+
execution_context: job.execution_context ?? null,
|
|
171
290
|
event_cursor: hubCanonicalEvents(job).at(-1)?.sequence ?? 0,
|
|
172
291
|
};
|
|
173
292
|
if (withResult) {
|
|
174
293
|
v.result = job.result; v.error = job.error; v.stopReason = job.stopReason;
|
|
175
294
|
v.reasonDetail = job.reasonDetail;
|
|
176
295
|
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;
|
|
296
|
+
v.delivery_missing = job.delivery_missing ?? [];
|
|
297
|
+
v.outcome = job.outcome ?? null;
|
|
298
|
+
v.review = job.review ?? null;
|
|
180
299
|
v.workspace_diff = job.workspaceDiff ?? null;
|
|
181
|
-
v.workspace_baseline_dirty = !!job.workspaceDiff?.dirtyBaseline;
|
|
182
|
-
v.canonical_events = hubCanonicalEvents(job);
|
|
183
|
-
}
|
|
300
|
+
v.workspace_baseline_dirty = !!job.workspaceDiff?.dirtyBaseline;
|
|
301
|
+
v.canonical_events = hubCanonicalEvents(job);
|
|
302
|
+
}
|
|
184
303
|
return v;
|
|
185
304
|
}
|
|
186
305
|
|
|
@@ -200,7 +319,7 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
200
319
|
* `role` (worker | reviewer) records who does the work; `tier` remains the
|
|
201
320
|
* legacy model-class slot. Reviewer-role jobs always use the pro slot.
|
|
202
321
|
*/
|
|
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 }) {
|
|
322
|
+
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 }) {
|
|
204
323
|
// role is only honored when the caller explicitly names it; a legacy
|
|
205
324
|
// tier-only spawn (role === undefined) keeps the exact v0.1 resolution.
|
|
206
325
|
const hasRole = role === 'worker' || role === 'reviewer';
|
|
@@ -219,7 +338,9 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
219
338
|
// Provider routing: follow-dsh reads the DSH Models selection live; the
|
|
220
339
|
// default deepseek-official keeps legacy setups unchanged. Re-resolved on
|
|
221
340
|
// every spawn so a Models change takes effect on the next worker.
|
|
222
|
-
const cfg = normalizeGlobalConfig(this.getConfig?.() ?? {});
|
|
341
|
+
const cfg = normalizeGlobalConfig(this.getConfig?.() ?? {});
|
|
342
|
+
const lifecycleState = readProviderLifecycleState();
|
|
343
|
+
const healthGate = cfg.health_gate ?? cfg.worker?.model_policy?.health_gate;
|
|
223
344
|
const workerProviderMode = normalizeWorkerProviderMode(cfg.worker_provider_mode);
|
|
224
345
|
const getCurrentSelection = () => this.ctx.get('agentDefaultModel')?.currentSelection?.();
|
|
225
346
|
let selection;
|
|
@@ -260,22 +381,30 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
260
381
|
// tier-only spawns resolve through the tier resolver exactly as before.
|
|
261
382
|
if (hasRole) {
|
|
262
383
|
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
|
-
|
|
384
|
+
selection = resolveModel({
|
|
385
|
+
role: effRole,
|
|
386
|
+
attempt,
|
|
387
|
+
policy,
|
|
388
|
+
catalog,
|
|
389
|
+
harnessDefault: catalog.harness_default ?? getCurrentSelection(),
|
|
390
|
+
healthStore: this.healthStore,
|
|
391
|
+
healthGate: policy.health_gate ?? healthGate,
|
|
392
|
+
allowFallback: cfg.allow_fallback !== false,
|
|
393
|
+
tombstones: lifecycleState.tombstones,
|
|
394
|
+
});
|
|
270
395
|
} else {
|
|
271
396
|
selection = resolveWorkerModel({
|
|
272
397
|
tier: effTier,
|
|
273
398
|
priority: cfg[`${effTier}_model_priority`],
|
|
274
399
|
priorityConfigured: cfg[`${effTier}_model_priority_configured`],
|
|
275
400
|
fallback: cfg[`${effTier}_model_fallback`],
|
|
276
|
-
catalog,
|
|
277
|
-
harnessDefault: catalog.harness_default ?? getCurrentSelection(),
|
|
278
|
-
|
|
401
|
+
catalog,
|
|
402
|
+
harnessDefault: catalog.harness_default ?? getCurrentSelection(),
|
|
403
|
+
healthStore: this.healthStore,
|
|
404
|
+
healthGate,
|
|
405
|
+
allowFallback: cfg.allow_fallback !== false,
|
|
406
|
+
tombstones: lifecycleState.tombstones,
|
|
407
|
+
traceContext: {
|
|
279
408
|
role: effRole,
|
|
280
409
|
logicalAttempt: attempt,
|
|
281
410
|
modelClassHint: effTier,
|
|
@@ -293,53 +422,79 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
293
422
|
const jobRole = hasRole ? role : (delivery === 'review' || role === 'reviewer' ? 'reviewer' : 'worker');
|
|
294
423
|
const workerPrompt = appendDeliveryInstructions(task, { tier: effTier, role: jobRole, isReview: delivery === 'review' || jobRole === 'reviewer' });
|
|
295
424
|
|
|
296
|
-
const id = `hub-${this.nextId++}-${Date.now().toString(36)}`;
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
425
|
+
const id = `hub-${this.nextId++}-${Date.now().toString(36)}`;
|
|
426
|
+
|
|
427
|
+
// Resolve presets before any side effect (registry entry, worktree): a
|
|
428
|
+
// rejected preset must fail spawn() cleanly instead of leaving a ghost
|
|
429
|
+
// "running" job and a leaked worktree behind.
|
|
430
|
+
const presets = this.ctx.get('agentPresets');
|
|
431
|
+
const wanted = preset ?? (tier === 'flash' ? cfg.preset_flash : cfg.preset_pro);
|
|
432
|
+
const presetId = presets === undefined
|
|
433
|
+
? undefined
|
|
434
|
+
: (await presets.resolve(!wanted || wanted === 'default' ? undefined : wanted)).id;
|
|
435
|
+
|
|
436
|
+
let executionCwd = cwd;
|
|
437
|
+
let isolatedWorkspace = null;
|
|
438
|
+
if ((requested_isolation === 'worktree' && jobRole === 'worker') || requested_isolation === 'readonly') {
|
|
439
|
+
const created = await createIsolatedWorkspace({ cwd, jobId: id, baseRevision: workspace_branch });
|
|
440
|
+
if (!created.ok) throw Object.assign(new Error(created.error ?? created.reason), { code: created.reason });
|
|
441
|
+
executionCwd = created.worktreePath;
|
|
442
|
+
isolatedWorkspace = { worktreePath: created.worktreePath, repoRoot: created.repoRoot };
|
|
443
|
+
}
|
|
444
|
+
const sessionId = `session-${randomUUID()}`;
|
|
445
|
+
const job = {
|
|
446
|
+
id, client_job_id: client_job_id ?? null, sessionId, role: jobRole, attempt, tier: effTier, provider: selection.provider, model: selection.model,
|
|
308
447
|
selection_source: selection.source, selection_trace: selection.selection_trace ?? null,
|
|
309
448
|
effort, reasoning_effort: selection.reasoningEffort,
|
|
310
449
|
task, source, cwd: executionCwd, requested_cwd: cwd,
|
|
311
450
|
isolation: isolatedWorkspace ? 'worktree' : 'shared',
|
|
451
|
+
execution_context: (() => {
|
|
452
|
+
const runtime = getHubRuntimeIdentity();
|
|
453
|
+
return {
|
|
454
|
+
execution_plane: runtime.execution_plane,
|
|
455
|
+
profile: runtime.profile,
|
|
456
|
+
listen_port: runtime.listen_port,
|
|
457
|
+
runtime_id: runtime.runtime_id,
|
|
458
|
+
};
|
|
459
|
+
})(),
|
|
312
460
|
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,
|
|
461
|
+
workspace_branch: workspace_branch ?? null, isolatedWorkspace,
|
|
462
|
+
profile_id: profile_id ?? null, workspace_context: workspace_context ?? null,
|
|
315
463
|
prompt: workerPrompt, delivery: delivery === 'review' ? 'review' : 'coding',
|
|
316
464
|
phase: JOB_PHASES.RUNNING,
|
|
317
465
|
status: 'running', turn: 0, step: 0, currentTool: null, toolCalls: 0,
|
|
318
466
|
tokens: { input: 0, output: 0, reasoning: 0 },
|
|
319
467
|
startedAt: new Date().toISOString(), endedAt: null,
|
|
320
468
|
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
|
-
|
|
469
|
+
delivery_complete: false, delivery_missing: [], delivery_metadata: null,
|
|
470
|
+
outcome: null,
|
|
471
|
+
lastAssistantText: null, handle_dispose_promise: null, disposeHandle: null,
|
|
472
|
+
// Abort latch: cancel()/timeout set this the moment the job becomes
|
|
473
|
+
// terminal, even while agents.create() is still pending, so a handle
|
|
474
|
+
// acquired after that point is disposed instead of being driven.
|
|
475
|
+
abortRequested: false,
|
|
476
|
+
abortPromise: null,
|
|
477
|
+
resolveAbort: null,
|
|
478
|
+
};
|
|
479
|
+
job.abortPromise = new Promise((resolve) => { job.resolveAbort = resolve; });
|
|
480
|
+
const disposeJobHandle = async () => {
|
|
481
|
+
if (!job.handle) return;
|
|
482
|
+
if (!job.handle_dispose_promise) {
|
|
483
|
+
try {
|
|
484
|
+
job.handle_dispose_promise = Promise.resolve(job.handle.dispose());
|
|
485
|
+
} catch (error) {
|
|
486
|
+
job.handle_dispose_promise = Promise.reject(error);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
await job.handle_dispose_promise;
|
|
490
|
+
};
|
|
491
|
+
job.disposeHandle = disposeJobHandle;
|
|
331
492
|
// Read-only pre-run snapshot (async, never blocks dispatch): the audit
|
|
332
493
|
// only needs the before-state by the time the worker finishes. Non-repos
|
|
333
494
|
// 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' }));
|
|
495
|
+
job.baseline = await captureWorkspaceBaseline({ cwd: executionCwd }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace audit failed' }));
|
|
335
496
|
this.jobs.set(id, job);
|
|
336
497
|
|
|
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
498
|
const onEvent = (session, event) => {
|
|
344
499
|
switch (event.type) {
|
|
345
500
|
case 'step/start':
|
|
@@ -355,9 +510,9 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
355
510
|
job.tokens.output += u.outputTokens ?? 0;
|
|
356
511
|
job.tokens.reasoning += u.reasoningTokens ?? 0;
|
|
357
512
|
}
|
|
358
|
-
const text = (event.data?.message?.content ?? [])
|
|
359
|
-
.filter((c) => c?.type === 'text').map((c) => c.text).join('');
|
|
360
|
-
if (text) job.lastAssistantText = text;
|
|
513
|
+
const text = (event.data?.message?.content ?? [])
|
|
514
|
+
.filter((c) => c?.type === 'text').map((c) => c.text).join('');
|
|
515
|
+
if (text) job.lastAssistantText = text;
|
|
361
516
|
break;
|
|
362
517
|
}
|
|
363
518
|
case 'turn/end':
|
|
@@ -369,9 +524,9 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
369
524
|
};
|
|
370
525
|
|
|
371
526
|
const run = async () => {
|
|
372
|
-
const
|
|
527
|
+
const createPromise = this.ctx.agents.create({
|
|
373
528
|
sessionId,
|
|
374
|
-
meta: { cwd: executionCwd, ...(presetId === undefined ? {} : { agentPreset: presetId }) },
|
|
529
|
+
meta: { cwd: executionCwd, ...(presetId === undefined ? {} : { agentPreset: presetId }) },
|
|
375
530
|
agentOptions: { provider: selection.provider, model: selection.model },
|
|
376
531
|
setup: async (agentCtx) => {
|
|
377
532
|
installModelSelection(agentCtx, { current: selection, assembled: undefined });
|
|
@@ -383,58 +538,90 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
383
538
|
});
|
|
384
539
|
},
|
|
385
540
|
});
|
|
541
|
+
const created = await Promise.race([
|
|
542
|
+
createPromise.then((handle) => ({ handle })),
|
|
543
|
+
job.abortPromise.then(() => ({ aborted: true })),
|
|
544
|
+
]);
|
|
545
|
+
if (created.aborted) {
|
|
546
|
+
createPromise.then(async (handle) => {
|
|
547
|
+
if (handle && (job.status !== 'running' || job.abortRequested)) {
|
|
548
|
+
job.handle = handle;
|
|
549
|
+
try { await job.disposeHandle(); } catch (error) {
|
|
550
|
+
job.cleanup_warning = `agent cleanup failed: ${error?.message ?? String(error)}`;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}).catch(() => {});
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const handle = created.handle;
|
|
386
557
|
job.handle = handle;
|
|
558
|
+
// The job may have become terminal (cancel/timeout) while create() was
|
|
559
|
+
// pending; the old disposer was a no-op in that window. Dispose the
|
|
560
|
+
// freshly acquired handle and never deliver the prompt.
|
|
561
|
+
if (job.status !== 'running' || job.abortRequested) {
|
|
562
|
+
try { await job.disposeHandle(); } catch (error) {
|
|
563
|
+
job.cleanup_warning = `agent cleanup failed: ${error?.message ?? String(error)}`;
|
|
564
|
+
}
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
387
567
|
try {
|
|
388
568
|
// Group the worker session under the workspace of its cwd; create the
|
|
389
569
|
// workspace when none exists yet (resolveByPath is exact-match, so a
|
|
390
570
|
// job cwd never inherits a parent directory's workspace).
|
|
391
571
|
const registry = this.ctx.get('workspaceRegistry');
|
|
392
572
|
if (registry !== undefined) {
|
|
393
|
-
const ws = (await registry.resolveByPath(executionCwd)) ?? (await registry.create(executionCwd));
|
|
573
|
+
const ws = (await registry.resolveByPath(executionCwd)) ?? (await registry.create(executionCwd));
|
|
394
574
|
await ws.attachSession(sessionId);
|
|
395
575
|
}
|
|
396
576
|
} catch (err) {
|
|
397
|
-
this.ctx.logger?.warn?.(`dsh-crew: workspace attach failed for ${executionCwd}: ${err?.message ?? err}`);
|
|
577
|
+
this.ctx.logger?.warn?.(`dsh-crew: workspace attach failed for ${executionCwd}: ${err?.message ?? err}`);
|
|
398
578
|
}
|
|
399
579
|
await handle.agent.whenIdle();
|
|
580
|
+
// Cancel/timeout may have become terminal while waiting for the initial
|
|
581
|
+
// idle boundary; do not deliver the prompt to a job that is no longer
|
|
582
|
+
// running. The shared finally block still disposes the handle exactly once.
|
|
583
|
+
if (job.status !== 'running' || job.abortRequested) return;
|
|
400
584
|
handle.agent.followup(userMessage(job.prompt));
|
|
401
585
|
await handle.agent.whenIdle();
|
|
402
|
-
job.result = job.lastAssistantText ?? '';
|
|
403
|
-
if (job.status === 'running') job.status = job.stopReason === 'completed' ? 'done' : 'failed';
|
|
586
|
+
job.result = job.lastAssistantText ?? '';
|
|
587
|
+
if (job.status === 'running') job.status = job.stopReason === 'completed' ? 'done' : 'failed';
|
|
404
588
|
if (job.status === 'failed' && !job.error) job.error = `turn ended: ${job.stopReason ?? 'unknown'}`;
|
|
405
589
|
await this.ctx.sessions.flush(handle.agent.session);
|
|
406
590
|
};
|
|
407
591
|
|
|
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
|
-
|
|
592
|
+
const timeoutMs = Number.isInteger(timeout_seconds) && timeout_seconds > 0 ? timeout_seconds * 1000 : null;
|
|
593
|
+
if (timeoutMs) {
|
|
594
|
+
job.timeoutHandle = setTimeout(async () => {
|
|
595
|
+
if (job.status !== 'running') return;
|
|
596
|
+
job.abortRequested = true;
|
|
597
|
+
job.status = 'failed';
|
|
598
|
+
job.phase = JOB_PHASES.FAILED;
|
|
599
|
+
job.error = `job timed out after ${timeout_seconds}s`;
|
|
600
|
+
job.error_code = 'JOB_TIMEOUT';
|
|
601
|
+
job.endedAt = new Date().toISOString();
|
|
602
|
+
job.resolveAbort?.();
|
|
603
|
+
this.publish();
|
|
604
|
+
for (const waiter of job.waiters.splice(0)) waiter();
|
|
605
|
+
try { await disposeJobHandle(); } catch (error) {
|
|
606
|
+
job.cleanup_warning = `agent cleanup failed: ${error?.message ?? String(error)}`;
|
|
607
|
+
this.publish();
|
|
608
|
+
}
|
|
609
|
+
}, timeoutMs);
|
|
610
|
+
job.timeoutHandle.unref?.();
|
|
611
|
+
}
|
|
612
|
+
job.promise = run()
|
|
426
613
|
.catch((err) => {
|
|
427
614
|
if (job.status === 'running') job.status = 'failed';
|
|
428
615
|
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
|
-
}
|
|
616
|
+
})
|
|
617
|
+
.finally(async () => {
|
|
618
|
+
if (job.timeoutHandle) clearTimeout(job.timeoutHandle);
|
|
619
|
+
job.endedAt = new Date().toISOString();
|
|
620
|
+
job.currentTool = null;
|
|
621
|
+
let handleCleanupWarning = job.cleanup_warning ?? null;
|
|
622
|
+
try { await disposeJobHandle(); } catch (error) {
|
|
623
|
+
handleCleanupWarning = `agent cleanup failed: ${error?.message ?? String(error)}`;
|
|
624
|
+
}
|
|
438
625
|
// Delivery completeness is separate from execution status: a job can
|
|
439
626
|
// be done yet fail to report Diff/Tests/Risks (or Review sections for
|
|
440
627
|
// an automatic review). Parse whatever final message the worker
|
|
@@ -442,21 +629,21 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
442
629
|
const parsed = parseDeliveryReport(job.result ?? '');
|
|
443
630
|
job.delivery_complete = parsed.complete;
|
|
444
631
|
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
|
-
}
|
|
632
|
+
job.delivery_metadata = formatDeliveryMetadata(parsed);
|
|
633
|
+
if (job.role === 'reviewer') {
|
|
634
|
+
const verdictText = String(parsed.sections?.Verdict ?? '').trim().toLowerCase();
|
|
635
|
+
const verdict = /^(approve|approved|pass)\b/.test(verdictText)
|
|
636
|
+
? 'approve'
|
|
637
|
+
: /request.*chang|chang.*request|reject|needs changes/i.test(verdictText)
|
|
638
|
+
? 'request_changes'
|
|
639
|
+
: 'inconclusive';
|
|
640
|
+
const lines = (value) => String(value ?? '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean).slice(0, 40);
|
|
641
|
+
job.review = {
|
|
642
|
+
verdict, status: job.status, delivery_complete: parsed.complete === true,
|
|
643
|
+
findings: lines(parsed.sections?.['Review Findings']),
|
|
644
|
+
evidence: lines(parsed.sections?.Evidence), risks: lines(parsed.sections?.Risks),
|
|
645
|
+
};
|
|
646
|
+
}
|
|
460
647
|
// Canonical structured outcome (shared workflow layer) + terminal phase.
|
|
461
648
|
job.outcome = buildOutcome({
|
|
462
649
|
result: job.result ?? '',
|
|
@@ -467,9 +654,9 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
467
654
|
});
|
|
468
655
|
job.phase = job.status === 'done' ? JOB_PHASES.COMPLETED : job.status === 'cancelled' ? JOB_PHASES.CANCELLED : JOB_PHASES.FAILED;
|
|
469
656
|
// 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;
|
|
657
|
+
job.workspaceDiff = job.baseline.kind === 'git'
|
|
658
|
+
? await captureWorkspaceDiff({ cwd: executionCwd, baseline: job.baseline }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace diff failed' }))
|
|
659
|
+
: job.baseline;
|
|
473
660
|
job.outcome = applyHubWorkspaceEvidence({
|
|
474
661
|
outcome: job.outcome,
|
|
475
662
|
workspaceDiff: job.workspaceDiff,
|
|
@@ -477,23 +664,32 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
477
664
|
isolation: job.isolation,
|
|
478
665
|
role: job.role,
|
|
479
666
|
});
|
|
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;
|
|
667
|
+
// Record only sanitized callability evidence. Cancelled jobs are
|
|
668
|
+
// operator actions, not provider health signals.
|
|
669
|
+
if (job.status !== 'cancelled') {
|
|
670
|
+
this.healthStore.record(job.provider, job.model, {
|
|
671
|
+
ok: job.status === 'done',
|
|
672
|
+
observed_at: Date.parse(job.endedAt ?? '') || Date.now(),
|
|
673
|
+
error: { code: job.error_code, message: job.error },
|
|
674
|
+
});
|
|
496
675
|
}
|
|
676
|
+
if (job.role === 'reviewer' && job.review && job.workspaceDiff?.kind === 'git') {
|
|
677
|
+
const changes = job.workspaceDiff.changes ?? {};
|
|
678
|
+
const mutated = ['modified', 'deleted', 'renamed', 'untracked'].some((key) => Array.isArray(changes[key]) && changes[key].length > 0);
|
|
679
|
+
if (mutated) {
|
|
680
|
+
job.review.mutated_candidate = true;
|
|
681
|
+
job.review.invalidated = true;
|
|
682
|
+
job.review.verdict = 'request_changes';
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
if (job.isolatedWorkspace) {
|
|
686
|
+
const cleanup = await cleanupIsolatedWorkspace(job.isolatedWorkspace).catch((error) => ({ ok: false, error: error?.message ?? String(error) }));
|
|
687
|
+
job.workspace_retained = cleanup.ok !== true;
|
|
688
|
+
const workspaceCleanupWarning = cleanup.ok === true ? null : cleanup.error ?? 'worktree cleanup failed';
|
|
689
|
+
job.cleanup_warning = [handleCleanupWarning, workspaceCleanupWarning].filter(Boolean).join('; ') || null;
|
|
690
|
+
} else {
|
|
691
|
+
job.cleanup_warning = handleCleanupWarning;
|
|
692
|
+
}
|
|
497
693
|
this.publish();
|
|
498
694
|
for (const w of job.waiters.splice(0)) w();
|
|
499
695
|
});
|
|
@@ -506,66 +702,49 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
506
702
|
const job = this.jobs.get(id);
|
|
507
703
|
if (!job) return undefined;
|
|
508
704
|
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
|
-
]);
|
|
705
|
+
await raceWaiters(job.waiters, { timeoutMs, immediateWithoutTimeout: true });
|
|
513
706
|
return job;
|
|
514
707
|
}
|
|
515
708
|
|
|
516
|
-
async cancel(id) {
|
|
709
|
+
async cancel(id) {
|
|
517
710
|
const job = this.jobs.get(id);
|
|
518
711
|
if (!job) return undefined;
|
|
519
712
|
if (job.status === 'running') {
|
|
713
|
+
job.abortRequested = true;
|
|
520
714
|
job.status = 'cancelled';
|
|
521
715
|
job.phase = JOB_PHASES.CANCELLED;
|
|
522
716
|
job.error = 'cancelled by request';
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
}
|
|
717
|
+
job.resolveAbort?.();
|
|
718
|
+
for (const waiter of job.waiters.splice(0)) waiter();
|
|
719
|
+
try { await job.disposeHandle?.(); } catch (error) {
|
|
720
|
+
job.cleanup_warning = `agent cleanup failed: ${error?.message ?? String(error)}`;
|
|
721
|
+
}
|
|
526
722
|
this.publish();
|
|
527
723
|
}
|
|
528
724
|
return job;
|
|
529
725
|
}
|
|
530
726
|
|
|
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
|
-
}
|
|
727
|
+
async dispose() {
|
|
728
|
+
for (const job of this.jobs.values()) {
|
|
729
|
+
if (job.status === 'running') {
|
|
730
|
+
try { await job.disposeHandle?.(); } catch (error) {
|
|
731
|
+
job.cleanup_warning = `agent cleanup failed: ${error?.message ?? String(error)}`;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
540
736
|
}
|
|
541
737
|
|
|
542
738
|
// ---------- loopback route helpers (pattern from dsh-noema) ----------
|
|
543
739
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
if (
|
|
550
|
-
return
|
|
740
|
+
// The common trust core and its Origin policies live in one shared module so
|
|
741
|
+
// the hub and the 3080 bridge cannot drift apart (see local-request-guard.mjs).
|
|
742
|
+
export function isLoopbackRequest(req) {
|
|
743
|
+
if (!localRequestCore(req)) return false;
|
|
744
|
+
const origin = req.headers?.origin;
|
|
745
|
+
if (origin === undefined) return true;
|
|
746
|
+
return originLoopback(origin);
|
|
551
747
|
}
|
|
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
748
|
function sendJson(res, status, value, headers = {}) {
|
|
570
749
|
const body = JSON.stringify(value);
|
|
571
750
|
res.writeHead(status, {
|
|
@@ -607,102 +786,103 @@ async function readBody(req, limit = 64 * 1024) {
|
|
|
607
786
|
* Returns { ok: true, payload } (payload.tier = effective tier) or
|
|
608
787
|
* { ok: false, code, error }.
|
|
609
788
|
*/
|
|
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
|
-
}
|
|
789
|
+
export function resolveHubSpawnPayload(payload, getConfig = () => ({}), dependencies = {}) {
|
|
790
|
+
const config = normalizeGlobalConfig(getConfig());
|
|
791
|
+
const raw = payload ?? {};
|
|
792
|
+
const advanced = raw.profile !== undefined || raw.workspace_id !== undefined || raw.workspace !== undefined
|
|
793
|
+
|| raw.constraints !== undefined || raw.context_refs !== undefined || raw.job_id !== undefined || raw.objective !== undefined;
|
|
794
|
+
let normalized = { ...raw };
|
|
795
|
+
if (advanced) {
|
|
796
|
+
if (raw.workspace !== undefined && (!raw.workspace || typeof raw.workspace !== 'object' || Array.isArray(raw.workspace))) {
|
|
797
|
+
return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'workspace must be an object' };
|
|
798
|
+
}
|
|
799
|
+
if (raw.constraints !== undefined && (!raw.constraints || typeof raw.constraints !== 'object' || Array.isArray(raw.constraints))) {
|
|
800
|
+
return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'constraints must be an object' };
|
|
801
|
+
}
|
|
802
|
+
if (raw.workspace?.repo_root !== undefined && typeof raw.workspace.repo_root !== 'string') {
|
|
803
|
+
return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'workspace repo_root must be a string' };
|
|
804
|
+
}
|
|
805
|
+
if (raw.workspace?.worktree !== undefined && !['auto', 'existing', 'none'].includes(raw.workspace.worktree)) {
|
|
806
|
+
return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'invalid worktree policy' };
|
|
807
|
+
}
|
|
808
|
+
if (raw.workspace?.branch !== undefined && !isSafeBranchName(raw.workspace.branch)) {
|
|
809
|
+
return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'invalid workspace branch' };
|
|
810
|
+
}
|
|
811
|
+
if (raw.constraints?.timeout_seconds !== undefined && (!Number.isInteger(raw.constraints.timeout_seconds) || raw.constraints.timeout_seconds < 1 || raw.constraints.timeout_seconds > 7200)) {
|
|
812
|
+
return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'invalid timeout_seconds' };
|
|
813
|
+
}
|
|
814
|
+
if (raw.constraints?.allow_fallback !== undefined && typeof raw.constraints.allow_fallback !== 'boolean') {
|
|
815
|
+
return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'allow_fallback must be boolean' };
|
|
816
|
+
}
|
|
817
|
+
if (raw.constraints?.allow_no_changes !== undefined && typeof raw.constraints.allow_no_changes !== 'boolean') {
|
|
818
|
+
return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'allow_no_changes must be boolean' };
|
|
819
|
+
}
|
|
820
|
+
const profileRegistry = dependencies.profileRegistry ?? loadRoleProfiles();
|
|
821
|
+
const workspaceRegistry = dependencies.workspaceRegistry ?? loadWorkspaceContexts();
|
|
822
|
+
if (!profileRegistry.ok) return { ok: false, code: 'PROFILE_FILE_INVALID', error: 'role profile registry is invalid' };
|
|
823
|
+
if (!workspaceRegistry.ok) return { ok: false, code: 'WORKSPACE_CONTEXT_FILE_INVALID', error: 'workspace registry is invalid' };
|
|
824
|
+
const profileRole = raw.profile ? profileRegistry.profiles?.[raw.profile]?.role : undefined;
|
|
825
|
+
const requestedRole = raw.role ?? profileRole ?? 'worker';
|
|
826
|
+
const resolvedProfile = resolveRoleProfile(profileRegistry, raw.profile, requestedRole);
|
|
827
|
+
if (!resolvedProfile.ok) return { ok: false, code: resolvedProfile.code, error: resolvedProfile.code };
|
|
828
|
+
const knownContext = raw.workspace_id ? workspaceRegistry.contexts?.[raw.workspace_id] : null;
|
|
829
|
+
const cwd = raw.workspace?.repo_root ?? raw.cwd ?? knownContext?.repo_root;
|
|
830
|
+
if (!cwd) return { ok: false, code: 'WORKSPACE_CONTEXT_NOT_FOUND', error: 'workspace repo_root or cwd is required' };
|
|
831
|
+
const workspace = resolveWorkspaceContext(workspaceRegistry, { workspace_id: raw.workspace_id, cwd });
|
|
832
|
+
if (!workspace.ok) return { ok: false, code: workspace.code, error: workspace.code };
|
|
833
|
+
const withRefs = addContextReferences(workspace.context, raw.context_refs, { cwd });
|
|
834
|
+
if (!withRefs.ok) return { ok: false, code: withRefs.code, error: withRefs.code };
|
|
835
|
+
const profile = resolvedProfile.profile;
|
|
836
|
+
const worktree = raw.workspace?.worktree;
|
|
837
|
+
const requestedIsolation = worktree === 'auto' ? 'worktree'
|
|
838
|
+
: worktree === 'existing' || worktree === 'none' ? 'shared'
|
|
839
|
+
: profile.isolation;
|
|
840
|
+
const objective = raw.objective ?? raw.task;
|
|
841
|
+
if (typeof objective !== 'string' || objective.trim() === '') return { ok: false, code: 'JOB_OBJECTIVE_REQUIRED', error: 'task or objective is required' };
|
|
842
|
+
if (raw.job_id !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(raw.job_id)) {
|
|
843
|
+
return { ok: false, code: 'JOB_ID_INVALID', error: 'invalid client job id' };
|
|
844
|
+
}
|
|
845
|
+
const contextTask = buildWorkspaceTask(objective, withRefs.context);
|
|
846
|
+
normalized = {
|
|
847
|
+
...raw,
|
|
848
|
+
task: profile.review_strictness === 'strict' && requestedRole === 'reviewer'
|
|
849
|
+
? `${contextTask}\n\nSTRICT REVIEW: fail closed on missing direct code or test evidence.`
|
|
850
|
+
: contextTask,
|
|
851
|
+
cwd,
|
|
852
|
+
role: requestedRole,
|
|
853
|
+
client_job_id: raw.job_id ?? null,
|
|
854
|
+
requested_isolation: requestedIsolation,
|
|
855
|
+
workspace_branch: raw.workspace?.branch ?? withRefs.context?.default_branch ?? null,
|
|
856
|
+
timeout_seconds: raw.constraints?.timeout_seconds ?? profile.timeout_seconds,
|
|
857
|
+
allow_fallback: raw.constraints?.allow_fallback ?? profile.fallback,
|
|
858
|
+
allow_no_changes: raw.constraints?.allow_no_changes === true,
|
|
859
|
+
routing: profile.routing,
|
|
860
|
+
review_strictness: profile.review_strictness,
|
|
861
|
+
profile_id: resolvedProfile.profile_id,
|
|
862
|
+
workspace_context: withRefs.context,
|
|
863
|
+
};
|
|
864
|
+
for (const key of ['objective', 'job_id', 'profile', 'workspace_id', 'workspace', 'constraints', 'context_refs']) delete normalized[key];
|
|
865
|
+
}
|
|
866
|
+
// v0.2 role-based dispatch: reviewer / worker are gated by their role state,
|
|
867
|
+
// and the tier slot is derived from the role (reviewer always → pro).
|
|
868
|
+
if (normalized.role === 'worker' || normalized.role === 'reviewer') {
|
|
869
|
+
const hint = resolveRoleTierHint(normalized.role, normalized.tier);
|
|
870
|
+
if (!hint.ok) return { ok: false, code: hint.code, error: hint.error };
|
|
871
|
+
const decision = canDispatchRole(config, normalized.role, true, {});
|
|
872
|
+
if (!decision.ok) return { ok: false, code: decision.error.policyCode, error: decision.error.message };
|
|
873
|
+
return { ok: true, payload: { ...normalized, role: normalized.role, tier: hint.tier } };
|
|
874
|
+
}
|
|
875
|
+
const decision = chooseDefaultTier(config, normalized.tier, {});
|
|
876
|
+
if (!decision.ok) return { ok: false, code: decision.error.policyCode, error: decision.error.message };
|
|
877
|
+
return { ok: true, payload: { ...normalized, tier: decision.tier } };
|
|
878
|
+
}
|
|
700
879
|
|
|
701
880
|
// ---------- plugin entry ----------
|
|
702
881
|
|
|
703
|
-
export async function apply(ctx) {
|
|
704
|
-
seedLangFromHost(ctx);
|
|
705
|
-
const hub = new WorkerRegistry(ctx);
|
|
882
|
+
export async function apply(ctx) {
|
|
883
|
+
seedLangFromHost(ctx);
|
|
884
|
+
const hub = new WorkerRegistry(ctx);
|
|
885
|
+
const builtInProviderProbe = createProviderProbe(ctx);
|
|
706
886
|
try {
|
|
707
887
|
const { readGlobalConfig } = await import('../install/install.mjs');
|
|
708
888
|
hub.getConfig = () => readGlobalConfig();
|
|
@@ -712,7 +892,13 @@ export async function apply(ctx) {
|
|
|
712
892
|
// enforced its own), so the check uses the global config only. The route
|
|
713
893
|
// calls resolveHubSpawnPayload (above) to stamp the effective tier onto
|
|
714
894
|
// the spawn payload.
|
|
715
|
-
const disposers = [];
|
|
895
|
+
const disposers = [];
|
|
896
|
+
const PROVIDER_DELETE_PLAN_TTL_MS = 10 * 60 * 1000;
|
|
897
|
+
const providerDeletePlans = new Map();
|
|
898
|
+
const rememberProviderDeletePlan = (plan) => {
|
|
899
|
+
providerDeletePlans.set(plan.plan_id, { plan, created_at: Date.now() });
|
|
900
|
+
while (providerDeletePlans.size > 32) providerDeletePlans.delete(providerDeletePlans.keys().next().value);
|
|
901
|
+
};
|
|
716
902
|
|
|
717
903
|
// Multimodal bridge: register describe_image / generate_image for the DS
|
|
718
904
|
// model. Config is read per call so settings-page edits apply live; the
|
|
@@ -770,29 +956,29 @@ export async function apply(ctx) {
|
|
|
770
956
|
const foreign = readMergedStatus({ excludeWriter: hub.shard.writer });
|
|
771
957
|
return sendJson(res, 200, { ok: true, jobs: [...own, ...foreign] });
|
|
772
958
|
}
|
|
773
|
-
if (req.method === 'GET' && parts.length === 1) {
|
|
959
|
+
if (req.method === 'GET' && parts.length === 1) {
|
|
774
960
|
const wait = Number(url.searchParams.get('wait') ?? 0);
|
|
775
961
|
const job = await hub.wait(parts[0], Math.min(wait, 600) * 1000);
|
|
776
962
|
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
|
-
}
|
|
963
|
+
return sendJson(res, 200, { ok: true, job: hub.view(job, true) });
|
|
964
|
+
}
|
|
965
|
+
if (req.method === 'GET' && parts.length === 2 && parts[1] === 'events') {
|
|
966
|
+
const job = hub.jobs.get(parts[0]);
|
|
967
|
+
if (!job) return sendJson(res, 404, { ok: false, error: 'no such job' });
|
|
968
|
+
const after = Math.max(0, Number(url.searchParams.get('after') ?? 0) || 0);
|
|
969
|
+
const events = hubCanonicalEvents(job).filter((event) => event.sequence > after);
|
|
970
|
+
return sendJson(res, 200, {
|
|
971
|
+
ok: true, job_id: job.id, events,
|
|
972
|
+
event_cursor: hubCanonicalEvents(job).at(-1)?.sequence ?? 0,
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
if (req.method === 'GET' && parts.length === 2 && parts[1] === 'contract') {
|
|
976
|
+
const job = hub.jobs.get(parts[0]);
|
|
977
|
+
if (!job) return sendJson(res, 404, { ok: false, error: 'no such job' });
|
|
978
|
+
const detail = url.searchParams.get('detail') === 'full' ? 'full' : 'compact';
|
|
979
|
+
const afterSequence = Math.max(0, Number(url.searchParams.get('after') ?? 0) || 0);
|
|
980
|
+
return sendJson(res, 200, { ok: true, job: projectWorkflowView(hub.view(job, true), { detail, afterSequence }) });
|
|
981
|
+
}
|
|
796
982
|
if (req.method === 'POST' && parts.length === 0) {
|
|
797
983
|
const payload = await readBody(req);
|
|
798
984
|
// Same policy resolver as the MCP server (src/server.mjs), with the
|
|
@@ -816,90 +1002,90 @@ export async function apply(ctx) {
|
|
|
816
1002
|
if (code) body.code = code;
|
|
817
1003
|
return sendJson(res, 400, body);
|
|
818
1004
|
}
|
|
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 });
|
|
884
|
-
const liveJobs = typeof hub.list === 'function' ? hub.list() : [];
|
|
885
|
-
const [modelExecution, reviewerExecution] = buildHubExecutionRows(liveJobs);
|
|
886
|
-
const contract = buildExtensionContract({
|
|
887
|
-
config,
|
|
888
|
-
readinessMatrix: { rows: [
|
|
889
|
-
{ id: 'hub_compatibility', status: 'PASS', reason_code: 'LIVE_CHECK_PASSED' },
|
|
890
|
-
catalogStatus,
|
|
891
|
-
modelExecution,
|
|
892
|
-
reviewerExecution,
|
|
893
|
-
] },
|
|
894
|
-
workspace: workspaceReadiness,
|
|
895
|
-
profiles,
|
|
896
|
-
runtime: getHubRuntimeIdentity(),
|
|
897
|
-
});
|
|
898
|
-
return sendJson(res, 200, { ok: true, extension: contract });
|
|
899
|
-
},
|
|
900
|
-
}));
|
|
901
|
-
|
|
902
|
-
disposers.push(webServer.register({
|
|
1005
|
+
},
|
|
1006
|
+
}));
|
|
1007
|
+
|
|
1008
|
+
disposers.push(webServer.register({
|
|
1009
|
+
kind: 'exact', path: `${ROUTE_BASE}/profiles`,
|
|
1010
|
+
handler: async (req, res) => {
|
|
1011
|
+
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
|
|
1012
|
+
if (req.method === 'GET') return sendJson(res, 200, { ok: true, ...loadRoleProfiles() });
|
|
1013
|
+
if (req.method === 'POST') {
|
|
1014
|
+
try {
|
|
1015
|
+
const saved = saveRoleProfiles(await readBody(req));
|
|
1016
|
+
return sendJson(res, saved.ok ? 200 : 400, { ...saved });
|
|
1017
|
+
} catch {
|
|
1018
|
+
return sendJson(res, 400, { ok: false, error: 'invalid JSON', code: 'PROFILE_FILE_INVALID' });
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
return sendJson(res, 405, { ok: false, error: 'GET or POST' }, { allow: 'GET, POST' });
|
|
1022
|
+
},
|
|
1023
|
+
}));
|
|
1024
|
+
|
|
1025
|
+
disposers.push(webServer.register({
|
|
1026
|
+
kind: 'exact', path: `${ROUTE_BASE}/workspaces`,
|
|
1027
|
+
handler: async (req, res) => {
|
|
1028
|
+
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
|
|
1029
|
+
if (req.method === 'GET') return sendJson(res, 200, { ok: true, ...loadWorkspaceContexts() });
|
|
1030
|
+
if (req.method === 'POST') {
|
|
1031
|
+
try {
|
|
1032
|
+
const saved = saveWorkspaceContexts(await readBody(req));
|
|
1033
|
+
return sendJson(res, saved.ok ? 200 : 400, { ...saved });
|
|
1034
|
+
} catch {
|
|
1035
|
+
return sendJson(res, 400, { ok: false, error: 'invalid JSON', code: 'WORKSPACE_CONTEXT_FILE_INVALID' });
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
return sendJson(res, 405, { ok: false, error: 'GET or POST' }, { allow: 'GET, POST' });
|
|
1039
|
+
},
|
|
1040
|
+
}));
|
|
1041
|
+
|
|
1042
|
+
disposers.push(webServer.register({
|
|
1043
|
+
kind: 'exact', path: `${ROUTE_BASE}/extension`,
|
|
1044
|
+
handler: async (req, res) => {
|
|
1045
|
+
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
|
|
1046
|
+
if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'GET only' }, { allow: 'GET' });
|
|
1047
|
+
const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
|
|
1048
|
+
let catalogStatus = { id: 'provider_catalog', status: 'NOT_RUN', reason_code: 'PROVIDER_MODE_UNKNOWN' };
|
|
1049
|
+
if (normalizeWorkerProviderMode(config.worker_provider_mode) === 'deepseek-official') {
|
|
1050
|
+
catalogStatus = { id: 'provider_catalog', status: 'SKIP', reason_code: 'PROVIDER_CATALOG_NOT_REQUIRED' };
|
|
1051
|
+
} else {
|
|
1052
|
+
try {
|
|
1053
|
+
await readHarnessModelCatalog({
|
|
1054
|
+
llm: ctx.llm ?? ctx.get('llm'),
|
|
1055
|
+
getCurrentSelection: () => ctx.get('agentDefaultModel')?.currentSelection?.(),
|
|
1056
|
+
});
|
|
1057
|
+
catalogStatus = { id: 'provider_catalog', status: 'PASS', reason_code: 'PROVIDER_CATALOG_RESOLVED' };
|
|
1058
|
+
} catch {
|
|
1059
|
+
catalogStatus = { id: 'provider_catalog', status: 'FAIL', reason_code: 'PROVIDER_CATALOG_UNAVAILABLE' };
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
const profiles = loadRoleProfiles();
|
|
1063
|
+
const requestUrl = new URL(req.url, 'http://localhost');
|
|
1064
|
+
const requestedWorkspaceId = requestUrl.searchParams.get('workspace_id');
|
|
1065
|
+
const workspaceRegistry = loadWorkspaceContexts();
|
|
1066
|
+
const requestedContext = requestedWorkspaceId ? workspaceRegistry.contexts?.[requestedWorkspaceId] : null;
|
|
1067
|
+
const workspaceReadiness = requestedWorkspaceId && !requestedContext
|
|
1068
|
+
? { status: 'UNAVAILABLE', reason_code: 'WORKSPACE_CONTEXT_NOT_FOUND' }
|
|
1069
|
+
: await assessWorkspaceReadiness({ cwd: requestedContext?.repo_root ?? null });
|
|
1070
|
+
const liveJobs = typeof hub.list === 'function' ? hub.list() : [];
|
|
1071
|
+
const [modelExecution, reviewerExecution] = buildHubExecutionRows(liveJobs);
|
|
1072
|
+
const contract = buildExtensionContract({
|
|
1073
|
+
config,
|
|
1074
|
+
readinessMatrix: { rows: [
|
|
1075
|
+
{ id: 'hub_compatibility', status: 'PASS', reason_code: 'LIVE_CHECK_PASSED' },
|
|
1076
|
+
catalogStatus,
|
|
1077
|
+
modelExecution,
|
|
1078
|
+
reviewerExecution,
|
|
1079
|
+
] },
|
|
1080
|
+
workspace: workspaceReadiness,
|
|
1081
|
+
profiles,
|
|
1082
|
+
runtime: getHubRuntimeIdentity(),
|
|
1083
|
+
});
|
|
1084
|
+
return sendJson(res, 200, { ok: true, extension: contract });
|
|
1085
|
+
},
|
|
1086
|
+
}));
|
|
1087
|
+
|
|
1088
|
+
disposers.push(webServer.register({
|
|
903
1089
|
kind: 'exact', path: `${ROUTE_BASE}/config`,
|
|
904
1090
|
handler: async (req, res) => {
|
|
905
1091
|
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
|
|
@@ -936,13 +1122,15 @@ export async function apply(ctx) {
|
|
|
936
1122
|
},
|
|
937
1123
|
}));
|
|
938
1124
|
|
|
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
|
|
1125
|
+
disposers.push(webServer.register({
|
|
1126
|
+
kind: 'exact', path: `${ROUTE_BASE}/provider`,
|
|
1127
|
+
handler: async (req, res) => {
|
|
1128
|
+
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
|
|
1129
|
+
try {
|
|
1130
|
+
const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
|
|
1131
|
+
const lifecycleState = readProviderLifecycleState();
|
|
1132
|
+
const healthGate = config.health_gate ?? config.worker?.model_policy?.health_gate;
|
|
1133
|
+
const mode = normalizeWorkerProviderMode(config.worker_provider_mode);
|
|
946
1134
|
let selections;
|
|
947
1135
|
if (mode === 'deepseek-official') {
|
|
948
1136
|
selections = {
|
|
@@ -960,10 +1148,14 @@ export async function apply(ctx) {
|
|
|
960
1148
|
tier,
|
|
961
1149
|
priority: config[`${tier}_model_priority`],
|
|
962
1150
|
priorityConfigured: config[`${tier}_model_priority_configured`],
|
|
963
|
-
fallback: config[`${tier}_model_fallback`],
|
|
964
|
-
catalog,
|
|
965
|
-
harnessDefault: catalog.harness_default,
|
|
966
|
-
|
|
1151
|
+
fallback: config[`${tier}_model_fallback`],
|
|
1152
|
+
catalog,
|
|
1153
|
+
harnessDefault: catalog.harness_default,
|
|
1154
|
+
healthStore: hub.healthStore,
|
|
1155
|
+
healthGate,
|
|
1156
|
+
allowFallback: config.allow_fallback !== false,
|
|
1157
|
+
tombstones: lifecycleState.tombstones,
|
|
1158
|
+
});
|
|
967
1159
|
selections[tier] = selected.ok
|
|
968
1160
|
? { provider: selected.provider, model: selected.model, source: selected.source }
|
|
969
1161
|
: { code: selected.code, error: selected.message };
|
|
@@ -978,11 +1170,228 @@ export async function apply(ctx) {
|
|
|
978
1170
|
} catch (err) {
|
|
979
1171
|
return sendJson(res, 503, { ok: false, code: 'MODEL_CATALOG_UNAVAILABLE', error: 'Unable to resolve Harness worker models.' });
|
|
980
1172
|
}
|
|
981
|
-
},
|
|
982
|
-
}));
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
1173
|
+
},
|
|
1174
|
+
}));
|
|
1175
|
+
|
|
1176
|
+
// Secret-free Harness provider inventory. This is deliberately separate
|
|
1177
|
+
// from /models (catalog) and /provider (effective routing): it reports
|
|
1178
|
+
// declaration provenance, ownership, lifecycle state and policy refs.
|
|
1179
|
+
disposers.push(webServer.register({
|
|
1180
|
+
kind: 'exact', path: `${ROUTE_BASE}/providers`,
|
|
1181
|
+
handler: async (req, res) => {
|
|
1182
|
+
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
|
|
1183
|
+
if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'GET only' }, { allow: 'GET' });
|
|
1184
|
+
try {
|
|
1185
|
+
const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
|
|
1186
|
+
const inventory = await readProviderInventorySnapshot(hub, ctx, config);
|
|
1187
|
+
return sendJson(res, 200, {
|
|
1188
|
+
ok: true,
|
|
1189
|
+
...inventory,
|
|
1190
|
+
runtime: getHubRuntimeIdentity(),
|
|
1191
|
+
});
|
|
1192
|
+
} catch {
|
|
1193
|
+
return sendJson(res, 503, { ok: false, code: 'PROVIDER_INVENTORY_UNAVAILABLE', error: 'Unable to read Harness provider inventory.' });
|
|
1194
|
+
}
|
|
1195
|
+
},
|
|
1196
|
+
}));
|
|
1197
|
+
|
|
1198
|
+
// Deletion is two-phase: this endpoint only computes a revision-bound,
|
|
1199
|
+
// secret-free impact plan. A later mutation endpoint must present that
|
|
1200
|
+
// plan and perform restart/verification through an owned supervisor.
|
|
1201
|
+
disposers.push(webServer.register({
|
|
1202
|
+
kind: 'prefix', path: `${ROUTE_BASE}/providers`,
|
|
1203
|
+
handler: async (req, res) => {
|
|
1204
|
+
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
|
|
1205
|
+
let url;
|
|
1206
|
+
try { url = new URL(req.url, 'http://localhost'); } catch { return sendJson(res, 400, { ok: false, code: 'PROVIDER_PLAN_INVALID' }); }
|
|
1207
|
+
const parts = url.pathname.slice(`${ROUTE_BASE}/providers`.length).split('/').filter(Boolean);
|
|
1208
|
+
try {
|
|
1209
|
+
const providerId = decodeURIComponent(parts[0]);
|
|
1210
|
+
const body = await readBody(req);
|
|
1211
|
+
const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
|
|
1212
|
+
const inventory = await readProviderInventorySnapshot(hub, ctx, config);
|
|
1213
|
+
if (req.method === 'POST' && parts.length === 2 && parts[1] === 'delete-plan') {
|
|
1214
|
+
const profile = readProviderProfileRevision();
|
|
1215
|
+
if (!profile.ok) return sendJson(res, 409, { ok: false, code: profile.code });
|
|
1216
|
+
const planned = planProviderDelete({
|
|
1217
|
+
providerId,
|
|
1218
|
+
inventory,
|
|
1219
|
+
activeJobs: hub.list(),
|
|
1220
|
+
replacementDefault: body?.replacement_default,
|
|
1221
|
+
expectedRevision: profile.revision,
|
|
1222
|
+
});
|
|
1223
|
+
if (!planned.ok) return sendJson(res, 409, { ok: false, code: planned.code });
|
|
1224
|
+
rememberProviderDeletePlan(planned.plan);
|
|
1225
|
+
return sendJson(res, 200, { ok: true, profile_revision: profile.revision, plan: planned.plan });
|
|
1226
|
+
}
|
|
1227
|
+
if (req.method === 'POST' && parts.length === 2 && parts[1] === 'probe') {
|
|
1228
|
+
const record = inventory.records.find((entry) => entry.id === providerId);
|
|
1229
|
+
if (!record) return sendJson(res, 404, { ok: false, code: 'PROVIDER_NOT_FOUND' });
|
|
1230
|
+
if (record.desired_state === 'absent') return sendJson(res, 409, { ok: false, code: 'PROVIDER_TOMBSTONED' });
|
|
1231
|
+
const model = selectProviderProbeModel({ providerId, record, config });
|
|
1232
|
+
if (!model) return sendJson(res, 409, { ok: false, code: 'PROVIDER_PROBE_MODEL_UNAVAILABLE' });
|
|
1233
|
+
let explicitProviderProbe;
|
|
1234
|
+
try { explicitProviderProbe = ctx?.providerProbe; } catch { explicitProviderProbe = undefined; }
|
|
1235
|
+
const providerProbe = typeof explicitProviderProbe === 'function' ? explicitProviderProbe : builtInProviderProbe;
|
|
1236
|
+
if (typeof providerProbe !== 'function') return sendJson(res, 503, { ok: false, code: 'PROVIDER_PROBE_UNAVAILABLE' });
|
|
1237
|
+
let observed;
|
|
1238
|
+
let timer;
|
|
1239
|
+
try {
|
|
1240
|
+
const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve({ error: { name: 'TimeoutError' } }), 15_000); });
|
|
1241
|
+
observed = await Promise.race([
|
|
1242
|
+
Promise.resolve().then(() => providerProbe({ provider: providerId, model, signal: AbortSignal.timeout(15_000) })),
|
|
1243
|
+
timeout,
|
|
1244
|
+
]);
|
|
1245
|
+
} catch (error) {
|
|
1246
|
+
// Keep probe transport failures as bounded health evidence so a
|
|
1247
|
+
// credential/quota/timeout result is visible to routing policy;
|
|
1248
|
+
// never copy the raw provider error into the response.
|
|
1249
|
+
observed = { error };
|
|
1250
|
+
} finally {
|
|
1251
|
+
clearTimeout(timer);
|
|
1252
|
+
}
|
|
1253
|
+
const health = hub.healthStore.record(providerId, model, observed?.ok === true ? { ok: true } : { error: observed?.error ?? observed });
|
|
1254
|
+
return sendJson(res, 200, { ok: true, provider_id: providerId, model, health });
|
|
1255
|
+
}
|
|
1256
|
+
if (req.method === 'POST' && parts.length === 2 && parts[1] === 'verify-delete') {
|
|
1257
|
+
if (body?.confirm !== true) return sendJson(res, 400, { ok: false, code: 'PROVIDER_DELETE_VERIFY_CONFIRM_REQUIRED' });
|
|
1258
|
+
const { transaction_id: transactionId } = body ?? {};
|
|
1259
|
+
const hooks = createProviderDeleteFileHooks({
|
|
1260
|
+
profileFile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
|
|
1261
|
+
configFile: join(CONFIG_DIR, 'config.json'),
|
|
1262
|
+
lifecycleFile: join(CONFIG_DIR, 'provider-lifecycle.json'),
|
|
1263
|
+
backupDir: join(CONFIG_DIR, 'provider-backups'),
|
|
1264
|
+
existingBackupId: transactionId,
|
|
1265
|
+
expectedProviderId: providerId,
|
|
1266
|
+
});
|
|
1267
|
+
const plan = hooks.backupPlan();
|
|
1268
|
+
if (!plan || plan.provider_id !== providerId) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_PLAN_PROVIDER_MISMATCH' });
|
|
1269
|
+
await hooks.acquireLock();
|
|
1270
|
+
try {
|
|
1271
|
+
const verification = await hooks.verify(plan);
|
|
1272
|
+
if (verification?.providerAbsent !== true || verification?.routingClear !== true || verification?.tombstonePresent !== true) {
|
|
1273
|
+
return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_VERIFY_FAILED', verification });
|
|
1274
|
+
}
|
|
1275
|
+
await hooks.recordTransaction({ transaction_id: transactionId, provider_id: providerId, state: 'VERIFIED' }, plan);
|
|
1276
|
+
return sendJson(res, 200, { ok: true, transaction_id: transactionId, provider_id: providerId, state: 'VERIFIED', verification });
|
|
1277
|
+
} finally {
|
|
1278
|
+
await hooks.release();
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
if (req.method === 'POST' && parts.length === 2 && parts[1] === 'rollback') {
|
|
1282
|
+
if (body?.confirm !== true) return sendJson(res, 400, { ok: false, code: 'PROVIDER_ROLLBACK_CONFIRM_REQUIRED' });
|
|
1283
|
+
const { transaction_id: transactionId } = body ?? {};
|
|
1284
|
+
const hooks = createProviderDeleteFileHooks({
|
|
1285
|
+
profileFile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
|
|
1286
|
+
configFile: join(CONFIG_DIR, 'config.json'),
|
|
1287
|
+
lifecycleFile: join(CONFIG_DIR, 'provider-lifecycle.json'),
|
|
1288
|
+
backupDir: join(CONFIG_DIR, 'provider-backups'),
|
|
1289
|
+
existingBackupId: transactionId,
|
|
1290
|
+
expectedProviderId: providerId,
|
|
1291
|
+
});
|
|
1292
|
+
await hooks.acquireLock();
|
|
1293
|
+
try {
|
|
1294
|
+
const plan = hooks.backupPlan();
|
|
1295
|
+
if (!plan || plan.provider_id !== providerId) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_PLAN_PROVIDER_MISMATCH' });
|
|
1296
|
+
await hooks.rollback(plan);
|
|
1297
|
+
try {
|
|
1298
|
+
await hooks.recordTransaction({ transaction_id: transactionId, provider_id: providerId, state: 'ROLLBACK_PENDING' }, plan);
|
|
1299
|
+
} catch (error) {
|
|
1300
|
+
return sendJson(res, 409, { ok: false, code: boundedMachineCodeFromError(error) ?? 'PROVIDER_LIFECYCLE_RECORD_FAILED' });
|
|
1301
|
+
}
|
|
1302
|
+
return sendJson(res, 202, { ok: true, restart_required: true, transaction_id: transactionId, provider_id: providerId, state: 'ROLLBACK_PENDING' });
|
|
1303
|
+
} finally {
|
|
1304
|
+
await hooks.release();
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
if (req.method === 'POST' && parts.length === 2 && parts[1] === 'verify-rollback') {
|
|
1308
|
+
if (body?.confirm !== true) return sendJson(res, 400, { ok: false, code: 'PROVIDER_ROLLBACK_VERIFY_CONFIRM_REQUIRED' });
|
|
1309
|
+
const { transaction_id: transactionId } = body ?? {};
|
|
1310
|
+
const hooks = createProviderDeleteFileHooks({
|
|
1311
|
+
profileFile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
|
|
1312
|
+
configFile: join(CONFIG_DIR, 'config.json'),
|
|
1313
|
+
lifecycleFile: join(CONFIG_DIR, 'provider-lifecycle.json'),
|
|
1314
|
+
backupDir: join(CONFIG_DIR, 'provider-backups'),
|
|
1315
|
+
existingBackupId: transactionId,
|
|
1316
|
+
expectedProviderId: providerId,
|
|
1317
|
+
});
|
|
1318
|
+
const plan = hooks.backupPlan();
|
|
1319
|
+
if (!plan || plan.provider_id !== providerId) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_PLAN_PROVIDER_MISMATCH' });
|
|
1320
|
+
await hooks.acquireLock();
|
|
1321
|
+
try {
|
|
1322
|
+
const verified = await hooks.verifyRollback(plan);
|
|
1323
|
+
if (verified?.ok !== true) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_ROLLBACK_VERIFY_FAILED' });
|
|
1324
|
+
await hooks.recordTransaction({ transaction_id: transactionId, provider_id: providerId, state: 'ROLLED_BACK' }, plan);
|
|
1325
|
+
return sendJson(res, 200, { ok: true, transaction_id: transactionId, provider_id: providerId, state: 'ROLLED_BACK' });
|
|
1326
|
+
} finally {
|
|
1327
|
+
await hooks.release();
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
if (req.method === 'DELETE' && parts.length === 1) {
|
|
1331
|
+
if (body?.confirm !== true) return sendJson(res, 400, { ok: false, code: 'PROVIDER_DELETE_CONFIRM_REQUIRED' });
|
|
1332
|
+
if (body?.purge_orphan_credentials === true) {
|
|
1333
|
+
return sendJson(res, 400, { ok: false, code: 'CREDENTIAL_PURGE_REQUIRES_EXPLICIT_CONFIRMATION' });
|
|
1334
|
+
}
|
|
1335
|
+
const stored = providerDeletePlans.get(body?.plan_id);
|
|
1336
|
+
if (!stored || Date.now() - stored.created_at > PROVIDER_DELETE_PLAN_TTL_MS || stored.plan.provider_id !== providerId) {
|
|
1337
|
+
providerDeletePlans.delete(body?.plan_id);
|
|
1338
|
+
return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_PLAN_EXPIRED' });
|
|
1339
|
+
}
|
|
1340
|
+
const profile = readProviderProfileRevision();
|
|
1341
|
+
if (!profile.ok) return sendJson(res, 409, { ok: false, code: profile.code });
|
|
1342
|
+
if (body?.expected_revision !== stored.plan.expected_revision || profile.revision !== stored.plan.expected_revision) {
|
|
1343
|
+
return sendJson(res, 409, { ok: false, code: 'PROVIDER_PROFILE_CHANGED' });
|
|
1344
|
+
}
|
|
1345
|
+
const refreshed = planProviderDelete({
|
|
1346
|
+
providerId,
|
|
1347
|
+
inventory,
|
|
1348
|
+
activeJobs: hub.list(),
|
|
1349
|
+
replacementDefault: stored.plan.replacement_default,
|
|
1350
|
+
expectedRevision: profile.revision,
|
|
1351
|
+
});
|
|
1352
|
+
if (!refreshed.ok) return sendJson(res, 409, { ok: false, code: refreshed.code });
|
|
1353
|
+
if (refreshed.plan.replacement_default_model !== stored.plan.replacement_default_model) {
|
|
1354
|
+
return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_PLAN_CHANGED' });
|
|
1355
|
+
}
|
|
1356
|
+
const executionPlan = { ...refreshed.plan, plan_id: stored.plan.plan_id, expected_revision: stored.plan.expected_revision };
|
|
1357
|
+
const hooks = createProviderDeleteFileHooks({
|
|
1358
|
+
profileFile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
|
|
1359
|
+
configFile: join(CONFIG_DIR, 'config.json'),
|
|
1360
|
+
lifecycleFile: join(CONFIG_DIR, 'provider-lifecycle.json'),
|
|
1361
|
+
backupDir: join(CONFIG_DIR, 'provider-backups'),
|
|
1362
|
+
});
|
|
1363
|
+
providerDeletePlans.delete(body.plan_id);
|
|
1364
|
+
const { executeProviderDelete } = await import('../provider-lifecycle.mjs');
|
|
1365
|
+
const result = await executeProviderDelete(executionPlan, hooks, { deferRestart: true });
|
|
1366
|
+
return sendJson(res, result.state === 'RESTART_PENDING' ? 202 : 409, { ok: result.state === 'RESTART_PENDING', restart_required: true, result });
|
|
1367
|
+
}
|
|
1368
|
+
return sendJson(res, 404, { ok: false, error: 'unknown providers endpoint' });
|
|
1369
|
+
} catch (err) {
|
|
1370
|
+
const isProbe = req.method === 'POST' && parts.length === 2 && parts[1] === 'probe';
|
|
1371
|
+
const code = boundedMachineCodeFromError(err) ?? (isProbe ? 'PROVIDER_PROBE_FAILED' : 'PROVIDER_PLAN_INVALID');
|
|
1372
|
+
const status = code === 'PROVIDER_DELETE_RESTART_SUPERVISOR_UNAVAILABLE' || code.startsWith('PROVIDER_PROBE') ? 503
|
|
1373
|
+
: code.startsWith('PROVIDER_') || code.startsWith('CREDENTIAL_') ? 409 : 400;
|
|
1374
|
+
return sendJson(res, status, { ok: false, code });
|
|
1375
|
+
}
|
|
1376
|
+
},
|
|
1377
|
+
}));
|
|
1378
|
+
|
|
1379
|
+
disposers.push(webServer.register({
|
|
1380
|
+
kind: 'exact', path: `${ROUTE_BASE}/provider-health`,
|
|
1381
|
+
handler: async (req, res) => {
|
|
1382
|
+
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
|
|
1383
|
+
if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'GET only' }, { allow: 'GET' });
|
|
1384
|
+
return sendJson(res, 200, {
|
|
1385
|
+
ok: true,
|
|
1386
|
+
schema_version: 1,
|
|
1387
|
+
health: hub.healthStore.list(),
|
|
1388
|
+
runtime: getHubRuntimeIdentity(),
|
|
1389
|
+
});
|
|
1390
|
+
},
|
|
1391
|
+
}));
|
|
1392
|
+
|
|
1393
|
+
disposers.push(webServer.register({
|
|
1394
|
+
kind: 'exact', path: `${ROUTE_BASE}/models`,
|
|
986
1395
|
handler: async (req, res) => {
|
|
987
1396
|
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
|
|
988
1397
|
if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'GET or POST' }, { allow: 'GET, POST' });
|
|
@@ -1058,8 +1467,8 @@ export async function apply(ctx) {
|
|
|
1058
1467
|
// Cache-busted import: the installer must always run the code
|
|
1059
1468
|
// currently on disk, not whatever this process first loaded —
|
|
1060
1469
|
// 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()}`);
|
|
1470
|
+
const { installClaudeCode, installCodex, installHudSegment, uninstallClaudeCode, uninstallCodex, installZCode, uninstallZCode } =
|
|
1471
|
+
await import(`../install/install.mjs?t=${Date.now()}`);
|
|
1063
1472
|
if (target === 'claude') {
|
|
1064
1473
|
const base = await installClaudeCode({ statusline: !!statusline });
|
|
1065
1474
|
const hud = installHudSegment({});
|
|
@@ -1068,12 +1477,12 @@ export async function apply(ctx) {
|
|
|
1068
1477
|
actions: [...base.actions, ...hud.actions.map((a) => `hud: ${a}`)],
|
|
1069
1478
|
});
|
|
1070
1479
|
}
|
|
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' });
|
|
1480
|
+
if (target === 'codex') return sendJson(res, 200, installCodex({}));
|
|
1481
|
+
if (target === 'zcode') return sendJson(res, 200, installZCode({}));
|
|
1482
|
+
if (target === 'claude-uninstall') return sendJson(res, 200, uninstallClaudeCode({}));
|
|
1483
|
+
if (target === 'codex-uninstall') return sendJson(res, 200, uninstallCodex({}));
|
|
1484
|
+
if (target === 'zcode-uninstall') return sendJson(res, 200, uninstallZCode({}));
|
|
1485
|
+
return sendJson(res, 400, { ok: false, error: 'target must be claude | codex | zcode | claude-uninstall | codex-uninstall | zcode-uninstall' });
|
|
1077
1486
|
} catch (err) {
|
|
1078
1487
|
return sendJson(res, 500, { ok: false, error: err?.message ?? String(err) });
|
|
1079
1488
|
}
|