@wenrwa/marketplace-cli 0.5.13-hcs.0 → 0.5.23

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 (91) hide show
  1. package/dist/__tests__/bridge-agent-launch.test.js +75 -0
  2. package/dist/__tests__/bridge-checkout-park-and-remove.test.d.ts +1 -0
  3. package/dist/__tests__/bridge-checkout-park-and-remove.test.js +386 -0
  4. package/dist/__tests__/bridge-checkout-reclaim.test.js +4 -2
  5. package/dist/__tests__/bridge-companion-forwarder-env-options.test.d.ts +1 -0
  6. package/dist/__tests__/bridge-companion-forwarder-env-options.test.js +46 -0
  7. package/dist/__tests__/bridge-companion-forwarder.test.d.ts +1 -0
  8. package/dist/__tests__/bridge-companion-forwarder.test.js +320 -0
  9. package/dist/__tests__/bridge-companion-frames.test.d.ts +1 -0
  10. package/dist/__tests__/bridge-companion-frames.test.js +69 -0
  11. package/dist/__tests__/bridge-companion-relay.test.d.ts +1 -0
  12. package/dist/__tests__/bridge-companion-relay.test.js +224 -0
  13. package/dist/__tests__/bridge-context-writer.test.js +33 -0
  14. package/dist/__tests__/bridge-disk-preflight.test.d.ts +1 -0
  15. package/dist/__tests__/bridge-disk-preflight.test.js +84 -0
  16. package/dist/__tests__/bridge-ensure-checkout-frame.test.d.ts +1 -0
  17. package/dist/__tests__/bridge-ensure-checkout-frame.test.js +280 -0
  18. package/dist/__tests__/bridge-listening-ports.test.d.ts +1 -0
  19. package/dist/__tests__/bridge-listening-ports.test.js +68 -0
  20. package/dist/__tests__/bridge-local-context-writer.test.js +32 -0
  21. package/dist/__tests__/bridge-park-remove-frame.test.d.ts +1 -0
  22. package/dist/__tests__/bridge-park-remove-frame.test.js +352 -0
  23. package/dist/__tests__/bridge-read-transcript-frame.test.d.ts +1 -0
  24. package/dist/__tests__/bridge-read-transcript-frame.test.js +136 -0
  25. package/dist/__tests__/bridge-relay-restore-open.test.d.ts +1 -0
  26. package/dist/__tests__/bridge-relay-restore-open.test.js +171 -0
  27. package/dist/__tests__/bridge-repo-size-estimate.test.d.ts +1 -0
  28. package/dist/__tests__/bridge-repo-size-estimate.test.js +132 -0
  29. package/dist/__tests__/bridge-server-local-context.test.js +8 -1
  30. package/dist/__tests__/bridge-server.test.js +163 -7
  31. package/dist/__tests__/bridge-storage-quota-fetch.test.d.ts +1 -0
  32. package/dist/__tests__/bridge-storage-quota-fetch.test.js +110 -0
  33. package/dist/__tests__/bridge-storage-quota-preflight.test.d.ts +1 -0
  34. package/dist/__tests__/bridge-storage-quota-preflight.test.js +72 -0
  35. package/dist/__tests__/bridge-work-graph-pointer.test.d.ts +1 -0
  36. package/dist/__tests__/bridge-work-graph-pointer.test.js +37 -0
  37. package/dist/__tests__/bridge-workspace-multirepo.test.js +12 -3
  38. package/dist/__tests__/bridge-workspace-repo-preflight.test.d.ts +1 -0
  39. package/dist/__tests__/bridge-workspace-repo-preflight.test.js +271 -0
  40. package/dist/__tests__/bridge-workspace-repo-quota-preflight.test.d.ts +1 -0
  41. package/dist/__tests__/bridge-workspace-repo-quota-preflight.test.js +131 -0
  42. package/dist/__tests__/checkout-follows-the-repo.test.d.ts +1 -0
  43. package/dist/__tests__/checkout-follows-the-repo.test.js +124 -0
  44. package/dist/__tests__/claude-session-history.test.d.ts +1 -0
  45. package/dist/__tests__/claude-session-history.test.js +82 -0
  46. package/dist/__tests__/claudeTranscriptReader.test.d.ts +1 -0
  47. package/dist/__tests__/claudeTranscriptReader.test.js +50 -0
  48. package/dist/__tests__/clone-credential-env.test.d.ts +1 -0
  49. package/dist/__tests__/clone-credential-env.test.js +81 -0
  50. package/dist/__tests__/session-git-identity.test.d.ts +1 -0
  51. package/dist/__tests__/session-git-identity.test.js +58 -0
  52. package/dist/bridge/agentLaunch.d.ts +44 -0
  53. package/dist/bridge/agentLaunch.js +7 -0
  54. package/dist/bridge/checkoutParkAndRemove.d.ts +106 -0
  55. package/dist/bridge/checkoutParkAndRemove.js +204 -0
  56. package/dist/bridge/checkoutReclaim.d.ts +11 -0
  57. package/dist/bridge/checkoutReclaim.js +6 -0
  58. package/dist/bridge/claudeSessionHistory.d.ts +40 -0
  59. package/dist/bridge/claudeSessionHistory.js +121 -0
  60. package/dist/bridge/claudeTranscriptReader.d.ts +21 -0
  61. package/dist/bridge/claudeTranscriptReader.js +123 -0
  62. package/dist/bridge/companionForwarder.d.ts +105 -0
  63. package/dist/bridge/companionForwarder.js +265 -0
  64. package/dist/bridge/companionFrames.d.ts +22 -0
  65. package/dist/bridge/companionFrames.js +58 -0
  66. package/dist/bridge/contextWriter.d.ts +1 -0
  67. package/dist/bridge/contextWriter.js +13 -2
  68. package/dist/bridge/diskPreflight.d.ts +95 -0
  69. package/dist/bridge/diskPreflight.js +124 -0
  70. package/dist/bridge/listeningPorts.d.ts +36 -0
  71. package/dist/bridge/listeningPorts.js +66 -0
  72. package/dist/bridge/localContextWriter.d.ts +1 -0
  73. package/dist/bridge/localContextWriter.js +11 -2
  74. package/dist/bridge/relayClient.d.ts +97 -0
  75. package/dist/bridge/relayClient.js +261 -4
  76. package/dist/bridge/repoCheckout.d.ts +80 -10
  77. package/dist/bridge/repoCheckout.js +159 -6
  78. package/dist/bridge/repoSizeEstimate.d.ts +66 -0
  79. package/dist/bridge/repoSizeEstimate.js +133 -0
  80. package/dist/bridge/server.d.ts +29 -0
  81. package/dist/bridge/server.js +22 -0
  82. package/dist/bridge/storageQuotaFetch.d.ts +39 -0
  83. package/dist/bridge/storageQuotaFetch.js +53 -0
  84. package/dist/bridge/storageQuotaPreflight.d.ts +89 -0
  85. package/dist/bridge/storageQuotaPreflight.js +85 -0
  86. package/dist/bridge/workGraphPointer.d.ts +34 -0
  87. package/dist/bridge/workGraphPointer.js +41 -0
  88. package/dist/commands/bridge.d.ts +16 -0
  89. package/dist/commands/bridge.js +279 -10
  90. package/dist/index.js +0 -0
  91. package/package.json +1 -1
