Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ jobs:
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bun run typecheck
- run: bun run lint
- run: bun test
49 changes: 46 additions & 3 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,55 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "unicorn", "oxc", "import", "promise"],
"plugins": ["typescript", "unicorn", "oxc", "import", "promise", "node"],
"categories": {
"correctness": "error",
"suspicious": "error"
"suspicious": "error",
"perf": "error",
"pedantic": "error"
},
"options": {
"typeAware": true
},
"ignorePatterns": ["node_modules", "scripts"]
"ignorePatterns": ["node_modules", "scripts"],
"rules": {
"no-await-in-loop": "off",
"typescript/prefer-readonly-parameter-types": "off",
"no-inline-comments": "off",
"typescript/strict-boolean-expressions": "off",
"require-await": "off",
"typescript/require-await": "off",
"max-lines-per-function": "off",
"max-lines": "off",
"max-depth": "off",
"max-classes-per-file": "off",
"import/max-dependencies": "off",
"require-unicode-regexp": "off",
"typescript/no-explicit-any": "error",
"typescript/use-unknown-in-catch-callback-variable": "error",
"typescript/no-require-imports": "error",
"typescript/no-var-requires": "error",
"import/no-cycle": "error",
"eqeqeq": ["error", "always", { "null": "ignore" }],
"unicorn/prefer-node-protocol": "error",
"no-negated-condition": "off",
"unicorn/no-negated-condition": "off",
"unicorn/prefer-top-level-await": "off"
},
"overrides": [
{
"files": ["test/**"],
"rules": {
"typescript/no-unsafe-assignment": "off",
"typescript/no-unsafe-member-access": "off",
"typescript/no-unsafe-call": "off",
"typescript/no-unsafe-argument": "off",
"typescript/no-unsafe-return": "off",
"typescript/no-explicit-any": "off",
"typescript/no-confusing-void-expression": "off",
"typescript/strict-void-return": "off",
"no-promise-executor-return": "off",
"no-useless-return": "off"
}
}
]
}
4 changes: 3 additions & 1 deletion src/adapter/outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ export interface RetryOpts {
}

function defaultSleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}

export async function deliverPost(post: () => Promise<PostResult>, opts: RetryOpts): Promise<PostResult | null> {
Expand Down
8 changes: 6 additions & 2 deletions src/adapter/reply-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ export class ReplyStream {
for (const piece of pieces) {
await this.opts.adapter
.appendStream!(this.opts.venueId, m.messageId, piece)
.catch((e) => this.opts.log.warn("appendStream failed", { venueId: this.opts.venueId, error: String(e) }));
.catch((e: unknown) => {
this.opts.log.warn("appendStream failed", { venueId: this.opts.venueId, error: String(e) });
});
}
return m.messageId;
});
Expand Down Expand Up @@ -154,7 +156,9 @@ export class ReplyStream {
for (const [i, item] of this.cards.entries()) {
await adapter
.appendTaskUpdate(venueId, messageId, { id: `item-${i}`, title: item.text.slice(0, 250), status: item.done ? "complete" : "pending" })
.catch((e) => log.warn("checklist card failed", { venueId, error: String(e) }));
.catch((e: unknown) => {
log.warn("checklist card failed", { venueId, error: String(e) });
});
}
}
}
6 changes: 3 additions & 3 deletions src/ledger/conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ function parseFiles(v: unknown): InboxMessage["files"] {
...(typeof item.size === "number" ? { size: item.size } : {}),
});
}
return files.length ? files : undefined;
return files.length > 0 ? files : undefined;
}

