gsd-pi 2.78.1-dev.b6a389b66 → 2.78.1-dev.d8826a445
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/resources/.managed-resources-content-hash +1 -1
- package/dist/resources/extensions/gsd/auto/phases.js +7 -2
- package/dist/resources/extensions/gsd/auto/session.js +3 -0
- package/dist/resources/extensions/gsd/auto-dispatch.js +3 -2
- package/dist/resources/extensions/gsd/auto-post-unit.js +7 -1
- package/dist/resources/extensions/gsd/auto-worktree.js +185 -40
- package/dist/resources/extensions/gsd/auto.js +62 -1
- package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +1 -1
- package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +17 -16
- package/dist/resources/extensions/gsd/bootstrap/write-gate.js +67 -55
- package/dist/resources/extensions/gsd/db-writer.js +96 -16
- package/dist/resources/extensions/gsd/delegation-policy.js +155 -0
- package/dist/resources/extensions/gsd/gsd-db.js +194 -0
- package/dist/resources/extensions/gsd/guided-flow-queue.js +1 -1
- package/dist/resources/extensions/gsd/guided-flow.js +117 -25
- package/dist/resources/extensions/gsd/metrics.js +287 -1
- package/dist/resources/extensions/gsd/paths.js +79 -8
- package/dist/resources/extensions/gsd/prompts/complete-slice.md +4 -4
- package/dist/resources/extensions/gsd/prompts/execute-task.md +3 -3
- package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +8 -1
- package/dist/resources/extensions/gsd/prompts/guided-discuss-project.md +22 -7
- package/dist/resources/extensions/gsd/prompts/guided-discuss-requirements.md +6 -2
- package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +8 -1
- package/dist/resources/extensions/gsd/templates/project.md +10 -0
- package/dist/resources/extensions/gsd/workflow-mcp.js +2 -2
- package/dist/resources/extensions/gsd/workspace.js +59 -0
- package/dist/resources/extensions/gsd/worktree-resolver.js +15 -2
- package/dist/resources/extensions/gsd/write-intercept.js +3 -3
- 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 +10 -10
- 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/required-server-files.json +1 -1
- 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 +10 -10
- 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/dist/web/standalone/server.js +1 -1
- package/package.json +1 -1
- package/packages/mcp-server/README.md +2 -11
- package/packages/mcp-server/dist/remote-questions.d.ts +27 -0
- package/packages/mcp-server/dist/remote-questions.d.ts.map +1 -1
- package/packages/mcp-server/dist/remote-questions.js +28 -0
- package/packages/mcp-server/dist/remote-questions.js.map +1 -1
- package/packages/mcp-server/dist/server.d.ts +28 -0
- package/packages/mcp-server/dist/server.d.ts.map +1 -1
- package/packages/mcp-server/dist/server.js +94 -4
- package/packages/mcp-server/dist/server.js.map +1 -1
- package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
- package/packages/mcp-server/src/mcp-server.test.ts +226 -0
- package/packages/mcp-server/src/remote-questions.test.ts +103 -0
- package/packages/mcp-server/src/remote-questions.ts +35 -0
- package/packages/mcp-server/src/server.ts +129 -6
- package/packages/mcp-server/src/workflow-tools.ts +1 -1
- package/packages/mcp-server/tsconfig.tsbuildinfo +1 -1
- package/src/resources/extensions/gsd/auto/phases.ts +8 -2
- package/src/resources/extensions/gsd/auto/session.ts +4 -0
- package/src/resources/extensions/gsd/auto-dispatch.ts +10 -2
- package/src/resources/extensions/gsd/auto-post-unit.ts +8 -1
- package/src/resources/extensions/gsd/auto-worktree.ts +225 -47
- package/src/resources/extensions/gsd/auto.ts +79 -1
- package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +1 -1
- package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +17 -17
- package/src/resources/extensions/gsd/bootstrap/tests/write-gate-basepath.test.ts +103 -0
- package/src/resources/extensions/gsd/bootstrap/write-gate.ts +80 -55
- package/src/resources/extensions/gsd/db-writer.ts +113 -17
- package/src/resources/extensions/gsd/delegation-policy.ts +197 -0
- package/src/resources/extensions/gsd/gsd-db.ts +184 -0
- package/src/resources/extensions/gsd/guided-flow-queue.ts +1 -1
- package/src/resources/extensions/gsd/guided-flow.ts +154 -25
- package/src/resources/extensions/gsd/metrics.ts +321 -1
- package/src/resources/extensions/gsd/paths.ts +67 -8
- package/src/resources/extensions/gsd/prompts/complete-slice.md +4 -4
- package/src/resources/extensions/gsd/prompts/execute-task.md +3 -3
- package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +8 -1
- package/src/resources/extensions/gsd/prompts/guided-discuss-project.md +22 -7
- package/src/resources/extensions/gsd/prompts/guided-discuss-requirements.md +6 -2
- package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +8 -1
- package/src/resources/extensions/gsd/templates/project.md +10 -0
- package/src/resources/extensions/gsd/tests/auto-discuss-milestone-deadlock-4973.test.ts +14 -14
- package/src/resources/extensions/gsd/tests/auto-session-scope.test.ts +331 -0
- package/src/resources/extensions/gsd/tests/auto-worktree-registry.test.ts +176 -0
- package/src/resources/extensions/gsd/tests/db-writer-path-containment.test.ts +152 -0
- package/src/resources/extensions/gsd/tests/db-writer-root-artifact.test.ts +221 -0
- package/src/resources/extensions/gsd/tests/db-writer-scope.test.ts +230 -0
- package/src/resources/extensions/gsd/tests/delegation-policy.test.ts +151 -0
- package/src/resources/extensions/gsd/tests/dispatch-backgroundable-annotation.test.ts +55 -0
- package/src/resources/extensions/gsd/tests/draft-promotion.test.ts +3 -23
- package/src/resources/extensions/gsd/tests/gate-1b-orphan-discrimination.test.ts +193 -0
- package/src/resources/extensions/gsd/tests/gate-1b-recovery-bound-corrections.test.ts +246 -0
- package/src/resources/extensions/gsd/tests/gate-1b-recovery-bound.test.ts +218 -0
- package/src/resources/extensions/gsd/tests/gsd-db-failed-open-restore.test.ts +117 -0
- package/src/resources/extensions/gsd/tests/gsd-db-workspace-scope.test.ts +226 -0
- package/src/resources/extensions/gsd/tests/gsd-root-canonical.test.ts +66 -0
- package/src/resources/extensions/gsd/tests/gsd-root-home-guard.test.ts +68 -5
- package/src/resources/extensions/gsd/tests/guided-flow-prompt-consolidation.test.ts +4 -4
- package/src/resources/extensions/gsd/tests/integration/workspace-collapse-integration.test.ts +371 -0
- package/src/resources/extensions/gsd/tests/metrics-atomic-merge.test.ts +222 -0
- package/src/resources/extensions/gsd/tests/metrics-lock-hardening.test.ts +400 -0
- package/src/resources/extensions/gsd/tests/metrics-lock-not-acquired.test.ts +141 -0
- package/src/resources/extensions/gsd/tests/metrics-lock-retry-sleep.test.ts +287 -0
- package/src/resources/extensions/gsd/tests/metrics-prune-cache-invalidation.test.ts +149 -0
- package/src/resources/extensions/gsd/tests/metrics-scope.test.ts +378 -0
- package/src/resources/extensions/gsd/tests/originalbase-path-comparison.test.ts +329 -0
- package/src/resources/extensions/gsd/tests/path-cache-decoupled.test.ts +209 -0
- package/src/resources/extensions/gsd/tests/path-normalization-unified.test.ts +175 -0
- package/src/resources/extensions/gsd/tests/paths-cache.test.ts +170 -0
- package/src/resources/extensions/gsd/tests/pending-autostart-scope.test.ts +120 -0
- package/src/resources/extensions/gsd/tests/prompt-contracts.test.ts +150 -7
- package/src/resources/extensions/gsd/tests/ready-phrase-no-files-4573.test.ts +74 -0
- package/src/resources/extensions/gsd/tests/register-hooks-depth-verification.test.ts +28 -16
- package/src/resources/extensions/gsd/tests/resume-missing-worktree-warning.test.ts +209 -0
- package/src/resources/extensions/gsd/tests/sync-layer-scope.test.ts +453 -0
- package/src/resources/extensions/gsd/tests/teardown-chdir-failure-clears-registry.test.ts +162 -0
- package/src/resources/extensions/gsd/tests/teardown-cleanup-parity.test.ts +102 -0
- package/src/resources/extensions/gsd/tests/teardown-failure-clears-registry.test.ts +186 -0
- package/src/resources/extensions/gsd/tests/tool-invocation-error-loop-break.test.ts +1 -1
- package/src/resources/extensions/gsd/tests/validator-scope-parity.test.ts +239 -0
- package/src/resources/extensions/gsd/tests/workflow-mcp.test.ts +2 -2
- package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +9 -15
- package/src/resources/extensions/gsd/tests/workspace.test.ts +190 -0
- package/src/resources/extensions/gsd/tests/write-gate-predicates.test.ts +35 -35
- package/src/resources/extensions/gsd/tests/write-gate.test.ts +67 -52
- package/src/resources/extensions/gsd/tests/write-intercept.test.ts +1 -1
- package/src/resources/extensions/gsd/workflow-mcp.ts +2 -2
- package/src/resources/extensions/gsd/workspace.ts +95 -0
- package/src/resources/extensions/gsd/worktree-resolver.ts +16 -2
- package/src/resources/extensions/gsd/write-intercept.ts +3 -3
- /package/dist/web/standalone/.next/static/{HahrZrc_Xn4wumj0O1Ydp → AT5qi39nKXkdmQIOIoh0f}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{HahrZrc_Xn4wumj0O1Ydp → AT5qi39nKXkdmQIOIoh0f}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// GSD-2 + metrics-scope.test.ts: tests for scope-aware metrics variants (C6)
|
|
2
|
+
|
|
3
|
+
import { describe, test, beforeEach, afterEach } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import {
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
realpathSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
initMetrics,
|
|
19
|
+
resetMetrics,
|
|
20
|
+
getLedger,
|
|
21
|
+
snapshotUnitMetrics,
|
|
22
|
+
initMetricsByScope,
|
|
23
|
+
getLedgerByScope,
|
|
24
|
+
resetMetricsByScope,
|
|
25
|
+
snapshotUnitMetricsByScope,
|
|
26
|
+
type MetricsLedger,
|
|
27
|
+
type UnitMetrics,
|
|
28
|
+
} from "../metrics.js";
|
|
29
|
+
import { createWorkspace, scopeMilestone } from "../workspace.js";
|
|
30
|
+
|
|
31
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
function makeProjectDir(): string {
|
|
34
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-metrics-scope-")));
|
|
35
|
+
mkdirSync(join(dir, ".gsd"), { recursive: true });
|
|
36
|
+
return dir;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function mockCtx(messages: any[] = []): any {
|
|
40
|
+
const entries = messages.map((msg, i) => ({
|
|
41
|
+
type: "message",
|
|
42
|
+
id: `entry-${i}`,
|
|
43
|
+
parentId: i > 0 ? `entry-${i - 1}` : null,
|
|
44
|
+
timestamp: new Date().toISOString(),
|
|
45
|
+
message: msg,
|
|
46
|
+
}));
|
|
47
|
+
return {
|
|
48
|
+
sessionManager: { getEntries: () => entries },
|
|
49
|
+
model: { id: "test-model" },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function assistantMsg(input = 1000, output = 500): any {
|
|
54
|
+
return {
|
|
55
|
+
role: "assistant",
|
|
56
|
+
content: [{ type: "text", text: "done" }],
|
|
57
|
+
usage: {
|
|
58
|
+
input,
|
|
59
|
+
output,
|
|
60
|
+
cacheRead: 0,
|
|
61
|
+
cacheWrite: 0,
|
|
62
|
+
totalTokens: input + output,
|
|
63
|
+
cost: { total: 0.01 },
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── Tests ──────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
describe("ByScope variant writes to the same path as legacy variant", () => {
|
|
71
|
+
let projectDir: string;
|
|
72
|
+
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
projectDir = makeProjectDir();
|
|
75
|
+
resetMetrics();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterEach(() => {
|
|
79
|
+
resetMetrics();
|
|
80
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("metrics.json written by snapshotUnitMetrics matches path used by snapshotUnitMetricsByScope", () => {
|
|
84
|
+
const ws = createWorkspace(projectDir);
|
|
85
|
+
const scope = scopeMilestone(ws, "M001");
|
|
86
|
+
|
|
87
|
+
const ctx = mockCtx([assistantMsg()]);
|
|
88
|
+
const startedAt = Date.now() - 5000;
|
|
89
|
+
|
|
90
|
+
// Write via legacy path
|
|
91
|
+
initMetrics(projectDir);
|
|
92
|
+
snapshotUnitMetrics(ctx, "execute-task", "M001/S01/T01", startedAt, "test-model");
|
|
93
|
+
resetMetrics();
|
|
94
|
+
|
|
95
|
+
// Read via scope path
|
|
96
|
+
initMetricsByScope(scope);
|
|
97
|
+
const scopedLedger = getLedgerByScope(scope);
|
|
98
|
+
assert.ok(scopedLedger, "scoped ledger should load the same metrics.json");
|
|
99
|
+
assert.equal(scopedLedger!.units.length, 1, "should see the unit written by legacy path");
|
|
100
|
+
assert.equal(scopedLedger!.units[0].id, "M001/S01/T01");
|
|
101
|
+
resetMetricsByScope(scope);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("snapshotUnitMetricsByScope writes to the same metrics.json as the legacy path", () => {
|
|
105
|
+
const ws = createWorkspace(projectDir);
|
|
106
|
+
const scope = scopeMilestone(ws, "M001");
|
|
107
|
+
const ctx = mockCtx([assistantMsg()]);
|
|
108
|
+
const startedAt = Date.now() - 5000;
|
|
109
|
+
|
|
110
|
+
// Write via scope path (no initMetrics called)
|
|
111
|
+
snapshotUnitMetricsByScope(scope, ctx, "execute-task", "M001/S01/T01", startedAt, "test-model");
|
|
112
|
+
resetMetricsByScope(scope);
|
|
113
|
+
|
|
114
|
+
// Read via legacy path
|
|
115
|
+
initMetrics(projectDir);
|
|
116
|
+
const legacyLedger = getLedger();
|
|
117
|
+
assert.ok(legacyLedger, "legacy path should read what the scope variant wrote");
|
|
118
|
+
assert.equal(legacyLedger!.units.length, 1);
|
|
119
|
+
assert.equal(legacyLedger!.units[0].id, "M001/S01/T01");
|
|
120
|
+
resetMetrics();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("ByScope variant is pinned to scope — cwd-drift does not move write target", () => {
|
|
125
|
+
let projectDir: string;
|
|
126
|
+
|
|
127
|
+
beforeEach(() => {
|
|
128
|
+
projectDir = makeProjectDir();
|
|
129
|
+
resetMetrics();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
afterEach(() => {
|
|
133
|
+
resetMetrics();
|
|
134
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("write target is the scope's projectRoot regardless of process.cwd()", () => {
|
|
138
|
+
const ws = createWorkspace(projectDir);
|
|
139
|
+
const scope = scopeMilestone(ws, "M001");
|
|
140
|
+
const ctx = mockCtx([assistantMsg()]);
|
|
141
|
+
const startedAt = Date.now() - 3000;
|
|
142
|
+
|
|
143
|
+
// Record projectRoot before writing
|
|
144
|
+
const expectedMetricsPath = join(ws.projectRoot, ".gsd", "metrics.json");
|
|
145
|
+
|
|
146
|
+
snapshotUnitMetricsByScope(scope, ctx, "execute-task", "M001/S01/T01", startedAt, "test-model");
|
|
147
|
+
|
|
148
|
+
// Verify the file was written to the expected location
|
|
149
|
+
const raw = readFileSync(expectedMetricsPath, "utf-8");
|
|
150
|
+
const parsed: MetricsLedger = JSON.parse(raw);
|
|
151
|
+
assert.equal(parsed.units.length, 1);
|
|
152
|
+
assert.equal(parsed.units[0].id, "M001/S01/T01");
|
|
153
|
+
|
|
154
|
+
resetMetricsByScope(scope);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("two scopes for different projectRoots write to separate metrics.json files", () => {
|
|
158
|
+
const projectDir2 = makeProjectDir();
|
|
159
|
+
try {
|
|
160
|
+
const ws1 = createWorkspace(projectDir);
|
|
161
|
+
const ws2 = createWorkspace(projectDir2);
|
|
162
|
+
const scope1 = scopeMilestone(ws1, "M001");
|
|
163
|
+
const scope2 = scopeMilestone(ws2, "M002");
|
|
164
|
+
|
|
165
|
+
const ctx = mockCtx([assistantMsg()]);
|
|
166
|
+
const startedAt = Date.now() - 3000;
|
|
167
|
+
|
|
168
|
+
snapshotUnitMetricsByScope(scope1, ctx, "execute-task", "M001/S01/T01", startedAt, "test-model");
|
|
169
|
+
snapshotUnitMetricsByScope(scope2, ctx, "execute-task", "M002/S01/T01", startedAt, "test-model");
|
|
170
|
+
|
|
171
|
+
const metrics1 = JSON.parse(
|
|
172
|
+
readFileSync(join(ws1.projectRoot, ".gsd", "metrics.json"), "utf-8"),
|
|
173
|
+
) as MetricsLedger;
|
|
174
|
+
const metrics2 = JSON.parse(
|
|
175
|
+
readFileSync(join(ws2.projectRoot, ".gsd", "metrics.json"), "utf-8"),
|
|
176
|
+
) as MetricsLedger;
|
|
177
|
+
|
|
178
|
+
assert.equal(metrics1.units.length, 1);
|
|
179
|
+
assert.equal(metrics1.units[0].id, "M001/S01/T01");
|
|
180
|
+
assert.equal(metrics2.units.length, 1);
|
|
181
|
+
assert.equal(metrics2.units[0].id, "M002/S01/T01");
|
|
182
|
+
|
|
183
|
+
resetMetricsByScope(scope1);
|
|
184
|
+
resetMetricsByScope(scope2);
|
|
185
|
+
} finally {
|
|
186
|
+
rmSync(projectDir2, { recursive: true, force: true });
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("ByScope works without calling initMetrics", () => {
|
|
192
|
+
let projectDir: string;
|
|
193
|
+
|
|
194
|
+
beforeEach(() => {
|
|
195
|
+
projectDir = makeProjectDir();
|
|
196
|
+
// Deliberately do NOT call initMetrics / resetMetrics
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
afterEach(() => {
|
|
200
|
+
resetMetrics();
|
|
201
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("snapshotUnitMetricsByScope succeeds without initMetrics having been called", () => {
|
|
205
|
+
const ws = createWorkspace(projectDir);
|
|
206
|
+
const scope = scopeMilestone(ws, "M001");
|
|
207
|
+
const ctx = mockCtx([assistantMsg()]);
|
|
208
|
+
|
|
209
|
+
// Confirm singleton was never initialized
|
|
210
|
+
assert.equal(getLedger(), null, "module singleton should be null — initMetrics was never called");
|
|
211
|
+
|
|
212
|
+
const unit = snapshotUnitMetricsByScope(
|
|
213
|
+
scope,
|
|
214
|
+
ctx,
|
|
215
|
+
"execute-task",
|
|
216
|
+
"M001/S01/T01",
|
|
217
|
+
Date.now() - 2000,
|
|
218
|
+
"test-model",
|
|
219
|
+
);
|
|
220
|
+
assert.ok(unit, "snapshotUnitMetricsByScope should return a unit");
|
|
221
|
+
assert.equal(unit!.id, "M001/S01/T01");
|
|
222
|
+
|
|
223
|
+
// Verify on disk
|
|
224
|
+
const raw = readFileSync(join(projectDir, ".gsd", "metrics.json"), "utf-8");
|
|
225
|
+
const parsed: MetricsLedger = JSON.parse(raw);
|
|
226
|
+
assert.equal(parsed.units.length, 1);
|
|
227
|
+
|
|
228
|
+
resetMetricsByScope(scope);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("initMetricsByScope succeeds without initMetrics having been called", () => {
|
|
232
|
+
const ws = createWorkspace(projectDir);
|
|
233
|
+
const scope = scopeMilestone(ws, "M001");
|
|
234
|
+
|
|
235
|
+
assert.equal(getLedger(), null);
|
|
236
|
+
|
|
237
|
+
initMetricsByScope(scope);
|
|
238
|
+
const l = getLedgerByScope(scope);
|
|
239
|
+
assert.ok(l, "getLedgerByScope should return a ledger after initMetricsByScope");
|
|
240
|
+
assert.equal(l!.version, 1);
|
|
241
|
+
assert.equal(l!.units.length, 0);
|
|
242
|
+
|
|
243
|
+
resetMetricsByScope(scope);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("ByScope atomic write-merge — concurrent writers do not clobber", () => {
|
|
248
|
+
let projectDir: string;
|
|
249
|
+
|
|
250
|
+
beforeEach(() => {
|
|
251
|
+
projectDir = makeProjectDir();
|
|
252
|
+
resetMetrics();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
afterEach(() => {
|
|
256
|
+
resetMetrics();
|
|
257
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Worker script: same lock+merge semantics as saveLedger, written in plain CJS
|
|
261
|
+
// so it can run as a child process without loading the full extension tree.
|
|
262
|
+
const MERGE_WORKER = `
|
|
263
|
+
const { openSync, closeSync, unlinkSync, existsSync, readFileSync, mkdirSync, renameSync } = require('node:fs');
|
|
264
|
+
const { dirname } = require('node:path');
|
|
265
|
+
const { randomBytes } = require('node:crypto');
|
|
266
|
+
|
|
267
|
+
const metricsPath = process.env.GSD_SCOPE_METRICS_PATH;
|
|
268
|
+
const milestoneId = process.env.GSD_SCOPE_MILESTONE_ID;
|
|
269
|
+
const lockPath = metricsPath + '.lock';
|
|
270
|
+
|
|
271
|
+
function acquireLock(lp, ms) {
|
|
272
|
+
const deadline = Date.now() + ms;
|
|
273
|
+
while (Date.now() < deadline) {
|
|
274
|
+
try { const fd = openSync(lp, 'wx'); closeSync(fd); return true; }
|
|
275
|
+
catch { const w = Date.now() + Math.min(50, deadline - Date.now()); while (Date.now() < w) {} }
|
|
276
|
+
}
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
function releaseLock(lp) { try { unlinkSync(lp); } catch {} }
|
|
280
|
+
function saveAtomic(fp, data) {
|
|
281
|
+
mkdirSync(dirname(fp), { recursive: true });
|
|
282
|
+
const tmp = fp + '.tmp.' + randomBytes(4).toString('hex');
|
|
283
|
+
require('node:fs').writeFileSync(tmp, JSON.stringify(data, null, 2) + '\\n', 'utf-8');
|
|
284
|
+
renameSync(tmp, fp);
|
|
285
|
+
}
|
|
286
|
+
function dedup(units) {
|
|
287
|
+
const m = new Map();
|
|
288
|
+
for (const u of units) {
|
|
289
|
+
const k = u.type + '\\0' + u.id + '\\0' + u.startedAt;
|
|
290
|
+
const e = m.get(k);
|
|
291
|
+
if (!e || u.finishedAt > e.finishedAt) m.set(k, u);
|
|
292
|
+
}
|
|
293
|
+
return Array.from(m.values());
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const unit = {
|
|
297
|
+
type: 'execute-task', id: milestoneId + '/S01/T01', model: 'test',
|
|
298
|
+
startedAt: 1000, finishedAt: Date.now(),
|
|
299
|
+
tokens: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, total: 15 },
|
|
300
|
+
cost: 0.001, toolCalls: 0, assistantMessages: 1, userMessages: 1,
|
|
301
|
+
};
|
|
302
|
+
const workerLedger = { version: 1, projectStartedAt: 1000, units: [unit] };
|
|
303
|
+
|
|
304
|
+
const acquired = acquireLock(lockPath, 5000);
|
|
305
|
+
try {
|
|
306
|
+
let diskUnits = [];
|
|
307
|
+
if (existsSync(metricsPath)) {
|
|
308
|
+
try { const p = JSON.parse(readFileSync(metricsPath, 'utf-8')); if (p && Array.isArray(p.units)) diskUnits = p.units; } catch {}
|
|
309
|
+
}
|
|
310
|
+
saveAtomic(metricsPath, { ...workerLedger, units: dedup([...diskUnits, ...workerLedger.units]) });
|
|
311
|
+
} finally {
|
|
312
|
+
if (acquired) releaseLock(lockPath);
|
|
313
|
+
}
|
|
314
|
+
`;
|
|
315
|
+
|
|
316
|
+
function spawnMergeWorker(metricsPath: string, milestoneId: string): void {
|
|
317
|
+
const result = spawnSync(process.execPath, ["-e", MERGE_WORKER], {
|
|
318
|
+
env: {
|
|
319
|
+
...process.env,
|
|
320
|
+
GSD_SCOPE_METRICS_PATH: metricsPath,
|
|
321
|
+
GSD_SCOPE_MILESTONE_ID: milestoneId,
|
|
322
|
+
},
|
|
323
|
+
encoding: "utf-8",
|
|
324
|
+
timeout: 10_000,
|
|
325
|
+
});
|
|
326
|
+
if (result.error) throw result.error;
|
|
327
|
+
if (result.status !== 0) {
|
|
328
|
+
throw new Error(`Worker for ${milestoneId} failed:\n${result.stderr}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
test("snapshotUnitMetricsByScope preserves a pre-existing entry written by a concurrent worker", () => {
|
|
333
|
+
const ws = createWorkspace(projectDir);
|
|
334
|
+
const scope = scopeMilestone(ws, "M002");
|
|
335
|
+
const metricsPath = join(ws.projectRoot, ".gsd", "metrics.json");
|
|
336
|
+
|
|
337
|
+
// Simulate a concurrent worker that already wrote M001's entry to disk
|
|
338
|
+
spawnMergeWorker(metricsPath, "M001");
|
|
339
|
+
|
|
340
|
+
// Now write M002 via scope variant — must preserve M001's entry
|
|
341
|
+
const ctx = mockCtx([assistantMsg()]);
|
|
342
|
+
snapshotUnitMetricsByScope(
|
|
343
|
+
scope,
|
|
344
|
+
ctx,
|
|
345
|
+
"execute-task",
|
|
346
|
+
"M002/S01/T01",
|
|
347
|
+
Date.now() - 2000,
|
|
348
|
+
"test-model",
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
const raw = readFileSync(metricsPath, "utf-8");
|
|
352
|
+
const parsed: MetricsLedger = JSON.parse(raw);
|
|
353
|
+
assert.equal(parsed.units.length, 2, "both M001 and M002 units must be in metrics.json");
|
|
354
|
+
|
|
355
|
+
const ids = parsed.units.map((u: UnitMetrics) => u.id);
|
|
356
|
+
assert.ok(ids.some((id) => id.startsWith("M001")), "M001 unit must be preserved");
|
|
357
|
+
assert.ok(ids.some((id) => id.startsWith("M002")), "M002 unit must be present");
|
|
358
|
+
|
|
359
|
+
resetMetricsByScope(scope);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("idempotent ByScope snapshot does not duplicate units on disk", () => {
|
|
363
|
+
const ws = createWorkspace(projectDir);
|
|
364
|
+
const scope = scopeMilestone(ws, "M001");
|
|
365
|
+
const ctx = mockCtx([assistantMsg()]);
|
|
366
|
+
const startedAt = Date.now() - 3000;
|
|
367
|
+
const metricsPath = join(ws.projectRoot, ".gsd", "metrics.json");
|
|
368
|
+
|
|
369
|
+
// Snapshot twice with same type+id+startedAt
|
|
370
|
+
snapshotUnitMetricsByScope(scope, ctx, "execute-task", "M001/S01/T01", startedAt, "test-model");
|
|
371
|
+
snapshotUnitMetricsByScope(scope, ctx, "execute-task", "M001/S01/T01", startedAt, "test-model");
|
|
372
|
+
|
|
373
|
+
const parsed: MetricsLedger = JSON.parse(readFileSync(metricsPath, "utf-8"));
|
|
374
|
+
assert.equal(parsed.units.length, 1, "duplicate snapshots must not create duplicate entries");
|
|
375
|
+
|
|
376
|
+
resetMetricsByScope(scope);
|
|
377
|
+
});
|
|
378
|
+
});
|