@ran-sh/dsh-crew 1.1.1 → 1.2.0-rc.4

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.
Files changed (83) hide show
  1. package/.claude-plugin/marketplace.json +16 -17
  2. package/.claude-plugin/plugin.json +7 -1
  3. package/README.md +44 -15
  4. package/README.zh.md +37 -12
  5. package/agents/ds-flash.md +16 -18
  6. package/agents/ds-pro.md +16 -24
  7. package/agents/ds-reviewer.md +15 -15
  8. package/agents/ds-worker.md +16 -16
  9. package/bin/dsh-crew.mjs +16 -12
  10. package/codex/AGENTS.md +37 -85
  11. package/codex/agents/ds-flash.toml +18 -14
  12. package/codex/agents/ds-pro.toml +18 -15
  13. package/codex/agents/ds-reviewer.toml +17 -13
  14. package/codex/agents/ds-worker.toml +17 -13
  15. package/codex/prompts/dsh-config.md +17 -1
  16. package/codex/prompts/dsh-status.md +5 -1
  17. package/commands/config.md +16 -4
  18. package/commands/status.md +6 -2
  19. package/docs/installation.md +54 -9
  20. package/docs/readiness-matrix.md +14 -6
  21. package/docs/ui-surfaces.md +32 -17
  22. package/lib/client.js +5434 -4844
  23. package/official-web-bridge/lib/client.js +567 -479
  24. package/official-web-bridge/overlay-entry.mjs +59 -0
  25. package/package.json +6 -3
  26. package/scripts/build-client.mjs +2 -0
  27. package/scripts/setup.mjs +8 -7
  28. package/scripts/verify-crew-ui-polish.mjs +145 -0
  29. package/scripts/verify-history-ui.mjs +97 -0
  30. package/scripts/verify-npm-install.mjs +1 -1
  31. package/src/client/history-panel.tsx +108 -0
  32. package/src/client/host-readiness.mjs +15 -6
  33. package/src/client/index.tsx +86 -64
  34. package/src/client/model-callability-view.mjs +17 -0
  35. package/src/client/panel-chrome.tsx +40 -0
  36. package/src/client/quick-panel.tsx +88 -73
  37. package/src/client/readiness-envelope.mjs +34 -0
  38. package/src/config-readiness.mjs +181 -115
  39. package/src/extension-contract.mjs +44 -26
  40. package/src/history/admission-gate.mjs +51 -0
  41. package/src/history/archive-store.mjs +261 -0
  42. package/src/history/cleanup-plan.mjs +64 -0
  43. package/src/history/http.mjs +32 -0
  44. package/src/history/operation.mjs +86 -0
  45. package/src/history/runner.mjs +51 -0
  46. package/src/history/runtime.mjs +28 -0
  47. package/src/history/service.mjs +137 -0
  48. package/src/history/state.mjs +27 -0
  49. package/src/hub/index.mjs +141 -24
  50. package/src/hub-client.mjs +17 -15
  51. package/src/image-output.mjs +71 -0
  52. package/src/install/install-legacy.mjs +126 -23
  53. package/src/install/npx-lifecycle.mjs +373 -64
  54. package/src/install/official-frontend-assets.mjs +78 -0
  55. package/src/install/payload-content.mjs +88 -0
  56. package/src/install/windows-startup.mjs +107 -19
  57. package/src/install/windows-supervisor-adapter.mjs +443 -0
  58. package/src/install/windows-supervisor-lifecycle.mjs +782 -0
  59. package/src/install/zcode.mjs +53 -14
  60. package/src/job-contracts.mjs +37 -31
  61. package/src/jobs.mjs +2 -2
  62. package/src/mcp-runtime.mjs +74 -9
  63. package/src/model-callability-contract.mjs +79 -0
  64. package/src/multimodal.mjs +0 -0
  65. package/src/official-web-bridge.mjs +0 -2
  66. package/src/provider-health.mjs +130 -124
  67. package/src/readiness-matrix.mjs +20 -15
  68. package/src/runtime-identity-contract.mjs +34 -0
  69. package/src/runtime-identity.mjs +14 -4
  70. package/src/runtime-readiness-snapshot.mjs +285 -87
  71. package/src/server.mjs +86 -76
  72. package/src/standalone-sdk.mjs +23 -0
  73. package/src/supervisor/restart-request.mjs +64 -10
  74. package/src/workflow-runtime.mjs +28 -13
  75. package/windows/start-dsh-crew.cmd +57 -57
  76. package/windows/start-dsh-crew.ps1 +314 -51
  77. package/windows/supervisor-control.ps1 +467 -0
  78. package/zcode/AGENTS.md +4 -1
  79. package/zcode/agents/ds-reviewer.md +31 -36
  80. package/zcode/agents/ds-worker.md +31 -39
  81. package/zcode/commands/dsh-config.md +17 -6
  82. package/zcode/commands/dsh-status.md +5 -4
  83. package/.mcp.json +0 -8