function payloadOf(raw: unknown): {
Expand Down Expand Up @@ -845,7 +845,7 @@ export function renderConversation(db: Database, identityId: string, key: Conver
...(lastNew ? { eventId: lastNew.id, principalId: lastNew.principalId } : {}),
});
const address = cref ? `${cref} ${where}` : where;
const header = headerBits.length || cref ? `[${address}${headerBits.length ? `: ${headerBits.join(" | ")}` : ""}]\n` : "";
const header = headerBits.length > 0 || cref ? `[${address}${headerBits.length > 0 ? `: ${headerBits.join(" | ")}` : ""}]\n` : "";
const tag = (surfaceTs: string | null, eventId?: string, principalId?: string | null): string => {
if (!opts.refs || !surfaceTs) return "";
return `[${opts.refs.mint({
Expand All @@ -858,7 +858,7 @@ export function renderConversation(db: Database, identityId: string, key: Conver
})}] `;
};
const tail = tailOf(db, identityId, key, opts.beforeRowid, selfLabel);
const tailBlock = tail.length
const tailBlock = tail.length > 0
? `earlier in ${where} (already heard — so you can tell who is talking to whom):\n${tail.map((t) => ` ${tag(t.surfaceTs, t.eventId, t.principalId)}${t.line}`).join("\n")}\n`
: "";
const newLines = opts.newMessages.map((m) => `${tag(m.ts, m.id, m.principalId)}${mark(m)}${inboxLine(m)}`).join("\n");
Expand Down
15 changes: 8 additions & 7 deletions src/ledger/db.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { readFileSync } from "node:fs";
import { Database, type SQLQueryBindings } from "bun:sqlite";
import { drizzle, type BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
import * as schema from "./schema";
Expand Down Expand Up @@ -306,13 +307,13 @@ const MIGRATIONS: Record<number, string> = {

export function openLedger(path: string): Database {
const db = new Database(path, { create: true });
db.exec("PRAGMA journal_mode = WAL");
db.exec("PRAGMA foreign_keys = ON");
db.run("PRAGMA journal_mode = WAL");
db.run("PRAGMA foreign_keys = ON");

// The ladder must run BEFORE schema.sql on an existing database: schema.sql declares the
// 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)");
db.run("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT 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})`);
Expand All @@ -324,26 +325,26 @@ export function openLedger(path: string): Database {
// One step, one transaction, one version bump: a crash mid-ladder resumes at the failed
// step on the next boot instead of wedging on non-idempotent DDL.
db.transaction(() => {
db.exec(migration);
db.run(migration);
db.query("UPDATE schema_version SET version = ?").run(v);
})();
}
}

db.exec(schemaSql());
db.run(schemaSql());
if (row === null) db.query("INSERT INTO schema_version (version) VALUES (?)").run(SCHEMA_VERSION);
return db;
}

function schemaSql(): string {
const url = new URL("./schema.sql", import.meta.url);
return require("fs").readFileSync(url, "utf8");
return readFileSync(url, "utf8");
}

// M9: a database under WAL for weeks accumulates a growing -wal file if it's never checkpointed
// (the service is a long-lived single writer, so auto-checkpoint on connection close never fires).
// TRUNCATE folds the WAL back into the main db and shrinks the -wal file. Safe to call
// periodically on a low-frequency timer; a no-op on :memory: databases.
export function checkpointWal(db: Database): void {
db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
db.run("PRAGMA wal_checkpoint(TRUNCATE)");
}
11 changes: 6 additions & 5 deletions src/ledger/inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ function parseFiles(v: unknown): InboxMessage["files"] {
...(typeof item.size === "number" ? { size: item.size } : {}),
});
}
return files.length ? files : undefined;
return files.length > 0 ? files : undefined;
}

