@ran-sh/dsh-crew 0.3.8 → 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 +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 +343 -62
- package/src/information-flow.mjs +67 -0
- package/src/install/npx-lifecycle.mjs +83 -3
- package/src/job-contracts.mjs +218 -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
|
@@ -1045,21 +1045,81 @@ Commands:
|
|
|
1045
1045
|
integrate show Crew inside the official 3080 UI; backend stays isolated on 3210
|
|
1046
1046
|
detach remove only the official 3080 bridge; isolated 3210 mode remains available
|
|
1047
1047
|
status read-only report of launcher/installed versions and integrations
|
|
1048
|
+
inspect print the machine-readable extension capability/readiness contract
|
|
1049
|
+
jobs machine-first job API: list|get|watch|cancel|submit
|
|
1048
1050
|
update resolve the newest permitted package from the configured npm registry (or
|
|
1049
1051
|
--candidate), stage and validate it, then activate; idempotent when current
|
|
1050
1052
|
uninstall remove the Crew-managed payload, registration, and integrations (config kept)
|
|
1051
1053
|
|
|
1052
1054
|
Options:
|
|
1053
1055
|
--candidate <path> update from a local payload directory or packed .tgz instead of the registry
|
|
1056
|
+
--after <sequence> with jobs watch/get: return canonical events after this cursor
|
|
1057
|
+
--detail <mode> with jobs get/watch: compact (default) or full
|
|
1058
|
+
--request <path> with jobs submit: JSON Job Request document
|
|
1054
1059
|
--purge with uninstall: also remove ~/.config/dsh-crew config/backups (destructive)
|
|
1055
1060
|
--help show this help
|
|
1056
1061
|
|
|
1057
1062
|
Primary install: npm install -g @ran-sh/dsh-crew (then run: dsh-crew install)
|
|
1058
1063
|
Source checkouts use scripts/setup.mjs instead.`;
|
|
1059
1064
|
|
|
1065
|
+
export async function npxInspect({
|
|
1066
|
+
log = console.log,
|
|
1067
|
+
fetchImpl = globalThis.fetch,
|
|
1068
|
+
readConfig = realInstaller.readGlobalConfig,
|
|
1069
|
+
} = {}) {
|
|
1070
|
+
const hubUrl = readConfig()?.hub_url ?? 'http://127.0.0.1:3210';
|
|
1071
|
+
const url = `${String(hubUrl).replace(/\/$/, '')}/_dsh/dsh-crew/extension`;
|
|
1072
|
+
const response = await fetchImpl(url, { headers: { accept: 'application/json' } });
|
|
1073
|
+
const body = await response.json();
|
|
1074
|
+
if (!response.ok || body?.ok !== true || !body.extension) {
|
|
1075
|
+
throw new Error('isolated Crew Hub extension contract is unavailable');
|
|
1076
|
+
}
|
|
1077
|
+
log(JSON.stringify(body.extension, null, 2));
|
|
1078
|
+
return { ok: true, extension: body.extension };
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
export async function npxJobs({
|
|
1082
|
+
args = [],
|
|
1083
|
+
after = 0,
|
|
1084
|
+
detail = 'compact',
|
|
1085
|
+
request,
|
|
1086
|
+
log = console.log,
|
|
1087
|
+
fetchImpl = globalThis.fetch,
|
|
1088
|
+
readConfig = realInstaller.readGlobalConfig,
|
|
1089
|
+
} = {}) {
|
|
1090
|
+
const hubUrl = String(readConfig()?.hub_url ?? 'http://127.0.0.1:3210').replace(/\/$/, '');
|
|
1091
|
+
const base = `${hubUrl}/_dsh/dsh-crew/jobs`;
|
|
1092
|
+
const action = args[0] ?? 'list';
|
|
1093
|
+
const id = args[1];
|
|
1094
|
+
let url = base;
|
|
1095
|
+
let init = { headers: { accept: 'application/json' } };
|
|
1096
|
+
if (action === 'get' || action === 'watch') {
|
|
1097
|
+
if (!id) throw new Error(`jobs ${action} requires a job id`);
|
|
1098
|
+
url = `${base}/${encodeURIComponent(id)}/contract?detail=${detail}&after=${after}`;
|
|
1099
|
+
} else if (action === 'cancel') {
|
|
1100
|
+
if (!id) throw new Error('jobs cancel requires a job id');
|
|
1101
|
+
url = `${base}/${encodeURIComponent(id)}/cancel`;
|
|
1102
|
+
init = { ...init, method: 'POST' };
|
|
1103
|
+
} else if (action === 'submit') {
|
|
1104
|
+
if (!request) throw new Error('jobs submit requires --request <json-file>');
|
|
1105
|
+
const document = JSON.parse(readFileSync(resolve(request), 'utf8'));
|
|
1106
|
+
init = { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify(document) };
|
|
1107
|
+
} else if (action !== 'list') {
|
|
1108
|
+
throw new Error(`unknown jobs action: ${action}`);
|
|
1109
|
+
}
|
|
1110
|
+
const response = await fetchImpl(url, init);
|
|
1111
|
+
const body = await response.json();
|
|
1112
|
+
if (!response.ok || body?.ok === false) throw new Error(body?.error ?? 'Crew jobs API unavailable');
|
|
1113
|
+
log(JSON.stringify(body, null, 2));
|
|
1114
|
+
return { ok: true, body };
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1060
1117
|
function normalizeCommand(argv) {
|
|
1061
1118
|
const flags = argv.slice(1);
|
|
1062
1119
|
let candidate;
|
|
1120
|
+
let after = 0;
|
|
1121
|
+
let detail = 'compact';
|
|
1122
|
+
let request;
|
|
1063
1123
|
for (let index = 0; index < flags.length; index += 1) {
|
|
1064
1124
|
if (flags[index] === '--candidate') {
|
|
1065
1125
|
candidate = flags[index + 1];
|
|
@@ -1069,11 +1129,28 @@ function normalizeCommand(argv) {
|
|
|
1069
1129
|
candidate = flags[index].slice('--candidate='.length);
|
|
1070
1130
|
flags.splice(index, 1);
|
|
1071
1131
|
index -= 1;
|
|
1132
|
+
} else if (flags[index] === '--after' || flags[index] === '--detail' || flags[index] === '--request') {
|
|
1133
|
+
const name = flags[index];
|
|
1134
|
+
const value = flags[index + 1];
|
|
1135
|
+
if (name === '--after') after = Number(value);
|
|
1136
|
+
if (name === '--detail') detail = value;
|
|
1137
|
+
if (name === '--request') request = value;
|
|
1138
|
+
flags.splice(index, 2);
|
|
1139
|
+
index -= 1;
|
|
1140
|
+
} else if (flags[index]?.startsWith('--after=')) {
|
|
1141
|
+
after = Number(flags[index].slice('--after='.length)); flags.splice(index, 1); index -= 1;
|
|
1142
|
+
} else if (flags[index]?.startsWith('--detail=')) {
|
|
1143
|
+
detail = flags[index].slice('--detail='.length); flags.splice(index, 1); index -= 1;
|
|
1144
|
+
} else if (flags[index]?.startsWith('--request=')) {
|
|
1145
|
+
request = flags[index].slice('--request='.length); flags.splice(index, 1); index -= 1;
|
|
1072
1146
|
}
|
|
1073
1147
|
}
|
|
1074
1148
|
const knownFlags = new Set(['--purge']);
|
|
1075
1149
|
const unknown = flags.filter((f) => f.startsWith('--') && !knownFlags.has(f));
|
|
1076
|
-
|
|
1150
|
+
const args = flags.filter((f) => !f.startsWith('--'));
|
|
1151
|
+
if (!Number.isInteger(after) || after < 0) unknown.push('--after');
|
|
1152
|
+
if (!['compact', 'full'].includes(detail)) unknown.push('--detail');
|
|
1153
|
+
return { command: argv[0], purge: flags.includes('--purge'), candidate, after, detail, request, args, unknown };
|
|
1077
1154
|
}
|
|
1078
1155
|
|
|
1079
1156
|
/**
|
|
@@ -1085,7 +1162,7 @@ export async function runNpxCli({
|
|
|
1085
1162
|
error = console.error,
|
|
1086
1163
|
commands = {},
|
|
1087
1164
|
} = {}) {
|
|
1088
|
-
const { command, purge, candidate, unknown } = normalizeCommand(argv);
|
|
1165
|
+
const { command, purge, candidate, after, detail, request, args, unknown } = normalizeCommand(argv);
|
|
1089
1166
|
if (command === '--help' || command === '-h' || command === 'help') {
|
|
1090
1167
|
log(USAGE);
|
|
1091
1168
|
return 0;
|
|
@@ -1094,7 +1171,7 @@ export async function runNpxCli({
|
|
|
1094
1171
|
error(USAGE);
|
|
1095
1172
|
return 1;
|
|
1096
1173
|
}
|
|
1097
|
-
if (unknown.length > 0 || !['install', 'integrate', 'detach', 'status', 'update', 'uninstall'].includes(command)) {
|
|
1174
|
+
if (unknown.length > 0 || !['install', 'integrate', 'detach', 'status', 'inspect', 'jobs', 'update', 'uninstall'].includes(command)) {
|
|
1098
1175
|
error(`unknown command: ${command ?? '<none>'}\n\n${USAGE}`);
|
|
1099
1176
|
return 1;
|
|
1100
1177
|
}
|
|
@@ -1104,12 +1181,15 @@ export async function runNpxCli({
|
|
|
1104
1181
|
integrate: commands.integrate ?? npxIntegrate,
|
|
1105
1182
|
detach: commands.detach ?? npxDetach,
|
|
1106
1183
|
status: commands.status ?? npxStatus,
|
|
1184
|
+
inspect: commands.inspect ?? npxInspect,
|
|
1185
|
+
jobs: commands.jobs ?? npxJobs,
|
|
1107
1186
|
update: commands.update ?? npxUpdate,
|
|
1108
1187
|
uninstall: commands.uninstall ?? npxUninstall,
|
|
1109
1188
|
};
|
|
1110
1189
|
let result;
|
|
1111
1190
|
if (command === 'uninstall') result = await actions.uninstall({ purge, log });
|
|
1112
1191
|
else if (command === 'update') result = await actions.update({ candidate, log });
|
|
1192
|
+
else if (command === 'jobs') result = await actions.jobs({ args, after, detail, request, log });
|
|
1113
1193
|
else result = await actions[command]({ log });
|
|
1114
1194
|
return result?.ok === false ? 1 : 0;
|
|
1115
1195
|
} catch (err) {
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// Versioned, transport-neutral contracts for one DSH Crew workflow.
|
|
2
|
+
//
|
|
3
|
+
// The workflow runtime may keep richer internal state for recovery and debug,
|
|
4
|
+
// but callers receive this bounded event/evidence layer by default. Full
|
|
5
|
+
// internal details remain available only through an explicit detail=full
|
|
6
|
+
// request at the MCP boundary.
|
|
7
|
+
|
|
8
|
+
export const JOB_CONTRACT_SCHEMA_VERSION = 1;
|
|
9
|
+
|
|
10
|
+
export const JOB_EVENT_TYPES = Object.freeze([
|
|
11
|
+
'job.created',
|
|
12
|
+
'job.started',
|
|
13
|
+
'model.selected',
|
|
14
|
+
'model.fallback',
|
|
15
|
+
'worker.started',
|
|
16
|
+
'worker.completed',
|
|
17
|
+
'review.started',
|
|
18
|
+
'review.completed',
|
|
19
|
+
'approval.required',
|
|
20
|
+
'job.completed',
|
|
21
|
+
'job.failed',
|
|
22
|
+
'job.cancelled',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const EVENT_TYPE_SET = new Set(JOB_EVENT_TYPES);
|
|
26
|
+
const RESULT_STATUSES = Object.freeze(['PASS', 'FAIL', 'PARTIAL', 'BLOCKED']);
|
|
27
|
+
|
|
28
|
+
function boundedText(value, limit = 800) {
|
|
29
|
+
if (value == null) return null;
|
|
30
|
+
const text = String(value).trim();
|
|
31
|
+
if (!text) return null;
|
|
32
|
+
return text.length <= limit ? text : `${text.slice(0, limit)}…`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function boundedStrings(values, { count = 80, length = 400 } = {}) {
|
|
36
|
+
if (!Array.isArray(values)) return [];
|
|
37
|
+
return values.slice(0, count)
|
|
38
|
+
.map((value) => boundedText(value, length))
|
|
39
|
+
.filter(Boolean);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function boundedTests(values) {
|
|
43
|
+
if (!Array.isArray(values)) return [];
|
|
44
|
+
return values.slice(0, 40).map((test) => ({
|
|
45
|
+
status: RESULT_STATUSES.includes(test?.status) ? test.status : boundedText(test?.status, 40),
|
|
46
|
+
command: boundedText(test?.command, 300),
|
|
47
|
+
summary: boundedText(test?.summary, 500),
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Create one canonical, monotonically sequenced workflow event. */
|
|
52
|
+
export function createCanonicalJobEvent({
|
|
53
|
+
jobId,
|
|
54
|
+
type,
|
|
55
|
+
sequence,
|
|
56
|
+
at,
|
|
57
|
+
role = null,
|
|
58
|
+
attempt = null,
|
|
59
|
+
data = {},
|
|
60
|
+
} = {}) {
|
|
61
|
+
if (!EVENT_TYPE_SET.has(type)) throw new Error(`unknown canonical job event: ${String(type)}`);
|
|
62
|
+
if (typeof jobId !== 'string' || !jobId) throw new Error('canonical job event requires jobId');
|
|
63
|
+
if (!Number.isInteger(sequence) || sequence < 1) throw new Error('canonical job event requires a positive sequence');
|
|
64
|
+
return {
|
|
65
|
+
schema_version: JOB_CONTRACT_SCHEMA_VERSION,
|
|
66
|
+
event_id: `${jobId}:${sequence}`,
|
|
67
|
+
job_id: jobId,
|
|
68
|
+
sequence,
|
|
69
|
+
type,
|
|
70
|
+
at,
|
|
71
|
+
role,
|
|
72
|
+
attempt,
|
|
73
|
+
data: data && typeof data === 'object' && !Array.isArray(data) ? { ...data } : {},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function evidenceStatus(view) {
|
|
78
|
+
if (view?.status === 'cancelled' || view?.phase === 'cancelled') return 'BLOCKED';
|
|
79
|
+
if (view?.status === 'failed' || view?.phase === 'failed' || view?.outcome?.execution_status === 'failed') return 'FAIL';
|
|
80
|
+
if (view?.review?.verdict === 'request_changes' || view?.outcome?.task_status === 'partial') return 'PARTIAL';
|
|
81
|
+
if (view?.outcome?.task_status === 'blocked') return 'BLOCKED';
|
|
82
|
+
if (view?.status === 'done' && view?.outcome?.task_status === 'success') return 'PASS';
|
|
83
|
+
return 'PARTIAL';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function compactSelectionTrace(attempts) {
|
|
87
|
+
if (!Array.isArray(attempts)) return [];
|
|
88
|
+
return attempts.slice(0, 16).map((attempt) => {
|
|
89
|
+
const trace = attempt?.selection_trace ?? {};
|
|
90
|
+
const selected = trace.selected ?? (
|
|
91
|
+
attempt?.provider || attempt?.model
|
|
92
|
+
? { provider: attempt?.provider ?? null, model: attempt?.model ?? null, source: attempt?.selection_source ?? null }
|
|
93
|
+
: null
|
|
94
|
+
);
|
|
95
|
+
const candidates = Array.isArray(trace.ordered_candidates)
|
|
96
|
+
? trace.ordered_candidates.slice(0, 32).map((candidate) => ({
|
|
97
|
+
model: candidate?.model ?? null,
|
|
98
|
+
provider: candidate?.provider ?? null,
|
|
99
|
+
status: String(candidate?.status ?? 'CANDIDATE').toUpperCase(),
|
|
100
|
+
...(candidate?.reason ? { reason: boundedText(candidate.reason, 200) } : {}),
|
|
101
|
+
}))
|
|
102
|
+
: selected ? [{ model: selected.model ?? null, provider: selected.provider ?? null, status: 'SELECTED' }] : [];
|
|
103
|
+
return {
|
|
104
|
+
attempt: attempt?.attempt ?? null,
|
|
105
|
+
role: attempt?.role ?? null,
|
|
106
|
+
selected,
|
|
107
|
+
selected_model: selected?.model ?? null,
|
|
108
|
+
candidates,
|
|
109
|
+
fallback_chain: trace.fallback_reason ? [boundedText(trace.fallback_reason, 200)] : [],
|
|
110
|
+
decision_reason: selected?.source ?? attempt?.selection_source ?? null,
|
|
111
|
+
fallback_reason: trace.fallback_reason ?? null,
|
|
112
|
+
escalation_reason: trace.escalation_reason ?? null,
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function changedFilesFromView(view) {
|
|
118
|
+
if (Array.isArray(view?.candidate?.changed_files)) return view.candidate.changed_files;
|
|
119
|
+
const changes = view?.workspace_diff?.changes;
|
|
120
|
+
if (!changes || typeof changes !== 'object') return [];
|
|
121
|
+
return [...new Set([
|
|
122
|
+
...(Array.isArray(changes.modified) ? changes.modified : []),
|
|
123
|
+
...(Array.isArray(changes.deleted) ? changes.deleted : []),
|
|
124
|
+
...(Array.isArray(changes.renamed) ? changes.renamed : []),
|
|
125
|
+
...(Array.isArray(changes.untracked) ? changes.untracked : []),
|
|
126
|
+
])];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Build the machine-first Result Contract for a workflow.
|
|
131
|
+
*
|
|
132
|
+
* Deliberately excluded: worker prose, raw provider payloads and candidate
|
|
133
|
+
* patch text. The envelope contains enough evidence for orchestration while
|
|
134
|
+
* artifact inspection remains an explicit follow-up operation.
|
|
135
|
+
*/
|
|
136
|
+
export function buildEvidenceEnvelope(view = {}) {
|
|
137
|
+
const outcome = view.outcome ?? {};
|
|
138
|
+
const candidate = view.candidate ?? {};
|
|
139
|
+
const review = view.review ?? null;
|
|
140
|
+
const errorMessage = boundedText(view.error, 1000);
|
|
141
|
+
const changedFiles = boundedStrings(changedFilesFromView(view), { count: 120, length: 500 });
|
|
142
|
+
const status = evidenceStatus(view);
|
|
143
|
+
return {
|
|
144
|
+
schema_version: JOB_CONTRACT_SCHEMA_VERSION,
|
|
145
|
+
job_id: view.id ?? null,
|
|
146
|
+
client_job_id: view.client_job_id ?? null,
|
|
147
|
+
role: view.role ?? null,
|
|
148
|
+
status,
|
|
149
|
+
summary: {
|
|
150
|
+
phase: view.phase ?? null,
|
|
151
|
+
task_status: outcome.task_status ?? null,
|
|
152
|
+
execution_status: outcome.execution_status ?? null,
|
|
153
|
+
tests_status: outcome.tests_status ?? null,
|
|
154
|
+
delivery_complete: outcome.delivery?.complete === true,
|
|
155
|
+
review_verdict: review?.verdict ?? null,
|
|
156
|
+
},
|
|
157
|
+
selection_trace: compactSelectionTrace(view.child_attempts),
|
|
158
|
+
changed_files: changedFiles,
|
|
159
|
+
changes: boundedStrings(outcome.changes),
|
|
160
|
+
tests: boundedTests(outcome.tests),
|
|
161
|
+
risks: boundedStrings(outcome.risks),
|
|
162
|
+
unverified: boundedStrings(outcome.unverified),
|
|
163
|
+
review: review ? {
|
|
164
|
+
verdict: review.verdict ?? null,
|
|
165
|
+
status: review.status ?? null,
|
|
166
|
+
findings: boundedStrings(review.findings),
|
|
167
|
+
evidence: boundedStrings(review.evidence),
|
|
168
|
+
risks: boundedStrings(review.risks),
|
|
169
|
+
delivery_complete: review.delivery_complete === true,
|
|
170
|
+
mutated_candidate: review.mutated_candidate === true,
|
|
171
|
+
} : null,
|
|
172
|
+
artifacts: {
|
|
173
|
+
candidate_available: view.candidate_available === true || changedFiles.length > 0,
|
|
174
|
+
candidate_fingerprint: candidate.fingerprint ?? null,
|
|
175
|
+
base_revision: candidate.base_revision ?? view.base_revision ?? null,
|
|
176
|
+
workspace_retained: view.workspace_retained === true,
|
|
177
|
+
candidate_capture_failed: view.candidate_capture_failed === true,
|
|
178
|
+
},
|
|
179
|
+
errors: errorMessage || view.error_code ? [{ code: view.error_code ?? null, message: errorMessage }] : [],
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Project a rich internal workflow view onto compact or explicit full detail. */
|
|
184
|
+
export function projectWorkflowView(view, { detail = 'compact', afterSequence = 0 } = {}) {
|
|
185
|
+
if (!view || typeof view !== 'object') return view;
|
|
186
|
+
const evidence = buildEvidenceEnvelope(view);
|
|
187
|
+
const allCanonical = Array.isArray(view.canonical_events) ? view.canonical_events : [];
|
|
188
|
+
const cursor = allCanonical.at(-1)?.sequence ?? view.event_cursor ?? 0;
|
|
189
|
+
const canonicalEvents = allCanonical.filter((event) => Number(event?.sequence) > afterSequence);
|
|
190
|
+
const eventProjection = {
|
|
191
|
+
canonical_events: canonicalEvents,
|
|
192
|
+
event_cursor: cursor,
|
|
193
|
+
events_truncated_before_cursor: afterSequence > 0 && canonicalEvents.length > 0
|
|
194
|
+
? canonicalEvents[0].sequence !== afterSequence + 1
|
|
195
|
+
: false,
|
|
196
|
+
};
|
|
197
|
+
if (detail === 'full') return { ...view, ...eventProjection, detail: 'full', evidence };
|
|
198
|
+
|
|
199
|
+
const {
|
|
200
|
+
candidate: _candidate,
|
|
201
|
+
outcome: _outcome,
|
|
202
|
+
review: _review,
|
|
203
|
+
events: _legacyEvents,
|
|
204
|
+
child_attempts: _childAttempts,
|
|
205
|
+
result: _rawResult,
|
|
206
|
+
workspace_diff: _workspaceDiff,
|
|
207
|
+
reasonDetail: _reasonDetail,
|
|
208
|
+
...safe
|
|
209
|
+
} = view;
|
|
210
|
+
return {
|
|
211
|
+
...safe,
|
|
212
|
+
...eventProjection,
|
|
213
|
+
error: boundedText(view.error, 1000),
|
|
214
|
+
cleanup_warning: boundedText(view.cleanup_warning, 1000),
|
|
215
|
+
detail: 'compact',
|
|
216
|
+
evidence,
|
|
217
|
+
};
|
|
218
|
+
}
|
package/src/mcp-runtime.mjs
CHANGED
|
@@ -25,6 +25,13 @@ const SESSION_CONFIG_KEYS = [
|
|
|
25
25
|
'flash_state', 'pro_state', 'pro_reviews_flash',
|
|
26
26
|
];
|
|
27
27
|
|
|
28
|
+
const HUB_POLL_SLICE_SECONDS = 20;
|
|
29
|
+
|
|
30
|
+
/** Keep each Hub request below intermediary/MCP transport idle deadlines. */
|
|
31
|
+
export function hubPollWaitSeconds(remainingMs) {
|
|
32
|
+
return Math.max(1, Math.min(HUB_POLL_SLICE_SECONDS, Math.ceil(remainingMs / 1000)));
|
|
33
|
+
}
|
|
34
|
+
|
|
28
35
|
/**
|
|
29
36
|
* Merge only defined session overrides onto the live global config before the
|
|
30
37
|
* workflow snapshots its policy. This keeps dsh_worker_config authoritative
|
|
@@ -124,7 +131,7 @@ export function buildMcpWorkflowRuntime(deps) {
|
|
|
124
131
|
const executeAttempt = async (spec) => {
|
|
125
132
|
const session = getSessionConfig?.() ?? {};
|
|
126
133
|
const effort = spec.effort ?? session.default_effort ?? 'max';
|
|
127
|
-
const timeoutMs = (
|
|
134
|
+
const timeoutMs = (spec.timeout_seconds ?? session.default_timeout_seconds ?? 1800) * 1000;
|
|
128
135
|
const tier = resolveAttemptTier({ role: spec.role, attempt: spec.attempt, modelClassHint: spec.model_class_hint });
|
|
129
136
|
const delivery = spec.role === 'reviewer' || spec.delivery === 'review' ? 'review' : 'coding';
|
|
130
137
|
const source = spec.source ?? 'api';
|
|
@@ -160,7 +167,7 @@ export function buildMcpWorkflowRuntime(deps) {
|
|
|
160
167
|
try { cancelled = await hub.cancel(spawned.id); } catch {}
|
|
161
168
|
return timedOutAttempt(cancelled, spec, timeoutMs);
|
|
162
169
|
}
|
|
163
|
-
const waitSeconds =
|
|
170
|
+
const waitSeconds = hubPollWaitSeconds(remainingMs);
|
|
164
171
|
resolved = await hub.get(spawned.id, waitSeconds);
|
|
165
172
|
}
|
|
166
173
|
return attemptFromView(resolved, spec);
|
|
@@ -199,18 +206,19 @@ export function buildMcpWorkflowRuntime(deps) {
|
|
|
199
206
|
const allocateWorkspace = async (job) => {
|
|
200
207
|
const config = getConfig();
|
|
201
208
|
const isolation = config.execution?.isolation ?? 'worktree';
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
|
|
209
|
+
// Explicit shared mode uses the requested workspace. Readonly profiles,
|
|
210
|
+
// including the default Reviewer, use a disposable worktree below so an
|
|
211
|
+
// accidental edit can be detected and never pollutes the primary tree.
|
|
212
|
+
if (job.requested_isolation === 'shared' || isolation === 'shared') {
|
|
205
213
|
return { ok: true, execution_cwd: job.requested_cwd, isolation: 'shared', base_revision: null, primary_workspace_dirty: false, handle: null };
|
|
206
214
|
}
|
|
207
|
-
//
|
|
215
|
+
// Isolated roles fail closed when the workspace
|
|
208
216
|
// is not a git repo — never silently fall back to sharing the working tree.
|
|
209
217
|
const repo = await inspectRepository({ cwd: job.requested_cwd });
|
|
210
218
|
if (!repo.ok) {
|
|
211
|
-
return { ok: false, reason: repo.reason ?? 'ISOLATION_UNAVAILABLE', error:
|
|
219
|
+
return { ok: false, reason: repo.reason ?? 'ISOLATION_UNAVAILABLE', error: `${job.role ?? 'worker'} needs an isolated git worktree: ${repo.error ?? repo.reason}` };
|
|
212
220
|
}
|
|
213
|
-
const created = await createIsolatedWorkspace({ cwd: job.requested_cwd, jobId: job.id, baseRevision: repo.baseRevision });
|
|
221
|
+
const created = await createIsolatedWorkspace({ cwd: job.requested_cwd, jobId: job.id, baseRevision: job.workspace_branch ?? repo.baseRevision });
|
|
214
222
|
if (!created.ok) {
|
|
215
223
|
return { ok: false, reason: created.reason ?? 'WORKTREE_CREATE_FAILED', error: `worktree create failed: ${created.error ?? ''}` };
|
|
216
224
|
}
|
|
@@ -21,6 +21,18 @@ function isLocalHostname(hostname) {
|
|
|
21
21
|
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1';
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
export function resolveCrewBridgeTarget(env = process.env) {
|
|
25
|
+
const raw = env?.DSH_CREW_BRIDGE_TARGET;
|
|
26
|
+
if (!raw) return CREW_BRIDGE_TARGET;
|
|
27
|
+
try {
|
|
28
|
+
const target = new URL(raw);
|
|
29
|
+
if (target.protocol !== 'http:' || !isLocalHostname(target.hostname.toLowerCase()) || target.pathname !== '/' || target.search || target.hash) return CREW_BRIDGE_TARGET;
|
|
30
|
+
const port = Number(target.port);
|
|
31
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return CREW_BRIDGE_TARGET;
|
|
32
|
+
return target.origin;
|
|
33
|
+
} catch { return CREW_BRIDGE_TARGET; }
|
|
34
|
+
}
|
|
35
|
+
|
|
24
36
|
export function isTrustedLocalRequest(req) {
|
|
25
37
|
if (!isLoopbackAddress(req?.socket?.remoteAddress)) return false;
|
|
26
38
|
const host = typeof req?.headers?.host === 'string' ? req.headers.host.trim().toLowerCase() : '';
|
|
@@ -74,9 +86,9 @@ async function readBoundedBody(req, limit = MAX_BODY_BYTES) {
|
|
|
74
86
|
return Buffer.concat(chunks);
|
|
75
87
|
}
|
|
76
88
|
|
|
77
|
-
async function defaultHealthCheck(fetchImpl = globalThis.fetch) {
|
|
89
|
+
async function defaultHealthCheck(fetchImpl = globalThis.fetch, bridgeTarget = CREW_BRIDGE_TARGET) {
|
|
78
90
|
try {
|
|
79
|
-
const response = await fetchImpl(`${
|
|
91
|
+
const response = await fetchImpl(`${bridgeTarget}${CREW_BRIDGE_PREFIX}/ping`, {
|
|
80
92
|
signal: AbortSignal.timeout(1_500),
|
|
81
93
|
headers: { accept: 'application/json' },
|
|
82
94
|
});
|
|
@@ -89,7 +101,8 @@ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
89
101
|
export function createCrewSidecarSupervisor({
|
|
90
102
|
home = homedir(),
|
|
91
103
|
exists = existsSync,
|
|
92
|
-
|
|
104
|
+
bridgeTarget = resolveCrewBridgeTarget(),
|
|
105
|
+
healthCheck = () => defaultHealthCheck(globalThis.fetch, bridgeTarget),
|
|
93
106
|
spawnImpl = spawn,
|
|
94
107
|
wait = delay,
|
|
95
108
|
maxAttempts = 120,
|
|
@@ -99,6 +112,7 @@ export function createCrewSidecarSupervisor({
|
|
|
99
112
|
let runningChild = null;
|
|
100
113
|
const runtime = crewDshRuntimeModule({ home });
|
|
101
114
|
const dshHome = crewDshHome({ home });
|
|
115
|
+
const bridgePort = new URL(bridgeTarget).port;
|
|
102
116
|
|
|
103
117
|
async function start() {
|
|
104
118
|
if (await healthCheck()) return { ok: true, started: false };
|
|
@@ -106,7 +120,7 @@ export function createCrewSidecarSupervisor({
|
|
|
106
120
|
const childAlive = runningChild && runningChild.killed !== true && runningChild.exitCode == null;
|
|
107
121
|
if (!childAlive) {
|
|
108
122
|
runningChild = spawnImpl(process.execPath, [
|
|
109
|
-
runtime, '--profile', 'dsh-crew', '--host', '127.0.0.1', '--port',
|
|
123
|
+
runtime, '--profile', 'dsh-crew', '--host', '127.0.0.1', '--port', bridgePort,
|
|
110
124
|
], {
|
|
111
125
|
cwd: dshHome,
|
|
112
126
|
env: { ...process.env, DSH_HOME: dshHome },
|
|
@@ -138,6 +152,7 @@ const processSupervisor = createCrewSidecarSupervisor();
|
|
|
138
152
|
export async function proxyCrewRequest(req, res, {
|
|
139
153
|
fetchImpl = globalThis.fetch,
|
|
140
154
|
ensureBackend = () => processSupervisor.ensure(),
|
|
155
|
+
bridgeTarget = resolveCrewBridgeTarget(),
|
|
141
156
|
} = {}) {
|
|
142
157
|
if (!isTrustedLocalRequest(req)) {
|
|
143
158
|
sendJson(res, 403, { ok: false, code: 'LOCAL_SAME_ORIGIN_ONLY' });
|
|
@@ -154,7 +169,7 @@ export async function proxyCrewRequest(req, res, {
|
|
|
154
169
|
if (backend?.ok === false) throw new Error('backend unavailable');
|
|
155
170
|
const method = String(req.method ?? 'GET').toUpperCase();
|
|
156
171
|
const bodyBuffer = method === 'GET' || method === 'HEAD' ? null : await readBoundedBody(req);
|
|
157
|
-
const response = await fetchImpl(`${
|
|
172
|
+
const response = await fetchImpl(`${bridgeTarget}${req.url}`, {
|
|
158
173
|
method,
|
|
159
174
|
headers: safeHeaders(req.headers),
|
|
160
175
|
body: bodyBuffer === null ? undefined : new Blob([bodyBuffer]),
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Versioned, narrow Worker/Reviewer profiles. Profiles configure one DSH
|
|
2
|
+
// delegation; they are not general Agent personas and never contain prompts or
|
|
3
|
+
// credentials.
|
|
4
|
+
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
|
|
9
|
+
export const ROLE_PROFILE_SCHEMA_VERSION = 1;
|
|
10
|
+
export const DEFAULT_ROLE_PROFILES = Object.freeze({
|
|
11
|
+
'worker-default': Object.freeze({
|
|
12
|
+
role: 'worker', routing: 'auto', isolation: 'worktree', fallback: true,
|
|
13
|
+
timeout_seconds: 1800, review_strictness: 'standard',
|
|
14
|
+
}),
|
|
15
|
+
'reviewer-default': Object.freeze({
|
|
16
|
+
role: 'reviewer', routing: 'stable', isolation: 'readonly', fallback: false,
|
|
17
|
+
timeout_seconds: 1800, review_strictness: 'strict',
|
|
18
|
+
}),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
22
|
+
const ROLES = new Set(['worker', 'reviewer']);
|
|
23
|
+
const ROUTING = new Set(['auto', 'priority', 'stable']);
|
|
24
|
+
const ISOLATION = new Set(['worktree', 'readonly', 'shared']);
|
|
25
|
+
const STRICTNESS = new Set(['standard', 'strict']);
|
|
26
|
+
|
|
27
|
+
function normalizeProfile(id, raw) {
|
|
28
|
+
if (!ID.test(id) || !raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
29
|
+
if (!ROLES.has(raw.role)) return null;
|
|
30
|
+
const base = DEFAULT_ROLE_PROFILES[`${raw.role}-default`];
|
|
31
|
+
const routing = raw.routing ?? base.routing;
|
|
32
|
+
const isolation = raw.isolation ?? base.isolation;
|
|
33
|
+
const reviewStrictness = raw.review_strictness ?? base.review_strictness;
|
|
34
|
+
const timeout = raw.timeout_seconds ?? base.timeout_seconds;
|
|
35
|
+
if (!ROUTING.has(routing) || !ISOLATION.has(isolation) || !STRICTNESS.has(reviewStrictness)) return null;
|
|
36
|
+
if (!Number.isInteger(timeout) || timeout < 1 || timeout > 7200) return null;
|
|
37
|
+
if (raw.fallback !== undefined && typeof raw.fallback !== 'boolean') return null;
|
|
38
|
+
return {
|
|
39
|
+
role: raw.role,
|
|
40
|
+
routing,
|
|
41
|
+
isolation,
|
|
42
|
+
fallback: raw.fallback ?? base.fallback,
|
|
43
|
+
timeout_seconds: timeout,
|
|
44
|
+
review_strictness: reviewStrictness,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function roleProfilesFile({ home = homedir() } = {}) {
|
|
49
|
+
return join(home, '.config', 'dsh-crew', 'profiles.json');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function loadRoleProfiles({ home = homedir(), file = roleProfilesFile({ home }) } = {}) {
|
|
53
|
+
if (!existsSync(file)) {
|
|
54
|
+
return { schema_version: ROLE_PROFILE_SCHEMA_VERSION, ok: true, source: 'defaults', profiles: { ...DEFAULT_ROLE_PROFILES }, errors: [] };
|
|
55
|
+
}
|
|
56
|
+
let raw;
|
|
57
|
+
try { raw = JSON.parse(readFileSync(file, 'utf8')); } catch {
|
|
58
|
+
return { schema_version: ROLE_PROFILE_SCHEMA_VERSION, ok: false, source: 'file', profiles: { ...DEFAULT_ROLE_PROFILES }, errors: [{ code: 'PROFILE_FILE_INVALID' }] };
|
|
59
|
+
}
|
|
60
|
+
return parseRoleProfiles(raw);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseRoleProfiles(raw) {
|
|
64
|
+
const errors = [];
|
|
65
|
+
const profiles = { ...DEFAULT_ROLE_PROFILES };
|
|
66
|
+
if (raw?.schema_version !== ROLE_PROFILE_SCHEMA_VERSION || !raw.profiles || typeof raw.profiles !== 'object' || Array.isArray(raw.profiles)) {
|
|
67
|
+
return { schema_version: ROLE_PROFILE_SCHEMA_VERSION, ok: false, source: 'file', profiles, errors: [{ code: 'PROFILE_FILE_INVALID' }] };
|
|
68
|
+
}
|
|
69
|
+
for (const [id, value] of Object.entries(raw.profiles)) {
|
|
70
|
+
if (id in DEFAULT_ROLE_PROFILES) {
|
|
71
|
+
const normalized = normalizeProfile(id, value);
|
|
72
|
+
if (!normalized || JSON.stringify(normalized) !== JSON.stringify(DEFAULT_ROLE_PROFILES[id])) {
|
|
73
|
+
errors.push({ code: 'PROFILE_DEFAULT_RESERVED', profile_id: id });
|
|
74
|
+
}
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const profile = normalizeProfile(id, value);
|
|
78
|
+
if (!profile) errors.push({ code: 'PROFILE_INVALID', profile_id: ID.test(id) ? id : '<invalid>' });
|
|
79
|
+
else profiles[id] = profile;
|
|
80
|
+
}
|
|
81
|
+
return { schema_version: ROLE_PROFILE_SCHEMA_VERSION, ok: errors.length === 0, source: 'file', profiles, errors: errors.slice(0, 32) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function saveRoleProfiles(document, { home = homedir(), file = roleProfilesFile({ home }) } = {}) {
|
|
85
|
+
const parsed = parseRoleProfiles(document);
|
|
86
|
+
if (!parsed.ok) return parsed;
|
|
87
|
+
const custom = Object.fromEntries(Object.entries(parsed.profiles).filter(([id]) => !(id in DEFAULT_ROLE_PROFILES)));
|
|
88
|
+
const payload = { schema_version: ROLE_PROFILE_SCHEMA_VERSION, profiles: custom };
|
|
89
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
90
|
+
const temp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
91
|
+
try {
|
|
92
|
+
writeFileSync(temp, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
93
|
+
renameSync(temp, file);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
rmSync(temp, { force: true });
|
|
96
|
+
return { ...parsed, ok: false, errors: [{ code: 'PROFILE_FILE_WRITE_FAILED' }], error_code: 'PROFILE_FILE_WRITE_FAILED' };
|
|
97
|
+
}
|
|
98
|
+
return { ...parsed, source: 'file' };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function resolveRoleProfile(registry, profileId, role = 'worker') {
|
|
102
|
+
const id = profileId ?? `${role}-default`;
|
|
103
|
+
const profile = registry?.profiles?.[id];
|
|
104
|
+
if (!profile) return { ok: false, code: 'PROFILE_NOT_FOUND', profile_id: id };
|
|
105
|
+
if (profile.role !== role) return { ok: false, code: 'PROFILE_ROLE_MISMATCH', profile_id: id, expected_role: role };
|
|
106
|
+
return { ok: true, profile_id: id, profile: { ...profile } };
|
|
107
|
+
}
|
package/src/runtime-identity.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// Keep this module pure and dependency-free so Hub, MCP and tests all use the
|
|
9
9
|
// exact same compatibility rules.
|
|
10
10
|
|
|
11
|
-
export const RUNTIME_VERSION = '0.
|
|
11
|
+
export const RUNTIME_VERSION = '0.4.0';
|
|
12
12
|
export const HUB_PROTOCOL_VERSION = 1;
|
|
13
13
|
|
|
14
14
|
export const HUB_CAPABILITIES = Object.freeze([
|
|
@@ -21,6 +21,11 @@ export const HUB_CAPABILITIES = Object.freeze([
|
|
|
21
21
|
'model-catalog',
|
|
22
22
|
'presets',
|
|
23
23
|
'config',
|
|
24
|
+
'canonical-events',
|
|
25
|
+
'evidence',
|
|
26
|
+
'profiles',
|
|
27
|
+
'workspace-context',
|
|
28
|
+
'extension-contract',
|
|
24
29
|
]);
|
|
25
30
|
|
|
26
31
|
// Capabilities the current MCP workflow depends on for full Hub execution.
|