@ran-sh/dsh-crew 1.0.3 → 1.1.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/codex/AGENTS.md +101 -92
- package/docs/ui-surfaces.md +48 -21
- package/lib/client.js +68 -44
- package/official-web-bridge/lib/client.js +368 -4709
- package/official-web-bridge/package.json +1 -2
- package/package.json +130 -67
- package/scripts/build-client.mjs +30 -15
- package/scripts/remove-legacy-official-bridge.ps1 +89 -0
- package/scripts/setup.mjs +152 -140
- package/scripts/verify-npm-install.mjs +311 -310
- package/scripts/verify-official-bridge-e2e.mjs +12 -1
- package/src/client/host-readiness.mjs +62 -59
- package/src/client/index.tsx +35 -30
- package/src/client/quick-entry.tsx +10 -0
- package/src/client/quick-panel.tsx +295 -0
- package/src/client/surface-detection.mjs +43 -30
- package/src/dsh-cli-runtime.mjs +500 -40
- package/src/dsh-cohort.mjs +20 -0
- package/src/hub/index.mjs +1602 -1485
- package/src/install/npx-lifecycle.mjs +1568 -111
- package/src/install/official-web.mjs +36 -73
- package/src/jobs.mjs +20 -24
- package/src/official-web-bridge.mjs +329 -249
- package/src/runtime-identity.mjs +23 -1
- package/src/supervisor/restart-request.mjs +202 -0
- package/windows/start-dsh-crew.ps1 +783 -370
- package/worker.cordis.yml +32 -46
- package/zcode/AGENTS.md +35 -26
|
@@ -1,30 +1,43 @@
|
|
|
1
|
-
export const CREW_UI_SURFACES = Object.freeze({
|
|
2
|
-
OFFICIAL: 'official-bridge',
|
|
3
|
-
NATIVE: 'native-crew-harness',
|
|
4
|
-
UNKNOWN: 'unknown',
|
|
5
|
-
});
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Classify the current same-origin UI from explicit backend contracts. The
|
|
9
|
-
* official bridge signal wins because its proxied runtime response correctly
|
|
10
|
-
* describes the 3210 backend, not the browser-facing 3080 surface.
|
|
11
|
-
*/
|
|
12
|
-
export function classifyCrewSurface({ bridgeStatus, runtime } = {}) {
|
|
13
|
-
if (bridgeStatus?.ok === true
|
|
14
|
-
&& (bridgeStatus.surface === CREW_UI_SURFACES.OFFICIAL
|
|
15
|
-
|| bridgeStatus.mode === 'official-3080-isolated-3210')) {
|
|
16
|
-
return CREW_UI_SURFACES.OFFICIAL;
|
|
17
|
-
}
|
|
18
|
-
if (runtime?.ok === true
|
|
19
|
-
&& runtime.service === 'dsh-crew-hub'
|
|
20
|
-
&& (runtime.surface === CREW_UI_SURFACES.NATIVE || runtime.surface === undefined)) {
|
|
21
|
-
return CREW_UI_SURFACES.NATIVE;
|
|
22
|
-
}
|
|
23
|
-
return CREW_UI_SURFACES.UNKNOWN;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
1
|
+
export const CREW_UI_SURFACES = Object.freeze({
|
|
2
|
+
OFFICIAL: 'official-bridge',
|
|
3
|
+
NATIVE: 'native-crew-harness',
|
|
4
|
+
UNKNOWN: 'unknown',
|
|
5
|
+
});
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Classify the current same-origin UI from explicit backend contracts. The
|
|
9
|
+
* official bridge signal wins because its proxied runtime response correctly
|
|
10
|
+
* describes the 3210 backend, not the browser-facing 3080 surface.
|
|
11
|
+
*/
|
|
12
|
+
export function classifyCrewSurface({ bridgeStatus, runtime } = {}) {
|
|
13
|
+
if (bridgeStatus?.ok === true
|
|
14
|
+
&& (bridgeStatus.surface === CREW_UI_SURFACES.OFFICIAL
|
|
15
|
+
|| bridgeStatus.mode === 'official-3080-isolated-3210')) {
|
|
16
|
+
return CREW_UI_SURFACES.OFFICIAL;
|
|
17
|
+
}
|
|
18
|
+
if (runtime?.ok === true
|
|
19
|
+
&& runtime.service === 'dsh-crew-hub'
|
|
20
|
+
&& (runtime.surface === CREW_UI_SURFACES.NATIVE || runtime.surface === undefined)) {
|
|
21
|
+
return CREW_UI_SURFACES.NATIVE;
|
|
22
|
+
}
|
|
23
|
+
return CREW_UI_SURFACES.UNKNOWN;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Surface capability model. The NATIVE 3210 Crew harness is the single full
|
|
28
|
+
* control plane; the OFFICIAL 3080 surface is a narrow quick-controls panel
|
|
29
|
+
* (total switch, flash/pro model priority, vision/imagegen toggles) plus a
|
|
30
|
+
* deep link back to 3210. Unknown surfaces get diagnostics only — never write
|
|
31
|
+
* authority. This replaces the old binary full-vs-minimal assumption.
|
|
32
|
+
*/
|
|
33
|
+
export function surfaceResponsibilities(surface) {
|
|
34
|
+
switch (surface) {
|
|
35
|
+
case CREW_UI_SURFACES.NATIVE:
|
|
36
|
+
return { fullControlPlane: true, quickControlPlane: true, diagnostics: true };
|
|
37
|
+
case CREW_UI_SURFACES.OFFICIAL:
|
|
38
|
+
return { fullControlPlane: false, quickControlPlane: true, diagnostics: true };
|
|
39
|
+
default:
|
|
40
|
+
return { fullControlPlane: false, quickControlPlane: false, diagnostics: true };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
package/src/dsh-cli-runtime.mjs
CHANGED
|
@@ -13,16 +13,26 @@ import {
|
|
|
13
13
|
mkdirSync,
|
|
14
14
|
readFileSync,
|
|
15
15
|
realpathSync,
|
|
16
|
+
renameSync,
|
|
17
|
+
rmSync,
|
|
16
18
|
symlinkSync,
|
|
17
19
|
unlinkSync,
|
|
18
20
|
writeFileSync,
|
|
19
21
|
} from 'node:fs';
|
|
20
|
-
import { dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
21
|
-
import { homedir } from 'node:os';
|
|
22
|
-
import { crewDshHome, crewProfileDir } from './install/install.mjs';
|
|
23
|
-
import { reconcileProviderDesiredState } from './provider-desired-state.mjs';
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
import { dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
24
|
+
import { crewDshHome, crewProfileDir } from './install/install.mjs';
|
|
25
|
+
import { reconcileProviderDesiredState } from './provider-desired-state.mjs';
|
|
26
|
+
import {
|
|
27
|
+
DSH_CLI_PACKAGE,
|
|
28
|
+
TARGET_DSH_VERSION,
|
|
29
|
+
TARGET_DSH_SPEC,
|
|
30
|
+
RETAINED_RUNTIMES_DIRNAME,
|
|
31
|
+
} from './dsh-cohort.mjs';
|
|
32
|
+
|
|
33
|
+
// Re-exported so existing importers keep working while the cohort value now
|
|
34
|
+
// lives in exactly one place (src/dsh-cohort.mjs).
|
|
35
|
+
export { DSH_CLI_PACKAGE, TARGET_DSH_VERSION, TARGET_DSH_SPEC };
|
|
26
36
|
export const CREW_DSH_RUNTIME_DIRNAME = 'runtime';
|
|
27
37
|
const CREW_PROFILE_DEFAULT_BUNDLES = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'];
|
|
28
38
|
const PROFILE_PATCH_TEMPLATE = '[]\n';
|
|
@@ -57,6 +67,10 @@ export function crewDshRuntimeRoot({ home = homedir() } = {}) {
|
|
|
57
67
|
return join(crewDshHome({ home }), CREW_DSH_RUNTIME_DIRNAME);
|
|
58
68
|
}
|
|
59
69
|
|
|
70
|
+
export function crewDshRuntimeVersionDir({ home = homedir(), version = TARGET_DSH_VERSION } = {}) {
|
|
71
|
+
return join(crewDshHome({ home }), `runtime-${version}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
60
74
|
export function crewDshRuntimeEntry({ home = homedir(), platform = process.platform } = {}) {
|
|
61
75
|
const suffix = platform === 'win32' ? '.cmd' : '';
|
|
62
76
|
return join(crewDshRuntimeRoot({ home }), 'node_modules', '.bin', `dsh${suffix}`);
|
|
@@ -190,10 +204,17 @@ export function runResolvedDsh(cli, args = [], {
|
|
|
190
204
|
/**
|
|
191
205
|
* Install a reusable DSH CLI into Crew-owned state. This is the only helper
|
|
192
206
|
* that may invoke a package manager, and callers must explicitly opt into it.
|
|
207
|
+
*
|
|
208
|
+
* The caller-supplied `version` defaults to the pinned TARGET. Reuse happens
|
|
209
|
+
* only when the live runtime already matches `version`; a mismatch is
|
|
210
|
+
* reported as DSH_RUNTIME_COHORT_MISMATCH and NEVER upgraded in place.
|
|
211
|
+
* Callers that intend an upgrade must interpret that code as needsMigration
|
|
212
|
+
* and run the staged transactional path — there is deliberately no `force`.
|
|
193
213
|
*/
|
|
194
214
|
export function ensureCrewDshRuntime({
|
|
195
215
|
home = homedir(),
|
|
196
|
-
|
|
216
|
+
version = TARGET_DSH_VERSION,
|
|
217
|
+
packageSpec = `${DSH_CLI_PACKAGE}@${version}`,
|
|
197
218
|
npmCommand = null,
|
|
198
219
|
pnpmCommand = null,
|
|
199
220
|
findCommand = defaultFindCommand,
|
|
@@ -204,7 +225,22 @@ export function ensureCrewDshRuntime({
|
|
|
204
225
|
env = process.env,
|
|
205
226
|
} = {}) {
|
|
206
227
|
const existing = resolveDshCli({ home, env, platform, exists, findCommand, includeCompatibility: false });
|
|
207
|
-
|
|
228
|
+
// Reuse only when the installed runtime already matches the requested
|
|
229
|
+
// cohort. A stale cohort (e.g. an online 0.1.1-rc.2 runtime) must never be
|
|
230
|
+
// mistaken for the target, and it must never be upgraded in place under a
|
|
231
|
+
// live hub.
|
|
232
|
+
if (existing?.kind === 'crew-runtime' && existing?.version === version) {
|
|
233
|
+
return { ok: true, cli: existing, reused: true };
|
|
234
|
+
}
|
|
235
|
+
if (existing?.kind === 'crew-runtime') {
|
|
236
|
+
return {
|
|
237
|
+
ok: false,
|
|
238
|
+
code: 'DSH_RUNTIME_COHORT_MISMATCH',
|
|
239
|
+
error: `Crew runtime is ${existing.version ?? 'unknown version'} but ${version} is required; refusing in-place upgrade of a live runtime`,
|
|
240
|
+
installed: existing.version ?? null,
|
|
241
|
+
target: version,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
208
244
|
|
|
209
245
|
const pnpm = pnpmCommand ?? findCommand('pnpm');
|
|
210
246
|
const npm = npmCommand ?? findCommand('npm');
|
|
@@ -234,6 +270,17 @@ export function ensureCrewDshRuntime({
|
|
|
234
270
|
stderrTail: String(result.stderr || result.stdout || '').trim().split(/\r?\n/).slice(-3).join(' | ').slice(0, 300),
|
|
235
271
|
};
|
|
236
272
|
}
|
|
273
|
+
// Fail closed when the installed cohort drifts from the requested target
|
|
274
|
+
// (registry race, hoisted pollution, partial install).
|
|
275
|
+
if (cli.version !== version) {
|
|
276
|
+
return {
|
|
277
|
+
ok: false,
|
|
278
|
+
code: 'DSH_RUNTIME_INSTALL_VERSION_MISMATCH',
|
|
279
|
+
error: `installed Crew runtime is ${cli.version ?? 'unknown version'} but ${version} is required`,
|
|
280
|
+
installed: cli.version ?? null,
|
|
281
|
+
target: version,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
237
284
|
return { ok: true, cli, reused: false, version: cli.version, runtimeRoot };
|
|
238
285
|
}
|
|
239
286
|
|
|
@@ -243,6 +290,419 @@ export function describeDshCli(cli) {
|
|
|
243
290
|
return `${cli.kind}${version}`;
|
|
244
291
|
}
|
|
245
292
|
|
|
293
|
+
// Shared pnpm/npm install of the pinned DSH cohort into a specific root
|
|
294
|
+
// directory. pnpm materializes ABSOLUTE-path .cmd shims and junctions that
|
|
295
|
+
// point at the install root's own .pnpm store, so the tree must be installed
|
|
296
|
+
// at its FINAL resting path: renaming a pnpm tree breaks every shim. Callers
|
|
297
|
+
// that need an atomic swap must install at the live root only after moving
|
|
298
|
+
// the old tree aside.
|
|
299
|
+
export function installDshInto({
|
|
300
|
+
root,
|
|
301
|
+
version,
|
|
302
|
+
packageSpec = `${DSH_CLI_PACKAGE}@${version}`,
|
|
303
|
+
npmCommand = null,
|
|
304
|
+
pnpmCommand = null,
|
|
305
|
+
findCommand = defaultFindCommand,
|
|
306
|
+
exists = defaultExists,
|
|
307
|
+
runner = spawnSync,
|
|
308
|
+
platform = process.platform,
|
|
309
|
+
comspec = process.env.ComSpec ?? 'cmd.exe',
|
|
310
|
+
env = process.env,
|
|
311
|
+
}) {
|
|
312
|
+
const pnpm = pnpmCommand ?? findCommand('pnpm');
|
|
313
|
+
const npm = npmCommand ?? findCommand('npm');
|
|
314
|
+
if (!pnpm && !npm) return { ok: false, code: 'DSH_RUNTIME_INSTALLER_NOT_FOUND', error: 'pnpm/npm unavailable' };
|
|
315
|
+
mkdirSync(root, { recursive: true });
|
|
316
|
+
const packageManager = pnpm
|
|
317
|
+
? descriptor({ kind: 'pnpm', command: pnpm, source: 'pnpm' })
|
|
318
|
+
: descriptor({ kind: 'npm', command: npm, source: 'npm' });
|
|
319
|
+
const packageArgs = pnpm
|
|
320
|
+
? ['add', '--dir', root, '--ignore-scripts', packageSpec]
|
|
321
|
+
: ['install', '--prefix', root, '--no-package-lock', '--ignore-scripts', '--omit=dev', packageSpec];
|
|
322
|
+
const invocation = buildDshInvocation(packageManager, packageArgs, { platform, comspec });
|
|
323
|
+
const result = runner(invocation.command, invocation.args, {
|
|
324
|
+
encoding: 'utf8',
|
|
325
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
326
|
+
shell: invocation.shell,
|
|
327
|
+
env: { ...env },
|
|
328
|
+
});
|
|
329
|
+
if (result.status !== 0) {
|
|
330
|
+
return {
|
|
331
|
+
ok: false,
|
|
332
|
+
code: 'DSH_RUNTIME_INSTALL_FAILED',
|
|
333
|
+
error: 'Crew runtime install failed',
|
|
334
|
+
status: result.status ?? -1,
|
|
335
|
+
stderrTail: String(result.stderr || result.stdout || '').trim().split(/\r?\n/).slice(-3).join(' | ').slice(0, 300),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
const moduleEntry = join(root, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js');
|
|
339
|
+
if (!exists(moduleEntry)) {
|
|
340
|
+
return { ok: false, code: 'DSH_RUNTIME_INSTALL_INCOMPLETE', error: 'runtime entry missing after install' };
|
|
341
|
+
}
|
|
342
|
+
const installedVersion = packageVersion(moduleEntry);
|
|
343
|
+
if (installedVersion !== version) {
|
|
344
|
+
return {
|
|
345
|
+
ok: false,
|
|
346
|
+
code: 'DSH_RUNTIME_INSTALL_VERSION_MISMATCH',
|
|
347
|
+
error: `installed Crew runtime is ${installedVersion ?? 'unknown version'} but ${version} is required`,
|
|
348
|
+
installed: installedVersion ?? null,
|
|
349
|
+
target: version,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
return { ok: true, root, version: installedVersion };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Staged cohort migration: install the target cohort into a versioned
|
|
356
|
+
// directory WITHOUT touching the live runtime/, verify its manifest, then
|
|
357
|
+
// report the staged root for an atomic switch by the caller (stop owned
|
|
358
|
+
// 3210 -> swap directories -> clean restart -> identity check). Never
|
|
359
|
+
// upgrades a live runtime in place.
|
|
360
|
+
//
|
|
361
|
+
// NOTE: pnpm trees embed absolute paths in .cmd shims and junctions, so a
|
|
362
|
+
// staged tree CANNOT be renamed onto the live root afterwards. migrate
|
|
363
|
+
//CrewDshRuntime therefore installs at the live root in place (after parking
|
|
364
|
+
// the old tree); stageCrewDshRuntime remains for callers that consume the
|
|
365
|
+
// stage dir at its fixed versioned path (tests, dry runs, audits).
|
|
366
|
+
export function stageCrewDshRuntime({
|
|
367
|
+
home = homedir(),
|
|
368
|
+
version = TARGET_DSH_VERSION,
|
|
369
|
+
packageSpec = `${DSH_CLI_PACKAGE}@${version}`,
|
|
370
|
+
npmCommand = null,
|
|
371
|
+
pnpmCommand = null,
|
|
372
|
+
findCommand = defaultFindCommand,
|
|
373
|
+
exists = defaultExists,
|
|
374
|
+
runner = spawnSync,
|
|
375
|
+
platform = process.platform,
|
|
376
|
+
comspec = process.env.ComSpec ?? 'cmd.exe',
|
|
377
|
+
env = process.env,
|
|
378
|
+
read = readFileSync,
|
|
379
|
+
} = {}) {
|
|
380
|
+
const stagedRoot = crewDshRuntimeVersionDir({ home, version });
|
|
381
|
+
// Clean/recreate the versioned stage dir: a failed older attempt must
|
|
382
|
+
// never contaminate the next staging.
|
|
383
|
+
try { rmSync(stagedRoot, { recursive: true, force: true }); } catch {}
|
|
384
|
+
const installed = installDshInto({
|
|
385
|
+
root: stagedRoot,
|
|
386
|
+
version,
|
|
387
|
+
packageSpec,
|
|
388
|
+
npmCommand,
|
|
389
|
+
pnpmCommand,
|
|
390
|
+
findCommand,
|
|
391
|
+
exists,
|
|
392
|
+
runner,
|
|
393
|
+
platform,
|
|
394
|
+
comspec,
|
|
395
|
+
env,
|
|
396
|
+
});
|
|
397
|
+
if (!installed.ok) {
|
|
398
|
+
const code = installed.code === 'DSH_RUNTIME_INSTALL_FAILED'
|
|
399
|
+
? 'DSH_RUNTIME_STAGE_FAILED'
|
|
400
|
+
: installed.code === 'DSH_RUNTIME_INSTALL_INCOMPLETE'
|
|
401
|
+
? 'DSH_RUNTIME_STAGE_INCOMPLETE'
|
|
402
|
+
: installed.code;
|
|
403
|
+
return { ...installed, code, error: code === 'DSH_RUNTIME_STAGE_FAILED' ? 'staged Crew runtime install failed' : installed.error };
|
|
404
|
+
}
|
|
405
|
+
const stagedModule = join(stagedRoot, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js');
|
|
406
|
+
if (!exists(stagedModule)) {
|
|
407
|
+
return { ok: false, code: 'DSH_RUNTIME_STAGE_INCOMPLETE', error: 'staged runtime entry missing' };
|
|
408
|
+
}
|
|
409
|
+
const stagedVersion = packageVersion(stagedModule, read);
|
|
410
|
+
if (stagedVersion !== version) {
|
|
411
|
+
return {
|
|
412
|
+
ok: false,
|
|
413
|
+
code: 'DSH_RUNTIME_INSTALL_VERSION_MISMATCH',
|
|
414
|
+
error: `staged Crew runtime is ${stagedVersion ?? 'unknown version'} but ${version} is required`,
|
|
415
|
+
installed: stagedVersion ?? null,
|
|
416
|
+
target: version,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
return { ok: true, stagedRoot, version: stagedVersion };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Full cohort migration transaction (caller holds the update lock):
|
|
423
|
+
// stop owned 3210 -> park the live tree (rename to prev, its shims stay
|
|
424
|
+
// valid because the tree returns to the same path on rollback) -> install
|
|
425
|
+
// the target cohort AT THE LIVE ROOT (pnpm shims are absolute-path, so the
|
|
426
|
+
// tree must be born at its final resting path; a renamed pnpm tree has
|
|
427
|
+
// dangling shims) -> restart -> identity verify -> rollback on any failure.
|
|
428
|
+
// stopOwned/startOwned/verifyOwned have NO defaults: a missing callback
|
|
429
|
+
// fails closed instead of pretending an unverified step succeeded.
|
|
430
|
+
//
|
|
431
|
+
// prepareOnly=true stops + swaps the tree but NEVER starts the process:
|
|
432
|
+
// the caller (a pair-ordered rollback/compensation) will activate the
|
|
433
|
+
// matching Crew payload and start exactly once itself. The parked prior
|
|
434
|
+
// tree is returned as prevRoot for durable retention by the caller.
|
|
435
|
+
export async function migrateCrewDshRuntime({
|
|
436
|
+
home = homedir(),
|
|
437
|
+
version = TARGET_DSH_VERSION,
|
|
438
|
+
stageOptions = {},
|
|
439
|
+
stopOwned,
|
|
440
|
+
startOwned,
|
|
441
|
+
verifyOwned,
|
|
442
|
+
rename = renameSync,
|
|
443
|
+
log = () => {},
|
|
444
|
+
prepareOnly = false,
|
|
445
|
+
} = {}) {
|
|
446
|
+
const liveRoot = crewDshRuntimeRoot({ home });
|
|
447
|
+
if (typeof stopOwned !== 'function') {
|
|
448
|
+
return { ok: false, code: 'DSH_RUNTIME_MIGRATION_CALLBACKS_MISSING', error: 'stop callback is required' };
|
|
449
|
+
}
|
|
450
|
+
if (!prepareOnly && (typeof startOwned !== 'function' || typeof verifyOwned !== 'function')) {
|
|
451
|
+
return { ok: false, code: 'DSH_RUNTIME_MIGRATION_CALLBACKS_MISSING', error: 'start/verify callbacks are required unless prepareOnly' };
|
|
452
|
+
}
|
|
453
|
+
const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
454
|
+
const prevRoot = join(crewDshHome({ home }), `runtime-prev-${nonce}`);
|
|
455
|
+
const stop = await stopOwned();
|
|
456
|
+
if (!stop.ok) {
|
|
457
|
+
return { ok: false, code: stop.code ?? 'DSH_RUNTIME_STOP_FAILED', error: stop.error ?? 'could not stop owned 3210' };
|
|
458
|
+
}
|
|
459
|
+
let liveMoved = false;
|
|
460
|
+
try {
|
|
461
|
+
if (existsSync(liveRoot)) {
|
|
462
|
+
rename(liveRoot, prevRoot);
|
|
463
|
+
liveMoved = true;
|
|
464
|
+
}
|
|
465
|
+
mkdirSync(liveRoot, { recursive: true });
|
|
466
|
+
} catch (error) {
|
|
467
|
+
// Park failed: restore the live tree before reporting.
|
|
468
|
+
let recovery = { ok: false };
|
|
469
|
+
try {
|
|
470
|
+
if (liveMoved && existsSync(prevRoot) && !existsSync(liveRoot)) rename(prevRoot, liveRoot);
|
|
471
|
+
if (!prepareOnly) {
|
|
472
|
+
const restarted = await startOwned();
|
|
473
|
+
recovery = restarted?.ok ? { ok: true } : { ok: false, code: restarted?.code ?? 'DSH_RUNTIME_RESTART_FAILED' };
|
|
474
|
+
} else {
|
|
475
|
+
recovery = { ok: true };
|
|
476
|
+
}
|
|
477
|
+
} catch (recoveryError) {
|
|
478
|
+
recovery = { ok: false, code: 'DSH_RUNTIME_SWAP_RECOVERY_FAILED', error: String(recoveryError?.message ?? recoveryError) };
|
|
479
|
+
}
|
|
480
|
+
return { ok: false, code: 'DSH_RUNTIME_PARK_FAILED', error: String(error?.message ?? error), recovery };
|
|
481
|
+
}
|
|
482
|
+
// Install the target cohort AT the live root so every pnpm shim/junction
|
|
483
|
+
// carries the correct final absolute path.
|
|
484
|
+
const installed = installDshInto({ root: liveRoot, version, ...stageOptions });
|
|
485
|
+
if (!installed.ok) {
|
|
486
|
+
const recovery = await rollbackRuntimeSwap({ liveRoot, prevRoot, stopOwned, startOwned, rename, prepareOnly });
|
|
487
|
+
return { ok: false, code: installed.code ?? 'DSH_RUNTIME_INSTALL_FAILED', error: installed.error ?? 'runtime install at live root failed', recovery };
|
|
488
|
+
}
|
|
489
|
+
if (prepareOnly) {
|
|
490
|
+
log(`- runtime tree prepared at live root (@${version}); process not started`);
|
|
491
|
+
return { ok: true, version, liveRoot, prevRoot, prepared: true };
|
|
492
|
+
}
|
|
493
|
+
const start = await startOwned();
|
|
494
|
+
if (!start.ok) {
|
|
495
|
+
const recovery = await rollbackRuntimeSwap({ liveRoot, prevRoot, stopOwned, startOwned, rename });
|
|
496
|
+
return { ok: false, code: start.code ?? 'DSH_RUNTIME_START_FAILED', error: start.error ?? 'restart after install failed', recovery };
|
|
497
|
+
}
|
|
498
|
+
const verified = await verifyOwned();
|
|
499
|
+
if (!verified.ok) {
|
|
500
|
+
const recovery = await rollbackRuntimeSwap({ liveRoot, prevRoot, stopOwned, startOwned, rename });
|
|
501
|
+
return { ok: false, code: verified.code ?? 'DSH_RUNTIME_VERIFY_FAILED', error: verified.error ?? 'identity check failed', recovery };
|
|
502
|
+
}
|
|
503
|
+
// Retain the prior cohort under retained-runtimes/<version> instead of
|
|
504
|
+
// deleting it: a later cross-cohort rollback can restore it offline with
|
|
505
|
+
// no registry round-trip. Retention is best-effort (a failure here must
|
|
506
|
+
// not fail the migration), and an existing retained copy of the same
|
|
507
|
+
// version is replaced so the retained set always holds the newest tree.
|
|
508
|
+
retainPriorRuntime({ home, prevRoot });
|
|
509
|
+
log(`- runtime cohort migrated to ${version}`);
|
|
510
|
+
return { ok: true, version, liveRoot };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async function rollbackRuntimeSwap({ liveRoot, prevRoot, stopOwned, startOwned, rename = renameSync, prepareOnly = false }) {
|
|
514
|
+
const recovery = { stoppedCandidate: false, restore: false, restart: false };
|
|
515
|
+
// The failed candidate 3210 may still be running against the tree we are
|
|
516
|
+
// about to replace: stop it FIRST, otherwise the swap races a live
|
|
517
|
+
// process (and on Windows the live handles can fail the rename/delete).
|
|
518
|
+
if (typeof stopOwned === 'function') {
|
|
519
|
+
try {
|
|
520
|
+
const stopped = await stopOwned();
|
|
521
|
+
recovery.stoppedCandidate = stopped?.ok === true;
|
|
522
|
+
if (!recovery.stoppedCandidate) recovery.stopError = stopped?.code ?? stopped?.error ?? 'candidate stop failed';
|
|
523
|
+
} catch (error) {
|
|
524
|
+
recovery.stopError = String(error?.message ?? error);
|
|
525
|
+
}
|
|
526
|
+
if (!recovery.stoppedCandidate) {
|
|
527
|
+
recovery.ok = false;
|
|
528
|
+
return recovery;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
try { rmSync(liveRoot, { recursive: true, force: true }); } catch {}
|
|
532
|
+
try {
|
|
533
|
+
if (existsSync(prevRoot)) { rename(prevRoot, liveRoot); recovery.restore = true; }
|
|
534
|
+
} catch (error) {
|
|
535
|
+
recovery.restoreError = String(error?.message ?? error);
|
|
536
|
+
}
|
|
537
|
+
if (prepareOnly) {
|
|
538
|
+
recovery.ok = recovery.restore === true;
|
|
539
|
+
return recovery;
|
|
540
|
+
}
|
|
541
|
+
try {
|
|
542
|
+
const restarted = await startOwned();
|
|
543
|
+
recovery.restart = restarted?.ok === true;
|
|
544
|
+
if (!recovery.restart) recovery.restartCode = restarted?.code ?? 'DSH_RUNTIME_RESTART_FAILED';
|
|
545
|
+
} catch (error) {
|
|
546
|
+
recovery.restartError = String(error?.message ?? error);
|
|
547
|
+
}
|
|
548
|
+
recovery.ok = recovery.restore === true && recovery.restart === true;
|
|
549
|
+
return recovery;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ---- retained runtime cohorts -------------------------------------------------
|
|
553
|
+
|
|
554
|
+
function retainedRuntimesRoot({ home }) {
|
|
555
|
+
return join(crewDshHome({ home }), RETAINED_RUNTIMES_DIRNAME);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function retainedRuntimeDir({ home, version }) {
|
|
559
|
+
return join(retainedRuntimesRoot({ home }), version);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Probe the dsh package version inside a runtime tree (live or prev/retained).
|
|
563
|
+
function runtimeTreeVersion(root, read = readFileSync) {
|
|
564
|
+
if (!root) return null;
|
|
565
|
+
try {
|
|
566
|
+
const file = join(root, 'node_modules', '@deepseek-ai', 'dsh', 'package.json');
|
|
567
|
+
const parsed = JSON.parse(read(file, 'utf8'));
|
|
568
|
+
return typeof parsed.version === 'string' && parsed.version.length > 0 ? parsed.version : null;
|
|
569
|
+
} catch { return null; }
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Best-effort retention of the swapped-out prior runtime tree. Never throws
|
|
573
|
+
// and never fails the caller: retention is an optimization for offline
|
|
574
|
+
// rollback, not a correctness requirement of the migration itself.
|
|
575
|
+
function retainPriorRuntime({ home, prevRoot, rename = renameSync }) {
|
|
576
|
+
try {
|
|
577
|
+
if (!existsSync(prevRoot)) return { ok: true, retained: false };
|
|
578
|
+
const version = runtimeTreeVersion(prevRoot);
|
|
579
|
+
if (!version) {
|
|
580
|
+
// No version to key retention on; the tree is unreadable junk.
|
|
581
|
+
try { rmSync(prevRoot, { recursive: true, force: true }); } catch {}
|
|
582
|
+
return { ok: true, retained: false, reason: 'prior tree version unreadable; removed' };
|
|
583
|
+
}
|
|
584
|
+
const retainedRoot = retainedRuntimesRoot({ home });
|
|
585
|
+
const target = retainedRuntimeDir({ home, version });
|
|
586
|
+
mkdirSync(retainedRoot, { recursive: true });
|
|
587
|
+
if (existsSync(target)) rmSync(target, { recursive: true, force: true });
|
|
588
|
+
rename(prevRoot, target);
|
|
589
|
+
return { ok: true, retained: true, version, path: target };
|
|
590
|
+
} catch (error) {
|
|
591
|
+
// A failed retain must not fail an otherwise-successful migration, and it
|
|
592
|
+
// must NOT delete the parked prior tree: that tree is the only offline
|
|
593
|
+
// rollback copy of the previous cohort. Leave it in place (a later
|
|
594
|
+
// operator/GC pass can retry or reap it) and report the failure loudly.
|
|
595
|
+
return { ok: false, retained: false, prevRoot, error: String(error?.message ?? error) };
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Locate a usable retained cohort tree. Returns the retained path when one
|
|
600
|
+
// exists and its manifest reports the requested version; otherwise null.
|
|
601
|
+
export function findRetainedRuntime({ home = homedir(), version, exists = existsSync, read = readFileSync } = {}) {
|
|
602
|
+
if (typeof version !== 'string' || version.length === 0) return null;
|
|
603
|
+
const dir = retainedRuntimeDir({ home, version });
|
|
604
|
+
if (!exists(dir)) return null;
|
|
605
|
+
if (runtimeTreeVersion(dir, read) !== version) return null;
|
|
606
|
+
return dir;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Offline cohort restore for cross-cohort rollback: move the retained tree
|
|
610
|
+
// back onto live runtime/. The caller must hold the update lock.
|
|
611
|
+
//
|
|
612
|
+
// Pair-ordering contract: with prepareOnly=false this helper ALSO starts and
|
|
613
|
+
// verifies the 3210, which is only safe when the caller has already
|
|
614
|
+
// activated the matching Crew payload. Callers that must activate the
|
|
615
|
+
// payload FIRST (rollback/compensation pair transition) pass prepareOnly=true
|
|
616
|
+
// to swap the tree WITHOUT starting the process, then activate the payload,
|
|
617
|
+
// then start once and dual-verify. On success the retained copy is consumed
|
|
618
|
+
// (moved), so a subsequent rollback to the same cohort re-stages from the
|
|
619
|
+
// registry.
|
|
620
|
+
export async function restoreRetainedRuntime({
|
|
621
|
+
home = homedir(),
|
|
622
|
+
version,
|
|
623
|
+
stopOwned,
|
|
624
|
+
startOwned,
|
|
625
|
+
verifyOwned,
|
|
626
|
+
rename = renameSync,
|
|
627
|
+
log = () => {},
|
|
628
|
+
prepareOnly = false,
|
|
629
|
+
} = {}) {
|
|
630
|
+
const retained = findRetainedRuntime({ home, version });
|
|
631
|
+
if (!retained) {
|
|
632
|
+
return { ok: false, code: 'DSH_RUNTIME_RETAINED_MISSING', error: `no retained runtime for cohort ${version}` };
|
|
633
|
+
}
|
|
634
|
+
if (typeof stopOwned !== 'function') {
|
|
635
|
+
return { ok: false, code: 'DSH_RUNTIME_MIGRATION_CALLBACKS_MISSING', error: 'stop callback is required' };
|
|
636
|
+
}
|
|
637
|
+
if (!prepareOnly && (typeof startOwned !== 'function' || typeof verifyOwned !== 'function')) {
|
|
638
|
+
return { ok: false, code: 'DSH_RUNTIME_MIGRATION_CALLBACKS_MISSING', error: 'start/verify callbacks are required unless prepareOnly' };
|
|
639
|
+
}
|
|
640
|
+
const liveRoot = crewDshRuntimeRoot({ home });
|
|
641
|
+
const stop = await stopOwned();
|
|
642
|
+
if (!stop.ok) {
|
|
643
|
+
return { ok: false, code: stop.code ?? 'DSH_RUNTIME_STOP_FAILED', error: stop.error ?? 'could not stop owned 3210' };
|
|
644
|
+
}
|
|
645
|
+
try {
|
|
646
|
+
if (existsSync(liveRoot)) rmSync(liveRoot, { recursive: true, force: true });
|
|
647
|
+
rename(retained, liveRoot);
|
|
648
|
+
} catch (error) {
|
|
649
|
+
// Restore the pre-existing live tree is impossible (it was replaced only
|
|
650
|
+
// on success above); report and let the caller reconcile.
|
|
651
|
+
return { ok: false, code: 'DSH_RUNTIME_RESTORE_SWAP_FAILED', error: String(error?.message ?? error) };
|
|
652
|
+
}
|
|
653
|
+
if (prepareOnly) {
|
|
654
|
+
log(`- runtime tree prepared offline from retained tree (@${version}); process not started`);
|
|
655
|
+
return { ok: true, version, liveRoot, prepared: true };
|
|
656
|
+
}
|
|
657
|
+
const start = await startOwned();
|
|
658
|
+
if (!start.ok) {
|
|
659
|
+
return { ok: false, code: start.code ?? 'DSH_RUNTIME_START_FAILED', error: start.error ?? 'restart after restore failed', restored: true };
|
|
660
|
+
}
|
|
661
|
+
const verified = await verifyOwned();
|
|
662
|
+
if (!verified.ok) {
|
|
663
|
+
return { ok: false, code: verified.code ?? 'DSH_RUNTIME_VERIFY_FAILED', error: verified.error ?? 'identity check after restore failed', restored: true };
|
|
664
|
+
}
|
|
665
|
+
log(`- runtime cohort restored offline from retained tree (@${version})`);
|
|
666
|
+
return { ok: true, version, liveRoot };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// GC retained runtimes that no release pins. A retained cohort is needed only
|
|
670
|
+
// while some managed release (current or retained) declares it as its exact
|
|
671
|
+
// @deepseek-ai/dsh dependency. Best-effort; never throws.
|
|
672
|
+
export function gcRetainedRuntimes({ home = homedir(), releases = [], log = () => {} } = {}) {
|
|
673
|
+
const root = retainedRuntimesRoot({ home });
|
|
674
|
+
if (!existsSync(root)) return [];
|
|
675
|
+
const needed = new Set();
|
|
676
|
+
for (const release of releases) {
|
|
677
|
+
const spec = payloadDshSpec(release);
|
|
678
|
+
if (spec && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(spec)) needed.add(spec);
|
|
679
|
+
}
|
|
680
|
+
const removed = [];
|
|
681
|
+
for (const name of readdirSync(root)) {
|
|
682
|
+
const dir = join(root, name);
|
|
683
|
+
if (!needed.has(name)) {
|
|
684
|
+
try { rmSync(dir, { recursive: true, force: true }); removed.push(name); } catch { /* best effort */ }
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
return removed;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Read the exact @deepseek-ai/dsh pin from a payload manifest (dependencies
|
|
691
|
+
// first, then peerDependencies). Exact pins only: a range or absence yields
|
|
692
|
+
// null so callers fail closed instead of guessing a cohort.
|
|
693
|
+
function payloadDshSpec(manifest) {
|
|
694
|
+
if (!manifest || typeof manifest !== 'object') return null;
|
|
695
|
+
const direct = manifest.dependencies?.['@deepseek-ai/dsh'];
|
|
696
|
+
if (typeof direct === 'string' && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(direct)) return direct;
|
|
697
|
+
const peer = manifest.peerDependencies?.['@deepseek-ai/dsh'];
|
|
698
|
+
if (typeof peer === 'string' && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(peer)) return peer;
|
|
699
|
+
return null;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
export function payloadDshVersion(manifest) {
|
|
703
|
+
return payloadDshSpec(manifest);
|
|
704
|
+
}
|
|
705
|
+
|
|
246
706
|
function isObject(value) {
|
|
247
707
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
248
708
|
}
|
|
@@ -314,37 +774,37 @@ function readProfileManifestForRegistration(profileRoot, { create = true, defaul
|
|
|
314
774
|
return { profileManifest, manifest, created: false };
|
|
315
775
|
}
|
|
316
776
|
|
|
317
|
-
function ensureProfileScaffold(profileRoot) {
|
|
777
|
+
function ensureProfileScaffold(profileRoot) {
|
|
318
778
|
mkdirSync(profileRoot, { recursive: true });
|
|
319
779
|
let changed = false;
|
|
320
780
|
const patchFile = join(profileRoot, 'cordis.patch.yml');
|
|
321
781
|
if (!existsSync(patchFile)) { writeFileSync(patchFile, PROFILE_PATCH_TEMPLATE); changed = true; }
|
|
322
782
|
const workspaceFile = join(profileRoot, 'pnpm-workspace.yaml');
|
|
323
783
|
if (!existsSync(workspaceFile)) { writeFileSync(workspaceFile, PROFILE_PNPM_WORKSPACE); changed = true; }
|
|
324
|
-
return changed;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
function readCrewTombstones(home) {
|
|
328
|
-
const lifecycleFile = join(dirname(crewDshHome({ home })), 'provider-lifecycle.json');
|
|
329
|
-
if (!existsSync(lifecycleFile)) return { ok: true, tombstones: {} };
|
|
330
|
-
try {
|
|
331
|
-
const state = JSON.parse(readFileSync(lifecycleFile, 'utf8'));
|
|
332
|
-
return { ok: true, tombstones: state?.tombstones && typeof state.tombstones === 'object' && !Array.isArray(state.tombstones) ? state.tombstones : {} };
|
|
333
|
-
} catch { return { ok: false, code: 'PROVIDER_LIFECYCLE_STATE_INVALID' }; }
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
function reconcileCrewProfileProviders({ home, profileRoot }) {
|
|
337
|
-
const patchFile = join(profileRoot, 'cordis.patch.yml');
|
|
338
|
-
if (!existsSync(patchFile)) return { ok: true, changed: false, removed: [] };
|
|
339
|
-
const lifecycle = readCrewTombstones(home);
|
|
340
|
-
if (!lifecycle.ok) return lifecycle;
|
|
341
|
-
const source = readFileSync(patchFile, 'utf8');
|
|
342
|
-
if (source.trim() === '[]') return { ok: true, changed: false, removed: [] };
|
|
343
|
-
const reconciled = reconcileProviderDesiredState(source, { tombstones: lifecycle.tombstones });
|
|
344
|
-
if (!reconciled.ok) return reconciled;
|
|
345
|
-
if (reconciled.changed) writeFileSync(patchFile, reconciled.text);
|
|
346
|
-
return reconciled;
|
|
347
|
-
}
|
|
784
|
+
return changed;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function readCrewTombstones(home) {
|
|
788
|
+
const lifecycleFile = join(dirname(crewDshHome({ home })), 'provider-lifecycle.json');
|
|
789
|
+
if (!existsSync(lifecycleFile)) return { ok: true, tombstones: {} };
|
|
790
|
+
try {
|
|
791
|
+
const state = JSON.parse(readFileSync(lifecycleFile, 'utf8'));
|
|
792
|
+
return { ok: true, tombstones: state?.tombstones && typeof state.tombstones === 'object' && !Array.isArray(state.tombstones) ? state.tombstones : {} };
|
|
793
|
+
} catch { return { ok: false, code: 'PROVIDER_LIFECYCLE_STATE_INVALID' }; }
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function reconcileCrewProfileProviders({ home, profileRoot }) {
|
|
797
|
+
const patchFile = join(profileRoot, 'cordis.patch.yml');
|
|
798
|
+
if (!existsSync(patchFile)) return { ok: true, changed: false, removed: [] };
|
|
799
|
+
const lifecycle = readCrewTombstones(home);
|
|
800
|
+
if (!lifecycle.ok) return lifecycle;
|
|
801
|
+
const source = readFileSync(patchFile, 'utf8');
|
|
802
|
+
if (source.trim() === '[]') return { ok: true, changed: false, removed: [] };
|
|
803
|
+
const reconciled = reconcileProviderDesiredState(source, { tombstones: lifecycle.tombstones });
|
|
804
|
+
if (!reconciled.ok) return reconciled;
|
|
805
|
+
if (reconciled.changed) writeFileSync(patchFile, reconciled.text);
|
|
806
|
+
return reconciled;
|
|
807
|
+
}
|
|
348
808
|
|
|
349
809
|
function ensureDirectoryLink(linkPath, targetRoot) {
|
|
350
810
|
let stat;
|
|
@@ -431,13 +891,13 @@ export function ensurePluginRegistration({
|
|
|
431
891
|
} catch { return { ok: false, code: 'CREW_PLUGIN_NOT_LOADABLE' }; }
|
|
432
892
|
}
|
|
433
893
|
|
|
434
|
-
export function ensureCrewPluginRegistration({ home = homedir(), root, name } = {}) {
|
|
435
|
-
const result = ensurePluginRegistration({ profileRoot: crewProfileDir({ home }), root, name });
|
|
436
|
-
if (!result.ok) return result;
|
|
437
|
-
const reconciled = reconcileCrewProfileProviders({ home, profileRoot: result.profileRoot });
|
|
438
|
-
if (!reconciled.ok) return reconciled;
|
|
439
|
-
return { ...result, changed: result.changed || reconciled.changed, provider_reconciliation: reconciled.removed ?? [] };
|
|
440
|
-
}
|
|
894
|
+
export function ensureCrewPluginRegistration({ home = homedir(), root, name } = {}) {
|
|
895
|
+
const result = ensurePluginRegistration({ profileRoot: crewProfileDir({ home }), root, name });
|
|
896
|
+
if (!result.ok) return result;
|
|
897
|
+
const reconciled = reconcileCrewProfileProviders({ home, profileRoot: result.profileRoot });
|
|
898
|
+
if (!reconciled.ok) return reconciled;
|
|
899
|
+
return { ...result, changed: result.changed || reconciled.changed, provider_reconciliation: reconciled.removed ?? [] };
|
|
900
|
+
}
|
|
441
901
|
|
|
442
902
|
/**
|
|
443
903
|
* Remove one Crew plugin registration without invoking a package manager.
|