export function messagesAfter(db: Database, identityId: string, afterRowid: number, limit = 200): InboxMessage[] {
Expand Down Expand Up @@ -78,7 +78,7 @@ export function messagesAfter(db: Database, identityId: string, afterRowid: numb
const p = isRecord(r.payload) ? r.payload : {};
const addressMode = asAddressMode(p.addressMode);
const files = parseFiles(p.files);
return {
const msg: InboxMessage = {
rowid: r.rowid,
id: r.id,
kind: asInboxKind(r.kind),
Expand All @@ -88,10 +88,11 @@ export function messagesAfter(db: Database, identityId: string, afterRowid: numb
text: asString(p.text),
ts: typeof p.ts === "string" ? p.ts : null,
receivedAt: r.receivedAt,
...(typeof p.principalName === "string" ? { principalName: p.principalName } : {}),
...(addressMode ? { addressMode } : {}),
...(files?.length ? { files } : {}),
};
if (typeof p.principalName === "string") msg.principalName = p.principalName;
if (addressMode) msg.addressMode = addressMode;
if (files && files.length > 0) msg.files = files;
return msg;
});
}

12 changes: 6 additions & 6 deletions src/ledger/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ export function ledgerView(db: Database, identityId: string, recentTerminalsLimi
.orderBy(desc(tasks.updatedAt))
.limit(recentTerminalsLimit)
.all();
return { open: openRows.map(rowToTask), recentTerminals: terminalRows.map(rowToTask) };
return { open: openRows.map((row) => rowToTask(row)), recentTerminals: terminalRows.map((row) => rowToTask(row)) };
}

export function requireTask(db: Database, taskId: string): Task {
Expand Down Expand Up @@ -454,16 +454,16 @@ export function transition(
cause: TransitionCause,
opts: TransitionOpts = {},
): Task {
db.exec("BEGIN IMMEDIATE");
db.run("BEGIN IMMEDIATE");
try {
const task = applyTransition(db, clock, taskId, to, cause);
for (const entry of opts.extraAudit ?? []) {
writeAudit(db, clock(), task.identityId, entry.kind, entry.payload);
}
db.exec("COMMIT");
db.run("COMMIT");
return task;
} catch (err) {
db.exec("ROLLBACK");
db.run("ROLLBACK");
throw err;
}
}
Expand Down Expand Up @@ -506,12 +506,12 @@ export interface SteerResult {
reply?: string;
}

const TERMINAL_STATUSES: TaskStatus[] = ["done", "failed", "cancelled"];
const TERMINAL_STATUSES = new Set<TaskStatus>(["done", "failed", "cancelled"]);

export function steerTask(db: Database, clock: Clock, params: SteerParams): SteerResult {
const task = requireTaskFor(db, params.identityId, params.taskId);

if (TERMINAL_STATUSES.includes(task.status)) {
if (TERMINAL_STATUSES.has(task.status)) {
insertSteeringRow(db, clock, params.taskId, params.kind, params.payload, params.sourceEventId, true);
return { applied: false, task, reply: `${task.id} already ${task.status}` };
}
Expand Down
16 changes: 12 additions & 4 deletions src/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,23 @@ function redact(fields: Record<string, unknown>): Record<string, unknown> {
}

export function createLogger(opts: CreateLoggerOpts = {}): Logger {
const sink = opts.sink ?? ((line: string) => console.log(line));
const sink = opts.sink ?? ((line: string) => {
console.log(line);
});
const clock = opts.clock ?? systemClock;
const emit = (level: LogLevel, msg: string, fields?: Record<string, unknown>) => {
const record = { at: clock(), level, msg, ...(fields ? redact(fields) : {}) };
sink(JSON.stringify(record));
};
return {
info: (m, f) => emit("info", m, f),
warn: (m, f) => emit("warn", m, f),
error: (m, f) => emit("error", m, f),
info: (m, f) => {
emit("info", m, f);
},
warn: (m, f) => {
emit("warn", m, f);
},
error: (m, f) => {
emit("error", m, f);
},
};
}
19 changes: 13 additions & 6 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,9 @@ async function cmdStart(): Promise<void> {
const db = openLedger(dbPath());
const clock = systemClock;
const log = createLogger(); // structured JSON lines to stdout (§15)
const adapter = new SlackAdapter({ botToken, appToken, botUserId }, (line) => log.info("slack", { line }));
const adapter = new SlackAdapter({ botToken, appToken, botUserId }, (line) => {
log.info("slack", { line });
});

// External tools an identity can be granted (KNOWN_TOOLS gates policy validation). The slack
// registry (tools/slack.ts) needs the live adapter and the daemon's Slack credentials, so it's
Expand Down Expand Up @@ -156,7 +158,9 @@ async function cmdStart(): Promise<void> {
};
process.on("SIGTERM", () => void shutdown("SIGTERM"));
process.on("SIGINT", () => void shutdown("SIGINT"));
process.on("unhandledRejection", (e) => console.error("[main] unhandled rejection:", e));
process.on("unhandledRejection", (e) => {
console.error("[main] unhandled rejection:", e);
});
}

// The one real codex wiring, shared by start and replay — a replay that drives a different
Expand All @@ -177,7 +181,9 @@ function makeCodexSessionFactory(log: ReturnType<typeof createLogger>) {
.filter(Boolean)
.join(" ");
const config = flags ? { ...DEFAULT_CODEX_CONFIG, command: `codex ${flags} app-server` } : DEFAULT_CODEX_CONFIG;
return new AppServerSession(config, tools, onEvent ?? ((e) => e.log && log.info("codex", { line: e.log })), { scrubEnv: allowlistEnv });
return new AppServerSession(config, tools, onEvent ?? ((e) => {
if (e.log) log.info("codex", { line: e.log });
}), { scrubEnv: allowlistEnv });
};
}

Expand Down Expand Up @@ -325,22 +331,23 @@ function cmdStatus(): void {
}

async function main(): Promise<void> {
const cmd = process.argv[2];
const cmd = process.argv[2] ?? "";
switch (cmd) {
case "start":
return cmdStart();
case "doctor":
return cmdDoctor();
case "status":
return cmdStatus();
cmdStatus();
return;
case "replay":
return cmdReplay();
default:
console.log(HELP);
}
}

main().catch((e) => {
main().catch((e: unknown) => {
console.error(e);
process.exit(1);
});
2 changes: 1 addition & 1 deletion src/policy/broker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ function actionClassDecision(ctx: ToolCallContext, grant: IdentityConfig["grants
// Canonical form of an action: sorted keys at every level, so the ref of "the call the human
// approved" and "the call the worker retries" agree regardless of property order.
export function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (Array.isArray(value)) return `[${value.map((v) => canonicalJson(v)).join(",")}]`;
if (value !== null && typeof value === "object") {
const entries = Object.entries(value).toSorted(([a], [b]) => (a < b ? -1 : 1));
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`;
Expand Down
9 changes: 5 additions & 4 deletions src/policy/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
TasksConfig,
TurnsConfig,
} from "./schema";
import { readFileSync } from "node:fs";
import { isRecord } from "../guard";

export function parsePolicyYaml(yamlText: string): unknown {
Expand Down Expand Up @@ -87,7 +88,7 @@ function toIdentity(raw: unknown): IdentityConfig {
persona: typeof i.persona === "string" ? i.persona : null,
venueIds: strArr(i.venue_ids),
learningSources: strArr(i.learning_sources),
grants: arr(i.grants).map(toGrant),
grants: arr(i.grants).map((g) => toGrant(g)),
budget: toIdentityBudget(i.budget),
ambient: toAmbient(i.ambient),
venueInstructions: toVenueInstructions(i.venue_instructions),
Expand Down Expand Up @@ -186,7 +187,7 @@ export function toPolicy(raw: unknown): Policy {
operatorPrincipals: strArr(r.operator_principals),
trustedBotPrincipals: strArr(r.trusted_bot_principals),
defaultDmIdentity: typeof r.default_dm_identity === "string" ? r.default_dm_identity : null,
identities: arr(r.identities).map(toIdentity),
identities: arr(r.identities).map((i) => toIdentity(i)),
turns: toTurns(r.turns),
executions: toExecutions(r.executions),
tasks: toTasks(r.tasks),
Expand Down Expand Up @@ -289,7 +290,7 @@ export class PolicyValidationFailedError extends Error {
}

export function fileSource(path: string): () => string {
return () => require("fs").readFileSync(path, "utf8");
return () => readFileSync(path, "utf8");
}

// SPEC §16.2 — reload keeps the last-known-good policy on any invalid reload, with an
Expand Down Expand Up @@ -335,6 +336,6 @@ export class PolicyStore {
}
const policy = toPolicy(raw);
const errors = validatePolicy(policy, this.opts);
return errors.length ? { errors } : { policy };
return errors.length > 0 ? { errors } : { policy };
}
}
Loading
Loading