@ran-sh/dsh-crew 2.1.8 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/docs/job-contracts.md +14 -1
  3. package/docs/readiness-matrix.md +20 -1
  4. package/lib/client.js +9 -8
  5. package/package.json +1 -1
  6. package/scripts/setup.mjs +6 -27
  7. package/scripts/verify-npm-install.mjs +2 -2
  8. package/skills/dsh-crew/SKILL.md +24 -15
  9. package/src/adaptive-routing.mjs +2 -2
  10. package/src/ci-evidence.mjs +1 -1
  11. package/src/client/history-panel.tsx +6 -5
  12. package/src/client/quick-panel.tsx +0 -1
  13. package/src/credential-lifecycle.mjs +0 -3
  14. package/src/credential-purge-state.mjs +1 -5
  15. package/src/credential-reference.mjs +0 -8
  16. package/src/crew-workspaces.mjs +136 -0
  17. package/src/delivery.mjs +2 -2
  18. package/src/dsh-cli-runtime.mjs +18 -22
  19. package/src/extension-contract.mjs +1 -1
  20. package/src/failure-classification.mjs +0 -10
  21. package/src/history/cleanup-plan.mjs +15 -5
  22. package/src/hub/entry.mjs +1 -1
  23. package/src/hub/index.mjs +45 -19
  24. package/src/i18n.mjs +0 -2
  25. package/src/install/crew-skill.mjs +1 -1
  26. package/src/install/install-legacy.mjs +3 -3
  27. package/src/install/npx-lifecycle.mjs +60 -112
  28. package/src/install/official-web.mjs +1 -1
  29. package/src/install/windows-startup.mjs +5 -5
  30. package/src/install/windows-supervisor-adapter.mjs +3 -2
  31. package/src/install/windows-supervisor-lifecycle.mjs +2 -2
  32. package/src/install/zcode.mjs +0 -2
  33. package/src/job-contracts.mjs +1 -1
  34. package/src/job-identity.mjs +13 -6
  35. package/src/jobs.mjs +3 -4
  36. package/src/mcp-runtime.mjs +17 -12
  37. package/src/model-routing.mjs +3 -4
  38. package/src/model-schedule.mjs +0 -1
  39. package/src/policy-legacy.mjs +10 -12
  40. package/src/provider-inventory.mjs +1 -1
  41. package/src/provider-layer-migration.mjs +0 -2
  42. package/src/readiness-matrix.mjs +1 -1
  43. package/src/release-in-use.mjs +1 -1
  44. package/src/role-profiles.mjs +2 -2
  45. package/src/runtime-controls.mjs +1 -1
  46. package/src/runtime-identity.mjs +1 -1
  47. package/src/status-shard.mjs +1 -1
  48. package/src/supervisor/restart-request.mjs +4 -4
  49. package/src/workflow-runtime.mjs +1 -2
  50. package/src/workspace-context.mjs +2 -2
  51. package/src/workspace-isolation.mjs +71 -4
  52. package/src/workspace-lock.mjs +134 -0
  53. package/scripts/policy-probe.mjs +0 -101
  54. package/scripts/verify-crew-ui-polish.mjs +0 -145
  55. package/scripts/verify-history-ui.mjs +0 -98
  56. package/scripts/verify-installer-fix.mjs +0 -26
