diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..9d80910 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,12 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript", "unicorn", "oxc", "import", "promise"], + "categories": { + "correctness": "error", + "suspicious": "error" + }, + "options": { + "typeAware": true + }, + "ignorePatterns": ["node_modules", "scripts"] +} diff --git a/bun.lock b/bun.lock index 2f4f762..63a080d 100644 --- a/bun.lock +++ b/bun.lock @@ -11,12 +11,25 @@ "@types/bun": "^1.2.0", "@typescript/native-preview": "^7.0.0-dev.20260707.2", "oxlint": "^1.73.0", + "oxlint-tsgolint": "^7.0.2001", }, }, }, "packages": { "@bevyl-ai/agent-tools": ["@bevyl-ai/agent-tools@0.5.0", "", {}, "sha512-wVuzGbmT8na0VOI+JGqlbL75TGNQKGSDhCcVhm92GY9I/YfspicXUfnFYCCex6fM2Av1zEv5kXe8Q6aL5klXzQ=="], + "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@7.0.2001", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CUJEdbSZ54+Xy9OXqOhWLTKZKV0BBiV7C2i/ygyVmXtkUNXx5YCzN8DpSSshTAKktoL7S+tnQ/ftFG/i7X896w=="], + + "@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@7.0.2001", "", { "os": "darwin", "cpu": "x64" }, "sha512-pXfBb5BqONCcgrXQNUZWXgiYmRSWJzd97S8i41VVOh6ut0tyo+cJ5FKFpczDHxiVNfj/3e7c9B4MtztNdpIVCw=="], + + "@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@7.0.2001", "", { "os": "linux", "cpu": "arm64" }, "sha512-roP7zujb/QDPzDwEKsFFpzNHHy91/Y7oX9vQXk78ekyZtcQj1QXDIMH33gjDdHBfRl4K9pZ36xhRgrP4Zr+R8A=="], + + "@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@7.0.2001", "", { "os": "linux", "cpu": "x64" }, "sha512-UDezNqdECVmngu2TPnjaS1YoAmcTaBoI5lV9vk3VahBxoi+I5r9k3iJTT7qZoYWOXTD/7T7bNcwRgrocR6BscQ=="], + + "@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@7.0.2001", "", { "os": "win32", "cpu": "arm64" }, "sha512-uJZhqB6pdXLuN+AD1F5082byyQti/NPmJA77GtcFlmT2HzRelqbNls3SaIqxpjdFgvSBF9g0yOKGBkGFg7kX8Q=="], + + "@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@7.0.2001", "", { "os": "win32", "cpu": "x64" }, "sha512-FkDRm8hx9OwzGQqyWG1tO5QrTLRApff9DzSgpz9QZau37BR8d1VYKOxMLGf6shPZntJFoTwIIJYT68VndYDCog=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.73.0", "", { "os": "android", "cpu": "arm" }, "sha512-HZQRN/UMBu+Ut+/9MiAChkbP4qZqrNOWBcNI45vOT40GVhbGR0JgHB87L48D4iAqFQIdVmeQYtV9RF89AjTKkg=="], "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.73.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Gp+KJRylv2aW7thRpG5p1KTxZq4ZJFbWowrKzufNq9d3ssl3r3JviYV45/+p+7CN1Nv0zDd1e8Ex0b/HUDq4TQ=="], @@ -79,6 +92,8 @@ "oxlint": ["oxlint@1.73.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.73.0", "@oxlint/binding-android-arm64": "1.73.0", "@oxlint/binding-darwin-arm64": "1.73.0", "@oxlint/binding-darwin-x64": "1.73.0", "@oxlint/binding-freebsd-x64": "1.73.0", "@oxlint/binding-linux-arm-gnueabihf": "1.73.0", "@oxlint/binding-linux-arm-musleabihf": "1.73.0", "@oxlint/binding-linux-arm64-gnu": "1.73.0", "@oxlint/binding-linux-arm64-musl": "1.73.0", "@oxlint/binding-linux-ppc64-gnu": "1.73.0", "@oxlint/binding-linux-riscv64-gnu": "1.73.0", "@oxlint/binding-linux-riscv64-musl": "1.73.0", "@oxlint/binding-linux-s390x-gnu": "1.73.0", "@oxlint/binding-linux-x64-gnu": "1.73.0", "@oxlint/binding-linux-x64-musl": "1.73.0", "@oxlint/binding-openharmony-arm64": "1.73.0", "@oxlint/binding-win32-arm64-msvc": "1.73.0", "@oxlint/binding-win32-ia32-msvc": "1.73.0", "@oxlint/binding-win32-x64-msvc": "1.73.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.24.0", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-u91G9TJzU6yqKWNZUYprQB07W7YvntZXaRxQ6CkoytepYhLWUXWsr1M8zUJ34VatNPuUAr3Z8GH+O2A331CluQ=="], + "oxlint-tsgolint": ["oxlint-tsgolint@7.0.2001", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "7.0.2001", "@oxlint-tsgolint/darwin-x64": "7.0.2001", "@oxlint-tsgolint/linux-arm64": "7.0.2001", "@oxlint-tsgolint/linux-x64": "7.0.2001", "@oxlint-tsgolint/win32-arm64": "7.0.2001", "@oxlint-tsgolint/win32-x64": "7.0.2001" }, "bin": { "tsgolint": "./bin/tsgolint.js" } }, "sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg=="], + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], } } diff --git a/package.json b/package.json index 0064ac9..92ce52c 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "devDependencies": { "@types/bun": "^1.2.0", "@typescript/native-preview": "^7.0.0-dev.20260707.2", - "oxlint": "^1.73.0" + "oxlint": "^1.73.0", + "oxlint-tsgolint": "^7.0.2001" } } diff --git a/src/guard.ts b/src/guard.ts new file mode 100644 index 0000000..17889f0 --- /dev/null +++ b/src/guard.ts @@ -0,0 +1,11 @@ +export function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +export function asString(v: unknown, fallback = ""): string { + return typeof v === "string" ? v : fallback; +} + +export function parseJson(text: string): unknown { + return JSON.parse(text); +} diff --git a/src/ledger/attention.ts b/src/ledger/attention.ts index db7f81a..a2e4d25 100644 --- a/src/ledger/attention.ts +++ b/src/ledger/attention.ts @@ -5,6 +5,7 @@ // is flagged to the mind's own judgment rather than trusted to the ear's closure call forever. import type { Database } from "bun:sqlite"; import type { Clock } from "./clock"; +import { many, one } from "./db"; export interface AttentionItem { id: string; @@ -60,16 +61,27 @@ export function reopenAttentionItem(db: Database, identityId: string, id: string } export function openItems(db: Database, identityId: string, limit = 50): AttentionItem[] { - const rows = db - .query("SELECT id, identity_id, venue_id, thread_root_id, ask_ts, what, opened_at FROM attention_items WHERE identity_id = ? AND closed_at IS NULL ORDER BY opened_at LIMIT ?") - .all(identityId, limit) as { id: string; identity_id: string; venue_id: string; thread_root_id: string | null; ask_ts: string | null; what: string; opened_at: string }[]; + const rows = many<{ + id: string; + identity_id: string; + venue_id: string; + thread_root_id: string | null; + ask_ts: string | null; + what: string; + opened_at: string; + }>( + db, + "SELECT id, identity_id, venue_id, thread_root_id, ask_ts, what, opened_at FROM attention_items WHERE identity_id = ? AND closed_at IS NULL ORDER BY opened_at LIMIT ?", + identityId, + limit, + ); return rows.map((r) => ({ id: r.id, identityId: r.identity_id, venueId: r.venue_id, threadRootId: r.thread_root_id, askTs: r.ask_ts, what: r.what, openedAt: r.opened_at })); } // --- the ear's own watermark (never the mind's resident_cursor) --- export function earCursor(db: Database, identityId: string): number { - return (db.query("SELECT judged_rowid FROM ear_cursor WHERE identity_id = ?").get(identityId) as { judged_rowid: number } | null)?.judged_rowid ?? 0; + return one<{ judged_rowid: number }>(db, "SELECT judged_rowid FROM ear_cursor WHERE identity_id = ?", identityId)?.judged_rowid ?? 0; } export function advanceEarCursor(db: Database, identityId: string, judgedRowid: number): void { diff --git a/src/ledger/audit.ts b/src/ledger/audit.ts index 0b217ee..40a8ada 100644 --- a/src/ledger/audit.ts +++ b/src/ledger/audit.ts @@ -1,6 +1,8 @@ // SPEC §4.1.12 — the append-only audit log. One shared writer so every module logs through the // same choke point (the table itself also enforces append-only via triggers, SPEC schema v1). import type { Database } from "bun:sqlite"; +import { many } from "./db"; +import { isRecord, parseJson } from "../guard"; export type AuditKind = | "event_received" @@ -35,10 +37,10 @@ export interface AuditRecord { } export interface AuditQueryFilter { - sinceIso?: string; - untilIso?: string; - kind?: AuditKind; - taskId?: string; // matches a `taskId` field embedded in the record's payload, if present + sinceIso?: string | undefined; + untilIso?: string | undefined; + kind?: AuditKind | undefined; + taskId?: string | undefined; // matches a `taskId` field embedded in the record's payload, if present } // SPEC §15: "queryable by the operator, at minimum: by identity, by task, by time range, by kind" @@ -46,7 +48,7 @@ export interface AuditQueryFilter { // other ledger query in this codebase (§7.1). export function queryAudit(db: Database, identityId: string, filter: AuditQueryFilter = {}): AuditRecord[] { const clauses = ["identity_id = ?"]; - const params: unknown[] = [identityId]; + const params: string[] = [identityId]; if (filter.sinceIso) { clauses.push("at >= ?"); params.push(filter.sinceIso); @@ -59,10 +61,14 @@ export function queryAudit(db: Database, identityId: string, filter: AuditQueryF clauses.push("kind = ?"); params.push(filter.kind); } - const rows = db - .query(`SELECT id, at, identity_id, kind, payload FROM audit WHERE ${clauses.join(" AND ")} ORDER BY at, id`) - .all(...(params as [])) as { id: number; at: string; identity_id: string; kind: AuditKind; payload: string }[]; + const rows = many<{ id: number; at: string; identity_id: string; kind: AuditKind; payload: string }>( + db, + `SELECT id, at, identity_id, kind, payload FROM audit WHERE ${clauses.join(" AND ")} ORDER BY at, id`, + ...params, + ); - const records = rows.map((r) => ({ id: r.id, at: r.at, identityId: r.identity_id, kind: r.kind, payload: JSON.parse(r.payload) as unknown })); - return filter.taskId ? records.filter((r) => (r.payload as { taskId?: string }).taskId === filter.taskId) : records; + const records = rows.map((r) => ({ id: r.id, at: r.at, identityId: r.identity_id, kind: r.kind, payload: parseJson(r.payload) })); + return filter.taskId + ? records.filter((r) => isRecord(r.payload) && r.payload.taskId === filter.taskId) + : records; } diff --git a/src/ledger/conversations.ts b/src/ledger/conversations.ts index 1ce0828..68ad2c7 100644 --- a/src/ledger/conversations.ts +++ b/src/ledger/conversations.ts @@ -14,7 +14,9 @@ // see her own words" is unrepresentable // A null thread root (top-level channel surface) normalizes to '' for the primary key. import type { Database } from "bun:sqlite"; +import { asString, isRecord, parseJson } from "../guard"; import type { Clock } from "./clock"; +import { many, one } from "./db"; import type { InboxMessage } from "./inbox"; const HOLD_WHY_KEEP = 4; // bounded history — never a single latest-wins why (a stale one would render as live fact) @@ -52,6 +54,68 @@ export function convoKey(venueId: string, threadRootId: string | null): string { return `${venueId}|${rootKey(threadRootId)}`; } +interface EventRow { + rowid: number; + id: string; + kind: string; + venue_id: string | null; + thread_root_id: string | null; + principal_id: string | null; + payload: string; + received_at: string; +} + +function stringList(v: unknown): string[] { + return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []; +} + +function asInboxKind(v: string): InboxMessage["kind"] { + return v === "addressed_message" || v === "external_signal" ? v : "observed_message"; +} + +function asAddressMode(v: unknown): InboxMessage["addressMode"] | undefined { + return v === "mention" || v === "dm" || v === "thread_follow" ? v : undefined; +} + +function parseFiles(v: unknown): InboxMessage["files"] { + if (!Array.isArray(v)) return undefined; + const files: NonNullable = []; + for (const item of v) { + if (!isRecord(item) || typeof item.name !== "string") continue; + files.push({ + name: item.name, + ...(typeof item.mimetype === "string" ? { mimetype: item.mimetype } : {}), + ...(typeof item.urlPrivate === "string" ? { urlPrivate: item.urlPrivate } : {}), + ...(typeof item.size === "number" ? { size: item.size } : {}), + }); + } + return files.length ? files : undefined; +} + +function payloadOf(text: string): { + text: string; + ts: string | null; + principalName?: string; + addressMode?: InboxMessage["addressMode"]; + files?: InboxMessage["files"]; +} { + const parsed = parseJson(text); + const p = isRecord(parsed) ? parsed : {}; + const addressMode = asAddressMode(p.addressMode); + const files = parseFiles(p.files); + return { + text: asString(p.text), + ts: typeof p.ts === "string" ? p.ts : null, + ...(typeof p.principalName === "string" ? { principalName: p.principalName } : {}), + ...(addressMode ? { addressMode } : {}), + ...(files?.length ? { files } : {}), + }; +} + +function asStance(v: string): Stance { + return v === "engaged" || v === "out" ? v : "none"; +} + export function ensureConversation(db: Database, clock: Clock, identityId: string, venueId: string, threadRootId: string | null): void { db.query( "INSERT INTO conversations (identity_id, venue_id, thread_root_id, first_at) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING", @@ -85,24 +149,30 @@ export function stepBack(db: Database, clock: Clock, identityId: string, venueId } export function stanceOf(db: Database, identityId: string, venueId: string, threadRootId: string | null): StanceState { - const row = db - .query("SELECT stance, stance_why, stance_at FROM conversations WHERE identity_id = ? AND venue_id = ? AND thread_root_id = ?") - .get(identityId, venueId, rootKey(threadRootId)) as { stance: Stance; stance_why: string | null; stance_at: string | null } | null; - return row ? { stance: row.stance, why: row.stance_why, at: row.stance_at } : { stance: "none", why: null, at: null }; + const row = one<{ stance: string; stance_why: string | null; stance_at: string | null }>( + db, + "SELECT stance, stance_why, stance_at FROM conversations WHERE identity_id = ? AND venue_id = ? AND thread_root_id = ?", + identityId, + venueId, + rootKey(threadRootId), + ); + return row ? { stance: asStance(row.stance), why: row.stance_why, at: row.stance_at } : { stance: "none", why: null, at: null }; } // Every venue the ledger knows a thread root by — heard messages plus her own established // conversations. A thread root ts is only meaningful within its venue; callers use this to // catch a threadRootId paired with the wrong venue before posting. export function venuesForThread(db: Database, threadRootId: string): string[] { - const rows = db - .query( - `SELECT venue_id FROM events + const rows = many<{ venue_id: string }>( + db, + `SELECT venue_id FROM events WHERE venue_id IS NOT NULL AND (thread_root_id = ? OR json_extract(payload, '$.ts') = ?) UNION SELECT venue_id FROM conversations WHERE thread_root_id = ?`, - ) - .all(threadRootId, threadRootId, threadRootId) as { venue_id: string }[]; + threadRootId, + threadRootId, + threadRootId, + ); return rows.map((r) => r.venue_id); } @@ -112,17 +182,22 @@ export function venuesForThread(db: Database, threadRootId: string): string[] { // already delivered must not re-deliver as fresh traffic under its new home (it would arrive as // a stale mention and flip a later wake's addressed duties — observed in test as a broken §5.5). export function rehomeThreadRoot(db: Database, clock: Clock, identityId: string, venueId: string, rootTs: string): void { - const root = db - .query( - "SELECT rowid FROM events WHERE identity_id = ? AND venue_id = ? AND thread_root_id IS NULL AND json_extract(payload, '$.ts') = ?", - ) - .get(identityId, venueId, rootTs) as { rowid: number } | null; + const root = one<{ rowid: number }>( + db, + "SELECT rowid FROM events WHERE identity_id = ? AND venue_id = ? AND thread_root_id IS NULL AND json_extract(payload, '$.ts') = ?", + identityId, + venueId, + rootTs, + ); if (!root) return; db.transaction(() => { db.query("UPDATE events SET thread_root_id = ? WHERE rowid = ?").run(rootTs, root.rowid); - const surface = db - .query("SELECT delivered_rowid, judged_rowid FROM conversations WHERE identity_id = ? AND venue_id = ? AND thread_root_id = ''") - .get(identityId, venueId) as { delivered_rowid: number; judged_rowid: number } | null; + const surface = one<{ delivered_rowid: number; judged_rowid: number }>( + db, + "SELECT delivered_rowid, judged_rowid FROM conversations WHERE identity_id = ? AND venue_id = ? AND thread_root_id = ''", + identityId, + venueId, + ); if (!surface) return; ensureConversation(db, clock, identityId, venueId, rootTs); // Judgment the ear pinned to the surface while the root lived there moves with it — but @@ -135,9 +210,12 @@ export function rehomeThreadRoot(db: Database, clock: Clock, identityId: string, ) .get(identityId, venueId, surface.delivered_rowid); if (surface.delivered_rowid < root.rowid && !otherUndelivered) { - const j = db - .query("SELECT holds, hold_whys, wake_why FROM conversations WHERE identity_id = ? AND venue_id = ? AND thread_root_id = ''") - .get(identityId, venueId) as { holds: number; hold_whys: string; wake_why: string | null }; + const j = one<{ holds: number; hold_whys: string; wake_why: string | null }>( + db, + "SELECT holds, hold_whys, wake_why FROM conversations WHERE identity_id = ? AND venue_id = ? AND thread_root_id = ''", + identityId, + venueId, + ) ?? { holds: 0, hold_whys: "[]", wake_why: null }; if (j.holds > 0 || j.wake_why) { db.query("UPDATE conversations SET holds = ?, hold_whys = ?, wake_why = ? WHERE identity_id = ? AND venue_id = ? AND thread_root_id = ?").run( j.holds, @@ -203,10 +281,14 @@ export function consumeJudgment(db: Database, clock: Clock, identityId: string, let out: ConversationJudgment; db.transaction(() => { ensureConversation(db, clock, identityId, key.venueId, key.threadRootId); - const row = db - .query("SELECT holds, hold_whys, wake_why FROM conversations WHERE identity_id = ? AND venue_id = ? AND thread_root_id = ?") - .get(identityId, key.venueId, rootKey(key.threadRootId)) as { holds: number; hold_whys: string; wake_why: string | null }; - out = { ...key, holds: row.holds, holdWhys: JSON.parse(row.hold_whys) as string[], wakeWhy: row.wake_why }; + const row = one<{ holds: number; hold_whys: string; wake_why: string | null }>( + db, + "SELECT holds, hold_whys, wake_why FROM conversations WHERE identity_id = ? AND venue_id = ? AND thread_root_id = ?", + identityId, + key.venueId, + rootKey(key.threadRootId), + ) ?? { holds: 0, hold_whys: "[]", wake_why: null }; + out = { ...key, holds: row.holds, holdWhys: stringList(parseJson(row.hold_whys)), wakeWhy: row.wake_why }; // Delivery advances ONLY its own watermark: the ear's judged cursor may trail so it can // still bookkeep addressed traffic after the fact (debts on asks she was woken for). db.query( @@ -219,11 +301,15 @@ export function consumeJudgment(db: Database, clock: Clock, identityId: string, } export function getConversationJudgment(db: Database, identityId: string, venueId: string, threadRootId: string | null): ConversationJudgment | null { - const row = db - .query("SELECT holds, hold_whys, wake_why FROM conversations WHERE identity_id = ? AND venue_id = ? AND thread_root_id = ?") - .get(identityId, venueId, rootKey(threadRootId)) as { holds: number; hold_whys: string; wake_why: string | null } | null; + const row = one<{ holds: number; hold_whys: string; wake_why: string | null }>( + db, + "SELECT holds, hold_whys, wake_why FROM conversations WHERE identity_id = ? AND venue_id = ? AND thread_root_id = ?", + identityId, + venueId, + rootKey(threadRootId), + ); return row - ? { venueId, threadRootId, holds: row.holds, holdWhys: JSON.parse(row.hold_whys) as string[], wakeWhy: row.wake_why } + ? { venueId, threadRootId, holds: row.holds, holdWhys: stringList(parseJson(row.hold_whys)), wakeWhy: row.wake_why } : null; } @@ -231,18 +317,18 @@ export function getConversationJudgment(db: Database, identityId: string, venueI const DELIVERABLE_KINDS = "('addressed_message','observed_message','external_signal')"; -function messagesOf(rows: { rowid: number; id: string; kind: string; venue_id: string | null; thread_root_id: string | null; principal_id: string | null; payload: string; received_at: string }[]): InboxMessage[] { +function messagesOf(rows: EventRow[]): InboxMessage[] { return rows.map((r) => { - const p = JSON.parse(r.payload) as { text?: string; ts?: string; principalName?: string; addressMode?: InboxMessage["addressMode"]; files?: InboxMessage["files"] }; + const p = payloadOf(r.payload); return { rowid: r.rowid, id: r.id, - kind: r.kind as InboxMessage["kind"], + kind: asInboxKind(r.kind), venueId: r.venue_id, threadRootId: r.thread_root_id, principalId: r.principal_id, - text: p.text ?? "", - ts: p.ts ?? null, + text: p.text, + ts: p.ts, receivedAt: r.received_at, ...(p.principalName ? { principalName: p.principalName } : {}), ...(p.addressMode ? { addressMode: p.addressMode } : {}), @@ -276,30 +362,31 @@ function groupByConversation(db: Database, identityId: string, messages: InboxMe const OUT_STANCE_EXCEPTIONS = "(ifnull(c.stance, 'none') != 'out' OR e.kind = 'external_signal' OR c.wake_why IS NOT NULL)"; export function pendingConversations(db: Database, identityId: string, limit = 200): PendingConversation[] { - const rows = db - .query( - `SELECT e.rowid, e.id, e.kind, e.venue_id, e.thread_root_id, e.principal_id, e.payload, e.received_at + const rows = many( + db, + `SELECT e.rowid, e.id, e.kind, e.venue_id, e.thread_root_id, e.principal_id, e.payload, e.received_at FROM events e LEFT JOIN conversations c ON c.identity_id = e.identity_id AND c.venue_id = e.venue_id AND c.thread_root_id = ifnull(e.thread_root_id, '') WHERE e.identity_id = ? AND e.kind IN ${DELIVERABLE_KINDS} AND e.venue_id IS NOT NULL AND e.rowid > ifnull(c.delivered_rowid, 0) AND ${OUT_STANCE_EXCEPTIONS} ORDER BY e.rowid LIMIT ?`, - ) - .all(identityId, limit) as Parameters[0]; - const direct = db - .query( - `SELECT e.rowid, e.id, e.kind, e.venue_id, e.thread_root_id, e.principal_id, e.payload, e.received_at + identityId, + limit, + ); + const direct = many( + db, + `SELECT e.rowid, e.id, e.kind, e.venue_id, e.thread_root_id, e.principal_id, e.payload, e.received_at FROM events e LEFT JOIN conversations c ON c.identity_id = e.identity_id AND c.venue_id = e.venue_id AND c.thread_root_id = ifnull(e.thread_root_id, '') WHERE e.identity_id = ? AND e.kind = 'addressed_message' AND e.venue_id IS NOT NULL AND e.rowid > ifnull(c.delivered_rowid, 0) AND json_extract(e.payload, '$.addressMode') IN ('mention', 'dm') ORDER BY e.rowid`, - ) - .all(identityId) as Parameters[0]; + identityId, + ); const seen = new Set(rows.map((r) => r.rowid)); - const merged = [...rows, ...direct.filter((r) => !seen.has(r.rowid))].sort((a, b) => a.rowid - b.rowid); + const merged = [...rows, ...direct.filter((r) => !seen.has(r.rowid))].toSorted((a, b) => a.rowid - b.rowid); return groupByConversation(db, identityId, messagesOf(merged)); } @@ -322,16 +409,17 @@ export function hasUndelivered(db: Database, identityId: string): boolean { // the ear listens to rooms she has left too (an emergency there should still wake her; her // stance gates delivery, never the listening). export function unjudgedConversations(db: Database, identityId: string, limit = 200): PendingConversation[] { - const rows = db - .query( - `SELECT e.rowid, e.id, e.kind, e.venue_id, e.thread_root_id, e.principal_id, e.payload, e.received_at + const rows = many( + db, + `SELECT e.rowid, e.id, e.kind, e.venue_id, e.thread_root_id, e.principal_id, e.payload, e.received_at FROM events e LEFT JOIN conversations c ON c.identity_id = e.identity_id AND c.venue_id = e.venue_id AND c.thread_root_id = ifnull(e.thread_root_id, '') WHERE e.identity_id = ? AND e.kind IN ${DELIVERABLE_KINDS} AND e.venue_id IS NOT NULL AND e.rowid > ifnull(c.judged_rowid, 0) ORDER BY e.rowid LIMIT ?`, - ) - .all(identityId, limit) as Parameters[0]; + identityId, + limit, + ); return groupByConversation(db, identityId, messagesOf(rows)); } @@ -428,9 +516,11 @@ export function saveDraft(db: Database, clock: Clock, identityId: string, venueI // withholds before any wake rendered them — review finding, 2026-08-11) and only for a // SUCCEEDED turn (a failed wake returns its drafts to the next one instead of eating them). export function peekDrafts(db: Database, identityId: string): { id: number; venueId: string; threadRootId: string | null; text: string }[] { - const rows = db - .query("SELECT id, venue_id, thread_root_id, text FROM drafts WHERE identity_id = ? AND consumed_at IS NULL ORDER BY id") - .all(identityId) as { id: number; venue_id: string; thread_root_id: string | null; text: string }[]; + const rows = many<{ id: number; venue_id: string; thread_root_id: string | null; text: string }>( + db, + "SELECT id, venue_id, thread_root_id, text FROM drafts WHERE identity_id = ? AND consumed_at IS NULL ORDER BY id", + identityId, + ); return rows.map((r) => ({ id: r.id, venueId: r.venue_id, threadRootId: r.thread_root_id, text: r.text })); } @@ -442,19 +532,23 @@ export function markDraftsConsumed(db: Database, clock: Clock, identityId: strin // The newest deliverable event a conversation has — the bounce card's "delivered through here". export function maxEventRowid(db: Database, identityId: string, venueId: string, threadRootId: string | null): number { - const row = ( - threadRootId - ? db - .query( - `SELECT max(rowid) AS r FROM events WHERE identity_id = ? AND venue_id = ? AND kind IN ${DELIVERABLE_KINDS} + const row = threadRootId + ? one<{ r: number | null }>( + db, + `SELECT max(rowid) AS r FROM events WHERE identity_id = ? AND venue_id = ? AND kind IN ${DELIVERABLE_KINDS} AND (thread_root_id = ? OR json_extract(payload, '$.ts') = ?)`, - ) - .get(identityId, venueId, threadRootId, threadRootId) - : db - .query(`SELECT max(rowid) AS r FROM events WHERE identity_id = ? AND venue_id = ? AND kind IN ${DELIVERABLE_KINDS} AND thread_root_id IS NULL`) - .get(identityId, venueId) - ) as { r: number | null }; - return row.r ?? 0; + identityId, + venueId, + threadRootId, + threadRootId, + ) + : one<{ r: number | null }>( + db, + `SELECT max(rowid) AS r FROM events WHERE identity_id = ? AND venue_id = ? AND kind IN ${DELIVERABLE_KINDS} AND thread_root_id IS NULL`, + identityId, + venueId, + ); + return row?.r ?? 0; } // --- refs: addressing as capability ----------------------------------------------------------- @@ -511,37 +605,41 @@ export function conversationOf(t: RefTarget): ConversationKey { export function provenanceOfRef(db: Database, identityId: string, t: RefTarget): { eventId: string; principalId: string | null } | null { if (t.eventId) return { eventId: t.eventId, principalId: t.principalId ?? null }; if (t.ts) { - const exact = db - .query( - `SELECT id, principal_id FROM events + const exact = one<{ id: string; principal_id: string | null }>( + db, + `SELECT id, principal_id FROM events WHERE identity_id = ? AND venue_id = ? AND json_extract(payload, '$.ts') = ? ORDER BY rowid DESC LIMIT 1`, - ) - .get(identityId, t.venueId, t.ts) as { id: string; principal_id: string | null } | null; + identityId, + t.venueId, + t.ts, + ); if (exact) return { eventId: exact.id, principalId: exact.principal_id }; } const key = conversationOf(t); - const row = ( - key.threadRootId - ? db - .query( - `SELECT id, principal_id FROM events + const row = key.threadRootId + ? one<{ id: string; principal_id: string | null }>( + db, + `SELECT id, principal_id FROM events WHERE identity_id = ? AND venue_id = ? AND kind IN ('addressed_message','observed_message','external_signal') AND (thread_root_id = ? OR json_extract(payload, '$.ts') = ?) ORDER BY rowid DESC LIMIT 1`, - ) - .get(identityId, key.venueId, key.threadRootId, key.threadRootId) - : db - .query( - `SELECT id, principal_id FROM events + identityId, + key.venueId, + key.threadRootId, + key.threadRootId, + ) + : one<{ id: string; principal_id: string | null }>( + db, + `SELECT id, principal_id FROM events WHERE identity_id = ? AND venue_id = ? AND kind IN ('addressed_message','observed_message','external_signal') AND thread_root_id IS NULL ORDER BY rowid DESC LIMIT 1`, - ) - .get(identityId, key.venueId) - ) as { id: string; principal_id: string | null } | null; + identityId, + key.venueId, + ); return row ? { eventId: row.id, principalId: row.principal_id } : null; } @@ -549,25 +647,27 @@ export function provenanceOfRef(db: Database, identityId: string, t: RefTarget): // machine-authored (a worker report has no principal). Scoped to the conversation the model // chose; never a batch-level pick. export function lastSpeakerIn(db: Database, identityId: string, key: ConversationKey): string | null { - const row = ( - key.threadRootId - ? db - .query( - `SELECT principal_id FROM events + const row = key.threadRootId + ? one<{ principal_id: string }>( + db, + `SELECT principal_id FROM events WHERE identity_id = ? AND venue_id = ? AND principal_id IS NOT NULL AND (thread_root_id = ? OR json_extract(payload, '$.ts') = ?) ORDER BY rowid DESC LIMIT 1`, - ) - .get(identityId, key.venueId, key.threadRootId, key.threadRootId) - : db - .query( - `SELECT principal_id FROM events + identityId, + key.venueId, + key.threadRootId, + key.threadRootId, + ) + : one<{ principal_id: string }>( + db, + `SELECT principal_id FROM events WHERE identity_id = ? AND venue_id = ? AND principal_id IS NOT NULL AND thread_root_id IS NULL ORDER BY rowid DESC LIMIT 1`, - ) - .get(identityId, key.venueId) - ) as { principal_id: string } | null; + identityId, + key.venueId, + ); return row?.principal_id ?? null; } @@ -601,57 +701,61 @@ function tailOf(db: Database, identityId: string, key: ConversationKey, beforeRo // A thread's tail is its replies plus its root message (a reply carries thread_root_id, the // root is its own ts — same OR-match the router uses). The venue surface's tail is its recent // top-level messages. - const events = ( - key.threadRootId - ? db - .query( - `SELECT id, principal_id, json_extract(payload, '$.text') AS text, json_extract(payload, '$.principalName') AS name, + type TailEvent = { id: string; principal_id: string | null; text: string | null; name: string | null; ts: string | null }; + const events = key.threadRootId + ? many( + db, + `SELECT id, principal_id, json_extract(payload, '$.text') AS text, json_extract(payload, '$.principalName') AS name, json_extract(payload, '$.ts') AS ts FROM events WHERE identity_id = ? AND venue_id = ? AND rowid <= ? AND kind IN ('addressed_message','observed_message') AND (thread_root_id = ? OR json_extract(payload, '$.ts') = ?) ORDER BY rowid DESC LIMIT ?`, - ) - .all(identityId, key.venueId, beforeRowid, key.threadRootId, key.threadRootId, TAIL_LIMIT) - : db - .query( - `SELECT id, principal_id, json_extract(payload, '$.text') AS text, json_extract(payload, '$.principalName') AS name, + identityId, + key.venueId, + beforeRowid, + key.threadRootId, + key.threadRootId, + TAIL_LIMIT, + ) + : many( + db, + `SELECT id, principal_id, json_extract(payload, '$.text') AS text, json_extract(payload, '$.principalName') AS name, json_extract(payload, '$.ts') AS ts FROM events WHERE identity_id = ? AND venue_id = ? AND rowid <= ? AND kind IN ('addressed_message','observed_message') AND thread_root_id IS NULL ORDER BY rowid DESC LIMIT ?`, - ) - .all(identityId, key.venueId, beforeRowid, TAIL_LIMIT) - ) as { - id: string; - principal_id: string | null; - text: string | null; - name: string | null; - ts: string | null; - }[]; - const theirs: TailLine[] = events.reverse().map((r) => ({ + identityId, + key.venueId, + beforeRowid, + TAIL_LIMIT, + ); + const theirs: TailLine[] = events.toReversed().map((r) => ({ sortTs: r.ts ? Number(r.ts) : 0, surfaceTs: r.ts, eventId: r.id, principalId: r.principal_id, line: `${who({ principalId: r.principal_id, ...(r.name ? { principalName: r.name } : {}) })}: ${(r.text ?? "").slice(0, 300)}`, })); - const acts = db - .query( - `SELECT kind, ts, text, at FROM acts + const acts = many<{ kind: string; ts: string | null; text: string | null; at: string }>( + db, + `SELECT kind, ts, text, at FROM acts WHERE identity_id = ? AND venue_id = ? AND thread_root_id IS ? ORDER BY id DESC LIMIT ?`, - ) - .all(identityId, key.venueId, key.threadRootId, TAIL_LIMIT) as { kind: "posted" | "reacted"; ts: string | null; text: string | null; at: string }[]; - const hers: TailLine[] = acts.reverse().map((a) => ({ + identityId, + key.venueId, + key.threadRootId, + TAIL_LIMIT, + ); + const hers: TailLine[] = acts.toReversed().map((a) => ({ sortTs: a.ts ? Number(a.ts) : Date.parse(a.at) / 1000, surfaceTs: null, line: a.kind === "posted" ? `${selfLabel}: ${(a.text ?? "").slice(0, 300)}` : `${selfLabel} reacted :${a.text}: to ts=${a.ts}`, })); - return [...theirs, ...hers].sort((a, b) => a.sortTs - b.sortTs).slice(-TAIL_LIMIT); + return [...theirs, ...hers].toSorted((a, b) => a.sortTs - b.sortTs).slice(-TAIL_LIMIT); } export interface RenderOpts { @@ -659,18 +763,18 @@ export interface RenderOpts { // frames how a line reached her ("[to you] ", the ear's "[she was woken for this] ") — the // framing differs between readers, the conversation body never does. newMessages: InboxMessage[]; - mark?: (m: InboxMessage) => string; - judgment?: ConversationJudgment; - stance?: StanceState; + mark?: ((m: InboxMessage) => string) | undefined; + judgment?: ConversationJudgment | undefined; + stance?: StanceState | undefined; // Tail cutoff: rows at or before this rowid are "already heard". Callers pass the rowid just // below their batch so the tail never duplicates the new lines. beforeRowid: number; // How her own acts read in the tail: the mind sees "you", the ear sees "she". Header lines // stay subject-free so both voices read naturally. - selfLabel?: "you" | "she"; + selfLabel?: "you" | "she" | undefined; // When present, the renderer MINTS a ref for the conversation and for every message line it // emits — the only source of addressable targets for the speaking tools (ladder R4). - refs?: RefTable; + refs?: RefTable | undefined; } // THE renderer — the only way a conversation enters any prompt, and (via refs) the only source diff --git a/src/ledger/db.ts b/src/ledger/db.ts index dba134d..0ec0473 100644 --- a/src/ledger/db.ts +++ b/src/ledger/db.ts @@ -1,4 +1,17 @@ -import { Database } from "bun:sqlite"; +import { Database, type SQLQueryBindings } from "bun:sqlite"; + +// T is the row shape — bun:sqlite cannot infer it from the SQL string. +/* oxlint-disable typescript/no-unnecessary-type-parameters */ +export function one(db: Database, sql: string, ...params: SQLQueryBindings[]): T | null { + const stmt = db.query(sql); + return stmt.get(...params); +} + +export function many(db: Database, sql: string, ...params: SQLQueryBindings[]): T[] { + const stmt = db.query(sql); + return stmt.all(...params); +} +/* oxlint-enable typescript/no-unnecessary-type-parameters */ const SCHEMA_VERSION = 14; @@ -285,7 +298,7 @@ export function openLedger(path: string): Database { // current shape (indexes included), and a migration may need to repair data (e.g. v5's timer // dedupe) before that shape can be enforced. db.exec("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)"); - const row = db.query("SELECT version FROM schema_version").get() as { version: number } | null; + const row = one<{ version: number }>(db, "SELECT version FROM schema_version"); if (row !== null && row.version > SCHEMA_VERSION) { throw new Error(`ledger schema version ${row.version} is newer than this build supports (${SCHEMA_VERSION})`); } diff --git a/src/ledger/inbox.ts b/src/ledger/inbox.ts index a2019f9..307024a 100644 --- a/src/ledger/inbox.ts +++ b/src/ledger/inbox.ts @@ -2,6 +2,8 @@ // per-conversation (ledger/conversations.ts owns the watermarks); this module keeps the row // shape and the raw after-rowid read that §5.5's moved-check uses. import type { Database } from "bun:sqlite"; +import { asString, isRecord, parseJson } from "../guard"; +import { many } from "./db"; export interface InboxMessage { rowid: number; @@ -24,30 +26,67 @@ export interface InboxMessage { files?: { name: string; mimetype?: string; urlPrivate?: string; size?: number }[]; } +function asInboxKind(v: string): InboxMessage["kind"] { + return v === "addressed_message" || v === "external_signal" ? v : "observed_message"; +} + +function asAddressMode(v: unknown): InboxMessage["addressMode"] | undefined { + return v === "mention" || v === "dm" || v === "thread_follow" ? v : undefined; +} + +function parseFiles(v: unknown): InboxMessage["files"] { + if (!Array.isArray(v)) return undefined; + const files: NonNullable = []; + for (const item of v) { + if (!isRecord(item) || typeof item.name !== "string") continue; + files.push({ + name: item.name, + ...(typeof item.mimetype === "string" ? { mimetype: item.mimetype } : {}), + ...(typeof item.urlPrivate === "string" ? { urlPrivate: item.urlPrivate } : {}), + ...(typeof item.size === "number" ? { size: item.size } : {}), + }); + } + return files.length ? files : undefined; +} + export function messagesAfter(db: Database, identityId: string, afterRowid: number, limit = 200): InboxMessage[] { const cursor = afterRowid; - const rows = db - .query( - `SELECT rowid, id, kind, venue_id, thread_root_id, principal_id, payload, received_at FROM events + const rows = many<{ + rowid: number; + id: string; + kind: string; + venue_id: string | null; + thread_root_id: string | null; + principal_id: string | null; + payload: string; + received_at: string; + }>( + db, + `SELECT rowid, id, kind, venue_id, thread_root_id, principal_id, payload, received_at FROM events WHERE identity_id = ? AND rowid > ? AND kind IN ('addressed_message','observed_message','external_signal') ORDER BY rowid LIMIT ?`, - ) - .all(identityId, cursor, limit) as { rowid: number; id: string; kind: InboxMessage["kind"]; venue_id: string | null; thread_root_id: string | null; principal_id: string | null; payload: string; received_at: string }[]; + identityId, + cursor, + limit, + ); return rows.map((r) => { - const p = JSON.parse(r.payload) as { text?: string; ts?: string; principalName?: string; addressMode?: InboxMessage["addressMode"]; files?: InboxMessage["files"] }; + const parsed = parseJson(r.payload); + const p = isRecord(parsed) ? parsed : {}; + const addressMode = asAddressMode(p.addressMode); + const files = parseFiles(p.files); return { rowid: r.rowid, id: r.id, - kind: r.kind, + kind: asInboxKind(r.kind), venueId: r.venue_id, threadRootId: r.thread_root_id, principalId: r.principal_id, - text: p.text ?? "", - ts: p.ts ?? null, + text: asString(p.text), + ts: typeof p.ts === "string" ? p.ts : null, receivedAt: r.received_at, - ...(p.principalName ? { principalName: p.principalName } : {}), - ...(p.addressMode ? { addressMode: p.addressMode } : {}), - ...(p.files?.length ? { files: p.files } : {}), + ...(typeof p.principalName === "string" ? { principalName: p.principalName } : {}), + ...(addressMode ? { addressMode } : {}), + ...(files?.length ? { files } : {}), }; }); } diff --git a/src/ledger/memory.ts b/src/ledger/memory.ts index 7330d3b..7573d09 100644 --- a/src/ledger/memory.ts +++ b/src/ledger/memory.ts @@ -4,6 +4,8 @@ import type { Database } from "bun:sqlite"; import type { Clock } from "./clock"; import { writeAudit } from "./audit"; +import { many, one } from "./db"; +import { parseJson } from "../guard"; export type MemoryStatus = "active" | "retracted"; // SPEC §8.6 — core is injected into turn context (budget-bounded); recent is internalized-but- @@ -38,11 +40,12 @@ interface Row { } function rowToItem(row: Row): MemoryItem { + const provenance = parseJson(row.provenance); return { id: row.id, identityId: row.identity_id, content: row.content, - provenance: JSON.parse(row.provenance), + provenance: Array.isArray(provenance) ? provenance : [], tier: row.tier, status: row.status, supersededBy: row.superseded_by, @@ -53,7 +56,7 @@ function rowToItem(row: Row): MemoryItem { } function getItem(db: Database, id: string): MemoryItem | null { - const row = db.query("SELECT * FROM memory_items WHERE id = ?").get(id) as Row | null; + const row = one(db, "SELECT * FROM memory_items WHERE id = ?", id); return row ? rowToItem(row) : null; } @@ -67,8 +70,8 @@ export interface WriteMemoryParams { id: string; identityId: string; content: string; - provenance?: unknown[]; - tier?: MemoryTier; // SPEC §8.6: explicit writes default to core — "remember X" acts next turn + provenance?: unknown[] | undefined; + tier?: MemoryTier | undefined; // SPEC §8.6: explicit writes default to core — "remember X" acts next turn } // §10.6: memory is not a secret sink. Credential-shaped content is refused at the write @@ -100,7 +103,7 @@ export function writeMemory(db: Database, clock: Clock, params: WriteMemoryParam export interface RetractMemoryParams { id: string; - supersededBy?: string; + supersededBy?: string | undefined; } // SPEC §8.3: "forget that" — takes effect immediately (a plain synchronous write); queryMemory's @@ -121,7 +124,7 @@ export interface CorrectMemoryParams { oldId: string; newId: string; newContent: string; - provenance?: unknown[]; + provenance?: unknown[] | undefined; } // SPEC §8.3: "that's wrong, it's actually Y" — retract the old item, linked to a freshly written @@ -165,7 +168,7 @@ export function queryMemory(db: Database, identityId: string, opts: QueryMemoryO where.push("tier = ?"); params.push(opts.tier); } - const rows = db.query(`SELECT * FROM memory_items WHERE ${where.join(" AND ")} ORDER BY created_at`).all(...params) as Row[]; + const rows = many(db, `SELECT * FROM memory_items WHERE ${where.join(" AND ")} ORDER BY created_at`, ...params); return rows.map(rowToItem); } @@ -191,7 +194,7 @@ export interface DecayResult { // per-identity size cap — evict the stalest remaining items first. export function decayStaleMemory(db: Database, clock: Clock, identityId: string, opts: DecayStaleMemoryOpts): DecayResult { const now = clock(); - const active = queryMemory(db, identityId).sort((a, b) => a.lastConfirmedAt.localeCompare(b.lastConfirmedAt)); + const active = queryMemory(db, identityId).toSorted((a, b) => a.lastConfirmedAt.localeCompare(b.lastConfirmedAt)); const decayed: string[] = []; const cutoff = Number.isFinite(opts.maxAgeMs) ? new Date(now).getTime() - opts.maxAgeMs : -Infinity; @@ -220,7 +223,7 @@ export function decayStaleMemory(db: Database, clock: Clock, identityId: string, // Returns what was dropped so the caller can log the hygiene defect (truncation is the safety // net; curation is the fix). export function coreWithinBudget(items: MemoryItem[], budgetChars: number): { kept: MemoryItem[]; dropped: MemoryItem[] } { - const byRecency = [...items].sort((a, b) => b.lastConfirmedAt.localeCompare(a.lastConfirmedAt)); + const byRecency = items.toSorted((a, b) => b.lastConfirmedAt.localeCompare(a.lastConfirmedAt)); const kept: MemoryItem[] = []; const dropped: MemoryItem[] = []; let used = 0; diff --git a/src/ledger/scheduler.ts b/src/ledger/scheduler.ts index 75bebc8..685cf67 100644 --- a/src/ledger/scheduler.ts +++ b/src/ledger/scheduler.ts @@ -4,6 +4,7 @@ import type { Database } from "bun:sqlite"; import type { Clock } from "./clock"; import { listDueTimers, markTimerFired, scheduleTimer, type TimerRow, type TimerKind } from "./timers"; import { getTask, transition, type Task, type WaitingOn } from "./tasks"; +import { many, one } from "./db"; export interface FiredTimerResult { timerId: string; @@ -110,7 +111,11 @@ function applyTimer(db: Database, clock: Clock, timer: TimerRow, opts: FireDueTi case "ambient_tick": return applyAmbientTick(db, clock, timer, opts); case "recurrence": - throw new Error(`timer kind not yet implemented by the scheduler: ${timer.kind}`); + throw new Error("timer kind not yet implemented by the scheduler: recurrence"); + default: { + const exhausted: never = timer.kind; + throw new Error(`timer kind not yet implemented by the scheduler: ${String(exhausted)}`); + } } } @@ -130,8 +135,8 @@ export function fireDueTimers(db: Database, clock: Clock, opts: FireDueTimersOpt // waking on a fixed short interval all night — while `maxMs` bounds the wait so a newly-dispatched // task or a policy reload is still picked up promptly. export function msUntilNextTimer(db: Database, clock: Clock, maxMs: number): number { - const row = db.query("SELECT MIN(due_at) as next FROM timers WHERE fired_at IS NULL").get() as { next: string | null }; - if (!row.next) return maxMs; + const row = one<{ next: string | null }>(db, "SELECT MIN(due_at) as next FROM timers WHERE fired_at IS NULL"); + if (!row?.next) return maxMs; const delta = new Date(row.next).getTime() - new Date(clock()).getTime(); return Math.max(0, Math.min(delta, maxMs)); } @@ -153,17 +158,17 @@ export interface DispatchResult { // concurrency, budget headroom checked before launch. waiting(timer) tasks whose wake_at has // passed are already promoted to open by fireDueTimers before this runs. export function dispatchRunnable(db: Database, clock: Clock, opts: DispatchOpts): DispatchResult { - const openTasks = db - .query("SELECT id, identity_id FROM tasks WHERE status = 'open' ORDER BY opened_at ASC, id ASC") - .all() as { id: string; identity_id: string }[]; + const openTasks = many<{ id: string; identity_id: string }>( + db, + "SELECT id, identity_id FROM tasks WHERE status = 'open' ORDER BY opened_at ASC, id ASC", + ); const runningByIdentity = new Map(); - const runningRows = db - .query( - `SELECT t.identity_id as identity_id, COUNT(*) as c FROM executions e + const runningRows = many<{ identity_id: string; c: number }>( + db, + `SELECT t.identity_id as identity_id, COUNT(*) as c FROM executions e JOIN tasks t ON t.id = e.task_id WHERE e.status = 'running' GROUP BY t.identity_id`, - ) - .all() as { identity_id: string; c: number }[]; + ); for (const row of runningRows) runningByIdentity.set(row.identity_id, row.c); let globalRunning = runningRows.reduce((sum, row) => sum + row.c, 0); @@ -227,9 +232,10 @@ export function recoverFromRestart( clock: Clock, opts: { maxConsecutiveInterruptions: number }, ): RestartRecoveryResult { - const orphaned = db - .query("SELECT id, consecutive_interruptions FROM tasks WHERE status = 'active'") - .all() as { id: string; consecutive_interruptions: number }[]; + const orphaned = many<{ id: string; consecutive_interruptions: number }>( + db, + "SELECT id, consecutive_interruptions FROM tasks WHERE status = 'active'", + ); const reopened: string[] = []; const parked: string[] = []; diff --git a/src/ledger/search.ts b/src/ledger/search.ts index 3a3f2eb..0dadfe9 100644 --- a/src/ledger/search.ts +++ b/src/ledger/search.ts @@ -4,14 +4,15 @@ // takes an explicit identityId and filters on it in SQL. import type { Database } from "bun:sqlite"; import type { MemoryTier } from "./memory"; +import { many } from "./db"; export interface SearchOpts { query: string; - venueId?: string; // messages only — memories carry no venue, so these filters skip them - principalId?: string; - after?: string; // ISO bounds on received_at (messages) / created_at (memories) - before?: string; - limit?: number; + venueId?: string | undefined; // messages only — memories carry no venue, so these filters skip them + principalId?: string | undefined; + after?: string | undefined; // ISO bounds on received_at (messages) / created_at (memories) + before?: string | undefined; + limit?: number | undefined; } export interface SearchHit { @@ -70,14 +71,23 @@ export function searchArchive(db: Database, identityId: string, opts: SearchOpts where.push("e.received_at <= ?"); params.push(opts.before); } - const rows = db - .query( - `SELECT json_extract(e.payload, '$.text') AS text, bm25(events_fts) AS rank, e.received_at AS at, + const rows = many<{ + text: string | null; + rank: number; + at: string; + venue_id: string | null; + thread_root_id: string | null; + principal_id: string | null; + ts: string | null; + }>( + db, + `SELECT json_extract(e.payload, '$.text') AS text, bm25(events_fts) AS rank, e.received_at AS at, e.venue_id, e.thread_root_id, e.principal_id, json_extract(e.payload, '$.ts') AS ts FROM events_fts JOIN events e ON e.rowid = events_fts.rowid WHERE ${where.join(" AND ")} ORDER BY rank LIMIT ?`, - ) - .all(...params, limit) as { text: string | null; rank: number; at: string; venue_id: string | null; thread_root_id: string | null; principal_id: string | null; ts: string | null }[]; + ...params, + limit, + ); return rows.map((r) => ({ kind: "message" as const, text: r.text ?? "", @@ -108,13 +118,14 @@ export function searchArchive(db: Database, identityId: string, opts: SearchOpts where.push("m.created_at <= ?"); params.push(opts.before); } - const rows = db - .query( - `SELECT m.content AS text, bm25(memory_fts) AS rank, m.created_at AS at, m.id, m.tier + const rows = many<{ text: string; rank: number; at: string; id: string; tier: MemoryTier }>( + db, + `SELECT m.content AS text, bm25(memory_fts) AS rank, m.created_at AS at, m.id, m.tier FROM memory_fts JOIN memory_items m ON m.rowid = memory_fts.rowid WHERE ${where.join(" AND ")} ORDER BY rank LIMIT ?`, - ) - .all(...params, limit) as { text: string; rank: number; at: string; id: string; tier: MemoryTier }[]; + ...params, + limit, + ); return rows.map((r) => ({ kind: "memory" as const, text: r.text, @@ -129,5 +140,5 @@ export function searchArchive(db: Database, identityId: string, opts: SearchOpts })); }, opts.query); - return [...messages, ...memories].sort((a, b) => a.rank - b.rank).slice(0, limit); + return [...messages, ...memories].toSorted((a, b) => a.rank - b.rank).slice(0, limit); } diff --git a/src/ledger/tasks.ts b/src/ledger/tasks.ts index 4b3f4e6..4fee61b 100644 --- a/src/ledger/tasks.ts +++ b/src/ledger/tasks.ts @@ -1,7 +1,9 @@ // SPEC §6 — Task Ledger. This module is the single choke point for task state changes: // every status change and every executions-row change for a task goes through transition(). import type { Database } from "bun:sqlite"; +import { asString, isRecord, parseJson } from "../guard"; import type { Clock } from "./clock"; +import { many, one } from "./db"; import { scheduleTimer, type TimerKind } from "./timers"; import { writeAudit, type AuditKind } from "./audit"; @@ -125,6 +127,35 @@ interface Row { consecutive_interruptions: number; } +function asTier(v: string): Task["tier"] { + return v === "low" || v === "medium" ? v : "high"; +} + +function parsePending(text: string | null): PendingConfirmation | null { + if (!text) return null; + const v = parseJson(text); + if (!isRecord(v)) return null; + const pending: PendingConfirmation = { + actionRef: asString(v.actionRef), + description: asString(v.description), + requestedAt: asString(v.requestedAt), + }; + if (isRecord(v.resolution)) { + pending.resolution = { + approved: v.resolution.approved === true, + principalId: asString(v.resolution.principalId), + resolvedAt: asString(v.resolution.resolvedAt), + }; + } + if (typeof v.consumedAt === "string") pending.consumedAt = v.consumedAt; + return pending; +} + +function parseArtifacts(text: string): string[] { + const v = parseJson(text); + return Array.isArray(v) ? v.map((x) => asString(x)) : []; +} + function rowToTask(row: Row): Task { return { id: row.id, @@ -137,10 +168,10 @@ function rowToTask(row: Row): Task { homeAnchor: { venueId: row.home_venue_id, threadRootId: row.home_thread_root_id }, originEventId: row.origin_event_id, wakeAt: row.wake_at, - pendingConfirmation: row.pending_confirmation ? JSON.parse(row.pending_confirmation) : null, + pendingConfirmation: parsePending(row.pending_confirmation), recurrence: row.recurrence, - tier: (row.tier as Task["tier"]) ?? "high", - artifacts: JSON.parse(row.artifacts), + tier: asTier(row.tier), + artifacts: parseArtifacts(row.artifacts), terminalReport: row.terminal_report, createdAt: row.created_at, updatedAt: row.updated_at, @@ -150,27 +181,30 @@ function rowToTask(row: Row): Task { } export function getTask(db: Database, taskId: string): Task | null { - const row = db.query("SELECT * FROM tasks WHERE id = ?").get(taskId) as Row | null; + const row = one(db, "SELECT * FROM tasks WHERE id = ?", taskId); return row ? rowToTask(row) : null; } // SPEC §4.2 — "short, human-readable, unique per service instance, and usable in chat." T-1, T-2, ... export function nextTaskId(db: Database): string { - const row = db.query("SELECT MAX(CAST(SUBSTR(id, 3) AS INTEGER)) as n FROM tasks WHERE id LIKE 'T-%'").get() as { - n: number | null; - }; - return `T-${(row.n ?? 0) + 1}`; + const row = one<{ n: number | null }>(db, "SELECT MAX(CAST(SUBSTR(id, 3) AS INTEGER)) as n FROM tasks WHERE id LIKE 'T-%'"); + return `T-${(row?.n ?? 0) + 1}`; } // SPEC §11 — the ledger view a turn's context is built from: open tasks + recent terminals, for // one identity (never cross-identity, per §7.1). export function ledgerView(db: Database, identityId: string, recentTerminalsLimit = 10): { open: Task[]; recentTerminals: Task[] } { - const openRows = db - .query("SELECT * FROM tasks WHERE identity_id = ? AND status NOT IN ('done','failed','cancelled') ORDER BY opened_at ASC") - .all(identityId) as Row[]; - const terminalRows = db - .query("SELECT * FROM tasks WHERE identity_id = ? AND status IN ('done','failed','cancelled') ORDER BY updated_at DESC LIMIT ?") - .all(identityId, recentTerminalsLimit) as Row[]; + const openRows = many( + db, + "SELECT * FROM tasks WHERE identity_id = ? AND status NOT IN ('done','failed','cancelled') ORDER BY opened_at ASC", + identityId, + ); + const terminalRows = many( + db, + "SELECT * FROM tasks WHERE identity_id = ? AND status IN ('done','failed','cancelled') ORDER BY updated_at DESC LIMIT ?", + identityId, + recentTerminalsLimit, + ); return { open: openRows.map(rowToTask), recentTerminals: terminalRows.map(rowToTask) }; } @@ -193,9 +227,7 @@ export function requireTaskFor(db: Database, identityId: string, taskId: string) // there's at most one). Exported so the service's dispatch driver can find the execution id // dispatchRunnable just created, to hand to runExecution. export function liveExecutionId(db: Database, taskId: string): string | null { - const row = db.query("SELECT id FROM executions WHERE task_id = ? AND status = 'running'").get(taskId) as - | { id: string } - | null; + const row = one<{ id: string }>(db, "SELECT id FROM executions WHERE task_id = ? AND status = 'running'", taskId); return row?.id ?? null; } @@ -219,9 +251,9 @@ export interface CreateTaskParams { sponsorId: string; homeAnchor: Anchor; originEventId: string; - recurrence?: string; - tier?: Task["tier"]; - sponsorIsOperator?: boolean; + recurrence?: string | undefined; + tier?: Task["tier"] | undefined; + sponsorIsOperator?: boolean | undefined; } export function createTask(db: Database, clock: Clock, params: CreateTaskParams): Task { @@ -319,9 +351,7 @@ function applyTransition( switch (cause.type) { case "dispatch": { - const attempt = - ((db.query("SELECT MAX(attempt) as m FROM executions WHERE task_id = ?").get(taskId) as { m: number | null }) - .m ?? 0) + 1; + const attempt = (one<{ m: number | null }>(db, "SELECT MAX(attempt) as m FROM executions WHERE task_id = ?", taskId)?.m ?? 0) + 1; db.query("INSERT INTO executions (id, task_id, attempt, status, started_at) VALUES (?, ?, ?, 'running', ?)").run( cause.executionId, taskId, @@ -513,6 +543,8 @@ export function steerTask(db: Database, clock: Clock, params: SteerParams): Stee return steerResume(db, clock, task, params); case "confirm": return steerConfirm(db, clock, task, params); + default: + throw new Error(`unhandled steer kind: ${asString(params.kind)}`); } } @@ -522,7 +554,7 @@ function appendSpec(db: Database, clock: Clock, task: Task, addition: string): v } function steerGuidance(db: Database, clock: Clock, task: Task, params: SteerParams): SteerResult { - const text = String(params.payload.text ?? ""); + const text = asString(params.payload.text); appendSpec(db, clock, task, text); const live = task.status === "active"; @@ -536,7 +568,7 @@ function steerGuidance(db: Database, clock: Clock, task: Task, params: SteerPara } function steerCancel(db: Database, clock: Clock, task: Task, params: SteerParams): SteerResult { - const report = String(params.payload.report ?? `Cancelled "${task.title}".`); + const report = asString(params.payload.report, `Cancelled "${task.title}".`); const wasLive = task.status === "active"; const after = transition(db, clock, task.id, "cancelled", { type: "cancelled", report }); insertSteeringRow(db, clock, task.id, "cancel", params.payload, params.sourceEventId, !wasLive); @@ -569,29 +601,38 @@ function steerResume(db: Database, clock: Clock, task: Task, params: SteerParams function steerConfirm(db: Database, clock: Clock, task: Task, params: SteerParams): SteerResult { const approve = Boolean(params.payload.approve); - const principalId = String(params.payload.principalId ?? ""); + const principalId = asString(params.payload.principalId); const outcome = resolveConfirmation(db, clock, { identityId: task.identityId, taskId: task.id, principalId, approve }); insertSteeringRow(db, clock, task.id, "confirm", params.payload, params.sourceEventId, true); return outcome; } export function consumeSteering(db: Database, clock: Clock, taskId: string): SteeringRow[] { - const rows = db - .query("SELECT * FROM steering WHERE task_id = ? AND consumed_at IS NULL ORDER BY created_at") - .all(taskId) as any[]; + const rows = many<{ + id: string; + task_id: string; + kind: SteeringKind; + payload: string; + source_event_id: string; + created_at: string; + consumed_at: string | null; + }>(db, "SELECT * FROM steering WHERE task_id = ? AND consumed_at IS NULL ORDER BY created_at", taskId); const now = clock(); for (const row of rows) { db.query("UPDATE steering SET consumed_at = ? WHERE id = ?").run(now, row.id); } - return rows.map((row) => ({ - id: row.id, - taskId: row.task_id, - kind: row.kind, - payload: JSON.parse(row.payload), - sourceEventId: row.source_event_id, - createdAt: row.created_at, - consumedAt: now, - })); + return rows.map((row) => { + const parsed = parseJson(row.payload); + return { + id: row.id, + taskId: row.task_id, + kind: row.kind, + payload: isRecord(parsed) ? parsed : {}, + sourceEventId: row.source_event_id, + createdAt: row.created_at, + consumedAt: now, + }; + }); } export interface RequestConfirmationParams { diff --git a/src/ledger/timers.ts b/src/ledger/timers.ts index b5e965d..50ff533 100644 --- a/src/ledger/timers.ts +++ b/src/ledger/timers.ts @@ -2,6 +2,7 @@ // task state machine (that lives in tasks.ts, which schedules timers through this module). import type { Database } from "bun:sqlite"; import type { Clock } from "./clock"; +import { many } from "./db"; export type TimerKind = "task_wake" | "nudge" | "park" | "ambient_tick" | "distillation" | "recurrence"; @@ -54,9 +55,7 @@ function rowToTimer(row: Row): TimerRow { // restart), every unfired timer at or before it comes back in due_at order (SPEC §13). export function listDueTimers(db: Database, clock: Clock): TimerRow[] { const now = clock(); - const rows = db - .query("SELECT * FROM timers WHERE fired_at IS NULL AND due_at <= ? ORDER BY due_at ASC, id ASC") - .all(now) as Row[]; + const rows = many(db, "SELECT * FROM timers WHERE fired_at IS NULL AND due_at <= ? ORDER BY due_at ASC, id ASC", now); return rows.map(rowToTimer); } diff --git a/src/ledger/turns.ts b/src/ledger/turns.ts index f7a9f16..82ee42a 100644 --- a/src/ledger/turns.ts +++ b/src/ledger/turns.ts @@ -2,8 +2,10 @@ // "running" turn row — a live turn's existence lives in the caller's process, not the ledger); // audit carries both the start and end events regardless. import type { Database } from "bun:sqlite"; +import { asString, isRecord, parseJson } from "../guard"; import type { Clock } from "./clock"; import { writeAudit } from "./audit"; +import { many, one } from "./db"; import type { Anchor } from "./tasks"; // The ledger accepts the live kinds (resident/execution_step/attention) plus the pre-collapse @@ -50,6 +52,11 @@ interface Row { ended_at: string | null; } +function asUnknownArray(text: string): unknown[] { + const v = parseJson(text); + return Array.isArray(v) ? v : []; +} + function rowToTurn(row: Row): Turn { return { id: row.id, @@ -58,7 +65,7 @@ function rowToTurn(row: Row): Turn { executionId: row.execution_id, anchor: row.venue_id ? { venueId: row.venue_id, threadRootId: row.thread_root_id } : null, status: row.status, - effects: JSON.parse(row.effects), + effects: asUnknownArray(row.effects), spendAmount: row.spend_amount, startedAt: row.started_at, endedAt: row.ended_at, @@ -66,7 +73,7 @@ function rowToTurn(row: Row): Turn { } export function getTurn(db: Database, turnId: string): Turn | null { - const row = db.query("SELECT * FROM turns WHERE id = ?").get(turnId) as Row | null; + const row = one(db, "SELECT * FROM turns WHERE id = ?", turnId); return row ? rowToTurn(row) : null; } @@ -111,24 +118,54 @@ export interface OutboundEffect { } export function lastTurnStartedAt(db: Database, identityId: string, kind: TurnKind): string | null { - const row = db.query("SELECT MAX(started_at) AS at FROM turns WHERE identity_id = ? AND kind = ?").get(identityId, kind) as { at: string | null }; - return row.at; + const row = one<{ at: string | null }>(db, "SELECT MAX(started_at) AS at FROM turns WHERE identity_id = ? AND kind = ?", identityId, kind); + return row?.at ?? null; } export function outboundEffectsSince(db: Database, identityId: string, sinceIso: string): OutboundEffect[] { - const rows = db - .query("SELECT effects FROM turns WHERE identity_id = ? AND kind = 'resident' AND started_at >= ? ORDER BY started_at") - .all(identityId, sinceIso) as { effects: string }[]; + const rows = many<{ effects: string }>( + db, + "SELECT effects FROM turns WHERE identity_id = ? AND kind = 'resident' AND started_at >= ? ORDER BY started_at", + identityId, + sinceIso, + ); const out: OutboundEffect[] = []; for (const row of rows) { - const effects = JSON.parse(row.effects) as { kind?: string; text?: string; emoji?: string; ts?: string; venueId?: string; threadRootId?: string | null; why?: string; anchor?: { venueId?: string; threadRootId?: string | null } }[]; - for (const e of effects) { - if (e.kind === "posted") { - out.push({ kind: "posted", venueId: e.anchor?.venueId ?? "", threadRootId: e.anchor?.threadRootId ?? null, ts: null, emoji: null, text: e.text ?? null, why: null }); - } else if (e.kind === "reacted") { - out.push({ kind: "reacted", venueId: e.venueId ?? "", threadRootId: null, ts: e.ts ?? null, emoji: e.emoji ?? null, text: null, why: null }); - } else if (e.kind === "stepped_back") { - out.push({ kind: "stepped_back", venueId: e.venueId ?? "", threadRootId: e.threadRootId ?? null, ts: null, emoji: null, text: null, why: e.why ?? null }); + const effects = parseJson(row.effects); + if (!Array.isArray(effects)) continue; + for (const item of effects) { + if (!isRecord(item)) continue; + const anchor = isRecord(item.anchor) ? item.anchor : {}; + if (item.kind === "posted") { + out.push({ + kind: "posted", + venueId: asString(anchor.venueId), + threadRootId: typeof anchor.threadRootId === "string" ? anchor.threadRootId : null, + ts: null, + emoji: null, + text: typeof item.text === "string" ? item.text : null, + why: null, + }); + } else if (item.kind === "reacted") { + out.push({ + kind: "reacted", + venueId: asString(item.venueId), + threadRootId: null, + ts: typeof item.ts === "string" ? item.ts : null, + emoji: typeof item.emoji === "string" ? item.emoji : null, + text: null, + why: null, + }); + } else if (item.kind === "stepped_back") { + out.push({ + kind: "stepped_back", + venueId: asString(item.venueId), + threadRootId: typeof item.threadRootId === "string" ? item.threadRootId : null, + ts: null, + emoji: null, + text: null, + why: typeof item.why === "string" ? item.why : null, + }); } } } @@ -138,17 +175,18 @@ export function outboundEffectsSince(db: Database, identityId: string, sinceIso: // The worker's task_ask question, recovered from its turn effects so the resident mind // can put the actual question to the room (the ask itself posts nothing). export function lastAskQuestion(db: Database, taskId: string): string | null { - const rows = db - .query( - `SELECT t.effects FROM turns t JOIN executions e ON t.execution_id = e.id + const rows = many<{ effects: string }>( + db, + `SELECT t.effects FROM turns t JOIN executions e ON t.execution_id = e.id WHERE e.task_id = ? ORDER BY t.started_at DESC LIMIT 10`, - ) - .all(taskId) as { effects: string }[]; + taskId, + ); for (const row of rows) { try { - const effects = JSON.parse(row.effects) as { kind?: string; question?: string }[]; - const ask = [...effects].reverse().find((e) => e.kind === "task_asked" && typeof e.question === "string"); - if (ask?.question) return ask.question; + const effects = parseJson(row.effects); + if (!Array.isArray(effects)) continue; + const ask = effects.toReversed().find((e) => isRecord(e) && e.kind === "task_asked" && typeof e.question === "string"); + if (isRecord(ask) && typeof ask.question === "string") return ask.question; } catch { // a malformed effects row is a recording bug, not a reason to fail delivery } diff --git a/src/main.ts b/src/main.ts index c1e4e22..3a4b987 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,6 +18,7 @@ import { SlackAdapter } from "@bevyl-ai/agent-tools"; import { AppServerSession } from "@bevyl-ai/agent-tools"; import { DEFAULT_CODEX_CONFIG } from "./turn-runner/types"; import type { DynamicTool } from "./turn-runner/types"; +import { isRecord } from "./guard"; const HELP = `earshot — a Slack-resident agent with a durable task ledger. @@ -199,18 +200,23 @@ needs: codex logged in, EARSHOT_POLICY (or ./policy.yaml), and the workspace dir --bot-id bot principal id (default SLACK_BOT_USER_ID, else UREPLAY) `; +function replayArg(name: string): string | undefined { + const i = process.argv.indexOf(`--${name}`); + return i >= 0 ? process.argv[i + 1] : undefined; +} + +function replayShow(kind: string, detail: unknown): string { + return ` ${kind}: ${JSON.stringify(detail)}`; +} + async function cmdReplay(): Promise { if (process.argv.includes("--help")) { console.log(REPLAY_HELP); return; } - const arg = (name: string) => { - const i = process.argv.indexOf(`--${name}`); - return i >= 0 ? process.argv[i + 1] : undefined; - }; - const snapshot = arg("db"); - const from = arg("from"); - const to = arg("to"); + const snapshot = replayArg("db"); + const from = replayArg("from"); + const to = replayArg("to"); if (!snapshot || !from || !to) { console.log(REPLAY_HELP); process.exit(1); @@ -219,7 +225,7 @@ async function cmdReplay(): Promise { const { runReplay } = await import("./replay/run"); const { copyFileSync } = await import("node:fs"); - const workspace = arg("workspace") ?? "./replay-workspace"; + const workspace = replayArg("workspace") ?? "./replay-workspace"; mkdirSync(workspace, { recursive: true }); const copy = join(workspace, "replay.db"); copyFileSync(snapshot, copy); // rewind is destructive — never open the snapshot itself @@ -227,7 +233,7 @@ async function cmdReplay(): Promise { const store = makeStore(); const log = createLogger(); - const venue = arg("venue"); + const venue = replayArg("venue"); const events = loadIncident(db, { fromIso: from, toIso: to, ...(venue ? { venueId: venue } : {}) }); if (events.length === 0) { console.error("no surface messages in that window"); @@ -240,7 +246,7 @@ async function cmdReplay(): Promise { `${rewound.tasks} tasks, ${rewound.timers} timers cleared` + (rewound.memoriesInWindow ? ` (caveat: ${rewound.memoriesInWindow} memories written in-window stay — no edit history to rewind)` : ""), ); - console.log(`replaying ${events.length} messages at speed ${arg("speed") ?? "1"}…\n`); + console.log(`replaying ${events.length} messages at speed ${replayArg("speed") ?? "1"}…\n`); const captured = await runReplay({ db, @@ -248,16 +254,20 @@ async function cmdReplay(): Promise { policyStore: store, sessionFactory: makeCodexSessionFactory(log), workspace, - botPrincipalId: arg("bot-id") ?? process.env.SLACK_BOT_USER_ID ?? "UREPLAY", - speed: Number(arg("speed") ?? "1"), + botPrincipalId: replayArg("bot-id") ?? process.env.SLACK_BOT_USER_ID ?? "UREPLAY", + speed: Number(replayArg("speed") ?? "1"), logger: log, }); - const show = (kind: string, detail: unknown) => ` ${kind}: ${JSON.stringify(detail)}`; console.log("\n=== originally ==="); - for (const t of original) for (const e of t.effects as { kind?: string }[]) console.log(show(e.kind ?? "?", e)); + for (const t of original) { + for (const e of t.effects) { + const kind = isRecord(e) && typeof e.kind === "string" ? e.kind : "?"; + console.log(replayShow(kind, e)); + } + } console.log("\n=== in replay ==="); - for (const c of captured) console.log(show(c.kind, c.detail)); + for (const c of captured) console.log(replayShow(c.kind, c.detail)); db.close(); } @@ -271,7 +281,7 @@ async function cmdDoctor(): Promise { makeStore(); console.log(`ok policy validates (${policyPath()})`); } catch (e) { - console.log(`MISSING policy — ${e instanceof Error ? e.message.split("\n")[0] : e}`); + console.log(`MISSING policy — ${e instanceof Error ? e.message.split("\n")[0] : String(e)}`); } } diff --git a/src/policy/broker.ts b/src/policy/broker.ts index cd28b78..01d260c 100644 --- a/src/policy/broker.ts +++ b/src/policy/broker.ts @@ -54,9 +54,9 @@ export interface ToolCallContext { // Required when tool === "task_confirm": task_confirm's eligibility gate (§10.4) is checked // HERE, at the same choke point as every other tool decision — not left for a caller to // remember to check separately before calling tasks.ts's resolveConfirmation. - principal?: { isGuest: boolean }; - guestPolicy?: GuestPolicyOpts; - taskId?: string; // the execution's task — the redemption scope for approved confirmations + principal?: { isGuest: boolean } | undefined; + guestPolicy?: GuestPolicyOpts | undefined; + taskId?: string | undefined; // the execution's task — the redemption scope for approved confirmations } type ToolClass = "task_mutating" | "confirm" | "task_read" | "memory_mutating" | "memory_read" | "posting" | "scheduling" | "task_outcome" | "presence"; @@ -140,7 +140,7 @@ function actionClassDecision(ctx: ToolCallContext, grant: IdentityConfig["grants export function canonicalJson(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; if (value !== null && typeof value === "object") { - const entries = Object.entries(value as Record).sort(([a], [b]) => (a < b ? -1 : 1)); + const entries = Object.entries(value).toSorted(([a], [b]) => (a < b ? -1 : 1)); return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`; } return JSON.stringify(value) ?? "null"; diff --git a/src/policy/budget.ts b/src/policy/budget.ts index 01231c0..577baab 100644 --- a/src/policy/budget.ts +++ b/src/policy/budget.ts @@ -2,6 +2,7 @@ // already recorded by turns.ts) and aggregated here, calendar-monthly in the configured timezone. import type { Database } from "bun:sqlite"; import type { Clock } from "../ledger/clock"; +import { many, one } from "../ledger/db"; // A calendar month never exceeds 31 days and timezone skew is at most ~14h, so scanning 35 days // back from "now" always covers the current calendar month in any timezone, without needing @@ -21,14 +22,13 @@ function sumSpendThisMonth(db: Database, now: string, timezone: string, identity const key = monthKey(now, timezone); const since = new Date(new Date(now).getTime() - SCAN_WINDOW_MS).toISOString(); const rows = identityId - ? (db.query("SELECT spend_amount, started_at FROM turns WHERE identity_id = ? AND started_at >= ?").all(identityId, since) as { - spend_amount: number; - started_at: string; - }[]) - : (db.query("SELECT spend_amount, started_at FROM turns WHERE started_at >= ?").all(since) as { - spend_amount: number; - started_at: string; - }[]); + ? many<{ spend_amount: number; started_at: string }>( + db, + "SELECT spend_amount, started_at FROM turns WHERE identity_id = ? AND started_at >= ?", + identityId, + since, + ) + : many<{ spend_amount: number; started_at: string }>(db, "SELECT spend_amount, started_at FROM turns WHERE started_at >= ?", since); return rows.filter((r) => monthKey(r.started_at, timezone) === key).reduce((sum, r) => sum + r.spend_amount, 0); } @@ -44,13 +44,13 @@ export function globalSpendThisMonth(db: Database, clock: Clock, timezone: strin // monthly allowance (SPEC §4.1.11 declares it alongside monthly caps but without the "calendar // month" qualifier those get). export function taskSpend(db: Database, taskId: string): number { - const row = db - .query( - `SELECT COALESCE(SUM(t.spend_amount), 0) as total FROM turns t + const row = one<{ total: number }>( + db, + `SELECT COALESCE(SUM(t.spend_amount), 0) as total FROM turns t JOIN executions e ON e.id = t.execution_id WHERE e.task_id = ?`, - ) - .get(taskId) as { total: number }; - return row.total; + taskId, + ); + return row?.total ?? 0; } export interface BudgetStatus { diff --git a/src/policy/load.ts b/src/policy/load.ts index 8a69750..4ec6417 100644 --- a/src/policy/load.ts +++ b/src/policy/load.ts @@ -13,13 +13,14 @@ import type { TasksConfig, TurnsConfig, } from "./schema"; +import { isRecord } from "../guard"; export function parsePolicyYaml(yamlText: string): unknown { return Bun.YAML.parse(yamlText); } function obj(v: unknown): Record { - return v && typeof v === "object" && !Array.isArray(v) ? (v as Record) : {}; + return isRecord(v) ? v : {}; } function arr(v: unknown): unknown[] { @@ -47,7 +48,7 @@ function toGrant(raw: unknown): GrantConfig { const g = obj(raw); return { tool: str(g.tool, ""), - scope: g.scope && typeof g.scope === "object" ? (g.scope as Record) : undefined, + scope: isRecord(g.scope) ? g.scope : undefined, preauthorizedActionClasses: strArr(g.preauthorized_action_classes), }; } diff --git a/src/policy/schema.ts b/src/policy/schema.ts index 70b51e7..b488ccf 100644 --- a/src/policy/schema.ts +++ b/src/policy/schema.ts @@ -8,7 +8,7 @@ export interface SurfaceConfig { export interface GrantConfig { tool: string; - scope?: Record; + scope?: Record | undefined; preauthorizedActionClasses: string[]; } diff --git a/src/replay/incident.ts b/src/replay/incident.ts index 4c0d577..dc7b3fb 100644 --- a/src/replay/incident.ts +++ b/src/replay/incident.ts @@ -4,6 +4,33 @@ // run it on a COPY of the ledger, never the live file (the CLI copies before opening). import type { Database } from "bun:sqlite"; import type { RawMessage, MessageFile } from "@bevyl-ai/agent-tools"; +import { asString, isRecord, parseJson } from "../guard"; +import { many, one } from "../ledger/db"; + +export function messageFiles(v: unknown): MessageFile[] | undefined { + if (!Array.isArray(v)) return undefined; + const files: MessageFile[] = []; + for (const item of v) { + if ( + !isRecord(item) || + typeof item.id !== "string" || + typeof item.name !== "string" || + typeof item.mimetype !== "string" || + typeof item.urlPrivate !== "string" || + typeof item.size !== "number" + ) { + continue; + } + files.push({ + id: item.id, + name: item.name, + mimetype: item.mimetype, + urlPrivate: item.urlPrivate, + size: item.size, + }); + } + return files.length ? files : undefined; +} export interface IncidentEvent { rowid: number; @@ -32,30 +59,33 @@ interface EventRow { // the router ever records. external_signal rows are excluded — those are the system's own // productions (worker outcomes, timers) and the replay's service re-derives them itself. export function loadIncident(db: Database, w: IncidentWindow): IncidentEvent[] { - const rows = db - .query( - `SELECT rowid, venue_id, thread_root_id, principal_id, payload, received_at FROM events + const rows = many( + db, + `SELECT rowid, venue_id, thread_root_id, principal_id, payload, received_at FROM events WHERE kind IN ('addressed_message','observed_message') AND received_at >= ? AND received_at < ? ${w.venueId ? "AND venue_id = ?" : ""} ORDER BY rowid`, - ) - .all(...(w.venueId ? [w.fromIso, w.toIso, w.venueId] : [w.fromIso, w.toIso])) as EventRow[]; + ...(w.venueId ? [w.fromIso, w.toIso, w.venueId] : [w.fromIso, w.toIso]), + ); return rows.map((r) => { - const p = JSON.parse(r.payload) as { text?: string; ts?: string; isBot?: boolean; addressMode?: string; files?: MessageFile[] }; + const parsed = parseJson(r.payload); + const p = isRecord(parsed) ? parsed : {}; + const ts = asString(p.ts); + const files = messageFiles(p.files); return { rowid: r.rowid, receivedAt: r.received_at, message: { venueId: r.venue_id ?? "", - venueKind: p.addressMode === "dm" ? ("dm" as const) : ("channel" as const), + venueKind: p.addressMode === "dm" ? "dm" : "channel", principalId: r.principal_id, - isBot: p.isBot ?? false, - text: p.text ?? "", - ts: p.ts ?? "", + isBot: p.isBot === true, + text: asString(p.text), + ts, // A root the router re-homed into its own thread (thread_root_id = its own ts) was // delivered top-level — reconstruct it that way so the replay's own router re-homes it. - threadRootTs: r.thread_root_id === (p.ts ?? "") ? null : r.thread_root_id, + threadRootTs: r.thread_root_id === ts ? null : r.thread_root_id, mentionsBotId: p.addressMode === "mention", - ...(p.files?.length ? { files: p.files } : {}), + ...(files ? { files } : {}), }, }; }); @@ -69,10 +99,16 @@ export interface OriginalTurn { // What she actually did in the window — read BEFORE rewindLedger, which deletes these rows. export function originalActions(db: Database, fromIso: string, toIso: string): OriginalTurn[] { - const rows = db - .query("SELECT started_at, kind, effects FROM turns WHERE started_at >= ? AND started_at < ? AND kind IN ('resident','attention') ORDER BY started_at") - .all(fromIso, toIso) as { started_at: string; kind: string; effects: string }[]; - return rows.map((r) => ({ startedAt: r.started_at, kind: r.kind, effects: JSON.parse(r.effects) as unknown[] })); + const rows = many<{ started_at: string; kind: string; effects: string }>( + db, + "SELECT started_at, kind, effects FROM turns WHERE started_at >= ? AND started_at < ? AND kind IN ('resident','attention') ORDER BY started_at", + fromIso, + toIso, + ); + return rows.map((r) => { + const effects = parseJson(r.effects); + return { startedAt: r.started_at, kind: r.kind, effects: Array.isArray(effects) ? effects : [] }; + }); } export interface RewindReport { @@ -95,9 +131,11 @@ export function rewindLedger(db: Database, cutoffRowid: number, fromIso: string) const tx = db.transaction(() => { // events_fts is contentless (content='') with an insert-only trigger, so doomed docs must be // removed explicitly — an fts5 'delete' needs the original text back. - const doomed = db - .query("SELECT rowid, coalesce(json_extract(payload,'$.text'),'') AS text FROM events WHERE rowid >= ?") - .all(cutoffRowid) as { rowid: number; text: string }[]; + const doomed = many<{ rowid: number; text: string }>( + db, + "SELECT rowid, coalesce(json_extract(payload,'$.text'),'') AS text FROM events WHERE rowid >= ?", + cutoffRowid, + ); for (const d of doomed) db.query("INSERT INTO events_fts (events_fts, rowid, text) VALUES ('delete', ?, ?)").run(d.rowid, d.text); const events = db.query("DELETE FROM events WHERE rowid >= ?").run(cutoffRowid).changes; const turns = db.query("DELETE FROM turns WHERE started_at >= ?").run(fromIso).changes; @@ -116,7 +154,7 @@ export function rewindLedger(db: Database, cutoffRowid: number, fromIso: string) db.query("DELETE FROM steering").run(); db.query("DELETE FROM executions").run(); const tasks = db.query("DELETE FROM tasks").run().changes; - const memoriesInWindow = (db.query("SELECT count(*) AS n FROM memory_items WHERE created_at >= ?").get(fromIso) as { n: number }).n; + const memoriesInWindow = one<{ n: number }>(db, "SELECT count(*) AS n FROM memory_items WHERE created_at >= ?", fromIso)?.n ?? 0; return { events, turns, itemsDeleted, itemsReopened, tasks, timers, memoriesInWindow }; }); return tx(); diff --git a/src/replay/run.ts b/src/replay/run.ts index edb30d3..f0f32ff 100644 --- a/src/replay/run.ts +++ b/src/replay/run.ts @@ -8,7 +8,9 @@ import { INTEGRATION_REGISTRIES, flattenRegistries, type ToolRegistry } from ".. import { systemClock, type Clock } from "../ledger/clock"; import type { PolicyStore } from "../policy/load"; import type { Logger } from "../log"; -import type { IncidentEvent } from "./incident"; +import { messageFiles, type IncidentEvent } from "./incident"; +import { many } from "../ledger/db"; +import { isRecord, parseJson } from "../guard"; export interface CapturedAction { at: string; @@ -32,13 +34,22 @@ class CaptureAdapter implements SurfaceAdapter { private clock: Clock, db: Database, ) { - const rows = db - .query("SELECT venue_id, thread_root_id, principal_id, payload FROM events WHERE kind IN ('addressed_message','observed_message') ORDER BY rowid") - .all() as { venue_id: string | null; thread_root_id: string | null; principal_id: string | null; payload: string }[]; + const rows = many<{ venue_id: string | null; thread_root_id: string | null; principal_id: string | null; payload: string }>( + db, + "SELECT venue_id, thread_root_id, principal_id, payload FROM events WHERE kind IN ('addressed_message','observed_message') ORDER BY rowid", + ); for (const r of rows) { - const p = JSON.parse(r.payload) as { text?: string; ts?: string; files?: MessageFile[] }; - if (!p.ts) continue; - this.append(r.thread_root_id ?? p.ts, { user: r.principal_id, text: p.text ?? "", ts: p.ts, ...(p.files?.length ? { files: p.files } : {}) }); + const parsed = parseJson(r.payload); + const p = isRecord(parsed) ? parsed : {}; + const ts = typeof p.ts === "string" ? p.ts : ""; + if (!ts) continue; + const files = messageFiles(p.files); + this.append(r.thread_root_id ?? ts, { + user: r.principal_id, + text: typeof p.text === "string" ? p.text : "", + ts, + ...(files ? { files } : {}), + }); } } @@ -106,17 +117,18 @@ export function recordingRegistries(captured: CapturedAction[], clock: Clock): T // slack registry (same names, so existing grants validate and expose them identically). export function snapshotSlackRegistry(db: Database): ToolRegistry { const messages = (where: string, params: string[], limit: number) => - db - .query( - `SELECT venue_id, thread_root_id, principal_id, payload FROM events + many<{ principal_id: string | null; payload: string }>( + db, + `SELECT venue_id, thread_root_id, principal_id, payload FROM events WHERE kind IN ('addressed_message','observed_message') AND ${where} ORDER BY rowid DESC LIMIT ?`, - ) - .all(...params, limit) - .reverse() - .map((row) => { - const r = row as { principal_id: string | null; payload: string }; - const p = JSON.parse(r.payload) as { text?: string; ts?: string }; - return { user: r.principal_id, text: p.text ?? "", ts: p.ts ?? "" }; + ...params, + limit, + ) + .toReversed() + .map((r) => { + const parsed = parseJson(r.payload); + const p = isRecord(parsed) ? parsed : {}; + return { user: r.principal_id, text: typeof p.text === "string" ? p.text : "", ts: typeof p.ts === "string" ? p.ts : "" }; }); return { name: "slack", @@ -126,19 +138,22 @@ export function snapshotSlackRegistry(db: Database): ToolRegistry { description: "Read recent messages from a Slack channel. Input: { channel, limit? } — channel as <#C…> link or id.", inputSchema: { type: "object", additionalProperties: false, required: ["channel"], properties: { channel: { type: "string" }, limit: { type: "number" } } }, run: async (args: unknown) => { - const a = (args ?? {}) as { channel?: string; limit?: number }; - const venueId = a.channel?.replace(/^<#|[|>].*$/g, ""); + const a = isRecord(args) ? args : {}; + const channel = typeof a.channel === "string" ? a.channel : ""; + const venueId = channel.replace(/^<#|[|>].*$/g, ""); if (!venueId) return { success: false, output: "read_channel needs a { channel }" }; - return { success: true, output: JSON.stringify(messages("venue_id = ? AND thread_root_id IS NULL", [venueId], Math.min(a.limit ?? 20, 100))) }; + return { success: true, output: JSON.stringify(messages("venue_id = ? AND thread_root_id IS NULL", [venueId], Math.min(typeof a.limit === "number" ? a.limit : 20, 100))) }; }, }, read_thread: { description: "Read a Slack thread's replies. Input: { channel, thread_ts, limit? }.", inputSchema: { type: "object", additionalProperties: false, required: ["channel", "thread_ts"], properties: { channel: { type: "string" }, thread_ts: { type: "string" }, limit: { type: "number" } } }, run: async (args: unknown) => { - const a = (args ?? {}) as { channel?: string; thread_ts?: string; limit?: number }; - if (!a.channel || !a.thread_ts) return { success: false, output: "read_thread needs { channel, thread_ts }" }; - return { success: true, output: JSON.stringify(messages("thread_root_id = ?", [a.thread_ts], Math.min(a.limit ?? 50, 200))) }; + const a = isRecord(args) ? args : {}; + const channel = typeof a.channel === "string" ? a.channel : ""; + const threadTs = typeof a.thread_ts === "string" ? a.thread_ts : ""; + if (!channel || !threadTs) return { success: false, output: "read_thread needs { channel, thread_ts }" }; + return { success: true, output: JSON.stringify(messages("thread_root_id = ?", [threadTs], Math.min(typeof a.limit === "number" ? a.limit : 50, 200))) }; }, }, }, diff --git a/src/service.ts b/src/service.ts index fa81df1..c394d7e 100644 --- a/src/service.ts +++ b/src/service.ts @@ -40,14 +40,12 @@ import { markDraftsConsumed, engage, stanceOf, - maxEventRowid, convoKey, makeRefTable, - conversationOf, - inboxLine, } from "./ledger/conversations"; import { composeEarInstructions } from "./turn-runner/ear-soul"; -import { checkpointWal } from "./ledger/db"; +import { asString, isRecord } from "./guard"; +import { checkpointWal, one } from "./ledger/db"; import { runExecution, type ExecutionOutcome } from "./turn-runner/execution-loop"; import { lastAskQuestion, type TurnStatus } from "./ledger/turns"; import { runTurn } from "./turn-runner/turn"; @@ -433,34 +431,38 @@ export class Service { }, }, run: async (args: unknown) => { - const a = args as { decision: string; why: string; ref?: string; itemId?: string }; - const target = a.ref ? refs.get(a.ref) : undefined; - if (a.ref && !target) { - return { success: false, output: `"${a.ref}" is not a ref — copy the [rN] tag (like r3) from the start of the line you are judging; timestamps and channel ids are labels, not addresses` }; + const a = isRecord(args) ? args : {}; + const decision = asString(a.decision); + const why = asString(a.why); + const ref = typeof a.ref === "string" ? a.ref : undefined; + const itemId = typeof a.itemId === "string" ? a.itemId : undefined; + const target = ref ? refs.get(ref) : undefined; + if (ref && !target) { + return { success: false, output: `"${ref}" is not a ref — copy the [rN] tag (like r3) from the start of the line you are judging; timestamps and channel ids are labels, not addresses` }; } // A hold/wake without a ref has nowhere durable to live — bounced, never nodded // through (audit 2026-08-13: a refless hold returned "noted" while recording nothing, // the 2026-08-10 discarded-judgment failure wearing a polite face). - if ((a.decision === "hold" || a.decision === "wake") && !target) { - return { success: false, output: `${a.decision} needs ref — the [rN] tag of a line in the conversation being judged, so the judgment lands on its row` }; + if ((decision === "hold" || decision === "wake") && !target) { + return { success: false, output: `${decision} needs ref — the [rN] tag of a line in the conversation being judged, so the judgment lands on its row` }; } const venueId = target?.venueId; // hold/wake judge the conversation the message LIVES in (a top-level line is surface // traffic); open_ask ROOTS the debt at the ask itself, where its answer will land. const residenceRoot = target ? target.threadRootId : null; const askRoot = target ? (target.threadRootId ?? target.ts ?? null) : null; - effects.push({ kind: "ear_verdict", decision: a.decision, why: a.why, venueId, threadRootId: residenceRoot }); - if (a.decision === "hold") { + effects.push({ kind: "ear_verdict", decision, why, venueId, threadRootId: residenceRoot }); + if (decision === "hold") { // A hold is durable judgment on the conversation's row, never a discarded verdict: // whenever these messages eventually deliver, the reads that held them ride along // (2026-08-10: four discarded "this is settled" holds preceded the stale post). - if (venueId) recordHold(this.d.db, this.d.clock, identityId, venueId, residenceRoot, a.why); - } else if (a.decision === "wake") { + if (venueId) recordHold(this.d.db, this.d.clock, identityId, venueId, residenceRoot, why); + } else if (decision === "wake") { needWake = true; // The why is her own first read, pinned to the conversation row — it rides the // wake that delivers these messages, and any later one, and survives a restart. - if (venueId) recordWakeWhy(this.d.db, this.d.clock, identityId, venueId, residenceRoot, a.why); - } else if (a.decision === "open_ask") { + if (venueId) recordWakeWhy(this.d.db, this.d.clock, identityId, venueId, residenceRoot, why); + } else if (decision === "open_ask") { if (!target || !venueId) { return { success: false, output: "open_ask needs ref — the [rN] tag of the ask itself (the message line), so the debt roots where its answer will land" }; } @@ -472,12 +474,12 @@ export class Service { // convention) — an anchor-less debt can never be settled by an in-thread answer. threadRootId: askRoot, askTs: target.ts ?? null, - what: a.why, + what: why, }); - } else if (a.decision === "close_ask") { - if (!a.itemId || !closeAttentionItem(this.d.db, this.d.clock, identityId, a.itemId, a.why)) return { success: false, output: "no open item with that id" }; - } else if (a.decision === "reopen_ask") { - if (!a.itemId || !reopenAttentionItem(this.d.db, identityId, a.itemId)) { + } else if (decision === "close_ask") { + if (!itemId || !closeAttentionItem(this.d.db, this.d.clock, identityId, itemId, why)) return { success: false, output: "no open item with that id" }; + } else if (decision === "reopen_ask") { + if (!itemId || !reopenAttentionItem(this.d.db, identityId, itemId)) { return { success: false, output: "nothing to reopen with that id: either it does not exist, or the operator settled it and that stays settled" }; } } @@ -569,7 +571,7 @@ export class Service { // choice, not a cheap-tier judgment); a mention re-engaged at ingest, so it always lands. const convos = pendingConversations(this.d.db, identityId); if (convos.length === 0) return; - const pending = convos.flatMap((c) => c.messages).sort((a, b) => a.rowid - b.rowid); + const pending = convos.flatMap((c) => c.messages).toSorted((a, b) => a.rowid - b.rowid); const wakeId = this.d.newId(); const addressed = pending.filter((m) => m.kind === "addressed_message"); // Direct addresses (mention/DM) alone carry the §14.2 duties: the failure fallback, the @@ -596,7 +598,7 @@ export class Service { let s = streams.get(k); if (!s) { const recipient = - [...pending].reverse().find((m) => m.principalId && convoKey(m.venueId ?? "", m.threadRootId ?? m.ts) === k)?.principalId ?? null; + pending.toReversed().find((m) => m.principalId && convoKey(m.venueId ?? "", m.threadRootId ?? m.ts) === k)?.principalId ?? null; s = new ReplyStream({ adapter: this.d.adapter, venueId: a.venueId, threadTs: a.threadRootId, recipient, log: this.log }); streams.set(k, s); } @@ -863,15 +865,15 @@ export class Service { // silence all of them). A conversation counts answered at either of a direct's two // anchors: its thread, or — for a top-level mention/DM — the venue surface. if (status !== "succeeded" && direct.length > 0) { - const owed = new Map(); + const owedRooms = new Map(); for (const m of direct) { const anchor: Anchor = { venueId: m.venueId ?? "", threadRootId: m.threadRootId ?? m.ts }; const k = convoKey(anchor.venueId, anchor.threadRootId); - if (!owed.has(k)) owed.set(k, { anchor, aliases: [k, ...(m.threadRootId ? [] : [convoKey(anchor.venueId, null)])] }); + if (!owedRooms.has(k)) owedRooms.set(k, { anchor, aliases: [k, ...(m.threadRootId ? [] : [convoKey(anchor.venueId, null)])] }); } const why = failureCause || (status === "timed_out" ? "it ran out of time" : "my agent runtime failed"); const fallbackText = `can't run right now — ${why}. try me again, or flag the operator if it keeps up.`; - for (const { anchor, aliases } of owed.values()) { + for (const { anchor, aliases } of owedRooms.values()) { if (aliases.some((k) => answeredConvos.has(k))) continue; // The sole harness-authored words the room ever hears go through the same acts door // as everything outward: idempotent across restarts of the same wake, visible in her @@ -973,6 +975,7 @@ export class Service { .then((r) => { this.log.info("execution finished", { taskId, outcome: r.outcome, turnsRun: r.turnsRun, tier: task.tier }); this.deliverWorkerReport(taskId, r.outcome); + return r; }) .catch((e) => { this.log.error("execution threw", { taskId, error: String(e) }); @@ -1008,9 +1011,11 @@ export class Service { // the same state cannot drag the mind out of bed for it. 2026-08-10 live: a task's // repeated identical "waiting on a human" wake was the one that posted stale into a // settled thread; the workflow measurement put this class as the largest wake driver. - const prev = this.d.db - .query("SELECT json_extract(payload, '$.text') AS text FROM events WHERE dedup_key LIKE ? ORDER BY rowid DESC LIMIT 1") - .get(`worker:${taskId}:%`) as { text: string | null } | null; + const prev = one<{ text: string | null }>( + this.d.db, + "SELECT json_extract(payload, '$.text') AS text FROM events WHERE dedup_key LIKE ? ORDER BY rowid DESC LIMIT 1", + `worker:${taskId}:%`, + ); this.d.db .query( `INSERT INTO events (id, dedup_key, kind, identity_id, venue_id, thread_root_id, principal_id, payload, received_at) diff --git a/src/status.ts b/src/status.ts index a1336bb..8d9ea18 100644 --- a/src/status.ts +++ b/src/status.ts @@ -4,6 +4,7 @@ import type { Database } from "bun:sqlite"; import type { Clock } from "./ledger/clock"; import { identitySpendThisMonth, globalSpendThisMonth } from "./policy/budget"; +import { many, one } from "./ledger/db"; export interface IdentityStatus { identityId: string; @@ -26,15 +27,16 @@ export interface RuntimeSnapshot { export function runtimeSnapshot(db: Database, clock: Clock, timezone: string): RuntimeSnapshot { const now = clock(); - const idRows = db.query("SELECT DISTINCT identity_id FROM tasks ORDER BY identity_id").all() as { identity_id: string }[]; + const idRows = many<{ identity_id: string }>(db, "SELECT DISTINCT identity_id FROM tasks ORDER BY identity_id"); const identities: IdentityStatus[] = idRows.map(({ identity_id }) => { - const count = (sql: string, ...params: unknown[]) => (db.query(sql).get(identity_id, ...(params as [])) as { c: number }).c; + const count = (sql: string, ...params: import("bun:sqlite").SQLQueryBindings[]) => + one<{ c: number }>(db, sql, identity_id, ...params)?.c ?? 0; return { identityId: identity_id, open: count("SELECT COUNT(*) as c FROM tasks WHERE identity_id = ? AND status = 'open'"), active: count("SELECT COUNT(*) as c FROM tasks WHERE identity_id = ? AND status = 'active'"), - running: (db.query("SELECT COUNT(*) as c FROM executions e JOIN tasks t ON t.id = e.task_id WHERE e.status = 'running' AND t.identity_id = ?").get(identity_id) as { c: number }).c, + running: one<{ c: number }>(db, "SELECT COUNT(*) as c FROM executions e JOIN tasks t ON t.id = e.task_id WHERE e.status = 'running' AND t.identity_id = ?", identity_id)?.c ?? 0, waitingHuman: count("SELECT COUNT(*) as c FROM tasks WHERE identity_id = ? AND status = 'waiting' AND waiting_on = 'human'"), waitingTimer: count("SELECT COUNT(*) as c FROM tasks WHERE identity_id = ? AND status = 'waiting' AND waiting_on = 'timer'"), parked: count("SELECT COUNT(*) as c FROM tasks WHERE identity_id = ? AND status = 'parked'"), @@ -42,8 +44,8 @@ export function runtimeSnapshot(db: Database, clock: Clock, timezone: string): R }; }); - const timersDue = (db.query("SELECT COUNT(*) as c FROM timers WHERE fired_at IS NULL AND due_at <= ?").get(now) as { c: number }).c; - const timersPending = (db.query("SELECT COUNT(*) as c FROM timers WHERE fired_at IS NULL AND due_at > ?").get(now) as { c: number }).c; + const timersDue = one<{ c: number }>(db, "SELECT COUNT(*) as c FROM timers WHERE fired_at IS NULL AND due_at <= ?", now)?.c ?? 0; + const timersPending = one<{ c: number }>(db, "SELECT COUNT(*) as c FROM timers WHERE fired_at IS NULL AND due_at > ?", now)?.c ?? 0; return { at: now, diff --git a/src/tools/catalog.ts b/src/tools/catalog.ts index 78b42d2..12fa853 100644 --- a/src/tools/catalog.ts +++ b/src/tools/catalog.ts @@ -20,6 +20,7 @@ import { type DynamicTool, } from "@bevyl-ai/agent-tools"; import type { ToolCatalog, ToolSpec } from "../policy/broker"; +import { isRecord } from "../guard"; // A worked call, injected into the turn prompt after the registry's skill. Structured (not // prose baked into the skill) so the renderer can filter to the turn's exposed tools: a @@ -106,7 +107,7 @@ export function topLevelMutationFields(query: string): string[] { } function asRecord(args: unknown): Record { - return args && typeof args === "object" && !Array.isArray(args) ? (args as Record) : {}; + return isRecord(args) ? args : {}; } function linearRegistry(): ToolRegistry { @@ -173,7 +174,7 @@ function linearRegistry(): ToolRegistry { if (!q) return "no mutation document to authorize"; const fields = topLevelMutationFields(q); if (fields.length === 0) return "couldn't identify the mutation being made — write one plain operation per call"; - const allowed = new Set(Array.isArray(scope.mutations) ? (scope.mutations as string[]) : []); + const allowed = new Set(Array.isArray(scope.mutations) ? scope.mutations.filter((x): x is string => typeof x === "string") : []); const outside = fields.filter((f) => !allowed.has(f)); return outside.length ? `this workspace only lets me make these kinds of changes: ${[...allowed].join(", ")} — ${outside.join(", ")} isn't one of them` : null; }, diff --git a/src/tools/slack.ts b/src/tools/slack.ts index e1af831..a58fa19 100644 --- a/src/tools/slack.ts +++ b/src/tools/slack.ts @@ -8,6 +8,26 @@ import { basename, resolve, sep } from "node:path"; import { mkdirSync } from "node:fs"; import type { ToolRegistry } from "./catalog"; +import { isRecord } from "../guard"; + +function fields(args: unknown): Record { + return isRecord(args) ? args : {}; +} + +function optString(v: unknown): string | undefined { + return typeof v === "string" ? v : undefined; +} + +function slackJson(v: unknown): SlackApiResponse { + if (!isRecord(v)) return { ok: false, error: "invalid response" }; + return { ...v, ok: v.ok === true, ...(typeof v.error === "string" ? { error: v.error } : {}) }; +} + +// Narrower than `typeof fetch` so tests can inject a fake without a type assertion. +export type SlackFetch = ( + url: string, + init?: { method?: string; headers?: Record; body?: string | Uint8Array }, +) => Promise<{ ok: boolean; status: number; json(): Promise }>; export interface SlackToolDeps { readHistory(channel: string, limit: number): Promise; @@ -17,9 +37,9 @@ export interface SlackToolDeps { botToken: string; // A user token with admin scope (SLACK_ADMIN_TOKEN) — custom emoji live behind the admin API. // Absent → emoji_set fails friendly, everything else works. - adminToken?: string; + adminToken?: string | undefined; workspace: string; // the codex workspace — downloads land in /files, uploads must come from inside it - fetch?: typeof fetch; // injectable for tests + fetch?: SlackFetch | undefined; // injectable for tests } export const SLACK_TOOL_NAMES = ["read_channel", "read_thread", "download_file", "upload_file", "emoji_set"] as const; @@ -46,7 +66,7 @@ export function slackRegistry(deps: SlackToolDeps): ToolRegistry { headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json; charset=utf-8" }, body: JSON.stringify(body), }); - return (await res.json()) as SlackApiResponse; + return slackJson(await res.json()); }; return { @@ -78,10 +98,11 @@ export function slackRegistry(deps: SlackToolDeps): ToolRegistry { tools: { read_channel: { run: async (args: unknown) => { - const a = (args ?? {}) as { channel?: string; limit?: number }; - if (!a.channel) return { success: false, output: "read_channel needs a { channel } — mention it as #channel so its id resolves" }; + const a = fields(args); + const channel = optString(a.channel); + if (!channel) return { success: false, output: "read_channel needs a { channel } — mention it as #channel so its id resolves" }; try { - const msgs = await deps.readHistory(a.channel, Math.min(a.limit ?? 20, 100)); + const msgs = await deps.readHistory(channel, Math.min(typeof a.limit === "number" ? a.limit : 20, 100)); return { success: true, output: JSON.stringify(msgs) }; } catch (e) { return { success: false, output: e instanceof Error ? e.message : String(e) }; @@ -93,10 +114,12 @@ export function slackRegistry(deps: SlackToolDeps): ToolRegistry { }, read_thread: { run: async (args: unknown) => { - const a = (args ?? {}) as { channel?: string; thread_ts?: string; limit?: number }; - if (!a.channel || !a.thread_ts) return { success: false, output: "read_thread needs { channel, thread_ts } — thread_ts is the root message's ts from read_channel" }; + const a = fields(args); + const channel = optString(a.channel); + const threadTs = optString(a.thread_ts); + if (!channel || !threadTs) return { success: false, output: "read_thread needs { channel, thread_ts } — thread_ts is the root message's ts from read_channel" }; try { - const msgs = await deps.readThread(a.channel, a.thread_ts, Math.min(a.limit ?? 50, 200)); + const msgs = await deps.readThread(channel, threadTs, Math.min(typeof a.limit === "number" ? a.limit : 50, 200)); return { success: true, output: JSON.stringify(msgs) }; } catch (e) { return { success: false, output: e instanceof Error ? e.message : String(e) }; @@ -108,21 +131,22 @@ export function slackRegistry(deps: SlackToolDeps): ToolRegistry { }, download_file: { run: async (args: unknown) => { - const a = (args ?? {}) as { url?: string; name?: string }; - if (!a.url) return { success: false, output: "download_file needs { url } — an attachment's url_private, from the message that carried it" }; + const a = fields(args); + const url = optString(a.url); + if (!url) return { success: false, output: "download_file needs { url } — an attachment's url_private, from the message that carried it" }; // The bot token rides the request as a bearer header — only Slack's file host may see it. let host: string; try { - host = new URL(a.url).host; + host = new URL(url).host; } catch { return { success: false, output: "download_file: that isn't a URL" }; } if (host !== "files.slack.com") return { success: false, output: "download_file only fetches Slack-hosted attachments (files.slack.com url_private links)" }; try { - const bytes = await deps.downloadFile(a.url); + const bytes = await deps.downloadFile(url); const dir = resolve(deps.workspace, "files"); mkdirSync(dir, { recursive: true }); - const name = safeName(a.name ?? new URL(a.url).pathname); + const name = safeName(optString(a.name) ?? new URL(url).pathname); await Bun.write(resolve(dir, name), bytes); return { success: true, output: JSON.stringify({ path: `files/${name}`, bytes: bytes.length }) }; } catch (e) { @@ -135,14 +159,16 @@ export function slackRegistry(deps: SlackToolDeps): ToolRegistry { }, upload_file: { run: async (args: unknown) => { - const a = (args ?? {}) as { path?: string; venueId?: string; threadRootId?: string | null; title?: string }; - if (!a.path || !a.venueId) return { success: false, output: "upload_file needs { path, venueId } — path is workspace-relative; venueId is the conversation's <#…>" }; - if (!insideWorkspace(deps.workspace, a.path)) return { success: false, output: "upload_file only sends files from your own workspace" }; + const a = fields(args); + const path = optString(a.path); + const venueId = optString(a.venueId); + if (!path || !venueId) return { success: false, output: "upload_file needs { path, venueId } — path is workspace-relative; venueId is the conversation's <#…>" }; + if (!insideWorkspace(deps.workspace, path)) return { success: false, output: "upload_file only sends files from your own workspace" }; try { - const file = Bun.file(resolve(deps.workspace, a.path)); - if (!(await file.exists())) return { success: false, output: `no such file in your workspace: ${a.path}` }; + const file = Bun.file(resolve(deps.workspace, path)); + if (!(await file.exists())) return { success: false, output: `no such file in your workspace: ${path}` }; const bytes = await file.bytes(); - const filename = basename(a.path); + const filename = basename(path); // Slack's external upload flow: reserve a URL, POST the bytes, then complete into the // venue. getUploadURLExternal is form-only — a JSON body earns invalid_arguments. const ticketRes = await doFetch("https://slack.com/api/files.getUploadURLExternal", { @@ -150,19 +176,19 @@ export function slackRegistry(deps: SlackToolDeps): ToolRegistry { headers: { Authorization: `Bearer ${deps.botToken}`, "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ filename, length: String(bytes.length) }).toString(), }); - const ticket = (await ticketRes.json()) as SlackApiResponse; + const ticket = slackJson(await ticketRes.json()); if (!ticket.ok || typeof ticket.upload_url !== "string" || typeof ticket.file_id !== "string") { return { success: false, output: `upload failed: ${ticket.error ?? "no upload url"}${ticket.error === "missing_scope" ? " — the Slack app needs the files:write scope" : ""}` }; } const put = await doFetch(ticket.upload_url, { method: "POST", body: bytes }); if (!put.ok) return { success: false, output: `upload failed: HTTP ${put.status} sending the file bytes` }; const done = await api("files.completeUploadExternal", deps.botToken, { - files: [{ id: ticket.file_id, title: a.title ?? filename }], - channel_id: a.venueId, - ...(a.threadRootId ? { thread_ts: a.threadRootId } : {}), + files: [{ id: ticket.file_id, title: optString(a.title) ?? filename }], + channel_id: venueId, + ...(typeof a.threadRootId === "string" ? { thread_ts: a.threadRootId } : {}), }); if (!done.ok) return { success: false, output: `upload failed: ${done.error}` }; - return { success: true, output: `sent ${filename} into <#${a.venueId}>${a.threadRootId ? ` thread=${a.threadRootId}` : ""}` }; + return { success: true, output: `sent ${filename} into <#${venueId}>${typeof a.threadRootId === "string" ? ` thread=${a.threadRootId}` : ""}` }; } catch (e) { return { success: false, output: e instanceof Error ? e.message : String(e) }; } @@ -179,17 +205,18 @@ export function slackRegistry(deps: SlackToolDeps): ToolRegistry { emoji_set: { actionClasses: () => ["outward"], run: async (args: unknown) => { - const a = (args ?? {}) as { name?: string; url?: string }; - const name = a.name?.replace(/:/g, "").trim().toLowerCase(); - if (!name || !a.url) return { success: false, output: "emoji_set needs { name, url } — the emoji's name (no colons) and a URL of its image" }; + const a = fields(args); + const name = optString(a.name)?.replace(/:/g, "").trim().toLowerCase(); + const emojiUrl = optString(a.url); + if (!name || !emojiUrl) return { success: false, output: "emoji_set needs { name, url } — the emoji's name (no colons) and a URL of its image" }; if (!deps.adminToken) return { success: false, output: "custom emoji aren't wired up here yet — an admin credential is missing; a workspace admin can add it by hand meanwhile" }; try { - let result = await api("admin.emoji.add", deps.adminToken, { name, url: a.url }); + let result = await api("admin.emoji.add", deps.adminToken, { name, url: emojiUrl }); if (!result.ok && (result.error === "emoji_already_exists" || result.error === "error_name_taken")) { // "update" = replace: remove the old image, then add the new one under the same name. const removed = await api("admin.emoji.remove", deps.adminToken, { name }); if (!removed.ok) return { success: false, output: `emoji_set: :${name}: exists and couldn't be replaced (${removed.error})` }; - result = await api("admin.emoji.add", deps.adminToken, { name, url: a.url }); + result = await api("admin.emoji.add", deps.adminToken, { name, url: emojiUrl }); } if (!result.ok) return { success: false, output: `emoji_set failed: ${result.error}` }; return { success: true, output: `:${name}: is live` }; diff --git a/src/turn-runner/execution-loop.ts b/src/turn-runner/execution-loop.ts index 3c498a8..0012fbf 100644 --- a/src/turn-runner/execution-loop.ts +++ b/src/turn-runner/execution-loop.ts @@ -15,6 +15,7 @@ import type { AgentRuntimeSession, DynamicTool } from "./types"; import type { ToolCatalog } from "../policy/broker"; import type { IdentityConfig } from "../policy/schema"; import type { Anchor } from "../ledger/tasks"; +import { asString } from "../guard"; export interface ExecutionLoopParams { db: Database; @@ -33,9 +34,9 @@ export interface ExecutionLoopParams { maxConsecutiveInterruptions: number; stallTimeoutMs: number; postMessage: (anchor: Anchor, text: string) => Promise<{ messageId: string }>; - permalink?: (venueId: string, messageId: string) => string | undefined; // receipts for search hits - updateMessage?: (venueId: string, messageId: string, text: string) => Promise; // for the live checklist - renderChecklist?: (items: { text: string; done: boolean }[]) => Promise; // native task cards on the execution's stream + permalink?: ((venueId: string, messageId: string) => string | undefined) | undefined; // receipts for search hits + updateMessage?: ((venueId: string, messageId: string, text: string) => Promise) | undefined; // for the live checklist + renderChecklist?: ((items: { text: string; done: boolean }[]) => Promise) | undefined; // native task cards on the execution's stream // Receives the execution's built toolset so turn 1 can open with the toolbox digest (SPEC §11). buildPrompt: (turnNumber: number, guidance: string[], tools: DynamicTool[]) => string; newTurnId: () => string; @@ -127,7 +128,7 @@ export async function runExecution(params: ExecutionLoopParams): Promise s.kind === "guidance").map((s) => String((s.payload as { text?: string }).text ?? "")); + const guidance = queued.filter((s) => s.kind === "guidance").map((s) => asString(s.payload.text)); const prompt = params.buildPrompt(turnNum, guidance, toolset); turnsRun++; diff --git a/src/turn-runner/toolset.ts b/src/turn-runner/toolset.ts index aa63b79..0e4c25f 100644 --- a/src/turn-runner/toolset.ts +++ b/src/turn-runner/toolset.ts @@ -15,7 +15,6 @@ import { ledgerView, nextTaskId, type Anchor, - type SteeringKind, } from "../ledger/tasks"; import { writeMemory, retractMemory, queryMemory, setMemoryTier, type MemoryTier } from "../ledger/memory"; import { closeAttentionItemsForThread } from "../ledger/attention"; @@ -26,6 +25,48 @@ import { decide, exposableForKind, actionRefFor, canonicalJson, type ToolCatalog import type { ToolRegistry } from "../tools/catalog"; import type { IdentityConfig } from "../policy/schema"; import type { DynamicTool } from "./types"; +import { asString, isRecord } from "../guard"; +import { one } from "../ledger/db"; + +function fields(args: unknown): Record { + return isRecord(args) ? args : {}; +} +function optString(v: unknown): string | undefined { + return typeof v === "string" ? v : undefined; +} +function optTaskTier(v: unknown): "low" | "medium" | "high" | undefined { + return v === "low" || v === "medium" || v === "high" ? v : undefined; +} +function optMemoryTier(v: unknown): MemoryTier | undefined { + return v === "core" || v === "recent" || v === "archive" ? v : undefined; +} +function optAuditKind(v: unknown): AuditKind | undefined { + switch (v) { + case "event_received": + case "turn_started": + case "turn_ended": + case "task_created": + case "task_transitioned": + case "tool_invoked": + case "confirmation_requested": + case "confirmation_resolved": + case "ambient_posted": + case "budget_denied": + case "memory_written": + case "memory_retracted": + case "memory_tier_changed": + return v; + default: + return undefined; + } +} +function checklistItems(v: unknown): { text: string; done: boolean }[] { + if (!Array.isArray(v)) return []; + return v.map((item) => { + const r = isRecord(item) ? item : {}; + return { text: asString(r.text), done: r.done === true }; + }); +} // A tool as its factory builds it: spec + raw implementation, NOT yet callable. buildToolset is // the only site that turns a factory into a DynamicTool, by wrapping impl in the broker gate — @@ -51,10 +92,10 @@ export interface ToolsetContext { // (execution_step), or null (ambient is venue-scoped not anchor-scoped; distillation posts // nowhere). anchor: Anchor | null; - principal?: Principal; - originEventId?: string; - taskId?: string; // the task this execution_step turn belongs to - outwardScopeId?: string; // outward-call dedupe scope for taskless turns (the wake id) + principal?: Principal | undefined; + originEventId?: string | undefined; + taskId?: string | undefined; // the task this execution_step turn belongs to + outwardScopeId?: string | undefined; // outward-call dedupe scope for taskless turns (the wake id) nudgeAfterMs: number; postMessage: (anchor: Anchor, text: string) => Promise<{ messageId: string }>; // SPEC §5.5 stale-reply withholding: set only when the turn's batch had no direct address. @@ -63,33 +104,33 @@ export interface ToolsetContext { // caller owns the posted/withheld effect records; replyTool records nothing for a buffered call. // Returns true when the reply buffered for §5.5's turn-end flush; false means the target // conversation was directly addressed this wake and the reply should post immediately. - bufferReply?: (anchor: Anchor, text: string) => boolean; + bufferReply?: ((anchor: Anchor, text: string) => boolean) | undefined; // Addressing as capability (ladder R4): the turn's ref table is the ONLY source of speakable // targets — reply/react/step_back accept refs, never coordinates. via='search' refs (drafts, // owed items, search hits) bounce once with the conversation's card before a send passes. - refs?: RefTable; - renderConversationCard?: (target: { venueId: string; threadRootId: string | null }) => string; + refs?: RefTable | undefined; + renderConversationCard?: ((target: { venueId: string; threadRootId: string | null }) => string) | undefined; // Edit an already-posted message (Slack chat.update). Enables the live checklist. Optional — a // surface without it just re-posts instead of editing in place. - updateMessage?: (venueId: string, messageId: string, text: string) => Promise; + updateMessage?: ((venueId: string, messageId: string, text: string) => Promise) | undefined; // Shared holder for live checklist message ids, keyed by convoKey — persists across a turn's // attempts (and an execution's turns) so the `checklist` tool edits ONE message in place per // conversation (Claude Tag's signature UX). - checklist?: Map; + checklist?: Map | undefined; // React to a message by venue + surface ts (Slack reactions.add) — sometimes an emoji IS the // right reply ("if u see this please emoji it"). threadRootId is the ref target's own thread // (null for a top-level message): the react's ledger residence comes from the line the model // was shown, never re-derived from the batch. Venue-scoped like any post. - reactTo?: (venueId: string, messageId: string, emoji: string, threadRootId: string | null) => Promise; + reactTo?: ((venueId: string, messageId: string, emoji: string, threadRootId: string | null) => Promise) | undefined; // Render a checklist as NATIVE task cards on the stream seated at `seat`. Returns false when // the surface has no native cards (caller falls back to the emoji-text message). - renderChecklist?: (items: { text: string; done: boolean }[], seat: Anchor) => Promise; + renderChecklist?: ((items: { text: string; done: boolean }[], seat: Anchor) => Promise) | undefined; // Resolve a principal id to its standing (operator/guest) — for durable writes whose person // comes from a ref's provenance rather than the wake-level principal. - resolvePrincipal?: (principalId: string) => Principal; + resolvePrincipal?: ((principalId: string) => Principal) | undefined; // Build a surface permalink for a message (SPEC §8.7: search hits carry receipts). Absent when // the surface can't construct one; hits then cite venue + timestamp only. - permalink?: (venueId: string, messageId: string) => string | undefined; + permalink?: ((venueId: string, messageId: string) => string | undefined) | undefined; effects: unknown[]; // mutated in place — collected for turns.ts's recordTurn } @@ -202,13 +243,17 @@ function taskCreateTool(ctx: ToolsetContext): ToolFactory { }, }, impl: async (args) => { - const a = args as { title: string; spec: string; ref?: string; tier?: "low" | "medium" | "high" }; + const a = fields(args); + const title = asString(a.title); + const spec = asString(a.spec); + const ref = optString(a.ref); + const tier = optTaskTier(a.tier); // The task's home is HER call, bound to a rendered conversation — never a batch-level // guess (live 2026-08-13: a task about an alert burst homed to the last thread that // happened to address her, and its report answered an adjacent incident). - const target = a.ref ? ctx.refs?.get(a.ref) : undefined; + const target = ref ? ctx.refs?.get(ref) : undefined; if (!target) { - return { success: false, output: `"${a.ref ?? ""}" is not a ref — home the task with the [rN] tag of the conversation its report belongs in` }; + return { success: false, output: `"${ref ?? ""}" is not a ref — home the task with the [rN] tag of the conversation its report belongs in` }; } const home = conversationOf(target); // Sponsor and origin bind to the ref's own provenance too: the same audit found the T-354 @@ -225,12 +270,12 @@ function taskCreateTool(ctx: ToolsetContext): ToolFactory { const task = createTask(ctx.db, ctx.clock, { id: nextTaskId(ctx.db), identityId: ctx.identity.id, - title: a.title, - spec: a.spec, + title, + spec, sponsorId, homeAnchor: { venueId: home.venueId, threadRootId: home.threadRootId }, originEventId: prov.eventId, - tier: a.tier, + tier, sponsorIsOperator: sponsor?.isOperator ?? false, }); pushEffect(ctx, { kind: "task_created", taskId: task.id }); @@ -273,17 +318,21 @@ function taskSteerTool(ctx: ToolsetContext): ToolFactory { }, }, impl: async (args) => { - const a = args as { taskId: string; kind: SteeringKind; text?: string; ref?: string }; - const source = steerSourceEvent(ctx, a.ref, "asking for this steer"); + const a = fields(args); + const taskId = asString(a.taskId); + const kind = a.kind; + const text = optString(a.text); + const ref = optString(a.ref); + const source = steerSourceEvent(ctx, ref, "asking for this steer"); if (typeof source !== "string") return { success: false, output: source.bounce }; // "cancel"/"confirm" have their own dedicated tools (task_cancel/task_confirm) with their // own eligibility rules — task_steer's declared schema excludes them, and the JS-level call // must enforce that too, not just trust codex to validate against inputSchema. - if (a.kind !== "guidance" && a.kind !== "pause" && a.kind !== "resume") { - return { success: false, output: `invalid_kind: task_steer only accepts guidance/pause/resume; use task_cancel or task_confirm for ${a.kind}` }; + if (kind !== "guidance" && kind !== "pause" && kind !== "resume") { + return { success: false, output: `invalid_kind: task_steer only accepts guidance/pause/resume; use task_cancel or task_confirm for ${asString(kind)}` }; } - const result = steerTask(ctx.db, ctx.clock, { identityId: ctx.identity.id, taskId: a.taskId, kind: a.kind, payload: { text: a.text }, sourceEventId: source }); - pushEffect(ctx, { kind: "task_steered", taskId: a.taskId, steerKind: a.kind, applied: result.applied }); + const result = steerTask(ctx.db, ctx.clock, { identityId: ctx.identity.id, taskId, kind, payload: { text }, sourceEventId: source }); + pushEffect(ctx, { kind: "task_steered", taskId, steerKind: kind, applied: result.applied }); return { success: result.applied, output: result.reply ?? JSON.stringify({ status: result.task.status }) }; }, }; @@ -307,11 +356,14 @@ function taskCancelTool(ctx: ToolsetContext): ToolFactory { }, }, impl: async (args) => { - const a = args as { taskId: string; report?: string; ref?: string }; - const source = steerSourceEvent(ctx, a.ref, "asking for the cancel"); + const a = fields(args); + const taskId = asString(a.taskId); + const report = optString(a.report); + const ref = optString(a.ref); + const source = steerSourceEvent(ctx, ref, "asking for the cancel"); if (typeof source !== "string") return { success: false, output: source.bounce }; - const result = steerTask(ctx.db, ctx.clock, { identityId: ctx.identity.id, taskId: a.taskId, kind: "cancel", payload: { report: a.report }, sourceEventId: source }); - pushEffect(ctx, { kind: "task_cancelled", taskId: a.taskId, applied: result.applied }); + const result = steerTask(ctx.db, ctx.clock, { identityId: ctx.identity.id, taskId, kind: "cancel", payload: { report }, sourceEventId: source }); + pushEffect(ctx, { kind: "task_cancelled", taskId, applied: result.applied }); return { success: result.applied, output: result.reply ?? JSON.stringify({ status: result.task.status }) }; }, }; @@ -340,15 +392,18 @@ function taskConfirmTool(ctx: ToolsetContext): ToolFactory { }, }, impl: async (args) => { - const a = args as { taskId: string; approve: boolean; ref?: string }; + const a = fields(args); + const taskId = asString(a.taskId); + const approve = a.approve === true; + const ref = optString(a.ref); let approverId: string; if (withRef) { - const target = a.ref ? ctx.refs?.get(a.ref) : undefined; + const target = ref ? ctx.refs?.get(ref) : undefined; // A go-ahead belongs to the person who SAID it: only a message ref names a speaker. A // conversation ref would resolve to whoever spoke last in the room — the exact // batch-tail guess this tool exists to prevent (audit 2026-08-13, verified live-shape). if (!target?.ts) { - return { success: false, output: `"${a.ref ?? ""}" is not a message ref — pass the [rN] tag of the member's own approve/deny line, not the conversation's` }; + return { success: false, output: `"${ref ?? ""}" is not a message ref — pass the [rN] tag of the member's own approve/deny line, not the conversation's` }; } // Unread targets are rejected outright (no one-shot bounce like reply's): recording who // authorized a consequential action from a line this turn never read is never right. @@ -364,8 +419,8 @@ function taskConfirmTool(ctx: ToolsetContext): ToolFactory { if (!ctx.principal) return { success: false, output: "missing principal for task_confirm" }; approverId = ctx.principal.id; } - const result = resolveConfirmation(ctx.db, ctx.clock, { identityId: ctx.identity.id, taskId: a.taskId, principalId: approverId, approve: a.approve }); - pushEffect(ctx, { kind: "confirmation_resolved", taskId: a.taskId, approve: a.approve, applied: result.applied }); + const result = resolveConfirmation(ctx.db, ctx.clock, { identityId: ctx.identity.id, taskId, principalId: approverId, approve }); + pushEffect(ctx, { kind: "confirmation_resolved", taskId, approve, applied: result.applied }); return { success: result.applied, output: result.reply ?? JSON.stringify({ status: result.task.status }) }; }, }; @@ -402,10 +457,12 @@ function replyTool(ctx: ToolsetContext): ToolFactory { }, }, impl: async (args) => { - const a = args as { text: string; ref?: string }; - const target = a.ref ? ctx.refs?.get(a.ref) : undefined; + const a = fields(args); + const text = asString(a.text); + const ref = optString(a.ref); + const target = ref ? ctx.refs?.get(ref) : undefined; if (!target) { - return { success: false, output: `"${a.ref ?? ""}" is not a ref — copy the [rN] tag (like r3) from the start of a line you were shown; timestamps and channel ids are labels, not addresses` }; + return { success: false, output: `"${ref ?? ""}" is not a ref — copy the [rN] tag (like r3) from the start of a line you were shown; timestamps and channel ids are labels, not addresses` }; } const key = conversationOf(target); const anchor: Anchor = { venueId: key.venueId, threadRootId: key.threadRootId }; @@ -416,8 +473,8 @@ function replyTool(ctx: ToolsetContext): ToolFactory { // conversation as it now stands instead of posting (live 2026-08-10: a fresh session // posted a confident correction into a settled thread it had never read). The re-send is // her informed call, and posting re-engages the conversation as any post does. - if (target.via === "search" && ctx.renderConversationCard && !bounced.has(a.ref!)) { - bounced.add(a.ref!); + if (target.via === "search" && ctx.renderConversationCard && ref && !bounced.has(ref)) { + bounced.add(ref); const card = ctx.renderConversationCard(key); return { success: false, @@ -430,7 +487,7 @@ function replyTool(ctx: ToolsetContext): ToolFactory { // instructions parroted into Slack). Screened at the single door every outward word // passes through. const HARNESS_TOKENS = ["requires_confirmation:", "posting_scope_violation", "not_available_for_turn_kind", "interactive_consequential_denied", "Requesting confirmation to call", "queued — it posts when your turn ends"]; - const leaked = HARNESS_TOKENS.find((tok) => a.text.includes(tok)); + const leaked = HARNESS_TOKENS.find((tok) => text.includes(tok)); if (leaked) { return { success: false, output: `that reads like my own internal scaffolding ("${leaked}") — say it in your words instead` }; } @@ -438,11 +495,11 @@ function replyTool(ctx: ToolsetContext): ToolFactory { // §5.5: this conversation didn't address her directly, so the reply waits for turn end — // the room may still be talking while the model composes, and an answer to a moved-on // conversation is the harness's to hold back, not the model's to re-litigate mid-turn. - if (ctx.bufferReply?.(anchor, a.text)) { + if (ctx.bufferReply?.(anchor, text)) { return { success: true, output: "queued — it posts when your turn ends, unless the conversation has moved by then (it would come back to you next time instead)" }; } - const result = await ctx.postMessage(anchor, a.text); + const result = await ctx.postMessage(anchor, text); // Delivery sentinels are not message ids: a post that never landed must not report // "posted", must not engage a conversation rooted on the sentinel string, and must not // arm the effects guard against the retry that could still say it. @@ -453,7 +510,7 @@ function replyTool(ctx: ToolsetContext): ToolFactory { return { success: true, output: "posted" }; // an earlier attempt of this wake already sent it } recordPostedThread(ctx, anchor, result.messageId); - pushEffect(ctx, { kind: "posted", anchor, text: a.text }); + pushEffect(ctx, { kind: "posted", anchor, text }); return { success: true, output: "posted" }; }, }; @@ -473,10 +530,11 @@ function reactTool(ctx: ToolsetContext): ToolFactory { }, }, impl: async (args) => { - const a = args as { emoji: string; ref?: string }; - const emoji = a.emoji.replace(/:/g, "").trim(); + const a = fields(args); + const emoji = asString(a.emoji).replace(/:/g, "").trim(); if (!emoji) return { success: false, output: "empty emoji name" }; - const target = a.ref ? ctx.refs?.get(a.ref) : undefined; + const ref = optString(a.ref); + const target = ref ? ctx.refs?.get(ref) : undefined; if (!target?.ts) return { success: false, output: "no such message ref — reactions land on a MESSAGE's [rN] tag, not a conversation's" }; if (!ctx.reactTo) return { success: false, output: "this turn cannot react" }; const violation = checkPostingScope(ctx, { venueId: target.venueId, threadRootId: null }); @@ -506,7 +564,8 @@ function setWakeTool(ctx: ToolsetContext): ToolFactory { inputSchema: { type: "object", additionalProperties: false, required: ["wakeAt"], properties: { wakeAt: { type: "string" } } }, }, impl: async (args) => { - const a = args as { wakeAt: string }; + const a = fields(args); + const wakeAtRaw = asString(a.wakeAt); if (!ctx.taskId) return { success: false, output: "set_wake is only available to an execution's own turns" }; const live = getTask(ctx.db, ctx.taskId); if (live && live.status !== "active") { @@ -515,7 +574,7 @@ function setWakeTool(ctx: ToolsetContext): ToolFactory { // The ledger stores only harness-normalized timestamps: parse, require a real future // instant, clamp to a sane horizon, re-serialize canonical ISO. A malformed or past // wake time is rejected here, never persisted for the scheduler to trip on. - const parsed = Date.parse(a.wakeAt); + const parsed = Date.parse(wakeAtRaw); if (Number.isNaN(parsed)) return { success: false, output: "wakeAt must be an ISO-8601 timestamp" }; const now = Date.parse(ctx.clock()); if (parsed <= now) return { success: false, output: "wakeAt is in the past — pick a future time" }; @@ -541,14 +600,15 @@ function taskCompleteTool(ctx: ToolsetContext): ToolFactory { inputSchema: { type: "object", additionalProperties: false, required: ["report"], properties: { report: { type: "string" } } }, }, impl: async (args) => { - const a = args as { report: string }; + const a = fields(args); + const report = asString(a.report); if (!ctx.taskId) return { success: false, output: "task_complete is only available to an execution's own turns" }; const live = getTask(ctx.db, ctx.taskId); if (live && live.status !== "active") { return { success: false, output: "this task is paused waiting on a human go-ahead — stop here and end the turn" }; } - if (!a.report?.trim()) return { success: false, output: "the report is the handoff — say what happened before completing" }; - transition(ctx.db, ctx.clock, ctx.taskId, "done", { type: "completed", report: a.report }); + if (!report.trim()) return { success: false, output: "the report is the handoff — say what happened before completing" }; + transition(ctx.db, ctx.clock, ctx.taskId, "done", { type: "completed", report }); pushEffect(ctx, { kind: "task_completed", taskId: ctx.taskId }); return { success: true, output: `task ${ctx.taskId} completed` }; }, @@ -564,14 +624,15 @@ function taskFailTool(ctx: ToolsetContext): ToolFactory { inputSchema: { type: "object", additionalProperties: false, required: ["report"], properties: { report: { type: "string" } } }, }, impl: async (args) => { - const a = args as { report: string }; + const a = fields(args); + const report = asString(a.report); if (!ctx.taskId) return { success: false, output: "task_fail is only available to an execution's own turns" }; const live = getTask(ctx.db, ctx.taskId); if (live && live.status !== "active") { return { success: false, output: "this task is paused waiting on a human go-ahead — stop here and end the turn" }; } - if (!a.report?.trim()) return { success: false, output: "the report is the handoff — say what happened before failing" }; - transition(ctx.db, ctx.clock, ctx.taskId, "failed", { type: "failed", report: a.report }); + if (!report.trim()) return { success: false, output: "the report is the handoff — say what happened before failing" }; + transition(ctx.db, ctx.clock, ctx.taskId, "failed", { type: "failed", report }); pushEffect(ctx, { kind: "task_failed", taskId: ctx.taskId }); return { success: true, output: `task ${ctx.taskId} failed` }; }, @@ -587,7 +648,8 @@ function taskAskTool(ctx: ToolsetContext): ToolFactory { inputSchema: { type: "object", additionalProperties: false, required: ["question"], properties: { question: { type: "string" } } }, }, impl: async (args) => { - const a = args as { question: string }; + const a = fields(args); + const question = asString(a.question); if (!ctx.taskId) return { success: false, output: "task_ask is only available to an execution's own turns" }; const live = getTask(ctx.db, ctx.taskId); if (live && live.status !== "active") { @@ -595,7 +657,7 @@ function taskAskTool(ctx: ToolsetContext): ToolFactory { } const nudgeDeadline = new Date(new Date(ctx.clock()).getTime() + ctx.nudgeAfterMs).toISOString(); transition(ctx.db, ctx.clock, ctx.taskId, "waiting", { type: "yield_human", nudgeDeadline }); - pushEffect(ctx, { kind: "task_asked", taskId: ctx.taskId, question: a.question }); + pushEffect(ctx, { kind: "task_asked", taskId: ctx.taskId, question }); return { success: true, output: `task ${ctx.taskId} waiting on a human` }; }, }; @@ -632,12 +694,14 @@ function checklistTool(ctx: ToolsetContext): ToolFactory { }, }, impl: async (args) => { - const a = args as { items: { text: string; done: boolean }[]; ref?: string }; + const a = fields(args); + const items = checklistItems(a.items); + const ref = optString(a.ref); let seat: Anchor; if (withRef) { - const target = a.ref ? ctx.refs?.get(a.ref) : undefined; + const target = ref ? ctx.refs?.get(ref) : undefined; if (!target) { - return { success: false, output: `"${a.ref ?? ""}" is not a ref — seat the checklist with the [rN] tag of the conversation its work is for` }; + return { success: false, output: `"${ref ?? ""}" is not a ref — seat the checklist with the [rN] tag of the conversation its work is for` }; } const key = conversationOf(target); seat = { venueId: key.venueId, threadRootId: key.threadRootId }; @@ -651,9 +715,9 @@ function checklistTool(ctx: ToolsetContext): ToolFactory { if (!holder) return { success: false, output: "checklist is not available in this turn" }; // Preferred rendering: native task cards on the seat conversation's streamed message. // Falls back to one edited-in-place emoji message only when the surface has no cards. - const native = ctx.renderChecklist ? await ctx.renderChecklist(a.items, seat) : false; + const native = ctx.renderChecklist ? await ctx.renderChecklist(items, seat) : false; if (!native) { - const text = renderChecklist(a.items); + const text = renderChecklist(items); const seatKey = convoKey(seat.venueId, seat.threadRootId); const existing = holder.get(seatKey); if (existing && ctx.updateMessage) { @@ -668,8 +732,8 @@ function checklistTool(ctx: ToolsetContext): ToolFactory { holder.set(seatKey, result.messageId); } } - pushEffect(ctx, { kind: "checklist", items: a.items.length, done: a.items.filter((i) => i.done).length }); - return { success: true, output: `checklist: ${a.items.filter((i) => i.done).length}/${a.items.length} done` }; + pushEffect(ctx, { kind: "checklist", items: items.length, done: items.filter((i) => i.done).length }); + return { success: true, output: `checklist: ${items.filter((i) => i.done).length}/${items.length} done` }; }, }; } @@ -693,11 +757,13 @@ function memoryWriteTool(ctx: ToolsetContext): ToolFactory { }, }, impl: async (args) => { - const a = args as { content: string; provenance?: unknown[]; tier?: MemoryTier }; + const a = fields(args); + const content = asString(a.content); + const provenance = Array.isArray(a.provenance) ? a.provenance : undefined; // SPEC §8.6: an explicit write defaults to core; she can save something merely noticed // at reduced standing by passing tier 'recent' herself. - const tier = a.tier ?? "core"; - const item = writeMemory(ctx.db, ctx.clock, { id: crypto.randomUUID(), identityId: ctx.identity.id, content: a.content, provenance: a.provenance, tier }); + const tier = optMemoryTier(a.tier) ?? "core"; + const item = writeMemory(ctx.db, ctx.clock, { id: crypto.randomUUID(), identityId: ctx.identity.id, content, provenance, tier }); pushEffect(ctx, { kind: "memory_written", memoryId: item.id }); return { success: true, output: JSON.stringify({ memoryId: item.id }) }; }, @@ -712,12 +778,14 @@ function memoryRetractTool(ctx: ToolsetContext): ToolFactory { inputSchema: { type: "object", additionalProperties: false, required: ["id"], properties: { id: { type: "string" }, supersededBy: { type: "string" } } }, }, impl: async (args) => { - const a = args as { id: string; supersededBy?: string }; - const existing = queryMemory(ctx.db, ctx.identity.id, { includeRetracted: true }).find((m) => m.id === a.id); - if (!existing) return { success: false, output: `not_found: no memory item ${a.id} for this identity` }; - retractMemory(ctx.db, ctx.clock, { id: a.id, supersededBy: a.supersededBy }); - pushEffect(ctx, { kind: "memory_retracted", memoryId: a.id }); - return { success: true, output: `retracted ${a.id}` }; + const a = fields(args); + const id = asString(a.id); + const supersededBy = optString(a.supersededBy); + const existing = queryMemory(ctx.db, ctx.identity.id, { includeRetracted: true }).find((m) => m.id === id); + if (!existing) return { success: false, output: `not_found: no memory item ${id} for this identity` }; + retractMemory(ctx.db, ctx.clock, { id, supersededBy }); + pushEffect(ctx, { kind: "memory_retracted", memoryId: id }); + return { success: true, output: `retracted ${id}` }; }, }; } @@ -746,8 +814,15 @@ function searchTool(ctx: ToolsetContext): ToolFactory { }, }, impl: async (args) => { - const a = args as { query: string; venueId?: string; principalId?: string; after?: string; before?: string; limit?: number }; - const hits = searchArchive(ctx.db, ctx.identity.id, a).map((h) => ({ + const a = fields(args); + const hits = searchArchive(ctx.db, ctx.identity.id, { + query: asString(a.query), + venueId: optString(a.venueId), + principalId: optString(a.principalId), + after: optString(a.after), + before: optString(a.before), + limit: typeof a.limit === "number" ? a.limit : undefined, + }).map((h) => ({ kind: h.kind, text: h.text.slice(0, 700), at: h.at, @@ -777,12 +852,14 @@ function memoryTierTool(ctx: ToolsetContext): ToolFactory { inputSchema: { type: "object", additionalProperties: false, required: ["id", "tier"], properties: { id: { type: "string" }, tier: { type: "string", enum: ["core", "recent", "archive"] } } }, }, impl: async (args) => { - const a = args as { id: string; tier: MemoryTier }; - const existing = queryMemory(ctx.db, ctx.identity.id, { includeRetracted: true }).find((m) => m.id === a.id); - if (!existing) return { success: false, output: `not_found: no memory item ${a.id} for this identity` }; - const item = setMemoryTier(ctx.db, ctx.clock, a.id, a.tier); - pushEffect(ctx, { kind: "memory_tiered", memoryId: a.id, tier: item.tier }); - return { success: true, output: `${a.id} → ${item.tier}` }; + const a = fields(args); + const id = asString(a.id); + const tier = optMemoryTier(a.tier) ?? "core"; + const existing = queryMemory(ctx.db, ctx.identity.id, { includeRetracted: true }).find((m) => m.id === id); + if (!existing) return { success: false, output: `not_found: no memory item ${id} for this identity` }; + const item = setMemoryTier(ctx.db, ctx.clock, id, tier); + pushEffect(ctx, { kind: "memory_tiered", memoryId: id, tier: item.tier }); + return { success: true, output: `${id} → ${item.tier}` }; }, }; } @@ -850,9 +927,14 @@ function externalTools(ctx: ToolsetContext): ToolFactory[] { // refused; a standing task legitimately repeating tomorrow's identical write passes // (review finding: task-lifetime scope permanently refused legitimate repeats). const cutoff = new Date(Date.parse(ctx.clock()) - 24 * 60 * 60 * 1000).toISOString(); - const prior = ctx.db - .query("SELECT confirmed FROM outward_calls WHERE scope_id = ? AND tool = ? AND args_hash = ? AND at > ?") - .get(outwardScope, grant.tool, argsHash, cutoff) as { confirmed: number } | null; + const prior = one<{ confirmed: number }>( + ctx.db, + "SELECT confirmed FROM outward_calls WHERE scope_id = ? AND tool = ? AND args_hash = ? AND at > ?", + outwardScope, + grant.tool, + argsHash, + cutoff, + ); if (prior?.confirmed) { return { success: false, output: "already done: this exact call already ran for this piece of work and completed. If you meant a different change, change the arguments." }; } @@ -905,8 +987,13 @@ function auditQueryTool(ctx: ToolsetContext): ToolFactory | null { }, }, impl: async (args) => { - const a = args as { sinceIso?: string; untilIso?: string; kind?: AuditKind; taskId?: string }; - const records = queryAudit(ctx.db, ctx.identity.id, a); + const a = fields(args); + const records = queryAudit(ctx.db, ctx.identity.id, { + sinceIso: optString(a.sinceIso), + untilIso: optString(a.untilIso), + kind: optAuditKind(a.kind), + taskId: optString(a.taskId), + }); return { success: true, output: JSON.stringify(records) }; }, }; @@ -928,15 +1015,17 @@ function stepBackTool(ctx: ToolsetContext): ToolFactory { }, }, impl: async (args) => { - const a = args as { why: string; ref?: string }; - const target = a.ref ? ctx.refs?.get(a.ref) : undefined; + const a = fields(args); + const why = asString(a.why); + const ref = optString(a.ref); + const target = ref ? ctx.refs?.get(ref) : undefined; if (!target) return { success: false, output: "no such ref — step back using an [rN] tag from the conversation you're leaving" }; const key = conversationOf(target); - stepBack(ctx.db, ctx.clock, ctx.identity.id, key.venueId, key.threadRootId, a.why); + stepBack(ctx.db, ctx.clock, ctx.identity.id, key.venueId, key.threadRootId, why); // Leaving a conversation settles what she owed in it: a debt she judged not hers must not // ride every future wake (the ear reopens it if it truly was hers — SPEC §11). closeAttentionItemsForThread(ctx.db, ctx.clock, ctx.identity.id, key.venueId, key.threadRootId, "stepped back"); - pushEffect(ctx, { kind: "stepped_back", venueId: key.venueId, threadRootId: key.threadRootId, why: a.why }); + pushEffect(ctx, { kind: "stepped_back", venueId: key.venueId, threadRootId: key.threadRootId, why }); return { success: true, output: "stepped back — a mention brings you back in" }; }, }; diff --git a/test/audit-query.test.ts b/test/audit-query.test.ts index e8096a3..9994611 100644 --- a/test/audit-query.test.ts +++ b/test/audit-query.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from "bun:test"; import { openLedger } from "../src/ledger/db"; import { writeAudit, queryAudit } from "../src/ledger/audit"; +import { isRecord } from "../src/guard"; + +function payloadTaskId(payload: unknown): unknown { + return isRecord(payload) ? payload.taskId : undefined; +} function freshDb() { return openLedger(":memory:"); @@ -23,7 +28,7 @@ describe("queryAudit (SPEC §15: 'what did you do this week / what did you spend writeAudit(db, "2026-07-15T00:00:00Z", "eng", "task_created", { taskId: "T-2" }); const results = queryAudit(db, "eng", { sinceIso: "2026-07-01T00:00:00Z" }); - expect(results.map((r) => (r.payload as any).taskId)).toEqual(["T-2"]); + expect(results.map((r) => payloadTaskId(r.payload))).toEqual(["T-2"]); }); test("filters by kind", () => { @@ -51,6 +56,6 @@ describe("queryAudit (SPEC §15: 'what did you do this week / what did you spend writeAudit(db, "2026-07-01T00:00:00Z", "eng", "task_created", { taskId: "T-1" }); const results = queryAudit(db, "eng"); - expect(results.map((r) => (r.payload as any).taskId)).toEqual(["T-1", "T-2"]); + expect(results.map((r) => payloadTaskId(r.payload))).toEqual(["T-1", "T-2"]); }); }); diff --git a/test/broker.test.ts b/test/broker.test.ts index 52335ec..3f1e138 100644 --- a/test/broker.test.ts +++ b/test/broker.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { openLedger } from "../src/ledger/db"; +import { isRecord } from "../src/guard"; +import { many, openLedger } from "../src/ledger/db"; import { decide, confirmationEligible, actionRefFor, type ToolCatalog } from "../src/policy/broker"; import { createTask, transition, requestConfirmation, resolveConfirmation } from "../src/ledger/tasks"; import type { Clock } from "../src/ledger/clock"; @@ -38,12 +39,15 @@ const CATALOG: ToolCatalog = { github_pr: { actionClasses: () => ["outward"] }, delete_branch: { actionClasses: () => ["irreversible"] }, send_payment: { - actionClasses: (args) => ((args as { amountCents?: number }).amountCents ?? 0) > 10_000 ? ["spend_above_threshold"] : [], + actionClasses: (args) => { + const amount = isRecord(args) && typeof args.amountCents === "number" ? args.amountCents : 0; + return amount > 10_000 ? ["spend_above_threshold"] : []; + }, }, scoped_repo_tool: { scopeCheck: (scope, args) => { - const allowed = (scope.repos as string[] | undefined) ?? []; - const repo = (args as { repo?: string }).repo; + const allowed = Array.isArray(scope.repos) ? scope.repos.filter((x): x is string => typeof x === "string") : []; + const repo = isRecord(args) && typeof args.repo === "string" ? args.repo : undefined; return repo && allowed.includes(repo) ? null : `repo ${repo} not in allowed list [${allowed.join(", ")}]`; }, }, @@ -55,8 +59,7 @@ describe("grant allowlist (SPEC §10.1)", () => { const db = freshDb(); const id = identity({ grants: [] }); const decision = decide(db, () => "2026-07-02T00:00:00Z", { identity: id, turnKind: "resident", tool: "github_pr", args: {}, catalog: CATALOG }); - expect(decision.allow).toBe(false); - expect(decision.allow === false && decision.reason).toBe("not_granted"); + expect(decision).toEqual({ allow: false, reason: "not_granted" }); }); test("a granted tool with no action classes is allowed", () => { @@ -71,9 +74,11 @@ describe("grant allowlist (SPEC §10.1)", () => { const id = identity({ grants: [] }); decide(db, () => "2026-07-02T00:00:00Z", { identity: id, turnKind: "resident", tool: "github_pr", args: {}, catalog: CATALOG }); - const rows = db.query("SELECT kind, payload FROM audit WHERE kind = 'tool_invoked'").all() as any[]; + const rows = many<{ kind: string; payload: string }>(db, "SELECT kind, payload FROM audit WHERE kind = 'tool_invoked'"); expect(rows).toHaveLength(1); - const payload = JSON.parse(rows[0].payload); + const first = rows[0]; + if (!first) throw new Error("expected an audit row"); + const payload = JSON.parse(first.payload); expect(payload.tool).toBe("github_pr"); expect(payload.decision).toBe("not_granted"); }); @@ -103,16 +108,14 @@ describe("scope narrowing enforced on arguments (SPEC §10.1)", () => { args: { repo: "acme/other-secret-repo" }, catalog: CATALOG, }); - expect(decision.allow).toBe(false); - expect(decision.allow === false && decision.reason).toBe("scope_violation"); + expect(decision).toMatchObject({ allow: false, reason: "scope_violation" }); }); test("a grant with scope configured but no scopeCheck registered fails closed", () => { const db = freshDb(); const id = identity({ grants: [{ tool: "github_pr", scope: { anything: true }, preauthorizedActionClasses: ["outward"] }] }); const decision = decide(db, () => "2026-07-02T00:00:00Z", { identity: id, turnKind: "resident", tool: "github_pr", args: {}, catalog: CATALOG }); - expect(decision.allow).toBe(false); - expect(decision.allow === false && decision.reason).toBe("scope_violation"); + expect(decision).toMatchObject({ allow: false, reason: "scope_violation" }); }); }); @@ -121,17 +124,14 @@ describe("action-class confirmation gate (SPEC §10.2)", () => { const db = freshDb(); const id = identity({ grants: [{ tool: "delete_branch", preauthorizedActionClasses: [] }] }); const decision = decide(db, () => "2026-07-02T00:00:00Z", { identity: id, turnKind: "resident", tool: "delete_branch", args: {}, catalog: CATALOG }); - expect(decision.allow).toBe(false); - expect(decision.allow === false && decision.reason).toBe("interactive_consequential_denied"); + expect(decision).toMatchObject({ allow: false, reason: "interactive_consequential_denied" }); }); test("execution_step turns are routed to confirmation instead of a flat denial", () => { const db = freshDb(); const id = identity({ grants: [{ tool: "delete_branch", preauthorizedActionClasses: [] }] }); const decision = decide(db, () => "2026-07-02T00:00:00Z", { identity: id, turnKind: "execution_step", tool: "delete_branch", args: {}, catalog: CATALOG }); - expect(decision.allow).toBe(false); - expect(decision.allow === false && decision.reason).toBe("requires_confirmation"); - expect(decision.allow === false && decision.reason === "requires_confirmation" && decision.actionClasses).toEqual(["irreversible"]); + expect(decision).toEqual({ allow: false, reason: "requires_confirmation", actionClasses: ["irreversible"] }); }); test("a preauthorized action class is allowed without confirmation (operator explicitly opted in)", () => { @@ -155,8 +155,7 @@ describe("action-class confirmation gate (SPEC §10.2)", () => { expect(small.allow).toBe(true); const large = decide(db, () => "2026-07-02T00:00:00Z", { identity: id, turnKind: "execution_step", tool: "send_payment", args: { amountCents: 50_000 }, catalog: CATALOG }); - expect(large.allow).toBe(false); - expect(large.allow === false && large.reason).toBe("requires_confirmation"); + expect(large).toMatchObject({ allow: false, reason: "requires_confirmation" }); }); }); @@ -227,8 +226,7 @@ describe("confirmation eligibility / guest policy (SPEC §10.4)", () => { catalog: CATALOG, principal: { isGuest: true }, }); - expect(guest.allow).toBe(false); - expect(guest.allow === false && guest.reason).toBe("confirmation_not_eligible"); + expect(guest).toEqual({ allow: false, reason: "confirmation_not_eligible" }); const member = decide(db, () => "2026-07-02T00:00:00Z", { identity: id, @@ -290,15 +288,17 @@ describe("injection resistance (SPEC §18.2 Safety, §10.4)", () => { }); }); +function seedConfirmableTask(db: ReturnType, clock: Clock) { + db.query("INSERT INTO events (id, dedup_key, kind, identity_id, received_at) VALUES ('e1', 'k1', 'addressed_message', 'eng', ?)").run(clock()); + createTask(db, clock, { id: "T-1", identityId: "eng", title: "t", spec: "s", sponsorId: "U1", homeAnchor: { venueId: "C1", threadRootId: null }, originEventId: "e1" }); + transition(db, clock, "T-1", "active", { type: "dispatch", executionId: "x1" }); +} + +const confirmClock: Clock = () => "2026-08-11T00:00:00Z"; + describe("the approval is a single-use capability token (ladder audit, §10.2)", () => { - function seedConfirmableTask(db: ReturnType, clock: Clock) { - db.query("INSERT INTO events (id, dedup_key, kind, identity_id, received_at) VALUES ('e1', 'k1', 'addressed_message', 'eng', ?)").run(clock()); - createTask(db, clock, { id: "T-1", identityId: "eng", title: "t", spec: "s", sponsorId: "U1", homeAnchor: { venueId: "C1", threadRootId: null }, originEventId: "e1" }); - transition(db, clock, "T-1", "active", { type: "dispatch", executionId: "x1" }); - } - const clock: Clock = () => "2026-08-11T00:00:00Z"; const workerCall = (db: ReturnType, args: unknown) => - decide(db, clock, { + decide(db, confirmClock, { identity: identity({ grants: [{ tool: "github_pr", scope: undefined, preauthorizedActionClasses: [] }] }), turnKind: "execution_step", tool: "github_pr", @@ -309,10 +309,10 @@ describe("the approval is a single-use capability token (ladder audit, §10.2)", test("an approved confirmation allows EXACTLY the approved action, once — then it is spent", () => { const db = freshDb(); - seedConfirmableTask(db, clock); + seedConfirmableTask(db, confirmClock); expect(workerCall(db, { repo: "acme/api", title: "fix" }).allow).toBe(false); // no approval yet - requestConfirmation(db, clock, { taskId: "T-1", actionRef: actionRefFor("github_pr", { repo: "acme/api", title: "fix" }), description: "d", nudgeDeadline: "2026-08-12T00:00:00Z" }); - resolveConfirmation(db, clock, { identityId: "eng", taskId: "T-1", principalId: "U1", approve: true }); + requestConfirmation(db, confirmClock, { taskId: "T-1", actionRef: actionRefFor("github_pr", { repo: "acme/api", title: "fix" }), description: "d", nudgeDeadline: "2026-08-12T00:00:00Z" }); + resolveConfirmation(db, confirmClock, { identityId: "eng", taskId: "T-1", principalId: "U1", approve: true }); // A DIFFERENT action cannot spend it — the ref binds the exact canonical args. expect(workerCall(db, { repo: "acme/api", title: "rm -rf" }).allow).toBe(false); @@ -324,9 +324,9 @@ describe("the approval is a single-use capability token (ladder audit, §10.2)", test("a resolved DENIAL is terminal for that action — no re-request loop", () => { const db = freshDb(); - seedConfirmableTask(db, clock); - requestConfirmation(db, clock, { taskId: "T-1", actionRef: actionRefFor("github_pr", { repo: "acme/api" }), description: "d", nudgeDeadline: "2026-08-12T00:00:00Z" }); - resolveConfirmation(db, clock, { identityId: "eng", taskId: "T-1", principalId: "U1", approve: false }); + seedConfirmableTask(db, confirmClock); + requestConfirmation(db, confirmClock, { taskId: "T-1", actionRef: actionRefFor("github_pr", { repo: "acme/api" }), description: "d", nudgeDeadline: "2026-08-12T00:00:00Z" }); + resolveConfirmation(db, confirmClock, { identityId: "eng", taskId: "T-1", principalId: "U1", approve: false }); const d = workerCall(db, { repo: "acme/api" }); expect(d.allow).toBe(false); if (!d.allow) expect(d.reason).toBe("confirmation_denied"); diff --git a/test/conversations.test.ts b/test/conversations.test.ts index 376dadc..d5f1704 100644 --- a/test/conversations.test.ts +++ b/test/conversations.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { openLedger } from "../src/ledger/db"; +import { one, openLedger } from "../src/ledger/db"; import { recordHold, recordWakeWhy, consumeJudgment, getConversationJudgment, engage, stepBack, stanceOf, pendingConversations } from "../src/ledger/conversations"; import type { Clock } from "../src/ledger/clock"; @@ -51,10 +51,10 @@ describe("conversation judgment (one room, one row — P1)", () => { expect(after.holds).toBe(0); expect(after.holdWhys).toEqual([]); expect(after.wakeWhy).toBeNull(); - const row = db.query("SELECT delivered_rowid, judged_rowid FROM conversations WHERE venue_id = 'C1' AND thread_root_id = '1.0'").get() as { - delivered_rowid: number; - judged_rowid: number; - }; + const row = one<{ delivered_rowid: number; judged_rowid: number }>( + db, + "SELECT delivered_rowid, judged_rowid FROM conversations WHERE venue_id = 'C1' AND thread_root_id = '1.0'", + )!; expect(row.delivered_rowid).toBe(42); // judged is the EAR's watermark and may trail — delivery never drags it forward expect(row.judged_rowid).toBe(0); @@ -93,12 +93,13 @@ describe("stance (SPEC §5.1 participation + the ear design's step-back, absorbe }); }); +function insertEvent(db: ReturnType, id: string, kind: string, venueId: string, threadRootId: string | null, text: string, addressMode?: string) { + db.query("INSERT INTO events (id, dedup_key, kind, identity_id, venue_id, thread_root_id, principal_id, payload, received_at) VALUES (?, ?, ?, 'eng', ?, ?, 'U1', ?, '2026-08-11T00:00:00Z')").run( + id, `k-${id}`, kind, venueId, threadRootId, JSON.stringify({ text, ts: id, ...(addressMode ? { addressMode } : {}) }), + ); +} + describe("out-stance delivery exceptions (review findings, 2026-08-11)", () => { - function insertEvent(db: ReturnType, id: string, kind: string, venueId: string, threadRootId: string | null, text: string, addressMode?: string) { - db.query("INSERT INTO events (id, dedup_key, kind, identity_id, venue_id, thread_root_id, principal_id, payload, received_at) VALUES (?, ?, ?, 'eng', ?, ?, 'U1', ?, '2026-08-11T00:00:00Z')").run( - id, `k-${id}`, kind, venueId, threadRootId, JSON.stringify({ text, ts: id, ...(addressMode ? { addressMode } : {}) }), - ); - } test("a worker's external_signal report delivers even into a stepped-out conversation — a terminal report can never be swallowed", () => { const db = freshDb(); diff --git a/test/ear.test.ts b/test/ear.test.ts index 41dfba2..1c929fb 100644 --- a/test/ear.test.ts +++ b/test/ear.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { openLedger } from "../src/ledger/db"; -import { refIn } from "./helpers"; +import { one, openLedger } from "../src/ledger/db"; +import { fakeClock, refIn } from "./helpers"; import { PolicyStore } from "../src/policy/load"; import { Service } from "../src/service"; import { pendingConversations } from "../src/ledger/conversations"; @@ -8,22 +8,12 @@ import { openItems } from "../src/ledger/attention"; import { FakeAdapter } from "./fakes/fake-adapter"; import { FakeAgentRuntimeSession } from "./fakes/fake-runtime-session"; import type { DynamicTool } from "../src/turn-runner/types"; -import type { Clock } from "../src/ledger/clock"; import type { RawMessage } from "@bevyl-ai/agent-tools"; // The Ear (specs/2026-07-13-the-ear-design.md): observed traffic settles into a voiceless // attention pass that decides WHEN the mind wakes — never what it sees (delivery is untouched), // never what it says (the ear has no posting tools). These are the design's §18 rows. -function fakeClock(start = "2026-07-02T00:00:00Z"): Clock & { set: (iso: string) => void } { - let now = start; - const clock = (() => now) as Clock & { set: (iso: string) => void }; - clock.set = (iso: string) => { - now = iso; - }; - return clock; -} - const POLICY_YAML = ` surface: kind: slack @@ -101,7 +91,7 @@ describe("the ear gates waking, never delivery", () => { expect(verdictResults[0]!.output).toContain("needs ref"); expect(verdictResults[1]!.success).toBe(true); // The recorded hold is durable judgment on the conversation row, not a discarded verdict. - const row = db.query("SELECT holds FROM conversations WHERE venue_id = 'C1'").get() as { holds: number } | null; + const row = one<{ holds: number }>(db, "SELECT holds FROM conversations WHERE venue_id = 'C1'"); expect(row?.holds).toBe(1); await service.stop(); }); @@ -245,7 +235,7 @@ describe("attention items (what she owes)", () => { const verdict = tools.get("verdict"); if (verdict) { if (++earCalls === 1) { - bad = (await verdict.run({ decision: "open_ask", why: "qa is needed on the preview" })) as { success: boolean; output: string }; + bad = await verdict.run({ decision: "open_ask", why: "qa is needed on the preview" }); await verdict.run({ decision: "open_ask", why: "qa is needed on the preview", ref: refIn(prompt, "preview") }); await verdict.run({ decision: "wake", why: "an open qa request with no taker", ref: refIn(prompt, "preview") }); } @@ -273,7 +263,7 @@ describe("attention items (what she owes)", () => { await verdict.run({ decision: "open_ask", why: "qa still outstanding", ref: refIn(prompt, "needs qa") }); return; } - reopen = (await verdict.run({ decision: "reopen_ask", why: "the work is still not done", itemId: openedId })) as { success: boolean; output: string }; + reopen = await verdict.run({ decision: "reopen_ask", why: "the work is still not done", itemId: openedId }); }); await service.start(); adapter.emit(msg({ text: "needs qa", ts: "6.1", threadRootTs: "6.0" })); diff --git a/test/execution-loop.test.ts b/test/execution-loop.test.ts index 723ec94..b7bcde1 100644 --- a/test/execution-loop.test.ts +++ b/test/execution-loop.test.ts @@ -6,20 +6,12 @@ import { runExecution } from "../src/turn-runner/execution-loop"; import { FakeAgentRuntimeSession } from "./fakes/fake-runtime-session"; import type { IdentityConfig } from "../src/policy/schema"; import type { Clock } from "../src/ledger/clock"; +import { fakeClock } from "./helpers"; function freshDb() { return openLedger(":memory:"); } -function fakeClock(start = "2026-07-02T00:00:00Z"): Clock & { advance: (iso: string) => void } { - let now = start; - const clock = (() => now) as Clock & { advance: (iso: string) => void }; - clock.advance = (iso: string) => { - now = iso; - }; - return clock; -} - function identity(overrides: Partial = {}): IdentityConfig { return { id: "eng", @@ -152,10 +144,8 @@ describe("runExecution (SPEC §17.4)", () => { makeActiveTask(db, clock); const seenGuidance: string[][] = []; - let _turnNum = 0; const params = baseParams(db, clock, (tools) => new FakeAgentRuntimeSession(tools, async (n, t) => { - _turnNum = n; if (n === 1) { db.query( "INSERT INTO events (id, dedup_key, kind, identity_id, received_at) VALUES ('e2', 'k2', 'addressed_message', 'eng', ?)", diff --git a/test/fakes/fake-adapter.ts b/test/fakes/fake-adapter.ts index f848d1e..0abf3cf 100644 --- a/test/fakes/fake-adapter.ts +++ b/test/fakes/fake-adapter.ts @@ -78,6 +78,12 @@ export class FakeAdapter implements SurfaceAdapter { this.taskCards.push({ messageId, ...task }); } + // ReplyStream treats a missing appendTaskUpdate as "this surface has no native cards". + withoutTaskCards(): this { + Object.defineProperty(this, "appendTaskUpdate", { value: undefined }); + return this; + } + async appendStream(_venueId: string, messageId: string, markdownDelta: string): Promise { const s = this.streams.find((x) => x.messageId === messageId); if (s) { diff --git a/test/helpers.ts b/test/helpers.ts index a4b9560..7e04e16 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,6 +1,15 @@ import { existsSync, unlinkSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; +import type { Clock } from "../src/ledger/clock"; + +export function fakeClock(start = "2026-07-02T00:00:00Z"): Clock & { set: (iso: string) => void; advance: (iso: string) => void } { + let now = start; + const set = (iso: string) => { + now = iso; + }; + return Object.assign(() => now, { set, advance: set }); +} export function tempDbPath(prefix: string): string { return join(tmpdir(), `${prefix}-${Math.random().toString(36).slice(2)}.db`); diff --git a/test/integrations.test.ts b/test/integrations.test.ts index ed0adf3..3bb382c 100644 --- a/test/integrations.test.ts +++ b/test/integrations.test.ts @@ -2,15 +2,22 @@ import { describe, expect, test } from "bun:test"; import { INTEGRATION_REGISTRIES, integrationCatalog, INTEGRATION_TOOL_NAMES, buildToolbox, type ToolRegistry } from "../src/tools/catalog"; import type { DynamicTool } from "../src/turn-runner/types"; +function dyn(name: string): DynamicTool { + return { + spec: { name, description: `${name} does its thing`, inputSchema: { type: "object" } }, + run: async () => ({ success: true, output: "" }), + }; +} + // SPEC §11 — the catalog is registries owning tool arrays; the flat broker catalog and the // name list are derivations of that one structure, so they can never drift from it. describe("registry derivations", () => { const cat = integrationCatalog(); test("flattened catalog and name list match the registries exactly", () => { - const fromRegistries = INTEGRATION_REGISTRIES.flatMap((r) => Object.keys(r.tools)).sort(); - expect([...INTEGRATION_TOOL_NAMES].sort()).toEqual(fromRegistries); - expect(Object.keys(cat).sort()).toEqual(fromRegistries); + const fromRegistries = INTEGRATION_REGISTRIES.flatMap((r) => Object.keys(r.tools)).toSorted(); + expect([...INTEGRATION_TOOL_NAMES].toSorted()).toEqual(fromRegistries); + expect(Object.keys(cat).toSorted()).toEqual(fromRegistries); }); test("every integration tool is present, self-describing, and runnable", () => { @@ -31,7 +38,7 @@ describe("registry derivations", () => { }); test("integration registries carry a skill and at least one worked example", () => { - for (const r of INTEGRATION_REGISTRIES.filter((r) => ["linear", "github", "notion"].includes(r.name))) { + for (const r of INTEGRATION_REGISTRIES.filter((reg) => ["linear", "github", "notion"].includes(reg.name))) { expect(r.skill!.length).toBeGreaterThan(0); expect(r.examples!.length).toBeGreaterThan(0); } @@ -122,10 +129,6 @@ describe("buildToolbox", () => { }, { name: "db", tools: { db_read: { description: "unused here" } } }, ]; - const dyn = (name: string): DynamicTool => ({ - spec: { name, description: `${name} does its thing`, inputSchema: { type: "object" } }, - run: async () => ({ success: true, output: "" }), - }); test("full exposure: groups in registry order, skill and all examples present", () => { const tb = buildToolbox([dyn("linear_read"), dyn("linear_write"), dyn("db_read")], registries); diff --git a/test/memory.test.ts b/test/memory.test.ts index 3c1ae61..1bdd433 100644 --- a/test/memory.test.ts +++ b/test/memory.test.ts @@ -1,21 +1,12 @@ +import { fakeClock } from "./helpers"; import { describe, expect, test } from "bun:test"; import { openLedger } from "../src/ledger/db"; import { writeMemory, retractMemory, correctMemory, queryMemory, confirmMemory, decayStaleMemory } from "../src/ledger/memory"; -import type { Clock } from "../src/ledger/clock"; function freshDb() { return openLedger(":memory:"); } -function fakeClock(start = "2026-07-02T00:00:00Z"): Clock & { advance: (iso: string) => void } { - let now = start; - const clock = (() => now) as Clock & { advance: (iso: string) => void }; - clock.advance = (iso: string) => { - now = iso; - }; - return clock; -} - describe("writeMemory (SPEC §8.1, §8.2 explicit write)", () => { test("writes an active item with provenance and audits it", () => { const db = freshDb(); @@ -151,7 +142,7 @@ describe("decayStaleMemory (SPEC §8.5 hygiene)", () => { const result = decayStaleMemory(db, clock, "eng", { maxAgeMs: Infinity, maxItems: 2 }); expect(result.decayed).toEqual(["m1"]); - expect(queryMemory(db, "eng").map((i) => i.id).sort()).toEqual(["m2", "m3"]); + expect(queryMemory(db, "eng").map((i) => i.id).toSorted()).toEqual(["m2", "m3"]); }); test("is scoped to one identity — never touches another identity's items", () => { diff --git a/test/migrations.test.ts b/test/migrations.test.ts index bd084d8..aac4413 100644 --- a/test/migrations.test.ts +++ b/test/migrations.test.ts @@ -1,18 +1,18 @@ import { describe, expect, test } from "bun:test"; import { Database } from "bun:sqlite"; -import { openLedger } from "../src/ledger/db"; +import { many, one, openLedger } from "../src/ledger/db"; import { tempDbPath, cleanupDbFile } from "./helpers"; describe("schema migrations", () => { test("fresh database lands on the current schema version with consecutive_interruptions present", () => { const db = openLedger(":memory:"); - const version = (db.query("SELECT version FROM schema_version").get() as { version: number }).version; + const version = one<{ version: number }>(db, "SELECT version FROM schema_version")?.version; expect(version).toBe(14); - const columns = db.query("PRAGMA table_info(tasks)").all() as any[]; + const columns = many<{ name: string }>(db, "PRAGMA table_info(tasks)"); expect(columns.map((c) => c.name)).toContain("consecutive_interruptions"); - const tables = db.query("SELECT name FROM sqlite_master WHERE type = 'table'").all() as any[]; + const tables = many<{ name: string }>(db, "SELECT name FROM sqlite_master WHERE type = 'table'"); // v13 (the one-room overhaul): the conversation row is the only conversation-state table for (const dead of ["thread_participation", "conversation_threads", "resident_cursor", "ear_cursor"]) { expect(tables.map((t) => t.name)).not.toContain(dead); @@ -20,9 +20,9 @@ describe("schema migrations", () => { expect(tables.map((t) => t.name)).toContain("conversations"); expect(tables.map((t) => t.name)).toContain("acts"); expect(tables.map((t) => t.name)).toContain("drafts"); - const memCols = db.query("PRAGMA table_info(memory_items)").all() as any[]; + const memCols = many<{ name: string }>(db, "PRAGMA table_info(memory_items)"); expect(memCols.map((c) => c.name)).toContain("tier"); // v7: memory tiers - const vtabs = db.query("SELECT name FROM sqlite_master WHERE type = 'table'").all() as any[]; + const vtabs = many<{ name: string }>(db, "SELECT name FROM sqlite_master WHERE type = 'table'"); expect(vtabs.map((t) => t.name)).toContain("events_fts"); // v7: the searchable floor expect(vtabs.map((t) => t.name)).toContain("memory_fts"); // v9: resident wakes are recordable turns @@ -104,32 +104,35 @@ describe("schema migrations", () => { seed.close(); const db = openLedger(path); - const version = (db.query("SELECT version FROM schema_version").get() as { version: number }).version; + const version = one<{ version: number }>(db, "SELECT version FROM schema_version")?.version; expect(version).toBe(14); - const task = db.query("SELECT id, consecutive_interruptions FROM tasks WHERE id = 'T-1'").get() as any; - expect(task.id).toBe("T-1"); - expect(task.consecutive_interruptions).toBe(0); + const task = one<{ id: string; consecutive_interruptions: number }>(db, "SELECT id, consecutive_interruptions FROM tasks WHERE id = 'T-1'"); + expect(task?.id).toBe("T-1"); + expect(task?.consecutive_interruptions).toBe(0); - const tables = db.query("SELECT name FROM sqlite_master WHERE type = 'table'").all() as any[]; + const tables = many<{ name: string }>(db, "SELECT name FROM sqlite_master WHERE type = 'table'"); // v13 dropped the pre-overhaul state tables outright for (const dead of ["thread_participation", "conversation_threads", "resident_cursor", "ear_cursor"]) { expect(tables.map((t) => t.name)).not.toContain(dead); } expect(tables.map((t) => t.name)).toContain("acts"); expect(tables.map((t) => t.name)).toContain("drafts"); - const memCols = db.query("PRAGMA table_info(memory_items)").all() as any[]; + const memCols = many<{ name: string }>(db, "PRAGMA table_info(memory_items)"); expect(memCols.map((c) => c.name)).toContain("tier"); // v7 reached via the ladder // the FTS backfill indexed rows that existed before the migration - const oldEvent = db.query("SELECT count(*) c FROM events_fts WHERE events_fts MATCH 'ancient'").get() as any; - expect(oldEvent.c).toBe(1); - const oldMemory = db.query("SELECT count(*) c FROM memory_fts WHERE memory_fts MATCH 'flaky'").get() as any; - expect(oldMemory.c).toBe(1); + const oldEvent = one<{ c: number }>(db, "SELECT count(*) c FROM events_fts WHERE events_fts MATCH 'ancient'"); + expect(oldEvent?.c).toBe(1); + const oldMemory = one<{ c: number }>(db, "SELECT count(*) c FROM memory_fts WHERE memory_fts MATCH 'flaky'"); + expect(oldMemory?.c).toBe(1); // v12: the conversations row seeded from pre-existing events, watermarked at the (v9-seeded) // global cursor so nothing re-delivers on upgrade. - const convo = db.query("SELECT delivered_rowid, judged_rowid, holds FROM conversations WHERE venue_id = 'C1' AND thread_root_id = ''").get() as any; + const convo = one<{ delivered_rowid: number; judged_rowid: number; holds: number }>( + db, + "SELECT delivered_rowid, judged_rowid, holds FROM conversations WHERE venue_id = 'C1' AND thread_root_id = ''", + ); expect(convo).not.toBeNull(); - expect(convo.holds).toBe(0); + expect(convo?.holds).toBe(0); db.close(); cleanupDbFile(path); @@ -165,10 +168,10 @@ describe("schema migrations", () => { seed.close(); const db = openLedger(path); - const pending = db.query("SELECT id FROM timers WHERE fired_at IS NULL ORDER BY id").all() as any[]; + const pending = many<{ id: string }>(db, "SELECT id FROM timers WHERE fired_at IS NULL ORDER BY id"); expect(pending.map((r) => r.id)).toEqual(["ambient_tick:eng:b", "ambient_tick:sales:a", "distillation:eng:a"]); - const fired = db.query("SELECT COUNT(*) c FROM timers WHERE fired_at IS NOT NULL").get() as any; - expect(fired.c).toBe(1); + const fired = one<{ c: number }>(db, "SELECT COUNT(*) c FROM timers WHERE fired_at IS NOT NULL"); + expect(fired?.c).toBe(1); db.close(); cleanupDbFile(path); @@ -201,11 +204,14 @@ describe("schema migrations", () => { const db = openLedger(path); // Judgment survived the rebuild; stance imported from participation. - const row = db.query("SELECT delivered_rowid, holds, stance, stance_why FROM conversations WHERE venue_id='C1' AND thread_root_id='1.0'").get() as any; - expect(row.delivered_rowid).toBe(5); - expect(row.holds).toBe(2); - expect(row.stance).toBe("out"); - expect(row.stance_why).toBe("noah said stop"); + const row = one<{ delivered_rowid: number; holds: number; stance: string; stance_why: string | null }>( + db, + "SELECT delivered_rowid, holds, stance, stance_why FROM conversations WHERE venue_id='C1' AND thread_root_id='1.0'", + ); + expect(row?.delivered_rowid).toBe(5); + expect(row?.holds).toBe(2); + expect(row?.stance).toBe("out"); + expect(row?.stance_why).toBe("noah said stop"); // The CHECK is gone: judged may now trail delivered (the ear bookkeeps after the fact). db.query("UPDATE conversations SET judged_rowid = 1 WHERE venue_id='C1'").run(); db.close(); diff --git a/test/replay.test.ts b/test/replay.test.ts index 12379b2..7001bd7 100644 --- a/test/replay.test.ts +++ b/test/replay.test.ts @@ -1,3 +1,4 @@ +import { fakeClock } from "./helpers"; import { describe, expect, test } from "bun:test"; import { openLedger } from "../src/ledger/db"; import { refIn } from "./helpers"; @@ -17,15 +18,6 @@ import type { RawMessage } from "@bevyl-ai/agent-tools"; // the snapshot to the moment before it, and relive it through the real Service against a capture // surface. Codex is faked here per the repo's test rules — the CLI injects the real factory. -function fakeClock(start = "2026-07-02T00:00:00Z"): Clock & { set: (iso: string) => void } { - let now = start; - const clock = (() => now) as Clock & { set: (iso: string) => void }; - clock.set = (iso: string) => { - now = iso; - }; - return clock; -} - const POLICY_YAML = ` surface: kind: slack @@ -125,7 +117,7 @@ describe("replay: rewind", () => { const events = loadIncident(db, { fromIso: "2026-07-02T10:00:00Z", toIso: "2026-07-02T11:00:00Z" }); const original = originalActions(db, "2026-07-02T10:00:00Z", "2026-07-02T11:00:00Z"); - expect(original.flatMap((t) => t.effects as { kind?: string; text?: string }[]).some((e) => e.text === "on it")).toBe(true); + expect(original.flatMap((t) => t.effects).some((e) => typeof e === "object" && e !== null && "text" in e && e.text === "on it")).toBe(true); const report = rewindLedger(db, events[0]!.rowid, "2026-07-02T10:00:00Z"); expect(report.events).toBeGreaterThanOrEqual(1); diff --git a/test/reply-stream.test.ts b/test/reply-stream.test.ts index e701bb8..895016b 100644 --- a/test/reply-stream.test.ts +++ b/test/reply-stream.test.ts @@ -68,8 +68,7 @@ describe("ReplyStream", () => { }); test("setCards reports false when the surface has no native cards, so the caller can fall back", () => { - const adapter = new FakeAdapter(); - (adapter as { appendTaskUpdate?: unknown }).appendTaskUpdate = undefined; + const adapter = new FakeAdapter().withoutTaskCards(); const stream = new ReplyStream({ adapter, venueId: "C1", threadTs: "1.0", recipient: "U1", log: silent }); expect(stream.setCards([{ text: "a", done: false }])).toBe(false); }); diff --git a/test/resident.test.ts b/test/resident.test.ts index f3ca729..418167e 100644 --- a/test/resident.test.ts +++ b/test/resident.test.ts @@ -1,29 +1,36 @@ import { describe, expect, test } from "bun:test"; -import { openLedger } from "../src/ledger/db"; +import { many, one, openLedger } from "../src/ledger/db"; +import { isRecord, parseJson } from "../src/guard"; import { PolicyStore } from "../src/policy/load"; import { Service } from "../src/service"; import { pendingConversations } from "../src/ledger/conversations"; import { FakeAdapter } from "./fakes/fake-adapter"; import { FakeAgentRuntimeSession } from "./fakes/fake-runtime-session"; import type { DynamicTool } from "../src/turn-runner/types"; -import type { Clock } from "../src/ledger/clock"; import type { RawMessage } from "@bevyl-ai/agent-tools"; -import { refIn } from "./helpers"; +import { fakeClock, refIn } from "./helpers"; + +function firstSearchRef(output: string): string { + const parsed = parseJson(output); + if (!Array.isArray(parsed)) throw new Error("search output is not an array"); + for (const h of parsed) { + if (isRecord(h) && typeof h.ref === "string") return h.ref; + } + throw new Error("no search ref"); +} + +const earWakes = async (tools: Map, prompt: string): Promise => { + const verdict = tools.get("verdict"); + if (!verdict) return false; + await verdict.run({ decision: "wake", why: "her thread is moving", ref: refIn(prompt, /<#C1>/) }); + return true; +}; // The Collapse (specs/2026-07-13-the-collapse-design.md), amended: every wake runs on a fresh // runtime thread (SPEC §11 "No thread survives its wake") — inbox messages delivered verbatim, // continuity via the standing document + ledger, restart-durable delivery. These are the // loop's conformance rows. -function fakeClock(start = "2026-07-02T00:00:00Z"): Clock & { set: (iso: string) => void } { - let now = start; - const clock = (() => now) as Clock & { set: (iso: string) => void }; - clock.set = (iso: string) => { - now = iso; - }; - return clock; -} - const POLICY_YAML = ` surface: kind: slack @@ -177,7 +184,7 @@ describe("resident delivery", () => { const assembledSeen = new Promise((r) => (assembled = r)); let release: (() => void) | undefined; const gate = new Promise((r) => (release = r)); - const { db, adapter, service } = harness(async (_turn, tools, _mark, prompt) => { + const { db, adapter, service } = harness(async (_turn, tools) => { if (tools.get("verdict")) return; assembled!(); // the prompt exists — assembly is done await gate; // the "process" hangs mid-turn @@ -213,8 +220,8 @@ describe("resident delivery", () => { } else if (mindWakes === 3) { // The stepped-out conversation isn't in this wake — the only way to address it is a // search-minted ref, which bounces with the card. - const hits = JSON.parse((await tools.get("search")!.run({ query: "watch this" })).output) as { ref?: string }[]; - await tools.get("reply")!.run({ text: "a stale take", ref: hits.find((h) => h.ref)!.ref }); // bounces + const searchRef = firstSearchRef((await tools.get("search")!.run({ query: "watch this" })).output); + await tools.get("reply")!.run({ text: "a stale take", ref: searchRef }); // bounces // ...and she chooses NOT to re-send after reading the card. } }); @@ -230,10 +237,11 @@ describe("resident delivery", () => { // The bounce rendered a card, but the held chatter is still undelivered and the hold intact: // a card shows at most a tail — it must never mark a backlog delivered unseen. - const row = db - .query("SELECT delivered_rowid, holds FROM conversations WHERE venue_id = 'C1' AND thread_root_id = '1.0'") - .get() as { delivered_rowid: number; holds: number }; - const chatterRowid = (db.query("SELECT rowid FROM events WHERE json_extract(payload, '$.ts') = '1.2'").get() as { rowid: number }).rowid; + const row = one<{ delivered_rowid: number; holds: number }>( + db, + "SELECT delivered_rowid, holds FROM conversations WHERE venue_id = 'C1' AND thread_root_id = '1.0'", + )!; + const chatterRowid = one<{ rowid: number }>(db, "SELECT rowid FROM events WHERE json_extract(payload, '$.ts') = '1.2'")!.rowid; expect(row.delivered_rowid).toBeLessThan(chatterRowid); expect(row.holds).toBeGreaterThanOrEqual(1); // NOT zeroed — the bounce didn't consume the judgment await service.stop(); @@ -383,7 +391,7 @@ describe("resident delivery", () => { adapter.emit(msg({ text: "<@BOT1> dig into it", mentionsBotId: true, ts: "77.1", threadRootTs: "77.0" })); await service.idle(); - const row = db.query("SELECT home_venue_id, home_thread_root_id FROM tasks").get() as { home_venue_id: string; home_thread_root_id: string } | null; + const row = one<{ home_venue_id: string; home_thread_root_id: string }>(db, "SELECT home_venue_id, home_thread_root_id FROM tasks"); expect(row?.home_venue_id).toBe("C1"); expect(row?.home_thread_root_id).toBe("77.0"); await service.stop(); @@ -453,9 +461,10 @@ describe("resident delivery", () => { await service.idle(); // flushes the boot wake carrying both conversations expect(rejected[0]).toContain("is not a ref"); - const row = db - .query("SELECT home_venue_id, home_thread_root_id, sponsor_id, origin_event_id FROM tasks") - .get() as { home_venue_id: string; home_thread_root_id: string | null; sponsor_id: string; origin_event_id: string } | null; + const row = one<{ home_venue_id: string; home_thread_root_id: string | null; sponsor_id: string; origin_event_id: string }>( + db, + "SELECT home_venue_id, home_thread_root_id, sponsor_id, origin_event_id FROM tasks", + ); expect(row?.home_venue_id).toBe("C1"); // the incident's thread... expect(row?.home_thread_root_id).toBe("1.0"); // ...not C2, the batch's last-addressed guess // Provenance binds to the ref too: sponsor and origin are the ref'd message's speaker and @@ -510,7 +519,7 @@ describe("resident delivery", () => { await service.idle(); const fallbacks = adapter.posts.filter((p) => p.text.includes("can't run right now")); - const where = fallbacks.map((p) => `${p.venueId}:${p.threadRootTs}`).sort(); + const where = fallbacks.map((p) => `${p.venueId}:${p.threadRootTs}`).toSorted(); expect(where).toEqual(["C1:1.0", "C2:2.0"]); // one per owed conversation — nobody left hanging await service.stop(); }); @@ -540,10 +549,10 @@ describe("resident delivery", () => { adapter.emit(msg({ text: "<@BOT1> check canary too, actually just stop", mentionsBotId: true, ts: "90.2", threadRootTs: "90.0", principalId: "U3" })); await service.idle(); - const task = db.query("SELECT status FROM tasks WHERE id = 'T-1'").get() as { status: string } | null; + const task = one<{ status: string }>(db, "SELECT status FROM tasks WHERE id = 'T-1'"); expect(task?.status).toBe("cancelled"); - const steer = db.query("SELECT source_event_id FROM steering WHERE kind = 'guidance'").get() as { source_event_id: string } | null; - const askEvent = db.query("SELECT id FROM events WHERE json_extract(payload, '$.ts') = '90.2'").get() as { id: string } | null; + const steer = one<{ source_event_id: string }>(db, "SELECT source_event_id FROM steering WHERE kind = 'guidance'"); + const askEvent = one<{ id: string }>(db, "SELECT id FROM events WHERE json_extract(payload, '$.ts') = '90.2'"); expect(steer?.source_event_id).toBe(askEvent!.id); // provenance = the message that asked await service.stop(); }); @@ -565,7 +574,7 @@ describe("resident delivery", () => { adapter.emit(msg({ text: "<@BOT1> did you see it?", mentionsBotId: true, ts: "77.9", threadRootTs: "77.0" })); await service.idle(); - const act = db.query("SELECT venue_id, thread_root_id, ts FROM acts WHERE kind = 'reacted'").get() as { venue_id: string; thread_root_id: string | null; ts: string } | null; + const act = one<{ venue_id: string; thread_root_id: string | null; ts: string }>(db, "SELECT venue_id, thread_root_id, ts FROM acts WHERE kind = 'reacted'"); expect(act?.ts).toBe("77.1"); // the tail line she reacted to... expect(act?.thread_root_id).toBe("77.0"); // ...filed in ITS thread — never the surface expect(adapter.reactions.at(-1)).toMatchObject({ venueId: "C1", messageId: "77.1", emoji: "eyes" }); @@ -631,7 +640,7 @@ describe("resident delivery", () => { await service.idle(); // flushes the boot wake carrying both conversations expect(rejected[0]).toContain("is not a ref"); - const row = db.query("SELECT home_venue_id, home_thread_root_id FROM tasks").get() as { home_venue_id: string; home_thread_root_id: string | null } | null; + const row = one<{ home_venue_id: string; home_thread_root_id: string | null }>(db, "SELECT home_venue_id, home_thread_root_id FROM tasks"); expect(row?.home_venue_id).toBe("C1"); // the incident's thread... expect(row?.home_thread_root_id).toBe("1.0"); // ...not C2, the batch's last-addressed guess await service.stop(); @@ -710,13 +719,6 @@ describe("resident delivery", () => { describe("stale-reply withholding (§5.5)", () => { // Each test's ear script wakes the mind for thread chatter — the ear's judgment isn't under // test here, the wake's posting behavior is. - const earWakes = async (tools: Map, prompt: string): Promise => { - const verdict = tools.get("verdict"); - if (!verdict) return false; - await verdict.run({ decision: "wake", why: "her thread is moving", ref: refIn(prompt, /<#C1>/) }); - return true; - }; - test("§5.5: a thread-follow reply is withheld when the conversation moved mid-turn; the next wake carries the unsent draft", async () => { let mindWakes = 0; let replyResult: { success: boolean; output: string } | undefined; @@ -726,10 +728,7 @@ describe("stale-reply withholding (§5.5)", () => { if (++mindWakes === 2) { // Noah answers Nina while she is still composing her own answer. emitMidTurn(); - replyResult = (await tools.get("reply")!.run({ text: "the shipping window was clean", ref: refIn(prompt, "when did this actually ship") })) as { - success: boolean; - output: string; - }; + replyResult = await tools.get("reply")!.run({ text: "the shipping window was clean", ref: refIn(prompt, "when did this actually ship") }); } }); emitMidTurn = () => adapter.emit(msg({ text: "already answered: it shipped at 8pm", ts: "1.3", threadRootTs: "1.0", principalId: "U_NOAH" })); @@ -744,7 +743,7 @@ describe("stale-reply withholding (§5.5)", () => { const everything = [...adapter.posts.map((p) => p.text), ...adapter.streams.map((s) => s.text)].join(" "); expect(everything).not.toContain("the shipping window was clean"); // The ledger records the withhold honestly — never a "posted" that didn't post. - const rows = db.query("SELECT effects FROM turns WHERE kind='resident'").all() as { effects: string }[]; + const rows = many<{ effects: string }>(db, "SELECT effects FROM turns WHERE kind='resident'"); expect(rows.some((r) => r.effects.includes('"kind":"withheld"'))).toBe(true); expect(rows.some((r) => r.effects.includes('"kind":"posted"') && r.effects.includes("shipping window was clean"))).toBe(false); // The immediately following wake carries both the mover and the unsent draft. @@ -771,7 +770,7 @@ describe("stale-reply withholding (§5.5)", () => { await service.idle(); expect(adapter.lastStreamText()).toBe("covered upthread — the fix shipped"); - const rows = db.query("SELECT effects FROM turns WHERE kind='resident'").all() as { effects: string }[]; + const rows = many<{ effects: string }>(db, "SELECT effects FROM turns WHERE kind='resident'"); expect(rows.some((r) => r.effects.includes('"kind":"posted"') && r.effects.includes("covered upthread"))).toBe(true); expect(rows.some((r) => r.effects.includes('"kind":"withheld"'))).toBe(false); await service.stop(); @@ -793,16 +792,9 @@ describe("stale-reply withholding (§5.5)", () => { } else if (mindWakes === 2) { await tools.get("step_back")!.run({ why: "noah asked me to leave this one", ref: refIn(prompt, "drop it") }); } else if (mindWakes === 3) { - const hits = JSON.parse((await tools.get("search")!.run({ query: "watch this" })).output) as { ref?: string }[]; - const searchRef = hits.find((h) => h.ref)!.ref!; - firstTry = (await tools.get("reply")!.run({ text: "reopening: this is not settled", ref: searchRef })) as { - success: boolean; - output: string; - }; - secondTry = (await tools.get("reply")!.run({ text: "read it — still worth saying", ref: searchRef })) as { - success: boolean; - output: string; - }; + const searchRef = firstSearchRef((await tools.get("search")!.run({ query: "watch this" })).output); + firstTry = await tools.get("reply")!.run({ text: "reopening: this is not settled", ref: searchRef }); + secondTry = await tools.get("reply")!.run({ text: "read it — still worth saying", ref: searchRef }); } }); await service.start(); @@ -824,7 +816,7 @@ describe("stale-reply withholding (§5.5)", () => { // The informed re-send posts, and posting re-engages the conversation. expect(secondTry!.success).toBe(true); expect(everything).toContain("read it — still worth saying"); - const row = db.query("SELECT stance FROM conversations WHERE venue_id = 'C1' AND thread_root_id = '1.0'").get() as { stance: string }; + const row = one<{ stance: string }>(db, "SELECT stance FROM conversations WHERE venue_id = 'C1' AND thread_root_id = '1.0'")!; expect(row.stance).toBe("engaged"); await service.stop(); }); @@ -843,16 +835,12 @@ describe("stale-reply withholding (§5.5)", () => { await tools.get("step_back")!.run({ why: "noah asked me to leave this one", ref: refIn(prompt, "drop it") }); } else { gateAttempts++; - const hits = JSON.parse((await tools.get("search")!.run({ query: "watch this" })).output) as { ref?: string }[]; - const searchRef = hits.find((h) => h.ref)!.ref!; + const searchRef = firstSearchRef((await tools.get("search")!.run({ query: "watch this" })).output); if (gateAttempts === 1) { await tools.get("reply")!.run({ text: "stale hot take", ref: searchRef }); throw new Error("stream disconnected before completion"); } - retryTry = (await tools.get("reply")!.run({ text: "stale hot take", ref: searchRef })) as { - success: boolean; - output: string; - }; + retryTry = await tools.get("reply")!.run({ text: "stale hot take", ref: searchRef }); } }); await service.start(); @@ -882,10 +870,7 @@ describe("stale-reply withholding (§5.5)", () => { if (mindWakes === 2) { await tools.get("step_back")!.run({ why: "the humans have it", ref: refIn(prompt, "drop it") }); } else if (mindWakes === 3) { - firstTry = (await tools.get("reply")!.run({ text: "here as asked", ref: refIn(prompt, "one more thing") })) as { - success: boolean; - output: string; - }; + firstTry = await tools.get("reply")!.run({ text: "here as asked", ref: refIn(prompt, "one more thing") }); } }); await service.start(); @@ -955,10 +940,10 @@ describe("stale-reply withholding (§5.5)", () => { expect(wake).toContain("kate closed this as settled"); expect(wake).toContain("still settled, nothing for her"); // Consumed with the delivery: the row is clean for the conversation's next stretch. - const row = db.query("SELECT holds, hold_whys FROM conversations WHERE venue_id = 'C1' AND thread_root_id = '1.0'").get() as { - holds: number; - hold_whys: string; - }; + const row = one<{ holds: number; hold_whys: string }>( + db, + "SELECT holds, hold_whys FROM conversations WHERE venue_id = 'C1' AND thread_root_id = '1.0'", + )!; expect(row.holds).toBe(0); expect(JSON.parse(row.hold_whys)).toEqual([]); await service.stop(); @@ -1026,7 +1011,7 @@ describe("stale-reply withholding (§5.5)", () => { const everything = [...adapter.posts.map((p) => p.text), ...adapter.streams.map((s) => s.text)].join(" "); expect(everything).toContain("answering you directly"); // the addressed reply landed expect(everything).not.toContain("my stale take"); // the overheard conversation's reply was withheld - const rows = db.query("SELECT effects FROM turns WHERE kind='resident'").all() as { effects: string }[]; + const rows = many<{ effects: string }>(db, "SELECT effects FROM turns WHERE kind='resident'"); expect(rows.some((r) => r.effects.includes('"kind":"withheld"'))).toBe(true); await service.stop(); }); @@ -1102,7 +1087,7 @@ describe("stale-reply withholding (§5.5)", () => { const everything = [...adapter.posts.map((p) => p.text), ...adapter.streams.map((s) => s.text)].join(" "); expect(everything).toContain("answering you directly"); - const rows = db.query("SELECT effects FROM turns WHERE kind='resident'").all() as { effects: string }[]; + const rows = many<{ effects: string }>(db, "SELECT effects FROM turns WHERE kind='resident'"); expect(rows.some((r) => r.effects.includes('"kind":"withheld"'))).toBe(false); await service.stop(); }); diff --git a/test/router.test.ts b/test/router.test.ts index a2dee86..e29f837 100644 --- a/test/router.test.ts +++ b/test/router.test.ts @@ -57,7 +57,7 @@ function msg(overrides: Partial = {}): RawMessage { }; } -function opts(db: ReturnType, overrides: Partial[3]> = {}) { +function opts(overrides: Partial[3]> = {}) { let n = 0; return { botPrincipalId: "BOT1", policy: basePolicy(), newEventId: () => `e${++n}`, ...overrides }; } @@ -66,7 +66,7 @@ describe("routeMessage (SPEC §17.1, §10.5)", () => { test("the agent's own messages are ignored entirely — no event, no audit", () => { const db = freshDb(); const clock = fakeClock(); - const result = routeMessage(db, clock, msg({ isBot: true, principalId: "BOT1" }), opts(db)); + const result = routeMessage(db, clock, msg({ isBot: true, principalId: "BOT1" }), opts()); expect(result.kind).toBe("ignored_self"); expect(db.query("SELECT COUNT(*) as c FROM events").get()).toEqual({ c: 0 }); @@ -76,7 +76,7 @@ describe("routeMessage (SPEC §17.1, §10.5)", () => { const db = freshDb(); const clock = fakeClock(); const logged: { value: string | null } = { value: null }; - const result = routeMessage(db, clock, msg({ venueId: "UNKNOWN" }), opts(db, { onUnboundVenue: (v) => (logged.value = v) })); + const result = routeMessage(db, clock, msg({ venueId: "UNKNOWN" }), opts({ onUnboundVenue: (v) => (logged.value = v) })); expect(result.kind).toBe("unbound_venue"); expect(logged.value).toBe("UNKNOWN"); @@ -86,7 +86,7 @@ describe("routeMessage (SPEC §17.1, §10.5)", () => { test("a duplicate delivery (same dedup key) is recognized and produces no second event", () => { const db = freshDb(); const clock = fakeClock(); - const o = opts(db); + const o = opts(); const first = routeMessage(db, clock, msg({ mentionsBotId: true, deliveryId: "d1" }), o); const second = routeMessage(db, clock, msg({ mentionsBotId: true, deliveryId: "d1" }), o); @@ -98,7 +98,7 @@ describe("routeMessage (SPEC §17.1, §10.5)", () => { test("a mention is addressed and bound to the venue's identity", () => { const db = freshDb(); const clock = fakeClock(); - const result = routeMessage(db, clock, msg({ mentionsBotId: true }), opts(db)); + const result = routeMessage(db, clock, msg({ mentionsBotId: true }), opts()); expect(result.kind).toBe("addressed"); if (result.kind === "addressed") { @@ -110,7 +110,7 @@ describe("routeMessage (SPEC §17.1, §10.5)", () => { test("a non-mention, non-thread-participating message is observed, not addressed", () => { const db = freshDb(); const clock = fakeClock(); - const result = routeMessage(db, clock, msg({ mentionsBotId: false }), opts(db)); + const result = routeMessage(db, clock, msg({ mentionsBotId: false }), opts()); expect(result.kind).toBe("observed"); if (result.kind === "observed") expect(result.event.kind).toBe("observed_message"); @@ -121,7 +121,7 @@ describe("routeMessage (SPEC §17.1, §10.5)", () => { const clock = fakeClock(); engage(db, clock, "eng", "C1", "50.0"); // the agent posted here — e.g. an ambient flag - const result = routeMessage(db, clock, msg({ ts: "51.0", threadRootTs: "50.0", mentionsBotId: false, deliveryId: "d-ambient-reply" }), opts(db)); + const result = routeMessage(db, clock, msg({ ts: "51.0", threadRootTs: "50.0", mentionsBotId: false, deliveryId: "d-ambient-reply" }), opts()); expect(result.kind).toBe("addressed"); }); @@ -129,7 +129,7 @@ describe("routeMessage (SPEC §17.1, §10.5)", () => { const db = freshDb(); const clock = fakeClock(); const policy = basePolicy({ identities: [{ ...basePolicy().identities[0]!, venueIds: [], id: "eng" }], defaultDmIdentity: "eng" }); - const result = routeMessage(db, clock, msg({ venueKind: "dm", venueId: "D1", mentionsBotId: false }), opts(db, { policy })); + const result = routeMessage(db, clock, msg({ venueKind: "dm", venueId: "D1", mentionsBotId: false }), opts({ policy })); expect(result.kind).toBe("addressed"); }); @@ -137,7 +137,7 @@ describe("routeMessage (SPEC §17.1, §10.5)", () => { test("a reply in a thread the agent already participates in is addressed without a fresh mention", () => { const db = freshDb(); const clock = fakeClock(); - const o = opts(db); + const o = opts(); const mention = routeMessage(db, clock, msg({ ts: "100.000", mentionsBotId: true }), o); expect(mention.kind).toBe("addressed"); @@ -148,7 +148,7 @@ describe("routeMessage (SPEC §17.1, §10.5)", () => { test("a reply in a thread the agent has NOT participated in is merely observed", () => { const db = freshDb(); const clock = fakeClock(); - const o = opts(db); + const o = opts(); const result = routeMessage(db, clock, msg({ ts: "200.000", threadRootTs: "199.000", mentionsBotId: false }), o); expect(result.kind).toBe("observed"); }); @@ -156,7 +156,7 @@ describe("routeMessage (SPEC §17.1, §10.5)", () => { test("an untrusted bot's mention is never addressed — only observed at most (SPEC §10.5)", () => { const db = freshDb(); const clock = fakeClock(); - const result = routeMessage(db, clock, msg({ isBot: true, principalId: "OTHERBOT", mentionsBotId: true }), opts(db)); + const result = routeMessage(db, clock, msg({ isBot: true, principalId: "OTHERBOT", mentionsBotId: true }), opts()); expect(result.kind).toBe("observed"); }); @@ -164,7 +164,7 @@ describe("routeMessage (SPEC §17.1, §10.5)", () => { const db = freshDb(); const clock = fakeClock(); const policy = basePolicy({ trustedBotPrincipals: ["OTHERBOT"] }); - const result = routeMessage(db, clock, msg({ isBot: true, principalId: "OTHERBOT", mentionsBotId: true }), opts(db, { policy })); + const result = routeMessage(db, clock, msg({ isBot: true, principalId: "OTHERBOT", mentionsBotId: true }), opts({ policy })); expect(result.kind).toBe("addressed"); }); @@ -172,7 +172,7 @@ describe("routeMessage (SPEC §17.1, §10.5)", () => { const db = freshDb(); const clock = fakeClock(); const policy = basePolicy({ defaultDmIdentity: "eng" }); - const result = routeMessage(db, clock, msg({ venueKind: "dm", venueId: "D1", isBot: true, principalId: "OTHERBOT" }), opts(db, { policy })); + const result = routeMessage(db, clock, msg({ venueKind: "dm", venueId: "D1", isBot: true, principalId: "OTHERBOT" }), opts({ policy })); expect(result.kind).toBe("observed"); }); @@ -181,14 +181,14 @@ describe("routeMessage (SPEC §17.1, §10.5)", () => { test("addressed events carry their address mode: mention, dm, or thread_follow", () => { const db = freshDb(); const clock = fakeClock(); - const o = opts(db); + const o = opts(); const mention = routeMessage(db, clock, msg({ ts: "300.000", mentionsBotId: true }), o); expect(mention.kind === "addressed" && mention.event.addressMode).toBe("mention"); const dmPolicy = basePolicy({ defaultDmIdentity: "eng" }); let m = 0; - const dm = routeMessage(db, clock, msg({ venueKind: "dm", venueId: "D1", ts: "301.000" }), opts(db, { policy: dmPolicy, newEventId: () => `dm${++m}` })); + const dm = routeMessage(db, clock, msg({ venueKind: "dm", venueId: "D1", ts: "301.000" }), opts({ policy: dmPolicy, newEventId: () => `dm${++m}` })); expect(dm.kind === "addressed" && dm.event.addressMode).toBe("dm"); const follow = routeMessage(db, clock, msg({ ts: "302.000", threadRootTs: "300.000", mentionsBotId: false, deliveryId: "d-follow" }), o); @@ -205,7 +205,7 @@ describe("routeMessage (SPEC §17.1, §10.5)", () => { const policy = basePolicy({ identities: [{ ...base, id: "eng", venueIds: ["*"] }] }); // an arbitrary channel the policy never named explicitly still routes to eng - const mention = routeMessage(db, clock, msg({ venueId: "C_RANDOM_9Z", mentionsBotId: true }), opts(db, { policy })); + const mention = routeMessage(db, clock, msg({ venueId: "C_RANDOM_9Z", mentionsBotId: true }), opts({ policy })); expect(mention.kind).toBe("addressed"); if (mention.kind === "addressed") expect(mention.event.identityId).toBe("eng"); }); @@ -220,7 +220,7 @@ describe("routeMessage (SPEC §17.1, §10.5)", () => { { ...engBase, id: "sales", venueIds: ["C_SALES"] }, ], }); - const result = routeMessage(db, clock, msg({ venueId: "C_SALES", mentionsBotId: true }), opts(db, { policy })); + const result = routeMessage(db, clock, msg({ venueId: "C_SALES", mentionsBotId: true }), opts({ policy })); expect(result.kind).toBe("addressed"); if (result.kind === "addressed") expect(result.event.identityId).toBe("sales"); // not the wildcard eng }); diff --git a/test/scheduler.test.ts b/test/scheduler.test.ts index 8206b5d..27a71c2 100644 --- a/test/scheduler.test.ts +++ b/test/scheduler.test.ts @@ -1,23 +1,14 @@ import { describe, expect, test } from "bun:test"; -import { openLedger, checkpointWal } from "../src/ledger/db"; +import { checkpointWal, many, one, openLedger } from "../src/ledger/db"; import { createTask, transition, getTask } from "../src/ledger/tasks"; import { fireDueTimers, dispatchRunnable, recoverFromRestart, scheduleDistillationTick, scheduleAmbientTick, msUntilNextTimer } from "../src/ledger/scheduler"; import type { Clock } from "../src/ledger/clock"; -import { tempDbPath, cleanupDbFile } from "./helpers"; +import { cleanupDbFile, fakeClock, tempDbPath } from "./helpers"; function freshDb() { return openLedger(":memory:"); } -function fakeClock(start = "2026-07-02T00:00:00Z"): Clock & { advance: (iso: string) => void } { - let now = start; - const clock = (() => now) as Clock & { advance: (iso: string) => void }; - clock.advance = (iso: string) => { - now = iso; - }; - return clock; -} - function seedEvent(db: ReturnType, id: string, clock: Clock, identityId = "eng") { db.query( "INSERT INTO events (id, dedup_key, kind, identity_id, received_at) VALUES (?, ?, 'addressed_message', ?, ?)", @@ -78,7 +69,7 @@ describe("fireDueTimers (SPEC §13)", () => { expect(task.waitingOn).toBe("human"); expect(task.wakeAt).toBe("2026-07-04T01:00:00.000Z"); - const parkTimers = db.query("SELECT due_at FROM timers WHERE subject_id = 'T-1' AND kind = 'park'").all() as any[]; + const parkTimers = many<{ due_at: string }>(db, "SELECT due_at FROM timers WHERE subject_id = 'T-1' AND kind = 'park'"); expect(parkTimers).toEqual([{ due_at: "2026-07-04T01:00:00.000Z" }]); }); @@ -188,7 +179,7 @@ describe("dispatchRunnable (SPEC §6.2, §17.3)", () => { newExecutionId: () => `x${++n}`, }); - expect(result.dispatched.sort()).toEqual(["T-1", "T-3"]); + expect(result.dispatched.toSorted()).toEqual(["T-1", "T-3"]); expect(result.deferredConcurrency).toEqual(["T-2"]); }); @@ -260,8 +251,8 @@ describe("recoverFromRestart (SPEC §14.2)", () => { const task = getTask(db, "T-1")!; expect(task.status).toBe("open"); expect(task.consecutiveInterruptions).toBe(1); - const exec = db.query("SELECT status FROM executions WHERE id = 'x1'").get() as any; - expect(exec.status).toBe("interrupted"); + const exec = one<{ status: string }>(db, "SELECT status FROM executions WHERE id = 'x1'"); + expect(exec?.status).toBe("interrupted"); }); test("exceeding the consecutive-interruption bound parks the task instead of churning", () => { @@ -314,8 +305,8 @@ describe("simulated process kill + restart (SPEC §14.2, real on-disk db)", () = expect(recovery.reopened).toEqual(["T-1"]); expect(getTask(db, "T-1")?.status).toBe("open"); - const exec = db.query("SELECT status FROM executions WHERE id = 'x1'").get() as any; - expect(exec.status).toBe("interrupted"); + const exec = one<{ status: string }>(db, "SELECT status FROM executions WHERE id = 'x1'"); + expect(exec?.status).toBe("interrupted"); db.close(); cleanupDbFile(path); @@ -361,7 +352,7 @@ describe("distillation timer cadence (SPEC §8.2)", () => { expect(notified).toEqual(["eng"]); // the next tick is already armed, one cadence out from firing (not from the original due date) - const rearmed = db.query("SELECT due_at FROM timers WHERE kind = 'distillation' AND fired_at IS NULL").all() as any[]; + const rearmed = many<{ due_at: string }>(db, "SELECT due_at FROM timers WHERE kind = 'distillation' AND fired_at IS NULL"); expect(rearmed).toEqual([{ due_at: "2026-07-04T00:00:00.000Z" }]); }); @@ -408,7 +399,7 @@ describe("ambient tick cadence (SPEC §9.1)", () => { expect(results).toEqual([{ timerId: "ambient_tick:eng:2026-07-02T00:30:00.000Z", kind: "ambient_tick", subjectId: null, applied: true }]); expect(notified).toEqual(["eng"]); - const rearmed = db.query("SELECT due_at FROM timers WHERE kind = 'ambient_tick' AND fired_at IS NULL").all() as any[]; + const rearmed = many<{ due_at: string }>(db, "SELECT due_at FROM timers WHERE kind = 'ambient_tick' AND fired_at IS NULL"); expect(rearmed).toEqual([{ due_at: "2026-07-02T01:00:00.000Z" }]); }); @@ -423,7 +414,7 @@ describe("ambient tick cadence (SPEC §9.1)", () => { scheduleDistillationTick(db, clock, "eng", 24 * 60 * 60 * 1000); scheduleDistillationTick(db, clock, "eng", 24 * 60 * 60 * 1000); - const pending = db.query("SELECT kind, due_at FROM timers WHERE fired_at IS NULL ORDER BY kind").all() as any[]; + const pending = many<{ kind: string; due_at: string }>(db, "SELECT kind, due_at FROM timers WHERE fired_at IS NULL ORDER BY kind"); expect(pending).toEqual([ { kind: "ambient_tick", due_at: "2026-07-02T00:30:00.000Z" }, // the original survives { kind: "distillation", due_at: "2026-07-03T00:10:00.000Z" }, @@ -438,7 +429,7 @@ describe("ambient tick cadence (SPEC §9.1)", () => { fireDueTimers(db, clock, { parkAfterMs: 172800000, ambientTickCadenceMs: 30 * 60 * 1000, onAmbientTickDue: () => {} }); scheduleAmbientTick(db, clock, "eng", 30 * 60 * 1000); // e.g. a concurrent restart re-arm - const pending = db.query("SELECT due_at FROM timers WHERE kind = 'ambient_tick' AND fired_at IS NULL").all() as any[]; + const pending = many<{ due_at: string }>(db, "SELECT due_at FROM timers WHERE kind = 'ambient_tick' AND fired_at IS NULL"); expect(pending).toEqual([{ due_at: "2026-07-02T01:00:00.000Z" }]); }); diff --git a/test/search.test.ts b/test/search.test.ts index 574ce8a..ef28192 100644 --- a/test/search.test.ts +++ b/test/search.test.ts @@ -6,6 +6,8 @@ import type { Clock } from "../src/ledger/clock"; import type { Database } from "bun:sqlite"; const clock: Clock = () => "2026-07-09T12:00:00Z"; +const oldClock: Clock = () => "2026-07-01T00:00:00Z"; +const nowClock: Clock = () => "2026-07-09T00:00:00Z"; let n = 0; function seedEvent(db: Database, identityId: string, text: string, over: Partial<{ venueId: string; principalId: string; receivedAt: string; ts: string }> = {}): void { @@ -43,7 +45,7 @@ describe("searchArchive (SPEC §8.7)", () => { retractMemory(db, clock, { id: dead.id }); const hits = searchArchive(db, "eng", { query: "export" }); - expect(hits.map((h) => h.memoryId).sort()).toEqual(["m1", "m2"]); + expect(hits.map((h) => h.memoryId ?? "").toSorted((a, b) => a.localeCompare(b))).toEqual(["m1", "m2"]); expect(hits.find((h) => h.memoryId === "m2")!.tier).toBe("archive"); }); @@ -117,13 +119,11 @@ describe("recent-tier decay (SPEC §8.6)", () => { test("stale recent items demote to archive; fresh ones and core items are untouched", async () => { const { decayRecentToArchive } = await import("../src/ledger/memory"); const db = openLedger(":memory:"); - const old: Clock = () => "2026-07-01T00:00:00Z"; - const now: Clock = () => "2026-07-09T00:00:00Z"; - writeMemory(db, old, { id: "stale", identityId: "eng", content: "overheard last week", tier: "recent" }); - writeMemory(db, now, { id: "fresh", identityId: "eng", content: "overheard today", tier: "recent" }); - writeMemory(db, old, { id: "durable", identityId: "eng", content: "an old core fact" }); + writeMemory(db, oldClock, { id: "stale", identityId: "eng", content: "overheard last week", tier: "recent" }); + writeMemory(db, nowClock, { id: "fresh", identityId: "eng", content: "overheard today", tier: "recent" }); + writeMemory(db, oldClock, { id: "durable", identityId: "eng", content: "an old core fact" }); - const demoted = decayRecentToArchive(db, now, "eng", 7 * 24 * 60 * 60 * 1000); + const demoted = decayRecentToArchive(db, nowClock, "eng", 7 * 24 * 60 * 60 * 1000); expect(demoted).toEqual(["stale"]); expect(queryMemory(db, "eng", { tier: "archive" }).map((m) => m.id)).toEqual(["stale"]); // demoted, still searchable expect(queryMemory(db, "eng", { tier: "recent" }).map((m) => m.id)).toEqual(["fresh"]); diff --git a/test/service.test.ts b/test/service.test.ts index b5993e3..4f2df9a 100644 --- a/test/service.test.ts +++ b/test/service.test.ts @@ -1,24 +1,14 @@ import { describe, expect, test } from "bun:test"; -import { openLedger } from "../src/ledger/db"; +import { one, openLedger } from "../src/ledger/db"; import { createTask, transition, getTask } from "../src/ledger/tasks"; import { PolicyStore } from "../src/policy/load"; import { Service } from "../src/service"; import { FakeAdapter } from "./fakes/fake-adapter"; import { FakeAgentRuntimeSession } from "./fakes/fake-runtime-session"; -import { firstRef, refIn } from "./helpers"; +import { fakeClock, firstRef, refIn } from "./helpers"; import type { AgentRuntimeSession, DynamicTool } from "../src/turn-runner/types"; -import type { Clock } from "../src/ledger/clock"; import type { RawMessage } from "@bevyl-ai/agent-tools"; -function fakeClock(start = "2026-07-02T00:00:00Z"): Clock & { set: (iso: string) => void } { - let now = start; - const clock = (() => now) as Clock & { set: (iso: string) => void }; - clock.set = (iso: string) => { - now = iso; - }; - return clock; -} - const POLICY_YAML = ` surface: kind: slack @@ -172,7 +162,6 @@ describe("Service inbound (SPEC §5, §17.1)", () => { sessionFactory: (tools) => { const sess = new FakeAgentRuntimeSession(tools, async () => {}); prompts.push = prompts.push.bind(prompts); - (sess as any).onPrompt = undefined; return sess; }, }); @@ -322,8 +311,8 @@ describe("Service dispatch driver (SPEC §6.2, §17.3, §17.4)", () => { await service.tick(); // At most 2 running immediately after the tick (cap=2). - const runningNow = db.query("SELECT COUNT(*) as c FROM executions WHERE status = 'running'").get() as { c: number }; - expect(runningNow.c).toBe(2); + const runningNow = one<{ c: number }>(db, "SELECT COUNT(*) as c FROM executions WHERE status = 'running'"); + expect(runningNow?.c).toBe(2); await service.idle(); await service.tick(); // third dispatches now that slots freed @@ -392,7 +381,7 @@ describe("Service workers report to the mind (2026-07-13)", () => { // the worker acts, the first mind wake delegates, later mind wakes run the report branch. let delegated = false; const sessions: FakeAgentRuntimeSession[] = []; - const overridesByKind: { kind: "ear" | "worker" | "mind"; overrides?: { model?: string; effort?: string } }[] = []; + const overridesByKind: { kind: "ear" | "worker" | "mind"; overrides?: { model?: string; effort?: string } | undefined }[] = []; const made = makeService({ sessionFactory: (tools, _onEvent, overrides) => { const kind = tools.some((x) => x.spec.name === "verdict") ? "ear" : tools.some((x) => x.spec.name === "task_complete") ? "worker" : "mind"; @@ -471,8 +460,10 @@ describe("Service workers report to the mind (2026-07-13)", () => { }); transition(db, () => "2026-07-02T00:00:00Z", taskId, "parked", { type: "paused" }); }; - const report = (taskId: string) => - (service as unknown as { deliverWorkerReport(taskId: string, outcome: string): void }).deliverWorkerReport(taskId, "parked"); + const report = (taskId: string) => { + const fn = Reflect.get(service, "deliverWorkerReport"); + if (typeof fn === "function") fn.call(service, taskId, "parked"); + }; await service.start(); const minds = () => sessions.filter((s) => s.hasTool("reply")); diff --git a/test/slack-tools.test.ts b/test/slack-tools.test.ts index 29c2f07..fcbac4b 100644 --- a/test/slack-tools.test.ts +++ b/test/slack-tools.test.ts @@ -2,7 +2,8 @@ import { describe, expect, test } from "bun:test"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { slackRegistry, SLACK_TOOL_NAMES, type SlackToolDeps } from "../src/tools/slack"; +import { slackRegistry, SLACK_TOOL_NAMES, type SlackFetch, type SlackToolDeps } from "../src/tools/slack"; +import { isRecord } from "../src/guard"; // A registry wired to fakes: no network, no Slack. `calls` records every Web API method hit so // tests assert the exact wire conversation; `responses` scripts what Slack answers. @@ -14,8 +15,7 @@ function makeRegistry(opts: { const workspace = mkdtempSync(join(tmpdir(), "earshot-slack-tools-")); const calls: { url: string; body?: unknown; contentType?: string }[] = []; const responses = new Map(Object.entries(opts.responses ?? {})); - const fakeFetch = (async (url: unknown, init?: { body?: unknown; headers?: Record }) => { - const u = String(url); + const fakeFetch: SlackFetch = async (url, init) => { const raw = init?.body; const body = typeof raw === "string" @@ -23,12 +23,12 @@ function makeRegistry(opts: { ? JSON.parse(raw) : Object.fromEntries(new URLSearchParams(raw)) : raw; - calls.push({ url: u, body, ...(init?.headers?.["Content-Type"] ? { contentType: init.headers["Content-Type"] } : {}) }); - const method = u.startsWith("https://slack.com/api/") ? u.slice("https://slack.com/api/".length) : u; + calls.push({ url, body, ...(init?.headers?.["Content-Type"] ? { contentType: init.headers["Content-Type"] } : {}) }); + const method = url.startsWith("https://slack.com/api/") ? url.slice("https://slack.com/api/".length) : url; const queued = responses.get(method); const payload = queued?.shift() ?? { ok: true }; return { ok: true, status: 200, json: async () => payload }; - }) as unknown as typeof fetch; + }; const deps: SlackToolDeps = { readHistory: async () => [{ text: "root" }], readThread: async () => [{ text: "reply" }], @@ -44,7 +44,7 @@ function makeRegistry(opts: { describe("slack registry shape", () => { test("SLACK_TOOL_NAMES matches the registry's tools exactly (KNOWN_TOOLS derives from it)", () => { const { registry } = makeRegistry({}); - expect(Object.keys(registry.tools).sort()).toEqual([...SLACK_TOOL_NAMES].sort()); + expect(Object.keys(registry.tools).toSorted()).toEqual([...SLACK_TOOL_NAMES].toSorted()); }); test("every example names a tool in the registry", () => { @@ -98,7 +98,8 @@ describe("upload_file", () => { const result = await registry.tools.upload_file!.run!({ path: "out.png", venueId: "C9", threadRootId: "17.001", title: "cleaned" }); expect(result.success).toBe(true); expect(result.output).toContain("<#C9>"); - const complete = calls.find((c) => c.url.endsWith("files.completeUploadExternal"))!.body as Record; + const complete = calls.find((c) => c.url.endsWith("files.completeUploadExternal"))!.body; + if (!isRecord(complete)) throw new Error("expected completeUploadExternal body"); expect(complete.channel_id).toBe("C9"); expect(complete.thread_ts).toBe("17.001"); expect(complete.files).toEqual([{ id: "F123", title: "cleaned" }]); @@ -146,7 +147,9 @@ describe("emoji_set", () => { const { registry, calls } = makeRegistry({ adminToken: "xoxp-admin", responses: { "admin.emoji.add": [{ ok: true }] } }); const result = await registry.tools.emoji_set!.run!({ name: ":Anya:", url: "https://files.slack.com/f/a.png" }); expect(result.success).toBe(true); - expect((calls[0]!.body as Record).name).toBe("anya"); + const added = calls[0]!.body; + if (!isRecord(added)) throw new Error("expected admin.emoji.add body"); + expect(added.name).toBe("anya"); }); test("an existing emoji is replaced: remove then re-add under the same name", async () => { diff --git a/test/tasks.test.ts b/test/tasks.test.ts index c72f372..6174c40 100644 --- a/test/tasks.test.ts +++ b/test/tasks.test.ts @@ -1,5 +1,6 @@ +import { fakeClock } from "./helpers"; import { describe, expect, test } from "bun:test"; -import { openLedger } from "../src/ledger/db"; +import { many, one, openLedger } from "../src/ledger/db"; import { createTask, transition, @@ -17,21 +18,16 @@ function freshDb() { return openLedger(":memory:"); } -function fakeClock(start = "2026-07-02T00:00:00Z"): Clock & { advance: (isoOrMs: string) => void } { - let now = start; - const clock = (() => now) as Clock & { advance: (iso: string) => void }; - clock.advance = (iso: string) => { - now = iso; - }; - return clock; -} - function seedEvent(db: ReturnType, id: string, clock: Clock) { db.query( "INSERT INTO events (id, dedup_key, kind, identity_id, received_at) VALUES (?, ?, 'addressed_message', 'eng', ?)", ).run(id, `k-${id}`, clock()); } +function dispatched(db: ReturnType, clock: Clock, executionId: string) { + transition(db, clock, "T-1", "active", { type: "dispatch", executionId }); +} + function baseTaskParams(overrides: Partial[2]> = {}) { return { id: "T-1", @@ -57,9 +53,9 @@ describe("createTask (SPEC §4.1.7, §6.1)", () => { expect(task.waitingOn).toBeNull(); expect(task.openedAt).toBe("2026-07-02T00:00:00Z"); - const audit = db.query("SELECT kind, payload FROM audit WHERE kind = 'task_created'").all() as any[]; + const audit = many<{ kind: string; payload: string }>(db, "SELECT kind, payload FROM audit WHERE kind = 'task_created'"); expect(audit).toHaveLength(1); - expect(JSON.parse(audit[0].payload).taskId).toBe("T-1"); + expect(JSON.parse(audit[0]!.payload).taskId).toBe("T-1"); }); test("rejects a recurrence from a non-operator sponsor (SPEC §6.5)", () => { @@ -92,9 +88,9 @@ describe("dispatch: open -> active (SPEC §6.2)", () => { const task = transition(db, clock, "T-1", "active", { type: "dispatch", executionId: "x1" }); expect(task.status).toBe("active"); - const exec = db.query("SELECT status, attempt FROM executions WHERE id = 'x1'").get() as any; - expect(exec.status).toBe("running"); - expect(exec.attempt).toBe(1); + const exec = one<{ status: string; attempt: number }>(db, "SELECT status, attempt FROM executions WHERE id = 'x1'"); + expect(exec?.status).toBe("running"); + expect(exec?.attempt).toBe(1); }); test("a second concurrent dispatch attempt on the same task is rejected", () => { @@ -140,8 +136,8 @@ describe("waiting(human) -> nudge -> parked -> revived (SPEC §6.1)", () => { expect(task.waitingOn).toBe("human"); expect(task.wakeAt).toBe("2026-07-02T01:00:00Z"); - const exec = db.query("SELECT status FROM executions WHERE id = 'x1'").get() as any; - expect(exec.status).toBe("yielded"); + const exec = one<{ status: string }>(db, "SELECT status FROM executions WHERE id = 'x1'"); + expect(exec?.status).toBe("yielded"); }); test("nudge fires: re-arms wake_at for the park deadline silently, status unchanged", () => { @@ -261,8 +257,8 @@ describe("cancel is reachable from every non-terminal state (SPEC §6.1, §6.4)" expect(result.applied).toBe(true); expect(result.task.status).toBe("cancelled"); - const exec = db.query("SELECT status FROM executions WHERE id = 'x1'").get() as any; - expect(exec.status).toBe("cancelled"); + const exec = one<{ status: string }>(db, "SELECT status FROM executions WHERE id = 'x1'"); + expect(exec?.status).toBe("cancelled"); const queued = consumeSteering(db, clock, "T-1"); expect(queued.map((s) => s.kind)).toContain("cancel"); @@ -292,8 +288,8 @@ describe("terminal transitions (SPEC §6.1 no dangling threads)", () => { const task = transition(db, clock, "T-1", "done", { type: "completed", report: "fixed the slow query" }); expect(task.terminalReport).toBe("fixed the slow query"); - const exec = db.query("SELECT status FROM executions WHERE id = 'x1'").get() as any; - expect(exec.status).toBe("succeeded"); + const exec = one<{ status: string }>(db, "SELECT status FROM executions WHERE id = 'x1'"); + expect(exec?.status).toBe("succeeded"); }); test("failed records an honest failure report and marks the execution failed", () => { @@ -307,8 +303,8 @@ describe("terminal transitions (SPEC §6.1 no dangling threads)", () => { expect(task.status).toBe("failed"); expect(task.terminalReport).toBe("could not reach the DB"); - const exec = db.query("SELECT status FROM executions WHERE id = 'x1'").get() as any; - expect(exec.status).toBe("failed"); + const exec = one<{ status: string }>(db, "SELECT status FROM executions WHERE id = 'x1'"); + expect(exec?.status).toBe("failed"); }); test("steering after a terminal transition returns a visible reply instead of a silent drop", () => { @@ -393,14 +389,14 @@ describe("steering (SPEC §6.4)", () => { sourceEventId: "e2", }); - const before = db.query("SELECT consumed_at FROM steering WHERE task_id = 'T-1'").all() as any[]; + const before = many<{ consumed_at: string | null }>(db, "SELECT consumed_at FROM steering WHERE task_id = 'T-1'"); expect(before[0]?.consumed_at).toBeNull(); const queued = consumeSteering(db, clock, "T-1"); expect(queued).toHaveLength(1); expect(queued[0]?.kind).toBe("guidance"); - const after = db.query("SELECT consumed_at FROM steering WHERE task_id = 'T-1'").all() as any[]; + const after = many<{ consumed_at: string | null }>(db, "SELECT consumed_at FROM steering WHERE task_id = 'T-1'"); expect(after[0]?.consumed_at).not.toBeNull(); }); @@ -575,8 +571,8 @@ describe("standing tasks (SPEC §6.5)", () => { expect(task.waitingOn).toBe("timer"); expect(task.wakeAt).toBe("2026-07-09T00:00:00Z"); expect(task.recurrence).toBe("weekly"); - const exec = db.query("SELECT status FROM executions WHERE id = 'x1'").get() as any; - expect(exec.status).toBe("succeeded"); + const exec = one<{ status: string }>(db, "SELECT status FROM executions WHERE id = 'x1'"); + expect(exec?.status).toBe("succeeded"); }); test("a failing firing re-arms instead of failing the task (failure carve-out)", () => { @@ -591,8 +587,8 @@ describe("standing tasks (SPEC §6.5)", () => { expect(task.status).toBe("waiting"); expect(task.recurrence).toBe("weekly"); - const exec = db.query("SELECT status FROM executions WHERE id = 'x1'").get() as any; - expect(exec.status).toBe("failed"); + const exec = one<{ status: string }>(db, "SELECT status FROM executions WHERE id = 'x1'"); + expect(exec?.status).toBe("failed"); }); test("recurrence_rearm on a non-standing task is illegal", () => { @@ -642,10 +638,6 @@ describe("opened_at refreshes on every re-entry to open (SPEC §6.2 dispatch ord }); describe("consecutive interruptions and crash-loop parking (SPEC §14.2)", () => { - function dispatched(db: ReturnType, clock: Clock, executionId: string) { - transition(db, clock, "T-1", "active", { type: "dispatch", executionId }); - } - test("interrupted increments the counter; a normal yield resets it", () => { const db = freshDb(); const clock = fakeClock(); @@ -676,7 +668,7 @@ describe("consecutive interruptions and crash-loop parking (SPEC §14.2)", () => expect(task.status).toBe("parked"); expect(task.consecutiveInterruptions).toBe(0); - const exec = db.query("SELECT status FROM executions WHERE id = 'x1'").get() as any; - expect(exec.status).toBe("interrupted"); + const exec = one<{ status: string }>(db, "SELECT status FROM executions WHERE id = 'x1'"); + expect(exec?.status).toBe("interrupted"); }); }); diff --git a/test/timers.test.ts b/test/timers.test.ts index 447abe1..b799f82 100644 --- a/test/timers.test.ts +++ b/test/timers.test.ts @@ -1,5 +1,6 @@ +import { fakeClock } from "./helpers"; import { describe, expect, test } from "bun:test"; -import { openLedger } from "../src/ledger/db"; +import { many, one, openLedger } from "../src/ledger/db"; import { createTask, transition } from "../src/ledger/tasks"; import { scheduleTimer, listDueTimers, markTimerFired } from "../src/ledger/timers"; import type { Clock } from "../src/ledger/clock"; @@ -8,15 +9,6 @@ function freshDb() { return openLedger(":memory:"); } -function fakeClock(start = "2026-07-02T00:00:00Z"): Clock & { advance: (iso: string) => void } { - let now = start; - const clock = (() => now) as Clock & { advance: (iso: string) => void }; - clock.advance = (iso: string) => { - now = iso; - }; - return clock; -} - function seedEvent(db: ReturnType, id: string, clock: Clock) { db.query( "INSERT INTO events (id, dedup_key, kind, identity_id, received_at) VALUES (?, ?, 'addressed_message', 'eng', ?)", @@ -53,8 +45,8 @@ describe("timers table mechanics (SPEC §13)", () => { scheduleTimer(db, { id: "t1", kind: "ambient_tick", identityId: "eng", dueAt: "2026-07-02T00:00:00Z" }), ).not.toThrow(); - const rows = db.query("SELECT COUNT(*) as c FROM timers WHERE id = 't1'").get() as any; - expect(rows.c).toBe(1); + const rows = one<{ c: number }>(db, "SELECT COUNT(*) as c FROM timers WHERE id = 't1'"); + expect(rows?.c).toBe(1); }); test("overdue-on-restart: timers well past due still fire, in due-time order", () => { @@ -93,7 +85,7 @@ describe("transition() schedules the matching durable timer (SPEC §13, §6.1)", nudgeDeadline: "2026-07-02T01:00:00Z", }); - const rows = db.query("SELECT kind, subject_id, due_at FROM timers WHERE subject_id = 'T-1'").all() as any[]; + const rows = many<{ kind: string; subject_id: string; due_at: string }>(db, "SELECT kind, subject_id, due_at FROM timers WHERE subject_id = 'T-1'"); expect(rows).toEqual([{ kind: "nudge", subject_id: "T-1", due_at: "2026-07-02T01:00:00Z" }]); }); @@ -111,7 +103,7 @@ describe("transition() schedules the matching durable timer (SPEC §13, §6.1)", parkDeadline: "2026-07-04T01:00:00Z", }); - const rows = db.query("SELECT kind, due_at FROM timers WHERE subject_id = 'T-1' AND kind = 'park'").all() as any[]; + const rows = many<{ kind: string; due_at: string }>(db, "SELECT kind, due_at FROM timers WHERE subject_id = 'T-1' AND kind = 'park'"); expect(rows).toEqual([{ kind: "park", due_at: "2026-07-04T01:00:00Z" }]); }); @@ -122,9 +114,10 @@ describe("transition() schedules the matching durable timer (SPEC §13, §6.1)", transition(db, clock, "T-1", "waiting", { type: "yield_timer", wakeAt: "2026-07-05T00:00:00Z" }); - const rows = db - .query("SELECT kind, due_at FROM timers WHERE subject_id = 'T-1' AND kind = 'task_wake'") - .all() as any[]; + const rows = many<{ kind: string; due_at: string }>( + db, + "SELECT kind, due_at FROM timers WHERE subject_id = 'T-1' AND kind = 'task_wake'", + ); expect(rows).toEqual([{ kind: "task_wake", due_at: "2026-07-05T00:00:00Z" }]); }); @@ -150,9 +143,10 @@ describe("transition() schedules the matching durable timer (SPEC §13, §6.1)", wakeAt: "2026-07-09T00:00:00Z", }); - const rows = db - .query("SELECT kind, due_at FROM timers WHERE subject_id = 'T-1' AND kind = 'task_wake'") - .all() as any[]; + const rows = many<{ kind: string; due_at: string }>( + db, + "SELECT kind, due_at FROM timers WHERE subject_id = 'T-1' AND kind = 'task_wake'", + ); expect(rows).toEqual([{ kind: "task_wake", due_at: "2026-07-09T00:00:00Z" }]); }); }); diff --git a/test/toolset.test.ts b/test/toolset.test.ts index bcf61f5..e0c37cc 100644 --- a/test/toolset.test.ts +++ b/test/toolset.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { openLedger } from "../src/ledger/db"; +import { one, openLedger } from "../src/ledger/db"; import { queryMemory } from "../src/ledger/memory"; import { getTask, transition } from "../src/ledger/tasks"; import { makeRefTable } from "../src/ledger/conversations"; @@ -65,9 +65,9 @@ function baseCtx(db: ReturnType, clock: Clock, overrides: Par } function tool(tools: ReturnType, name: string) { - const t = tools.find((t) => t.spec.name === name); - if (!t) throw new Error(`no such tool: ${name}`); - return t; + const found = tools.find((candidate) => candidate.spec.name === name); + if (!found) throw new Error(`no such tool: ${name}`); + return found; } describe("task_create (SPEC §5.3, §11)", () => { @@ -108,8 +108,8 @@ describe("task_create (SPEC §5.3, §11)", () => { expect(JSON.stringify(create.spec.inputSchema)).not.toContain("recurrence"); const result = await create.run({ title: "t", spec: "s", ref: "r1", recurrence: "every day" }); expect(result.success).toBe(true); // the stray arg is ignored, never stored - const row = db.query("SELECT recurrence FROM tasks WHERE id = 'T-1'").get() as { recurrence: string | null }; - expect(row.recurrence).toBeNull(); + const row = one<{ recurrence: string | null }>(db, "SELECT recurrence FROM tasks WHERE id = 'T-1'"); + expect(row?.recurrence).toBeNull(); }); }); @@ -221,11 +221,12 @@ describe("task_query returns the identity's ledger view", () => { } }); +function seededRefs(targets: Parameters["mint"]>[0][]): { refs: ReturnType; minted: string[] } { + const refs = makeRefTable(); + return { refs, minted: targets.map((t) => refs.mint(t)) }; +} + describe("reply posting-scope rule (SPEC §11) — addressing as refs", () => { - function seededRefs(targets: Parameters["mint"]>[0][]): { refs: ReturnType; minted: string[] } { - const refs = makeRefTable(); - return { refs, minted: targets.map((t) => refs.mint(t)) }; - } test("resident wakes may post to any venue the identity serves", async () => { const db = freshDb(); @@ -535,7 +536,7 @@ describe("memory tools (SPEC §8, §7.1 isolation)", () => { test("memory_retract cannot retract another identity's item, even by guessing its id", async () => { const db = freshDb(); const clock = fakeClock(); - const { writeMemory, queryMemory } = await import("../src/ledger/memory"); + const { writeMemory, queryMemory: memoryOf } = await import("../src/ledger/memory"); writeMemory(db, clock, { id: "finance-secret", identityId: "finance", content: "confidential roadmap" }); const ctx = baseCtx(db, clock, { identity: identity({ id: "eng" }) }); @@ -543,7 +544,7 @@ describe("memory tools (SPEC §8, §7.1 isolation)", () => { expect(result.success).toBe(false); expect(result.output).toContain("not_found"); - expect(queryMemory(db, "finance").map((i) => i.id)).toEqual(["finance-secret"]); + expect(memoryOf(db, "finance").map((i) => i.id)).toEqual(["finance-secret"]); }); @@ -612,7 +613,7 @@ describe("toolbox digest covers the built toolset", () => { }); const tools = buildToolset(ctx); const tb = buildToolbox(tools, BUILTIN_REGISTRIES); - expect(tb.flatMap((g) => g.tools.map((t) => t.name)).sort()).toEqual(tools.map((t) => t.spec.name).sort()); + expect(tb.flatMap((g) => g.tools.map((t) => t.name)).toSorted()).toEqual(tools.map((t) => t.spec.name).toSorted()); const named = new Set(BUILTIN_REGISTRIES.map((r) => r.name)); for (const g of tb) expect(named.has(g.registry)).toBe(true); }); @@ -630,7 +631,7 @@ describe("toolbox digest covers the built toolset", () => { expect(linear.tools.map((t) => t.name)).toEqual(["linear_read"]); expect(linear.skill!.length).toBeGreaterThan(0); expect(linear.examples!.every((e) => e.tool === "linear_read")).toBe(true); - expect(tb.flatMap((g) => g.tools.map((t) => t.name)).sort()).toEqual(tools.map((t) => t.spec.name).sort()); + expect(tb.flatMap((g) => g.tools.map((t) => t.name)).toSorted()).toEqual(tools.map((t) => t.spec.name).toSorted()); }); }); @@ -723,20 +724,18 @@ describe("duplicate outward calls (one wake, one write)", () => { }); describe("outward-call idempotency is durable (ladder audit)", () => { - const CATALOG = { - linear_write: { - description: "write to linear", - actionClasses: () => ["outward"], - run: undefined as unknown as (args: unknown) => Promise<{ success: boolean; output: string }>, - }, - }; function outwardCtx(db: ReturnType, clock: Clock, impl: (args: unknown) => Promise<{ success: boolean; output: string }>) { - (CATALOG.linear_write as { run: unknown }).run = impl; return baseCtx(db, clock, { - turnKind: "execution_step" as const, + turnKind: "execution_step", taskId: "T-1", - catalog: CATALOG as never, - identity: { ...identity(), grants: [{ tool: "linear_write", preauthorizedActionClasses: ["outward"] }] } as never, + catalog: { + linear_write: { + description: "write to linear", + actionClasses: () => ["outward"], + run: impl, + }, + }, + identity: { ...identity(), grants: [{ tool: "linear_write", preauthorizedActionClasses: ["outward"] }] }, }); } @@ -787,8 +786,8 @@ describe("linear_write mutation scoping (ladder: blast radius as configuration)" }); test("the grant's allowlist refuses an unlisted operation before any call, and passes listed ones", async () => { - const { integrationCatalog } = require("../src/tools/catalog"); - const check = integrationCatalog().linear_write.scopeCheck!; + const { integrationCatalog: catalogOf } = require("../src/tools/catalog"); + const check = catalogOf().linear_write.scopeCheck!; const scope = { mutations: ["commentCreate", "issueCreate", "issueUpdate", "attachmentCreate"] }; expect(check(scope, { query: "mutation($i: X!) { commentCreate(input: $i) { success } }" })).toBeNull(); const denied = check(scope, { query: "mutation { issueDelete(id: \"x\") { success } }" }); diff --git a/test/turn.test.ts b/test/turn.test.ts index 1d13560..a40027d 100644 --- a/test/turn.test.ts +++ b/test/turn.test.ts @@ -1,23 +1,14 @@ +import { fakeClock } from "./helpers"; import { describe, expect, test } from "bun:test"; import { openLedger } from "../src/ledger/db"; import { getTurn } from "../src/ledger/turns"; import { runTurn } from "../src/turn-runner/turn"; import { FakeAgentRuntimeSession } from "./fakes/fake-runtime-session"; -import type { Clock } from "../src/ledger/clock"; function freshDb() { return openLedger(":memory:"); } -function fakeClock(start = "2026-07-02T00:00:00Z"): Clock & { advance: (iso: string) => void } { - let now = start; - const clock = (() => now) as Clock & { advance: (iso: string) => void }; - clock.advance = (iso: string) => { - now = iso; - }; - return clock; -} - describe("runTurn (SPEC §4.1.6 turn envelope, §11)", () => { test("a normal turn records succeeded with its effects and spend", async () => { const db = freshDb(); diff --git a/tsconfig.json b/tsconfig.json index a2291d5..8fbcc24 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,14 @@ "types": ["bun"], "strict": true, "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, "noEmit": true, "skipLibCheck": true },