@kontourai/flow-agents 0.1.2 → 0.3.0
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/.github/dependabot.yml +23 -0
- package/.github/workflows/release-please.yml +31 -0
- package/.github/workflows/runtime-compat.yml +118 -0
- package/CHANGELOG.md +46 -0
- package/CONTRIBUTING.md +4 -0
- package/README.md +80 -18
- package/build/src/cli/flow-kit.js +9 -4
- package/build/src/cli/init.js +215 -5
- package/build/src/cli/runtime-adapter.js +9 -5
- package/build/src/cli/telemetry-doctor.js +4 -1
- package/build/src/cli/utterance-check.js +65 -1
- package/build/src/runtime-adapters.js +34 -0
- package/build/src/tools/build-universal-bundles.js +285 -0
- package/build/src/tools/filter-installed-packs.js +3 -0
- package/build/src/tools/validate-source-tree.js +5 -1
- package/console.telemetry.json +115 -20
- package/context/scripts/telemetry/lib/config.sh +5 -1
- package/context/settings/flow-agents-settings.json +7 -0
- package/docs/_layouts/default.html +2 -0
- package/docs/context-map.md +1 -0
- package/docs/index.md +53 -4
- package/docs/integrations/conformance.md +246 -0
- package/docs/integrations/framework-adapter.md +275 -0
- package/docs/integrations/harness-install.md +213 -0
- package/docs/integrations/index.md +58 -0
- package/docs/integrations/knowledge-kit-live.md +211 -0
- package/docs/kit-authoring-guide.md +169 -0
- package/docs/north-star.md +2 -2
- package/docs/spec/runtime-hook-surface.md +525 -0
- package/docs/survey-utterance-check.md +211 -94
- package/docs/vision.md +45 -0
- package/evals/acceptance/run.sh +13 -2
- package/evals/acceptance/test_knowledge_kit_live.sh +221 -0
- package/evals/acceptance/test_opencode_harness.sh +121 -0
- package/evals/acceptance/test_pi_harness.sh +113 -0
- package/evals/integration/test_bundle_install.sh +226 -1
- package/evals/integration/test_bundle_lifecycle.sh +641 -0
- package/evals/integration/test_runtime_adapter_activation.sh +113 -1
- package/evals/integration/test_utterance_check.sh +291 -44
- package/evals/run.sh +2 -0
- package/evals/static/test_universal_bundles.sh +137 -2
- package/integrations/strands/README.md +256 -0
- package/integrations/strands/example.py +74 -0
- package/integrations/strands/examples/knowledge_kit_live.py +461 -0
- package/integrations/strands/flow_agents_strands/__init__.py +27 -0
- package/integrations/strands/flow_agents_strands/hooks.py +194 -0
- package/integrations/strands/flow_agents_strands/policy.py +348 -0
- package/integrations/strands/flow_agents_strands/steering.py +225 -0
- package/integrations/strands/flow_agents_strands/telemetry.py +238 -0
- package/integrations/strands/pyproject.toml +38 -0
- package/integrations/strands/tests/__init__.py +0 -0
- package/integrations/strands/tests/test_hooks.py +392 -0
- package/integrations/strands/tests/test_policy.py +315 -0
- package/integrations/strands/tests/test_telemetry.py +184 -0
- package/integrations/strands-ts/README.md +224 -0
- package/integrations/strands-ts/bin/conformance-shim.mjs +257 -0
- package/integrations/strands-ts/package.json +53 -0
- package/integrations/strands-ts/src/hooks.ts +312 -0
- package/integrations/strands-ts/src/index.ts +22 -0
- package/integrations/strands-ts/src/policy.ts +345 -0
- package/integrations/strands-ts/src/telemetry.ts +251 -0
- package/integrations/strands-ts/test/test-policy.ts +322 -0
- package/integrations/strands-ts/test/test-steering.ts +159 -0
- package/integrations/strands-ts/test/test-telemetry.ts +226 -0
- package/integrations/strands-ts/tsconfig.json +20 -0
- package/kits/catalog.json +6 -0
- package/kits/knowledge/adapters/default-store/index.js +821 -0
- package/kits/knowledge/adapters/flow-runner/index.js +1179 -0
- package/kits/knowledge/adapters/flow-runner/telemetry.js +174 -0
- package/kits/knowledge/docs/README.md +135 -0
- package/kits/knowledge/docs/store-contract.md +526 -0
- package/kits/knowledge/evals/consolidation/suite.test.js +1234 -0
- package/kits/knowledge/evals/contract-suite/suite.test.js +670 -0
- package/kits/knowledge/evals/ingest-compile/suite.test.js +574 -0
- package/kits/knowledge/evals/synthesis/suite.test.js +909 -0
- package/kits/knowledge/flows/compile.flow.json +60 -0
- package/kits/knowledge/flows/consolidate.flow.json +77 -0
- package/kits/knowledge/flows/ingest.flow.json +60 -0
- package/kits/knowledge/flows/store-contract.flow.json +48 -0
- package/kits/knowledge/flows/synthesize.flow.json +77 -0
- package/kits/knowledge/kit.json +78 -0
- package/package.json +7 -2
- package/packaging/conformance/README.md +142 -0
- package/packaging/conformance/fixtures/config-protection--allow-no-path.json +18 -0
- package/packaging/conformance/fixtures/config-protection--allow-safe-file.json +20 -0
- package/packaging/conformance/fixtures/config-protection--block-biome.json +20 -0
- package/packaging/conformance/fixtures/config-protection--block-eslintrc.json +20 -0
- package/packaging/conformance/fixtures/quality-gate--allow-no-path.json +17 -0
- package/packaging/conformance/fixtures/quality-gate--allow-nonexistent-file.json +19 -0
- package/packaging/conformance/fixtures/stop-goal-fit--allow-clean-cwd.json +17 -0
- package/packaging/conformance/fixtures/stop-goal-fit--block-strict-mode.json +23 -0
- package/packaging/conformance/fixtures/stop-goal-fit--warn-active-delivery.json +21 -0
- package/packaging/conformance/fixtures/workflow-steering--allow-no-state.json +16 -0
- package/packaging/conformance/fixtures/workflow-steering--inject-active-state.json +29 -0
- package/packaging/conformance/fixtures/workflow-steering--inject-subagent-steering.json +25 -0
- package/packaging/conformance/package.json +4 -0
- package/packaging/conformance/run-conformance.js +322 -0
- package/packaging/manifest.json +59 -0
- package/schemas/flow-agents-settings.schema.json +48 -0
- package/scripts/README.md +4 -0
- package/scripts/dogfood.js +16 -0
- package/scripts/hooks/opencode-hook-adapter.js +123 -0
- package/scripts/hooks/opencode-telemetry-hook.js +101 -0
- package/scripts/hooks/pi-hook-adapter.js +123 -0
- package/scripts/hooks/pi-telemetry-hook.js +105 -0
- package/scripts/hooks/run-hook.js +8 -0
- package/scripts/hooks/utterance-check.js +124 -22
- package/scripts/telemetry/lib/config.sh +5 -1
- package/src/cli/flow-kit.ts +10 -4
- package/src/cli/init.ts +219 -6
- package/src/cli/runtime-adapter.ts +10 -5
- package/src/cli/telemetry-doctor.ts +4 -1
- package/src/cli/utterance-check.ts +71 -1
- package/src/runtime-adapters.ts +35 -0
- package/src/tools/build-universal-bundles.ts +283 -0
- package/src/tools/filter-installed-packs.ts +3 -0
- package/src/tools/validate-source-tree.ts +5 -1
|
@@ -0,0 +1,1234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowledge Kit — Consolidation Eval Suite
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* AC1 (R2): a related event yields a consolidation proposal, not a mutation.
|
|
6
|
+
* After propose, the snapshot body is unchanged; only a "proposes" link
|
|
7
|
+
* and mutation log entry are created.
|
|
8
|
+
* AC2: applied consolidation updates exactly ONE new snapshot, links supersedes
|
|
9
|
+
* refs to the prior snapshot(s), and leaves all superseded sources queryable
|
|
10
|
+
* with provenance intact.
|
|
11
|
+
* AC3 fixture: a decision changed across 3 events (3 compiled records representing
|
|
12
|
+
* 3 decision states) — the resulting snapshot body reflects ONLY the latest
|
|
13
|
+
* decision (from the proposedBody), with provenance to all 3 compiled sources.
|
|
14
|
+
* Supersede-not-delete invariant: calling consolidate never removes any record;
|
|
15
|
+
* superseded snapshots are still returned by get() and listByType("snapshot").
|
|
16
|
+
* Gate telemetry: related-event, propose, evidence, apply gate events emitted.
|
|
17
|
+
*
|
|
18
|
+
* Run:
|
|
19
|
+
* node --test kits/knowledge/evals/consolidation/suite.test.js
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { test, describe, before, after } from "node:test";
|
|
23
|
+
import assert from "node:assert/strict";
|
|
24
|
+
import * as fs from "node:fs";
|
|
25
|
+
import * as path from "node:path";
|
|
26
|
+
import * as os from "node:os";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
|
|
29
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const KIT_ROOT = path.resolve(__dirname, "../..");
|
|
31
|
+
|
|
32
|
+
const adapterPath = path.join(KIT_ROOT, "adapters/default-store/index.js");
|
|
33
|
+
const runnerPath = path.join(KIT_ROOT, "adapters/flow-runner/index.js");
|
|
34
|
+
|
|
35
|
+
const { DefaultKnowledgeStore } = await import(adapterPath);
|
|
36
|
+
const { KnowledgeFlowRunner } = await import(runnerPath);
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Helpers
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
function makeTempDir() {
|
|
43
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "knowledge-consolidation-"));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function makeStore(dir) {
|
|
47
|
+
return new DefaultKnowledgeStore({ storeRoot: dir });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function makeRunner(store, storeDir) {
|
|
51
|
+
return new KnowledgeFlowRunner({
|
|
52
|
+
store,
|
|
53
|
+
workspace: storeDir,
|
|
54
|
+
agent: "consolidation-test-runner",
|
|
55
|
+
sessionId: "consolidation-session-001",
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readTelemetryEvents(dir) {
|
|
60
|
+
const sinkPath = path.join(dir, ".telemetry", "full.jsonl");
|
|
61
|
+
if (!fs.existsSync(sinkPath)) return [];
|
|
62
|
+
return fs.readFileSync(sinkPath, "utf8")
|
|
63
|
+
.trim()
|
|
64
|
+
.split("\n")
|
|
65
|
+
.filter(Boolean)
|
|
66
|
+
.map((line) => JSON.parse(line));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Build a baseline fixture:
|
|
71
|
+
* - 2 compiled records in category "ops.decisions"
|
|
72
|
+
* - 1 existing snapshot for topic "ops.decisions" (the "prior snapshot")
|
|
73
|
+
* Returns { store, compiledId1, compiledId2, priorSnapshotId }
|
|
74
|
+
*/
|
|
75
|
+
async function buildFixture(dir) {
|
|
76
|
+
const store = makeStore(dir);
|
|
77
|
+
|
|
78
|
+
// Create two compiled records representing decisions
|
|
79
|
+
const compiledId1 = await store.create({
|
|
80
|
+
type: "compiled",
|
|
81
|
+
title: "Decision: Use REST for public API",
|
|
82
|
+
body: "## Decision\n\nWe will use REST for the public API.",
|
|
83
|
+
category: "ops.decisions",
|
|
84
|
+
provenance: { agent: "fixture", source_ids: [] },
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const compiledId2 = await store.create({
|
|
88
|
+
type: "compiled",
|
|
89
|
+
title: "Decision: Versioning via URL path",
|
|
90
|
+
body: "## Decision\n\nVersioning will be done via URL path (/v1/, /v2/).",
|
|
91
|
+
category: "ops.decisions",
|
|
92
|
+
provenance: { agent: "fixture", source_ids: [] },
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Create a prior snapshot for the topic
|
|
96
|
+
const priorSnapshotId = await store.create({
|
|
97
|
+
type: "snapshot",
|
|
98
|
+
title: "Snapshot: ops.decisions",
|
|
99
|
+
body: "Prior snapshot body: REST API, no versioning decision yet.",
|
|
100
|
+
category: "ops.decisions",
|
|
101
|
+
tags: ["topic:ops.decisions"],
|
|
102
|
+
provenance: { agent: "fixture", source_ids: [compiledId1] },
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
return { store, compiledId1, compiledId2, priorSnapshotId };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// AC1: related event yields a consolidation proposal, not a direct mutation
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
describe("AC1 — related event yields a proposal, not a direct mutation", () => {
|
|
113
|
+
test("consolidate with decision=apply uses propose op before updating snapshot", async () => {
|
|
114
|
+
const dir = makeTempDir();
|
|
115
|
+
try {
|
|
116
|
+
const { store, compiledId1, compiledId2, priorSnapshotId } = await buildFixture(dir);
|
|
117
|
+
const runner = makeRunner(store, dir);
|
|
118
|
+
|
|
119
|
+
// Capture the prior snapshot body before consolidation
|
|
120
|
+
const beforeSnapshot = await store.get(priorSnapshotId);
|
|
121
|
+
assert.ok(beforeSnapshot.body.includes("Prior snapshot body"), "prior snapshot has expected body");
|
|
122
|
+
|
|
123
|
+
const result = await runner.consolidate(priorSnapshotId, {
|
|
124
|
+
proposedBody: "Updated: REST API with URL versioning (/v1/, /v2/).",
|
|
125
|
+
rationale: "Two compiled decisions confirm this updated summary.",
|
|
126
|
+
decision: "apply",
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
assert.ok(result.snapshotId, "result has snapshotId");
|
|
130
|
+
assert.ok(result.proposerId, "result has proposerId");
|
|
131
|
+
assert.ok(Array.isArray(result.cluster), "result has cluster array");
|
|
132
|
+
assert.ok(result.cluster.length >= 1, "cluster has at least one member");
|
|
133
|
+
assert.equal(result.decision, "apply");
|
|
134
|
+
assert.ok(result.newSnapshotId, "result has newSnapshotId after apply");
|
|
135
|
+
} finally {
|
|
136
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("proposer record has a 'proposes' link to the snapshot before mutation", async () => {
|
|
141
|
+
const dir = makeTempDir();
|
|
142
|
+
try {
|
|
143
|
+
const { store, priorSnapshotId } = await buildFixture(dir);
|
|
144
|
+
const runner = makeRunner(store, dir);
|
|
145
|
+
|
|
146
|
+
const result = await runner.consolidate(priorSnapshotId, {
|
|
147
|
+
proposedBody: "Propose link test body.",
|
|
148
|
+
rationale: "Testing proposes link.",
|
|
149
|
+
decision: "apply",
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// The proposer must have a "proposes" link to the snapshot
|
|
153
|
+
const { forward } = await store.getLinks(result.proposerId);
|
|
154
|
+
const proposesLinks = forward.filter(
|
|
155
|
+
(l) => l.target_id === result.snapshotId && l.kind === "proposes"
|
|
156
|
+
);
|
|
157
|
+
assert.ok(proposesLinks.length >= 1, "AC1: proposer has a 'proposes' link to the snapshot");
|
|
158
|
+
} finally {
|
|
159
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("store.propose is used — mutation log on snapshot has a propose entry", async () => {
|
|
164
|
+
const dir = makeTempDir();
|
|
165
|
+
try {
|
|
166
|
+
const { store, priorSnapshotId } = await buildFixture(dir);
|
|
167
|
+
const runner = makeRunner(store, dir);
|
|
168
|
+
|
|
169
|
+
await runner.consolidate(priorSnapshotId, {
|
|
170
|
+
proposedBody: "Mutation log test body.",
|
|
171
|
+
rationale: "Checking mutation log.",
|
|
172
|
+
decision: "apply",
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// The prior snapshot (which receives the propose op) should have a propose log entry
|
|
176
|
+
const snapshot = await store.get(priorSnapshotId);
|
|
177
|
+
const proposeEntries = (snapshot.mutation_log || []).filter((e) => e.op === "propose");
|
|
178
|
+
assert.ok(proposeEntries.length >= 1, "AC1: snapshot mutation log has a propose entry");
|
|
179
|
+
} finally {
|
|
180
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("rejection leaves snapshot body unchanged (proposal did not mutate)", async () => {
|
|
185
|
+
const dir = makeTempDir();
|
|
186
|
+
try {
|
|
187
|
+
const { store, priorSnapshotId } = await buildFixture(dir);
|
|
188
|
+
const runner = makeRunner(store, dir);
|
|
189
|
+
|
|
190
|
+
const beforeSnapshot = await store.get(priorSnapshotId);
|
|
191
|
+
const originalBody = beforeSnapshot.body;
|
|
192
|
+
|
|
193
|
+
await runner.consolidate(priorSnapshotId, {
|
|
194
|
+
proposedBody: "This body should NOT replace the snapshot.",
|
|
195
|
+
decision: "reject",
|
|
196
|
+
rejectReason: "AC1: verifying propose does not mutate on reject path.",
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const afterSnapshot = await store.get(priorSnapshotId);
|
|
200
|
+
assert.equal(
|
|
201
|
+
afterSnapshot.body,
|
|
202
|
+
originalBody,
|
|
203
|
+
"AC1: snapshot body unchanged after rejection — proposal did not mutate"
|
|
204
|
+
);
|
|
205
|
+
} finally {
|
|
206
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// AC2: applied consolidation updates exactly ONE snapshot, links supersedes
|
|
213
|
+
// refs, and superseded sources remain queryable with provenance intact
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
describe("AC2 — apply updates exactly ONE new snapshot, links supersedes refs, superseded sources queryable", () => {
|
|
217
|
+
test("apply creates exactly one new snapshot", async () => {
|
|
218
|
+
const dir = makeTempDir();
|
|
219
|
+
try {
|
|
220
|
+
const { store, priorSnapshotId } = await buildFixture(dir);
|
|
221
|
+
const runner = makeRunner(store, dir);
|
|
222
|
+
|
|
223
|
+
const snapshotsBefore = await store.listByType("snapshot");
|
|
224
|
+
assert.equal(snapshotsBefore.length, 1, "one prior snapshot before consolidation");
|
|
225
|
+
|
|
226
|
+
const result = await runner.consolidate(priorSnapshotId, {
|
|
227
|
+
proposedBody: "Consolidated: REST API with URL versioning.",
|
|
228
|
+
rationale: "AC2 test: one new snapshot created.",
|
|
229
|
+
decision: "apply",
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const snapshotsAfter = await store.listByType("snapshot");
|
|
233
|
+
// We may have: the prior snapshot, possibly a placeholder, and the new snapshot.
|
|
234
|
+
// The key constraint is that newSnapshotId is exactly one new record.
|
|
235
|
+
assert.ok(result.newSnapshotId, "AC2: newSnapshotId is set");
|
|
236
|
+
const newSnap = await store.get(result.newSnapshotId);
|
|
237
|
+
assert.ok(newSnap, "AC2: new snapshot record exists");
|
|
238
|
+
assert.equal(newSnap.type, "snapshot", "AC2: new record is type snapshot");
|
|
239
|
+
assert.equal(
|
|
240
|
+
newSnap.body,
|
|
241
|
+
"Consolidated: REST API with URL versioning.",
|
|
242
|
+
"AC2: new snapshot has the proposed body"
|
|
243
|
+
);
|
|
244
|
+
} finally {
|
|
245
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("new snapshot links supersedes refs to the prior snapshot", async () => {
|
|
250
|
+
const dir = makeTempDir();
|
|
251
|
+
try {
|
|
252
|
+
const { store, priorSnapshotId } = await buildFixture(dir);
|
|
253
|
+
const runner = makeRunner(store, dir);
|
|
254
|
+
|
|
255
|
+
const result = await runner.consolidate(priorSnapshotId, {
|
|
256
|
+
proposedBody: "New snapshot with supersedes link.",
|
|
257
|
+
rationale: "AC2 test: verify supersedes links.",
|
|
258
|
+
decision: "apply",
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// The new snapshot must have a "supersedes" link to the prior snapshot
|
|
262
|
+
const { forward } = await store.getLinks(result.newSnapshotId);
|
|
263
|
+
const supersededLinks = forward.filter((l) => l.kind === "supersedes");
|
|
264
|
+
assert.ok(supersededLinks.length >= 1, "AC2: new snapshot has supersedes links");
|
|
265
|
+
|
|
266
|
+
// The prior snapshot must appear in supersedes links
|
|
267
|
+
const priorInLinks = supersededLinks.some((l) => l.target_id === priorSnapshotId);
|
|
268
|
+
assert.ok(priorInLinks, "AC2: new snapshot links supersedes to the prior snapshot");
|
|
269
|
+
} finally {
|
|
270
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("prior snapshot is still queryable after consolidation (supersede-not-delete)", async () => {
|
|
275
|
+
const dir = makeTempDir();
|
|
276
|
+
try {
|
|
277
|
+
const { store, priorSnapshotId } = await buildFixture(dir);
|
|
278
|
+
const runner = makeRunner(store, dir);
|
|
279
|
+
|
|
280
|
+
const priorBody = (await store.get(priorSnapshotId)).body;
|
|
281
|
+
|
|
282
|
+
await runner.consolidate(priorSnapshotId, {
|
|
283
|
+
proposedBody: "New snapshot body.",
|
|
284
|
+
rationale: "AC2: verify prior snapshot queryable.",
|
|
285
|
+
decision: "apply",
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// Prior snapshot must still be retrievable
|
|
289
|
+
const priorAfter = await store.get(priorSnapshotId);
|
|
290
|
+
assert.ok(priorAfter, "AC2: prior snapshot still exists after consolidation");
|
|
291
|
+
assert.equal(priorAfter.body, priorBody, "AC2: prior snapshot body is intact (not deleted)");
|
|
292
|
+
assert.equal(priorAfter.type, "snapshot", "AC2: prior snapshot type preserved");
|
|
293
|
+
} finally {
|
|
294
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("prior snapshot is returned by listByType('snapshot') after supersession", async () => {
|
|
299
|
+
const dir = makeTempDir();
|
|
300
|
+
try {
|
|
301
|
+
const { store, priorSnapshotId } = await buildFixture(dir);
|
|
302
|
+
const runner = makeRunner(store, dir);
|
|
303
|
+
|
|
304
|
+
await runner.consolidate(priorSnapshotId, {
|
|
305
|
+
proposedBody: "New snapshot body.",
|
|
306
|
+
rationale: "AC2 list test.",
|
|
307
|
+
decision: "apply",
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const allSnapshots = await store.listByType("snapshot");
|
|
311
|
+
const priorInList = allSnapshots.some((s) => s.id === priorSnapshotId);
|
|
312
|
+
assert.ok(priorInList, "AC2: prior snapshot appears in listByType('snapshot') after supersession");
|
|
313
|
+
} finally {
|
|
314
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("prior snapshot has a superseded-by mutation log entry", async () => {
|
|
319
|
+
const dir = makeTempDir();
|
|
320
|
+
try {
|
|
321
|
+
const { store, priorSnapshotId } = await buildFixture(dir);
|
|
322
|
+
const runner = makeRunner(store, dir);
|
|
323
|
+
|
|
324
|
+
const result = await runner.consolidate(priorSnapshotId, {
|
|
325
|
+
proposedBody: "New snapshot for superseded-by log test.",
|
|
326
|
+
rationale: "AC2: verify superseded-by log.",
|
|
327
|
+
decision: "apply",
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
const priorAfter = await store.get(priorSnapshotId);
|
|
331
|
+
const supersededByEntries = (priorAfter.mutation_log || []).filter(
|
|
332
|
+
(e) => e.op === "superseded-by"
|
|
333
|
+
);
|
|
334
|
+
assert.ok(supersededByEntries.length >= 1, "AC2: prior snapshot has superseded-by log entry");
|
|
335
|
+
assert.equal(
|
|
336
|
+
supersededByEntries[supersededByEntries.length - 1].new_id,
|
|
337
|
+
result.newSnapshotId,
|
|
338
|
+
"AC2: superseded-by entry references the new snapshot id"
|
|
339
|
+
);
|
|
340
|
+
} finally {
|
|
341
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("new snapshot provenance source_ids references all contributing compiled records", async () => {
|
|
346
|
+
const dir = makeTempDir();
|
|
347
|
+
try {
|
|
348
|
+
const { store, compiledId1, compiledId2, priorSnapshotId } = await buildFixture(dir);
|
|
349
|
+
const runner = makeRunner(store, dir);
|
|
350
|
+
|
|
351
|
+
const result = await runner.consolidate(priorSnapshotId, {
|
|
352
|
+
proposedBody: "New snapshot with full provenance.",
|
|
353
|
+
rationale: "AC2: verify source_ids in provenance.",
|
|
354
|
+
decision: "apply",
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
const newSnap = await store.get(result.newSnapshotId);
|
|
358
|
+
assert.ok(newSnap.provenance.source_ids, "AC2: new snapshot has provenance.source_ids");
|
|
359
|
+
assert.ok(
|
|
360
|
+
Array.isArray(newSnap.provenance.source_ids),
|
|
361
|
+
"AC2: provenance.source_ids is an array"
|
|
362
|
+
);
|
|
363
|
+
assert.ok(newSnap.provenance.source_ids.length >= 1, "AC2: at least one source_id in provenance");
|
|
364
|
+
// Every source_id must resolve to a compiled record
|
|
365
|
+
for (const srcId of newSnap.provenance.source_ids) {
|
|
366
|
+
const rec = await store.get(srcId);
|
|
367
|
+
assert.ok(rec, `AC2: source ${srcId} resolves to a record`);
|
|
368
|
+
assert.equal(rec.type, "compiled", `AC2: source ${srcId} is a compiled record`);
|
|
369
|
+
}
|
|
370
|
+
} finally {
|
|
371
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
// AC3 fixture: decision changed across 3 events → snapshot reflects ONLY the
|
|
378
|
+
// latest decision, with provenance to all 3 compiled sources
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
describe("AC3 — decision changed across 3 events: snapshot reflects latest, provenance to all 3", () => {
|
|
382
|
+
test("AC3 fixture: 3 compiled records, snapshot body has latest decision, provenance to all 3", async () => {
|
|
383
|
+
const dir = makeTempDir();
|
|
384
|
+
try {
|
|
385
|
+
const store = makeStore(dir);
|
|
386
|
+
const runner = makeRunner(store, dir);
|
|
387
|
+
|
|
388
|
+
// Event 1: initial decision
|
|
389
|
+
const compiled1 = await store.create({
|
|
390
|
+
type: "compiled",
|
|
391
|
+
title: "Decision v1: Deploy to single region",
|
|
392
|
+
body: "Decision: Deploy the service to us-east-1 only.",
|
|
393
|
+
category: "ops.deployment",
|
|
394
|
+
provenance: { agent: "fixture", source_ids: [] },
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// Event 2: decision changed
|
|
398
|
+
const compiled2 = await store.create({
|
|
399
|
+
type: "compiled",
|
|
400
|
+
title: "Decision v2: Deploy to two regions",
|
|
401
|
+
body: "Decision revised: Deploy to us-east-1 AND eu-west-1 for redundancy.",
|
|
402
|
+
category: "ops.deployment",
|
|
403
|
+
provenance: { agent: "fixture", source_ids: [compiled1] },
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// Event 3: decision changed again (latest)
|
|
407
|
+
const compiled3 = await store.create({
|
|
408
|
+
type: "compiled",
|
|
409
|
+
title: "Decision v3: Deploy to three regions",
|
|
410
|
+
body: "Decision final: Deploy to us-east-1, eu-west-1, AND ap-southeast-1.",
|
|
411
|
+
category: "ops.deployment",
|
|
412
|
+
provenance: { agent: "fixture", source_ids: [compiled1, compiled2] },
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// Run consolidation using a topic selector (no prior snapshot yet)
|
|
416
|
+
const proposedBody =
|
|
417
|
+
"## Current Deployment Decision\n\n" +
|
|
418
|
+
"Deploy to three regions: us-east-1, eu-west-1, ap-southeast-1.\n\n" +
|
|
419
|
+
"This decision supersedes earlier single-region and two-region proposals.";
|
|
420
|
+
|
|
421
|
+
const result = await runner.consolidate(
|
|
422
|
+
{ topic: "ops.deployment", category: "ops.deployment" },
|
|
423
|
+
{
|
|
424
|
+
proposedBody,
|
|
425
|
+
rationale:
|
|
426
|
+
"AC3: final deployment decision across 3 compiled events. " +
|
|
427
|
+
"Latest decision (v3: three regions) supersedes v1 and v2.",
|
|
428
|
+
decision: "apply",
|
|
429
|
+
}
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
assert.ok(result.newSnapshotId, "AC3: a new snapshot was created");
|
|
433
|
+
assert.equal(result.decision, "apply", "AC3: decision is apply");
|
|
434
|
+
|
|
435
|
+
// The new snapshot body must reflect ONLY the latest decision
|
|
436
|
+
const newSnap = await store.get(result.newSnapshotId);
|
|
437
|
+
assert.ok(newSnap, "AC3: new snapshot record exists");
|
|
438
|
+
assert.ok(
|
|
439
|
+
newSnap.body.includes("three regions"),
|
|
440
|
+
"AC3: snapshot body reflects the latest decision (three regions)"
|
|
441
|
+
);
|
|
442
|
+
assert.ok(
|
|
443
|
+
!newSnap.body.includes("single region") && !newSnap.body.includes("us-east-1 only"),
|
|
444
|
+
"AC3: snapshot body does NOT contain stale earlier decisions verbatim"
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
// Provenance must link to all 3 compiled source records
|
|
448
|
+
assert.ok(
|
|
449
|
+
Array.isArray(newSnap.provenance.source_ids),
|
|
450
|
+
"AC3: provenance.source_ids is an array"
|
|
451
|
+
);
|
|
452
|
+
// The cluster should include all 3 compiled records (same category)
|
|
453
|
+
for (const srcId of [compiled1, compiled2, compiled3]) {
|
|
454
|
+
const inCluster = result.cluster.includes(srcId);
|
|
455
|
+
const inProvenance = newSnap.provenance.source_ids.includes(srcId);
|
|
456
|
+
// At minimum, all 3 should be in the cluster
|
|
457
|
+
assert.ok(
|
|
458
|
+
inCluster,
|
|
459
|
+
`AC3: compiled record ${srcId} is in the cluster (related event)`
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// The snapshot links source records via "source" links
|
|
464
|
+
const { forward } = await store.getLinks(result.newSnapshotId);
|
|
465
|
+
const sourceLinks = forward.filter((l) => l.kind === "source");
|
|
466
|
+
assert.ok(sourceLinks.length >= 1, "AC3: new snapshot has source links to compiled records");
|
|
467
|
+
|
|
468
|
+
// All 3 compiled records must still be queryable (not deleted)
|
|
469
|
+
for (const srcId of [compiled1, compiled2, compiled3]) {
|
|
470
|
+
const rec = await store.get(srcId);
|
|
471
|
+
assert.ok(rec, `AC3: compiled source ${srcId} is still queryable after consolidation`);
|
|
472
|
+
assert.equal(rec.type, "compiled", `AC3: source ${srcId} type preserved`);
|
|
473
|
+
}
|
|
474
|
+
} finally {
|
|
475
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test("AC3: reading only the snapshot gives the latest decision (not stale earlier text)", async () => {
|
|
480
|
+
const dir = makeTempDir();
|
|
481
|
+
try {
|
|
482
|
+
const store = makeStore(dir);
|
|
483
|
+
const runner = makeRunner(store, dir);
|
|
484
|
+
|
|
485
|
+
// Three compiled records with evolving decisions
|
|
486
|
+
const c1 = await store.create({
|
|
487
|
+
type: "compiled",
|
|
488
|
+
title: "Auth Decision v1",
|
|
489
|
+
body: "Use Basic Auth for all endpoints.",
|
|
490
|
+
category: "ops.auth",
|
|
491
|
+
provenance: { agent: "fixture", source_ids: [] },
|
|
492
|
+
});
|
|
493
|
+
const c2 = await store.create({
|
|
494
|
+
type: "compiled",
|
|
495
|
+
title: "Auth Decision v2",
|
|
496
|
+
body: "Switch to API keys; Basic Auth deprecated.",
|
|
497
|
+
category: "ops.auth",
|
|
498
|
+
provenance: { agent: "fixture", source_ids: [c1] },
|
|
499
|
+
});
|
|
500
|
+
const c3 = await store.create({
|
|
501
|
+
type: "compiled",
|
|
502
|
+
title: "Auth Decision v3",
|
|
503
|
+
body: "Use OAuth2 with short-lived tokens. API keys phase out by Q3.",
|
|
504
|
+
category: "ops.auth",
|
|
505
|
+
provenance: { agent: "fixture", source_ids: [c1, c2] },
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
const latestDecision =
|
|
509
|
+
"Current auth decision: OAuth2 with short-lived tokens. " +
|
|
510
|
+
"API keys phased out Q3. Basic Auth deprecated.";
|
|
511
|
+
|
|
512
|
+
const result = await runner.consolidate(
|
|
513
|
+
{ topic: "ops.auth", category: "ops.auth" },
|
|
514
|
+
{
|
|
515
|
+
proposedBody: latestDecision,
|
|
516
|
+
rationale: "Auth decision updated three times; snapshot reflects v3 only.",
|
|
517
|
+
decision: "apply",
|
|
518
|
+
}
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
// An agent reading ONLY the snapshot gets the latest decision
|
|
522
|
+
const snapshot = await store.get(result.newSnapshotId);
|
|
523
|
+
assert.equal(
|
|
524
|
+
snapshot.body,
|
|
525
|
+
latestDecision,
|
|
526
|
+
"AC3: agent reading snapshot gets only the latest decision"
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
// Provenance still links back to all 3
|
|
530
|
+
assert.ok(result.cluster.includes(c1), "AC3: c1 in cluster (provenance traceability)");
|
|
531
|
+
assert.ok(result.cluster.includes(c2), "AC3: c2 in cluster (provenance traceability)");
|
|
532
|
+
assert.ok(result.cluster.includes(c3), "AC3: c3 in cluster (provenance traceability)");
|
|
533
|
+
} finally {
|
|
534
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
// ---------------------------------------------------------------------------
|
|
540
|
+
// Supersede-not-delete invariant
|
|
541
|
+
// ---------------------------------------------------------------------------
|
|
542
|
+
|
|
543
|
+
describe("Supersede-not-delete invariant", () => {
|
|
544
|
+
test("store.supersede does not delete any record", async () => {
|
|
545
|
+
const dir = makeTempDir();
|
|
546
|
+
try {
|
|
547
|
+
const store = makeStore(dir);
|
|
548
|
+
|
|
549
|
+
const oldId = await store.create({
|
|
550
|
+
type: "snapshot",
|
|
551
|
+
title: "Old Snapshot",
|
|
552
|
+
body: "Old snapshot body.",
|
|
553
|
+
category: "ops.test",
|
|
554
|
+
tags: ["topic:ops.test"],
|
|
555
|
+
provenance: { agent: "tester" },
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
const newId = await store.create({
|
|
559
|
+
type: "snapshot",
|
|
560
|
+
title: "New Snapshot",
|
|
561
|
+
body: "New snapshot body.",
|
|
562
|
+
category: "ops.test",
|
|
563
|
+
tags: ["topic:ops.test"],
|
|
564
|
+
provenance: { agent: "tester" },
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
await store.supersede(newId, [oldId], {
|
|
568
|
+
agent: "tester",
|
|
569
|
+
rationale: "Supersede invariant test.",
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
// Old record must still exist
|
|
573
|
+
const old = await store.get(oldId);
|
|
574
|
+
assert.ok(old, "supersede-not-delete: old record still exists after supersede");
|
|
575
|
+
assert.equal(old.body, "Old snapshot body.", "supersede-not-delete: old body intact");
|
|
576
|
+
|
|
577
|
+
// New record has supersedes link
|
|
578
|
+
const { forward } = await store.getLinks(newId);
|
|
579
|
+
assert.ok(
|
|
580
|
+
forward.some((l) => l.target_id === oldId && l.kind === "supersedes"),
|
|
581
|
+
"supersede-not-delete: new record has supersedes link"
|
|
582
|
+
);
|
|
583
|
+
} finally {
|
|
584
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
test("supersede requires agent (missing agent throws MISSING_EVIDENCE)", async () => {
|
|
589
|
+
const dir = makeTempDir();
|
|
590
|
+
try {
|
|
591
|
+
const store = makeStore(dir);
|
|
592
|
+
|
|
593
|
+
const aId = await store.create({
|
|
594
|
+
type: "snapshot",
|
|
595
|
+
title: "A",
|
|
596
|
+
body: "a",
|
|
597
|
+
category: "ops.test",
|
|
598
|
+
tags: ["topic:ops.test"],
|
|
599
|
+
provenance: { agent: "tester" },
|
|
600
|
+
});
|
|
601
|
+
const bId = await store.create({
|
|
602
|
+
type: "snapshot",
|
|
603
|
+
title: "B",
|
|
604
|
+
body: "b",
|
|
605
|
+
category: "ops.test",
|
|
606
|
+
tags: ["topic:ops.test"],
|
|
607
|
+
provenance: { agent: "tester" },
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
await assert.rejects(
|
|
611
|
+
() => store.supersede(bId, [aId], { rationale: "r" }),
|
|
612
|
+
(err) => {
|
|
613
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
614
|
+
assert.match(err.message, /agent/);
|
|
615
|
+
return true;
|
|
616
|
+
}
|
|
617
|
+
);
|
|
618
|
+
} finally {
|
|
619
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
test("supersede requires rationale (missing rationale throws MISSING_EVIDENCE)", async () => {
|
|
624
|
+
const dir = makeTempDir();
|
|
625
|
+
try {
|
|
626
|
+
const store = makeStore(dir);
|
|
627
|
+
|
|
628
|
+
const aId = await store.create({
|
|
629
|
+
type: "snapshot",
|
|
630
|
+
title: "A",
|
|
631
|
+
body: "a",
|
|
632
|
+
category: "ops.test",
|
|
633
|
+
tags: ["topic:ops.test"],
|
|
634
|
+
provenance: { agent: "tester" },
|
|
635
|
+
});
|
|
636
|
+
const bId = await store.create({
|
|
637
|
+
type: "snapshot",
|
|
638
|
+
title: "B",
|
|
639
|
+
body: "b",
|
|
640
|
+
category: "ops.test",
|
|
641
|
+
tags: ["topic:ops.test"],
|
|
642
|
+
provenance: { agent: "tester" },
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
await assert.rejects(
|
|
646
|
+
() => store.supersede(bId, [aId], { agent: "tester" }),
|
|
647
|
+
(err) => {
|
|
648
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
649
|
+
assert.match(err.message, /rationale/);
|
|
650
|
+
return true;
|
|
651
|
+
}
|
|
652
|
+
);
|
|
653
|
+
} finally {
|
|
654
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
test("supersede requires non-empty supersededIds array", async () => {
|
|
659
|
+
const dir = makeTempDir();
|
|
660
|
+
try {
|
|
661
|
+
const store = makeStore(dir);
|
|
662
|
+
|
|
663
|
+
const bId = await store.create({
|
|
664
|
+
type: "snapshot",
|
|
665
|
+
title: "B",
|
|
666
|
+
body: "b",
|
|
667
|
+
category: "ops.test",
|
|
668
|
+
tags: ["topic:ops.test"],
|
|
669
|
+
provenance: { agent: "tester" },
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
await assert.rejects(
|
|
673
|
+
() => store.supersede(bId, [], { agent: "tester", rationale: "r" }),
|
|
674
|
+
(err) => {
|
|
675
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
676
|
+
return true;
|
|
677
|
+
}
|
|
678
|
+
);
|
|
679
|
+
} finally {
|
|
680
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
test("supersede rejects nonexistent new_id", async () => {
|
|
685
|
+
const dir = makeTempDir();
|
|
686
|
+
try {
|
|
687
|
+
const store = makeStore(dir);
|
|
688
|
+
|
|
689
|
+
const aId = await store.create({
|
|
690
|
+
type: "snapshot",
|
|
691
|
+
title: "A",
|
|
692
|
+
body: "a",
|
|
693
|
+
category: "ops.test",
|
|
694
|
+
tags: ["topic:ops.test"],
|
|
695
|
+
provenance: { agent: "tester" },
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
await assert.rejects(
|
|
699
|
+
() => store.supersede("nonexistent-id", [aId], { agent: "tester", rationale: "r" }),
|
|
700
|
+
{ code: "NOT_FOUND" }
|
|
701
|
+
);
|
|
702
|
+
} finally {
|
|
703
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test("superseded record has reverse link from superseding record", async () => {
|
|
708
|
+
const dir = makeTempDir();
|
|
709
|
+
try {
|
|
710
|
+
const store = makeStore(dir);
|
|
711
|
+
|
|
712
|
+
const oldId = await store.create({
|
|
713
|
+
type: "snapshot",
|
|
714
|
+
title: "Old",
|
|
715
|
+
body: "old body",
|
|
716
|
+
category: "ops.test",
|
|
717
|
+
tags: ["topic:ops.test"],
|
|
718
|
+
provenance: { agent: "tester" },
|
|
719
|
+
});
|
|
720
|
+
const newId = await store.create({
|
|
721
|
+
type: "snapshot",
|
|
722
|
+
title: "New",
|
|
723
|
+
body: "new body",
|
|
724
|
+
category: "ops.test",
|
|
725
|
+
tags: ["topic:ops.test"],
|
|
726
|
+
provenance: { agent: "tester" },
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
await store.supersede(newId, [oldId], {
|
|
730
|
+
agent: "tester",
|
|
731
|
+
rationale: "Reverse link test.",
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
const { reverse } = await store.getLinks(oldId);
|
|
735
|
+
assert.ok(
|
|
736
|
+
reverse.some((l) => l.source_id === newId && l.kind === "supersedes"),
|
|
737
|
+
"supersede: old record has reverse link showing who supersedes it"
|
|
738
|
+
);
|
|
739
|
+
} finally {
|
|
740
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
test("snapshot type accepted in create", async () => {
|
|
745
|
+
const dir = makeTempDir();
|
|
746
|
+
try {
|
|
747
|
+
const store = makeStore(dir);
|
|
748
|
+
|
|
749
|
+
const id = await store.create({
|
|
750
|
+
type: "snapshot",
|
|
751
|
+
title: "Test Snapshot",
|
|
752
|
+
body: "snapshot body",
|
|
753
|
+
category: "ops.test",
|
|
754
|
+
tags: ["topic:ops.test"],
|
|
755
|
+
provenance: { agent: "tester" },
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
assert.ok(id, "snapshot record created");
|
|
759
|
+
const rec = await store.get(id);
|
|
760
|
+
assert.equal(rec.type, "snapshot");
|
|
761
|
+
} finally {
|
|
762
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
// ---------------------------------------------------------------------------
|
|
768
|
+
// Contract suite extensions: snapshot semantics in contract suite
|
|
769
|
+
// ---------------------------------------------------------------------------
|
|
770
|
+
|
|
771
|
+
describe("Contract suite extensions — snapshot semantics", () => {
|
|
772
|
+
test("listByType('snapshot') returns only snapshot records", async () => {
|
|
773
|
+
const dir = makeTempDir();
|
|
774
|
+
try {
|
|
775
|
+
const store = makeStore(dir);
|
|
776
|
+
|
|
777
|
+
await store.create({
|
|
778
|
+
type: "raw",
|
|
779
|
+
title: "R",
|
|
780
|
+
body: "r",
|
|
781
|
+
category: "test",
|
|
782
|
+
provenance: { agent: "tester" },
|
|
783
|
+
});
|
|
784
|
+
await store.create({
|
|
785
|
+
type: "snapshot",
|
|
786
|
+
title: "S",
|
|
787
|
+
body: "s",
|
|
788
|
+
category: "test",
|
|
789
|
+
tags: ["topic:test"],
|
|
790
|
+
provenance: { agent: "tester" },
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
const snaps = await store.listByType("snapshot");
|
|
794
|
+
assert.ok(snaps.length >= 1, "at least 1 snapshot returned");
|
|
795
|
+
assert.ok(snaps.every((r) => r.type === "snapshot"), "all returned are snapshots");
|
|
796
|
+
} finally {
|
|
797
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
798
|
+
}
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
test("snapshot round-trips: body, tags, category intact", async () => {
|
|
802
|
+
const dir = makeTempDir();
|
|
803
|
+
try {
|
|
804
|
+
const store = makeStore(dir);
|
|
805
|
+
|
|
806
|
+
const id = await store.create({
|
|
807
|
+
type: "snapshot",
|
|
808
|
+
title: "Round-trip Snapshot",
|
|
809
|
+
body: "Decision: Use GraphQL for internal APIs.",
|
|
810
|
+
category: "ops.api",
|
|
811
|
+
tags: ["topic:ops.api", "priority:high"],
|
|
812
|
+
provenance: { agent: "tester", source_ids: [] },
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
const rec = await store.get(id);
|
|
816
|
+
assert.equal(rec.type, "snapshot");
|
|
817
|
+
assert.equal(rec.category, "ops.api");
|
|
818
|
+
assert.ok(rec.tags.includes("topic:ops.api"), "topic tag preserved");
|
|
819
|
+
assert.equal(rec.body, "Decision: Use GraphQL for internal APIs.");
|
|
820
|
+
} finally {
|
|
821
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
test("propose on snapshot creates proposes link (propose op extended to snapshot)", async () => {
|
|
826
|
+
const dir = makeTempDir();
|
|
827
|
+
try {
|
|
828
|
+
const store = makeStore(dir);
|
|
829
|
+
|
|
830
|
+
const snapId = await store.create({
|
|
831
|
+
type: "snapshot",
|
|
832
|
+
title: "Target Snapshot",
|
|
833
|
+
body: "current decisions",
|
|
834
|
+
category: "ops.test",
|
|
835
|
+
tags: ["topic:ops.test"],
|
|
836
|
+
provenance: { agent: "tester" },
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
const proposerId = await store.create({
|
|
840
|
+
type: "compiled",
|
|
841
|
+
title: "Proposer",
|
|
842
|
+
body: "proposing new snapshot content",
|
|
843
|
+
category: "ops.test",
|
|
844
|
+
provenance: { agent: "tester", source_ids: [] },
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
await store.propose(snapId, proposerId, {
|
|
848
|
+
agent: "tester",
|
|
849
|
+
proposal: "Updated decisions summary.",
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
const { forward } = await store.getLinks(proposerId);
|
|
853
|
+
assert.ok(
|
|
854
|
+
forward.some((l) => l.target_id === snapId && l.kind === "proposes"),
|
|
855
|
+
"propose on snapshot creates proposes link"
|
|
856
|
+
);
|
|
857
|
+
} finally {
|
|
858
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
859
|
+
}
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
test("reject on snapshot does not mutate snapshot body (supersede-not-delete extends to reject)", async () => {
|
|
863
|
+
const dir = makeTempDir();
|
|
864
|
+
try {
|
|
865
|
+
const store = makeStore(dir);
|
|
866
|
+
|
|
867
|
+
const snapId = await store.create({
|
|
868
|
+
type: "snapshot",
|
|
869
|
+
title: "Stable Snapshot",
|
|
870
|
+
body: "stable body",
|
|
871
|
+
category: "ops.test",
|
|
872
|
+
tags: ["topic:ops.test"],
|
|
873
|
+
provenance: { agent: "tester" },
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
const proposerId = await store.create({
|
|
877
|
+
type: "compiled",
|
|
878
|
+
title: "Proposer",
|
|
879
|
+
body: "proposer content",
|
|
880
|
+
category: "ops.test",
|
|
881
|
+
provenance: { agent: "tester", source_ids: [] },
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
await store.propose(snapId, proposerId, {
|
|
885
|
+
agent: "tester",
|
|
886
|
+
proposal: "Controversial change.",
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
const bodyBefore = (await store.get(snapId)).body;
|
|
890
|
+
|
|
891
|
+
await store.reject(snapId, proposerId, {
|
|
892
|
+
agent: "tester",
|
|
893
|
+
reason: "Not aligned.",
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
const snapAfter = await store.get(snapId);
|
|
897
|
+
assert.equal(
|
|
898
|
+
snapAfter.body,
|
|
899
|
+
bodyBefore,
|
|
900
|
+
"reject on snapshot leaves body unchanged"
|
|
901
|
+
);
|
|
902
|
+
} finally {
|
|
903
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
// ---------------------------------------------------------------------------
|
|
909
|
+
// Gate telemetry: consolidate emits events at each gate
|
|
910
|
+
// ---------------------------------------------------------------------------
|
|
911
|
+
|
|
912
|
+
describe("Gate telemetry — consolidate emits events at each gate", () => {
|
|
913
|
+
test("consolidate emits related-event, propose, evidence, apply gate events", async () => {
|
|
914
|
+
const dir = makeTempDir();
|
|
915
|
+
try {
|
|
916
|
+
const { store, priorSnapshotId } = await buildFixture(dir);
|
|
917
|
+
const runner = makeRunner(store, dir);
|
|
918
|
+
|
|
919
|
+
await runner.consolidate(priorSnapshotId, {
|
|
920
|
+
proposedBody: "Telemetry gate test body.",
|
|
921
|
+
rationale: "Checking all gate events are emitted.",
|
|
922
|
+
decision: "apply",
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
const events = readTelemetryEvents(dir);
|
|
926
|
+
assert.ok(events.length > 0, "telemetry events were emitted");
|
|
927
|
+
|
|
928
|
+
const gateNames = events
|
|
929
|
+
.filter((e) => e.tool?.name)
|
|
930
|
+
.map((e) => e.tool.name);
|
|
931
|
+
|
|
932
|
+
const hasRelatedEvent = gateNames.some((n) => n.includes("related-event-gate"));
|
|
933
|
+
const hasPropose = gateNames.some((n) => n.includes("propose-gate"));
|
|
934
|
+
const hasEvidence = gateNames.some((n) => n.includes("evidence-gate"));
|
|
935
|
+
const hasApply = gateNames.some((n) => n.includes("apply-gate"));
|
|
936
|
+
|
|
937
|
+
assert.ok(hasRelatedEvent, "related-event-gate events emitted");
|
|
938
|
+
assert.ok(hasPropose, "propose-gate events emitted");
|
|
939
|
+
assert.ok(hasEvidence, "evidence-gate events emitted");
|
|
940
|
+
assert.ok(hasApply, "apply-gate events emitted");
|
|
941
|
+
} finally {
|
|
942
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
943
|
+
}
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
test("gate events have entry (tool.invoke) and exit (tool.result) pairs", async () => {
|
|
947
|
+
const dir = makeTempDir();
|
|
948
|
+
try {
|
|
949
|
+
const { store, priorSnapshotId } = await buildFixture(dir);
|
|
950
|
+
const runner = makeRunner(store, dir);
|
|
951
|
+
|
|
952
|
+
await runner.consolidate(priorSnapshotId, {
|
|
953
|
+
proposedBody: "Entry/exit event pair test.",
|
|
954
|
+
rationale: "Checking event pairs.",
|
|
955
|
+
decision: "apply",
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
const events = readTelemetryEvents(dir);
|
|
959
|
+
const consolidateEvents = events.filter((e) => e.tool?.name?.includes("knowledge.consolidate"));
|
|
960
|
+
|
|
961
|
+
const gateIds = [
|
|
962
|
+
"related-event-gate",
|
|
963
|
+
"propose-gate",
|
|
964
|
+
"evidence-gate",
|
|
965
|
+
"apply-gate",
|
|
966
|
+
];
|
|
967
|
+
for (const gateId of gateIds) {
|
|
968
|
+
const gateEvents = consolidateEvents.filter((e) => e.tool?.name?.includes(gateId));
|
|
969
|
+
const invokeEvent = gateEvents.find((e) => e.event_type === "tool.invoke");
|
|
970
|
+
const resultEvent = gateEvents.find((e) => e.event_type === "tool.result");
|
|
971
|
+
assert.ok(invokeEvent, `${gateId}: tool.invoke event present`);
|
|
972
|
+
assert.ok(resultEvent, `${gateId}: tool.result event present`);
|
|
973
|
+
}
|
|
974
|
+
} finally {
|
|
975
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
976
|
+
}
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
test("rejection path emits apply-gate events with decision=reject", async () => {
|
|
980
|
+
const dir = makeTempDir();
|
|
981
|
+
try {
|
|
982
|
+
const { store, priorSnapshotId } = await buildFixture(dir);
|
|
983
|
+
const runner = makeRunner(store, dir);
|
|
984
|
+
|
|
985
|
+
await runner.consolidate(priorSnapshotId, {
|
|
986
|
+
proposedBody: "Reject path telemetry test.",
|
|
987
|
+
decision: "reject",
|
|
988
|
+
rejectReason: "Testing rejection telemetry.",
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
const events = readTelemetryEvents(dir);
|
|
992
|
+
const applyGateEvents = events.filter((e) => e.tool?.name?.includes("apply-gate"));
|
|
993
|
+
assert.ok(applyGateEvents.length >= 2, "apply-gate has entry and exit events on reject path");
|
|
994
|
+
|
|
995
|
+
const resultEvent = applyGateEvents.find(
|
|
996
|
+
(e) => e.event_type === "tool.result" && e.tool?.output?.decision === "reject"
|
|
997
|
+
);
|
|
998
|
+
assert.ok(resultEvent, "apply-gate result event has decision=reject");
|
|
999
|
+
} finally {
|
|
1000
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1001
|
+
}
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
test("consolidate telemetry events have correct schema_version and agent block", async () => {
|
|
1005
|
+
const dir = makeTempDir();
|
|
1006
|
+
try {
|
|
1007
|
+
const { store, priorSnapshotId } = await buildFixture(dir);
|
|
1008
|
+
const runner = new KnowledgeFlowRunner({
|
|
1009
|
+
store,
|
|
1010
|
+
workspace: dir,
|
|
1011
|
+
agent: "tel-consolidation-agent",
|
|
1012
|
+
sessionId: "tel-consolidation-session",
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
await runner.consolidate(priorSnapshotId, {
|
|
1016
|
+
proposedBody: "Schema version test.",
|
|
1017
|
+
rationale: "Checking schema version.",
|
|
1018
|
+
decision: "apply",
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
const events = readTelemetryEvents(dir);
|
|
1022
|
+
const consolidateEvents = events.filter((e) => e.tool?.name?.includes("knowledge.consolidate"));
|
|
1023
|
+
|
|
1024
|
+
assert.ok(consolidateEvents.length > 0, "consolidate events were emitted");
|
|
1025
|
+
for (const ev of consolidateEvents) {
|
|
1026
|
+
assert.equal(ev.schema_version, "0.3.0", "event has schema_version 0.3.0");
|
|
1027
|
+
assert.equal(ev.agent.name, "tel-consolidation-agent", "agent.name matches");
|
|
1028
|
+
assert.equal(ev.agent.runtime, "knowledge-kit", "agent.runtime is knowledge-kit");
|
|
1029
|
+
assert.equal(ev.session_id, "tel-consolidation-session", "session_id matches");
|
|
1030
|
+
}
|
|
1031
|
+
} finally {
|
|
1032
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1033
|
+
}
|
|
1034
|
+
});
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
// ---------------------------------------------------------------------------
|
|
1038
|
+
// Input validation
|
|
1039
|
+
// ---------------------------------------------------------------------------
|
|
1040
|
+
|
|
1041
|
+
describe("consolidate — input validation", () => {
|
|
1042
|
+
test("rejects missing snapshotIdOrTopic", async () => {
|
|
1043
|
+
const dir = makeTempDir();
|
|
1044
|
+
try {
|
|
1045
|
+
const store = makeStore(dir);
|
|
1046
|
+
const runner = makeRunner(store, dir);
|
|
1047
|
+
|
|
1048
|
+
await assert.rejects(
|
|
1049
|
+
() => runner.consolidate(null, {
|
|
1050
|
+
proposedBody: "body",
|
|
1051
|
+
decision: "apply",
|
|
1052
|
+
rationale: "r",
|
|
1053
|
+
}),
|
|
1054
|
+
(err) => {
|
|
1055
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
1056
|
+
return true;
|
|
1057
|
+
}
|
|
1058
|
+
);
|
|
1059
|
+
} finally {
|
|
1060
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1061
|
+
}
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
test("rejects nonexistent snapshot id", async () => {
|
|
1065
|
+
const dir = makeTempDir();
|
|
1066
|
+
try {
|
|
1067
|
+
const store = makeStore(dir);
|
|
1068
|
+
const runner = makeRunner(store, dir);
|
|
1069
|
+
|
|
1070
|
+
await assert.rejects(
|
|
1071
|
+
() => runner.consolidate("nonexistent-snapshot-id", {
|
|
1072
|
+
proposedBody: "body",
|
|
1073
|
+
decision: "apply",
|
|
1074
|
+
rationale: "r",
|
|
1075
|
+
}),
|
|
1076
|
+
(err) => {
|
|
1077
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
1078
|
+
assert.match(err.message, /snapshot not found/);
|
|
1079
|
+
return true;
|
|
1080
|
+
}
|
|
1081
|
+
);
|
|
1082
|
+
} finally {
|
|
1083
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1084
|
+
}
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
test("rejects snapshot id that points to a non-snapshot record", async () => {
|
|
1088
|
+
const dir = makeTempDir();
|
|
1089
|
+
try {
|
|
1090
|
+
const store = makeStore(dir);
|
|
1091
|
+
const runner = makeRunner(store, dir);
|
|
1092
|
+
|
|
1093
|
+
const rawId = await store.create({
|
|
1094
|
+
type: "raw",
|
|
1095
|
+
title: "Not a snapshot",
|
|
1096
|
+
body: "raw body",
|
|
1097
|
+
category: "test",
|
|
1098
|
+
provenance: { agent: "test" },
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
await assert.rejects(
|
|
1102
|
+
() => runner.consolidate(rawId, {
|
|
1103
|
+
proposedBody: "body",
|
|
1104
|
+
decision: "apply",
|
|
1105
|
+
rationale: "r",
|
|
1106
|
+
}),
|
|
1107
|
+
(err) => {
|
|
1108
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
1109
|
+
assert.match(err.message, /expected "snapshot"/);
|
|
1110
|
+
return true;
|
|
1111
|
+
}
|
|
1112
|
+
);
|
|
1113
|
+
} finally {
|
|
1114
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1115
|
+
}
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
test("rejects missing proposedBody", async () => {
|
|
1119
|
+
const dir = makeTempDir();
|
|
1120
|
+
try {
|
|
1121
|
+
const { store, priorSnapshotId } = await buildFixture(dir);
|
|
1122
|
+
const runner = makeRunner(store, dir);
|
|
1123
|
+
|
|
1124
|
+
await assert.rejects(
|
|
1125
|
+
() => runner.consolidate(priorSnapshotId, {
|
|
1126
|
+
// proposedBody omitted
|
|
1127
|
+
decision: "apply",
|
|
1128
|
+
rationale: "r",
|
|
1129
|
+
}),
|
|
1130
|
+
(err) => {
|
|
1131
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
1132
|
+
assert.match(err.message, /proposedBody is required/);
|
|
1133
|
+
return true;
|
|
1134
|
+
}
|
|
1135
|
+
);
|
|
1136
|
+
} finally {
|
|
1137
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1138
|
+
}
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
test("rejects invalid decision value", async () => {
|
|
1142
|
+
const dir = makeTempDir();
|
|
1143
|
+
try {
|
|
1144
|
+
const { store, priorSnapshotId } = await buildFixture(dir);
|
|
1145
|
+
const runner = makeRunner(store, dir);
|
|
1146
|
+
|
|
1147
|
+
await assert.rejects(
|
|
1148
|
+
() => runner.consolidate(priorSnapshotId, {
|
|
1149
|
+
proposedBody: "body",
|
|
1150
|
+
decision: "maybe",
|
|
1151
|
+
}),
|
|
1152
|
+
(err) => {
|
|
1153
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
1154
|
+
assert.match(err.message, /"apply" or "reject"/);
|
|
1155
|
+
return true;
|
|
1156
|
+
}
|
|
1157
|
+
);
|
|
1158
|
+
} finally {
|
|
1159
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1160
|
+
}
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
test("rejects missing rationale on apply", async () => {
|
|
1164
|
+
const dir = makeTempDir();
|
|
1165
|
+
try {
|
|
1166
|
+
const { store, priorSnapshotId } = await buildFixture(dir);
|
|
1167
|
+
const runner = makeRunner(store, dir);
|
|
1168
|
+
|
|
1169
|
+
await assert.rejects(
|
|
1170
|
+
() => runner.consolidate(priorSnapshotId, {
|
|
1171
|
+
proposedBody: "Some body.",
|
|
1172
|
+
decision: "apply",
|
|
1173
|
+
// rationale intentionally omitted
|
|
1174
|
+
}),
|
|
1175
|
+
(err) => {
|
|
1176
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
1177
|
+
assert.match(err.message, /rationale is required/);
|
|
1178
|
+
return true;
|
|
1179
|
+
}
|
|
1180
|
+
);
|
|
1181
|
+
} finally {
|
|
1182
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1183
|
+
}
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1186
|
+
test("rejects missing rejectReason on reject", async () => {
|
|
1187
|
+
const dir = makeTempDir();
|
|
1188
|
+
try {
|
|
1189
|
+
const { store, priorSnapshotId } = await buildFixture(dir);
|
|
1190
|
+
const runner = makeRunner(store, dir);
|
|
1191
|
+
|
|
1192
|
+
await assert.rejects(
|
|
1193
|
+
() => runner.consolidate(priorSnapshotId, {
|
|
1194
|
+
proposedBody: "Some body.",
|
|
1195
|
+
decision: "reject",
|
|
1196
|
+
// rejectReason intentionally omitted
|
|
1197
|
+
}),
|
|
1198
|
+
(err) => {
|
|
1199
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
1200
|
+
assert.match(err.message, /rejectReason is required/);
|
|
1201
|
+
return true;
|
|
1202
|
+
}
|
|
1203
|
+
);
|
|
1204
|
+
} finally {
|
|
1205
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1206
|
+
}
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
test("topicSelector with missing topic/category throws MISSING_EVIDENCE", async () => {
|
|
1210
|
+
const dir = makeTempDir();
|
|
1211
|
+
try {
|
|
1212
|
+
const store = makeStore(dir);
|
|
1213
|
+
const runner = makeRunner(store, dir);
|
|
1214
|
+
|
|
1215
|
+
await assert.rejects(
|
|
1216
|
+
() => runner.consolidate(
|
|
1217
|
+
{}, // empty topicSelector
|
|
1218
|
+
{
|
|
1219
|
+
proposedBody: "body",
|
|
1220
|
+
decision: "apply",
|
|
1221
|
+
rationale: "r",
|
|
1222
|
+
}
|
|
1223
|
+
),
|
|
1224
|
+
(err) => {
|
|
1225
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
1226
|
+
assert.match(err.message, /topicSelector must include/);
|
|
1227
|
+
return true;
|
|
1228
|
+
}
|
|
1229
|
+
);
|
|
1230
|
+
} finally {
|
|
1231
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1232
|
+
}
|
|
1233
|
+
});
|
|
1234
|
+
});
|