@@ -68,4 +68,79 @@ describe('resolveAgentLaunch (lws-06)', () => {
68
68
  });
69
69
  });
70
70
  });
71
+ // psp-01 (production bug, 2026-09-14; supersedes bres-01's `resume`
72
+ // boolean) — the local bridge's per-AGENT session pin. bres-01's
73
+ // `resume: boolean` decided --continue off the CWD alone, so two agents
74
+ // sharing one workspace checkout both resumed whichever conversation was
75
+ // most recently active for that directory — never their own. `sessionPin`
76
+ // pins the launch's session id to the AGENT instead: a prior transcript
77
+ // file for THIS agent → --resume <agentId>; none → --session-id <agentId>
78
+ // (a clean session PINNED to this agent, never a directory-scoped one).
79
+ describe('sessionPin (psp-01)', () => {
80
+ it('sessionPin.exists:false → --session-id <agentId> (first launch, PINS a fresh session)', () => {
81
+ const launch = (0, agentLaunch_1.resolveAgentLaunch)({ sessionPin: { agentId: 'agent-A', exists: false } });
82
+ expect(launch.args).toEqual(['--session-id', 'agent-A']);
83
+ });
84
+ it('sessionPin.exists:true → --resume <agentId> (a prior session for THIS agent already exists)', () => {
85
+ const launch = (0, agentLaunch_1.resolveAgentLaunch)({ sessionPin: { agentId: 'agent-A', exists: true } });
86
+ expect(launch.args).toEqual(['--resume', 'agent-A']);
87
+ });
88
+ it('stamps WENRWA_AGENT_ID into env whenever a sessionPin is given', () => {
89
+ const launch = (0, agentLaunch_1.resolveAgentLaunch)({ sessionPin: { agentId: 'agent-A', exists: true } });
90
+ expect(launch.env).toEqual({ HIVEOP_CONTEXT_FILE: '.hiveop/context.md', WENRWA_AGENT_ID: 'agent-A' });
91
+ });
92
+ it('omitting sessionPin entirely is byte-identical to today\'s bare shape (no args, no WENRWA_AGENT_ID)', () => {
93
+ expect((0, agentLaunch_1.resolveAgentLaunch)({})).toEqual({
94
+ command: 'claude',
95
+ args: [],
96
+ env: { HIVEOP_CONTEXT_FILE: '.hiveop/context.md' },
97
+ });
98
+ });
99
+ it('composes with autonomous AND activity — the session-pin flag lands LAST', () => {
100
+ const activity = { settingsPath: '/Users/t/.wen-market/claude-hooks-settings.json', sessionId: 'agent-123', activityFile: '/Users/t/.wen-market/activity.jsonl' };
101
+ const launch = (0, agentLaunch_1.resolveAgentLaunch)({ autonomous: true, activity, sessionPin: { agentId: 'agent-A', exists: true } });
102
+ expect(launch.args).toEqual(['--dangerously-skip-permissions', '--settings', activity.settingsPath, '--resume', 'agent-A']);
103
+ });
104
+ // THE PRODUCTION REPRO at this layer: two different agents in the same
105
+ // cwd (a shared workspace checkout) must never resolve to the same argv.
106
+ it('THE PRODUCTION REPRO: two different agents each get their OWN resume target, not a shared one', () => {
107
+ const forA = (0, agentLaunch_1.resolveAgentLaunch)({ sessionPin: { agentId: 'agent-A', exists: true } });
108
+ const forB = (0, agentLaunch_1.resolveAgentLaunch)({ sessionPin: { agentId: 'agent-B', exists: true } });
109
+ expect(forA.args).not.toEqual(forB.args);
110
+ expect(forA.env.WENRWA_AGENT_ID).not.toBe(forB.env.WENRWA_AGENT_ID);
111
+ });
112
+ });
113
+ // local-workspace-id-env — the local lane never set WENRWA_WORKSPACE_ID
114
+ // (unlike the hosted/relay lane, assembleSessionEnv in relayClient.ts),
115
+ // so the workgraph MCP tools' `workspaceId` parameter stayed a REQUIRED
116
+ // field the model had to guess a UUID for instead of an auto-pinned one
117
+ // (mcp-server/src/tools/workgraph-tools.ts's `ws()` helper). The local
118
+ // bridge's `open` frame already carries a per-session `workspaceId`
119
+ // (server.ts) — threading it through here stamps the same env var the
120
+ // hosted lane stamps, closing the gap without inventing a new lookup.
121
+ describe('workspaceId (local-workspace-id-env)', () => {
122
+ it('stamps WENRWA_WORKSPACE_ID into env when workspaceId is given', () => {
123
+ const launch = (0, agentLaunch_1.resolveAgentLaunch)({ workspaceId: 'ws-1' });
124
+ expect(launch.env).toEqual({ HIVEOP_CONTEXT_FILE: '.hiveop/context.md', WENRWA_WORKSPACE_ID: 'ws-1' });
125
+ });
126
+ it('omitting workspaceId is byte-identical to today\'s bare shape (no WENRWA_WORKSPACE_ID)', () => {
127
+ expect((0, agentLaunch_1.resolveAgentLaunch)({}).env).toEqual({ HIVEOP_CONTEXT_FILE: '.hiveop/context.md' });
128
+ });
129
+ it('composes with autonomous, activity, and sessionPin — independent of the other opts', () => {
130
+ const activity = { settingsPath: '/Users/t/.wen-market/claude-hooks-settings.json', sessionId: 'agent-123', activityFile: '/Users/t/.wen-market/activity.jsonl' };
131
+ const launch = (0, agentLaunch_1.resolveAgentLaunch)({
132
+ autonomous: true,
133
+ activity,
134
+ sessionPin: { agentId: 'agent-A', exists: true },
135
+ workspaceId: 'ws-1',
136
+ });
137
+ expect(launch.env).toEqual({
138
+ HIVEOP_CONTEXT_FILE: '.hiveop/context.md',
139
+ WENRWA_SESSION_ID: 'agent-123',
140
+ HIVEOP_ACTIVITY_FILE: activity.activityFile,
141
+ WENRWA_AGENT_ID: 'agent-A',
142
+ WENRWA_WORKSPACE_ID: 'ws-1',
143
+ });
144
+ });
145
+ });
71
146
  });
