@ran-sh/dsh-crew 0.3.8 → 0.4.1
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 +17 -1
- package/README.zh.md +16 -1
- package/docs/gpt-relay-extension.md +103 -0
- package/docs/job-contracts.md +107 -0
- package/docs/readiness-matrix.md +73 -0
- package/official-web-bridge/lib/client.js +3446 -3446
- package/package.json +4 -1
- package/scripts/verify-official-bridge-e2e.mjs +34 -13
- package/src/extension-contract.mjs +78 -0
- package/src/failure-classification.mjs +29 -0
- package/src/hub/index.mjs +376 -73
- package/src/information-flow.mjs +67 -0
- package/src/install/npx-lifecycle.mjs +83 -3
- package/src/job-contracts.mjs +228 -0
- package/src/mcp-runtime.mjs +16 -8
- package/src/official-web-bridge.mjs +20 -5
- package/src/role-profiles.mjs +107 -0
- package/src/runtime-identity.mjs +6 -1
- package/src/server.mjs +141 -98
- package/src/workflow-runtime.mjs +109 -10
- package/src/workspace-context.mjs +146 -0
- package/src/workspace-readiness.mjs +32 -0
package/src/hub/index.mjs
CHANGED
|
@@ -21,7 +21,14 @@ import { readHarnessModelCatalog } from '../model-catalog.mjs';
|
|
|
21
21
|
import { appendDeliveryInstructions, parseDeliveryReport, formatDeliveryMetadata } from '../delivery.mjs';
|
|
22
22
|
import { captureWorkspaceBaseline, captureWorkspaceDiff, NOT_A_GIT_REPOSITORY } from '../workspace-audit.mjs';
|
|
23
23
|
import { buildOutcome, JOB_PHASES } from '../workflow.mjs';
|
|
24
|
-
import { boundedMachineCodeFromError } from '../structured-error-code.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';
|
|
25
32
|
|
|
26
33
|
// policy.mjs is pure (no @deepseek-ai imports, no ctx access), so importing it
|
|
27
34
|
// here is safe for the profile-realm discipline: it never pulls in package
|
|
@@ -82,7 +89,35 @@ const LEGACY_TIER_MODELS = { flash: 'deepseek-v4-flash', pro: 'deepseek-v4-pro'
|
|
|
82
89
|
// Local copy (the hub must not import jobs.mjs, which pulls the DSH SDK into
|
|
83
90
|
// the profile realm): a valid dispatch role set.
|
|
84
91
|
const ROLES = { worker: true, reviewer: true };
|
|
85
|
-
const CONFIG_DIR = join(homedir(), '.config', 'dsh-crew');
|
|
92
|
+
const CONFIG_DIR = join(homedir(), '.config', 'dsh-crew');
|
|
93
|
+
|
|
94
|
+
export function hubCanonicalEvents(job = {}) {
|
|
95
|
+
if (!job.id) return [];
|
|
96
|
+
const role = job.role === 'reviewer' ? 'reviewer' : 'worker';
|
|
97
|
+
const atStart = job.startedAt ?? null;
|
|
98
|
+
const atEnd = job.endedAt ?? atStart;
|
|
99
|
+
const definitions = [
|
|
100
|
+
['job.created', atStart, { client_job_id: job.client_job_id ?? null }],
|
|
101
|
+
['job.started', atStart, {}],
|
|
102
|
+
['model.selected', atStart, { provider: job.provider ?? null, model: job.model ?? null, source: job.selection_source ?? null }],
|
|
103
|
+
[role === 'reviewer' ? 'review.started' : 'worker.started', atStart, { run_id: job.id }],
|
|
104
|
+
];
|
|
105
|
+
if (job.status !== 'running') {
|
|
106
|
+
definitions.push([
|
|
107
|
+
role === 'reviewer' ? 'review.completed' : 'worker.completed',
|
|
108
|
+
atEnd,
|
|
109
|
+
role === 'reviewer'
|
|
110
|
+
? { status: job.status ?? null, verdict: job.review?.verdict ?? null }
|
|
111
|
+
: { status: job.status ?? null, summary: job.outcome?.task_status ?? null },
|
|
112
|
+
]);
|
|
113
|
+
if (job.status === 'done') definitions.push(['job.completed', atEnd, { result_ref: job.id }]);
|
|
114
|
+
else if (job.status === 'cancelled') definitions.push(['job.cancelled', atEnd, { reason: 'cancelled' }]);
|
|
115
|
+
else definitions.push(['job.failed', atEnd, { error_code: job.error_code ?? null }]);
|
|
116
|
+
}
|
|
117
|
+
return definitions.map(([type, at, data], index) => createCanonicalJobEvent({
|
|
118
|
+
jobId: job.id, type, sequence: index + 1, at, role, attempt: job.attempt ?? 0, data,
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
86
121
|
|
|
87
122
|
// ---------- job registry ----------
|
|
88
123
|
|
|
@@ -98,7 +133,7 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
98
133
|
|
|
99
134
|
view(job, withResult = false) {
|
|
100
135
|
const v = {
|
|
101
|
-
id: job.id, sessionId: job.sessionId, role: job.role ?? 'worker', attempt: job.attempt ?? 0,
|
|
136
|
+
id: job.id, client_job_id: job.client_job_id ?? null, sessionId: job.sessionId, role: job.role ?? 'worker', attempt: job.attempt ?? 0,
|
|
102
137
|
tier: job.tier, provider: job.provider, model: job.model,
|
|
103
138
|
selection_source: job.selection_source,
|
|
104
139
|
selection_trace: job.selection_trace ?? null,
|
|
@@ -107,19 +142,27 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
107
142
|
cwd: job.cwd, turn: job.turn, step: job.step, currentTool: job.currentTool,
|
|
108
143
|
phase: job.phase ?? null,
|
|
109
144
|
toolCalls: job.toolCalls, tokens: job.tokens, mode: 'hub',
|
|
110
|
-
startedAt: job.startedAt, endedAt: job.endedAt,
|
|
111
|
-
|
|
112
|
-
|
|
145
|
+
startedAt: job.startedAt, endedAt: job.endedAt,
|
|
146
|
+
isolation: job.isolation ?? 'shared', workspace_branch: job.workspace_branch ?? null,
|
|
147
|
+
delivery_complete: !!job.delivery_complete,
|
|
148
|
+
workspace_diff_available: !!job.workspaceDiff && job.workspaceDiff.kind === 'git',
|
|
149
|
+
workspace_retained: job.workspace_retained === true,
|
|
150
|
+
cleanup_warning: job.cleanup_warning ?? null,
|
|
151
|
+
profile_id: job.profile_id ?? null,
|
|
152
|
+
workspace_context: job.workspace_context ?? null,
|
|
153
|
+
event_cursor: hubCanonicalEvents(job).at(-1)?.sequence ?? 0,
|
|
113
154
|
};
|
|
114
155
|
if (withResult) {
|
|
115
156
|
v.result = job.result; v.error = job.error; v.stopReason = job.stopReason;
|
|
116
157
|
v.reasonDetail = job.reasonDetail;
|
|
117
158
|
v.delivery = job.delivery_metadata ?? null;
|
|
118
|
-
v.delivery_missing = job.delivery_missing ?? [];
|
|
119
|
-
v.outcome = job.outcome ?? null;
|
|
159
|
+
v.delivery_missing = job.delivery_missing ?? [];
|
|
160
|
+
v.outcome = job.outcome ?? null;
|
|
161
|
+
v.review = job.review ?? null;
|
|
120
162
|
v.workspace_diff = job.workspaceDiff ?? null;
|
|
121
|
-
v.workspace_baseline_dirty = !!job.workspaceDiff?.dirtyBaseline;
|
|
122
|
-
|
|
163
|
+
v.workspace_baseline_dirty = !!job.workspaceDiff?.dirtyBaseline;
|
|
164
|
+
v.canonical_events = hubCanonicalEvents(job);
|
|
165
|
+
}
|
|
123
166
|
return v;
|
|
124
167
|
}
|
|
125
168
|
|
|
@@ -135,7 +178,7 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
135
178
|
* `role` (worker | reviewer) records who does the work; `tier` remains the
|
|
136
179
|
* legacy model-class slot. Reviewer-role jobs always use the pro slot.
|
|
137
180
|
*/
|
|
138
|
-
async spawn({ task, tier = 'flash', role, attempt = 0, effort = 'max', cwd, source = 'api', preset, delivery = 'coding' }) {
|
|
181
|
+
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 }) {
|
|
139
182
|
// role is only honored when the caller explicitly names it; a legacy
|
|
140
183
|
// tier-only spawn (role === undefined) keeps the exact v0.1 resolution.
|
|
141
184
|
const hasRole = role === 'worker' || role === 'reviewer';
|
|
@@ -228,27 +271,44 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
228
271
|
const jobRole = hasRole ? role : (delivery === 'review' || role === 'reviewer' ? 'reviewer' : 'worker');
|
|
229
272
|
const workerPrompt = appendDeliveryInstructions(task, { tier: effTier, role: jobRole, isReview: delivery === 'review' || jobRole === 'reviewer' });
|
|
230
273
|
|
|
231
|
-
const id = `hub-${this.nextId++}-${Date.now().toString(36)}`;
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
274
|
+
const id = `hub-${this.nextId++}-${Date.now().toString(36)}`;
|
|
275
|
+
let executionCwd = cwd;
|
|
276
|
+
let isolatedWorkspace = null;
|
|
277
|
+
if ((requested_isolation === 'worktree' && jobRole === 'worker') || requested_isolation === 'readonly') {
|
|
278
|
+
const created = await createIsolatedWorkspace({ cwd, jobId: id, baseRevision: workspace_branch });
|
|
279
|
+
if (!created.ok) throw Object.assign(new Error(created.error ?? created.reason), { code: created.reason });
|
|
280
|
+
executionCwd = created.worktreePath;
|
|
281
|
+
isolatedWorkspace = { worktreePath: created.worktreePath, repoRoot: created.repoRoot };
|
|
282
|
+
}
|
|
283
|
+
const sessionId = `session-${randomUUID()}`;
|
|
284
|
+
const job = {
|
|
285
|
+
id, client_job_id: client_job_id ?? null, sessionId, role: jobRole, attempt, tier: effTier, provider: selection.provider, model: selection.model,
|
|
235
286
|
selection_source: selection.source, selection_trace: selection.selection_trace ?? null,
|
|
236
287
|
effort, reasoning_effort: selection.reasoningEffort,
|
|
237
|
-
task, source, cwd,
|
|
288
|
+
task, source, cwd: executionCwd, requested_cwd: cwd,
|
|
289
|
+
isolation: isolatedWorkspace ? 'worktree' : 'shared',
|
|
290
|
+
workspace_branch: workspace_branch ?? null, isolatedWorkspace,
|
|
291
|
+
profile_id: profile_id ?? null, workspace_context: workspace_context ?? null,
|
|
238
292
|
prompt: workerPrompt, delivery: delivery === 'review' ? 'review' : 'coding',
|
|
239
293
|
phase: JOB_PHASES.RUNNING,
|
|
240
294
|
status: 'running', turn: 0, step: 0, currentTool: null, toolCalls: 0,
|
|
241
295
|
tokens: { input: 0, output: 0, reasoning: 0 },
|
|
242
296
|
startedAt: new Date().toISOString(), endedAt: null,
|
|
243
297
|
result: null, error: null, stopReason: null, handle: null, waiters: [],
|
|
244
|
-
delivery_complete: false, delivery_missing: [], delivery_metadata: null,
|
|
245
|
-
outcome: null,
|
|
246
|
-
|
|
247
|
-
};
|
|
298
|
+
delivery_complete: false, delivery_missing: [], delivery_metadata: null,
|
|
299
|
+
outcome: null,
|
|
300
|
+
lastAssistantText: null, handle_dispose_promise: null, disposeHandle: null,
|
|
301
|
+
};
|
|
302
|
+
const disposeJobHandle = async () => {
|
|
303
|
+
if (!job.handle) return;
|
|
304
|
+
job.handle_dispose_promise ??= Promise.resolve().then(() => job.handle.dispose());
|
|
305
|
+
await job.handle_dispose_promise;
|
|
306
|
+
};
|
|
307
|
+
job.disposeHandle = disposeJobHandle;
|
|
248
308
|
// Read-only pre-run snapshot (async, never blocks dispatch): the audit
|
|
249
309
|
// only needs the before-state by the time the worker finishes. Non-repos
|
|
250
310
|
// degrade to { kind:'no-git' } instead of failing the job.
|
|
251
|
-
job.baseline = await captureWorkspaceBaseline({ cwd }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace audit failed' }));
|
|
311
|
+
job.baseline = await captureWorkspaceBaseline({ cwd: executionCwd }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace audit failed' }));
|
|
252
312
|
this.jobs.set(id, job);
|
|
253
313
|
|
|
254
314
|
const presets = this.ctx.get('agentPresets');
|
|
@@ -272,9 +332,9 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
272
332
|
job.tokens.output += u.outputTokens ?? 0;
|
|
273
333
|
job.tokens.reasoning += u.reasoningTokens ?? 0;
|
|
274
334
|
}
|
|
275
|
-
const text = (event.data?.message?.content ?? [])
|
|
276
|
-
.filter((c) => c?.type === 'text').map((c) => c.text).join('');
|
|
277
|
-
if (text) job.
|
|
335
|
+
const text = (event.data?.message?.content ?? [])
|
|
336
|
+
.filter((c) => c?.type === 'text').map((c) => c.text).join('');
|
|
337
|
+
if (text) job.lastAssistantText = text;
|
|
278
338
|
break;
|
|
279
339
|
}
|
|
280
340
|
case 'turn/end':
|
|
@@ -288,7 +348,7 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
288
348
|
const run = async () => {
|
|
289
349
|
const handle = await this.ctx.agents.create({
|
|
290
350
|
sessionId,
|
|
291
|
-
meta: { cwd, ...(presetId === undefined ? {} : { agentPreset: presetId }) },
|
|
351
|
+
meta: { cwd: executionCwd, ...(presetId === undefined ? {} : { agentPreset: presetId }) },
|
|
292
352
|
agentOptions: { provider: selection.provider, model: selection.model },
|
|
293
353
|
setup: async (agentCtx) => {
|
|
294
354
|
installModelSelection(agentCtx, { current: selection, assembled: undefined });
|
|
@@ -307,29 +367,51 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
307
367
|
// job cwd never inherits a parent directory's workspace).
|
|
308
368
|
const registry = this.ctx.get('workspaceRegistry');
|
|
309
369
|
if (registry !== undefined) {
|
|
310
|
-
const ws = (await registry.resolveByPath(
|
|
370
|
+
const ws = (await registry.resolveByPath(executionCwd)) ?? (await registry.create(executionCwd));
|
|
311
371
|
await ws.attachSession(sessionId);
|
|
312
372
|
}
|
|
313
373
|
} catch (err) {
|
|
314
|
-
this.ctx.logger?.warn?.(`dsh-crew: workspace attach failed for ${
|
|
374
|
+
this.ctx.logger?.warn?.(`dsh-crew: workspace attach failed for ${executionCwd}: ${err?.message ?? err}`);
|
|
315
375
|
}
|
|
316
376
|
await handle.agent.whenIdle();
|
|
317
377
|
handle.agent.followup(userMessage(job.prompt));
|
|
318
378
|
await handle.agent.whenIdle();
|
|
319
|
-
job.result = job.
|
|
320
|
-
job.status = job.stopReason === 'completed' ? 'done' : 'failed';
|
|
379
|
+
job.result = job.lastAssistantText ?? '';
|
|
380
|
+
if (job.status === 'running') job.status = job.stopReason === 'completed' ? 'done' : 'failed';
|
|
321
381
|
if (job.status === 'failed' && !job.error) job.error = `turn ended: ${job.stopReason ?? 'unknown'}`;
|
|
322
382
|
await this.ctx.sessions.flush(handle.agent.session);
|
|
323
383
|
};
|
|
324
384
|
|
|
325
|
-
|
|
385
|
+
const timeoutMs = Number.isInteger(timeout_seconds) && timeout_seconds > 0 ? timeout_seconds * 1000 : null;
|
|
386
|
+
if (timeoutMs) {
|
|
387
|
+
job.timeoutHandle = setTimeout(async () => {
|
|
388
|
+
if (job.status !== 'running') return;
|
|
389
|
+
job.status = 'failed';
|
|
390
|
+
job.error = `job timed out after ${timeout_seconds}s`;
|
|
391
|
+
job.error_code = 'JOB_TIMEOUT';
|
|
392
|
+
job.endedAt = new Date().toISOString();
|
|
393
|
+
this.publish();
|
|
394
|
+
for (const waiter of job.waiters.splice(0)) waiter();
|
|
395
|
+
try { await disposeJobHandle(); } catch (error) {
|
|
396
|
+
job.cleanup_warning = `agent cleanup failed: ${error?.message ?? String(error)}`;
|
|
397
|
+
this.publish();
|
|
398
|
+
}
|
|
399
|
+
}, timeoutMs);
|
|
400
|
+
job.timeoutHandle.unref?.();
|
|
401
|
+
}
|
|
402
|
+
job.promise = run()
|
|
326
403
|
.catch((err) => {
|
|
327
404
|
if (job.status === 'running') job.status = 'failed';
|
|
328
405
|
job.error = job.error ?? (err?.message ?? String(err));
|
|
329
|
-
})
|
|
330
|
-
.finally(async () => {
|
|
331
|
-
job.
|
|
332
|
-
job.
|
|
406
|
+
})
|
|
407
|
+
.finally(async () => {
|
|
408
|
+
if (job.timeoutHandle) clearTimeout(job.timeoutHandle);
|
|
409
|
+
job.endedAt = new Date().toISOString();
|
|
410
|
+
job.currentTool = null;
|
|
411
|
+
let handleCleanupWarning = job.cleanup_warning ?? null;
|
|
412
|
+
try { await disposeJobHandle(); } catch (error) {
|
|
413
|
+
handleCleanupWarning = `agent cleanup failed: ${error?.message ?? String(error)}`;
|
|
414
|
+
}
|
|
333
415
|
// Delivery completeness is separate from execution status: a job can
|
|
334
416
|
// be done yet fail to report Diff/Tests/Risks (or Review sections for
|
|
335
417
|
// an automatic review). Parse whatever final message the worker
|
|
@@ -337,7 +419,21 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
337
419
|
const parsed = parseDeliveryReport(job.result ?? '');
|
|
338
420
|
job.delivery_complete = parsed.complete;
|
|
339
421
|
job.delivery_missing = parsed.missing;
|
|
340
|
-
job.delivery_metadata = formatDeliveryMetadata(parsed);
|
|
422
|
+
job.delivery_metadata = formatDeliveryMetadata(parsed);
|
|
423
|
+
if (job.role === 'reviewer') {
|
|
424
|
+
const verdictText = String(parsed.sections?.Verdict ?? '').trim().toLowerCase();
|
|
425
|
+
const verdict = /^(approve|approved|pass)\b/.test(verdictText)
|
|
426
|
+
? 'approve'
|
|
427
|
+
: /request.*chang|chang.*request|reject|needs changes/i.test(verdictText)
|
|
428
|
+
? 'request_changes'
|
|
429
|
+
: 'inconclusive';
|
|
430
|
+
const lines = (value) => String(value ?? '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean).slice(0, 40);
|
|
431
|
+
job.review = {
|
|
432
|
+
verdict, status: job.status, delivery_complete: parsed.complete === true,
|
|
433
|
+
findings: lines(parsed.sections?.['Review Findings']),
|
|
434
|
+
evidence: lines(parsed.sections?.Evidence), risks: lines(parsed.sections?.Risks),
|
|
435
|
+
};
|
|
436
|
+
}
|
|
341
437
|
// Canonical structured outcome (shared workflow layer) + terminal phase.
|
|
342
438
|
job.outcome = buildOutcome({
|
|
343
439
|
result: job.result ?? '',
|
|
@@ -348,9 +444,26 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
348
444
|
});
|
|
349
445
|
job.phase = job.status === 'done' ? JOB_PHASES.COMPLETED : job.status === 'cancelled' ? JOB_PHASES.CANCELLED : JOB_PHASES.FAILED;
|
|
350
446
|
// Read-only after-snapshot of the workspace: bounded, redacted patch.
|
|
351
|
-
job.workspaceDiff = job.baseline.kind === 'git'
|
|
352
|
-
? await captureWorkspaceDiff({ cwd, baseline: job.baseline }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace diff failed' }))
|
|
353
|
-
: job.baseline;
|
|
447
|
+
job.workspaceDiff = job.baseline.kind === 'git'
|
|
448
|
+
? await captureWorkspaceDiff({ cwd: executionCwd, baseline: job.baseline }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace diff failed' }))
|
|
449
|
+
: job.baseline;
|
|
450
|
+
if (job.role === 'reviewer' && job.review && job.workspaceDiff?.kind === 'git') {
|
|
451
|
+
const changes = job.workspaceDiff.changes ?? {};
|
|
452
|
+
const mutated = ['modified', 'deleted', 'renamed', 'untracked'].some((key) => Array.isArray(changes[key]) && changes[key].length > 0);
|
|
453
|
+
if (mutated) {
|
|
454
|
+
job.review.mutated_candidate = true;
|
|
455
|
+
job.review.invalidated = true;
|
|
456
|
+
job.review.verdict = 'request_changes';
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (job.isolatedWorkspace) {
|
|
460
|
+
const cleanup = await cleanupIsolatedWorkspace(job.isolatedWorkspace).catch((error) => ({ ok: false, error: error?.message ?? String(error) }));
|
|
461
|
+
job.workspace_retained = cleanup.ok !== true;
|
|
462
|
+
const workspaceCleanupWarning = cleanup.ok === true ? null : cleanup.error ?? 'worktree cleanup failed';
|
|
463
|
+
job.cleanup_warning = [handleCleanupWarning, workspaceCleanupWarning].filter(Boolean).join('; ') || null;
|
|
464
|
+
} else {
|
|
465
|
+
job.cleanup_warning = handleCleanupWarning;
|
|
466
|
+
}
|
|
354
467
|
this.publish();
|
|
355
468
|
for (const w of job.waiters.splice(0)) w();
|
|
356
469
|
});
|
|
@@ -370,24 +483,30 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
370
483
|
return job;
|
|
371
484
|
}
|
|
372
485
|
|
|
373
|
-
async cancel(id) {
|
|
486
|
+
async cancel(id) {
|
|
374
487
|
const job = this.jobs.get(id);
|
|
375
488
|
if (!job) return undefined;
|
|
376
489
|
if (job.status === 'running') {
|
|
377
490
|
job.status = 'cancelled';
|
|
378
491
|
job.phase = JOB_PHASES.CANCELLED;
|
|
379
492
|
job.error = 'cancelled by request';
|
|
380
|
-
try { await job.
|
|
493
|
+
try { await job.disposeHandle?.(); } catch (error) {
|
|
494
|
+
job.cleanup_warning = `agent cleanup failed: ${error?.message ?? String(error)}`;
|
|
495
|
+
}
|
|
381
496
|
this.publish();
|
|
382
497
|
}
|
|
383
498
|
return job;
|
|
384
499
|
}
|
|
385
500
|
|
|
386
|
-
async dispose() {
|
|
387
|
-
for (const job of this.jobs.values()) {
|
|
388
|
-
if (job.status === 'running') {
|
|
389
|
-
|
|
390
|
-
|
|
501
|
+
async dispose() {
|
|
502
|
+
for (const job of this.jobs.values()) {
|
|
503
|
+
if (job.status === 'running') {
|
|
504
|
+
try { await job.disposeHandle?.(); } catch (error) {
|
|
505
|
+
job.cleanup_warning = `agent cleanup failed: ${error?.message ?? String(error)}`;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
391
510
|
}
|
|
392
511
|
|
|
393
512
|
// ---------- loopback route helpers (pattern from dsh-noema) ----------
|
|
@@ -400,11 +519,23 @@ function isLoopbackAddress(address) {
|
|
|
400
519
|
if (!n.startsWith('::ffff:')) return false;
|
|
401
520
|
return isIpv4Loopback(n.slice(7));
|
|
402
521
|
}
|
|
403
|
-
function isLoopbackRequest(req) {
|
|
404
|
-
if (!isLoopbackAddress(req.socket?.remoteAddress)) return false;
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
522
|
+
export function isLoopbackRequest(req) {
|
|
523
|
+
if (!isLoopbackAddress(req.socket?.remoteAddress)) return false;
|
|
524
|
+
let host;
|
|
525
|
+
try { host = new URL(`http://${String(req.headers.host ?? '')}`).hostname.toLowerCase(); } catch { return false; }
|
|
526
|
+
if (!(host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || isIpv4Loopback(host))) return false;
|
|
527
|
+
const fetchSite = String(req.headers?.['sec-fetch-site'] ?? '').toLowerCase();
|
|
528
|
+
if (fetchSite && fetchSite !== 'same-origin' && fetchSite !== 'none') return false;
|
|
529
|
+
const origin = req.headers?.origin;
|
|
530
|
+
if (origin !== undefined) {
|
|
531
|
+
if (typeof origin !== 'string') return false;
|
|
532
|
+
let parsed;
|
|
533
|
+
try { parsed = new URL(origin); } catch { return false; }
|
|
534
|
+
const originHost = parsed.hostname.toLowerCase();
|
|
535
|
+
if (!(originHost === 'localhost' || originHost === '127.0.0.1' || originHost === '::1' || originHost === '[::1]' || isIpv4Loopback(originHost))) return false;
|
|
536
|
+
}
|
|
537
|
+
return true;
|
|
538
|
+
}
|
|
408
539
|
function sendJson(res, status, value, headers = {}) {
|
|
409
540
|
const body = JSON.stringify(value);
|
|
410
541
|
res.writeHead(status, {
|
|
@@ -446,22 +577,92 @@ async function readBody(req, limit = 64 * 1024) {
|
|
|
446
577
|
* Returns { ok: true, payload } (payload.tier = effective tier) or
|
|
447
578
|
* { ok: false, code, error }.
|
|
448
579
|
*/
|
|
449
|
-
export function resolveHubSpawnPayload(payload, getConfig = () => ({})) {
|
|
450
|
-
const config = normalizeGlobalConfig(getConfig());
|
|
451
|
-
const raw = payload ?? {};
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
if (
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
}
|
|
580
|
+
export function resolveHubSpawnPayload(payload, getConfig = () => ({}), dependencies = {}) {
|
|
581
|
+
const config = normalizeGlobalConfig(getConfig());
|
|
582
|
+
const raw = payload ?? {};
|
|
583
|
+
const advanced = raw.profile !== undefined || raw.workspace_id !== undefined || raw.workspace !== undefined
|
|
584
|
+
|| raw.constraints !== undefined || raw.context_refs !== undefined || raw.job_id !== undefined || raw.objective !== undefined;
|
|
585
|
+
let normalized = { ...raw };
|
|
586
|
+
if (advanced) {
|
|
587
|
+
if (raw.workspace !== undefined && (!raw.workspace || typeof raw.workspace !== 'object' || Array.isArray(raw.workspace))) {
|
|
588
|
+
return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'workspace must be an object' };
|
|
589
|
+
}
|
|
590
|
+
if (raw.constraints !== undefined && (!raw.constraints || typeof raw.constraints !== 'object' || Array.isArray(raw.constraints))) {
|
|
591
|
+
return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'constraints must be an object' };
|
|
592
|
+
}
|
|
593
|
+
if (raw.workspace?.repo_root !== undefined && typeof raw.workspace.repo_root !== 'string') {
|
|
594
|
+
return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'workspace repo_root must be a string' };
|
|
595
|
+
}
|
|
596
|
+
if (raw.workspace?.worktree !== undefined && !['auto', 'existing', 'none'].includes(raw.workspace.worktree)) {
|
|
597
|
+
return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'invalid worktree policy' };
|
|
598
|
+
}
|
|
599
|
+
if (raw.workspace?.branch !== undefined && !isSafeBranchName(raw.workspace.branch)) {
|
|
600
|
+
return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'invalid workspace branch' };
|
|
601
|
+
}
|
|
602
|
+
if (raw.constraints?.timeout_seconds !== undefined && (!Number.isInteger(raw.constraints.timeout_seconds) || raw.constraints.timeout_seconds < 1 || raw.constraints.timeout_seconds > 7200)) {
|
|
603
|
+
return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'invalid timeout_seconds' };
|
|
604
|
+
}
|
|
605
|
+
if (raw.constraints?.allow_fallback !== undefined && typeof raw.constraints.allow_fallback !== 'boolean') {
|
|
606
|
+
return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'allow_fallback must be boolean' };
|
|
607
|
+
}
|
|
608
|
+
const profileRegistry = dependencies.profileRegistry ?? loadRoleProfiles();
|
|
609
|
+
const workspaceRegistry = dependencies.workspaceRegistry ?? loadWorkspaceContexts();
|
|
610
|
+
if (!profileRegistry.ok) return { ok: false, code: 'PROFILE_FILE_INVALID', error: 'role profile registry is invalid' };
|
|
611
|
+
if (!workspaceRegistry.ok) return { ok: false, code: 'WORKSPACE_CONTEXT_FILE_INVALID', error: 'workspace registry is invalid' };
|
|
612
|
+
const profileRole = raw.profile ? profileRegistry.profiles?.[raw.profile]?.role : undefined;
|
|
613
|
+
const requestedRole = raw.role ?? profileRole ?? 'worker';
|
|
614
|
+
const resolvedProfile = resolveRoleProfile(profileRegistry, raw.profile, requestedRole);
|
|
615
|
+
if (!resolvedProfile.ok) return { ok: false, code: resolvedProfile.code, error: resolvedProfile.code };
|
|
616
|
+
const knownContext = raw.workspace_id ? workspaceRegistry.contexts?.[raw.workspace_id] : null;
|
|
617
|
+
const cwd = raw.workspace?.repo_root ?? raw.cwd ?? knownContext?.repo_root;
|
|
618
|
+
if (!cwd) return { ok: false, code: 'WORKSPACE_CONTEXT_NOT_FOUND', error: 'workspace repo_root or cwd is required' };
|
|
619
|
+
const workspace = resolveWorkspaceContext(workspaceRegistry, { workspace_id: raw.workspace_id, cwd });
|
|
620
|
+
if (!workspace.ok) return { ok: false, code: workspace.code, error: workspace.code };
|
|
621
|
+
const withRefs = addContextReferences(workspace.context, raw.context_refs, { cwd });
|
|
622
|
+
if (!withRefs.ok) return { ok: false, code: withRefs.code, error: withRefs.code };
|
|
623
|
+
const profile = resolvedProfile.profile;
|
|
624
|
+
const worktree = raw.workspace?.worktree;
|
|
625
|
+
const requestedIsolation = worktree === 'auto' ? 'worktree'
|
|
626
|
+
: worktree === 'existing' || worktree === 'none' ? 'shared'
|
|
627
|
+
: profile.isolation;
|
|
628
|
+
const objective = raw.objective ?? raw.task;
|
|
629
|
+
if (typeof objective !== 'string' || objective.trim() === '') return { ok: false, code: 'JOB_OBJECTIVE_REQUIRED', error: 'task or objective is required' };
|
|
630
|
+
if (raw.job_id !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(raw.job_id)) {
|
|
631
|
+
return { ok: false, code: 'JOB_ID_INVALID', error: 'invalid client job id' };
|
|
632
|
+
}
|
|
633
|
+
const contextTask = buildWorkspaceTask(objective, withRefs.context);
|
|
634
|
+
normalized = {
|
|
635
|
+
...raw,
|
|
636
|
+
task: profile.review_strictness === 'strict' && requestedRole === 'reviewer'
|
|
637
|
+
? `${contextTask}\n\nSTRICT REVIEW: fail closed on missing direct code or test evidence.`
|
|
638
|
+
: contextTask,
|
|
639
|
+
cwd,
|
|
640
|
+
role: requestedRole,
|
|
641
|
+
client_job_id: raw.job_id ?? null,
|
|
642
|
+
requested_isolation: requestedIsolation,
|
|
643
|
+
workspace_branch: raw.workspace?.branch ?? withRefs.context?.default_branch ?? null,
|
|
644
|
+
timeout_seconds: raw.constraints?.timeout_seconds ?? profile.timeout_seconds,
|
|
645
|
+
allow_fallback: raw.constraints?.allow_fallback ?? profile.fallback,
|
|
646
|
+
routing: profile.routing,
|
|
647
|
+
review_strictness: profile.review_strictness,
|
|
648
|
+
profile_id: resolvedProfile.profile_id,
|
|
649
|
+
workspace_context: withRefs.context,
|
|
650
|
+
};
|
|
651
|
+
for (const key of ['objective', 'job_id', 'profile', 'workspace_id', 'workspace', 'constraints', 'context_refs']) delete normalized[key];
|
|
652
|
+
}
|
|
653
|
+
// v0.2 role-based dispatch: reviewer / worker are gated by their role state,
|
|
654
|
+
// and the tier slot is derived from the role (reviewer always → pro).
|
|
655
|
+
if (normalized.role === 'worker' || normalized.role === 'reviewer') {
|
|
656
|
+
const hint = resolveRoleTierHint(normalized.role, normalized.tier);
|
|
657
|
+
if (!hint.ok) return { ok: false, code: hint.code, error: hint.error };
|
|
658
|
+
const decision = canDispatchRole(config, normalized.role, true, {});
|
|
659
|
+
if (!decision.ok) return { ok: false, code: decision.error.policyCode, error: decision.error.message };
|
|
660
|
+
return { ok: true, payload: { ...normalized, role: normalized.role, tier: hint.tier } };
|
|
661
|
+
}
|
|
662
|
+
const decision = chooseDefaultTier(config, normalized.tier, {});
|
|
663
|
+
if (!decision.ok) return { ok: false, code: decision.error.policyCode, error: decision.error.message };
|
|
664
|
+
return { ok: true, payload: { ...normalized, tier: decision.tier } };
|
|
665
|
+
}
|
|
465
666
|
|
|
466
667
|
// ---------- plugin entry ----------
|
|
467
668
|
|
|
@@ -535,12 +736,29 @@ export async function apply(ctx) {
|
|
|
535
736
|
const foreign = readMergedStatus({ excludeWriter: hub.shard.writer });
|
|
536
737
|
return sendJson(res, 200, { ok: true, jobs: [...own, ...foreign] });
|
|
537
738
|
}
|
|
538
|
-
if (req.method === 'GET' && parts.length === 1) {
|
|
739
|
+
if (req.method === 'GET' && parts.length === 1) {
|
|
539
740
|
const wait = Number(url.searchParams.get('wait') ?? 0);
|
|
540
741
|
const job = await hub.wait(parts[0], Math.min(wait, 600) * 1000);
|
|
541
742
|
if (!job) return sendJson(res, 404, { ok: false, error: 'no such job' });
|
|
542
|
-
return sendJson(res, 200, { ok: true, job: hub.view(job, true) });
|
|
543
|
-
}
|
|
743
|
+
return sendJson(res, 200, { ok: true, job: hub.view(job, true) });
|
|
744
|
+
}
|
|
745
|
+
if (req.method === 'GET' && parts.length === 2 && parts[1] === 'events') {
|
|
746
|
+
const job = hub.jobs.get(parts[0]);
|
|
747
|
+
if (!job) return sendJson(res, 404, { ok: false, error: 'no such job' });
|
|
748
|
+
const after = Math.max(0, Number(url.searchParams.get('after') ?? 0) || 0);
|
|
749
|
+
const events = hubCanonicalEvents(job).filter((event) => event.sequence > after);
|
|
750
|
+
return sendJson(res, 200, {
|
|
751
|
+
ok: true, job_id: job.id, events,
|
|
752
|
+
event_cursor: hubCanonicalEvents(job).at(-1)?.sequence ?? 0,
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
if (req.method === 'GET' && parts.length === 2 && parts[1] === 'contract') {
|
|
756
|
+
const job = hub.jobs.get(parts[0]);
|
|
757
|
+
if (!job) return sendJson(res, 404, { ok: false, error: 'no such job' });
|
|
758
|
+
const detail = url.searchParams.get('detail') === 'full' ? 'full' : 'compact';
|
|
759
|
+
const afterSequence = Math.max(0, Number(url.searchParams.get('after') ?? 0) || 0);
|
|
760
|
+
return sendJson(res, 200, { ok: true, job: projectWorkflowView(hub.view(job, true), { detail, afterSequence }) });
|
|
761
|
+
}
|
|
544
762
|
if (req.method === 'POST' && parts.length === 0) {
|
|
545
763
|
const payload = await readBody(req);
|
|
546
764
|
// Same policy resolver as the MCP server (src/server.mjs), with the
|
|
@@ -564,10 +782,95 @@ export async function apply(ctx) {
|
|
|
564
782
|
if (code) body.code = code;
|
|
565
783
|
return sendJson(res, 400, body);
|
|
566
784
|
}
|
|
567
|
-
},
|
|
568
|
-
}));
|
|
569
|
-
|
|
570
|
-
disposers.push(webServer.register({
|
|
785
|
+
},
|
|
786
|
+
}));
|
|
787
|
+
|
|
788
|
+
disposers.push(webServer.register({
|
|
789
|
+
kind: 'exact', path: `${ROUTE_BASE}/profiles`,
|
|
790
|
+
handler: async (req, res) => {
|
|
791
|
+
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
|
|
792
|
+
if (req.method === 'GET') return sendJson(res, 200, { ok: true, ...loadRoleProfiles() });
|
|
793
|
+
if (req.method === 'POST') {
|
|
794
|
+
try {
|
|
795
|
+
const saved = saveRoleProfiles(await readBody(req));
|
|
796
|
+
return sendJson(res, saved.ok ? 200 : 400, { ...saved });
|
|
797
|
+
} catch {
|
|
798
|
+
return sendJson(res, 400, { ok: false, error: 'invalid JSON', code: 'PROFILE_FILE_INVALID' });
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
return sendJson(res, 405, { ok: false, error: 'GET or POST' }, { allow: 'GET, POST' });
|
|
802
|
+
},
|
|
803
|
+
}));
|
|
804
|
+
|
|
805
|
+
disposers.push(webServer.register({
|
|
806
|
+
kind: 'exact', path: `${ROUTE_BASE}/workspaces`,
|
|
807
|
+
handler: async (req, res) => {
|
|
808
|
+
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
|
|
809
|
+
if (req.method === 'GET') return sendJson(res, 200, { ok: true, ...loadWorkspaceContexts() });
|
|
810
|
+
if (req.method === 'POST') {
|
|
811
|
+
try {
|
|
812
|
+
const saved = saveWorkspaceContexts(await readBody(req));
|
|
813
|
+
return sendJson(res, saved.ok ? 200 : 400, { ...saved });
|
|
814
|
+
} catch {
|
|
815
|
+
return sendJson(res, 400, { ok: false, error: 'invalid JSON', code: 'WORKSPACE_CONTEXT_FILE_INVALID' });
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
return sendJson(res, 405, { ok: false, error: 'GET or POST' }, { allow: 'GET, POST' });
|
|
819
|
+
},
|
|
820
|
+
}));
|
|
821
|
+
|
|
822
|
+
disposers.push(webServer.register({
|
|
823
|
+
kind: 'exact', path: `${ROUTE_BASE}/extension`,
|
|
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, 405, { ok: false, error: 'GET only' }, { allow: 'GET' });
|
|
827
|
+
const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
|
|
828
|
+
let catalogStatus = { id: 'provider_catalog', status: 'NOT_RUN', reason_code: 'PROVIDER_MODE_UNKNOWN' };
|
|
829
|
+
if (normalizeWorkerProviderMode(config.worker_provider_mode) === 'deepseek-official') {
|
|
830
|
+
catalogStatus = { id: 'provider_catalog', status: 'SKIP', reason_code: 'PROVIDER_CATALOG_NOT_REQUIRED' };
|
|
831
|
+
} else {
|
|
832
|
+
try {
|
|
833
|
+
await readHarnessModelCatalog({
|
|
834
|
+
llm: ctx.llm ?? ctx.get('llm'),
|
|
835
|
+
getCurrentSelection: () => ctx.get('agentDefaultModel')?.currentSelection?.(),
|
|
836
|
+
});
|
|
837
|
+
catalogStatus = { id: 'provider_catalog', status: 'PASS', reason_code: 'PROVIDER_CATALOG_RESOLVED' };
|
|
838
|
+
} catch {
|
|
839
|
+
catalogStatus = { id: 'provider_catalog', status: 'FAIL', reason_code: 'PROVIDER_CATALOG_UNAVAILABLE' };
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
const profiles = loadRoleProfiles();
|
|
843
|
+
const requestUrl = new URL(req.url, 'http://localhost');
|
|
844
|
+
const requestedWorkspaceId = requestUrl.searchParams.get('workspace_id');
|
|
845
|
+
const workspaceRegistry = loadWorkspaceContexts();
|
|
846
|
+
const requestedContext = requestedWorkspaceId ? workspaceRegistry.contexts?.[requestedWorkspaceId] : null;
|
|
847
|
+
const workspaceReadiness = requestedWorkspaceId && !requestedContext
|
|
848
|
+
? { status: 'UNAVAILABLE', reason_code: 'WORKSPACE_CONTEXT_NOT_FOUND' }
|
|
849
|
+
: await assessWorkspaceReadiness({ cwd: requestedContext?.repo_root ?? null });
|
|
850
|
+
const liveJobs = typeof hub.list === 'function' ? hub.list() : [];
|
|
851
|
+
const modelExecution = liveJobs.some((job) => job?.role === 'worker' && job?.status === 'done')
|
|
852
|
+
? { id: 'model_execution', status: 'PASS', reason_code: 'REAL_EXECUTION_PASSED' }
|
|
853
|
+
: { id: 'model_execution', status: 'NOT_RUN', reason_code: 'NO_EXECUTION_EVIDENCE' };
|
|
854
|
+
const reviewerExecution = liveJobs.some((job) => job?.role === 'reviewer' && job?.status === 'done')
|
|
855
|
+
? { id: 'reviewer_pipeline', status: 'PASS', reason_code: 'REAL_REVIEW_PASSED' }
|
|
856
|
+
: { id: 'reviewer_pipeline', status: 'NOT_RUN', reason_code: 'NO_EXECUTION_EVIDENCE' };
|
|
857
|
+
const contract = buildExtensionContract({
|
|
858
|
+
config,
|
|
859
|
+
readinessMatrix: { rows: [
|
|
860
|
+
{ id: 'hub_compatibility', status: 'PASS', reason_code: 'LIVE_CHECK_PASSED' },
|
|
861
|
+
catalogStatus,
|
|
862
|
+
modelExecution,
|
|
863
|
+
reviewerExecution,
|
|
864
|
+
] },
|
|
865
|
+
workspace: workspaceReadiness,
|
|
866
|
+
profiles,
|
|
867
|
+
runtime: getHubRuntimeIdentity(),
|
|
868
|
+
});
|
|
869
|
+
return sendJson(res, 200, { ok: true, extension: contract });
|
|
870
|
+
},
|
|
871
|
+
}));
|
|
872
|
+
|
|
873
|
+
disposers.push(webServer.register({
|
|
571
874
|
kind: 'exact', path: `${ROUTE_BASE}/config`,
|
|
572
875
|
handler: async (req, res) => {
|
|
573
876
|
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
|