teamshare-bridge 0.22.7 → 0.22.8

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 (82) hide show
  1. package/README.md +383 -383
  2. package/dist/bridge/daemon.js +43 -20
  3. package/dist/bridge/daemon.js.map +1 -1
  4. package/dist/bridge/install-verify.js +8 -8
  5. package/dist/bridge/spawn.d.ts +14 -0
  6. package/dist/bridge/spawn.js +7 -1
  7. package/dist/bridge/spawn.js.map +1 -1
  8. package/dist/bridge/wake-handler.d.ts +8 -15
  9. package/dist/bridge/wake-handler.js +109 -14
  10. package/dist/bridge/wake-handler.js.map +1 -1
  11. package/dist/bridge/wake-outbox.js +7 -0
  12. package/dist/bridge/wake-outbox.js.map +1 -1
  13. package/dist/cli/commands/plan.js +4 -0
  14. package/dist/cli/commands/plan.js.map +1 -1
  15. package/dist/lib/browser/human-input-guard.js +98 -98
  16. package/dist/lib/browser/screen-change.js +60 -60
  17. package/dist/lib/llm/usage-reader.js +4 -4
  18. package/dist/lib/workspace/classify.d.ts +29 -0
  19. package/dist/lib/workspace/classify.js +62 -0
  20. package/dist/lib/workspace/classify.js.map +1 -1
  21. package/dist/lib/workspace/presence.d.ts +31 -0
  22. package/dist/lib/workspace/presence.js +35 -1
  23. package/dist/lib/workspace/presence.js.map +1 -1
  24. package/dist/lib/workspace/shim-runner.js +23 -0
  25. package/dist/lib/workspace/shim-runner.js.map +1 -1
  26. package/package.json +70 -70
  27. package/skills/THIRD_PARTY_NOTICES.md +26 -26
  28. package/skills/architect/LICENSE +21 -21
  29. package/skills/codebase-design/LICENSE +21 -21
  30. package/skills/diagnosing-bugs/LICENSE +21 -21
  31. package/skills/domain-modeling/LICENSE +21 -21
  32. package/skills/improve-codebase-architecture/LICENSE +21 -21
  33. package/skills/recover/LICENSE +21 -21
  34. package/skills/review/LICENSE +21 -21
  35. package/skills/tdd/LICENSE +21 -21
  36. package/skills/teamshare-auth-rbac/SKILL.md +78 -78
  37. package/skills/teamshare-cdd/SKILL.md +205 -205
  38. package/skills/teamshare-modularity/SKILL.md +146 -146
  39. package/skills/teamshare-orchestrator-ask/SKILL.md +77 -77
  40. package/skills/teamshare-scaffold/SKILL.md +116 -116
  41. package/templates/backend/.env.example.tpl +13 -13
  42. package/templates/backend/nest-cli.json.tpl +8 -8
  43. package/templates/backend/package.json.tpl +41 -41
  44. package/templates/backend/prisma/schema.prisma.tpl +39 -39
  45. package/templates/backend/src/app.module.ts.tpl +24 -24
  46. package/templates/backend/src/common/filters/all-exceptions.filter.ts.tpl +59 -59
  47. package/templates/backend/src/common/guards/jwt-auth.guard.ts.tpl +27 -27
  48. package/templates/backend/src/common/guards/rbac.guard.ts.tpl +48 -48
  49. package/templates/backend/src/common/interceptors/transform.interceptor.ts.tpl +44 -44
  50. package/templates/backend/src/common/pipes/zod-validation.pipe.ts.tpl +29 -29
  51. package/templates/backend/src/common/swagger/envelope.schemas.ts.tpl +53 -53
  52. package/templates/backend/src/common/swagger/error.schemas.ts.tpl +62 -62
  53. package/templates/backend/src/main.ts.tpl +27 -27
  54. package/templates/backend/src/modules/example/example.controller.ts.tpl +102 -102
  55. package/templates/backend/src/modules/example/example.dto.ts.tpl +14 -14
  56. package/templates/backend/src/modules/example/example.module.ts.tpl +10 -10
  57. package/templates/backend/src/modules/example/example.service.ts.tpl +45 -45
  58. package/templates/backend/tsconfig.json.tpl +26 -26
  59. package/templates/common/.gitignore.tpl +42 -42
  60. package/templates/common/AGENTS.md.tpl +58 -58
  61. package/templates/common/docs/adr/README.md +25 -25
  62. package/templates/common/docs/api-contract.md.tpl +72 -72
  63. package/templates/web/next.config.ts.tpl +7 -7
  64. package/templates/web/package.json.tpl +37 -37
  65. package/templates/web/src/app/globals.css.tpl +48 -48
  66. package/templates/web/src/app/layout.tsx.tpl +25 -25
  67. package/templates/web/src/components/shared/app-button.tsx.tpl +59 -59
  68. package/templates/web/src/components/shared/app-empty-state.tsx.tpl +39 -39
  69. package/templates/web/src/components/shared/app-list-toolbar.tsx.tpl +63 -63
  70. package/templates/web/src/components/shared/app-skeleton.tsx.tpl +14 -14
  71. package/templates/web/src/components/shared/app-table.tsx.tpl +128 -128
  72. package/templates/web/src/components/shared/index.ts.tpl +5 -5
  73. package/templates/web/src/hooks/use-list-state.ts.tpl +72 -72
  74. package/templates/web/src/hooks/use-url-tab.ts.tpl +25 -25
  75. package/templates/web/src/lib/api/client.ts.tpl +123 -123
  76. package/templates/web/src/lib/providers.tsx.tpl +35 -35
  77. package/templates/web/src/lib/query-keys.ts.tpl +21 -21
  78. package/templates/web/src/lib/utils.ts.tpl +7 -7
  79. package/templates/web/src/lib/validation/schemas.ts.tpl +15 -15
  80. package/templates/web/src/modules/example/views/example-list-view.tsx.tpl +97 -97
  81. package/templates/web/tsconfig.json.tpl +23 -23
  82. package/tools/workspace-arbitration-check.mjs +389 -389
