@opengsd/gsd-pi 1.3.0-dev.965d1788 → 1.3.0-dev.cca9c184
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/cli.js +17 -4
- package/dist/headless-answers.js +1 -1
- package/dist/headless-ui.d.ts +1 -1
- package/dist/headless-ui.js +1 -1
- package/dist/headless.js +1 -1
- package/dist/resources/.managed-resources-content-hash +1 -1
- package/dist/resources/extensions/claude-code-cli/models.js +9 -0
- package/dist/resources/extensions/gsd/auto/session.js +8 -0
- package/dist/resources/extensions/gsd/auto-budget.js +5 -1
- package/dist/resources/extensions/gsd/auto-dispatch.js +14 -1
- package/dist/resources/extensions/gsd/auto-prompts.js +3 -3
- package/dist/resources/extensions/gsd/auto-recovery.js +3 -3
- package/dist/resources/extensions/gsd/auto.js +19 -10
- package/dist/resources/extensions/gsd/bootstrap/core-session-tools.js +1 -0
- package/dist/resources/extensions/gsd/bootstrap/db-tools.js +5 -4
- package/dist/resources/extensions/gsd/bootstrap/register-extension.js +2 -0
- package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +25 -2
- package/dist/resources/extensions/gsd/bootstrap/tool-call-loop-guard.js +32 -4
- package/dist/resources/extensions/gsd/bootstrap/write-gate.js +15 -4
- package/dist/resources/extensions/gsd/commands/handlers/auto.js +1 -1
- package/dist/resources/extensions/gsd/commands-prefs-wizard.js +1 -1
- package/dist/resources/extensions/gsd/commands-usage.js +1 -1
- package/dist/resources/extensions/gsd/db/engine.js +4 -1
- package/dist/resources/extensions/gsd/db-memory-fts-schema.js +34 -0
- package/dist/resources/extensions/gsd/delegation-policy.js +1 -1
- package/dist/resources/extensions/gsd/docs/preferences-reference.md +2 -2
- package/dist/resources/extensions/gsd/doctor-engine-checks.js +192 -4
- package/dist/resources/extensions/gsd/exec-sandbox.js +8 -2
- package/dist/resources/extensions/gsd/git-service.js +56 -0
- package/dist/resources/extensions/gsd/gitignore.js +1 -1
- package/dist/resources/extensions/gsd/migrate-external.js +11 -2
- package/dist/resources/extensions/gsd/prompts/plan-milestone.md +2 -2
- package/dist/resources/extensions/gsd/prompts/plan-slice.md +3 -3
- package/dist/resources/extensions/gsd/root-write-leak-guard.js +7 -1
- package/dist/resources/extensions/gsd/safety/destructive-guard.js +8 -1
- package/dist/resources/extensions/gsd/safety/evidence-collector.js +16 -1
- package/dist/resources/extensions/gsd/tools/plan-slice.js +59 -44
- package/dist/resources/extensions/gsd/tools/plan-task.js +28 -5
- package/dist/resources/extensions/gsd/unit-registry.js +4 -2
- package/dist/resources/extensions/gsd/workflow-manifest.js +122 -12
- package/dist/tsconfig.extensions.tsbuildinfo +1 -1
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +11 -11
- 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 +1 -1
- 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 +11 -11
- package/dist/web/standalone/.next/server/chunks/8357.js +1 -1
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +1 -1
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/package.json +1 -1
- package/packages/cloud-mcp-gateway/package.json +2 -2
- package/packages/contracts/package.json +1 -1
- package/packages/daemon/package.json +4 -4
- package/packages/gsd-agent-core/package.json +5 -5
- package/packages/gsd-agent-modes/dist/modes/rpc/rpc-client.d.ts +2 -0
- package/packages/gsd-agent-modes/dist/modes/rpc/rpc-client.d.ts.map +1 -1
- package/packages/gsd-agent-modes/dist/modes/rpc/rpc-client.js +23 -5
- package/packages/gsd-agent-modes/dist/modes/rpc/rpc-client.js.map +1 -1
- package/packages/gsd-agent-modes/package.json +7 -7
- package/packages/mcp-server/dist/workflow-tools.js +1 -1
- package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
- package/packages/mcp-server/package.json +4 -4
- package/packages/native/package.json +1 -1
- package/packages/pi-agent-core/dist/agent-loop.js +3 -2
- package/packages/pi-agent-core/dist/agent-loop.js.map +1 -1
- package/packages/pi-agent-core/package.json +1 -1
- package/packages/pi-ai/dist/models.generated.d.ts +46 -0
- package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
- package/packages/pi-ai/dist/models.generated.js +38 -0
- package/packages/pi-ai/dist/models.generated.js.map +1 -1
- package/packages/pi-ai/package.json +1 -1
- package/packages/pi-coding-agent/package.json +7 -7
- package/packages/pi-tui/package.json +2 -2
- package/packages/rpc-client/package.json +2 -2
- package/pkg/package.json +1 -1
- package/src/resources/extensions/claude-code-cli/models.ts +9 -0
- package/src/resources/extensions/claude-code-cli/tests/index.test.ts +14 -0
- package/src/resources/extensions/gsd/auto/loop-deps.ts +1 -0
- package/src/resources/extensions/gsd/auto/session.ts +8 -0
- package/src/resources/extensions/gsd/auto-budget.ts +12 -1
- package/src/resources/extensions/gsd/auto-dispatch.ts +14 -1
- package/src/resources/extensions/gsd/auto-prompts.ts +3 -3
- package/src/resources/extensions/gsd/auto-recovery.ts +3 -3
- package/src/resources/extensions/gsd/auto.ts +23 -8
- package/src/resources/extensions/gsd/bootstrap/core-session-tools.ts +1 -0
- package/src/resources/extensions/gsd/bootstrap/db-tools.ts +5 -4
- package/src/resources/extensions/gsd/bootstrap/register-extension.ts +2 -0
- package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +32 -2
- package/src/resources/extensions/gsd/bootstrap/tests/write-gate-basepath.test.ts +49 -1
- package/src/resources/extensions/gsd/bootstrap/tool-call-loop-guard.ts +34 -4
- package/src/resources/extensions/gsd/bootstrap/write-gate.ts +16 -4
- package/src/resources/extensions/gsd/commands/handlers/auto.ts +1 -1
- package/src/resources/extensions/gsd/commands-prefs-wizard.ts +1 -1
- package/src/resources/extensions/gsd/commands-usage.ts +1 -1
- package/src/resources/extensions/gsd/db/engine.ts +8 -1
- package/src/resources/extensions/gsd/db-memory-fts-schema.ts +45 -0
- package/src/resources/extensions/gsd/delegation-policy.ts +1 -1
- package/src/resources/extensions/gsd/docs/preferences-reference.md +2 -2
- package/src/resources/extensions/gsd/doctor-engine-checks.ts +233 -5
- package/src/resources/extensions/gsd/doctor-types.ts +3 -0
- package/src/resources/extensions/gsd/exec-sandbox.ts +7 -2
- package/src/resources/extensions/gsd/git-service.ts +63 -0
- package/src/resources/extensions/gsd/gitignore.ts +1 -1
- package/src/resources/extensions/gsd/migrate-external.ts +8 -2
- package/src/resources/extensions/gsd/prompts/plan-milestone.md +2 -2
- package/src/resources/extensions/gsd/prompts/plan-slice.md +3 -3
- package/src/resources/extensions/gsd/root-write-leak-guard.ts +13 -1
- package/src/resources/extensions/gsd/safety/destructive-guard.ts +9 -1
- package/src/resources/extensions/gsd/safety/evidence-collector.ts +16 -1
- package/src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +14 -5
- package/src/resources/extensions/gsd/tests/auto-pause-double-entry-guard.test.ts +36 -0
- package/src/resources/extensions/gsd/tests/auto-paused-ui-cleanup.test.ts +182 -4
- package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +35 -0
- package/src/resources/extensions/gsd/tests/auto-retry-mcp-churn-fixes.test.ts +30 -0
- package/src/resources/extensions/gsd/tests/db-memory-fts-schema.test.ts +58 -3
- package/src/resources/extensions/gsd/tests/destructive-guard.test.ts +44 -0
- package/src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts +143 -0
- package/src/resources/extensions/gsd/tests/doctor-scope-db-unavailable.test.ts +239 -2
- package/src/resources/extensions/gsd/tests/exec-sandbox.test.ts +16 -0
- package/src/resources/extensions/gsd/tests/memory-store.test.ts +48 -0
- package/src/resources/extensions/gsd/tests/migrate-external-worktree.test.ts +57 -0
- package/src/resources/extensions/gsd/tests/parsers-legacy-importers.test.ts +2 -0
- package/src/resources/extensions/gsd/tests/plan-slice.test.ts +103 -4
- package/src/resources/extensions/gsd/tests/plan-task.test.ts +44 -1
- package/src/resources/extensions/gsd/tests/post-mutation-hook.test.ts +4 -2
- package/src/resources/extensions/gsd/tests/prompt-budget-enforcement.test.ts +58 -0
- package/src/resources/extensions/gsd/tests/prompt-contracts.test.ts +8 -6
- package/src/resources/extensions/gsd/tests/register-hooks-loop-guard-auto.test.ts +103 -0
- package/src/resources/extensions/gsd/tests/root-write-leak-guard.test.ts +20 -1
- package/src/resources/extensions/gsd/tests/runtime-invariant-modules.test.ts +8 -3
- package/src/resources/extensions/gsd/tests/tool-call-loop-guard.test.ts +62 -5
- package/src/resources/extensions/gsd/tests/tool-param-optionality.test.ts +6 -3
- package/src/resources/extensions/gsd/tests/uok-gitops-turn-action.test.ts +38 -0
- package/src/resources/extensions/gsd/tests/workflow-manifest.test.ts +44 -17
- package/src/resources/extensions/gsd/tests/workflow-mcp-preflight.test.ts +1 -0
- package/src/resources/extensions/gsd/tests/workflow-mcp.test.ts +8 -4
- package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +6 -5
- package/src/resources/extensions/gsd/tools/plan-slice.ts +60 -47
- package/src/resources/extensions/gsd/tools/plan-task.ts +30 -5
- package/src/resources/extensions/gsd/unit-registry.ts +4 -2
- package/src/resources/extensions/gsd/workflow-manifest.ts +137 -6
- /package/dist/web/standalone/.next/static/{-ji77QJck2IKIwrGza1Bs → Eud_8lddsyi_13HIlB2i7}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{-ji77QJck2IKIwrGza1Bs → Eud_8lddsyi_13HIlB2i7}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import { classifyCommand } from "../safety/destructive-guard.ts";
|
|
5
|
+
|
|
6
|
+
test("destructive guard ignores rm -rf text inside quoted literals and comments", () => {
|
|
7
|
+
const readOnlyCommands = [
|
|
8
|
+
'grep -rn "rm -rf" .',
|
|
9
|
+
'echo "rm -rf /tmp/x"',
|
|
10
|
+
'git commit -m "remove stray rm -rf usage"',
|
|
11
|
+
"cat notes.md # remove stray rm -rf usage",
|
|
12
|
+
'printf "rm -rf /tmp/x\\n"',
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
for (const command of readOnlyCommands) {
|
|
16
|
+
assert.deepEqual(classifyCommand(command), { destructive: false, labels: [] }, command);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("destructive guard still detects direct and wrapper-prefixed recursive deletes", () => {
|
|
21
|
+
const destructiveCommands = [
|
|
22
|
+
"rm -rf build",
|
|
23
|
+
"rm build -rf",
|
|
24
|
+
"sudo rm -rf /",
|
|
25
|
+
"doas rm -rf /",
|
|
26
|
+
"sudo -u bob rm -rf /x",
|
|
27
|
+
"timeout 5 rm -rf /x",
|
|
28
|
+
"time rm -rf build",
|
|
29
|
+
"find . -exec rm -rf {} \\;",
|
|
30
|
+
"cat doomed.txt | xargs rm -rf /tmp/x",
|
|
31
|
+
'rm -rf "/tmp/my build"',
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
for (const command of destructiveCommands) {
|
|
35
|
+
assert.deepEqual(classifyCommand(command), {
|
|
36
|
+
destructive: true,
|
|
37
|
+
labels: ["recursive delete"],
|
|
38
|
+
}, command);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("destructive guard treats quoted interpreter payloads as literals", () => {
|
|
43
|
+
assert.deepEqual(classifyCommand('bash -c "rm -rf /"'), { destructive: false, labels: [] });
|
|
44
|
+
});
|
|
@@ -96,6 +96,40 @@ function scaffoldMilestoneContext(basePath: string, mid: string): void {
|
|
|
96
96
|
].join("\n"));
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
function scaffoldLegacyMilestoneContext(basePath: string, mid: string): void {
|
|
100
|
+
const dir = join(basePath, ".gsd", "milestones", mid);
|
|
101
|
+
mkdirSync(dir, { recursive: true });
|
|
102
|
+
writeFileSync(join(dir, `${mid}-CONTEXT.md`), [
|
|
103
|
+
`# ${mid}: Test Milestone`,
|
|
104
|
+
"",
|
|
105
|
+
"Context for legacy dispatch recovery tests.",
|
|
106
|
+
"",
|
|
107
|
+
].join("\n"));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function scaffoldLegacySlicePlan(basePath: string, mid: string, sid: string): void {
|
|
111
|
+
const dir = join(basePath, ".gsd", "milestones", mid, "slices", sid);
|
|
112
|
+
mkdirSync(join(dir, "tasks"), { recursive: true });
|
|
113
|
+
writeFileSync(join(dir, `${sid}-PLAN.md`), [
|
|
114
|
+
`# ${sid}: Legacy Slice`,
|
|
115
|
+
"",
|
|
116
|
+
"## Tasks",
|
|
117
|
+
"- T01: Do something",
|
|
118
|
+
"",
|
|
119
|
+
].join("\n"));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function scaffoldLegacyTaskPlan(basePath: string, mid: string, sid: string, tid: string): void {
|
|
123
|
+
const dir = join(basePath, ".gsd", "milestones", mid, "slices", sid, "tasks");
|
|
124
|
+
mkdirSync(dir, { recursive: true });
|
|
125
|
+
writeFileSync(join(dir, `${tid}-PLAN.md`), [
|
|
126
|
+
`# ${tid}: First Task`,
|
|
127
|
+
"",
|
|
128
|
+
"Implement the task.",
|
|
129
|
+
"",
|
|
130
|
+
].join("\n"));
|
|
131
|
+
}
|
|
132
|
+
|
|
99
133
|
function scaffoldTaskPlan(basePath: string, mid: string, sid: string, tid: string): void {
|
|
100
134
|
// Flat-phase: no per-task plan files. This is a no-op — tasks live as
|
|
101
135
|
// checkboxes inside the slice plan. Kept for backward-compat with tests
|
|
@@ -167,6 +201,115 @@ test("dispatch: present task plan proceeds to execute-task normally", async (t)
|
|
|
167
201
|
`unitId should be M002/S03/T01, got: ${result.action === "dispatch" ? result.unitId : "(stop)"}`);
|
|
168
202
|
});
|
|
169
203
|
|
|
204
|
+
test("dispatch: missing legacy task plan recovery increments a per-slice retry counter", async (t) => {
|
|
205
|
+
const tmp = mkdtempSync(join(tmpdir(), "gsd-1087-retry-"));
|
|
206
|
+
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
|
207
|
+
|
|
208
|
+
scaffoldLegacyMilestoneContext(tmp, "M002");
|
|
209
|
+
scaffoldLegacySlicePlan(tmp, "M002", "S03");
|
|
210
|
+
|
|
211
|
+
const session = {
|
|
212
|
+
missingTaskPlanRetryCount: new Map<string, number>(),
|
|
213
|
+
};
|
|
214
|
+
const result = await resolveDispatch(makeContextFor(tmp, "M002", "S03", "T01", session));
|
|
215
|
+
|
|
216
|
+
assert.equal(result.action, "dispatch");
|
|
217
|
+
assert.ok(result.action === "dispatch" && result.unitType === "plan-slice",
|
|
218
|
+
`unitType should be plan-slice, got: ${result.action === "dispatch" ? result.unitType : "(stop)"}`);
|
|
219
|
+
assert.ok(result.action === "dispatch" && result.unitId === "M002/S03",
|
|
220
|
+
`unitId should be M002/S03, got: ${result.action === "dispatch" ? result.unitId : "(stop)"}`);
|
|
221
|
+
assert.equal(session.missingTaskPlanRetryCount.get("M002/S03"), 1);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("dispatch: missing legacy task plan recovery stops when retry counter is exhausted", async (t) => {
|
|
225
|
+
const tmp = mkdtempSync(join(tmpdir(), "gsd-1087-stop-"));
|
|
226
|
+
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
|
227
|
+
|
|
228
|
+
scaffoldLegacyMilestoneContext(tmp, "M002");
|
|
229
|
+
scaffoldLegacySlicePlan(tmp, "M002", "S03");
|
|
230
|
+
|
|
231
|
+
const session = {
|
|
232
|
+
missingTaskPlanRetryCount: new Map<string, number>([["M002/S03", 2]]),
|
|
233
|
+
};
|
|
234
|
+
const result = await resolveDispatch(makeContextFor(tmp, "M002", "S03", "T01", session));
|
|
235
|
+
|
|
236
|
+
assert.equal(result.action, "stop");
|
|
237
|
+
assert.ok(result.action === "stop");
|
|
238
|
+
assert.equal(result.level, "error");
|
|
239
|
+
assert.match(result.reason, /Missing task-plan recovery failed 2 times for M002\/S03/);
|
|
240
|
+
assert.match(result.reason, /manual intervention required/);
|
|
241
|
+
assert.equal(session.missingTaskPlanRetryCount.has("M002/S03"), false);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("dispatch: present legacy task plan clears missing-plan recovery retry counter", async (t) => {
|
|
245
|
+
const tmp = mkdtempSync(join(tmpdir(), "gsd-1087-clear-"));
|
|
246
|
+
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
|
247
|
+
|
|
248
|
+
scaffoldLegacyMilestoneContext(tmp, "M002");
|
|
249
|
+
scaffoldLegacySlicePlan(tmp, "M002", "S03");
|
|
250
|
+
scaffoldLegacyTaskPlan(tmp, "M002", "S03", "T01");
|
|
251
|
+
|
|
252
|
+
const session = {
|
|
253
|
+
missingTaskPlanRetryCount: new Map<string, number>([["M002/S03", 1]]),
|
|
254
|
+
preExecRetryCount: new Map<string, number>([["M002/S03", 2]]),
|
|
255
|
+
};
|
|
256
|
+
const result = await resolveDispatch(makeContextFor(tmp, "M002", "S03", "T01", session));
|
|
257
|
+
|
|
258
|
+
assert.equal(result.action, "dispatch");
|
|
259
|
+
assert.ok(result.action === "dispatch" && result.unitType === "execute-task",
|
|
260
|
+
`unitType should be execute-task, got: ${result.action === "dispatch" ? result.unitType : "(stop)"}`);
|
|
261
|
+
assert.equal(session.missingTaskPlanRetryCount.has("M002/S03"), false);
|
|
262
|
+
assert.equal(session.preExecRetryCount.get("M002/S03"), 2,
|
|
263
|
+
"pre-exec retry counter must not be cleared when task plan is present");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("dispatch: missing-task-plan recovery loop terminates even when the shared pre-exec key is reset between rounds (#1087)", async (t) => {
|
|
267
|
+
// The recovery rule re-dispatches plan-slice with unitId "${mid}/${sid}".
|
|
268
|
+
// That regenerated plan-slice carries the same currentUnit.id, and on a
|
|
269
|
+
// pre-execution pass the post-unit hook deletes that key from
|
|
270
|
+
// preExecRetryCount (auto-post-unit.ts). Missing per-task PLAN projection
|
|
271
|
+
// files do not fail pre-exec checks, so the regenerated plan-slice typically
|
|
272
|
+
// passes and that delete fires every cycle. If recovery shared
|
|
273
|
+
// preExecRetryCount, its counter would be wiped to 0 each round and the loop
|
|
274
|
+
// would never reach the cap. Here we simulate that reset between rounds and
|
|
275
|
+
// assert the dedicated counter still climbs to MAX and stops.
|
|
276
|
+
const tmp = mkdtempSync(join(tmpdir(), "gsd-1087-loop-term-"));
|
|
277
|
+
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
|
278
|
+
|
|
279
|
+
scaffoldLegacyMilestoneContext(tmp, "M002");
|
|
280
|
+
scaffoldLegacySlicePlan(tmp, "M002", "S03");
|
|
281
|
+
|
|
282
|
+
const session = {
|
|
283
|
+
missingTaskPlanRetryCount: new Map<string, number>(),
|
|
284
|
+
preExecRetryCount: new Map<string, number>(),
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
// Round 1: missing task plan → recover (counter 0 → 1).
|
|
288
|
+
const r1 = await resolveDispatch(makeContextFor(tmp, "M002", "S03", "T01", session));
|
|
289
|
+
assert.ok(r1.action === "dispatch" && r1.unitType === "plan-slice",
|
|
290
|
+
`round 1 should re-dispatch plan-slice, got: ${r1.action === "dispatch" ? r1.unitType : "(stop)"}`);
|
|
291
|
+
assert.equal(session.missingTaskPlanRetryCount.get("M002/S03"), 1);
|
|
292
|
+
|
|
293
|
+
// The regenerated plan-slice passes its post-unit pre-exec check, deleting
|
|
294
|
+
// the shared "${mid}/${sid}" key from preExecRetryCount.
|
|
295
|
+
session.preExecRetryCount.delete("M002/S03");
|
|
296
|
+
|
|
297
|
+
// Round 2: still missing → recover (counter 1 → 2), unaffected by the reset.
|
|
298
|
+
const r2 = await resolveDispatch(makeContextFor(tmp, "M002", "S03", "T01", session));
|
|
299
|
+
assert.ok(r2.action === "dispatch" && r2.unitType === "plan-slice",
|
|
300
|
+
`round 2 should re-dispatch plan-slice, got: ${r2.action === "dispatch" ? r2.unitType : "(stop)"}`);
|
|
301
|
+
assert.equal(session.missingTaskPlanRetryCount.get("M002/S03"), 2);
|
|
302
|
+
|
|
303
|
+
session.preExecRetryCount.delete("M002/S03");
|
|
304
|
+
|
|
305
|
+
// Round 3: cap reached → stop. The loop terminates despite the resets.
|
|
306
|
+
const r3 = await resolveDispatch(makeContextFor(tmp, "M002", "S03", "T01", session));
|
|
307
|
+
assert.equal(r3.action, "stop");
|
|
308
|
+
assert.ok(r3.action === "stop" && r3.level === "error");
|
|
309
|
+
assert.match(r3.reason, /Missing task-plan recovery failed 2 times for M002\/S03/);
|
|
310
|
+
assert.equal(session.missingTaskPlanRetryCount.has("M002/S03"), false);
|
|
311
|
+
});
|
|
312
|
+
|
|
170
313
|
test("dispatch: session milestone mismatch stops before missing-task-plan recovery", async (t) => {
|
|
171
314
|
const tmp = mkdtempSync(join(tmpdir(), "gsd-session-milestone-mismatch-"));
|
|
172
315
|
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
import { afterEach, test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
_getAdapter,
|
|
5
|
+
closeDatabase,
|
|
6
|
+
insertArtifact,
|
|
7
|
+
insertMilestone,
|
|
8
|
+
insertSlice,
|
|
9
|
+
insertTask,
|
|
10
|
+
openDatabase,
|
|
11
|
+
} from "../gsd-db.ts";
|
|
12
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
13
|
import { join } from "node:path";
|
|
6
14
|
import { tmpdir } from "node:os";
|
|
7
15
|
import { filterDoctorIssues } from "../doctor-format.ts";
|
|
8
16
|
import { checkEngineHealth } from "../doctor-engine-checks.ts";
|
|
17
|
+
import { MEMORIES_FTS_REBUILT_KEY } from "../db-memory-fts-schema.ts";
|
|
9
18
|
import { appendEvent } from "../workflow-events.ts";
|
|
19
|
+
import { renderPlanFromDb, renderRoadmapFromDb } from "../markdown-renderer.ts";
|
|
10
20
|
|
|
11
21
|
afterEach(() => {
|
|
12
22
|
closeDatabase();
|
|
@@ -61,6 +71,139 @@ test("checkEngineHealth reports db_unavailable when gsd.db exists but the DB is
|
|
|
61
71
|
assert.equal(dbIssue.file, ".gsd/gsd.db");
|
|
62
72
|
});
|
|
63
73
|
|
|
74
|
+
test("checkEngineHealth reports memories_fts without the rebuild marker", async (t) => {
|
|
75
|
+
const base = mkdtempSync(join(tmpdir(), "gsd-doctor-memory-fts-marker-"));
|
|
76
|
+
t.after(() => rmSync(base, { recursive: true, force: true }));
|
|
77
|
+
|
|
78
|
+
const gsdDir = join(base, ".gsd");
|
|
79
|
+
mkdirSync(gsdDir, { recursive: true });
|
|
80
|
+
|
|
81
|
+
openDatabase(join(gsdDir, "gsd.db"));
|
|
82
|
+
const adapter = _getAdapter()!;
|
|
83
|
+
const fts = adapter.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='memories_fts'").get();
|
|
84
|
+
if (!fts) return;
|
|
85
|
+
|
|
86
|
+
adapter.prepare(
|
|
87
|
+
"DELETE FROM runtime_kv WHERE scope = 'global' AND scope_id = '' AND key = :key",
|
|
88
|
+
).run({ ":key": MEMORIES_FTS_REBUILT_KEY });
|
|
89
|
+
|
|
90
|
+
const issues: any[] = [];
|
|
91
|
+
await checkEngineHealth(base, issues, []);
|
|
92
|
+
|
|
93
|
+
const ftsIssue = issues.find((issue) => issue.code === "memories_fts_rebuild_missing");
|
|
94
|
+
assert.ok(ftsIssue, "doctor should surface a potentially desynced memory FTS index");
|
|
95
|
+
assert.equal(ftsIssue.severity, "warning");
|
|
96
|
+
assert.equal(ftsIssue.unitId, "project");
|
|
97
|
+
assert.equal(ftsIssue.file, ".gsd/gsd.db");
|
|
98
|
+
assert.equal(ftsIssue.fixable, false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("checkEngineHealth reports checkbox divergence against DB status", async (t) => {
|
|
102
|
+
const base = mkdtempSync(join(tmpdir(), "gsd-doctor-checkbox-drift-"));
|
|
103
|
+
t.after(() => rmSync(base, { recursive: true, force: true }));
|
|
104
|
+
|
|
105
|
+
const gsdDir = join(base, ".gsd");
|
|
106
|
+
mkdirSync(gsdDir, { recursive: true });
|
|
107
|
+
|
|
108
|
+
openDatabase(join(gsdDir, "gsd.db"));
|
|
109
|
+
insertMilestone({ id: "M001", title: "Foundation", status: "active" });
|
|
110
|
+
insertSlice({ id: "S01", milestoneId: "M001", title: "Slice", status: "pending", risk: "low", depends: [], sequence: 1 });
|
|
111
|
+
insertTask({ id: "T01", milestoneId: "M001", sliceId: "S01", title: "Task", status: "complete", sequence: 1 });
|
|
112
|
+
|
|
113
|
+
const roadmap = await renderRoadmapFromDb(base, "M001");
|
|
114
|
+
if ("skipped" in roadmap) assert.fail("planned milestone should render a roadmap");
|
|
115
|
+
const plan = await renderPlanFromDb(base, "M001", "S01");
|
|
116
|
+
|
|
117
|
+
writeFileSync(roadmap.roadmapPath, readFileSync(roadmap.roadmapPath, "utf-8").replace("- [ ] **S01:", "- [x] **S01:"), "utf-8");
|
|
118
|
+
writeFileSync(plan.planPath, readFileSync(plan.planPath, "utf-8").replace("- [x] **T01**:", "- [ ] **T01**:"), "utf-8");
|
|
119
|
+
|
|
120
|
+
const issues: any[] = [];
|
|
121
|
+
await checkEngineHealth(base, issues, []);
|
|
122
|
+
|
|
123
|
+
const divergences = issues.filter((issue) => issue.code === "checkbox_db_status_divergence");
|
|
124
|
+
assert.deepEqual(
|
|
125
|
+
divergences.map((issue) => issue.unitId).sort(),
|
|
126
|
+
["M001/S01", "M001/S01/T01"],
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("checkEngineHealth keeps PLAN checkbox divergence after stale projection flush", async (t) => {
|
|
131
|
+
const base = mkdtempSync(join(tmpdir(), "gsd-doctor-checkbox-plan-drift-"));
|
|
132
|
+
t.after(() => rmSync(base, { recursive: true, force: true }));
|
|
133
|
+
|
|
134
|
+
const gsdDir = join(base, ".gsd");
|
|
135
|
+
mkdirSync(gsdDir, { recursive: true });
|
|
136
|
+
|
|
137
|
+
openDatabase(join(gsdDir, "gsd.db"));
|
|
138
|
+
insertMilestone({ id: "M001", title: "Foundation", status: "active" });
|
|
139
|
+
insertSlice({ id: "S01", milestoneId: "M001", title: "Slice", status: "pending", risk: "low", depends: [], sequence: 1 });
|
|
140
|
+
insertTask({ id: "T01", milestoneId: "M001", sliceId: "S01", title: "Task", status: "complete", sequence: 1 });
|
|
141
|
+
|
|
142
|
+
const roadmap = await renderRoadmapFromDb(base, "M001");
|
|
143
|
+
if ("skipped" in roadmap) assert.fail("planned milestone should render a roadmap");
|
|
144
|
+
const plan = await renderPlanFromDb(base, "M001", "S01");
|
|
145
|
+
|
|
146
|
+
writeFileSync(roadmap.roadmapPath, readFileSync(roadmap.roadmapPath, "utf-8").replace("- [ ] **S01:", "- [x] **S01:"), "utf-8");
|
|
147
|
+
writeFileSync(plan.planPath, readFileSync(plan.planPath, "utf-8").replace("- [x] **T01**:", "- [ ] **T01**:"), "utf-8");
|
|
148
|
+
appendEvent(base, {
|
|
149
|
+
cmd: "complete-task",
|
|
150
|
+
params: { milestoneId: "M001", sliceId: "S01", taskId: "T01" },
|
|
151
|
+
ts: "2999-01-01T00:00:00.000Z",
|
|
152
|
+
actor: "agent",
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const issues: any[] = [];
|
|
156
|
+
const fixes: string[] = [];
|
|
157
|
+
await checkEngineHealth(base, issues, fixes);
|
|
158
|
+
|
|
159
|
+
const divergences = issues.filter((issue) => issue.code === "checkbox_db_status_divergence");
|
|
160
|
+
assert.deepEqual(
|
|
161
|
+
divergences.map((issue) => issue.unitId),
|
|
162
|
+
["M001/S01/T01"],
|
|
163
|
+
"stale ROADMAP divergence is cleared after re-render, but stale PLAN task divergence remains",
|
|
164
|
+
);
|
|
165
|
+
assert.ok(fixes.includes("re-rendered stale projections for M001"));
|
|
166
|
+
assert.match(readFileSync(plan.planPath, "utf-8"), /- \[ \] \*\*T01\*\*:/);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("checkEngineHealth reads task checkboxes from the <tasks> block, not a stray line above it", async (t) => {
|
|
170
|
+
const base = mkdtempSync(join(tmpdir(), "gsd-doctor-task-section-"));
|
|
171
|
+
t.after(() => rmSync(base, { recursive: true, force: true }));
|
|
172
|
+
|
|
173
|
+
const gsdDir = join(base, ".gsd");
|
|
174
|
+
mkdirSync(gsdDir, { recursive: true });
|
|
175
|
+
|
|
176
|
+
openDatabase(join(gsdDir, "gsd.db"));
|
|
177
|
+
insertMilestone({ id: "M001", title: "Foundation", status: "active" });
|
|
178
|
+
insertSlice({ id: "S01", milestoneId: "M001", title: "Slice", status: "pending", risk: "low", depends: [], sequence: 1 });
|
|
179
|
+
insertTask({ id: "T01", milestoneId: "M001", sliceId: "S01", title: "Task", status: "complete", sequence: 1 });
|
|
180
|
+
|
|
181
|
+
const roadmap = await renderRoadmapFromDb(base, "M001");
|
|
182
|
+
if ("skipped" in roadmap) assert.fail("planned milestone should render a roadmap");
|
|
183
|
+
const plan = await renderPlanFromDb(base, "M001", "S01");
|
|
184
|
+
|
|
185
|
+
// Flip the authoritative <tasks> checkbox so it disagrees with the DB
|
|
186
|
+
// (DB: complete, markdown: unchecked = real drift), then inject a *checked*
|
|
187
|
+
// decoy line for the same task above the <tasks> block. The old whole-file
|
|
188
|
+
// regex matched the decoy first and hid the real drift; parsePlan reads the
|
|
189
|
+
// authoritative <tasks> block and still reports it. No events are appended,
|
|
190
|
+
// so the projection-drift re-render never runs and the crafted file stands.
|
|
191
|
+
const rendered = readFileSync(plan.planPath, "utf-8")
|
|
192
|
+
.replace("- [x] **T01**:", "- [ ] **T01**:")
|
|
193
|
+
.replace("<tasks>\n", "- [x] **T01**: stale duplicate above the tasks block\n\n<tasks>\n");
|
|
194
|
+
writeFileSync(plan.planPath, rendered, "utf-8");
|
|
195
|
+
|
|
196
|
+
const issues: any[] = [];
|
|
197
|
+
await checkEngineHealth(base, issues, []);
|
|
198
|
+
|
|
199
|
+
const divergences = issues.filter((issue) => issue.code === "checkbox_db_status_divergence");
|
|
200
|
+
assert.deepEqual(
|
|
201
|
+
divergences.map((issue) => issue.unitId),
|
|
202
|
+
["M001/S01/T01"],
|
|
203
|
+
"task drift must be read from the authoritative <tasks> block, not a stray checkbox above it",
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
64
207
|
test("checkEngineHealth reads canonical reopen events from worktree bases", async (t) => {
|
|
65
208
|
const base = mkdtempSync(join(tmpdir(), "gsd-doctor-reopen-worktree-"));
|
|
66
209
|
t.after(() => rmSync(base, { recursive: true, force: true }));
|
|
@@ -149,3 +292,97 @@ test("checkEngineHealth treats explicit reopen as authoritative when dispatch ti
|
|
|
149
292
|
"explicit reopen should exempt reopened milestone even when completion dispatch timestamps are absent",
|
|
150
293
|
);
|
|
151
294
|
});
|
|
295
|
+
|
|
296
|
+
test("checkEngineHealth reports artifact rows whose files are missing on disk", async (t) => {
|
|
297
|
+
const base = mkdtempSync(join(tmpdir(), "gsd-doctor-missing-artifact-"));
|
|
298
|
+
t.after(() => rmSync(base, { recursive: true, force: true }));
|
|
299
|
+
|
|
300
|
+
const gsdDir = join(base, ".gsd");
|
|
301
|
+
const worktree = join(gsdDir, "worktrees", "M001");
|
|
302
|
+
mkdirSync(join(worktree, ".gsd", "milestones", "M001"), { recursive: true });
|
|
303
|
+
writeFileSync(join(worktree, ".git"), "gitdir: ../../../../.git/worktrees/M001\n", "utf-8");
|
|
304
|
+
writeFileSync(join(worktree, ".gsd", "milestones", "M001", "M001-CONTEXT.md"), "# Context\n", "utf-8");
|
|
305
|
+
|
|
306
|
+
openDatabase(join(gsdDir, "gsd.db"));
|
|
307
|
+
insertArtifact({
|
|
308
|
+
path: "milestones/M001/M001-CONTEXT.md",
|
|
309
|
+
artifact_type: "CONTEXT",
|
|
310
|
+
milestone_id: "M001",
|
|
311
|
+
slice_id: null,
|
|
312
|
+
task_id: null,
|
|
313
|
+
full_content: "# Context\n",
|
|
314
|
+
});
|
|
315
|
+
insertArtifact({
|
|
316
|
+
path: "milestones/M001/M001-MISSING.md",
|
|
317
|
+
artifact_type: "CONTEXT",
|
|
318
|
+
milestone_id: "M001",
|
|
319
|
+
slice_id: null,
|
|
320
|
+
task_id: null,
|
|
321
|
+
full_content: "# Missing\n",
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const issues: any[] = [];
|
|
325
|
+
await checkEngineHealth(worktree, issues, []);
|
|
326
|
+
|
|
327
|
+
assert.equal(
|
|
328
|
+
issues.some((issue) => issue.code === "artifact_file_missing" && issue.file === "milestones/M001/M001-CONTEXT.md"),
|
|
329
|
+
false,
|
|
330
|
+
"worktree-local artifacts should resolve through the projection root",
|
|
331
|
+
);
|
|
332
|
+
const missing = issues.find((issue) => issue.code === "artifact_file_missing" && issue.file === "milestones/M001/M001-MISSING.md");
|
|
333
|
+
assert.ok(missing, "missing artifact rows should be reported");
|
|
334
|
+
assert.equal(missing.unitId, "M001");
|
|
335
|
+
assert.equal(missing.fixable, false);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("checkEngineHealth clears artifact_file_missing after projection re-render recreates the file", async (t) => {
|
|
339
|
+
const base = mkdtempSync(join(tmpdir(), "gsd-doctor-stale-missing-artifact-"));
|
|
340
|
+
t.after(() => rmSync(base, { recursive: true, force: true }));
|
|
341
|
+
|
|
342
|
+
const gsdDir = join(base, ".gsd");
|
|
343
|
+
mkdirSync(gsdDir, { recursive: true });
|
|
344
|
+
|
|
345
|
+
openDatabase(join(gsdDir, "gsd.db"));
|
|
346
|
+
insertMilestone({ id: "M001", title: "Foundation", status: "active", planning: { vision: "Ship the foundation." } });
|
|
347
|
+
insertSlice({ id: "S01", milestoneId: "M001", title: "Slice", status: "pending", risk: "low", depends: [], sequence: 1 });
|
|
348
|
+
insertArtifact({
|
|
349
|
+
path: "phases/01-foundation/01-ROADMAP.md",
|
|
350
|
+
artifact_type: "ROADMAP",
|
|
351
|
+
milestone_id: "M001",
|
|
352
|
+
slice_id: null,
|
|
353
|
+
task_id: null,
|
|
354
|
+
full_content: "# stale row only\n",
|
|
355
|
+
});
|
|
356
|
+
// CONTEXT is not re-rendered by flushWorkflowProjections, so its missing-file
|
|
357
|
+
// diagnostic must survive the post-re-render cleanup (guards against the
|
|
358
|
+
// clearing logic over-broadening to artifacts the same run did not recreate).
|
|
359
|
+
insertArtifact({
|
|
360
|
+
path: "phases/01-foundation/01-CONTEXT.md",
|
|
361
|
+
artifact_type: "CONTEXT",
|
|
362
|
+
milestone_id: "M001",
|
|
363
|
+
slice_id: null,
|
|
364
|
+
task_id: null,
|
|
365
|
+
full_content: "# stale context row only\n",
|
|
366
|
+
});
|
|
367
|
+
appendEvent(base, {
|
|
368
|
+
cmd: "plan-milestone",
|
|
369
|
+
params: { milestoneId: "M001" },
|
|
370
|
+
ts: "2999-01-01T00:00:00.000Z",
|
|
371
|
+
actor: "agent",
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
const issues: any[] = [];
|
|
375
|
+
const fixes: string[] = [];
|
|
376
|
+
await checkEngineHealth(base, issues, fixes);
|
|
377
|
+
|
|
378
|
+
assert.ok(fixes.includes("re-rendered missing projections for M001"));
|
|
379
|
+
assert.equal(
|
|
380
|
+
issues.some((issue) => issue.code === "artifact_file_missing" && issue.file === "phases/01-foundation/01-ROADMAP.md"),
|
|
381
|
+
false,
|
|
382
|
+
"doctor should not report a missing artifact that projection repair recreated in the same run",
|
|
383
|
+
);
|
|
384
|
+
assert.ok(
|
|
385
|
+
issues.some((issue) => issue.code === "artifact_file_missing" && issue.file === "phases/01-foundation/01-CONTEXT.md"),
|
|
386
|
+
"doctor should still report a missing artifact that projection repair did not recreate",
|
|
387
|
+
);
|
|
388
|
+
});
|
|
@@ -7,6 +7,7 @@ import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:f
|
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
|
|
10
|
+
import { getShellConfig } from '@gsd/pi-coding-agent';
|
|
10
11
|
import { EXEC_DEFAULTS, runExecSandbox, type ExecSandboxOptions } from '../exec-sandbox.ts';
|
|
11
12
|
import { buildExecOptions, executeGsdExec } from '../tools/exec-tool.ts';
|
|
12
13
|
import { isContextModeEnabled } from '../preferences-types.ts';
|
|
@@ -53,6 +54,21 @@ test('runExecSandbox: captures stdout, persists artifacts, returns digest', asyn
|
|
|
53
54
|
}
|
|
54
55
|
});
|
|
55
56
|
|
|
57
|
+
test('runExecSandbox: bash runtime uses resolved shell config', async () => {
|
|
58
|
+
const base = freshBase();
|
|
59
|
+
try {
|
|
60
|
+
const { shell } = getShellConfig();
|
|
61
|
+
const result = await runExecSandbox(
|
|
62
|
+
{ runtime: 'bash', script: 'printf "%s" "$0"' },
|
|
63
|
+
baseOpts(base),
|
|
64
|
+
);
|
|
65
|
+
assert.equal(result.exit_code, 0);
|
|
66
|
+
assert.equal(readFileSync(result.stdout_path, 'utf-8'), shell);
|
|
67
|
+
} finally {
|
|
68
|
+
cleanup(base);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
56
72
|
test('runExecSandbox: persists optional request metadata', async () => {
|
|
57
73
|
const base = freshBase();
|
|
58
74
|
try {
|
|
@@ -5,6 +5,9 @@ import {
|
|
|
5
5
|
SCHEMA_VERSION,
|
|
6
6
|
_getAdapter,
|
|
7
7
|
} from '../gsd-db.ts';
|
|
8
|
+
import * as fs from 'node:fs';
|
|
9
|
+
import * as path from 'node:path';
|
|
10
|
+
import * as os from 'node:os';
|
|
8
11
|
import {
|
|
9
12
|
_resetLogs,
|
|
10
13
|
peekLogs,
|
|
@@ -22,6 +25,7 @@ import {
|
|
|
22
25
|
markUnitProcessed,
|
|
23
26
|
decayStaleMemories,
|
|
24
27
|
enforceMemoryCap,
|
|
28
|
+
queryMemoriesRanked,
|
|
25
29
|
applyMemoryActions,
|
|
26
30
|
formatMemoriesForPrompt,
|
|
27
31
|
} from '../memory-store.ts';
|
|
@@ -29,6 +33,16 @@ import type { MemoryAction } from '../memory-store.ts';
|
|
|
29
33
|
import { describe, test, beforeEach, afterEach } from 'node:test';
|
|
30
34
|
import assert from 'node:assert/strict';
|
|
31
35
|
|
|
36
|
+
function tempDbPath(): string {
|
|
37
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-memory-store-test-'));
|
|
38
|
+
return path.join(dir, 'test.db');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function cleanupDbPath(dbPath: string): void {
|
|
42
|
+
closeDatabase();
|
|
43
|
+
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
|
|
44
|
+
}
|
|
45
|
+
|
|
32
46
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
33
47
|
// memory-store: fallback when DB not open
|
|
34
48
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -366,6 +380,40 @@ test('memory-store: schema includes memories table', () => {
|
|
|
366
380
|
closeDatabase();
|
|
367
381
|
});
|
|
368
382
|
|
|
383
|
+
test('memory-store: reopening rebuilds FTS index for pre-FTS memories', () => {
|
|
384
|
+
const dbPath = tempDbPath();
|
|
385
|
+
try {
|
|
386
|
+
openDatabase(dbPath);
|
|
387
|
+
const id = createMemory({ category: 'gotcha', content: 'pre FTS auth memory must become searchable' });
|
|
388
|
+
assert.equal(id, 'MEM001');
|
|
389
|
+
|
|
390
|
+
const adapter = _getAdapter()!;
|
|
391
|
+
const fts = adapter.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='memories_fts'").get();
|
|
392
|
+
if (!fts) return;
|
|
393
|
+
|
|
394
|
+
adapter.exec('DROP TRIGGER IF EXISTS memories_ai');
|
|
395
|
+
adapter.exec('DROP TRIGGER IF EXISTS memories_ad');
|
|
396
|
+
adapter.exec('DROP TRIGGER IF EXISTS memories_au');
|
|
397
|
+
adapter.exec('DROP TABLE IF EXISTS memories_fts');
|
|
398
|
+
adapter.prepare(
|
|
399
|
+
"DELETE FROM runtime_kv WHERE scope = 'global' AND scope_id = '' AND key = 'memories_fts_rebuilt_at'",
|
|
400
|
+
).run();
|
|
401
|
+
closeDatabase();
|
|
402
|
+
|
|
403
|
+
openDatabase(dbPath);
|
|
404
|
+
const reopened = _getAdapter()!;
|
|
405
|
+
const marker = reopened.prepare(
|
|
406
|
+
"SELECT value_json FROM runtime_kv WHERE scope = 'global' AND scope_id = '' AND key = 'memories_fts_rebuilt_at'",
|
|
407
|
+
).get();
|
|
408
|
+
assert.ok(marker, 'FTS rebuild marker should be set after reopening');
|
|
409
|
+
|
|
410
|
+
const hits = queryMemoriesRanked({ query: 'auth', k: 5 }).map((hit) => hit.memory.id);
|
|
411
|
+
assert.deepEqual(hits, ['MEM001']);
|
|
412
|
+
} finally {
|
|
413
|
+
cleanupDbPath(dbPath);
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
|
|
369
417
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
370
418
|
// regression #4967 — createMemory must not silently swallow SQL errors
|
|
371
419
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -7,7 +7,10 @@ import {
|
|
|
7
7
|
existsSync,
|
|
8
8
|
mkdirSync,
|
|
9
9
|
realpathSync,
|
|
10
|
+
readFileSync,
|
|
10
11
|
} from "node:fs";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import { syncBuiltinESMExports } from "node:module";
|
|
11
14
|
import { join } from "node:path";
|
|
12
15
|
import { tmpdir } from "node:os";
|
|
13
16
|
import { execSync } from "node:child_process";
|
|
@@ -116,6 +119,60 @@ describe("migrate-external worktree guard (#2970)", () => {
|
|
|
116
119
|
});
|
|
117
120
|
});
|
|
118
121
|
|
|
122
|
+
describe("migrateToExternalState copy failure rollback (#1047)", () => {
|
|
123
|
+
test("restores .gsd when a source entry fails to copy", () => {
|
|
124
|
+
const base = realpathSync(mkdtempSync(join(tmpdir(), "gsd-migrate-copy-fail-")));
|
|
125
|
+
const stateDir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-state-copy-fail-")));
|
|
126
|
+
const previousStateDir = process.env.GSD_STATE_DIR;
|
|
127
|
+
const previousProjectId = process.env.GSD_PROJECT_ID;
|
|
128
|
+
const originalCpSync = fs.cpSync;
|
|
129
|
+
process.env.GSD_STATE_DIR = stateDir;
|
|
130
|
+
process.env.GSD_PROJECT_ID = "copy-fail";
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
run("git init -b main", base);
|
|
134
|
+
mkdirSync(join(base, ".gsd"), { recursive: true });
|
|
135
|
+
writeFileSync(join(base, ".gsd", "PREFERENCES.md"), "# prefs\n", "utf-8");
|
|
136
|
+
writeFileSync(join(base, ".gsd", "gsd.db"), "state", "utf-8");
|
|
137
|
+
const failingSource = join(base, ".gsd.migrating", "gsd.db");
|
|
138
|
+
fs.cpSync = ((...args: Parameters<typeof fs.cpSync>) => {
|
|
139
|
+
const src = args[0];
|
|
140
|
+
if (String(src) === failingSource) {
|
|
141
|
+
const error = new Error("simulated copy failure") as NodeJS.ErrnoException;
|
|
142
|
+
error.code = "EACCES";
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
return originalCpSync(...args);
|
|
146
|
+
}) as typeof fs.cpSync;
|
|
147
|
+
syncBuiltinESMExports();
|
|
148
|
+
|
|
149
|
+
const result = migrateToExternalState(base);
|
|
150
|
+
|
|
151
|
+
assert.equal(result.migrated, false, "copy failure must fail the migration");
|
|
152
|
+
assert.match(result.error ?? "", /Migration copy failed/);
|
|
153
|
+
assert.ok(existsSync(join(base, ".gsd", "gsd.db")), "source .gsd must be restored");
|
|
154
|
+
assert.equal(readFileSync(join(base, ".gsd", "PREFERENCES.md"), "utf-8"), "# prefs\n");
|
|
155
|
+
assert.ok(!existsSync(join(base, ".gsd.migrating")), "backup staging dir must not remain after restore");
|
|
156
|
+
assert.ok(!existsSync(join(stateDir, "projects", "copy-fail", "gsd.db")), "failed file must not be reported as migrated");
|
|
157
|
+
} finally {
|
|
158
|
+
fs.cpSync = originalCpSync;
|
|
159
|
+
syncBuiltinESMExports();
|
|
160
|
+
if (previousStateDir === undefined) {
|
|
161
|
+
delete process.env.GSD_STATE_DIR;
|
|
162
|
+
} else {
|
|
163
|
+
process.env.GSD_STATE_DIR = previousStateDir;
|
|
164
|
+
}
|
|
165
|
+
if (previousProjectId === undefined) {
|
|
166
|
+
delete process.env.GSD_PROJECT_ID;
|
|
167
|
+
} else {
|
|
168
|
+
process.env.GSD_PROJECT_ID = previousProjectId;
|
|
169
|
+
}
|
|
170
|
+
rmSync(base, { recursive: true, force: true });
|
|
171
|
+
rmSync(stateDir, { recursive: true, force: true });
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
119
176
|
// Regression tests for #5571 — recoverFailedMigration handles orphaned .gsd.migrating
|
|
120
177
|
describe("recoverFailedMigration (#5571)", () => {
|
|
121
178
|
test("returns false when .gsd.migrating does not exist", () => {
|
|
@@ -58,6 +58,8 @@ const ALLOWED_IMPORTERS = new Set([
|
|
|
58
58
|
"gsd/artifact-verification.ts",
|
|
59
59
|
// diagnostics-only surfaces: report on projections, make no dispatch decisions
|
|
60
60
|
"gsd/doctor.ts",
|
|
61
|
+
// diagnostics-only: compares PLAN.md task checkboxes against DB status
|
|
62
|
+
"gsd/doctor-engine-checks.ts",
|
|
61
63
|
// display/telemetry-only surfaces
|
|
62
64
|
"gsd/workspace-index.ts",
|
|
63
65
|
"gsd/visualizer-data.ts",
|