@@ -0,0 +1,386 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * hcs-06 (docs/prd/hosted-container-storage-reclamation.md, ADR-0008) —
5
+ * checkoutParkAndRemove: the deliberate, user-initiated lever for a DIRTY
6
+ * checkout that automatic reclamation (hcs-04/hcs-05) will never touch.
7
+ *
8
+ * FAILS CLOSED BY CONSTRUCTION: commit -> push -> verify-on-remote -> only
9
+ * then delete (by reusing hcs-05's own checkoutReclaim executor for the
10
+ * delete step, never a second `rm` implementation). Every test below proves
11
+ * one of:
12
+ * - a push failure (rejected / unauthenticated / network / unverifiable)
13
+ * retains the checkout and deletes nothing;
14
+ * - a commit failure retains the checkout and deletes nothing;
15
+ * - a genuine success pushes AND removes;
16
+ * - untracked files ride the commit;
17
+ * - nothing-to-park is reported without deleting anything;
18
+ * - a second concurrent park for the same workspace is refused, not raced.
19
+ *
20
+ * No real git, no real filesystem, no real network — `run` and the
21
+ * `reclaimer` are both injected fakes, same DI discipline as
22
+ * checkoutReclaim.ts's own test suite.
23
+ */
24
+ const checkoutParkAndRemove_1 = require("../bridge/checkoutParkAndRemove");
25
+ const REPOS = '/data/repos';
26
+ const WS = 'ws-1';
27
+ const TARGET = `${REPOS}/${WS}`;
28
+ /** A scriptable git runner: maps an argv-prefix pattern to a canned result,
29
+ * and records every call so a test can assert exactly what was run
30
+ * (e.g. that untracked files are staged via `-A`). */
31
+ function makeGitRunner(script) {
32
+ const calls = [];
33
+ const run = async (cmd, args) => {
34
+ calls.push({ cmd, args });
35
+ const outcome = script(args);
36
+ if (outcome === 'throw')
37
+ throw new Error('simulated network failure: getaddrinfo ENOTFOUND github.com');
38
+ return outcome;
39
+ };
40
+ return { run, calls };
41
+ }
42
+ /** Default happy-path script: dirty working tree, clean push, matching
43
+ * ls-remote verification. Individual tests override specific branches. */
44
+ function defaultScript(headSha = 'a'.repeat(40)) {
45
+ return (args) => {
46
+ if (args.includes('add'))
47
+ return { stdout: '', code: 0 };
48
+ if (args.includes('status'))
49
+ return { stdout: ' M some/file.ts\n?? new/untracked.ts\n', code: 0 };
50
+ if (args.includes('commit'))
51
+ return { stdout: '', code: 0 };
52
+ if (args.includes('rev-list'))
53
+ return { stdout: '0\n', code: 0 };
54
+ if (args.includes('rev-parse'))
55
+ return { stdout: `${headSha}\n`, code: 0 };
56
+ if (args.includes('push'))
57
+ return { stdout: '', code: 0 };
58
+ if (args.includes('ls-remote'))
59
+ return { stdout: `${headSha}\trefs/heads/park/${WS}\n`, code: 0 };
60
+ return { stdout: '', code: 0 };
61
+ };
62
+ }
63
+ function fakeReclaimer(overrides) {
64
+ const calls = [];
65
+ return {
66
+ calls,
67
+ async reclaim(verdicts) {
68
+ calls.push(verdicts);
69
+ if (overrides?.reclaim)
70
+ return overrides.reclaim(verdicts);
71
+ return verdicts.map((v) => ({ workspaceId: v.workspaceId, outcome: 'removed', bytesFreed: 4096 }));
72
+ },
73
+ };
74
+ }
75
+ function baseDeps(script, reclaimer, exists = () => true) {
76
+ const g = makeGitRunner(script);
77
+ return {
78
+ deps: { reposBaseDir: REPOS, exists, run: g.run, reclaimer },
79
+ calls: g.calls,
80
+ };
81
+ }
82
+ describe('checkoutParkAndRemove (hcs-06)', () => {
83
+ it('parkingBranchName is deterministic from the workspace id', () => {
84
+ expect((0, checkoutParkAndRemove_1.parkingBranchName)('ws-1')).toBe((0, checkoutParkAndRemove_1.parkingBranchName)('ws-1'));
85
+ expect((0, checkoutParkAndRemove_1.parkingBranchName)('ws-1')).not.toBe((0, checkoutParkAndRemove_1.parkingBranchName)('ws-2'));
86
+ expect((0, checkoutParkAndRemove_1.parkingBranchName)('ws-1')).toContain('ws-1');
87
+ });
88
+ it('successful park: work is verified on the remote AND the checkout is removed', async () => {
89
+ const reclaimer = fakeReclaimer();
90
+ const { deps, calls } = baseDeps(defaultScript(), reclaimer);
91
+ const parker = (0, checkoutParkAndRemove_1.createCheckoutParker)(deps);
92
+ const phases = [];
93
+ const result = await parker.park(WS, (p) => phases.push(p));
94
+ expect(result).toMatchObject({ workspaceId: WS, outcome: 'removed', bytesFreed: 4096 });
95
+ expect(result.branch).toBe((0, checkoutParkAndRemove_1.parkingBranchName)(WS));
96
+ expect(phases).toEqual(['committing', 'pushing', 'removing']);
97
+ expect(reclaimer.calls).toEqual([[{ workspaceId: WS, verdict: 'RECLAIM', reason: expect.any(String) }]]);
98
+ // Untracked files are included: `git add -A`, not a narrower add.
99
+ const addCall = calls.find((c) => c.args.includes('add'));
100
+ expect(addCall?.args).toEqual(expect.arrayContaining(['-A']));
101
+ });
102
+ it('push rejected (non-fast-forward) -> checkout retained, zero deletion, reason surfaced', async () => {
103
+ const reclaimer = fakeReclaimer();
104
+ const script = (args) => {
105
+ if (args.includes('add'))
106
+ return { stdout: '', code: 0 };
107
+ if (args.includes('status'))
108
+ return { stdout: ' M file.ts\n', code: 0 };
109
+ if (args.includes('commit'))
110
+ return { stdout: '', code: 0 };
111
+ if (args.includes('rev-list'))
112
+ return { stdout: '0\n', code: 0 };
113
+ if (args.includes('rev-parse'))
114
+ return { stdout: `${'a'.repeat(40)}\n`, code: 0 };
115
+ if (args.includes('push')) {
116
+ return { stdout: '! [rejected] HEAD -> park/ws-1 (non-fast-forward)\n', code: 1 };
117
+ }
118
+ return { stdout: '', code: 0 };
119
+ };
120
+ const { deps } = baseDeps(script, reclaimer);
121
+ const parker = (0, checkoutParkAndRemove_1.createCheckoutParker)(deps);
122
+ const result = await parker.park(WS);
123
+ expect(result.outcome).toBe('retained');
124
+ expect(result.phase).toBe('pushing');
125
+ expect(result.reason).toMatch(/non-fast-forward|rejected/i);
126
+ expect(reclaimer.calls).toEqual([]); // removal is NEVER attempted
127
+ });
128
+ it('push unauthenticated -> checkout retained, zero deletion, reason surfaced', async () => {
129
+ const reclaimer = fakeReclaimer();
130
+ const script = (args) => {
131
+ if (args.includes('add'))
132
+ return { stdout: '', code: 0 };
133
+ if (args.includes('status'))
134
+ return { stdout: ' M file.ts\n', code: 0 };
135
+ if (args.includes('commit'))
136
+ return { stdout: '', code: 0 };
137
+ if (args.includes('rev-list'))
138
+ return { stdout: '0\n', code: 0 };
139
+ if (args.includes('rev-parse'))
140
+ return { stdout: `${'a'.repeat(40)}\n`, code: 0 };
141
+ if (args.includes('push')) {
142
+ return { stdout: 'fatal: could not read Username for \'https://github.com\': terminal prompts disabled\n', code: 1 };
143
+ }
144
+ return { stdout: '', code: 0 };
145
+ };
146
+ const { deps } = baseDeps(script, reclaimer);
147
+ const parker = (0, checkoutParkAndRemove_1.createCheckoutParker)(deps);
148
+ const result = await parker.park(WS);
149
+ expect(result.outcome).toBe('retained');
150
+ expect(result.phase).toBe('pushing');
151
+ expect(result.reason).toMatch(/Username|credentials|auth/i);
152
+ expect(reclaimer.calls).toEqual([]);
153
+ });
154
+ it('network failure during push -> checkout retained, zero deletion, reason surfaced', async () => {
155
+ const reclaimer = fakeReclaimer();
156
+ const script = (args) => {
157
+ if (args.includes('add'))
158
+ return { stdout: '', code: 0 };
159
+ if (args.includes('status'))
160
+ return { stdout: ' M file.ts\n', code: 0 };
161
+ if (args.includes('commit'))
162
+ return { stdout: '', code: 0 };
163
+ if (args.includes('rev-list'))
164
+ return { stdout: '0\n', code: 0 };
165
+ if (args.includes('rev-parse'))
166
+ return { stdout: `${'a'.repeat(40)}\n`, code: 0 };
167
+ if (args.includes('push'))
168
+ return 'throw'; // the injected runner rejects — real ECONNREFUSED/ENOTFOUND shape
169
+ return { stdout: '', code: 0 };
170
+ };
171
+ const { deps } = baseDeps(script, reclaimer);
172
+ const parker = (0, checkoutParkAndRemove_1.createCheckoutParker)(deps);
173
+ const result = await parker.park(WS);
174
+ expect(result.outcome).toBe('retained');
175
+ expect(result.phase).toBe('pushing');
176
+ expect(result.reason).toMatch(/network|ENOTFOUND|failure/i);
177
+ expect(reclaimer.calls).toEqual([]);
178
+ });
179
+ it('push reports success but the remote ref does not verify -> retained, zero deletion (never trust exit code alone)', async () => {
180
+ const reclaimer = fakeReclaimer();
181
+ const script = (args) => {
182
+ if (args.includes('add'))
183
+ return { stdout: '', code: 0 };
184
+ if (args.includes('status'))
185
+ return { stdout: ' M file.ts\n', code: 0 };
186
+ if (args.includes('commit'))
187
+ return { stdout: '', code: 0 };
188
+ if (args.includes('rev-list'))
189
+ return { stdout: '0\n', code: 0 };
190
+ if (args.includes('rev-parse'))
191
+ return { stdout: `${'a'.repeat(40)}\n`, code: 0 };
192
+ if (args.includes('push'))
193
+ return { stdout: '', code: 0 }; // claims success...
194
+ if (args.includes('ls-remote'))
195
+ return { stdout: '', code: 0 }; // ...but the ref isn't actually there
196
+ return { stdout: '', code: 0 };
197
+ };
198
+ const { deps } = baseDeps(script, reclaimer);
199
+ const parker = (0, checkoutParkAndRemove_1.createCheckoutParker)(deps);
200
+ const result = await parker.park(WS);
201
+ expect(result.outcome).toBe('retained');
202
+ expect(result.phase).toBe('pushing');
203
+ expect(result.reason).toMatch(/verif|remote/i);
204
+ expect(reclaimer.calls).toEqual([]);
205
+ });
206
+ it('push reports success but ls-remote returns a DIFFERENT sha -> retained, zero deletion', async () => {
207
+ const reclaimer = fakeReclaimer();
208
+ const localSha = 'a'.repeat(40);
209
+ const otherSha = 'b'.repeat(40);
210
+ const script = (args) => {
211
+ if (args.includes('add'))
212
+ return { stdout: '', code: 0 };
213
+ if (args.includes('status'))
214
+ return { stdout: ' M file.ts\n', code: 0 };
215
+ if (args.includes('commit'))
216
+ return { stdout: '', code: 0 };
217
+ if (args.includes('rev-list'))
218
+ return { stdout: '0\n', code: 0 };
219
+ if (args.includes('rev-parse'))
220
+ return { stdout: `${localSha}\n`, code: 0 };
221
+ if (args.includes('push'))
222
+ return { stdout: '', code: 0 };
223
+ if (args.includes('ls-remote'))
224
+ return { stdout: `${otherSha}\trefs/heads/park/${WS}\n`, code: 0 };
225
+ return { stdout: '', code: 0 };
226
+ };
227
+ const { deps } = baseDeps(script, reclaimer);
228
+ const parker = (0, checkoutParkAndRemove_1.createCheckoutParker)(deps);
229
+ const result = await parker.park(WS);
230
+ expect(result.outcome).toBe('retained');
231
+ expect(reclaimer.calls).toEqual([]);
232
+ });
233
+ it('commit fails -> checkout retained, zero deletion, reason surfaced, push never attempted', async () => {
234
+ const reclaimer = fakeReclaimer();
235
+ const script = (args) => {
236
+ if (args.includes('add'))
237
+ return { stdout: '', code: 0 };
238
+ if (args.includes('status'))
239
+ return { stdout: ' M file.ts\n', code: 0 };
240
+ if (args.includes('commit'))
241
+ return { stdout: 'fatal: unable to create index.lock\n', code: 1 };
242
+ return { stdout: '', code: 0 };
243
+ };
244
+ const { deps, calls } = baseDeps(script, reclaimer);
245
+ const parker = (0, checkoutParkAndRemove_1.createCheckoutParker)(deps);
246
+ const result = await parker.park(WS);
247
+ expect(result.outcome).toBe('retained');
248
+ expect(result.phase).toBe('committing');
249
+ expect(result.reason).toMatch(/index\.lock|commit/i);
250
+ expect(reclaimer.calls).toEqual([]);
251
+ expect(calls.some((c) => c.args.includes('push'))).toBe(false);
252
+ });
253
+ it('git add fails -> checkout retained, zero deletion, nothing committed or pushed', async () => {
254
+ const reclaimer = fakeReclaimer();
255
+ const script = (args) => {
256
+ if (args.includes('add'))
257
+ return { stdout: 'fatal: not a git repository\n', code: 128 };
258
+ return { stdout: '', code: 0 };
259
+ };
260
+ const { deps, calls } = baseDeps(script, reclaimer);
261
+ const parker = (0, checkoutParkAndRemove_1.createCheckoutParker)(deps);
262
+ const result = await parker.park(WS);
263
+ expect(result.outcome).toBe('retained');
264
+ expect(result.phase).toBe('committing');
265
+ expect(reclaimer.calls).toEqual([]);
266
+ expect(calls.some((c) => c.args.includes('commit'))).toBe(false);
267
+ expect(calls.some((c) => c.args.includes('push'))).toBe(false);
268
+ });
269
+ it('nothing to commit and nothing unpushed -> nothing-to-park, deletes nothing, never pushes', async () => {
270
+ const reclaimer = fakeReclaimer();
271
+ const script = (args) => {
272
+ if (args.includes('add'))
273
+ return { stdout: '', code: 0 };
274
+ if (args.includes('status'))
275
+ return { stdout: '', code: 0 }; // clean working tree after add
276
+ if (args.includes('rev-list'))
277
+ return { stdout: '0\n', code: 0 }; // nothing unpushed either
278
+ return { stdout: '', code: 0 };
279
+ };
280
+ const { deps, calls } = baseDeps(script, reclaimer);
281
+ const parker = (0, checkoutParkAndRemove_1.createCheckoutParker)(deps);
282
+ const result = await parker.park(WS);
283
+ expect(result.outcome).toBe('nothing-to-park');
284
+ expect(result.branch).toBe((0, checkoutParkAndRemove_1.parkingBranchName)(WS));
285
+ expect(reclaimer.calls).toEqual([]);
286
+ expect(calls.some((c) => c.args.includes('push'))).toBe(false);
287
+ expect(calls.some((c) => c.args.includes('commit'))).toBe(false);
288
+ });
289
+ it('nothing to commit but there ARE unpushed commits -> still pushes and removes', async () => {
290
+ const reclaimer = fakeReclaimer();
291
+ const script = (args) => {
292
+ if (args.includes('add'))
293
+ return { stdout: '', code: 0 };
294
+ if (args.includes('status'))
295
+ return { stdout: '', code: 0 }; // clean working tree
296
+ if (args.includes('rev-list'))
297
+ return { stdout: '2\n', code: 0 }; // but 2 unpushed commits
298
+ if (args.includes('rev-parse'))
299
+ return { stdout: `${'a'.repeat(40)}\n`, code: 0 };
300
+ if (args.includes('push'))
301
+ return { stdout: '', code: 0 };
302
+ if (args.includes('ls-remote'))
303
+ return { stdout: `${'a'.repeat(40)}\trefs/heads/park/${WS}\n`, code: 0 };
304
+ return { stdout: '', code: 0 };
305
+ };
306
+ const { deps, calls } = baseDeps(script, reclaimer);
307
+ const parker = (0, checkoutParkAndRemove_1.createCheckoutParker)(deps);
308
+ const result = await parker.park(WS);
309
+ expect(result.outcome).toBe('removed');
310
+ expect(calls.some((c) => c.args.includes('commit'))).toBe(false); // nothing NEW to commit
311
+ expect(calls.some((c) => c.args.includes('push'))).toBe(true);
312
+ });
313
+ it('push succeeds and verifies, but the reuse-hcs-05 removal step aborts (e.g. a stash reappeared) -> retained, work already safe', async () => {
314
+ const reclaimer = fakeReclaimer({
315
+ reclaim: async (verdicts) => verdicts.map((v) => ({ workspaceId: v.workspaceId, outcome: 'aborted', reason: 'checkout became dirty between the inventory scan and removal — aborted' })),
316
+ });
317
+ const { deps } = baseDeps(defaultScript(), reclaimer);
318
+ const parker = (0, checkoutParkAndRemove_1.createCheckoutParker)(deps);
319
+ const result = await parker.park(WS);
320
+ expect(result.outcome).toBe('retained');
321
+ expect(result.phase).toBe('removing');
322
+ expect(result.reason).toMatch(/dirty|abort/i);
323
+ expect(result.branch).toBe((0, checkoutParkAndRemove_1.parkingBranchName)(WS)); // still findable — the push DID succeed
324
+ });
325
+ it('unsafe workspace id is refused before any git command runs', async () => {
326
+ const reclaimer = fakeReclaimer();
327
+ const { deps, calls } = baseDeps(defaultScript(), reclaimer);
328
+ const parker = (0, checkoutParkAndRemove_1.createCheckoutParker)(deps);
329
+ const result = await parker.park('../escape');
330
+ expect(result.outcome).toBe('refused');
331
+ expect(calls.length).toBe(0);
332
+ expect(reclaimer.calls).toEqual([]);
333
+ });
334
+ it('no checkout exists for this workspace id -> refused, not treated as trivially parked', async () => {
335
+ const reclaimer = fakeReclaimer();
336
+ const { deps } = baseDeps(defaultScript(), reclaimer, () => false);
337
+ const parker = (0, checkoutParkAndRemove_1.createCheckoutParker)(deps);
338
+ const result = await parker.park(WS);
339
+ expect(result.outcome).toBe('refused');
340
+ expect(reclaimer.calls).toEqual([]);
341
+ });
342
+ it('a second concurrent park for the same checkout is refused, not raced', async () => {
343
+ const reclaimer = fakeReclaimer();
344
+ let releasePush;
345
+ const pushGate = new Promise((r) => { releasePush = r; });
346
+ let pushCalls = 0;
347
+ const script = (args) => {
348
+ if (args.includes('add'))
349
+ return { stdout: '', code: 0 };
350
+ if (args.includes('status'))
351
+ return { stdout: ' M file.ts\n', code: 0 };
352
+ if (args.includes('commit'))
353
+ return { stdout: '', code: 0 };
354
+ if (args.includes('rev-list'))
355
+ return { stdout: '0\n', code: 0 };
356
+ if (args.includes('rev-parse'))
357
+ return { stdout: `${'a'.repeat(40)}\n`, code: 0 };
358
+ if (args.includes('push')) {
359
+ pushCalls++;
360
+ return { stdout: '', code: 0 };
361
+ }
362
+ if (args.includes('ls-remote'))
363
+ return { stdout: `${'a'.repeat(40)}\trefs/heads/park/${WS}\n`, code: 0 };
364
+ return { stdout: '', code: 0 };
365
+ };
366
+ // Wrap the runner so the FIRST push call blocks on pushGate, giving the
367
+ // second park() call a real window to race in.
368
+ const g = makeGitRunner(script);
369
+ const gatedRun = async (cmd, args) => {
370
+ if (args.includes('push'))
371
+ await pushGate;
372
+ return g.run(cmd, args);
373
+ };
374
+ const parker = (0, checkoutParkAndRemove_1.createCheckoutParker)({ reposBaseDir: REPOS, exists: () => true, run: gatedRun, reclaimer });
375
+ const first = parker.park(WS);
376
+ // Let the first call reach (and block inside) the push phase.
377
+ await new Promise((r) => setTimeout(r, 10));
378
+ const second = await parker.park(WS);
379
+ expect(second.outcome).toBe('refused');
380
+ expect(second.reason).toMatch(/already.*progress|in progress/i);
381
+ releasePush();
382
+ const firstResult = await first;
383
+ expect(firstResult.outcome).toBe('removed');
384
+ expect(pushCalls).toBe(1); // never raced into a second push
385
+ });
386
+ });
@@ -329,7 +329,9 @@ describe('checkoutReclaim + repoCheckout — re-clone after reclaim', () => {
329
329
  // 1. Before reclaim: the terminal reuses the existing checkout.
330
330
  const before = await (0, repoCheckout_1.ensureWorkspaceRepo)({ workspaceId, repoUrl }, repoDeps);
331
331
  expect(before).toEqual({ path: dirPath, action: 'existing' });
332
- expect(cloneRuns).toHaveLength(0);
332
+ // grad-04: reuse now also verifies `origin` matches the workspace's repo,
333
+ // so count CLONES — the invariant this test is named for — not every git call.
334
+ expect(cloneRuns.filter((r) => r.includes('clone'))).toHaveLength(0);
333
335
  // 2. Reclaim removes the checkout directory entirely.
334
336
  const reclaimDeps = {
335
337
  reposBaseDir: REPOS,
@@ -348,6 +350,6 @@ describe('checkoutReclaim + repoCheckout — re-clone after reclaim', () => {
348
350
  // the repo (action: 'cloned'), never 'failed' (the home-dir degrade).
349
351
  const after = await (0, repoCheckout_1.ensureWorkspaceRepo)({ workspaceId, repoUrl }, repoDeps);
350
352
  expect(after).toEqual({ path: dirPath, action: 'cloned' });
351
- expect(cloneRuns).toHaveLength(1);
353
+ expect(cloneRuns.filter((r) => r.includes('clone'))).toHaveLength(1);
352
354
  });
353
355
  });
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * bws-08 — pure resolver for the companion forwarder's env-derived knobs
5
+ * (buildCompanionForwarderEnvOptions in commands/bridge.ts). Same
6
+ * "pure helper, thin command shell" pattern as buildBrowserDriverOptions
7
+ * (bridge-command-browser-options.test.ts) — `buildCompanionForwarder`
8
+ * itself is thin glue over real `/etc/hosts` + `/proc/net/tcp[6]` reads and
9
+ * is deliberately left untested here for the same reason the commander
10
+ * action itself is: the actual decisions (resolveFlyLocal6pnHost,
11
+ * createCompanionForwarder, createNoopCompanionForwarder) are each unit
12
+ * tested directly in bridge-companion-forwarder.test.ts.
13
+ */
14
+ const bridge_1 = require("../commands/bridge");
15
+ describe('buildCompanionForwarderEnvOptions (bws-08)', () => {
16
+ it('defaults to a 5s poll and no excluded ports', () => {
17
+ expect((0, bridge_1.buildCompanionForwarderEnvOptions)({})).toEqual({
18
+ pollMs: 5_000,
19
+ excludePorts: new Set(),
20
+ });
21
+ });
22
+ it('honors HT_COMPANION_FORWARDER_POLL_MS', () => {
23
+ expect((0, bridge_1.buildCompanionForwarderEnvOptions)({ HT_COMPANION_FORWARDER_POLL_MS: '2000' })).toEqual({
24
+ pollMs: 2000,
25
+ excludePorts: new Set(),
26
+ });
27
+ });
28
+ it('parses a comma-separated HT_COMPANION_FORWARDER_EXCLUDE_PORTS list', () => {
29
+ expect((0, bridge_1.buildCompanionForwarderEnvOptions)({ HT_COMPANION_FORWARDER_EXCLUDE_PORTS: '9229, 8377,3000' })).toEqual({
30
+ pollMs: 5_000,
31
+ excludePorts: new Set([9229, 8377, 3000]),
32
+ });
33
+ });
34
+ it('ignores garbage entries in the exclude-ports list rather than crashing', () => {
35
+ expect((0, bridge_1.buildCompanionForwarderEnvOptions)({ HT_COMPANION_FORWARDER_EXCLUDE_PORTS: 'nope,,-1,0,8080' })).toEqual({
36
+ pollMs: 5_000,
37
+ excludePorts: new Set([8080]),
38
+ });
39
+ });
40
+ it('falls back to the default poll on a non-numeric override', () => {
41
+ expect((0, bridge_1.buildCompanionForwarderEnvOptions)({ HT_COMPANION_FORWARDER_POLL_MS: 'abc' })).toEqual({
42
+ pollMs: 5_000,
43
+ excludePorts: new Set(),
44
+ });
45
+ });
46
+ });