@ran-sh/dsh-crew 0.3.7 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -67
- package/README.zh.md +52 -68
- package/docs/gpt-relay-extension.md +103 -0
- package/docs/job-contracts.md +107 -0
- package/docs/readiness-matrix.md +73 -0
- package/lib/client.js +3446 -3446
- package/official-web-bridge/cordis.patch.yml +4 -0
- package/official-web-bridge/entry.mjs +1 -0
- package/official-web-bridge/lib/client.js +3446 -0
- package/official-web-bridge/package.json +26 -0
- package/package.json +8 -3
- package/scripts/build-client.mjs +5 -1
- package/scripts/verify-official-bridge-e2e.mjs +180 -0
- package/src/dsh-cli-runtime.mjs +219 -208
- package/src/extension-contract.mjs +78 -0
- package/src/failure-classification.mjs +29 -0
- package/src/hub/index.mjs +343 -62
- package/src/information-flow.mjs +67 -0
- package/src/install/npx-lifecycle.mjs +149 -5
- package/src/install/official-web.mjs +132 -0
- package/src/job-contracts.mjs +218 -0
- package/src/mcp-runtime.mjs +16 -8
- package/src/official-web-bridge.mjs +211 -0
- 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,38 @@ 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
|
-
|
|
298
|
+
delivery_complete: false, delivery_missing: [], delivery_metadata: null,
|
|
299
|
+
outcome: null,
|
|
300
|
+
lastAssistantText: null,
|
|
247
301
|
};
|
|
248
302
|
// Read-only pre-run snapshot (async, never blocks dispatch): the audit
|
|
249
303
|
// only needs the before-state by the time the worker finishes. Non-repos
|
|
250
304
|
// 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' }));
|
|
305
|
+
job.baseline = await captureWorkspaceBaseline({ cwd: executionCwd }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace audit failed' }));
|
|
252
306
|
this.jobs.set(id, job);
|
|
253
307
|
|
|
254
308
|
const presets = this.ctx.get('agentPresets');
|
|
@@ -272,9 +326,9 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
272
326
|
job.tokens.output += u.outputTokens ?? 0;
|
|
273
327
|
job.tokens.reasoning += u.reasoningTokens ?? 0;
|
|
274
328
|
}
|
|
275
|
-
const text = (event.data?.message?.content ?? [])
|
|
276
|
-
.filter((c) => c?.type === 'text').map((c) => c.text).join('');
|
|
277
|
-
if (text) job.
|
|
329
|
+
const text = (event.data?.message?.content ?? [])
|
|
330
|
+
.filter((c) => c?.type === 'text').map((c) => c.text).join('');
|
|
331
|
+
if (text) job.lastAssistantText = text;
|
|
278
332
|
break;
|
|
279
333
|
}
|
|
280
334
|
case 'turn/end':
|
|
@@ -288,7 +342,7 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
288
342
|
const run = async () => {
|
|
289
343
|
const handle = await this.ctx.agents.create({
|
|
290
344
|
sessionId,
|
|
291
|
-
meta: { cwd, ...(presetId === undefined ? {} : { agentPreset: presetId }) },
|
|
345
|
+
meta: { cwd: executionCwd, ...(presetId === undefined ? {} : { agentPreset: presetId }) },
|
|
292
346
|
agentOptions: { provider: selection.provider, model: selection.model },
|
|
293
347
|
setup: async (agentCtx) => {
|
|
294
348
|
installModelSelection(agentCtx, { current: selection, assembled: undefined });
|
|
@@ -307,27 +361,42 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
307
361
|
// job cwd never inherits a parent directory's workspace).
|
|
308
362
|
const registry = this.ctx.get('workspaceRegistry');
|
|
309
363
|
if (registry !== undefined) {
|
|
310
|
-
const ws = (await registry.resolveByPath(
|
|
364
|
+
const ws = (await registry.resolveByPath(executionCwd)) ?? (await registry.create(executionCwd));
|
|
311
365
|
await ws.attachSession(sessionId);
|
|
312
366
|
}
|
|
313
367
|
} catch (err) {
|
|
314
|
-
this.ctx.logger?.warn?.(`dsh-crew: workspace attach failed for ${
|
|
368
|
+
this.ctx.logger?.warn?.(`dsh-crew: workspace attach failed for ${executionCwd}: ${err?.message ?? err}`);
|
|
315
369
|
}
|
|
316
370
|
await handle.agent.whenIdle();
|
|
317
371
|
handle.agent.followup(userMessage(job.prompt));
|
|
318
372
|
await handle.agent.whenIdle();
|
|
319
|
-
job.result = job.
|
|
320
|
-
job.status = job.stopReason === 'completed' ? 'done' : 'failed';
|
|
373
|
+
job.result = job.lastAssistantText ?? '';
|
|
374
|
+
if (job.status === 'running') job.status = job.stopReason === 'completed' ? 'done' : 'failed';
|
|
321
375
|
if (job.status === 'failed' && !job.error) job.error = `turn ended: ${job.stopReason ?? 'unknown'}`;
|
|
322
376
|
await this.ctx.sessions.flush(handle.agent.session);
|
|
323
377
|
};
|
|
324
378
|
|
|
325
|
-
|
|
379
|
+
const timeoutMs = Number.isInteger(timeout_seconds) && timeout_seconds > 0 ? timeout_seconds * 1000 : null;
|
|
380
|
+
if (timeoutMs) {
|
|
381
|
+
job.timeoutHandle = setTimeout(async () => {
|
|
382
|
+
if (job.status !== 'running') return;
|
|
383
|
+
job.status = 'failed';
|
|
384
|
+
job.error = `job timed out after ${timeout_seconds}s`;
|
|
385
|
+
job.error_code = 'JOB_TIMEOUT';
|
|
386
|
+
job.endedAt = new Date().toISOString();
|
|
387
|
+
this.publish();
|
|
388
|
+
for (const waiter of job.waiters.splice(0)) waiter();
|
|
389
|
+
try { await job.handle?.dispose(); } catch {}
|
|
390
|
+
}, timeoutMs);
|
|
391
|
+
job.timeoutHandle.unref?.();
|
|
392
|
+
}
|
|
393
|
+
job.promise = run()
|
|
326
394
|
.catch((err) => {
|
|
327
395
|
if (job.status === 'running') job.status = 'failed';
|
|
328
396
|
job.error = job.error ?? (err?.message ?? String(err));
|
|
329
397
|
})
|
|
330
|
-
.finally(async () => {
|
|
398
|
+
.finally(async () => {
|
|
399
|
+
if (job.timeoutHandle) clearTimeout(job.timeoutHandle);
|
|
331
400
|
job.endedAt = new Date().toISOString();
|
|
332
401
|
job.currentTool = null;
|
|
333
402
|
// Delivery completeness is separate from execution status: a job can
|
|
@@ -337,7 +406,21 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
337
406
|
const parsed = parseDeliveryReport(job.result ?? '');
|
|
338
407
|
job.delivery_complete = parsed.complete;
|
|
339
408
|
job.delivery_missing = parsed.missing;
|
|
340
|
-
job.delivery_metadata = formatDeliveryMetadata(parsed);
|
|
409
|
+
job.delivery_metadata = formatDeliveryMetadata(parsed);
|
|
410
|
+
if (job.role === 'reviewer') {
|
|
411
|
+
const verdictText = String(parsed.sections?.Verdict ?? '').trim().toLowerCase();
|
|
412
|
+
const verdict = /^(approve|approved|pass)\b/.test(verdictText)
|
|
413
|
+
? 'approve'
|
|
414
|
+
: /request.*chang|chang.*request|reject|needs changes/i.test(verdictText)
|
|
415
|
+
? 'request_changes'
|
|
416
|
+
: 'inconclusive';
|
|
417
|
+
const lines = (value) => String(value ?? '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean).slice(0, 40);
|
|
418
|
+
job.review = {
|
|
419
|
+
verdict, status: job.status, delivery_complete: parsed.complete === true,
|
|
420
|
+
findings: lines(parsed.sections?.['Review Findings']),
|
|
421
|
+
evidence: lines(parsed.sections?.Evidence), risks: lines(parsed.sections?.Risks),
|
|
422
|
+
};
|
|
423
|
+
}
|
|
341
424
|
// Canonical structured outcome (shared workflow layer) + terminal phase.
|
|
342
425
|
job.outcome = buildOutcome({
|
|
343
426
|
result: job.result ?? '',
|
|
@@ -348,9 +431,23 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
348
431
|
});
|
|
349
432
|
job.phase = job.status === 'done' ? JOB_PHASES.COMPLETED : job.status === 'cancelled' ? JOB_PHASES.CANCELLED : JOB_PHASES.FAILED;
|
|
350
433
|
// 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;
|
|
434
|
+
job.workspaceDiff = job.baseline.kind === 'git'
|
|
435
|
+
? await captureWorkspaceDiff({ cwd: executionCwd, baseline: job.baseline }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace diff failed' }))
|
|
436
|
+
: job.baseline;
|
|
437
|
+
if (job.role === 'reviewer' && job.review && job.workspaceDiff?.kind === 'git') {
|
|
438
|
+
const changes = job.workspaceDiff.changes ?? {};
|
|
439
|
+
const mutated = ['modified', 'deleted', 'renamed', 'untracked'].some((key) => Array.isArray(changes[key]) && changes[key].length > 0);
|
|
440
|
+
if (mutated) {
|
|
441
|
+
job.review.mutated_candidate = true;
|
|
442
|
+
job.review.invalidated = true;
|
|
443
|
+
job.review.verdict = 'request_changes';
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
if (job.isolatedWorkspace) {
|
|
447
|
+
const cleanup = await cleanupIsolatedWorkspace(job.isolatedWorkspace).catch((error) => ({ ok: false, error: error?.message ?? String(error) }));
|
|
448
|
+
job.workspace_retained = cleanup.ok !== true;
|
|
449
|
+
job.cleanup_warning = cleanup.ok === true ? null : cleanup.error ?? 'worktree cleanup failed';
|
|
450
|
+
}
|
|
354
451
|
this.publish();
|
|
355
452
|
for (const w of job.waiters.splice(0)) w();
|
|
356
453
|
});
|
|
@@ -400,11 +497,23 @@ function isLoopbackAddress(address) {
|
|
|
400
497
|
if (!n.startsWith('::ffff:')) return false;
|
|
401
498
|
return isIpv4Loopback(n.slice(7));
|
|
402
499
|
}
|
|
403
|
-
function isLoopbackRequest(req) {
|
|
404
|
-
if (!isLoopbackAddress(req.socket?.remoteAddress)) return false;
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
500
|
+
export function isLoopbackRequest(req) {
|
|
501
|
+
if (!isLoopbackAddress(req.socket?.remoteAddress)) return false;
|
|
502
|
+
let host;
|
|
503
|
+
try { host = new URL(`http://${String(req.headers.host ?? '')}`).hostname.toLowerCase(); } catch { return false; }
|
|
504
|
+
if (!(host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || isIpv4Loopback(host))) return false;
|
|
505
|
+
const fetchSite = String(req.headers?.['sec-fetch-site'] ?? '').toLowerCase();
|
|
506
|
+
if (fetchSite && fetchSite !== 'same-origin' && fetchSite !== 'none') return false;
|
|
507
|
+
const origin = req.headers?.origin;
|
|
508
|
+
if (origin !== undefined) {
|
|
509
|
+
if (typeof origin !== 'string') return false;
|
|
510
|
+
let parsed;
|
|
511
|
+
try { parsed = new URL(origin); } catch { return false; }
|
|
512
|
+
const originHost = parsed.hostname.toLowerCase();
|
|
513
|
+
if (!(originHost === 'localhost' || originHost === '127.0.0.1' || originHost === '::1' || originHost === '[::1]' || isIpv4Loopback(originHost))) return false;
|
|
514
|
+
}
|
|
515
|
+
return true;
|
|
516
|
+
}
|
|
408
517
|
function sendJson(res, status, value, headers = {}) {
|
|
409
518
|
const body = JSON.stringify(value);
|
|
410
519
|
res.writeHead(status, {
|
|
@@ -446,22 +555,92 @@ async function readBody(req, limit = 64 * 1024) {
|
|
|
446
555
|
* Returns { ok: true, payload } (payload.tier = effective tier) or
|
|
447
556
|
* { ok: false, code, error }.
|
|
448
557
|
*/
|
|
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
|
-
}
|
|
558
|
+
export function resolveHubSpawnPayload(payload, getConfig = () => ({}), dependencies = {}) {
|
|
559
|
+
const config = normalizeGlobalConfig(getConfig());
|
|
560
|
+
const raw = payload ?? {};
|
|
561
|
+
const advanced = raw.profile !== undefined || raw.workspace_id !== undefined || raw.workspace !== undefined
|
|
562
|
+
|| raw.constraints !== undefined || raw.context_refs !== undefined || raw.job_id !== undefined || raw.objective !== undefined;
|
|
563
|
+
let normalized = { ...raw };
|
|
564
|
+
if (advanced) {
|
|
565
|
+
if (raw.workspace !== undefined && (!raw.workspace || typeof raw.workspace !== 'object' || Array.isArray(raw.workspace))) {
|
|
566
|
+
return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'workspace must be an object' };
|
|
567
|
+
}
|
|
568
|
+
if (raw.constraints !== undefined && (!raw.constraints || typeof raw.constraints !== 'object' || Array.isArray(raw.constraints))) {
|
|
569
|
+
return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'constraints must be an object' };
|
|
570
|
+
}
|
|
571
|
+
if (raw.workspace?.repo_root !== undefined && typeof raw.workspace.repo_root !== 'string') {
|
|
572
|
+
return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'workspace repo_root must be a string' };
|
|
573
|
+
}
|
|
574
|
+
if (raw.workspace?.worktree !== undefined && !['auto', 'existing', 'none'].includes(raw.workspace.worktree)) {
|
|
575
|
+
return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'invalid worktree policy' };
|
|
576
|
+
}
|
|
577
|
+
if (raw.workspace?.branch !== undefined && !isSafeBranchName(raw.workspace.branch)) {
|
|
578
|
+
return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'invalid workspace branch' };
|
|
579
|
+
}
|
|
580
|
+
if (raw.constraints?.timeout_seconds !== undefined && (!Number.isInteger(raw.constraints.timeout_seconds) || raw.constraints.timeout_seconds < 1 || raw.constraints.timeout_seconds > 7200)) {
|
|
581
|
+
return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'invalid timeout_seconds' };
|
|
582
|
+
}
|
|
583
|
+
if (raw.constraints?.allow_fallback !== undefined && typeof raw.constraints.allow_fallback !== 'boolean') {
|
|
584
|
+
return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'allow_fallback must be boolean' };
|
|
585
|
+
}
|
|
586
|
+
const profileRegistry = dependencies.profileRegistry ?? loadRoleProfiles();
|
|
587
|
+
const workspaceRegistry = dependencies.workspaceRegistry ?? loadWorkspaceContexts();
|
|
588
|
+
if (!profileRegistry.ok) return { ok: false, code: 'PROFILE_FILE_INVALID', error: 'role profile registry is invalid' };
|
|
589
|
+
if (!workspaceRegistry.ok) return { ok: false, code: 'WORKSPACE_CONTEXT_FILE_INVALID', error: 'workspace registry is invalid' };
|
|
590
|
+
const profileRole = raw.profile ? profileRegistry.profiles?.[raw.profile]?.role : undefined;
|
|
591
|
+
const requestedRole = raw.role ?? profileRole ?? 'worker';
|
|
592
|
+
const resolvedProfile = resolveRoleProfile(profileRegistry, raw.profile, requestedRole);
|
|
593
|
+
if (!resolvedProfile.ok) return { ok: false, code: resolvedProfile.code, error: resolvedProfile.code };
|
|
594
|
+
const knownContext = raw.workspace_id ? workspaceRegistry.contexts?.[raw.workspace_id] : null;
|
|
595
|
+
const cwd = raw.workspace?.repo_root ?? raw.cwd ?? knownContext?.repo_root;
|
|
596
|
+
if (!cwd) return { ok: false, code: 'WORKSPACE_CONTEXT_NOT_FOUND', error: 'workspace repo_root or cwd is required' };
|
|
597
|
+
const workspace = resolveWorkspaceContext(workspaceRegistry, { workspace_id: raw.workspace_id, cwd });
|
|
598
|
+
if (!workspace.ok) return { ok: false, code: workspace.code, error: workspace.code };
|
|
599
|
+
const withRefs = addContextReferences(workspace.context, raw.context_refs, { cwd });
|
|
600
|
+
if (!withRefs.ok) return { ok: false, code: withRefs.code, error: withRefs.code };
|
|
601
|
+
const profile = resolvedProfile.profile;
|
|
602
|
+
const worktree = raw.workspace?.worktree;
|
|
603
|
+
const requestedIsolation = worktree === 'auto' ? 'worktree'
|
|
604
|
+
: worktree === 'existing' || worktree === 'none' ? 'shared'
|
|
605
|
+
: profile.isolation;
|
|
606
|
+
const objective = raw.objective ?? raw.task;
|
|
607
|
+
if (typeof objective !== 'string' || objective.trim() === '') return { ok: false, code: 'JOB_OBJECTIVE_REQUIRED', error: 'task or objective is required' };
|
|
608
|
+
if (raw.job_id !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(raw.job_id)) {
|
|
609
|
+
return { ok: false, code: 'JOB_ID_INVALID', error: 'invalid client job id' };
|
|
610
|
+
}
|
|
611
|
+
const contextTask = buildWorkspaceTask(objective, withRefs.context);
|
|
612
|
+
normalized = {
|
|
613
|
+
...raw,
|
|
614
|
+
task: profile.review_strictness === 'strict' && requestedRole === 'reviewer'
|
|
615
|
+
? `${contextTask}\n\nSTRICT REVIEW: fail closed on missing direct code or test evidence.`
|
|
616
|
+
: contextTask,
|
|
617
|
+
cwd,
|
|
618
|
+
role: requestedRole,
|
|
619
|
+
client_job_id: raw.job_id ?? null,
|
|
620
|
+
requested_isolation: requestedIsolation,
|
|
621
|
+
workspace_branch: raw.workspace?.branch ?? withRefs.context?.default_branch ?? null,
|
|
622
|
+
timeout_seconds: raw.constraints?.timeout_seconds ?? profile.timeout_seconds,
|
|
623
|
+
allow_fallback: raw.constraints?.allow_fallback ?? profile.fallback,
|
|
624
|
+
routing: profile.routing,
|
|
625
|
+
review_strictness: profile.review_strictness,
|
|
626
|
+
profile_id: resolvedProfile.profile_id,
|
|
627
|
+
workspace_context: withRefs.context,
|
|
628
|
+
};
|
|
629
|
+
for (const key of ['objective', 'job_id', 'profile', 'workspace_id', 'workspace', 'constraints', 'context_refs']) delete normalized[key];
|
|
630
|
+
}
|
|
631
|
+
// v0.2 role-based dispatch: reviewer / worker are gated by their role state,
|
|
632
|
+
// and the tier slot is derived from the role (reviewer always → pro).
|
|
633
|
+
if (normalized.role === 'worker' || normalized.role === 'reviewer') {
|
|
634
|
+
const hint = resolveRoleTierHint(normalized.role, normalized.tier);
|
|
635
|
+
if (!hint.ok) return { ok: false, code: hint.code, error: hint.error };
|
|
636
|
+
const decision = canDispatchRole(config, normalized.role, true, {});
|
|
637
|
+
if (!decision.ok) return { ok: false, code: decision.error.policyCode, error: decision.error.message };
|
|
638
|
+
return { ok: true, payload: { ...normalized, role: normalized.role, tier: hint.tier } };
|
|
639
|
+
}
|
|
640
|
+
const decision = chooseDefaultTier(config, normalized.tier, {});
|
|
641
|
+
if (!decision.ok) return { ok: false, code: decision.error.policyCode, error: decision.error.message };
|
|
642
|
+
return { ok: true, payload: { ...normalized, tier: decision.tier } };
|
|
643
|
+
}
|
|
465
644
|
|
|
466
645
|
// ---------- plugin entry ----------
|
|
467
646
|
|
|
@@ -535,12 +714,29 @@ export async function apply(ctx) {
|
|
|
535
714
|
const foreign = readMergedStatus({ excludeWriter: hub.shard.writer });
|
|
536
715
|
return sendJson(res, 200, { ok: true, jobs: [...own, ...foreign] });
|
|
537
716
|
}
|
|
538
|
-
if (req.method === 'GET' && parts.length === 1) {
|
|
717
|
+
if (req.method === 'GET' && parts.length === 1) {
|
|
539
718
|
const wait = Number(url.searchParams.get('wait') ?? 0);
|
|
540
719
|
const job = await hub.wait(parts[0], Math.min(wait, 600) * 1000);
|
|
541
720
|
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
|
-
}
|
|
721
|
+
return sendJson(res, 200, { ok: true, job: hub.view(job, true) });
|
|
722
|
+
}
|
|
723
|
+
if (req.method === 'GET' && parts.length === 2 && parts[1] === 'events') {
|
|
724
|
+
const job = hub.jobs.get(parts[0]);
|
|
725
|
+
if (!job) return sendJson(res, 404, { ok: false, error: 'no such job' });
|
|
726
|
+
const after = Math.max(0, Number(url.searchParams.get('after') ?? 0) || 0);
|
|
727
|
+
const events = hubCanonicalEvents(job).filter((event) => event.sequence > after);
|
|
728
|
+
return sendJson(res, 200, {
|
|
729
|
+
ok: true, job_id: job.id, events,
|
|
730
|
+
event_cursor: hubCanonicalEvents(job).at(-1)?.sequence ?? 0,
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
if (req.method === 'GET' && parts.length === 2 && parts[1] === 'contract') {
|
|
734
|
+
const job = hub.jobs.get(parts[0]);
|
|
735
|
+
if (!job) return sendJson(res, 404, { ok: false, error: 'no such job' });
|
|
736
|
+
const detail = url.searchParams.get('detail') === 'full' ? 'full' : 'compact';
|
|
737
|
+
const afterSequence = Math.max(0, Number(url.searchParams.get('after') ?? 0) || 0);
|
|
738
|
+
return sendJson(res, 200, { ok: true, job: projectWorkflowView(hub.view(job, true), { detail, afterSequence }) });
|
|
739
|
+
}
|
|
544
740
|
if (req.method === 'POST' && parts.length === 0) {
|
|
545
741
|
const payload = await readBody(req);
|
|
546
742
|
// Same policy resolver as the MCP server (src/server.mjs), with the
|
|
@@ -564,10 +760,95 @@ export async function apply(ctx) {
|
|
|
564
760
|
if (code) body.code = code;
|
|
565
761
|
return sendJson(res, 400, body);
|
|
566
762
|
}
|
|
567
|
-
},
|
|
568
|
-
}));
|
|
569
|
-
|
|
570
|
-
disposers.push(webServer.register({
|
|
763
|
+
},
|
|
764
|
+
}));
|
|
765
|
+
|
|
766
|
+
disposers.push(webServer.register({
|
|
767
|
+
kind: 'exact', path: `${ROUTE_BASE}/profiles`,
|
|
768
|
+
handler: async (req, res) => {
|
|
769
|
+
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
|
|
770
|
+
if (req.method === 'GET') return sendJson(res, 200, { ok: true, ...loadRoleProfiles() });
|
|
771
|
+
if (req.method === 'POST') {
|
|
772
|
+
try {
|
|
773
|
+
const saved = saveRoleProfiles(await readBody(req));
|
|
774
|
+
return sendJson(res, saved.ok ? 200 : 400, { ...saved });
|
|
775
|
+
} catch {
|
|
776
|
+
return sendJson(res, 400, { ok: false, error: 'invalid JSON', code: 'PROFILE_FILE_INVALID' });
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
return sendJson(res, 405, { ok: false, error: 'GET or POST' }, { allow: 'GET, POST' });
|
|
780
|
+
},
|
|
781
|
+
}));
|
|
782
|
+
|
|
783
|
+
disposers.push(webServer.register({
|
|
784
|
+
kind: 'exact', path: `${ROUTE_BASE}/workspaces`,
|
|
785
|
+
handler: async (req, res) => {
|
|
786
|
+
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
|
|
787
|
+
if (req.method === 'GET') return sendJson(res, 200, { ok: true, ...loadWorkspaceContexts() });
|
|
788
|
+
if (req.method === 'POST') {
|
|
789
|
+
try {
|
|
790
|
+
const saved = saveWorkspaceContexts(await readBody(req));
|
|
791
|
+
return sendJson(res, saved.ok ? 200 : 400, { ...saved });
|
|
792
|
+
} catch {
|
|
793
|
+
return sendJson(res, 400, { ok: false, error: 'invalid JSON', code: 'WORKSPACE_CONTEXT_FILE_INVALID' });
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
return sendJson(res, 405, { ok: false, error: 'GET or POST' }, { allow: 'GET, POST' });
|
|
797
|
+
},
|
|
798
|
+
}));
|
|
799
|
+
|
|
800
|
+
disposers.push(webServer.register({
|
|
801
|
+
kind: 'exact', path: `${ROUTE_BASE}/extension`,
|
|
802
|
+
handler: async (req, res) => {
|
|
803
|
+
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
|
|
804
|
+
if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'GET only' }, { allow: 'GET' });
|
|
805
|
+
const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
|
|
806
|
+
let catalogStatus = { id: 'provider_catalog', status: 'NOT_RUN', reason_code: 'PROVIDER_MODE_UNKNOWN' };
|
|
807
|
+
if (normalizeWorkerProviderMode(config.worker_provider_mode) === 'deepseek-official') {
|
|
808
|
+
catalogStatus = { id: 'provider_catalog', status: 'SKIP', reason_code: 'PROVIDER_CATALOG_NOT_REQUIRED' };
|
|
809
|
+
} else {
|
|
810
|
+
try {
|
|
811
|
+
await readHarnessModelCatalog({
|
|
812
|
+
llm: ctx.llm ?? ctx.get('llm'),
|
|
813
|
+
getCurrentSelection: () => ctx.get('agentDefaultModel')?.currentSelection?.(),
|
|
814
|
+
});
|
|
815
|
+
catalogStatus = { id: 'provider_catalog', status: 'PASS', reason_code: 'PROVIDER_CATALOG_RESOLVED' };
|
|
816
|
+
} catch {
|
|
817
|
+
catalogStatus = { id: 'provider_catalog', status: 'FAIL', reason_code: 'PROVIDER_CATALOG_UNAVAILABLE' };
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
const profiles = loadRoleProfiles();
|
|
821
|
+
const requestUrl = new URL(req.url, 'http://localhost');
|
|
822
|
+
const requestedWorkspaceId = requestUrl.searchParams.get('workspace_id');
|
|
823
|
+
const workspaceRegistry = loadWorkspaceContexts();
|
|
824
|
+
const requestedContext = requestedWorkspaceId ? workspaceRegistry.contexts?.[requestedWorkspaceId] : null;
|
|
825
|
+
const workspaceReadiness = requestedWorkspaceId && !requestedContext
|
|
826
|
+
? { status: 'UNAVAILABLE', reason_code: 'WORKSPACE_CONTEXT_NOT_FOUND' }
|
|
827
|
+
: await assessWorkspaceReadiness({ cwd: requestedContext?.repo_root ?? null });
|
|
828
|
+
const liveJobs = typeof hub.list === 'function' ? hub.list() : [];
|
|
829
|
+
const modelExecution = liveJobs.some((job) => job?.role === 'worker' && job?.status === 'done')
|
|
830
|
+
? { id: 'model_execution', status: 'PASS', reason_code: 'REAL_EXECUTION_PASSED' }
|
|
831
|
+
: { id: 'model_execution', status: 'NOT_RUN', reason_code: 'NO_EXECUTION_EVIDENCE' };
|
|
832
|
+
const reviewerExecution = liveJobs.some((job) => job?.role === 'reviewer' && job?.status === 'done')
|
|
833
|
+
? { id: 'reviewer_pipeline', status: 'PASS', reason_code: 'REAL_REVIEW_PASSED' }
|
|
834
|
+
: { id: 'reviewer_pipeline', status: 'NOT_RUN', reason_code: 'NO_EXECUTION_EVIDENCE' };
|
|
835
|
+
const contract = buildExtensionContract({
|
|
836
|
+
config,
|
|
837
|
+
readinessMatrix: { rows: [
|
|
838
|
+
{ id: 'hub_compatibility', status: 'PASS', reason_code: 'LIVE_CHECK_PASSED' },
|
|
839
|
+
catalogStatus,
|
|
840
|
+
modelExecution,
|
|
841
|
+
reviewerExecution,
|
|
842
|
+
] },
|
|
843
|
+
workspace: workspaceReadiness,
|
|
844
|
+
profiles,
|
|
845
|
+
runtime: getHubRuntimeIdentity(),
|
|
846
|
+
});
|
|
847
|
+
return sendJson(res, 200, { ok: true, extension: contract });
|
|
848
|
+
},
|
|
849
|
+
}));
|
|
850
|
+
|
|
851
|
+
disposers.push(webServer.register({
|
|
571
852
|
kind: 'exact', path: `${ROUTE_BASE}/config`,
|
|
572
853
|
handler: async (req, res) => {
|
|
573
854
|
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Bounded context builders for agent-to-agent hand-offs.
|
|
2
|
+
//
|
|
3
|
+
// Workers still receive the user's complete delegated task. Once a worker has
|
|
4
|
+
// run, downstream agents receive only an objective plus structured evidence
|
|
5
|
+
// and artifact references. They inspect the isolated workspace directly when
|
|
6
|
+
// more detail is required; raw prose and whole patches are never re-embedded.
|
|
7
|
+
|
|
8
|
+
function clip(value, limit) {
|
|
9
|
+
const text = String(value ?? '').trim();
|
|
10
|
+
if (text.length <= limit) return text;
|
|
11
|
+
return `${text.slice(0, limit)}\n[truncated: ${text.length - limit} characters omitted]`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function list(values, { count = 40, itemLimit = 300 } = {}) {
|
|
15
|
+
if (!Array.isArray(values) || values.length === 0) return ['(none)'];
|
|
16
|
+
const selected = values.slice(0, count).map((value) => `- ${clip(value, itemLimit)}`);
|
|
17
|
+
if (values.length > count) selected.push(`- [truncated: ${values.length - count} additional items omitted]`);
|
|
18
|
+
return selected;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function tests(values) {
|
|
22
|
+
if (!Array.isArray(values) || values.length === 0) return ['(none reported)'];
|
|
23
|
+
return list(values.map((entry) => [entry?.status, entry?.command, entry?.summary].filter(Boolean).join(' — ')), {
|
|
24
|
+
count: 30,
|
|
25
|
+
itemLimit: 500,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Build the automatic-review context capsule. */
|
|
30
|
+
export function buildReviewTask(task, view = {}, { strictness = 'standard' } = {}) {
|
|
31
|
+
const outcome = view?.outcome ?? {};
|
|
32
|
+
const candidate = view?.candidate ?? {};
|
|
33
|
+
const changedFiles = Array.isArray(candidate.changed_files) ? candidate.changed_files : [];
|
|
34
|
+
const parts = [
|
|
35
|
+
'You are the automatic reviewer of a completed worker implementation.',
|
|
36
|
+
'REVIEW ONLY: inspect the candidate and report findings. Do not modify files.',
|
|
37
|
+
...(strictness === 'strict' ? [
|
|
38
|
+
'STRICT REVIEW: fail closed. Approve only when direct code and test evidence supports every material claim; treat missing evidence as needs changes.',
|
|
39
|
+
] : []),
|
|
40
|
+
'',
|
|
41
|
+
'Objective:',
|
|
42
|
+
clip(task, 4000),
|
|
43
|
+
'',
|
|
44
|
+
'Worker outcome:',
|
|
45
|
+
`task_status=${outcome.task_status ?? 'unknown'} tests_status=${outcome.tests_status ?? 'unknown'} delivery=${outcome.delivery?.complete ? 'complete' : 'incomplete'}`,
|
|
46
|
+
'',
|
|
47
|
+
'Reported changes:',
|
|
48
|
+
...list(outcome.changes),
|
|
49
|
+
'',
|
|
50
|
+
'Reported tests:',
|
|
51
|
+
...tests(outcome.tests),
|
|
52
|
+
'',
|
|
53
|
+
'Reported risks:',
|
|
54
|
+
...list(outcome.risks),
|
|
55
|
+
'',
|
|
56
|
+
'Candidate artifact:',
|
|
57
|
+
`base_revision=${candidate.base_revision ?? 'unknown'}`,
|
|
58
|
+
`fingerprint=${candidate.fingerprint ?? 'unknown'}`,
|
|
59
|
+
'changed_files:',
|
|
60
|
+
...list(changedFiles, { count: 80, itemLimit: 500 }),
|
|
61
|
+
'',
|
|
62
|
+
'Inspect the candidate directly in the current isolated workspace. Use git diff against the base revision and open only the files needed for review. The worker\'s raw prose and full patch are intentionally not embedded in this hand-off.',
|
|
63
|
+
'',
|
|
64
|
+
'Report: 1) whether the implementation satisfies the objective, 2) concrete bugs/style/security risks, 3) suggested fixes. End with ## Review Findings / ## Evidence / ## Risks / ## Verdict (approved | needs changes | rejected).',
|
|
65
|
+
];
|
|
66
|
+
return parts.join('\n');
|
|
67
|
+
}
|