@@ -0,0 +1,59 @@
1
+ // UI-only overlay host. No process lifecycle, installer or profile mutations.
2
+ import { localRequestCore, originAuthorityMatches } from '../src/local-request-guard.mjs';
3
+ import { readFileSync } from 'node:fs';
4
+
5
+ const PREFIX = '/_dsh/dsh-crew';
6
+ const BACKEND = 'http://127.0.0.1:3210';
7
+ const REVISION = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).dshCrewFrontendRevision ?? null;
8
+
9
+ function trusted(req) {
10
+ return localRequestCore(req) && (req.headers.origin === undefined
11
+ || originAuthorityMatches(req.headers.origin, req.headers.host));
12
+ }
13
+
14
+ function send(res, status, body) {
15
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
16
+ res.end(JSON.stringify(body));
17
+ }
18
+
19
+ export function registerFrontend(ctx, { fetchImpl = globalThis.fetch } = {}) {
20
+ return ctx.inject(['webServer'], ({ webServer }) => {
21
+ const disposers = [];
22
+ disposers.push(webServer.register({ kind: 'exact', path: `${PREFIX}/bridge-status`, handler(req, res) {
23
+ if (!trusted(req)) return send(res, 403, { ok: false, code: 'LOCAL_SAME_ORIGIN_ONLY' });
24
+ send(res, 200, { ok: true, surface: 'official-bridge', ui_role: 'quick-controls', frontend_revision: REVISION, full_control_plane_url: `${BACKEND}/` });
25
+ } }));
26
+ for (const suffix of ['/quick-config', '/quick-status']) {
27
+ disposers.push(webServer.register({ kind: 'exact', path: `${PREFIX}${suffix}`, async handler(req, res) {
28
+ if (!trusted(req)) return send(res, 403, { ok: false, code: 'LOCAL_SAME_ORIGIN_ONLY' });
29
+ const method = req.method ?? 'GET';
30
+ if (method !== 'GET' && !(suffix === '/quick-config' && method === 'POST')) return send(res, 405, { ok: false, code: 'METHOD_NOT_ALLOWED' });
31
+ try {
32
+ const identityResponse = await fetchImpl(`${BACKEND}${PREFIX}/runtime`, { signal: AbortSignal.timeout(2500) });
33
+ const identity = await identityResponse.json();
34
+ if (!identityResponse.ok || identity.ok !== true || identity.service !== 'dsh-crew-hub'
35
+ || identity.profile !== 'dsh-crew' || identity.execution_plane !== 'hub-3210'
36
+ || identity.listen_port !== 3210 || identity.protocol_version !== 1) throw new Error('incompatible backend');
37
+ const chunks = [];
38
+ let size = 0;
39
+ if (method === 'POST') for await (const chunk of req) {
40
+ size += chunk.length;
41
+ if (size > 64 * 1024) return send(res, 413, { ok: false, code: 'BODY_TOO_LARGE' });
42
+ chunks.push(Buffer.from(chunk));
43
+ }
44
+ const response = await fetchImpl(`${BACKEND}${PREFIX}${suffix}`, {
45
+ method, headers: { 'content-type': 'application/json' },
46
+ ...(method === 'POST' ? { body: Buffer.concat(chunks) } : {}),
47
+ signal: AbortSignal.timeout(5000),
48
+ });
49
+ send(res, response.status, await response.json());
50
+ } catch {
51
+ send(res, 503, { ok: false, ready: false, code: 'CREW_BACKEND_UNAVAILABLE' });
52
+ }
53
+ } }));
54
+ }
55
+ return () => { for (const dispose of disposers) dispose?.(); };
56
+ });
57
+ }
58
+
59
+ export function apply(ctx) { registerFrontend(ctx); }
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "@ran-sh/dsh-crew",
3
- "version": "1.1.1",
3
+ "version": "1.2.0-rc.4",
4
4
  "type": "module",
5
5
  "main": "./src/hub/entry.mjs",
6
6
  "bin": {
7
7
  "dsh-crew": "bin/dsh-crew.mjs"
8
8
  },
9
9
  "description": "DeepSeek Harness plugin: dispatch work to DSH agents from Claude Code / Codex, as native subagents with live progress",
