@nanobpm/agentic 0.1.0 → 0.4.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/README.md +1 -0
- package/dist/demand/model.d.ts +7 -4
- package/dist/demand/model.js +22 -4
- package/dist/demand/taskdef.d.ts +13 -1
- package/dist/demand/taskdef.js +20 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/protocol/conformance/frames.js +32 -4
- package/dist/protocol/index.d.ts +1 -1
- package/dist/protocol/payloads.d.ts +44 -0
- package/dist/protocol/payloads.js +61 -7
- package/dist/session/acp/client.d.ts +109 -0
- package/dist/session/acp/client.js +254 -0
- package/dist/session/acp/index.d.ts +27 -0
- package/dist/session/acp/index.js +27 -0
- package/dist/session/acp/jsonrpc.d.ts +25 -0
- package/dist/session/acp/jsonrpc.js +148 -0
- package/dist/session/acp/normalize.d.ts +48 -0
- package/dist/session/acp/normalize.js +162 -0
- package/dist/session/acp/protocol.d.ts +94 -0
- package/dist/session/acp/protocol.js +136 -0
- package/dist/session/acp/spawn.d.ts +36 -0
- package/dist/session/acp/spawn.js +68 -0
- package/dist/session/acp/transport.d.ts +62 -0
- package/dist/session/acp/transport.js +126 -0
- package/dist/session/adapter.d.ts +135 -0
- package/dist/session/adapter.js +24 -0
- package/dist/session/backend.d.ts +43 -0
- package/dist/session/backend.js +95 -0
- package/dist/session/events.d.ts +152 -0
- package/dist/session/events.js +192 -0
- package/dist/session/index.d.ts +31 -0
- package/dist/session/index.js +5 -0
- package/dist/session/log.d.ts +107 -0
- package/dist/session/log.js +351 -0
- package/dist/session/normalizer/claude.d.ts +23 -0
- package/dist/session/normalizer/claude.js +138 -0
- package/dist/session/normalizer/copilot.d.ts +27 -0
- package/dist/session/normalizer/copilot.js +105 -0
- package/dist/session/normalizer/deepseek.d.ts +11 -0
- package/dist/session/normalizer/deepseek.js +68 -0
- package/dist/session/normalizer/index.d.ts +36 -0
- package/dist/session/normalizer/index.js +29 -0
- package/dist/session/normalizer/kimi.d.ts +10 -0
- package/dist/session/normalizer/kimi.js +80 -0
- package/dist/session/normalizer/link.d.ts +36 -0
- package/dist/session/normalizer/link.js +56 -0
- package/dist/session/normalizer/pi.d.ts +13 -0
- package/dist/session/normalizer/pi.js +61 -0
- package/dist/session/normalizer/qwen.d.ts +11 -0
- package/dist/session/normalizer/qwen.js +65 -0
- package/dist/session/normalizer/record.d.ts +21 -0
- package/dist/session/normalizer/record.js +87 -0
- package/dist/session/normalizer/types.d.ts +139 -0
- package/dist/session/normalizer/types.js +31 -0
- package/dist/session/schema.d.ts +38 -0
- package/dist/session/schema.js +74 -0
- package/package.json +17 -1
- package/src/demand/model.test.ts +82 -4
- package/src/demand/model.ts +30 -9
- package/src/demand/taskdef.test.ts +51 -6
- package/src/demand/taskdef.ts +31 -2
- package/src/index.ts +1 -0
- package/src/protocol/conformance/frames.ts +32 -4
- package/src/protocol/index.ts +4 -0
- package/src/protocol/payloads.test.ts +31 -1
- package/src/protocol/payloads.ts +110 -7
- package/src/session/acp/client.test.ts +222 -0
- package/src/session/acp/client.ts +356 -0
- package/src/session/acp/fake-agent.ts +71 -0
- package/src/session/acp/index.ts +68 -0
- package/src/session/acp/integration.test.ts +37 -0
- package/src/session/acp/jsonrpc.test.ts +75 -0
- package/src/session/acp/jsonrpc.ts +171 -0
- package/src/session/acp/normalize.test.ts +150 -0
- package/src/session/acp/normalize.ts +204 -0
- package/src/session/acp/protocol.ts +178 -0
- package/src/session/acp/spawn.test.ts +45 -0
- package/src/session/acp/spawn.ts +91 -0
- package/src/session/acp/transport.test.ts +82 -0
- package/src/session/acp/transport.ts +155 -0
- package/src/session/adapter.ts +159 -0
- package/src/session/backend.test.ts +198 -0
- package/src/session/backend.ts +128 -0
- package/src/session/events.test.ts +168 -0
- package/src/session/events.ts +347 -0
- package/src/session/index.ts +67 -0
- package/src/session/log.test.ts +215 -0
- package/src/session/log.ts +525 -0
- package/src/session/normalizer/backend-integration.test.ts +103 -0
- package/src/session/normalizer/claude.test.ts +68 -0
- package/src/session/normalizer/claude.ts +136 -0
- package/src/session/normalizer/copilot.test.ts +59 -0
- package/src/session/normalizer/copilot.ts +133 -0
- package/src/session/normalizer/deepseek.ts +80 -0
- package/src/session/normalizer/index.ts +61 -0
- package/src/session/normalizer/kimi.ts +82 -0
- package/src/session/normalizer/link.test.ts +24 -0
- package/src/session/normalizer/link.ts +81 -0
- package/src/session/normalizer/pi.ts +75 -0
- package/src/session/normalizer/probe.test.ts +49 -0
- package/src/session/normalizer/qwen.test.ts +20 -0
- package/src/session/normalizer/qwen.ts +77 -0
- package/src/session/normalizer/record.test.ts +68 -0
- package/src/session/normalizer/record.ts +88 -0
- package/src/session/normalizer/resume.test.ts +25 -0
- package/src/session/normalizer/types.ts +152 -0
- package/src/session/normalizer/vectors.test.ts +180 -0
- package/src/session/schema.test.ts +84 -0
- package/src/session/schema.ts +78 -0
- package/src/session/test-db.ts +56 -0
- package/dist/blackboard/test-db.d.ts +0 -5
- package/dist/blackboard/test-db.js +0 -42
- package/dist/presence/test-db.d.ts +0 -5
- package/dist/presence/test-db.js +0 -42
- package/dist/transcript/test-db.d.ts +0 -5
- package/dist/transcript/test-db.js +0 -41
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { parseSessionEvent, type SessionEvent } from "../events.ts";
|
|
4
|
+
import { normalizeSession } from "./link.ts";
|
|
5
|
+
import { FLEET_NORMALIZERS } from "./index.ts";
|
|
6
|
+
import type { DistributiveOmit, HarnessNormalizer } from "./types.ts";
|
|
7
|
+
import { copilotNormalizer } from "./copilot.ts";
|
|
8
|
+
import { claudeNormalizer } from "./claude.ts";
|
|
9
|
+
import { qwenNormalizer } from "./qwen.ts";
|
|
10
|
+
import { kimiNormalizer } from "./kimi.ts";
|
|
11
|
+
import { piNormalizer } from "./pi.ts";
|
|
12
|
+
import { deepseekNormalizer } from "./deepseek.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The shared canonical scenario every dialect must map onto (ADR 0062 slice 3
|
|
16
|
+
* acceptance: "shared normalization test vectors validate every dialect maps
|
|
17
|
+
* onto the same canonical SessionEvent shape"). It is the *semantic* projection —
|
|
18
|
+
* the canonical union payload minus the causal-chain fields (`id`/`parentId`),
|
|
19
|
+
* which the shared linker assigns and a separate test checks. A native transcript
|
|
20
|
+
* from *any* harness in the fleet, when normalized, must equal this exactly.
|
|
21
|
+
*/
|
|
22
|
+
const CANONICAL: readonly DistributiveOmit<SessionEvent, "id" | "parentId">[] = [
|
|
23
|
+
{ type: "user", text: "please refactor the parser" },
|
|
24
|
+
{ type: "reasoning", text: "weighing two approaches", providerContinuation: "OPAQUE-CONT" },
|
|
25
|
+
{ type: "assistant", text: "here is the plan" },
|
|
26
|
+
{ type: "tool-call", callId: "t1", name: "write_file", args: { path: "a.ts" } },
|
|
27
|
+
{ type: "tool-result", callId: "t1", ok: true, result: "written" },
|
|
28
|
+
{ type: "usage", inputTokens: 100, outputTokens: 50, model: "m-1" },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/** One dialect's native transcript that must normalize to {@link CANONICAL}. */
|
|
32
|
+
interface Vector {
|
|
33
|
+
readonly normalizer: HarnessNormalizer;
|
|
34
|
+
readonly records: readonly unknown[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const VECTORS: readonly Vector[] = [
|
|
38
|
+
{
|
|
39
|
+
// @github/copilot — copilot-sdk SessionEvent stream. reasoningOpaque is the
|
|
40
|
+
// provider reasoning-continuation blob (ADR 0062 §5 resume-critical fidelity).
|
|
41
|
+
normalizer: copilotNormalizer,
|
|
42
|
+
records: [
|
|
43
|
+
{ type: "user_message", text: "please refactor the parser" },
|
|
44
|
+
{ type: "reasoning", text: "weighing two approaches", reasoningOpaque: "OPAQUE-CONT" },
|
|
45
|
+
{ type: "assistant_message", text: "here is the plan" },
|
|
46
|
+
{ type: "tool_call", id: "t1", name: "write_file", arguments: { path: "a.ts" } },
|
|
47
|
+
{ type: "tool_result", id: "t1", output: "written" },
|
|
48
|
+
{ type: "usage", inputTokens: 100, outputTokens: 50, model: "m-1" },
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
// Claude Code — `-p --output-format stream-json`. thinking.signature is the
|
|
53
|
+
// encrypted reasoning-continuation token; usage rides the terminal `result`.
|
|
54
|
+
normalizer: claudeNormalizer,
|
|
55
|
+
records: [
|
|
56
|
+
{ type: "user", message: { role: "user", content: [{ type: "text", text: "please refactor the parser" }] } },
|
|
57
|
+
{
|
|
58
|
+
type: "assistant",
|
|
59
|
+
message: {
|
|
60
|
+
role: "assistant",
|
|
61
|
+
content: [
|
|
62
|
+
{ type: "thinking", thinking: "weighing two approaches", signature: "OPAQUE-CONT" },
|
|
63
|
+
{ type: "text", text: "here is the plan" },
|
|
64
|
+
{ type: "tool_use", id: "t1", name: "write_file", input: { path: "a.ts" } },
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{ type: "user", message: { role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: "written" }] } },
|
|
69
|
+
{ type: "result", subtype: "success", model: "m-1", usage: { input_tokens: 100, output_tokens: 50 } },
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
// Qwen Code — `-p -o stream-json` (Gemini-CLI lineage).
|
|
74
|
+
normalizer: qwenNormalizer,
|
|
75
|
+
records: [
|
|
76
|
+
{ type: "content", role: "user", text: "please refactor the parser" },
|
|
77
|
+
{ type: "thought", description: "weighing two approaches", thoughtSignature: "OPAQUE-CONT" },
|
|
78
|
+
{ type: "content", role: "model", text: "here is the plan" },
|
|
79
|
+
{ type: "tool_call_request", callId: "t1", name: "write_file", args: { path: "a.ts" } },
|
|
80
|
+
{ type: "tool_call_response", callId: "t1", responseParts: "written" },
|
|
81
|
+
{ type: "usage_metadata", promptTokenCount: 100, candidatesTokenCount: 50, model: "m-1" },
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
// Kimi — `-p --output-format stream-json` (event/phase discriminated).
|
|
86
|
+
normalizer: kimiNormalizer,
|
|
87
|
+
records: [
|
|
88
|
+
{ event: "text", role: "user", text: "please refactor the parser" },
|
|
89
|
+
{ event: "reasoning", text: "weighing two approaches", continuation: "OPAQUE-CONT" },
|
|
90
|
+
{ event: "text", role: "assistant", text: "here is the plan" },
|
|
91
|
+
{ event: "tool", phase: "call", id: "t1", name: "write_file", arguments: { path: "a.ts" } },
|
|
92
|
+
{ event: "tool", phase: "result", id: "t1", ok: true, output: "written" },
|
|
93
|
+
{ event: "usage", prompt_tokens: 100, completion_tokens: 50, model: "m-1" },
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
// pi / little-coder — `-p --mode json` (JSON-RPC notifications).
|
|
98
|
+
normalizer: piNormalizer,
|
|
99
|
+
records: [
|
|
100
|
+
{ jsonrpc: "2.0", method: "session/message", params: { role: "user", text: "please refactor the parser" } },
|
|
101
|
+
{ jsonrpc: "2.0", method: "session/reasoning", params: { text: "weighing two approaches", continuation: "OPAQUE-CONT" } },
|
|
102
|
+
{ jsonrpc: "2.0", method: "session/message", params: { role: "assistant", text: "here is the plan" } },
|
|
103
|
+
{ jsonrpc: "2.0", method: "session/toolCall", params: { id: "t1", name: "write_file", args: { path: "a.ts" } } },
|
|
104
|
+
{ jsonrpc: "2.0", method: "session/toolResult", params: { id: "t1", ok: true, result: "written" } },
|
|
105
|
+
{ jsonrpc: "2.0", method: "session/usage", params: { inputTokens: 100, outputTokens: 50, model: "m-1" } },
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
// DeepSeek Harness — live SessionEvent feed (kind-tagged).
|
|
110
|
+
normalizer: deepseekNormalizer,
|
|
111
|
+
records: [
|
|
112
|
+
{ kind: "message", role: "user", content: "please refactor the parser" },
|
|
113
|
+
{ kind: "reasoning", content: "weighing two approaches", continuation: "OPAQUE-CONT" },
|
|
114
|
+
{ kind: "message", role: "assistant", content: "here is the plan" },
|
|
115
|
+
{ kind: "tool", id: "t1", name: "write_file", arguments: { path: "a.ts" } },
|
|
116
|
+
{ kind: "tool_result", id: "t1", ok: true, output: "written" },
|
|
117
|
+
{ kind: "usage", input: 100, output: 50, model: "m-1" },
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
/** Strip the linker-assigned causal-chain fields to get the semantic projection. */
|
|
123
|
+
function semantic(event: SessionEvent): DistributiveOmit<SessionEvent, "id" | "parentId"> {
|
|
124
|
+
const { id: _id, parentId: _parentId, ...rest } = event;
|
|
125
|
+
return rest;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Every dialect in the fleet has a vector (no harness ships without a test vector).
|
|
129
|
+
// Compare the harness-id *sets* (not just counts) and assert vector uniqueness, so a
|
|
130
|
+
// duplicated vector masking a missing normalizer cannot slip through a length check.
|
|
131
|
+
test("every fleet normalizer has a shared test vector", () => {
|
|
132
|
+
const vectorHarnesses = VECTORS.map((v) => v.normalizer.harness).sort();
|
|
133
|
+
const fleetHarnesses = FLEET_NORMALIZERS.map((n) => n.harness).sort();
|
|
134
|
+
assert.equal(new Set(vectorHarnesses).size, vectorHarnesses.length, "each harness must have a unique shared test vector (no duplicates)");
|
|
135
|
+
assert.deepEqual(vectorHarnesses, fleetHarnesses, "the set of test-vector harnesses must match the fleet exactly");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
for (const { normalizer, records } of VECTORS) {
|
|
139
|
+
test(`[${normalizer.harness}] native transcript normalizes onto the shared canonical shape`, () => {
|
|
140
|
+
const events = normalizeSession(normalizer, records);
|
|
141
|
+
assert.deepEqual(events.map(semantic), CANONICAL, "dialect must map onto the identical canonical SessionEvent shape");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test(`[${normalizer.harness}] normalized events form a well-formed causal chain`, () => {
|
|
145
|
+
const events = normalizeSession(normalizer, records);
|
|
146
|
+
assert.equal(events[0].parentId, null, "first event starts a fresh chain");
|
|
147
|
+
const ids = new Set<string>();
|
|
148
|
+
for (let i = 0; i < events.length; i++) {
|
|
149
|
+
assert.equal(typeof events[i].id, "string");
|
|
150
|
+
assert.ok(!ids.has(events[i].id), "ids are unique");
|
|
151
|
+
ids.add(events[i].id);
|
|
152
|
+
if (i > 0) assert.equal(events[i].parentId, events[i - 1].id, "parentId links to the prior event");
|
|
153
|
+
// The whole event re-validates against the slice-1 storage boundary.
|
|
154
|
+
assert.deepEqual(parseSessionEvent(events[i]), events[i]);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test(`[${normalizer.harness}] preserves the resume-critical provider reasoning continuation`, () => {
|
|
159
|
+
const events = normalizeSession(normalizer, records);
|
|
160
|
+
const reasoning = events.find((e) => e.type === "reasoning");
|
|
161
|
+
assert.ok(reasoning && reasoning.type === "reasoning");
|
|
162
|
+
assert.equal(reasoning.providerContinuation, "OPAQUE-CONT", "the opaque reasoning blob must survive normalization");
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
test("a resume continues the same causal chain across the boundary", () => {
|
|
167
|
+
// First leg produces a chain; the resumed leg is threaded onto the last id, so
|
|
168
|
+
// offset-free causality is preserved across the resume (parentId, not offset).
|
|
169
|
+
const first = normalizeSession(copilotNormalizer, [
|
|
170
|
+
{ type: "user_message", text: "hi" },
|
|
171
|
+
{ type: "assistant_message", text: "hello" },
|
|
172
|
+
]);
|
|
173
|
+
const lastId = first[first.length - 1].id;
|
|
174
|
+
const resumed = normalizeSession(
|
|
175
|
+
copilotNormalizer,
|
|
176
|
+
[{ type: "user_message", text: "continue" }],
|
|
177
|
+
{ parentId: lastId, newId: (() => { let n = 0; return () => `r-${n++}`; })() },
|
|
178
|
+
);
|
|
179
|
+
assert.equal(resumed[0].parentId, lastId, "the resumed transcript continues the pre-resume chain");
|
|
180
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import {
|
|
6
|
+
SESSION_CHECKPOINT_TABLE,
|
|
7
|
+
SESSION_EVENT_TABLE,
|
|
8
|
+
SESSION_LOG_TABLE,
|
|
9
|
+
SESSION_SCHEMA_SQL,
|
|
10
|
+
} from "./schema.ts";
|
|
11
|
+
import { SqliteSessionLog } from "./log.ts";
|
|
12
|
+
import { openTestDb } from "./test-db.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Normalise SQL for a drift comparison: drop `-- …` line comments, collapse
|
|
16
|
+
* whitespace runs to one space, trim. Two DDL scripts that create the same
|
|
17
|
+
* objects normalise identically regardless of comments/indentation.
|
|
18
|
+
*/
|
|
19
|
+
function normaliseSql(sql: string): string {
|
|
20
|
+
return sql
|
|
21
|
+
.split("\n")
|
|
22
|
+
.map((line) => line.replace(/--.*$/, ""))
|
|
23
|
+
.join("\n")
|
|
24
|
+
.replace(/\s+/g, " ")
|
|
25
|
+
.trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const migrationPath = fileURLToPath(
|
|
29
|
+
new URL("../../../../db/migrations/005_agentic_session.sql", import.meta.url),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
test("the boot migration and SqliteSessionLog's DDL do not drift", () => {
|
|
33
|
+
const migrationSql = readFileSync(migrationPath, "utf8");
|
|
34
|
+
assert.equal(
|
|
35
|
+
normaliseSql(migrationSql),
|
|
36
|
+
normaliseSql(SESSION_SCHEMA_SQL),
|
|
37
|
+
"db/migrations/005_agentic_session.sql must match SESSION_SCHEMA_SQL — update both together",
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("the boot migration is forward-only and additive (IF NOT EXISTS, no drops/alters)", () => {
|
|
42
|
+
const migrationSql = readFileSync(migrationPath, "utf8");
|
|
43
|
+
assert.match(migrationSql, /CREATE TABLE IF NOT EXISTS agentic_session_log/);
|
|
44
|
+
assert.match(migrationSql, /CREATE TABLE IF NOT EXISTS agentic_session_event/);
|
|
45
|
+
assert.match(migrationSql, /CREATE TABLE IF NOT EXISTS agentic_session_checkpoint/);
|
|
46
|
+
assert.doesNotMatch(migrationSql, /\bDROP\b/i);
|
|
47
|
+
assert.doesNotMatch(migrationSql, /\bALTER\b/i);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("the migration takes prefix 005, after 004_urban_lineage", () => {
|
|
51
|
+
assert.match(migrationPath, /\/005_agentic_session\.sql$/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("ensureSchema creates all three session tables idempotently", (t) => {
|
|
55
|
+
const db = openTestDb(t);
|
|
56
|
+
const log = new SqliteSessionLog(db);
|
|
57
|
+
log.ensureSchema();
|
|
58
|
+
log.ensureSchema();
|
|
59
|
+
const tables = db
|
|
60
|
+
.all<{ name: string }>(
|
|
61
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name IN (?, ?, ?) ORDER BY name",
|
|
62
|
+
[SESSION_CHECKPOINT_TABLE, SESSION_EVENT_TABLE, SESSION_LOG_TABLE],
|
|
63
|
+
)
|
|
64
|
+
.map((r) => r.name);
|
|
65
|
+
assert.deepEqual(tables, [SESSION_CHECKPOINT_TABLE, SESSION_EVENT_TABLE, SESSION_LOG_TABLE].sort());
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("the production build excludes the test-only sqlite helper (it is never published)", () => {
|
|
69
|
+
// Guards the failure-mode class the `test-db.ts` header documents: a test-only
|
|
70
|
+
// helper must not leak into the emitted `dist`. The comment claims exclusion via
|
|
71
|
+
// `tsconfig.build.json`; this asserts the config actually honours it.
|
|
72
|
+
const buildTsconfigPath = fileURLToPath(new URL("../../tsconfig.build.json", import.meta.url));
|
|
73
|
+
const buildTsconfig: unknown = JSON.parse(readFileSync(buildTsconfigPath, "utf8"));
|
|
74
|
+
assert.ok(
|
|
75
|
+
buildTsconfig !== null && typeof buildTsconfig === "object" && "exclude" in buildTsconfig,
|
|
76
|
+
"tsconfig.build.json must declare an exclude list",
|
|
77
|
+
);
|
|
78
|
+
const exclude = buildTsconfig.exclude;
|
|
79
|
+
assert.ok(Array.isArray(exclude), "tsconfig.build.json exclude must be an array");
|
|
80
|
+
assert.ok(
|
|
81
|
+
exclude.includes("**/test-db.ts"),
|
|
82
|
+
"tsconfig.build.json must exclude **/test-db.ts so the test-only helper is not shipped",
|
|
83
|
+
);
|
|
84
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical authoritative session-log schema — ADR 0062, slice 1.
|
|
3
|
+
*
|
|
4
|
+
* The DDL here is the single source of truth {@link SqliteSessionLog.ensureSchema}
|
|
5
|
+
* applies, and is mirrored statement-for-statement by the app-boot migration
|
|
6
|
+
* `db/migrations/005_agentic_session.sql`. `schema.test.ts` normalises both and
|
|
7
|
+
* asserts they are identical — drift is a red test, not a silent boot mismatch.
|
|
8
|
+
* This mirrors the S6 transcript store's drift-guard exactly (ADR 0056 §12), the
|
|
9
|
+
* advisory precedent this authoritative log is promoted from.
|
|
10
|
+
*
|
|
11
|
+
* Three tables back the log:
|
|
12
|
+
* - `agentic_session_log` — one row per **activation** `(processInstanceKey,
|
|
13
|
+
* elementId)`: its fence high-water (`incarnation`), retention lifecycle,
|
|
14
|
+
* status, and the retained offset window (`first_offset` … `next_offset`).
|
|
15
|
+
* - `agentic_session_event` — the durable, authoritative events, keyed
|
|
16
|
+
* `(process_instance_key, element_id, event_offset)` so a re-lease can replay
|
|
17
|
+
* from any offset and a resuming incarnation can overwrite an uncommitted tail
|
|
18
|
+
* idempotently.
|
|
19
|
+
* - `agentic_session_checkpoint` — the mind/world join points, keyed
|
|
20
|
+
* `(process_instance_key, element_id, checkpoint_id)` with the pinned offset.
|
|
21
|
+
*
|
|
22
|
+
* `event_offset`/`checkpoint_offset` (not `offset`) is deliberate: `OFFSET` is a
|
|
23
|
+
* SQLite keyword, so the columns are named to avoid quoting it everywhere.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The per-activation metadata + fence table name. */
|
|
27
|
+
export const SESSION_LOG_TABLE = "agentic_session_log";
|
|
28
|
+
|
|
29
|
+
/** The durable per-event table name. */
|
|
30
|
+
export const SESSION_EVENT_TABLE = "agentic_session_event";
|
|
31
|
+
|
|
32
|
+
/** The checkpoint table name. */
|
|
33
|
+
export const SESSION_CHECKPOINT_TABLE = "agentic_session_checkpoint";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The canonical session-log DDL. Forward-only and additive; every column added
|
|
37
|
+
* here must also be added to the boot migration (the drift guard enforces it).
|
|
38
|
+
* Events are immutable once committed under an incarnation; a resume overwrites
|
|
39
|
+
* only the *uncommitted* tail past the last checkpoint (a re-key at the same
|
|
40
|
+
* `(activation, offset)`), never a committed row.
|
|
41
|
+
*/
|
|
42
|
+
export const SESSION_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS ${SESSION_LOG_TABLE} (
|
|
43
|
+
process_instance_key TEXT NOT NULL,
|
|
44
|
+
element_id TEXT NOT NULL,
|
|
45
|
+
incarnation INTEGER NOT NULL DEFAULT 0,
|
|
46
|
+
lifecycle TEXT NOT NULL DEFAULT 'activation',
|
|
47
|
+
status TEXT NOT NULL DEFAULT 'open',
|
|
48
|
+
created_at TEXT NOT NULL,
|
|
49
|
+
completed_at TEXT,
|
|
50
|
+
first_offset INTEGER,
|
|
51
|
+
next_offset INTEGER NOT NULL DEFAULT 0,
|
|
52
|
+
PRIMARY KEY (process_instance_key, element_id)
|
|
53
|
+
);
|
|
54
|
+
CREATE TABLE IF NOT EXISTS ${SESSION_EVENT_TABLE} (
|
|
55
|
+
process_instance_key TEXT NOT NULL,
|
|
56
|
+
element_id TEXT NOT NULL,
|
|
57
|
+
event_offset INTEGER NOT NULL,
|
|
58
|
+
incarnation INTEGER NOT NULL,
|
|
59
|
+
event_id TEXT NOT NULL,
|
|
60
|
+
parent_id TEXT,
|
|
61
|
+
event_type TEXT NOT NULL,
|
|
62
|
+
payload TEXT NOT NULL,
|
|
63
|
+
appended_at TEXT NOT NULL,
|
|
64
|
+
PRIMARY KEY (process_instance_key, element_id, event_offset)
|
|
65
|
+
);
|
|
66
|
+
CREATE TABLE IF NOT EXISTS ${SESSION_CHECKPOINT_TABLE} (
|
|
67
|
+
process_instance_key TEXT NOT NULL,
|
|
68
|
+
element_id TEXT NOT NULL,
|
|
69
|
+
checkpoint_id TEXT NOT NULL,
|
|
70
|
+
checkpoint_offset INTEGER NOT NULL,
|
|
71
|
+
incarnation INTEGER NOT NULL,
|
|
72
|
+
commit_sha TEXT NOT NULL,
|
|
73
|
+
effect_ledger TEXT NOT NULL,
|
|
74
|
+
created_at TEXT NOT NULL,
|
|
75
|
+
PRIMARY KEY (process_instance_key, element_id, checkpoint_id)
|
|
76
|
+
);
|
|
77
|
+
CREATE INDEX IF NOT EXISTS idx_${SESSION_CHECKPOINT_TABLE}_offset ON ${SESSION_CHECKPOINT_TABLE} (process_instance_key, element_id, checkpoint_offset);
|
|
78
|
+
CREATE INDEX IF NOT EXISTS idx_${SESSION_LOG_TABLE}_retention ON ${SESSION_LOG_TABLE} (lifecycle, status, completed_at);`;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test-only helper: a {@link SqliteDb} backed by an in-memory `node:sqlite`
|
|
3
|
+
* database, mirroring the Node host adapter's `wrapNodeSqlite` (and the identical
|
|
4
|
+
* helper in @nanobpm/agentic's presence/transcript families). Kept out of the
|
|
5
|
+
* published build (see `tsconfig.build.json` exclude) — it exists solely so the
|
|
6
|
+
* durable-log tests exercise the store against a real SQLite engine, not a mock.
|
|
7
|
+
*/
|
|
8
|
+
import type { TestContext } from "node:test";
|
|
9
|
+
import { DatabaseSync } from "node:sqlite";
|
|
10
|
+
import type { SqliteDb } from "./log.ts";
|
|
11
|
+
|
|
12
|
+
export interface TestDb extends SqliteDb {
|
|
13
|
+
close(): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function toParams(params: unknown[]): (string | number | bigint | null | Uint8Array)[] {
|
|
17
|
+
return params.map((p) => {
|
|
18
|
+
if (p === null) return null;
|
|
19
|
+
if (typeof p === "string" || typeof p === "number" || typeof p === "bigint" || p instanceof Uint8Array) {
|
|
20
|
+
return p;
|
|
21
|
+
}
|
|
22
|
+
if (typeof p === "boolean") return p ? 1 : 0;
|
|
23
|
+
throw new TypeError(`unsupported SQLite parameter type: ${typeof p}`);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Open an in-memory test database. Pass the test's {@link TestContext} to make
|
|
29
|
+
* the DB close automatically when the test finishes (`t.after`) — the single
|
|
30
|
+
* canonical cleanup path, so no test can leak an open SQLite handle by forgetting
|
|
31
|
+
* to call `close()`. `close()` is idempotent, so an explicit call is still safe.
|
|
32
|
+
*/
|
|
33
|
+
export function openTestDb(t?: TestContext): TestDb {
|
|
34
|
+
const db = new DatabaseSync(":memory:");
|
|
35
|
+
let closed = false;
|
|
36
|
+
const close = (): void => {
|
|
37
|
+
if (closed) return;
|
|
38
|
+
closed = true;
|
|
39
|
+
db.close();
|
|
40
|
+
};
|
|
41
|
+
t?.after(close);
|
|
42
|
+
return {
|
|
43
|
+
exec: (sql) => db.exec(sql),
|
|
44
|
+
run: (sql, params = []) => {
|
|
45
|
+
const stmt = db.prepare(sql);
|
|
46
|
+
const r = stmt.run(...toParams(params));
|
|
47
|
+
return { changes: Number(r.changes), lastInsertRowid: r.lastInsertRowid };
|
|
48
|
+
},
|
|
49
|
+
all: <T>(sql: string, params: unknown[] = []): T[] => {
|
|
50
|
+
const stmt = db.prepare(sql);
|
|
51
|
+
// biome-ignore lint/plugin: Node sqlite returns untyped row objects; SqliteDb.all<T> is the host adapter boundary.
|
|
52
|
+
return stmt.all(...toParams(params)) as T[];
|
|
53
|
+
},
|
|
54
|
+
close,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Test-only helper: a {@link SqliteDb} backed by an in-memory `node:sqlite`
|
|
3
|
-
* database, mirroring the Node host adapter's `wrapNodeSqlite`. Kept out of the
|
|
4
|
-
* published build (see `tsconfig.build.json` exclude) — it exists solely so the
|
|
5
|
-
* store/family tests exercise the store against a real SQLite engine, not a mock.
|
|
6
|
-
*/
|
|
7
|
-
import { DatabaseSync } from "node:sqlite";
|
|
8
|
-
function toParams(params) {
|
|
9
|
-
return params.map((p) => {
|
|
10
|
-
if (p === null)
|
|
11
|
-
return null;
|
|
12
|
-
if (typeof p === "string" || typeof p === "number" || typeof p === "bigint" || p instanceof Uint8Array) {
|
|
13
|
-
return p;
|
|
14
|
-
}
|
|
15
|
-
if (typeof p === "boolean")
|
|
16
|
-
return p ? 1 : 0;
|
|
17
|
-
// Mirror wrapNodeSqlite's `sqliteParams`: unsupported types (including
|
|
18
|
-
// `undefined`) throw rather than coerce, so a test can never pass on a
|
|
19
|
-
// parameter production would reject.
|
|
20
|
-
throw new TypeError(`unsupported SQLite parameter type: ${typeof p}`);
|
|
21
|
-
});
|
|
22
|
-
}
|
|
23
|
-
export function openTestDb() {
|
|
24
|
-
const db = new DatabaseSync(":memory:");
|
|
25
|
-
return {
|
|
26
|
-
exec: (sql) => db.exec(sql),
|
|
27
|
-
run: (sql, params = []) => {
|
|
28
|
-
const stmt = db.prepare(sql);
|
|
29
|
-
const r = stmt.run(...toParams(params));
|
|
30
|
-
return { changes: Number(r.changes), lastInsertRowid: r.lastInsertRowid };
|
|
31
|
-
},
|
|
32
|
-
all: (sql, params = []) => {
|
|
33
|
-
const stmt = db.prepare(sql);
|
|
34
|
-
// Mirror wrapNodeSqlite exactly: return the driver's row objects directly.
|
|
35
|
-
// A JSON round-trip would throw on `bigint` values and mangle `Uint8Array`
|
|
36
|
-
// blobs, diverging from production and breaking test-only reads.
|
|
37
|
-
// biome-ignore lint/plugin: Node sqlite returns untyped row objects; SqliteDb.all<T> is the host adapter boundary.
|
|
38
|
-
return stmt.all(...toParams(params));
|
|
39
|
-
},
|
|
40
|
-
close: () => db.close(),
|
|
41
|
-
};
|
|
42
|
-
}
|
package/dist/presence/test-db.js
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Test-only helper: a {@link SqliteDb} backed by an in-memory `node:sqlite`
|
|
3
|
-
* database, mirroring the Node host adapter's `wrapNodeSqlite`. Kept out of the
|
|
4
|
-
* published build (see `tsconfig.build.json` exclude) — it exists solely so the
|
|
5
|
-
* store/family tests exercise the store against a real SQLite engine, not a mock.
|
|
6
|
-
*/
|
|
7
|
-
import { DatabaseSync } from "node:sqlite";
|
|
8
|
-
function toParams(params) {
|
|
9
|
-
return params.map((p) => {
|
|
10
|
-
if (p === null)
|
|
11
|
-
return null;
|
|
12
|
-
if (typeof p === "string" || typeof p === "number" || typeof p === "bigint" || p instanceof Uint8Array) {
|
|
13
|
-
return p;
|
|
14
|
-
}
|
|
15
|
-
if (typeof p === "boolean")
|
|
16
|
-
return p ? 1 : 0;
|
|
17
|
-
// Mirror wrapNodeSqlite's `sqliteParams`: unsupported types (including
|
|
18
|
-
// `undefined`) throw rather than coerce, so a test can never pass on a
|
|
19
|
-
// parameter production would reject.
|
|
20
|
-
throw new TypeError(`unsupported SQLite parameter type: ${typeof p}`);
|
|
21
|
-
});
|
|
22
|
-
}
|
|
23
|
-
export function openTestDb() {
|
|
24
|
-
const db = new DatabaseSync(":memory:");
|
|
25
|
-
return {
|
|
26
|
-
exec: (sql) => db.exec(sql),
|
|
27
|
-
run: (sql, params = []) => {
|
|
28
|
-
const stmt = db.prepare(sql);
|
|
29
|
-
const r = stmt.run(...toParams(params));
|
|
30
|
-
return { changes: Number(r.changes), lastInsertRowid: r.lastInsertRowid };
|
|
31
|
-
},
|
|
32
|
-
all: (sql, params = []) => {
|
|
33
|
-
const stmt = db.prepare(sql);
|
|
34
|
-
// Mirror wrapNodeSqlite exactly: return the driver's row objects directly.
|
|
35
|
-
// A JSON round-trip would throw on `bigint` values and mangle `Uint8Array`
|
|
36
|
-
// blobs, diverging from production and breaking test-only reads.
|
|
37
|
-
// biome-ignore lint/plugin: Node sqlite returns untyped row objects; SqliteDb.all<T> is the host adapter boundary.
|
|
38
|
-
return stmt.all(...toParams(params));
|
|
39
|
-
},
|
|
40
|
-
close: () => db.close(),
|
|
41
|
-
};
|
|
42
|
-
}
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Test-only helper: a {@link SqliteDb} backed by an in-memory `node:sqlite`
|
|
3
|
-
* database, mirroring the Node host adapter's `wrapNodeSqlite` (and the identical
|
|
4
|
-
* helper in @nanobpm/agentic-presence). Kept out of the published build (see
|
|
5
|
-
* `tsconfig.build.json` exclude) — it exists solely so the store tests exercise
|
|
6
|
-
* the store against a real SQLite engine, not a mock.
|
|
7
|
-
*/
|
|
8
|
-
import { DatabaseSync } from "node:sqlite";
|
|
9
|
-
function toParams(params) {
|
|
10
|
-
return params.map((p) => {
|
|
11
|
-
if (p === null)
|
|
12
|
-
return null;
|
|
13
|
-
if (typeof p === "string" || typeof p === "number" || typeof p === "bigint" || p instanceof Uint8Array) {
|
|
14
|
-
return p;
|
|
15
|
-
}
|
|
16
|
-
if (typeof p === "boolean")
|
|
17
|
-
return p ? 1 : 0;
|
|
18
|
-
// Mirror wrapNodeSqlite's `sqliteParams`: unsupported types (including
|
|
19
|
-
// `undefined`) throw rather than coerce, so a test can never pass on a
|
|
20
|
-
// parameter production would reject.
|
|
21
|
-
throw new TypeError(`unsupported SQLite parameter type: ${typeof p}`);
|
|
22
|
-
});
|
|
23
|
-
}
|
|
24
|
-
export function openTestDb() {
|
|
25
|
-
const db = new DatabaseSync(":memory:");
|
|
26
|
-
return {
|
|
27
|
-
exec: (sql) => db.exec(sql),
|
|
28
|
-
run: (sql, params = []) => {
|
|
29
|
-
const stmt = db.prepare(sql);
|
|
30
|
-
const r = stmt.run(...toParams(params));
|
|
31
|
-
return { changes: Number(r.changes), lastInsertRowid: r.lastInsertRowid };
|
|
32
|
-
},
|
|
33
|
-
all: (sql, params = []) => {
|
|
34
|
-
const stmt = db.prepare(sql);
|
|
35
|
-
// Mirror wrapNodeSqlite exactly: return the driver's row objects directly.
|
|
36
|
-
// biome-ignore lint/plugin: Node sqlite returns untyped row objects; SqliteDb.all<T> is the host adapter boundary.
|
|
37
|
-
return stmt.all(...toParams(params));
|
|
38
|
-
},
|
|
39
|
-
close: () => db.close(),
|
|
40
|
-
};
|
|
41
|
-
}
|