gsd-pi 2.79.0-dev.5c910bb05 → 2.79.0-dev.bbb2f88ce
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 +6 -1
- package/dist/resources/extensions/gsd/auto-artifact-paths.js +2 -2
- package/dist/resources/extensions/gsd/auto-dispatch.js +2 -0
- package/dist/resources/extensions/gsd/auto-recovery.js +18 -3
- package/dist/resources/extensions/gsd/auto-start.js +3 -2
- package/dist/resources/extensions/gsd/paths.js +5 -1
- package/dist/resources/extensions/gsd/uok/audit.js +23 -9
- package/dist/resources/extensions/gsd/uok/contracts.js +69 -1
- package/dist/resources/extensions/gsd/uok/dispatch-envelope.js +3 -0
- package/dist/resources/extensions/gsd/uok/loop-adapter.js +48 -33
- package/dist/resources/extensions/gsd/uok/timeline.js +125 -0
- package/dist/resources/extensions/shared/gsd-phase-state.js +45 -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 +14 -14
- 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 +14 -14
- 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/src/resources/extensions/gsd/auto/phases.ts +6 -1
- package/src/resources/extensions/gsd/auto-artifact-paths.ts +2 -2
- package/src/resources/extensions/gsd/auto-dispatch.ts +1 -0
- package/src/resources/extensions/gsd/auto-recovery.ts +17 -3
- package/src/resources/extensions/gsd/auto-start.ts +3 -2
- package/src/resources/extensions/gsd/paths.ts +6 -1
- package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +108 -1
- package/src/resources/extensions/gsd/tests/deep-planning-mode-dispatch.test.ts +42 -0
- package/src/resources/extensions/gsd/tests/deep-project-auto-loop.test.ts +63 -2
- package/src/resources/extensions/gsd/tests/uok-contracts.test.ts +109 -1
- package/src/resources/extensions/gsd/tests/uok-loop-adapter-writer.test.ts +98 -0
- package/src/resources/extensions/gsd/uok/audit.ts +25 -9
- package/src/resources/extensions/gsd/uok/contracts.ts +105 -0
- package/src/resources/extensions/gsd/uok/dispatch-envelope.ts +4 -0
- package/src/resources/extensions/gsd/uok/loop-adapter.ts +60 -45
- package/src/resources/extensions/gsd/uok/timeline.ts +158 -0
- package/src/resources/extensions/shared/gsd-phase-state.ts +56 -3
- package/src/resources/extensions/shared/tests/gsd-phase-state.test.ts +43 -1
- /package/dist/web/standalone/.next/static/{DSZPSz1kgrF8zPIrV_AMD → 3HYkAopiKls15zp5a8I9n}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{DSZPSz1kgrF8zPIrV_AMD → 3HYkAopiKls15zp5a8I9n}/_ssgManifest.js +0 -0
|
@@ -7,7 +7,7 @@ import { randomUUID } from "node:crypto";
|
|
|
7
7
|
|
|
8
8
|
import { verifyExpectedArtifact, hasImplementationArtifacts, resolveExpectedArtifactPath, diagnoseExpectedArtifact, buildLoopRemediationSteps, writeBlockerPlaceholder } from "../auto-recovery.ts";
|
|
9
9
|
import { resolveMilestoneFile } from "../paths.ts";
|
|
10
|
-
import { openDatabase, closeDatabase, insertMilestone, insertSlice, insertGateRow } from "../gsd-db.ts";
|
|
10
|
+
import { openDatabase, closeDatabase, insertMilestone, insertSlice, insertGateRow, insertTask } from "../gsd-db.ts";
|
|
11
11
|
import { clearParseCache } from "../files.ts";
|
|
12
12
|
import { parseRoadmap } from "../parsers-legacy.ts";
|
|
13
13
|
import { invalidateAllCaches } from "../cache.ts";
|
|
@@ -90,6 +90,46 @@ test("resolveExpectedArtifactPath returns correct path for plan-slice", () => {
|
|
|
90
90
|
}
|
|
91
91
|
});
|
|
92
92
|
|
|
93
|
+
test("plan-slice artifact resolution handles lowercase unit IDs against uppercase paths", () => {
|
|
94
|
+
const base = makeTmpBase();
|
|
95
|
+
try {
|
|
96
|
+
const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
|
|
97
|
+
const tasksDir = join(sliceDir, "tasks");
|
|
98
|
+
writeFileSync(join(sliceDir, "S01-PLAN.md"), [
|
|
99
|
+
"# S01: Test Slice",
|
|
100
|
+
"",
|
|
101
|
+
"## Tasks",
|
|
102
|
+
"",
|
|
103
|
+
"- [ ] **T01: Implement feature** `est:1h`",
|
|
104
|
+
].join("\n"));
|
|
105
|
+
writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan");
|
|
106
|
+
|
|
107
|
+
const artifactPath = resolveExpectedArtifactPath("plan-slice", "m001/s01", base);
|
|
108
|
+
assert.ok(
|
|
109
|
+
artifactPath?.endsWith(".gsd/milestones/M001/slices/S01/S01-PLAN.md"),
|
|
110
|
+
"lowercase unit IDs should resolve to the existing uppercase artifact path",
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const diagnostic = diagnoseExpectedArtifact("plan-slice", "m001/s01", base);
|
|
114
|
+
assert.ok(
|
|
115
|
+
diagnostic?.includes(".gsd/milestones/M001/slices/S01/S01-PLAN.md"),
|
|
116
|
+
"diagnostic should report the existing uppercase artifact path",
|
|
117
|
+
);
|
|
118
|
+
assert.ok(
|
|
119
|
+
diagnostic?.includes("task plans"),
|
|
120
|
+
"diagnostic should mention task plans because slice plan alone is insufficient",
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
assert.equal(
|
|
124
|
+
verifyExpectedArtifact("plan-slice", "m001/s01", base),
|
|
125
|
+
true,
|
|
126
|
+
"verification should pass when the uppercase slice plan and task plans exist",
|
|
127
|
+
);
|
|
128
|
+
} finally {
|
|
129
|
+
cleanup(base);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
93
133
|
test("resolveExpectedArtifactPath returns null for unknown type", () => {
|
|
94
134
|
const base = makeTmpBase();
|
|
95
135
|
try {
|
|
@@ -764,6 +804,73 @@ test("hasImplementationArtifacts finds implementation commits when .gsd/ is giti
|
|
|
764
804
|
}
|
|
765
805
|
});
|
|
766
806
|
|
|
807
|
+
test("hasImplementationArtifacts binds GSD-Task trailer to milestone via DB state when .gsd/ is gitignored", () => {
|
|
808
|
+
const base = makeGitBase();
|
|
809
|
+
try {
|
|
810
|
+
writeFileSync(join(base, ".git", "info", "exclude"), ".gsd/\n");
|
|
811
|
+
mkdirSync(join(base, ".gsd"), { recursive: true });
|
|
812
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
813
|
+
insertMilestone({ id: "M001", title: "Milestone One", status: "active" });
|
|
814
|
+
insertSlice({
|
|
815
|
+
id: "S01",
|
|
816
|
+
milestoneId: "M001",
|
|
817
|
+
title: "Slice One",
|
|
818
|
+
status: "complete",
|
|
819
|
+
risk: "low",
|
|
820
|
+
depends: [],
|
|
821
|
+
});
|
|
822
|
+
insertTask({
|
|
823
|
+
id: "T01",
|
|
824
|
+
sliceId: "S01",
|
|
825
|
+
milestoneId: "M001",
|
|
826
|
+
title: "Task One",
|
|
827
|
+
status: "complete",
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
mkdirSync(join(base, "src"), { recursive: true });
|
|
831
|
+
writeFileSync(join(base, "src", "feature.ts"), "export function feature() {}\n");
|
|
832
|
+
execFileSync("git", ["add", "."], { cwd: base, stdio: "ignore" });
|
|
833
|
+
execFileSync(
|
|
834
|
+
"git",
|
|
835
|
+
["commit", "-m", "feat: add feature\n\nGSD-Task: S01/T01"],
|
|
836
|
+
{ cwd: base, stdio: "ignore" },
|
|
837
|
+
);
|
|
838
|
+
|
|
839
|
+
const result = hasImplementationArtifacts(base, "M001");
|
|
840
|
+
assert.equal(
|
|
841
|
+
result,
|
|
842
|
+
"present",
|
|
843
|
+
"DB task ownership should bind S01/T01 implementation commits to M001 without explicit M001 text",
|
|
844
|
+
);
|
|
845
|
+
} finally {
|
|
846
|
+
cleanup(base);
|
|
847
|
+
}
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
test("hasImplementationArtifacts does not bind GSD-Task trailer without milestone ownership evidence", () => {
|
|
851
|
+
const base = makeGitBase();
|
|
852
|
+
try {
|
|
853
|
+
writeFileSync(join(base, ".git", "info", "exclude"), ".gsd/\n");
|
|
854
|
+
mkdirSync(join(base, "src"), { recursive: true });
|
|
855
|
+
writeFileSync(join(base, "src", "feature.ts"), "export function feature() {}\n");
|
|
856
|
+
execFileSync("git", ["add", "."], { cwd: base, stdio: "ignore" });
|
|
857
|
+
execFileSync(
|
|
858
|
+
"git",
|
|
859
|
+
["commit", "-m", "feat: add feature\n\nGSD-Task: S01/T01"],
|
|
860
|
+
{ cwd: base, stdio: "ignore" },
|
|
861
|
+
);
|
|
862
|
+
|
|
863
|
+
const result = hasImplementationArtifacts(base, "M001");
|
|
864
|
+
assert.equal(
|
|
865
|
+
result,
|
|
866
|
+
"absent",
|
|
867
|
+
"S01/T01 shape alone must not bind an implementation commit to M001",
|
|
868
|
+
);
|
|
869
|
+
} finally {
|
|
870
|
+
cleanup(base);
|
|
871
|
+
}
|
|
872
|
+
});
|
|
873
|
+
|
|
767
874
|
test("hasImplementationArtifacts ignores malformed milestone IDs in commit-message fallback", () => {
|
|
768
875
|
const base = makeGitBase();
|
|
769
876
|
try {
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
DISPATCH_RULES,
|
|
15
15
|
getDeepStageGate,
|
|
16
16
|
hasPendingDeepStage,
|
|
17
|
+
resolveDispatch,
|
|
17
18
|
setResearchProjectPromptBuilderForTest,
|
|
18
19
|
type DispatchContext,
|
|
19
20
|
} from "../auto-dispatch.ts";
|
|
@@ -248,6 +249,11 @@ function writeCapturedDeepPrefs(base: string): void {
|
|
|
248
249
|
);
|
|
249
250
|
}
|
|
250
251
|
|
|
252
|
+
function writeSkippedProjectResearchDecision(base: string): void {
|
|
253
|
+
mkdirSync(join(base, ".gsd", "runtime"), { recursive: true });
|
|
254
|
+
writeFileSync(join(base, ".gsd", "runtime", "research-decision.json"), JSON.stringify({ decision: "skip" }));
|
|
255
|
+
}
|
|
256
|
+
|
|
251
257
|
function makeCtx(
|
|
252
258
|
basePath: string,
|
|
253
259
|
prefs: GSDPreferences | undefined,
|
|
@@ -785,6 +791,42 @@ test("Deep mode: research-project DOES dispatch when only 3 of 4 research files
|
|
|
785
791
|
assert.ok(result && result.action === "dispatch", "any missing dimension must trigger re-run");
|
|
786
792
|
});
|
|
787
793
|
|
|
794
|
+
test("Deep mode: queued milestone without CONTEXT.md routes to milestone research after project setup", async (t) => {
|
|
795
|
+
const base = makeIsolatedBaseWithCleanup(t);
|
|
796
|
+
|
|
797
|
+
writeCapturedDeepPrefs(base);
|
|
798
|
+
writeValidProject(base);
|
|
799
|
+
writeValidRequirements(base);
|
|
800
|
+
writeSkippedProjectResearchDecision(base);
|
|
801
|
+
|
|
802
|
+
const prefs = { planning_depth: "deep" } as GSDPreferences;
|
|
803
|
+
const result = await resolveDispatch(makeCtx(base, prefs));
|
|
804
|
+
|
|
805
|
+
assert.equal(result.action, "dispatch");
|
|
806
|
+
if (result.action === "dispatch") {
|
|
807
|
+
assert.equal(result.unitType, "research-milestone");
|
|
808
|
+
assert.equal(result.unitId, "M001");
|
|
809
|
+
}
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
test("Deep mode: queued milestone without CONTEXT.md can route directly to milestone planning", async (t) => {
|
|
813
|
+
const base = makeIsolatedBaseWithCleanup(t);
|
|
814
|
+
|
|
815
|
+
writeCapturedDeepPrefs(base);
|
|
816
|
+
writeValidProject(base);
|
|
817
|
+
writeValidRequirements(base);
|
|
818
|
+
writeSkippedProjectResearchDecision(base);
|
|
819
|
+
|
|
820
|
+
const prefs = { planning_depth: "deep", phases: { skip_research: true } } as GSDPreferences;
|
|
821
|
+
const result = await resolveDispatch(makeCtx(base, prefs));
|
|
822
|
+
|
|
823
|
+
assert.equal(result.action, "dispatch");
|
|
824
|
+
if (result.action === "dispatch") {
|
|
825
|
+
assert.equal(result.unitType, "plan-milestone");
|
|
826
|
+
assert.equal(result.unitId, "M001");
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
|
|
788
830
|
// ─── centralized deep-stage gate ─────────────────────────────────────────
|
|
789
831
|
|
|
790
832
|
test("Deep mode gate reports the earliest missing section", (t) => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { execFileSync } from "node:child_process";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
@@ -23,6 +23,11 @@ import {
|
|
|
23
23
|
showSmartEntry,
|
|
24
24
|
startDeepProjectSetupForeground,
|
|
25
25
|
} from "../guided-flow.ts";
|
|
26
|
+
import {
|
|
27
|
+
closeDatabase,
|
|
28
|
+
insertMilestone,
|
|
29
|
+
openDatabase,
|
|
30
|
+
} from "../gsd-db.ts";
|
|
26
31
|
import type { GSDPreferences } from "../preferences.ts";
|
|
27
32
|
import type { GSDState } from "../types.ts";
|
|
28
33
|
|
|
@@ -342,6 +347,62 @@ test("deep project setup: pre-dispatch can run before the first milestone exists
|
|
|
342
347
|
}
|
|
343
348
|
});
|
|
344
349
|
|
|
350
|
+
test("deep project setup: bootstrap continues queued M002 without milestone context", async () => {
|
|
351
|
+
const base = makeRepo();
|
|
352
|
+
try {
|
|
353
|
+
writeCapturedDeepPrefs(base);
|
|
354
|
+
writeValidProjectAndRequirements(base);
|
|
355
|
+
mkdirSync(join(base, ".gsd", "runtime"), { recursive: true });
|
|
356
|
+
writeFileSync(join(base, ".gsd", "runtime", "research-decision.json"), '{"decision":"skip"}\n');
|
|
357
|
+
|
|
358
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
359
|
+
insertMilestone({ id: "M001", title: "First milestone", status: "complete" });
|
|
360
|
+
insertMilestone({ id: "M002", title: "Second milestone", status: "queued" });
|
|
361
|
+
closeDatabase();
|
|
362
|
+
|
|
363
|
+
const messages: unknown[] = [];
|
|
364
|
+
const pi = {
|
|
365
|
+
...makePi(messages),
|
|
366
|
+
getThinkingLevel: () => "medium",
|
|
367
|
+
};
|
|
368
|
+
const s = new AutoSession();
|
|
369
|
+
const ready = await bootstrapAutoSession(
|
|
370
|
+
s,
|
|
371
|
+
makeCtx(`queued-${randomUUID()}`) as any,
|
|
372
|
+
pi as any,
|
|
373
|
+
base,
|
|
374
|
+
false,
|
|
375
|
+
false,
|
|
376
|
+
{
|
|
377
|
+
shouldUseWorktreeIsolation: () => false,
|
|
378
|
+
registerSigtermHandler: () => {},
|
|
379
|
+
lockBase: () => base,
|
|
380
|
+
buildResolver: () => ({}) as any,
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
classification: "none",
|
|
384
|
+
lock: null,
|
|
385
|
+
pausedSession: null,
|
|
386
|
+
state: null,
|
|
387
|
+
recovery: null,
|
|
388
|
+
recoveryPrompt: null,
|
|
389
|
+
recoveryToolCallCount: 0,
|
|
390
|
+
artifactSatisfied: false,
|
|
391
|
+
hasResumableDiskState: false,
|
|
392
|
+
isBootstrapCrash: false,
|
|
393
|
+
},
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
assert.equal(ready, true);
|
|
397
|
+
assert.equal(s.active, true);
|
|
398
|
+
assert.equal(s.currentMilestoneId, "M002");
|
|
399
|
+
assert.equal(messages.length, 0, "queued deep milestone must not re-enter smart new-milestone discussion");
|
|
400
|
+
} finally {
|
|
401
|
+
try { closeDatabase(); } catch {}
|
|
402
|
+
rmSync(base, { recursive: true, force: true });
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
345
406
|
test("deep project setup: pre-dispatch takes precedence over an existing draft milestone", async () => {
|
|
346
407
|
const base = makeBase();
|
|
347
408
|
try {
|
|
@@ -1020,7 +1081,7 @@ test("deep project setup: research-project blocker placeholder is a file, not th
|
|
|
1020
1081
|
const base = makeBase();
|
|
1021
1082
|
try {
|
|
1022
1083
|
const expectedPath = resolveExpectedArtifactPath("research-project", "PROJECT-RESEARCH", base);
|
|
1023
|
-
assert.equal(expectedPath, join(base, ".gsd", "research", "PROJECT-RESEARCH-BLOCKER.md"));
|
|
1084
|
+
assert.equal(expectedPath, join(realpathSync(base), ".gsd", "research", "PROJECT-RESEARCH-BLOCKER.md"));
|
|
1024
1085
|
|
|
1025
1086
|
mkdirSync(join(base, ".gsd", "research"), { recursive: true });
|
|
1026
1087
|
const diagnosis = writeBlockerPlaceholder(
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
// GSD2 UOK Contract Versioning and DB Authority Tests
|
|
2
|
+
|
|
1
3
|
import test from "node:test";
|
|
2
4
|
import assert from "node:assert/strict";
|
|
5
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
3
8
|
|
|
4
9
|
import type {
|
|
5
10
|
AuditEventEnvelope,
|
|
@@ -11,8 +16,17 @@ import type {
|
|
|
11
16
|
WriteRecord,
|
|
12
17
|
WriterToken,
|
|
13
18
|
} from "../uok/contracts.ts";
|
|
14
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
CURRENT_UOK_CONTRACT_VERSION,
|
|
21
|
+
normalizeAuditEvent,
|
|
22
|
+
validateAuditEvent,
|
|
23
|
+
validateDispatchEnvelope,
|
|
24
|
+
validateTurnResult,
|
|
25
|
+
} from "../uok/contracts.ts";
|
|
26
|
+
import { buildAuditEnvelope, emitUokAuditEvent } from "../uok/audit.ts";
|
|
15
27
|
import { buildDispatchEnvelope, explainDispatch } from "../uok/dispatch-envelope.ts";
|
|
28
|
+
import { buildTurnTimeline } from "../uok/timeline.ts";
|
|
29
|
+
import { _getAdapter, closeDatabase, openDatabase } from "../gsd-db.ts";
|
|
16
30
|
|
|
17
31
|
test("uok contracts serialize/deserialize turn envelopes", () => {
|
|
18
32
|
const contract: TurnContract = {
|
|
@@ -37,6 +51,7 @@ test("uok contracts serialize/deserialize turn envelopes", () => {
|
|
|
37
51
|
};
|
|
38
52
|
|
|
39
53
|
const result: TurnResult = {
|
|
54
|
+
version: CURRENT_UOK_CONTRACT_VERSION,
|
|
40
55
|
traceId: contract.traceId,
|
|
41
56
|
turnId: contract.turnId,
|
|
42
57
|
iteration: contract.iteration,
|
|
@@ -56,8 +71,10 @@ test("uok contracts serialize/deserialize turn envelopes", () => {
|
|
|
56
71
|
|
|
57
72
|
const roundTrip = JSON.parse(JSON.stringify(result)) as TurnResult;
|
|
58
73
|
assert.equal(roundTrip.turnId, "turn-1");
|
|
74
|
+
assert.equal(roundTrip.version, CURRENT_UOK_CONTRACT_VERSION);
|
|
59
75
|
assert.equal(roundTrip.gateResults?.[0]?.gateId, "Q3");
|
|
60
76
|
assert.equal(roundTrip.phaseResults.length, 3);
|
|
77
|
+
assert.equal(validateTurnResult(roundTrip).ok, true);
|
|
61
78
|
});
|
|
62
79
|
|
|
63
80
|
test("uok contracts include required DAG node kinds", () => {
|
|
@@ -84,9 +101,11 @@ test("uok audit envelope includes trace/turn/causality fields", () => {
|
|
|
84
101
|
});
|
|
85
102
|
|
|
86
103
|
assert.equal(event.traceId, "trace-xyz");
|
|
104
|
+
assert.equal(event.version, CURRENT_UOK_CONTRACT_VERSION);
|
|
87
105
|
assert.equal(event.turnId, "turn-xyz");
|
|
88
106
|
assert.equal(event.causedBy, "turn-start");
|
|
89
107
|
assert.equal(event.payload.status, "completed");
|
|
108
|
+
assert.equal(validateAuditEvent(event).ok, true);
|
|
90
109
|
});
|
|
91
110
|
|
|
92
111
|
test("uok dispatch envelope carries scheduler reason and constraints", () => {
|
|
@@ -107,9 +126,98 @@ test("uok dispatch envelope carries scheduler reason and constraints", () => {
|
|
|
107
126
|
});
|
|
108
127
|
|
|
109
128
|
assert.equal(envelope.nodeKind, "unit");
|
|
129
|
+
assert.equal(envelope.version, CURRENT_UOK_CONTRACT_VERSION);
|
|
110
130
|
assert.equal(envelope.reason.reasonCode, "dependency");
|
|
111
131
|
assert.deepEqual(envelope.constraints?.dependsOn, ["plan-gate"]);
|
|
112
132
|
assert.ok(explainDispatch(envelope).includes("execute-task M001/S01/T01"));
|
|
133
|
+
assert.equal(validateDispatchEnvelope(envelope).ok, true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("uok contracts normalize legacy records without losing payload fields", () => {
|
|
137
|
+
const legacy = {
|
|
138
|
+
eventId: "event-legacy",
|
|
139
|
+
traceId: "trace-legacy",
|
|
140
|
+
category: "orchestration",
|
|
141
|
+
type: "turn-result",
|
|
142
|
+
ts: new Date().toISOString(),
|
|
143
|
+
payload: { status: "completed", extra: "preserved" },
|
|
144
|
+
} as AuditEventEnvelope;
|
|
145
|
+
|
|
146
|
+
const normalized = normalizeAuditEvent(legacy);
|
|
147
|
+
assert.equal(normalized.version, "0");
|
|
148
|
+
assert.equal(normalized.payload.extra, "preserved");
|
|
149
|
+
assert.equal(validateAuditEvent(legacy).ok, true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("uok audit emission writes DB as authoritative before jsonl projection", (t) => {
|
|
153
|
+
const basePath = mkdtempSync(join(tmpdir(), "gsd-uok-db-audit-"));
|
|
154
|
+
mkdirSync(join(basePath, ".gsd"), { recursive: true });
|
|
155
|
+
t.after(() => {
|
|
156
|
+
closeDatabase();
|
|
157
|
+
rmSync(basePath, { recursive: true, force: true });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
assert.equal(openDatabase(join(basePath, ".gsd", "gsd.db")), true);
|
|
161
|
+
emitUokAuditEvent(
|
|
162
|
+
basePath,
|
|
163
|
+
buildAuditEnvelope({
|
|
164
|
+
traceId: "trace-db",
|
|
165
|
+
turnId: "turn-db",
|
|
166
|
+
category: "orchestration",
|
|
167
|
+
type: "turn-start",
|
|
168
|
+
payload: { unitType: "execute-task" },
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const row = _getAdapter()!.prepare(
|
|
173
|
+
"SELECT payload_json FROM audit_events WHERE trace_id = 'trace-db' AND turn_id = 'turn-db'",
|
|
174
|
+
).get() as { payload_json: string } | undefined;
|
|
175
|
+
assert.ok(row, "DB audit row should be written");
|
|
176
|
+
assert.equal(JSON.parse(row.payload_json).contractVersion, CURRENT_UOK_CONTRACT_VERSION);
|
|
177
|
+
|
|
178
|
+
const projection = readFileSync(join(basePath, ".gsd", "audit", "events.jsonl"), "utf-8");
|
|
179
|
+
assert.ok(projection.includes("trace-db"), "jsonl projection should still be written");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("uok timeline prefers DB records over jsonl projection when DB is available", (t) => {
|
|
183
|
+
const basePath = mkdtempSync(join(tmpdir(), "gsd-uok-timeline-"));
|
|
184
|
+
const auditDir = join(basePath, ".gsd", "audit");
|
|
185
|
+
mkdirSync(auditDir, { recursive: true });
|
|
186
|
+
writeFileSync(
|
|
187
|
+
join(auditDir, "events.jsonl"),
|
|
188
|
+
`${JSON.stringify({
|
|
189
|
+
version: CURRENT_UOK_CONTRACT_VERSION,
|
|
190
|
+
eventId: "jsonl-only",
|
|
191
|
+
traceId: "trace-timeline",
|
|
192
|
+
turnId: "turn-timeline",
|
|
193
|
+
category: "orchestration",
|
|
194
|
+
type: "jsonl-projection",
|
|
195
|
+
ts: "2026-01-01T00:00:00.000Z",
|
|
196
|
+
payload: {},
|
|
197
|
+
})}\n`,
|
|
198
|
+
);
|
|
199
|
+
t.after(() => {
|
|
200
|
+
closeDatabase();
|
|
201
|
+
rmSync(basePath, { recursive: true, force: true });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
assert.equal(openDatabase(join(basePath, ".gsd", "gsd.db")), true);
|
|
205
|
+
emitUokAuditEvent(
|
|
206
|
+
basePath,
|
|
207
|
+
buildAuditEnvelope({
|
|
208
|
+
traceId: "trace-timeline",
|
|
209
|
+
turnId: "turn-timeline",
|
|
210
|
+
category: "orchestration",
|
|
211
|
+
type: "db-authoritative",
|
|
212
|
+
payload: {},
|
|
213
|
+
}),
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const timeline = buildTurnTimeline(basePath, { traceId: "trace-timeline", turnId: "turn-timeline" });
|
|
217
|
+
assert.equal(timeline.authoritative, "db");
|
|
218
|
+
assert.equal(timeline.degraded, false);
|
|
219
|
+
assert.ok(timeline.entries.some((entry) => entry.type === "db-authoritative"));
|
|
220
|
+
assert.equal(timeline.entries.some((entry) => entry.type === "jsonl-projection"), false);
|
|
113
221
|
});
|
|
114
222
|
|
|
115
223
|
test("uok writer records serialize sequence metadata", () => {
|
|
@@ -63,3 +63,101 @@ test("uok turn observer adds writer sequence metadata to audit events", (t) => {
|
|
|
63
63
|
assert.equal(payloads[1]?.writeSequence, 2);
|
|
64
64
|
assert.equal(typeof payloads[0]?.writerTokenId, "string");
|
|
65
65
|
});
|
|
66
|
+
|
|
67
|
+
test("uok turn observer releases writer token when validation throws", (t) => {
|
|
68
|
+
const basePath = mkdtempSync(join(tmpdir(), "gsd-uok-loop-writer-throw-"));
|
|
69
|
+
resetWriterTokensForTests();
|
|
70
|
+
t.after(() => {
|
|
71
|
+
resetWriterTokensForTests();
|
|
72
|
+
rmSync(basePath, { recursive: true, force: true });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const observer = createTurnObserver({
|
|
76
|
+
basePath,
|
|
77
|
+
gitAction: "status-only",
|
|
78
|
+
gitPush: false,
|
|
79
|
+
enableAudit: false,
|
|
80
|
+
enableGitops: false,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
observer.onTurnStart({
|
|
84
|
+
basePath,
|
|
85
|
+
traceId: "trace-throw",
|
|
86
|
+
turnId: "turn-throw",
|
|
87
|
+
iteration: 1,
|
|
88
|
+
unitType: "execute-task",
|
|
89
|
+
unitId: "M001/S01/T01",
|
|
90
|
+
startedAt: new Date().toISOString(),
|
|
91
|
+
});
|
|
92
|
+
assert.equal(hasActiveWriterToken(basePath, "turn-throw"), true);
|
|
93
|
+
|
|
94
|
+
// Invalid payload (missing required fields like status/finishedAt) should
|
|
95
|
+
// trigger validateTurnResult to fail and throw.
|
|
96
|
+
assert.throws(() => {
|
|
97
|
+
observer.onTurnResult({
|
|
98
|
+
traceId: "trace-throw",
|
|
99
|
+
turnId: "turn-throw",
|
|
100
|
+
// @ts-expect-error intentionally invalid for test
|
|
101
|
+
iteration: "not-a-number",
|
|
102
|
+
unitType: "execute-task",
|
|
103
|
+
unitId: "M001/S01/T01",
|
|
104
|
+
status: "completed",
|
|
105
|
+
failureClass: "none",
|
|
106
|
+
phaseResults: [],
|
|
107
|
+
startedAt: new Date().toISOString(),
|
|
108
|
+
finishedAt: new Date().toISOString(),
|
|
109
|
+
});
|
|
110
|
+
}, /Invalid UOK turn result/);
|
|
111
|
+
|
|
112
|
+
// Cleanup must run in finally — token released, no leaked state.
|
|
113
|
+
assert.equal(hasActiveWriterToken(basePath, "turn-throw"), false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("uok turn observer falls back to cached phaseResults when result.phaseResults is missing", (t) => {
|
|
117
|
+
const basePath = mkdtempSync(join(tmpdir(), "gsd-uok-loop-writer-missing-"));
|
|
118
|
+
resetWriterTokensForTests();
|
|
119
|
+
t.after(() => {
|
|
120
|
+
resetWriterTokensForTests();
|
|
121
|
+
rmSync(basePath, { recursive: true, force: true });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const observer = createTurnObserver({
|
|
125
|
+
basePath,
|
|
126
|
+
gitAction: "status-only",
|
|
127
|
+
gitPush: false,
|
|
128
|
+
enableAudit: false,
|
|
129
|
+
enableGitops: false,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
observer.onTurnStart({
|
|
133
|
+
basePath,
|
|
134
|
+
traceId: "trace-missing",
|
|
135
|
+
turnId: "turn-missing",
|
|
136
|
+
iteration: 1,
|
|
137
|
+
unitType: "execute-task",
|
|
138
|
+
unitId: "M001/S01/T01",
|
|
139
|
+
startedAt: new Date().toISOString(),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Without the Array.isArray guard, accessing result.phaseResults.length on a
|
|
143
|
+
// payload where phaseResults is undefined would throw TypeError before
|
|
144
|
+
// validateTurnResult could surface a structured error. The guard must defer
|
|
145
|
+
// to the cached phaseResults fallback so the turn completes cleanly.
|
|
146
|
+
assert.doesNotThrow(() => {
|
|
147
|
+
observer.onTurnResult({
|
|
148
|
+
traceId: "trace-missing",
|
|
149
|
+
turnId: "turn-missing",
|
|
150
|
+
iteration: 1,
|
|
151
|
+
unitType: "execute-task",
|
|
152
|
+
unitId: "M001/S01/T01",
|
|
153
|
+
status: "completed",
|
|
154
|
+
failureClass: "none",
|
|
155
|
+
// @ts-expect-error intentionally missing for test
|
|
156
|
+
phaseResults: undefined,
|
|
157
|
+
startedAt: new Date().toISOString(),
|
|
158
|
+
finishedAt: new Date().toISOString(),
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
assert.equal(hasActiveWriterToken(basePath, "turn-missing"), false);
|
|
163
|
+
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// GSD2 UOK Audit Events and DB-First Projection Writes
|
|
2
|
+
|
|
1
3
|
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync } from "node:fs";
|
|
2
4
|
import { join } from "node:path";
|
|
3
5
|
import { randomUUID } from "node:crypto";
|
|
@@ -6,7 +8,7 @@ import { isStaleWrite } from "../auto/turn-epoch.js";
|
|
|
6
8
|
import { withFileLockSync } from "../file-lock.js";
|
|
7
9
|
import { gsdRoot } from "../paths.js";
|
|
8
10
|
import { isDbAvailable, insertAuditEvent } from "../gsd-db.js";
|
|
9
|
-
import type
|
|
11
|
+
import { CURRENT_UOK_CONTRACT_VERSION, validateAuditEvent, type AuditEventEnvelope } from "./contracts.js";
|
|
10
12
|
|
|
11
13
|
function auditLogPath(basePath: string): string {
|
|
12
14
|
return join(gsdRoot(basePath), "audit", "events.jsonl");
|
|
@@ -25,6 +27,7 @@ export function buildAuditEnvelope(args: {
|
|
|
25
27
|
payload?: Record<string, unknown>;
|
|
26
28
|
}): AuditEventEnvelope {
|
|
27
29
|
return {
|
|
30
|
+
version: CURRENT_UOK_CONTRACT_VERSION,
|
|
28
31
|
eventId: randomUUID(),
|
|
29
32
|
traceId: args.traceId,
|
|
30
33
|
turnId: args.turnId,
|
|
@@ -39,6 +42,26 @@ export function buildAuditEnvelope(args: {
|
|
|
39
42
|
export function emitUokAuditEvent(basePath: string, event: AuditEventEnvelope): void {
|
|
40
43
|
// Drop writes from a turn superseded by timeout recovery / cancellation.
|
|
41
44
|
if (isStaleWrite("uok-audit")) return;
|
|
45
|
+
const validation = validateAuditEvent(event);
|
|
46
|
+
if (!validation.ok) {
|
|
47
|
+
throw new Error(`Invalid UOK audit event: ${validation.issues.map((issue) => `${issue.path}: ${issue.message}`).join("; ")}`);
|
|
48
|
+
}
|
|
49
|
+
const canonical = validation.value;
|
|
50
|
+
|
|
51
|
+
if (isDbAvailable()) {
|
|
52
|
+
try {
|
|
53
|
+
insertAuditEvent({
|
|
54
|
+
...canonical,
|
|
55
|
+
payload: {
|
|
56
|
+
...canonical.payload,
|
|
57
|
+
contractVersion: canonical.version ?? CURRENT_UOK_CONTRACT_VERSION,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
} catch (err) {
|
|
61
|
+
throw new Error(`DB authoritative audit write failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
42
65
|
try {
|
|
43
66
|
ensureAuditDir(basePath);
|
|
44
67
|
const path = auditLogPath(basePath);
|
|
@@ -52,18 +75,11 @@ export function emitUokAuditEvent(basePath: string, event: AuditEventEnvelope):
|
|
|
52
75
|
withFileLockSync(
|
|
53
76
|
path,
|
|
54
77
|
() => {
|
|
55
|
-
appendFileSync(path, `${JSON.stringify(
|
|
78
|
+
appendFileSync(path, `${JSON.stringify(canonical)}\n`, "utf-8");
|
|
56
79
|
},
|
|
57
80
|
{ onLocked: "skip" },
|
|
58
81
|
);
|
|
59
82
|
} catch {
|
|
60
83
|
// Best-effort: audit writes must never break orchestration.
|
|
61
84
|
}
|
|
62
|
-
|
|
63
|
-
if (!isDbAvailable()) return;
|
|
64
|
-
try {
|
|
65
|
-
insertAuditEvent(event);
|
|
66
|
-
} catch {
|
|
67
|
-
// Projection failures are non-fatal while legacy readers are still active.
|
|
68
|
-
}
|
|
69
85
|
}
|