mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents can currently perform many mutations directly, while humans often need a durable review point before cross-issue or destructive actions occur > - Existing approvals and issue-thread interactions do not provide a standalone, reusable object for presenting options, collecting typed inputs, detecting stale targets, and auditing effect execution > - The control plane therefore needs a first-class propose mode that separates an agent's recommendation from the governed mutation it may cause > - This pull request adds Decisions v1 across the database, shared contracts, server execution and telemetry, agent skill guidance, and operator UI > - The benefit is that agents can propose multi-option actions safely while operators get explicit provenance, fail-closed execution, per-effect results, and a focused attention workflow ## Linked Issues or Issue Description ### Subsystem affected Cross-cutting: `packages/db`, `packages/shared`, `server`, and `ui`. ### Problem or motivation Agents need a governed way to propose consequential work without immediately mutating issues, especially when one choice can affect several issue trees. Existing approvals and issue-thread interactions do not provide a standalone object with typed options, target snapshots, effect-level authorization, expiration, execution outcomes, and reusable attention-feed presentation. ### Proposed solution Add first-class Decisions that store options and typed inputs, surface open proposals in the operator attention feed, validate target freshness and the origin-agent/operator authorization intersection at decision time, execute a bounded set of auditable effects, and retain terminal outcomes. Decisions v1 supports comments, status and assignee changes, follow-up issue creation, blocker resolution, and issue-tree cancellation, plus bundle grouping, expiration/dismissal, rule-key telemetry, and agent-facing API guidance. ### Alternatives considered - Extend approvals with arbitrary effects: rejected because approvals represent governed yes/no actions and would become an unsafe generic mutation envelope. - Model every proposal as an issue-thread interaction: rejected because decisions can span several targets and need independent lifecycle, telemetry, idempotency, and effect results. - Let agents perform the mutation and ask for retrospective review: rejected because it removes the pre-execution governance boundary this feature is meant to provide. ### Roadmap alignment Aligns with `ROADMAP.md` sections **Agent Reviews and Approvals**, **Enforced Outcomes**, **MCP Tool Gateway & Apps (governed tool access)**, and **Activity History** by making explicit decisions, authorization gates, auditable execution, and terminal outcomes first-class control-plane objects. ### Additional context This does not replace existing approvals or issue-thread interactions, and it does not add an unrestricted generic mutation effect. ## What Changed - Added company-scoped decision, option, target, and effect-execution schema plus migration and shared TypeScript/Zod contracts. - Added decision routes and services for propose, list/get, decide, dismiss, cancel, target freshness checks, authorization intersection, idempotency, activity logging, and execution auditing. - Added rule-key decision telemetry and attention-feed metadata so open decisions are visible and measurable. - Added agent skill documentation for proposing and resolving decisions through the Paperclip API. - Added the Decisions UI: API client, query keys, inline attention resolver, bundle grouping, target-issue strip, terminal history, destructive confirmation, and per-effect result rendering. - Added server service coverage, DecisionCard state tests, and Storybook stories for the supported visual states. ## Verification - `pnpm -r typecheck` — passed. - `pnpm test:run` — 2,876 passed, 1 skipped, with one unrelated cross-suite cleanup-order failure in `heartbeat-responsible-user-invariant.test.ts`; the failing file passes in isolation (`6/6`). - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/heartbeat-responsible-user-invariant.test.ts` — passed. - `pnpm --filter @paperclipai/ui exec vitest run src/components/DecisionCard.test.tsx` — passed (`9/9`). - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/authz-existence-oracle-guard.test.ts src/__tests__/openapi-routes.test.ts` — passed (`5/5`). - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/decisions-service.test.ts` — passed (`16/16`). - `pnpm --filter paperclipai exec vitest run src/__tests__/company-import-export-e2e.test.ts` — passed (`1/1`). - `pnpm --filter @paperclipai/server typecheck` and `pnpm --filter paperclipai typecheck` — passed. - `pnpm build` — passed. - Rebased-head focused suite — passed (`6` files, `88` tests): shared decision contracts, Decisions service, OpenAPI routes, startup feedback export, DecisionCard states, and attention helpers. The follow-up stale-secondary-target regression passes in the DecisionCard suite (`10/10`). - Rebased-head scoped typechecks — passed for `@paperclipai/shared`, `@paperclipai/db`, `@paperclipai/server`, and `@paperclipai/ui`. - Rebased-head migration numbering and safety checks — passed after renumbering the additive migration to `0193` and making it replay-safe for environments that applied the earlier feature-branch number. - `pnpm check:token-gates` — passed with all gates clean. - GitHub PR workflow and Greptile review for `1f9f7645882d05dfdd9c99377c03a1f53f20e8be` — running after the stale-secondary-target fix and PR metadata refresh on July 27, 2026. - `pnpm --filter @paperclipai/ui build-storybook` exposes an existing Storybook version mismatch (`storybook` 10.4.6 vs `@storybook/addon-docs` 10.5.0); Decisions stories were validated with the docs addon temporarily disabled and the tracked config remains unchanged. ## Risks - **Migration:** Adds replay-safe migration `0193`; migration numbering and safety checks pass. The new tables and indexes are additive. - **Authorization:** Effect execution intersects the proposing agent's permissions with the responsible user context and fails closed; mistakes could reject a valid proposal rather than silently over-authorize it. - **Concurrency:** Target snapshots and idempotency keys protect against stale or duplicate execution, but reviewers should focus on mixed-effect partial outcomes and retry behavior. - **UI:** Decisions are integrated into the existing attention feed rather than a separate navigation surface, reducing routing risk but increasing the importance of attention-item metadata compatibility. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex CLI using `gpt-5.6-sol` for final PR preparation, review fixes, and verification; repository tools and code execution were enabled, and context-window size is not exposed in this runtime. - Anthropic Claude Opus 4.8 with 1M context assisted with the Decisions UI implementation, as recorded in the relevant commits. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
615 lines
44 KiB
TypeScript
615 lines
44 KiB
TypeScript
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, decisions, decisionTargetIssues, heartbeatRuns, issueRelations, issues } from "@paperclipai/db";
|
|
import type { 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 } from "./activity-log.js";
|
|
import { signDecisionSpec, verifyDecisionSpec } from "./decision-signing.js";
|
|
import { issueService } from "./issues.js";
|
|
|
|
type Snapshot = { status: string; assigneeAgentId: string | null; assigneeUserId: string | null; updatedAt: string;
|
|
descendantCount?: number; descendantIds?: string[]; childCount?: number };
|
|
type Wake = (input: { companyId: string; agentId: string; issueId: string; decisionId: string; outcome: "decided" | "expired" | "cancelled" }) => Promise<unknown>;
|
|
export type DecisionServiceOptions = { wakeOriginAgent: Wake };
|
|
const DAY = 86_400_000;
|
|
|
|
function effectTargetIds(effect: DecisionEffect) {
|
|
const result = new Set([effect.targetIssueId]);
|
|
if (effect.type === "create_issue") {
|
|
if (effect.draft.parentId) result.add(effect.draft.parentId);
|
|
for (const id of effect.draft.blockedByIssueIds ?? []) result.add(id);
|
|
}
|
|
if (effect.type === "resolve_blocker") for (const id of effect.removeBlockedByIssueIds) result.add(id);
|
|
return [...result];
|
|
}
|
|
|
|
function targetIds(options: DecisionOption[]) {
|
|
const result = new Set<string>();
|
|
for (const option of options) for (const effect of option.effects) for (const id of effectTargetIds(effect)) result.add(id);
|
|
return [...result];
|
|
}
|
|
|
|
function targetActions(options: DecisionOption[]) {
|
|
const result = new Map<string, Set<"issue:comment" | "issue:mutate">>();
|
|
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 effectTargetIds(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<string, string>, right: Record<string, string>) {
|
|
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<string, Snapshot> }) {
|
|
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<string, string>) {
|
|
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<string, unknown>).sort(([left], [right]) => left.localeCompare(right))
|
|
.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(",")}}`;
|
|
}
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
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<string, unknown> };
|
|
|
|
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<AuthorizationActor> {
|
|
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<typeof targetActions>,
|
|
cancellationTargetIds: ReadonlySet<string>, 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<string, Snapshot> = {};
|
|
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: CreateInput, 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 equivalent = existing.title === input.title && existing.body === input.body &&
|
|
canonicalJson(existing.options) === canonicalJson(input.options) && canonicalJson(existing.inputs ?? null) === canonicalJson(input.inputs ?? null);
|
|
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);
|
|
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 equivalent = existing && existing.title === input.title && existing.body === input.body &&
|
|
canonicalJson(existing.options) === canonicalJson(input.options) && canonicalJson(existing.inputs ?? null) === canonicalJson(input.inputs ?? null);
|
|
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: CreateInput, 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 currentTargets = openDecisionIds.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)))
|
|
: [];
|
|
const currentTargetsById = new Map(currentTargets.map((target) => [target.id, target.updatedAt]));
|
|
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<string, typeof terminalExecutions>();
|
|
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<string, boolean> = {};
|
|
if (decision.status === "open") for (const [id, snapshot] of Object.entries(decision.targetSnapshots as Record<string, Snapshot>)) {
|
|
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<DecisionStatsResponse> {
|
|
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<boolean>`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<string | null, { counts: DecisionStatsCounts; chosenOptions: Map<string, number> }>();
|
|
for (const row of rows) {
|
|
const value = Number(row.value);
|
|
const group = grouped.get(row.ruleKey) ?? { counts: emptyCounts(), chosenOptions: new Map<string, number>() };
|
|
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<string, unknown>) {
|
|
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<string, unknown>) => 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 {
|
|
return 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<string, unknown>) => {
|
|
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(effectTargetIds(effect));
|
|
const snapshots = decision.targetSnapshots as Record<string, Snapshot>;
|
|
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(tx as unknown as Db);
|
|
const values = decision.inputValues ?? {};
|
|
let result: Record<string, unknown>;
|
|
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);
|
|
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);
|
|
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);
|
|
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);
|
|
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;
|
|
});
|
|
} 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);
|
|
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<string, unknown>;
|
|
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<string, string>; 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<string, unknown>;
|
|
if (!verifyDecisionSpec(spec({ id: current.id, options: current.options, targetSnapshots: current.targetSnapshots as Record<string, Snapshot> }), 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<string, Snapshot> }), 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<Omit<Parameters<typeof create>[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").map((effect) => effect.targetIssueId)));
|
|
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");
|
|
if (!targetGone && decision.expiresAt >= now) continue;
|
|
const reason = targetGone ? "target_gone" : "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 };
|
|
}
|