feat(decisions): add first-class propose mode (#10010)
## 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>
This commit is contained in:
parent
71231dfa38
commit
9c1f8e7887
|
|
@ -153,6 +153,7 @@ function createServerEnv(
|
|||
env.PORT = String(port);
|
||||
env.SERVE_UI = "false";
|
||||
env.PAPERCLIP_DB_BACKUP_ENABLED = "false";
|
||||
env.PAPERCLIP_DECISION_SIGNING_SECRET = "company-import-export-decision-signing-secret";
|
||||
env.HEARTBEAT_SCHEDULER_ENABLED = "false";
|
||||
env.PAPERCLIP_MIGRATION_AUTO_APPLY = "true";
|
||||
env.PAPERCLIP_UI_DEV_MIDDLEWARE = "false";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
CREATE TABLE IF NOT EXISTS "decision_bundles" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"summary" text NOT NULL,
|
||||
"origin_agent_id" uuid NOT NULL,
|
||||
"origin_issue_id" uuid NOT NULL,
|
||||
"origin_run_id" uuid NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "decisions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"bundle_id" uuid,
|
||||
"origin_agent_id" uuid NOT NULL,
|
||||
"origin_issue_id" uuid NOT NULL,
|
||||
"origin_run_id" uuid NOT NULL,
|
||||
"rule_key" text,
|
||||
"title" text NOT NULL,
|
||||
"body" text NOT NULL,
|
||||
"options" jsonb NOT NULL,
|
||||
"inputs" jsonb,
|
||||
"status" text DEFAULT 'open' NOT NULL,
|
||||
"execution_status" text,
|
||||
"chosen_option_id" text,
|
||||
"input_values" jsonb,
|
||||
"decided_by_user_id" text,
|
||||
"decided_at" timestamp with time zone,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"idempotency_key" text,
|
||||
"signed_spec" text NOT NULL,
|
||||
"target_snapshots" jsonb NOT NULL,
|
||||
"continuation_policy" text DEFAULT 'none' NOT NULL,
|
||||
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "decision_target_issues" (
|
||||
"decision_id" uuid NOT NULL,
|
||||
"issue_id" uuid NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
CONSTRAINT "decision_target_issues_decision_id_issue_id_pk" PRIMARY KEY("decision_id","issue_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "decision_effect_executions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"decision_id" uuid NOT NULL,
|
||||
"effect_index" integer NOT NULL,
|
||||
"effect_type" text NOT NULL,
|
||||
"target_issue_id" uuid NOT NULL,
|
||||
"status" text DEFAULT 'claimed' NOT NULL,
|
||||
"result" jsonb,
|
||||
"error" text,
|
||||
"activity_log_id" uuid,
|
||||
"executed_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "decision_bundles" ADD CONSTRAINT "decision_bundles_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "decision_bundles" ADD CONSTRAINT "decision_bundles_origin_agent_id_agents_id_fk" FOREIGN KEY ("origin_agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "decision_bundles" ADD CONSTRAINT "decision_bundles_origin_issue_id_issues_id_fk" FOREIGN KEY ("origin_issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "decision_bundles" ADD CONSTRAINT "decision_bundles_origin_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("origin_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "decisions" ADD CONSTRAINT "decisions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "decisions" ADD CONSTRAINT "decisions_bundle_id_decision_bundles_id_fk" FOREIGN KEY ("bundle_id") REFERENCES "public"."decision_bundles"("id") ON DELETE set null ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "decisions" ADD CONSTRAINT "decisions_origin_agent_id_agents_id_fk" FOREIGN KEY ("origin_agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "decisions" ADD CONSTRAINT "decisions_origin_issue_id_issues_id_fk" FOREIGN KEY ("origin_issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "decisions" ADD CONSTRAINT "decisions_origin_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("origin_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "decision_target_issues" ADD CONSTRAINT "decision_target_issues_decision_id_decisions_id_fk" FOREIGN KEY ("decision_id") REFERENCES "public"."decisions"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "decision_target_issues" ADD CONSTRAINT "decision_target_issues_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "decision_target_issues" ADD CONSTRAINT "decision_target_issues_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "decision_effect_executions" ADD CONSTRAINT "decision_effect_executions_decision_id_decisions_id_fk" FOREIGN KEY ("decision_id") REFERENCES "public"."decisions"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "decision_effect_executions" ADD CONSTRAINT "decision_effect_executions_target_issue_id_issues_id_fk" FOREIGN KEY ("target_issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "decision_effect_executions" ADD CONSTRAINT "decision_effect_executions_activity_log_id_activity_log_id_fk" FOREIGN KEY ("activity_log_id") REFERENCES "public"."activity_log"("id") ON DELETE set null ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "decision_bundles_company_created_at_idx" ON "decision_bundles" USING btree ("company_id","created_at");
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "decisions_company_idempotency_uq" ON "decisions" USING btree ("company_id","idempotency_key") WHERE "decisions"."idempotency_key" IS NOT NULL;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "decisions_company_status_expires_at_idx" ON "decisions" USING btree ("company_id","status","expires_at");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "decisions_bundle_idx" ON "decisions" USING btree ("bundle_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "decisions_origin_issue_idx" ON "decisions" USING btree ("origin_issue_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "decision_target_issues_decision_idx" ON "decision_target_issues" USING btree ("decision_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "decision_target_issues_issue_idx" ON "decision_target_issues" USING btree ("issue_id");
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "decision_effect_executions_decision_effect_uq" ON "decision_effect_executions" USING btree ("decision_id","effect_index");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "decision_effect_executions_target_issue_idx" ON "decision_effect_executions" USING btree ("target_issue_id");
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1366,6 +1366,13 @@
|
|||
"when": 1785170001001,
|
||||
"tag": "0196_drop_cloud_upstream_tables",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 197,
|
||||
"version": "7",
|
||||
"when": 1785175200000,
|
||||
"tag": "0197_decisions_v1",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
import type { DecisionInput, DecisionOption } from "@paperclipai/shared";
|
||||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { activityLog } from "./activity_log.js";
|
||||
import { agents } from "./agents.js";
|
||||
import { companies } from "./companies.js";
|
||||
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
import { issues } from "./issues.js";
|
||||
|
||||
export const decisionBundles = pgTable(
|
||||
"decision_bundles",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
title: text("title").notNull(),
|
||||
summary: text("summary").notNull(),
|
||||
originAgentId: uuid("origin_agent_id").notNull().references(() => agents.id),
|
||||
originIssueId: uuid("origin_issue_id").notNull().references(() => issues.id),
|
||||
originRunId: uuid("origin_run_id").notNull().references(() => heartbeatRuns.id),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyCreatedAtIdx: index("decision_bundles_company_created_at_idx").on(table.companyId, table.createdAt),
|
||||
}),
|
||||
);
|
||||
|
||||
export const decisions = pgTable(
|
||||
"decisions",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
bundleId: uuid("bundle_id").references(() => decisionBundles.id, { onDelete: "set null" }),
|
||||
originAgentId: uuid("origin_agent_id").notNull().references(() => agents.id),
|
||||
originIssueId: uuid("origin_issue_id").notNull().references(() => issues.id),
|
||||
originRunId: uuid("origin_run_id").notNull().references(() => heartbeatRuns.id),
|
||||
ruleKey: text("rule_key"),
|
||||
title: text("title").notNull(),
|
||||
body: text("body").notNull(),
|
||||
options: jsonb("options").$type<DecisionOption[]>().notNull(),
|
||||
inputs: jsonb("inputs").$type<DecisionInput[]>(),
|
||||
status: text("status").notNull().default("open"),
|
||||
executionStatus: text("execution_status"),
|
||||
chosenOptionId: text("chosen_option_id"),
|
||||
inputValues: jsonb("input_values").$type<Record<string, string>>(),
|
||||
decidedByUserId: text("decided_by_user_id"),
|
||||
decidedAt: timestamp("decided_at", { withTimezone: true }),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
idempotencyKey: text("idempotency_key"),
|
||||
signedSpec: text("signed_spec").notNull(),
|
||||
targetSnapshots: jsonb("target_snapshots").$type<Record<string, unknown>>().notNull(),
|
||||
continuationPolicy: text("continuation_policy").notNull().default("none"),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default({}),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyStatusExpiresAtIdx: index("decisions_company_status_expires_at_idx").on(
|
||||
table.companyId,
|
||||
table.status,
|
||||
table.expiresAt,
|
||||
),
|
||||
bundleIdx: index("decisions_bundle_idx").on(table.bundleId),
|
||||
originIssueIdx: index("decisions_origin_issue_idx").on(table.originIssueId),
|
||||
companyIdempotencyUq: uniqueIndex("decisions_company_idempotency_uq")
|
||||
.on(table.companyId, table.idempotencyKey)
|
||||
.where(sql`${table.idempotencyKey} IS NOT NULL`),
|
||||
}),
|
||||
);
|
||||
|
||||
export const decisionTargetIssues = pgTable(
|
||||
"decision_target_issues",
|
||||
{
|
||||
decisionId: uuid("decision_id").notNull().references(() => decisions.id, { onDelete: "cascade" }),
|
||||
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
},
|
||||
(table) => ({
|
||||
pk: primaryKey({ columns: [table.decisionId, table.issueId] }),
|
||||
decisionIdx: index("decision_target_issues_decision_idx").on(table.decisionId),
|
||||
issueIdx: index("decision_target_issues_issue_idx").on(table.issueId),
|
||||
}),
|
||||
);
|
||||
|
||||
export const decisionEffectExecutions = pgTable(
|
||||
"decision_effect_executions",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
decisionId: uuid("decision_id").notNull().references(() => decisions.id, { onDelete: "cascade" }),
|
||||
effectIndex: integer("effect_index").notNull(),
|
||||
effectType: text("effect_type").notNull(),
|
||||
targetIssueId: uuid("target_issue_id").notNull().references(() => issues.id),
|
||||
status: text("status").notNull().default("claimed"),
|
||||
result: jsonb("result").$type<Record<string, unknown>>(),
|
||||
error: text("error"),
|
||||
activityLogId: uuid("activity_log_id").references(() => activityLog.id, { onDelete: "set null" }),
|
||||
executedAt: timestamp("executed_at", { withTimezone: true }),
|
||||
},
|
||||
(table) => ({
|
||||
decisionEffectUq: uniqueIndex("decision_effect_executions_decision_effect_uq").on(
|
||||
table.decisionId,
|
||||
table.effectIndex,
|
||||
),
|
||||
targetIssueIdx: index("decision_effect_executions_target_issue_idx").on(table.targetIssueId),
|
||||
}),
|
||||
);
|
||||
|
|
@ -70,6 +70,12 @@ export { issueApprovals } from "./issue_approvals.js";
|
|||
export { issueComments } from "./issue_comments.js";
|
||||
export { issueCreateIdempotencyKeys } from "./issue_create_idempotency_keys.js";
|
||||
export { issueThreadInteractions } from "./issue_thread_interactions.js";
|
||||
export {
|
||||
decisions,
|
||||
decisionBundles,
|
||||
decisionTargetIssues,
|
||||
decisionEffectExecutions,
|
||||
} from "./decisions.js";
|
||||
export { issueTreeHolds } from "./issue_tree_holds.js";
|
||||
export { issueTreeHoldMembers } from "./issue_tree_hold_members.js";
|
||||
export { issueExecutionDecisions } from "./issue_execution_decisions.js";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
decisionEffectSchema,
|
||||
decisionInputsSchema,
|
||||
decisionOptionSchema,
|
||||
decisionOptionsSchema,
|
||||
} from "./validators/decision.js";
|
||||
|
||||
const targetIssueId = "11111111-1111-4111-8111-111111111111";
|
||||
const secondIssueId = "22222222-2222-4222-8222-222222222222";
|
||||
|
||||
describe("decision validators", () => {
|
||||
it("accepts all six effect variants", () => {
|
||||
const effects = [
|
||||
{
|
||||
type: "comment_on_issue",
|
||||
targetIssueId,
|
||||
staleness: "lenient",
|
||||
bodyMarkdown: "Approved with {{input.note}}",
|
||||
},
|
||||
{
|
||||
type: "create_issue",
|
||||
targetIssueId,
|
||||
staleness: "strict",
|
||||
draft: { title: "Follow up", parentId: targetIssueId },
|
||||
},
|
||||
{
|
||||
type: "update_issue_status",
|
||||
targetIssueId,
|
||||
staleness: "strict",
|
||||
status: "done",
|
||||
comment: "Decision approved",
|
||||
},
|
||||
{
|
||||
type: "assign_issue",
|
||||
targetIssueId,
|
||||
staleness: "lenient",
|
||||
assigneeAgentId: secondIssueId,
|
||||
},
|
||||
{
|
||||
type: "cancel_issue_tree",
|
||||
targetIssueId,
|
||||
staleness: "strict",
|
||||
reasonComment: "No longer needed",
|
||||
},
|
||||
{
|
||||
type: "resolve_blocker",
|
||||
targetIssueId,
|
||||
staleness: "strict",
|
||||
removeBlockedByIssueIds: [secondIssueId],
|
||||
},
|
||||
];
|
||||
|
||||
for (const effect of effects) {
|
||||
expect(decisionEffectSchema.parse(effect)).toEqual(effect);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects malformed and unknown effects", () => {
|
||||
expect(() => decisionEffectSchema.parse({
|
||||
type: "comment_on_issue",
|
||||
targetIssueId,
|
||||
staleness: "strict",
|
||||
})).toThrow();
|
||||
expect(() => decisionEffectSchema.parse({
|
||||
type: "delete_company",
|
||||
targetIssueId,
|
||||
staleness: "strict",
|
||||
})).toThrow();
|
||||
expect(() => decisionEffectSchema.parse({
|
||||
type: "assign_issue",
|
||||
targetIssueId,
|
||||
staleness: "strict",
|
||||
})).toThrow();
|
||||
});
|
||||
|
||||
it("forces cancel-tree effects to be strict and destructive", () => {
|
||||
expect(() => decisionEffectSchema.parse({
|
||||
type: "cancel_issue_tree",
|
||||
targetIssueId,
|
||||
staleness: "lenient",
|
||||
reasonComment: "No longer needed",
|
||||
})).toThrow();
|
||||
|
||||
expect(() => decisionOptionSchema.parse({
|
||||
id: "cancel",
|
||||
label: "Cancel tree",
|
||||
effects: [{
|
||||
type: "cancel_issue_tree",
|
||||
targetIssueId,
|
||||
staleness: "strict",
|
||||
reasonComment: "No longer needed",
|
||||
}],
|
||||
})).toThrow();
|
||||
|
||||
expect(decisionOptionSchema.parse({
|
||||
id: "cancel",
|
||||
label: "Cancel tree",
|
||||
style: "destructive",
|
||||
effects: [{
|
||||
type: "cancel_issue_tree",
|
||||
targetIssueId,
|
||||
staleness: "strict",
|
||||
reasonComment: "No longer needed",
|
||||
}],
|
||||
}).style).toBe("destructive");
|
||||
});
|
||||
|
||||
it("enforces option, input, and effect limits", () => {
|
||||
const dismissOption = { id: "dismiss", label: "Dismiss", effects: [] };
|
||||
expect(decisionOptionsSchema.parse(Array.from({ length: 8 }, (_, index) => ({
|
||||
...dismissOption,
|
||||
id: `option-${index}`,
|
||||
})))).toHaveLength(8);
|
||||
expect(() => decisionOptionsSchema.parse(Array.from({ length: 9 }, (_, index) => ({
|
||||
...dismissOption,
|
||||
id: `option-${index}`,
|
||||
})))).toThrow();
|
||||
|
||||
expect(decisionInputsSchema.parse(Array.from({ length: 4 }, (_, index) => ({
|
||||
id: `input-${index}`,
|
||||
label: `Input ${index}`,
|
||||
})))).toHaveLength(4);
|
||||
expect(() => decisionInputsSchema.parse(Array.from({ length: 5 }, (_, index) => ({
|
||||
id: `input-${index}`,
|
||||
label: `Input ${index}`,
|
||||
})))).toThrow();
|
||||
|
||||
const commentEffect = {
|
||||
type: "comment_on_issue",
|
||||
targetIssueId,
|
||||
staleness: "strict",
|
||||
bodyMarkdown: "Comment",
|
||||
};
|
||||
expect(decisionOptionSchema.parse({
|
||||
id: "approve",
|
||||
label: "Approve",
|
||||
effects: Array.from({ length: 10 }, () => commentEffect),
|
||||
}).effects).toHaveLength(10);
|
||||
expect(() => decisionOptionSchema.parse({
|
||||
id: "approve",
|
||||
label: "Approve",
|
||||
effects: Array.from({ length: 11 }, () => commentEffect),
|
||||
})).toThrow();
|
||||
});
|
||||
|
||||
it("rejects duplicate option and input ids", () => {
|
||||
expect(() => decisionOptionsSchema.parse([
|
||||
{ id: "same", label: "One", effects: [] },
|
||||
{ id: "same", label: "Two", effects: [] },
|
||||
])).toThrow();
|
||||
expect(() => decisionInputsSchema.parse([
|
||||
{ id: "same", label: "One" },
|
||||
{ id: "same", label: "Two" },
|
||||
])).toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,43 @@
|
|||
export { agentAdapterTypeSchema, optionalAgentAdapterTypeSchema } from "./adapter-type.js";
|
||||
export {
|
||||
decisionEffectStalenessSchema,
|
||||
decisionOptionStyleSchema,
|
||||
commentOnIssueDecisionEffectSchema,
|
||||
createIssueDecisionEffectSchema,
|
||||
updateIssueStatusDecisionEffectSchema,
|
||||
assignIssueDecisionEffectSchema,
|
||||
cancelIssueTreeDecisionEffectSchema,
|
||||
resolveBlockerDecisionEffectSchema,
|
||||
decisionEffectSchema,
|
||||
decisionInputSchema,
|
||||
decisionOptionSchema,
|
||||
decisionOptionsSchema,
|
||||
decisionInputsSchema,
|
||||
decisionSpecSchema,
|
||||
type DecisionEffectInput,
|
||||
type DecisionOptionInput,
|
||||
type DecisionInputInput,
|
||||
type DecisionSpecInput,
|
||||
} from "./validators/decision.js";
|
||||
|
||||
export type {
|
||||
DecisionEffectStaleness,
|
||||
DecisionOptionStyle,
|
||||
DecisionInput,
|
||||
CommentOnIssueDecisionEffect,
|
||||
CreateIssueDecisionEffect,
|
||||
UpdateIssueStatusDecisionEffect,
|
||||
AssignIssueDecisionEffect,
|
||||
CancelIssueTreeDecisionEffect,
|
||||
ResolveBlockerDecisionEffect,
|
||||
DecisionEffect,
|
||||
DecisionOption,
|
||||
DecisionStatsCounts,
|
||||
DecisionChosenOptionCount,
|
||||
DecisionRuleKeyStats,
|
||||
DecisionStatsResponse,
|
||||
} from "./types/decision.js";
|
||||
|
||||
export {
|
||||
getAgentOrgChainHealth,
|
||||
getAgentWorkEligibility,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { InboxDismissalKind } from "./inbox-dismissal.js";
|
|||
|
||||
export type AttentionSourceKind =
|
||||
| "approval"
|
||||
| "decision"
|
||||
| "issue_thread_interaction"
|
||||
| "join_request"
|
||||
| "recovery_action"
|
||||
|
|
@ -14,6 +15,7 @@ export type AttentionSourceKind =
|
|||
|
||||
export type AttentionSubjectKind =
|
||||
| "approval"
|
||||
| "decision"
|
||||
| "issue"
|
||||
| "interaction"
|
||||
| "join_request"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
import type { IssueStatus } from "../constants.js";
|
||||
|
||||
export type DecisionEffectStaleness = "strict" | "lenient";
|
||||
export type DecisionOptionStyle = "default" | "primary" | "destructive";
|
||||
|
||||
export interface DecisionInput {
|
||||
id: string;
|
||||
label: string;
|
||||
placeholder?: string | null;
|
||||
required?: boolean;
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
interface DecisionEffectBase {
|
||||
targetIssueId: string;
|
||||
staleness: DecisionEffectStaleness;
|
||||
}
|
||||
|
||||
export interface CommentOnIssueDecisionEffect extends DecisionEffectBase {
|
||||
type: "comment_on_issue";
|
||||
bodyMarkdown: string;
|
||||
}
|
||||
|
||||
export interface CreateIssueDecisionEffect extends DecisionEffectBase {
|
||||
type: "create_issue";
|
||||
draft: {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
parentId?: string | null;
|
||||
assigneeAgentId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
projectId?: string | null;
|
||||
goalId?: string | null;
|
||||
blockedByIssueIds?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateIssueStatusDecisionEffect extends DecisionEffectBase {
|
||||
type: "update_issue_status";
|
||||
status: IssueStatus;
|
||||
comment?: string | null;
|
||||
}
|
||||
|
||||
export interface AssignIssueDecisionEffect extends DecisionEffectBase {
|
||||
type: "assign_issue";
|
||||
assigneeAgentId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
comment?: string | null;
|
||||
}
|
||||
|
||||
export interface CancelIssueTreeDecisionEffect extends DecisionEffectBase {
|
||||
type: "cancel_issue_tree";
|
||||
staleness: "strict";
|
||||
reasonComment: string;
|
||||
}
|
||||
|
||||
export interface ResolveBlockerDecisionEffect extends DecisionEffectBase {
|
||||
type: "resolve_blocker";
|
||||
removeBlockedByIssueIds: string[];
|
||||
}
|
||||
|
||||
export type DecisionEffect =
|
||||
| CommentOnIssueDecisionEffect
|
||||
| CreateIssueDecisionEffect
|
||||
| UpdateIssueStatusDecisionEffect
|
||||
| AssignIssueDecisionEffect
|
||||
| CancelIssueTreeDecisionEffect
|
||||
| ResolveBlockerDecisionEffect;
|
||||
|
||||
export interface DecisionOption {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
style?: DecisionOptionStyle;
|
||||
effects: DecisionEffect[];
|
||||
}
|
||||
|
||||
export interface DecisionStatsCounts {
|
||||
proposed: number;
|
||||
accepted: number;
|
||||
rejected: number;
|
||||
expired: number;
|
||||
}
|
||||
|
||||
export interface DecisionChosenOptionCount {
|
||||
optionId: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface DecisionRuleKeyStats extends DecisionStatsCounts {
|
||||
ruleKey: string | null;
|
||||
chosenOptions: DecisionChosenOptionCount[];
|
||||
}
|
||||
|
||||
export interface DecisionStatsResponse {
|
||||
groupBy: "ruleKey";
|
||||
filters: {
|
||||
originAgentId: string | null;
|
||||
since: string | null;
|
||||
};
|
||||
totals: DecisionStatsCounts;
|
||||
groups: DecisionRuleKeyStats[];
|
||||
}
|
||||
|
|
@ -793,6 +793,23 @@ export type {
|
|||
} from "./resource-memberships.js";
|
||||
export { RESOURCE_MEMBERSHIP_STATES } from "./resource-memberships.js";
|
||||
export type { InboxDismissal, InboxDismissalKind } from "./inbox-dismissal.js";
|
||||
export type {
|
||||
DecisionEffectStaleness,
|
||||
DecisionOptionStyle,
|
||||
DecisionInput,
|
||||
CommentOnIssueDecisionEffect,
|
||||
CreateIssueDecisionEffect,
|
||||
UpdateIssueStatusDecisionEffect,
|
||||
AssignIssueDecisionEffect,
|
||||
CancelIssueTreeDecisionEffect,
|
||||
ResolveBlockerDecisionEffect,
|
||||
DecisionEffect,
|
||||
DecisionOption,
|
||||
DecisionStatsCounts,
|
||||
DecisionChosenOptionCount,
|
||||
DecisionRuleKeyStats,
|
||||
DecisionStatsResponse,
|
||||
} from "./decision.js";
|
||||
export type {
|
||||
AccessUserProfile,
|
||||
CompanyMemberRecord,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,149 @@
|
|||
import { z } from "zod";
|
||||
import { ISSUE_STATUSES } from "../constants.js";
|
||||
|
||||
export const decisionEffectStalenessSchema = z.enum(["strict", "lenient"]);
|
||||
export const decisionOptionStyleSchema = z.enum(["default", "primary", "destructive"]);
|
||||
|
||||
const decisionEffectBaseShape = {
|
||||
targetIssueId: z.string().uuid(),
|
||||
staleness: decisionEffectStalenessSchema,
|
||||
};
|
||||
|
||||
export const commentOnIssueDecisionEffectSchema = z.object({
|
||||
type: z.literal("comment_on_issue"),
|
||||
...decisionEffectBaseShape,
|
||||
bodyMarkdown: z.string().trim().min(1).max(20_000),
|
||||
});
|
||||
|
||||
export const createIssueDecisionEffectSchema = z.object({
|
||||
type: z.literal("create_issue"),
|
||||
...decisionEffectBaseShape,
|
||||
draft: z.object({
|
||||
title: z.string().trim().min(1).max(500),
|
||||
description: z.string().max(100_000).nullable().optional(),
|
||||
parentId: z.string().uuid().nullable().optional(),
|
||||
assigneeAgentId: z.string().uuid().nullable().optional(),
|
||||
assigneeUserId: z.string().trim().min(1).nullable().optional(),
|
||||
projectId: z.string().uuid().nullable().optional(),
|
||||
goalId: z.string().uuid().nullable().optional(),
|
||||
blockedByIssueIds: z.array(z.string().uuid()).max(100).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const updateIssueStatusDecisionEffectSchema = z.object({
|
||||
type: z.literal("update_issue_status"),
|
||||
...decisionEffectBaseShape,
|
||||
status: z.enum(ISSUE_STATUSES),
|
||||
comment: z.string().trim().min(1).max(20_000).nullable().optional(),
|
||||
});
|
||||
|
||||
export const assignIssueDecisionEffectSchema = z.object({
|
||||
type: z.literal("assign_issue"),
|
||||
...decisionEffectBaseShape,
|
||||
assigneeAgentId: z.string().uuid().nullable().optional(),
|
||||
assigneeUserId: z.string().trim().min(1).nullable().optional(),
|
||||
comment: z.string().trim().min(1).max(20_000).nullable().optional(),
|
||||
});
|
||||
|
||||
export const cancelIssueTreeDecisionEffectSchema = z.object({
|
||||
type: z.literal("cancel_issue_tree"),
|
||||
targetIssueId: z.string().uuid(),
|
||||
staleness: z.literal("strict"),
|
||||
reasonComment: z.string().trim().min(1).max(20_000),
|
||||
});
|
||||
|
||||
export const resolveBlockerDecisionEffectSchema = z.object({
|
||||
type: z.literal("resolve_blocker"),
|
||||
...decisionEffectBaseShape,
|
||||
removeBlockedByIssueIds: z.array(z.string().uuid()).min(1).max(100),
|
||||
});
|
||||
|
||||
export const decisionEffectSchema = z.discriminatedUnion("type", [
|
||||
commentOnIssueDecisionEffectSchema,
|
||||
createIssueDecisionEffectSchema,
|
||||
updateIssueStatusDecisionEffectSchema,
|
||||
assignIssueDecisionEffectSchema,
|
||||
cancelIssueTreeDecisionEffectSchema,
|
||||
resolveBlockerDecisionEffectSchema,
|
||||
]).superRefine((effect, ctx) => {
|
||||
if (effect.type === "create_issue" && effect.draft.assigneeAgentId && effect.draft.assigneeUserId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Only one assignee may be set",
|
||||
path: ["draft", "assigneeUserId"],
|
||||
});
|
||||
}
|
||||
|
||||
if (effect.type === "assign_issue") {
|
||||
const assigneeCount = Number(Boolean(effect.assigneeAgentId)) + Number(Boolean(effect.assigneeUserId));
|
||||
if (assigneeCount !== 1) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Exactly one assignee must be set",
|
||||
path: ["assigneeAgentId"],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const decisionInputSchema = z.object({
|
||||
id: z.string().trim().min(1).max(120),
|
||||
label: z.string().trim().min(1).max(240),
|
||||
placeholder: z.string().max(500).nullable().optional(),
|
||||
required: z.boolean().optional(),
|
||||
maxLength: z.number().int().positive().max(20_000).optional(),
|
||||
});
|
||||
|
||||
export const decisionOptionSchema = z.object({
|
||||
id: z.string().trim().min(1).max(120),
|
||||
label: z.string().trim().min(1).max(240),
|
||||
description: z.string().max(2_000).nullable().optional(),
|
||||
style: decisionOptionStyleSchema.optional(),
|
||||
effects: z.array(decisionEffectSchema).max(10),
|
||||
}).superRefine((option, ctx) => {
|
||||
if (option.effects.some((effect) => effect.type === "cancel_issue_tree") && option.style !== "destructive") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Options that cancel an issue tree must use destructive style",
|
||||
path: ["style"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const decisionOptionsSchema = z.array(decisionOptionSchema).min(1).max(8).superRefine((options, ctx) => {
|
||||
const seenIds = new Set<string>();
|
||||
for (const [index, option] of options.entries()) {
|
||||
if (seenIds.has(option.id)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Decision option ids must be unique",
|
||||
path: [index, "id"],
|
||||
});
|
||||
}
|
||||
seenIds.add(option.id);
|
||||
}
|
||||
});
|
||||
|
||||
export const decisionInputsSchema = z.array(decisionInputSchema).max(4).superRefine((inputs, ctx) => {
|
||||
const seenIds = new Set<string>();
|
||||
for (const [index, input] of inputs.entries()) {
|
||||
if (seenIds.has(input.id)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Decision input ids must be unique",
|
||||
path: [index, "id"],
|
||||
});
|
||||
}
|
||||
seenIds.add(input.id);
|
||||
}
|
||||
});
|
||||
|
||||
export const decisionSpecSchema = z.object({
|
||||
options: decisionOptionsSchema,
|
||||
inputs: decisionInputsSchema.nullable().optional(),
|
||||
});
|
||||
|
||||
export type DecisionEffectInput = z.input<typeof decisionEffectSchema>;
|
||||
export type DecisionOptionInput = z.input<typeof decisionOptionSchema>;
|
||||
export type DecisionInputInput = z.input<typeof decisionInputSchema>;
|
||||
export type DecisionSpecInput = z.input<typeof decisionSpecSchema>;
|
||||
|
|
@ -1,3 +1,24 @@
|
|||
export {
|
||||
decisionEffectStalenessSchema,
|
||||
decisionOptionStyleSchema,
|
||||
commentOnIssueDecisionEffectSchema,
|
||||
createIssueDecisionEffectSchema,
|
||||
updateIssueStatusDecisionEffectSchema,
|
||||
assignIssueDecisionEffectSchema,
|
||||
cancelIssueTreeDecisionEffectSchema,
|
||||
resolveBlockerDecisionEffectSchema,
|
||||
decisionEffectSchema,
|
||||
decisionInputSchema,
|
||||
decisionOptionSchema,
|
||||
decisionOptionsSchema,
|
||||
decisionInputsSchema,
|
||||
decisionSpecSchema,
|
||||
type DecisionEffectInput,
|
||||
type DecisionOptionInput,
|
||||
type DecisionInputInput,
|
||||
type DecisionSpecInput,
|
||||
} from "./decision.js";
|
||||
|
||||
export {
|
||||
instanceSettingsSchema,
|
||||
instanceGeneralSettingsSchema,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createDecisionWakeOriginAgent } from "../services/decision-wakeup.js";
|
||||
|
||||
const input = {
|
||||
companyId: "company-1",
|
||||
agentId: "agent-1",
|
||||
issueId: "issue-1",
|
||||
decisionId: "decision-1",
|
||||
outcome: "decided" as const,
|
||||
};
|
||||
|
||||
describe("createDecisionWakeOriginAgent", () => {
|
||||
it("does not enqueue when the heartbeat runtime is disabled", async () => {
|
||||
await expect(createDecisionWakeOriginAgent(null)(input)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("maps a decision continuation onto the enabled heartbeat runtime", async () => {
|
||||
const wakeup = vi.fn().mockResolvedValue({ id: "run-1" });
|
||||
|
||||
await expect(createDecisionWakeOriginAgent(wakeup)(input)).resolves.toEqual({ id: "run-1" });
|
||||
expect(wakeup).toHaveBeenCalledWith("agent-1", {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "decision_decided",
|
||||
payload: { issueId: "issue-1", decisionId: "decision-1", outcome: "decided" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,497 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
authUsers,
|
||||
companies,
|
||||
companyMemberships,
|
||||
createDb,
|
||||
decisionEffectExecutions,
|
||||
decisions,
|
||||
decisionTargetIssues,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
issueRelations,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { attentionService } from "../services/attention.js";
|
||||
import { decisionService } from "../services/decisions.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const describePg = support.supported ? describe : describe.skip;
|
||||
|
||||
describePg("decisionService", () => {
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let companyId: string;
|
||||
let agentId: string;
|
||||
let originIssueId: string;
|
||||
let targetIssueId: string;
|
||||
let runId: string;
|
||||
let originResponsibleUserId: string;
|
||||
let decidedByUserId: string;
|
||||
let wakes: Array<Record<string, unknown>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-decisions-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
beforeEach(async () => {
|
||||
process.env.PAPERCLIP_DECISION_SIGNING_SECRET = "0123456789abcdef0123456789abcdef";
|
||||
companyId = randomUUID(); agentId = randomUUID(); originIssueId = randomUUID(); targetIssueId = randomUUID(); runId = randomUUID();
|
||||
originResponsibleUserId = `origin-${randomUUID()}`; decidedByUserId = `decider-${randomUUID()}`; wakes = [];
|
||||
const now = new Date();
|
||||
await db.insert(companies).values({ id: companyId, name: "Decisions", issuePrefix: `D${companyId.slice(0, 6)}`, requireBoardApprovalForNewAgents: false });
|
||||
await db.insert(authUsers).values([
|
||||
{ id: originResponsibleUserId, name: "Origin", email: `${originResponsibleUserId}@example.test`, createdAt: now, updatedAt: now },
|
||||
{ id: decidedByUserId, name: "Decider", email: `${decidedByUserId}@example.test`, createdAt: now, updatedAt: now },
|
||||
]);
|
||||
await db.insert(companyMemberships).values([
|
||||
{ companyId, principalType: "user", principalId: originResponsibleUserId, status: "active", membershipRole: "member" },
|
||||
{ companyId, principalType: "user", principalId: decidedByUserId, status: "active", membershipRole: "member" },
|
||||
]);
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Proposer", role: "engineer", status: "active", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {} });
|
||||
await db.insert(issues).values([
|
||||
{ id: originIssueId, companyId, title: "Origin", status: "in_progress", priority: "medium", assigneeAgentId: agentId, responsibleUserId: originResponsibleUserId },
|
||||
{ id: targetIssueId, companyId, title: "Target", status: "todo", priority: "medium", responsibleUserId: decidedByUserId },
|
||||
]);
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", responsibleUserId: originResponsibleUserId, contextSnapshot: { issueId: originIssueId } });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.PAPERCLIP_DECISIONS_SWEEP_BATCH_SIZE;
|
||||
delete process.env.PAPERCLIP_DECISIONS_RECOVERY_GRACE_MS;
|
||||
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
await db.delete(decisionEffectExecutions); await db.delete(decisionTargetIssues); await db.delete(decisions); await db.delete(activityLog);
|
||||
await db.delete(issueComments); await db.delete(issueRelations); await db.delete(heartbeatRuns); await db.delete(issues); await db.delete(agents); await db.delete(companyMemberships); await db.delete(authUsers); await db.delete(companies);
|
||||
});
|
||||
afterAll(async () => tempDb?.cleanup());
|
||||
|
||||
const agentActor = () => ({ type: "agent" as const, companyId, agentId, runId, source: "agent_jwt" as const,
|
||||
onBehalfOfUserId: originResponsibleUserId, onBehalfOfMemberships: [{ companyId, membershipRole: "member", status: "active" }] });
|
||||
const boardActor = () => ({ type: "board" as const, userId: decidedByUserId, companyIds: [companyId], source: "session" as const,
|
||||
memberships: [{ companyId, membershipRole: "member", status: "active" }] });
|
||||
const service = () => decisionService(db, { wakeOriginAgent: async (input) => { wakes.push(input); } });
|
||||
const createCommentDecision = (staleness: "strict" | "lenient" = "lenient", extra: Record<string, unknown> = {}) => service().create({
|
||||
companyId, actor: agentActor(), agentId, runId, title: "Comment?", body: "Body", continuationPolicy: "wake_origin_agent",
|
||||
options: [{ id: "yes", label: "Yes", effects: [{ type: "comment_on_issue", targetIssueId, staleness, bodyMarkdown: "hello" }] }],
|
||||
...extra,
|
||||
});
|
||||
|
||||
it("returns the existing decision for concurrent idempotent creates", async () => {
|
||||
const input = {
|
||||
companyId, actor: agentActor(), agentId, runId, title: "Same?", body: "Body", idempotencyKey: "concurrent-create",
|
||||
options: [{ id: "yes", label: "Yes", effects: [{ type: "comment_on_issue" as const, targetIssueId, staleness: "lenient" as const, bodyMarkdown: "hello" }] }],
|
||||
};
|
||||
const [first, second] = await Promise.all([service().create(input), service().create(input)]);
|
||||
expect(second.id).toBe(first.id);
|
||||
expect(await db.select().from(decisions).where(eq(decisions.idempotencyKey, "concurrent-create"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("executes once, replays stored outcome, and attributes executor audit to the decider", async () => {
|
||||
const created = await createCommentDecision();
|
||||
const first = await service().decide({ id: created.id, optionId: "yes", idempotencyKey: "decide-1", decidedByUserId, userActor: boardActor() });
|
||||
const replay = await service().decide({ id: created.id, optionId: "yes", idempotencyKey: "decide-1", decidedByUserId, userActor: boardActor() });
|
||||
expect(first.executionStatus).toBe("succeeded"); expect(replay.executionStatus).toBe("succeeded");
|
||||
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, targetIssueId))).toHaveLength(1);
|
||||
const audit = await db.select().from(activityLog).where(eq(activityLog.action, "decision.effect_executed"));
|
||||
expect(audit[0]?.responsibleUserId).toBe(decidedByUserId);
|
||||
expect(audit[0]?.details).toMatchObject({ decidedByUserId, originResponsibleUserId });
|
||||
expect(first.executions[0]?.activityLogId).toBe(audit[0]?.id);
|
||||
expect(wakes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("allows one double-decide winner and rejects the loser", async () => {
|
||||
const created = await createCommentDecision();
|
||||
const outcomes = await Promise.allSettled([
|
||||
service().decide({ id: created.id, optionId: "yes", idempotencyKey: "race-a", decidedByUserId, userActor: boardActor() }),
|
||||
service().decide({ id: created.id, optionId: "yes", idempotencyKey: "race-b", decidedByUserId, userActor: boardActor() }),
|
||||
]);
|
||||
expect(outcomes.filter((item) => item.status === "fulfilled")).toHaveLength(1);
|
||||
expect(outcomes.filter((item) => item.status === "rejected")).toHaveLength(1);
|
||||
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, targetIssueId))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("skips strict stale targets and fails closed on intersection denial", async () => {
|
||||
const stale = await createCommentDecision("strict");
|
||||
await db.update(issues).set({ updatedAt: new Date(Date.now() + 1_000) }).where(eq(issues.id, targetIssueId));
|
||||
const staleResult = await service().decide({ id: stale.id, optionId: "yes", decidedByUserId, userActor: boardActor() });
|
||||
expect(staleResult.executions[0]).toMatchObject({ status: "skipped", error: "target_changed" });
|
||||
|
||||
const denied = await createCommentDecision("lenient", { idempotencyKey: "denied" });
|
||||
const deniedResult = await service().decide({ id: denied.id, optionId: "yes", decidedByUserId, userActor: { type: "none", source: "none" } });
|
||||
expect(deniedResult.executions[0]).toMatchObject({ status: "failed", error: "deny_decision_intersection" });
|
||||
const failedAudit = await db.select().from(activityLog).where(eq(activityLog.action, "decision.effect_failed"));
|
||||
expect(failedAudit.at(-1)?.details).toMatchObject({ reason: "deny_decision_intersection" });
|
||||
});
|
||||
|
||||
it("fails closed when the origin actor retains read access but loses mutation access", async () => {
|
||||
const created = await service().create({
|
||||
companyId, actor: agentActor(), agentId, runId, title: "Update?", body: "Body",
|
||||
options: [{ id: "yes", label: "Yes", effects: [{ type: "update_issue_status", targetIssueId, staleness: "lenient", status: "in_progress" }] }],
|
||||
});
|
||||
await db.update(companyMemberships).set({ membershipRole: "viewer" }).where(eq(companyMemberships.principalId, originResponsibleUserId));
|
||||
const result = await service().decide({ id: created.id, optionId: "yes", decidedByUserId, userActor: boardActor() });
|
||||
expect(result.executions[0]).toMatchObject({ status: "failed", error: "deny_decision_intersection" });
|
||||
});
|
||||
|
||||
it("removes inbound blockers without replacing them with outgoing relations", async () => {
|
||||
const removedBlockerId = randomUUID();
|
||||
const retainedBlockerId = randomUUID();
|
||||
const downstreamId = randomUUID();
|
||||
await db.insert(issues).values([
|
||||
{ id: removedBlockerId, companyId, title: "Removed blocker", status: "todo", priority: "medium", responsibleUserId: decidedByUserId },
|
||||
{ id: retainedBlockerId, companyId, title: "Retained blocker", status: "todo", priority: "medium", responsibleUserId: decidedByUserId },
|
||||
{ id: downstreamId, companyId, title: "Downstream", status: "todo", priority: "medium", responsibleUserId: decidedByUserId },
|
||||
]);
|
||||
await db.insert(issueRelations).values([
|
||||
{ companyId, issueId: removedBlockerId, relatedIssueId: targetIssueId, type: "blocks" },
|
||||
{ companyId, issueId: retainedBlockerId, relatedIssueId: targetIssueId, type: "blocks" },
|
||||
{ companyId, issueId: targetIssueId, relatedIssueId: downstreamId, type: "blocks" },
|
||||
]);
|
||||
const created = await service().create({
|
||||
companyId, actor: agentActor(), agentId, runId, title: "Resolve blocker?", body: "Body",
|
||||
options: [{ id: "yes", label: "Yes", effects: [{ type: "resolve_blocker", targetIssueId, staleness: "lenient",
|
||||
removeBlockedByIssueIds: [removedBlockerId] }] }],
|
||||
});
|
||||
|
||||
await service().decide({ id: created.id, optionId: "yes", decidedByUserId, userActor: boardActor() });
|
||||
|
||||
const relations = await db.select().from(issueRelations).where(eq(issueRelations.companyId, companyId));
|
||||
expect(relations).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ issueId: retainedBlockerId, relatedIssueId: targetIssueId, type: "blocks" }),
|
||||
expect.objectContaining({ issueId: targetIssueId, relatedIssueId: downstreamId, type: "blocks" }),
|
||||
]));
|
||||
expect(relations).not.toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ issueId: removedBlockerId, relatedIssueId: targetIssueId, type: "blocks" }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("rejects mutation proposals from an origin actor with read-only access", async () => {
|
||||
await db.update(companyMemberships).set({ membershipRole: "viewer" }).where(eq(companyMemberships.principalId, originResponsibleUserId));
|
||||
const readOnlyActor = { ...agentActor(),
|
||||
onBehalfOfMemberships: [{ companyId, membershipRole: "viewer" as const, status: "active" as const }] };
|
||||
|
||||
await expect(service().create({
|
||||
companyId, actor: readOnlyActor, agentId, runId, title: "Update?", body: "Body",
|
||||
options: [{ id: "yes", label: "Yes", effects: [{
|
||||
type: "update_issue_status", targetIssueId, staleness: "lenient", status: "in_progress",
|
||||
}] }],
|
||||
})).rejects.toThrow("Decision effect exceeds the origin authority boundary");
|
||||
});
|
||||
|
||||
it("expires a decision atomically instead of executing after its deadline", async () => {
|
||||
const created = await createCommentDecision("lenient", { expiresAt: new Date(Date.now() + 5) });
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
await expect(service().decide({ id: created.id, optionId: "yes", decidedByUserId, userActor: boardActor() }))
|
||||
.rejects.toThrow("decision_expired");
|
||||
expect((await service().get(created.id))?.status).toBe("expired");
|
||||
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, targetIssueId))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects strict effects when secondary targets or cancellation scope change", async () => {
|
||||
const blockerId = randomUUID();
|
||||
await db.insert(issues).values({ id: blockerId, companyId, title: "Blocker", status: "todo", priority: "medium", responsibleUserId: decidedByUserId });
|
||||
const createDecision = await service().create({
|
||||
companyId, actor: agentActor(), agentId, runId, title: "Create?", body: "Body",
|
||||
options: [{ id: "yes", label: "Yes", effects: [{ type: "create_issue", targetIssueId, staleness: "strict", draft: { title: "Follow-up", blockedByIssueIds: [blockerId] } }] }],
|
||||
});
|
||||
await db.update(issues).set({ updatedAt: new Date(Date.now() + 1_000) }).where(eq(issues.id, blockerId));
|
||||
const createResult = await service().decide({ id: createDecision.id, optionId: "yes", decidedByUserId, userActor: boardActor() });
|
||||
expect(createResult.executions[0]).toMatchObject({ status: "skipped", error: "target_changed" });
|
||||
|
||||
const cancelDecision = await service().create({
|
||||
companyId, actor: agentActor(), agentId, runId, title: "Cancel?", body: "Body",
|
||||
options: [{ id: "yes", label: "Yes", effects: [{ type: "cancel_issue_tree", targetIssueId, staleness: "strict", reasonComment: "cleanup" }] }],
|
||||
});
|
||||
const childId = randomUUID();
|
||||
await db.insert(issues).values({ id: childId, companyId, title: "New child", status: "todo", priority: "medium", parentId: targetIssueId, responsibleUserId: decidedByUserId });
|
||||
const cancelResult = await service().decide({ id: cancelDecision.id, optionId: "yes", decidedByUserId, userActor: boardActor() });
|
||||
expect(cancelResult.executions[0]).toMatchObject({ status: "skipped", error: "target_changed" });
|
||||
expect((await db.select().from(issues).where(eq(issues.id, childId)))[0]?.status).toBe("todo");
|
||||
});
|
||||
|
||||
it("never expands a lenient cancellation beyond the signed descendant scope", async () => {
|
||||
const reviewedChildId = randomUUID();
|
||||
await db.insert(issues).values({ id: reviewedChildId, companyId, title: "Reviewed child", status: "todo", priority: "medium",
|
||||
parentId: targetIssueId, responsibleUserId: decidedByUserId });
|
||||
const created = await service().create({
|
||||
companyId, actor: agentActor(), agentId, runId, title: "Cancel?", body: "Body",
|
||||
options: [{ id: "yes", label: "Yes", effects: [{
|
||||
type: "cancel_issue_tree", targetIssueId, staleness: "lenient", reasonComment: "cleanup",
|
||||
}] }],
|
||||
});
|
||||
const unreviewedChildId = randomUUID();
|
||||
await db.insert(issues).values({ id: unreviewedChildId, companyId, title: "Unreviewed child", status: "todo", priority: "medium",
|
||||
parentId: targetIssueId, responsibleUserId: decidedByUserId });
|
||||
|
||||
const result = await service().decide({ id: created.id, optionId: "yes", decidedByUserId, userActor: boardActor() });
|
||||
|
||||
expect(result.executions[0]).toMatchObject({ status: "executed",
|
||||
result: { cancelledIssueIds: [reviewedChildId, targetIssueId] } });
|
||||
expect((await db.select().from(issues).where(eq(issues.id, unreviewedChildId)))[0]?.status).toBe("todo");
|
||||
});
|
||||
|
||||
it("bounds cyclic issue traversal for snapshots and cancel-tree execution", async () => {
|
||||
const childId = randomUUID();
|
||||
await db.insert(issues).values({ id: childId, companyId, title: "Cycle child", status: "todo", priority: "medium",
|
||||
parentId: targetIssueId, responsibleUserId: decidedByUserId });
|
||||
await db.update(issues).set({ parentId: childId }).where(eq(issues.id, targetIssueId));
|
||||
const created = await service().create({
|
||||
companyId, actor: agentActor(), agentId, runId, title: "Cancel cycle?", body: "Body",
|
||||
options: [{ id: "cancel", label: "Cancel", style: "destructive", effects: [{
|
||||
type: "cancel_issue_tree", targetIssueId, staleness: "strict", reasonComment: "cleanup",
|
||||
}] }],
|
||||
});
|
||||
|
||||
expect((created.targetSnapshots as Record<string, { descendantCount: number }>)[targetIssueId]?.descendantCount).toBe(1);
|
||||
const result = await service().decide({ id: created.id, optionId: "cancel", decidedByUserId, userActor: boardActor() });
|
||||
expect(result.executions[0]).toMatchObject({ status: "executed", result: { cancelledIssueIds: [childId, targetIssueId] } });
|
||||
expect(await db.select({ id: issues.id, status: issues.status }).from(issues).where(eq(issues.companyId, companyId)))
|
||||
.toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: targetIssueId, status: "cancelled" }),
|
||||
expect.objectContaining({ id: childId, status: "cancelled" }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("fails closed when the deciding user lacks assignment capability", async () => {
|
||||
const assigneeAgentId = randomUUID();
|
||||
await db.insert(agents).values({ id: assigneeAgentId, companyId, name: "Assignee", role: "engineer", status: "active", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {} });
|
||||
const created = await service().create({
|
||||
companyId, actor: agentActor(), agentId, runId, title: "Assign?", body: "Body",
|
||||
options: [{ id: "yes", label: "Yes", effects: [{ type: "assign_issue", targetIssueId, staleness: "lenient", assigneeAgentId }] }],
|
||||
});
|
||||
await db.update(companyMemberships).set({ membershipRole: "viewer" }).where(eq(companyMemberships.principalId, decidedByUserId));
|
||||
const result = await service().decide({ id: created.id, optionId: "yes", decidedByUserId, userActor: boardActor() });
|
||||
expect(result.executions[0]).toMatchObject({ status: "failed", error: "deny_decision_intersection" });
|
||||
});
|
||||
|
||||
it("fails closed when the origin responsible user loses visibility", async () => {
|
||||
const created = await createCommentDecision();
|
||||
await db.update(companyMemberships).set({ status: "inactive" }).where(eq(companyMemberships.principalId, originResponsibleUserId));
|
||||
const result = await service().decide({ id: created.id, optionId: "yes", decidedByUserId, userActor: boardActor() });
|
||||
expect(result.executions[0]).toMatchObject({ status: "failed", error: "deny_decision_intersection" });
|
||||
expect(result.executions[0]?.result).toMatchObject({ originReason: "deny_missing_membership" });
|
||||
});
|
||||
|
||||
it("refuses execution when the decision signing secret is unavailable", async () => {
|
||||
const created = await createCommentDecision();
|
||||
delete process.env.PAPERCLIP_DECISION_SIGNING_SECRET;
|
||||
process.env.PAPERCLIP_AGENT_JWT_SECRET = "agent-jwt-secret-must-not-sign-decisions";
|
||||
await expect(service().decide({ id: created.id, optionId: "yes", decidedByUserId, userActor: boardActor() }))
|
||||
.rejects.toThrow("PAPERCLIP_DECISION_SIGNING_SECRET is required");
|
||||
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, targetIssueId))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("records a failed effect and continues independent later effects", async () => {
|
||||
const created = await service().create({
|
||||
companyId, actor: agentActor(), agentId, runId, title: "Continue?", body: "Body",
|
||||
options: [{ id: "yes", label: "Yes", effects: [
|
||||
{ type: "update_issue_status", targetIssueId, staleness: "lenient", status: "in_progress" },
|
||||
{ type: "comment_on_issue", targetIssueId, staleness: "lenient", bodyMarkdown: "still runs" },
|
||||
] }],
|
||||
});
|
||||
const result = await service().decide({ id: created.id, optionId: "yes", decidedByUserId, userActor: boardActor() });
|
||||
expect(result.executionStatus).toBe("partial");
|
||||
expect(result.executions).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ effectIndex: 0, status: "failed", error: "effect_execution_failed" }),
|
||||
expect.objectContaining({ effectIndex: 1, status: "executed" }),
|
||||
]));
|
||||
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, targetIssueId))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("resumes claimed effects exactly once after a simulated crash without a client idempotency key", async () => {
|
||||
const created = await createCommentDecision();
|
||||
await db.update(decisions).set({ status: "decided", executionStatus: "running", chosenOptionId: "yes", decidedByUserId,
|
||||
inputValues: {} }).where(eq(decisions.id, created.id));
|
||||
await db.insert(decisionEffectExecutions).values({ decisionId: created.id, effectIndex: 0, effectType: "comment_on_issue", targetIssueId, status: "claimed" });
|
||||
const resumed = await service().decide({ id: created.id, optionId: "yes", decidedByUserId, userActor: boardActor() });
|
||||
expect(resumed.executionStatus).toBe("succeeded");
|
||||
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, targetIssueId))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("recovers stale running decisions from the bounded server sweep", async () => {
|
||||
process.env.PAPERCLIP_DECISIONS_RECOVERY_GRACE_MS = "0";
|
||||
const created = await createCommentDecision();
|
||||
await db.update(decisions).set({ status: "decided", executionStatus: "running", chosenOptionId: "yes", decidedByUserId,
|
||||
inputValues: {}, updatedAt: new Date(Date.now() - 1_000) }).where(eq(decisions.id, created.id));
|
||||
await db.insert(decisionEffectExecutions).values({ decisionId: created.id, effectIndex: 0, effectType: "comment_on_issue",
|
||||
targetIssueId, status: "claimed" });
|
||||
|
||||
expect(await service().sweepExpired()).toEqual({ expired: 0, resumed: 1 });
|
||||
expect((await service().get(created.id))?.executionStatus).toBe("succeeded");
|
||||
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, targetIssueId))).toHaveLength(1);
|
||||
expect(wakes).toEqual([{ companyId, agentId, issueId: originIssueId, decisionId: created.id, outcome: "decided" }]);
|
||||
});
|
||||
|
||||
it("retries a terminal continuation after a crash between effects and wake delivery", async () => {
|
||||
const created = await createCommentDecision();
|
||||
const crashingService = decisionService(db, { wakeOriginAgent: async () => {
|
||||
throw new Error("simulated post-execution crash");
|
||||
} });
|
||||
|
||||
await expect(crashingService.decide({ id: created.id, optionId: "yes", decidedByUserId, userActor: boardActor() }))
|
||||
.rejects.toThrow("simulated post-execution crash");
|
||||
expect((await service().get(created.id))?.executionStatus).toBe("succeeded");
|
||||
expect((await service().get(created.id))?.metadata).toMatchObject({ continuationPending: true });
|
||||
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, targetIssueId))).toHaveLength(1);
|
||||
|
||||
await service().sweepExpired();
|
||||
|
||||
expect(wakes).toEqual([{ companyId, agentId, issueId: originIssueId, decisionId: created.id, outcome: "decided" }]);
|
||||
expect((await service().get(created.id))?.metadata).toMatchObject({ continuationPending: false });
|
||||
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, targetIssueId))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("delivers the continuation when the origin agent cancels a decision", async () => {
|
||||
const created = await createCommentDecision();
|
||||
|
||||
const cancelled = await service().cancel(created.id, { actorType: "agent", actorId: agentId, runId });
|
||||
|
||||
expect(cancelled.status).toBe("cancelled");
|
||||
expect(cancelled.metadata).toMatchObject({ continuationPending: false });
|
||||
expect(wakes).toEqual([{ companyId, agentId, issueId: originIssueId, decisionId: created.id, outcome: "cancelled" }]);
|
||||
});
|
||||
|
||||
it("adds open decisions to the attention feed and badge count", async () => {
|
||||
const created = await createCommentDecision();
|
||||
const feed = await attentionService(db).list(companyId, { userId: decidedByUserId });
|
||||
expect(feed.countsBySourceKind.decision).toBe(1);
|
||||
expect(feed.items).toEqual(expect.arrayContaining([expect.objectContaining({
|
||||
sourceKind: "decision",
|
||||
subject: expect.objectContaining({ id: created.id, kind: "decision" }),
|
||||
})]));
|
||||
});
|
||||
|
||||
it("batches effect executions into terminal decision lists", async () => {
|
||||
const created = await createCommentDecision();
|
||||
await service().decide({ id: created.id, optionId: "yes", decidedByUserId, userActor: boardActor() });
|
||||
|
||||
const listed = await service().list(companyId, { status: "decided" });
|
||||
|
||||
expect(listed).toEqual(expect.arrayContaining([expect.objectContaining({
|
||||
id: created.id,
|
||||
executions: [expect.objectContaining({ effectIndex: 0, status: "executed" })],
|
||||
})]));
|
||||
});
|
||||
|
||||
it("bounds the open-decision slice of the attention feed", async () => {
|
||||
const older = await createCommentDecision("lenient", { idempotencyKey: "attention-older" });
|
||||
const newer = await createCommentDecision("lenient", { idempotencyKey: "attention-newer" });
|
||||
await db.update(decisions).set({ updatedAt: new Date(Date.now() - 1_000) }).where(eq(decisions.id, older.id));
|
||||
|
||||
const feed = await attentionService(db, { openDecisionLimit: 1 }).list(companyId, { userId: decidedByUserId });
|
||||
|
||||
expect(feed.countsBySourceKind.decision).toBe(1);
|
||||
expect(feed.items.filter((item) => item.sourceKind === "decision").map((item) => item.subject.id)).toEqual([newer.id]);
|
||||
});
|
||||
|
||||
it("loads target staleness in a bounded query for the open-decision list", async () => {
|
||||
const first = await createCommentDecision("lenient", { idempotencyKey: "list-query-1" });
|
||||
const second = await createCommentDecision("lenient", { idempotencyKey: "list-query-2" });
|
||||
await db.update(issues).set({ updatedAt: new Date(Date.now() + 1_000) }).where(eq(issues.id, targetIssueId));
|
||||
|
||||
const selectSpy = vi.spyOn(db, "select");
|
||||
try {
|
||||
const listed = await service().list(companyId, { status: "open" });
|
||||
expect(selectSpy).toHaveBeenCalledTimes(2);
|
||||
expect(listed.filter((decision) => decision.id === first.id || decision.id === second.id))
|
||||
.toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: first.id, targetChanged: { [targetIssueId]: true } }),
|
||||
expect.objectContaining({ id: second.id, targetChanged: { [targetIssueId]: true } }),
|
||||
]));
|
||||
} finally {
|
||||
selectSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("bounds expiration work to the configured batch size", async () => {
|
||||
process.env.PAPERCLIP_DECISIONS_SWEEP_BATCH_SIZE = "1";
|
||||
await createCommentDecision("lenient", { idempotencyKey: "batch-1", expiresAt: new Date(Date.now() + 5) });
|
||||
await createCommentDecision("lenient", { idempotencyKey: "batch-2", expiresAt: new Date(Date.now() + 5) });
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect((await service().sweepExpired()).expired).toBe(1);
|
||||
expect((await service().sweepExpired()).expired).toBe(1);
|
||||
});
|
||||
|
||||
it("falls back to the default sweep batch size for invalid configuration", async () => {
|
||||
process.env.PAPERCLIP_DECISIONS_SWEEP_BATCH_SIZE = "not-a-number";
|
||||
await createCommentDecision("lenient", { idempotencyKey: "invalid-batch-1", expiresAt: new Date(Date.now() + 5) });
|
||||
await createCommentDecision("lenient", { idempotencyKey: "invalid-batch-2", expiresAt: new Date(Date.now() + 5) });
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
await expect(service().sweepExpired()).resolves.toMatchObject({ expired: 2 });
|
||||
});
|
||||
|
||||
it("expires TTL and target-gone decisions and wakes the origin agent", async () => {
|
||||
const ttl = await createCommentDecision("lenient", { expiresAt: new Date(Date.now() + 5) });
|
||||
const gone = await createCommentDecision("strict", { idempotencyKey: "gone" });
|
||||
await db.update(issues).set({ status: "cancelled" }).where(eq(issues.id, targetIssueId));
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect((await service().sweepExpired()).expired).toBe(2);
|
||||
const rows = await db.select().from(decisions);
|
||||
expect(rows.find((row) => row.id === ttl.id)?.metadata).toMatchObject({ expiredReason: "ttl" });
|
||||
expect(rows.find((row) => row.id === gone.id)?.metadata).toMatchObject({ expiredReason: "target_gone" });
|
||||
expect(wakes).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("groups rule-key stats and separates explicit dismissals from expiry", async () => {
|
||||
const accepted = await service().create({
|
||||
companyId, actor: agentActor(), agentId, runId, ruleKey: "routing.assign", title: "Assign?", body: "Body",
|
||||
options: [{ id: "assign", label: "Assign", effects: [] }, { id: "skip", label: "Skip", effects: [] }],
|
||||
});
|
||||
const rejected = await service().create({
|
||||
companyId, actor: agentActor(), agentId, runId, ruleKey: "routing.assign", title: "Assign another?", body: "Body",
|
||||
options: [{ id: "assign", label: "Assign", effects: [] }, { id: "skip", label: "Skip", effects: [] }],
|
||||
});
|
||||
const acceptedAgain = await service().create({
|
||||
companyId, actor: agentActor(), agentId, runId, ruleKey: "routing.assign", title: "Assign again?", body: "Body",
|
||||
options: [{ id: "assign", label: "Assign", effects: [] }, { id: "skip", label: "Skip", effects: [] }],
|
||||
});
|
||||
await service().create({
|
||||
companyId, actor: agentActor(), agentId, runId, ruleKey: "cleanup.stale", title: "Clean up?", body: "Body",
|
||||
options: [{ id: "clean", label: "Clean", effects: [] }], expiresAt: new Date(Date.now() + 5),
|
||||
});
|
||||
await service().decide({ id: accepted.id, optionId: "assign", decidedByUserId, userActor: boardActor() });
|
||||
await service().decide({ id: acceptedAgain.id, optionId: "assign", decidedByUserId, userActor: boardActor() });
|
||||
await service().dismiss(rejected.id, decidedByUserId, boardActor(), "Not this time");
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
await service().sweepExpired();
|
||||
|
||||
const stats = await service().stats(companyId, { originAgentId: agentId });
|
||||
expect(stats.filters).toEqual({ originAgentId: agentId, since: null });
|
||||
expect(stats.totals).toEqual({ proposed: 4, accepted: 2, rejected: 1, expired: 1 });
|
||||
expect(stats.groups).toEqual([
|
||||
{ ruleKey: "cleanup.stale", proposed: 1, accepted: 0, rejected: 0, expired: 1, chosenOptions: [] },
|
||||
{ ruleKey: "routing.assign", proposed: 3, accepted: 2, rejected: 1, expired: 0,
|
||||
chosenOptions: [{ optionId: "assign", count: 2 }] },
|
||||
]);
|
||||
expect((await service().outcome(rejected.id)).metadata).toMatchObject({ dismissed: true, dismissReason: "Not this time" });
|
||||
expect(await db.select().from(activityLog).where(eq(activityLog.action, "decision.dismissed")))
|
||||
.toEqual([expect.objectContaining({ entityId: rejected.id, responsibleUserId: decidedByUserId })]);
|
||||
});
|
||||
|
||||
it("rejects a direct dismissal when the signed decision spec was tampered with", async () => {
|
||||
const created = await createCommentDecision();
|
||||
await db.update(decisions).set({ options: [{ id: "tampered", label: "Tampered", effects: [{
|
||||
type: "comment_on_issue", targetIssueId, staleness: "lenient", bodyMarkdown: "tampered",
|
||||
}] }] }).where(eq(decisions.id, created.id));
|
||||
|
||||
await expect(service().dismiss(created.id, decidedByUserId, boardActor(), "No"))
|
||||
.rejects.toThrow("Decision signature verification failed");
|
||||
expect((await service().get(created.id))?.status).toBe("open");
|
||||
expect(await db.select().from(activityLog).where(eq(activityLog.action, "decision.dismissed"))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("wakes the origin agent after a direct dismissal", async () => {
|
||||
const created = await createCommentDecision();
|
||||
const result = await service().dismiss(created.id, decidedByUserId, boardActor(), "No");
|
||||
|
||||
expect(result).toMatchObject({ status: "decided", chosenOptionId: "dismissed" });
|
||||
expect(wakes).toEqual([{ companyId, agentId, issueId: originIssueId, decisionId: created.id, outcome: "decided" }]);
|
||||
});
|
||||
});
|
||||
|
|
@ -26,6 +26,7 @@ const apiPrefixes: Record<string, string> = {
|
|||
"company-skill-policy.ts": "/api",
|
||||
"costs.ts": "/api",
|
||||
"dashboard.ts": "/api",
|
||||
"decisions.ts": "/api",
|
||||
"decision-training.ts": "/api",
|
||||
"environments.ts": "/api",
|
||||
"execution-workspaces.ts": "/api",
|
||||
|
|
|
|||
|
|
@ -204,6 +204,9 @@ vi.mock("../services/index.js", () => ({
|
|||
agentMembershipsInserted: 0,
|
||||
humanGrantsInserted: 0,
|
||||
})),
|
||||
decisionService: vi.fn(() => ({
|
||||
sweepExpired: vi.fn(async () => ({ expired: 0 })),
|
||||
})),
|
||||
feedbackService: feedbackServiceFactoryMock,
|
||||
bootstrapExecutionPolicyFromEnv: vi.fn(async () => null),
|
||||
applyManagedEnvironments: vi.fn(async () => null),
|
||||
|
|
@ -283,6 +286,8 @@ import { startServer } from "../index.ts";
|
|||
describe("startServer feedback export wiring", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.PAPERCLIP_DECISION_SIGNING_SECRET = "fedcba9876543210fedcba9876543210";
|
||||
process.env.PAPERCLIP_AGENT_JWT_SECRET = "0123456789abcdef0123456789abcdef";
|
||||
loadConfigMock.mockReturnValue(buildTestConfig());
|
||||
resolveHeartbeatSchedulingSuppressionMock.mockReturnValue({
|
||||
suppressed: false,
|
||||
|
|
@ -293,6 +298,12 @@ describe("startServer feedback export wiring", () => {
|
|||
process.env.BETTER_AUTH_SECRET = "test-secret";
|
||||
});
|
||||
|
||||
it("refuses startup when the decision signing secret is unavailable", async () => {
|
||||
delete process.env.PAPERCLIP_DECISION_SIGNING_SECRET;
|
||||
await expect(startServer()).rejects.toThrow("PAPERCLIP_DECISION_SIGNING_SECRET is required");
|
||||
expect(loadConfigMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes the feedback export service into createApp so pending traces flush in runtime", async () => {
|
||||
const started = await startServer();
|
||||
|
||||
|
|
@ -392,6 +403,7 @@ describe("startServer feedback export wiring", () => {
|
|||
describe("startServer authenticated auth origin setup", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.PAPERCLIP_DECISION_SIGNING_SECRET = "fedcba9876543210fedcba9876543210";
|
||||
loadConfigMock.mockReturnValue(buildTestConfig());
|
||||
createBetterAuthInstanceMock.mockReturnValue({});
|
||||
deriveAuthTrustedOriginsMock.mockReturnValue([]);
|
||||
|
|
@ -438,6 +450,7 @@ describe("startServer authenticated auth origin setup", () => {
|
|||
describe("startServer PAPERCLIP_API_URL handling", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.PAPERCLIP_DECISION_SIGNING_SECRET = "fedcba9876543210fedcba9876543210";
|
||||
loadConfigMock.mockReturnValue(buildTestConfig());
|
||||
process.env.BETTER_AUTH_SECRET = "test-secret";
|
||||
delete process.env.PAPERCLIP_API_URL;
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ import { activityRoutes } from "./routes/activity.js";
|
|||
import { dashboardRoutes } from "./routes/dashboard.js";
|
||||
import { attentionRoutes } from "./routes/attention.js";
|
||||
import { decisionTrainingRoutes } from "./routes/decision-training.js";
|
||||
import { decisionRoutes } from "./routes/decisions.js";
|
||||
import type { DecisionServiceOptions } from "./services/decisions.js";
|
||||
import { userProfileRoutes } from "./routes/user-profiles.js";
|
||||
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
|
||||
import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js";
|
||||
|
|
@ -256,6 +258,7 @@ export async function createApp(
|
|||
localPluginDir?: string;
|
||||
pluginMigrationDb?: Db;
|
||||
pluginWorkerManager?: PluginWorkerManager;
|
||||
decisionServiceOptions: DecisionServiceOptions;
|
||||
betterAuthHandler?: express.RequestHandler;
|
||||
resolveSession?: (req: ExpressRequest) => Promise<BetterAuthSessionResult | null>;
|
||||
/**
|
||||
|
|
@ -403,6 +406,7 @@ export async function createApp(
|
|||
api.use(dashboardRoutes(db));
|
||||
api.use(attentionRoutes(db));
|
||||
api.use(decisionTrainingRoutes(db));
|
||||
api.use(decisionRoutes(db, opts.decisionServiceOptions));
|
||||
api.use(userProfileRoutes(db));
|
||||
api.use(sidebarBadgeRoutes(db));
|
||||
api.use(sidebarPreferenceRoutes(db));
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import {
|
|||
backfillLegacyToolOAuthTokens,
|
||||
bootstrapExecutionPolicyFromEnv,
|
||||
environmentCustomImageService,
|
||||
decisionService,
|
||||
heartbeatService,
|
||||
issueService,
|
||||
instanceSettingsService,
|
||||
|
|
@ -71,6 +72,8 @@ import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-
|
|||
import { maybePersistWorktreeRuntimePorts } from "./worktree-config.js";
|
||||
import { initTelemetry, getTelemetryClient } from "./telemetry.js";
|
||||
import { conflict } from "./errors.js";
|
||||
import { validateDecisionSigningSecret } from "./services/decision-signing.js";
|
||||
import { createDecisionWakeOriginAgent } from "./services/decision-wakeup.js";
|
||||
import { coordinateHeartbeatSchedulerShutdown } from "./shutdown.js";
|
||||
import { systemdNotify } from "./services/systemd-notify.js";
|
||||
import { flushInFlightRunLogMirrors } from "./services/run-log-store.js";
|
||||
|
|
@ -120,6 +123,7 @@ export async function startServer(): Promise<StartedServer> {
|
|||
// Tracing must be active (or have failed and logged) before the first DB
|
||||
// connection or the HTTP server exists — see instrumentation.ts.
|
||||
await instrumentationReady;
|
||||
validateDecisionSigningSecret();
|
||||
let config = loadConfig();
|
||||
initTelemetry({ enabled: config.telemetryEnabled });
|
||||
if (process.env.PAPERCLIP_SECRETS_PROVIDER === undefined) {
|
||||
|
|
@ -700,6 +704,12 @@ export async function startServer(): Promise<StartedServer> {
|
|||
}
|
||||
};
|
||||
const pluginWorkerManager = createPluginWorkerManager();
|
||||
const heartbeat = config.heartbeatSchedulerEnabled
|
||||
? heartbeatService(db as any, { pluginWorkerManager })
|
||||
: null;
|
||||
const decisionServiceOptions = {
|
||||
wakeOriginAgent: createDecisionWakeOriginAgent(heartbeat?.wakeup ?? null),
|
||||
};
|
||||
// Managed instances drive bundled plugin auto-install from the managed-config
|
||||
// document parsed fail-closed above (`plugins.autoInstall`). Absent env means
|
||||
// self-hosted: createApp falls back to its built-in kubernetes-only default.
|
||||
|
|
@ -737,6 +747,7 @@ export async function startServer(): Promise<StartedServer> {
|
|||
betterAuthHandler,
|
||||
resolveSession,
|
||||
pluginWorkerManager,
|
||||
decisionServiceOptions,
|
||||
managedPluginAutoInstall,
|
||||
});
|
||||
const server = createServer(app as unknown as Parameters<typeof createServer>[0]);
|
||||
|
|
@ -900,8 +911,8 @@ export async function startServer(): Promise<StartedServer> {
|
|||
}
|
||||
};
|
||||
|
||||
if (config.heartbeatSchedulerEnabled) {
|
||||
const heartbeat = heartbeatService(db as any, { pluginWorkerManager });
|
||||
if (heartbeat) {
|
||||
const decisionExecutor = decisionService(db as any, decisionServiceOptions);
|
||||
drainHeartbeatRunsForShutdown = heartbeat.drainRunningRunsForShutdown;
|
||||
prepareHotRestartShutdown = heartbeat.prepareHotRestartShutdown;
|
||||
const environmentCustomImages = environmentCustomImageService(db as any, { pluginWorkerManager });
|
||||
|
|
@ -1034,6 +1045,7 @@ export async function startServer(): Promise<StartedServer> {
|
|||
if (toolHealthSweep.failed > 0) {
|
||||
logger.warn({ ...toolHealthSweep }, "startup tool connection health sweep found failing connections");
|
||||
}
|
||||
await decisionExecutor.sweepExpired();
|
||||
|
||||
heartbeatSchedulerInterval = setInterval(() => {
|
||||
// Async so the suppression checks below can honor the override-aware
|
||||
|
|
@ -1041,6 +1053,9 @@ export async function startServer(): Promise<StartedServer> {
|
|||
// wrapped in trackHeartbeatSchedulerWork with its own error handling.
|
||||
void (async () => {
|
||||
if (heartbeatSchedulerStopped) return;
|
||||
trackHeartbeatSchedulerWork(decisionExecutor.sweepExpired().catch((err: unknown) => {
|
||||
logger.error({ err }, "decision expiry sweep failed");
|
||||
}));
|
||||
const sweptRuntimeStatuses = heartbeat.sweepExpiredRuntimeStatuses();
|
||||
if (sweptRuntimeStatuses > 0) {
|
||||
logger.info(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { decisionInputsSchema, decisionOptionsSchema } from "@paperclipai/shared";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { decisionService, type DecisionServiceOptions } from "../services/decisions.js";
|
||||
import { assertBoard, assertBoardOrAgent, assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.js";
|
||||
|
||||
const createSchema = z.object({
|
||||
title: z.string().trim().min(1).max(500),
|
||||
body: z.string().max(100_000),
|
||||
ruleKey: z.string().trim().max(240).nullable().optional(),
|
||||
options: decisionOptionsSchema,
|
||||
inputs: decisionInputsSchema.nullable().optional(),
|
||||
expiresAt: z.coerce.date().optional(),
|
||||
idempotencyKey: z.string().trim().min(1).max(500).nullable().optional(),
|
||||
continuationPolicy: z.enum(["none", "wake_origin_agent"]).optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
}).strict();
|
||||
const bundleSchema = z.object({ title: z.string().trim().min(1).max(500), summary: z.string().max(100_000), decisions: z.array(createSchema).min(1).max(50) }).strict();
|
||||
const decideSchema = z.object({ optionId: z.string().trim().min(1).max(120), inputValues: z.record(z.string().max(20_000)).optional(), idempotencyKey: z.string().trim().min(1).max(500).nullable().optional() }).strict();
|
||||
const dismissSchema = z.object({ reason: z.string().max(20_000).nullable().optional() }).strict();
|
||||
const statsQuerySchema = z.object({
|
||||
groupBy: z.literal("ruleKey"),
|
||||
originAgentId: z.string().uuid().optional(),
|
||||
since: z.coerce.date().optional(),
|
||||
}).strict();
|
||||
|
||||
function agentContext(req: Parameters<typeof getActorInfo>[0]) {
|
||||
if (req.actor.type !== "agent" || !req.actor.agentId || !req.actor.runId) return null;
|
||||
return { agentId: req.actor.agentId, runId: req.actor.runId };
|
||||
}
|
||||
|
||||
function boardUserId(req: Parameters<typeof getActorInfo>[0]) {
|
||||
assertBoard(req);
|
||||
return req.actor.userId ?? "local-implicit-board";
|
||||
}
|
||||
|
||||
export function decisionRoutes(db: Db, options: DecisionServiceOptions) {
|
||||
const router = Router();
|
||||
const svc = decisionService(db, options);
|
||||
router.post("/companies/:companyId/decisions", validate(createSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId);
|
||||
const agent = agentContext(req); if (!agent) { res.status(403).json({ error: "Agent run context required" }); return; }
|
||||
res.status(201).json(await svc.create({ companyId, actor: req.actor, ...agent, ...req.body }));
|
||||
});
|
||||
router.post("/companies/:companyId/decision-bundles", validate(bundleSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId);
|
||||
const agent = agentContext(req); if (!agent) { res.status(403).json({ error: "Agent run context required" }); return; }
|
||||
res.status(201).json(await svc.createBundle({ companyId, actor: req.actor, ...agent, ...req.body }));
|
||||
});
|
||||
router.get("/companies/:companyId/decisions", async (req, res) => {
|
||||
const companyId = req.params.companyId as string; assertBoard(req); assertCompanyAccess(req, companyId);
|
||||
const query = z.object({ status: z.enum(["open", "decided", "expired", "cancelled"]).optional(), bundleId: z.string().uuid().optional(), targetIssueId: z.string().uuid().optional(), originAgentId: z.string().uuid().optional(), limit: z.coerce.number().int().positive().max(100).optional() }).safeParse(req.query);
|
||||
if (!query.success) { res.status(400).json({ error: "Invalid decision filters", details: query.error.flatten() }); return; }
|
||||
res.json(await svc.list(companyId, query.data));
|
||||
});
|
||||
/**
|
||||
* Gardener telemetry contract:
|
||||
* { groupBy: "ruleKey", filters: { originAgentId: string|null, since: ISO-8601|null },
|
||||
* totals: { proposed, accepted, rejected, expired },
|
||||
* groups: [{ ruleKey: string|null, proposed, accepted, rejected, expired,
|
||||
* chosenOptions: [{ optionId, count }] }] }
|
||||
* Accepted means a non-dismissed decided outcome; rejected means an explicit dismiss;
|
||||
* chosenOptions counts accepted outcomes only; expired is separate, and cancelled
|
||||
* decisions contribute only to proposed.
|
||||
*/
|
||||
router.get("/companies/:companyId/decisions/stats", async (req, res) => {
|
||||
const companyId = req.params.companyId as string; assertBoardOrAgent(req); assertCompanyAccess(req, companyId);
|
||||
const query = statsQuerySchema.safeParse(req.query);
|
||||
if (!query.success) { res.status(400).json({ error: "Invalid decision stats filters", details: query.error.flatten() }); return; }
|
||||
if (req.actor.type === "agent" && query.data.originAgentId && query.data.originAgentId !== req.actor.agentId) {
|
||||
res.status(403).json({ error: "Agents may only read their own decision stats" }); return;
|
||||
}
|
||||
const originAgentId = req.actor.type === "agent" ? req.actor.agentId : query.data.originAgentId;
|
||||
res.json(await svc.stats(companyId, { originAgentId, since: query.data.since }));
|
||||
});
|
||||
router.get("/decisions/:id", async (req, res) => {
|
||||
assertBoardOrAgent(req);
|
||||
const decision = await getAccessibleResource(req, res, svc.get(req.params.id as string), "Decision not found");
|
||||
if (!decision) return;
|
||||
if (req.actor.type === "agent" && req.actor.agentId !== decision.originAgentId) { res.status(403).json({ error: "Only the origin agent may read this decision" }); return; }
|
||||
res.json(await svc.outcome(decision.id));
|
||||
});
|
||||
router.post("/decisions/:id/decide", validate(decideSchema), async (req, res) => {
|
||||
const userId = boardUserId(req);
|
||||
const decision = await getAccessibleResource(req, res, svc.get(req.params.id as string), "Decision not found");
|
||||
if (!decision) return;
|
||||
res.json(await svc.decide({ id: decision.id, decidedByUserId: userId, userActor: req.actor, ...req.body }));
|
||||
});
|
||||
router.post("/decisions/:id/dismiss", validate(dismissSchema), async (req, res) => {
|
||||
const userId = boardUserId(req);
|
||||
const decision = await getAccessibleResource(req, res, svc.get(req.params.id as string), "Decision not found");
|
||||
if (!decision) return;
|
||||
res.json(await svc.dismiss(decision.id, userId, req.actor, req.body.reason));
|
||||
});
|
||||
router.post("/decisions/:id/cancel", async (req, res) => {
|
||||
assertBoardOrAgent(req);
|
||||
const decision = await getAccessibleResource(req, res, svc.get(req.params.id as string), "Decision not found");
|
||||
if (!decision) return;
|
||||
const actor = getActorInfo(req); res.json(await svc.cancel(decision.id, { actorType: actor.actorType, actorId: actor.actorId, runId: actor.runId }));
|
||||
});
|
||||
return router;
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ export { costRoutes } from "./costs.js";
|
|||
export { activityRoutes } from "./activity.js";
|
||||
export { dashboardRoutes } from "./dashboard.js";
|
||||
export { attentionRoutes } from "./attention.js";
|
||||
export { decisionRoutes } from "./decisions.js";
|
||||
export { sidebarBadgeRoutes } from "./sidebar-badges.js";
|
||||
export { sidebarPreferenceRoutes } from "./sidebar-preferences.js";
|
||||
export { resourceMembershipRoutes } from "./resource-memberships.js";
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@ import {
|
|||
updateCompanyBrandingSchema,
|
||||
companyArtifactsQuerySchema,
|
||||
companyArtifactsResponseSchema,
|
||||
// Decisions
|
||||
decisionInputsSchema,
|
||||
decisionOptionsSchema,
|
||||
// Routine
|
||||
createRoutineSchema,
|
||||
updateRoutineSchema,
|
||||
|
|
@ -3272,6 +3275,108 @@ registry.registerPath({
|
|||
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden },
|
||||
});
|
||||
|
||||
// ─── Decisions ──────────────────────────────────────────────────────────────
|
||||
|
||||
const createDecisionBodySchema = z.object({
|
||||
title: z.string().trim().min(1).max(500),
|
||||
body: z.string().max(100_000),
|
||||
ruleKey: z.string().trim().max(240).nullable().optional(),
|
||||
options: decisionOptionsSchema,
|
||||
inputs: decisionInputsSchema.nullable().optional(),
|
||||
expiresAt: z.string().datetime().optional(),
|
||||
idempotencyKey: z.string().trim().min(1).max(500).nullable().optional(),
|
||||
continuationPolicy: z.enum(["none", "wake_origin_agent"]).optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
}).strict();
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "post",
|
||||
path: "/api/companies/{companyId}/decisions",
|
||||
tags: ["decisions"],
|
||||
summary: "Propose a decision",
|
||||
body: createDecisionBodySchema,
|
||||
responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 409: r.conflict },
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "post",
|
||||
path: "/api/companies/{companyId}/decision-bundles",
|
||||
tags: ["decisions"],
|
||||
summary: "Propose a decision bundle",
|
||||
body: z.object({
|
||||
title: z.string().trim().min(1).max(500),
|
||||
summary: z.string().max(100_000),
|
||||
decisions: z.array(createDecisionBodySchema).min(1).max(50),
|
||||
}).strict(),
|
||||
responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 409: r.conflict },
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "get",
|
||||
path: "/api/companies/{companyId}/decisions",
|
||||
tags: ["decisions"],
|
||||
summary: "List decisions",
|
||||
query: z.object({
|
||||
status: z.enum(["open", "decided", "expired", "cancelled"]).optional(),
|
||||
bundleId: z.string().uuid().optional(),
|
||||
targetIssueId: z.string().uuid().optional(),
|
||||
originAgentId: z.string().uuid().optional(),
|
||||
limit: z.coerce.number().int().positive().max(100).optional(),
|
||||
}),
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden },
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "get",
|
||||
path: "/api/companies/{companyId}/decisions/stats",
|
||||
tags: ["decisions"],
|
||||
summary: "Get decision telemetry grouped by rule key",
|
||||
query: z.object({
|
||||
groupBy: z.literal("ruleKey"),
|
||||
originAgentId: z.string().uuid().optional(),
|
||||
since: z.string().datetime().optional(),
|
||||
}),
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden },
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "get",
|
||||
path: "/api/decisions/{id}",
|
||||
tags: ["decisions"],
|
||||
summary: "Get a decision outcome",
|
||||
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "post",
|
||||
path: "/api/decisions/{id}/decide",
|
||||
tags: ["decisions"],
|
||||
summary: "Resolve a decision",
|
||||
body: z.object({
|
||||
optionId: z.string().trim().min(1).max(120),
|
||||
inputValues: z.record(z.string(), z.string().max(20_000)).optional(),
|
||||
idempotencyKey: z.string().trim().min(1).max(500).nullable().optional(),
|
||||
}).strict(),
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict },
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "post",
|
||||
path: "/api/decisions/{id}/dismiss",
|
||||
tags: ["decisions"],
|
||||
summary: "Dismiss a decision",
|
||||
body: z.object({ reason: z.string().max(20_000).nullable().optional() }).strict(),
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict },
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "post",
|
||||
path: "/api/decisions/{id}/cancel",
|
||||
tags: ["decisions"],
|
||||
summary: "Cancel a decision",
|
||||
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict },
|
||||
});
|
||||
|
||||
// ─── Decision training ──────────────────────────────────────────────────────
|
||||
|
||||
const decisionTrainingSourceKindSchema = z.enum(["interaction", "approval", "execution_decision"]);
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ export interface LogActivityInput {
|
|||
agentApiKeyId?: string | null;
|
||||
issueId?: string | null;
|
||||
details?: Record<string, unknown> | null;
|
||||
responsibleUserIdOverride?: string | null;
|
||||
}
|
||||
|
||||
export async function createActivityDetailsRedactor(db: Db) {
|
||||
|
|
@ -84,6 +85,9 @@ function readNonEmptyString(value: unknown) {
|
|||
}
|
||||
|
||||
export async function resolveResponsibleUserIdForActivity(db: Db, input: LogActivityInput) {
|
||||
if (input.responsibleUserIdOverride !== undefined) {
|
||||
return readNonEmptyString(input.responsibleUserIdOverride);
|
||||
}
|
||||
if (input.actorType === "user") return readNonEmptyString(input.actorId);
|
||||
|
||||
const runId = readNonEmptyString(input.runId);
|
||||
|
|
@ -141,7 +145,7 @@ export async function resolveResponsibleUserIdForActivity(db: Db, input: LogActi
|
|||
export async function logActivity(db: Db, input: LogActivityInput) {
|
||||
const redactedDetails = await redactActivityDetails(db, input.details ?? null);
|
||||
const responsibleUserId = await resolveResponsibleUserIdForActivity(db, input);
|
||||
await db.insert(activityLog).values({
|
||||
const [activity] = await db.insert(activityLog).values({
|
||||
companyId: input.companyId,
|
||||
actorType: input.actorType,
|
||||
actorId: input.actorId,
|
||||
|
|
@ -152,7 +156,7 @@ export async function logActivity(db: Db, input: LogActivityInput) {
|
|||
runId: input.runId ?? null,
|
||||
responsibleUserId,
|
||||
details: redactedDetails,
|
||||
});
|
||||
}).returning({ id: activityLog.id });
|
||||
|
||||
publishLiveEvent({
|
||||
companyId: input.companyId,
|
||||
|
|
@ -190,4 +194,6 @@ export async function logActivity(db: Db, input: LogActivityInput) {
|
|||
};
|
||||
publishPluginDomainEvent(event);
|
||||
}
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import {
|
|||
approvals,
|
||||
assets,
|
||||
companies,
|
||||
decisionBundles,
|
||||
decisionTrainingExamples,
|
||||
decisions,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
inboxDismissals,
|
||||
|
|
@ -43,6 +45,7 @@ import { isProspectiveBlockedTransition } from "./routable-blocked.js";
|
|||
|
||||
const ATTENTION_SOURCE_KINDS: AttentionSourceKind[] = [
|
||||
"approval",
|
||||
"decision",
|
||||
"issue_thread_interaction",
|
||||
"join_request",
|
||||
"recovery_action",
|
||||
|
|
@ -68,10 +71,11 @@ const SOURCE_RANK: Record<AttentionSourceKind, number> = {
|
|||
budget_alert: 3,
|
||||
agent_error_alert: 4,
|
||||
approval: 5,
|
||||
issue_thread_interaction: 6,
|
||||
review: 7,
|
||||
productivity_review: 8,
|
||||
join_request: 9,
|
||||
decision: 6,
|
||||
issue_thread_interaction: 7,
|
||||
review: 8,
|
||||
productivity_review: 9,
|
||||
join_request: 10,
|
||||
};
|
||||
|
||||
const PENDING_INTERACTION_STATUSES = ["pending"] as const;
|
||||
|
|
@ -81,6 +85,8 @@ const PRODUCTIVITY_REVIEW_TERMINAL_STATUSES = ["done", "cancelled"] as const;
|
|||
const FAILED_RUN_STATUSES = ["failed", "timed_out"] as const;
|
||||
const DETAIL_EXCERPT_LENGTH = 160;
|
||||
const DETAIL_IMAGE_LIMIT = 3;
|
||||
const OPEN_DECISION_DEFAULT_LIMIT = 500;
|
||||
const OPEN_DECISION_MAX_LIMIT = 1_000;
|
||||
|
||||
type IssueSummaryRow = {
|
||||
id: string;
|
||||
|
|
@ -121,6 +127,10 @@ type AttentionListOptions = {
|
|||
includeDismissed?: boolean;
|
||||
};
|
||||
|
||||
type AttentionServiceOptions = {
|
||||
openDecisionLimit?: number;
|
||||
};
|
||||
|
||||
function emptyCounts(): Record<AttentionSourceKind, number> {
|
||||
return Object.fromEntries(ATTENTION_SOURCE_KINDS.map((kind) => [kind, 0])) as Record<AttentionSourceKind, number>;
|
||||
}
|
||||
|
|
@ -606,7 +616,11 @@ function readRunIssueId(contextSnapshot: Record<string, unknown> | null) {
|
|||
return typeof issueId === "string" && issueId.length > 0 ? issueId : null;
|
||||
}
|
||||
|
||||
export function attentionService(db: Db) {
|
||||
export function attentionService(db: Db, options: AttentionServiceOptions = {}) {
|
||||
const openDecisionLimit = Math.min(
|
||||
Math.max(Math.trunc(options.openDecisionLimit ?? OPEN_DECISION_DEFAULT_LIMIT), 1),
|
||||
OPEN_DECISION_MAX_LIMIT,
|
||||
);
|
||||
return {
|
||||
list: async (companyId: string, options: AttentionListOptions = {}): Promise<AttentionFeed> => {
|
||||
const prefix = await companyPrefix(db, companyId);
|
||||
|
|
@ -760,6 +774,54 @@ export function attentionService(db: Db) {
|
|||
}));
|
||||
}
|
||||
|
||||
const openDecisions = await db.select({
|
||||
id: decisions.id,
|
||||
bundleId: decisions.bundleId,
|
||||
originAgentId: decisions.originAgentId,
|
||||
title: decisions.title,
|
||||
body: decisions.body,
|
||||
status: decisions.status,
|
||||
originIssueId: decisions.originIssueId,
|
||||
createdAt: decisions.createdAt,
|
||||
updatedAt: decisions.updatedAt,
|
||||
}).from(decisions).where(and(eq(decisions.companyId, companyId), eq(decisions.status, "open")))
|
||||
.orderBy(desc(decisions.updatedAt), desc(decisions.id))
|
||||
.limit(openDecisionLimit);
|
||||
const decisionIssueMap = await issueSummaryMap(db, companyId, openDecisions.map((decision) => decision.originIssueId));
|
||||
// Bundle titles let the feed render a single "Agent proposed N decisions"
|
||||
// group header over sibling decisions (v1 still decides each independently).
|
||||
const bundleIds = [...new Set(openDecisions.map((decision) => decision.bundleId).filter((value): value is string => Boolean(value)))];
|
||||
const bundleTitleMap = new Map<string, string>();
|
||||
if (bundleIds.length > 0) {
|
||||
const bundleRows = await db.select({ id: decisionBundles.id, title: decisionBundles.title })
|
||||
.from(decisionBundles).where(and(eq(decisionBundles.companyId, companyId), inArray(decisionBundles.id, bundleIds)));
|
||||
for (const row of bundleRows) bundleTitleMap.set(row.id, row.title);
|
||||
}
|
||||
for (const decision of openDecisions) {
|
||||
const issue = decisionIssueMap.get(decision.originIssueId) ?? null;
|
||||
add(createItem({
|
||||
companyId,
|
||||
sourceKind: "decision",
|
||||
subject: { kind: "decision", id: decision.id, companyId, title: decision.title, identifier: null, status: decision.status,
|
||||
href: `/${prefix}/decisions?decisionId=${decision.id}`,
|
||||
metadata: { originIssueId: decision.originIssueId, originAgentId: decision.originAgentId, bundleId: decision.bundleId,
|
||||
bundleTitle: decision.bundleId ? bundleTitleMap.get(decision.bundleId) ?? null : null } },
|
||||
whyNow: "An agent decision is waiting for a board response.",
|
||||
decisionVerbs: decisionVerbs({ id: "decide", label: "Review", description: "Review and choose an option." }),
|
||||
inlineResolvable: true,
|
||||
entryRule: "decisions.status = 'open'",
|
||||
exitRule: "Decision is decided, expired, or cancelled.",
|
||||
dedupKey: `decision:${decision.id}`,
|
||||
severity: "medium",
|
||||
activityAt: toIso(decision.updatedAt),
|
||||
createdAt: toIso(decision.createdAt),
|
||||
updatedAt: toIso(decision.updatedAt),
|
||||
relatedIssue: issue ? issueSubject(prefix, issue) : null,
|
||||
...issueContext(issue),
|
||||
detail: { kind: "generic", summaryExcerpt: decision.body.slice(0, DETAIL_EXCERPT_LENGTH), images: [] },
|
||||
}));
|
||||
}
|
||||
|
||||
const pendingJoins = await db
|
||||
.select({
|
||||
id: joinRequests.id,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
const VERSION = "decision-spec-v1";
|
||||
|
||||
export function validateDecisionSigningSecret() {
|
||||
const value = process.env.PAPERCLIP_DECISION_SIGNING_SECRET?.trim();
|
||||
if (!value || value.length < 32) throw new Error("PAPERCLIP_DECISION_SIGNING_SECRET is required and must be at least 32 characters");
|
||||
return value;
|
||||
}
|
||||
|
||||
function canonical(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
||||
if (value && typeof value === "object") {
|
||||
return `{${Object.entries(value as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function signDecisionSpec(value: unknown) {
|
||||
return `${VERSION}.${createHmac("sha256", validateDecisionSigningSecret()).update(`${VERSION}:${canonical(value)}`).digest("hex")}`;
|
||||
}
|
||||
|
||||
export function verifyDecisionSpec(value: unknown, signature: string) {
|
||||
const expected = Buffer.from(signDecisionSpec(value));
|
||||
const actual = Buffer.from(signature);
|
||||
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import type { DecisionServiceOptions } from "./decisions.js";
|
||||
|
||||
type HeartbeatWakeup = (
|
||||
agentId: string,
|
||||
options: {
|
||||
source: "automation";
|
||||
triggerDetail: "system";
|
||||
reason: string;
|
||||
payload: Record<string, unknown>;
|
||||
},
|
||||
) => Promise<unknown>;
|
||||
|
||||
/**
|
||||
* Connect decision continuations to the heartbeat runtime only while that
|
||||
* runtime is enabled. A disabled scheduler must not accept wakeups that it
|
||||
* cannot own for the rest of the process lifetime.
|
||||
*/
|
||||
export function createDecisionWakeOriginAgent(
|
||||
wakeup: HeartbeatWakeup | null,
|
||||
): DecisionServiceOptions["wakeOriginAgent"] {
|
||||
if (!wakeup) return async () => null;
|
||||
return async (input) => wakeup(input.agentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: `decision_${input.outcome}`,
|
||||
payload: {
|
||||
issueId: input.issueId,
|
||||
decisionId: input.decisionId,
|
||||
outcome: input.outcome,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,614 @@
|
|||
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 };
|
||||
}
|
||||
|
|
@ -65,6 +65,7 @@ export { activityService, type ActivityFilters } from "./activity.js";
|
|||
export { workTimelineService, normalizeTimelineWindow } from "./work-timeline.js";
|
||||
export { attentionService } from "./attention.js";
|
||||
export { captureDecisionSnapshot, decisionTrainingService } from "./decision-training.js";
|
||||
export { decisionService } from "./decisions.js";
|
||||
export type {
|
||||
WorkTimelineActor,
|
||||
WorkTimelineEdge,
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@ POST /api/companies/{companyId}/approvals
|
|||
|
||||
Issue-thread interactions are first-class cards that render in the issue thread and capture a typed board/user response. Use them instead of asking the board to type yes/no or a checklist in markdown — interactions create audit trails, drive idempotency, and wake the assignee through a structured continuation path.
|
||||
|
||||
Five kinds are supported. Pick the smallest kind that fits the decision shape:
|
||||
Five issue-thread interaction kinds are supported. Pick the smallest kind that fits the decision shape:
|
||||
|
||||
| Kind | When to use | When **not** to use |
|
||||
| ------------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
|
|
@ -242,6 +242,9 @@ Five kinds are supported. Pick the smallest kind that fits the decision shape:
|
|||
| `request_item_verdicts` | Board must approve/reject/defer individual known items, potentially over multiple submits. | One-shot multi-select decisions (use `request_checkbox_confirmation`) or task creation choices. |
|
||||
| `ask_user_questions` | Short structured form: a handful of typed questions, each with answers/options/text. | Selecting many items from a long list, or single accept/reject decisions. |
|
||||
| `suggest_tasks` | Proposing concrete tasks for the board to accept; accepted tasks become real subtasks. | Asking the board to confirm a plan or arbitrary selection. Tasks are the unit; not arbitrary ids. |
|
||||
| `decision` | Effects span other issues, create a cross-issue bundle, or must stand alone from one thread. | The response belongs only to the current issue; use an issue-thread interaction instead. |
|
||||
|
||||
Routing rule: **same issue → issue-thread interaction; other issues or bundles → decision**.
|
||||
|
||||
Key shared semantics:
|
||||
|
||||
|
|
@ -252,6 +255,70 @@ Key shared semantics:
|
|||
- **Idempotency.** Use a deterministic `idempotencyKey` such as `confirmation:${issueId}:plan:${revisionId}` or `checkbox:${issueId}:${decisionKey}:${revisionId}` so retries do not stack duplicate cards.
|
||||
- **Source issue posture.** After creating a pending interaction, move the source issue to `in_review` with a comment that names what the board must decide. The pending interaction is the explicit waiting path.
|
||||
|
||||
### Standalone Decisions
|
||||
|
||||
Create a decision from an issue-scoped agent run with `POST /api/companies/{companyId}/decisions`:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Reassign the blocked launch issue?",
|
||||
"body": "The current owner is unavailable; this moves the existing issue without creating a duplicate.",
|
||||
"ruleKey": "routing.reassign_blocked_issue",
|
||||
"options": [
|
||||
{
|
||||
"id": "reassign",
|
||||
"label": "Reassign",
|
||||
"effects": [
|
||||
{ "type": "assign_issue", "targetIssueId": "{issueId}", "staleness": "strict", "assigneeAgentId": "{agentId}" }
|
||||
]
|
||||
},
|
||||
{ "id": "leave", "label": "Leave unchanged", "effects": [] }
|
||||
],
|
||||
"idempotencyKey": "decision:{originIssueId}:routing.reassign_blocked_issue:v1",
|
||||
"continuationPolicy": "wake_origin_agent"
|
||||
}
|
||||
```
|
||||
|
||||
- `options` accepts 1–8 options; option ids are unique and each option accepts up to 10 effects.
|
||||
- Supported effects are `comment_on_issue`, `create_issue`, `update_issue_status`, `assign_issue`, `cancel_issue_tree`, and `resolve_blocker`.
|
||||
- `expiresAt` is optional, defaults to seven days, and must be no more than 30 days away.
|
||||
- `idempotencyKey` is optional but strongly recommended; reuse is safe only with the same payload.
|
||||
- `continuationPolicy` is `none` or `wake_origin_agent`. Use the latter only when resolution or expiry must resume the proposer.
|
||||
- Each origin agent may have at most 50 open decisions by default.
|
||||
|
||||
Bundle related cross-issue decisions with `POST /api/companies/{companyId}/decision-bundles`:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Launch recovery choices",
|
||||
"summary": "Independent choices for ownership and blocker cleanup.",
|
||||
"decisions": [
|
||||
{
|
||||
"title": "Reassign owner?",
|
||||
"body": "Move the issue to the recovery owner.",
|
||||
"ruleKey": "routing.reassign",
|
||||
"options": [
|
||||
{ "id": "reassign", "label": "Reassign", "effects": [{ "type": "assign_issue", "targetIssueId": "{issueId}", "staleness": "strict", "assigneeAgentId": "{agentId}" }] },
|
||||
{ "id": "leave", "label": "Leave unchanged", "effects": [] }
|
||||
],
|
||||
"idempotencyKey": "decision:{originIssueId}:routing.reassign:v1"
|
||||
},
|
||||
{
|
||||
"title": "Clear obsolete blocker?",
|
||||
"body": "Remove the resolved dependency from the blocked issue.",
|
||||
"ruleKey": "blockers.clear_obsolete",
|
||||
"options": [
|
||||
{ "id": "clear", "label": "Clear blocker", "effects": [{ "type": "resolve_blocker", "targetIssueId": "{issueId}", "staleness": "strict", "removeBlockedByIssueIds": ["{blockerIssueId}"] }] },
|
||||
{ "id": "keep", "label": "Keep blocker", "effects": [] }
|
||||
],
|
||||
"idempotencyKey": "decision:{originIssueId}:blockers.clear_obsolete:v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Bundles accept 1–50 decisions and are created atomically. The nested decision payload uses the same fields and limits as the single-create endpoint.
|
||||
|
||||
Create a `request_checkbox_confirmation` (board selects any subset, then confirms):
|
||||
|
||||
```json
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ const PAPERCLIP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-e2e-home
|
|||
const PAPERCLIP_INSTANCE_ID = "playwright-e2e";
|
||||
const PAPERCLIP_CONFIG = path.join(PAPERCLIP_HOME, "instances", PAPERCLIP_INSTANCE_ID, "config.json");
|
||||
const PAPERCLIP_AGENT_JWT_SECRET = process.env.PAPERCLIP_AGENT_JWT_SECRET ?? "playwright-e2e-agent-jwt-secret";
|
||||
const PAPERCLIP_DECISION_SIGNING_SECRET =
|
||||
process.env.PAPERCLIP_DECISION_SIGNING_SECRET ?? "playwright-e2e-decision-signing-secret";
|
||||
const PAPERCLIP_TOOL_ACTION_SIGNING_SECRET =
|
||||
process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET ?? "playwright-e2e-tool-action-signing-secret";
|
||||
const PLAYWRIGHT_CHANNEL = process.env.PAPERCLIP_PLAYWRIGHT_CHANNEL;
|
||||
|
|
@ -18,6 +20,7 @@ const PLAYWRIGHT_CHANNEL = process.env.PAPERCLIP_PLAYWRIGHT_CHANNEL;
|
|||
process.env.PAPERCLIP_HOME = PAPERCLIP_HOME;
|
||||
process.env.PAPERCLIP_CONFIG = PAPERCLIP_CONFIG;
|
||||
process.env.PAPERCLIP_AGENT_JWT_SECRET = PAPERCLIP_AGENT_JWT_SECRET;
|
||||
process.env.PAPERCLIP_DECISION_SIGNING_SECRET = PAPERCLIP_DECISION_SIGNING_SECRET;
|
||||
process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET = PAPERCLIP_TOOL_ACTION_SIGNING_SECRET;
|
||||
|
||||
export default defineConfig({
|
||||
|
|
@ -67,6 +70,7 @@ export default defineConfig({
|
|||
PAPERCLIP_INSTANCE_ID,
|
||||
PAPERCLIP_CONFIG,
|
||||
PAPERCLIP_AGENT_JWT_SECRET,
|
||||
PAPERCLIP_DECISION_SIGNING_SECRET,
|
||||
PAPERCLIP_TOOL_ACTION_SIGNING_SECRET,
|
||||
PAPERCLIP_BIND: "loopback",
|
||||
PAPERCLIP_DEPLOYMENT_MODE: "local_trusted",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,114 @@
|
|||
import type { DecisionInput, DecisionOption } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
/**
|
||||
* Decisions v1 (PAP-14939 §4). Standalone decision objects proposed by agents
|
||||
* and resolved by the board. Open decisions surface in the attention feed as a
|
||||
* `decision` source; decided/expired history is fetched directly here. Response
|
||||
* DTOs mirror the P3 service (`server/src/services/decisions.ts`) and are kept
|
||||
* UI-local rather than in `@paperclipai/shared` on purpose — only the option /
|
||||
* input / effect specs are shared (they round-trip on create).
|
||||
*/
|
||||
|
||||
export type DecisionStatus = "open" | "decided" | "expired" | "cancelled";
|
||||
export type DecisionExecutionStatus = "running" | "succeeded" | "partial" | "failed";
|
||||
export type DecisionEffectExecutionStatus = "claimed" | "executed" | "failed" | "skipped";
|
||||
|
||||
export interface DecisionTargetSnapshot {
|
||||
status: string;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
updatedAt: string;
|
||||
descendantCount?: number;
|
||||
descendantIds?: string[];
|
||||
/** Legacy snapshots created before descendantCount was named explicitly. */
|
||||
childCount?: number;
|
||||
}
|
||||
|
||||
export interface Decision {
|
||||
id: string;
|
||||
companyId: string;
|
||||
bundleId: string | null;
|
||||
originAgentId: string;
|
||||
originIssueId: string;
|
||||
originRunId: string;
|
||||
ruleKey: string | null;
|
||||
title: string;
|
||||
body: string;
|
||||
options: DecisionOption[];
|
||||
inputs: DecisionInput[] | null;
|
||||
status: DecisionStatus;
|
||||
executionStatus: DecisionExecutionStatus | null;
|
||||
chosenOptionId: string | null;
|
||||
inputValues: Record<string, string> | null;
|
||||
decidedByUserId: string | null;
|
||||
decidedAt: string | null;
|
||||
expiresAt: string;
|
||||
idempotencyKey: string | null;
|
||||
targetSnapshots: Record<string, DecisionTargetSnapshot>;
|
||||
continuationPolicy: "none" | "wake_origin_agent";
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** `list()` annotates each open decision with which targets drifted since snapshot. */
|
||||
export interface DecisionListItem extends Decision {
|
||||
targetChanged: Record<string, boolean>;
|
||||
/** Included by terminal-state lists so history cards avoid detail-query fan-out. */
|
||||
executions?: DecisionEffectExecution[];
|
||||
}
|
||||
|
||||
export interface DecisionEffectExecution {
|
||||
id: string;
|
||||
decisionId: string;
|
||||
effectIndex: number;
|
||||
effectType: string;
|
||||
targetIssueId: string;
|
||||
status: DecisionEffectExecutionStatus;
|
||||
result: Record<string, unknown> | null;
|
||||
error: string | null;
|
||||
activityLogId: string | null;
|
||||
executedAt: string | null;
|
||||
}
|
||||
|
||||
/** `get()` / `decide()` / `dismiss()` return the decision plus per-effect executions. */
|
||||
export interface DecisionOutcome extends Decision {
|
||||
executions: DecisionEffectExecution[];
|
||||
}
|
||||
|
||||
export interface DecisionListFilter {
|
||||
status?: DecisionStatus;
|
||||
bundleId?: string;
|
||||
targetIssueId?: string;
|
||||
originAgentId?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface DecideInput {
|
||||
optionId: string;
|
||||
inputValues?: Record<string, string>;
|
||||
idempotencyKey?: string | null;
|
||||
}
|
||||
|
||||
function listQuery(filter: DecisionListFilter): string {
|
||||
const params = new URLSearchParams();
|
||||
if (filter.status) params.set("status", filter.status);
|
||||
if (filter.bundleId) params.set("bundleId", filter.bundleId);
|
||||
if (filter.targetIssueId) params.set("targetIssueId", filter.targetIssueId);
|
||||
if (filter.originAgentId) params.set("originAgentId", filter.originAgentId);
|
||||
if (filter.limit != null) params.set("limit", String(filter.limit));
|
||||
const qs = params.toString();
|
||||
return qs ? `?${qs}` : "";
|
||||
}
|
||||
|
||||
export const decisionsApi = {
|
||||
list: (companyId: string, filter: DecisionListFilter = {}) =>
|
||||
api.get<DecisionListItem[]>(`/companies/${companyId}/decisions${listQuery(filter)}`),
|
||||
get: (id: string) => api.get<DecisionOutcome>(`/decisions/${id}`),
|
||||
decide: (id: string, input: DecideInput) =>
|
||||
api.post<DecisionOutcome>(`/decisions/${id}/decide`, input),
|
||||
dismiss: (id: string, reason?: string | null) =>
|
||||
api.post<DecisionOutcome>(`/decisions/${id}/dismiss`, reason ? { reason } : {}),
|
||||
cancel: (id: string) => api.post<Decision>(`/decisions/${id}/cancel`, {}),
|
||||
};
|
||||
|
|
@ -10,6 +10,7 @@ export { externalObjectsApi } from "./externalObjects";
|
|||
export { routinesApi } from "./routines";
|
||||
export { goalsApi } from "./goals";
|
||||
export { approvalsApi } from "./approvals";
|
||||
export { decisionsApi } from "./decisions";
|
||||
export { costsApi } from "./costs";
|
||||
export { activityApi } from "./activity";
|
||||
export { dashboardApi } from "./dashboard";
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
import { AttentionInteractionResolver } from "./AttentionInteractionResolver";
|
||||
import { DecisionResolver } from "./DecisionResolver";
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const DAY_MS = 24 * HOUR_MS;
|
||||
|
|
@ -736,6 +737,17 @@ function InlineResolver({
|
|||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||||
toggle: ReactNode;
|
||||
}) {
|
||||
if (item.sourceKind === "decision") {
|
||||
return (
|
||||
<DecisionResolver
|
||||
companyId={companyId}
|
||||
decisionId={item.subject.id}
|
||||
originIssue={item.relatedIssue}
|
||||
agentMap={agentMap}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.sourceKind === "issue_thread_interaction") {
|
||||
const issueId = (item.subject.metadata?.issueId as string | undefined) ?? item.relatedIssue?.id;
|
||||
if (!issueId) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,265 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act as reactAct, type ComponentProps, type ReactNode } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DecisionCard, type DecisionIssueRef } from "./DecisionCard";
|
||||
import type { Decision, DecisionEffectExecution } from "../api/decisions";
|
||||
import { ThemeProvider } from "../context/ThemeContext";
|
||||
import { TooltipProvider } from "./ui/tooltip";
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
if (typeof reactAct === "function") {
|
||||
await reactAct(callback);
|
||||
return;
|
||||
}
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ to, children, className }: { to: string; children: ReactNode; className?: string }) => (
|
||||
<a href={to} className={className}>{children}</a>
|
||||
),
|
||||
useCaseHref: () => () => "#",
|
||||
}));
|
||||
|
||||
const ISSUES: Record<string, DecisionIssueRef> = {
|
||||
"issue-origin": { id: "issue-origin", identifier: "PAP-123", title: "Gardener sweep", href: "/PAP/issues/PAP-123", status: "in_progress" },
|
||||
"issue-target": { id: "issue-target", identifier: "PAP-456", title: "Stale epic", href: "/PAP/issues/PAP-456", status: "backlog" },
|
||||
"issue-new": { id: "issue-new", identifier: "PAP-999", title: "Follow-up", href: "/PAP/issues/PAP-999", status: "todo" },
|
||||
};
|
||||
const resolveIssue = (id: string): DecisionIssueRef | null => ISSUES[id] ?? null;
|
||||
|
||||
function mkDecision(overrides: Partial<Decision> = {}): Decision {
|
||||
return {
|
||||
id: "decision-1",
|
||||
companyId: "c1",
|
||||
bundleId: null,
|
||||
originAgentId: "agent-gardener",
|
||||
originIssueId: "issue-origin",
|
||||
originRunId: "run-1",
|
||||
ruleKey: "stale-epic",
|
||||
title: "Stale epic PAP-456",
|
||||
body: "No activity for three weeks.",
|
||||
options: [
|
||||
{ id: "comment", label: "Comment and snooze", effects: [{ type: "comment_on_issue", targetIssueId: "issue-target", staleness: "lenient", bodyMarkdown: "nudge" }] },
|
||||
],
|
||||
inputs: null,
|
||||
status: "open",
|
||||
executionStatus: null,
|
||||
chosenOptionId: null,
|
||||
inputValues: null,
|
||||
decidedByUserId: null,
|
||||
decidedAt: null,
|
||||
expiresAt: "2026-07-29T12:00:00Z",
|
||||
idempotencyKey: null,
|
||||
targetSnapshots: { "issue-target": { status: "backlog", assigneeAgentId: null, assigneeUserId: null, updatedAt: "2026-07-01T09:00:00Z", childCount: 2 } },
|
||||
continuationPolicy: "none",
|
||||
metadata: {},
|
||||
createdAt: "2026-07-22T09:00:00Z",
|
||||
updatedAt: "2026-07-22T09:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function exec(overrides: Partial<DecisionEffectExecution>): DecisionEffectExecution {
|
||||
return {
|
||||
id: `exec-${overrides.effectIndex ?? 0}`,
|
||||
decisionId: "decision-1",
|
||||
effectIndex: 0,
|
||||
effectType: "comment_on_issue",
|
||||
targetIssueId: "issue-target",
|
||||
status: "executed",
|
||||
result: {},
|
||||
error: null,
|
||||
activityLogId: null,
|
||||
executedAt: "2026-07-22T10:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function render(props: Partial<ComponentProps<typeof DecisionCard>>) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root?.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<TooltipProvider>
|
||||
<DecisionCard decision={mkDecision()} resolveIssue={resolveIssue} originAgentName="Gardener" originIssue={ISSUES["issue-origin"]} {...props} />
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
return container!;
|
||||
}
|
||||
|
||||
function clickButtonWithText(el: HTMLElement, text: string) {
|
||||
const button = [...el.querySelectorAll("button")].find((b) => b.textContent?.includes(text));
|
||||
if (!button) throw new Error(`No button with text "${text}"`);
|
||||
act(() => {
|
||||
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (root) act(() => root?.unmount());
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
describe("DecisionCard", () => {
|
||||
it("renders a pending decision with provenance, effect summary and dismiss", () => {
|
||||
const el = render({});
|
||||
expect(el.textContent).toContain("Pending");
|
||||
expect(el.textContent).toContain("Gardener");
|
||||
expect(el.textContent).toContain("PAP-123");
|
||||
expect(el.textContent).toContain("Comment on PAP-456");
|
||||
expect([...el.querySelectorAll("button")].some((b) => b.textContent?.includes("Dismiss"))).toBe(true);
|
||||
});
|
||||
|
||||
it("fires onDecide with the chosen option id", () => {
|
||||
const onDecide = vi.fn();
|
||||
const el = render({ onDecide });
|
||||
clickButtonWithText(el, "Comment and snooze");
|
||||
expect(onDecide).toHaveBeenCalledWith("comment", expect.any(Object));
|
||||
});
|
||||
|
||||
it("fires onDismiss from the always-present zero-effect dismiss", () => {
|
||||
const onDismiss = vi.fn();
|
||||
const el = render({ onDismiss });
|
||||
clickButtonWithText(el, "Dismiss");
|
||||
expect(onDismiss).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("warns and disables strict options when a target is stale", () => {
|
||||
const el = render({
|
||||
targetChanged: { "issue-target": true },
|
||||
decision: mkDecision({
|
||||
options: [
|
||||
{ id: "strict", label: "Cancel it", effects: [{ type: "update_issue_status", targetIssueId: "issue-target", staleness: "strict", status: "cancelled" }] },
|
||||
{ id: "lenient", label: "Just comment", effects: [{ type: "comment_on_issue", targetIssueId: "issue-target", staleness: "lenient", bodyMarkdown: "hi" }] },
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(el.textContent).toContain("changed since this was proposed");
|
||||
expect(el.textContent).toContain("Blocked · stale");
|
||||
const strict = [...el.querySelectorAll("button")].find((b) => b.textContent?.includes("Cancel it"));
|
||||
expect(strict?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("disables strict options when a secondary target is stale", () => {
|
||||
const el = render({
|
||||
targetChanged: { "issue-target": false, "issue-parent": true },
|
||||
decision: mkDecision({
|
||||
targetSnapshots: {
|
||||
"issue-target": { status: "backlog", assigneeAgentId: null, assigneeUserId: null, updatedAt: "2026-07-01T09:00:00Z", childCount: 2 },
|
||||
"issue-parent": { status: "todo", assigneeAgentId: null, assigneeUserId: null, updatedAt: "2026-07-01T09:00:00Z", childCount: 0 },
|
||||
},
|
||||
options: [
|
||||
{
|
||||
id: "strict-create",
|
||||
label: "Create the follow-up",
|
||||
effects: [{
|
||||
type: "create_issue",
|
||||
targetIssueId: "issue-target",
|
||||
staleness: "strict",
|
||||
draft: { title: "Follow-up", parentId: "issue-parent" },
|
||||
}],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
const strict = [...el.querySelectorAll("button")].find((button) => button.textContent?.includes("Create the follow-up"));
|
||||
expect(strict?.disabled).toBe(true);
|
||||
expect(strict?.textContent).toContain("Blocked · stale");
|
||||
});
|
||||
|
||||
it("gates a cancel_issue_tree option behind a type-to-confirm step", () => {
|
||||
const onDecide = vi.fn();
|
||||
const el = render({
|
||||
onDecide,
|
||||
cancelTreePreview: () => [ISSUES["issue-target"]!],
|
||||
decision: mkDecision({
|
||||
options: [
|
||||
{ id: "cancel", label: "Cancel the tree", style: "destructive", effects: [{ type: "cancel_issue_tree", targetIssueId: "issue-target", staleness: "strict", reasonComment: "stale" }] },
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(el.textContent).toContain("Destructive");
|
||||
// First click opens the confirm gate rather than deciding.
|
||||
clickButtonWithText(el, "Cancel the tree");
|
||||
expect(onDecide).not.toHaveBeenCalled();
|
||||
expect(el.textContent).toContain("to confirm");
|
||||
const confirm = [...el.querySelectorAll("button")].find((b) => b.textContent?.match(/Cancel \d+ issue/));
|
||||
expect(confirm?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("renders decided result rows with entity links", () => {
|
||||
const el = render({
|
||||
decision: mkDecision({ status: "decided", executionStatus: "succeeded", chosenOptionId: "create" }),
|
||||
executions: [
|
||||
exec({ effectIndex: 0, effectType: "create_issue", status: "executed", result: { issueId: "issue-new" } }),
|
||||
exec({ effectIndex: 1, effectType: "update_issue_status", status: "executed", result: { issueId: "issue-target", status: "done" } }),
|
||||
],
|
||||
});
|
||||
expect(el.textContent).toContain("Decided");
|
||||
expect(el.textContent).toContain("Created PAP-999");
|
||||
expect(el.textContent).toContain("Set PAP-456 to done");
|
||||
expect([...el.querySelectorAll("a")].some((a) => a.getAttribute("href") === "/PAP/issues/PAP-999")).toBe(true);
|
||||
// No option buttons on a terminal decision.
|
||||
expect([...el.querySelectorAll("button")].length).toBe(0);
|
||||
});
|
||||
|
||||
it("surfaces failure cause and the fail-closed / re-propose guidance on partial", () => {
|
||||
const el = render({
|
||||
decision: mkDecision({ status: "decided", executionStatus: "partial", chosenOptionId: "create" }),
|
||||
executions: [
|
||||
exec({ effectIndex: 0, effectType: "comment_on_issue", status: "executed" }),
|
||||
exec({ effectIndex: 1, effectType: "update_issue_status", status: "failed", error: "deny_decision_intersection" }),
|
||||
],
|
||||
});
|
||||
expect(el.textContent).toContain("Partial");
|
||||
expect(el.textContent).toContain("permission boundary");
|
||||
expect(el.textContent).toContain("may already have been applied");
|
||||
expect(el.textContent).toContain("re-propose");
|
||||
});
|
||||
|
||||
it("marks skipped effects as target-changed on a failed decision", () => {
|
||||
const el = render({
|
||||
decision: mkDecision({ status: "decided", executionStatus: "failed", chosenOptionId: "cancel" }),
|
||||
executions: [exec({ effectIndex: 0, effectType: "cancel_issue_tree", status: "skipped", error: "target_changed" })],
|
||||
});
|
||||
expect(el.textContent).toContain("Failed");
|
||||
expect(el.textContent).toContain("target changed since proposal");
|
||||
});
|
||||
|
||||
it("renders expired and dismissed terminal states", () => {
|
||||
const expired = render({ decision: mkDecision({ status: "expired", metadata: { expiredReason: "ttl" } }) });
|
||||
expect(expired.textContent).toContain("Expired");
|
||||
expect(expired.textContent).toContain("window closed");
|
||||
act(() => root?.unmount());
|
||||
container?.remove();
|
||||
|
||||
const dismissed = render({ decision: mkDecision({ status: "decided", executionStatus: "succeeded", chosenOptionId: "dismissed", metadata: { dismissed: true } }), executions: [] });
|
||||
expect(dismissed.textContent).toContain("Dismissed");
|
||||
expect(dismissed.textContent).toContain("no effects were run");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,604 @@
|
|||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
MinusCircle,
|
||||
ShieldAlert,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import type { DecisionEffect, DecisionOption } from "@paperclipai/shared";
|
||||
import type {
|
||||
Decision,
|
||||
DecisionEffectExecution,
|
||||
DecisionTargetSnapshot,
|
||||
} from "../api/decisions";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
|
||||
/**
|
||||
* Presentational card for a single Decisions-v1 decision (PAP-14966 / PAP-14939
|
||||
* §4), modeled on {@link IssueThreadInteractionCard}. It renders every state —
|
||||
* pending / stale-target / destructive cancel-tree / decided / partial / failed
|
||||
* / expired / cancelled / dismissed — and delegates the actual decide / dismiss
|
||||
* mutations to the parent (the feed's `DecisionResolver`, or the history view).
|
||||
* It never fetches: issue labels + the cancel-tree preview arrive via resolvers
|
||||
* so the same component drives the live feed and the screenshot harness.
|
||||
*/
|
||||
|
||||
export interface DecisionIssueRef {
|
||||
id: string;
|
||||
identifier: string | null;
|
||||
title: string | null;
|
||||
href: string;
|
||||
status?: string | null;
|
||||
}
|
||||
|
||||
export interface DecisionCardProps {
|
||||
decision: Decision;
|
||||
/** Per-effect execution rows (present once decided). */
|
||||
executions?: DecisionEffectExecution[] | null;
|
||||
/** Which target issues drifted since the snapshot (open decisions only). */
|
||||
targetChanged?: Record<string, boolean> | null;
|
||||
/** Resolve an issue id to a display ref (identifier / title / link). */
|
||||
resolveIssue?: (issueId: string) => DecisionIssueRef | null;
|
||||
/** Full sub-tree that a `cancel_issue_tree` option would cancel. */
|
||||
cancelTreePreview?: (targetIssueId: string) => DecisionIssueRef[] | null;
|
||||
originAgentName?: string | null;
|
||||
originIssue?: DecisionIssueRef | null;
|
||||
runHref?: string | null;
|
||||
busy?: boolean;
|
||||
errorMessage?: string | null;
|
||||
onDecide?: (optionId: string, inputValues: Record<string, string>) => void;
|
||||
onDismiss?: (reason?: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// --- small helpers ----------------------------------------------------------
|
||||
|
||||
function humanStatus(status: string | null | undefined): string {
|
||||
if (!status) return "unknown";
|
||||
return status.replaceAll("_", " ");
|
||||
}
|
||||
|
||||
function referencedTargetIds(effect: DecisionEffect): string[] {
|
||||
const ids = new Set([effect.targetIssueId]);
|
||||
if (effect.type === "create_issue") {
|
||||
if (effect.draft.parentId) ids.add(effect.draft.parentId);
|
||||
for (const id of effect.draft.blockedByIssueIds ?? []) ids.add(id);
|
||||
}
|
||||
if (effect.type === "resolve_blocker") {
|
||||
for (const id of effect.removeBlockedByIssueIds) ids.add(id);
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
function issueLabel(ref: DecisionIssueRef | null, fallbackId: string): string {
|
||||
if (ref?.identifier) return ref.identifier;
|
||||
if (ref?.title) return ref.title;
|
||||
return `issue ${fallbackId.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
function pluralize(count: number, singular: string): string {
|
||||
return `${count} ${count === 1 ? singular : `${singular}s`}`;
|
||||
}
|
||||
|
||||
function isDestructiveOption(option: DecisionOption): boolean {
|
||||
return option.style === "destructive" || option.effects.some((effect) => effect.type === "cancel_issue_tree");
|
||||
}
|
||||
|
||||
function cancelTreeEffect(option: DecisionOption): Extract<DecisionEffect, { type: "cancel_issue_tree" }> | null {
|
||||
return (option.effects.find((effect) => effect.type === "cancel_issue_tree") ?? null) as
|
||||
| Extract<DecisionEffect, { type: "cancel_issue_tree" }>
|
||||
| null;
|
||||
}
|
||||
|
||||
/** One-line feed-forward preview of what an effect will do, before you commit. */
|
||||
function effectSummary(
|
||||
effect: DecisionEffect,
|
||||
resolve: (id: string) => DecisionIssueRef | null,
|
||||
snapshots: Record<string, DecisionTargetSnapshot>,
|
||||
): string {
|
||||
const target = issueLabel(resolve(effect.targetIssueId), effect.targetIssueId);
|
||||
switch (effect.type) {
|
||||
case "comment_on_issue":
|
||||
return `Comment on ${target}`;
|
||||
case "create_issue": {
|
||||
const parent = effect.draft.parentId
|
||||
? issueLabel(resolve(effect.draft.parentId), effect.draft.parentId)
|
||||
: target;
|
||||
return `Create issue “${effect.draft.title}” under ${parent}`;
|
||||
}
|
||||
case "update_issue_status":
|
||||
return `Set ${target} to ${humanStatus(effect.status)}`;
|
||||
case "assign_issue":
|
||||
return `Reassign ${target}`;
|
||||
case "resolve_blocker":
|
||||
return `Unblock ${target} — remove ${pluralize(effect.removeBlockedByIssueIds.length, "blocker")}`;
|
||||
case "cancel_issue_tree": {
|
||||
const snapshot = snapshots[effect.targetIssueId];
|
||||
const descendantCount = snapshot?.descendantCount ?? snapshot?.descendantIds?.length ?? snapshot?.childCount ?? 0;
|
||||
return `Cancel ${target} and its sub-tree (${pluralize(descendantCount + 1, "issue")})`;
|
||||
}
|
||||
default:
|
||||
return "Apply effect";
|
||||
}
|
||||
}
|
||||
|
||||
const FAILURE_CAUSE: Record<string, string> = {
|
||||
deny_decision_intersection: "blocked by the permission boundary (fail-closed)",
|
||||
invalid_effect_reference: "a referenced issue no longer exists",
|
||||
target_changed: "the target changed since this was proposed",
|
||||
effect_execution_failed: "the effect errored while running",
|
||||
};
|
||||
|
||||
interface ResultRow {
|
||||
key: string;
|
||||
status: DecisionEffectExecution["status"];
|
||||
summary: string;
|
||||
link: DecisionIssueRef | null;
|
||||
}
|
||||
|
||||
function executionRow(
|
||||
execution: DecisionEffectExecution,
|
||||
resolve: (id: string) => DecisionIssueRef | null,
|
||||
): ResultRow {
|
||||
const targetRef = resolve(execution.targetIssueId);
|
||||
const target = issueLabel(targetRef, execution.targetIssueId);
|
||||
const result = execution.result ?? {};
|
||||
if (execution.status === "skipped") {
|
||||
return { key: execution.id, status: "skipped", summary: `Skipped ${target} — target changed since proposal`, link: targetRef };
|
||||
}
|
||||
if (execution.status === "failed") {
|
||||
const cause = FAILURE_CAUSE[execution.error ?? ""] ?? execution.error ?? "the effect could not run";
|
||||
return { key: execution.id, status: "failed", summary: `Failed on ${target} — ${cause}`, link: targetRef };
|
||||
}
|
||||
if (execution.status === "claimed") {
|
||||
return { key: execution.id, status: "claimed", summary: `Running on ${target}…`, link: targetRef };
|
||||
}
|
||||
// executed
|
||||
switch (execution.effectType) {
|
||||
case "comment_on_issue":
|
||||
return { key: execution.id, status: "executed", summary: `Commented on ${target}`, link: targetRef };
|
||||
case "create_issue": {
|
||||
const createdId = typeof result.issueId === "string" ? result.issueId : null;
|
||||
const created = createdId ? resolve(createdId) : null;
|
||||
return {
|
||||
key: execution.id,
|
||||
status: "executed",
|
||||
summary: `Created ${created ? issueLabel(created, createdId!) : "a new issue"}`,
|
||||
link: created ?? targetRef,
|
||||
};
|
||||
}
|
||||
case "update_issue_status":
|
||||
return { key: execution.id, status: "executed", summary: `Set ${target} to ${humanStatus(typeof result.status === "string" ? result.status : null)}`, link: targetRef };
|
||||
case "assign_issue":
|
||||
return { key: execution.id, status: "executed", summary: `Reassigned ${target}`, link: targetRef };
|
||||
case "resolve_blocker": {
|
||||
const removed = Array.isArray(result.removedBlockedByIssueIds) ? result.removedBlockedByIssueIds.length : 0;
|
||||
return { key: execution.id, status: "executed", summary: `Removed ${pluralize(removed, "blocker")} from ${target}`, link: targetRef };
|
||||
}
|
||||
case "cancel_issue_tree": {
|
||||
const cancelled = Array.isArray(result.cancelledIssueIds) ? result.cancelledIssueIds.length : 0;
|
||||
return { key: execution.id, status: "executed", summary: `Cancelled ${pluralize(cancelled, "issue")} under ${target}`, link: targetRef };
|
||||
}
|
||||
default:
|
||||
return { key: execution.id, status: "executed", summary: `Applied effect on ${target}`, link: targetRef };
|
||||
}
|
||||
}
|
||||
|
||||
// --- shell / badge palette (matches IssueThreadInteractionCard) --------------
|
||||
|
||||
type CardTone = "pending" | "destructive" | "success" | "partial" | "failed" | "neutral";
|
||||
|
||||
const SHELL: Record<CardTone, string> = {
|
||||
pending: "border-sky-500/70",
|
||||
destructive: "border-2 border-rose-500/80",
|
||||
success: "border-emerald-400/70",
|
||||
partial: "border-amber-400/70",
|
||||
failed: "border-rose-400/70",
|
||||
neutral: "border-border/60",
|
||||
};
|
||||
|
||||
const BADGE: Record<CardTone, string> = {
|
||||
pending: "border-sky-500/60 bg-sky-500/10 text-sky-900 dark:bg-sky-500/15 dark:text-sky-100",
|
||||
destructive: "border-rose-500/60 bg-rose-500/10 text-rose-800 dark:bg-rose-500/15 dark:text-rose-100",
|
||||
success: "border-emerald-500/60 bg-emerald-500/10 text-emerald-900 dark:bg-emerald-500/15 dark:text-emerald-100",
|
||||
partial: "border-amber-500/60 bg-amber-500/10 text-amber-900 dark:bg-amber-500/15 dark:text-amber-100",
|
||||
failed: "border-rose-500/60 bg-rose-500/10 text-rose-800 dark:bg-rose-500/15 dark:text-rose-100",
|
||||
neutral: "border-border/70 bg-muted/50 text-muted-foreground",
|
||||
};
|
||||
|
||||
function IssueLink({ ref: link }: { ref: DecisionIssueRef | null }) {
|
||||
if (!link) return null;
|
||||
return (
|
||||
<a
|
||||
href={link.href}
|
||||
className="inline-flex items-center gap-1 rounded-sm border border-border/70 bg-background px-1.5 py-0.5 text-xs font-medium text-foreground hover:border-sky-500/70 hover:text-sky-700 dark:hover:text-sky-300"
|
||||
>
|
||||
{issueLabel(link, link.id)}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const RESULT_ICON: Record<ResultRow["status"], ReactNode> = {
|
||||
executed: <CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600 dark:text-emerald-400" aria-hidden />,
|
||||
failed: <XCircle className="mt-0.5 h-4 w-4 shrink-0 text-rose-600 dark:text-rose-400" aria-hidden />,
|
||||
skipped: <MinusCircle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" aria-hidden />,
|
||||
claimed: <Loader2 className="mt-0.5 h-4 w-4 shrink-0 animate-spin text-muted-foreground" aria-hidden />,
|
||||
};
|
||||
|
||||
export function DecisionCard({
|
||||
decision,
|
||||
executions,
|
||||
targetChanged,
|
||||
resolveIssue = () => null,
|
||||
cancelTreePreview,
|
||||
originAgentName,
|
||||
originIssue,
|
||||
runHref,
|
||||
busy = false,
|
||||
errorMessage,
|
||||
onDecide,
|
||||
onDismiss,
|
||||
className,
|
||||
}: DecisionCardProps) {
|
||||
const [inputValues, setInputValues] = useState<Record<string, string>>({});
|
||||
const [confirmOptionId, setConfirmOptionId] = useState<string | null>(null);
|
||||
const [confirmText, setConfirmText] = useState("");
|
||||
|
||||
const open = decision.status === "open";
|
||||
const dismissed =
|
||||
decision.chosenOptionId === "dismissed" || (decision.metadata as { dismissed?: boolean } | null)?.dismissed === true;
|
||||
const snapshots = (decision.targetSnapshots ?? {}) as Record<string, DecisionTargetSnapshot>;
|
||||
|
||||
const staleTargetIds = useMemo(
|
||||
() => (open ? Object.entries(targetChanged ?? {}).filter(([, changed]) => changed).map(([id]) => id) : []),
|
||||
[open, targetChanged],
|
||||
);
|
||||
const staleTargetIdSet = useMemo(() => new Set(staleTargetIds), [staleTargetIds]);
|
||||
const isStale = staleTargetIds.length > 0;
|
||||
const hasCancelTree = decision.options.some((option) => option.effects.some((effect) => effect.type === "cancel_issue_tree"));
|
||||
|
||||
const tone: CardTone = open
|
||||
? hasCancelTree
|
||||
? "destructive"
|
||||
: "pending"
|
||||
: decision.status !== "decided"
|
||||
? "neutral" // expired / cancelled
|
||||
: dismissed
|
||||
? "neutral"
|
||||
: decision.executionStatus === "succeeded"
|
||||
? "success"
|
||||
: decision.executionStatus === "partial"
|
||||
? "partial"
|
||||
: "failed";
|
||||
|
||||
const badgeLabel = open
|
||||
? "Pending"
|
||||
: decision.status === "expired"
|
||||
? "Expired"
|
||||
: decision.status === "cancelled"
|
||||
? "Cancelled"
|
||||
: dismissed
|
||||
? "Dismissed"
|
||||
: decision.executionStatus === "succeeded"
|
||||
? "Decided"
|
||||
: decision.executionStatus === "partial"
|
||||
? "Partial"
|
||||
: "Failed";
|
||||
|
||||
const requiredUnmet = (decision.inputs ?? []).some(
|
||||
(field) => field.required && !(inputValues[field.id] ?? "").trim(),
|
||||
);
|
||||
|
||||
const runOption = (option: DecisionOption) => {
|
||||
if (busy) return;
|
||||
const cancelTree = cancelTreeEffect(option);
|
||||
if (cancelTree && confirmOptionId !== option.id) {
|
||||
setConfirmOptionId(option.id);
|
||||
setConfirmText("");
|
||||
return;
|
||||
}
|
||||
onDecide?.(option.id, inputValues);
|
||||
};
|
||||
|
||||
const dimmed = decision.status === "expired" || decision.status === "cancelled";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl border bg-background/82 p-4 text-sm",
|
||||
SHELL[tone],
|
||||
dimmed && "opacity-80",
|
||||
className,
|
||||
)}
|
||||
data-decision-state={badgeLabel.toLowerCase()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<h3 className="min-w-0 flex-1 text-base font-semibold text-foreground">{decision.title}</h3>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{open && hasCancelTree && (
|
||||
<span className={cn("inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-(length:--text-micro) font-semibold uppercase tracking-wide", BADGE.destructive)}>
|
||||
<ShieldAlert className="h-3 w-3" aria-hidden /> Destructive
|
||||
</span>
|
||||
)}
|
||||
<span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-(length:--text-micro) font-semibold uppercase tracking-wide", BADGE[tone])}>
|
||||
{badgeLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provenance */}
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Proposed by <span className="font-medium text-foreground">{originAgentName ?? "an agent"}</span>
|
||||
{originIssue && (
|
||||
<>
|
||||
{" "}while running{" "}
|
||||
<a href={originIssue.href} className="font-medium text-sky-700 hover:underline dark:text-sky-300">
|
||||
{issueLabel(originIssue, originIssue.id)}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{runHref && (
|
||||
<>
|
||||
{" · "}
|
||||
<a href={runHref} className="hover:underline">view run</a>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Body */}
|
||||
{decision.body?.trim() && (
|
||||
<div className="mt-3 text-sm leading-6 text-foreground/90">
|
||||
<MarkdownBody>{decision.body}</MarkdownBody>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stale-target warning (open only) */}
|
||||
{open && isStale && (
|
||||
<div className="mt-3 rounded-lg border border-amber-500/50 bg-amber-500/10 px-3 py-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" aria-hidden />
|
||||
{pluralize(staleTargetIds.length, "target")} changed since this was proposed
|
||||
</div>
|
||||
<ul className="mt-1.5 space-y-1 text-xs text-amber-900/90 dark:text-amber-100/90">
|
||||
{staleTargetIds.map((id) => {
|
||||
const ref = resolveIssue(id);
|
||||
const from = snapshots[id];
|
||||
return (
|
||||
<li key={id} className="flex flex-wrap items-center gap-1">
|
||||
<span className="font-medium">{issueLabel(ref, id)}:</span>
|
||||
<span className="tabular-nums">{humanStatus(from?.status)}</span>
|
||||
<ArrowRight className="h-3 w-3" aria-hidden />
|
||||
<span className="tabular-nums">{humanStatus(ref?.status) || "changed"}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<p className="mt-1.5 text-xs text-amber-800/80 dark:text-amber-200/80">
|
||||
Options that require an unchanged target are disabled below.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inputs (open only) */}
|
||||
{open && (decision.inputs ?? []).length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{(decision.inputs ?? []).map((field) => (
|
||||
<label key={field.id} className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{field.label}
|
||||
{field.required && <span className="text-rose-500"> *</span>}
|
||||
</span>
|
||||
<Textarea
|
||||
value={inputValues[field.id] ?? ""}
|
||||
onChange={(event) => setInputValues((prev) => ({ ...prev, [field.id]: event.target.value }))}
|
||||
placeholder={field.placeholder ?? undefined}
|
||||
maxLength={field.maxLength ?? undefined}
|
||||
className="mt-1 min-h-16 bg-background text-sm"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options (open only) */}
|
||||
{open && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{decision.options.map((option) => {
|
||||
const destructive = isDestructiveOption(option);
|
||||
const blockedStale = option.effects.some(
|
||||
(effect) => effect.staleness === "strict" && referencedTargetIds(effect).some((id) => staleTargetIdSet.has(id)),
|
||||
);
|
||||
const disabled = busy || requiredUnmet || blockedStale;
|
||||
const cancelTree = cancelTreeEffect(option);
|
||||
const confirming = confirmOptionId === option.id;
|
||||
const previewRows = cancelTree && cancelTreePreview ? cancelTreePreview(cancelTree.targetIssueId) : null;
|
||||
const confirmRef = cancelTree ? resolveIssue(cancelTree.targetIssueId) : null;
|
||||
const confirmToken = confirmRef?.identifier ?? confirmRef?.id ?? cancelTree?.targetIssueId ?? "";
|
||||
return (
|
||||
<div key={option.id} className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => runOption(option)}
|
||||
className={cn(
|
||||
"w-full rounded-sm border px-4 py-3 text-left transition-colors outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50",
|
||||
disabled && "cursor-not-allowed opacity-60",
|
||||
destructive
|
||||
? "border-rose-500/70 bg-rose-500/5 text-foreground hover:border-rose-500 hover:bg-rose-500/10"
|
||||
: "border-border/70 bg-transparent text-foreground hover:border-sky-500/70 hover:bg-sky-500/10",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={cn("text-sm font-medium", destructive && "text-rose-700 dark:text-rose-300")}>
|
||||
{option.label}
|
||||
</span>
|
||||
{blockedStale && (
|
||||
<span className="shrink-0 rounded-full border border-amber-500/60 bg-amber-500/10 px-2 py-0.5 text-(length:--text-micro) font-medium text-amber-800 dark:text-amber-200">
|
||||
Blocked · stale
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{option.description && (
|
||||
<div className="mt-1 text-sm leading-6 text-muted-foreground">{option.description}</div>
|
||||
)}
|
||||
{option.effects.length > 0 && (
|
||||
<ul className="mt-2 space-y-0.5">
|
||||
{option.effects.map((effect, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-start gap-1.5 text-xs",
|
||||
effect.type === "cancel_issue_tree" ? "text-rose-700 dark:text-rose-300" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<ArrowRight className="mt-0.5 h-3 w-3 shrink-0" aria-hidden />
|
||||
{effectSummary(effect, resolveIssue, snapshots)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Destructive cancel-tree confirm gate */}
|
||||
{confirming && cancelTree && (
|
||||
<div className="rounded-lg border border-rose-500/50 bg-rose-500/5 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-rose-700 dark:text-rose-300">
|
||||
<Ban className="h-4 w-4" aria-hidden /> This cancels an entire issue tree
|
||||
</div>
|
||||
{previewRows && previewRows.length > 0 ? (
|
||||
<>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{pluralize(previewRows.length, "issue")} will be cancelled:
|
||||
</p>
|
||||
<ul className="mt-1 max-h-40 space-y-0.5 overflow-auto text-xs">
|
||||
{previewRows.map((row) => (
|
||||
<li key={row.id} className="flex items-center gap-1.5">
|
||||
<Ban className="h-3 w-3 shrink-0 text-rose-500" aria-hidden />
|
||||
<span className="font-medium">{issueLabel(row, row.id)}</span>
|
||||
{row.title && row.identifier && (
|
||||
<span className="truncate text-muted-foreground">{row.title}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
This issue and every sub-issue beneath it will be cancelled.
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Type <span className="font-mono font-medium text-foreground">{confirmToken}</span> to confirm.
|
||||
</p>
|
||||
<Input
|
||||
value={confirmText}
|
||||
onChange={(event) => setConfirmText(event.target.value)}
|
||||
placeholder={confirmToken}
|
||||
aria-label="Type the issue identifier to confirm"
|
||||
autoFocus
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="mt-2 flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setConfirmOptionId(null);
|
||||
setConfirmText("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={busy || confirmText.trim() !== confirmToken}
|
||||
onClick={() => onDecide?.(option.id, inputValues)}
|
||||
>
|
||||
{busy && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
{previewRows ? `Cancel ${pluralize(previewRows.length, "issue")}` : "Cancel tree"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Always-present zero-effect Dismiss (telemetered "no", distinct from expiry) */}
|
||||
{!decision.options.some((option) => option.effects.length === 0) && (
|
||||
<div className="flex items-center justify-between gap-2 pt-1">
|
||||
<span className="text-xs text-muted-foreground">Not now?</span>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => onDismiss?.()}>
|
||||
Dismiss — no effects
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{errorMessage && <p className="text-xs text-rose-600 dark:text-rose-400">{errorMessage}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Terminal states */}
|
||||
{!open && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{decision.status === "expired" && (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2 font-medium text-foreground">
|
||||
<Clock className="h-4 w-4" aria-hidden /> The decision window closed
|
||||
</div>
|
||||
<p className="mt-1">
|
||||
{((decision.metadata as { expiredReason?: string } | null)?.expiredReason === "target_gone")
|
||||
? "A target issue was cancelled before this was decided."
|
||||
: "No response before the expiry deadline."}
|
||||
{decision.continuationPolicy === "wake_origin_agent" && " The proposer was re-woken."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{decision.status === "cancelled" && (
|
||||
<p className="rounded-lg border border-border/60 bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||
This decision was withdrawn by the proposer before a response.
|
||||
</p>
|
||||
)}
|
||||
{decision.status === "decided" && dismissed && (
|
||||
<p className="rounded-lg border border-border/60 bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||
Dismissed — no effects were run.
|
||||
</p>
|
||||
)}
|
||||
{decision.status === "decided" && !dismissed && (executions ?? []).length > 0 && (
|
||||
<>
|
||||
<ul className="space-y-1.5">
|
||||
{(executions ?? []).map((execution) => {
|
||||
const row = executionRow(execution, resolveIssue);
|
||||
return (
|
||||
<li key={row.key} className="flex items-start gap-2">
|
||||
{RESULT_ICON[row.status]}
|
||||
<span className="min-w-0 flex-1 text-sm text-foreground/90">{row.summary}</span>
|
||||
<IssueLink ref={row.link} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{decision.executionStatus !== "succeeded" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Some effects may already have been applied. Review the results before asking the proposer to re-propose.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({ issueStatus: "todo" }));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn(), setQueryData: vi.fn() }),
|
||||
useMutation: () => ({ isPending: false, error: null, mutate: vi.fn() }),
|
||||
useQuery: ({ queryKey }: { queryKey: readonly string[] }) => queryKey[1] === "detail"
|
||||
? {
|
||||
data: {
|
||||
id: "decision-1",
|
||||
companyId: "company-1",
|
||||
originAgentId: "agent-1",
|
||||
originIssueId: "origin-1",
|
||||
status: "open",
|
||||
targetSnapshots: { "target-1": { updatedAt: "2026-07-31T00:00:00.000Z" } },
|
||||
options: [{ id: "yes", label: "Yes", effects: [{
|
||||
type: "comment_on_issue", targetIssueId: "target-1", staleness: "lenient", bodyMarkdown: "hello",
|
||||
}] }],
|
||||
executions: [],
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}
|
||||
: { data: [], isLoading: false, error: null },
|
||||
useQueries: ({
|
||||
queries,
|
||||
combine,
|
||||
}: {
|
||||
queries: Array<{ queryKey: readonly string[] }>;
|
||||
combine?: (results: Array<{ data: { id: string; identifier: string; title: string; status: string } }>) => unknown;
|
||||
}) => {
|
||||
const results = queries.map(() => ({
|
||||
data: { id: "target-1", identifier: "PAP-1", title: "Target", status: state.issueStatus },
|
||||
}));
|
||||
return combine ? combine(results) : results;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
useCompany: () => ({ selectedCompany: { issuePrefix: "PAP" } }),
|
||||
}));
|
||||
|
||||
vi.mock("./DecisionCard", () => ({
|
||||
DecisionCard: ({ resolveIssue }: { resolveIssue: (id: string) => { status: string | null } | null }) => (
|
||||
<div data-testid="resolved-status">{resolveIssue("target-1")?.status}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import { DecisionResolver, signedCancelTreePreviewIds } from "./DecisionResolver";
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("DecisionResolver", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
state.issueStatus = "todo";
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
it("derives cancel-tree previews from the signed descendant scope", () => {
|
||||
expect(signedCancelTreePreviewIds("root", { descendantIds: ["signed-child", "signed-grandchild"] }))
|
||||
.toEqual(["root", "signed-child", "signed-grandchild"]);
|
||||
expect(signedCancelTreePreviewIds("root", undefined)).toBeNull();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("refreshes resolved target status when an issue query changes", () => {
|
||||
const root = createRoot(container);
|
||||
flushSync(() => root.render(<DecisionResolver companyId="company-1" decisionId="decision-1" />));
|
||||
expect(container.querySelector('[data-testid="resolved-status"]')?.textContent).toBe("todo");
|
||||
|
||||
state.issueStatus = "done";
|
||||
flushSync(() => root.render(<DecisionResolver companyId="company-1" decisionId="decision-1" />));
|
||||
expect(container.querySelector('[data-testid="resolved-status"]')?.textContent).toBe("done");
|
||||
|
||||
flushSync(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
import { useCallback, useMemo } from "react";
|
||||
import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { Agent, AttentionSubject } from "@paperclipai/shared";
|
||||
import { decisionsApi, type DecisionOutcome } from "../api/decisions";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { DecisionCard, type DecisionIssueRef } from "./DecisionCard";
|
||||
|
||||
interface DecisionResolverProps {
|
||||
companyId: string;
|
||||
decisionId: string;
|
||||
/** Origin issue subject from the attention row (already carries identifier/href). */
|
||||
originIssue?: AttentionSubject | null;
|
||||
agentMap?: Map<string, Agent>;
|
||||
/** Complete terminal list item used to avoid one detail request per history row. */
|
||||
initialDecision?: DecisionOutcome;
|
||||
/** Called after a decide/dismiss so the parent can refresh the feed row. */
|
||||
onResolved?: () => void;
|
||||
}
|
||||
|
||||
type IssueDetail = Awaited<ReturnType<typeof issuesApi.get>>;
|
||||
|
||||
// A module-level combine function lets TanStack Query structurally share this
|
||||
// projection until issue data actually changes. Depending on useQueries' raw
|
||||
// result array would invalidate resolveIssue on every render.
|
||||
function combineIssueData(results: Array<{ data?: IssueDetail }>) {
|
||||
return results.map((result) => result.data);
|
||||
}
|
||||
|
||||
export function signedCancelTreePreviewIds(targetIssueId: string, snapshot: { descendantIds?: string[] } | undefined) {
|
||||
return snapshot?.descendantIds ? [targetIssueId, ...snapshot.descendantIds] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Container for a Decisions-v1 decision surfaced in the attention feed. Fetches
|
||||
* the full decision (`get`) plus the shared open-list (for `targetChanged`),
|
||||
* resolves referenced issue ids + the cancel-tree preview, owns the decide /
|
||||
* dismiss mutations, and invalidates the attention feed + target-issue keys on
|
||||
* success — same conventions as {@link AttentionInteractionResolver}.
|
||||
*/
|
||||
export function DecisionResolver({ companyId, decisionId, originIssue, agentMap, initialDecision, onResolved }: DecisionResolverProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedCompany } = useCompany();
|
||||
const prefix = selectedCompany?.issuePrefix ?? "";
|
||||
const issueHref = useCallback(
|
||||
(idOrIdentifier: string) => (prefix ? `/${prefix}/issues/${idOrIdentifier}` : `/issues/${idOrIdentifier}`),
|
||||
[prefix],
|
||||
);
|
||||
|
||||
const detail = useQuery({
|
||||
queryKey: queryKeys.decisions.detail(decisionId),
|
||||
queryFn: () => decisionsApi.get(decisionId),
|
||||
enabled: !!decisionId,
|
||||
initialData: initialDecision,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const decision = detail.data;
|
||||
|
||||
// Shared across every open decision row → a single fetch carries `targetChanged`.
|
||||
const openList = useQuery({
|
||||
queryKey: queryKeys.decisions.list(companyId, "open"),
|
||||
queryFn: () => decisionsApi.list(companyId, { status: "open" }),
|
||||
enabled: !!companyId && decision?.status === "open",
|
||||
});
|
||||
const targetChanged = useMemo(
|
||||
() => openList.data?.find((entry) => entry.id === decisionId)?.targetChanged ?? null,
|
||||
[openList.data, decisionId],
|
||||
);
|
||||
|
||||
// Issue ids referenced by snapshots / effects / results, resolved to labels.
|
||||
const referencedIds = useMemo(() => {
|
||||
if (!decision) return [] as string[];
|
||||
const ids = new Set<string>(Object.keys(decision.targetSnapshots ?? {}));
|
||||
for (const snapshot of Object.values(decision.targetSnapshots ?? {})) {
|
||||
for (const descendantId of snapshot.descendantIds ?? []) ids.add(descendantId);
|
||||
}
|
||||
for (const option of decision.options) {
|
||||
for (const effect of option.effects) {
|
||||
ids.add(effect.targetIssueId);
|
||||
if (effect.type === "create_issue" && effect.draft.parentId) ids.add(effect.draft.parentId);
|
||||
}
|
||||
}
|
||||
for (const execution of decision.executions ?? []) {
|
||||
ids.add(execution.targetIssueId);
|
||||
const created = (execution.result ?? {}).issueId;
|
||||
if (typeof created === "string") ids.add(created);
|
||||
}
|
||||
if (originIssue?.id) ids.delete(originIssue.id);
|
||||
return [...ids];
|
||||
}, [decision, originIssue?.id]);
|
||||
|
||||
const issueData = useQueries({
|
||||
queries: referencedIds.map((id) => ({
|
||||
queryKey: queryKeys.issues.detail(id),
|
||||
queryFn: () => issuesApi.get(id),
|
||||
staleTime: 30_000,
|
||||
})),
|
||||
combine: combineIssueData,
|
||||
});
|
||||
|
||||
const resolveIssue = useMemo(() => {
|
||||
const map = new Map<string, DecisionIssueRef>();
|
||||
if (originIssue?.id) {
|
||||
map.set(originIssue.id, {
|
||||
id: originIssue.id,
|
||||
identifier: originIssue.identifier,
|
||||
title: originIssue.title,
|
||||
href: originIssue.href ?? issueHref(originIssue.identifier ?? originIssue.id),
|
||||
status: originIssue.status,
|
||||
});
|
||||
}
|
||||
issueData.forEach((issue, index) => {
|
||||
const id = referencedIds[index];
|
||||
if (issue && id) {
|
||||
map.set(id, {
|
||||
id,
|
||||
identifier: issue.identifier ?? null,
|
||||
title: issue.title ?? null,
|
||||
href: issueHref(issue.identifier ?? id),
|
||||
status: issue.status ?? null,
|
||||
});
|
||||
}
|
||||
});
|
||||
return (id: string) => map.get(id) ?? null;
|
||||
}, [originIssue, referencedIds, issueData, issueHref]);
|
||||
|
||||
const cancelTreePreview = useCallback((targetIssueId: string): DecisionIssueRef[] | null => {
|
||||
const snapshot = decision?.targetSnapshots?.[targetIssueId];
|
||||
const signedIds = signedCancelTreePreviewIds(targetIssueId, snapshot);
|
||||
if (!signedIds) return null;
|
||||
const refs = signedIds.map(resolveIssue);
|
||||
return refs.every((ref): ref is DecisionIssueRef => ref !== null) ? refs : null;
|
||||
}, [decision?.targetSnapshots, resolveIssue]);
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.decisions.detail(decisionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.decisions.list(companyId, "open") });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.decisions.list(companyId, "decided") });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) });
|
||||
for (const id of Object.keys(decision?.targetSnapshots ?? {})) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.decisions.forTargetIssue(companyId, id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(id) });
|
||||
}
|
||||
onResolved?.();
|
||||
};
|
||||
|
||||
const decideMutation = useMutation({
|
||||
mutationFn: (input: { optionId: string; inputValues: Record<string, string>; idempotencyKey?: string | null }) =>
|
||||
decisionsApi.decide(decisionId, input),
|
||||
retry: 2,
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(queryKeys.decisions.detail(decisionId), data);
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const dismissMutation = useMutation({
|
||||
mutationFn: (reason: string | undefined) => decisionsApi.dismiss(decisionId, reason),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(queryKeys.decisions.detail(decisionId), data);
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
if (detail.isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-3 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> Loading decision…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (detail.error || !decision) {
|
||||
return (
|
||||
<p className="py-3 text-xs text-muted-foreground">
|
||||
This decision is no longer available — it may have been resolved elsewhere.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const busy = decideMutation.isPending || dismissMutation.isPending;
|
||||
const errorMessage =
|
||||
(decideMutation.error instanceof Error && decideMutation.error.message) ||
|
||||
(dismissMutation.error instanceof Error && dismissMutation.error.message) ||
|
||||
null;
|
||||
|
||||
return (
|
||||
<DecisionCard
|
||||
decision={decision}
|
||||
executions={decision.executions}
|
||||
targetChanged={targetChanged}
|
||||
resolveIssue={resolveIssue}
|
||||
cancelTreePreview={cancelTreePreview}
|
||||
originAgentName={agentMap?.get(decision.originAgentId)?.name ?? null}
|
||||
originIssue={
|
||||
originIssue
|
||||
? {
|
||||
id: originIssue.id,
|
||||
identifier: originIssue.identifier,
|
||||
title: originIssue.title,
|
||||
href: originIssue.href ?? issueHref(originIssue.identifier ?? originIssue.id),
|
||||
status: originIssue.status,
|
||||
}
|
||||
: null
|
||||
}
|
||||
busy={busy}
|
||||
errorMessage={errorMessage}
|
||||
onDecide={(optionId, inputValues) => decideMutation.mutate({ optionId, inputValues, idempotencyKey: crypto.randomUUID() })}
|
||||
onDismiss={(reason) => dismissMutation.mutate(reason)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Scale } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { decisionsApi } from "../api/decisions";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
||||
/**
|
||||
* Information-scent breadcrumb on a target issue: when a decision proposed
|
||||
* *elsewhere* targets this issue (via `decision_target_issues`), surface it so
|
||||
* the pending decision isn't lost in another thread. It never actuates inline —
|
||||
* decisions are always decided from the one Decisions inbox (PAP-14966 §3).
|
||||
*/
|
||||
export function PendingDecisionStrip({ companyId, issueId }: { companyId: string; issueId: string }) {
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.decisions.forTargetIssue(companyId, issueId),
|
||||
queryFn: () => decisionsApi.list(companyId, { targetIssueId: issueId, status: "open" }),
|
||||
enabled: !!companyId && !!issueId,
|
||||
});
|
||||
|
||||
const count = data?.length ?? 0;
|
||||
if (count === 0) return null;
|
||||
|
||||
// Deep-link to the single decision when there's just one; otherwise the inbox.
|
||||
const to = count === 1 ? `/decisions?decisionId=${data![0]!.id}` : "/decisions";
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="flex items-center gap-2 rounded-lg border-l-2 border-violet-500/60 bg-violet-500/5 px-3 py-2 text-sm text-violet-900 transition-colors hover:bg-violet-500/10 dark:text-violet-100"
|
||||
>
|
||||
<Scale className="h-4 w-4 shrink-0 text-violet-600 dark:text-violet-400" aria-hidden />
|
||||
<span className="font-medium">
|
||||
{count === 1 ? "1 pending decision affects this issue" : `${count} pending decisions affect this issue`}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">Review in Decisions →</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
@ -115,6 +115,7 @@ describe("sourceMeta + severityStyle", () => {
|
|||
it("labels every catalog source kind", () => {
|
||||
const kinds: AttentionSourceKind[] = [
|
||||
"approval",
|
||||
"decision",
|
||||
"issue_thread_interaction",
|
||||
"join_request",
|
||||
"recovery_action",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import type {
|
|||
*/
|
||||
export const INLINE_RESOLVABLE_SOURCE_KINDS: ReadonlySet<AttentionSourceKind> = new Set<AttentionSourceKind>([
|
||||
"approval",
|
||||
"decision",
|
||||
"issue_thread_interaction",
|
||||
"join_request",
|
||||
]);
|
||||
|
|
@ -37,6 +38,7 @@ interface SourceMeta {
|
|||
|
||||
const SOURCE_META: Record<AttentionSourceKind, SourceMeta> = {
|
||||
approval: { label: "Approval" },
|
||||
decision: { label: "Decision" },
|
||||
issue_thread_interaction: { label: "Decision requested" },
|
||||
join_request: { label: "Join request" },
|
||||
recovery_action: { label: "Recovery" },
|
||||
|
|
@ -106,6 +108,8 @@ export const ATTENTION_KIND_STATUS: Record<AttentionKind, "blocked" | "in_review
|
|||
/** Does this row report something stuck, or something waiting on a verdict? */
|
||||
export function attentionKind(item: AttentionItem): AttentionKind {
|
||||
switch (item.sourceKind) {
|
||||
case "decision":
|
||||
return "review";
|
||||
case "failed_run":
|
||||
case "agent_error_alert":
|
||||
case "blocker_attention":
|
||||
|
|
|
|||
|
|
@ -388,6 +388,13 @@ export const queryKeys = {
|
|||
list: (companyId: string) => ["decision-training", companyId] as const,
|
||||
detail: (id: string) => ["decision-training", "detail", id] as const,
|
||||
},
|
||||
decisions: {
|
||||
list: (companyId: string, status?: string) =>
|
||||
["decisions", companyId, status ?? "__all-statuses__"] as const,
|
||||
detail: (id: string) => ["decisions", "detail", id] as const,
|
||||
forTargetIssue: (companyId: string, issueId: string) =>
|
||||
["decisions", companyId, "target", issueId] as const,
|
||||
},
|
||||
workTimeline: (companyId: string, lens?: string) => ["work-timeline", companyId, lens ?? "all"] as const,
|
||||
userProfile: (companyId: string, userSlug: string) =>
|
||||
["user-profile", companyId, userSlug] as const,
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ import {
|
|||
} from "../components/IssueMonitorBanner";
|
||||
import { IssueScheduledRetryCard } from "../components/IssueScheduledRetryCard";
|
||||
import { IssueProperties } from "../components/IssueProperties";
|
||||
import { PendingDecisionStrip } from "../components/PendingDecisionStrip";
|
||||
import { PauseAffectsSummaryView } from "../components/interrupt-handoff/InterruptHandoffViews";
|
||||
import { computePauseAffectsSummary } from "../lib/interrupt-handoff";
|
||||
import { useIssueExternalObjects } from "../hooks/useIssueExternalObjects";
|
||||
|
|
@ -4516,6 +4517,8 @@ export function IssueDetail() {
|
|||
checkingNow={checkIssueMonitorNow.isPending}
|
||||
/>
|
||||
|
||||
<PendingDecisionStrip companyId={issue.companyId} issueId={issue.id} />
|
||||
|
||||
<InlineEditor
|
||||
value={issue.description ?? ""}
|
||||
onSave={(description) => updateIssue.mutateAsync({ description })}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DecisionBundleHeader, decisionHistoryCount, decisionHistoryQueryEnabled } from "./WhatNeedsMe";
|
||||
|
||||
describe("WhatNeedsMe decision history", () => {
|
||||
it("defers terminal-history queries until their curtain opens", () => {
|
||||
expect(decisionHistoryQueryEnabled("company-1", false)).toBe(false);
|
||||
expect(decisionHistoryQueryEnabled("company-1", true)).toBe(true);
|
||||
expect(decisionHistoryQueryEnabled(null, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("discloses when terminal history exceeds the visible window", () => {
|
||||
expect(decisionHistoryCount(undefined)).toBeUndefined();
|
||||
expect(decisionHistoryCount(49)).toBe(49);
|
||||
expect(decisionHistoryCount(50)).toBe(50);
|
||||
expect(decisionHistoryCount(51)).toBe("50+");
|
||||
});
|
||||
|
||||
it("labels general bundles as decisions instead of cleanups", () => {
|
||||
const single = renderToStaticMarkup(
|
||||
<DecisionBundleHeader agentName="Planner" title="Choose a route" originIssue={null} count={1} />,
|
||||
);
|
||||
const multiple = renderToStaticMarkup(
|
||||
<DecisionBundleHeader agentName="Planner" title="Choose routes" originIssue={null} count={2} />,
|
||||
);
|
||||
|
||||
expect(single).toContain("Planner proposed 1 decision");
|
||||
expect(multiple).toContain("Planner proposed 2 decisions");
|
||||
expect(single).not.toContain("cleanup");
|
||||
expect(multiple).not.toContain("cleanup");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowUpDown, Check, CheckCircle2, GraduationCap, Inbox, Layers, ListFilter } from "lucide-react";
|
||||
import type { Agent, AttentionItem } from "@paperclipai/shared";
|
||||
import type { Agent, AttentionItem, AttentionSubject } from "@paperclipai/shared";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { attentionApi } from "../api/attention";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { authApi } from "../api/auth";
|
||||
import { decisionsApi } from "../api/decisions";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useToastActions } from "../context/ToastContext";
|
||||
|
|
@ -41,6 +42,7 @@ import { cn } from "../lib/utils";
|
|||
import { hasBlockingShortcutDialog, resolveAttentionQueueKeyAction } from "../lib/keyboardShortcuts";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { AttentionQueueRow } from "../components/AttentionQueueRow";
|
||||
import { DecisionResolver } from "../components/DecisionResolver";
|
||||
import { DecisionTrainingDrawer } from "../components/DecisionTrainingDrawer";
|
||||
import { IssueGroupHeader } from "../components/IssueGroupHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
|
|
@ -65,6 +67,17 @@ const noopToggleExpand = () => {};
|
|||
const INITIAL_ATTENTION_ROW_RENDER_LIMIT = 50;
|
||||
const ATTENTION_ROW_RENDER_BATCH_SIZE = 100;
|
||||
const ATTENTION_SCROLL_LOAD_THRESHOLD_PX = 480;
|
||||
const DECISION_HISTORY_VISIBLE_LIMIT = 50;
|
||||
const DECISION_HISTORY_QUERY_LIMIT = DECISION_HISTORY_VISIBLE_LIMIT + 1;
|
||||
|
||||
export function decisionHistoryQueryEnabled(companyId: string | null | undefined, open: boolean) {
|
||||
return Boolean(companyId && open);
|
||||
}
|
||||
|
||||
export function decisionHistoryCount(count: number | undefined) {
|
||||
if (count == null) return undefined;
|
||||
return count > DECISION_HISTORY_VISIBLE_LIMIT ? `${DECISION_HISTORY_VISIBLE_LIMIT}+` : count;
|
||||
}
|
||||
|
||||
function findScrollContainer(element: HTMLElement | null): HTMLElement | null {
|
||||
if (!element || typeof window === "undefined") return null;
|
||||
|
|
@ -103,6 +116,8 @@ export function WhatNeedsMe() {
|
|||
const [collapsedGroupKeys, setCollapsedGroupKeys] = useState<Set<string>>(() => new Set());
|
||||
const [snoozedOpen, setSnoozedOpen] = useState(false);
|
||||
const [dismissedOpen, setDismissedOpen] = useState(false);
|
||||
const [decidedOpen, setDecidedOpen] = useState(false);
|
||||
const [expiredOpen, setExpiredOpen] = useState(false);
|
||||
|
||||
// Optimistic hide/restore. Reset whenever a fresh feed lands (server truth).
|
||||
const [pendingHide, setPendingHide] = useState<Set<string>>(() => new Set());
|
||||
|
|
@ -142,6 +157,19 @@ export function WhatNeedsMe() {
|
|||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
// Decision history — decided / expired decisions leave the open attention
|
||||
// feed (entryRule = open only), so we fetch them directly for the curtains.
|
||||
const { data: decidedDecisions, isLoading: decidedDecisionsLoading } = useQuery({
|
||||
queryKey: queryKeys.decisions.list(selectedCompanyId!, "decided"),
|
||||
queryFn: () => decisionsApi.list(selectedCompanyId!, { status: "decided", limit: DECISION_HISTORY_QUERY_LIMIT }),
|
||||
enabled: decisionHistoryQueryEnabled(selectedCompanyId, decidedOpen),
|
||||
});
|
||||
const { data: expiredDecisions, isLoading: expiredDecisionsLoading } = useQuery({
|
||||
queryKey: queryKeys.decisions.list(selectedCompanyId!, "expired"),
|
||||
queryFn: () => decisionsApi.list(selectedCompanyId!, { status: "expired", limit: DECISION_HISTORY_QUERY_LIMIT }),
|
||||
enabled: decisionHistoryQueryEnabled(selectedCompanyId, expiredOpen),
|
||||
});
|
||||
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
|
|
@ -571,21 +599,53 @@ export function WhatNeedsMe() {
|
|||
)}
|
||||
{!collapsed && (
|
||||
<div className="space-y-4">
|
||||
{(renderPlan.groupRows.get(group.key) ?? []).map((item) => (
|
||||
<AttentionQueueRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
companyId={selectedCompanyId}
|
||||
expanded={expandedId === item.id}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
onDismiss={handleDismiss}
|
||||
onSnooze={handleSnooze}
|
||||
onTrain={handleTrain}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
selected={selectionFromKeyboard && selectedAttentionId === item.id}
|
||||
/>
|
||||
))}
|
||||
{(() => {
|
||||
const rows = renderPlan.groupRows.get(group.key) ?? [];
|
||||
const seenBundles = new Set<string>();
|
||||
return rows.map((item) => {
|
||||
const bundleId =
|
||||
item.sourceKind === "decision"
|
||||
? ((item.subject.metadata?.bundleId as string | null | undefined) ?? null)
|
||||
: null;
|
||||
let header: ReactNode = null;
|
||||
if (bundleId && !seenBundles.has(bundleId)) {
|
||||
seenBundles.add(bundleId);
|
||||
const bundleRows = rows.filter(
|
||||
(row) =>
|
||||
row.sourceKind === "decision" &&
|
||||
((row.subject.metadata?.bundleId as string | null | undefined) ?? null) === bundleId,
|
||||
);
|
||||
const first = bundleRows[0];
|
||||
header = (
|
||||
<DecisionBundleHeader
|
||||
agentName={agentMap.get(first?.subject.metadata?.originAgentId as string)?.name ?? null}
|
||||
title={(first?.subject.metadata?.bundleTitle as string | null | undefined) ?? null}
|
||||
originIssue={first?.relatedIssue ?? null}
|
||||
count={bundleRows.length}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Fragment key={item.id}>
|
||||
{header}
|
||||
<div className={bundleId ? "border-l-2 border-violet-500/40 pl-3" : undefined}>
|
||||
<AttentionQueueRow
|
||||
item={item}
|
||||
companyId={selectedCompanyId}
|
||||
expanded={expandedId === item.id}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
onDismiss={handleDismiss}
|
||||
onSnooze={handleSnooze}
|
||||
onTrain={handleTrain}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
selected={selectionFromKeyboard && selectedAttentionId === item.id}
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
|
@ -640,9 +700,58 @@ export function WhatNeedsMe() {
|
|||
))}
|
||||
</Curtain>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<Curtain
|
||||
label="Decided"
|
||||
count={decisionHistoryCount(decidedDecisions?.length)}
|
||||
open={decidedOpen}
|
||||
onToggle={() => setDecidedOpen((prev) => !prev)}
|
||||
>
|
||||
{decidedDecisionsLoading ? (
|
||||
<p className="text-xs text-muted-foreground">Loading decided decisions…</p>
|
||||
) : (decidedDecisions?.length ?? 0) > 0 ? (
|
||||
decidedDecisions!.slice(0, DECISION_HISTORY_VISIBLE_LIMIT).map((decision) => (
|
||||
<DecisionResolver
|
||||
key={decision.id}
|
||||
companyId={selectedCompanyId}
|
||||
decisionId={decision.id}
|
||||
agentMap={agentMap}
|
||||
initialDecision={{ ...decision, executions: decision.executions ?? [] }}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No decided decisions.</p>
|
||||
)}
|
||||
</Curtain>
|
||||
|
||||
<Curtain
|
||||
label="Expired"
|
||||
count={decisionHistoryCount(expiredDecisions?.length)}
|
||||
open={expiredOpen}
|
||||
onToggle={() => setExpiredOpen((prev) => !prev)}
|
||||
>
|
||||
{expiredDecisionsLoading ? (
|
||||
<p className="text-xs text-muted-foreground">Loading expired decisions…</p>
|
||||
) : (expiredDecisions?.length ?? 0) > 0 ? (
|
||||
expiredDecisions!.slice(0, DECISION_HISTORY_VISIBLE_LIMIT).map((decision) => (
|
||||
<DecisionResolver
|
||||
key={decision.id}
|
||||
companyId={selectedCompanyId}
|
||||
decisionId={decision.id}
|
||||
agentMap={agentMap}
|
||||
initialDecision={{ ...decision, executions: decision.executions ?? [] }}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No expired decisions.</p>
|
||||
)}
|
||||
</Curtain>
|
||||
</div>
|
||||
|
||||
<DecisionTrainingDrawer
|
||||
open={trainingItem !== null}
|
||||
onOpenChange={(next) => {
|
||||
|
|
@ -656,6 +765,46 @@ export function WhatNeedsMe() {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Violet left-rule strip over a run of decisions that share a bundle, e.g.
|
||||
* "Planner proposed 6 decisions · from PAP-123 · routing review · 6 pending".
|
||||
* Grouping is a surface only — each decision is still decided independently.
|
||||
*/
|
||||
export function DecisionBundleHeader({
|
||||
agentName,
|
||||
title,
|
||||
originIssue,
|
||||
count,
|
||||
}: {
|
||||
agentName: string | null;
|
||||
title: string | null;
|
||||
originIssue: AttentionSubject | null;
|
||||
count: number;
|
||||
}) {
|
||||
const noun = count === 1 ? "decision" : "decisions";
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 rounded-sm border-l-2 border-violet-500/60 bg-violet-500/5 px-3 py-1.5 text-xs">
|
||||
<span className="font-semibold text-violet-800 dark:text-violet-200">
|
||||
{agentName ?? "An agent"} proposed {count} {noun}
|
||||
</span>
|
||||
{originIssue && (originIssue.identifier || originIssue.title) && (
|
||||
<span className="text-muted-foreground">
|
||||
{"· from "}
|
||||
{originIssue.href ? (
|
||||
<a href={originIssue.href} className="hover:underline">
|
||||
{originIssue.identifier ?? originIssue.title}
|
||||
</a>
|
||||
) : (
|
||||
originIssue.identifier ?? originIssue.title
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{title && <span className="text-muted-foreground">· {title}</span>}
|
||||
<span className="text-muted-foreground">· {count} pending</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterMenu({
|
||||
options,
|
||||
filters,
|
||||
|
|
@ -796,7 +945,7 @@ function Curtain({
|
|||
children,
|
||||
}: {
|
||||
label: string;
|
||||
count: number;
|
||||
count?: number | string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
children: ReactNode;
|
||||
|
|
@ -804,7 +953,7 @@ function Curtain({
|
|||
return (
|
||||
<section className="space-y-2">
|
||||
<IssueGroupHeader
|
||||
label={`${label} (${count})`}
|
||||
label={count == null ? label : `${label} (${count})`}
|
||||
collapsible
|
||||
collapsed={!open}
|
||||
onToggle={onToggle}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,220 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { DecisionCard, type DecisionIssueRef } from "@/components/DecisionCard";
|
||||
import type { Decision, DecisionEffectExecution } from "@/api/decisions";
|
||||
|
||||
// --- fixtures ---------------------------------------------------------------
|
||||
|
||||
const ISSUES: Record<string, DecisionIssueRef> = {
|
||||
"issue-origin": { id: "issue-origin", identifier: "PAP-123", title: "Gardener sweep", href: "/PAP/issues/PAP-123", status: "in_progress" },
|
||||
"issue-target": { id: "issue-target", identifier: "PAP-456", title: "Stale integration epic", href: "/PAP/issues/PAP-456", status: "backlog" },
|
||||
"issue-child-1": { id: "issue-child-1", identifier: "PAP-457", title: "Wire the adapter", href: "/PAP/issues/PAP-457", status: "backlog" },
|
||||
"issue-child-2": { id: "issue-child-2", identifier: "PAP-458", title: "Backfill fixtures", href: "/PAP/issues/PAP-458", status: "todo" },
|
||||
"issue-new": { id: "issue-new", identifier: "PAP-999", title: "Follow-up: document rollout", href: "/PAP/issues/PAP-999", status: "todo" },
|
||||
};
|
||||
|
||||
const resolveIssue = (id: string): DecisionIssueRef | null => ISSUES[id] ?? null;
|
||||
const cancelTreePreview = () => [ISSUES["issue-target"]!, ISSUES["issue-child-1"]!, ISSUES["issue-child-2"]!];
|
||||
|
||||
const originIssue = ISSUES["issue-origin"]!;
|
||||
|
||||
function mkDecision(overrides: Partial<Decision> = {}): Decision {
|
||||
return {
|
||||
id: "decision-1",
|
||||
companyId: "company-storybook",
|
||||
bundleId: null,
|
||||
originAgentId: "agent-gardener",
|
||||
originIssueId: "issue-origin",
|
||||
originRunId: "run-1",
|
||||
ruleKey: "stale-epic-sweep",
|
||||
title: "Stale epic PAP-456 hasn’t moved in 21 days",
|
||||
body: "PAP-456 and its two sub-issues have had no activity for three weeks. Cancel the tree, or keep it and I’ll snooze for another week.",
|
||||
options: [
|
||||
{
|
||||
id: "keep",
|
||||
label: "Keep it open",
|
||||
description: "Leave the epic as-is; I’ll re-check in a week.",
|
||||
style: "default",
|
||||
effects: [{ type: "comment_on_issue", targetIssueId: "issue-target", staleness: "lenient", bodyMarkdown: "Kept open by the board." }],
|
||||
},
|
||||
],
|
||||
inputs: null,
|
||||
status: "open",
|
||||
executionStatus: null,
|
||||
chosenOptionId: null,
|
||||
inputValues: null,
|
||||
decidedByUserId: null,
|
||||
decidedAt: null,
|
||||
expiresAt: "2026-07-29T12:00:00Z",
|
||||
idempotencyKey: null,
|
||||
targetSnapshots: {
|
||||
"issue-target": { status: "backlog", assigneeAgentId: null, assigneeUserId: null, updatedAt: "2026-07-01T09:00:00Z", childCount: 2 },
|
||||
},
|
||||
continuationPolicy: "none",
|
||||
metadata: {},
|
||||
createdAt: "2026-07-22T09:00:00Z",
|
||||
updatedAt: "2026-07-22T09:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const cancelTreeOption = {
|
||||
id: "cancel",
|
||||
label: "Cancel the tree",
|
||||
description: "Cancel PAP-456 and everything beneath it.",
|
||||
style: "destructive" as const,
|
||||
effects: [
|
||||
{ type: "cancel_issue_tree" as const, targetIssueId: "issue-target", staleness: "strict" as const, reasonComment: "Cancelled as stale by the board." },
|
||||
],
|
||||
};
|
||||
|
||||
function exec(overrides: Partial<DecisionEffectExecution>): DecisionEffectExecution {
|
||||
return {
|
||||
id: `exec-${Math.round(Math.random() * 1e6)}`,
|
||||
decisionId: "decision-1",
|
||||
effectIndex: 0,
|
||||
effectType: "comment_on_issue",
|
||||
targetIssueId: "issue-target",
|
||||
status: "executed",
|
||||
result: {},
|
||||
error: null,
|
||||
activityLogId: null,
|
||||
executedAt: "2026-07-22T10:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const shared = {
|
||||
resolveIssue,
|
||||
cancelTreePreview,
|
||||
originAgentName: "Gardener",
|
||||
originIssue,
|
||||
};
|
||||
|
||||
const meta: Meta<typeof DecisionCard> = {
|
||||
title: "Decisions/DecisionCard",
|
||||
component: DecisionCard,
|
||||
render: (args) => (
|
||||
<div className="max-w-2xl p-6">
|
||||
<DecisionCard {...args} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof DecisionCard>;
|
||||
|
||||
export const Pending: Story = {
|
||||
args: {
|
||||
...shared,
|
||||
decision: mkDecision({
|
||||
options: [
|
||||
{
|
||||
id: "cancel-soft",
|
||||
label: "Comment and snooze",
|
||||
description: "Post a nudge and re-check in a week.",
|
||||
effects: [{ type: "comment_on_issue", targetIssueId: "issue-target", staleness: "lenient", bodyMarkdown: "Nudging — still stale." }],
|
||||
},
|
||||
{
|
||||
id: "create",
|
||||
label: "Split off a follow-up",
|
||||
effects: [
|
||||
{ type: "create_issue", targetIssueId: "issue-target", staleness: "lenient", draft: { title: "Follow-up: document rollout", parentId: "issue-target" } },
|
||||
{ type: "update_issue_status", targetIssueId: "issue-target", staleness: "lenient", status: "done" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const PendingWithInput: Story = {
|
||||
args: {
|
||||
...shared,
|
||||
decision: mkDecision({
|
||||
body: "Reassign PAP-456 to whoever should own it. Add a note for the record.",
|
||||
inputs: [{ id: "note", label: "Reassignment note", placeholder: "Why this owner?", required: true, maxLength: 500 }],
|
||||
options: [
|
||||
{
|
||||
id: "assign",
|
||||
label: "Reassign the epic",
|
||||
effects: [{ type: "assign_issue", targetIssueId: "issue-target", staleness: "lenient", comment: "Reassigned: {{input.note}}" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const StaleTarget: Story = {
|
||||
args: {
|
||||
...shared,
|
||||
targetChanged: { "issue-target": true },
|
||||
decision: mkDecision({
|
||||
options: [
|
||||
{
|
||||
id: "cancel-strict",
|
||||
label: "Cancel it (needs unchanged target)",
|
||||
effects: [{ type: "update_issue_status", targetIssueId: "issue-target", staleness: "strict", status: "cancelled" }],
|
||||
},
|
||||
{
|
||||
id: "comment-lenient",
|
||||
label: "Just comment",
|
||||
effects: [{ type: "comment_on_issue", targetIssueId: "issue-target", staleness: "lenient", bodyMarkdown: "Still worth a look." }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const DestructiveCancelTree: Story = {
|
||||
args: {
|
||||
...shared,
|
||||
decision: mkDecision({ options: [cancelTreeOption] }),
|
||||
},
|
||||
};
|
||||
|
||||
export const Decided: Story = {
|
||||
args: {
|
||||
...shared,
|
||||
decision: mkDecision({ status: "decided", executionStatus: "succeeded", chosenOptionId: "create", decidedAt: "2026-07-22T10:00:00Z" }),
|
||||
executions: [
|
||||
exec({ effectIndex: 0, effectType: "create_issue", status: "executed", result: { issueId: "issue-new" } }),
|
||||
exec({ effectIndex: 1, effectType: "update_issue_status", status: "executed", result: { issueId: "issue-target", status: "done" } }),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Partial: Story = {
|
||||
args: {
|
||||
...shared,
|
||||
decision: mkDecision({ status: "decided", executionStatus: "partial", chosenOptionId: "create", decidedAt: "2026-07-22T10:00:00Z" }),
|
||||
executions: [
|
||||
exec({ effectIndex: 0, effectType: "comment_on_issue", status: "executed" }),
|
||||
exec({ effectIndex: 1, effectType: "update_issue_status", status: "failed", error: "deny_decision_intersection" }),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Failed: Story = {
|
||||
args: {
|
||||
...shared,
|
||||
decision: mkDecision({ status: "decided", executionStatus: "failed", chosenOptionId: "cancel", decidedAt: "2026-07-22T10:00:00Z" }),
|
||||
executions: [
|
||||
exec({ effectIndex: 0, effectType: "cancel_issue_tree", status: "skipped", error: "target_changed" }),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Expired: Story = {
|
||||
args: {
|
||||
...shared,
|
||||
decision: mkDecision({ status: "expired", metadata: { expiredReason: "ttl" }, continuationPolicy: "wake_origin_agent" }),
|
||||
},
|
||||
};
|
||||
|
||||
export const Dismissed: Story = {
|
||||
args: {
|
||||
...shared,
|
||||
decision: mkDecision({ status: "decided", executionStatus: "succeeded", chosenOptionId: "dismissed", decidedAt: "2026-07-22T10:00:00Z", metadata: { dismissed: true } }),
|
||||
executions: [],
|
||||
},
|
||||
};
|
||||
Loading…
Reference in New Issue