@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
|
@@ -40,6 +40,11 @@ import { homedir } from 'node:os';
|
|
|
40
40
|
import * as realInstaller from './install.mjs';
|
|
41
41
|
import { crewProfileDir } from './install.mjs';
|
|
42
42
|
import { ensureCrewDshRuntime, ensureCrewPluginRegistration, removeCrewPluginRegistration } from '../dsh-cli-runtime.mjs';
|
|
43
|
+
import {
|
|
44
|
+
ensureOfficialWebIntegration,
|
|
45
|
+
officialWebIntegrationStatus,
|
|
46
|
+
removeOfficialWebIntegration,
|
|
47
|
+
} from './official-web.mjs';
|
|
43
48
|
|
|
44
49
|
export const CREW_APP_DIRNAME = 'app';
|
|
45
50
|
export const RELEASES_DIRNAME = 'releases';
|
|
@@ -429,7 +434,11 @@ export function validateInstalledPayload(dir, { expectedName, expectedVersion, a
|
|
|
429
434
|
}
|
|
430
435
|
}
|
|
431
436
|
}
|
|
432
|
-
for (const rel of [
|
|
437
|
+
for (const rel of [
|
|
438
|
+
'cordis.patch.yml', 'src/server.mjs', 'src/hub/entry.mjs', 'lib/client.js', 'bin/dsh-crew.mjs',
|
|
439
|
+
'official-web-bridge/package.json', 'official-web-bridge/cordis.patch.yml',
|
|
440
|
+
'official-web-bridge/entry.mjs', 'official-web-bridge/lib/client.js',
|
|
441
|
+
]) {
|
|
433
442
|
if (!existsSync(join(dir, rel))) errors.push(`payload artifact missing: ${rel}`);
|
|
434
443
|
}
|
|
435
444
|
if (!allowIncomplete && existsSync(join(dir, INCOMPLETE_MARKER))) errors.push('release is still marked incomplete');
|
|
@@ -553,9 +562,52 @@ async function activateRelease({ home, releaseDir, manifest, log, installer }) {
|
|
|
553
562
|
return false;
|
|
554
563
|
}
|
|
555
564
|
log('✓ Claude Code integration');
|
|
565
|
+
|
|
566
|
+
const official = officialWebIntegrationStatus({ home });
|
|
567
|
+
if (official.enabled) {
|
|
568
|
+
const repaired = ensureOfficialWebIntegration({ home, releaseDir });
|
|
569
|
+
if (!repaired.ok) {
|
|
570
|
+
log(`✗ official 3080 bridge repair failed (${repaired.code ?? 'unknown'})`);
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
573
|
+
log('✓ official 3080 UI bridge → isolated Crew backend on 3210');
|
|
574
|
+
}
|
|
556
575
|
return true;
|
|
557
576
|
}
|
|
558
577
|
|
|
578
|
+
export async function npxIntegrate({ home = homedir(), log = console.log } = {}) {
|
|
579
|
+
const pointer = readCurrentPointer({ home });
|
|
580
|
+
if (!pointer || !existsSync(pointer.path)) {
|
|
581
|
+
log('✗ install DSH Crew before enabling the official 3080 integration');
|
|
582
|
+
return { ok: false, error: 'DSH Crew is not installed' };
|
|
583
|
+
}
|
|
584
|
+
const validated = validateInstalledPayload(pointer.path, { expectedName: pointer.name, expectedVersion: pointer.version });
|
|
585
|
+
if (!validated.ok) {
|
|
586
|
+
log('✗ installed DSH Crew payload is damaged; run dsh-crew update first');
|
|
587
|
+
return { ok: false, error: 'installed payload invalid' };
|
|
588
|
+
}
|
|
589
|
+
const result = ensureOfficialWebIntegration({ home, releaseDir: pointer.path });
|
|
590
|
+
if (!result.ok) {
|
|
591
|
+
log(`✗ official 3080 integration failed (${result.code ?? 'unknown'})`);
|
|
592
|
+
return { ok: false, error: result.code ?? 'integration failed' };
|
|
593
|
+
}
|
|
594
|
+
log(result.changed
|
|
595
|
+
? '✓ official 3080 UI connected to the isolated Crew backend on 3210'
|
|
596
|
+
: '- official 3080 UI integration already healthy');
|
|
597
|
+
log(` backup: ${result.backupFile}`);
|
|
598
|
+
return { ok: true, changed: result.changed, backupFile: result.backupFile };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
export async function npxDetach({ home = homedir(), log = console.log } = {}) {
|
|
602
|
+
const result = removeOfficialWebIntegration({ home });
|
|
603
|
+
if (!result.ok) {
|
|
604
|
+
log(`✗ official 3080 integration removal failed (${result.code ?? 'unknown'})`);
|
|
605
|
+
return { ok: false, error: result.code ?? 'detach failed' };
|
|
606
|
+
}
|
|
607
|
+
log(result.removed ? '✓ official 3080 bridge removed; isolated 3210 mode remains available' : '- official 3080 bridge already absent');
|
|
608
|
+
return { ok: true, removed: result.removed };
|
|
609
|
+
}
|
|
610
|
+
|
|
559
611
|
export async function npxInstall({
|
|
560
612
|
home = homedir(),
|
|
561
613
|
log = console.log,
|
|
@@ -895,6 +947,8 @@ export function npxStatus({
|
|
|
895
947
|
const st = installer.installStatus ? installer.installStatus({ home }) : realInstaller.installStatus({ home });
|
|
896
948
|
const codex = st?.codex?.installed ? 'installed' : 'not installed';
|
|
897
949
|
const claude = st?.claude?.installed ? 'installed' : 'not installed';
|
|
950
|
+
const official = officialWebIntegrationStatus({ home, releaseDir: pointer?.path });
|
|
951
|
+
const officialWeb = !official.enabled ? 'disabled' : official.healthy ? 'installed' : 'needs repair';
|
|
898
952
|
|
|
899
953
|
log(`DSH Crew launcher/candidate: ${candidateVersion ?? 'unknown'}`);
|
|
900
954
|
log(`Installed DSH Crew payload: ${installedLine}`);
|
|
@@ -908,7 +962,8 @@ export function npxStatus({
|
|
|
908
962
|
log(` Refresh the launcher with: npm install -g ${UPDATE_PACKAGE_NAME}@${installedVersion}`);
|
|
909
963
|
}
|
|
910
964
|
}
|
|
911
|
-
log(`DSH plugin: ${dshPlugin} (dedicated dsh-crew profile
|
|
965
|
+
log(`DSH plugin: ${dshPlugin} (dedicated dsh-crew profile on 3210)`);
|
|
966
|
+
log(`Official 3080 UI bridge: ${officialWeb}`);
|
|
912
967
|
log(`Codex Desktop integration: ${codex}`);
|
|
913
968
|
log(`Claude Code integration: ${claude}`);
|
|
914
969
|
|
|
@@ -918,6 +973,7 @@ export function npxStatus({
|
|
|
918
973
|
installedVersion,
|
|
919
974
|
installedPath: pointer?.path ?? null,
|
|
920
975
|
dshPlugin,
|
|
976
|
+
officialWeb,
|
|
921
977
|
codex,
|
|
922
978
|
claude,
|
|
923
979
|
};
|
|
@@ -944,6 +1000,10 @@ export async function npxUninstall({
|
|
|
944
1000
|
if (cl.ok !== false) log('✓ Claude Code integration removed');
|
|
945
1001
|
else fail('Claude Code integration removal failed');
|
|
946
1002
|
|
|
1003
|
+
const official = removeOfficialWebIntegration({ home, preserveIntent: !purge, remember: !purge });
|
|
1004
|
+
if (!official.ok) fail(`official 3080 bridge removal failed (${official.code ?? 'unknown'})`);
|
|
1005
|
+
else log(official.removed ? '✓ official 3080 bridge removed' : '- official 3080 bridge already absent');
|
|
1006
|
+
|
|
947
1007
|
if (name) {
|
|
948
1008
|
const removed = removeCrewPluginRegistration({ home, name });
|
|
949
1009
|
if (!removed.ok) fail(`Harness registration removal failed (${removed.code ?? 'unknown'})`);
|
|
@@ -982,22 +1042,84 @@ export const USAGE = `usage: dsh-crew <command> [--purge] [--candidate <path>]
|
|
|
982
1042
|
|
|
983
1043
|
Commands:
|
|
984
1044
|
install persist the candidate package into Crew-owned state and register it
|
|
1045
|
+
integrate show Crew inside the official 3080 UI; backend stays isolated on 3210
|
|
1046
|
+
detach remove only the official 3080 bridge; isolated 3210 mode remains available
|
|
985
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
|
|
986
1050
|
update resolve the newest permitted package from the configured npm registry (or
|
|
987
1051
|
--candidate), stage and validate it, then activate; idempotent when current
|
|
988
1052
|
uninstall remove the Crew-managed payload, registration, and integrations (config kept)
|
|
989
1053
|
|
|
990
1054
|
Options:
|
|
991
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
|
|
992
1059
|
--purge with uninstall: also remove ~/.config/dsh-crew config/backups (destructive)
|
|
993
1060
|
--help show this help
|
|
994
1061
|
|
|
995
1062
|
Primary install: npm install -g @ran-sh/dsh-crew (then run: dsh-crew install)
|
|
996
1063
|
Source checkouts use scripts/setup.mjs instead.`;
|
|
997
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
|
+
|
|
998
1117
|
function normalizeCommand(argv) {
|
|
999
1118
|
const flags = argv.slice(1);
|
|
1000
1119
|
let candidate;
|
|
1120
|
+
let after = 0;
|
|
1121
|
+
let detail = 'compact';
|
|
1122
|
+
let request;
|
|
1001
1123
|
for (let index = 0; index < flags.length; index += 1) {
|
|
1002
1124
|
if (flags[index] === '--candidate') {
|
|
1003
1125
|
candidate = flags[index + 1];
|
|
@@ -1007,11 +1129,28 @@ function normalizeCommand(argv) {
|
|
|
1007
1129
|
candidate = flags[index].slice('--candidate='.length);
|
|
1008
1130
|
flags.splice(index, 1);
|
|
1009
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;
|
|
1010
1146
|
}
|
|
1011
1147
|
}
|
|
1012
1148
|
const knownFlags = new Set(['--purge']);
|
|
1013
1149
|
const unknown = flags.filter((f) => f.startsWith('--') && !knownFlags.has(f));
|
|
1014
|
-
|
|
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 };
|
|
1015
1154
|
}
|
|
1016
1155
|
|
|
1017
1156
|
/**
|
|
@@ -1023,7 +1162,7 @@ export async function runNpxCli({
|
|
|
1023
1162
|
error = console.error,
|
|
1024
1163
|
commands = {},
|
|
1025
1164
|
} = {}) {
|
|
1026
|
-
const { command, purge, candidate, unknown } = normalizeCommand(argv);
|
|
1165
|
+
const { command, purge, candidate, after, detail, request, args, unknown } = normalizeCommand(argv);
|
|
1027
1166
|
if (command === '--help' || command === '-h' || command === 'help') {
|
|
1028
1167
|
log(USAGE);
|
|
1029
1168
|
return 0;
|
|
@@ -1032,20 +1171,25 @@ export async function runNpxCli({
|
|
|
1032
1171
|
error(USAGE);
|
|
1033
1172
|
return 1;
|
|
1034
1173
|
}
|
|
1035
|
-
if (unknown.length > 0 || !['install', 'status', 'update', 'uninstall'].includes(command)) {
|
|
1174
|
+
if (unknown.length > 0 || !['install', 'integrate', 'detach', 'status', 'inspect', 'jobs', 'update', 'uninstall'].includes(command)) {
|
|
1036
1175
|
error(`unknown command: ${command ?? '<none>'}\n\n${USAGE}`);
|
|
1037
1176
|
return 1;
|
|
1038
1177
|
}
|
|
1039
1178
|
try {
|
|
1040
1179
|
const actions = {
|
|
1041
1180
|
install: commands.install ?? npxInstall,
|
|
1181
|
+
integrate: commands.integrate ?? npxIntegrate,
|
|
1182
|
+
detach: commands.detach ?? npxDetach,
|
|
1042
1183
|
status: commands.status ?? npxStatus,
|
|
1184
|
+
inspect: commands.inspect ?? npxInspect,
|
|
1185
|
+
jobs: commands.jobs ?? npxJobs,
|
|
1043
1186
|
update: commands.update ?? npxUpdate,
|
|
1044
1187
|
uninstall: commands.uninstall ?? npxUninstall,
|
|
1045
1188
|
};
|
|
1046
1189
|
let result;
|
|
1047
1190
|
if (command === 'uninstall') result = await actions.uninstall({ purge, log });
|
|
1048
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 });
|
|
1049
1193
|
else result = await actions[command]({ log });
|
|
1050
1194
|
return result?.ok === false ? 1 : 0;
|
|
1051
1195
|
} catch (err) {
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import {
|
|
2
|
+
copyFileSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
lstatSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
realpathSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from 'node:fs';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
import { ensurePluginRegistration, removeCrewPluginRegistration } from '../dsh-cli-runtime.mjs';
|
|
14
|
+
|
|
15
|
+
export const OFFICIAL_BRIDGE_PACKAGE = '@ran-sh/dsh-crew-web-bridge';
|
|
16
|
+
const STATE_FILENAME = 'official-web.json';
|
|
17
|
+
|
|
18
|
+
export function officialWebProfileDir({ home = homedir() } = {}) {
|
|
19
|
+
return join(home, '.dsh', 'profiles', 'web');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function officialWebIntegrationStateFile({ home = homedir() } = {}) {
|
|
23
|
+
return join(home, '.config', 'dsh-crew', STATE_FILENAME);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readState(home) {
|
|
27
|
+
try {
|
|
28
|
+
const value = JSON.parse(readFileSync(officialWebIntegrationStateFile({ home }), 'utf8'));
|
|
29
|
+
return value && typeof value === 'object' ? value : null;
|
|
30
|
+
} catch { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function writeState(home, value) {
|
|
34
|
+
const file = officialWebIntegrationStateFile({ home });
|
|
35
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
36
|
+
writeFileSync(file, JSON.stringify(value, null, 2) + '\n');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function validOfficialManifest(file) {
|
|
40
|
+
if (!existsSync(file)) return { ok: false, code: 'OFFICIAL_WEB_PROFILE_NOT_FOUND' };
|
|
41
|
+
try {
|
|
42
|
+
const raw = readFileSync(file, 'utf8');
|
|
43
|
+
const manifest = JSON.parse(raw);
|
|
44
|
+
if (!manifest || typeof manifest !== 'object'
|
|
45
|
+
|| (manifest.dependencies !== undefined && (!manifest.dependencies || typeof manifest.dependencies !== 'object' || Array.isArray(manifest.dependencies)))
|
|
46
|
+
|| !Array.isArray(manifest.dsh?.profile?.bundles)) {
|
|
47
|
+
return { ok: false, code: 'OFFICIAL_WEB_PROFILE_INVALID' };
|
|
48
|
+
}
|
|
49
|
+
return { ok: true, raw, manifest };
|
|
50
|
+
} catch { return { ok: false, code: 'OFFICIAL_WEB_PROFILE_INVALID' }; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function makeBackup({ home, profileManifest, previous }) {
|
|
54
|
+
if (previous?.backup_file && existsSync(previous.backup_file)) return previous.backup_file;
|
|
55
|
+
const backupDir = join(home, '.config', 'dsh-crew', 'backups');
|
|
56
|
+
mkdirSync(backupDir, { recursive: true });
|
|
57
|
+
const backupFile = join(backupDir, `official-web-package-${Date.now()}.json`);
|
|
58
|
+
copyFileSync(profileManifest, backupFile);
|
|
59
|
+
return backupFile;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function ensureOfficialWebIntegration({ home = homedir(), releaseDir } = {}) {
|
|
63
|
+
let resolvedRelease;
|
|
64
|
+
try { resolvedRelease = realpathSync(releaseDir); } catch { return { ok: false, code: 'RELEASE_NOT_FOUND' }; }
|
|
65
|
+
const bridgeRoot = join(resolvedRelease, 'official-web-bridge');
|
|
66
|
+
const profileRoot = officialWebProfileDir({ home });
|
|
67
|
+
const profileManifest = join(profileRoot, 'package.json');
|
|
68
|
+
const profile = validOfficialManifest(profileManifest);
|
|
69
|
+
if (!profile.ok) return profile;
|
|
70
|
+
const previous = readState(home);
|
|
71
|
+
const backupFile = makeBackup({ home, profileManifest, previous });
|
|
72
|
+
const registration = ensurePluginRegistration({
|
|
73
|
+
profileRoot,
|
|
74
|
+
root: bridgeRoot,
|
|
75
|
+
name: OFFICIAL_BRIDGE_PACKAGE,
|
|
76
|
+
createProfile: false,
|
|
77
|
+
});
|
|
78
|
+
if (!registration.ok) {
|
|
79
|
+
return { ok: false, code: registration.code === 'CREW_PROFILE_METADATA_INVALID' ? 'OFFICIAL_WEB_PROFILE_INVALID' : registration.code };
|
|
80
|
+
}
|
|
81
|
+
const stateChanged = previous?.enabled !== true || previous?.release_dir !== resolvedRelease || previous?.backup_file !== backupFile;
|
|
82
|
+
writeState(home, {
|
|
83
|
+
enabled: true,
|
|
84
|
+
release_dir: resolvedRelease,
|
|
85
|
+
backup_file: backupFile,
|
|
86
|
+
package: OFFICIAL_BRIDGE_PACKAGE,
|
|
87
|
+
});
|
|
88
|
+
return { ...registration, changed: registration.changed || stateChanged, backupFile };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function officialWebIntegrationStatus({ home = homedir(), releaseDir } = {}) {
|
|
92
|
+
const state = readState(home);
|
|
93
|
+
if (state?.enabled !== true) return { enabled: false, healthy: false, state };
|
|
94
|
+
const expectedRelease = releaseDir ?? state.release_dir;
|
|
95
|
+
let expectedBridge;
|
|
96
|
+
try { expectedBridge = realpathSync(join(expectedRelease, 'official-web-bridge')); } catch {
|
|
97
|
+
return { enabled: true, healthy: false, code: 'BRIDGE_RELEASE_MISSING', state };
|
|
98
|
+
}
|
|
99
|
+
const profileRoot = officialWebProfileDir({ home });
|
|
100
|
+
const profile = validOfficialManifest(join(profileRoot, 'package.json'));
|
|
101
|
+
if (!profile.ok) return { enabled: true, healthy: false, code: profile.code, state };
|
|
102
|
+
const dependency = profile.manifest.dependencies?.[OFFICIAL_BRIDGE_PACKAGE];
|
|
103
|
+
const bundled = profile.manifest.dsh.profile.bundles.includes(OFFICIAL_BRIDGE_PACKAGE);
|
|
104
|
+
const linkPath = join(profileRoot, 'node_modules', ...OFFICIAL_BRIDGE_PACKAGE.split('/'));
|
|
105
|
+
let linked = false;
|
|
106
|
+
try { linked = lstatSync(linkPath).isSymbolicLink() && realpathSync(linkPath) === expectedBridge; } catch {}
|
|
107
|
+
return { enabled: true, healthy: Boolean(dependency && bundled && linked), state, linkPath, expectedBridge };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function removeOfficialWebIntegration({ home = homedir(), remember = true, preserveIntent = false } = {}) {
|
|
111
|
+
const profileRoot = officialWebProfileDir({ home });
|
|
112
|
+
const profileManifest = join(profileRoot, 'package.json');
|
|
113
|
+
const profile = validOfficialManifest(profileManifest);
|
|
114
|
+
if (!profile.ok && profile.code !== 'OFFICIAL_WEB_PROFILE_NOT_FOUND') return profile;
|
|
115
|
+
let removed = false;
|
|
116
|
+
if (profile.ok) {
|
|
117
|
+
const result = removeCrewPluginRegistration({ home, name: OFFICIAL_BRIDGE_PACKAGE, profileRoot });
|
|
118
|
+
if (!result.ok) return { ok: false, code: result.code === 'CREW_PROFILE_METADATA_INVALID' ? 'OFFICIAL_WEB_PROFILE_INVALID' : result.code };
|
|
119
|
+
removed = result.removed;
|
|
120
|
+
}
|
|
121
|
+
const previous = readState(home);
|
|
122
|
+
if (remember) writeState(home, {
|
|
123
|
+
...(previous ?? {}),
|
|
124
|
+
enabled: preserveIntent ? previous?.enabled === true : false,
|
|
125
|
+
package: OFFICIAL_BRIDGE_PACKAGE,
|
|
126
|
+
});
|
|
127
|
+
else {
|
|
128
|
+
const stateFile = officialWebIntegrationStateFile({ home });
|
|
129
|
+
if (existsSync(stateFile)) unlinkSync(stateFile);
|
|
130
|
+
}
|
|
131
|
+
return { ok: true, removed };
|
|
132
|
+
}
|
|
@@ -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
|
}
|