gsd-pi 2.45.0-dev.6b9da3e → 2.45.0-dev.fdcf73c
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.
- package/dist/help-text.js +1 -1
- package/dist/loader.js +34 -0
- package/dist/resources/extensions/gsd/auto/phases.js +16 -10
- package/dist/resources/extensions/gsd/auto/run-unit.js +6 -3
- package/dist/resources/extensions/gsd/auto-worktree.js +5 -4
- package/dist/resources/extensions/gsd/auto.js +4 -5
- package/dist/resources/extensions/gsd/bootstrap/db-tools.js +15 -12
- package/dist/resources/extensions/gsd/db-writer.js +9 -9
- package/dist/resources/extensions/gsd/doctor-checks.js +1 -1
- package/dist/resources/extensions/gsd/doctor.js +2 -2
- package/dist/resources/extensions/gsd/gsd-db.js +5 -1
- package/dist/resources/extensions/gsd/preferences-types.js +2 -2
- package/dist/resources/extensions/gsd/preferences.js +8 -4
- package/dist/resources/extensions/gsd/prompts/complete-milestone.md +20 -7
- package/dist/resources/extensions/gsd/tools/complete-milestone.js +4 -0
- package/dist/resources/extensions/gsd/workflow-logger.js +138 -0
- package/dist/resources/extensions/gsd/worktree-manager.js +4 -3
- package/dist/resources/extensions/gsd/worktree-resolver.js +37 -0
- package/dist/resources/extensions/voice/index.js +11 -16
- package/dist/resources/extensions/voice/linux-ready.js +67 -0
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +17 -17
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +17 -17
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +2 -2
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/package.json +2 -1
- package/packages/pi-coding-agent/dist/core/compaction-orchestrator.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/compaction-orchestrator.js +2 -0
- package/packages/pi-coding-agent/dist/core/compaction-orchestrator.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +2 -1
- package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/lifecycle-hooks.d.ts +4 -0
- package/packages/pi-coding-agent/dist/core/lifecycle-hooks.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/lifecycle-hooks.js +10 -5
- package/packages/pi-coding-agent/dist/core/lifecycle-hooks.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/lifecycle-hooks.test.d.ts +2 -0
- package/packages/pi-coding-agent/dist/core/lifecycle-hooks.test.d.ts.map +1 -0
- package/packages/pi-coding-agent/dist/core/lifecycle-hooks.test.js +185 -0
- package/packages/pi-coding-agent/dist/core/lifecycle-hooks.test.js.map +1 -0
- package/packages/pi-coding-agent/dist/core/model-registry-auth-mode.test.js +239 -10
- package/packages/pi-coding-agent/dist/core/model-registry-auth-mode.test.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/model-registry.d.ts +2 -1
- package/packages/pi-coding-agent/dist/core/model-registry.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/model-registry.js +20 -2
- package/packages/pi-coding-agent/dist/core/model-registry.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/package-commands.test.js +206 -195
- package/packages/pi-coding-agent/dist/core/package-commands.test.js.map +1 -1
- package/packages/pi-coding-agent/src/core/compaction-orchestrator.ts +2 -0
- package/packages/pi-coding-agent/src/core/extensions/types.ts +2 -1
- package/packages/pi-coding-agent/src/core/lifecycle-hooks.test.ts +227 -0
- package/packages/pi-coding-agent/src/core/lifecycle-hooks.ts +11 -5
- package/packages/pi-coding-agent/src/core/model-registry-auth-mode.test.ts +297 -11
- package/packages/pi-coding-agent/src/core/model-registry.ts +30 -3
- package/packages/pi-coding-agent/src/core/package-commands.test.ts +227 -205
- package/src/resources/extensions/gsd/auto/phases.ts +16 -12
- package/src/resources/extensions/gsd/auto/run-unit.ts +6 -3
- package/src/resources/extensions/gsd/auto-worktree.ts +8 -5
- package/src/resources/extensions/gsd/auto.ts +3 -3
- package/src/resources/extensions/gsd/bootstrap/db-tools.ts +15 -12
- package/src/resources/extensions/gsd/db-writer.ts +9 -17
- package/src/resources/extensions/gsd/doctor-checks.ts +1 -1
- package/src/resources/extensions/gsd/doctor.ts +2 -2
- package/src/resources/extensions/gsd/gsd-db.ts +5 -1
- package/src/resources/extensions/gsd/journal.ts +6 -1
- package/src/resources/extensions/gsd/preferences-types.ts +2 -2
- package/src/resources/extensions/gsd/preferences.ts +7 -3
- package/src/resources/extensions/gsd/prompts/complete-milestone.md +20 -7
- package/src/resources/extensions/gsd/tests/complete-milestone.test.ts +96 -0
- package/src/resources/extensions/gsd/tests/merge-conflict-stops-loop.test.ts +1 -1
- package/src/resources/extensions/gsd/tests/none-mode-gates.test.ts +42 -3
- package/src/resources/extensions/gsd/tests/preferences.test.ts +7 -9
- package/src/resources/extensions/gsd/tests/workflow-logger.test.ts +275 -0
- package/src/resources/extensions/gsd/tests/worktree-journal-events.test.ts +220 -0
- package/src/resources/extensions/gsd/tools/complete-milestone.ts +6 -0
- package/src/resources/extensions/gsd/workflow-logger.ts +193 -0
- package/src/resources/extensions/gsd/worktree-manager.ts +4 -9
- package/src/resources/extensions/gsd/worktree-resolver.ts +37 -0
- package/src/resources/extensions/voice/index.ts +11 -21
- package/src/resources/extensions/voice/linux-ready.ts +87 -0
- package/src/resources/extensions/voice/tests/linux-ready.test.ts +124 -0
- /package/dist/web/standalone/.next/static/{rzO54ZboyINyEt7cVM_uS → zWYDSwB-terOjfhmWzqk1}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{rzO54ZboyINyEt7cVM_uS → zWYDSwB-terOjfhmWzqk1}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// GSD Extension — Workflow Logger Tests
|
|
2
|
+
// Tests for the centralized warning/error accumulator.
|
|
3
|
+
|
|
4
|
+
import { describe, test, beforeEach } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import {
|
|
7
|
+
logWarning,
|
|
8
|
+
logError,
|
|
9
|
+
drainLogs,
|
|
10
|
+
drainAndSummarize,
|
|
11
|
+
peekLogs,
|
|
12
|
+
hasErrors,
|
|
13
|
+
hasWarnings,
|
|
14
|
+
hasAnyIssues,
|
|
15
|
+
summarizeLogs,
|
|
16
|
+
formatForNotification,
|
|
17
|
+
_resetLogs,
|
|
18
|
+
} from "../workflow-logger.ts";
|
|
19
|
+
|
|
20
|
+
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
21
|
+
|
|
22
|
+
describe("workflow-logger", () => {
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
_resetLogs();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("accumulation", () => {
|
|
28
|
+
test("logWarning adds an entry with severity warn", () => {
|
|
29
|
+
logWarning("engine", "test warning");
|
|
30
|
+
const entries = peekLogs();
|
|
31
|
+
assert.equal(entries.length, 1);
|
|
32
|
+
assert.equal(entries[0].severity, "warn");
|
|
33
|
+
assert.equal(entries[0].component, "engine");
|
|
34
|
+
assert.equal(entries[0].message, "test warning");
|
|
35
|
+
assert.match(entries[0].ts, ISO_RE);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("logError adds an entry with severity error", () => {
|
|
39
|
+
logError("intercept", "blocked write", { path: "/foo/STATE.md" });
|
|
40
|
+
const entries = peekLogs();
|
|
41
|
+
assert.equal(entries.length, 1);
|
|
42
|
+
assert.equal(entries[0].severity, "error");
|
|
43
|
+
assert.equal(entries[0].component, "intercept");
|
|
44
|
+
assert.deepEqual(entries[0].context, { path: "/foo/STATE.md" });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("accumulates multiple entries in order", () => {
|
|
48
|
+
logWarning("projection", "render failed");
|
|
49
|
+
logError("intercept", "blocked write");
|
|
50
|
+
logWarning("manifest", "write failed");
|
|
51
|
+
assert.equal(peekLogs().length, 3);
|
|
52
|
+
assert.equal(peekLogs()[0].component, "projection");
|
|
53
|
+
assert.equal(peekLogs()[1].component, "intercept");
|
|
54
|
+
assert.equal(peekLogs()[2].component, "manifest");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("omits context field when not provided", () => {
|
|
58
|
+
logWarning("engine", "no context");
|
|
59
|
+
assert.equal("context" in peekLogs()[0], false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("omits context field when undefined is passed", () => {
|
|
63
|
+
logWarning("engine", "no context", undefined);
|
|
64
|
+
assert.equal("context" in peekLogs()[0], false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("context with special characters is stored as-is", () => {
|
|
68
|
+
logError("tool", "failed", { path: '/foo/"quoted".md', msg: "line1\nline2" });
|
|
69
|
+
assert.deepEqual(peekLogs()[0].context, {
|
|
70
|
+
path: '/foo/"quoted".md',
|
|
71
|
+
msg: "line1\nline2",
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("ts field is a valid ISO 8601 timestamp", () => {
|
|
76
|
+
logWarning("engine", "ts check");
|
|
77
|
+
assert.match(peekLogs()[0].ts, ISO_RE);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("drain", () => {
|
|
82
|
+
test("returns all entries and clears buffer", () => {
|
|
83
|
+
logWarning("engine", "w1");
|
|
84
|
+
logError("engine", "e1");
|
|
85
|
+
const drained = drainLogs();
|
|
86
|
+
assert.equal(drained.length, 2);
|
|
87
|
+
assert.equal(peekLogs().length, 0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("returns empty array when no entries", () => {
|
|
91
|
+
assert.deepEqual(drainLogs(), []);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("second drain returns empty array", () => {
|
|
95
|
+
logWarning("engine", "w1");
|
|
96
|
+
drainLogs();
|
|
97
|
+
assert.deepEqual(drainLogs(), []);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("drainAndSummarize", () => {
|
|
102
|
+
test("returns summary and clears buffer atomically", () => {
|
|
103
|
+
logError("intercept", "blocked");
|
|
104
|
+
logWarning("projection", "render failed");
|
|
105
|
+
const { logs, summary } = drainAndSummarize();
|
|
106
|
+
assert.equal(logs.length, 2);
|
|
107
|
+
assert.equal(peekLogs().length, 0);
|
|
108
|
+
assert.ok(summary?.includes("1 error(s)"));
|
|
109
|
+
assert.ok(summary?.includes("1 warning(s)"));
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("returns null summary when buffer is empty", () => {
|
|
113
|
+
const { logs, summary } = drainAndSummarize();
|
|
114
|
+
assert.deepEqual(logs, []);
|
|
115
|
+
assert.equal(summary, null);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("hasErrors / hasWarnings / hasAnyIssues", () => {
|
|
120
|
+
test("hasErrors returns false when only warnings", () => {
|
|
121
|
+
logWarning("engine", "just a warning");
|
|
122
|
+
assert.equal(hasErrors(), false);
|
|
123
|
+
assert.equal(hasWarnings(), true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("hasErrors returns true when errors present", () => {
|
|
127
|
+
logWarning("engine", "warning");
|
|
128
|
+
logError("intercept", "error");
|
|
129
|
+
assert.equal(hasErrors(), true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("hasWarnings returns false when buffer empty", () => {
|
|
133
|
+
assert.equal(hasWarnings(), false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("hasWarnings returns false when buffer contains only errors", () => {
|
|
137
|
+
logError("intercept", "only an error");
|
|
138
|
+
assert.equal(hasWarnings(), false);
|
|
139
|
+
assert.equal(hasErrors(), true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("hasAnyIssues returns true for warnings only", () => {
|
|
143
|
+
logWarning("engine", "warn");
|
|
144
|
+
assert.equal(hasAnyIssues(), true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("hasAnyIssues returns true for errors only", () => {
|
|
148
|
+
logError("engine", "err");
|
|
149
|
+
assert.equal(hasAnyIssues(), true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("hasAnyIssues returns false when buffer empty", () => {
|
|
153
|
+
assert.equal(hasAnyIssues(), false);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("summarizeLogs", () => {
|
|
158
|
+
test("returns null when empty", () => {
|
|
159
|
+
assert.equal(summarizeLogs(), null);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("summarizes errors and warnings separately", () => {
|
|
163
|
+
logError("intercept", "blocked STATE.md");
|
|
164
|
+
logWarning("projection", "render failed");
|
|
165
|
+
logWarning("manifest", "write failed");
|
|
166
|
+
const summary = summarizeLogs()!;
|
|
167
|
+
assert.ok(summary.includes("1 error(s)"));
|
|
168
|
+
assert.ok(summary.includes("blocked STATE.md"));
|
|
169
|
+
assert.ok(summary.includes("2 warning(s)"));
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("only shows errors section when no warnings", () => {
|
|
173
|
+
logError("intercept", "blocked");
|
|
174
|
+
const summary = summarizeLogs()!;
|
|
175
|
+
assert.ok(summary.includes("1 error(s)"));
|
|
176
|
+
assert.ok(!summary.includes("warning"));
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("only shows warnings section when no errors", () => {
|
|
180
|
+
logWarning("projection", "render degraded");
|
|
181
|
+
logWarning("manifest", "write slow");
|
|
182
|
+
const summary = summarizeLogs()!;
|
|
183
|
+
assert.ok(summary.includes("2 warning(s)"));
|
|
184
|
+
assert.ok(!summary.includes("error"));
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("does not clear buffer", () => {
|
|
188
|
+
logError("intercept", "blocked");
|
|
189
|
+
summarizeLogs();
|
|
190
|
+
assert.equal(peekLogs().length, 1);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("formatForNotification", () => {
|
|
195
|
+
test("returns empty string for empty array", () => {
|
|
196
|
+
assert.equal(formatForNotification([]), "");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("formats single entry without line breaks", () => {
|
|
200
|
+
logError("intercept", "blocked write");
|
|
201
|
+
const entries = drainLogs();
|
|
202
|
+
const formatted = formatForNotification(entries);
|
|
203
|
+
assert.equal(formatted, "[intercept] blocked write");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("formats multiple entries with line breaks", () => {
|
|
207
|
+
logWarning("projection", "render failed");
|
|
208
|
+
logError("intercept", "blocked write");
|
|
209
|
+
const entries = drainLogs();
|
|
210
|
+
const formatted = formatForNotification(entries);
|
|
211
|
+
assert.ok(formatted.includes("[projection] render failed"));
|
|
212
|
+
assert.ok(formatted.includes("[intercept] blocked write"));
|
|
213
|
+
assert.ok(formatted.includes("\n"));
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("does not include context in formatted output", () => {
|
|
217
|
+
logError("tool", "failed", { cmd: "complete_task" });
|
|
218
|
+
const entries = drainLogs();
|
|
219
|
+
const formatted = formatForNotification(entries);
|
|
220
|
+
assert.equal(formatted, "[tool] failed");
|
|
221
|
+
assert.ok(!formatted.includes("complete_task"));
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("buffer limit", () => {
|
|
226
|
+
test("caps at MAX_BUFFER entries, dropping oldest", () => {
|
|
227
|
+
const OVER = 110;
|
|
228
|
+
const MAX = 100;
|
|
229
|
+
for (let i = 0; i < OVER; i++) {
|
|
230
|
+
logWarning("engine", `msg-${i}`);
|
|
231
|
+
}
|
|
232
|
+
const entries = peekLogs();
|
|
233
|
+
assert.equal(entries.length, MAX);
|
|
234
|
+
// First MAX entries dropped; oldest surviving = msg-(OVER-MAX)
|
|
235
|
+
assert.equal(entries[0].message, `msg-${OVER - MAX}`);
|
|
236
|
+
assert.equal(entries[MAX - 1].message, `msg-${OVER - 1}`);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe("stderr output", () => {
|
|
241
|
+
test("writes WARN prefix to stderr for warnings", (t) => {
|
|
242
|
+
const written: string[] = [];
|
|
243
|
+
const orig = process.stderr.write.bind(process.stderr);
|
|
244
|
+
// @ts-ignore — patching for test
|
|
245
|
+
process.stderr.write = (chunk: string) => { written.push(chunk); return true; };
|
|
246
|
+
t.after(() => { process.stderr.write = orig; });
|
|
247
|
+
|
|
248
|
+
logWarning("engine", "test warn");
|
|
249
|
+
assert.equal(written.length, 1);
|
|
250
|
+
assert.ok(written[0].includes("[gsd:engine] WARN: test warn"));
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("writes ERROR prefix to stderr for errors", (t) => {
|
|
254
|
+
const written: string[] = [];
|
|
255
|
+
const orig = process.stderr.write.bind(process.stderr);
|
|
256
|
+
// @ts-ignore — patching for test
|
|
257
|
+
process.stderr.write = (chunk: string) => { written.push(chunk); return true; };
|
|
258
|
+
t.after(() => { process.stderr.write = orig; });
|
|
259
|
+
|
|
260
|
+
logError("intercept", "blocked");
|
|
261
|
+
assert.ok(written[0].includes("[gsd:intercept] ERROR: blocked"));
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("includes serialized context in stderr output", (t) => {
|
|
265
|
+
const written: string[] = [];
|
|
266
|
+
const orig = process.stderr.write.bind(process.stderr);
|
|
267
|
+
// @ts-ignore — patching for test
|
|
268
|
+
process.stderr.write = (chunk: string) => { written.push(chunk); return true; };
|
|
269
|
+
t.after(() => { process.stderr.write = orig; });
|
|
270
|
+
|
|
271
|
+
logError("tool", "failed", { cmd: "complete_task" });
|
|
272
|
+
assert.ok(written[0].includes('"cmd":"complete_task"'));
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { describe, test, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, rmSync, readFileSync, readdirSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import {
|
|
7
|
+
WorktreeResolver,
|
|
8
|
+
type WorktreeResolverDeps,
|
|
9
|
+
type NotifyCtx,
|
|
10
|
+
} from "../worktree-resolver.js";
|
|
11
|
+
import { AutoSession } from "../auto/session.js";
|
|
12
|
+
import type { JournalEntry } from "../journal.js";
|
|
13
|
+
|
|
14
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
function makeSession(
|
|
17
|
+
overrides?: Partial<{ basePath: string; originalBasePath: string }>,
|
|
18
|
+
): AutoSession {
|
|
19
|
+
const s = new AutoSession();
|
|
20
|
+
s.basePath = overrides?.basePath ?? "/project";
|
|
21
|
+
s.originalBasePath = overrides?.originalBasePath ?? "/project";
|
|
22
|
+
return s;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makeDeps(
|
|
26
|
+
overrides?: Partial<WorktreeResolverDeps>,
|
|
27
|
+
): WorktreeResolverDeps {
|
|
28
|
+
const deps: WorktreeResolverDeps = {
|
|
29
|
+
isInAutoWorktree: () => false,
|
|
30
|
+
shouldUseWorktreeIsolation: () => true,
|
|
31
|
+
getIsolationMode: () => "worktree",
|
|
32
|
+
mergeMilestoneToMain: () => ({ pushed: false, codeFilesChanged: true }),
|
|
33
|
+
syncWorktreeStateBack: () => ({ synced: [] }),
|
|
34
|
+
teardownAutoWorktree: () => {},
|
|
35
|
+
createAutoWorktree: (_basePath: string, milestoneId: string) =>
|
|
36
|
+
`/project/.gsd/worktrees/${milestoneId}`,
|
|
37
|
+
enterAutoWorktree: (_basePath: string, milestoneId: string) =>
|
|
38
|
+
`/project/.gsd/worktrees/${milestoneId}`,
|
|
39
|
+
getAutoWorktreePath: () => null,
|
|
40
|
+
autoCommitCurrentBranch: () => {},
|
|
41
|
+
getCurrentBranch: () => "main",
|
|
42
|
+
autoWorktreeBranch: (milestoneId: string) => `milestone/${milestoneId}`,
|
|
43
|
+
resolveMilestoneFile: (_basePath: string, milestoneId: string) =>
|
|
44
|
+
`/project/.gsd/milestones/${milestoneId}/${milestoneId}-ROADMAP.md`,
|
|
45
|
+
readFileSync: () => "# Roadmap\n- [x] S01: Slice one\n",
|
|
46
|
+
GitServiceImpl: class {
|
|
47
|
+
constructor() {}
|
|
48
|
+
} as unknown as WorktreeResolverDeps["GitServiceImpl"],
|
|
49
|
+
loadEffectiveGSDPreferences: () => ({ preferences: { git: {} } }),
|
|
50
|
+
invalidateAllCaches: () => {},
|
|
51
|
+
captureIntegrationBranch: () => {},
|
|
52
|
+
...overrides,
|
|
53
|
+
};
|
|
54
|
+
return deps;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function makeNotifyCtx(): NotifyCtx {
|
|
58
|
+
return {
|
|
59
|
+
notify: () => {},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Read all journal entries from a temp .gsd/journal directory. */
|
|
64
|
+
function readJournalEntries(basePath: string): JournalEntry[] {
|
|
65
|
+
const journalDir = join(basePath, ".gsd", "journal");
|
|
66
|
+
try {
|
|
67
|
+
const files = readdirSync(journalDir).filter(f => f.endsWith(".jsonl")).sort();
|
|
68
|
+
const entries: JournalEntry[] = [];
|
|
69
|
+
for (const file of files) {
|
|
70
|
+
const raw = readFileSync(join(journalDir, file), "utf-8");
|
|
71
|
+
for (const line of raw.split("\n")) {
|
|
72
|
+
if (!line.trim()) continue;
|
|
73
|
+
entries.push(JSON.parse(line) as JournalEntry);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return entries;
|
|
77
|
+
} catch {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── Tests ───────────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
describe("worktree journal events", () => {
|
|
85
|
+
let tmp: string;
|
|
86
|
+
const originalCwd = process.cwd();
|
|
87
|
+
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
tmp = mkdtempSync(join(tmpdir(), "wt-journal-"));
|
|
90
|
+
});
|
|
91
|
+
afterEach(() => {
|
|
92
|
+
// Restore cwd before cleanup — on Windows, rmSync fails with EPERM
|
|
93
|
+
// if the process cwd is inside the directory being deleted.
|
|
94
|
+
try { process.chdir(originalCwd); } catch { /* best-effort */ }
|
|
95
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("enterMilestone emits worktree-enter on success (new worktree)", () => {
|
|
99
|
+
const s = makeSession({ basePath: tmp, originalBasePath: tmp });
|
|
100
|
+
const deps = makeDeps({ getAutoWorktreePath: () => null });
|
|
101
|
+
const resolver = new WorktreeResolver(s, deps);
|
|
102
|
+
|
|
103
|
+
resolver.enterMilestone("M001", makeNotifyCtx());
|
|
104
|
+
|
|
105
|
+
const entries = readJournalEntries(tmp);
|
|
106
|
+
const enter = entries.find(e => e.eventType === "worktree-enter");
|
|
107
|
+
assert.ok(enter, "worktree-enter event should be emitted");
|
|
108
|
+
assert.equal(enter!.data?.milestoneId, "M001");
|
|
109
|
+
assert.equal(enter!.data?.created, true);
|
|
110
|
+
assert.ok(enter!.data?.wtPath);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("enterMilestone emits worktree-enter with created=false for existing worktree", () => {
|
|
114
|
+
const s = makeSession({ basePath: tmp, originalBasePath: tmp });
|
|
115
|
+
const deps = makeDeps({
|
|
116
|
+
getAutoWorktreePath: () => "/project/.gsd/worktrees/M001",
|
|
117
|
+
});
|
|
118
|
+
const resolver = new WorktreeResolver(s, deps);
|
|
119
|
+
|
|
120
|
+
resolver.enterMilestone("M001", makeNotifyCtx());
|
|
121
|
+
|
|
122
|
+
const entries = readJournalEntries(tmp);
|
|
123
|
+
const enter = entries.find(e => e.eventType === "worktree-enter");
|
|
124
|
+
assert.ok(enter, "worktree-enter event should be emitted");
|
|
125
|
+
assert.equal(enter!.data?.created, false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("enterMilestone emits worktree-skip when isolation disabled", () => {
|
|
129
|
+
const s = makeSession({ basePath: tmp, originalBasePath: tmp });
|
|
130
|
+
const deps = makeDeps({ shouldUseWorktreeIsolation: () => false });
|
|
131
|
+
const resolver = new WorktreeResolver(s, deps);
|
|
132
|
+
|
|
133
|
+
resolver.enterMilestone("M001", makeNotifyCtx());
|
|
134
|
+
|
|
135
|
+
const entries = readJournalEntries(tmp);
|
|
136
|
+
const skip = entries.find(e => e.eventType === "worktree-skip");
|
|
137
|
+
assert.ok(skip, "worktree-skip event should be emitted");
|
|
138
|
+
assert.equal(skip!.data?.milestoneId, "M001");
|
|
139
|
+
assert.equal(skip!.data?.reason, "isolation-disabled");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("enterMilestone emits worktree-create-failed on error", () => {
|
|
143
|
+
const s = makeSession({ basePath: tmp, originalBasePath: tmp });
|
|
144
|
+
const deps = makeDeps({
|
|
145
|
+
getAutoWorktreePath: () => null,
|
|
146
|
+
createAutoWorktree: () => { throw new Error("disk full"); },
|
|
147
|
+
});
|
|
148
|
+
const resolver = new WorktreeResolver(s, deps);
|
|
149
|
+
|
|
150
|
+
resolver.enterMilestone("M001", makeNotifyCtx());
|
|
151
|
+
|
|
152
|
+
const entries = readJournalEntries(tmp);
|
|
153
|
+
const failed = entries.find(e => e.eventType === "worktree-create-failed");
|
|
154
|
+
assert.ok(failed, "worktree-create-failed event should be emitted");
|
|
155
|
+
assert.equal(failed!.data?.milestoneId, "M001");
|
|
156
|
+
assert.equal(failed!.data?.error, "disk full");
|
|
157
|
+
assert.equal(failed!.data?.fallback, "project-root");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("mergeAndExit emits worktree-merge-start", () => {
|
|
161
|
+
const s = makeSession({
|
|
162
|
+
basePath: join(tmp, "worktree"),
|
|
163
|
+
originalBasePath: tmp,
|
|
164
|
+
});
|
|
165
|
+
const deps = makeDeps({
|
|
166
|
+
isInAutoWorktree: () => true,
|
|
167
|
+
getIsolationMode: () => "worktree",
|
|
168
|
+
});
|
|
169
|
+
const resolver = new WorktreeResolver(s, deps);
|
|
170
|
+
|
|
171
|
+
resolver.mergeAndExit("M001", makeNotifyCtx());
|
|
172
|
+
|
|
173
|
+
const entries = readJournalEntries(tmp);
|
|
174
|
+
const start = entries.find(e => e.eventType === "worktree-merge-start");
|
|
175
|
+
assert.ok(start, "worktree-merge-start event should be emitted");
|
|
176
|
+
assert.equal(start!.data?.milestoneId, "M001");
|
|
177
|
+
assert.equal(start!.data?.mode, "worktree");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("mergeAndExit emits worktree-merge-failed on error", () => {
|
|
181
|
+
const s = makeSession({
|
|
182
|
+
basePath: join(tmp, "worktree"),
|
|
183
|
+
originalBasePath: tmp,
|
|
184
|
+
});
|
|
185
|
+
const deps = makeDeps({
|
|
186
|
+
isInAutoWorktree: () => true,
|
|
187
|
+
getIsolationMode: () => "worktree",
|
|
188
|
+
mergeMilestoneToMain: () => { throw new Error("conflict in main"); },
|
|
189
|
+
});
|
|
190
|
+
const resolver = new WorktreeResolver(s, deps);
|
|
191
|
+
|
|
192
|
+
resolver.mergeAndExit("M001", makeNotifyCtx());
|
|
193
|
+
|
|
194
|
+
const entries = readJournalEntries(tmp);
|
|
195
|
+
const failed = entries.find(e => e.eventType === "worktree-merge-failed");
|
|
196
|
+
assert.ok(failed, "worktree-merge-failed event should be emitted");
|
|
197
|
+
assert.equal(failed!.data?.milestoneId, "M001");
|
|
198
|
+
assert.equal(failed!.data?.error, "conflict in main");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("journal entries have valid flowId, seq, and ts fields", () => {
|
|
202
|
+
const s = makeSession({ basePath: tmp, originalBasePath: tmp });
|
|
203
|
+
const deps = makeDeps({ shouldUseWorktreeIsolation: () => false });
|
|
204
|
+
const resolver = new WorktreeResolver(s, deps);
|
|
205
|
+
|
|
206
|
+
resolver.enterMilestone("M001", makeNotifyCtx());
|
|
207
|
+
|
|
208
|
+
const entries = readJournalEntries(tmp);
|
|
209
|
+
assert.ok(entries.length > 0, "at least one entry should exist");
|
|
210
|
+
const entry = entries[0];
|
|
211
|
+
assert.ok(entry.flowId, "flowId should be set");
|
|
212
|
+
assert.ok(
|
|
213
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(entry.flowId),
|
|
214
|
+
"flowId should be a valid UUID",
|
|
215
|
+
);
|
|
216
|
+
assert.equal(entry.seq, 0);
|
|
217
|
+
assert.ok(entry.ts, "ts should be set");
|
|
218
|
+
assert.ok(!isNaN(Date.parse(entry.ts)), "ts should be a valid ISO date");
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -31,6 +31,7 @@ export interface CompleteMilestoneParams {
|
|
|
31
31
|
lessonsLearned: string[];
|
|
32
32
|
followUps: string;
|
|
33
33
|
deviations: string;
|
|
34
|
+
verificationPassed: boolean;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
export interface CompleteMilestoneResult {
|
|
@@ -108,6 +109,11 @@ export async function handleCompleteMilestone(
|
|
|
108
109
|
return { error: "title is required and must be a non-empty string" };
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
// ── Verify that verification passed ─────────────────────────────────────
|
|
113
|
+
if (params.verificationPassed !== true) {
|
|
114
|
+
return { error: "verification did not pass — milestone completion blocked. verificationPassed must be explicitly set to true after all verification steps succeed" };
|
|
115
|
+
}
|
|
116
|
+
|
|
111
117
|
// ── Verify all slices are complete ───────────────────────────────────────
|
|
112
118
|
const slices = getMilestoneSlices(params.milestoneId);
|
|
113
119
|
if (slices.length === 0) {
|