@@ -0,0 +1,134 @@
1
+ // One job at a time per Crew workspace.
2
+ //
3
+ // A stable workspace is shared by every job that uses it, which is the point —
4
+ // the sessions group under one directory instead of multiplying. The cost is
5
+ // that two jobs running in it at once would corrupt each other's evidence: the
6
+ // client only captures a candidate for `worktree` isolation, the Hub diffs the
7
+ // whole tree before and after and would attribute a concurrent job's edits to
8
+ // this one, and the reviewer's before/after fingerprint would see the other
9
+ // job's writes as a mutated candidate and force `request_changes`. Nothing in
10
+ // the runtime serialised by directory before, so this does.
11
+ //
12
+ // The lock lives beside the workspace, never inside it: a file in the tree would
13
+ // show up in the very diffs it is there to keep clean.
14
+ //
15
+ // It fails closed. A holder that died leaves a lock behind, so an obviously
16
+ // abandoned one (its process is gone, or it has held the lock past any plausible
17
+ // job) is reclaimed; but a lock whose owner still looks alive is waited for and
18
+ // then refused with `WORKSPACE_BUSY`, never taken by force. Waiting is bounded,
19
+ // and the bound is part of the caller's attempt budget.
20
+
21
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
22
+ import { dirname } from 'node:path';
23
+ import { randomBytes } from 'node:crypto';
24
+
25
+ export const WORKSPACE_BUSY = 'WORKSPACE_BUSY';
26
+ const WORKSPACE_LOCK_SCHEMA = 1;
27
+
28
+ export const DEFAULT_LOCK_WAIT_MS = 120_000;
29
+ const DEFAULT_LOCK_POLL_MS = 500;
30
+ // The two reasons a lock outlives its job are not the same, and they are covered
31
+ // by different things. A holder whose process died is caught immediately by the
32
+ // pid check below. This bound covers the other case — the process is alive but
33
+ // the job is gone, which is what a job that ended without releasing looks like —
34
+ // and it is only a backstop, so it wants to be as short as it can safely be.
35
+ //
36
+ // It must still exceed the longest a job may legitimately run, or a live job
37
+ // would lose its own workspace: `timeout_seconds` is capped at 2h, so this is
38
+ // that plus half an hour of margin. Six hours, the first value here, would have
39
+ // stalled a workspace for four hours longer than any job can last.
40
+ export const DEFAULT_LOCK_MAX_HOLD_MS = (2 * 60 + 30) * 60 * 1000;
41
+
42
+ const defaultSleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
43
+
44
+ /** Is a process with this pid running? Unknown pids read as dead. */
45
+ function defaultIsAlive(pid) {
46
+ if (!Number.isInteger(pid) || pid <= 0) return false;
47
+ try {
48
+ process.kill(pid, 0);
49
+ return true;
50
+ } catch (error) {
51
+ // EPERM means it exists and belongs to someone else; ESRCH means it is gone.
52
+ return error?.code === 'EPERM';
53
+ }
54
+ }
55
+
56
+ function readRecord(path) {
57
+ try {
58
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
59
+ return parsed && typeof parsed === 'object' ? parsed : null;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Acquire the workspace lock, waiting up to `waitMs` for a live holder.
67
+ *
68
+ * Returns `{ ok: true, release }` or `{ ok: false, reason, error, holder }`.
69
+ * `release` only removes the lock this call created: after a reclaim, a stale
70
+ * holder waking up must not delete its successor's lock.
71
+ */
72
+ export async function acquireWorkspaceLock({
73
+ lockPath,
74
+ purpose = null,
75
+ waitMs = DEFAULT_LOCK_WAIT_MS,
76
+ pollMs = DEFAULT_LOCK_POLL_MS,
77
+ maxHoldMs = DEFAULT_LOCK_MAX_HOLD_MS,
78
+ pid = process.pid,
79
+ now = Date.now,
80
+ sleep = defaultSleep,
81
+ isAlive = defaultIsAlive,
82
+ } = {}) {
83
+ if (typeof lockPath !== 'string' || lockPath === '') {
84
+ return { ok: false, reason: WORKSPACE_BUSY, error: 'workspace lock requires a path' };
85
+ }
86
+ const deadline = now() + Math.max(0, waitMs);
87
+ const nonce = randomBytes(16).toString('hex');
88
+
89
+ for (;;) {
90
+ try {
91
+ mkdirSync(dirname(lockPath), { recursive: true });
92
+ writeFileSync(lockPath, `${JSON.stringify({
93
+ schemaVersion: WORKSPACE_LOCK_SCHEMA,
94
+ pid,
95
+ purpose,
96
+ nonce,
97
+ startedAt: now(),
98
+ })}\n`, { flag: 'wx' });
99
+ return {
100
+ ok: true,
101
+ release: () => {
102
+ const held = readRecord(lockPath);
103
+ if (held?.nonce !== nonce) return false;
104
+ try { rmSync(lockPath, { force: true }); return true; } catch { return false; }
105
+ },
106
+ };
107
+ } catch (error) {
108
+ if (error?.code !== 'EEXIST') {
109
+ return { ok: false, reason: WORKSPACE_BUSY, error: `cannot take workspace lock: ${error?.message ?? error}` };
110
+ }
111
+ }
112
+
113
+ const holder = readRecord(lockPath);
114
+ const startedAt = Number.isFinite(holder?.startedAt) ? holder.startedAt : null;
115
+ const abandoned = holder === null
116
+ || !isAlive(holder.pid)
117
+ || (startedAt !== null && now() - startedAt > maxHoldMs);
118
+ if (abandoned) {
119
+ // Reclaim and loop: the exclusive create on the next pass decides the
120
+ // race, so two waiters cannot both take it.
121
+ try { rmSync(lockPath, { force: true }); } catch { /* the retry decides */ }
122
+ continue;
123
+ }
124
+ if (now() >= deadline) {
125
+ return {
126
+ ok: false,
127
+ reason: WORKSPACE_BUSY,
128
+ error: `workspace is held by another job (pid ${holder.pid}${holder.purpose ? `, ${holder.purpose}` : ''})`,
129
+ holder,
130
+ };
131
+ }
132
+ await sleep(Math.min(pollMs, Math.max(1, deadline - now())));
133
+ }
134
+ }
@@ -1,101 +0,0 @@
1
- // Functional probe of the real MCP stdio server (src/server.mjs), no DSH
2
- // instance and no DeepSeek credentials needed: policy rejections must happen
3
- // before any worker runtime is touched.
4
- // Run: node scripts/policy-probe.mjs
5
-
6
- import { spawn } from 'node:child_process';
7
-
8
- const child = spawn(process.execPath, ['src/server.mjs'], { stdio: ['pipe', 'pipe', 'pipe'] });
9
- let buffer = '';
10
- const pending = new Map();
11
- let nextId = 1;
12
-
13
- child.stdout.on('data', (d) => {
14
- buffer += d.toString();
15
- let idx;
16
- while ((idx = buffer.indexOf('\n')) >= 0) {
17
- const line = buffer.slice(0, idx);
18
- buffer = buffer.slice(idx + 1);
19
- if (!line.trim()) continue;
20
- let msg;
21
- try { msg = JSON.parse(line); } catch { continue; }
22
- if (msg.id !== undefined && pending.has(msg.id)) {
23
- pending.get(msg.id)(msg);
24
- pending.delete(msg.id);
25
- }
26
- }
27
- });
28
-
29
- function call(method, params) {
30
- return new Promise((resolve) => {
31
- const id = nextId++;
32
- pending.set(id, resolve);
33
- child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
34
- });
35
- }
36
-
37
- const results = [];
38
- function check(name, cond, detail) {
39
- results.push({ name, ok: !!cond, detail });
40
- console.log(`${cond ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`);
41
- }
42
-
43
- await call('initialize', { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'probe', version: '1' } });
44
- await new Promise((r) => setTimeout(r, 200));
45
-
46
- // 1. Read config: must expose the new effective policy fields.
47
- const cfg = await call('tools/call', { name: 'dsh_worker_config', arguments: {} });
48
- const cfgText = cfg.result?.content?.[0]?.text;
49
- let cfgJson = null;
50
- try { cfgJson = JSON.parse(cfgText); } catch {}
51
- check('config returns effective policy', !!cfgJson && typeof cfgJson.flash_state === 'string' && typeof cfgJson.pro_state === 'string' && typeof cfgJson.routing_guidance === 'string',
52
- cfgJson ? `flash=${cfgJson.flash_state} pro=${cfgJson.pro_state} mode=${cfgJson.collaboration_mode}` : cfgText?.slice(0, 120));
53
- check('config hides API keys', cfgText !== undefined && !/api_key|sk-/.test(cfgText));
54
- check('config reports effective_policy + roles + legacy_source', !!cfgJson
55
- && typeof cfgJson.effective_policy === 'string' && cfgJson.effective_policy.includes('mode=')
56
- && Array.isArray(cfgJson.flash_roles) && cfgJson.flash_roles.length > 0
57
- && Array.isArray(cfgJson.pro_roles) && cfgJson.pro_roles.length > 0
58
- && typeof cfgJson.legacy_source === 'string',
59
- cfgJson ? cfgJson.effective_policy : undefined);
60
- check('config reflects main_agent_mode', !!cfgJson && ['direct-allowed', 'coordinator-first', 'dispatcher-only'].includes(cfgJson.main_agent_mode), cfgJson?.main_agent_mode);
61
-
62
- // 2. Session clamp: flash-only + explicit pro must be refused with TIER_DISABLED.
63
- const clamp = await call('tools/call', { name: 'dsh_worker_config', arguments: { tier_policy: 'flash-only' } });
64
- check('session tier_policy clamp set', clamp.result !== undefined && !clamp.result.isError);
65
-
66
- const refusePro = await call('tools/call', { name: 'dsh_run_worker', arguments: { task: 'probe', tier: 'pro' } });
67
- const refuseText = refusePro.result?.content?.[0]?.text ?? '';
68
- check('pro refused under flash-only', /TIER_DISABLED/.test(refuseText), refuseText.slice(0, 120));
69
-
70
- // 3. subagents master switch via session enabled=false.
71
- const off = await call('tools/call', { name: 'dsh_worker_config', arguments: { reset: true, enabled: false } });
72
- check('session enabled=false set', off.result !== undefined && !off.result.isError);
73
- const refuseAll = await call('tools/call', { name: 'dsh_run_worker', arguments: { task: 'probe' } });
74
- const refuseAllText = refuseAll.result?.content?.[0]?.text ?? '';
75
- check('dispatch refused when disabled', /SUBAGENTS_DISABLED/.test(refuseAllText), refuseAllText.slice(0, 120));
76
-
77
- // 4. dsh_spawn_worker shares the same policy.
78
- const refuseSpawn = await call('tools/call', { name: 'dsh_spawn_worker', arguments: { task: 'probe' } });
79
- const refuseSpawnText = refuseSpawn.result?.content?.[0]?.text ?? '';
80
- check('spawn refused when disabled', /SUBAGENTS_DISABLED/.test(refuseSpawnText));
81
-
82
- // 5. Manual semantics: flash manual + pro auto, no explicit tier → pro is chosen, never flash.
83
- const manual = await call('tools/call', { name: 'dsh_worker_config', arguments: { reset: true, tier_policy: 'auto' } });
84
- // global config is balanced here; exercise manual via session state override:
85
- const manualSet = await call('tools/call', { name: 'dsh_worker_config', arguments: { collaboration_mode: 'custom', flash_state: 'manual', pro_state: 'auto', default_tier: 'flash' } });
86
- check('manual/custom session set', manualSet.result !== undefined && !manualSet.result.isError);
87
- const manualCfg = await call('tools/call', { name: 'dsh_worker_config', arguments: {} });
88
- const manualCfgJson = JSON.parse(manualCfg.result.content[0].text);
89
- check('manual tier never auto-default (effective_default_tier=pro)',
90
- manualCfgJson.effective_default_tier === 'pro', `got ${manualCfgJson.effective_default_tier}`);
91
- check('effective states reported', manualCfgJson.flash_state === 'manual' && manualCfgJson.pro_state === 'auto');
92
-
93
- // 6. Reset back to global defaults.
94
- const reset = await call('tools/call', { name: 'dsh_worker_config', arguments: { reset: true } });
95
- check('reset restores defaults', reset.result !== undefined && !reset.result.isError);
96
-
97
- child.kill();
98
- await new Promise((r) => setTimeout(r, 100));
99
- const failed = results.filter((r) => !r.ok);
100
- console.log(`\n${results.length - failed.length}/${results.length} checks passed`);
101
- process.exit(failed.length > 0 ? 1 : 0);
@@ -1,145 +0,0 @@
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(); }
@@ -1,98 +0,0 @@
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
- import { CREW_SESSIONS_REL } from '../src/install/crew-paths.mjs';
14
-
15
- const require = createRequire(import.meta.url);
16
- const { chromium } = require(process.env.CREW_PLAYWRIGHT_MODULE || 'playwright');
17
- const repo = dirname(dirname(fileURLToPath(import.meta.url)));
18
- const root = mkdtempSync(join(tmpdir(), 'crew-history-e2e-'));
19
- const output = mkdtempSync(join(tmpdir(), 'crew-history-ui-shots-'));
20
- mkdirSync(join(root, 'harness/storages'), { recursive: true });
21
- mkdirSync(join(root, CREW_SESSIONS_REL, 'example/session-test'), { recursive: true });
22
- const file = join(root, CREW_SESSIONS_REL, 'example/session-test/session.jsonl');
23
- writeFileSync(file, 'DISPOSABLE TEST CONVERSATION');
24
- 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'] } } } }));
25
- const errors = []; let stopped = false; let operationPromise;
26
- const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
27
- const service = createHistoryService({ crewRoot: root, runtimeId: 'test', agents: { list: () => [], create: async () => ({}) },
28
- persistence: { supportsRawArtifacts: true, listSnapshots: async () => existsSync(file) ? [{ header: { id: 'session-test', createdAt: 1767225600000 }, revision: 'r1' }] : [], locate: () => ({ kind: 'jsonl', path: file }) },
29
- launch: async id => { operationPromise = pause(200).then(() => runHistoryOperation({ crewRoot: root, id,
30
- acquire: () => ({ ok: true }), release: () => ({ ok: true }), checkFence: () => service.fencedCheck(), assertStopped: () => stopped,
31
- supervisor: { stopOwnedBackend: async () => { stopped = true; await pause(150); return { ok: true }; }, startOwnedBackend: async () => { await pause(150); stopped = false; return { ok: true }; } }, verifyRunning: async () => !stopped,
32
- })); operationPromise.catch(error => errors.push(error.message)); },
33
- });
34
- let historyHandler;
35
- registerHistoryHttp({ register: route => { historyHandler = route.handler; return () => {}; } }, service);
36
- const browser = await chromium.launch({ headless: true, ...(process.env.CREW_BROWSER_EXE ? { executablePath: process.env.CREW_BROWSER_EXE } : {}) });
37
- try {
38
- const page = await browser.newPage({ viewport: { width: 1100, height: 1100 } });
39
- page.on('pageerror', error => errors.push(error.message));
40
- page.on('dialog', dialog => dialog.accept());
41
- await page.route('**/*', async route => {
42
- const request = route.request(); const url = new URL(request.url());
43
- if (url.origin !== 'http://127.0.0.1:41791') return route.abort();
44
- 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>' });
45
- if (url.pathname.includes('/history/')) {
46
- if (stopped) return route.abort();
47
- const req = Readable.from([Buffer.from(request.postData() ?? '{}')]);
48
- Object.assign(req, { url: url.pathname, method: request.method(), headers: { host: url.host, origin: url.origin }, socket: { remoteAddress: '127.0.0.1' } });
49
- let status, body;
50
- await historyHandler(req, { writeHead: n => { status = n; }, end: s => { body = s; } });
51
- return route.fulfill({ status, contentType: 'application/json', body });
52
- }
53
- const suffix = url.pathname.split('/').pop(); let body = { ok: true };
54
- if (suffix === 'runtime') body = { ok: true, service: 'dsh-crew-hub', profile: 'dsh-crew', listen_port: 3210 };
55
- if (suffix === 'bridge-status') body = { ok: false };
56
- if (suffix === 'config') body = { ok: true, config: { flash_model_priority: [], pro_model_priority: [], default_timeout_seconds: 1800 } };
57
- if (suffix === 'status') body = { ok: true, status: {} };
58
- return route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) });
59
- });
60
- await page.goto('http://127.0.0.1:41791/'); await page.waitForLoadState('networkidle');
61
- await page.addScriptTag({ content: readFileSync(join(repo, 'node_modules/react/umd/react.development.js'), 'utf8') });
62
- await page.addScriptTag({ content: readFileSync(join(repo, 'node_modules/react-dom/umd/react-dom.development.js'), 'utf8') });
63
- await page.evaluate(() => {
64
- const root = window.ReactDOM.createRoot(document.getElementById('root'));
65
- const ctx = { on: () => () => {}, locale: { getLocale: () => ({ active: 'zh' }) }, slots: { inject: (_n, callback) => callback(), register: (_meta, render) => { root.render(render()); return () => {}; } } };
66
- window.__ModuleLoader__ = { load({ factory }) { const jsx = (tag, props, key) => window.React.createElement(tag, { ...props, ...(key === undefined ? {} : { key }) }); 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 dependency');
71
- }).apply(ctx); } };
72
- });
73
- await page.addScriptTag({ content: readFileSync(join(repo, 'lib/client.js'), 'utf8') });
74
- await page.getByText('工作区与会话清理', { exact: true }).click();
75
- const preview = page.getByRole('button', { name: '预览清理范围', exact: true });
76
- await preview.click();
77
- await page.getByText('1 个工作区 · 1 个会话', { exact: true }).first().waitFor();
78
- const confirm = page.getByRole('button', { name: '确认执行', exact: true });
79
- assert.equal(await confirm.isEnabled(), false);
80
- await page.getByRole('checkbox', { name: '我已确认范围,并同意短暂重启 3210' }).check();
81
- await page.screenshot({ path: join(output, 'preview.png'), fullPage: true });
82
- await confirm.click(); await page.getByRole('button', { name: '恢复', exact: true }).waitFor({ timeout: 15000 }); await operationPromise;
83
- assert.equal(existsSync(file), false);
84
- await page.getByRole('button', { name: '恢复', exact: true }).click();
85
- await page.waitForFunction(() => [...document.querySelectorAll('[role=status]')].some(el => el.textContent === '已完成'));
86
- await operationPromise; assert.equal(readFileSync(file, 'utf8'), 'DISPOSABLE TEST CONVERSATION');
87
- await page.getByRole('combobox', { name: '操作', exact: true }).selectOption('delete');
88
- await page.getByRole('combobox', { name: '时间范围', exact: true }).selectOption('before');
89
- await page.getByLabel('创建时间早于', { exact: true }).fill('2027-01-01T00:00');
90
- await preview.click(); await page.getByRole('checkbox', { name: '我已确认范围,并同意短暂重启 3210' }).check();
91
- assert.equal(await confirm.isEnabled(), false);
92
- await page.getByLabel('删除确认:请输入 DELETE', { exact: true }).fill('DELETE'); await confirm.click();
93
- await page.waitForFunction(() => [...document.querySelectorAll('[role=status]')].some(el => el.textContent === '已完成'));
94
- await operationPromise; assert.equal(existsSync(file), false); assert.deepEqual(service.archives(), []);
95
- await page.screenshot({ path: join(output, 'completed.png'), fullPage: true });
96
- assert.deepEqual(errors, []);
97
- console.log(JSON.stringify({ ok: true, flows: ['archive all', 'restore', 'delete before date', 'confirmation guards', 'reconnect'], screenshots: output }));
98
- } finally { await browser.close(); service.dispose(); rmSync(root, { recursive: true, force: true }); }
@@ -1,26 +0,0 @@
1
- // Live-verify the fixed installer against a fake home: the settings must
2
- // point at the repository itself as the marketplace root (no parent-dir
3
- // assumption), permission rules intact, CLI step skipped in test mode.
4
- import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
5
- import { join } from 'node:path';
6
- import { tmpdir } from 'node:os';
7
- import { installClaudeCode, uninstallClaudeCode } from '../src/install/install.mjs';
8
-
9
- const home = mkdtempSync(join(tmpdir(), 'dsh-crew-fix-test-'));
10
- try {
11
- const r = await installClaudeCode({ home });
12
- console.log('actions:', r.actions.slice(0, 3).join(' | '));
13
- const s = JSON.parse(readFileSync(join(home, '.claude', 'settings.json'), 'utf8'));
14
- const mp = s.extraKnownMarketplaces['dsh-crew'];
15
- const cwd = process.cwd().replaceAll('\\', '/');
16
- const mpPath = String(mp?.source?.path ?? '').replaceAll('\\', '/');
17
- console.log('marketplace path is repo root:', mpPath === cwd, '->', mpPath);
18
- console.log('permission rules:', (s.permissions?.allow ?? []).filter((x) => String(x).includes('dsh-crew')).length);
19
- console.log('cli step skipped in test mode:', r.actions.some((a) => a.includes('test mode')));
20
- if (mpPath !== cwd) throw new Error('marketplace not pointing at repo root');
21
- const u = await uninstallClaudeCode({ home });
22
- console.log('uninstall ok:', u.ok);
23
- } finally {
24
- rmSync(home, { recursive: true, force: true });
25
- }
26
- console.log('INSTALLER FIX VERIFIED');