@gr8ful/spf 0.1.5 → 0.1.7

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.
@@ -1,17 +1,15 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
+ import path from "node:path";
3
4
  import { branchNameFor, claimNewWork, createWatchState, finishReviews, reconcileOrphans } from "../core/watch.js";
4
5
  /** In-memory fake — exactly the seam `provider.ts` exists for. */
5
6
  class FakeProvider {
6
7
  entries = new Map();
7
- prs = new Map();
8
8
  ensureLabelsCalls = 0;
9
9
  transitions = [];
10
- openedPrs = [];
11
10
  claimCalls = [];
12
- nextPrNumber = 1000;
13
- addIssue(number, title, state = "ready", marker = null) {
14
- this.entries.set(number, { issue: { number, title, body: "", labels: [`spf:${state}`] }, state, marker });
11
+ addIssue(id, title, state = "ready", marker = null) {
12
+ this.entries.set(id, { issue: { id, title, body: "", labels: [`spf:${state}`] }, state, marker });
15
13
  }
16
14
  async ensureLabels() {
17
15
  this.ensureLabelsCalls++;
@@ -24,33 +22,39 @@ class FakeProvider {
24
22
  return [...this.entries.values()].filter((e) => e.state === state).map((e) => e.issue);
25
23
  }
26
24
  async claim(issue) {
27
- this.claimCalls.push(issue.number);
28
- const entry = this.entries.get(issue.number);
25
+ this.claimCalls.push(issue.id);
26
+ const entry = this.entries.get(issue.id);
29
27
  if (entry.state !== "ready")
30
28
  return false;
31
29
  entry.state = "working";
32
30
  return true;
33
31
  }
34
32
  async transition(issue, to, detail) {
35
- this.entries.get(issue.number).state = to;
36
- this.transitions.push({ number: issue.number, to, detail });
33
+ this.entries.get(issue.id).state = to;
34
+ this.transitions.push({ id: issue.id, to, detail });
37
35
  }
38
36
  async comment() { }
39
- async openPr(issue, opts) {
37
+ async readMarker(issue) {
38
+ return this.entries.get(issue.id)?.marker ?? null;
39
+ }
40
+ async writeMarker(issue, marker) {
41
+ this.entries.get(issue.id).marker = marker;
42
+ }
43
+ }
44
+ /** In-memory fake `CodeHostProvider` — separate from `FakeProvider`, mirroring the real split. */
45
+ class FakeCodeHost {
46
+ prs = new Map();
47
+ openedPrs = [];
48
+ nextPrNumber = 1000;
49
+ async openPr(opts) {
40
50
  const number = this.nextPrNumber++;
41
- this.openedPrs.push({ issueNumber: issue.number, branch: opts.branch });
51
+ this.openedPrs.push({ title: opts.title, branch: opts.branch });
42
52
  this.prs.set(number, { merged: false, state: "open", ciStatus: "pending" });
43
53
  return { number, branch: opts.branch, url: `https://example.invalid/pr/${number}` };
44
54
  }
45
55
  async prStatus(pr) {
46
56
  return this.prs.get(pr.number) ?? { merged: false, state: "open", ciStatus: "pending" };
47
57
  }
48
- async readMarker(issue) {
49
- return this.entries.get(issue.number)?.marker ?? null;
50
- }
51
- async writeMarker(issue, marker) {
52
- this.entries.get(issue.number).marker = marker;
53
- }
54
58
  }
55
59
  function fakeGit(overrides = {}) {
56
60
  return {
@@ -77,9 +81,10 @@ function fakeGit(overrides = {}) {
77
81
  ...overrides,
78
82
  };
79
83
  }
80
- function makeDeps(provider, overrides = {}) {
84
+ function makeDeps(provider, codeHost, overrides = {}) {
81
85
  return {
82
86
  provider,
87
+ codeHost,
83
88
  git: fakeGit(),
84
89
  worktreeGit: () => fakeGit({ diffFiles: () => ["src/index.ts"] }), // a real commit landed, by default
85
90
  labelPrefix: "spf",
@@ -87,6 +92,7 @@ function makeDeps(provider, overrides = {}) {
87
92
  baseBranch: "main",
88
93
  concurrency: 2,
89
94
  worktreesDir: "/tmp/spf-watch-test-worktrees",
95
+ linkDataDir: () => { },
90
96
  dryRun: false,
91
97
  runChain: async () => ({ accepted: true, adwId: "issue-1", detail: "" }),
92
98
  log: () => { },
@@ -102,63 +108,107 @@ async function waitUntil(predicate, timeoutMs = 2000) {
102
108
  }
103
109
  }
104
110
  test("branchNameFor: sanitizes a title into a safe branch name", () => {
105
- assert.equal(branchNameFor({ number: 42, title: "Add a /health endpoint!!", body: "", labels: [] }), "spf-watch/42-add-a-health-endpoint");
106
- assert.equal(branchNameFor({ number: 7, title: "", body: "", labels: [] }), "spf-watch/7-issue");
111
+ assert.equal(branchNameFor({ id: "42", title: "Add a /health endpoint!!", body: "", labels: [] }), "spf-watch/42-add-a-health-endpoint");
112
+ assert.equal(branchNameFor({ id: "7", title: "", body: "", labels: [] }), "spf-watch/7-issue");
113
+ assert.equal(branchNameFor({ id: "PROJ-123", title: "Fix the thing", body: "", labels: [] }), "spf-watch/PROJ-123-fix-the-thing");
114
+ });
115
+ test("claimNewWork: clears a stale worktree/branch from a killed prior attempt before creating a fresh one", async () => {
116
+ const provider = new FakeProvider();
117
+ provider.addIssue("2", "Retry me");
118
+ const codeHost = new FakeCodeHost();
119
+ const state = createWatchState();
120
+ const calls = [];
121
+ // Simulates a `spf watch` process killed mid-run for this exact issue: a
122
+ // real `git.worktreeAdd` would refuse outright with "fatal: a branch
123
+ // named '...' already exists" until the leftover branch is cleared.
124
+ let branchExists = true;
125
+ const deps = makeDeps(provider, codeHost, {
126
+ git: fakeGit({
127
+ worktreeRemove: (p) => calls.push(`remove:${p}`),
128
+ deleteLocalBranch: (n) => {
129
+ calls.push(`delete-branch:${n}`);
130
+ branchExists = false;
131
+ },
132
+ worktreeAdd: (p, b) => {
133
+ if (branchExists)
134
+ throw new Error(`fatal: a branch named '${b}' already exists`);
135
+ calls.push(`add:${p}`);
136
+ },
137
+ }),
138
+ });
139
+ await claimNewWork(deps, state);
140
+ await waitUntil(() => state.inflight.size === 0);
141
+ const branchIdx = calls.findIndex((c) => c.startsWith("delete-branch:"));
142
+ const addIdx = calls.findIndex((c) => c.startsWith("add:"));
143
+ assert.ok(branchIdx !== -1 && addIdx !== -1 && branchIdx < addIdx, `expected the stale branch cleared before worktreeAdd, got: ${calls.join(", ")}`);
144
+ // The retry then succeeds normally, same as any other claim.
145
+ assert.deepEqual(provider.transitions.map((t) => t.to), ["review"]);
107
146
  });
108
147
  test("claimNewWork: claims a ready issue, runs the chain, opens a PR, and moves to review", async () => {
109
148
  const provider = new FakeProvider();
110
- provider.addIssue(1, "Add a /health endpoint");
149
+ provider.addIssue("1", "Add a /health endpoint");
150
+ const codeHost = new FakeCodeHost();
111
151
  const state = createWatchState();
112
- const deps = makeDeps(provider);
152
+ const linkedWorktrees = [];
153
+ const deps = makeDeps(provider, codeHost, { linkDataDir: (worktreePath) => linkedWorktrees.push(worktreePath) });
113
154
  await claimNewWork(deps, state);
114
155
  await waitUntil(() => state.inflight.size === 0);
115
- assert.deepEqual(provider.claimCalls, [1]);
116
- assert.equal(provider.openedPrs.length, 1);
117
- assert.equal(provider.openedPrs[0].issueNumber, 1);
156
+ assert.deepEqual(provider.claimCalls, ["1"]);
157
+ assert.equal(codeHost.openedPrs.length, 1);
158
+ assert.match(codeHost.openedPrs[0].title, /^Add a \/health endpoint \(1\)/);
118
159
  assert.deepEqual(provider.transitions.map((t) => t.to), ["review"]);
119
- assert.equal(provider.entries.get(1).marker?.pr, 1000);
160
+ assert.equal(provider.entries.get("1").marker?.pr, 1000);
161
+ // linkDataDir must run before the chain does — otherwise the run's own
162
+ // session/trace data resolves into the worktree's throwaway .spf/data
163
+ // instead of the main repo's persistent one (invisible in spf ui, and
164
+ // deleted along with the worktree on cleanup).
165
+ assert.deepEqual(linkedWorktrees, [path.join(deps.worktreesDir, "issue-1")]);
120
166
  });
121
167
  test("claimNewWork: a rejected chain run blocks the issue with the failure detail", async () => {
122
168
  const provider = new FakeProvider();
123
- provider.addIssue(2, "Flaky feature");
169
+ provider.addIssue("2", "Flaky feature");
170
+ const codeHost = new FakeCodeHost();
124
171
  const state = createWatchState();
125
- const deps = makeDeps(provider, {
172
+ const deps = makeDeps(provider, codeHost, {
126
173
  runChain: async () => ({ accepted: false, adwId: "issue-2", detail: "build-test failed at phase build" }),
127
174
  });
128
175
  await claimNewWork(deps, state);
129
176
  await waitUntil(() => state.inflight.size === 0);
130
- assert.deepEqual(provider.transitions, [{ number: 2, to: "blocked", detail: "build-test failed at phase build" }]);
131
- assert.equal(provider.openedPrs.length, 0);
177
+ assert.deepEqual(provider.transitions, [{ id: "2", to: "blocked", detail: "build-test failed at phase build" }]);
178
+ assert.equal(codeHost.openedPrs.length, 0);
132
179
  });
133
180
  test("claimNewWork: an accepted run with nothing committed also blocks, without opening a PR", async () => {
134
181
  const provider = new FakeProvider();
135
- provider.addIssue(3, "No-op request");
182
+ provider.addIssue("3", "No-op request");
183
+ const codeHost = new FakeCodeHost();
136
184
  const state = createWatchState();
137
- const deps = makeDeps(provider, { worktreeGit: () => fakeGit({ diffFiles: () => [] }) });
185
+ const deps = makeDeps(provider, codeHost, { worktreeGit: () => fakeGit({ diffFiles: () => [] }) });
138
186
  await claimNewWork(deps, state);
139
187
  await waitUntil(() => state.inflight.size === 0);
140
188
  assert.equal(provider.transitions[0]?.to, "blocked");
141
189
  assert.match(provider.transitions[0]?.detail ?? "", /no committed changes/);
142
- assert.equal(provider.openedPrs.length, 0);
190
+ assert.equal(codeHost.openedPrs.length, 0);
143
191
  });
144
192
  test("claimNewWork: never claims more than the concurrency budget in one tick", async () => {
145
193
  const provider = new FakeProvider();
146
- provider.addIssue(10, "one");
147
- provider.addIssue(11, "two");
148
- provider.addIssue(12, "three");
194
+ provider.addIssue("10", "one");
195
+ provider.addIssue("11", "two");
196
+ provider.addIssue("12", "three");
197
+ const codeHost = new FakeCodeHost();
149
198
  const state = createWatchState();
150
199
  // A runChain that never resolves keeps every claimed issue "inflight" for this assertion.
151
- const deps = makeDeps(provider, { concurrency: 2, runChain: () => new Promise(() => { }) });
200
+ const deps = makeDeps(provider, codeHost, { concurrency: 2, runChain: () => new Promise(() => { }) });
152
201
  await claimNewWork(deps, state);
153
202
  assert.equal(state.inflight.size, 2);
154
203
  assert.equal(provider.claimCalls.length, 2);
155
204
  });
156
205
  test("claimNewWork: dry-run claims nothing and calls neither claim() nor runChain", async () => {
157
206
  const provider = new FakeProvider();
158
- provider.addIssue(20, "dry run me");
207
+ provider.addIssue("20", "dry run me");
208
+ const codeHost = new FakeCodeHost();
159
209
  const state = createWatchState();
160
210
  let runChainCalled = false;
161
- const deps = makeDeps(provider, {
211
+ const deps = makeDeps(provider, codeHost, {
162
212
  dryRun: true,
163
213
  runChain: async () => {
164
214
  runChainCalled = true;
@@ -168,60 +218,66 @@ test("claimNewWork: dry-run claims nothing and calls neither claim() nor runChai
168
218
  await claimNewWork(deps, state);
169
219
  assert.equal(provider.claimCalls.length, 0);
170
220
  assert.equal(runChainCalled, false);
171
- assert.equal(provider.entries.get(20).state, "ready");
221
+ assert.equal(provider.entries.get("20").state, "ready");
172
222
  });
173
223
  test("reconcileOrphans: a working issue with an open PR in its marker resumes as review", async () => {
174
224
  const provider = new FakeProvider();
175
- provider.addIssue(30, "orphaned mid-review", "working", { pr: 500, branch: "spf-watch/30-x" });
176
- provider.prs.set(500, { merged: false, state: "open", ciStatus: "pending" });
225
+ provider.addIssue("30", "orphaned mid-review", "working", { pr: 500, branch: "spf-watch/30-x" });
226
+ const codeHost = new FakeCodeHost();
227
+ codeHost.prs.set(500, { merged: false, state: "open", ciStatus: "pending" });
177
228
  const state = createWatchState();
178
- await reconcileOrphans(makeDeps(provider), state);
179
- assert.deepEqual(provider.transitions, [{ number: 30, to: "review", detail: undefined }]);
229
+ await reconcileOrphans(makeDeps(provider, codeHost), state);
230
+ assert.deepEqual(provider.transitions, [{ id: "30", to: "review", detail: undefined }]);
180
231
  });
181
232
  test("reconcileOrphans: a working issue with no marker retries up to the cap, then blocks", async () => {
182
233
  const provider = new FakeProvider();
183
- provider.addIssue(31, "orphaned, no marker", "working", null);
234
+ provider.addIssue("31", "orphaned, no marker", "working", null);
235
+ const codeHost = new FakeCodeHost();
184
236
  const state = createWatchState();
185
- const deps = makeDeps(provider);
237
+ const deps = makeDeps(provider, codeHost);
186
238
  await reconcileOrphans(deps, state); // attempt 1 -> ready
187
- assert.equal(provider.entries.get(31).state, "ready");
188
- assert.equal(provider.entries.get(31).marker?.attempt, 1);
189
- provider.entries.get(31).state = "working"; // simulate it getting re-claimed and orphaned again
239
+ assert.equal(provider.entries.get("31").state, "ready");
240
+ assert.equal(provider.entries.get("31").marker?.attempt, 1);
241
+ provider.entries.get("31").state = "working"; // simulate it getting re-claimed and orphaned again
190
242
  await reconcileOrphans(deps, state); // attempt 2 -> ready
191
- assert.equal(provider.entries.get(31).state, "ready");
192
- provider.entries.get(31).state = "working";
243
+ assert.equal(provider.entries.get("31").state, "ready");
244
+ provider.entries.get("31").state = "working";
193
245
  await reconcileOrphans(deps, state); // attempt 3 exceeds MAX_ORPHAN_ATTEMPTS (2) -> blocked
194
- assert.equal(provider.entries.get(31).state, "blocked");
246
+ assert.equal(provider.entries.get("31").state, "blocked");
195
247
  assert.match(provider.transitions.at(-1)?.detail ?? "", /Gave up after 2 orphaned attempts/);
196
248
  });
197
249
  test("reconcileOrphans: skips issues this process is already tracking as in-flight", async () => {
198
250
  const provider = new FakeProvider();
199
- provider.addIssue(32, "actually still running", "working", null);
251
+ provider.addIssue("32", "actually still running", "working", null);
252
+ const codeHost = new FakeCodeHost();
200
253
  const state = createWatchState();
201
- state.inflight.add(32);
202
- await reconcileOrphans(makeDeps(provider), state);
254
+ state.inflight.add("32");
255
+ await reconcileOrphans(makeDeps(provider, codeHost), state);
203
256
  assert.equal(provider.transitions.length, 0, "an in-flight issue must not be treated as orphaned");
204
257
  });
205
258
  test("finishReviews: a merged PR moves the issue to done", async () => {
206
259
  const provider = new FakeProvider();
207
- provider.addIssue(40, "shipped", "review", { pr: 600, branch: "spf-watch/40-x" });
208
- provider.prs.set(600, { merged: true, state: "closed", ciStatus: "success" });
209
- await finishReviews(makeDeps(provider));
210
- assert.deepEqual(provider.transitions, [{ number: 40, to: "done", detail: undefined }]);
260
+ provider.addIssue("40", "shipped", "review", { pr: 600, branch: "spf-watch/40-x" });
261
+ const codeHost = new FakeCodeHost();
262
+ codeHost.prs.set(600, { merged: true, state: "closed", ciStatus: "success" });
263
+ await finishReviews(makeDeps(provider, codeHost));
264
+ assert.deepEqual(provider.transitions, [{ id: "40", to: "done", detail: undefined }]);
211
265
  });
212
266
  test("finishReviews: a closed-without-merging PR blocks the issue", async () => {
213
267
  const provider = new FakeProvider();
214
- provider.addIssue(41, "rejected", "review", { pr: 601, branch: "spf-watch/41-x" });
215
- provider.prs.set(601, { merged: false, state: "closed", ciStatus: "failure" });
216
- await finishReviews(makeDeps(provider));
268
+ provider.addIssue("41", "rejected", "review", { pr: 601, branch: "spf-watch/41-x" });
269
+ const codeHost = new FakeCodeHost();
270
+ codeHost.prs.set(601, { merged: false, state: "closed", ciStatus: "failure" });
271
+ await finishReviews(makeDeps(provider, codeHost));
217
272
  assert.equal(provider.transitions[0]?.to, "blocked");
218
273
  assert.match(provider.transitions[0]?.detail ?? "", /closed without merging/);
219
274
  });
220
275
  test("finishReviews: a still-open PR leaves the issue in review", async () => {
221
276
  const provider = new FakeProvider();
222
- provider.addIssue(42, "still cooking", "review", { pr: 602, branch: "spf-watch/42-x" });
223
- provider.prs.set(602, { merged: false, state: "open", ciStatus: "pending" });
224
- await finishReviews(makeDeps(provider));
277
+ provider.addIssue("42", "still cooking", "review", { pr: 602, branch: "spf-watch/42-x" });
278
+ const codeHost = new FakeCodeHost();
279
+ codeHost.prs.set(602, { merged: false, state: "open", ciStatus: "pending" });
280
+ await finishReviews(makeDeps(provider, codeHost));
225
281
  assert.equal(provider.transitions.length, 0);
226
- assert.equal(provider.entries.get(42).state, "review");
282
+ assert.equal(provider.entries.get("42").state, "review");
227
283
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gr8ful/spf",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Super Portable Factory — a global CLI for repeatable agents-plus-code workflows (ADWs)",
5
5
  "type": "module",
6
6
  "license": "MIT",