import { randomUUID } from "node:crypto"; import { and, asc, count, desc, eq, gt, gte, inArray, lte, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { companyMemberships, decisionBundles, decisionEffectExecutions, decisionRetention, decisions, decisionTargetIssues, heartbeatRuns, issueRelations, issues } from "@paperclipai/db"; import { ATTENTION_SOURCE_KINDS, decisionEffectTargetIssueIds } from "@paperclipai/shared"; import type { AttentionArchiveManifestEntry, DecisionEffect, DecisionInput, DecisionOption, DecisionStatsCounts, DecisionStatsResponse } from "@paperclipai/shared"; import { conflict, forbidden, notFound, tooManyRequests, unprocessable } from "../errors.js"; import { authorizationService, type AuthorizationActor } from "./authorization.js"; import { logActivity, publishActivity, type ActivityPublication } from "./activity-log.js"; import { signDecisionSpec, verifyDecisionSpec } from "./decision-signing.js"; import { issueService, type IssuePostCommitAction, } from "./issues.js"; import { decisionRetentionService, hashAttentionArchiveManifest } from "./decision-retention.js"; type Snapshot = { status: string; assigneeAgentId: string | null; assigneeUserId: string | null; updatedAt: string; descendantCount?: number; descendantIds?: string[]; childCount?: number; attentionArchive?: unknown }; type Wake = (input: { companyId: string; agentId: string; issueId: string; decisionId: string; outcome: "decided" | "expired" | "cancelled" }) => Promise; export type DecisionServiceOptions = { wakeOriginAgent: Wake }; const DAY = 86_400_000; function targetIds(options: DecisionOption[]) { const result = new Set(); for (const option of options) for (const effect of option.effects) for (const id of decisionEffectTargetIssueIds(effect)) result.add(id); return [...result]; } function targetActions(options: DecisionOption[]) { const result = new Map>(); for (const option of options) for (const effect of option.effects) { const action = effect.type === "comment_on_issue" ? "issue:comment" as const : "issue:mutate" as const; for (const id of decisionEffectTargetIssueIds(effect)) { const actions = result.get(id) ?? new Set(); actions.add(action); result.set(id, actions); } } return result; } function sameIds(left: readonly string[], right: readonly string[]) { if (left.length !== right.length) return false; const rightIds = new Set(right); return rightIds.size === right.length && left.every((id) => rightIds.has(id)); } function sameInputValues(left: Record, right: Record) { const leftKeys = Object.keys(left); const rightKeys = Object.keys(right); return leftKeys.length === rightKeys.length && leftKeys.every((key) => left[key] === right[key]); } function spec(decision: { id: string; options: DecisionOption[]; targetSnapshots: Record }) { return { decisionId: decision.id, options: decision.options, targetSnapshots: decision.targetSnapshots }; } function resource(issue: typeof issues.$inferSelect) { return { type: "issue" as const, companyId: issue.companyId, issueId: issue.id, projectId: issue.projectId, parentIssueId: issue.parentId, assigneeAgentId: issue.assigneeAgentId, assigneeUserId: issue.assigneeUserId, status: issue.status }; } function interpolate(text: string, values: Record) { return text.replace(/\{\{input\.([A-Za-z0-9_-]+)\}\}/g, (_all, id: string) => values[id] ?? ""); } function canonicalJson(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; if (value && typeof value === "object") { return `{${Object.entries(value as Record).sort(([left], [right]) => left.localeCompare(right)) .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(",")}}`; } return JSON.stringify(value); } function parseAttentionArchiveManifest( companyId: string, targetSnapshots: Record, ): AttentionArchiveManifestEntry[] | null { const entries: AttentionArchiveManifestEntry[] = []; const seen = new Set(); for (const [key, snapshot] of Object.entries(targetSnapshots)) { if (!key.startsWith("attention:")) return null; const entry = snapshot.attentionArchive; if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null; const candidate = entry as Record; if (candidate.companyId !== companyId || typeof candidate.sourceKind !== "string" || !ATTENTION_SOURCE_KINDS.includes(candidate.sourceKind as (typeof ATTENTION_SOURCE_KINDS)[number]) || typeof candidate.sourceId !== "string" || !candidate.sourceId || !Number.isInteger(candidate.expectedVersion) || Number(candidate.expectedVersion) < 1 || typeof candidate.activityAt !== "string" || !Number.isFinite(Date.parse(candidate.activityAt)) || typeof candidate.reason !== "string" || !candidate.reason.trim()) return null; const expectedKey = `attention:${candidate.sourceKind}:${candidate.sourceId}`; if (key !== expectedKey || seen.has(expectedKey)) return null; seen.add(expectedKey); entries.push({ companyId, sourceKind: candidate.sourceKind, sourceId: candidate.sourceId, expectedVersion: Number(candidate.expectedVersion), activityAt: candidate.activityAt, reason: candidate.reason, }); } return entries.length > 0 ? entries : null; } function boardCanActDirectly(actor: AuthorizationActor, companyId: string) { if (actor.type !== "board") return false; if (actor.source === "local_implicit" || actor.isInstanceAdmin) return true; return actor.companyIds?.includes(companyId) === true || actor.memberships?.some((membership) => membership.companyId === companyId && membership.status === "active") === true; } export function decisionService(db: Db, options: DecisionServiceOptions) { const authz = authorizationService(db); let targetSweepCursor: string | null = null; type CreateInput = { companyId: string; actor: AuthorizationActor; agentId: string; runId: string; bundleId?: string | null; ruleKey?: string | null; title: string; body: string; options: DecisionOption[]; inputs?: DecisionInput[] | null; expiresAt?: Date | null; idempotencyKey?: string | null; continuationPolicy?: "none" | "wake_origin_agent"; metadata?: Record }; type CreateInputWithSnapshots = CreateInput & { additionalTargetSnapshots?: Record }; async function origin(companyId: string, agentId: string, runId: string) { const run = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.agentId, agentId))) .then((rows) => rows[0] ?? null); if (!run) throw forbidden("Decision provenance requires the origin run"); const issueId = typeof run.contextSnapshot?.issueId === "string" ? run.contextSnapshot.issueId : null; if (!issueId) throw unprocessable("Origin run is not issue-scoped"); return { run, issueId }; } async function recoveryActor(companyId: string, userId: string): Promise { const membership = await db.select({ companyId: companyMemberships.companyId, membershipRole: companyMemberships.membershipRole, status: companyMemberships.status }).from(companyMemberships).where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, userId), eq(companyMemberships.status, "active"))) .then((rows) => rows[0] ?? null); if (!membership) return { type: "none", source: "none" }; return { type: "board", userId, companyIds: [companyId], memberships: [membership], source: "session" }; } async function collectDescendantIds(companyId: string, rootId: string, dbOrTx: Db) { const queue = [rootId]; const visited = new Set([rootId]); const result: string[] = []; while (queue.length) { const parentId = queue.shift()!; const children = await dbOrTx.select({ id: issues.id }).from(issues).where(and(eq(issues.companyId, companyId), eq(issues.parentId, parentId))); for (const child of children) { if (visited.has(child.id)) continue; visited.add(child.id); result.push(child.id); queue.push(child.id); } } return result; } async function snapshots(companyId: string, ids: string[], actor: AuthorizationActor, requiredActions: ReturnType, cancellationTargetIds: ReadonlySet, dbOrTx: Db) { const rows = ids.length ? await dbOrTx.select().from(issues).where(and(eq(issues.companyId, companyId), inArray(issues.id, ids))) : []; if (rows.length !== ids.length) throw unprocessable("All referenced issues must exist in the company"); const result: Record = {}; for (const issue of rows) { const access = await authz.decide({ actor, action: "issue:read", resource: resource(issue) }); if (!access.allowed) throw forbidden("Decision target is outside the origin visibility boundary"); for (const action of requiredActions.get(issue.id) ?? []) { const effectAccess = await authz.decide({ actor, action, resource: resource(issue) }); if (!effectAccess.allowed) throw forbidden("Decision effect exceeds the origin authority boundary"); } const cancellationTarget = cancellationTargetIds.has(issue.id); const descendantIds = cancellationTarget ? await collectDescendantIds(companyId, issue.id, dbOrTx) : []; if (cancellationTarget && descendantIds.length) { const descendants = await dbOrTx.select().from(issues) .where(and(eq(issues.companyId, companyId), inArray(issues.id, descendantIds))); const descendantAccess = await Promise.all(descendants.map((descendant) => authz.decide({ actor, action: "issue:mutate", resource: resource(descendant) }))); if (descendants.length !== descendantIds.length || descendantAccess.some((access) => !access.allowed)) { throw forbidden("Decision effect exceeds the origin authority boundary"); } } result[issue.id] = { status: issue.status, assigneeAgentId: issue.assigneeAgentId, assigneeUserId: issue.assigneeUserId, updatedAt: issue.updatedAt.toISOString(), ...(cancellationTarget ? { descendantCount: descendantIds.length, descendantIds } : {}) }; } return result; } async function createInStore(input: CreateInputWithSnapshots, dbOrTx: Db) { if (input.idempotencyKey) { const lockKey = `decision-create:${input.companyId}:${input.idempotencyKey}`; await dbOrTx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`); } const provenance = await origin(input.companyId, input.agentId, input.runId); if (input.idempotencyKey) { const existing = await dbOrTx.select().from(decisions).where(and(eq(decisions.companyId, input.companyId), eq(decisions.idempotencyKey, input.idempotencyKey))) .then((rows) => rows[0] ?? null); if (existing) { const archiveEquivalent = input.additionalTargetSnapshots === undefined || ( canonicalJson(existing.metadata ?? {}) === canonicalJson(input.metadata ?? {}) && canonicalJson(Object.fromEntries(Object.entries(existing.targetSnapshots as Record) .filter(([key]) => key.startsWith("attention:")))) === canonicalJson(input.additionalTargetSnapshots) ); const equivalent = existing.title === input.title && existing.body === input.body && canonicalJson(existing.options) === canonicalJson(input.options) && canonicalJson(existing.inputs ?? null) === canonicalJson(input.inputs ?? null) && archiveEquivalent; if (!equivalent) throw conflict("Decision idempotency key already used with a different payload"); return existing; } } const open = await dbOrTx.select({ value: count() }).from(decisions).where(and(eq(decisions.companyId, input.companyId), eq(decisions.originAgentId, input.agentId), eq(decisions.status, "open"))); const cap = Number(process.env.PAPERCLIP_DECISIONS_OPEN_CAP ?? 50); if (Number(open[0]?.value ?? 0) >= cap) throw tooManyRequests("Open decision cap reached"); const expiresAt = input.expiresAt ?? new Date(Date.now() + 7 * DAY); if (expiresAt.getTime() <= Date.now() || expiresAt.getTime() > Date.now() + 30 * DAY) throw unprocessable("expiresAt must be within 30 days"); const ids = targetIds(input.options); const cancellationTargetIds = new Set(input.options.flatMap((option) => option.effects .filter((effect) => effect.type === "cancel_issue_tree").map((effect) => effect.targetIssueId))); const targetSnapshots = { ...await snapshots(input.companyId, ids, input.actor, targetActions(input.options), cancellationTargetIds, dbOrTx), ...(input.additionalTargetSnapshots ?? {}), }; const id = randomUUID(); const [created] = await dbOrTx.insert(decisions).values({ id, companyId: input.companyId, bundleId: input.bundleId ?? null, originAgentId: input.agentId, originIssueId: provenance.issueId, originRunId: input.runId, ruleKey: input.ruleKey ?? null, title: input.title, body: input.body, options: input.options, inputs: input.inputs ?? null, expiresAt, idempotencyKey: input.idempotencyKey ?? null, signedSpec: signDecisionSpec(spec({ id, options: input.options, targetSnapshots })), targetSnapshots, continuationPolicy: input.continuationPolicy ?? "none", metadata: input.metadata ?? {} }).onConflictDoNothing().returning(); if (!created) { const existing = input.idempotencyKey ? await dbOrTx.select().from(decisions).where(and(eq(decisions.companyId, input.companyId), eq(decisions.idempotencyKey, input.idempotencyKey))).then((rows) => rows[0] ?? null) : null; const archiveEquivalent = !existing || input.additionalTargetSnapshots === undefined || ( canonicalJson(existing.metadata ?? {}) === canonicalJson(input.metadata ?? {}) && canonicalJson(Object.fromEntries(Object.entries(existing.targetSnapshots as Record) .filter(([key]) => key.startsWith("attention:")))) === canonicalJson(input.additionalTargetSnapshots) ); const equivalent = existing && existing.title === input.title && existing.body === input.body && canonicalJson(existing.options) === canonicalJson(input.options) && canonicalJson(existing.inputs ?? null) === canonicalJson(input.inputs ?? null) && archiveEquivalent; if (equivalent) return existing; throw conflict("Decision idempotency key already used with a different payload"); } if (ids.length) await dbOrTx.insert(decisionTargetIssues).values(ids.map((issueId) => ({ decisionId: id, issueId, companyId: input.companyId }))); await logActivity(dbOrTx, { companyId: input.companyId, actorType: "agent", actorId: input.agentId, agentId: input.agentId, runId: input.runId, action: "decision.created", entityType: "decision", entityId: id, details: { originIssueId: provenance.issueId, originAgentId: input.agentId, originResponsibleUserId: provenance.run.responsibleUserId } }); return created; } async function create(input: CreateInputWithSnapshots, dbOrTx?: Db) { if (dbOrTx) return createInStore(input, dbOrTx); return db.transaction((tx) => createInStore(input, tx as unknown as Db)); } const get = (id: string) => db.select().from(decisions).where(eq(decisions.id, id)).then((rows) => rows[0] ?? null); async function outcome(id: string) { const decision = await get(id); const executions = await db.select().from(decisionEffectExecutions).where(eq(decisionEffectExecutions.decisionId, id)).orderBy(asc(decisionEffectExecutions.effectIndex)); return { ...decision, executions }; } async function list(companyId: string, filter: { status?: string; bundleId?: string; targetIssueId?: string; originAgentId?: string; limit?: number } = {}) { const conditions = [eq(decisions.companyId, companyId)]; if (filter.status) conditions.push(eq(decisions.status, filter.status)); if (filter.bundleId) conditions.push(eq(decisions.bundleId, filter.bundleId)); if (filter.originAgentId) conditions.push(eq(decisions.originAgentId, filter.originAgentId)); if (filter.targetIssueId) { const links = await db.select({ id: decisionTargetIssues.decisionId }).from(decisionTargetIssues).where(and(eq(decisionTargetIssues.companyId, companyId), eq(decisionTargetIssues.issueId, filter.targetIssueId))); if (!links.length) return []; conditions.push(inArray(decisions.id, links.map((row) => row.id))); } const rows = await db.select().from(decisions).where(and(...conditions)).orderBy(desc(decisions.createdAt)).limit(Math.min(filter.limit ?? 50, 100)); const openDecisionIds = rows.filter((decision) => decision.status === "open").map((decision) => decision.id); const currentIssueTargetIds = rows.flatMap((decision) => Object.keys(decision.targetSnapshots as Record)) .filter((id) => /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id)); const currentTargets = openDecisionIds.length && currentIssueTargetIds.length ? await db.select({ id: issues.id, updatedAt: issues.updatedAt }) .from(decisionTargetIssues) .innerJoin(issues, and(eq(issues.companyId, companyId), eq(issues.id, decisionTargetIssues.issueId))) .where(and(eq(decisionTargetIssues.companyId, companyId), inArray(decisionTargetIssues.decisionId, openDecisionIds), inArray(issues.id, currentIssueTargetIds))) : []; const currentTargetsById = new Map(currentTargets.map((target) => [target.id, target.updatedAt])); const attentionSnapshotIds = rows.flatMap((decision) => Object.values(decision.targetSnapshots as Record)) .map((snapshot) => snapshot.attentionArchive as AttentionArchiveManifestEntry | undefined) .filter((entry): entry is AttentionArchiveManifestEntry => Boolean(entry)) .map((entry) => entry.sourceId); const currentAttentionStates = attentionSnapshotIds.length ? await db.select().from(decisionRetention).where(and( eq(decisionRetention.companyId, companyId), inArray(decisionRetention.sourceId, attentionSnapshotIds), )) : []; const currentAttentionByKey = new Map(currentAttentionStates.map((state) => [ `attention:${state.sourceKind}:${state.sourceId}`, state, ])); const terminalDecisionIds = rows.filter((decision) => decision.status !== "open").map((decision) => decision.id); const terminalExecutions = terminalDecisionIds.length ? await db.select().from(decisionEffectExecutions) .where(inArray(decisionEffectExecutions.decisionId, terminalDecisionIds)) .orderBy(asc(decisionEffectExecutions.decisionId), asc(decisionEffectExecutions.effectIndex)) : []; const executionsByDecision = new Map(); for (const execution of terminalExecutions) { const grouped = executionsByDecision.get(execution.decisionId) ?? []; grouped.push(execution); executionsByDecision.set(execution.decisionId, grouped); } return rows.map((decision) => { const changed: Record = {}; if (decision.status === "open") for (const [id, snapshot] of Object.entries(decision.targetSnapshots as Record)) { if (id.startsWith("attention:")) { const entry = snapshot.attentionArchive as AttentionArchiveManifestEntry | undefined; const current = currentAttentionByKey.get(id); changed[id] = !entry || !current || current.version !== entry.expectedVersion || current.sourceActivityAt.toISOString() !== entry.activityAt || Boolean(current.archivedAt); continue; } const currentUpdatedAt = currentTargetsById.get(id); changed[id] = !currentUpdatedAt || currentUpdatedAt.toISOString() !== snapshot.updatedAt; } return { ...decision, targetChanged: changed, ...(decision.status === "open" ? {} : { executions: executionsByDecision.get(decision.id) ?? [] }) }; }); } async function stats(companyId: string, filter: { originAgentId?: string; since?: Date } = {}): Promise { const conditions = [eq(decisions.companyId, companyId)]; if (filter.originAgentId) conditions.push(eq(decisions.originAgentId, filter.originAgentId)); if (filter.since) conditions.push(gte(decisions.createdAt, filter.since)); const dismissed = sql`coalesce(${decisions.metadata}->'dismissed' = 'true'::jsonb, false)`; const rows = await db.select({ ruleKey: decisions.ruleKey, status: decisions.status, chosenOptionId: decisions.chosenOptionId, dismissed, value: count(), }).from(decisions).where(and(...conditions)) .groupBy(decisions.ruleKey, decisions.status, decisions.chosenOptionId, dismissed); const emptyCounts = (): DecisionStatsCounts => ({ proposed: 0, accepted: 0, rejected: 0, expired: 0 }); const totals = emptyCounts(); const grouped = new Map }>(); for (const row of rows) { const value = Number(row.value); const group = grouped.get(row.ruleKey) ?? { counts: emptyCounts(), chosenOptions: new Map() }; grouped.set(row.ruleKey, group); totals.proposed += value; group.counts.proposed += value; if (row.status === "expired") { totals.expired += value; group.counts.expired += value; continue; } if (row.status !== "decided") continue; const rejected = row.chosenOptionId === "dismissed" || row.dismissed; if (rejected) { totals.rejected += value; group.counts.rejected += value; continue; } totals.accepted += value; group.counts.accepted += value; if (row.chosenOptionId) group.chosenOptions.set(row.chosenOptionId, (group.chosenOptions.get(row.chosenOptionId) ?? 0) + value); } return { groupBy: "ruleKey", filters: { originAgentId: filter.originAgentId ?? null, since: filter.since?.toISOString() ?? null }, totals, groups: [...grouped.entries()] .sort(([left], [right]) => left === null ? 1 : right === null ? -1 : left.localeCompare(right)) .map(([ruleKey, group]) => ({ ruleKey, ...group.counts, chosenOptions: [...group.chosenOptions.entries()] .sort(([leftId, leftCount], [rightId, rightCount]) => rightCount - leftCount || leftId.localeCompare(rightId)) .map(([optionId, optionCount]) => ({ optionId, count: optionCount })), })), }; } async function effectAudit(tx: Db, decision: typeof decisions.$inferSelect, executionId: string, effect: DecisionEffect, status: "executed" | "failed" | "skipped", decidedByUserId: string, originResponsibleUserId: string | null, details: Record) { return logActivity(tx, { companyId: decision.companyId, actorType: "system", actorId: "decision-executor", agentId: decision.originAgentId, runId: decision.originRunId, responsibleUserIdOverride: decidedByUserId, action: `decision.effect_${status}`, entityType: "decision", entityId: decision.id, details: { effectType: effect.type, targetIssueId: effect.targetIssueId, originIssueId: decision.originIssueId, originAgentId: decision.originAgentId, chosenOptionId: decision.chosenOptionId, executionId, decidedByUserId, originResponsibleUserId, ...details } }); } async function executeEffect(decision: typeof decisions.$inferSelect, effect: DecisionEffect, effectIndex: number, userActor: AuthorizationActor, decidedByUserId: string, originResponsibleUserId: string | null) { const lockKey = `decision-effect:${decision.id}:${effectIndex}`; const recordFailure = async (reason: string, details: Record) => db.transaction(async (tx) => { await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`); let execution = await tx.select().from(decisionEffectExecutions).where(and(eq(decisionEffectExecutions.decisionId, decision.id), eq(decisionEffectExecutions.effectIndex, effectIndex))) .then((rows) => rows[0] ?? null); if (execution && execution.status !== "claimed") return execution; if (!execution) { [execution] = await tx.insert(decisionEffectExecutions).values({ decisionId: decision.id, effectIndex, effectType: effect.type, targetIssueId: effect.targetIssueId }).onConflictDoNothing().returning(); if (!execution) execution = await tx.select().from(decisionEffectExecutions).where(and(eq(decisionEffectExecutions.decisionId, decision.id), eq(decisionEffectExecutions.effectIndex, effectIndex))).then((rows) => rows[0] ?? null); } if (!execution || execution.status !== "claimed") return execution; const activity = await effectAudit(tx as unknown as Db, decision, execution.id, effect, "failed", decidedByUserId, originResponsibleUserId, details); const [row] = await tx.update(decisionEffectExecutions).set({ status: "failed", error: reason, result: details, activityLogId: activity?.id ?? null, executedAt: new Date() }).where(eq(decisionEffectExecutions.id, execution.id)).returning(); return row; }); try { const postCommitActivityPublications: ActivityPublication[] = []; const postCommitIssueActions: IssuePostCommitAction[] = []; const executionResult = await db.transaction(async (tx) => { await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`); let execution = await tx.select().from(decisionEffectExecutions).where(and(eq(decisionEffectExecutions.decisionId, decision.id), eq(decisionEffectExecutions.effectIndex, effectIndex))) .then((rows) => rows[0] ?? null); if (execution && execution.status !== "claimed") return execution; if (!execution) { [execution] = await tx.insert(decisionEffectExecutions).values({ decisionId: decision.id, effectIndex, effectType: effect.type, targetIssueId: effect.targetIssueId }).onConflictDoNothing().returning(); if (!execution) execution = await tx.select().from(decisionEffectExecutions).where(and(eq(decisionEffectExecutions.decisionId, decision.id), eq(decisionEffectExecutions.effectIndex, effectIndex))).then((rows) => rows[0] ?? null); if (!execution || execution.status !== "claimed") return execution; } const finish = async (status: "failed" | "skipped", reason: string, details: Record) => { const activity = await effectAudit(tx as unknown as Db, decision, execution!.id, effect, status, decidedByUserId, originResponsibleUserId, details); const [row] = await tx.update(decisionEffectExecutions).set({ status, error: reason, result: details, activityLogId: activity?.id ?? null, executedAt: new Date() }).where(eq(decisionEffectExecutions.id, execution!.id)).returning(); return row; }; const directReferencedIds = new Set(decisionEffectTargetIssueIds(effect)); const snapshots = decision.targetSnapshots as Record; const cancellationDescendantIds = effect.type === "cancel_issue_tree" ? snapshots[effect.targetIssueId]?.descendantIds : undefined; if (effect.type === "cancel_issue_tree" && !Array.isArray(cancellationDescendantIds)) { return finish("failed", "invalid_signed_cancellation_scope", { reason: "invalid_signed_cancellation_scope" }); } const referencedIds = new Set([...directReferencedIds, ...(cancellationDescendantIds ?? [])]); const referencedIssues = await tx.select().from(issues).where(and(eq(issues.companyId, decision.companyId), inArray(issues.id, [...referencedIds]))); if (referencedIssues.length !== referencedIds.size) return finish("failed", "invalid_effect_reference", { reason: "invalid_effect_reference" }); const target = referencedIssues.find((item) => item.id === effect.targetIssueId)!; const originActor: AuthorizationActor = { type: "agent", agentId: decision.originAgentId, companyId: decision.companyId, runId: decision.originRunId, onBehalfOfUserId: originResponsibleUserId, source: "agent_jwt" }; const originAction = effect.type === "comment_on_issue" ? "issue:comment" : "issue:mutate"; const originAccess = await Promise.all(referencedIssues.map((item) => authz.decide({ actor: originActor, action: originAction, resource: resource(item) }))); let userAccess: { allowed: boolean; reason: string }; if (effect.type === "assign_issue" || (effect.type === "create_issue" && (effect.draft.assigneeAgentId || effect.draft.assigneeUserId))) { const assigneeAgentId = effect.type === "assign_issue" ? effect.assigneeAgentId : effect.draft.assigneeAgentId; const assigneeUserId = effect.type === "assign_issue" ? effect.assigneeUserId : effect.draft.assigneeUserId; const parentIssueId = effect.type === "create_issue" ? effect.draft.parentId ?? target.id : target.parentId; const projectId = effect.type === "create_issue" ? effect.draft.projectId ?? target.projectId : target.projectId; userAccess = await authz.decide({ actor: userActor, action: "tasks:assign", resource: { ...resource(target), parentIssueId, projectId }, scope: { issueId: target.id, parentIssueId, projectId, assigneeAgentId, assigneeUserId } }); } else { userAccess = boardCanActDirectly(userActor, decision.companyId) ? { allowed: true, reason: "allow_board_direct_route" } : { allowed: false, reason: "deny_company_boundary" }; } const deniedOrigin = originAccess.find((access) => !access.allowed); if (!userAccess.allowed || deniedOrigin) return finish("failed", "deny_decision_intersection", { reason: "deny_decision_intersection", userReason: userAccess.reason, originReason: deniedOrigin?.reason ?? null }); if (effect.staleness === "strict") { const staleReference = referencedIssues.some((item) => directReferencedIds.has(item.id) && snapshots[item.id]?.updatedAt !== item.updatedAt.toISOString()); const changedTree = effect.type === "cancel_issue_tree" && !sameIds(cancellationDescendantIds!, await collectDescendantIds(decision.companyId, target.id, tx as unknown as Db)); if (staleReference || changedTree) return finish("skipped", "target_changed", { reason: "target_changed" }); } const svc = issueService(db); const values = decision.inputValues ?? {}; let result: Record; if (effect.type === "comment_on_issue") { const comment = await svc.addComment(target.id, interpolate(effect.bodyMarkdown, values), { userId: decidedByUserId }, undefined, tx); result = { commentId: comment.id }; } else if (effect.type === "update_issue_status") { const updated = await svc.update( target.id, { status: effect.status, actorUserId: decidedByUserId }, tx, postCommitActivityPublications, postCommitIssueActions, ); if (effect.comment) await svc.addComment(target.id, interpolate(effect.comment, values), { userId: decidedByUserId }, undefined, tx); result = { issueId: updated?.id, status: updated?.status }; } else if (effect.type === "assign_issue") { const updated = await svc.update( target.id, { assigneeAgentId: effect.assigneeAgentId ?? null, assigneeUserId: effect.assigneeUserId ?? null, actorUserId: decidedByUserId, }, tx, postCommitActivityPublications, postCommitIssueActions, ); if (effect.comment) await svc.addComment(target.id, interpolate(effect.comment, values), { userId: decidedByUserId }, undefined, tx); result = { issueId: updated?.id }; } else if (effect.type === "resolve_blocker") { const current = await tx.select({ id: issueRelations.issueId }).from(issueRelations).where(and(eq(issueRelations.companyId, decision.companyId), eq(issueRelations.relatedIssueId, target.id), eq(issueRelations.type, "blocks"))); await svc.update( target.id, { blockedByIssueIds: current.map((row) => row.id).filter((id) => !effect.removeBlockedByIssueIds.includes(id)), actorUserId: decidedByUserId, }, tx, postCommitActivityPublications, postCommitIssueActions, ); result = { removedBlockedByIssueIds: effect.removeBlockedByIssueIds }; } else if (effect.type === "create_issue") { const draft = effect.draft; const created = await svc.create(decision.companyId, { title: draft.title, description: draft.description ?? null, parentId: draft.parentId ?? target.id, assigneeAgentId: draft.assigneeAgentId ?? null, assigneeUserId: draft.assigneeUserId ?? null, projectId: draft.projectId ?? target.projectId, goalId: draft.goalId ?? null, blockedByIssueIds: draft.blockedByIssueIds ?? [], createdByUserId: decidedByUserId, actorRunId: decision.originRunId, idempotencyKey: `decision-effect:${decision.id}:${effectIndex}` }); result = { issueId: created.id }; } else { const cancelled = [target.id, ...cancellationDescendantIds!].reverse(); for (const id of cancelled) { await svc.update( id, { status: "cancelled", actorUserId: decidedByUserId }, tx, postCommitActivityPublications, postCommitIssueActions, ); } await svc.addComment(target.id, interpolate(effect.reasonComment, values), { userId: decidedByUserId }, undefined, tx); result = { cancelledIssueIds: cancelled }; } const activity = await effectAudit(tx as unknown as Db, decision, execution.id, effect, "executed", decidedByUserId, originResponsibleUserId, result); const [row] = await tx.update(decisionEffectExecutions).set({ status: "executed", result, error: null, activityLogId: activity?.id ?? null, executedAt: new Date() }).where(eq(decisionEffectExecutions.id, execution.id)).returning(); return row; }); for (const publication of postCommitActivityPublications) publishActivity(publication); if (postCommitIssueActions.length > 0) { const { executeIssuePostCommitActions } = await import("./issues.js"); await executeIssuePostCommitActions(db, postCommitIssueActions); } return executionResult; } catch (error) { const message = error instanceof Error ? error.message : "Decision effect execution failed"; return recordFailure("effect_execution_failed", { reason: "effect_execution_failed", message }); } } async function runEffects(decision: typeof decisions.$inferSelect, userActor: AuthorizationActor) { const option = decision.options.find((item) => item.id === decision.chosenOptionId); if (!option || !decision.decidedByUserId) throw unprocessable("Stored decision outcome is invalid"); const run = await db.select({ responsibleUserId: heartbeatRuns.responsibleUserId }).from(heartbeatRuns).where(eq(heartbeatRuns.id, decision.originRunId)).then((rows) => rows[0] ?? null); const metadata = decision.metadata as Record; if (metadata.kind === "attention_archive_proposal") { if (!verifyDecisionSpec(spec({ id: decision.id, options: decision.options, targetSnapshots: decision.targetSnapshots as Record, }), decision.signedSpec)) { await db.update(decisions).set({ executionStatus: "failed", updatedAt: new Date(), metadata: { ...metadata, archiveProposalError: "invalid_signature" } }) .where(eq(decisions.id, decision.id)); return outcome(decision.id); } if (option.id === "archive") { const manifest = parseAttentionArchiveManifest( decision.companyId, decision.targetSnapshots as Record, ); const manifestHash = manifest ? hashAttentionArchiveManifest(manifest) : null; if (!manifest || manifestHash !== metadata.manifestHash) { await db.update(decisions).set({ executionStatus: "failed", updatedAt: new Date(), metadata: { ...metadata, archiveProposalError: "manifest_mismatch" } }) .where(eq(decisions.id, decision.id)); return outcome(decision.id); } const originActor: AuthorizationActor = { type: "agent", agentId: decision.originAgentId, companyId: decision.companyId, runId: decision.originRunId, onBehalfOfUserId: run?.responsibleUserId ?? null, source: "agent_jwt", }; try { await decisionRetentionService(db).archiveReviewedManifest({ companyId: decision.companyId, manifest, originActor, decidingActor: userActor, decidedByUserId: decision.decidedByUserId, }); } catch (error) { const message = error instanceof Error ? error.message : "archive_proposal_failed"; await db.update(decisions).set({ executionStatus: "failed", updatedAt: new Date(), metadata: { ...metadata, archiveProposalError: message } }) .where(eq(decisions.id, decision.id)); return outcome(decision.id); } } await db.update(decisions).set({ executionStatus: "succeeded", updatedAt: new Date(), metadata: { ...metadata, ...(decision.continuationPolicy === "wake_origin_agent" ? { continuationPending: true } : {}) }, }).where(eq(decisions.id, decision.id)); return outcome(decision.id); } for (let index = 0; index < option.effects.length; index += 1) await executeEffect(decision, option.effects[index]!, index, userActor, decision.decidedByUserId, run?.responsibleUserId ?? null); const rows = await db.select().from(decisionEffectExecutions).where(eq(decisionEffectExecutions.decisionId, decision.id)); const successful = rows.filter((row) => row.status === "executed").length; const status = rows.every((row) => row.status === "executed") ? "succeeded" : successful ? "partial" : "failed"; await db.update(decisions).set({ executionStatus: status, updatedAt: new Date(), metadata: { ...decision.metadata, ...(decision.continuationPolicy === "wake_origin_agent" ? { continuationPending: true } : {}) } }).where(eq(decisions.id, decision.id)); return outcome(decision.id); } async function deliverContinuation( decision: typeof decisions.$inferSelect, outcome: "decided" | "expired" | "cancelled", ) { const metadata = decision.metadata as Record; if (decision.continuationPolicy !== "wake_origin_agent" || metadata.continuationPending !== true) return; // Delivery is intentionally at-least-once: a crash after wakeup but before // this acknowledgement can retry, while a crash before wakeup cannot lose // the continuation. Heartbeat wakeups coalesce concurrent queued work. await options.wakeOriginAgent({ companyId: decision.companyId, agentId: decision.originAgentId, issueId: decision.originIssueId, decisionId: decision.id, outcome }); await db.update(decisions).set({ metadata: sql`coalesce(${decisions.metadata}, '{}'::jsonb) || jsonb_build_object( 'continuationPending', false, 'continuationDeliveredAt', ${new Date().toISOString()}::text )`, updatedAt: new Date(), }).where(and(eq(decisions.id, decision.id), sql`${decisions.metadata} ->> 'continuationPending' = 'true'`)); } async function resumeDecision(decision: typeof decisions.$inferSelect, userActor: AuthorizationActor) { const result = decision.executionStatus === "running" ? await runEffects(decision, userActor) : await outcome(decision.id); await deliverContinuation(result, "decided"); return outcome(decision.id); } async function decide(input: { id: string; optionId: string; inputValues?: Record; idempotencyKey?: string | null; decidedByUserId: string; userActor: AuthorizationActor; dismissed?: boolean; dismissReason?: string | null }) { const current = await get(input.id); if (!current) throw notFound("Decision not found"); const metadata = current.metadata as Record; if (!verifyDecisionSpec(spec({ id: current.id, options: current.options, targetSnapshots: current.targetSnapshots as Record }), current.signedSpec)) throw forbidden("Decision signature verification failed"); if (current.status === "decided" && input.idempotencyKey && metadata.decideIdempotencyKey === input.idempotencyKey) { if (current.decidedByUserId !== input.decidedByUserId) throw forbidden("Decision replay belongs to a different user"); return resumeDecision(current, input.userActor); } if (current.status === "decided" && current.chosenOptionId === input.optionId && sameInputValues(current.inputValues ?? {}, input.inputValues ?? {})) { if (current.decidedByUserId !== input.decidedByUserId) throw forbidden("Decision replay belongs to a different user"); return resumeDecision(current, input.userActor); } if (current.status !== "open") throw conflict("decision_already_resolved", { code: "decision_already_resolved" }); if (current.expiresAt <= new Date()) { const [expired] = await db.update(decisions).set({ status: "expired", updatedAt: new Date(), metadata: { ...metadata, expiredReason: "ttl", ...(current.continuationPolicy === "wake_origin_agent" ? { continuationPending: true } : {}) } }) .where(and(eq(decisions.id, current.id), eq(decisions.status, "open"), lte(decisions.expiresAt, new Date()))).returning(); if (expired) { await logActivity(db, { companyId: expired.companyId, actorType: "system", actorId: "decision-expiry-sweeper", agentId: expired.originAgentId, runId: expired.originRunId, action: "decision.expired", entityType: "decision", entityId: expired.id, details: { expiredReason: "ttl" } }); await deliverContinuation(expired, "expired"); } throw conflict("decision_expired", { code: "decision_expired" }); } if (!current.options.some((option) => option.id === input.optionId)) throw unprocessable("Unknown optionId"); const values = input.inputValues ?? {}; for (const field of current.inputs ?? []) { const value = values[field.id] ?? ""; if (field.required && !value.trim()) throw unprocessable(`Input ${field.id} is required`); if (field.maxLength && value.length > field.maxLength) throw unprocessable(`Input ${field.id} is too long`); } const [claimed] = await db.update(decisions).set({ status: "decided", executionStatus: "running", chosenOptionId: input.optionId, inputValues: values, decidedByUserId: input.decidedByUserId, decidedAt: new Date(), updatedAt: new Date(), metadata: { ...metadata, decideIdempotencyKey: input.idempotencyKey ?? null, ...(current.continuationPolicy === "wake_origin_agent" ? { continuationPending: true } : {}), ...(input.dismissed ? { dismissed: true, dismissReason: input.dismissReason ?? null } : {}) } }) .where(and(eq(decisions.id, current.id), eq(decisions.status, "open"))).returning(); if (!claimed) throw conflict("decision_already_resolved", { code: "decision_already_resolved" }); const run = await db.select({ responsibleUserId: heartbeatRuns.responsibleUserId }).from(heartbeatRuns).where(eq(heartbeatRuns.id, claimed.originRunId)).then((rows) => rows[0] ?? null); await logActivity(db, { companyId: claimed.companyId, actorType: "system", actorId: "decision-executor", agentId: claimed.originAgentId, runId: claimed.originRunId, responsibleUserIdOverride: input.decidedByUserId, action: input.dismissed ? "decision.dismissed" : "decision.decided", entityType: "decision", entityId: claimed.id, details: { chosenOptionId: input.optionId, decidedByUserId: input.decidedByUserId, originResponsibleUserId: run?.responsibleUserId ?? null, ...(input.dismissed ? { dismissed: true, dismissReason: input.dismissReason ?? null } : {}) } }); return resumeDecision(claimed, input.userActor); } async function cancel(id: string, actor: { actorType: "agent" | "user"; actorId: string; runId?: string | null }) { const current = await get(id); if (!current) throw notFound("Decision not found"); if (actor.actorType === "agent" && actor.actorId !== current.originAgentId) throw forbidden("Only the origin agent may cancel"); if (current.status === "cancelled") { await deliverContinuation(current, "cancelled"); return (await get(id))!; } const [updated] = await db.update(decisions).set({ status: "cancelled", updatedAt: new Date(), metadata: { ...current.metadata, ...(current.continuationPolicy === "wake_origin_agent" ? { continuationPending: true } : {}) } }).where(and(eq(decisions.id, id), eq(decisions.status, "open"))).returning(); if (!updated) throw conflict("decision_already_resolved", { code: "decision_already_resolved" }); await logActivity(db, { companyId: updated.companyId, actorType: actor.actorType, actorId: actor.actorId, runId: actor.runId, action: "decision.cancelled", entityType: "decision", entityId: id }); await deliverContinuation(updated, "cancelled"); return (await get(id))!; } async function dismiss(id: string, userId: string, userActor: AuthorizationActor, reason?: string | null) { const current = await get(id); if (!current) throw notFound("Decision not found"); if (!verifyDecisionSpec(spec({ id: current.id, options: current.options, targetSnapshots: current.targetSnapshots as Record }), current.signedSpec)) throw forbidden("Decision signature verification failed"); const empty = current.options.find((option) => option.effects.length === 0); if (empty) return decide({ id, optionId: empty.id, decidedByUserId: userId, userActor, dismissed: true, dismissReason: reason }); const [updated] = await db.update(decisions).set({ status: "decided", executionStatus: "succeeded", chosenOptionId: "dismissed", decidedByUserId: userId, decidedAt: new Date(), updatedAt: new Date(), metadata: { ...current.metadata, dismissed: true, dismissReason: reason ?? null, ...(current.continuationPolicy === "wake_origin_agent" ? { continuationPending: true } : {}) } }).where(and(eq(decisions.id, id), eq(decisions.status, "open"))).returning(); if (!updated) throw conflict("decision_already_resolved", { code: "decision_already_resolved" }); await logActivity(db, { companyId: updated.companyId, actorType: "system", actorId: "decision-executor", agentId: updated.originAgentId, runId: updated.originRunId, responsibleUserIdOverride: userId, action: "decision.dismissed", entityType: "decision", entityId: updated.id, details: { chosenOptionId: "dismissed", decidedByUserId: userId, dismissed: true } }); await deliverContinuation(updated, "decided"); return outcome(id); } async function createBundle(input: { companyId: string; actor: AuthorizationActor; agentId: string; runId: string; title: string; summary: string; decisions: Array[0], "companyId" | "actor" | "agentId" | "runId" | "bundleId">> }) { return db.transaction(async (tx) => { const provenance = await origin(input.companyId, input.agentId, input.runId); const [bundle] = await tx.insert(decisionBundles).values({ companyId: input.companyId, title: input.title, summary: input.summary, originAgentId: input.agentId, originIssueId: provenance.issueId, originRunId: input.runId }).returning(); const created = []; for (const item of input.decisions) created.push(await create({ ...item, companyId: input.companyId, actor: input.actor, agentId: input.agentId, runId: input.runId, bundleId: bundle.id }, tx as unknown as Db)); return { ...bundle, decisions: created }; }); } async function sweepExpired(now = new Date()) { const configuredBatchSize = Number(process.env.PAPERCLIP_DECISIONS_SWEEP_BATCH_SIZE ?? 100); const batchSize = Number.isFinite(configuredBatchSize) ? Math.max(1, Math.trunc(configuredBatchSize)) : 100; const configuredRecoveryGraceMs = Number(process.env.PAPERCLIP_DECISIONS_RECOVERY_GRACE_MS ?? 60_000); const recoveryGraceMs = Number.isFinite(configuredRecoveryGraceMs) && configuredRecoveryGraceMs >= 0 ? configuredRecoveryGraceMs : 60_000; const runningRows = await db.select().from(decisions) .where(and(eq(decisions.status, "decided"), eq(decisions.executionStatus, "running"), lte(decisions.updatedAt, new Date(now.getTime() - recoveryGraceMs)))) .orderBy(asc(decisions.updatedAt)).limit(batchSize); let resumed = 0; for (const decision of runningRows) { if (!decision.decidedByUserId) continue; const recovered = await runEffects(decision, await recoveryActor(decision.companyId, decision.decidedByUserId)); if (recovered.executionStatus === "running") continue; resumed += 1; await deliverContinuation(recovered, "decided"); } const pendingContinuations = await db.select().from(decisions) .where(and(eq(decisions.continuationPolicy, "wake_origin_agent"), sql`${decisions.metadata} ->> 'continuationPending' = 'true'`, or(inArray(decisions.status, ["expired", "cancelled"]), and(eq(decisions.status, "decided"), inArray(decisions.executionStatus, ["succeeded", "partial", "failed"]))))) .orderBy(asc(decisions.updatedAt)).limit(batchSize); for (const decision of pendingContinuations) { await deliverContinuation(decision, decision.status === "expired" ? "expired" : decision.status === "cancelled" ? "cancelled" : "decided"); } const ttlRows = await db.select().from(decisions) .where(and(eq(decisions.status, "open"), lte(decisions.expiresAt, now))) .orderBy(asc(decisions.expiresAt)).limit(batchSize); const remaining = batchSize - ttlRows.length; const targetRows = remaining > 0 ? await db.select().from(decisions) .where(and(eq(decisions.status, "open"), ...(targetSweepCursor ? [gt(decisions.id, targetSweepCursor)] : []))) .orderBy(asc(decisions.id)).limit(remaining) : []; targetSweepCursor = targetRows.length === remaining && targetRows.length > 0 ? targetRows[targetRows.length - 1]!.id : null; const rows = [...new Map([...ttlRows, ...targetRows].map((row) => [row.id, row])).values()]; let expired = 0; for (const decision of rows) { const strictTargetIds = new Set(decision.options.flatMap((option) => option.effects.filter((effect) => effect.staleness === "strict").flatMap(decisionEffectTargetIssueIds))); const targets = strictTargetIds.size > 0 ? await db.select({ id: issues.id, status: issues.status }).from(issues).where(and(eq(issues.companyId, decision.companyId), inArray(issues.id, [...strictTargetIds]))) : []; const targetGone = targets.length !== strictTargetIds.size || targets.some((target) => target.status === "cancelled"); // A decision whose strict targets all completed after it was proposed is moot: // the strict guard would skip its effects anyway, so retire it instead of // leaving it pending until TTL. Targets that were already done at proposal // time don't count — those decisions intentionally act on a done issue. const snapshots = decision.targetSnapshots as Record; const targetsCompleted = !targetGone && strictTargetIds.size > 0 && targets.every((target) => target.status === "done") && targets.some((target) => snapshots[target.id]?.status !== "done"); if (!targetGone && !targetsCompleted && decision.expiresAt >= now) continue; const reason = targetGone ? "target_gone" : targetsCompleted ? "target_completed" : "ttl"; const [updated] = await db.update(decisions).set({ status: "expired", updatedAt: now, metadata: { ...decision.metadata, expiredReason: reason, ...(decision.continuationPolicy === "wake_origin_agent" ? { continuationPending: true } : {}) } }).where(and(eq(decisions.id, decision.id), eq(decisions.status, "open"))).returning(); if (!updated) continue; expired += 1; await logActivity(db, { companyId: updated.companyId, actorType: "system", actorId: "decision-expiry-sweeper", agentId: updated.originAgentId, runId: updated.originRunId, action: "decision.expired", entityType: "decision", entityId: updated.id, details: { expiredReason: reason } }); await deliverContinuation(updated, "expired"); } return { expired, resumed }; } return { create, createBundle, get, list, stats, outcome, decide, cancel, dismiss, sweepExpired }; }