diff --git a/apps/worker/src/context.ts b/apps/worker/src/context.ts index 5f7247a..ecfd127 100644 --- a/apps/worker/src/context.ts +++ b/apps/worker/src/context.ts @@ -4,6 +4,9 @@ import { BudgetedLlmAdapter, EchoLlmAdapter, WorkersAiLlmAdapter } from "@aipm/a import { buildConfig } from "@aipm/config"; import { configIdentitySource, + Err, + Ok, + Result, systemClock, type EngineContext, type LlmAdapter, @@ -22,12 +25,12 @@ const DEFAULT_LLM_TIMEOUT_MS = 30_000; * adapter is built per-event because the installation (and thus token) varies; * this keeps token scoping correct (DESIGN §6). */ -export function buildEngineContext(env: Env, event: RawEvent): EngineContext { +export function buildEngineContext(env: Env, event: RawEvent): Result { // Fail safe: shadow stays ON unless explicitly disabled with "false", both // globally and per capability — so a capability goes live only when its var is // exactly "false" (DESIGN §8/§10 staged rollout). const cap = (v: string | undefined) => (v === undefined ? undefined : v !== "false"); - const config = buildConfig({ + const configResult = buildConfig({ llmJudge: env.LLM_JUDGE === "true", shadow: { global: env.SHADOW_GLOBAL !== "false", @@ -40,6 +43,10 @@ export function buildEngineContext(env: Env, event: RawEvent): EngineContext { }, }, }); + if (!configResult.ok) return configResult; + const config = configResult.data; + const identitiesResult = configIdentitySource(env.IDENTITY_ROSTER ?? "[]"); + if (!identitiesResult.ok) return identitiesResult; const store = new D1Store(env.DB); const platforms = new Map(); @@ -72,14 +79,14 @@ export function buildEngineContext(env: Env, event: RawEvent): EngineContext { perDay: intVar(env.LLM_DAILY_BUDGET, 1000), }); - return { + return Ok({ store, platforms, - identities: configIdentitySource(env.IDENTITY_ROSTER ?? "[]"), + identities: identitiesResult.data, llm, config, clock: systemClock, - }; + }); } /** @@ -92,7 +99,7 @@ function intVar(raw: string | undefined, fallback: number): number { return Number(raw.trim()); } -function buildGitHubAdapter(env: Env, event: RawEvent, botAccounts: string[]): GitHubAdapter { +function buildGitHubAdapter(env: Env, event: RawEvent, botAccounts: Array): GitHubAdapter { const token = env.GITHUB_APP_PRIVATE_KEY && env.GITHUB_APP_CLIENT_ID && event.installationId != null ? installationTokenProvider({ @@ -101,7 +108,7 @@ function buildGitHubAdapter(env: Env, event: RawEvent, botAccounts: string[]): G clientId: env.GITHUB_APP_CLIENT_ID, installationId: event.installationId, }) - : () => Promise.reject(new Error("GitHub App credentials/installation id missing")); + : () => Promise.resolve(Err(new Error("GitHub App credentials/installation id missing"))); return new GitHubAdapter({ token, botAccounts }); } diff --git a/apps/worker/src/coordinator.ts b/apps/worker/src/coordinator.ts index d894bb6..0298cb7 100644 --- a/apps/worker/src/coordinator.ts +++ b/apps/worker/src/coordinator.ts @@ -12,10 +12,12 @@ import { evaluate, ingest, ingestThread, + Ok, Result, route, synthesize, synthesizeCluster, + type EngineContext, type Link, type RawEvent, type Thread, @@ -54,133 +56,197 @@ interface ClusterWorkItem { export class ClusterCoordinator extends DurableObject { private draining: Promise | undefined; - async process(args: ClusterWorkArgs) { + async process(args: ClusterWorkArgs): Promise> { const key = workKey(args); const delayMs = debounceMs(args.event); - await this.ctx.storage.put(key, { - id: crypto.randomUUID(), - args, - attempts: 0, - enqueuedAt: new Date().toISOString(), - readyAt: Date.now() + delayMs, - }); - await this.scheduleDrain(delayMs); + const stored = await Result.from(() => + this.ctx.storage.put(key, { + id: crypto.randomUUID(), + args, + attempts: 0, + enqueuedAt: new Date().toISOString(), + readyAt: Date.now() + delayMs, + }), + ); + if (!stored.ok) return stored; + + const scheduled = await this.scheduleDrain(delayMs); + if (!scheduled.ok) return scheduled; + return Ok(undefined); } override async alarm() { if (this.draining) return this.draining; - this.draining = this.drainOne().finally(() => { + this.draining = (async () => { + const drained = await this.drainOne(); + if (!drained.ok) throw drained.error; + })().finally(() => { this.draining = undefined; }); return this.draining; } - private async scheduleDrain(delayMs = 0) { + private async scheduleDrain(delayMs = 0): Promise> { const target = Date.now() + delayMs; - const current = await this.ctx.storage.getAlarm(); + const alarm = await Result.from(() => this.ctx.storage.getAlarm()); + if (!alarm.ok) return alarm; + const current = alarm.data; if (current === null || current > target) { - await this.ctx.storage.setAlarm(target); + const scheduled = await Result.from(() => this.ctx.storage.setAlarm(target)); + if (!scheduled.ok) return scheduled; } + return Ok(undefined); } - private async drainOne() { + private async drainOne(): Promise> { const next = await this.nextReadyWork(); - if (!next) return; - - const { key, item } = next; - await this.ctx.storage.put(key, { - ...item, - attempts: item.attempts + 1, - }); + if (!next.ok) return next; + if (!next.data) return Ok(undefined); + + const { key, item } = next.data; + const markedAttempt = await Result.from(() => + this.ctx.storage.put(key, { + ...item, + attempts: item.attempts + 1, + }), + ); + if (!markedAttempt.ok) return markedAttempt; - const processed = await Result.from(() => this.processOne(item.args)); + const processed = await this.processOne(item.args); if (processed.ok) { - await this.deleteIfCurrent(key, item.id); + const deleted = await this.deleteIfCurrent(key, item.id); + if (!deleted.ok) return deleted; } else if (item.attempts + 1 >= MAX_ATTEMPTS) { console.error( `cluster work failed permanently for ${item.args.threadNativeId}:`, processed.error, ); - await this.deleteIfCurrent(key, item.id); + const deleted = await this.deleteIfCurrent(key, item.id); + if (!deleted.ok) return deleted; } else { console.error(`cluster work failed for ${item.args.threadNativeId}:`, processed.error); - await this.scheduleDrain(); - return; + const scheduled = await this.scheduleDrain(); + if (!scheduled.ok) return scheduled; + return Ok(undefined); } - if (await this.hasWork()) await this.scheduleDrain(); + const hasWork = await this.hasWork(); + if (!hasWork.ok) return hasWork; + if (hasWork.data) { + const scheduled = await this.scheduleDrain(); + if (!scheduled.ok) return scheduled; + } + return Ok(undefined); } - private async deleteIfCurrent(key: string, id: string) { - const current = await this.ctx.storage.get(key); - if (current?.id === id) await this.ctx.storage.delete(key); + private async deleteIfCurrent(key: string, id: string): Promise> { + const loaded = await Result.from(() => this.ctx.storage.get(key)); + if (!loaded.ok) return loaded; + const current = loaded.data; + if (current?.id === id) { + const deleted = await Result.from(() => this.ctx.storage.delete(key)); + if (!deleted.ok) return deleted; + } + return Ok(undefined); } - private async nextReadyWork(): Promise<{ key: string; item: ClusterWorkItem } | undefined> { + private async nextReadyWork(): Promise< + Result<{ key: string; item: ClusterWorkItem } | undefined, Error> + > { const now = Date.now(); - const items = await this.ctx.storage.list({ prefix: WORK_PREFIX }); + const listed = await Result.from(() => + this.ctx.storage.list({ prefix: WORK_PREFIX }), + ); + if (!listed.ok) return listed; + const items = listed.data; const entries = [...items]; const ready = entries.find((entry) => entry[1].readyAt <= now); - if (ready) return { key: ready[0], item: ready[1] }; + if (ready) return Ok({ key: ready[0], item: ready[1] }); - if (!entries.length) return undefined; + if (!entries.length) return Ok(undefined); const earliestReadyAt = Math.min(...entries.map((entry) => entry[1].readyAt)); - await this.ctx.storage.setAlarm(earliestReadyAt); - return undefined; + const scheduled = await Result.from(() => this.ctx.storage.setAlarm(earliestReadyAt)); + if (!scheduled.ok) return scheduled; + return Ok(undefined); } - private async hasWork(): Promise { - const items = await this.ctx.storage.list({ prefix: WORK_PREFIX, limit: 1 }); - return items.size > 0; + private async hasWork(): Promise> { + const listed = await Result.from(() => + this.ctx.storage.list({ prefix: WORK_PREFIX, limit: 1 }), + ); + if (!listed.ok) return listed; + const items = listed.data; + return Ok(items.size > 0); } - private async processOne(args: ClusterWorkArgs) { - const ctx = buildEngineContext(this.env, args.event); + private async processOne(args: ClusterWorkArgs): Promise> { + const ctxResult = buildEngineContext(this.env, args.event); + if (!ctxResult.ok) return ctxResult; + const ctx = ctxResult.data; const store = ctx.store; - const ownerBefore = await store.findCluster(args.threadNativeId); + const ownerBeforeResult = await store.findCluster(args.threadNativeId); + if (!ownerBeforeResult.ok) return ownerBeforeResult; + const ownerBefore = ownerBeforeResult.data; const movedBeforeIngest = ownerBefore !== undefined && ownerBefore !== args.clusterId; if (movedBeforeIngest) return this.forward(args, ownerBefore); - const thread = await ingest(ctx, args.event); - if (!thread) return; - - const links = await store.getLinks(thread.nativeId); - const ownCluster = await store.getOrCreateCluster(thread.nativeId); - await asyncForEach(links, async (link) => { + const ingested = await ingest(ctx, args.event); + if (!ingested.ok) return ingested; + const thread = ingested.data; + if (!thread) return Ok(undefined); + + const linksResult = await store.getLinks(thread.nativeId); + if (!linksResult.ok) return linksResult; + const links = linksResult.data; + const ownClusterResult = await store.getOrCreateCluster(thread.nativeId); + if (!ownClusterResult.ok) return ownClusterResult; + const ownCluster = ownClusterResult.data; + const linkResults = await asyncMap(links, async (link) => { const counterpart = link.from === thread.nativeId ? link.to : link.from; - const counterpartCluster = await store.getOrCreateCluster(counterpart); + const counterpartClusterResult = await store.getOrCreateCluster(counterpart); + if (!counterpartClusterResult.ok) return counterpartClusterResult; + const counterpartCluster = counterpartClusterResult.data; const crossesClusters = ownCluster !== counterpartCluster; - if (crossesClusters) { - const registryId = this.env.MERGE_REGISTRY.idFromName(MERGE_REGISTRY_KEY); - await this.env.MERGE_REGISTRY.get(registryId).union({ + if (!crossesClusters) return Ok(undefined); + const registryId = this.env.MERGE_REGISTRY.idFromName(MERGE_REGISTRY_KEY); + const registry = this.env.MERGE_REGISTRY.get(registryId); + const unioned = await Result.from(() => + registry.union({ threadA: thread.nativeId, threadB: counterpart, - }); - } + }), + ); + if (!unioned.ok) return unioned; + return Ok(undefined); }); - - const ownerAfter = await store.findCluster(thread.nativeId); - if (!ownerAfter) return; + const linkErrors = linkResults.flatMap((it) => (it.ok ? [] : [it])); + const firstLinkError = linkErrors[0]; + if (firstLinkError) return firstLinkError; + + const ownerAfterResult = await store.findCluster(thread.nativeId); + if (!ownerAfterResult.ok) return ownerAfterResult; + const ownerAfter = ownerAfterResult.data; + if (!ownerAfter) return Ok(undefined); const mergedAway = ownerAfter !== args.clusterId; if (mergedAway) return this.forward(args, ownerAfter); const hydratedGitHubThreads = thread.platform === "slack" ? await this.hydrateLinkedGitHubThreads(ctx, thread, links) : []; - const members = await store.listClusterThreads(args.clusterId); + const membersResult = await store.listClusterThreads(args.clusterId); + if (!membersResult.ok) return membersResult; + const members = membersResult.data; const cluster = members.length > 1 ? { id: args.clusterId, threadIds: members } : undefined; if (cluster) { - const synthesized = await Result.from(() => synthesizeCluster(ctx, cluster)); + const synthesized = await synthesizeCluster(ctx, cluster); if (!synthesized.ok) { console.error(`cluster synth failed for ${args.clusterId}:`, synthesized.error); } } await asyncForEach(hydratedGitHubThreads, async (hydrated) => { - const synthesizedThread = await Result.from(() => - synthesize(hydrated.ctx, hydrated.thread, cluster), - ); + const synthesizedThread = await synthesize(hydrated.ctx, hydrated.thread, cluster); if (!synthesizedThread.ok) { console.error( `synthesize failed for ${hydrated.thread.nativeId}:`, @@ -189,39 +255,44 @@ export class ClusterCoordinator extends DurableObject { } }); if (thread.platform === "github") { - const synthesizedThread = await Result.from(() => synthesize(ctx, thread, cluster)); + const synthesizedThread = await synthesize(ctx, thread, cluster); if (!synthesizedThread.ok) { console.error(`synthesize failed for ${thread.nativeId}:`, synthesizedThread.error); } } - const routed = await Result.from(async () => { - const signals = await evaluate(ctx, thread); - await route(ctx, thread, signals); - }); - if (!routed.ok) { - console.error(`evaluate/route failed for ${thread.nativeId}:`, routed.error); + const signals = await evaluate(ctx, thread); + if (!signals.ok) { + console.error(`evaluate failed for ${thread.nativeId}:`, signals.error); + } else { + const routed = await route(ctx, thread, signals.data); + if (!routed.ok) { + console.error(`route failed for ${thread.nativeId}:`, routed.error); + } } + return Ok(undefined); } - async forward(args: ClusterWorkArgs, target: string) { + async forward(args: ClusterWorkArgs, target: string): Promise> { const overLimit = args.hop >= MAX_FORWARD_HOPS; if (overLimit) { console.error(`cluster forward hop limit for ${args.threadNativeId} -> ${target}`); - return; + return Ok(undefined); } const id = this.env.CLUSTER_COORDINATOR.idFromName(target); - await this.env.CLUSTER_COORDINATOR.get(id).process({ + const forwarded = await this.env.CLUSTER_COORDINATOR.get(id).process({ event: args.event, threadNativeId: args.threadNativeId, clusterId: target, hop: args.hop + 1, }); + if (!forwarded.ok) return forwarded; + return Ok(undefined); } private async hydrateLinkedGitHubThreads( - ctx: ReturnType, + ctx: EngineContext, sourceThread: Thread, - links: Link[], + links: Array, ) { const typeHints = githubTypeHints(sourceThread); const candidateIds = links.flatMap((link) => { @@ -232,37 +303,34 @@ export class ClusterCoordinator extends DurableObject { const nativeIds = new Set(candidateIds); const hydratedResults = await asyncMap([...nativeIds], async (nativeId) => { - const linked = await Result.from(() => - this.hydrateLinkedGitHubThread(ctx, nativeId, typeHints), - ); - if (linked.ok && linked.data) return linked.data; - if (!linked.ok) { - console.error(`linked GitHub hydration failed for ${nativeId}:`, linked.error); - } + const linked = await this.hydrateLinkedGitHubThread(ctx, nativeId, typeHints); + if (linked.ok) return linked.data; + console.error(`linked GitHub hydration failed for ${nativeId}:`, linked.error); return undefined; }); return hydratedResults.flatMap((it) => (it ? [it] : [])); } private async hydrateLinkedGitHubThread( - ctx: ReturnType, + ctx: EngineContext, nativeId: string, typeHints: Map, - ): Promise<{ ctx: ReturnType; thread: Thread } | undefined> { - const parsed = Result.fromSync(() => parseNativeId(nativeId)); - if (!parsed.ok) return undefined; + ): Promise> { + const parsed = parseNativeId(nativeId); + if (!parsed.ok) return Ok(undefined); const installationId = await this.resolveRepoInstallationId( parsed.data.owner, parsed.data.repo, ); - if (!installationId) return undefined; + if (!installationId.ok) return installationId; + if (!installationId.data) return Ok(undefined); const github = new GitHubAdapter({ token: installationTokenProvider({ kv: this.env.INSTALL_TOKENS, privateKeyPem: this.env.GITHUB_APP_PRIVATE_KEY!, clientId: this.env.GITHUB_APP_CLIENT_ID!, - installationId, + installationId: installationId.data, }), botAccounts: ctx.config.botAccounts, }); @@ -270,23 +338,35 @@ export class ClusterCoordinator extends DurableObject { ...ctx, platforms: new Map(ctx.platforms).set("github", github), }; - const thread = await github.getThread(nativeId, typeHints.get(nativeId)); - return { ctx: githubCtx, thread: await ingestThread(githubCtx, thread) }; + const fetchedThread = await github.getThread(nativeId, typeHints.get(nativeId)); + if (!fetchedThread.ok) return fetchedThread; + const ingestedThread = await ingestThread(githubCtx, fetchedThread.data); + if (!ingestedThread.ok) return ingestedThread; + return Ok({ ctx: githubCtx, thread: ingestedThread.data }); } private async resolveRepoInstallationId( owner: string, repo: string, - ): Promise { - if (!this.env.GITHUB_APP_PRIVATE_KEY || !this.env.GITHUB_APP_CLIENT_ID) return undefined; + ): Promise> { + const privateKey = this.env.GITHUB_APP_PRIVATE_KEY; + const clientId = this.env.GITHUB_APP_CLIENT_ID; + if (!privateKey || !clientId) return Ok(undefined); const key = `repo-inst:${owner}/${repo}`; - const cached = await this.env.INSTALL_TOKENS.get(key); - if (cached && /^\d+$/.test(cached)) return Number(cached); - - const jwt = await mintAppJwt(this.env.GITHUB_APP_PRIVATE_KEY, this.env.GITHUB_APP_CLIENT_ID); - const id = await resolveRepoInstallationId(jwt, owner, repo); - await this.env.INSTALL_TOKENS.put(key, String(id), { expirationTtl: 86_400 }); - return id; + const cachedResult = await Result.from(() => this.env.INSTALL_TOKENS.get(key)); + if (!cachedResult.ok) return cachedResult; + const cached = cachedResult.data; + if (cached && /^\d+$/.test(cached)) return Ok(Number(cached)); + + const jwt = await mintAppJwt(privateKey, clientId); + if (!jwt.ok) return jwt; + const id = await resolveRepoInstallationId(jwt.data, owner, repo); + if (!id.ok) return id; + const cachedInstallation = await Result.from(() => + this.env.INSTALL_TOKENS.put(key, String(id.data), { expirationTtl: 86_400 }), + ); + if (!cachedInstallation.ok) return cachedInstallation; + return Ok(id.data); } } diff --git a/apps/worker/src/dedupe.ts b/apps/worker/src/dedupe.ts new file mode 100644 index 0000000..b1263b4 --- /dev/null +++ b/apps/worker/src/dedupe.ts @@ -0,0 +1,13 @@ +import { Ok, Result } from "@aipm/core"; + +const DEDUPE_TTL_SECONDS = 86_400; + +/** + * Mark a webhook delivery as processed in KV so a retried delivery dedupes + * (DESIGN §6/§9). A null key (no delivery/event id) is a no-op, so callers pass + * the id-or-null directly instead of branching at every site. + */ +export const markDelivered = async (kv: KVNamespace, key: string | null) => { + if (!key) return Ok(undefined); + return Result.from(() => kv.put(key, "1", { expirationTtl: DEDUPE_TTL_SECONDS })); +}; diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index c623854..a7ca744 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -7,8 +7,11 @@ import { aggregate, aggregateOrg, asyncForEach, + asyncMap, capturePreference, chunk, + Err, + Ok, Result, type RawEvent, } from "@aipm/core"; @@ -41,6 +44,9 @@ function deriveNativeId(event: RawEvent): string | undefined { /** The cron expression that triggers the per-person digest (see wrangler.jsonc). */ const DIGEST_CRON = "0 14 * * *"; +/** Retry signal for a preference-capture infra failure (reason:"error") at the queue boundary. */ +const PREFERENCE_CAPTURE_FAILED = "PREFERENCE_CAPTURE_FAILED"; + interface SweepRepo { owner: string; repo: string; @@ -53,26 +59,36 @@ export default { /** Ingest queue consumer (DESIGN §6): route each event to its cluster DO. */ async queue(batch: MessageBatch, env: Env): Promise { await asyncForEach([...batch.messages], async (msg) => { - const handled = await Result.from(async () => { + const handled = await (async (): Promise> => { if (msg.body.platform === "slack" && msg.body.event === "preference") { // Preference capture isn't thread-scoped; handle it directly (DESIGN §8). const { slackUserId, text } = msg.body.payload as { slackUserId: string; text: string }; - await capturePreference(buildEngineContext(env, msg.body), slackUserId, text); - return; + const ctxResult = buildEngineContext(env, msg.body); + if (!ctxResult.ok) return ctxResult; + const captured = await capturePreference(ctxResult.data, slackUserId, text); + if (captured.reason === "error") return Err(new Error(PREFERENCE_CAPTURE_FAILED)); + return Ok(undefined); } const nativeId = deriveNativeId(msg.body); - if (!nativeId) return; + if (!nativeId) return Ok(undefined); const store = new D1Store(env.DB); const existing = await store.findCluster(nativeId); - const clusterId = existing ?? (await store.getOrCreateCluster(nativeId)); - const coordinatorId = env.CLUSTER_COORDINATOR.idFromName(clusterId); - await env.CLUSTER_COORDINATOR.get(coordinatorId).process({ + if (!existing.ok) return existing; + const cluster = await (async () => { + if (existing.data) return Ok(existing.data); + return store.getOrCreateCluster(nativeId); + })(); + if (!cluster.ok) return cluster; + const coordinatorId = env.CLUSTER_COORDINATOR.idFromName(cluster.data); + const processed = await env.CLUSTER_COORDINATOR.get(coordinatorId).process({ event: msg.body, threadNativeId: nativeId, - clusterId, + clusterId: cluster.data, hop: 0, }); - }); + if (!processed.ok) return processed; + return Ok(undefined); + })(); if (handled.ok) msg.ack(); else msg.retry(); }); @@ -82,9 +98,19 @@ export default { async scheduled(event: ScheduledController, env: Env): Promise { if (event.cron === DIGEST_CRON) { // No installation needed; pass a synthetic event. Digest + org rollup. - const ctx = buildEngineContext(env, { platform: "slack", payload: {} }); - await aggregate(ctx); - await aggregateOrg(ctx, { channelId: env.ORG_ROLLUP_CHANNEL_ID }); + const ctxResult = buildEngineContext(env, { platform: "slack", payload: {} }); + if (!ctxResult.ok) throw ctxResult.error; + const ctx = ctxResult.data; + const aggregated = await aggregate(ctx); + if (!aggregated.ok) { + console.error("digest cron failed:", aggregated.error); + throw aggregated.error; + } + const rolled = await aggregateOrg(ctx, { channelId: env.ORG_ROLLUP_CHANNEL_ID }); + if (!rolled.ok) { + console.error("digest cron failed:", rolled.error); + throw rolled.error; + } return; } @@ -93,7 +119,7 @@ export default { const privateKeyPem = env.GITHUB_APP_PRIVATE_KEY; const clientId = env.GITHUB_APP_CLIENT_ID; - await asyncForEach(repos, async (sweepRepo) => { + const sweepResults = await asyncMap(repos, async (sweepRepo) => { const token = installationTokenProvider({ kv: env.INSTALL_TOKENS, privateKeyPem, @@ -101,10 +127,12 @@ export default { installationId: sweepRepo.installationId, }); const adapter = new GitHubAdapter({ token }); - const threads = await adapter.listThreads({ + const threadsResult = await adapter.listThreads({ owner: sweepRepo.owner, repo: sweepRepo.repo, }); + if (!threadsResult.ok) return threadsResult; + const threads = threadsResult.data; const messages = threads.map((t) => ({ body: { platform: "github" as const, @@ -113,18 +141,29 @@ export default { payload: { nativeId: t.nativeId, type: t.type }, }, })); - await asyncForEach(chunk(messages, 100), async (batch) => { - await env.INGEST_QUEUE.sendBatch(batch); - }); + const batches = chunk(messages, 100); + const sendResults = await asyncMap(batches, (batch) => + Result.from(() => env.INGEST_QUEUE.sendBatch(batch)), + ); + const sendErrors = sendResults.flatMap((it) => (it.ok ? [] : [it])); + const firstSendError = sendErrors[0]; + if (firstSendError) return firstSendError; + return Ok(undefined); }); + const sweepErrors = sweepResults.flatMap((it) => (it.ok ? [] : [it])); + const firstSweepError = sweepErrors[0]; + if (firstSweepError) { + console.error("sweep cron failed:", firstSweepError.error); + throw firstSweepError.error; + } }, } satisfies ExportedHandler; -function parseSweepRepos(raw: string | undefined): SweepRepo[] { +function parseSweepRepos(raw: string | undefined): Array { if (!raw) return []; const parsed = Result.fromSync(() => JSON.parse(raw)); if (!parsed.ok) return []; - return Array.isArray(parsed.data) ? (parsed.data as SweepRepo[]) : []; + return Array.isArray(parsed.data) ? (parsed.data as Array) : []; } export { ClusterCoordinator } from "./coordinator.js"; diff --git a/apps/worker/src/members.ts b/apps/worker/src/members.ts index 61c2394..f7fd888 100644 --- a/apps/worker/src/members.ts +++ b/apps/worker/src/members.ts @@ -1,4 +1,4 @@ -import { configIdentitySource, type PlatformId } from "@aipm/core"; +import { configIdentitySource, Ok, Result, type PlatformId } from "@aipm/core"; import type { Env } from "./env.js"; /** @@ -22,21 +22,23 @@ export interface MemberGate { allows(platform: PlatformId, handle: string | undefined): Promise; } -export function memberGate(env: Env): MemberGate { +export function memberGate(env: Env): Result { const required = env.REQUIRE_MEMBER_TRIGGER !== "false"; - const source = configIdentitySource(env.IDENTITY_ROSTER ?? "[]"); + const sourceResult = configIdentitySource(env.IDENTITY_ROSTER ?? "[]"); + if (!sourceResult.ok) return sourceResult; + const source = sourceResult.data; const isMember: MemberGate["isMember"] = async (platform, handle) => { if (!handle) return false; return !!(await source.resolve({ handle, platform })); }; - return { + return Ok({ required, isMember, async allows(platform, handle) { if (!required) return true; return isMember(platform, handle); }, - }; + }); } diff --git a/apps/worker/src/merge-registry.ts b/apps/worker/src/merge-registry.ts index 11ceefd..792e338 100644 --- a/apps/worker/src/merge-registry.ts +++ b/apps/worker/src/merge-registry.ts @@ -1,4 +1,5 @@ import { DurableObject } from "cloudflare:workers"; +import { Ok } from "@aipm/core"; import { D1Store } from "@aipm/db"; import type { Env } from "./env.js"; @@ -9,16 +10,25 @@ import type { Env } from "./env.js"; */ export class MergeRegistry extends DurableObject { async union(args: { threadA: string; threadB: string }) { - return this.ctx.blockConcurrencyWhile(async () => { + const mergeResult = await this.ctx.blockConcurrencyWhile(async () => { const store = new D1Store(this.env.DB); - const clusterA = await store.getOrCreateCluster(args.threadA); - const clusterB = await store.getOrCreateCluster(args.threadB); - if (clusterA === clusterB) return clusterA; + const clusterAResult = await store.getOrCreateCluster(args.threadA); + if (!clusterAResult.ok) return clusterAResult; + const clusterA = clusterAResult.data; + const clusterBResult = await store.getOrCreateCluster(args.threadB); + if (!clusterBResult.ok) return clusterBResult; + const clusterB = clusterBResult.data; + if (clusterA === clusterB) return Ok(clusterA); const winner = clusterA < clusterB ? clusterA : clusterB; const loser = clusterA < clusterB ? clusterB : clusterA; - await store.repointCluster({ fromClusterId: loser, toClusterId: winner }); - await store.deleteCluster(loser); - return winner; + const repointArgs = { fromClusterId: loser, toClusterId: winner }; + const repointResult = await store.repointCluster(repointArgs); + if (!repointResult.ok) return repointResult; + const deleteResult = await store.deleteCluster(loser); + if (!deleteResult.ok) return deleteResult; + return Ok(winner); }); + if (!mergeResult.ok) throw mergeResult.error; + return mergeResult.data; } } diff --git a/apps/worker/src/routes/github.ts b/apps/worker/src/routes/github.ts index 339a9f3..e2ff37e 100644 --- a/apps/worker/src/routes/github.ts +++ b/apps/worker/src/routes/github.ts @@ -1,6 +1,7 @@ import { verifyWebhook } from "@aipm/adapter-github"; -import { NOTES_MARKER } from "@aipm/core"; +import { NOTES_MARKER, Ok, Result } from "@aipm/core"; import { Hono } from "hono"; +import { markDelivered } from "../dedupe.js"; import type { Env } from "../env.js"; import { memberGate } from "../members.js"; @@ -15,22 +16,33 @@ interface GithubWebhookBody { } githubRoutes.post("/", async (c) => { - const raw = await c.req.text(); + const raw = await Result.from(() => c.req.text()); + if (!raw.ok) return c.json({ error: "bad request" }, 400); + const sig = c.req.header("x-hub-signature-256") ?? null; const secret = c.env.GITHUB_WEBHOOK_SECRET; - if (!secret || !(await verifyWebhook(secret, raw, sig))) { - return c.json({ error: "bad signature" }, 401); - } + if (!secret) throw new Error("GITHUB_WEBHOOK_SECRET is not configured"); + const verified = await verifyWebhook(secret, raw.data, sig); + if (!verified.ok) throw verified.error; + if (!verified.data) return c.json({ error: "bad signature" }, 401); // Delivery-id dedupe in KV (DESIGN §6/§9). const delivery = c.req.header("x-github-delivery") ?? undefined; - if (delivery && (await c.env.DELIVERY_DEDUPE.get(`gh:${delivery}`))) { + const dedupe = await (async () => { + if (!delivery) return Ok(null); + return Result.from(() => c.env.DELIVERY_DEDUPE.get(`gh:${delivery}`)); + })(); + if (!dedupe.ok) throw dedupe.error; + if (dedupe.data) { return c.json({ ok: true, deduped: true }); } // Carry the discriminators the engine needs: event name (header — the only // reliable classifier), action, delivery id, and installation id (for token). - const body = JSON.parse(raw) as GithubWebhookBody; + const parsed = Result.fromSync(() => JSON.parse(raw.data)); + if (!parsed.ok) return c.json({ error: "invalid json" }, 400); + if (!isGithubWebhookBody(parsed.data)) return c.json({ error: "invalid payload" }, 400); + const body = parsed.data; // Ignore the bot's own sticky-note comment edits — otherwise editing the note // fires issue_comment events that re-ingest and re-edit it in a loop. @@ -38,29 +50,84 @@ githubRoutes.post("/", async (c) => { c.req.header("x-github-event") === "issue_comment" && body.comment?.body?.includes(NOTES_MARKER) ) { - if (delivery) await c.env.DELIVERY_DEDUPE.put(`gh:${delivery}`, "1", { expirationTtl: 86_400 }); + const delivered = await markDelivered( + c.env.DELIVERY_DEDUPE, + delivery ? `gh:${delivery}` : null, + ); + if (!delivered.ok) throw delivered.error; return c.json({ ok: true, ignored: "own-comment" }); } // Member-trigger gate: drop events fired by non-members before any spend // (queue/DO/LLM), so a public repo's strangers — or a comment loop — can't run // up the bill. Default on; bypass with REQUIRE_MEMBER_TRIGGER="false". - if (!(await memberGate(c.env).allows("github", body.sender?.login))) { - if (delivery) await c.env.DELIVERY_DEDUPE.put(`gh:${delivery}`, "1", { expirationTtl: 86_400 }); + const gate = memberGate(c.env); + if (!gate.ok) throw gate.error; + const allowed = await gate.data.allows("github", body.sender?.login); + if (!allowed) { + const delivered = await markDelivered( + c.env.DELIVERY_DEDUPE, + delivery ? `gh:${delivery}` : null, + ); + if (!delivered.ok) throw delivered.error; return c.json({ ok: true, ignored: "non-member" }); } - await c.env.INGEST_QUEUE.send({ - platform: "github", - event: c.req.header("x-github-event") ?? undefined, - action: body.action, - deliveryId: delivery, - installationId: body.installation?.id, - payload: body, - }); + const queued = await Result.from(() => + c.env.INGEST_QUEUE.send({ + platform: "github", + event: c.req.header("x-github-event") ?? undefined, + action: body.action, + deliveryId: delivery, + installationId: body.installation?.id, + payload: body, + }), + ); + if (!queued.ok) throw queued.error; // Mark delivered only after a successful enqueue, so an enqueue failure (which // returns 5xx and is retried by GitHub) can't permanently drop the event. - if (delivery) await c.env.DELIVERY_DEDUPE.put(`gh:${delivery}`, "1", { expirationTtl: 86_400 }); + const delivered = await markDelivered(c.env.DELIVERY_DEDUPE, delivery ? `gh:${delivery}` : null); + if (!delivered.ok) throw delivered.error; return c.json({ ok: true }); }); + +const isRecord = (value: unknown): value is Record => { + const isObject = typeof value === "object"; + return isObject && value !== null; +}; + +const isOptionalString = (value: unknown) => { + return value === undefined || typeof value === "string"; +}; + +const isOptionalNumber = (value: unknown) => { + return value === undefined || typeof value === "number"; +}; + +const isOptionalComment = (value: unknown) => { + if (value === undefined) return true; + if (!isRecord(value)) return false; + return isOptionalString(value.body); +}; + +const isOptionalInstallation = (value: unknown) => { + if (value === undefined) return true; + if (!isRecord(value)) return false; + return isOptionalNumber(value.id); +}; + +const isOptionalSender = (value: unknown) => { + if (value === undefined) return true; + if (!isRecord(value)) return false; + return isOptionalString(value.login); +}; + +const isGithubWebhookBody = (value: unknown): value is GithubWebhookBody => { + if (!isRecord(value)) return false; + const validAction = isOptionalString(value.action); + const validComment = isOptionalComment(value.comment); + const validInstallation = isOptionalInstallation(value.installation); + const validSender = isOptionalSender(value.sender); + return validAction && validComment && validInstallation && validSender; +}; diff --git a/apps/worker/src/routes/slack.ts b/apps/worker/src/routes/slack.ts index 7ee3ce3..6504f2a 100644 --- a/apps/worker/src/routes/slack.ts +++ b/apps/worker/src/routes/slack.ts @@ -1,29 +1,43 @@ import { verifySlackRequest } from "@aipm/adapter-slack"; +import { Ok, Result } from "@aipm/core"; import { Hono } from "hono"; +import { markDelivered } from "../dedupe.js"; import type { Env } from "../env.js"; import { memberGate } from "../members.js"; export const slackRoutes = new Hono<{ Bindings: Env }>(); slackRoutes.post("/", async (c) => { - const raw = await c.req.text(); + const raw = await Result.from(() => c.req.text()); + if (!raw.ok) return c.json({ error: "bad request" }, 400); + const secret = c.env.SLACK_SIGNING_SECRET; - const ok = - !!secret && - (await verifySlackRequest( + const verified = await (async () => { + if (!secret) return Ok(false); + return verifySlackRequest( secret, - raw, + raw.data, c.req.header("x-slack-signature") ?? null, c.req.header("x-slack-request-timestamp") ?? null, - )); - if (!ok) return c.json({ error: "bad signature" }, 401); + ); + })(); + if (!verified.ok) throw verified.error; + if (!verified.data) return c.json({ error: "bad signature" }, 401); - const body = JSON.parse(raw) as SlackEnvelope; + const parsed = Result.fromSync(() => JSON.parse(raw.data)); + if (!parsed.ok) return c.json({ error: "invalid json" }, 400); + if (!isSlackEnvelope(parsed.data)) return c.json({ error: "invalid payload" }, 400); + const body = parsed.data; // Slack Events API URL verification handshake. if (body.type === "url_verification") return c.json({ challenge: body.challenge }); // Event dedupe — Slack retries deliveries (DESIGN §6 delivery-id dedupe). - if (body.event_id && (await c.env.DELIVERY_DEDUPE.get(`sl:${body.event_id}`))) { + const dedupe = await (async () => { + if (!body.event_id) return Ok(null); + return Result.from(() => c.env.DELIVERY_DEDUPE.get(`sl:${body.event_id}`)); + })(); + if (!dedupe.ok) throw dedupe.error; + if (dedupe.data) { return c.json({ ok: true }); } @@ -32,25 +46,34 @@ slackRoutes.post("/", async (c) => { let enqueued = false; if (!subject) { console.info("slack event ignored", slackEventLog(body, "no_subject")); - } else if (!(await memberGate(c.env).allows("slack", subject.user))) { - console.info("slack event ignored", slackEventLog(body, "not_roster_member", subject)); } else { - if (subject.channelType === "im" && subject.text) { - await c.env.INGEST_QUEUE.send({ - platform: "slack", - event: "preference", - deliveryId: body.event_id, - payload: { slackUserId: subject.user, text: subject.text }, - }); + const gate = memberGate(c.env); + if (!gate.ok) throw gate.error; + const allowed = await gate.data.allows("slack", subject.user); + if (!allowed) { + console.info("slack event ignored", slackEventLog(body, "not_roster_member", subject)); + } else if (subject.channelType === "im" && subject.text) { + const queued = await Result.from(() => + c.env.INGEST_QUEUE.send({ + platform: "slack", + event: "preference", + deliveryId: body.event_id, + payload: { slackUserId: subject.user, text: subject.text }, + }), + ); + if (!queued.ok) throw queued.error; enqueued = true; console.info("slack event enqueued", slackEventLog(body, "preference", subject)); } else if (subject.channelType === "channel" || subject.channelType === "group") { - await c.env.INGEST_QUEUE.send({ - platform: "slack", - event: "thread_message", - deliveryId: body.event_id, - payload: { channel: subject.channel, threadTs: subject.threadTs }, - }); + const queued = await Result.from(() => + c.env.INGEST_QUEUE.send({ + platform: "slack", + event: "thread_message", + deliveryId: body.event_id, + payload: { channel: subject.channel, threadTs: subject.threadTs }, + }), + ); + if (!queued.ok) throw queued.error; enqueued = true; console.info("slack event enqueued", slackEventLog(body, "thread_message", subject)); } else { @@ -59,9 +82,9 @@ slackRoutes.post("/", async (c) => { } // Mark delivered only after a successful enqueue so ignored events can be // redelivered after config fixes such as a corrected identity roster. - if (body.event_id && enqueued) { - await c.env.DELIVERY_DEDUPE.put(`sl:${body.event_id}`, "1", { expirationTtl: 86_400 }); - } + const deliveredKey = body.event_id && enqueued ? `sl:${body.event_id}` : null; + const delivered = await markDelivered(c.env.DELIVERY_DEDUPE, deliveredKey); + if (!delivered.ok) throw delivered.error; return c.json({ ok: true }); }); @@ -166,3 +189,74 @@ function slackEventLog( user: subject?.user ?? body.event?.user, }; } + +const isRecord = (value: unknown): value is Record => { + const isObject = typeof value === "object"; + return isObject && value !== null; +}; + +const isOptionalString = (value: unknown) => { + return value === undefined || typeof value === "string"; +}; + +const isOptionalReplies = (value: unknown) => { + if (value === undefined) return true; + if (!Array.isArray(value)) return false; + return value.every((item) => { + if (!isRecord(item)) return false; + const validUser = isOptionalString(item.user); + const validTs = isOptionalString(item.ts); + return validUser && validTs; + }); +}; + +const isOptionalSlackMessage = (value: unknown) => { + if (value === undefined) return true; + if (!isRecord(value)) return false; + const validChannel = isOptionalString(value.channel); + const validBotId = isOptionalString(value.bot_id); + const validUser = isOptionalString(value.user); + const validText = isOptionalString(value.text); + const validTs = isOptionalString(value.ts); + const validThreadTs = isOptionalString(value.thread_ts); + const validReplies = isOptionalReplies(value.replies); + return ( + validChannel && validBotId && validUser && validText && validTs && validThreadTs && validReplies + ); +}; + +const isOptionalSlackEvent = (value: unknown) => { + if (value === undefined) return true; + if (!isRecord(value)) return false; + const validType = isOptionalString(value.type); + const validChannelType = isOptionalString(value.channel_type); + const validChannel = isOptionalString(value.channel); + const validBotId = isOptionalString(value.bot_id); + const validSubtype = isOptionalString(value.subtype); + const validUser = isOptionalString(value.user); + const validText = isOptionalString(value.text); + const validTs = isOptionalString(value.ts); + const validThreadTs = isOptionalString(value.thread_ts); + const validMessage = isOptionalSlackMessage(value.message); + return ( + validType && + validChannelType && + validChannel && + validBotId && + validSubtype && + validUser && + validText && + validTs && + validThreadTs && + validMessage + ); +}; + +const isSlackEnvelope = (value: unknown): value is SlackEnvelope => { + if (!isRecord(value)) return false; + const validType = isOptionalString(value.type); + const validChallenge = isOptionalString(value.challenge); + const validEventId = isOptionalString(value.event_id); + const validEvent = isOptionalSlackEvent(value.event); + return validType && validChallenge && validEventId && validEvent; +}; diff --git a/apps/worker/test/cluster.test.ts b/apps/worker/test/cluster.test.ts index be579d9..4012f6b 100644 --- a/apps/worker/test/cluster.test.ts +++ b/apps/worker/test/cluster.test.ts @@ -9,64 +9,119 @@ describe("D1Store cluster membership (issue #8)", () => { it("getOrCreateCluster mints once and is idempotent for the same thread", async () => { const store = new D1Store(env.DB); const first = await store.getOrCreateCluster("o/r#1"); + expect(first.ok).toBe(true); + if (!first.ok) throw first.error; const second = await store.getOrCreateCluster("o/r#1"); - expect(first).toBe(second); + expect(second.ok).toBe(true); + if (!second.ok) throw second.error; + expect(first.data).toBe(second.data); const found = await store.findCluster("o/r#1"); - expect(found).toBe(first); + expect(found.ok).toBe(true); + if (!found.ok) throw found.error; + expect(found.data).toBe(first.data); }); it("findCluster returns undefined for an unknown thread", async () => { const store = new D1Store(env.DB); const found = await store.findCluster("o/r#never"); - expect(found).toBeUndefined(); + expect(found.ok).toBe(true); + if (!found.ok) throw found.error; + expect(found.data).toBeUndefined(); }); it("two distinct threads get two distinct minted cluster ids", async () => { const store = new D1Store(env.DB); const a = await store.getOrCreateCluster("o/r#a"); + expect(a.ok).toBe(true); + if (!a.ok) throw a.error; const b = await store.getOrCreateCluster("o/r#b"); - expect(a).not.toBe(b); + expect(b.ok).toBe(true); + if (!b.ok) throw b.error; + expect(a.data).not.toBe(b.data); }); it("listClusterThreads returns the cluster's members ordered by thread id", async () => { const store = new D1Store(env.DB); const clusterId = await store.getOrCreateCluster("o/r#10"); - await store.repointCluster({ - fromClusterId: await store.getOrCreateCluster("o/r#2"), - toClusterId: clusterId, + expect(clusterId.ok).toBe(true); + if (!clusterId.ok) throw clusterId.error; + const member2 = await store.getOrCreateCluster("o/r#2"); + expect(member2.ok).toBe(true); + if (!member2.ok) throw member2.error; + const repointed2 = await store.repointCluster({ + fromClusterId: member2.data, + toClusterId: clusterId.data, }); - await store.repointCluster({ - fromClusterId: await store.getOrCreateCluster("o/r#5"), - toClusterId: clusterId, + expect(repointed2.ok).toBe(true); + if (!repointed2.ok) throw repointed2.error; + const member5 = await store.getOrCreateCluster("o/r#5"); + expect(member5.ok).toBe(true); + if (!member5.ok) throw member5.error; + const repointed5 = await store.repointCluster({ + fromClusterId: member5.data, + toClusterId: clusterId.data, }); - const members = await store.listClusterThreads(clusterId); - expect(members).toEqual(["o/r#10", "o/r#2", "o/r#5"]); + expect(repointed5.ok).toBe(true); + if (!repointed5.ok) throw repointed5.error; + const members = await store.listClusterThreads(clusterId.data); + expect(members.ok).toBe(true); + if (!members.ok) throw members.error; + expect(members.data).toEqual(["o/r#10", "o/r#2", "o/r#5"]); }); it("repointCluster moves every member and deleteCluster drops the row + cluster note", async () => { const store = new D1Store(env.DB); const loser = await store.getOrCreateCluster("o/r#loser1"); - await store.repointCluster({ - fromClusterId: await store.getOrCreateCluster("o/r#loser2"), - toClusterId: loser, + expect(loser.ok).toBe(true); + if (!loser.ok) throw loser.error; + const loser2 = await store.getOrCreateCluster("o/r#loser2"); + expect(loser2.ok).toBe(true); + if (!loser2.ok) throw loser2.error; + const repointedLoser2 = await store.repointCluster({ + fromClusterId: loser2.data, + toClusterId: loser.data, }); + expect(repointedLoser2.ok).toBe(true); + if (!repointedLoser2.ok) throw repointedLoser2.error; const winner = await store.getOrCreateCluster("o/r#winner"); - await store.upsertWorkingNotes({ + expect(winner.ok).toBe(true); + if (!winner.ok) throw winner.error; + const noted = await store.upsertWorkingNotes({ scope: "cluster", - targetId: loser, + targetId: loser.data, content: "stale cluster note", contentHash: "h-loser", provenance: "cluster", }); + expect(noted.ok).toBe(true); + if (!noted.ok) throw noted.error; - await store.repointCluster({ fromClusterId: loser, toClusterId: winner }); - await store.deleteCluster(loser); + const repointedLoser = await store.repointCluster({ + fromClusterId: loser.data, + toClusterId: winner.data, + }); + expect(repointedLoser.ok).toBe(true); + if (!repointedLoser.ok) throw repointedLoser.error; + const deleted = await store.deleteCluster(loser.data); + expect(deleted.ok).toBe(true); + if (!deleted.ok) throw deleted.error; - expect(await store.listClusterThreads(loser)).toEqual([]); - const winnerMembers = await store.listClusterThreads(winner); - expect(winnerMembers).toEqual(["o/r#loser1", "o/r#loser2", "o/r#winner"]); - expect(await store.findCluster("o/r#loser1")).toBe(winner); - expect(await store.getWorkingNotes("cluster", loser)).toBeUndefined(); + const loserMembers = await store.listClusterThreads(loser.data); + expect(loserMembers.ok).toBe(true); + if (!loserMembers.ok) throw loserMembers.error; + expect(loserMembers.data).toEqual([]); + const winnerMembers = await store.listClusterThreads(winner.data); + expect(winnerMembers.ok).toBe(true); + if (!winnerMembers.ok) throw winnerMembers.error; + expect(winnerMembers.data).toEqual(["o/r#loser1", "o/r#loser2", "o/r#winner"]); + const foundLoser1 = await store.findCluster("o/r#loser1"); + expect(foundLoser1.ok).toBe(true); + if (!foundLoser1.ok) throw foundLoser1.error; + expect(foundLoser1.data).toBe(winner.data); + const loserNote = await store.getWorkingNotes("cluster", loser.data); + expect(loserNote.ok).toBe(true); + if (!loserNote.ok) throw loserNote.error; + expect(loserNote.data).toBeUndefined(); }); }); @@ -86,43 +141,63 @@ describe("D1Store.tryClaimNudge atomic claim (issue #8)", () => { const store = new D1Store(env.DB); const key = "u-1:o/r#claim:review_requested"; const wonFirst = await store.tryClaimNudge(nudge({ dedupeKey: key, escalations: 1 })); - expect(wonFirst).toBe(true); + expect(wonFirst.ok).toBe(true); + if (!wonFirst.ok) throw wonFirst.error; + expect(wonFirst.data).toBe(true); const wonSecond = await store.tryClaimNudge(nudge({ dedupeKey: key, escalations: 99 })); - expect(wonSecond).toBe(false); + expect(wonSecond.ok).toBe(true); + if (!wonSecond.ok) throw wonSecond.error; + expect(wonSecond.data).toBe(false); const persisted = await store.getNudgeByDedupeKey(key); - expect(persisted?.escalations).toBe(1); + expect(persisted.ok).toBe(true); + if (!persisted.ok) throw persisted.error; + expect(persisted.data?.escalations).toBe(1); }); it("upgrades an existing shadow row to a real send (the go-live path)", async () => { const store = new D1Store(env.DB); const key = "u-1:o/r#shadow:review_requested"; - await store.upsertNudge(nudge({ dedupeKey: key, state: "shadow" })); + const seeded = await store.upsertNudge(nudge({ dedupeKey: key, state: "shadow" })); + expect(seeded.ok).toBe(true); + if (!seeded.ok) throw seeded.error; const won = await store.tryClaimNudge(nudge({ dedupeKey: key, state: "sent" })); - expect(won).toBe(true); + expect(won.ok).toBe(true); + if (!won.ok) throw won.error; + expect(won.data).toBe(true); const persisted = await store.getNudgeByDedupeKey(key); - expect(persisted?.state).toBe("sent"); + expect(persisted.ok).toBe(true); + if (!persisted.ok) throw persisted.error; + expect(persisted.data?.state).toBe("sent"); const wonAgain = await store.tryClaimNudge(nudge({ dedupeKey: key, state: "sent" })); - expect(wonAgain).toBe(false); + expect(wonAgain.ok).toBe(true); + if (!wonAgain.ok) throw wonAgain.error; + expect(wonAgain.data).toBe(false); }); }); describe("D1Store.replaceLinksFrom", () => { it("replaces only links owned by the source thread", async () => { const store = new D1Store(env.DB); - await store.upsertLinks([ + const seeded = await store.upsertLinks([ { from: "links/source", to: "links/stale", kind: "refs" }, { from: "links/inbound", to: "links/source", kind: "refs" }, ]); + expect(seeded.ok).toBe(true); + if (!seeded.ok) throw seeded.error; - await store.replaceLinksFrom("links/source", [ + const replaced = await store.replaceLinksFrom("links/source", [ { from: "links/source", to: "links/fresh", kind: "refs" }, { from: "links/other", to: "links/ignored", kind: "refs" }, ]); + expect(replaced.ok).toBe(true); + if (!replaced.ok) throw replaced.error; const links = await store.getLinks("links/source"); - expect(links).toHaveLength(2); - expect(links).toContainEqual({ from: "links/inbound", to: "links/source", kind: "refs" }); - expect(links).toContainEqual({ from: "links/source", to: "links/fresh", kind: "refs" }); + expect(links.ok).toBe(true); + if (!links.ok) throw links.error; + expect(links.data).toHaveLength(2); + expect(links.data).toContainEqual({ from: "links/inbound", to: "links/source", kind: "refs" }); + expect(links.data).toContainEqual({ from: "links/source", to: "links/fresh", kind: "refs" }); }); }); @@ -133,15 +208,28 @@ describe("MergeRegistry.union (issue #8)", () => { const registry = env.MERGE_REGISTRY.get(registryId); const clusterA = await store.getOrCreateCluster("m/r#1"); + expect(clusterA.ok).toBe(true); + if (!clusterA.ok) throw clusterA.error; const clusterB = await store.getOrCreateCluster("m/r#2"); - const expectedWinner = clusterA < clusterB ? clusterA : clusterB; - const expectedLoser = clusterA < clusterB ? clusterB : clusterA; + expect(clusterB.ok).toBe(true); + if (!clusterB.ok) throw clusterB.error; + const expectedWinner = clusterA.data < clusterB.data ? clusterA.data : clusterB.data; + const expectedLoser = clusterA.data < clusterB.data ? clusterB.data : clusterA.data; const winner = await registry.union({ threadA: "m/r#1", threadB: "m/r#2" }); expect(winner).toBe(expectedWinner); - expect(await store.findCluster("m/r#1")).toBe(expectedWinner); - expect(await store.findCluster("m/r#2")).toBe(expectedWinner); - expect(await store.listClusterThreads(expectedLoser)).toEqual([]); + const foundA = await store.findCluster("m/r#1"); + expect(foundA.ok).toBe(true); + if (!foundA.ok) throw foundA.error; + expect(foundA.data).toBe(expectedWinner); + const foundB = await store.findCluster("m/r#2"); + expect(foundB.ok).toBe(true); + if (!foundB.ok) throw foundB.error; + expect(foundB.data).toBe(expectedWinner); + const loserMembers = await store.listClusterThreads(expectedLoser); + expect(loserMembers.ok).toBe(true); + if (!loserMembers.ok) throw loserMembers.error; + expect(loserMembers.data).toEqual([]); const repeat = await registry.union({ threadA: "m/r#1", threadB: "m/r#2" }); expect(repeat).toBe(expectedWinner); @@ -151,12 +239,22 @@ describe("MergeRegistry.union (issue #8)", () => { const store = new D1Store(env.DB); const registry = env.MERGE_REGISTRY.get(env.MERGE_REGISTRY.idFromName(MERGE_REGISTRY_KEY)); const shared = await store.getOrCreateCluster("m/r#same1"); - await store.repointCluster({ - fromClusterId: await store.getOrCreateCluster("m/r#same2"), - toClusterId: shared, + expect(shared.ok).toBe(true); + if (!shared.ok) throw shared.error; + const same2 = await store.getOrCreateCluster("m/r#same2"); + expect(same2.ok).toBe(true); + if (!same2.ok) throw same2.error; + const repointed = await store.repointCluster({ + fromClusterId: same2.data, + toClusterId: shared.data, }); + expect(repointed.ok).toBe(true); + if (!repointed.ok) throw repointed.error; const result = await registry.union({ threadA: "m/r#same1", threadB: "m/r#same2" }); - expect(result).toBe(shared); - expect(await store.findCluster("m/r#same2")).toBe(shared); + expect(result).toBe(shared.data); + const foundSame2 = await store.findCluster("m/r#same2"); + expect(foundSame2.ok).toBe(true); + if (!foundSame2.ok) throw foundSame2.error; + expect(foundSame2.data).toBe(shared.data); }); }); diff --git a/apps/worker/test/env.d.ts b/apps/worker/test/env.d.ts index a4b2565..4098159 100644 --- a/apps/worker/test/env.d.ts +++ b/apps/worker/test/env.d.ts @@ -3,6 +3,6 @@ import type { Env } from "../src/env.js"; declare module "cloudflare:test" { interface ProvidedEnv extends Env { - TEST_MIGRATIONS: D1Migration[]; + TEST_MIGRATIONS: Array; } } diff --git a/apps/worker/test/worker.test.ts b/apps/worker/test/worker.test.ts index 8f34aaa..103411e 100644 --- a/apps/worker/test/worker.test.ts +++ b/apps/worker/test/worker.test.ts @@ -19,42 +19,48 @@ describe("worker", () => { it("D1Store round-trips an identity against the migrated schema", async () => { const store = new D1Store(env.DB); - await store.upsertIdentity({ + const upserted = await store.upsertIdentity({ id: "u1", handles: { github: "octocat" }, email: "o@example.com", }); + expect(upserted.ok).toBe(true); const found = await store.findIdentity({ handle: "octocat" }); - expect(found?.id).toBe("u1"); + expect(found.ok).toBe(true); + expect(found.data?.id).toBe("u1"); }); }); describe("memberGate", () => { it("allows roster members and drops non-members by default", async () => { const gate = memberGate(gateEnv()); - expect(gate.required).toBe(true); - expect(await gate.allows("github", "octocat")).toBe(true); - expect(await gate.allows("slack", "U01ALICE")).toBe(true); - expect(await gate.allows("github", "randostranger")).toBe(false); - expect(await gate.allows("slack", "U99NOBODY")).toBe(false); - expect(await gate.allows("github", undefined)).toBe(false); + expect(gate.ok).toBe(true); + expect(gate.data!.required).toBe(true); + expect(await gate.data!.allows("github", "octocat")).toBe(true); + expect(await gate.data!.allows("slack", "U01ALICE")).toBe(true); + expect(await gate.data!.allows("github", "randostranger")).toBe(false); + expect(await gate.data!.allows("slack", "U99NOBODY")).toBe(false); + expect(await gate.data!.allows("github", undefined)).toBe(false); }); it("processes everyone when REQUIRE_MEMBER_TRIGGER=false", async () => { const gate = memberGate(gateEnv({ REQUIRE_MEMBER_TRIGGER: "false" })); - expect(gate.required).toBe(false); - expect(await gate.allows("github", "randostranger")).toBe(true); - expect(await gate.allows("github", undefined)).toBe(true); + expect(gate.ok).toBe(true); + expect(gate.data!.required).toBe(false); + expect(await gate.data!.allows("github", "randostranger")).toBe(true); + expect(await gate.data!.allows("github", undefined)).toBe(true); }); it("fails safe: an empty roster drops everyone while the gate is on", async () => { const gate = memberGate(gateEnv({ IDENTITY_ROSTER: "[]" })); - expect(await gate.allows("github", "octocat")).toBe(false); + expect(gate.ok).toBe(true); + expect(await gate.data!.allows("github", "octocat")).toBe(false); }); it("only matches a handle on its own platform", async () => { const gate = memberGate(gateEnv()); + expect(gate.ok).toBe(true); // octocat is a github handle, not a slack id. - expect(await gate.allows("slack", "octocat")).toBe(false); + expect(await gate.data!.allows("slack", "octocat")).toBe(false); }); }); diff --git a/apps/worker/vitest.config.ts b/apps/worker/vitest.config.ts index f8776fb..a16fc67 100644 --- a/apps/worker/vitest.config.ts +++ b/apps/worker/vitest.config.ts @@ -12,7 +12,9 @@ export default defineConfig(async () => { remoteBindings: false, wrangler: { configPath: "./wrangler.jsonc" }, miniflare: { - bindings: { TEST_MIGRATIONS: migrations }, + // A secret must be present so the github route exercises real signature + // verification; without it the route 500s on missing config, not 401. + bindings: { TEST_MIGRATIONS: migrations, GITHUB_WEBHOOK_SECRET: "test-webhook-secret" }, }, }), ], diff --git a/eslint.config.mjs b/eslint.config.mjs index 1a04524..05ec19c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -34,6 +34,7 @@ export default defineConfig( "error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, ], + "@typescript-eslint/array-type": ["error", { default: "generic" }], }, }, eslintPluginPrettierRecommended, diff --git a/packages/adapter-github/src/adapter.test.ts b/packages/adapter-github/src/adapter.test.ts index 8817b94..0ca93e5 100644 --- a/packages/adapter-github/src/adapter.test.ts +++ b/packages/adapter-github/src/adapter.test.ts @@ -41,8 +41,10 @@ describe("GitHubAdapter outbound", () => { }); const adapter = new GitHubAdapter({ token: "t", fetchImpl }); const res = await adapter.postMessage({ threadNativeId: "o/r#5" }, "hello"); + expect(res.ok).toBe(true); + if (!res.ok) throw res.error; - expect(res.id).toBe("https://api.github.com/repos/o/r/issues/comments/99"); + expect(res.data.id).toBe("https://api.github.com/repos/o/r/issues/comments/99"); expect(calls[0]).toMatchObject({ url: "https://api.github.com/repos/o/r/issues/5/comments", method: "POST", @@ -53,7 +55,12 @@ describe("GitHubAdapter outbound", () => { it("editMessage PATCHes the comment url in place", async () => { const { fetchImpl, calls } = recordingFetch({}); const adapter = new GitHubAdapter({ token: "t", fetchImpl }); - await adapter.editMessage("https://api.github.com/repos/o/r/issues/comments/99", "updated"); + const res = await adapter.editMessage( + "https://api.github.com/repos/o/r/issues/comments/99", + "updated", + ); + expect(res.ok).toBe(true); + if (!res.ok) throw res.error; expect(calls[0]).toMatchObject({ url: "https://api.github.com/repos/o/r/issues/comments/99", @@ -72,13 +79,18 @@ describe("GitHubAdapter outbound", () => { ]); const adapter = new GitHubAdapter({ token: "t", fetchImpl }); const id = await adapter.findStickyComment("o/r#5", ""); - expect(id).toBe("https://api.github.com/repos/o/r/issues/comments/2"); + expect(id.ok).toBe(true); + if (!id.ok) throw id.error; + expect(id.data).toBe("https://api.github.com/repos/o/r/issues/comments/2"); }); it("findStickyComment returns undefined when no comment matches", async () => { const { fetchImpl } = recordingFetch([{ url: "u1", body: "nope" }]); const adapter = new GitHubAdapter({ token: "t", fetchImpl }); - expect(await adapter.findStickyComment("o/r#5", "")).toBeUndefined(); + const id = await adapter.findStickyComment("o/r#5", ""); + expect(id.ok).toBe(true); + if (!id.ok) throw id.error; + expect(id.data).toBeUndefined(); }); }); @@ -106,8 +118,10 @@ describe("GitHubAdapter getThread", () => { const adapter = new GitHubAdapter({ token: "t", fetchImpl }); const thread = await adapter.getThread("acme-corp/web-backend#3809"); + expect(thread.ok).toBe(true); + if (!thread.ok) throw thread.error; - expect(thread).toMatchObject({ + expect(thread.data).toMatchObject({ platform: "github", nativeId: "acme-corp/web-backend#3809", type: "issue", diff --git a/packages/adapter-github/src/adapter.ts b/packages/adapter-github/src/adapter.ts index 330ee8b..b51c237 100644 --- a/packages/adapter-github/src/adapter.ts +++ b/packages/adapter-github/src/adapter.ts @@ -1,15 +1,15 @@ import { asyncUnfold, + Err, + Ok, Result, type Identity, - type Link, type NormalizedRef, type Platform, type PostTarget, type RawEvent, type Thread, type ThreadType, - type TimelineEvent, } from "@aipm/core"; import { discoverLinksFromGraphql } from "./discover-links.js"; import { ghGraphQL } from "./graphql.js"; @@ -25,12 +25,12 @@ import { ghRest, type GhRestOptions } from "./rest.js"; export interface GitHubAdapterConfig { /** Installation token, or a provider that mints/caches one (see auth.ts). */ - token: string | (() => Promise); + token: string | (() => Promise>); apiBaseUrl?: string; // default https://api.github.com /** Enable the regex link fallback (DESIGN §4). */ regexLinkFallback?: boolean; /** Automation logins excluded from participants. */ - botAccounts?: string[]; + botAccounts?: Array; /** Injectable for tests. */ fetchImpl?: typeof fetch; } @@ -54,39 +54,48 @@ export class GitHubAdapter implements Platform { return normalizeWebhookEvent(raw); } - async getThread(nativeId: string, hint?: ThreadType): Promise { - const { owner, repo, number } = parseNativeId(nativeId); + async getThread(nativeId: string, hint?: ThreadType) { + const parsedNativeId = parseNativeId(nativeId); + if (!parsedNativeId.ok) return parsedNativeId; + const { owner, repo, number } = parsedNativeId.data; const opts = { botAccounts: this.config.botAccounts }; if (hint === "issue") { - const node = await this.fetchNode(owner, repo, number, "issue"); - if (!node) throw new Error(`issue not found: ${nativeId}`); + const fetchedNode = await this.fetchNode(owner, repo, number, "issue"); + if (!fetchedNode.ok) return fetchedNode; + const node = fetchedNode.data; + if (!node) return Err(new Error(`issue not found: ${nativeId}`)); this.rawByNativeId.set(nativeId, node); - return normalizeIssueGraphql(node, `${owner}/${repo}`, opts); + return Ok(normalizeIssueGraphql(node, `${owner}/${repo}`, opts)); } // hint 'pr' or unknown: try PR first, fall back to issue. GitHub GraphQL // returns an error, not null, when a number exists as an issue but not a PR. const pr = await this.fetchNodeIfExists(owner, repo, number, "pr"); - if (pr) { - this.rawByNativeId.set(nativeId, pr); - return normalizePrGraphql(pr, `${owner}/${repo}`, opts); + if (!pr.ok) return pr; + if (pr.data) { + this.rawByNativeId.set(nativeId, pr.data); + return Ok(normalizePrGraphql(pr.data, `${owner}/${repo}`, opts)); } - const issue = await this.fetchNode(owner, repo, number, "issue"); - if (!issue) throw new Error(`thread not found: ${nativeId}`); + const fetchedIssue = await this.fetchNode(owner, repo, number, "issue"); + if (!fetchedIssue.ok) return fetchedIssue; + const issue = fetchedIssue.data; + if (!issue) return Err(new Error(`thread not found: ${nativeId}`)); this.rawByNativeId.set(nativeId, issue); - return normalizeIssueGraphql(issue, `${owner}/${repo}`, opts); + return Ok(normalizeIssueGraphql(issue, `${owner}/${repo}`, opts)); } - async getTimeline(nativeId: string): Promise { - const thread = await this.getThread(nativeId); - return thread.timeline; + async getTimeline(nativeId: string) { + const fetchedThread = await this.getThread(nativeId); + if (!fetchedThread.ok) return fetchedThread; + return Ok(fetchedThread.data.timeline); } - async listThreads(query: Record): Promise { + async listThreads(query: Record) { const owner = String(query.owner); const repo = String(query.repo); const token = await this.resolveToken(); + if (!token.ok) return token; const repoFull = `${owner}/${repo}`; // Shallow Threads for sweeps; per-thread ingest does the full fetch. @@ -96,12 +105,14 @@ export class GitHubAdapter implements Platform { acc: [], }; return asyncUnfold(seed, async (state) => { - const data = await ghGraphQL( - token, + const dataResult = await ghGraphQL( + token.data, LIST_THREADS_BY_REPO, { owner, repo, issuesAfter: state.issuesAfter, prsAfter: state.prsAfter }, { apiBaseUrl: this.config.apiBaseUrl, fetchImpl: this.config.fetchImpl }, ); + if (!dataResult.ok) return { kind: "STOP", value: dataResult }; + const data = dataResult.data; const issues = data.repository?.issues; const prs = data.repository?.pullRequests; const issueThreads = (issues?.nodes ?? []).map((n) => { @@ -114,87 +125,110 @@ export class GitHubAdapter implements Platform { const issuesAfter = issues?.pageInfo?.hasNextPage ? issues.pageInfo.endCursor : undefined; const prsAfter = prs?.pageInfo?.hasNextPage ? prs.pageInfo.endCursor : undefined; const hasMore = issuesAfter || prsAfter; - if (!hasMore) return { kind: "STOP", value: acc }; + if (!hasMore) return { kind: "STOP", value: Ok(acc) }; return { kind: "CONTINUE", next: { issuesAfter, prsAfter, acc } }; }); } - async discoverLinks(thread: Thread): Promise { + async discoverLinks(thread: Thread) { const raw = this.rawByNativeId.get(thread.nativeId); const nativeLinks = raw ? discoverLinksFromGraphql(thread.nativeId, raw) : []; - if (!this.config.regexLinkFallback) return nativeLinks; - const { owner, repo } = parseNativeId(thread.nativeId); + if (!this.config.regexLinkFallback) return Ok(nativeLinks); + const parsedNativeId = parseNativeId(thread.nativeId); + if (!parsedNativeId.ok) return parsedNativeId; + const { owner, repo } = parsedNativeId.data; const text = `${thread.title ?? ""}\n${thread.body ?? ""}`; const seen = new Set(nativeLinks.map((l) => `${l.to}:${l.kind}`)); const textLinks = discoverLinksFromText(thread.nativeId, text, expandRef(`${owner}/${repo}`)); const extraLinks = textLinks.flatMap((l) => { return seen.has(`${l.to}:${l.kind}`) ? [] : [l]; }); - return [...nativeLinks, ...extraLinks]; + return Ok([...nativeLinks, ...extraLinks]); } // --- outbound --- /** Create a comment; returns its REST url as the opaque message id. */ - async postMessage(target: PostTarget, body: string): Promise<{ id: string }> { - if (!target.threadNativeId) throw new Error("postMessage requires target.threadNativeId"); - const { owner, repo, number } = parseNativeId(target.threadNativeId); - const resp = await ghRest<{ url: string }>( - await this.resolveToken(), + async postMessage(target: PostTarget, body: string) { + if (!target.threadNativeId) { + return Err(new Error("postMessage requires target.threadNativeId")); + } + const threadNativeId = target.threadNativeId; + const parsedNativeId = parseNativeId(threadNativeId); + if (!parsedNativeId.ok) return parsedNativeId; + const { owner, repo, number } = parsedNativeId.data; + const token = await this.resolveToken(); + if (!token.ok) return token; + const response = await ghRest<{ url: string }>( + token.data, "POST", `/repos/${owner}/${repo}/issues/${number}/comments`, { body }, this.restOpts(), ); - return { id: resp.url }; + if (!response.ok) return response; + return Ok({ id: response.data.url }); } /** Edit the sticky comment in place. `messageId` is the comment REST url. */ - async editMessage(messageId: string, body: string): Promise { - await ghRest(await this.resolveToken(), "PATCH", messageId, { body }, this.restOpts()); + async editMessage(messageId: string, body: string) { + const token = await this.resolveToken(); + if (!token.ok) return token; + const edited = await ghRest(token.data, "PATCH", messageId, { body }, this.restOpts()); + if (!edited.ok) return edited; + return Ok(undefined); } /** Find an existing comment containing the marker (the bot's sticky note). */ - async findStickyComment(threadNativeId: string, marker: string): Promise { - const { owner, repo, number } = parseNativeId(threadNativeId); + async findStickyComment(threadNativeId: string, marker: string) { + const parsedNativeId = parseNativeId(threadNativeId); + if (!parsedNativeId.ok) return parsedNativeId; + const { owner, repo, number } = parsedNativeId.data; const token = await this.resolveToken(); - const seed: number = 1; - return asyncUnfold(seed, async (page) => { - const comments = await ghRest>( - token, + if (!token.ok) return token; + const seed = 1; + return asyncUnfold(seed, async (page) => { + const commentsResult = await ghRest>( + token.data, "GET", `/repos/${owner}/${repo}/issues/${number}/comments?per_page=${COMMENTS_PAGE_SIZE}&page=${page}`, undefined, this.restOpts(), ); + if (!commentsResult.ok) return { kind: "STOP", value: commentsResult }; + const comments = commentsResult.data; const hit = comments.find((c) => c.body?.includes(marker)); - if (hit) return { kind: "STOP", value: hit.url }; + if (hit) return { kind: "STOP", value: Ok(hit.url) }; const isLastPage = comments.length < COMMENTS_PAGE_SIZE; - if (isLastPage) return { kind: "STOP", value: undefined }; + if (isLastPage) return { kind: "STOP", value: Ok(undefined) }; return { kind: "CONTINUE", next: page + 1 }; }); } /** `messageId` is the comment REST url; `emoji` is a GitHub reaction content. */ - async react(messageId: string, emoji: string): Promise { - await ghRest( - await this.resolveToken(), + async react(messageId: string, emoji: string) { + const token = await this.resolveToken(); + if (!token.ok) return token; + const reacted = await ghRest( + token.data, "POST", `${messageId}/reactions`, { content: emoji }, this.restOpts(), ); + if (!reacted.ok) return reacted; + return Ok(undefined); } - async notifyPerson(_identity: Identity, _body: string): Promise { + async notifyPerson(_identity: Identity, _body: string) { // GitHub has no DM; nudges go out via Slack (phase-3). - throw new Error("TODO(phase-3): GitHub has no DM channel"); + return Err(new Error("TODO(phase-3): GitHub has no DM channel")); } // --- internals --- - private resolveToken(): Promise { + private resolveToken() { return typeof this.config.token === "function" ? this.config.token() - : Promise.resolve(this.config.token); + : Promise.resolve(Ok(this.config.token)); } private restOpts(): GhRestOptions { @@ -202,32 +236,30 @@ export class GitHubAdapter implements Platform { } /** Fetch an issue/PR node with its timeline fully paginated, or undefined. */ - private async fetchNode( - owner: string, - repo: string, - number: number, - kind: "issue" | "pr", - ): Promise | undefined> { + private async fetchNode(owner: string, repo: string, number: number, kind: "issue" | "pr") { const token = await this.resolveToken(); + if (!token.ok) return token; const query = kind === "pr" ? GET_PULL_REQUEST : GET_ISSUE; const field = kind === "pr" ? "pullRequest" : "issue"; const seed: FetchNodeState = { cursor: undefined, acc: [] }; - return asyncUnfold | undefined>(seed, async (state) => { - const data = await ghGraphQL( - token, + return asyncUnfold(seed, async (state) => { + const dataResult = await ghGraphQL( + token.data, query, { owner, repo, number, timelineCount: TIMELINE_PAGE, afterTimeline: state.cursor }, { apiBaseUrl: this.config.apiBaseUrl, fetchImpl: this.config.fetchImpl }, ); + if (!dataResult.ok) return { kind: "STOP", value: dataResult }; + const data = dataResult.data; const fetched = data.repository?.[field] as Record | null | undefined; - if (!fetched) return { kind: "STOP", value: undefined }; + if (!fetched) return { kind: "STOP", value: Ok(undefined) }; const ti = fetched.timelineItems as TimelineConn | undefined; const acc = [...state.acc, ...(ti?.nodes ?? [])]; const cursor = ti?.pageInfo?.hasNextPage ? ti.pageInfo.endCursor : undefined; if (cursor) return { kind: "CONTINUE", next: { cursor, acc } }; fetched.timelineItems = { nodes: acc }; - return { kind: "STOP", value: fetched }; + return { kind: "STOP", value: Ok(fetched) }; }); } @@ -236,25 +268,25 @@ export class GitHubAdapter implements Platform { repo: string, number: number, kind: "issue" | "pr", - ): Promise | undefined> { - const fetched = await Result.from(() => this.fetchNode(owner, repo, number, kind)); - if (fetched.ok) return fetched.data; - if (isMissingNumberError(fetched.error, kind)) return undefined; - throw fetched.error; + ) { + const fetched = await this.fetchNode(owner, repo, number, kind); + if (fetched.ok) return Ok(fetched.data); + if (isMissingNumberError(fetched.error, kind)) return Ok(undefined); + return fetched; } } // --- shapes + helpers --------------------------------------------------------- interface TimelineConn { - nodes?: unknown[]; + nodes?: Array; pageInfo?: { hasNextPage?: boolean; endCursor?: string }; } interface RepoNodeData { repository?: Record | null; } interface RepoConn { - nodes?: T[]; + nodes?: Array; pageInfo?: { hasNextPage?: boolean; endCursor?: string }; } interface RepoThreadsData { diff --git a/packages/adapter-github/src/auth.test.ts b/packages/adapter-github/src/auth.test.ts index b459df3..0976660 100644 --- a/packages/adapter-github/src/auth.test.ts +++ b/packages/adapter-github/src/auth.test.ts @@ -45,8 +45,10 @@ describe("mintAppJwt", () => { it("produces an RS256 JWT verifiable against the public key with iat backdated", async () => { const { pem, publicKey } = await genKeyPair(); const now = 1_900_000_000_000; - const jwt = await mintAppJwt(pem, "client-123", now); - const [header, payload, sig] = jwt.split("."); + const jwtResult = await mintAppJwt(pem, "client-123", now); + expect(jwtResult.ok).toBe(true); + if (!jwtResult.ok) throw jwtResult.error; + const [header, payload, sig] = jwtResult.data.split("."); const ok = await crypto.subtle.verify( "RSASSA-PKCS1-v1_5", @@ -63,9 +65,12 @@ describe("mintAppJwt", () => { }); it("rejects a non-PKCS#8 key with a helpful error", async () => { - await expect( - mintAppJwt("-----BEGIN RSA PRIVATE KEY-----\nx\n-----END RSA PRIVATE KEY-----", "c"), - ).rejects.toThrow(/PKCS#8/); + const r = await mintAppJwt( + "-----BEGIN RSA PRIVATE KEY-----\nx\n-----END RSA PRIVATE KEY-----", + "c", + ); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.message).toMatch(/PKCS#8/); }); }); @@ -92,8 +97,12 @@ describe("installationTokenProvider", () => { now: () => now, }); - expect(await provider()).toBe("ghs_abc"); - expect(await provider()).toBe("ghs_abc"); + const first = await provider(); + expect(first.ok).toBe(true); + if (first.ok) expect(first.data).toBe("ghs_abc"); + const second = await provider(); + expect(second.ok).toBe(true); + if (second.ok) expect(second.data).toBe("ghs_abc"); expect(calls).toBe(1); // second call served from KV expect(kv.store.has("inst:555")).toBe(true); }); diff --git a/packages/adapter-github/src/auth.ts b/packages/adapter-github/src/auth.ts index 62630b7..f27e3a5 100644 --- a/packages/adapter-github/src/auth.ts +++ b/packages/adapter-github/src/auth.ts @@ -1,7 +1,7 @@ // GitHub App authentication using WebCrypto only (runs on Cloudflare Workers). // App JWT (RS256) -> installation access token -> KV-cached for ~1h. -import { Result } from "@aipm/core"; +import { Err, Ok, Result } from "@aipm/core"; /** Minimal KV surface so this module needn't depend on @cloudflare/workers-types. */ export interface KVLike { @@ -33,20 +33,24 @@ const SKEW_SECONDS = 300; // --- PEM / base64url ---------------------------------------------------------- -export function pkcs8PemToArrayBuffer(pem: string): ArrayBuffer { +export function pkcs8PemToArrayBuffer(pem: string): Result { if (!pem.includes("BEGIN PRIVATE KEY")) { - throw new Error( - "GITHUB_APP_PRIVATE_KEY must be PKCS#8 ('BEGIN PRIVATE KEY'). Convert GitHub's " + - "downloaded PKCS#1 key once with: openssl pkcs8 -topk8 -nocrypt -in key.pem", + return Err( + new Error( + "GITHUB_APP_PRIVATE_KEY must be PKCS#8 ('BEGIN PRIVATE KEY'). Convert GitHub's " + + "downloaded PKCS#1 key once with: openssl pkcs8 -topk8 -nocrypt -in key.pem", + ), ); } const b64 = pem .replace(/-----BEGIN PRIVATE KEY-----/, "") .replace(/-----END PRIVATE KEY-----/, "") .replace(/\s+/g, ""); - const bin = atob(b64); + const decoded = Result.fromSync(() => atob(b64)); + if (!decoded.ok) return decoded; + const bin = decoded.data; const buf = Uint8Array.from(bin, (ch) => ch.charCodeAt(0)); - return buf.buffer; + return Ok(buf.buffer); } const b64url = (bytes: ArrayBuffer | Uint8Array): string => { @@ -63,25 +67,29 @@ export async function mintAppJwt( pkcs8Pem: string, iss: string, now: number = Date.now(), -): Promise { - const key = await crypto.subtle.importKey( - "pkcs8", - pkcs8PemToArrayBuffer(pkcs8Pem), - { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, - false, - ["sign"], +): Promise> { + const pem = pkcs8PemToArrayBuffer(pkcs8Pem); + if (!pem.ok) return pem; + const key = await Result.from(() => + crypto.subtle.importKey( + "pkcs8", + pem.data, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["sign"], + ), ); + if (!key.ok) return key; const iat = Math.floor(now / 1000) - 60; // backdate for clock skew const exp = iat + 60 + 540; // <= 10 min const header = b64urlText(JSON.stringify({ alg: "RS256", typ: "JWT" })); const payload = b64urlText(JSON.stringify({ iat, exp, iss })); const signingInput = `${header}.${payload}`; - const sig = await crypto.subtle.sign( - "RSASSA-PKCS1-v1_5", - key, - new TextEncoder().encode(signingInput), + const sig = await Result.from(() => + crypto.subtle.sign("RSASSA-PKCS1-v1_5", key.data, new TextEncoder().encode(signingInput)), ); - return `${signingInput}.${b64url(sig)}`; + if (!sig.ok) return sig; + return Ok(`${signingInput}.${b64url(sig.data)}`); } // --- REST: installation id + token -------------------------------------------- @@ -98,28 +106,40 @@ export async function resolveRepoInstallationId( owner: string, repo: string, opts: { apiBaseUrl?: string; fetchImpl?: typeof fetch } = {}, -): Promise { +): Promise> { const base = opts.apiBaseUrl ?? DEFAULT_BASE; - const res = await (opts.fetchImpl ?? fetch)(`${base}/repos/${owner}/${repo}/installation`, { - headers: ghHeaders(appJwt), - }); - if (!res.ok) throw new Error(`installation lookup HTTP ${res.status}`); - return ((await res.json()) as { id: number }).id; + const fetched = await Result.from(() => + (opts.fetchImpl ?? fetch)(`${base}/repos/${owner}/${repo}/installation`, { + headers: ghHeaders(appJwt), + }), + ); + if (!fetched.ok) return fetched; + const res = fetched.data; + if (!res.ok) return Err(new Error(`installation lookup HTTP ${res.status}`)); + const parsed = await Result.from(() => res.json()); + if (!parsed.ok) return parsed; + return Ok((parsed.data as { id: number }).id); } export async function mintInstallationToken( appJwt: string, installationId: number, opts: { apiBaseUrl?: string; fetchImpl?: typeof fetch } = {}, -): Promise { +): Promise> { const base = opts.apiBaseUrl ?? DEFAULT_BASE; - const res = await (opts.fetchImpl ?? fetch)( - `${base}/app/installations/${installationId}/access_tokens`, - { method: "POST", headers: ghHeaders(appJwt) }, + const fetched = await Result.from(() => + (opts.fetchImpl ?? fetch)(`${base}/app/installations/${installationId}/access_tokens`, { + method: "POST", + headers: ghHeaders(appJwt), + }), ); - if (!res.ok) throw new Error(`installation token HTTP ${res.status}`); - const json = (await res.json()) as { token: string; expires_at: string }; - return { token: json.token, expiresAt: json.expires_at }; + if (!fetched.ok) return fetched; + const res = fetched.data; + if (!res.ok) return Err(new Error(`installation token HTTP ${res.status}`)); + const parsed = await Result.from(() => res.json()); + if (!parsed.ok) return parsed; + const json = parsed.data as { token: string; expires_at: string }; + return Ok({ token: json.token, expiresAt: json.expires_at }); } // --- Provider (KV-cached) ----------------------------------------------------- @@ -131,28 +151,35 @@ export async function mintInstallationToken( */ export function installationTokenProvider( config: InstallationTokenProviderConfig, -): () => Promise { +): () => Promise> { const now = config.now ?? Date.now; const key = `inst:${config.installationId}`; return async () => { - const cached = await config.kv.get(key); + const cachedResult = await Result.from(() => config.kv.get(key)); + if (!cachedResult.ok) return cachedResult; + const cached = cachedResult.data; if (cached) { // Treat a corrupt/unparseable entry as a miss rather than wedging forever. const parsed = Result.fromSync(() => JSON.parse(cached) as CachedToken); if (parsed.ok) { const { token, expiresAt } = parsed.data; - if (token && (Date.parse(expiresAt) - now()) / 1000 > SKEW_SECONDS) return token; + if (token && (Date.parse(expiresAt) - now()) / 1000 > SKEW_SECONDS) return Ok(token); } } const jwt = await mintAppJwt(config.privateKeyPem, config.clientId, now()); - const fresh = await mintInstallationToken(jwt, config.installationId, { + if (!jwt.ok) return jwt; + const fresh = await mintInstallationToken(jwt.data, config.installationId, { apiBaseUrl: config.apiBaseUrl, fetchImpl: config.fetchImpl, }); - const ttl = Math.floor((Date.parse(fresh.expiresAt) - now()) / 1000) - SKEW_SECONDS; - await config.kv.put(key, JSON.stringify(fresh), { expirationTtl: Math.max(60, ttl) }); - return fresh.token; + if (!fresh.ok) return fresh; + const ttl = Math.floor((Date.parse(fresh.data.expiresAt) - now()) / 1000) - SKEW_SECONDS; + const cachedFresh = await Result.from(() => + config.kv.put(key, JSON.stringify(fresh.data), { expirationTtl: Math.max(60, ttl) }), + ); + if (!cachedFresh.ok) return cachedFresh; + return Ok(fresh.data.token); }; } diff --git a/packages/adapter-github/src/discover-links.ts b/packages/adapter-github/src/discover-links.ts index 84b5d19..a4b4afb 100644 --- a/packages/adapter-github/src/discover-links.ts +++ b/packages/adapter-github/src/discover-links.ts @@ -13,7 +13,7 @@ import type { Link, LinkKind } from "@aipm/core"; export function discoverLinksFromGraphql( fromNativeId: string, node: Record, -): Link[] { +): Array { const links = new Map(); const key = (l: Link) => `${l.from}|${l.to}|${l.kind}`; const add = (from: string | undefined, to: string | undefined, kind: LinkKind) => { @@ -59,7 +59,7 @@ export function linkNativeId(repoNameWithOwner: string, number: number): string return `${repoNameWithOwner}#${number}`; } -const conn = (node: Record, field: string): unknown[] => { +const conn = (node: Record, field: string): Array => { const nodes = (node[field] as { nodes?: unknown } | undefined)?.nodes; return Array.isArray(nodes) ? nodes : []; }; diff --git a/packages/adapter-github/src/graphql.ts b/packages/adapter-github/src/graphql.ts index 1586de6..c6769cc 100644 --- a/packages/adapter-github/src/graphql.ts +++ b/packages/adapter-github/src/graphql.ts @@ -1,3 +1,5 @@ +import { Err, Ok, Result } from "@aipm/core"; + export interface GhGraphQLOptions { apiBaseUrl?: string; // default https://api.github.com /** Injectable for tests. */ @@ -11,37 +13,47 @@ interface GraphQLError { /** * Minimal GitHub GraphQL client. Sends the `sub_issues` feature header (harmless - * once GA, required while in preview) and throws on transport or `errors`. + * once GA, required while in preview) and returns `Err` on transport or `errors`. */ export async function ghGraphQL( token: string, query: string, variables: Record, opts: GhGraphQLOptions = {}, -): Promise { +): Promise> { const base = opts.apiBaseUrl ?? "https://api.github.com"; const doFetch = opts.fetchImpl ?? fetch; + const requestBody = Result.fromSync(() => JSON.stringify({ query, variables })); + if (!requestBody.ok) return requestBody; - const res = await doFetch(`${base}/graphql`, { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - "User-Agent": "aipm-worker", - Accept: "application/vnd.github+json", - "GraphQL-Features": "sub_issues", - }, - body: JSON.stringify({ query, variables }), - }); + const fetched = await Result.from(() => + doFetch(`${base}/graphql`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "User-Agent": "aipm-worker", + Accept: "application/vnd.github+json", + "GraphQL-Features": "sub_issues", + }, + body: requestBody.data, + }), + ); + if (!fetched.ok) return fetched; + const res = fetched.data; if (!res.ok) { - throw new Error(`GitHub GraphQL HTTP ${res.status}: ${await res.text().catch(() => "")}`); + const bodyText = await Result.from(() => res.text()); + const detail = bodyText.ok ? bodyText.data : ""; + return Err(new Error(`GitHub GraphQL HTTP ${res.status}: ${detail}`)); } - const json = (await res.json()) as { data?: T; errors?: GraphQLError[] }; + const parsed = await Result.from(() => res.json()); + if (!parsed.ok) return parsed; + const json = parsed.data as { data?: T; errors?: Array }; if (json.errors?.length) { - throw new Error(`GitHub GraphQL errors: ${json.errors.map((e) => e.message).join("; ")}`); + return Err(new Error(`GitHub GraphQL errors: ${json.errors.map((e) => e.message).join("; ")}`)); } - if (json.data === undefined) throw new Error("GitHub GraphQL: empty response"); - return json.data; + if (json.data === undefined) return Err(new Error("GitHub GraphQL: empty response")); + return Ok(json.data); } diff --git a/packages/adapter-github/src/links.ts b/packages/adapter-github/src/links.ts index 59ac934..b4f3a99 100644 --- a/packages/adapter-github/src/links.ts +++ b/packages/adapter-github/src/links.ts @@ -12,8 +12,8 @@ export function discoverLinksFromText( fromNativeId: string, text: string, resolveRef: (ref: string) => string = (r) => r, -): Link[] { - const links: Link[] = []; +): Array { + const links: Array = []; const seen = new Set(); const push = (ref: string, kind: LinkKind) => { const to = resolveRef(ref); diff --git a/packages/adapter-github/src/normalize.ts b/packages/adapter-github/src/normalize.ts index 5d07368..3635374 100644 --- a/packages/adapter-github/src/normalize.ts +++ b/packages/adapter-github/src/normalize.ts @@ -1,4 +1,13 @@ -import type { NormalizedRef, RawEvent, Thread, ThreadType, TimelineEvent } from "@aipm/core"; +import { + Err, + Ok, + Result, + type NormalizedRef, + type RawEvent, + type Thread, + type ThreadType, + type TimelineEvent, +} from "@aipm/core"; // --- helpers ------------------------------------------------------------------ @@ -8,13 +17,13 @@ export interface ParsedNativeId { number: number; } -export function parseNativeId(nativeId: string): ParsedNativeId { +export function parseNativeId(nativeId: string): Result { const m = /^([^/]+)\/([^#]+)#(\d+)$/.exec(nativeId); - if (!m) throw new Error(`unparseable GitHub nativeId: ${nativeId}`); - return { owner: m[1]!, repo: m[2]!, number: Number(m[3]) }; + if (!m) return Err(new Error(`unparseable GitHub nativeId: ${nativeId}`)); + return Ok({ owner: m[1]!, repo: m[2]!, number: Number(m[3]) }); } -export function isBotLogin(login: string | undefined, botAccounts: string[] = []): boolean { +export function isBotLogin(login: string | undefined, botAccounts: Array = []): boolean { if (!login) return true; const normalized = login.toLowerCase(); return normalized.endsWith("[bot]") || botAccounts.includes(normalized); @@ -33,16 +42,16 @@ const MENTION = /(? s.replace(/[‐‑]/g, "-"); /** Extract @-mention logins from a comment/review body (deduped), or undefined. */ -export function mentionsOf(body: unknown): string[] | undefined { +export function mentionsOf(body: unknown): Array | undefined { if (typeof body !== "string") return undefined; const matches = [...foldHyphens(body).matchAll(MENTION)]; const out = new Set(matches.flatMap((m) => (m[1] ? [m[1]] : []))); return out.size ? [...out] : undefined; } -const nodesOf = (conn: unknown): T[] => { +const nodesOf = (conn: unknown): Array => { const nodes = (conn as { nodes?: unknown } | null | undefined)?.nodes; - return Array.isArray(nodes) ? (nodes as T[]) : []; + return Array.isArray(nodes) ? (nodes as Array) : []; }; // --- webhook event -> routable ref -------------------------------------------- @@ -91,7 +100,7 @@ export function normalizeWebhookEvent(raw: RawEvent): NormalizedRef | undefined // --- GraphQL node -> Thread --------------------------------------------------- export interface NormalizeOptions { - botAccounts?: string[]; + botAccounts?: Array; } export function issueState(node: { state?: string }): string { @@ -114,7 +123,7 @@ export function prState(node: { export function collectParticipantLogins( node: Record, opts: NormalizeOptions = {}, -): string[] { +): Array { const bots = opts.botAccounts ?? []; const out = new Set(); const add = (login: string | undefined) => { @@ -143,7 +152,7 @@ const clean = >(obj: T): Partial => /** Map filtered timeline union nodes to TimelineEvents. Link-only nodes (cross-ref, * connected, sub-issue, blocked-by, …) are dropped here and handled by discoverLinks. */ -export function normalizeTimeline(nodes: Array>): TimelineEvent[] { +export function normalizeTimeline(nodes: Array>): Array { return nodes.flatMap((n) => { const actor = loginOf(n.actor) ?? loginOf(n.author); const at = (n.createdAt ?? n.submittedAt) as string | undefined; diff --git a/packages/adapter-github/src/rest.ts b/packages/adapter-github/src/rest.ts index c9ecf58..b47df36 100644 --- a/packages/adapter-github/src/rest.ts +++ b/packages/adapter-github/src/rest.ts @@ -1,3 +1,5 @@ +import { Err, Ok, Result } from "@aipm/core"; + export interface GhRestOptions { apiBaseUrl?: string; // default https://api.github.com fetchImpl?: typeof fetch; @@ -10,26 +12,34 @@ export async function ghRest( pathOrUrl: string, body?: unknown, opts: GhRestOptions = {}, -): Promise { +): Promise> { const base = opts.apiBaseUrl ?? "https://api.github.com"; const url = pathOrUrl.startsWith("http") ? pathOrUrl : `${base}${pathOrUrl}`; - const res = await (opts.fetchImpl ?? fetch)(url, { - method, - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/vnd.github+json", - "User-Agent": "aipm-worker", - "X-GitHub-Api-Version": "2022-11-28", - ...(body !== undefined ? { "Content-Type": "application/json" } : {}), - }, - body: body !== undefined ? JSON.stringify(body) : undefined, - }); + const requestBody = body !== undefined ? Result.fromSync(() => JSON.stringify(body)) : null; + if (requestBody && !requestBody.ok) return requestBody; + const fetched = await Result.from(() => + (opts.fetchImpl ?? fetch)(url, { + method, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "User-Agent": "aipm-worker", + "X-GitHub-Api-Version": "2022-11-28", + ...(body !== undefined ? { "Content-Type": "application/json" } : {}), + }, + body: requestBody ? requestBody.data : undefined, + }), + ); + if (!fetched.ok) return fetched; + const res = fetched.data; if (!res.ok) { const msg = `GitHub REST ${method} ${url} HTTP ${res.status}: ${await res.text().catch(() => "")}`; // Attach status structurally so callers can branch (e.g. 404 deleted comment) // without importing this module's types. - throw Object.assign(new Error(msg), { status: res.status }); + return Err(Object.assign(new Error(msg), { status: res.status })); } - if (res.status === 204) return undefined as T; - return (await res.json()) as T; + if (res.status === 204) return Ok(undefined as T); + const parsed = await Result.from(() => res.json()); + if (!parsed.ok) return parsed; + return Ok(parsed.data as T); } diff --git a/packages/adapter-github/src/webhook.ts b/packages/adapter-github/src/webhook.ts index f83be53..6569b8c 100644 --- a/packages/adapter-github/src/webhook.ts +++ b/packages/adapter-github/src/webhook.ts @@ -1,3 +1,5 @@ +import { Ok, Result } from "@aipm/core"; + /** * Verify a GitHub webhook signature (X-Hub-Signature-256: "sha256="). * Uses Web Crypto (available in Workers); constant-time comparison. @@ -6,20 +8,26 @@ export async function verifyWebhook( secret: string, payload: string, signatureHeader: string | null, -): Promise { - if (!signatureHeader?.startsWith("sha256=")) return false; +): Promise> { + if (!signatureHeader?.startsWith("sha256=")) return Ok(false); const expected = signatureHeader.slice("sha256=".length).toLowerCase(); - const key = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"], + const key = await Result.from(() => + crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ), + ); + if (!key.ok) return key; + const sig = await Result.from(() => + crypto.subtle.sign("HMAC", key.data, new TextEncoder().encode(payload)), ); - const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload)); - const actual = [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join(""); - return timingSafeEqual(actual, expected); + if (!sig.ok) return sig; + const actual = [...new Uint8Array(sig.data)].map((b) => b.toString(16).padStart(2, "0")).join(""); + return Ok(timingSafeEqual(actual, expected)); } function timingSafeEqual(a: string, b: string): boolean { diff --git a/packages/adapter-llm/src/index.test.ts b/packages/adapter-llm/src/index.test.ts index 4d8eb62..67baacd 100644 --- a/packages/adapter-llm/src/index.test.ts +++ b/packages/adapter-llm/src/index.test.ts @@ -34,7 +34,10 @@ describe("extractText", () => { describe("EchoLlmAdapter", () => { it("echoes the prompt for deterministic shadow runs", async () => { const llm = new EchoLlmAdapter(); - expect(await llm.complete("hello")).toBe("hello"); + const r = await llm.complete("hello"); + expect(r.ok).toBe(true); + if (!r.ok) throw r.error; + expect(r.data).toBe("hello"); }); }); @@ -59,8 +62,14 @@ describe("BudgetedLlmAdapter", () => { perDay: 100, now, }); - expect(await llm.complete("hi")).toBe("hi"); - expect(await llm.complete("yo")).toBe("yo"); + const hi = await llm.complete("hi"); + expect(hi.ok).toBe(true); + if (!hi.ok) throw hi.error; + expect(hi.data).toBe("hi"); + const yo = await llm.complete("yo"); + expect(yo.ok).toBe(true); + if (!yo.ok) throw yo.error; + expect(yo.data).toBe("yo"); expect([...map.values()]).toEqual(["2", "2"]); // minute + day buckets both at 2 }); @@ -73,9 +82,15 @@ describe("BudgetedLlmAdapter", () => { perDay: 100, now, }); - await llm.complete("a"); - await llm.complete("b"); - await expect(llm.complete("c")).rejects.toBeInstanceOf(LlmBudgetExceededError); + const a = await llm.complete("a"); + expect(a.ok).toBe(true); + if (!a.ok) throw a.error; + const b = await llm.complete("b"); + expect(b.ok).toBe(true); + if (!b.ok) throw b.error; + const r = await llm.complete("c"); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toBeInstanceOf(LlmBudgetExceededError); }); it("recovers in the next minute bucket", async () => { @@ -87,10 +102,17 @@ describe("BudgetedLlmAdapter", () => { perDay: 100, now: () => t, }); - await llm.complete("a"); - await expect(llm.complete("b")).rejects.toBeInstanceOf(LlmBudgetExceededError); + const a = await llm.complete("a"); + expect(a.ok).toBe(true); + if (!a.ok) throw a.error; + const b = await llm.complete("b"); + expect(b.ok).toBe(false); + if (!b.ok) expect(b.error).toBeInstanceOf(LlmBudgetExceededError); t = new Date("2026-06-21T12:01:00Z"); - expect(await llm.complete("c")).toBe("c"); + const c = await llm.complete("c"); + expect(c.ok).toBe(true); + if (!c.ok) throw c.error; + expect(c.data).toBe("c"); }); it("enforces the per-day window across minutes", async () => { @@ -102,11 +124,17 @@ describe("BudgetedLlmAdapter", () => { perDay: 2, now: () => t, }); - await llm.complete("a"); + const a = await llm.complete("a"); + expect(a.ok).toBe(true); + if (!a.ok) throw a.error; t = new Date("2026-06-21T12:05:00Z"); - await llm.complete("b"); + const b = await llm.complete("b"); + expect(b.ok).toBe(true); + if (!b.ok) throw b.error; t = new Date("2026-06-21T13:00:00Z"); - await expect(llm.complete("c")).rejects.toThrow(/day limit/); + const r = await llm.complete("c"); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.message).toMatch(/day limit/); }); it("does not consume a minute slot when the day budget is already full", async () => { @@ -118,9 +146,13 @@ describe("BudgetedLlmAdapter", () => { perDay: 1, now, }); - await llm.complete("a"); // day bucket -> 1 + const a = await llm.complete("a"); // day bucket -> 1 + expect(a.ok).toBe(true); + if (!a.ok) throw a.error; const minuteBefore = map.get("llm:budget:2026-06-21T12:00"); - await expect(llm.complete("b")).rejects.toBeInstanceOf(LlmBudgetExceededError); + const r = await llm.complete("b"); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toBeInstanceOf(LlmBudgetExceededError); expect(map.get("llm:budget:2026-06-21T12:00")).toBe(minuteBefore); // unchanged }); @@ -133,6 +165,11 @@ describe("BudgetedLlmAdapter", () => { perDay: 0, now, }); - for (let i = 0; i < 50; i++) expect(await llm.complete("x")).toBe("x"); + for (let i = 0; i < 50; i++) { + const r = await llm.complete("x"); + expect(r.ok).toBe(true); + if (!r.ok) throw r.error; + expect(r.data).toBe("x"); + } }); }); diff --git a/packages/adapter-llm/src/index.ts b/packages/adapter-llm/src/index.ts index d18ae33..abc9ec9 100644 --- a/packages/adapter-llm/src/index.ts +++ b/packages/adapter-llm/src/index.ts @@ -1,4 +1,12 @@ -import type { LlmAdapter, LlmOptions } from "@aipm/core"; +import { + asyncForEach, + asyncMap, + Err, + Ok, + Result, + type LlmAdapter, + type LlmOptions, +} from "@aipm/core"; export interface WorkersAiConfig { ai: Ai; @@ -18,30 +26,34 @@ export interface WorkersAiConfig { export class WorkersAiLlmAdapter implements LlmAdapter { constructor(private readonly config: WorkersAiConfig) {} - async complete(prompt: string, opts: LlmOptions = {}): Promise { + async complete(prompt: string, opts: LlmOptions = {}) { const messages = [ ...(opts.system ? [{ role: "system" as const, content: opts.system }] : []), { role: "user" as const, content: prompt }, ]; - const completion = this.config.ai.run( - // workers-types types `model` as a known union; deployments may use any. - this.config.model as never, - { - messages, - max_tokens: opts.maxTokens ?? this.config.defaultMaxTokens ?? 1024, - temperature: opts.temperature, - } as never, - this.config.gatewayId - ? { gateway: { id: this.config.gatewayId, cacheKey: opts.cacheKey } } - : undefined, + const completion = Result.from(() => + this.config.ai.run( + // workers-types types `model` as a known union; deployments may use any. + this.config.model as never, + { + messages, + max_tokens: opts.maxTokens ?? this.config.defaultMaxTokens ?? 1024, + temperature: opts.temperature, + } as never, + this.config.gatewayId + ? { gateway: { id: this.config.gatewayId, cacheKey: opts.cacheKey } } + : undefined, + ), ); const timedOut = Symbol("llm-timeout"); const timer = new Promise((resolve) => setTimeout(() => resolve(timedOut), this.config.requestTimeoutMs), ); const res = await Promise.race([completion, timer]); - if (res === timedOut) return ""; - return extractText(res); + if (res === timedOut) return Ok(""); + if (!res.ok) return res; + const text = extractText(res.data); + return Ok(text); } } @@ -60,7 +72,9 @@ export function extractText(res: unknown): string { const output = r.output as Array>; const parts = output.flatMap((item) => { if (item?.type === "reasoning") return []; // skip chain-of-thought items - const content = (item?.content as Array>) ?? []; + const content = Array.isArray(item?.content) + ? (item.content as Array>) + : []; return content.flatMap((c) => (typeof c?.text === "string" ? [c.text] : [])); }); if (parts.length) return parts.join(""); @@ -75,8 +89,8 @@ export function extractText(res: unknown): string { /** A deterministic stub for tests / shadow runs without an AI binding. */ export class EchoLlmAdapter implements LlmAdapter { - async complete(prompt: string): Promise { - return prompt; + async complete(prompt: string) { + return Ok(prompt); } } @@ -137,7 +151,7 @@ export class BudgetedLlmAdapter implements LlmAdapter { private readonly config: BudgetConfig, ) {} - async complete(prompt: string, opts?: LlmOptions): Promise { + async complete(prompt: string, opts?: LlmOptions) { const now = (this.config.now ?? (() => new Date()))(); const windows = ( [ @@ -146,26 +160,35 @@ export class BudgetedLlmAdapter implements LlmAdapter { ] as const ).filter((w) => w.limit > 0); - const checked = await Promise.all( - windows.map(async (w) => ({ ...w, count: await this.read(w.key) })), - ); + const checkedResults = await asyncMap(windows, async (w) => { + const countResult = await this.read(w.key); + if (!countResult.ok) return countResult; + const count = countResult.data; + return Ok({ ...w, count }); + }); + const checkedErrors = checkedResults.flatMap((it) => (it.ok ? [] : [it])); + const firstCheckedError = checkedErrors[0]; + if (firstCheckedError) return firstCheckedError; + const checkedWindows = checkedResults.flatMap((it) => (it.ok ? [it.data] : [])); // Check every window before reserving any, so an exhausted day-budget doesn't // burn a minute-slot (and vice versa). - checked.forEach((w) => { - if (w.count >= w.limit) throw new LlmBudgetExceededError(w.name, w.limit); - }); - await Promise.all( - checked.map((w) => - this.config.store.put(w.key, String(w.count + 1), { expirationTtl: w.ttl }), - ), + const exceeded = checkedWindows.find((w) => w.count >= w.limit); + if (exceeded) return Err(new LlmBudgetExceededError(exceeded.name, exceeded.limit)); + const reserved = await Result.from(() => + asyncForEach(checkedWindows, async (w) => { + await this.config.store.put(w.key, String(w.count + 1), { expirationTtl: w.ttl }); + }), ); + if (!reserved.ok) return reserved; return this.inner.complete(prompt, opts); } - private async read(key: string): Promise { - const raw = await this.config.store.get(key); + private async read(key: string) { + const loaded = await Result.from(() => this.config.store.get(key)); + if (!loaded.ok) return loaded; + const raw = loaded.data; const n = Number(raw); - return Number.isFinite(n) && n > 0 ? n : 0; + return Ok(Number.isFinite(n) && n > 0 ? n : 0); } } diff --git a/packages/adapter-slack/src/adapter.test.ts b/packages/adapter-slack/src/adapter.test.ts index 4442e26..96be853 100644 --- a/packages/adapter-slack/src/adapter.test.ts +++ b/packages/adapter-slack/src/adapter.test.ts @@ -18,7 +18,9 @@ describe("SlackAdapter.notifyPerson", () => { { ok: true }, ]); const slack = new SlackAdapter({ botToken: "xoxb", fetchImpl }); - await slack.notifyPerson({ id: "u1", handles: { slack: "U1" } }, "hi there"); + const r = await slack.notifyPerson({ id: "u1", handles: { slack: "U1" } }, "hi there"); + expect(r.ok).toBe(true); + if (!r.ok) throw r.error; expect(calls[0]).toMatchObject({ url: "https://slack.com/api/conversations.open", @@ -30,12 +32,12 @@ describe("SlackAdapter.notifyPerson", () => { }); }); - it("throws without a resolved Slack id", async () => { + it("returns an Err without a resolved Slack id", async () => { const { fetchImpl } = scriptedFetch([]); const slack = new SlackAdapter({ botToken: "xoxb", fetchImpl }); - await expect(slack.notifyPerson({ id: "u1", handles: {} }, "hi")).rejects.toThrow( - /no Slack id/, - ); + const r = await slack.notifyPerson({ id: "u1", handles: {} }, "hi"); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.message).toMatch(/no Slack id/); }); }); @@ -45,7 +47,9 @@ describe("SlackAdapter.postMessage", () => { const slack = new SlackAdapter({ botToken: "xoxb", fetchImpl }); const res = await slack.postMessage({ meta: { channelId: "C123" } }, "daily pulse"); - expect(res).toEqual({ id: "C123/1782220000.000100" }); + expect(res.ok).toBe(true); + if (!res.ok) throw res.error; + expect(res.data).toEqual({ id: "C123/1782220000.000100" }); expect(calls[0]).toMatchObject({ url: "https://slack.com/api/chat.postMessage", body: { channel: "C123", text: "daily pulse" }, @@ -77,7 +81,10 @@ describe("SlackAdapter slack-as-thread", () => { }, ]); const slack = new SlackAdapter({ botToken: "x", fetchImpl }); - const t = await slack.getThread("C1/1700.0001"); + const r = await slack.getThread("C1/1700.0001"); + expect(r.ok).toBe(true); + if (!r.ok) throw r.error; + const t = r.data; expect(t).toMatchObject({ platform: "slack", type: "slack_thread", nativeId: "C1/1700.0001" }); expect(t.participants.sort()).toEqual(["U1", "U2"]); // bot excluded expect(t.timeline[1]?.data.mentions).toEqual(["U1"]); @@ -85,7 +92,7 @@ describe("SlackAdapter slack-as-thread", () => { it("discoverLinks cross-references GitHub issues/PRs mentioned in the thread", async () => { const slack = new SlackAdapter({ botToken: "x" }); - const links = await slack.discoverLinks({ + const r = await slack.discoverLinks({ platform: "slack", nativeId: "C1/1700.0001", type: "slack_thread", @@ -109,6 +116,9 @@ describe("SlackAdapter slack-as-thread", () => { }, ], }); + expect(r.ok).toBe(true); + if (!r.ok) throw r.error; + const links = r.data; expect(links).toEqual([ { from: "C1/1700.0001", to: "gantrydev/aipm#3", kind: "cross_ref" }, { from: "C1/1700.0001", to: "acme-corp/web-frontend#2317", kind: "cross_ref" }, @@ -121,9 +131,10 @@ describe("SlackAdapter.resolvePerson", () => { it("returns a cached U… id without an API call", async () => { const { fetchImpl, calls } = scriptedFetch([]); const slack = new SlackAdapter({ botToken: "xoxb", fetchImpl }); - expect(await slack.resolvePerson({ id: "u1", handles: { slack: "U01ALICE" } })).toBe( - "U01ALICE", - ); + const r = await slack.resolvePerson({ id: "u1", handles: { slack: "U01ALICE" } }); + expect(r.ok).toBe(true); + if (!r.ok) throw r.error; + expect(r.data).toBe("U01ALICE"); expect(calls).toHaveLength(0); }); @@ -132,14 +143,18 @@ describe("SlackAdapter.resolvePerson", () => { { ok: true, members: [{ id: "U01ALICE", name: "alice" }] }, ]); const slack = new SlackAdapter({ botToken: "xoxb", fetchImpl }); - expect(await slack.resolvePerson({ id: "u1", handles: { slack: "alice" } })).toBe("U01ALICE"); + const r = await slack.resolvePerson({ id: "u1", handles: { slack: "alice" } }); + expect(r.ok).toBe(true); + if (!r.ok) throw r.error; + expect(r.data).toBe("U01ALICE"); }); it("looks up by email when the Slack id is unknown", async () => { const { fetchImpl } = scriptedFetch([{ ok: true, user: { id: "U7" } }]); const slack = new SlackAdapter({ botToken: "xoxb", fetchImpl }); - expect( - await slack.resolvePerson({ id: "u1", handles: { github: "g" }, email: "a@x.com" }), - ).toBe("U7"); + const r = await slack.resolvePerson({ id: "u1", handles: { github: "g" }, email: "a@x.com" }); + expect(r.ok).toBe(true); + if (!r.ok) throw r.error; + expect(r.data).toBe("U7"); }); }); diff --git a/packages/adapter-slack/src/adapter.ts b/packages/adapter-slack/src/adapter.ts index 1144448..e7f0d45 100644 --- a/packages/adapter-slack/src/adapter.ts +++ b/packages/adapter-slack/src/adapter.ts @@ -9,6 +9,7 @@ import type { ThreadType, TimelineEvent, } from "@aipm/core"; +import { Err, Ok, Result } from "@aipm/core"; import { resolveSlackId } from "./identity.js"; export interface SlackAdapterConfig { @@ -35,29 +36,31 @@ export class SlackAdapter implements Platform { /** A Slack thread event (channel message) → its thread ref `${channel}/${ts}`. */ normalizeEvent(raw: RawEvent): NormalizedRef | undefined { if (raw.event !== "thread_message") return undefined; - const p = raw.payload as { channel?: string; threadTs?: string }; - if (!p.channel || !p.threadTs) return undefined; - return { nativeId: `${p.channel}/${p.threadTs}`, type: "slack_thread" }; + if (!isSlackThreadPayload(raw.payload)) return undefined; + return { nativeId: `${raw.payload.channel}/${raw.payload.threadTs}`, type: "slack_thread" }; } - async listThreads(_query: Record): Promise { - throw new Error("TODO: Slack thread sweeps"); + async listThreads(_query: Record): Promise, Error>> { + return Err(new Error("TODO: Slack thread sweeps")); } /** Fetch a Slack thread's replies and normalize to a Thread. */ - async getThread(nativeId: string, _hint?: ThreadType): Promise { - const { channel, ts } = parseSlackNativeId(nativeId); - const res = await this.get<{ messages?: SlackMessage[] }>("conversations.replies", { + async getThread(nativeId: string, _hint?: ThreadType): Promise> { + const parsed = parseSlackNativeId(nativeId); + if (!parsed.ok) return parsed; + const { channel, ts } = parsed.data; + const res = await this.get<{ messages?: Array }>("conversations.replies", { channel, ts, limit: "200", }); - const messages = res.messages ?? []; + if (!res.ok) return res; + const messages = res.data.messages ?? []; const root = messages[0]; const participants = [ ...new Set(messages.filter((m) => m.user && !m.bot_id).map((m) => m.user!)), ]; - return { + return Ok({ platform: "slack", nativeId, type: "slack_thread", @@ -72,15 +75,17 @@ export class SlackAdapter implements Platform { at: slackTsToIso(m.ts), data: clean({ body: m.text, mentions: parseSlackMentions(m.text) }), })), - }; + }); } - async getTimeline(nativeId: string): Promise { - return (await this.getThread(nativeId)).timeline; + async getTimeline(nativeId: string): Promise, Error>> { + const t = await this.getThread(nativeId); + if (!t.ok) return t; + return Ok(t.data.timeline); } /** Cluster a Slack thread with referenced GitHub issues/PRs (DESIGN §8). */ - async discoverLinks(thread: Thread): Promise { + async discoverLinks(thread: Thread): Promise, Error>> { const text = [thread.body ?? "", ...thread.timeline.map((e) => String(e.data.body ?? ""))].join( "\n", ); @@ -91,50 +96,71 @@ export class SlackAdapter implements Platform { const link: Link = { from: thread.nativeId, to, kind: "cross_ref" }; return [link]; }); - return links; + return Ok(links); } /** Post into a channel (`meta.channelId`) or thread (`threadNativeId` = `${channel}/${threadTs}`). */ - async postMessage(target: PostTarget, body: string): Promise<{ id: string }> { - const channelId = - typeof target.meta?.channelId === "string" ? target.meta.channelId : undefined; + async postMessage(target: PostTarget, body: string): Promise> { + const channelMeta = target.meta?.channelId; + const channelId = typeof channelMeta === "string" ? channelMeta : undefined; if (channelId) { - const res = await this.post<{ ts?: string }>("chat.postMessage", { + const posted = await this.post<{ ts?: string }>("chat.postMessage", { channel: channelId, text: body, }); - return { id: `${channelId}/${res.ts}` }; + if (!posted.ok) return posted; + return Ok({ id: `${channelId}/${posted.data.ts}` }); + } + const threadNativeId = target.threadNativeId; + if (!threadNativeId) { + return Err(new Error("postMessage requires target.threadNativeId")); } - if (!target.threadNativeId) throw new Error("postMessage requires target.threadNativeId"); - const { channel, ts } = parseSlackNativeId(target.threadNativeId); + const parsed = parseSlackNativeId(threadNativeId); + if (!parsed.ok) return parsed; + const { channel, ts } = parsed.data; const res = await this.post<{ ts?: string }>("chat.postMessage", { channel, thread_ts: ts, text: body, }); - return { id: `${channel}/${res.ts}` }; + if (!res.ok) return res; + return Ok({ id: `${channel}/${res.data.ts}` }); } /** Edit a posted message (`messageId` = `${channel}/${messageTs}`). */ - async editMessage(messageId: string, body: string): Promise { - const { channel, ts } = parseSlackNativeId(messageId); - await this.post("chat.update", { channel, ts, text: body }); + async editMessage(messageId: string, body: string): Promise> { + const parsed = parseSlackNativeId(messageId); + if (!parsed.ok) return parsed; + const { channel, ts } = parsed.data; + const r = await this.post("chat.update", { channel, ts, text: body }); + if (!r.ok) return r; + return Ok(undefined); } - async findStickyComment(threadNativeId: string, marker: string): Promise { - const { channel, ts } = parseSlackNativeId(threadNativeId); - const res = await this.get<{ messages?: SlackMessage[] }>("conversations.replies", { + async findStickyComment( + threadNativeId: string, + marker: string, + ): Promise> { + const parsed = parseSlackNativeId(threadNativeId); + if (!parsed.ok) return parsed; + const { channel, ts } = parsed.data; + const res = await this.get<{ messages?: Array }>("conversations.replies", { channel, ts, limit: "200", }); - const hit = (res.messages ?? []).find((m) => m.text?.includes(marker)); - return hit ? `${channel}/${hit.ts}` : undefined; + if (!res.ok) return res; + const hit = (res.data.messages ?? []).find((m) => m.text?.includes(marker)); + return Ok(hit ? `${channel}/${hit.ts}` : undefined); } - async react(messageId: string, emoji: string): Promise { - const { channel, ts } = parseSlackNativeId(messageId); - await this.post("reactions.add", { channel, timestamp: ts, name: emoji }); + async react(messageId: string, emoji: string): Promise> { + const parsed = parseSlackNativeId(messageId); + if (!parsed.ok) return parsed; + const { channel, ts } = parsed.data; + const r = await this.post("reactions.add", { channel, timestamp: ts, name: emoji }); + if (!r.ok) return r; + return Ok(undefined); } /** @@ -142,56 +168,73 @@ export class SlackAdapter implements Platform { * "U…"/"W…" id (fast path) or a roster-supplied username — resolve the latter * via email then handle and let the caller cache the real id. */ - async resolvePerson(identity: Identity): Promise { + async resolvePerson(identity: Identity): Promise> { const cached = identity.handles.slack; - if (cached && isSlackUserId(cached)) return cached; + if (cached && isSlackUserId(cached)) return Ok(cached); return resolveSlackId(this.config, { email: identity.email, handle: cached }); } /** Open a DM and post the nudge (DESIGN §7). Requires a resolved Slack id. */ - async notifyPerson(identity: Identity, body: string): Promise { + async notifyPerson(identity: Identity, body: string): Promise> { const uid = identity.handles.slack; - if (!uid) throw new Error(`no Slack id on identity ${identity.id}`); + if (!uid) return Err(new Error(`no Slack id on identity ${identity.id}`)); const opened = await this.post<{ channel?: { id?: string } }>("conversations.open", { users: uid, }); - const channel = opened.channel?.id; - if (!channel) throw new Error("conversations.open returned no channel"); - await this.post("chat.postMessage", { channel, text: body }); + if (!opened.ok) return opened; + const channel = opened.data.channel?.id; + if (!channel) return Err(new Error("conversations.open returned no channel")); + const r = await this.post("chat.postMessage", { channel, text: body }); + if (!r.ok) return r; + return Ok(undefined); } private async post( method: string, body: Record, - ): Promise { + ): Promise> { const base = this.config.apiBaseUrl ?? DEFAULT_BASE; - const res = await (this.config.fetchImpl ?? fetch)(`${base}/${method}`, { - method: "POST", - headers: { - Authorization: `Bearer ${this.config.botToken}`, - "Content-Type": "application/json; charset=utf-8", - }, - body: JSON.stringify(body), - }); - if (!res.ok) throw new Error(`Slack ${method} HTTP ${res.status}`); - const json = (await res.json()) as T & { ok: boolean; error?: string }; - if (!json.ok) throw new Error(`Slack ${method} error: ${json.error ?? "unknown"}`); - return json; + const requestBody = Result.fromSync(() => JSON.stringify(body)); + if (!requestBody.ok) return requestBody; + const fetched = await Result.from(() => + (this.config.fetchImpl ?? fetch)(`${base}/${method}`, { + method: "POST", + headers: { + Authorization: `Bearer ${this.config.botToken}`, + "Content-Type": "application/json; charset=utf-8", + }, + body: requestBody.data, + }), + ); + if (!fetched.ok) return fetched; + const res = fetched.data; + if (!res.ok) return Err(new Error(`Slack ${method} HTTP ${res.status}`)); + const parsed = await Result.from(() => res.json()); + if (!parsed.ok) return parsed; + const json = parsed.data as T & { ok: boolean; error?: string }; + if (!json.ok) return Err(new Error(`Slack ${method} error: ${json.error ?? "unknown"}`)); + return Ok(json); } private async get( method: string, params: Record, - ): Promise { + ): Promise> { const base = this.config.apiBaseUrl ?? DEFAULT_BASE; const url = `${base}/${method}?${new URLSearchParams(params).toString()}`; - const res = await (this.config.fetchImpl ?? fetch)(url, { - headers: { Authorization: `Bearer ${this.config.botToken}` }, - }); - if (!res.ok) throw new Error(`Slack ${method} HTTP ${res.status}`); - const json = (await res.json()) as T & { ok: boolean; error?: string }; - if (!json.ok) throw new Error(`Slack ${method} error: ${json.error ?? "unknown"}`); - return json; + const fetched = await Result.from(() => + (this.config.fetchImpl ?? fetch)(url, { + headers: { Authorization: `Bearer ${this.config.botToken}` }, + }), + ); + if (!fetched.ok) return fetched; + const res = fetched.data; + if (!res.ok) return Err(new Error(`Slack ${method} HTTP ${res.status}`)); + const parsed = await Result.from(() => res.json()); + if (!parsed.ok) return parsed; + const json = parsed.data as T & { ok: boolean; error?: string }; + if (!json.ok) return Err(new Error(`Slack ${method} error: ${json.error ?? "unknown"}`)); + return Ok(json); } } @@ -203,10 +246,10 @@ interface SlackMessage { } /** `${channel}/${ts}` — channel ids have no '/', ts is `.`. */ -function parseSlackNativeId(nativeId: string): { channel: string; ts: string } { +function parseSlackNativeId(nativeId: string): Result<{ channel: string; ts: string }, Error> { const i = nativeId.indexOf("/"); - if (i < 0) throw new Error(`unparseable Slack nativeId: ${nativeId}`); - return { channel: nativeId.slice(0, i), ts: nativeId.slice(i + 1) }; + if (i < 0) return Err(new Error(`unparseable Slack nativeId: ${nativeId}`)); + return Ok({ channel: nativeId.slice(0, i), ts: nativeId.slice(i + 1) }); } const slackTsToIso = (ts: string): string => new Date(Number.parseFloat(ts) * 1000).toISOString(); @@ -222,7 +265,7 @@ function githubRefs(text: string): Array { } /** Slack mentions are `<@U…>`; return the bare user ids. */ -function parseSlackMentions(text: string | undefined): string[] | undefined { +function parseSlackMentions(text: string | undefined): Array | undefined { if (!text) return undefined; const ids = [...text.matchAll(/<@([UW][A-Z0-9]+)>/g)].flatMap((m) => (m[1] ? [m[1]] : [])); const out = new Set(ids); @@ -231,3 +274,15 @@ function parseSlackMentions(text: string | undefined): string[] | undefined { const clean = >(o: T): T => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && v !== null)) as T; + +const isRecord = (value: unknown): value is Record => { + const isObject = typeof value === "object"; + return isObject && value !== null; +}; + +const isSlackThreadPayload = (value: unknown): value is { channel: string; threadTs: string } => { + if (!isRecord(value)) return false; + const validChannel = typeof value.channel === "string" && value.channel.length > 0; + const validThreadTs = typeof value.threadTs === "string" && value.threadTs.length > 0; + return validChannel && validThreadTs; +}; diff --git a/packages/adapter-slack/src/identity.test.ts b/packages/adapter-slack/src/identity.test.ts index 90362ac..0979eb2 100644 --- a/packages/adapter-slack/src/identity.test.ts +++ b/packages/adapter-slack/src/identity.test.ts @@ -8,23 +8,29 @@ const cfg = (fetchImpl: typeof fetch) => ({ botToken: "xoxb-test", fetchImpl }); describe("resolveSlackId (lookupByEmail)", () => { it("returns the user id on ok", async () => { - const id = await resolveSlackId(cfg(json({ ok: true, user: { id: "U123" } })), { + const r = await resolveSlackId(cfg(json({ ok: true, user: { id: "U123" } })), { email: "a@x.com", }); - expect(id).toBe("U123"); + expect(r.ok).toBe(true); + if (!r.ok) throw r.error; + expect(r.data).toBe("U123"); }); it("returns undefined on users_not_found (a gap, not an error)", async () => { - const id = await resolveSlackId(cfg(json({ ok: false, error: "users_not_found" })), { + const r = await resolveSlackId(cfg(json({ ok: false, error: "users_not_found" })), { email: "a@x.com", }); - expect(id).toBeUndefined(); + expect(r.ok).toBe(true); + if (!r.ok) throw r.error; + expect(r.data).toBeUndefined(); }); - it("throws on missing_scope", async () => { - await expect( - resolveSlackId(cfg(json({ ok: false, error: "missing_scope" })), { email: "a@x.com" }), - ).rejects.toThrow(/missing_scope/); + it("returns an error on missing_scope", async () => { + const r = await resolveSlackId(cfg(json({ ok: false, error: "missing_scope" })), { + email: "a@x.com", + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.message).toMatch(/missing_scope/); }); }); @@ -49,8 +55,10 @@ describe("resolveSlackId (users.list fallback)", () => { const fetchImpl = (async () => new Response(JSON.stringify(pages[i++]), { status: 200 })) as unknown as typeof fetch; - const id = await resolveSlackId({ botToken: "x", fetchImpl }, { handle: "carol" }); - expect(id).toBe("Ucarol"); + const r = await resolveSlackId({ botToken: "x", fetchImpl }, { handle: "carol" }); + expect(r.ok).toBe(true); + if (!r.ok) throw r.error; + expect(r.data).toBe("Ucarol"); expect(i).toBe(2); // both pages fetched }); @@ -62,11 +70,13 @@ describe("resolveSlackId (users.list fallback)", () => { return new Response(JSON.stringify({ ok: true, user: { id: "U7" } }), { status: 200 }); }) as unknown as typeof fetch; - const id = await resolveSlackId( + const r = await resolveSlackId( { botToken: "x", fetchImpl, sleep: async () => {} }, { email: "a@x.com" }, ); - expect(id).toBe("U7"); + expect(r.ok).toBe(true); + if (!r.ok) throw r.error; + expect(r.data).toBe("U7"); expect(calls).toBe(2); }); }); diff --git a/packages/adapter-slack/src/identity.ts b/packages/adapter-slack/src/identity.ts index cb88c8e..91e3fba 100644 --- a/packages/adapter-slack/src/identity.ts +++ b/packages/adapter-slack/src/identity.ts @@ -2,7 +2,7 @@ // caches the result on Identity.handles.slack; an unresolved participant is // never DM'd (digest-only) — that routing fallback is phase-3, not here. -import { asyncUnfold } from "@aipm/core"; +import { asyncUnfold, Err, Ok, Result } from "@aipm/core"; export interface SlackResolveConfig { botToken: string; @@ -28,37 +28,45 @@ const FATAL = new Set(["missing_scope", "invalid_auth", "account_inactive", "tok export async function resolveSlackId( config: SlackResolveConfig, query: { email?: string; handle?: string }, -): Promise { +): Promise> { if (query.email) { const byEmail = await lookupByEmail(config, query.email); - if (byEmail) return byEmail; + if (!byEmail.ok) return byEmail; + if (byEmail.data) return Ok(byEmail.data); } if (query.handle) return lookupByHandle(config, query.handle); - return undefined; + return Ok(undefined); } async function lookupByEmail( config: SlackResolveConfig, email: string, -): Promise { +): Promise> { const res = await call<{ user?: SlackUser }>(config, "users.lookupByEmail", { email }); - if (res.ok) return res.user?.id; - if (res.error === "users_not_found") return undefined; // gap: log upstream - throw slackError(res.error); + if (!res.ok) return res; + if (res.data.ok) return Ok(res.data.user?.id); + if (res.data.error === "users_not_found") return Ok(undefined); // gap: log upstream + return Err(slackError(res.data.error)); } async function lookupByHandle( config: SlackResolveConfig, handle: string, -): Promise { - const seed: string | undefined = undefined; - return asyncUnfold(seed, async (cursor: string | undefined) => { - const res = await call<{ - members?: SlackUser[]; +): Promise> { + const seed = ""; + return asyncUnfold(seed, async (cursor) => { + const listResult = await call<{ + members?: Array; response_metadata?: { next_cursor?: string }; }>(config, "users.list", { limit: "200", ...(cursor ? { cursor } : {}) }); - if (!res.ok) throw slackError(res.error); - const match = (res.members ?? []).find((u) => { + if (!listResult.ok) return { kind: "STOP" as const, value: listResult }; + const res = listResult.data; + if (!res.ok) { + const apiError = slackError(res.error); + return { kind: "STOP" as const, value: Err(apiError) }; + } + const members = res.members ?? []; + const match = members.find((u) => { if (u.deleted || u.is_bot) return false; return ( u.name === handle || @@ -66,10 +74,11 @@ async function lookupByHandle( u.profile?.display_name_normalized === handle ); }); - if (match) return { kind: "STOP" as const, value: match.id }; - const next = res.response_metadata?.next_cursor || undefined; + if (match) return { kind: "STOP" as const, value: Ok(match.id) }; + const nextCursor = res.response_metadata?.next_cursor; + const next = nextCursor || undefined; if (next) return { kind: "CONTINUE" as const, next }; - return { kind: "STOP" as const, value: undefined }; + return { kind: "STOP" as const, value: Ok(undefined) }; }); } @@ -82,7 +91,7 @@ async function call( config: SlackResolveConfig, method: string, params: Record, -): Promise { +): Promise> { const base = config.apiBaseUrl ?? DEFAULT_BASE; const url = `${base}/${method}?${new URLSearchParams(params).toString()}`; const doFetch = config.fetchImpl ?? fetch; @@ -91,18 +100,32 @@ async function call( const firstAttempt = 0; return asyncUnfold(firstAttempt, async (attempt) => { - const res = await doFetch(url, { - headers: { Authorization: `Bearer ${config.botToken}` }, - }); - const shouldRetry = res.status === 429 && attempt < maxRetries; + const fetched = await Result.from(() => + doFetch(url, { + headers: { Authorization: `Bearer ${config.botToken}` }, + }), + ); + if (!fetched.ok) return { kind: "STOP" as const, value: fetched }; + const res = fetched.data; + const isRateLimited = res.status === 429; + const hasRetriesLeft = attempt < maxRetries; + const shouldRetry = isRateLimited && hasRetriesLeft; if (shouldRetry) { - const retryAfter = Number(res.headers.get("retry-after")) || 1; - await sleep(retryAfter * 1000); + const retryAfterHeader = Number(res.headers.get("retry-after")); + const retryAfter = retryAfterHeader || 1; + const slept = await Result.from(() => sleep(retryAfter * 1000)); + if (!slept.ok) return { kind: "STOP" as const, value: slept }; return { kind: "CONTINUE" as const, next: attempt + 1 }; } - if (!res.ok) throw new Error(`Slack ${method} HTTP ${res.status}`); - const body = (await res.json()) as SlackResponse & T; - return { kind: "STOP" as const, value: body }; + const httpFailed = !res.ok; + if (httpFailed) { + const httpError = new Error(`Slack ${method} HTTP ${res.status}`); + return { kind: "STOP" as const, value: Err(httpError) }; + } + const parsed = await Result.from(() => res.json()); + if (!parsed.ok) return { kind: "STOP" as const, value: parsed }; + const body = parsed.data as SlackResponse & T; + return { kind: "STOP" as const, value: Ok(body) }; }); } diff --git a/packages/adapter-slack/src/verify.ts b/packages/adapter-slack/src/verify.ts index 60b0c2f..19cd75d 100644 --- a/packages/adapter-slack/src/verify.ts +++ b/packages/adapter-slack/src/verify.ts @@ -1,3 +1,5 @@ +import { Ok, Result } from "@aipm/core"; + /** * Verify a Slack request signature (DESIGN §3). Slack signs * `v0::` with HMAC-SHA256 and sends @@ -9,26 +11,32 @@ export async function verifySlackRequest( signature: string | null, timestamp: string | null, now: number = Date.now(), -): Promise { - if (!signature?.startsWith("v0=") || !timestamp) return false; +): Promise> { + if (!signature?.startsWith("v0=") || !timestamp) return Ok(false); // Reject requests older than 5 minutes (replay protection). - if (Math.abs(now / 1000 - Number(timestamp)) > 60 * 5) return false; + if (Math.abs(now / 1000 - Number(timestamp)) > 60 * 5) return Ok(false); const base = `v0:${timestamp}:${rawBody}`; - const key = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(signingSecret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"], + const key = await Result.from(() => + crypto.subtle.importKey( + "raw", + new TextEncoder().encode(signingSecret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ), + ); + if (!key.ok) return key; + const sig = await Result.from(() => + crypto.subtle.sign("HMAC", key.data, new TextEncoder().encode(base)), ); - const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(base)); - const actual = - "v0=" + [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join(""); - if (actual.length !== signature.length) return false; + if (!sig.ok) return sig; + const hex = [...new Uint8Array(sig.data)].map((b) => b.toString(16).padStart(2, "0")).join(""); + const actual = `v0=${hex}`; + if (actual.length !== signature.length) return Ok(false); const mismatch = [...actual].reduce( (acc, ch, i) => acc | (ch.charCodeAt(0) ^ signature.charCodeAt(i)), 0, ); - return mismatch === 0; + return Ok(mismatch === 0); } diff --git a/packages/config/src/index.test.ts b/packages/config/src/index.test.ts index 4853dd1..2f86cdb 100644 --- a/packages/config/src/index.test.ts +++ b/packages/config/src/index.test.ts @@ -3,21 +3,30 @@ import { buildConfig } from "./index.js"; describe("buildConfig", () => { it("defaults to global shadow mode", () => { - const cfg = buildConfig(); + const r = buildConfig(); + expect(r.ok).toBe(true); + if (!r.ok) throw r.error; + const cfg = r.data; expect(cfg.shadow.global).toBe(true); }); it("fills all signal thresholds from DESIGN §7 defaults", () => { - const cfg = buildConfig(); + const r = buildConfig(); + expect(r.ok).toBe(true); + if (!r.ok) throw r.error; + const cfg = r.data; expect(cfg.signals.pr_no_reviewer.quietPeriodHours).toBe(4); expect(cfg.signals.draft_pr_aged.quietPeriodHours).toBe(168); }); it("merges overrides over defaults", () => { - const cfg = buildConfig({ + const r = buildConfig({ signals: { pr_no_reviewer: { quietPeriodHours: 2, maxEscalations: 1, enabled: false } }, botAccounts: ["dependabot[bot]"], }); + expect(r.ok).toBe(true); + if (!r.ok) throw r.error; + const cfg = r.data; expect(cfg.signals.pr_no_reviewer.enabled).toBe(false); expect(cfg.signals.review_requested.enabled).toBe(true); expect(cfg.botAccounts).toContain("dependabot[bot]"); diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index c455e80..d5988fd 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -1,4 +1,4 @@ -import type { EngineConfig, SignalConfig, SignalKind } from "@aipm/core"; +import { Ok, Result, type EngineConfig, type SignalConfig, type SignalKind } from "@aipm/core"; import { engineConfigSchema, signalKinds, type EngineConfigInput } from "./schema.js"; export * from "./schema.js"; @@ -23,11 +23,12 @@ const defaultSignals: Record = { * Validate + merge a partial config over DESIGN defaults. Defaults to shadow * mode on (global: true) so a fresh deployment posts nothing. */ -export function buildConfig(input: Partial = {}): EngineConfig { +export function buildConfig(input: Partial = {}) { const signals = { ...defaultSignals, ...(input.signals ?? {}) }; - const parsed = engineConfigSchema.parse({ ...input, signals }); + const parsed = Result.fromSync(() => engineConfigSchema.parse({ ...input, signals })); + if (!parsed.ok) return parsed; // zod's record over an enum yields a partial type; defaults guarantee all keys. - return parsed as EngineConfig; + return Ok(parsed.data as EngineConfig); } export { signalKinds }; diff --git a/packages/core/src/aggregate.test.ts b/packages/core/src/aggregate.test.ts index 3de06af..7dcca88 100644 --- a/packages/core/src/aggregate.test.ts +++ b/packages/core/src/aggregate.test.ts @@ -3,6 +3,7 @@ import { fixedClock } from "./clock.js"; import type { EngineConfig, Identity, Nudge, Signal } from "./index.js"; import type { Platform } from "./platform.js"; import { aggregate, type EngineContext } from "./pipeline.js"; +import { Ok } from "./result.js"; import type { Store } from "./store.js"; const NOW = "2026-01-10T00:00:00.000Z"; @@ -38,28 +39,31 @@ function harness(opts: { const dms: Array<{ to: string; body: string }> = []; const store = { async listPendingDigestNudges() { - return [...nudges.values()].filter((n) => n.state === "pending"); + return Ok([...nudges.values()].filter((n) => n.state === "pending")); }, async getIdentity(id: string) { - return ids.get(id); + return Ok(ids.get(id)); }, async getSignal(id: string) { - return sigs.get(id); + return Ok(sigs.get(id)); }, async upsertNudge(n: Nudge) { nudges.set(n.dedupeKey, { ...n }); + return Ok(undefined); }, async setIdentityHandle(id: string, platform: string, handle: string) { const current = ids.get(id); if (current) ids.set(id, { ...current, handles: { ...current.handles, [platform]: handle } }); + return Ok(undefined); }, } as unknown as Store; const slack = { id: "slack", async notifyPerson(i: Identity, body: string) { dms.push({ to: i.handles.slack ?? "?", body }); + return Ok(undefined); }, - ...(opts.resolver ? { resolvePerson: async () => opts.resolver } : {}), + ...(opts.resolver ? { resolvePerson: async () => Ok(opts.resolver) } : {}), } as unknown as Platform; const ctx: EngineContext = { store, @@ -93,7 +97,9 @@ describe("aggregate (per-person digest)", () => { sig("s3", "draft_pr_aged", "o/r#3"), ], }); - await aggregate(ctx); + const r = await aggregate(ctx); + expect(r.ok).toBe(true); + if (!r.ok) throw r.error; expect(dms).toHaveLength(2); const a = dms.find((d) => d.to === SLACK_ID.personA)!; expect(a.body).toContain("2 item(s)"); @@ -109,7 +115,9 @@ describe("aggregate (per-person digest)", () => { signals: [sig("s1", "review_requested", "o/r#1")], shadow: true, }); - await aggregate(ctx); + const r = await aggregate(ctx); + expect(r.ok).toBe(true); + if (!r.ok) throw r.error; expect(dms).toHaveLength(0); expect(nudges.get("u-a:s1")?.state).toBe("pending"); }); @@ -120,7 +128,9 @@ describe("aggregate (per-person digest)", () => { identities: [{ id: "u-a", handles: { github: "a" } }], signals: [sig("s1", "review_requested", "o/r#1")], }); - await aggregate(ctx); + const r = await aggregate(ctx); + expect(r.ok).toBe(true); + if (!r.ok) throw r.error; expect(dms).toHaveLength(0); expect(nudges.get("u-a:s1")?.state).toBe("pending"); }); @@ -131,7 +141,9 @@ describe("aggregate (per-person digest)", () => { identities: [{ id: "u-a", handles: { slack: SLACK_ID.personA } }], signals: [{ ...sig("s1", "review_requested", "o/r#1"), clearedAt: NOW }], }); - await aggregate(ctx); + const r = await aggregate(ctx); + expect(r.ok).toBe(true); + if (!r.ok) throw r.error; expect(dms).toHaveLength(0); expect(nudges.get("u-a:s1")?.state).toBe("cleared"); }); @@ -142,7 +154,9 @@ describe("aggregate (per-person digest)", () => { identities: [{ id: "u-a", handles: { slack: ROSTER_USERNAME } }], signals: [sig("s1", "review_requested", "o/r#1")], }); - await aggregate(ctx); + const r = await aggregate(ctx); + expect(r.ok).toBe(true); + if (!r.ok) throw r.error; expect(dms).toHaveLength(0); expect(nudges.get("u-a:s1")?.state).toBe("pending"); }); @@ -154,7 +168,9 @@ describe("aggregate (per-person digest)", () => { signals: [sig("s1", "review_requested", "o/r#1")], resolver: SLACK_ID.freshlyResolved, }); - await aggregate(ctx); + const r = await aggregate(ctx); + expect(r.ok).toBe(true); + if (!r.ok) throw r.error; expect(dms.map((d) => d.to)).toContain(SLACK_ID.freshlyResolved); expect(dms).toHaveLength(1); expect(nudges.get("u-a:s1")?.state).toBe("sent"); diff --git a/packages/core/src/cluster.ts b/packages/core/src/cluster.ts index e0c5a47..5b09626 100644 --- a/packages/core/src/cluster.ts +++ b/packages/core/src/cluster.ts @@ -5,7 +5,10 @@ import type { Link, SignalKind } from "./domain.js"; * Clusters are connected components over the Link set, plus any manual grouping * (DESIGN §4). Pure union-find over thread ids referenced by links. */ -export function computeClusters(threadIds: string[], links: Link[]): string[][] { +export function computeClusters( + threadIds: Array, + links: Array, +): Array> { const parent = new Map(threadIds.map((id) => [id, id] as const)); const find = (x: string): string => { const p = parent.get(x); diff --git a/packages/core/src/clusters.test.ts b/packages/core/src/clusters.test.ts index eea0ca9..5f0548a 100644 --- a/packages/core/src/clusters.test.ts +++ b/packages/core/src/clusters.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { systemClock } from "./clock.js"; import { aggregateOrg, synthesizeCluster } from "./clusters.js"; +import { Ok } from "./result.js"; import type { EngineConfig, Cluster, Identity, Signal, Thread, WorkingNotes } from "./index.js"; import type { Platform } from "./platform.js"; import { type EngineContext } from "./pipeline.js"; @@ -20,22 +21,23 @@ function fakeStore( const identities = new Map((opts.identities ?? []).map((i) => [i.id, i])); const store = { async getThread(_p: string, nid: string) { - return opts.threads?.[nid]; + return Ok(opts.threads?.[nid]); }, async getIdentity(id: string) { - return identities.get(id); + return Ok(identities.get(id)); }, async getWorkingNotes(scope: string, targetId: string) { - return notes.get(`${scope}:${targetId}`); + return Ok(notes.get(`${scope}:${targetId}`)); }, async upsertWorkingNotes(n: WorkingNotes) { notes.set(`${n.scope}:${n.targetId}`, n); + return Ok(undefined); }, async listWorkingNotes(scope: string) { - return [...notes.values()].filter((n) => n.scope === scope); + return Ok([...notes.values()].filter((n) => n.scope === scope)); }, async listOpenSignals() { - return opts.signals ?? []; + return Ok(opts.signals ?? []); }, } as unknown as Store; return { store, notes }; @@ -48,7 +50,7 @@ const ctx = ( store, platforms: opts.slack ? new Map([["slack", opts.slack]]) : new Map(), identities: { list: async () => [], resolve: async () => undefined }, - llm: { complete: async (p) => p }, + llm: { complete: async (p) => Ok(p) }, config: { shadow: { global: false, capabilities: { orgRollup: opts.orgShadow } }, } as EngineConfig, @@ -64,7 +66,9 @@ describe("synthesizeCluster", () => { it("writes a concise cluster note without a raw member listing", async () => { const { store, notes } = fakeStore({ threads }); - await synthesizeCluster(ctx(store), cluster); + const r = await synthesizeCluster(ctx(store), cluster); + expect(r.ok).toBe(true); + if (!r.ok) throw r.error; const note = notes.get("cluster:cluster:o/r#1"); expect(note?.content).toContain(""); expect(note?.content).not.toContain("o/r#1` — open"); @@ -104,10 +108,12 @@ describe("synthesizeCluster", () => { }, }); - await synthesizeCluster(ctx(store), { + const r = await synthesizeCluster(ctx(store), { id: "cluster:o/r#1", threadIds: ["o/r#1", slackId], }); + expect(r.ok).toBe(true); + if (!r.ok) throw r.error; const note = notes.get("cluster:cluster:o/r#1"); expect(note?.content).toContain("Slack discussion"); @@ -118,9 +124,13 @@ describe("synthesizeCluster", () => { it("is idempotent — unchanged members rewrite nothing", async () => { const { store, notes } = fakeStore({ threads }); - await synthesizeCluster(ctx(store), cluster); + const first_run = await synthesizeCluster(ctx(store), cluster); + expect(first_run.ok).toBe(true); + if (!first_run.ok) throw first_run.error; const first = notes.get("cluster:cluster:o/r#1"); - await synthesizeCluster(ctx(store), cluster); + const second_run = await synthesizeCluster(ctx(store), cluster); + expect(second_run.ok).toBe(true); + if (!second_run.ok) throw second_run.error; expect(notes.get("cluster:cluster:o/r#1")).toBe(first); }); }); @@ -146,12 +156,16 @@ describe("aggregateOrg", () => { const { store, notes } = fakeStore({ notes: [note("cluster:o/r#1", "h1"), note("cluster:o/r#5", "h2")], }); - await aggregateOrg(ctx(store)); + const first_run = await aggregateOrg(ctx(store), {}); + expect(first_run.ok).toBe(true); + if (!first_run.ok) throw first_run.error; const org = notes.get("cluster:org"); expect(org?.content).toContain("Org rollup — 2 cluster(s)"); expect(org?.content).toContain("cluster cluster:o/r#1"); // Re-running excludes the org note itself and stays stable. - await aggregateOrg(ctx(store)); + const second_run = await aggregateOrg(ctx(store), {}); + expect(second_run.ok).toBe(true); + if (!second_run.ok) throw second_run.error; expect(notes.get("cluster:org")?.contentHash).toBe(org?.contentHash); }); @@ -161,7 +175,7 @@ describe("aggregateOrg", () => { id: "slack", async postMessage(target: unknown, body: string) { posts.push({ target, body }); - return { id: "C123/1782220000.000100" }; + return Ok({ id: "C123/1782220000.000100" }); }, } as unknown as Platform; const { store, notes } = fakeStore({ diff --git a/packages/core/src/clusters.ts b/packages/core/src/clusters.ts index 2fcce85..7d0be24 100644 --- a/packages/core/src/clusters.ts +++ b/packages/core/src/clusters.ts @@ -4,6 +4,7 @@ import type { Cluster, Identity, PlatformId, Signal, SignalKind, Thread } from " import { NOTES_MARKER, stableHash } from "./notes.js"; import { digestRefMrkdwn, platformForNativeId, type EngineContext } from "./pipeline.js"; import { signalLabel } from "./nudge.js"; +import { Ok, Result } from "./result.js"; const CLUSTER_MARKER = ""; const ROLLUP_MARKER = ""; @@ -38,7 +39,7 @@ const memberLabel = (member: { platform: PlatformId; title?: string }, index: nu }; function buildClusterPrompt( - members: { platform: PlatformId; title?: string; discussion: string }[], + members: Array<{ platform: PlatformId; title?: string; discussion: string }>, ): string { const blocks = members .map((m, index) => `### ${memberLabel(m, index)}\n${m.discussion || "(no discussion yet)"}`) @@ -68,41 +69,56 @@ function buildClusterPrompt( * (no extra API calls); idempotent via a fingerprint of member state + discussion * so the LLM only runs when something actually changed. */ -export async function synthesizeCluster(ctx: EngineContext, cluster: Cluster): Promise { - const memberEntries = await asyncMap(cluster.threadIds, async (nid) => { +export async function synthesizeCluster( + ctx: EngineContext, + cluster: Cluster, +): Promise> { + const memberEntryResults = await asyncMap(cluster.threadIds, async (nid) => { const memberPlatform: PlatformId = platformForNativeId(nid); - const thread = await ctx.store.getThread(memberPlatform, nid); + const threadResult = await ctx.store.getThread(memberPlatform, nid); + if (!threadResult.ok) return threadResult; + const thread = threadResult.data; const state = thread?.state ?? "unknown"; const discussion = thread ? memberDiscussion(thread) : ""; - return { + return Ok({ member: { platform: memberPlatform, title: thread?.title, discussion }, fingerprint: `${nid}:${state}:${stableHash(discussion)}`, - }; + }); }); + const memberEntryErrors = memberEntryResults.flatMap((it) => (it.ok ? [] : [it])); + const firstMemberEntryError = memberEntryErrors[0]; + if (firstMemberEntryError) return firstMemberEntryError; + const memberEntries = memberEntryResults.flatMap((it) => (it.ok ? [it.data] : [])); const members = memberEntries.map((it) => it.member); const fingerprint = memberEntries.map((it) => it.fingerprint); const contentHash = stableHash( `${cluster.id}|${cluster.threadIds.join(",")}|${fingerprint.join(",")}`, ); - const stored = await ctx.store.getWorkingNotes("cluster", cluster.id); - if (stored?.contentHash === contentHash) return; + const storedNotes = await ctx.store.getWorkingNotes("cluster", cluster.id); + if (!storedNotes.ok) return storedNotes; + const stored = storedNotes.data; + if (stored?.contentHash === contentHash) return Ok(undefined); - const summary = await ctx.llm.complete(buildClusterPrompt(members), { + const completed = await ctx.llm.complete(buildClusterPrompt(members), { cacheKey: `cluster:${cluster.id}:${contentHash}`, temperature: 0, }); + if (!completed.ok) return completed; + const summary = completed.data; // Don't overwrite a good cluster note with a transient empty LLM result. - if (!summary.trim()) return; + if (!summary.trim()) return Ok(undefined); const body = [CLUSTER_MARKER, summary.trim()].join("\n"); - await ctx.store.upsertWorkingNotes({ + const upserted = await ctx.store.upsertWorkingNotes({ scope: "cluster", targetId: cluster.id, content: `${body}\n\naipm · ${contentHash}`, contentHash, provenance: "cluster", }); + if (!upserted.ok) return upserted; + return Ok(undefined); } export interface OrgRollupOptions { @@ -114,87 +130,124 @@ export interface OrgRollupOptions { * Org rollup (DESIGN §8): a durable artifact over all cluster notes plus an * optional daily Slack pulse for org-visible attention items. */ -export async function aggregateOrg(ctx: EngineContext, opts: OrgRollupOptions = {}): Promise { - const notes = (await ctx.store.listWorkingNotes("cluster")).filter( - (n) => n.targetId !== ORG_TARGET && !n.targetId.startsWith(DAILY_ROLLUP_TARGET_PREFIX), - ); +export async function aggregateOrg( + ctx: EngineContext, + opts: OrgRollupOptions, +): Promise> { + const listed = await ctx.store.listWorkingNotes("cluster"); + if (!listed.ok) return listed; + const notes = listed.data.flatMap((note) => { + const isRollupArtifact = + note.targetId === ORG_TARGET || note.targetId.startsWith(DAILY_ROLLUP_TARGET_PREFIX); + return isRollupArtifact ? [] : [note]; + }); const contentHash = stableHash( notes .map((n) => `${n.targetId}:${n.contentHash}`) .sort() .join(","), ); - const stored = await ctx.store.getWorkingNotes("cluster", ORG_TARGET); + const storedNotes = await ctx.store.getWorkingNotes("cluster", ORG_TARGET); + if (!storedNotes.ok) return storedNotes; + const stored = storedNotes.data; const sections = notes .map((n) => n.content.replace(CLUSTER_MARKER, "").trim()) .join("\n\n---\n\n"); const content = `${ROLLUP_MARKER}\n# Org rollup — ${notes.length} cluster(s)\n\n${sections}\n\naipm · ${contentHash}`; if (stored?.contentHash !== contentHash) { - await ctx.store.upsertWorkingNotes({ + const upserted = await ctx.store.upsertWorkingNotes({ scope: "cluster", targetId: ORG_TARGET, content, contentHash, provenance: "org-rollup", }); + if (!upserted.ok) return upserted; } - if (opts.channelId) { - await postDailyOrgPulse(ctx, opts.channelId, opts.at ?? ctx.clock.now()); - } + if (!opts.channelId) return Ok(undefined); + const at = opts.at ?? ctx.clock.now(); + return postDailyOrgPulse(ctx, opts.channelId, at); } -async function postDailyOrgPulse(ctx: EngineContext, channelId: string, at: Date): Promise { - const body = await buildDailyOrgPulse(ctx, at); +async function postDailyOrgPulse( + ctx: EngineContext, + channelId: string, + at: Date, +): Promise> { + const built = await buildDailyOrgPulse(ctx, at); + if (!built.ok) return built; + const body = built.data; const dateKey = at.toISOString().slice(0, 10); const targetId = `${DAILY_ROLLUP_TARGET_PREFIX}${dateKey}`; const contentHash = stableHash(body); - const stored = await ctx.store.getWorkingNotes("cluster", targetId); + const storedNotes = await ctx.store.getWorkingNotes("cluster", targetId); + if (!storedNotes.ok) return storedNotes; + const stored = storedNotes.data; const slack = ctx.platforms.get("slack"); const shadow = isShadowed(ctx.config, "orgRollup"); - if (stored?.contentHash === contentHash && (shadow || stored.externalRef)) return; + const unchanged = stored?.contentHash === contentHash; + if (unchanged && (shadow || stored?.externalRef)) return Ok(undefined); if (shadow || !slack) { - await ctx.store.upsertWorkingNotes({ + const upserted = await ctx.store.upsertWorkingNotes({ scope: "cluster", targetId, content: body, contentHash, provenance: "org-rollup:shadow", }); - return; + if (!upserted.ok) return upserted; + return Ok(undefined); } - let externalRef = stored?.externalRef; - if (externalRef) { - await slack.editMessage(externalRef, body); - } else { - externalRef = (await slack.postMessage({ meta: { channelId } }, body)).id; - } + const externalRefResult = await (async (): Promise> => { + const existing = stored?.externalRef; + if (existing) { + const edited = await slack.editMessage(existing, body); + if (!edited.ok) return edited; + return Ok(existing); + } + const posted = await slack.postMessage({ meta: { channelId } }, body); + if (!posted.ok) return posted; + return Ok(posted.data.id); + })(); + if (!externalRefResult.ok) return externalRefResult; - await ctx.store.upsertWorkingNotes({ + const upserted = await ctx.store.upsertWorkingNotes({ scope: "cluster", targetId, content: body, contentHash, provenance: "org-rollup:slack", - externalRef, + externalRef: externalRefResult.data, }); + if (!upserted.ok) return upserted; + return Ok(undefined); } -async function buildDailyOrgPulse(ctx: EngineContext, at: Date): Promise { - const signals = await ctx.store.listOpenSignals(); - const identities = new Map( - await asyncMap( - [...new Set(signals.flatMap((s) => (s.owedBy ? [s.owedBy] : [])))], - async (id) => [id, await ctx.store.getIdentity(id)] as const, - ), - ); +async function buildDailyOrgPulse(ctx: EngineContext, at: Date): Promise> { + const signalsResult = await ctx.store.listOpenSignals(); + if (!signalsResult.ok) return signalsResult; + const signals = signalsResult.data; + const ownerIds = [...new Set(signals.flatMap((s) => (s.owedBy ? [s.owedBy] : [])))]; + const identityEntryResults = await asyncMap(ownerIds, async (id) => { + const identityResult = await ctx.store.getIdentity(id); + if (!identityResult.ok) return identityResult; + const identity = identityResult.data; + return Ok([id, identity] as const); + }); + const identityEntryErrors = identityEntryResults.flatMap((it) => (it.ok ? [] : [it])); + const firstIdentityEntryError = identityEntryErrors[0]; + if (firstIdentityEntryError) return firstIdentityEntryError; + const identityEntries = identityEntryResults.flatMap((it) => (it.ok ? [it.data] : [])); + const identities = new Map(identityEntries); + const title = `*aipm daily pulse* — ${formatDay(at)}`; const summary = `${signals.length} active signal(s)`; - if (!signals.length) return `${title}\n${summary}\n\nNo active coordination gaps.`; + if (!signals.length) return Ok(`${title}\n${summary}\n\nNo active coordination gaps.`); const sections = [ section("Needs attention", signals, identities, [ @@ -207,16 +260,16 @@ async function buildDailyOrgPulse(ctx: EngineContext, at: Date): Promise section("Unblocked today", signals, identities, ["blocker_cleared"]), adminSection(signals, identities), countsSection(signals), - ].filter(Boolean); + ].flatMap((s) => (s ? [s] : [])); - return [title, summary, ...sections].join("\n\n"); + return Ok([title, summary, ...sections].join("\n\n")); } function section( label: string, - signals: Signal[], + signals: Array, identities: Map, - kinds: SignalKind[], + kinds: Array, ): string | undefined { const selected = signals .filter((s) => kinds.includes(s.kind)) @@ -233,7 +286,7 @@ function pulseLine(signal: Signal, identities: Map } function adminSection( - signals: Signal[], + signals: Array, identities: Map, ): string | undefined { const gaps = [ @@ -250,7 +303,7 @@ function adminSection( : undefined; } -function countsSection(signals: Signal[]): string { +function countsSection(signals: Array): string { const counts = signals.reduce( (acc, s) => acc.set(s.kind, (acc.get(s.kind) ?? 0) + 1), new Map(), diff --git a/packages/core/src/common.helper.ts b/packages/core/src/common.helper.ts index 8d93265..1519a6c 100644 --- a/packages/core/src/common.helper.ts +++ b/packages/core/src/common.helper.ts @@ -1,4 +1,5 @@ /* eslint-disable local/no-raw-loops */ + const MAX_ITERATIONS = 10_000_000; const asyncMap = async (arrayList: Array, fn: (item: T, index: number) => Promise) => { @@ -35,10 +36,10 @@ const asyncFilter = async (arrayList: Array, fn: (item: T) => Promise = { kind: "CONTINUE"; next: S } | { kind: "STOP"; value: R }; -const asyncUnfold = async ( +const asyncUnfold = async >( seed: S, - step: (state: S) => Promise>, -): Promise => { + step: (state: S) => Promise, +): Promise["value"]> => { let state = seed; let iterations = 0; while (true) { @@ -83,4 +84,17 @@ const chunk = (arrayList: Array, size: number): Array> => { }); }; -export { asyncMap, asyncForEach, asyncFilter, asyncUnfold, groupBy, indexBy, chunk }; +const findMap = ( + arr: Array, + mapper: (item: T, index: number) => { kind: "FOUND"; data: V } | { kind: "CONTINUE" }, +): V | null => { + let index = 0; + for (const item of arr) { + const mapped = mapper(item, index); + if (mapped.kind === "FOUND") return mapped.data; + index = index + 1; + } + return null; +}; + +export { asyncMap, asyncForEach, asyncFilter, asyncUnfold, groupBy, indexBy, chunk, findMap }; diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 12f0171..a7e74aa 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -5,11 +5,11 @@ export interface CalendarConfig { /** IANA timezone, e.g. "America/New_York". */ timezone: string; /** Working days as ISO weekday numbers (1 = Mon … 7 = Sun). */ - workingDays: number[]; + workingDays: Array; /** Working hours window in local time, 24h, e.g. [9, 17]. */ workingHours?: [number, number]; /** ISO dates (YYYY-MM-DD) treated as holidays. */ - holidays?: string[]; + holidays?: Array; } /** Per-signal threshold + routing config. All thresholds are configuration. */ @@ -47,7 +47,7 @@ export interface EngineConfig { signals: Record; shadow: ShadowConfig; /** Logins/handles never nudged and ignored for "is a reply owed" (DESIGN §7). */ - botAccounts: string[]; + botAccounts: Array; /** Enable bounded LLM judgment of replies (e.g. did an @mention get answered). */ llmJudge: boolean; /** Per-platform free-form deployment config (regex fallbacks, repos, etc.). */ diff --git a/packages/core/src/detectors.ts b/packages/core/src/detectors.ts index 58e521e..fec0a3c 100644 --- a/packages/core/src/detectors.ts +++ b/packages/core/src/detectors.ts @@ -21,7 +21,7 @@ export interface DetectorContext { */ export interface Detector { kind: SignalKind; - detect(thread: Thread, ctx: DetectorContext): ActiveSignal[]; + detect(thread: Thread, ctx: DetectorContext): Array; } // --- helpers ------------------------------------------------------------------ @@ -131,7 +131,7 @@ export const mentionedNoResponse: Detector = { // Latest mention time per mentioned identity (ignoring self-mentions). const lastMention = thread.timeline.reduce((acc, e) => { if (e.kind !== "comment" && e.kind !== "review") return acc; - const mentions = Array.isArray(e.data.mentions) ? (e.data.mentions as string[]) : []; + const mentions = Array.isArray(e.data.mentions) ? (e.data.mentions as Array) : []; return mentions.reduce((inner, id) => { if (id === e.actor) return inner; const prev = inner.get(id); @@ -171,7 +171,7 @@ export const inProgressStale: Detector = { }; /** Pure per-thread detectors. `blocker_cleared` is cross-thread (see evaluate). */ -export const detectors: Detector[] = [ +export const detectors: Array = [ prNoReviewer, reviewRequested, unaddressedReviewComments, diff --git a/packages/core/src/domain.ts b/packages/core/src/domain.ts index 5743726..301e0ba 100644 --- a/packages/core/src/domain.ts +++ b/packages/core/src/domain.ts @@ -24,12 +24,12 @@ export interface Thread { /** Adapter-normalized lifecycle state (open/closed/merged/…). */ state: string; /** Identity ids. */ - participants: string[]; + participants: Array; /** Identity id of who owns the next step. */ owner?: string; /** Labels, board status, draft flag, etc. Shape is the deployment's concern. */ meta: Record; - timeline: TimelineEvent[]; + timeline: Array; } export interface TimelineEvent { @@ -61,7 +61,7 @@ export interface Link { export interface Cluster { id: string; - threadIds: string[]; + threadIds: Array; } export interface WorkingNotes { diff --git a/packages/core/src/evaluate.test.ts b/packages/core/src/evaluate.test.ts index ee306b2..a416ac2 100644 --- a/packages/core/src/evaluate.test.ts +++ b/packages/core/src/evaluate.test.ts @@ -3,6 +3,7 @@ import { fixedClock } from "./clock.js"; import type { EngineConfig, SignalConfig } from "./config.js"; import type { Link, Signal, SignalKind, Thread } from "./domain.js"; import { evaluate, type EngineContext } from "./pipeline.js"; +import { Ok } from "./result.js"; import type { Store } from "./store.js"; const NOW = "2026-01-10T00:00:00.000Z"; @@ -36,20 +37,22 @@ function fakeStore( const open = new Map((opts.signals ?? []).map((s) => [s.id, { ...s }])); const store = { async getOpenSignals(tid: string) { - return [...open.values()].filter((s) => s.threadId === tid && !s.clearedAt); + return Ok([...open.values()].filter((s) => s.threadId === tid && !s.clearedAt)); }, async upsertSignal(s: Signal) { open.set(s.id, { ...s }); + return Ok(undefined); }, async clearSignal(id: string, at: string) { const s = open.get(id); if (s) s.clearedAt = at; + return Ok(undefined); }, async getLinks() { - return opts.links ?? []; + return Ok(opts.links ?? []); }, async getThread(_p: string, nid: string) { - return opts.threads?.[nid]; + return Ok(opts.threads?.[nid]); }, } as unknown as Store; return { store, open }; @@ -78,7 +81,9 @@ describe("evaluate", () => { it("opens a newly-active signal", async () => { const { store, open } = fakeStore(); const result = await evaluate(ctx(store), prNoReviewerThread); - expect(result.map((s) => s.kind)).toContain("pr_no_reviewer"); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(result.data.map((s) => s.kind)).toContain("pr_no_reviewer"); expect(open.get("o/r#1:pr_no_reviewer:u-author")?.detectedAt).toBe(NOW); }); @@ -96,7 +101,9 @@ describe("evaluate", () => { }; const { store, open } = fakeStore({ signals: [existing] }); const result = await evaluate(ctx(store), withReviewer); - expect(result).toEqual([]); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(result.data).toEqual([]); expect(open.get(existing.id)?.clearedAt).toBe(NOW); }); @@ -110,7 +117,9 @@ describe("evaluate", () => { }; const { store, open } = fakeStore({ signals: [existing] }); const result = await evaluate(ctx(store), { ...prNoReviewerThread, state: "merged" }); - expect(result).toEqual([]); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(result.data).toEqual([]); expect(open.get(existing.id)?.clearedAt).toBe(NOW); }); @@ -130,6 +139,8 @@ describe("evaluate", () => { }; const { store } = fakeStore({ links, threads }); const result = await evaluate(ctx(store), blocked); - expect(result.find((s) => s.kind === "blocker_cleared")?.owedBy).toBe("u-owner"); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(result.data.find((s) => s.kind === "blocker_cleared")?.owedBy).toBe("u-owner"); }); }); diff --git a/packages/core/src/identity-source.test.ts b/packages/core/src/identity-source.test.ts index 3b2d3d7..67e3983 100644 --- a/packages/core/src/identity-source.test.ts +++ b/packages/core/src/identity-source.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { Identity } from "./domain.js"; import { configIdentitySource, ensureIdentityForHandle, rowToIdentity } from "./identity-source.js"; +import { Ok } from "./result.js"; import type { Store } from "./store.js"; function fakeStore(seed: Identity[] = []) { @@ -8,16 +9,18 @@ function fakeStore(seed: Identity[] = []) { const store = { async upsertIdentity(i: Identity) { ids.set(i.id, i); + return Ok(undefined); }, async findIdentity(q: { handle?: string; email?: string }) { for (const i of ids.values()) { - if (q.email && i.email === q.email) return i; - if (q.handle && Object.values(i.handles).includes(q.handle)) return i; + if (q.email && i.email === q.email) return Ok(i); + if (q.handle && Object.values(i.handles).includes(q.handle)) return Ok(i); } - return undefined; + return Ok(undefined); }, async deleteIdentity(id: string) { ids.delete(id); + return Ok(undefined); }, } as unknown as Store; return { store, ids }; @@ -30,24 +33,36 @@ const roster = [ describe("rowToIdentity", () => { it("defaults id to github: and maps handles", () => { - expect(rowToIdentity({ github: "bob" })).toEqual({ + const r = rowToIdentity({ github: "bob" }); + expect(r.ok).toBe(true); + expect(r.data).toEqual({ id: "github:bob", handles: { github: "bob" }, email: undefined, displayName: undefined, }); }); - it("throws without id or github", () => { - expect(() => rowToIdentity({ email: "x@y.com" })).toThrow(); + it("errs without id or github", () => { + expect(rowToIdentity({ email: "x@y.com" }).ok).toBe(false); }); }); describe("configIdentitySource", () => { it("resolves by email then by handle", async () => { const src = configIdentitySource(roster); - expect((await src.resolve({ email: "alice@x.com" }))?.id).toBe("u-alice"); - expect((await src.resolve({ handle: "bob" }))?.id).toBe("github:bob"); - expect(await src.resolve({ handle: "nobody" })).toBeUndefined(); + expect(src.ok).toBe(true); + expect((await src.data!.resolve({ email: "alice@x.com" }))?.id).toBe("u-alice"); + expect((await src.data!.resolve({ handle: "bob" }))?.id).toBe("github:bob"); + expect(await src.data!.resolve({ handle: "nobody" })).toBeUndefined(); + }); + + it("treats explicit null optional fields in a roster string as absent", async () => { + const json = JSON.stringify([ + { id: "u-alice", github: "alice", email: null, displayName: null }, + ]); + const src = configIdentitySource(json); + expect(src.ok).toBe(true); + expect((await src.data!.resolve({ handle: "alice" }))?.id).toBe("u-alice"); }); }); @@ -55,33 +70,38 @@ describe("ensureIdentityForHandle", () => { it("uses the roster canonical id and merges the handle", async () => { const { store, ids } = fakeStore(); const src = configIdentitySource(roster); - const id = await ensureIdentityForHandle(store, src, "github", "alice"); - expect(id).toBe("u-alice"); + expect(src.ok).toBe(true); + const r = await ensureIdentityForHandle(store, src.data!, "github", "alice"); + expect(r.ok).toBe(true); + expect(r.data).toBe("u-alice"); expect(ids.get("u-alice")?.handles.github).toBe("alice"); }); it("creates a github: partial on miss", async () => { const { store, ids } = fakeStore(); const src = configIdentitySource([]); - const id = await ensureIdentityForHandle(store, src, "github", "carol"); - expect(id).toBe("github:carol"); + expect(src.ok).toBe(true); + const r = await ensureIdentityForHandle(store, src.data!, "github", "carol"); + expect(r.ok).toBe(true); + expect(r.data).toBe("github:carol"); expect(ids.get("github:carol")?.handles.github).toBe("carol"); }); it("collapses a pre-existing partial into a roster custom id (no duplicate rows)", async () => { // 1) ingested before the roster knew bob -> partial github:bob created. const { store, ids } = fakeStore(); - await ensureIdentityForHandle(store, configIdentitySource([]), "github", "bob"); + const emptySource = configIdentitySource([]); + expect(emptySource.ok).toBe(true); + const partial = await ensureIdentityForHandle(store, emptySource.data!, "github", "bob"); + expect(partial.ok).toBe(true); expect(ids.has("github:bob")).toBe(true); // 2) roster later maps bob to a custom canonical id. - const id = await ensureIdentityForHandle( - store, - configIdentitySource([{ id: "u-bob", github: "bob", slack: "U9" }]), - "github", - "bob", - ); - expect(id).toBe("u-bob"); + const bobSource = configIdentitySource([{ id: "u-bob", github: "bob", slack: "U9" }]); + expect(bobSource.ok).toBe(true); + const r = await ensureIdentityForHandle(store, bobSource.data!, "github", "bob"); + expect(r.ok).toBe(true); + expect(r.data).toBe("u-bob"); expect(ids.has("github:bob")).toBe(false); // stale partial collapsed expect(ids.get("u-bob")?.handles).toMatchObject({ github: "bob", slack: "U9" }); }); diff --git a/packages/core/src/identity-source.ts b/packages/core/src/identity-source.ts index 73a9f83..24fe23e 100644 --- a/packages/core/src/identity-source.ts +++ b/packages/core/src/identity-source.ts @@ -1,5 +1,7 @@ +import { findMap } from "./common.helper.js"; import type { Identity, PlatformId } from "./domain.js"; import type { IdentitySource } from "./platform.js"; +import { Err, Ok, Result } from "./result.js"; import type { Store } from "./store.js"; /** A roster row from config (file / Worker var / D1 seed). */ @@ -13,24 +15,32 @@ export interface IdentityRow { } /** Map a roster row to an Identity. Canonical id is handle-derived, never email. */ -export function rowToIdentity(row: IdentityRow): Identity { +export function rowToIdentity(row: IdentityRow): Result { const handles: Identity["handles"] = {}; if (row.github) handles.github = row.github; if (row.slack) handles.slack = row.slack; const id = row.id ?? (row.github ? `github:${row.github}` : undefined); - if (!id) throw new Error("IdentityRow needs an id or a github handle"); - return { id, handles, email: row.email, displayName: row.displayName }; + if (!id) return Err(new Error("IdentityRow needs an id or a github handle")); + return Ok({ id, handles, email: row.email, displayName: row.displayName }); } /** * Config-backed IdentitySource (DESIGN §5). Pure over a parsed roster — no I/O * beyond the optional JSON.parse — so resolution is O(roster) and adapter-free. */ -export function configIdentitySource(roster: string | IdentityRow[]): IdentitySource { - const rows: IdentityRow[] = typeof roster === "string" ? safeParse(roster) : roster; - const identities = rows.map(rowToIdentity); +export function configIdentitySource( + roster: string | Array, +): Result { + const rowsResult = typeof roster === "string" ? safeParse(roster) : Ok(roster); + if (!rowsResult.ok) return rowsResult; + const mapped = rowsResult.data.map(rowToIdentity); + const firstErr = findMap(mapped, (r) => + r.ok ? { kind: "CONTINUE" } : { kind: "FOUND", data: r }, + ); + if (firstErr) return firstErr; + const identities = mapped.flatMap((r) => (r.ok ? [r.data] : [])); - return { + return Ok({ async list() { return identities; }, @@ -49,7 +59,7 @@ export function configIdentitySource(roster: string | IdentityRow[]): IdentitySo } return undefined; }, - }; + }); } /** @@ -66,9 +76,11 @@ export async function ensureIdentityForHandle( source: IdentitySource, platform: PlatformId, handle: string, -): Promise { +): Promise> { const fromRoster = await source.resolve({ handle, platform }); - const stored = await store.findIdentity({ handle }); + const storedResult = await store.findIdentity({ handle }); + if (!storedResult.ok) return storedResult; + const stored = storedResult.data; const canonicalId = fromRoster?.id ?? stored?.id ?? `${platform}:${handle}`; const identity: Identity = { @@ -78,15 +90,48 @@ export async function ensureIdentityForHandle( handles: { ...stored?.handles, ...fromRoster?.handles, [platform]: handle }, }; - if (stored && stored.id !== canonicalId) await store.deleteIdentity(stored.id); - await store.upsertIdentity(identity); - return canonicalId; + if (stored && stored.id !== canonicalId) { + const deleted = await store.deleteIdentity(stored.id); + if (!deleted.ok) return deleted; + } + const upserted = await store.upsertIdentity(identity); + if (!upserted.ok) return upserted; + return Ok(canonicalId); } -const safeParse = (s: string): IdentityRow[] => { - const v = JSON.parse(s); - if (!Array.isArray(v)) throw new Error("identity roster must be a JSON array"); - return v as IdentityRow[]; +const safeParse = (s: string): Result, Error> => { + const parsed = Result.fromSync(() => JSON.parse(s)); + if (!parsed.ok) return parsed; + const v = parsed.data; + if (!Array.isArray(v)) return Err(new Error("identity roster must be a JSON array")); + const rows = v.map((row) => { + if (!isIdentityRow(row)) { + return Err(new Error("identity roster row must contain only strings")); + } + return Ok(row); + }); + const firstErr = findMap(rows, (r) => (r.ok ? { kind: "CONTINUE" } : { kind: "FOUND", data: r })); + if (firstErr) return firstErr; + return Ok(rows.flatMap((r) => (r.ok ? [r.data] : []))); }; const eq = (a: string, b?: string) => !!b && a.toLowerCase() === b.toLowerCase(); + +const isRecord = (value: unknown): value is Record => { + const isObject = typeof value === "object"; + return isObject && value !== null; +}; + +const isOptionalString = (value: unknown) => { + return value === undefined || value === null || typeof value === "string"; +}; + +const isIdentityRow = (value: unknown): value is IdentityRow => { + if (!isRecord(value)) return false; + const validId = isOptionalString(value.id); + const validGithub = isOptionalString(value.github); + const validSlack = isOptionalString(value.slack); + const validEmail = isOptionalString(value.email); + const validDisplayName = isOptionalString(value.displayName); + return validId && validGithub && validSlack && validEmail && validDisplayName; +}; diff --git a/packages/core/src/judge.test.ts b/packages/core/src/judge.test.ts index 30685b2..97e8965 100644 --- a/packages/core/src/judge.test.ts +++ b/packages/core/src/judge.test.ts @@ -4,6 +4,7 @@ import type { EngineConfig, SignalConfig } from "./config.js"; import type { Thread, TimelineEvent } from "./domain.js"; import { judgeUnansweredMentions } from "./judge.js"; import type { EngineContext } from "./pipeline.js"; +import { Ok } from "./result.js"; import type { Store } from "./store.js"; const NOW = "2026-01-10T00:00:00.000Z"; @@ -17,7 +18,7 @@ function ctx(verdict: string): EngineContext { store: {} as Store, platforms: new Map(), identities: { list: async () => [], resolve: async () => undefined }, - llm: { complete: async () => verdict }, + llm: { complete: async () => Ok(verdict) }, config: { calendar: { timezone: "UTC", workingDays: [1, 2, 3, 4, 5] }, signals, @@ -54,20 +55,24 @@ const mentionThenReply: TimelineEvent[] = [ describe("judgeUnansweredMentions", () => { it("keeps the signal when the LLM says the reply did not answer", async () => { - const out = await judgeUnansweredMentions(ctx("no"), thread(mentionThenReply)); - expect(out).toEqual([{ kind: "mentioned_no_response", owedBy: "u-m" }]); + const r = await judgeUnansweredMentions(ctx("no"), thread(mentionThenReply)); + expect(r.ok).toBe(true); + if (r.ok) expect(r.data).toEqual([{ kind: "mentioned_no_response", owedBy: "u-m" }]); }); it("clears (no signal) when the LLM says it was answered", async () => { - const out = await judgeUnansweredMentions(ctx("yes"), thread(mentionThenReply)); - expect(out).toEqual([]); + const r = await judgeUnansweredMentions(ctx("yes"), thread(mentionThenReply)); + expect(r.ok).toBe(true); + if (r.ok) expect(r.data).toEqual([]); }); it("ignores a mention that has no reply yet (deterministic detector owns it)", async () => { const tl = [ ev({ actor: "u-asker", at: ago(40), data: { body: "@u-m ping", mentions: ["u-m"] } }), ]; - expect(await judgeUnansweredMentions(ctx("no"), thread(tl))).toEqual([]); + const r = await judgeUnansweredMentions(ctx("no"), thread(tl)); + expect(r.ok).toBe(true); + if (r.ok) expect(r.data).toEqual([]); }); it("does not judge before the quiet period", async () => { @@ -75,6 +80,8 @@ describe("judgeUnansweredMentions", () => { ev({ actor: "u-asker", at: ago(2), data: { body: "@u-m ping", mentions: ["u-m"] } }), ev({ actor: "u-m", at: ago(1), data: { body: "k" } }), ]; - expect(await judgeUnansweredMentions(ctx("no"), thread(tl))).toEqual([]); + const r = await judgeUnansweredMentions(ctx("no"), thread(tl)); + expect(r.ok).toBe(true); + if (r.ok) expect(r.data).toEqual([]); }); }); diff --git a/packages/core/src/judge.ts b/packages/core/src/judge.ts index bf31912..5613f24 100644 Binary files a/packages/core/src/judge.ts and b/packages/core/src/judge.ts differ diff --git a/packages/core/src/notes.ts b/packages/core/src/notes.ts index 2f76695..e1f1f1a 100644 --- a/packages/core/src/notes.ts +++ b/packages/core/src/notes.ts @@ -62,7 +62,7 @@ export function buildNotesPrompt(thread: Thread): string { export interface WorkingNotesParts { thread: Thread; - links: Link[]; + links: Array; /** nativeId -> normalized state, for linked threads we have in the store. */ linkedStates: Map; /** Owner's display handle, if resolved. */ @@ -81,13 +81,14 @@ export interface WorkingNotesParts { */ export function notesInputDigest(parts: Omit): string { const { thread, links, linkedStates, ownerHandle } = parts; - return JSON.stringify({ + const stringified = JSON.stringify({ prompt: buildNotesPrompt(thread), state: thread.state, ownerHandle: ownerHandle ?? null, links: links.map((l) => `${l.from}|${l.to}|${l.kind}`).sort(), linkedStates: [...linkedStates.entries()].sort(), }); + return stringified; } /** @@ -99,7 +100,7 @@ export function notesInputDigest(parts: Omit = [ NOTES_MARKER, "**Working notes** _by aipm_", "", diff --git a/packages/core/src/pipeline.test.ts b/packages/core/src/pipeline.test.ts index c0624e4..5096275 100644 --- a/packages/core/src/pipeline.test.ts +++ b/packages/core/src/pipeline.test.ts @@ -5,6 +5,7 @@ import type { Identity, Link, Thread } from "./domain.js"; import { configIdentitySource } from "./identity-source.js"; import type { NormalizedRef, Platform, RawEvent } from "./platform.js"; import { ingest, type EngineContext } from "./pipeline.js"; +import { Ok } from "./result.js"; import type { Store } from "./store.js"; function fakeStore() { @@ -14,24 +15,29 @@ function fakeStore() { const store = { async upsertThread(t: Thread) { threads.push(t); + return Ok(undefined); }, async upsertLinks(l: Link[]) { links.push(...l); + return Ok(undefined); }, async replaceLinksFrom(fromId: string, l: Link[]) { for (let i = links.length - 1; i >= 0; i--) { if (links[i]?.from === fromId) links.splice(i, 1); } links.push(...l.filter((it) => it.from === fromId)); + return Ok(undefined); }, async upsertIdentity(i: Identity) { identities.set(i.id, i); + return Ok(undefined); }, async findIdentity() { - return undefined; + return Ok(undefined); }, async deleteIdentity(id: string) { identities.delete(id); + return Ok(undefined); }, } as unknown as Store; return { store, threads, links, identities }; @@ -44,15 +50,15 @@ function fakePlatform(thread: Thread, links: Link[]): Platform { nativeId: thread.nativeId, type: thread.type, }), - getThread: async () => thread, - getTimeline: async () => thread.timeline, - discoverLinks: async () => links, - listThreads: async () => [], - postMessage: async () => ({ id: "x" }), - editMessage: async () => {}, - findStickyComment: async () => undefined, - react: async () => {}, - notifyPerson: async () => {}, + getThread: async () => Ok(thread), + getTimeline: async () => Ok(thread.timeline), + discoverLinks: async () => Ok(links), + listThreads: async () => Ok([]), + postMessage: async () => Ok({ id: "x" }), + editMessage: async () => Ok(undefined), + findStickyComment: async () => Ok(undefined), + react: async () => Ok(undefined), + notifyPerson: async () => Ok(undefined), }; } @@ -71,21 +77,23 @@ describe("ingest", () => { }; const link: Link = { from: "o/r#1", to: "o/r#2", kind: "refs" }; const { store, threads, links, identities } = fakeStore(); + const identitySource = configIdentitySource([{ id: "u-alice", github: "alice" }]); + expect(identitySource.ok).toBe(true); const ctx: EngineContext = { store, platforms: new Map([["github", fakePlatform(thread, [link])]]), - identities: configIdentitySource([{ id: "u-alice", github: "alice" }]), - llm: { complete: async (p) => p }, + identities: identitySource.data!, + llm: { complete: async (p) => Ok(p) }, config: {} as EngineConfig, clock: systemClock, }; const result = await ingest(ctx, { platform: "github", payload: {} }); - - expect(result?.participants).toEqual(["u-alice", "github:bob"]); - expect(result?.owner).toBe("u-alice"); - expect(result?.timeline[0]?.actor).toBe("github:carol"); // timeline actor resolved + expect(result.ok).toBe(true); + expect(result.data?.participants).toEqual(["u-alice", "github:bob"]); + expect(result.data?.owner).toBe("u-alice"); + expect(result.data?.timeline[0]?.actor).toBe("github:carol"); // timeline actor resolved expect(threads).toHaveLength(1); expect(links).toEqual([link]); expect(identities.get("u-alice")?.handles.github).toBe("alice"); @@ -107,18 +115,20 @@ describe("ingest", () => { { from: "o/r#inbound", to: "o/r#1", kind: "refs" }, ); const fresh: Link = { from: "o/r#1", to: "o/r#fresh", kind: "refs" }; + const emptySource = configIdentitySource([]); + expect(emptySource.ok).toBe(true); const ctx: EngineContext = { store, platforms: new Map([["github", fakePlatform(thread, [fresh])]]), - identities: configIdentitySource([]), - llm: { complete: async (p) => p }, + identities: emptySource.data!, + llm: { complete: async (p) => Ok(p) }, config: {} as EngineConfig, clock: systemClock, }; - await ingest(ctx, { platform: "github", payload: {} }); - + const result = await ingest(ctx, { platform: "github", payload: {} }); + expect(result.ok).toBe(true); expect(links).toEqual([ { from: "o/r#inbound", to: "o/r#1", kind: "refs" }, { from: "o/r#1", to: "o/r#fresh", kind: "refs" }, @@ -140,15 +150,19 @@ describe("ingest", () => { [], ); platform.normalizeEvent = () => undefined; + const emptySource = configIdentitySource([]); + expect(emptySource.ok).toBe(true); const ctx: EngineContext = { store, platforms: new Map([["github", platform]]), - identities: configIdentitySource([]), - llm: { complete: async (p) => p }, + identities: emptySource.data!, + llm: { complete: async (p) => Ok(p) }, config: {} as EngineConfig, clock: systemClock, }; - expect(await ingest(ctx, { platform: "github", payload: {} })).toBeUndefined(); + const result = await ingest(ctx, { platform: "github", payload: {} }); + expect(result.ok).toBe(true); + expect(result.data).toBeUndefined(); expect(threads).toHaveLength(0); }); }); diff --git a/packages/core/src/pipeline.ts b/packages/core/src/pipeline.ts index 29d5693..b27e4b4 100644 --- a/packages/core/src/pipeline.ts +++ b/packages/core/src/pipeline.ts @@ -1,4 +1,4 @@ -import { Result } from "./result.js"; +import { Err, Ok, Result } from "./result.js"; import { businessHoursBetween, type Clock } from "./clock.js"; import { isShadowed, type EngineConfig } from "./config.js"; import { detectors, isTerminal, type ActiveSignal, type DetectorContext } from "./detectors.js"; @@ -8,7 +8,7 @@ import { ensureIdentityForHandle } from "./identity-source.js"; import { judgeUnansweredMentions } from "./judge.js"; import { buildNudgeMessage, chooseChannel, signalLabel } from "./nudge.js"; import { dedupeKey } from "./cluster.js"; -import { asyncForEach, asyncMap, groupBy } from "./common.helper.js"; +import { asyncMap, groupBy } from "./common.helper.js"; import { buildNotesPrompt, NOTES_MARKER, @@ -35,34 +35,50 @@ export interface EngineContext { // --- Ingest ------------------------------------------------------------------- // webhook or sweep -> adapter normalizes -> upsert Thread/Link/participants. -export async function ingest(ctx: EngineContext, event: RawEvent): Promise { +export async function ingest( + ctx: EngineContext, + event: RawEvent, +): Promise> { const platform = ctx.platforms.get(event.platform); - if (!platform) throw new Error(`No adapter for platform: ${event.platform}`); + if (!platform) return Err(new Error(`No adapter for platform: ${event.platform}`)); - const ref = platform.normalizeEvent(event); - if (!ref) return undefined; // ignored event kind + const normalized = Result.fromSync(() => platform.normalizeEvent(event)); + if (!normalized.ok) return normalized; + const ref = normalized.data; + if (!ref) return Ok(undefined); // ignored event kind // Adapter returns participants/owner as platform handles (see Platform docs); // resolve them to canonical Identity ids and persist the rows. - const thread = await platform.getThread(ref.nativeId, ref.type); - return ingestThread(ctx, thread); + const fetched = await platform.getThread(ref.nativeId, ref.type); + if (!fetched.ok) return fetched; + const ingested = await ingestThread(ctx, fetched.data); + if (!ingested.ok) return ingested; + return Ok(ingested.data); } -export async function ingestThread(ctx: EngineContext, thread: Thread): Promise { +export async function ingestThread( + ctx: EngineContext, + thread: Thread, +): Promise> { const platform = ctx.platforms.get(thread.platform); - if (!platform) throw new Error(`No adapter for platform: ${thread.platform}`); + if (!platform) return Err(new Error(`No adapter for platform: ${thread.platform}`)); - const resolved = await resolveThreadIdentities(ctx, thread); + const resolvedResult = await resolveThreadIdentities(ctx, thread); + if (!resolvedResult.ok) return resolvedResult; + const resolved = resolvedResult.data; - await ctx.store.upsertThread(resolved); + const upsertedThread = await ctx.store.upsertThread(resolved); + if (!upsertedThread.ok) return upsertedThread; const links = await platform.discoverLinks(resolved); - await ctx.store.replaceLinksFrom( - resolved.nativeId, - links.filter((l) => l.from === resolved.nativeId), + if (!links.ok) return links; + const outgoingLinks = links.data.flatMap((link) => + link.from === resolved.nativeId ? [link] : [], ); + const replacedLinks = await ctx.store.replaceLinksFrom(resolved.nativeId, outgoingLinks); + if (!replacedLinks.ok) return replacedLinks; - return resolved; + return Ok(resolved); } /** @@ -72,7 +88,10 @@ export async function ingestThread(ctx: EngineContext, thread: Thread): Promise< * once so the persisted Thread is uniformly in the Identity-id namespace, which * the detectors (DESIGN §7) rely on for matching owed-by against repliers. */ -async function resolveThreadIdentities(ctx: EngineContext, thread: Thread): Promise { +async function resolveThreadIdentities( + ctx: EngineContext, + thread: Thread, +): Promise> { const author = strField(thread.meta.author); const assignees = strArray(thread.meta.assignees); const timelineHandles = thread.timeline.flatMap((e) => { @@ -90,12 +109,22 @@ async function resolveThreadIdentities(ctx: EngineContext, thread: Thread): Prom ...timelineHandles, ]); - const map = new Map(); - await Promise.all( - [...handles].map(async (h) => - map.set(h, await ensureIdentityForHandle(ctx.store, ctx.identities, thread.platform, h)), - ), - ); + const handleEntryResults = await asyncMap([...handles], async (handle) => { + const idResult = await ensureIdentityForHandle( + ctx.store, + ctx.identities, + thread.platform, + handle, + ); + if (!idResult.ok) return idResult; + const id = idResult.data; + return Ok([handle, id] as const); + }); + const handleEntryErrors = handleEntryResults.flatMap((it) => (it.ok ? [] : [it])); + const firstHandleEntryError = handleEntryErrors[0]; + if (firstHandleEntryError) return firstHandleEntryError; + const handleEntries = handleEntryResults.flatMap((it) => (it.ok ? [it.data] : [])); + const map = new Map(handleEntries); const idOf = (h: string) => map.get(h) ?? h; const remapData = (data: Record): Record => { const t = strField(data.target); @@ -110,7 +139,7 @@ async function resolveThreadIdentities(ctx: EngineContext, thread: Thread): Prom }; }; - return { + return Ok({ ...thread, participants: [...new Set(thread.participants.map(idOf))], owner: thread.owner ? idOf(thread.owner) : undefined, @@ -124,11 +153,11 @@ async function resolveThreadIdentities(ctx: EngineContext, thread: Thread): Prom ...(e.actor ? { actor: idOf(e.actor) } : {}), data: remapData(e.data), })), - }; + }); } const strField = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined); -const strArray = (v: unknown): string[] => +const strArray = (v: unknown): Array => Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []; export const platformForNativeId = (nativeId: string): PlatformId => nativeId.includes("#") ? "github" : "slack"; @@ -152,49 +181,70 @@ export const digestRefMrkdwn = (nativeId: string): string => { const signalKey = (s: { kind: SignalKind; owedBy?: string }) => `${s.kind}:${s.owedBy ?? ""}`; -export async function evaluate(ctx: EngineContext, thread: Thread): Promise { - const open = await ctx.store.getOpenSignals(thread.nativeId); +export async function evaluate( + ctx: EngineContext, + thread: Thread, +): Promise, Error>> { + const openSignals = await ctx.store.getOpenSignals(thread.nativeId); + if (!openSignals.ok) return openSignals; + const open = openSignals.data; const now = ctx.clock.now().toISOString(); // Universal stop condition: terminal thread clears all signals (DESIGN §7). if (isTerminal(thread)) { - await asyncForEach(open, async (s) => { - await ctx.store.clearSignal(s.id, now); - }); - return []; + const clearedResults = await asyncMap(open, (s) => ctx.store.clearSignal(s.id, now)); + const clearedErrors = clearedResults.flatMap((it) => (it.ok ? [] : [it])); + const firstClearedError = clearedErrors[0]; + if (firstClearedError) return firstClearedError; + return Ok([]); } const dctx: DetectorContext = { config: ctx.config, clock: ctx.clock }; - const active: ActiveSignal[] = detectors.flatMap((d) => { + const active: Array = detectors.flatMap((d) => { const enabled = ctx.config.signals[d.kind]?.enabled; return enabled ? d.detect(thread, dctx) : []; }); if (ctx.config.signals.blocker_cleared?.enabled) { - active.push(...(await detectBlockerCleared(ctx, thread))); + const blockerCleared = await detectBlockerCleared(ctx, thread); + if (!blockerCleared.ok) return blockerCleared; + active.push(...blockerCleared.data); } if (ctx.config.llmJudge && ctx.config.signals.mentioned_no_response?.enabled) { - active.push(...(await judgeUnansweredMentions(ctx, thread))); + const judgedMentions = await judgeUnansweredMentions(ctx, thread); + if (!judgedMentions.ok) return judgedMentions; + active.push(...judgedMentions.data); } // Reconcile: clear open signals no longer active; open newly-active ones. const activeKeys = new Set(active.map(signalKey)); - await asyncForEach(open, async (s) => { - if (activeKeys.has(signalKey(s))) return; - await ctx.store.clearSignal(s.id, now); + const reconciledClearResults = await asyncMap(open, async (s) => { + const stillActive = activeKeys.has(signalKey(s)); + if (stillActive) return Ok(undefined); + return ctx.store.clearSignal(s.id, now); }); + const reconciledClearErrors = reconciledClearResults.flatMap((it) => (it.ok ? [] : [it])); + const firstReconciledClearError = reconciledClearErrors[0]; + if (firstReconciledClearError) return firstReconciledClearError; const openKeys = new Set(open.map(signalKey)); - await asyncForEach(active, async (s) => { - if (openKeys.has(signalKey(s))) return; // already open; preserve detectedAt - await ctx.store.upsertSignal({ - id: `${thread.nativeId}:${s.kind}:${s.owedBy ?? ""}`, + const openedResults = await asyncMap(active, async (s) => { + const alreadyOpen = openKeys.has(signalKey(s)); + if (alreadyOpen) return Ok(undefined); // already open; preserve detectedAt + const signalId = `${thread.nativeId}:${s.kind}:${s.owedBy ?? ""}`; + return ctx.store.upsertSignal({ + id: signalId, threadId: thread.nativeId, kind: s.kind, owedBy: s.owedBy, detectedAt: now, }); }); - return ctx.store.getOpenSignals(thread.nativeId); + const openedErrors = openedResults.flatMap((it) => (it.ok ? [] : [it])); + const firstOpenedError = openedErrors[0]; + if (firstOpenedError) return firstOpenedError; + const currentOpen = await ctx.store.getOpenSignals(thread.nativeId); + if (!currentOpen.ok) return currentOpen; + return Ok(currentOpen.data); } /** @@ -203,24 +253,39 @@ export async function evaluate(ctx: EngineContext, thread: Thread): Promise { - const links = await ctx.store.getLinks(thread.nativeId); +async function detectBlockerCleared( + ctx: EngineContext, + thread: Thread, +): Promise, Error>> { + const linksResult = await ctx.store.getLinks(thread.nativeId); + if (!linksResult.ok) return linksResult; + const links = linksResult.data; const blockers = links .filter((l) => l.kind === "blocked_by" && l.from === thread.nativeId) .map((l) => l.to); - if (!blockers.length) return []; + if (!blockers.length) return Ok([]); - const blockerThreads = await asyncMap(blockers, (nid) => - ctx.store.getThread(thread.platform, nid), - ); - const present = blockerThreads.flatMap((t) => (t ? [t] : [])); - if (present.length === 0) return []; // we don't have the blockers' states yet + const blockerThreadResults = await asyncMap(blockers, async (nativeId) => { + const threadResult = await ctx.store.getThread(thread.platform, nativeId); + if (!threadResult.ok) return threadResult; + const blockerThread = threadResult.data; + return Ok(blockerThread); + }); + const blockerThreadErrors = blockerThreadResults.flatMap((it) => (it.ok ? [] : [it])); + const firstBlockerThreadError = blockerThreadErrors[0]; + if (firstBlockerThreadError) return firstBlockerThreadError; + const present = blockerThreadResults.flatMap((it) => { + if (!it.ok) return []; + if (!it.data) return []; + return [it.data]; + }); + if (present.length === 0) return Ok([]); // we don't have the blockers' states yet const anyStillOpen = present.some((t) => !isTerminal(t)); - if (anyStillOpen) return []; // still blocked by an open thread + if (anyStillOpen) return Ok([]); // still blocked by an open thread const person = thread.owner ?? (typeof thread.meta.author === "string" ? thread.meta.author : undefined); - return person ? [{ kind: "blocker_cleared", owedBy: person }] : []; + return Ok(person ? [{ kind: "blocker_cleared", owedBy: person }] : []); } // --- Synthesize (LLM) --------------------------------------------------------- @@ -230,33 +295,53 @@ export async function synthesize( ctx: EngineContext, thread: Thread, cluster?: Cluster, -): Promise { +): Promise> { const platform = ctx.platforms.get(thread.platform); - if (!platform) return; + if (!platform) return Ok(undefined); // Linked work + the linked threads' states (best effort, from our store). - const links = await ctx.store.getLinks(thread.nativeId); + const linksResult = await ctx.store.getLinks(thread.nativeId); + if (!linksResult.ok) return linksResult; + const links = linksResult.data; const counterparts = new Set(links.map((l) => (l.from === thread.nativeId ? l.to : l.from))); - const counterpartStates = await asyncMap([...counterparts], async (nid) => { - const lt = await ctx.store.getThread(platformForNativeId(nid), nid); - return lt ? ([nid, lt.state] as const) : null; + const counterpartStateResults = await asyncMap([...counterparts], async (nid) => { + const platformId = platformForNativeId(nid); + const threadResult = await ctx.store.getThread(platformId, nid); + if (!threadResult.ok) return threadResult; + const linkedThread = threadResult.data; + if (!linkedThread) return Ok(null); + return Ok([nid, linkedThread.state] as const); + }); + const counterpartStateErrors = counterpartStateResults.flatMap((it) => (it.ok ? [] : [it])); + const firstCounterpartStateError = counterpartStateErrors[0]; + if (firstCounterpartStateError) return firstCounterpartStateError; + const presentStates = counterpartStateResults.flatMap((it) => { + if (!it.ok) return []; + if (!it.data) return []; + return [it.data]; }); - const presentStates = counterpartStates.flatMap((entry) => (entry ? [entry] : [])); const linkedStates = new Map(presentStates); // Owner display handle (owner is a resolved Identity id). - let ownerHandle: string | undefined; - if (thread.owner) { - const id = await ctx.store.getIdentity(thread.owner); - ownerHandle = id?.handles[thread.platform] ?? id?.handles.github; - } + const identity = thread.owner ? await ctx.store.getIdentity(thread.owner) : Ok(undefined); + if (!identity.ok) return identity; + const ownerHandle = (() => { + if (!thread.owner) return undefined; + const id = identity.data; + if (!id) return undefined; + const platformHandle = id.handles[thread.platform]; + if (platformHandle !== undefined) return platformHandle; + return id.handles.github; + })(); // Fold in the cluster's cross-thread summary (e.g. a linked Slack discussion) // so the issue note shows the shared picture, not just its own timeline. let related: string | undefined; let clusterHash = ""; if (cluster && cluster.threadIds.length > 1) { - const cn = await ctx.store.getWorkingNotes("cluster", cluster.id); + const clusterNotes = await ctx.store.getWorkingNotes("cluster", cluster.id); + if (!clusterNotes.ok) return clusterNotes; + const cn = clusterNotes.data; if (cn) { related = clusterSummaryFor(cn.content); clusterHash = cn.contentHash; @@ -268,50 +353,61 @@ export async function synthesize( const parts = { thread, links, linkedStates, ownerHandle }; const contentHash = stableHash(`${notesInputDigest(parts)}|cluster:${clusterHash}`); - const stored = await ctx.store.getWorkingNotes("thread", thread.nativeId); + const storedNotes = await ctx.store.getWorkingNotes("thread", thread.nativeId); + if (!storedNotes.ok) return storedNotes; + const stored = storedNotes.data; const shadow = isShadowed(ctx.config, "workingNotes"); // Up to date: in shadow, "computed" is enough; live also needs it actually posted. - if (stored?.contentHash === contentHash && (shadow || stored.externalRef)) return; + if (stored?.contentHash === contentHash && (shadow || stored.externalRef)) return Ok(undefined); // Only call the LLM when something changed (bounds cost incl. in shadow). - const summaryMarkdown = await ctx.llm.complete(buildNotesPrompt(thread), { + const completed = await ctx.llm.complete(buildNotesPrompt(thread), { cacheKey: `notes:${thread.nativeId}:${contentHash}`, temperature: 0, }); + if (!completed.ok) return completed; + const summaryMarkdown = completed.data; // A transient empty LLM result must not overwrite a good note; retry next event. - if (!summaryMarkdown.trim()) return; + if (!summaryMarkdown.trim()) return Ok(undefined); const content = renderWorkingNotes({ ...parts, summaryMarkdown, related }, contentHash); if (shadow) { // Compute + persist the would-be note (no externalRef = not posted), so a // later live run still posts the first real comment, and unchanged events // short-circuit above. - await ctx.store.upsertWorkingNotes({ + const upserted = await ctx.store.upsertWorkingNotes({ scope: "thread", targetId: thread.nativeId, content, contentHash, provenance: `${thread.platform}:shadow`, }); - return; + if (!upserted.ok) return upserted; + return Ok(undefined); } // Edit-or-create exactly one sticky comment. Recover the id from the marker if // it wasn't persisted (retry after a partial post) or D1 lost it. - let externalRef = - stored?.externalRef ?? (await platform.findStickyComment(thread.nativeId, NOTES_MARKER)); + let externalRef = stored?.externalRef; + if (!externalRef) { + const stickyComment = await platform.findStickyComment(thread.nativeId, NOTES_MARKER); + if (!stickyComment.ok) return stickyComment; + externalRef = stickyComment.data; + } if (externalRef) { const ref = externalRef; - const edited = await Result.from(() => platform.editMessage(ref, content)); - if (!edited.ok && !isNotFound(edited.error)) throw edited.error; - if (!edited.ok) externalRef = undefined; // comment was deleted → re-post below + const edited = await platform.editMessage(ref, content); + if (!edited.ok && !isNotFound(edited.error)) return edited; + if (!edited.ok) externalRef = undefined; } if (!externalRef) { - externalRef = (await platform.postMessage({ threadNativeId: thread.nativeId }, content)).id; + const posted = await platform.postMessage({ threadNativeId: thread.nativeId }, content); + if (!posted.ok) return posted; + externalRef = posted.data.id; } - await ctx.store.upsertWorkingNotes({ + const upserted = await ctx.store.upsertWorkingNotes({ scope: "thread", targetId: thread.nativeId, content, @@ -319,6 +415,8 @@ export async function synthesize( provenance: `${thread.platform}:sticky`, externalRef, }); + if (!upserted.ok) return upserted; + return Ok(undefined); } /** Strip the cluster note's hidden marker + hash footer for embedding in an issue note. */ @@ -331,7 +429,10 @@ function clusterSummaryFor(content: string): string { /** A deleted-comment HTTP error (status attached by the adapter's REST client). */ function isNotFound(e: unknown): boolean { - const status = (e as { status?: number } | null)?.status; + if (typeof e !== "object") return false; + if (e === null) return false; + if (!("status" in e)) return false; + const status = e.status; return status === 404 || status === 410; } @@ -342,25 +443,34 @@ function isNotFound(e: unknown): boolean { export async function route( ctx: EngineContext, thread: Thread, - signals: Signal[], -): Promise { + signals: Array, +): Promise, Error>> { const now = ctx.clock.now(); const shadow = isShadowed(ctx.config, "nudges"); - const out: Nudge[] = []; + const out: Array = []; - await asyncForEach(signals, async (sig) => { - if (!sig.owedBy) return; + const routedResults = await asyncMap(signals, async (sig) => { + if (!sig.owedBy) return Ok(undefined); const person = sig.owedBy; - const identity = await ctx.store.getIdentity(person); + const identityResult = await ctx.store.getIdentity(person); + if (!identityResult.ok) return identityResult; + const identity = identityResult.data; // Bot exclusion + preferences (mute/snooze) always win (DESIGN §7/§8). - if (isBotIdentity(person, identity?.handles.github, ctx.config.botAccounts)) return; - const prefs = await ctx.store.getPreferences(person); - if (isSuppressed(prefs, thread, sig.kind, now)) return; + const githubHandle = identity?.handles.github; + const isBot = isBotIdentity(person, githubHandle, ctx.config.botAccounts); + if (isBot) return Ok(undefined); + const prefsResult = await ctx.store.getPreferences(person); + if (!prefsResult.ok) return prefsResult; + const prefs = prefsResult.data; + const suppressed = isSuppressed(prefs, thread, sig.kind, now); + if (suppressed) return Ok(undefined); const elevated = isElevated(prefs, thread, sig.kind); const key = dedupeKey(person, thread.nativeId, sig.kind); - const existing = await ctx.store.getNudgeByDedupeKey(key); + const existingResult = await ctx.store.getNudgeByDedupeKey(key); + if (!existingResult.ok) return existingResult; + const existing = existingResult.data; const sigCfg = ctx.config.signals[sig.kind]; // Shadow rows are not real deliveries: they neither throttle nor escalate the // first live send, so going live posts the first real nudge (DESIGN §8). @@ -370,10 +480,10 @@ export async function route( // (e.g. blocker_cleared), suppressed once any real nudge exists (DESIGN §7). if (priorReal?.sentAt) { const quiet = sigCfg?.quietPeriodHours ?? 0; - if (quiet === 0) return; - if (businessHoursBetween(new Date(priorReal.sentAt), now, ctx.config.calendar) < quiet) { - return; - } + if (quiet === 0) return Ok(undefined); + const elapsed = businessHoursBetween(new Date(priorReal.sentAt), now, ctx.config.calendar); + const withinQuiet = elapsed < quiet; + if (withinQuiet) return Ok(undefined); } const escalations = (priorReal?.escalations ?? 0) + 1; @@ -390,7 +500,11 @@ export async function route( const slack = ctx.platforms.get("slack"); let dmTarget: typeof identity; if (channel === "dm") { - const slackId = identity ? await resolveSlackUserId(ctx, slack, identity) : undefined; + const slackIdResult = identity + ? await resolveSlackUserId(ctx, slack, identity) + : Ok(undefined); + if (!slackIdResult.ok) return slackIdResult; + const slackId = slackIdResult.data; if (slackId && slack && identity) { dmTarget = { ...identity, handles: { ...identity.handles, slack: slackId } }; } else { @@ -412,15 +526,26 @@ export async function route( // row and won't re-DM (at-most-once per period; the open signal re-nudges // next period if a send was lost). const isFirstRealSend = !shadow && !priorReal; - const claimed = isFirstRealSend ? await ctx.store.tryClaimNudge(nudge) : true; - if (!claimed) return; - if (!isFirstRealSend) await ctx.store.upsertNudge(nudge); + const claimedResult = isFirstRealSend ? await ctx.store.tryClaimNudge(nudge) : Ok(true); + if (!claimedResult.ok) return claimedResult; + const claimed = claimedResult.data; + if (!claimed) return Ok(undefined); + if (!isFirstRealSend) { + const upsertedNudge = await ctx.store.upsertNudge(nudge); + if (!upsertedNudge.ok) return upsertedNudge; + } if (channel === "dm" && !shadow && slack && dmTarget) { - await slack.notifyPerson(dmTarget, buildNudgeMessage(thread, sig.kind)); + const message = buildNudgeMessage(thread, sig.kind); + const notified = await slack.notifyPerson(dmTarget, message); + if (!notified.ok) return notified; } out.push(nudge); + return Ok(undefined); }); - return out; + const routedErrors = routedResults.flatMap((it) => (it.ok ? [] : [it])); + const firstRoutedError = routedErrors[0]; + if (firstRoutedError) return firstRoutedError; + return Ok(out); } /** A resolved Slack user id (U…/W…) vs a roster-supplied username to resolve. */ @@ -431,19 +556,21 @@ async function resolveSlackUserId( ctx: EngineContext, slack: Platform | undefined, identity: Identity, -): Promise { +): Promise> { const rosterHandle = identity.handles.slack; - if (looksLikeSlackId(rosterHandle)) return rosterHandle; - if (!slack?.resolvePerson) return undefined; - - const resolved = await slack.resolvePerson(identity); - if (!looksLikeSlackId(resolved)) return undefined; - - await ctx.store.setIdentityHandle(identity.id, "slack", resolved); - return resolved; + if (looksLikeSlackId(rosterHandle)) return Ok(rosterHandle); + if (!slack?.resolvePerson) return Ok(undefined); + const resolvedResult = await slack.resolvePerson(identity); + if (!resolvedResult.ok) return resolvedResult; + const resolved = resolvedResult.data; + if (!looksLikeSlackId(resolved)) return Ok(undefined); + + const stored = await ctx.store.setIdentityHandle(identity.id, "slack", resolved); + if (!stored.ok) return stored; + return Ok(resolved); } -function isBotIdentity(id: string, githubHandle: string | undefined, bots: string[]): boolean { +function isBotIdentity(id: string, githubHandle: string | undefined, bots: Array): boolean { if (githubHandle) return githubHandle.endsWith("[bot]") || bots.includes(githubHandle); return id.endsWith("[bot]") || bots.some((b) => id === `github:${b}`); } @@ -464,7 +591,12 @@ function selectorMatches( return true; } -function isSuppressed(prefs: Preference[], thread: Thread, kind: SignalKind, now: Date): boolean { +function isSuppressed( + prefs: Array, + thread: Thread, + kind: SignalKind, + now: Date, +): boolean { return prefs.some((p) => { const matches = selectorMatches(p.selector, thread, kind); if (!matches) return false; @@ -476,7 +608,7 @@ function isSuppressed(prefs: Preference[], thread: Thread, kind: SignalKind, now } /** "I care about repo X high-pri" / "I own Z" → elevate matching nudges to DM. */ -function isElevated(prefs: Preference[], thread: Thread, kind: SignalKind): boolean { +function isElevated(prefs: Array, thread: Thread, kind: SignalKind): boolean { return prefs.some( (p) => (p.rule === "own" || (p.rule === "route" && p.selector.priority === "high")) && @@ -488,51 +620,74 @@ function isElevated(prefs: Preference[], thread: Thread, kind: SignalKind): bool // Per-person digest: collect queued digest nudges, DM each person one summary, // and mark them delivered (DESIGN §8). Org rollup over cluster notes is phase-5. -export async function aggregate(ctx: EngineContext): Promise { - const pending = await ctx.store.listPendingDigestNudges(); +export async function aggregate(ctx: EngineContext): Promise> { + const pendingResult = await ctx.store.listPendingDigestNudges(); + if (!pendingResult.ok) return pendingResult; + const pending = pendingResult.data; const slack = ctx.platforms.get("slack"); const shadow = isShadowed(ctx.config, "digest"); const now = ctx.clock.now().toISOString(); const byPerson = groupBy(pending, (n) => n.person); - await asyncForEach(Object.entries(byPerson), async ([person, nudges]) => { - if (!nudges) return; - const identity = await ctx.store.getIdentity(person); + const aggregatedResults = await asyncMap(Object.entries(byPerson), async ([person, nudges]) => { + if (!nudges) return Ok(undefined); + const identityResult = await ctx.store.getIdentity(person); + if (!identityResult.ok) return identityResult; + const identity = identityResult.data; // Split still-owed vs dead (signal cleared/missing). Dead nudges are reaped // regardless of deliverability so they aren't re-scanned by every digest. - const evaluated = await asyncMap(nudges, async (n) => { - const sig = await ctx.store.getSignal(n.signalId); + const evaluatedResults = await asyncMap(nudges, async (n) => { + const sigResult = await ctx.store.getSignal(n.signalId); + if (!sigResult.ok) return sigResult; + const sig = sigResult.data; if (sig && !sig.clearedAt) { - const line = `• ${signalLabel(sig.kind)} — ${digestRefMrkdwn(sig.threadId)}`; - return { kind: "live" as const, line, nudge: n }; + const label = signalLabel(sig.kind); + const ref = digestRefMrkdwn(sig.threadId); + const line = `• ${label} — ${ref}`; + return Ok({ kind: "live" as const, line, nudge: n }); } - await ctx.store.upsertNudge({ ...n, state: "cleared" }); - return { kind: "dead" as const }; + const clearedNudge = await ctx.store.upsertNudge({ ...n, state: "cleared" }); + if (!clearedNudge.ok) return clearedNudge; + return Ok({ kind: "dead" as const }); }); + const evaluatedErrors = evaluatedResults.flatMap((it) => (it.ok ? [] : [it])); + const firstEvaluatedError = evaluatedErrors[0]; + if (firstEvaluatedError) return firstEvaluatedError; + const evaluated = evaluatedResults.flatMap((it) => (it.ok ? [it.data] : [])); const live = evaluated.flatMap((entry) => { if (entry.kind !== "live") return []; return [{ line: entry.line, nudge: entry.nudge }]; }); - if (!live.length) return; + if (!live.length) return Ok(undefined); // Can't deliver (shadow / no Slack adapter / no identity): leave live nudges // pending so a later digest with a delivery path still sends them. - if (shadow || !slack || !identity) return; + if (shadow || !slack || !identity) return Ok(undefined); - const slackId = await resolveSlackUserId(ctx, slack, identity); - if (!slackId) return; + const slackIdResult = await resolveSlackUserId(ctx, slack, identity); + if (!slackIdResult.ok) return slackIdResult; + const slackId = slackIdResult.data; + if (!slackId) return Ok(undefined); // Claim before delivery (at-most-once): mark sent first so a cron re-fire or // mid-loop crash can't double-DM; a lost send re-digests after the quiet period. - await asyncForEach(live, async (item) => { - await ctx.store.upsertNudge({ ...item.nudge, state: "sent", sentAt: now }); + const sentResults = await asyncMap(live, (item) => { + return ctx.store.upsertNudge({ ...item.nudge, state: "sent", sentAt: now }); }); - const body = `🗒️ *Your plate* — ${live.length} item(s):\n${live.map((l) => l.line).join("\n")}`; - await slack.notifyPerson( - { ...identity, handles: { ...identity.handles, slack: slackId } }, - body, - ); + const sentErrors = sentResults.flatMap((it) => (it.ok ? [] : [it])); + const firstSentError = sentErrors[0]; + if (firstSentError) return firstSentError; + const lines = live.map((l) => l.line).join("\n"); + const body = `🗒️ *Your plate* — ${live.length} item(s):\n${lines}`; + const dmIdentity = { ...identity, handles: { ...identity.handles, slack: slackId } }; + const notified = await slack.notifyPerson(dmIdentity, body); + if (!notified.ok) return notified; + return Ok(undefined); }); + const aggregatedErrors = aggregatedResults.flatMap((it) => (it.ok ? [] : [it])); + const firstAggregatedError = aggregatedErrors[0]; + if (firstAggregatedError) return firstAggregatedError; + return Ok(undefined); } diff --git a/packages/core/src/platform.ts b/packages/core/src/platform.ts index b1ee04f..2449b80 100644 --- a/packages/core/src/platform.ts +++ b/packages/core/src/platform.ts @@ -1,4 +1,5 @@ import type { Identity, Link, PlatformId, Thread, ThreadType, TimelineEvent } from "./domain.js"; +import type { Result } from "./result.js"; /** A target for posting — a thread, a person, or a platform-native location. */ export interface PostTarget { @@ -48,34 +49,37 @@ export interface Platform { normalizeEvent(raw: RawEvent): NormalizedRef | undefined; /** For sweeps. Query shape is adapter-defined. */ - listThreads(query: Record): Promise; + listThreads(query: Record): Promise, Error>>; /** `hint` (from `normalizeEvent`) avoids an issue-vs-PR probe round-trip. */ - getThread(nativeId: string, hint?: ThreadType): Promise; - getTimeline(nativeId: string): Promise; - discoverLinks(thread: Thread): Promise; + getThread(nativeId: string, hint?: ThreadType): Promise>; + getTimeline(nativeId: string): Promise, Error>>; + discoverLinks(thread: Thread): Promise, Error>>; - postMessage(target: PostTarget, body: string): Promise<{ id: string }>; - editMessage(messageId: string, body: string): Promise; + postMessage(target: PostTarget, body: string): Promise>; + editMessage(messageId: string, body: string): Promise>; /** * Find the bot's existing sticky comment on a thread by a hidden marker, if * any — so the engine edits-or-creates one comment even if its stored id was * lost (D1 reset) or never persisted (retry after a partial post). */ - findStickyComment(threadNativeId: string, marker: string): Promise; - react(messageId: string, emoji: string): Promise; + findStickyComment( + threadNativeId: string, + marker: string, + ): Promise>; + react(messageId: string, emoji: string): Promise>; /** DM / mention a specific person. */ - notifyPerson(identity: Identity, body: string): Promise; + notifyPerson(identity: Identity, body: string): Promise>; /** * Resolve a person to this platform's native user id for DMs (e.g. Slack "U…" * via email/handle), or undefined if unresolvable. Optional — platforms with * no DM channel omit it (DESIGN §5). */ - resolvePerson?(identity: Identity): Promise; + resolvePerson?(identity: Identity): Promise>; } /** LLM adapter — a thin completion behind AI Gateway (swappable + cached). */ export interface LlmAdapter { - complete(prompt: string, opts?: LlmOptions): Promise; + complete(prompt: string, opts?: LlmOptions): Promise>; } export interface LlmOptions { @@ -92,7 +96,7 @@ export interface LlmOptions { * on top of this. */ export interface IdentitySource { - list(): Promise; + list(): Promise>; resolve(query: { handle?: string; email?: string; diff --git a/packages/core/src/preferences.test.ts b/packages/core/src/preferences.test.ts index 895b248..96e7f56 100644 --- a/packages/core/src/preferences.test.ts +++ b/packages/core/src/preferences.test.ts @@ -4,6 +4,7 @@ import type { EngineConfig, Identity, Preference } from "./index.js"; import type { Platform } from "./platform.js"; import { capturePreference, parsePreferenceText } from "./preferences.js"; import type { EngineContext } from "./pipeline.js"; +import { Ok } from "./result.js"; import type { Store } from "./store.js"; const NOW = "2026-01-10T00:00:00.000Z"; @@ -64,16 +65,18 @@ function harness(identity?: Identity) { const dms: string[] = []; const store = { async findIdentity() { - return identity; + return Ok(identity); }, async upsertPreference(p: Preference) { prefs.push(p); + return Ok(undefined); }, } as unknown as Store; const slack = { id: "slack", async notifyPerson(_i: Identity, body: string) { dms.push(body); + return Ok(undefined); }, } as unknown as Platform; const ctx: EngineContext = { diff --git a/packages/core/src/preferences.ts b/packages/core/src/preferences.ts index bb808a9..a87ce25 100644 --- a/packages/core/src/preferences.ts +++ b/packages/core/src/preferences.ts @@ -41,7 +41,7 @@ export function parsePreferenceText(text: string, now: Date): ParsedPreference | export interface CaptureResult { ok: boolean; - reason?: "unknown_user" | "unparsed"; + reason?: "unknown_user" | "unparsed" | "error"; preference?: Preference; } @@ -63,7 +63,9 @@ export async function capturePreference( slackUserId: string, text: string, ): Promise { - const identity = await ctx.store.findIdentity({ handle: slackUserId }); + const identityResult = await ctx.store.findIdentity({ handle: slackUserId }); + if (!identityResult.ok) return { ok: false, reason: "error" }; + const identity = identityResult.data; const slack = ctx.platforms.get("slack"); if (!identity) { // We know the Slack id but have no roster mapping — tell them, don't go silent. @@ -88,7 +90,10 @@ export async function capturePreference( } const preference: Preference = { person: identity.id, ...parsed }; - await ctx.store.upsertPreference(preference); - if (slack) await slack.notifyPerson(identity, `Got it — ${describe(parsed)}.`); + const upserted = await ctx.store.upsertPreference(preference); + if (!upserted.ok) return { ok: false, reason: "error" }; + if (slack) { + await slack.notifyPerson(identity, `Got it — ${describe(parsed)}.`); + } return { ok: true, preference }; } diff --git a/packages/core/src/route.test.ts b/packages/core/src/route.test.ts index 372ace5..376c648 100644 --- a/packages/core/src/route.test.ts +++ b/packages/core/src/route.test.ts @@ -4,6 +4,7 @@ import type { EngineConfig, SignalConfig } from "./config.js"; import type { Identity, Nudge, Preference, Signal, SignalKind, Thread } from "./domain.js"; import type { Platform } from "./platform.js"; import { route, type EngineContext } from "./pipeline.js"; +import { Ok } from "./result.js"; import type { Store } from "./store.js"; const NOW = "2026-01-10T00:00:00.000Z"; @@ -63,30 +64,33 @@ function fakeStore( const nudges = new Map(Object.entries(opts.nudges ?? {})); const store = { async getIdentity(id: string) { - return ids.get(id); + return Ok(ids.get(id)); }, async upsertIdentity(i: Identity) { ids.set(i.id, i); + return Ok(undefined); }, async setIdentityHandle(id: string, platform: string, handle: string) { const i = ids.get(id); if (i) ids.set(id, { ...i, handles: { ...i.handles, [platform]: handle } }); + return Ok(undefined); }, async getPreferences(person: string) { - return opts.prefs?.[person] ?? []; + return Ok(opts.prefs?.[person] ?? []); }, async getNudgeByDedupeKey(key: string) { - return nudges.get(key); + return Ok(nudges.get(key)); }, async upsertNudge(n: Nudge) { nudges.set(n.dedupeKey, n); + return Ok(undefined); }, async tryClaimNudge(n: Nudge) { const existing = nudges.get(n.dedupeKey); const ownedByOther = existing !== undefined && existing.state !== "shadow"; - if (ownedByOther) return false; + if (ownedByOther) return Ok(false); nudges.set(n.dedupeKey, n); - return true; + return Ok(true); }, } as unknown as Store; return { store, nudges }; @@ -98,14 +102,28 @@ function fakeSlack(resolve?: string) { id: "slack", async notifyPerson(identity: Identity, body: string) { sent.push(`${identity.handles.slack}:${body}`); + return Ok(undefined); }, async resolvePerson(identity: Identity) { - return resolve ?? identity.handles.slack; + return Ok(resolve ?? identity.handles.slack); }, } as unknown as Platform; return { platform, sent }; } +class ThisBoundSlack { + readonly id = "slack"; + readonly sent: Array = []; + constructor(private readonly resolvedId: string) {} + async notifyPerson(identity: Identity, body: string) { + this.sent.push(`${identity.handles.slack}:${body}`); + return Ok(undefined); + } + async resolvePerson(_identity: Identity) { + return Ok(this.resolvedId); + } +} + function ctx(store: Store, slack: Platform, shadow = false, maxEscalations = 3): EngineContext { return { store, @@ -123,7 +141,10 @@ describe("route", () => { identities: [{ id: "u-r", handles: { github: "r", slack: "U0ROUTE1" } }], }); const { platform, sent } = fakeSlack(); - const out = await route(ctx(store, platform), thread, [signal("u-r")]); + const routed = await route(ctx(store, platform), thread, [signal("u-r")]); + expect(routed.ok).toBe(true); + if (!routed.ok) throw routed.error; + const out = routed.data; expect(sent).toHaveLength(1); expect(out[0]).toMatchObject({ channel: "dm", state: "sent", person: "u-r" }); expect(nudges.get("u-r:o/r#1:review_requested")?.state).toBe("sent"); @@ -145,7 +166,10 @@ describe("route", () => { }, }); const { platform, sent } = fakeSlack(); - const out = await route(ctx(store, platform), thread, [signal("u-r")]); + const routed = await route(ctx(store, platform), thread, [signal("u-r")]); + expect(routed.ok).toBe(true); + if (!routed.ok) throw routed.error; + const out = routed.data; expect(sent).toHaveLength(0); expect(out).toHaveLength(0); }); @@ -166,7 +190,10 @@ describe("route", () => { }, }); const { platform, sent } = fakeSlack(); - const out = await route(ctx(store, platform, false, 3), thread, [signal("u-r")]); + const routed = await route(ctx(store, platform, false, 3), thread, [signal("u-r")]); + expect(routed.ok).toBe(true); + if (!routed.ok) throw routed.error; + const out = routed.data; expect(sent).toHaveLength(0); expect(out[0]).toMatchObject({ channel: "digest", state: "pending", escalations: 4 }); }); @@ -177,7 +204,10 @@ describe("route", () => { prefs: { "u-r": [{ person: "u-r", rule: "mute", selector: { repo: "o/r" } }] }, }); const { platform, sent } = fakeSlack(); - const out = await route(ctx(store, platform), thread, [signal("u-r")]); + const routed = await route(ctx(store, platform), thread, [signal("u-r")]); + expect(routed.ok).toBe(true); + if (!routed.ok) throw routed.error; + const out = routed.data; expect(sent).toHaveLength(0); expect(out).toHaveLength(0); }); @@ -185,7 +215,10 @@ describe("route", () => { it("falls back to digest when no Slack id resolves", async () => { const { store } = fakeStore({ identities: [{ id: "u-r", handles: { github: "r" } }] }); const { platform, sent } = fakeSlack(undefined); - const out = await route(ctx(store, platform), thread, [signal("u-r")]); + const routed = await route(ctx(store, platform), thread, [signal("u-r")]); + expect(routed.ok).toBe(true); + if (!routed.ok) throw routed.error; + const out = routed.data; expect(sent).toHaveLength(0); expect(out[0]).toMatchObject({ channel: "digest", state: "pending" }); }); @@ -193,17 +226,34 @@ describe("route", () => { it("caches a resolved Slack id back onto the identity", async () => { const { store } = fakeStore({ identities: [{ id: "u-r", handles: { github: "r" } }] }); const { platform, sent } = fakeSlack("U0ROUTE9"); - await route(ctx(store, platform), thread, [signal("u-r")]); - expect((await store.getIdentity("u-r"))?.handles.slack).toBe("U0ROUTE9"); + const routed = await route(ctx(store, platform), thread, [signal("u-r")]); + expect(routed.ok).toBe(true); + if (!routed.ok) throw routed.error; + const cached = await store.getIdentity("u-r"); + expect(cached.ok).toBe(true); + if (!cached.ok) throw cached.error; + expect(cached.data?.handles.slack).toBe("U0ROUTE9"); expect(sent[0]).toContain("U0ROUTE9:"); }); + it("calls resolvePerson as a method, not detached, so its `this` receiver survives", async () => { + const { store } = fakeStore({ identities: [{ id: "u-r", handles: { github: "r" } }] }); + const slack = new ThisBoundSlack("U0THIS01"); + const routed = await route(ctx(store, slack as unknown as Platform), thread, [signal("u-r")]); + expect(routed.ok).toBe(true); + expect(routed.data?.[0]).toMatchObject({ channel: "dm", state: "sent", person: "u-r" }); + expect(slack.sent[0]).toContain("U0THIS01:"); + }); + it("falls back to digest when a roster username does not resolve to a Slack id", async () => { const { store, nudges } = fakeStore({ identities: [{ id: "u-r", handles: { slack: "john.doe" } }], }); const { platform, sent } = fakeSlack("john.doe"); - const out = await route(ctx(store, platform), thread, [signal("u-r")]); + const routed = await route(ctx(store, platform), thread, [signal("u-r")]); + expect(routed.ok).toBe(true); + if (!routed.ok) throw routed.error; + const out = routed.data; expect(sent).toHaveLength(0); expect(out[0]).toMatchObject({ channel: "digest", state: "pending" }); expect(nudges.get("u-r:o/r#1:review_requested")?.state).toBe("pending"); @@ -214,12 +264,18 @@ describe("route", () => { identities: [{ id: "u-r", handles: { slack: "U0ROUTE1" } }], }); const { platform, sent } = fakeSlack(); - const shadowOut = await route(ctx(store, platform, true), thread, [signal("u-r")]); + const shadowRouted = await route(ctx(store, platform, true), thread, [signal("u-r")]); + expect(shadowRouted.ok).toBe(true); + if (!shadowRouted.ok) throw shadowRouted.error; + const shadowOut = shadowRouted.data; expect(sent).toHaveLength(0); expect(shadowOut[0]).toMatchObject({ state: "shadow", channel: "dm" }); // Flip live: the shadow row must not throttle the first real send. - const liveOut = await route(ctx(store, platform, false), thread, [signal("u-r")]); + const liveRouted = await route(ctx(store, platform, false), thread, [signal("u-r")]); + expect(liveRouted.ok).toBe(true); + if (!liveRouted.ok) throw liveRouted.error; + const liveOut = liveRouted.data; expect(sent).toHaveLength(1); expect(liveOut[0]).toMatchObject({ state: "sent", escalations: 1 }); expect(nudges.get("u-r:o/r#1:review_requested")?.state).toBe("sent"); @@ -235,7 +291,10 @@ describe("route", () => { config: config(false), clock: fixedClock(NOW), }; - const out = await route(ctxNoSlack, thread, [signal("u-r")]); + const routed = await route(ctxNoSlack, thread, [signal("u-r")]); + expect(routed.ok).toBe(true); + if (!routed.ok) throw routed.error; + const out = routed.data; expect(out[0]).toMatchObject({ channel: "digest", state: "pending" }); }); @@ -243,11 +302,17 @@ describe("route", () => { const { store } = fakeStore({ identities: [{ id: "u-o", handles: { slack: "U0ROUTE1" } }] }); const { platform, sent } = fakeSlack(); const sig = signal("u-o", "blocker_cleared"); - const first = await route(ctx(store, platform), thread, [sig]); + const firstRouted = await route(ctx(store, platform), thread, [sig]); + expect(firstRouted.ok).toBe(true); + if (!firstRouted.ok) throw firstRouted.error; + const first = firstRouted.data; expect(first[0]).toMatchObject({ channel: "dm", state: "sent" }); expect(sent).toHaveLength(1); // Re-evaluated next event while still active: must not DM again. - const second = await route(ctx(store, platform), thread, [sig]); + const secondRouted = await route(ctx(store, platform), thread, [sig]); + expect(secondRouted.ok).toBe(true); + if (!secondRouted.ok) throw secondRouted.error; + const second = secondRouted.data; expect(second).toHaveLength(0); expect(sent).toHaveLength(1); }); @@ -260,7 +325,10 @@ describe("route", () => { }, }); const { platform, sent } = fakeSlack(); - const out = await route(ctx(store, platform), thread, [signal("u-a", "draft_pr_aged")]); + const routed = await route(ctx(store, platform), thread, [signal("u-a", "draft_pr_aged")]); + expect(routed.ok).toBe(true); + if (!routed.ok) throw routed.error; + const out = routed.data; expect(out[0]).toMatchObject({ channel: "dm", state: "sent" }); expect(sent).toHaveLength(1); }); @@ -270,7 +338,10 @@ describe("route", () => { identities: [{ id: "github:dependabot[bot]", handles: { github: "dependabot[bot]" } }], }); const { platform, sent } = fakeSlack("U0ROUTE1"); - const out = await route(ctx(store, platform), thread, [signal("github:dependabot[bot]")]); + const routed = await route(ctx(store, platform), thread, [signal("github:dependabot[bot]")]); + expect(routed.ok).toBe(true); + if (!routed.ok) throw routed.error; + const out = routed.data; expect(sent).toHaveLength(0); expect(out).toHaveLength(0); }); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 76eb268..90f93ef 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -1,4 +1,5 @@ import type { Identity, Link, Nudge, Preference, Signal, Thread, WorkingNotes } from "./domain.js"; +import type { Result } from "./result.js"; /** * Persistence port. The engine depends on this interface, not on D1 directly; @@ -6,63 +7,69 @@ import type { Identity, Link, Nudge, Preference, Signal, Thread, WorkingNotes } */ export interface Store { // identities - upsertIdentity(identity: Identity): Promise; - getIdentity(id: string): Promise; - findIdentity(query: { handle?: string; email?: string }): Promise; + upsertIdentity(identity: Identity): Promise>; + getIdentity(id: string): Promise>; + findIdentity(query: { + handle?: string; + email?: string; + }): Promise>; /** Remove an identity row (used to collapse a stale partial into a canonical id). */ - deleteIdentity(id: string): Promise; + deleteIdentity(id: string): Promise>; /** Atomically set one platform handle without clobbering concurrent writes. */ - setIdentityHandle(id: string, platform: string, handle: string): Promise; + setIdentityHandle(id: string, platform: string, handle: string): Promise>; // threads + links - upsertThread(thread: Thread): Promise; - getThread(platform: string, nativeId: string): Promise; - upsertLinks(links: Link[]): Promise; - replaceLinksFrom(fromId: string, links: Link[]): Promise; - getLinks(threadId: string): Promise; + upsertThread(thread: Thread): Promise>; + getThread(platform: string, nativeId: string): Promise>; + upsertLinks(links: Array): Promise>; + replaceLinksFrom(fromId: string, links: Array): Promise>; + getLinks(threadId: string): Promise, Error>>; // clusters — flat thread→cluster membership; ids are minted, membership only grows (issue #8). /** The thread's current cluster id, or undefined if it has none yet. */ - findCluster(threadNativeId: string): Promise; + findCluster(threadNativeId: string): Promise>; /** The thread's cluster id, minting a fresh singleton cluster if it has none. Race-safe. */ - getOrCreateCluster(threadNativeId: string): Promise; + getOrCreateCluster(threadNativeId: string): Promise>; /** All thread nativeIds in a cluster, ordered for a stable fingerprint. */ - listClusterThreads(clusterId: string): Promise; + listClusterThreads(clusterId: string): Promise, Error>>; /** Move every thread from one cluster id to another (merge). */ - repointCluster(args: { fromClusterId: string; toClusterId: string }): Promise; + repointCluster(args: { + fromClusterId: string; + toClusterId: string; + }): Promise>; /** Drop a merged-away cluster's note + row. */ - deleteCluster(clusterId: string): Promise; + deleteCluster(clusterId: string): Promise>; // signals - upsertSignal(signal: Signal): Promise; - getOpenSignals(threadId: string): Promise; - listOpenSignals(): Promise; - getSignal(id: string): Promise; - clearSignal(id: string, clearedAt: string): Promise; + upsertSignal(signal: Signal): Promise>; + getOpenSignals(threadId: string): Promise, Error>>; + listOpenSignals(): Promise, Error>>; + getSignal(id: string): Promise>; + clearSignal(id: string, clearedAt: string): Promise>; // nudges - upsertNudge(nudge: Nudge): Promise; + upsertNudge(nudge: Nudge): Promise>; /** * Atomically claim the right to send the FIRST real nudge for a dedupe key: * inserts the row, or upgrades an existing shadow row to it. Returns true iff * this caller won the claim (then, and only then, send). A row already in a * non-shadow state means another caller/retry owns it → returns false. */ - tryClaimNudge(nudge: Nudge): Promise; - getNudgeByDedupeKey(dedupeKey: string): Promise; + tryClaimNudge(nudge: Nudge): Promise>; + getNudgeByDedupeKey(dedupeKey: string): Promise>; /** Queued digest nudges awaiting the next per-person digest (DESIGN §8). */ - listPendingDigestNudges(): Promise; + listPendingDigestNudges(): Promise, Error>>; // preferences - getPreferences(person: string): Promise; - upsertPreference(pref: Preference): Promise; + getPreferences(person: string): Promise, Error>>; + upsertPreference(pref: Preference): Promise>; // working notes getWorkingNotes( scope: WorkingNotes["scope"], targetId: string, - ): Promise; - upsertWorkingNotes(notes: WorkingNotes): Promise; + ): Promise>; + upsertWorkingNotes(notes: WorkingNotes): Promise>; /** All working notes of a scope (e.g. every cluster note for the org rollup). */ - listWorkingNotes(scope: WorkingNotes["scope"]): Promise; + listWorkingNotes(scope: WorkingNotes["scope"]): Promise, Error>>; } diff --git a/packages/core/src/synthesize.test.ts b/packages/core/src/synthesize.test.ts index 66fb487..4b09607 100644 --- a/packages/core/src/synthesize.test.ts +++ b/packages/core/src/synthesize.test.ts @@ -4,6 +4,7 @@ import type { EngineConfig } from "./config.js"; import type { Link, Thread, WorkingNotes } from "./domain.js"; import type { LlmAdapter, Platform } from "./platform.js"; import { synthesize, type EngineContext } from "./pipeline.js"; +import { Err, Ok } from "./result.js"; import type { Store } from "./store.js"; const COMMENT_URL = "https://api.github.com/repos/o/r/issues/comments/1"; @@ -26,19 +27,20 @@ function fakeStore(links: Link[] = [], threads = new Map()) { const notes = new Map(); const store = { async getLinks() { - return links; + return Ok(links); }, async getThread(platform: string, nativeId: string) { - return threads.get(`${platform}:${nativeId}`); + return Ok(threads.get(`${platform}:${nativeId}`)); }, async getIdentity() { - return undefined; + return Ok(undefined); }, async getWorkingNotes(scope: string, targetId: string) { - return notes.get(`${scope}:${targetId}`); + return Ok(notes.get(`${scope}:${targetId}`)); }, async upsertWorkingNotes(n: WorkingNotes) { notes.set(`${n.scope}:${n.targetId}`, n); + return Ok(undefined); }, } as unknown as Store; return { store, notes }; @@ -54,15 +56,18 @@ function fakePlatform(opts: FakeOpts = {}) { id: "github", async findStickyComment() { calls.find++; - return opts.sticky; + return Ok(opts.sticky); }, async postMessage() { calls.post++; - return { id: COMMENT_URL }; + return Ok({ id: COMMENT_URL }); }, async editMessage(messageId: string) { calls.edit.push(messageId); - if (opts.editThrows) throw Object.assign(new Error("gone"), opts.editThrows); + if (opts.editThrows) { + return Err(Object.assign(new Error("gone"), opts.editThrows)); + } + return Ok(undefined); }, } as unknown as Platform; return { platform, calls }; @@ -74,7 +79,7 @@ function ctxWith( llmOut: () => string, shadow = false, ): EngineContext { - const llm: LlmAdapter = { complete: async () => llmOut() }; + const llm: LlmAdapter = { complete: async () => Ok(llmOut()) }; return { store, platforms: new Map([["github", platform]]), @@ -89,10 +94,12 @@ describe("synthesize", () => { it("posts a new sticky comment on first run and stores externalRef", async () => { const { store, notes } = fakeStore(); const { platform, calls } = fakePlatform(); - await synthesize( + const r = await synthesize( ctxWith(store, platform, () => "v1"), mkThread(), ); + expect(r.ok).toBe(true); + if (!r.ok) throw r.error; expect(calls.post).toBe(1); expect(notes.get("thread:o/r#1")?.externalRef).toBe(COMMENT_URL); }); @@ -105,8 +112,12 @@ describe("synthesize", () => { llmCalls++; return "v1"; }); - await synthesize(ctx, mkThread()); - await synthesize(ctx, mkThread()); + const r1 = await synthesize(ctx, mkThread()); + expect(r1.ok).toBe(true); + if (!r1.ok) throw r1.error; + const r2 = await synthesize(ctx, mkThread()); + expect(r2.ok).toBe(true); + if (!r2.ok) throw r2.error; expect(calls.post).toBe(1); expect(calls.edit).toHaveLength(0); expect(llmCalls).toBe(1); // second run short-circuits before the LLM @@ -116,8 +127,12 @@ describe("synthesize", () => { const { store } = fakeStore(); const { platform, calls } = fakePlatform(); const ctx = ctxWith(store, platform, () => "vN"); - await synthesize(ctx, mkThread()); - await synthesize(ctx, mkThread({ state: "merged" })); // input changed + const r1 = await synthesize(ctx, mkThread()); + expect(r1.ok).toBe(true); + if (!r1.ok) throw r1.error; + const r2 = await synthesize(ctx, mkThread({ state: "merged" })); // input changed + expect(r2.ok).toBe(true); + if (!r2.ok) throw r2.error; expect(calls.post).toBe(1); expect(calls.edit).toEqual([COMMENT_URL]); }); @@ -125,10 +140,12 @@ describe("synthesize", () => { it("recovers a lost externalRef via the marker (D1 reset) and edits, not duplicates", async () => { const { store } = fakeStore(); const { platform, calls } = fakePlatform({ sticky: COMMENT_URL }); - await synthesize( + const r = await synthesize( ctxWith(store, platform, () => "v1"), mkThread(), ); + expect(r.ok).toBe(true); + if (!r.ok) throw r.error; expect(calls.find).toBe(1); expect(calls.edit).toEqual([COMMENT_URL]); expect(calls.post).toBe(0); // found existing → edited, no duplicate @@ -137,10 +154,12 @@ describe("synthesize", () => { it("re-posts when the sticky comment was deleted (edit 404)", async () => { const { store } = fakeStore(); const { platform, calls } = fakePlatform({ sticky: COMMENT_URL, editThrows: { status: 404 } }); - await synthesize( + const r = await synthesize( ctxWith(store, platform, () => "v1"), mkThread(), ); + expect(r.ok).toBe(true); + if (!r.ok) throw r.error; expect(calls.edit).toEqual([COMMENT_URL]); // tried to edit expect(calls.post).toBe(1); // 404 → fell back to a fresh post }); @@ -156,11 +175,13 @@ describe("synthesize", () => { provenance: "cluster", }); const { platform } = fakePlatform(); - await synthesize( + const r = await synthesize( ctxWith(store, platform, () => "issue summary"), mkThread(), { id: "cluster:o/r#1", threadIds: ["o/r#1", "C1/1.2"] }, ); + expect(r.ok).toBe(true); + if (!r.ok) throw r.error; const posted = notes.get("thread:o/r#1")!.content; expect(posted).toContain("### Related discussion"); expect(posted).toContain("slack says ship it"); @@ -181,29 +202,37 @@ describe("synthesize", () => { const { store, notes } = fakeStore(links, threads); const { platform, calls } = fakePlatform(); const ctx = ctxWith(store, platform, () => "issue summary"); - await synthesize(ctx, mkThread()); + const r1 = await synthesize(ctx, mkThread()); + expect(r1.ok).toBe(true); + if (!r1.ok) throw r1.error; expect(notes.get("thread:o/r#1")!.content).not.toContain("C1/1700.0001"); threads.set("slack:C1/1700.0001", { ...slackThread, state: "closed" }); - await synthesize(ctx, mkThread()); + const r2 = await synthesize(ctx, mkThread()); + expect(r2.ok).toBe(true); + if (!r2.ok) throw r2.error; expect(calls.edit).toHaveLength(1); }); it("shadow posts nothing but persists the would-be note; flipping live then posts", async () => { const { store, notes } = fakeStore(); const { platform, calls } = fakePlatform(); - await synthesize( + const shadowRun = await synthesize( ctxWith(store, platform, () => "v1", true), mkThread(), ); + expect(shadowRun.ok).toBe(true); + if (!shadowRun.ok) throw shadowRun.error; expect(calls.post).toBe(0); expect(notes.get("thread:o/r#1")?.externalRef).toBeUndefined(); // computed, not posted // Flip shadow off, same inputs: must post the first real comment. - await synthesize( + const liveRun = await synthesize( ctxWith(store, platform, () => "v1", false), mkThread(), ); + expect(liveRun.ok).toBe(true); + if (!liveRun.ok) throw liveRun.error; expect(calls.post).toBe(1); expect(notes.get("thread:o/r#1")?.externalRef).toBe(COMMENT_URL); }); diff --git a/packages/db/src/d1-store.ts b/packages/db/src/d1-store.ts index ec49a23..3f17aa2 100644 --- a/packages/db/src/d1-store.ts +++ b/packages/db/src/d1-store.ts @@ -8,12 +8,21 @@ import type { Thread, WorkingNotes, } from "@aipm/core"; +import { Err, findMap, Ok, Result } from "@aipm/core"; const threadKey = (platform: string, nativeId: string) => `${platform}:${nativeId}`; -const json = (v: unknown) => JSON.stringify(v); -const parse = (v: unknown, fallback: T): T => - typeof v === "string" && v.length ? (JSON.parse(v) as T) : fallback; +const json = (v: unknown): Result => { + return Result.fromSync(() => JSON.stringify(v)); +}; + +const parse = (v: unknown, fallback: T): Result => { + const hasJson = typeof v === "string" && v.length > 0; + if (!hasJson) return Ok(fallback); + const parsed = Result.fromSync(() => JSON.parse(v)); + if (!parsed.ok) return parsed; + return Ok(parsed.data as T); +}; interface ThreadRow { platform: string; @@ -28,19 +37,25 @@ interface ThreadRow { timeline: string; } -function rowToThread(r: ThreadRow): Thread { - return { +function rowToThread(r: ThreadRow): Result { + const participants = parse(r.participants, [] as Array); + if (!participants.ok) return participants; + const meta = parse(r.meta, {} as Record); + if (!meta.ok) return meta; + const timeline = parse(r.timeline, [] as Thread["timeline"]); + if (!timeline.ok) return timeline; + return Ok({ platform: r.platform, nativeId: r.native_id, type: r.type as Thread["type"], title: r.title ?? undefined, body: r.body ?? undefined, state: r.state, - participants: parse(r.participants, [] as string[]), + participants: participants.data, owner: r.owner ?? undefined, - meta: parse(r.meta, {} as Record), - timeline: parse(r.timeline, [] as Thread["timeline"]), - }; + meta: meta.data, + timeline: timeline.data, + }); } function rowToNudge(r: Record): Nudge { @@ -82,315 +97,462 @@ export class D1Store implements Store { constructor(private readonly db: D1Database) {} // --- identities --- - async upsertIdentity(i: Identity): Promise { - await this.db - .prepare( - `INSERT INTO identities (id, handles, email, display_name) VALUES (?, ?, ?, ?) + async upsertIdentity(i: Identity): Promise> { + const handles = json(i.handles); + if (!handles.ok) return handles; + const written = await Result.from(() => + this.db + .prepare( + `INSERT INTO identities (id, handles, email, display_name) VALUES (?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET handles=excluded.handles, email=excluded.email, display_name=excluded.display_name`, - ) - .bind(i.id, json(i.handles), i.email ?? null, i.displayName ?? null) - .run(); + ) + .bind(i.id, handles.data, i.email ?? null, i.displayName ?? null) + .run(), + ); + if (!written.ok) return written; + return Ok(undefined); } - async getIdentity(id: string): Promise { - const r = await this.db.prepare(`SELECT * FROM identities WHERE id = ?`).bind(id).first(); - return r ? this.rowToIdentity(r) : undefined; + async getIdentity(id: string): Promise> { + const r = await Result.from(() => + this.db.prepare(`SELECT * FROM identities WHERE id = ?`).bind(id).first(), + ); + if (!r.ok) return r; + const row = r.data; + if (!row) return Ok(undefined); + const mapped = this.rowToIdentity(row); + if (!mapped.ok) return mapped; + return Ok(mapped.data); } - async findIdentity(q: { handle?: string; email?: string }): Promise { - if (q.email) { - const r = await this.db - .prepare(`SELECT * FROM identities WHERE email = ? LIMIT 1`) - .bind(q.email) - .first(); - if (r) return this.rowToIdentity(r); - } - if (q.handle) { - // handle match across any platform in the JSON blob. - const handle = q.handle; - const { results } = await this.db.prepare(`SELECT * FROM identities`).all(); - const idents = results.map((r) => this.rowToIdentity(r)); - const match = idents.find((ident) => Object.values(ident.handles).includes(handle)); - if (match) return match; - } - return undefined; + async findIdentity(q: { + handle?: string; + email?: string; + }): Promise> { + if (!q.email && !q.handle) return Ok(undefined); + const queried = await Result.from(() => this.db.prepare(`SELECT * FROM identities`).all()); + if (!queried.ok) return queried; + const match = findMap(queried.data.results, (it) => { + const mapped = this.rowToIdentity(it); + if (!mapped.ok) return { kind: "CONTINUE" }; + const ident = mapped.data; + const byEmail = q.email !== undefined && ident.email === q.email; + const byHandle = q.handle !== undefined && Object.values(ident.handles).includes(q.handle); + return byEmail || byHandle ? { kind: "FOUND", data: ident } : { kind: "CONTINUE" }; + }); + return Ok(match ?? undefined); } - async deleteIdentity(id: string): Promise { - await this.db.prepare(`DELETE FROM identities WHERE id = ?`).bind(id).run(); + async deleteIdentity(id: string): Promise> { + const written = await Result.from(() => + this.db.prepare(`DELETE FROM identities WHERE id = ?`).bind(id).run(), + ); + if (!written.ok) return written; + return Ok(undefined); } - async setIdentityHandle(id: string, platform: string, handle: string): Promise { + async setIdentityHandle( + id: string, + platform: string, + handle: string, + ): Promise> { // json_set on the JSON column avoids a read-modify-write race across DOs. - await this.db - .prepare(`UPDATE identities SET handles = json_set(handles, '$.' || ?, ?) WHERE id = ?`) - .bind(platform, handle, id) - .run(); + const written = await Result.from(() => + this.db + .prepare(`UPDATE identities SET handles = json_set(handles, '$.' || ?, ?) WHERE id = ?`) + .bind(platform, handle, id) + .run(), + ); + if (!written.ok) return written; + return Ok(undefined); } - private rowToIdentity(r: Record): Identity { - return { + private rowToIdentity(r: Record): Result { + const handles = parse(r.handles, {} as Identity["handles"]); + if (!handles.ok) return handles; + return Ok({ id: r.id as string, - handles: parse(r.handles, {} as Identity["handles"]), + handles: handles.data, email: (r.email as string | null) ?? undefined, displayName: (r.display_name as string | null) ?? undefined, - }; + }); } // --- threads + links --- - async upsertThread(t: Thread): Promise { - await this.db - .prepare( - `INSERT INTO threads (id, platform, native_id, type, title, body, state, participants, owner, meta, timeline, updated_at) + async upsertThread(t: Thread): Promise> { + const participants = json(t.participants); + if (!participants.ok) return participants; + const meta = json(t.meta); + if (!meta.ok) return meta; + const timeline = json(t.timeline); + if (!timeline.ok) return timeline; + const written = await Result.from(() => + this.db + .prepare( + `INSERT INTO threads (id, platform, native_id, type, title, body, state, participants, owner, meta, timeline, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET type=excluded.type, title=excluded.title, body=excluded.body, state=excluded.state, participants=excluded.participants, owner=excluded.owner, meta=excluded.meta, timeline=excluded.timeline, updated_at=excluded.updated_at`, - ) - .bind( - threadKey(t.platform, t.nativeId), - t.platform, - t.nativeId, - t.type, - t.title ?? null, - t.body ?? null, - t.state, - json(t.participants), - t.owner ?? null, - json(t.meta), - json(t.timeline), - new Date().toISOString(), - ) - .run(); - } - - async getThread(platform: string, nativeId: string): Promise { - const r = await this.db - .prepare(`SELECT * FROM threads WHERE platform = ? AND native_id = ?`) - .bind(platform, nativeId) - .first(); - return r ? rowToThread(r) : undefined; + ) + .bind( + threadKey(t.platform, t.nativeId), + t.platform, + t.nativeId, + t.type, + t.title ?? null, + t.body ?? null, + t.state, + participants.data, + t.owner ?? null, + meta.data, + timeline.data, + new Date().toISOString(), + ) + .run(), + ); + if (!written.ok) return written; + return Ok(undefined); } - async upsertLinks(links: Link[]): Promise { - if (!links.length) return; - await this.db.batch( - links.map((l) => - this.db - .prepare(`INSERT OR IGNORE INTO links (from_id, to_id, kind) VALUES (?, ?, ?)`) - .bind(l.from, l.to, l.kind), - ), + async getThread(platform: string, nativeId: string): Promise> { + const r = await Result.from(() => + this.db + .prepare(`SELECT * FROM threads WHERE platform = ? AND native_id = ?`) + .bind(platform, nativeId) + .first(), ); + if (!r.ok) return r; + const row = r.data; + if (!row) return Ok(undefined); + const mapped = rowToThread(row); + if (!mapped.ok) return mapped; + return Ok(mapped.data); } - async replaceLinksFrom(fromId: string, links: Link[]): Promise { - await this.db.batch([ - this.db.prepare(`DELETE FROM links WHERE from_id = ?`).bind(fromId), - ...links - .filter((l) => l.from === fromId) - .map((l) => + async upsertLinks(links: Array): Promise> { + if (!links.length) return Ok(undefined); + const written = await Result.from(() => + this.db.batch( + links.map((l) => this.db .prepare(`INSERT OR IGNORE INTO links (from_id, to_id, kind) VALUES (?, ?, ?)`) .bind(l.from, l.to, l.kind), ), - ]); + ), + ); + if (!written.ok) return written; + return Ok(undefined); + } + + async replaceLinksFrom(fromId: string, links: Array): Promise> { + const written = await Result.from(() => + this.db.batch([ + this.db.prepare(`DELETE FROM links WHERE from_id = ?`).bind(fromId), + ...links + .filter((l) => l.from === fromId) + .map((l) => + this.db + .prepare(`INSERT OR IGNORE INTO links (from_id, to_id, kind) VALUES (?, ?, ?)`) + .bind(l.from, l.to, l.kind), + ), + ]), + ); + if (!written.ok) return written; + return Ok(undefined); } - async getLinks(threadId: string): Promise { - const { results } = await this.db - .prepare(`SELECT from_id, to_id, kind FROM links WHERE from_id = ? OR to_id = ?`) - .bind(threadId, threadId) - .all<{ from_id: string; to_id: string; kind: string }>(); - return results.map((r) => ({ from: r.from_id, to: r.to_id, kind: r.kind as Link["kind"] })); + async getLinks(threadId: string): Promise, Error>> { + const queried = await Result.from(() => + this.db + .prepare(`SELECT from_id, to_id, kind FROM links WHERE from_id = ? OR to_id = ?`) + .bind(threadId, threadId) + .all<{ from_id: string; to_id: string; kind: string }>(), + ); + if (!queried.ok) return queried; + const links = queried.data.results.map((r) => ({ + from: r.from_id, + to: r.to_id, + kind: r.kind as Link["kind"], + })); + return Ok(links); } // --- clusters --- - async findCluster(threadNativeId: string) { - const row = await this.db - .prepare(`SELECT cluster_id FROM thread_cluster WHERE thread_id = ?`) - .bind(threadNativeId) - .first<{ cluster_id: string }>(); - return row ? row.cluster_id : undefined; + async findCluster(threadNativeId: string): Promise> { + const row = await Result.from(() => + this.db + .prepare(`SELECT cluster_id FROM thread_cluster WHERE thread_id = ?`) + .bind(threadNativeId) + .first<{ cluster_id: string }>(), + ); + if (!row.ok) return row; + return Ok(row.data ? row.data.cluster_id : undefined); } - async getOrCreateCluster(threadNativeId: string) { + async getOrCreateCluster(threadNativeId: string): Promise> { const freshId = crypto.randomUUID(); - await this.db - .prepare(`INSERT OR IGNORE INTO thread_cluster (thread_id, cluster_id) VALUES (?, ?)`) - .bind(threadNativeId, freshId) - .run(); - const row = await this.db - .prepare(`SELECT cluster_id FROM thread_cluster WHERE thread_id = ?`) - .bind(threadNativeId) - .first<{ cluster_id: string }>(); - if (!row) throw new Error("CLUSTER_MEMBERSHIP_LOST"); - const clusterId = row.cluster_id; - await this.db.prepare(`INSERT OR IGNORE INTO clusters (id) VALUES (?)`).bind(clusterId).run(); - return clusterId; + const inserted = await Result.from(() => + this.db + .prepare(`INSERT OR IGNORE INTO thread_cluster (thread_id, cluster_id) VALUES (?, ?)`) + .bind(threadNativeId, freshId) + .run(), + ); + if (!inserted.ok) return inserted; + const row = await Result.from(() => + this.db + .prepare(`SELECT cluster_id FROM thread_cluster WHERE thread_id = ?`) + .bind(threadNativeId) + .first<{ cluster_id: string }>(), + ); + if (!row.ok) return row; + if (!row.data) return Err(new Error("CLUSTER_MEMBERSHIP_LOST")); + const clusterId = row.data.cluster_id; + const ensured = await Result.from(() => + this.db.prepare(`INSERT OR IGNORE INTO clusters (id) VALUES (?)`).bind(clusterId).run(), + ); + if (!ensured.ok) return ensured; + return Ok(clusterId); } - async listClusterThreads(clusterId: string) { - const { results } = await this.db - .prepare(`SELECT thread_id FROM thread_cluster WHERE cluster_id = ? ORDER BY thread_id`) - .bind(clusterId) - .all<{ thread_id: string }>(); - return results.map((it) => it.thread_id); + async listClusterThreads(clusterId: string): Promise, Error>> { + const queried = await Result.from(() => + this.db + .prepare(`SELECT thread_id FROM thread_cluster WHERE cluster_id = ? ORDER BY thread_id`) + .bind(clusterId) + .all<{ thread_id: string }>(), + ); + if (!queried.ok) return queried; + return Ok(queried.data.results.map((it) => it.thread_id)); } - async repointCluster(args: { fromClusterId: string; toClusterId: string }) { - await this.db - .prepare(`UPDATE thread_cluster SET cluster_id = ? WHERE cluster_id = ?`) - .bind(args.toClusterId, args.fromClusterId) - .run(); + async repointCluster(args: { + fromClusterId: string; + toClusterId: string; + }): Promise> { + const written = await Result.from(() => + this.db + .prepare(`UPDATE thread_cluster SET cluster_id = ? WHERE cluster_id = ?`) + .bind(args.toClusterId, args.fromClusterId) + .run(), + ); + if (!written.ok) return written; + return Ok(undefined); } - async deleteCluster(clusterId: string) { - await this.db.batch([ - this.db - .prepare(`DELETE FROM working_notes WHERE scope = 'cluster' AND target_id = ?`) - .bind(clusterId), - this.db.prepare(`DELETE FROM clusters WHERE id = ?`).bind(clusterId), - ]); + async deleteCluster(clusterId: string): Promise> { + const written = await Result.from(() => + this.db.batch([ + this.db + .prepare(`DELETE FROM working_notes WHERE scope = 'cluster' AND target_id = ?`) + .bind(clusterId), + this.db.prepare(`DELETE FROM clusters WHERE id = ?`).bind(clusterId), + ]), + ); + if (!written.ok) return written; + return Ok(undefined); } // --- signals --- - async upsertSignal(s: Signal): Promise { - await this.db - .prepare( - `INSERT INTO signals (id, thread_id, kind, owed_by, detected_at, cleared_at) VALUES (?, ?, ?, ?, ?, ?) + async upsertSignal(s: Signal): Promise> { + const written = await Result.from(() => + this.db + .prepare( + `INSERT INTO signals (id, thread_id, kind, owed_by, detected_at, cleared_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET owed_by=excluded.owed_by, cleared_at=excluded.cleared_at`, - ) - .bind(s.id, s.threadId, s.kind, s.owedBy ?? null, s.detectedAt, s.clearedAt ?? null) - .run(); + ) + .bind(s.id, s.threadId, s.kind, s.owedBy ?? null, s.detectedAt, s.clearedAt ?? null) + .run(), + ); + if (!written.ok) return written; + return Ok(undefined); } - async getOpenSignals(threadId: string): Promise { - const { results } = await this.db - .prepare(`SELECT * FROM signals WHERE thread_id = ? AND cleared_at IS NULL`) - .bind(threadId) - .all(); - return results.map(rowToSignal); + async getOpenSignals(threadId: string): Promise, Error>> { + const queried = await Result.from(() => + this.db + .prepare(`SELECT * FROM signals WHERE thread_id = ? AND cleared_at IS NULL`) + .bind(threadId) + .all(), + ); + if (!queried.ok) return queried; + const signals = queried.data.results.map(rowToSignal); + return Ok(signals); } - async listOpenSignals(): Promise { - const { results } = await this.db - .prepare(`SELECT * FROM signals WHERE cleared_at IS NULL ORDER BY detected_at`) - .all(); - return results.map(rowToSignal); + async listOpenSignals(): Promise, Error>> { + const queried = await Result.from(() => + this.db.prepare(`SELECT * FROM signals WHERE cleared_at IS NULL ORDER BY detected_at`).all(), + ); + if (!queried.ok) return queried; + const signals = queried.data.results.map(rowToSignal); + return Ok(signals); } - async getSignal(id: string): Promise { - const r = await this.db.prepare(`SELECT * FROM signals WHERE id = ?`).bind(id).first(); - return r ? rowToSignal(r) : undefined; + async getSignal(id: string): Promise> { + const r = await Result.from(() => + this.db.prepare(`SELECT * FROM signals WHERE id = ?`).bind(id).first(), + ); + if (!r.ok) return r; + if (!r.data) return Ok(undefined); + return Ok(rowToSignal(r.data)); } - async clearSignal(id: string, clearedAt: string): Promise { - await this.db - .prepare(`UPDATE signals SET cleared_at = ? WHERE id = ?`) - .bind(clearedAt, id) - .run(); + async clearSignal(id: string, clearedAt: string): Promise> { + const written = await Result.from(() => + this.db.prepare(`UPDATE signals SET cleared_at = ? WHERE id = ?`).bind(clearedAt, id).run(), + ); + if (!written.ok) return written; + return Ok(undefined); } // --- nudges --- - async upsertNudge(n: Nudge): Promise { - await this.db - .prepare( - `INSERT INTO nudges (dedupe_key, person, signal_id, channel, sent_at, state, escalations) + async upsertNudge(n: Nudge): Promise> { + const written = await Result.from(() => + this.db + .prepare( + `INSERT INTO nudges (dedupe_key, person, signal_id, channel, sent_at, state, escalations) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(dedupe_key) DO UPDATE SET signal_id=excluded.signal_id, channel=excluded.channel, sent_at=excluded.sent_at, state=excluded.state, escalations=excluded.escalations`, - ) - .bind(n.dedupeKey, n.person, n.signalId, n.channel, n.sentAt ?? null, n.state, n.escalations) - .run(); + ) + .bind( + n.dedupeKey, + n.person, + n.signalId, + n.channel, + n.sentAt ?? null, + n.state, + n.escalations, + ) + .run(), + ); + if (!written.ok) return written; + return Ok(undefined); } - async tryClaimNudge(n: Nudge) { - const res = await this.db - .prepare( - `INSERT INTO nudges (dedupe_key, person, signal_id, channel, sent_at, state, escalations) + async tryClaimNudge(n: Nudge): Promise> { + const res = await Result.from(() => + this.db + .prepare( + `INSERT INTO nudges (dedupe_key, person, signal_id, channel, sent_at, state, escalations) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(dedupe_key) DO UPDATE SET person=excluded.person, signal_id=excluded.signal_id, channel=excluded.channel, sent_at=excluded.sent_at, state=excluded.state, escalations=excluded.escalations WHERE nudges.state = 'shadow'`, - ) - .bind(n.dedupeKey, n.person, n.signalId, n.channel, n.sentAt ?? null, n.state, n.escalations) - .run(); - return res.meta.changes === 1; + ) + .bind( + n.dedupeKey, + n.person, + n.signalId, + n.channel, + n.sentAt ?? null, + n.state, + n.escalations, + ) + .run(), + ); + if (!res.ok) return res; + return Ok(res.data.meta.changes === 1); } - async getNudgeByDedupeKey(dedupeKey: string): Promise { - const r = await this.db - .prepare(`SELECT * FROM nudges WHERE dedupe_key = ?`) - .bind(dedupeKey) - .first(); - return r ? rowToNudge(r) : undefined; + async getNudgeByDedupeKey(dedupeKey: string): Promise> { + const r = await Result.from(() => + this.db.prepare(`SELECT * FROM nudges WHERE dedupe_key = ?`).bind(dedupeKey).first(), + ); + if (!r.ok) return r; + return Ok(r.data ? rowToNudge(r.data) : undefined); } - async listPendingDigestNudges(): Promise { - const { results } = await this.db - .prepare(`SELECT * FROM nudges WHERE channel = 'digest' AND state = 'pending'`) - .all(); - return results.map(rowToNudge); + async listPendingDigestNudges(): Promise, Error>> { + const queried = await Result.from(() => + this.db.prepare(`SELECT * FROM nudges WHERE channel = 'digest' AND state = 'pending'`).all(), + ); + if (!queried.ok) return queried; + return Ok(queried.data.results.map(rowToNudge)); } // --- preferences --- - async getPreferences(person: string): Promise { - const { results } = await this.db - .prepare(`SELECT * FROM preferences WHERE person = ?`) - .bind(person) - .all(); - return results.map((r) => ({ - person: r.person as string, - rule: r.rule as Preference["rule"], - selector: parse(r.selector, {} as Record), - until: (r.until as string | null) ?? undefined, - })); + async getPreferences(person: string): Promise, Error>> { + const queried = await Result.from(() => + this.db.prepare(`SELECT * FROM preferences WHERE person = ?`).bind(person).all(), + ); + if (!queried.ok) return queried; + const mappedRows = queried.data.results.map((r) => { + const selector = parse(r.selector, {} as Record); + if (!selector.ok) return selector; + return Ok({ + person: r.person as string, + rule: r.rule as Preference["rule"], + selector: selector.data, + until: (r.until as string | null) ?? undefined, + }); + }); + const firstErr = mappedRows.find((m) => !m.ok); + if (firstErr && !firstErr.ok) return firstErr; + const preferences = mappedRows.flatMap((m) => (m.ok ? [m.data] : [])); + return Ok(preferences); } - async upsertPreference(p: Preference): Promise { + async upsertPreference(p: Preference): Promise> { // Idempotent on (person, rule, selector): repeating a command updates `until` // (re-snooze) instead of piling up duplicate rows. selector JSON is canonical // because it's produced by fixed code paths. - await this.db - .prepare( - `INSERT INTO preferences (person, rule, selector, until) VALUES (?, ?, ?, ?) + const selector = json(p.selector); + if (!selector.ok) return selector; + const written = await Result.from(() => + this.db + .prepare( + `INSERT INTO preferences (person, rule, selector, until) VALUES (?, ?, ?, ?) ON CONFLICT(person, rule, selector) DO UPDATE SET until=excluded.until`, - ) - .bind(p.person, p.rule, json(p.selector), p.until ?? null) - .run(); + ) + .bind(p.person, p.rule, selector.data, p.until ?? null) + .run(), + ); + if (!written.ok) return written; + return Ok(undefined); } // --- working notes --- async getWorkingNotes( scope: WorkingNotes["scope"], targetId: string, - ): Promise { - const r = await this.db - .prepare(`SELECT * FROM working_notes WHERE scope = ? AND target_id = ?`) - .bind(scope, targetId) - .first(); - return r ? rowToWorkingNotes(r) : undefined; + ): Promise> { + const r = await Result.from(() => + this.db + .prepare(`SELECT * FROM working_notes WHERE scope = ? AND target_id = ?`) + .bind(scope, targetId) + .first(), + ); + if (!r.ok) return r; + return Ok(r.data ? rowToWorkingNotes(r.data) : undefined); } - async listWorkingNotes(scope: WorkingNotes["scope"]): Promise { - const { results } = await this.db - .prepare(`SELECT * FROM working_notes WHERE scope = ?`) - .bind(scope) - .all(); - return results.map(rowToWorkingNotes); + async listWorkingNotes( + scope: WorkingNotes["scope"], + ): Promise, Error>> { + const queried = await Result.from(() => + this.db.prepare(`SELECT * FROM working_notes WHERE scope = ?`).bind(scope).all(), + ); + if (!queried.ok) return queried; + return Ok(queried.data.results.map(rowToWorkingNotes)); } - async upsertWorkingNotes(n: WorkingNotes): Promise { - await this.db - .prepare( - `INSERT INTO working_notes (scope, target_id, content, content_hash, provenance, external_ref) + async upsertWorkingNotes(n: WorkingNotes): Promise> { + const written = await Result.from(() => + this.db + .prepare( + `INSERT INTO working_notes (scope, target_id, content, content_hash, provenance, external_ref) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(scope, target_id) DO UPDATE SET content=excluded.content, content_hash=excluded.content_hash, provenance=excluded.provenance, external_ref=excluded.external_ref`, - ) - .bind(n.scope, n.targetId, n.content, n.contentHash, n.provenance, n.externalRef ?? null) - .run(); + ) + .bind(n.scope, n.targetId, n.content, n.contentHash, n.provenance, n.externalRef ?? null) + .run(), + ); + if (!written.ok) return written; + return Ok(undefined); } }