@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/workflow-runtime.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { resolveModelPolicy, shouldAutoReview, getRoleState } from './policy.mjs
|
|
|
18
18
|
import { buildOutcome, decideNextStep, JOB_PHASES, canTransition } from './workflow.mjs';
|
|
19
19
|
import { parseDeliveryReport } from './delivery.mjs';
|
|
20
20
|
import { classifyFailure } from './failure-classification.mjs';
|
|
21
|
+
import { createCanonicalJobEvent } from './job-contracts.mjs';
|
|
21
22
|
|
|
22
23
|
export const WORKFLOW_ERROR_CODES = {
|
|
23
24
|
ISOLATION_UNAVAILABLE: 'ISOLATION_UNAVAILABLE',
|
|
@@ -111,6 +112,19 @@ export function createWorkflowRuntime(adapters, {
|
|
|
111
112
|
const queue = [];
|
|
112
113
|
let active = 0;
|
|
113
114
|
let maxParallelLimit = normalizeMaxParallel(maxParallel);
|
|
115
|
+
let nestedIdFactory = null;
|
|
116
|
+
|
|
117
|
+
function nextWorkflowId() {
|
|
118
|
+
if (nestedIdFactory) return nestedIdFactory();
|
|
119
|
+
const generated = idFactory();
|
|
120
|
+
// A few embedders provide a factory-builder for deterministic tests.
|
|
121
|
+
// Normalize that legacy shape once while keeping the public id a string.
|
|
122
|
+
if (typeof generated === 'function') {
|
|
123
|
+
nestedIdFactory = generated;
|
|
124
|
+
return nestedIdFactory();
|
|
125
|
+
}
|
|
126
|
+
return generated;
|
|
127
|
+
}
|
|
114
128
|
|
|
115
129
|
function log(msg) { logger?.debug?.(`workflow: ${msg}`); }
|
|
116
130
|
|
|
@@ -140,16 +154,38 @@ export function createWorkflowRuntime(adapters, {
|
|
|
140
154
|
log(`${job.id} ${to}`);
|
|
141
155
|
}
|
|
142
156
|
|
|
157
|
+
function recordCanonical(job, type, data = {}, attempt = null, at = clock()) {
|
|
158
|
+
job.canonical_event_sequence += 1;
|
|
159
|
+
job.canonical_events.push(createCanonicalJobEvent({
|
|
160
|
+
jobId: job.id,
|
|
161
|
+
type,
|
|
162
|
+
sequence: job.canonical_event_sequence,
|
|
163
|
+
at,
|
|
164
|
+
role: job.role,
|
|
165
|
+
attempt,
|
|
166
|
+
data,
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
|
|
143
170
|
function createJob(spec) {
|
|
144
171
|
const now = clock();
|
|
145
|
-
|
|
146
|
-
id:
|
|
172
|
+
const job = {
|
|
173
|
+
id: nextWorkflowId(),
|
|
174
|
+
client_job_id: spec.client_job_id ?? null,
|
|
147
175
|
role: spec.role ?? 'worker',
|
|
148
176
|
delivery: spec.delivery === 'review' ? 'review' : 'coding',
|
|
149
177
|
model_class_hint: spec.model_class_hint === 'pro' ? 'pro' : spec.model_class_hint === 'flash' ? 'flash' : null,
|
|
150
178
|
original_task: spec.task,
|
|
151
179
|
source: spec.source ?? 'api',
|
|
152
180
|
requested_cwd: spec.cwd,
|
|
181
|
+
requested_isolation: spec.requested_isolation ?? null,
|
|
182
|
+
workspace_branch: spec.workspace_branch ?? null,
|
|
183
|
+
timeout_seconds: spec.timeout_seconds ?? null,
|
|
184
|
+
profile_id: spec.profile_id ?? null,
|
|
185
|
+
allow_fallback: spec.allow_fallback !== false,
|
|
186
|
+
routing: spec.routing ?? 'auto',
|
|
187
|
+
review_strictness: spec.review_strictness ?? null,
|
|
188
|
+
workspace_context: spec.workspace_context ? { ...spec.workspace_context } : null,
|
|
153
189
|
effort: spec.effort ?? 'max',
|
|
154
190
|
phase: JOB_PHASES.CREATED,
|
|
155
191
|
status: 'running',
|
|
@@ -170,12 +206,16 @@ export function createWorkflowRuntime(adapters, {
|
|
|
170
206
|
retain_workspace: false,
|
|
171
207
|
workspace_retained: false,
|
|
172
208
|
events: [{ at: now, phase: JOB_PHASES.CREATED, type: 'created' }],
|
|
209
|
+
canonical_events: [],
|
|
210
|
+
canonical_event_sequence: 0,
|
|
173
211
|
createdAt: now,
|
|
174
212
|
startedAt: now,
|
|
175
213
|
endedAt: null,
|
|
176
214
|
workspaceHandle: null,
|
|
177
215
|
waiters: [],
|
|
178
216
|
};
|
|
217
|
+
recordCanonical(job, 'job.created', { client_job_id: job.client_job_id }, null, now);
|
|
218
|
+
return job;
|
|
179
219
|
}
|
|
180
220
|
|
|
181
221
|
function releaseSlot() {
|
|
@@ -227,6 +267,9 @@ export function createWorkflowRuntime(adapters, {
|
|
|
227
267
|
job.error = err?.message ?? String(err);
|
|
228
268
|
job.error_code = err?.code ?? err?.policyCode ?? job.error_code ?? null;
|
|
229
269
|
job.phase = JOB_PHASES.FAILED;
|
|
270
|
+
recordCanonical(job, 'job.failed', {
|
|
271
|
+
error_code: job.error_code,
|
|
272
|
+
});
|
|
230
273
|
setTerminal(job);
|
|
231
274
|
}
|
|
232
275
|
|
|
@@ -259,10 +302,7 @@ export function createWorkflowRuntime(adapters, {
|
|
|
259
302
|
config = adapters.getConfig?.() ?? {};
|
|
260
303
|
const alloc = await adapters.allocateWorkspace?.(job);
|
|
261
304
|
if (alloc && alloc.ok === false) {
|
|
262
|
-
job.
|
|
263
|
-
job.phase = JOB_PHASES.FAILED;
|
|
264
|
-
if (alloc.reason) job.error_code = alloc.reason;
|
|
265
|
-
setTerminal(job);
|
|
305
|
+
failJob(job, Object.assign(new Error(alloc.error ?? alloc.reason), { code: alloc.reason }));
|
|
266
306
|
return;
|
|
267
307
|
}
|
|
268
308
|
if (alloc && alloc.ok) {
|
|
@@ -272,12 +312,16 @@ export function createWorkflowRuntime(adapters, {
|
|
|
272
312
|
job.base_revision = alloc.base_revision ?? null;
|
|
273
313
|
job.primary_workspace_dirty = alloc.primary_workspace_dirty === true;
|
|
274
314
|
}
|
|
315
|
+
recordCanonical(job, 'job.started', {
|
|
316
|
+
isolation: job.isolation,
|
|
317
|
+
base_revision: job.base_revision,
|
|
318
|
+
});
|
|
275
319
|
transition(job, JOB_PHASES.RUNNING, 'start');
|
|
276
320
|
|
|
277
321
|
if (isReviewJob) {
|
|
278
322
|
transition(job, JOB_PHASES.REVIEWING, 'explicit reviewer');
|
|
279
323
|
const before = alloc?.ok && alloc.isolation === 'worktree' ? await safeCapture(adapters, job.execution_cwd, job.base_revision) : null;
|
|
280
|
-
const reviewTask = adapters.buildReviewTask(job.original_task, null);
|
|
324
|
+
const reviewTask = adapters.buildReviewTask(job.original_task, null, { strictness: job.review_strictness ?? 'standard' });
|
|
281
325
|
const review = await runReviewerAttempt(job, reviewTask, config, before);
|
|
282
326
|
job.review = review;
|
|
283
327
|
if (job.review) transition(job, JOB_PHASES.READY, 'review complete');
|
|
@@ -290,10 +334,20 @@ export function createWorkflowRuntime(adapters, {
|
|
|
290
334
|
for (;;) {
|
|
291
335
|
if (job.cancelling) { cancelWorkflow(job); return; }
|
|
292
336
|
if (attempt > 0) transition(job, JOB_PHASES.RUNNING, `escalated attempt ${attempt}`);
|
|
293
|
-
const
|
|
337
|
+
const resolvedPolicy = resolveModelPolicy(config, 'worker', { attempt });
|
|
338
|
+
const fallbackPolicy = job.allow_fallback === false
|
|
339
|
+
? { ...resolvedPolicy, escalation: { ...resolvedPolicy.escalation, enabled: false } }
|
|
340
|
+
: resolvedPolicy;
|
|
341
|
+
const policy = job.routing === 'stable'
|
|
342
|
+
? { ...fallbackPolicy, adaptive: { ...fallbackPolicy.adaptive, enabled: false } }
|
|
343
|
+
: fallbackPolicy;
|
|
294
344
|
const attemptId = attemptIdFor(adapters, job.id, attempt === 0 ? '' : String(attempt));
|
|
295
345
|
job.current_attempt_id = attemptId;
|
|
296
346
|
job.events.push({ at: clock(), phase: job.phase, type: 'attempt/start', attempt, escalation_reason: escalationReason });
|
|
347
|
+
recordCanonical(job, 'worker.started', {
|
|
348
|
+
attempt_id: attemptId,
|
|
349
|
+
escalation_reason: escalationReason,
|
|
350
|
+
}, attempt);
|
|
297
351
|
const ar = await adapters.executeAttempt({
|
|
298
352
|
id: attemptId,
|
|
299
353
|
workflowId: job.id,
|
|
@@ -302,6 +356,7 @@ export function createWorkflowRuntime(adapters, {
|
|
|
302
356
|
task: job.original_task,
|
|
303
357
|
cwd: job.execution_cwd,
|
|
304
358
|
effort: job.effort,
|
|
359
|
+
timeout_seconds: job.timeout_seconds,
|
|
305
360
|
policy,
|
|
306
361
|
source: job.source,
|
|
307
362
|
model_class_hint: job.model_class_hint,
|
|
@@ -314,6 +369,18 @@ export function createWorkflowRuntime(adapters, {
|
|
|
314
369
|
if (job.cancelling) { cancelWorkflow(job); return; }
|
|
315
370
|
const attemptView = attemptRecord(ar, attempt);
|
|
316
371
|
job.attempts.push(attemptView);
|
|
372
|
+
recordCanonical(job, 'model.selected', {
|
|
373
|
+
provider: attemptView.provider,
|
|
374
|
+
model: attemptView.model,
|
|
375
|
+
source: attemptView.selection_source,
|
|
376
|
+
fallback_reason: attemptView.selection_trace?.fallback_reason ?? null,
|
|
377
|
+
}, attempt);
|
|
378
|
+
recordCanonical(job, 'worker.completed', {
|
|
379
|
+
attempt_id: attemptView.id,
|
|
380
|
+
status: attemptView.status,
|
|
381
|
+
stop_reason: attemptView.stopReason,
|
|
382
|
+
error_code: attemptView.error_code,
|
|
383
|
+
}, attempt);
|
|
317
384
|
if (ar.infra === true) {
|
|
318
385
|
failJob(job, Object.assign(new Error(ar.error ?? 'infrastructure failure'), { code: WORKFLOW_ERROR_CODES.ATTEMPT_INFRA_FAILURE }));
|
|
319
386
|
return;
|
|
@@ -360,6 +427,11 @@ export function createWorkflowRuntime(adapters, {
|
|
|
360
427
|
return;
|
|
361
428
|
}
|
|
362
429
|
if (decision.step === 'escalate') {
|
|
430
|
+
recordCanonical(job, 'model.fallback', {
|
|
431
|
+
reason: decision.reason,
|
|
432
|
+
from_attempt: attempt,
|
|
433
|
+
to_attempt: attempt + 1,
|
|
434
|
+
}, attempt);
|
|
363
435
|
transition(job, JOB_PHASES.ESCALATING, decision.reason);
|
|
364
436
|
escalationReason = decision.reason;
|
|
365
437
|
attempt += 1;
|
|
@@ -368,7 +440,7 @@ export function createWorkflowRuntime(adapters, {
|
|
|
368
440
|
if (decision.step === 'review') {
|
|
369
441
|
transition(job, JOB_PHASES.REVIEWING, 'automatic review');
|
|
370
442
|
const before = job.candidate;
|
|
371
|
-
const reviewTask = adapters.buildReviewTask(job.original_task, { outcome, candidate: job.candidate ?? null });
|
|
443
|
+
const reviewTask = adapters.buildReviewTask(job.original_task, { outcome, candidate: job.candidate ?? null }, { strictness: job.review_strictness ?? 'standard' });
|
|
372
444
|
const review = await runReviewerAttempt(job, reviewTask, config, before, job.execution_cwd, job.base_revision);
|
|
373
445
|
job.review = review;
|
|
374
446
|
transition(job, JOB_PHASES.READY, decision.reason);
|
|
@@ -401,6 +473,7 @@ export function createWorkflowRuntime(adapters, {
|
|
|
401
473
|
const attemptId = attemptIdFor(adapters, job.id, 'review');
|
|
402
474
|
job.current_attempt_id = attemptId;
|
|
403
475
|
job.events.push({ at: clock(), phase: job.phase, type: 'review/start' });
|
|
476
|
+
recordCanonical(job, 'review.started', { attempt_id: attemptId }, 0);
|
|
404
477
|
const ar = await adapters.executeAttempt({
|
|
405
478
|
id: attemptId,
|
|
406
479
|
workflowId: job.id,
|
|
@@ -420,13 +493,30 @@ export function createWorkflowRuntime(adapters, {
|
|
|
420
493
|
job.current_attempt_id = null;
|
|
421
494
|
const attemptView = { ...attemptRecord(ar, 0), phase: 'review' };
|
|
422
495
|
job.attempts.push(attemptView);
|
|
496
|
+
recordCanonical(job, 'model.selected', {
|
|
497
|
+
provider: attemptView.provider,
|
|
498
|
+
model: attemptView.model,
|
|
499
|
+
source: attemptView.selection_source,
|
|
500
|
+
fallback_reason: attemptView.selection_trace?.fallback_reason ?? null,
|
|
501
|
+
}, 0);
|
|
423
502
|
if (ar.status === 'failed' && attemptView.error_code) job.error_code = attemptView.error_code;
|
|
424
503
|
const afterCandidate = job.isolation === 'worktree' ? await safeCapture(adapters, cwd, baseRevision) : null;
|
|
425
|
-
|
|
504
|
+
const review = normalizeReview({ attemptResult: ar, beforeCandidate, afterCandidate });
|
|
505
|
+
recordCanonical(job, 'review.completed', {
|
|
506
|
+
attempt_id: attemptView.id,
|
|
507
|
+
status: review.status,
|
|
508
|
+
verdict: review.verdict,
|
|
509
|
+
error_code: attemptView.error_code,
|
|
510
|
+
}, 0);
|
|
511
|
+
return review;
|
|
426
512
|
}
|
|
427
513
|
|
|
428
514
|
function finalize(job) {
|
|
429
515
|
transition(job, JOB_PHASES.COMPLETED, job.review ? 'reviewed' : 'verified');
|
|
516
|
+
recordCanonical(job, 'job.completed', {
|
|
517
|
+
reviewed: !!job.review,
|
|
518
|
+
task_status: job.outcome?.task_status ?? null,
|
|
519
|
+
});
|
|
430
520
|
setTerminal(job);
|
|
431
521
|
}
|
|
432
522
|
|
|
@@ -464,6 +554,7 @@ export function createWorkflowRuntime(adapters, {
|
|
|
464
554
|
if (activeAttempt) adapters.cancelAttempt?.(activeAttempt).catch(() => {});
|
|
465
555
|
job.phase = JOB_PHASES.CANCELLED;
|
|
466
556
|
job.status = 'cancelled';
|
|
557
|
+
recordCanonical(job, 'job.cancelled', { active_attempt_id: activeAttempt ?? null });
|
|
467
558
|
job.endedAt = clock();
|
|
468
559
|
job.current_attempt_id = null;
|
|
469
560
|
for (const w of job.waiters.splice(0)) w();
|
|
@@ -481,6 +572,7 @@ export function createWorkflowRuntime(adapters, {
|
|
|
481
572
|
});
|
|
482
573
|
const v = {
|
|
483
574
|
id: job.id,
|
|
575
|
+
client_job_id: job.client_job_id,
|
|
484
576
|
role: job.role,
|
|
485
577
|
phase: job.phase,
|
|
486
578
|
status: job.status,
|
|
@@ -488,6 +580,11 @@ export function createWorkflowRuntime(adapters, {
|
|
|
488
580
|
current_model: job.attempts[job.attempts.length - 1]?.model ?? null,
|
|
489
581
|
model_class_hint: job.model_class_hint,
|
|
490
582
|
source: job.source,
|
|
583
|
+
profile_id: job.profile_id,
|
|
584
|
+
routing: job.routing,
|
|
585
|
+
workspace_context: job.workspace_context ? { ...job.workspace_context } : null,
|
|
586
|
+
workspace_branch: job.workspace_branch,
|
|
587
|
+
timeout_seconds: job.timeout_seconds,
|
|
491
588
|
requested_cwd: job.requested_cwd,
|
|
492
589
|
execution_cwd: job.execution_cwd,
|
|
493
590
|
isolation: job.isolation,
|
|
@@ -505,6 +602,7 @@ export function createWorkflowRuntime(adapters, {
|
|
|
505
602
|
decision: job.decision,
|
|
506
603
|
candidate_available: !!job.candidate,
|
|
507
604
|
review_status: job.review ? job.review.status ?? null : null,
|
|
605
|
+
event_cursor: job.canonical_events.at(-1)?.sequence ?? 0,
|
|
508
606
|
};
|
|
509
607
|
if (withResult) {
|
|
510
608
|
v.child_attempts = job.attempts.map((a) => ({
|
|
@@ -520,6 +618,7 @@ export function createWorkflowRuntime(adapters, {
|
|
|
520
618
|
v.candidate = job.candidate;
|
|
521
619
|
v.review = job.review;
|
|
522
620
|
v.events = job.events;
|
|
621
|
+
v.canonical_events = job.canonical_events.map((event) => ({ ...event, data: { ...event.data } }));
|
|
523
622
|
}
|
|
524
623
|
return v;
|
|
525
624
|
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Workspace Context carries stable project facts by reference. Instruction
|
|
2
|
+
// file contents and validation output remain in the workspace and are never
|
|
3
|
+
// copied into this registry or across Agent hand-offs.
|
|
4
|
+
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
8
|
+
|
|
9
|
+
export const WORKSPACE_CONTEXT_SCHEMA_VERSION = 1;
|
|
10
|
+
const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
11
|
+
|
|
12
|
+
export function isSafeBranchName(value) {
|
|
13
|
+
if (typeof value !== 'string' || value.length < 1 || value.length > 256) return false;
|
|
14
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(value)) return false;
|
|
15
|
+
if (value.includes('..') || value.includes('@{') || value.includes('//')) return false;
|
|
16
|
+
if (value.endsWith('/') || value.endsWith('.') || value.endsWith('.lock')) return false;
|
|
17
|
+
return value.split('/').every((part) => part && !part.startsWith('.'));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function boundedStrings(value, { maxItems = 32, maxLength = 256 } = {}) {
|
|
21
|
+
if (value === undefined) return [];
|
|
22
|
+
if (!Array.isArray(value)) return null;
|
|
23
|
+
const result = [];
|
|
24
|
+
for (const item of value) {
|
|
25
|
+
if (typeof item !== 'string' || item.trim() === '' || item.length > maxLength) return null;
|
|
26
|
+
result.push(item.trim());
|
|
27
|
+
if (result.length > maxItems) return null;
|
|
28
|
+
}
|
|
29
|
+
return [...new Set(result)];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function safeReference(value) {
|
|
33
|
+
if (isAbsolute(value)) return false;
|
|
34
|
+
const normalized = value.replace(/\\/g, '/');
|
|
35
|
+
return normalized !== '..' && !normalized.startsWith('../') && !normalized.includes('/../');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeContext(id, raw) {
|
|
39
|
+
if (!ID.test(id) || !raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
40
|
+
if (typeof raw.repo_root !== 'string' || !isAbsolute(raw.repo_root)) return null;
|
|
41
|
+
const instructionFiles = boundedStrings(raw.instruction_files);
|
|
42
|
+
const validationHints = boundedStrings(raw.validation_hints, { maxItems: 32, maxLength: 512 });
|
|
43
|
+
if (!instructionFiles || !validationHints || instructionFiles.some((entry) => !safeReference(entry))) return null;
|
|
44
|
+
if (raw.default_branch != null && !isSafeBranchName(raw.default_branch)) return null;
|
|
45
|
+
return {
|
|
46
|
+
workspace_id: id,
|
|
47
|
+
repo_root: resolve(raw.repo_root),
|
|
48
|
+
default_branch: raw.default_branch?.trim() || null,
|
|
49
|
+
instruction_files: instructionFiles,
|
|
50
|
+
validation_hints: validationHints,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function workspaceContextsFile({ home = homedir() } = {}) {
|
|
55
|
+
return join(home, '.config', 'dsh-crew', 'workspaces.json');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function loadWorkspaceContexts({ home = homedir(), file = workspaceContextsFile({ home }) } = {}) {
|
|
59
|
+
if (!existsSync(file)) {
|
|
60
|
+
return { schema_version: WORKSPACE_CONTEXT_SCHEMA_VERSION, ok: true, source: 'none', contexts: {}, errors: [] };
|
|
61
|
+
}
|
|
62
|
+
let raw;
|
|
63
|
+
try { raw = JSON.parse(readFileSync(file, 'utf8')); } catch {
|
|
64
|
+
return { schema_version: WORKSPACE_CONTEXT_SCHEMA_VERSION, ok: false, source: 'file', contexts: {}, errors: [{ code: 'WORKSPACE_CONTEXT_FILE_INVALID' }] };
|
|
65
|
+
}
|
|
66
|
+
return parseWorkspaceContexts(raw);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseWorkspaceContexts(raw) {
|
|
70
|
+
if (raw?.schema_version !== WORKSPACE_CONTEXT_SCHEMA_VERSION || !raw.workspaces || typeof raw.workspaces !== 'object' || Array.isArray(raw.workspaces)) {
|
|
71
|
+
return { schema_version: WORKSPACE_CONTEXT_SCHEMA_VERSION, ok: false, source: 'file', contexts: {}, errors: [{ code: 'WORKSPACE_CONTEXT_FILE_INVALID' }] };
|
|
72
|
+
}
|
|
73
|
+
const contexts = {};
|
|
74
|
+
const errors = [];
|
|
75
|
+
for (const [id, value] of Object.entries(raw.workspaces)) {
|
|
76
|
+
const context = normalizeContext(id, value);
|
|
77
|
+
if (!context) errors.push({ code: 'WORKSPACE_CONTEXT_INVALID', workspace_id: ID.test(id) ? id : '<invalid>' });
|
|
78
|
+
else contexts[id] = context;
|
|
79
|
+
}
|
|
80
|
+
return { schema_version: WORKSPACE_CONTEXT_SCHEMA_VERSION, ok: errors.length === 0, source: 'file', contexts, errors: errors.slice(0, 32) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function saveWorkspaceContexts(document, { home = homedir(), file = workspaceContextsFile({ home }) } = {}) {
|
|
84
|
+
const parsed = parseWorkspaceContexts(document);
|
|
85
|
+
if (!parsed.ok) return parsed;
|
|
86
|
+
const payload = { schema_version: WORKSPACE_CONTEXT_SCHEMA_VERSION, workspaces: parsed.contexts };
|
|
87
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
88
|
+
const temp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
89
|
+
try {
|
|
90
|
+
writeFileSync(temp, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
91
|
+
renameSync(temp, file);
|
|
92
|
+
} catch {
|
|
93
|
+
rmSync(temp, { force: true });
|
|
94
|
+
return { ...parsed, ok: false, errors: [{ code: 'WORKSPACE_CONTEXT_FILE_WRITE_FAILED' }], error_code: 'WORKSPACE_CONTEXT_FILE_WRITE_FAILED' };
|
|
95
|
+
}
|
|
96
|
+
return parsed;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function contains(root, target) {
|
|
100
|
+
const rel = relative(resolve(root), resolve(target));
|
|
101
|
+
return rel === '' || (rel !== '..' && !rel.startsWith(`..\\`) && !rel.startsWith('../') && !isAbsolute(rel));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function resolveWorkspaceContext(registry, { workspace_id: workspaceId, cwd } = {}) {
|
|
105
|
+
if (!workspaceId) return { ok: true, context: null };
|
|
106
|
+
const context = registry?.contexts?.[workspaceId];
|
|
107
|
+
if (!context) return { ok: false, code: 'WORKSPACE_CONTEXT_NOT_FOUND', workspace_id: workspaceId };
|
|
108
|
+
if (cwd && !contains(context.repo_root, cwd)) {
|
|
109
|
+
return { ok: false, code: 'WORKSPACE_ROOT_MISMATCH', workspace_id: workspaceId };
|
|
110
|
+
}
|
|
111
|
+
return { ok: true, context: { ...context, instruction_files: [...context.instruction_files], validation_hints: [...context.validation_hints] } };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function buildWorkspaceTask(objective, context) {
|
|
115
|
+
const task = String(objective ?? '').slice(0, 32_768);
|
|
116
|
+
if (!context) return task;
|
|
117
|
+
const lines = [
|
|
118
|
+
'[DSH Workspace Context — references only]',
|
|
119
|
+
`Workspace: ${context.workspace_id}`,
|
|
120
|
+
`Repository root: ${context.repo_root}`,
|
|
121
|
+
];
|
|
122
|
+
if (context.default_branch) lines.push(`Default branch: ${context.default_branch}`);
|
|
123
|
+
if (context.instruction_files.length) lines.push(`Instruction references: ${context.instruction_files.join(', ')}`);
|
|
124
|
+
if (context.validation_hints.length) lines.push(`Validation hints: ${context.validation_hints.join(' | ')}`);
|
|
125
|
+
lines.push('Open referenced files in the workspace when needed; do not expect their contents in this hand-off.', '', '[Delegated objective]', task);
|
|
126
|
+
return lines.join('\n');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function addContextReferences(context, references, { cwd } = {}) {
|
|
130
|
+
const refs = boundedStrings(references);
|
|
131
|
+
if (refs === null || refs.some((entry) => !safeReference(entry))) {
|
|
132
|
+
return { ok: false, code: 'WORKSPACE_CONTEXT_REFS_INVALID' };
|
|
133
|
+
}
|
|
134
|
+
if (refs.length === 0) return { ok: true, context };
|
|
135
|
+
const base = context ?? {
|
|
136
|
+
workspace_id: null,
|
|
137
|
+
repo_root: resolve(cwd ?? process.cwd()),
|
|
138
|
+
default_branch: null,
|
|
139
|
+
instruction_files: [],
|
|
140
|
+
validation_hints: [],
|
|
141
|
+
};
|
|
142
|
+
return {
|
|
143
|
+
ok: true,
|
|
144
|
+
context: { ...base, instruction_files: [...new Set([...base.instruction_files, ...refs])] },
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Read-only workspace preflight used by MCP and local HTTP readiness. It never
|
|
2
|
+
// changes branches, creates worktrees, or touches user files.
|
|
3
|
+
|
|
4
|
+
import { constants } from 'node:fs';
|
|
5
|
+
import { access as fsAccess } from 'node:fs/promises';
|
|
6
|
+
import { inspectRepository } from './workspace-isolation.mjs';
|
|
7
|
+
|
|
8
|
+
export async function assessWorkspaceReadiness({ cwd, inspect = inspectRepository, access = fsAccess } = {}) {
|
|
9
|
+
if (!cwd) return { ok: true, status: 'READY', reason_code: 'WORKSPACE_NOT_REQUESTED', repo_root: null, base_revision: null };
|
|
10
|
+
const repository = await inspect({ cwd });
|
|
11
|
+
if (!repository?.ok) {
|
|
12
|
+
return { ok: false, status: 'UNAVAILABLE', reason_code: repository?.reason ?? 'WORKSPACE_UNAVAILABLE' };
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
await access(repository.repoRoot, constants.W_OK);
|
|
16
|
+
} catch {
|
|
17
|
+
return {
|
|
18
|
+
ok: true, status: 'READ_ONLY', reason_code: 'WORKSPACE_READ_ONLY',
|
|
19
|
+
repo_root: repository.repoRoot, base_revision: repository.baseRevision,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
if (repository.dirty === true) {
|
|
23
|
+
return {
|
|
24
|
+
ok: true, status: 'CONFLICT', reason_code: 'WORKSPACE_DIRTY',
|
|
25
|
+
repo_root: repository.repoRoot, base_revision: repository.baseRevision,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
ok: true, status: 'READY', reason_code: 'WORKSPACE_READY',
|
|
30
|
+
repo_root: repository.repoRoot, base_revision: repository.baseRevision,
|
|
31
|
+
};
|
|
32
|
+
}
|