@@ -1,390 +1,390 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Workspace arbitration E2E check (IMP-950).
4
- * Run from teamshare-bridge/ AFTER `npm run build`:
5
- *
6
- * node tools/workspace-arbitration-check.mjs
7
- *
8
- * Exercises the stack in an ISOLATED state root (TS_WS_LOCK_ROOT /
9
- * TS_PRESENCE_ROOT point at a temp dir set before any dist module loads).
10
- * Multi-holder scenarios spawn CHILD processes - same-PID callers are one
11
- * owner by design (nested-call re-entrancy), so distinct holders must have
12
- * distinct pids.
13
- */
14
- import { spawn, spawnSync } from 'node:child_process';
15
- import { mkdtempSync, rmSync, writeFileSync, readdirSync } from 'node:fs';
16
- import { tmpdir } from 'node:os';
17
- import { join, resolve } from 'node:path';
18
- import { pathToFileURL, fileURLToPath } from 'node:url';
19
-
20
- const BRIDGE = resolve(fileURLToPath(new URL('../', import.meta.url)));
21
- const DIST = join(BRIDGE, 'dist');
22
-
23
- // Isolate BEFORE importing dist modules (they read env at import time).
24
- const stateRoot = mkdtempSync(join(tmpdir(), 'ts-ws-e2e-'));
25
- process.env.TS_WS_LOCK_ROOT = join(stateRoot, 'locks');
26
- process.env.TS_PRESENCE_ROOT = join(stateRoot, 'presence');
27
-
28
- const distUrl = (rel) => pathToFileURL(join(DIST, rel)).href;
29
-
30
- const { tryAcquireWsLock, acquireWsLock, releaseWsLock, readWsHolders, __writeForeignHolder } =
31
- await import(distUrl('lib/workspace/repo-lock.js'));
32
- const { registerPresence, listPresent, unregisterPresence } =
33
- await import(distUrl('lib/workspace/presence.js'));
34
- const { classifyCommand } = await import(distUrl('lib/workspace/classify.js'));
35
- const { startProbeServer } = await import(distUrl('bridge/local-server.js'));
36
-
37
- let pass = 0;
38
- let fail = 0;
39
- function check(name, cond, extra = '') {
40
- if (cond) {
41
- pass++;
42
- console.log(` ok ${name}`);
43
- } else {
44
- fail++;
45
- console.log(` FAIL ${name}${extra ? ` - ${extra}` : ''}`);
46
- }
47
- }
48
- const section = (t) => console.log(`\n== ${t} ==`);
49
-
50
- const dirScope = (p) => ({ kind: 'dir', path: p });
51
- const workDir = mkdtempSync(join(tmpdir(), 'ts-ws-work-'));
52
-
53
- /** Child-process lock holder: acquires, prints HELD, releases after ms. */
54
- const HOLDER_SCRIPT = `
55
- const { tryAcquireWsLock, releaseWsLock } = await import(${JSON.stringify(distUrl('lib/workspace/repo-lock.js'))});
56
- const [dir, mode, ms] = process.argv.slice(2);
57
- const r = tryAcquireWsLock({ kind: 'dir', path: dir }, mode === 'shared' ? 'shared' : 'exclusive', { label: 'child-holder' });
58
- if (!r.ok) { console.log('NOLOCK:' + r.reason); process.exit(3); }
59
- console.log('HELD');
60
- setTimeout(() => { releaseWsLock(r.handle); }, Number(ms));
61
- `;
62
-
63
- function spawnHolder(dir, mode, ms) {
64
- const script = join(stateRoot, `holder-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.mjs`);
65
- writeFileSync(script, HOLDER_SCRIPT);
66
- const child = spawn(process.execPath, [script, dir, mode, String(ms)], {
67
- env: process.env,
68
- stdio: ['ignore', 'pipe', 'pipe'],
69
- });
70
- let out = '';
71
- child.stdout.on('data', (d) => (out += d.toString()));
72
- const held = new Promise((res) => {
73
- const iv = setInterval(() => {
74
- if (out.includes('HELD') || out.includes('NOLOCK')) {
75
- clearInterval(iv);
76
- res(out.includes('HELD'));
77
- }
78
- }, 50);
79
- child.on('exit', () => {
80
- clearInterval(iv);
81
- res(out.includes('HELD'));
82
- });
83
- });
84
- return { child, held, output: () => out };
85
- }
86
-
87
- async function waitFor(condition, timeoutMs = 4000) {
88
- const deadline = Date.now() + timeoutMs;
89
- for (;;) {
90
- if (condition()) return true;
91
- if (Date.now() >= deadline) return false;
92
- await new Promise((r) => setTimeout(r, 60));
93
- }
94
- }
95
-
96
- // ?? 1-4: repo-lock (cross-pid semantics) ????????????????????????????????????
97
- section('repo-lock basics');
98
- {
99
- const holder = spawnHolder(workDir, 'exclusive', 4000);
100
- check('child holder acquired', await holder.held);
101
- check('holder visible in readWsHolders',
102
- await waitFor(() => readWsHolders(dirScope(workDir)).some((h) => !h.stale && h.holder.label === 'child-holder')));
103
-
104
- const b = tryAcquireWsLock(dirScope(workDir), 'exclusive', { label: 'parent-B' });
105
- check('second exclusive (other pid) refused busy', !b.ok && b.reason === 'busy');
106
- const c = tryAcquireWsLock(dirScope(workDir), 'shared', { label: 'parent-C' });
107
- check('shared blocked while exclusive held', !c.ok && c.reason === 'busy');
108
-
109
- // Timeout against the live foreign holder.
110
- const t0 = Date.now();
111
- const timedOut = await acquireWsLock(dirScope(workDir), 'exclusive', {}, { waitMs: 800, pollMs: 100 });
112
- const elapsed = Date.now() - t0;
113
- check('wait deadline returns null', timedOut === null);
114
- check('deadline respected (~800ms)', elapsed >= 700 && elapsed < 3000, `${elapsed}ms`);
115
-
116
- // Wait for the child to release, then acquire from the parent.
117
- await new Promise((res) => holder.child.on('exit', res));
118
- const after = tryAcquireWsLock(dirScope(workDir), 'exclusive', { label: 'parent-after' });
119
- check('acquire succeeds once holder exits', after.ok === true);
120
- releaseWsLock(after.ok ? after.handle : null);
121
-
122
- // Shared coexistence + exclusive blocked by readers (all cross-pid).
123
- const s1 = spawnHolder(workDir, 'shared', 2500);
124
- const s2 = spawnHolder(workDir, 'shared', 2500);
125
- check('shared holder #1 acquired', await s1.held);
126
- check('shared holder #2 acquired', await s2.held);
127
- const excl = tryAcquireWsLock(dirScope(workDir), 'exclusive', { label: 'parent-excl' });
128
- check('exclusive blocked by shared readers', !excl.reason || excl.reason === 'busy' ? !excl.ok : !excl.ok, JSON.stringify(excl).slice(0, 80));
129
- await new Promise((res) => s1.child.on('exit', res));
130
- await new Promise((res) => s2.child.on('exit', res));
131
- }
132
-
133
- // Stale takeover (dead pid).
134
- {
135
- const fakeDeadPid = 0x3ffffff0;
136
- __writeForeignHolder(dirScope(workDir), 'exclusive', fakeDeadPid);
137
- const holders = readWsHolders(dirScope(workDir));
138
- check('dead-pid holder flagged stale', holders.length === 1 && holders[0].stale);
139
- const g = tryAcquireWsLock(dirScope(workDir), 'exclusive', { label: 'takeover' });
140
- check('stale exclusive taken over', g.ok === true);
141
- releaseWsLock(g.ok ? g.handle : null);
142
- }
143
-
144
- // ?? 5: presence ?????????????????????????????????????????????????????????????
145
- section('presence registry');
146
- {
147
- registerPresence(workDir, { agentId: 'agent-a', kind: 'task' });
148
- const here = listPresent(workDir);
149
- check('presence listed after register', here.length === 1 && here[0].agentId === 'agent-a');
150
- unregisterPresence(workDir);
151
- check('presence empty after unregister', listPresent(workDir).length === 0);
152
- }
153
-
154
- // ?? 6: classification ???????????????????????????????????????????????????????
155
- section('classification');
156
- {
157
- const cases = [
158
- ['npm install', 'exclusive'],
159
- ['npm ci --silent', 'exclusive'],
160
- ['pnpm add zod', 'exclusive'],
161
- ['yarn remove left-pad', 'exclusive'],
162
- ['npm run build', 'exclusive'],
163
- ['npm run db:migrate', 'exclusive'],
164
- ['npx prisma generate', 'exclusive'],
165
- ['git checkout main', 'exclusive'],
166
- ['git reset --hard HEAD~1', 'exclusive'],
167
- ['git rebase origin/main', 'exclusive'],
168
- ['npm run dev', 'long-shared'],
169
- ['npm test', 'shared'],
170
- ['npx eslint .', 'shared'],
171
- ['git status', 'shared'],
172
- ['git commit -m x', 'shared'],
173
- ['git push -u origin HEAD', 'shared'],
174
- ['node server.js', 'passthrough'],
175
- ['python script.py', 'passthrough'],
176
- ];
177
- for (const [cmd, expected] of cases) {
178
- check(`${cmd} -> ${expected}`, classifyCommand(cmd).cls === expected, classifyCommand(cmd).cls);
179
- }
180
- const prisma = classifyCommand('npx prisma migrate deploy');
181
- check('prisma migrate needs global prisma-generate', (prisma.global ?? []).includes('prisma-generate'));
182
- }
183
-
184
- // ?? 7+8: arbiter HTTP endpoints + SSE ???????????????????????????????????????
185
- section('arbiter HTTP + SSE');
186
- {
187
- const { server, port } = await startProbeServer(49777, () => ({
188
- configPath: '', agents: [], registeredSchemes: false, projects: {},
189
- }));
190
- const base = `http://127.0.0.1:${port}`;
191
- try {
192
- const ping = await fetch(`${base}/ping`);
193
- check('/ping alive', ping.ok);
194
-
195
- // SSE subscriber first so granted/busy events below are observed.
196
- const events = [];
197
- const ac = new AbortController();
198
- const sseDone = (async () => {
199
- const res = await fetch(`${base}/arbitrate/events`, { signal: ac.signal });
200
- const reader = res.body.getReader();
201
- const dec = new TextDecoder();
202
- let buf = '';
203
- for (;;) {
204
- const { done, value } = await reader.read();
205
- if (done) break;
206
- buf += dec.decode(value, { stream: true });
207
- let idx;
208
- while ((idx = buf.indexOf('\n\n')) >= 0) {
209
- const frame = buf.slice(0, idx);
210
- buf = buf.slice(idx + 2);
211
- const line = frame.split('\n').find((l) => l.startsWith('data: '));
212
- if (line) events.push(JSON.parse(line.slice(6)));
213
- }
214
- }
215
- })().catch(() => {});
216
- await new Promise((r) => setTimeout(r, 150));
217
-
218
- // Cross-pid busy over HTTP: child holds, endpoint must report busy...
219
- const httpHolder = spawnHolder(workDir, 'exclusive', 6000);
220
- await httpHolder.held;
221
- const scope = { kind: 'dir', path: workDir };
222
- const busyRes = await fetch(`${base}/arbitrate/acquire`, {
223
- method: 'POST',
224
- headers: { 'Content-Type': 'application/json' },
225
- body: JSON.stringify({ scope, mode: 'exclusive', waitMs: 600 }),
226
- });
227
- const busyBody = await busyRes.json();
228
- check('HTTP acquire reports busy vs foreign holder', busyBody.ok === false && busyBody.reason === 'busy');
229
-
230
- // ...and grants once the foreign holder exits.
231
- await new Promise((res) => httpHolder.child.on('exit', res));
232
- const grantRes = await fetch(`${base}/arbitrate/acquire`, {
233
- method: 'POST',
234
- headers: { 'Content-Type': 'application/json' },
235
- body: JSON.stringify({ scope, mode: 'exclusive', waitMs: 2000, holder: { label: 'http-A' } }),
236
- });
237
- const granted = await grantRes.json();
238
- check('HTTP acquire grants after holder exits', granted.ok === true && typeof granted.file === 'string');
239
-
240
- const rel = await fetch(`${base}/arbitrate/release`, {
241
- method: 'POST',
242
- headers: { 'Content-Type': 'application/json' },
243
- body: JSON.stringify({ file: granted.file }),
244
- });
245
- check('HTTP release ok', rel.ok && (await rel.json()).ok === true);
246
-
247
- // Release guard: paths outside the lock tree must be refused.
248
- const evil = await fetch(`${base}/arbitrate/release`, {
249
- method: 'POST',
250
- headers: { 'Content-Type': 'application/json' },
251
- body: JSON.stringify({ file: join(stateRoot, 'innocent.txt') }),
252
- });
253
- check('release refuses non-lock paths', (await evil.json()).ok === false);
254
-
255
- const status = await (await fetch(`${base}/arbitrate/status`)).json();
256
- check('status lists scopes', Array.isArray(status.scopes));
257
-
258
- await fetch(`${base}/arbitrate/publish`, {
259
- method: 'POST',
260
- headers: { 'Content-Type': 'application/json' },
261
- body: JSON.stringify({ event: { type: 'peer.ping', note: 'e2e-hello' } }),
262
- });
263
- await new Promise((r) => setTimeout(r, 400));
264
- check('SSE receives lock.granted', events.some((e) => e.type === 'lock.granted'), JSON.stringify(events.slice(0, 2)));
265
- check('SSE receives peer.ping', events.some((e) => e.type === 'peer.ping'));
266
- ac.abort();
267
- await sseDone;
268
- } finally {
269
- server.close();
270
- }
271
- }
272
-
273
- // ?? 9-11: shim runner ???????????????????????????????????????????????????????
274
- section('shim runner');
275
-
276
- // Parent-held FS blocker helpers (distinct pid from the shim subprocess).
277
- let blockerHandle = null;
278
- function tryAcquireFsBlocker(dir) {
279
- const r = tryAcquireWsLock(dirScope(dir), 'exclusive', { label: 'blocker' });
280
- if (r.ok) blockerHandle = r.handle;
281
- return r.ok;
282
- }
283
- function releaseFsBlocker() {
284
- releaseWsLock(blockerHandle);
285
- blockerHandle = null;
286
- }
287
- {
288
- const runner = join(DIST, 'lib/workspace/shim-runner.js');
289
- const env = {
290
- ...process.env,
291
- TS_AGENT_ID: 'e2e-agent',
292
- TS_SESSION_KIND: 'task',
293
- TS_SHIM_WAIT_MS: '1500',
294
- };
295
-
296
- // 9a: passthrough commands never touch locks.
297
- const pt = spawnSync(process.execPath, [runner, 'node', '-e', 'console.log("passthrough-ok")'], {
298
- cwd: workDir, encoding: 'utf8', env,
299
- });
300
- check('passthrough executes directly', pt.stdout.includes('passthrough-ok') && pt.status === 0);
301
-
302
- // 9b: fail-open when the lock root is unusable (a file where a dir must be).
303
- const badRootFile = join(stateRoot, 'not-a-dir');
304
- writeFileSync(badRootFile, 'x');
305
- const fo = spawnSync(
306
- process.execPath,
307
- [runner, 'npm', 'run', 'build'],
308
- { cwd: workDir, encoding: 'utf8', env: { ...env, TS_WS_LOCK_ROOT: badRootFile }, timeout: 120_000 },
309
- );
310
- const reachedNpm =
311
- (fo.stderr ?? '').includes('arbitration skipped') ||
312
- (fo.stderr ?? '').includes('Missing script') ||
313
- (fo.stdout ?? '').includes('Missing script');
314
- check('broken lock root -> fail-open runs npm', reachedNpm, `exit=${fo.status}`);
315
-
316
- // 10: exclusive hold blocks shim npm build (fail-closed exit 124)...
317
- const held = tryAcquireFsBlocker(workDir);
318
- check('test blocker acquired', held === true);
319
- const dumpTree = () => {
320
- const out = [];
321
- const walk = (d, pfx) => {
322
- for (const e of readdirSync(d, { withFileTypes: true })) {
323
- const fp = join(d, e.name);
324
- if (e.isDirectory()) walk(fp, `${pfx}${e.name}/`);
325
- else out.push(pfx + e.name);
326
- }
327
- };
328
- try {
329
- walk(process.env.TS_WS_LOCK_ROOT, '');
330
- } catch {
331
- /* root missing */
332
- }
333
- return out;
334
- };
335
- console.log(' [diag] lock tree while blocked:', JSON.stringify(dumpTree()));
336
- const blocked = spawnSync(
337
- process.execPath,
338
- [runner, 'npm', 'run', 'build'],
339
- { cwd: workDir, encoding: 'utf8', env, timeout: 60_000 },
340
- );
341
- check('shim waits and exits 124 on busy workspace', blocked.status === 124, `exit=${blocked.status}`);
342
- console.log(' [diag] blocked stderr head:', (blocked.stderr ?? '').slice(0, 500).replace(/\n/g, ' | '));
343
- check('timeout note explains refusal', (blocked.stderr ?? '').includes('NOT running'), (blocked.stderr ?? '').slice(-160));
344
- releaseFsBlocker();
345
-
346
- // ...then proceeds to the REAL npm once released.
347
- const freed = spawnSync(
348
- process.execPath,
349
- [runner, 'npm', 'run', 'build'],
350
- { cwd: workDir, encoding: 'utf8', env, timeout: 120_000 },
351
- );
352
- const ranNpm =
353
- (freed.stderr ?? '').includes('Missing script') ||
354
- (freed.stdout ?? '').includes('Missing script') ||
355
- (freed.stderr ?? '').includes('npm error');
356
- check('after release shim reaches real npm', ranNpm && freed.status !== 124, `exit=${freed.status}`);
357
- }
358
-
359
- // Nested bypass: parent holds the scope; shim with TS_WS_HELD skips locking.
360
- section('nested TS_WS_HELD bypass');
361
- {
362
- const { scopeKey } = await import(distUrl('lib/workspace/repo-lock.js'));
363
- const key = scopeKey(dirScope(workDir));
364
- const ok = tryAcquireFsBlocker(workDir);
365
- check('parent holds scope', ok === true);
366
- const nested = spawnSync(
367
- process.execPath,
368
- [join(DIST, 'lib/workspace/shim-runner.js'), 'npm', 'install', '--dry-run'],
369
- {
370
- cwd: workDir,
371
- encoding: 'utf8',
372
- timeout: 120_000,
373
- env: { ...process.env, TS_WS_HELD: key },
374
- },
375
- );
376
- check('nested shim bypasses parent hold (no 124)', nested.status !== 124, `exit=${nested.status}`);
377
- releaseFsBlocker();
378
- }
379
-
380
- // ?? cleanup ?????????????????????????????????????????????????????????????????
381
- try {
382
- rmSync(stateRoot, { recursive: true, force: true });
383
- rmSync(workDir, { recursive: true, force: true });
384
- } catch {
385
- /* best-effort */
386
- }
387
-
388
- console.log(`\n${pass} passed, ${fail} failed`);
389
- process.exit(fail > 0 ? 1 : 0);
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Workspace arbitration E2E check (IMP-950).
4
+ * Run from teamshare-bridge/ AFTER `npm run build`:
5
+ *
6
+ * node tools/workspace-arbitration-check.mjs
7
+ *
8
+ * Exercises the stack in an ISOLATED state root (TS_WS_LOCK_ROOT /
9
+ * TS_PRESENCE_ROOT point at a temp dir set before any dist module loads).
10
+ * Multi-holder scenarios spawn CHILD processes - same-PID callers are one
11
+ * owner by design (nested-call re-entrancy), so distinct holders must have
12
+ * distinct pids.
13
+ */
14
+ import { spawn, spawnSync } from 'node:child_process';
15
+ import { mkdtempSync, rmSync, writeFileSync, readdirSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join, resolve } from 'node:path';
18
+ import { pathToFileURL, fileURLToPath } from 'node:url';
19
+
20
+ const BRIDGE = resolve(fileURLToPath(new URL('../', import.meta.url)));
21
+ const DIST = join(BRIDGE, 'dist');
22
+
23
+ // Isolate BEFORE importing dist modules (they read env at import time).
24
+ const stateRoot = mkdtempSync(join(tmpdir(), 'ts-ws-e2e-'));
25
+ process.env.TS_WS_LOCK_ROOT = join(stateRoot, 'locks');
26
+ process.env.TS_PRESENCE_ROOT = join(stateRoot, 'presence');
27
+
28
+ const distUrl = (rel) => pathToFileURL(join(DIST, rel)).href;
29
+
30
+ const { tryAcquireWsLock, acquireWsLock, releaseWsLock, readWsHolders, __writeForeignHolder } =
31
+ await import(distUrl('lib/workspace/repo-lock.js'));
32
+ const { registerPresence, listPresent, unregisterPresence } =
33
+ await import(distUrl('lib/workspace/presence.js'));
34
+ const { classifyCommand } = await import(distUrl('lib/workspace/classify.js'));
35
+ const { startProbeServer } = await import(distUrl('bridge/local-server.js'));
36
+
37
+ let pass = 0;
38
+ let fail = 0;
39
+ function check(name, cond, extra = '') {
40
+ if (cond) {
41
+ pass++;
42
+ console.log(` ok ${name}`);
43
+ } else {
44
+ fail++;
45
+ console.log(` FAIL ${name}${extra ? ` - ${extra}` : ''}`);
46
+ }
47
+ }
48
+ const section = (t) => console.log(`\n== ${t} ==`);
49
+
50
+ const dirScope = (p) => ({ kind: 'dir', path: p });
51
+ const workDir = mkdtempSync(join(tmpdir(), 'ts-ws-work-'));
52
+
53
+ /** Child-process lock holder: acquires, prints HELD, releases after ms. */
54
+ const HOLDER_SCRIPT = `
55
+ const { tryAcquireWsLock, releaseWsLock } = await import(${JSON.stringify(distUrl('lib/workspace/repo-lock.js'))});
56
+ const [dir, mode, ms] = process.argv.slice(2);
57
+ const r = tryAcquireWsLock({ kind: 'dir', path: dir }, mode === 'shared' ? 'shared' : 'exclusive', { label: 'child-holder' });
58
+ if (!r.ok) { console.log('NOLOCK:' + r.reason); process.exit(3); }
59
+ console.log('HELD');
60
+ setTimeout(() => { releaseWsLock(r.handle); }, Number(ms));
61
+ `;
62
+
63
+ function spawnHolder(dir, mode, ms) {
64
+ const script = join(stateRoot, `holder-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.mjs`);
65
+ writeFileSync(script, HOLDER_SCRIPT);
66
+ const child = spawn(process.execPath, [script, dir, mode, String(ms)], {
67
+ env: process.env,
68
+ stdio: ['ignore', 'pipe', 'pipe'],
69
+ });
70
+ let out = '';
71
+ child.stdout.on('data', (d) => (out += d.toString()));
72
+ const held = new Promise((res) => {
73
+ const iv = setInterval(() => {
74
+ if (out.includes('HELD') || out.includes('NOLOCK')) {
75
+ clearInterval(iv);
76
+ res(out.includes('HELD'));
77
+ }
78
+ }, 50);
79
+ child.on('exit', () => {
80
+ clearInterval(iv);
81
+ res(out.includes('HELD'));
82
+ });
83
+ });
84
+ return { child, held, output: () => out };
85
+ }
86
+
87
+ async function waitFor(condition, timeoutMs = 4000) {
88
+ const deadline = Date.now() + timeoutMs;
89
+ for (;;) {
90
+ if (condition()) return true;
91
+ if (Date.now() >= deadline) return false;
92
+ await new Promise((r) => setTimeout(r, 60));
93
+ }
94
+ }
95
+
96
+ // ?? 1-4: repo-lock (cross-pid semantics) ????????????????????????????????????
97
+ section('repo-lock basics');
98
+ {
99
+ const holder = spawnHolder(workDir, 'exclusive', 4000);
100
+ check('child holder acquired', await holder.held);
101
+ check('holder visible in readWsHolders',
102
+ await waitFor(() => readWsHolders(dirScope(workDir)).some((h) => !h.stale && h.holder.label === 'child-holder')));
103
+
104
+ const b = tryAcquireWsLock(dirScope(workDir), 'exclusive', { label: 'parent-B' });
105
+ check('second exclusive (other pid) refused busy', !b.ok && b.reason === 'busy');
106
+ const c = tryAcquireWsLock(dirScope(workDir), 'shared', { label: 'parent-C' });
107
+ check('shared blocked while exclusive held', !c.ok && c.reason === 'busy');
108
+
109
+ // Timeout against the live foreign holder.
110
+ const t0 = Date.now();
111
+ const timedOut = await acquireWsLock(dirScope(workDir), 'exclusive', {}, { waitMs: 800, pollMs: 100 });
112
+ const elapsed = Date.now() - t0;
113
+ check('wait deadline returns null', timedOut === null);
114
+ check('deadline respected (~800ms)', elapsed >= 700 && elapsed < 3000, `${elapsed}ms`);
115
+
116
+ // Wait for the child to release, then acquire from the parent.
117
+ await new Promise((res) => holder.child.on('exit', res));
118
+ const after = tryAcquireWsLock(dirScope(workDir), 'exclusive', { label: 'parent-after' });
119
+ check('acquire succeeds once holder exits', after.ok === true);
120
+ releaseWsLock(after.ok ? after.handle : null);
121
+
122
+ // Shared coexistence + exclusive blocked by readers (all cross-pid).
123
+ const s1 = spawnHolder(workDir, 'shared', 2500);
124
+ const s2 = spawnHolder(workDir, 'shared', 2500);
125
+ check('shared holder #1 acquired', await s1.held);
126
+ check('shared holder #2 acquired', await s2.held);
127
+ const excl = tryAcquireWsLock(dirScope(workDir), 'exclusive', { label: 'parent-excl' });
128
+ check('exclusive blocked by shared readers', !excl.reason || excl.reason === 'busy' ? !excl.ok : !excl.ok, JSON.stringify(excl).slice(0, 80));
129
+ await new Promise((res) => s1.child.on('exit', res));
130
+ await new Promise((res) => s2.child.on('exit', res));
131
+ }
132
+
133
+ // Stale takeover (dead pid).
134
+ {
135
+ const fakeDeadPid = 0x3ffffff0;
136
+ __writeForeignHolder(dirScope(workDir), 'exclusive', fakeDeadPid);
137
+ const holders = readWsHolders(dirScope(workDir));
138
+ check('dead-pid holder flagged stale', holders.length === 1 && holders[0].stale);
139
+ const g = tryAcquireWsLock(dirScope(workDir), 'exclusive', { label: 'takeover' });
140
+ check('stale exclusive taken over', g.ok === true);
141
+ releaseWsLock(g.ok ? g.handle : null);
142
+ }
143
+
144
+ // ?? 5: presence ?????????????????????????????????????????????????????????????
145
+ section('presence registry');
146
+ {
147
+ registerPresence(workDir, { agentId: 'agent-a', kind: 'task' });
148
+ const here = listPresent(workDir);
149
+ check('presence listed after register', here.length === 1 && here[0].agentId === 'agent-a');
150
+ unregisterPresence(workDir);
151
+ check('presence empty after unregister', listPresent(workDir).length === 0);
152
+ }
153
+
154
+ // ?? 6: classification ???????????????????????????????????????????????????????
155
+ section('classification');
156
+ {
157
+ const cases = [
158
+ ['npm install', 'exclusive'],
159
+ ['npm ci --silent', 'exclusive'],
160
+ ['pnpm add zod', 'exclusive'],
161
+ ['yarn remove left-pad', 'exclusive'],
162
+ ['npm run build', 'exclusive'],
163
+ ['npm run db:migrate', 'exclusive'],
164
+ ['npx prisma generate', 'exclusive'],
165
+ ['git checkout main', 'exclusive'],
166
+ ['git reset --hard HEAD~1', 'exclusive'],
167
+ ['git rebase origin/main', 'exclusive'],
168
+ ['npm run dev', 'long-shared'],
169
+ ['npm test', 'shared'],
170
+ ['npx eslint .', 'shared'],
171
+ ['git status', 'shared'],
172
+ ['git commit -m x', 'shared'],
173
+ ['git push -u origin HEAD', 'shared'],
174
+ ['node server.js', 'passthrough'],
175
+ ['python script.py', 'passthrough'],
176
+ ];
177
+ for (const [cmd, expected] of cases) {
178
+ check(`${cmd} -> ${expected}`, classifyCommand(cmd).cls === expected, classifyCommand(cmd).cls);
179
+ }
180
+ const prisma = classifyCommand('npx prisma migrate deploy');
181
+ check('prisma migrate needs global prisma-generate', (prisma.global ?? []).includes('prisma-generate'));
182
+ }
183
+
184
+ // ?? 7+8: arbiter HTTP endpoints + SSE ???????????????????????????????????????
185
+ section('arbiter HTTP + SSE');
186
+ {
187
+ const { server, port } = await startProbeServer(49777, () => ({
188
+ configPath: '', agents: [], registeredSchemes: false, projects: {},
189
+ }));
190
+ const base = `http://127.0.0.1:${port}`;
191
+ try {
192
+ const ping = await fetch(`${base}/ping`);
193
+ check('/ping alive', ping.ok);
194
+
195
+ // SSE subscriber first so granted/busy events below are observed.
196
+ const events = [];
197
+ const ac = new AbortController();
198
+ const sseDone = (async () => {
199
+ const res = await fetch(`${base}/arbitrate/events`, { signal: ac.signal });
200
+ const reader = res.body.getReader();
201
+ const dec = new TextDecoder();
202
+ let buf = '';
203
+ for (;;) {
204
+ const { done, value } = await reader.read();
205
+ if (done) break;
206
+ buf += dec.decode(value, { stream: true });
207
+ let idx;
208
+ while ((idx = buf.indexOf('\n\n')) >= 0) {
209
+ const frame = buf.slice(0, idx);
210
+ buf = buf.slice(idx + 2);
211
+ const line = frame.split('\n').find((l) => l.startsWith('data: '));
212
+ if (line) events.push(JSON.parse(line.slice(6)));
213
+ }
214
+ }
215
+ })().catch(() => {});
216
+ await new Promise((r) => setTimeout(r, 150));
217
+
218
+ // Cross-pid busy over HTTP: child holds, endpoint must report busy...
219
+ const httpHolder = spawnHolder(workDir, 'exclusive', 6000);
220
+ await httpHolder.held;
221
+ const scope = { kind: 'dir', path: workDir };
222
+ const busyRes = await fetch(`${base}/arbitrate/acquire`, {
223
+ method: 'POST',
224
+ headers: { 'Content-Type': 'application/json' },
225
+ body: JSON.stringify({ scope, mode: 'exclusive', waitMs: 600 }),
226
+ });
227
+ const busyBody = await busyRes.json();
228
+ check('HTTP acquire reports busy vs foreign holder', busyBody.ok === false && busyBody.reason === 'busy');
229
+
230
+ // ...and grants once the foreign holder exits.
231
+ await new Promise((res) => httpHolder.child.on('exit', res));
232
+ const grantRes = await fetch(`${base}/arbitrate/acquire`, {
233
+ method: 'POST',
234
+ headers: { 'Content-Type': 'application/json' },
235
+ body: JSON.stringify({ scope, mode: 'exclusive', waitMs: 2000, holder: { label: 'http-A' } }),
236
+ });
237
+ const granted = await grantRes.json();
238
+ check('HTTP acquire grants after holder exits', granted.ok === true && typeof granted.file === 'string');
239
+
240
+ const rel = await fetch(`${base}/arbitrate/release`, {
241
+ method: 'POST',
242
+ headers: { 'Content-Type': 'application/json' },
243
+ body: JSON.stringify({ file: granted.file }),
244
+ });
245
+ check('HTTP release ok', rel.ok && (await rel.json()).ok === true);
246
+
247
+ // Release guard: paths outside the lock tree must be refused.
248
+ const evil = await fetch(`${base}/arbitrate/release`, {
249
+ method: 'POST',
250
+ headers: { 'Content-Type': 'application/json' },
251
+ body: JSON.stringify({ file: join(stateRoot, 'innocent.txt') }),
252
+ });
253
+ check('release refuses non-lock paths', (await evil.json()).ok === false);
254
+
255
+ const status = await (await fetch(`${base}/arbitrate/status`)).json();
256
+ check('status lists scopes', Array.isArray(status.scopes));
257
+
258
+ await fetch(`${base}/arbitrate/publish`, {
259
+ method: 'POST',
260
+ headers: { 'Content-Type': 'application/json' },
261
+ body: JSON.stringify({ event: { type: 'peer.ping', note: 'e2e-hello' } }),
262
+ });
263
+ await new Promise((r) => setTimeout(r, 400));
264
+ check('SSE receives lock.granted', events.some((e) => e.type === 'lock.granted'), JSON.stringify(events.slice(0, 2)));
265
+ check('SSE receives peer.ping', events.some((e) => e.type === 'peer.ping'));
266
+ ac.abort();
267
+ await sseDone;
268
+ } finally {
269
+ server.close();
270
+ }
271
+ }
272
+
273
+ // ?? 9-11: shim runner ???????????????????????????????????????????????????????
274
+ section('shim runner');
275
+
276
+ // Parent-held FS blocker helpers (distinct pid from the shim subprocess).
277
+ let blockerHandle = null;
278
+ function tryAcquireFsBlocker(dir) {
279
+ const r = tryAcquireWsLock(dirScope(dir), 'exclusive', { label: 'blocker' });
280
+ if (r.ok) blockerHandle = r.handle;
281
+ return r.ok;
282
+ }
283
+ function releaseFsBlocker() {
284
+ releaseWsLock(blockerHandle);
285
+ blockerHandle = null;
286
+ }
287
+ {
288
+ const runner = join(DIST, 'lib/workspace/shim-runner.js');
289
+ const env = {
290
+ ...process.env,
291
+ TS_AGENT_ID: 'e2e-agent',
292
+ TS_SESSION_KIND: 'task',
293
+ TS_SHIM_WAIT_MS: '1500',
294
+ };
295
+
296
+ // 9a: passthrough commands never touch locks.
297
+ const pt = spawnSync(process.execPath, [runner, 'node', '-e', 'console.log("passthrough-ok")'], {
298
+ cwd: workDir, encoding: 'utf8', env,
299
+ });
300
+ check('passthrough executes directly', pt.stdout.includes('passthrough-ok') && pt.status === 0);
301
+
302
+ // 9b: fail-open when the lock root is unusable (a file where a dir must be).
303
+ const badRootFile = join(stateRoot, 'not-a-dir');
304
+ writeFileSync(badRootFile, 'x');
305
+ const fo = spawnSync(
306
+ process.execPath,
307
+ [runner, 'npm', 'run', 'build'],
308
+ { cwd: workDir, encoding: 'utf8', env: { ...env, TS_WS_LOCK_ROOT: badRootFile }, timeout: 120_000 },
309
+ );
310
+ const reachedNpm =
311
+ (fo.stderr ?? '').includes('arbitration skipped') ||
312
+ (fo.stderr ?? '').includes('Missing script') ||
313
+ (fo.stdout ?? '').includes('Missing script');
314
+ check('broken lock root -> fail-open runs npm', reachedNpm, `exit=${fo.status}`);
315
+
316
+ // 10: exclusive hold blocks shim npm build (fail-closed exit 124)...
317
+ const held = tryAcquireFsBlocker(workDir);
318
+ check('test blocker acquired', held === true);
319
+ const dumpTree = () => {
320
+ const out = [];
321
+ const walk = (d, pfx) => {
322
+ for (const e of readdirSync(d, { withFileTypes: true })) {
323
+ const fp = join(d, e.name);
324
+ if (e.isDirectory()) walk(fp, `${pfx}${e.name}/`);
325
+ else out.push(pfx + e.name);
326
+ }
327
+ };
328
+ try {
329
+ walk(process.env.TS_WS_LOCK_ROOT, '');
330
+ } catch {
331
+ /* root missing */
332
+ }
333
+ return out;
334
+ };
335
+ console.log(' [diag] lock tree while blocked:', JSON.stringify(dumpTree()));
336
+ const blocked = spawnSync(
337
+ process.execPath,
338
+ [runner, 'npm', 'run', 'build'],
339
+ { cwd: workDir, encoding: 'utf8', env, timeout: 60_000 },
340
+ );
341
+ check('shim waits and exits 124 on busy workspace', blocked.status === 124, `exit=${blocked.status}`);
342
+ console.log(' [diag] blocked stderr head:', (blocked.stderr ?? '').slice(0, 500).replace(/\n/g, ' | '));
343
+ check('timeout note explains refusal', (blocked.stderr ?? '').includes('NOT running'), (blocked.stderr ?? '').slice(-160));
344
+ releaseFsBlocker();
345
+
346
+ // ...then proceeds to the REAL npm once released.
347
+ const freed = spawnSync(
348
+ process.execPath,
349
+ [runner, 'npm', 'run', 'build'],
350
+ { cwd: workDir, encoding: 'utf8', env, timeout: 120_000 },
351
+ );
352
+ const ranNpm =
353
+ (freed.stderr ?? '').includes('Missing script') ||
354
+ (freed.stdout ?? '').includes('Missing script') ||
355
+ (freed.stderr ?? '').includes('npm error');
356
+ check('after release shim reaches real npm', ranNpm && freed.status !== 124, `exit=${freed.status}`);
357
+ }
358
+
359
+ // Nested bypass: parent holds the scope; shim with TS_WS_HELD skips locking.
360
+ section('nested TS_WS_HELD bypass');
361
+ {
362
+ const { scopeKey } = await import(distUrl('lib/workspace/repo-lock.js'));
363
+ const key = scopeKey(dirScope(workDir));
364
+ const ok = tryAcquireFsBlocker(workDir);
365
+ check('parent holds scope', ok === true);
366
+ const nested = spawnSync(
367
+ process.execPath,
368
+ [join(DIST, 'lib/workspace/shim-runner.js'), 'npm', 'install', '--dry-run'],
369
+ {
370
+ cwd: workDir,
371
+ encoding: 'utf8',
372
+ timeout: 120_000,
373
+ env: { ...process.env, TS_WS_HELD: key },
374
+ },
375
+ );
376
+ check('nested shim bypasses parent hold (no 124)', nested.status !== 124, `exit=${nested.status}`);
377
+ releaseFsBlocker();
378
+ }
379
+
380
+ // ?? cleanup ?????????????????????????????????????????????????????????????????
381
+ try {
382
+ rmSync(stateRoot, { recursive: true, force: true });
383
+ rmSync(workDir, { recursive: true, force: true });
384
+ } catch {
385
+ /* best-effort */
386
+ }
387
+
388
+ console.log(`\n${pass} passed, ${fail} failed`);
389
+ process.exit(fail > 0 ? 1 : 0);
390
390