10
+ "dshCrew": {
11
+ "payloadSchema": 2,
12
+ "windowsSupervisorHandoff": 1
13
+ },
10
14
  "dsh": {
11
15
  "bundle": {
12
16
  "patch": "./cordis.patch.yml"
@@ -22,7 +26,7 @@
22
26
  },
23
27
  "scripts": {
24
28
  "mcp": "node src/server.mjs",
25
- "build:client": "tsdown src/client/entry.tsx src/client/quick-entry.tsx --format cjs --platform browser --target es2022 --tsconfig tsconfig.client.json --deps.never-bundle react --deps.never-bundle react/jsx-runtime --deps.never-bundle react-dom --deps.never-bundle react-dom/client --out-dir .client-build --clean --logLevel warn && node scripts/build-client.mjs",
29
+ "build:client": "tsdown src/client/entry.tsx --format cjs --platform browser --target es2022 --tsconfig tsconfig.client.json --deps.never-bundle react --deps.never-bundle react/jsx-runtime --deps.never-bundle react-dom --deps.never-bundle react-dom/client --out-dir .client-build --clean --logLevel warn && tsdown src/client/quick-entry.tsx --format cjs --platform browser --target es2022 --tsconfig tsconfig.client.json --deps.never-bundle react --deps.never-bundle react/jsx-runtime --deps.never-bundle react-dom --deps.never-bundle react-dom/client --out-dir .client-build --no-clean --logLevel warn && node scripts/build-client.mjs",
26
30
  "verify:npm-install": "node scripts/verify-npm-install.mjs",
27
31
  "verify:npm-install:official": "node scripts/verify-npm-install.mjs --with-official-dsh",
28
32
  "verify:official-bridge": "node scripts/verify-official-bridge-e2e.mjs --diagnostic-only"
@@ -212,7 +216,6 @@
212
216
  "cordis.patch.yml",
213
217
  "worker.cordis.yml",
214
218
  ".claude-plugin",
215
- ".mcp.json",
216
219
  "README.md",
217
220
  "README.*.md",
218
221
  "docs/job-contracts.md",
@@ -24,6 +24,8 @@ for (const expected of [fullEntry, quickEntry]) {
24
24
  }
25
25
 
26
26
  function wrap(source, id) {
27
+ // The host resolves package modules, not discarded tsdown shared chunks.
28
+ if (/require\(["']\.\.?\//.test(source)) throw new Error(`client ${id} depends on an unshipped relative chunk`);
27
29
  return [
28
30
  `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) => {`,
29
31
  'var module = { exports: {} }; var exports = module.exports;',
package/scripts/setup.mjs CHANGED
@@ -220,7 +220,7 @@ export async function setupInstall({
220
220
 
221
221
  if (dryRun) mark(log, true, 'Codex Desktop integration (dry-run)');
222
222
  else {
223
- const r = installer.installCodex ? installer.installCodex({ home }) : realInstaller.installCodex({ home });
223
+ const r = installer.installCodex ? installer.installCodex({ home, root }) : realInstaller.installCodex({ home, root });
224
224
  mark(log, r.ok !== false, r.ok === false ? `Codex Desktop integration failed: ${(r.actions ?? []).join('; ')}` : 'Codex Desktop integration');
225
225
  if (r.ok === false) return { ok: false, error: 'Codex integration failed' };
226
226
  }
@@ -252,7 +252,7 @@ export async function setupInstall({
252
252
  if (commandExists('claude')) {
253
253
  if (dryRun) mark(log, true, 'Claude Code integration (dry-run)');
254
254
  else {
255
- const r = installer.installClaudeCode ? await installer.installClaudeCode({ home }) : await realInstaller.installClaudeCode({ home });
255
+ const r = installer.installClaudeCode ? await installer.installClaudeCode({ home, root }) : await realInstaller.installClaudeCode({ home, root });
256
256
  const ok = r.ok !== false;
257
257
  mark(log, ok, ok ? 'Claude Code integration' : 'Claude Code integration failed');
258
258
  if (!ok) return { ok: false, error: 'Claude Code integration failed' };
@@ -333,12 +333,13 @@ export async function setupUninstall({
333
333
 
334
334
  export async function setupStatus({ log = console.log, root = ROOT, home = homedir(), installer = realInstaller } = {}) {
335
335
  const st = installer.installStatus ? installer.installStatus({ home, root }) : realInstaller.installStatus({ home, root });
336
- const claude = st?.claude?.installed ? 'installed' : 'not installed';
337
- const codex = st?.codex?.installed ? 'installed' : 'not installed';
338
- const zcode = st?.zcode?.installed ? 'installed' : 'not installed';
336
+ const integrationLabel = (entry) => !entry?.installed ? 'not installed' : entry.ready === true ? 'installed' : 'needs repair';
337
+ const claude = integrationLabel(st?.claude);
338
+ const codex = integrationLabel(st?.codex);
339
+ const zcode = integrationLabel(st?.zcode);
339
340
  const startupState = installer.windowsStartupStatus
340
- ? installer.windowsStartupStatus({ home })
341
- : realInstaller.windowsStartupStatus({ home });
341
+ ? installer.windowsStartupStatus({ home, root })
342
+ : realInstaller.windowsStartupStatus({ home, root });
342
343
  const windowsStartup = !startupState.supported ? 'not supported'
343
344
  : startupState.ready ? 'installed' : startupState.installed ? 'needs repair' : 'not installed';
344
345
  // Crew status reads ONLY the dedicated Crew profile under the Crew DSH_HOME;
@@ -0,0 +1,145 @@
1
+ // Isolated browser acceptance of the shipped modules. All HTTP requests are
2
+ // intercepted: no real Crew configuration or host browser profile is accessed.
3
+ import assert from 'node:assert/strict';
4
+ import { createRequire } from 'node:module';
5
+ import { readFileSync, mkdtempSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { join, dirname } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const require = createRequire(import.meta.url);
11
+ const { chromium } = require(process.env.CREW_PLAYWRIGHT_MODULE || 'playwright');
12
+ const root = dirname(dirname(fileURLToPath(import.meta.url)));
13
+ const output = mkdtempSync(join(tmpdir(), 'crew-ui-polish-'));
14
+ const browser = await chromium.launch({ headless: true,
15
+ ...(process.env.CREW_BROWSER_EXE ? { executablePath: process.env.CREW_BROWSER_EXE } : {}),
16
+ });
17
+ const errors = [];
18
+ const results = [];
19
+ const baseConfig = {
20
+ subagents_enabled: true, collaboration_mode: 'balanced', main_agent_mode: 'direct-allowed',
21
+ flash_state: 'auto', pro_state: 'auto', isolation: 'worktree', max_parallel: 16,
22
+ default_timeout_seconds: 1800,
23
+ default_tier: 'flash', default_effort: 'max', mode: 'auto',
24
+ vision_enabled: true, imagegen_enabled: false, vision_provider: 'example', imagegen_provider: 'example',
25
+ flash_model_priority: [
26
+ { provider: 'example-provider', model: 'long-model-name-for-responsive-layout-check' },
27
+ { provider: 'example-provider', model: 'second-model' },
28
+ ], pro_model_priority: [],
29
+ };
30
+
31
+ async function mount(surface, locale = 'zh') {
32
+ const page = await browser.newPage({ viewport: { width: 1000, height: 1000 } });
33
+ page.on('pageerror', error => errors.push(error.message));
34
+ let config = structuredClone(baseConfig);
35
+ const patches = [];
36
+ await page.route('**/*', async route => {
37
+ const request = route.request();
38
+ const url = new URL(request.url());
39
+ if (url.origin !== 'http://127.0.0.1:41790') return route.abort();
40
+ if (url.pathname === '/') return route.fulfill({ contentType: 'text/html', body:
41
+ '<!doctype html><html><head><meta charset="utf-8"><style>body{margin:0;background:#f6f7f9;color:#20252b;font-family:Arial,sans-serif}main{max-width:760px;margin:24px auto;padding:20px;background:white;border-radius:16px}@media(prefers-color-scheme:dark){body{background:#16191d;color:#e5e7eb}main{background:#20242a}}</style></head><body><main id="root"></main></body></html>' });
42
+ const suffix = url.pathname.replace('/_dsh/dsh-crew', '');
43
+ let body = { ok: true };
44
+ if (suffix === '/bridge-status') body = surface === 'quick' ? { ok: true, surface: 'official-bridge' } : { ok: false };
45
+ else if (suffix === '/runtime') body = { ok: true, service: 'dsh-crew-hub', profile: 'dsh-crew', listen_port: 3210, execution_plane: 'hub-3210', runtime_version: 'test', protocol_version: 1 };
46
+ else if (suffix === '/quick-config' && request.method() === 'POST') {
47
+ const patch = request.postDataJSON(); patches.push(patch); config = { ...config, ...patch }; body = { ok: true, config };
48
+ } else if (suffix === '/quick-status' || suffix === '/config') body = { ok: true, config };
49
+ else if (suffix === '/jobs') body = { ok: true, jobs: [] };
50
+ else if (suffix === '/install/status') body = { ok: true, status: { codex: { ready: true, installed: true }, claude: { ready: true, installed: true }, zcode: { ready: true, installed: true } } };
51
+ else if (suffix === '/model-catalog' || suffix === '/providers') body = { ok: true, providers: [], records: [] };
52
+ return route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) });
53
+ });
54
+ await page.goto('http://127.0.0.1:41790/');
55
+ await page.waitForLoadState('networkidle');
56
+ await page.addScriptTag({ content: readFileSync(join(root, 'node_modules/react/umd/react.development.js'), 'utf8') });
57
+ await page.addScriptTag({ content: readFileSync(join(root, 'node_modules/react-dom/umd/react-dom.development.js'), 'utf8') });
58
+ await page.evaluate(locale => {
59
+ const ctx = {
60
+ on: () => () => {}, locale: { getLocale: () => ({ active: locale }) },
61
+ slots: { inject: (_name, callback) => callback(), register: (_meta, render) => { window.crewRoot.render(render()); return () => {}; } },
62
+ };
63
+ window.crewRoot = window.ReactDOM.createRoot(document.getElementById('root'));
64
+ window.__ModuleLoader__ = { load({ factory }) {
65
+ const jsx = (tag, props, key) => window.React.createElement(tag, { ...props, ...(key === undefined ? {} : { key }) });
66
+ factory(id => {
67
+ if (id === 'react') return window.React;
68
+ if (id === 'react-dom' || id === 'react-dom/client') return window.ReactDOM;
69
+ if (id === 'react/jsx-runtime') return { jsx, jsxs: jsx, Fragment: window.React.Fragment };
70
+ throw Error('Unexpected fixture dependency: ' + id);
71
+ }).apply(ctx);
72
+ } };
73
+ }, locale);
74
+ await page.addScriptTag({ content: readFileSync(join(root, surface === 'quick' ? 'official-web-bridge/lib/client.js' : 'lib/client.js'), 'utf8') });
75
+ try { await page.locator('.crew-panel-header').waitFor({ timeout: 10000 }); }
76
+ catch (error) {
77
+ console.error(JSON.stringify({ surface, errors, body: (await page.locator('body').innerText()).slice(0, 1600) }));
78
+ throw error;
79
+ }
80
+ return { page, patches };
81
+ }
82
+
83
+ try {
84
+ const { page, patches } = await mount('quick');
85
+ await page.getByRole('link', { name: '打开 3210 后台 →' }).waitFor();
86
+ assert.equal(await page.getByRole('link', { name: '打开 3210 后台 →' }).getAttribute('href'), 'http://127.0.0.1:3210/');
87
+ await page.screenshot({ path: join(output, '3080-quick.png'), fullPage: true });
88
+ const worker = page.locator('details.crew-quick-group').filter({ has: page.locator('summary strong', { hasText: 'Worker / Flash' }) });
89
+ await worker.getByRole('button', { name: '下移 long-model-name-for-responsive-layout-check' }).click();
90
+ await page.waitForFunction(() => document.querySelector('.crew-quick-list li .crew-quick-model-name')?.textContent === 'second-model');
91
+ assert.equal(patches.at(-1).flash_model_priority[0].model, 'second-model');
92
+ await worker.getByRole('button', { name: '从优先级移除 second-model' }).click();
93
+ await worker.locator('li').first().waitFor();
94
+ await worker.locator('.crew-quick-add > summary').click();
95
+ await worker.getByRole('textbox', { name: 'Worker / Flash Provider', exact: true }).fill('added-provider');
96
+ await worker.getByRole('textbox', { name: 'Worker / Flash 模型', exact: true }).fill('added-model');
97
+ await worker.getByRole('button', { name: '+ 添加模型', exact: true }).click();
98
+ await worker.getByText('added-model', { exact: true }).waitFor();
99
+ assert.equal(patches.at(-1).flash_model_priority.at(-1).model, 'added-model');
100
+ const reviewer = page.locator('details.crew-quick-group').filter({ has: page.locator('summary strong', { hasText: 'Reviewer / Pro' }) });
101
+ await reviewer.locator(':scope > summary').click();
102
+ await reviewer.locator('.crew-quick-add > summary').click();
103
+ await reviewer.getByRole('textbox', { name: 'Reviewer / Pro Provider', exact: true }).fill('review-provider');
104
+ await reviewer.getByRole('textbox', { name: 'Reviewer / Pro 模型', exact: true }).fill('review-model');
105
+ await reviewer.getByRole('button', { name: '+ 添加模型', exact: true }).click();
106
+ await reviewer.getByText('review-model', { exact: true }).waitFor();
107
+ await page.waitForFunction(() => !document.querySelector('input[type=checkbox]').disabled);
108
+ assert.notEqual(await reviewer.getAttribute('open'), null, 'Pro stays expanded after save rerenders');
109
+ const multimodal = page.locator('details.crew-quick-group').filter({ has: page.locator('summary strong', { hasText: '多模态' }) });
110
+ await multimodal.locator(':scope > summary').click();
111
+ await page.getByRole('checkbox', { name: '启用子 Agent' }).uncheck();
112
+ await page.waitForFunction(() => !document.querySelector('input[type=checkbox]').disabled);
113
+ assert.equal(patches.at(-1).subagents_enabled, false);
114
+ assert.notEqual(await reviewer.getAttribute('open'), null, 'Pro stays expanded after other settings change');
115
+ assert.notEqual(await multimodal.getAttribute('open'), null, 'Multimodal stays expanded after other settings change');
116
+ await worker.locator(':scope > summary').click();
117
+ assert.equal(await worker.getAttribute('open'), null);
118
+ await page.setViewportSize({ width: 420, height: 900 });
119
+ await worker.locator(':scope > summary').click();
120
+ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
121
+ await page.screenshot({ path: join(output, '3080-narrow.png'), fullPage: true });
122
+ await page.emulateMedia({ colorScheme: 'dark' });
123
+ await page.screenshot({ path: join(output, '3080-dark.png'), fullPage: true });
124
+ results.push('quick: reorder/remove/add/toggle/collapse; Pro and multimodal stay open across save rerenders; narrow/dark');
125
+ await page.close();
126
+
127
+ const { page: backend } = await mount('full');
128
+ const backLink = backend.getByRole('link', { name: '打开 3080 官方界面 →' });
129
+ assert.equal(await backLink.getAttribute('href'), 'http://127.0.0.1:3080/');
130
+ await backend.getByRole('button', { name: '全部折叠', exact: true }).click();
131
+ await backend.screenshot({ path: join(output, '3210-backend.png'), fullPage: true });
132
+ await backend.getByRole('button', { name: '全部展开', exact: true }).click();
133
+ assert.equal(await backend.locator('.crew-section-trigger[aria-expanded=false]').count(), 0);
134
+ await backend.getByRole('button', { name: '全部折叠', exact: true }).click();
135
+ await backend.setViewportSize({ width: 420, height: 900 });
136
+ assert.equal(await backend.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
137
+ await backend.screenshot({ path: join(output, '3210-narrow.png'), fullPage: true });
138
+ results.push('backend: reciprocal link/expand all/collapse all/narrow');
139
+ await backend.close();
140
+ const { page: english } = await mount('quick', 'en');
141
+ await english.getByRole('button', { name: 'Move down long-model-name-for-responsive-layout-check' }).waitFor();
142
+ await english.close();
143
+ assert.deepEqual(errors, []);
144
+ console.log(JSON.stringify({ ok: true, results, consoleErrors: errors, screenshots: output }));
145
+ } finally { await browser.close(); }
@@ -0,0 +1,97 @@
1
+ // Full browser -> HTTP handler -> controller -> executor -> real TEMP files.
2
+ // Supervisor process control is simulated; never addresses real 3080/3210.
3
+ import assert from 'node:assert/strict';
4
+ import { createRequire } from 'node:module';
5
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
6
+ import { join, dirname } from 'node:path';
7
+ import { tmpdir } from 'node:os';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { Readable } from 'node:stream';
10
+ import { createHistoryService } from '../src/history/service.mjs';
11
+ import { registerHistoryHttp } from '../src/history/http.mjs';
12
+ import { runHistoryOperation } from '../src/history/operation.mjs';
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const { chromium } = require(process.env.CREW_PLAYWRIGHT_MODULE || 'playwright');
16
+ const repo = dirname(dirname(fileURLToPath(import.meta.url)));
17
+ const root = mkdtempSync(join(tmpdir(), 'crew-history-e2e-'));
18
+ const output = mkdtempSync(join(tmpdir(), 'crew-history-ui-shots-'));
19
+ mkdirSync(join(root, 'harness/storages'), { recursive: true });
20
+ mkdirSync(join(root, 'harness/sessions/example/session-test'), { recursive: true });
21
+ const file = join(root, 'harness/sessions/example/session-test/session.jsonl');
22
+ writeFileSync(file, 'DISPOSABLE TEST CONVERSATION');
23
+ writeFileSync(join(root, 'harness/storages/workspace.json'), JSON.stringify({ unit: { name: 'workspace', version: 2 }, global: { initialized: true, workspaceIds: ['test-workspace'], archivedSessionIds: [] }, tables: { workspaces: { 'test-workspace': { path: '/example/project', title: 'Disposable workspace', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z', sessionIds: ['session-test'] } } } }));
24
+ const errors = []; let stopped = false; let operationPromise;
25
+ const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
26
+ const service = createHistoryService({ crewRoot: root, runtimeId: 'test', agents: { list: () => [], create: async () => ({}) },
27
+ persistence: { supportsRawArtifacts: true, listSnapshots: async () => existsSync(file) ? [{ header: { id: 'session-test', createdAt: 1767225600000 }, revision: 'r1' }] : [], locate: () => ({ kind: 'jsonl', path: file }) },
28
+ launch: async id => { operationPromise = pause(200).then(() => runHistoryOperation({ crewRoot: root, id,
29
+ acquire: () => ({ ok: true }), release: () => ({ ok: true }), checkFence: () => service.fencedCheck(), assertStopped: () => stopped,
30
+ supervisor: { stopOwnedBackend: async () => { stopped = true; await pause(150); return { ok: true }; }, startOwnedBackend: async () => { await pause(150); stopped = false; return { ok: true }; } }, verifyRunning: async () => !stopped,
31
+ })); operationPromise.catch(error => errors.push(error.message)); },
32
+ });
33
+ let historyHandler;
34
+ registerHistoryHttp({ register: route => { historyHandler = route.handler; return () => {}; } }, service);
35
+ const browser = await chromium.launch({ headless: true, ...(process.env.CREW_BROWSER_EXE ? { executablePath: process.env.CREW_BROWSER_EXE } : {}) });
36
+ try {
37
+ const page = await browser.newPage({ viewport: { width: 1100, height: 1100 } });
38
+ page.on('pageerror', error => errors.push(error.message));
39
+ page.on('dialog', dialog => dialog.accept());
40
+ await page.route('**/*', async route => {
41
+ const request = route.request(); const url = new URL(request.url());
42
+ if (url.origin !== 'http://127.0.0.1:41791') return route.abort();
43
+ if (url.pathname === '/') return route.fulfill({ contentType: 'text/html', body: '<html><head><meta charset="utf-8"></head><body style="font:14px Arial;background:#f5f6f8"><main id="root" style="padding:24px;max-width:800px;margin:auto;background:white"></main></body></html>' });
44
+ if (url.pathname.includes('/history/')) {
45
+ if (stopped) return route.abort();
46
+ const req = Readable.from([Buffer.from(request.postData() ?? '{}')]);
47
+ Object.assign(req, { url: url.pathname, method: request.method(), headers: { host: url.host, origin: url.origin }, socket: { remoteAddress: '127.0.0.1' } });
48
+ let status, body;
49
+ await historyHandler(req, { writeHead: n => { status = n; }, end: s => { body = s; } });
50
+ return route.fulfill({ status, contentType: 'application/json', body });
51
+ }
52
+ const suffix = url.pathname.split('/').pop(); let body = { ok: true };
53
+ if (suffix === 'runtime') body = { ok: true, service: 'dsh-crew-hub', profile: 'dsh-crew', listen_port: 3210 };
54
+ if (suffix === 'bridge-status') body = { ok: false };
55
+ if (suffix === 'config') body = { ok: true, config: { flash_model_priority: [], pro_model_priority: [], default_timeout_seconds: 1800 } };
56
+ if (suffix === 'status') body = { ok: true, status: {} };
57
+ return route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) });
58
+ });
59
+ await page.goto('http://127.0.0.1:41791/'); await page.waitForLoadState('networkidle');
60
+ await page.addScriptTag({ content: readFileSync(join(repo, 'node_modules/react/umd/react.development.js'), 'utf8') });
61
+ await page.addScriptTag({ content: readFileSync(join(repo, 'node_modules/react-dom/umd/react-dom.development.js'), 'utf8') });
62
+ await page.evaluate(() => {
63
+ const root = window.ReactDOM.createRoot(document.getElementById('root'));
64
+ const ctx = { on: () => () => {}, locale: { getLocale: () => ({ active: 'zh' }) }, slots: { inject: (_n, callback) => callback(), register: (_meta, render) => { root.render(render()); return () => {}; } } };
65
+ window.__ModuleLoader__ = { load({ factory }) { const jsx = (tag, props, key) => window.React.createElement(tag, { ...props, ...(key === undefined ? {} : { key }) }); factory(id => {
66
+ if (id === 'react') return window.React;
67
+ if (id === 'react-dom' || id === 'react-dom/client') return window.ReactDOM;
68
+ if (id === 'react/jsx-runtime') return { jsx, jsxs: jsx, Fragment: window.React.Fragment };
69
+ throw Error('Unexpected dependency');
70
+ }).apply(ctx); } };
71
+ });
72
+ await page.addScriptTag({ content: readFileSync(join(repo, 'lib/client.js'), 'utf8') });
73
+ await page.getByText('工作区与会话清理', { exact: true }).click();
74
+ const preview = page.getByRole('button', { name: '预览清理范围', exact: true });
75
+ await preview.click();
76
+ await page.getByText('1 个工作区 · 1 个会话', { exact: true }).first().waitFor();
77
+ const confirm = page.getByRole('button', { name: '确认执行', exact: true });
78
+ assert.equal(await confirm.isEnabled(), false);
79
+ await page.getByRole('checkbox', { name: '我已确认范围,并同意短暂重启 3210' }).check();
80
+ await page.screenshot({ path: join(output, 'preview.png'), fullPage: true });
81
+ await confirm.click(); await page.getByRole('button', { name: '恢复', exact: true }).waitFor({ timeout: 15000 }); await operationPromise;
82
+ assert.equal(existsSync(file), false);
83
+ await page.getByRole('button', { name: '恢复', exact: true }).click();
84
+ await page.waitForFunction(() => [...document.querySelectorAll('[role=status]')].some(el => el.textContent === '已完成'));
85
+ await operationPromise; assert.equal(readFileSync(file, 'utf8'), 'DISPOSABLE TEST CONVERSATION');
86
+ await page.getByRole('combobox', { name: '操作', exact: true }).selectOption('delete');
87
+ await page.getByRole('combobox', { name: '时间范围', exact: true }).selectOption('before');
88
+ await page.getByLabel('创建时间早于', { exact: true }).fill('2027-01-01T00:00');
89
+ await preview.click(); await page.getByRole('checkbox', { name: '我已确认范围,并同意短暂重启 3210' }).check();
90
+ assert.equal(await confirm.isEnabled(), false);
91
+ await page.getByLabel('删除确认:请输入 DELETE', { exact: true }).fill('DELETE'); await confirm.click();
92
+ await page.waitForFunction(() => [...document.querySelectorAll('[role=status]')].some(el => el.textContent === '已完成'));
93
+ await operationPromise; assert.equal(existsSync(file), false); assert.deepEqual(service.archives(), []);
94
+ await page.screenshot({ path: join(output, 'completed.png'), fullPage: true });
95
+ assert.deepEqual(errors, []);
96
+ console.log(JSON.stringify({ ok: true, flows: ['archive all', 'restore', 'delete before date', 'confirmation guards', 'reconnect'], screenshots: output }));
97
+ } finally { await browser.close(); service.dispose(); rmSync(root, { recursive: true, force: true }); }
@@ -35,7 +35,6 @@ export function resolveNpmInvocation({
35
35
  return { command: nodePath, argsPrefix: [npmCli] };
36
36
  }
37
37
 
38
- const npmInvocation = resolveNpmInvocation();
39
38
 
40
39
  /**
41
40
  * The candidate version is derived from the candidate package manifest at the
@@ -231,6 +230,7 @@ export async function auditOfficialDshCohort({
231
230
  }
232
231
 
233
232
  function run(args, cwd = root) {
233
+ const npmInvocation = resolveNpmInvocation();
234
234
  const result = spawnSync(npmInvocation.command, [...npmInvocation.argsPrefix, ...args], {
235
235
  cwd,
236
236
  encoding: 'utf8',
@@ -0,0 +1,108 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ const API = '/_dsh/dsh-crew/history/';
4
+ const pending = (phase?: string) => !!phase && !['IDLE', 'DONE', 'FAILED', 'ROLLED_BACK'].includes(phase);
5
+ const copy = {
6
+ zh: {
7
+ title: '工作区与会话清理', intro: '仅影响 3210 的工作区记录与会话日志;不删除项目文件、附件或 3080 数据。',
8
+ operation: '操作', archive: '归档(可恢复)', delete: '删除(不可恢复)', scope: '时间范围', all: '全部', before: '指定时间之前',
9
+ time: '创建时间早于', timeHint: '按本机时区选择,严格按创建时间筛选;包含新会话的工作区会保留。',
10
+ preview: '预览清理范围', confirm: '确认执行', acknowledgement: '删除确认:请输入 DELETE', idle: '会短暂停止并重启 3210;请先结束或关闭所有正在使用的 3210 会话。',
11
+ consent: '我已确认范围,并同意短暂重启 3210', restore: '恢复', archives: '已归档批次', empty: '暂无可恢复的归档',
12
+ working: '维护中,正在等待 3210 重新连接…', refresh: '刷新页面与工作区列表', recover: '恢复未完成的维护',
13
+ recovery: '维护尚未完成,请使用恢复按钮;若 3210 无法连接,在终端运行 dsh-crew history recover。',
14
+ cancelQueued: '取消尚未开始的维护', cancelQueuedHint: '仅取消尚未开始的维护,不清理任何数据;如果操作已开始,请等待完成。',
15
+ count: (w: number, s: number) => `${w} 个工作区 · ${s} 个会话`, confirmRestore: '恢复这一批归档?将短暂重启 3210,不会覆盖冲突数据。',
16
+ phase: { IDLE: '就绪', QUEUED: '已提交', STOPPING: '停止后台', APPLYING: '处理数据', STARTING: '启动后台', VERIFYING: '验证结果', DONE: '已完成', FAILED: '未执行成功', ROLLED_BACK: '已回滚', RECOVERY_REQUIRED: '需要恢复' },
17
+ },
18
+ en: {
19
+ title: 'Workspace & session cleanup', intro: 'Only 3210 workspace records and session logs. Project files, attachments and 3080 data are untouched.',
20
+ operation: 'Action', archive: 'Archive (restorable)', delete: 'Delete (permanent)', scope: 'Time range', all: 'All', before: 'Before a date',
21
+ time: 'Created before', timeHint: 'Local timezone; strict creation-time cutoff. Workspaces with newer sessions are kept.',
22
+ preview: 'Preview cleanup', confirm: 'Confirm operation', acknowledgement: 'Type DELETE to confirm deletion', idle: '3210 will briefly stop and restart. End or close all active 3210 conversations first.',
23
+ consent: 'I reviewed the scope and agree to restart 3210', restore: 'Restore', archives: 'Archived batches', empty: 'No restorable archives',
24
+ working: 'Maintenance in progress; waiting for 3210 to reconnect…', refresh: 'Reload page and workspace list', recover: 'Recover unfinished maintenance',
25
+ recovery: 'Maintenance is unfinished. Use recovery; if 3210 is unreachable, run dsh-crew history recover in a terminal.',
26
+ cancelQueued: 'Cancel unstarted maintenance', cancelQueuedHint: 'Cancel only if maintenance has not started. No data is cleaned; if already running, wait for completion.',
27
+ count: (w: number, s: number) => `${w} workspaces · ${s} sessions`, confirmRestore: 'Restore this archive? 3210 will briefly restart; conflicting data will not be overwritten.',
28
+ phase: { IDLE: 'Ready', QUEUED: 'Queued', STOPPING: 'Stopping backend', APPLYING: 'Processing data', STARTING: 'Starting backend', VERIFYING: 'Verifying', DONE: 'Completed', FAILED: 'Failed', ROLLED_BACK: 'Rolled back', RECOVERY_REQUIRED: 'Recovery required' },
29
+ },
30
+ };
31
+
32
+ export function HistoryPanel({ locale }: { locale: string }) {
33
+ const t = copy[locale === 'zh' ? 'zh' : 'en'];
34
+ const [open, setOpen] = useState(false);
35
+ const [operation, setOperation] = useState('archive');
36
+ const [scope, setScope] = useState('all');
37
+ const [before, setBefore] = useState('');
38
+ const [preview, setPreview] = useState<any>(null);
39
+ const [status, setStatus] = useState<any>({ phase: 'IDLE' });
40
+ const [archives, setArchives] = useState<any[]>([]);
41
+ const [busy, setBusy] = useState(false);
42
+ const [consent, setConsent] = useState(false);
43
+ const [ack, setAck] = useState('');
44
+ const [notice, setNotice] = useState('');
45
+ const [connected, setConnected] = useState(false);
46
+ const locked = busy || pending(status.phase);
47
+ const invalidate = () => { setPreview(null); setConsent(false); setAck(''); };
48
+
49
+ async function request(action: string, body?: unknown) {
50
+ const res = await fetch(API + action, { cache: 'no-store', ...(body === undefined ? {} : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }) });
51
+ const data = await res.json();
52
+ if (!res.ok || !data.ok) throw Error(data.code ?? `HTTP ${res.status}`);
53
+ return data;
54
+ }
55
+ useEffect(() => {
56
+ if (!open) return;
57
+ let disposed = false;
58
+ const poll = async () => {
59
+ try {
60
+ const [s, a] = await Promise.all([request('status'), request('archives')]);
61
+ if (!disposed) { setStatus(s.status); setArchives(a.archives); setConnected(true); }
62
+ } catch { if (!disposed) setConnected(false); }
63
+ };
64
+ void poll(); const timer = setInterval(poll, 2500);
65
+ return () => { disposed = true; clearInterval(timer); };
66
+ }, [open]);
67
+
68
+ async function act(action: string, body: any) {
69
+ setBusy(true); setNotice('');
70
+ try {
71
+ const result = await request(action, body);
72
+ if (result.preview) { setPreview(result.preview); setConsent(false); setAck(''); }
73
+ if (result.status) { setStatus(result.status); setPreview(null); }
74
+ } catch (error: any) { setNotice(String(error.message)); setPreview(null); }
75
+ finally { setBusy(false); }
76
+ }
77
+ const style = { border: '1px solid rgba(128,128,128,.25)', borderRadius: 8, padding: '7px 10px', color: 'inherit', background: 'transparent', font: 'inherit' };
78
+ return <details style={{ border: '1px solid rgba(128,128,128,.24)', borderRadius: 10, padding: '10px 13px' }} onToggle={e => setOpen(e.currentTarget.open)}>
79
+ <summary style={{ cursor: 'pointer', fontWeight: 650 }}>{t.title}</summary>
80
+ {open && <div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 12 }}>
81
+ <p style={{ margin: 0, opacity: .75 }}>{t.intro}</p>
82
+ <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
83
+ <label>{t.operation} <select style={style} value={operation} disabled={locked} onChange={e => { setOperation(e.target.value); invalidate(); }}><option value="archive">{t.archive}</option><option value="delete">{t.delete}</option></select></label>
84
+ <label>{t.scope} <select style={style} value={scope} disabled={locked} onChange={e => { setScope(e.target.value); invalidate(); }}><option value="all">{t.all}</option><option value="before">{t.before}</option></select></label>
85
+ </div>
86
+ {scope === 'before' && <label>{t.time} <input type="datetime-local" style={style} value={before} disabled={locked} onChange={e => { setBefore(e.target.value); invalidate(); }} /></label>}
87
+ <div style={{ fontSize: 11.5, opacity: .7 }}>{t.timeHint}</div>
88
+ <button type="button" style={style} disabled={locked || !connected || (scope === 'before' && !Number.isFinite(new Date(before).getTime()))} onClick={() => void act('preview', { operation, scope, ...(scope === 'before' ? { before: new Date(before).toISOString() } : {}) })}>{t.preview}</button>
89
+ {preview && <div style={{ ...style, display: 'flex', flexDirection: 'column', gap: 9 }}>
90
+ <strong>{t.count(preview.counts.workspaces, preview.counts.sessions)}</strong>
91
+ {preview.before && <span>{t.time}: {new Date(preview.before).toLocaleString()}</span>}
92
+ <details><summary>{t.preview}</summary><ul>{preview.items?.map((item: any) => <li key={item.id}>{item.title} <small>{item.id}</small></li>)}</ul><div style={{ overflowWrap: 'anywhere', fontSize: 11 }}>{preview.sessionIds?.slice(0, 100).join(', ')}{preview.sessionIds?.length > 100 ? ' …' : ''}</div></details>
93
+ {!preview.executable && <div role="alert">{preview.blockedReason}</div>}
94
+ <div>{t.idle}</div>
95
+ <label><input type="checkbox" checked={consent} disabled={locked} onChange={e => setConsent(e.target.checked)} /> {t.consent}</label>
96
+ {operation === 'delete' && <label>{t.acknowledgement} <input style={style} value={ack} disabled={locked} onChange={e => setAck(e.target.value)} /></label>}
97
+ <button type="button" style={style} disabled={locked || !consent || !preview.executable || (operation === 'delete' && ack !== 'DELETE')} onClick={() => void act('execute', { planId: preview.planId, confirm: true, acknowledgement: ack })}>{t.confirm}</button>
98
+ </div>}
99
+ <div role="status" aria-live="polite">{(t.phase as any)[status.phase] ?? status.phase}{status.code ? ` · ${status.code}` : ''}{notice ? ` · ${notice}` : ''}</div>
100
+ {!connected && <div role="status">{pending(status.phase) ? t.working : '3210 history API unavailable'}<p>{t.recovery}</p></div>}
101
+ {['RECOVERY_REQUIRED', 'QUEUED'].includes(status.phase) && <div><p>{status.phase === 'QUEUED' ? t.cancelQueuedHint : t.recovery}</p><button type="button" style={style} disabled={busy} onClick={() => { if (window.confirm(status.phase === 'QUEUED' ? t.cancelQueuedHint : t.recovery)) void act('recover', { confirm: true }); }}>{status.phase === 'QUEUED' ? t.cancelQueued : t.recover}</button></div>}
102
+ {['DONE', 'ROLLED_BACK'].includes(status.phase) && <button type="button" style={style} onClick={() => window.location.reload()}>{t.refresh}</button>}
103
+ <strong>{t.archives}</strong>
104
+ {archives.length === 0 && <span style={{ opacity: .6 }}>{t.empty}</span>}
105
+ {archives.map(a => <div key={a.id} style={{ ...style, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}><span style={{ flex: 1 }}>{a.invalid ? `${a.id} · ${a.code}` : `${new Date(a.createdAt).toLocaleString()} · ${t.count(a.workspaces, a.sessions)}`}</span><button type="button" style={style} disabled={locked || a.invalid} onClick={() => { if (window.confirm(t.confirmRestore)) void act('restore', { archiveId: a.id, confirm: true }); }}>{t.restore}</button></div>)}
106
+ </div>}
107
+ </details>;
108
+ }
@@ -1,10 +1,15 @@
1
+ import { isCompleteRuntimeIdentity, isExactNativeCrewIdentity, sameCompleteRuntimeIdentity } from '../runtime-identity-contract.mjs';
2
+
1
3
  export const READINESS_STATES = Object.freeze({
2
4
  READY: 'READY',
3
5
  DEGRADED: 'DEGRADED',
4
6
  UNAVAILABLE: 'UNAVAILABLE',
5
7
  UNKNOWN: 'UNKNOWN',
8
+ NOT_APPLICABLE: 'NOT_APPLICABLE',
6
9
  });
7
10
 
11
+ const MAX_RUNTIME_SNAPSHOT_AGE_MS = 5 * 60 * 1000;
12
+
8
13
  function componentState(status, keys, { aligned = false } = {}) {
9
14
  if (status === undefined || status === null || typeof status !== 'object') return READINESS_STATES.UNKNOWN;
10
15
  if (status.installed === false) return READINESS_STATES.UNAVAILABLE;
@@ -18,10 +23,15 @@ function componentState(status, keys, { aligned = false } = {}) {
18
23
  function runtimeState(runtime, readinessSnapshot) {
19
24
  if (readinessSnapshot && typeof readinessSnapshot === 'object') {
20
25
  const identity = readinessSnapshot.runtime;
21
- if (!(identity?.execution_plane === 'hub-3210' && identity.profile === 'dsh-crew'
22
- && Number(identity.listen_port) === 3210 && typeof identity.runtime_id === 'string' && identity.runtime_id.trim())) {
26
+ if (!isExactNativeCrewIdentity(identity)) {
23
27
  return READINESS_STATES.UNAVAILABLE;
24
28
  }
29
+ if (runtime && (!isCompleteRuntimeIdentity(runtime) || !sameCompleteRuntimeIdentity(runtime, identity))) {
30
+ return READINESS_STATES.UNKNOWN;
31
+ }
32
+ if (!Number.isFinite(readinessSnapshot.captured_at)) return READINESS_STATES.UNKNOWN;
33
+ if (readinessSnapshot.captured_at > Date.now() + 30_000) return READINESS_STATES.UNKNOWN;
34
+ if (Date.now() - readinessSnapshot.captured_at > MAX_RUNTIME_SNAPSHOT_AGE_MS) return READINESS_STATES.DEGRADED;
25
35
  const row = Array.isArray(readinessSnapshot.readiness_matrix?.rows)
26
36
  ? readinessSnapshot.readiness_matrix.rows.find((entry) => entry?.id === 'hub_compatibility') : undefined;
27
37
  if (row?.status === 'PASS') return READINESS_STATES.READY;
@@ -31,9 +41,8 @@ function runtimeState(runtime, readinessSnapshot) {
31
41
  }
32
42
  if (runtime === undefined) return READINESS_STATES.UNKNOWN;
33
43
  if (runtime === null) return READINESS_STATES.UNAVAILABLE;
34
- return runtime?.ok === true && runtime.service === 'dsh-crew-hub'
35
- ? READINESS_STATES.READY
36
- : READINESS_STATES.DEGRADED;
44
+ const exactIdentity = runtime?.ok === true && isExactNativeCrewIdentity(runtime);
45
+ return exactIdentity ? READINESS_STATES.READY : READINESS_STATES.UNKNOWN;
37
46
  }
38
47
 
39
48
  function bridgeState(surface) {
@@ -41,7 +50,7 @@ function bridgeState(surface) {
41
50
  // absence must never degrade Crew readiness: on the native 3210 control
42
51
  // plane the bridge is simply not part of the picture.
43
52
  if (surface === 'official-bridge') return READINESS_STATES.READY;
44
- if (surface === 'native-crew-harness') return READINESS_STATES.UNKNOWN;
53
+ if (surface === 'native-crew-harness') return READINESS_STATES.NOT_APPLICABLE;
45
54
  return READINESS_STATES.UNKNOWN;
46
55
  }
47
56