History

Backfill projected shell summaries and stale approval cleanup (#2004)

Julius Marminge committed 7 months ago

Commit c9b07d61 parent 2e42f3f5 files · +859 −0Browse files

Unified · Split

5 files
  1. apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +323 −0
  2. apps/server/src/orchestration/Layers/ProjectionPipeline.ts +39 −0
  3. apps/server/src/persistence/Migrations.ts +2 −0
  4. apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts +218 −0
  5. apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.ts +277 −0
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+323−0
@@ -1505,6 +1505,329 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 1504 unmodified lines
15051505 }),
15061506 );
15071507
1508+ it.effect("clears stale pending approvals from projected shell summaries", () =>
1509+ Effect.gen(function* () {
1510+ const projectionPipeline = yield* OrchestrationProjectionPipeline;
1511+ const eventStore = yield* OrchestrationEventStore;
1512+ const sql = yield* SqlClient.SqlClient;
1513+ const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
1514+ eventStore
1515+ .append(event)
1516+ .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
1517+
1518+ yield* appendAndProject({
1519+ type: "project.created",
1520+ eventId: EventId.make("evt-stale-approval-1"),
1521+ aggregateKind: "project",
1522+ aggregateId: ProjectId.make("project-stale-approval"),
1523+ occurredAt: "2026-02-26T12:30:00.000Z",
1524+ commandId: CommandId.make("cmd-stale-approval-1"),
1525+ causationEventId: null,
1526+ correlationId: CorrelationId.make("cmd-stale-approval-1"),
1527+ metadata: {},
1528+ payload: {
1529+ projectId: ProjectId.make("project-stale-approval"),
1530+ title: "Project Stale Approval",
1531+ workspaceRoot: "/tmp/project-stale-approval",
1532+ defaultModelSelection: null,
1533+ scripts: [],
1534+ createdAt: "2026-02-26T12:30:00.000Z",
1535+ updatedAt: "2026-02-26T12:30:00.000Z",
1536+ },
1537+ });
1538+
1539+ yield* appendAndProject({
1540+ type: "thread.created",
1541+ eventId: EventId.make("evt-stale-approval-2"),
1542+ aggregateKind: "thread",
1543+ aggregateId: ThreadId.make("thread-stale-approval"),
1544+ occurredAt: "2026-02-26T12:30:01.000Z",
1545+ commandId: CommandId.make("cmd-stale-approval-2"),
1546+ causationEventId: null,
1547+ correlationId: CorrelationId.make("cmd-stale-approval-2"),
1548+ metadata: {},
1549+ payload: {
1550+ threadId: ThreadId.make("thread-stale-approval"),
1551+ projectId: ProjectId.make("project-stale-approval"),
1552+ title: "Thread Stale Approval",
1553+ modelSelection: {
1554+ provider: "codex",
1555+ model: "gpt-5-codex",
1556+ },
1557+ runtimeMode: "approval-required",
1558+ interactionMode: "default",
1559+ branch: null,
1560+ worktreePath: null,
1561+ createdAt: "2026-02-26T12:30:01.000Z",
1562+ updatedAt: "2026-02-26T12:30:01.000Z",
1563+ },
1564+ });
1565+
1566+ yield* appendAndProject({
1567+ type: "thread.activity-appended",
1568+ eventId: EventId.make("evt-stale-approval-3"),
1569+ aggregateKind: "thread",
1570+ aggregateId: ThreadId.make("thread-stale-approval"),
1571+ occurredAt: "2026-02-26T12:30:02.000Z",
1572+ commandId: CommandId.make("cmd-stale-approval-3"),
1573+ causationEventId: null,
1574+ correlationId: CorrelationId.make("cmd-stale-approval-3"),
1575+ metadata: {},
1576+ payload: {
1577+ threadId: ThreadId.make("thread-stale-approval"),
1578+ activity: {
1579+ id: EventId.make("activity-stale-approval-requested"),
1580+ tone: "approval",
1581+ kind: "approval.requested",
1582+ summary: "Command approval requested",
1583+ payload: {
1584+ requestId: "approval-request-stale-1",
1585+ requestKind: "command",
1586+ },
1587+ turnId: null,
1588+ createdAt: "2026-02-26T12:30:02.000Z",
1589+ },
1590+ },
1591+ });
1592+
1593+ yield* appendAndProject({
1594+ type: "thread.activity-appended",
1595+ eventId: EventId.make("evt-stale-approval-4"),
1596+ aggregateKind: "thread",
1597+ aggregateId: ThreadId.make("thread-stale-approval"),
1598+ occurredAt: "2026-02-26T12:30:03.000Z",
1599+ commandId: CommandId.make("cmd-stale-approval-4"),
1600+ causationEventId: null,
1601+ correlationId: CorrelationId.make("cmd-stale-approval-4"),
1602+ metadata: {},
1603+ payload: {
1604+ threadId: ThreadId.make("thread-stale-approval"),
1605+ activity: {
1606+ id: EventId.make("activity-stale-approval-failed"),
1607+ tone: "error",
1608+ kind: "provider.approval.respond.failed",
1609+ summary: "Provider approval response failed",
1610+ payload: {
1611+ requestId: "approval-request-stale-1",
1612+ detail: "Unknown pending permission request: approval-request-stale-1",
1613+ },
1614+ turnId: null,
1615+ createdAt: "2026-02-26T12:30:03.000Z",
1616+ },
1617+ },
1618+ });
1619+
1620+ const approvalRows = yield* sql<{
1621+ readonly requestId: string;
1622+ readonly status: string;
1623+ readonly resolvedAt: string | null;
1624+ }>`
1625+ SELECT
1626+ request_id AS "requestId",
1627+ status,
1628+ resolved_at AS "resolvedAt"
1629+ FROM projection_pending_approvals
1630+ WHERE request_id = 'approval-request-stale-1'
1631+ `;
1632+ assert.deepEqual(approvalRows, [
1633+ {
1634+ requestId: "approval-request-stale-1",
1635+ status: "resolved",
1636+ resolvedAt: "2026-02-26T12:30:03.000Z",
1637+ },
1638+ ]);
1639+
1640+ const threadRows = yield* sql<{
1641+ readonly pendingApprovalCount: number;
1642+ }>`
1643+ SELECT pending_approval_count AS "pendingApprovalCount"
1644+ FROM projection_threads
1645+ WHERE thread_id = 'thread-stale-approval'
1646+ `;
1647+ assert.deepEqual(threadRows, [{ pendingApprovalCount: 0 }]);
1648+ }),
1649+ );
1650+
1651+ it.effect("ignores non-stale provider approval response failures", () =>
1652+ Effect.gen(function* () {
1653+ const projectionPipeline = yield* OrchestrationProjectionPipeline;
1654+ const eventStore = yield* OrchestrationEventStore;
1655+ const sql = yield* SqlClient.SqlClient;
1656+ const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
1657+ eventStore
1658+ .append(event)
1659+ .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
1660+
1661+ yield* appendAndProject({
1662+ type: "project.created",
1663+ eventId: EventId.make("evt-nonstale-approval-1"),
1664+ aggregateKind: "project",
1665+ aggregateId: ProjectId.make("project-nonstale-approval"),
1666+ occurredAt: "2026-02-26T12:45:00.000Z",
1667+ commandId: CommandId.make("cmd-nonstale-approval-1"),
1668+ causationEventId: null,
1669+ correlationId: CorrelationId.make("cmd-nonstale-approval-1"),
1670+ metadata: {},
1671+ payload: {
1672+ projectId: ProjectId.make("project-nonstale-approval"),
1673+ title: "Project Non-Stale Approval",
1674+ workspaceRoot: "/tmp/project-nonstale-approval",
1675+ defaultModelSelection: null,
1676+ scripts: [],
1677+ createdAt: "2026-02-26T12:45:00.000Z",
1678+ updatedAt: "2026-02-26T12:45:00.000Z",
1679+ },
1680+ });
1681+
1682+ yield* appendAndProject({
1683+ type: "thread.created",
1684+ eventId: EventId.make("evt-nonstale-approval-2"),
1685+ aggregateKind: "thread",
1686+ aggregateId: ThreadId.make("thread-nonstale-approval"),
1687+ occurredAt: "2026-02-26T12:45:01.000Z",
1688+ commandId: CommandId.make("cmd-nonstale-approval-2"),
1689+ causationEventId: null,
1690+ correlationId: CorrelationId.make("cmd-nonstale-approval-2"),
1691+ metadata: {},
1692+ payload: {
1693+ threadId: ThreadId.make("thread-nonstale-approval"),
1694+ projectId: ProjectId.make("project-nonstale-approval"),
1695+ title: "Thread Non-Stale Approval",
1696+ modelSelection: {
1697+ provider: "codex",
1698+ model: "gpt-5-codex",
1699+ },
1700+ runtimeMode: "approval-required",
1701+ interactionMode: "default",
1702+ branch: null,
1703+ worktreePath: null,
1704+ createdAt: "2026-02-26T12:45:01.000Z",
1705+ updatedAt: "2026-02-26T12:45:01.000Z",
1706+ },
1707+ });
1708+
1709+ yield* appendAndProject({
1710+ type: "thread.activity-appended",
1711+ eventId: EventId.make("evt-nonstale-approval-3"),
1712+ aggregateKind: "thread",
1713+ aggregateId: ThreadId.make("thread-nonstale-approval"),
1714+ occurredAt: "2026-02-26T12:45:02.000Z",
1715+ commandId: CommandId.make("cmd-nonstale-approval-3"),
1716+ causationEventId: null,
1717+ correlationId: CorrelationId.make("cmd-nonstale-approval-3"),
1718+ metadata: {},
1719+ payload: {
1720+ threadId: ThreadId.make("thread-nonstale-approval"),
1721+ activity: {
1722+ id: EventId.make("activity-nonstale-approval-requested"),
1723+ tone: "approval",
1724+ kind: "approval.requested",
1725+ summary: "Command approval requested",
1726+ payload: {
1727+ requestId: "approval-request-nonstale-existing",
1728+ requestKind: "command",
1729+ },
1730+ turnId: null,
1731+ createdAt: "2026-02-26T12:45:02.000Z",
1732+ },
1733+ },
1734+ });
1735+
1736+ yield* appendAndProject({
1737+ type: "thread.activity-appended",
1738+ eventId: EventId.make("evt-nonstale-approval-4"),
1739+ aggregateKind: "thread",
1740+ aggregateId: ThreadId.make("thread-nonstale-approval"),
1741+ occurredAt: "2026-02-26T12:45:03.000Z",
1742+ commandId: CommandId.make("cmd-nonstale-approval-4"),
1743+ causationEventId: null,
1744+ correlationId: CorrelationId.make("cmd-nonstale-approval-4"),
1745+ metadata: {},
1746+ payload: {
1747+ threadId: ThreadId.make("thread-nonstale-approval"),
1748+ activity: {
1749+ id: EventId.make("activity-nonstale-approval-failed-existing"),
1750+ tone: "error",
1751+ kind: "provider.approval.respond.failed",
1752+ summary: "Provider approval response failed",
1753+ payload: {
1754+ requestId: "approval-request-nonstale-existing",
1755+ detail: "Provider timed out while responding to approval request",
1756+ },
1757+ turnId: TurnId.make("turn-nonstale-failure"),
1758+ createdAt: "2026-02-26T12:45:03.000Z",
1759+ },
1760+ },
1761+ });
1762+
1763+ yield* appendAndProject({
1764+ type: "thread.activity-appended",
1765+ eventId: EventId.make("evt-nonstale-approval-5"),
1766+ aggregateKind: "thread",
1767+ aggregateId: ThreadId.make("thread-nonstale-approval"),
1768+ occurredAt: "2026-02-26T12:45:04.000Z",
1769+ commandId: CommandId.make("cmd-nonstale-approval-5"),
1770+ causationEventId: null,
1771+ correlationId: CorrelationId.make("cmd-nonstale-approval-5"),
1772+ metadata: {},
1773+ payload: {
1774+ threadId: ThreadId.make("thread-nonstale-approval"),
1775+ activity: {
1776+ id: EventId.make("activity-nonstale-approval-failed-missing"),
1777+ tone: "error",
1778+ kind: "provider.approval.respond.failed",
1779+ summary: "Provider approval response failed",
1780+ payload: {
1781+ requestId: "approval-request-nonstale-missing",
1782+ detail: "Provider timed out while responding to approval request",
1783+ },
1784+ turnId: null,
1785+ createdAt: "2026-02-26T12:45:04.000Z",
1786+ },
1787+ },
1788+ });
1789+
1790+ const approvalRows = yield* sql<{
1791+ readonly requestId: string;
1792+ readonly status: string;
1793+ readonly turnId: string | null;
1794+ readonly createdAt: string;
1795+ readonly resolvedAt: string | null;
1796+ }>`
1797+ SELECT
1798+ request_id AS "requestId",
1799+ status,
1800+ turn_id AS "turnId",
1801+ created_at AS "createdAt",
1802+ resolved_at AS "resolvedAt"
1803+ FROM projection_pending_approvals
1804+ WHERE request_id IN (
1805+ 'approval-request-nonstale-existing',
1806+ 'approval-request-nonstale-missing'
1807+ )
1808+ ORDER BY request_id
1809+ `;
1810+ assert.deepEqual(approvalRows, [
1811+ {
1812+ requestId: "approval-request-nonstale-existing",
1813+ status: "pending",
1814+ turnId: null,
1815+ createdAt: "2026-02-26T12:45:02.000Z",
1816+ resolvedAt: null,
1817+ },
1818+ ]);
1819+
1820+ const threadRows = yield* sql<{
1821+ readonly pendingApprovalCount: number;
1822+ }>`
1823+ SELECT pending_approval_count AS "pendingApprovalCount"
1824+ FROM projection_threads
1825+ WHERE thread_id = 'thread-nonstale-approval'
1826+ `;
1827+ assert.deepEqual(threadRows, [{ pendingApprovalCount: 1 }]);
1828+ }),
1829+ );
1830+
15081831 it.effect("does not fallback-retain messages whose turnId is removed by revert", () =>
15091832 Effect.gen(function* () {
15101833 const projectionPipeline = yield* OrchestrationProjectionPipeline;
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+39−0
@@ -90,6 +90,17 @@ function extractActivityRequestId(payload: unknown): ApprovalRequestId | null { · 89 unmodified lines
9090 return typeof requestId === "string" ? ApprovalRequestId.make(requestId) : null;
9191 }
9292
93+function isStalePendingApprovalFailureDetail(detail: string | null): boolean {
94+ if (detail === null) {
95+ return false;
96+ }
97+ return (
98+ detail.includes("stale pending approval request") ||
99+ detail.includes("unknown pending approval request") ||
100+ detail.includes("unknown pending permission request")
101+ );
102+}
103+
93104 function derivePendingUserInputCountFromActivities(
94105 activities: ReadonlyArray<ProjectionThreadActivity>,
95106 ): number {
@@ -1245,6 +1256,34 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 1149 unmodified lines
12451256 });
12461257 return;
12471258 }
1259+ if (event.payload.activity.kind === "provider.approval.respond.failed") {
1260+ const payload =
1261+ typeof event.payload.activity.payload === "object" &&
1262+ event.payload.activity.payload !== null
1263+ ? (event.payload.activity.payload as Record<string, unknown>)
1264+ : null;
1265+ const detail =
1266+ typeof payload?.detail === "string" ? payload.detail.toLowerCase() : null;
1267+ if (isStalePendingApprovalFailureDetail(detail)) {
1268+ if (Option.isNone(existingRow)) {
1269+ return;
1270+ }
1271+ if (existingRow.value.status === "resolved") {
1272+ return;
1273+ }
1274+ yield* projectionPendingApprovalRepository.upsert({
1275+ requestId,
1276+ threadId: existingRow.value.threadId,
1277+ turnId: existingRow.value.turnId,
1278+ status: "resolved",
1279+ decision: null,
1280+ createdAt: existingRow.value.createdAt,
1281+ resolvedAt: event.payload.activity.createdAt,
1282+ });
1283+ return;
1284+ }
1285+ return;
1286+ }
12481287 if (Option.isSome(existingRow) && existingRow.value.status === "resolved") {
12491288 return;
12501289 }
apps/server/src/persistence/Migrations.ts+2−0
@@ -36,6 +36,7 @@ import Migration0020 from "./Migrations/020_AuthAccessManagement.ts"; · 35 unmodified lines
3636 import Migration0021 from "./Migrations/021_AuthSessionClientMetadata.ts";
3737 import Migration0022 from "./Migrations/022_AuthSessionLastConnectedAt.ts";
3838 import Migration0023 from "./Migrations/023_ProjectionThreadShellSummary.ts";
39+import Migration0024 from "./Migrations/024_BackfillProjectionThreadShellSummary.ts";
3940
4041 /**
4142 * Migration loader with all migrations defined inline.
@@ -71,6 +72,7 @@ export const migrationEntries = [ · 29 unmodified lines
7172 [21, "AuthSessionClientMetadata", Migration0021],
7273 [22, "AuthSessionLastConnectedAt", Migration0022],
7374 [23, "ProjectionThreadShellSummary", Migration0023],
75+ [24, "BackfillProjectionThreadShellSummary", Migration0024],
7476 ] as const;
7577
7678 export const makeMigrationLoader = (throughId?: number) =>
apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts+218−0
@@ -0,0 +1,218 @@
1+import { assert, it } from "@effect/vitest";
2+import { Effect, Layer } from "effect";
3+import * as SqlClient from "effect/unstable/sql/SqlClient";
4+
5+import { runMigrations } from "../Migrations.ts";
6+import * as NodeSqliteClient from "../NodeSqliteClient.ts";
7+
8+const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
9+
10+layer("024_BackfillProjectionThreadShellSummary", (it) => {
11+ it.effect("backfills thread shell summary fields and clears stale projected approvals", () =>
12+ Effect.gen(function* () {
13+ const sql = yield* SqlClient.SqlClient;
14+
15+ yield* runMigrations({ toMigrationInclusive: 23 });
16+
17+ yield* sql`
18+ INSERT INTO projection_threads (
19+ thread_id,
20+ project_id,
21+ title,
22+ model_selection_json,
23+ runtime_mode,
24+ interaction_mode,
25+ branch,
26+ worktree_path,
27+ latest_turn_id,
28+ created_at,
29+ updated_at,
30+ archived_at,
31+ latest_user_message_at,
32+ pending_approval_count,
33+ pending_user_input_count,
34+ has_actionable_proposed_plan,
35+ deleted_at
36+ )
37+ VALUES (
38+ 'thread-1',
39+ 'project-1',
40+ 'Thread 1',
41+ '{"provider":"codex","model":"gpt-5-codex"}',
42+ 'approval-required',
43+ 'plan',
44+ NULL,
45+ NULL,
46+ 'turn-1',
47+ '2026-02-24T00:00:00.000Z',
48+ '2026-02-24T00:00:00.000Z',
49+ NULL,
50+ NULL,
51+ 0,
52+ 0,
53+ 0,
54+ NULL
55+ )
56+ `;
57+
58+ yield* sql`
59+ INSERT INTO projection_thread_messages (
60+ message_id,
61+ thread_id,
62+ turn_id,
63+ role,
64+ text,
65+ attachments_json,
66+ is_streaming,
67+ created_at,
68+ updated_at
69+ )
70+ VALUES (
71+ 'message-user-1',
72+ 'thread-1',
73+ 'turn-1',
74+ 'user',
75+ 'Need help',
76+ NULL,
77+ 0,
78+ '2026-02-24T00:01:00.000Z',
79+ '2026-02-24T00:01:00.000Z'
80+ )
81+ `;
82+
83+ yield* sql`
84+ INSERT INTO projection_thread_activities (
85+ activity_id,
86+ thread_id,
87+ turn_id,
88+ tone,
89+ kind,
90+ summary,
91+ payload_json,
92+ sequence,
93+ created_at
94+ )
95+ VALUES
96+ (
97+ 'activity-approval-requested',
98+ 'thread-1',
99+ 'turn-1',
100+ 'approval',
101+ 'approval.requested',
102+ 'Command approval requested',
103+ '{"requestId":"approval-1","requestKind":"command"}',
104+ NULL,
105+ '2026-02-24T00:02:00.000Z'
106+ ),
107+ (
108+ 'activity-approval-stale',
109+ 'thread-1',
110+ 'turn-1',
111+ 'error',
112+ 'provider.approval.respond.failed',
113+ 'Provider approval response failed',
114+ '{"requestId":"approval-1","detail":"Unknown pending permission request: approval-1"}',
115+ NULL,
116+ '2026-02-24T00:03:00.000Z'
117+ ),
118+ (
119+ 'activity-user-input-requested',
120+ 'thread-1',
121+ 'turn-1',
122+ 'info',
123+ 'user-input.requested',
124+ 'User input requested',
125+ '{"requestId":"input-1","questions":[{"id":"area","header":"Area","question":"Which repo area should I inspect next?","options":[{"label":"Server","description":"Server orchestration."}]}]}',
126+ NULL,
127+ '2026-02-24T00:04:00.000Z'
128+ )
129+ `;
130+
131+ yield* sql`
132+ INSERT INTO projection_thread_proposed_plans (
133+ plan_id,
134+ thread_id,
135+ turn_id,
136+ plan_markdown,
137+ implemented_at,
138+ implementation_thread_id,
139+ created_at,
140+ updated_at
141+ )
142+ VALUES (
143+ 'plan-1',
144+ 'thread-1',
145+ 'turn-1',
146+ '# Do the thing',
147+ NULL,
148+ NULL,
149+ '2026-02-24T00:05:00.000Z',
150+ '2026-02-24T00:05:00.000Z'
151+ )
152+ `;
153+
154+ yield* sql`
155+ INSERT INTO projection_pending_approvals (
156+ request_id,
157+ thread_id,
158+ turn_id,
159+ status,
160+ decision,
161+ created_at,
162+ resolved_at
163+ )
164+ VALUES (
165+ 'approval-1',
166+ 'thread-1',
167+ 'turn-1',
168+ 'pending',
169+ NULL,
170+ '2026-02-24T00:02:00.000Z',
171+ NULL
172+ )
173+ `;
174+
175+ yield* runMigrations({ toMigrationInclusive: 24 });
176+
177+ const threadRows = yield* sql<{
178+ readonly latestUserMessageAt: string | null;
179+ readonly pendingApprovalCount: number;
180+ readonly pendingUserInputCount: number;
181+ readonly hasActionableProposedPlan: number;
182+ }>`
183+ SELECT
184+ latest_user_message_at AS "latestUserMessageAt",
185+ pending_approval_count AS "pendingApprovalCount",
186+ pending_user_input_count AS "pendingUserInputCount",
187+ has_actionable_proposed_plan AS "hasActionableProposedPlan"
188+ FROM projection_threads
189+ WHERE thread_id = 'thread-1'
190+ `;
191+ assert.deepStrictEqual(threadRows, [
192+ {
193+ latestUserMessageAt: "2026-02-24T00:01:00.000Z",
194+ pendingApprovalCount: 0,
195+ pendingUserInputCount: 1,
196+ hasActionableProposedPlan: 1,
197+ },
198+ ]);
199+
200+ const approvalRows = yield* sql<{
201+ readonly status: string;
202+ readonly resolvedAt: string | null;
203+ }>`
204+ SELECT
205+ status,
206+ resolved_at AS "resolvedAt"
207+ FROM projection_pending_approvals
208+ WHERE request_id = 'approval-1'
209+ `;
210+ assert.deepStrictEqual(approvalRows, [
211+ {
212+ status: "resolved",
213+ resolvedAt: "2026-02-24T00:03:00.000Z",
214+ },
215+ ]);
216+ }),
217+ );
218+});
apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.ts+277−0
@@ -0,0 +1,277 @@
1+import * as SqlClient from "effect/unstable/sql/SqlClient";
2+import * as Effect from "effect/Effect";
3+
4+export default Effect.gen(function* () {
5+ const sql = yield* SqlClient.SqlClient;
6+
7+ yield* sql`
8+ INSERT OR IGNORE INTO projection_pending_approvals (
9+ request_id,
10+ thread_id,
11+ turn_id,
12+ status,
13+ decision,
14+ created_at,
15+ resolved_at
16+ )
17+ SELECT
18+ requested.request_id,
19+ requested.thread_id,
20+ requested.turn_id,
21+ 'pending',
22+ NULL,
23+ requested.created_at,
24+ NULL
25+ FROM (
26+ SELECT
27+ json_extract(payload_json, '$.requestId') AS request_id,
28+ thread_id,
29+ turn_id,
30+ created_at,
31+ ROW_NUMBER() OVER (
32+ PARTITION BY json_extract(payload_json, '$.requestId')
33+ ORDER BY created_at ASC, activity_id ASC
34+ ) AS row_number
35+ FROM projection_thread_activities
36+ WHERE kind = 'approval.requested'
37+ AND json_extract(payload_json, '$.requestId') IS NOT NULL
38+ ) AS requested
39+ WHERE requested.row_number = 1
40+ `;
41+
42+ yield* sql`
43+ WITH latest_resolutions AS (
44+ SELECT
45+ resolved.request_id,
46+ resolved.resolved_at,
47+ resolved.decision
48+ FROM (
49+ SELECT
50+ json_extract(payload_json, '$.requestId') AS request_id,
51+ created_at AS resolved_at,
52+ CASE
53+ WHEN json_extract(payload_json, '$.decision') IN (
54+ 'accept',
55+ 'acceptForSession',
56+ 'decline',
57+ 'cancel'
58+ )
59+ THEN json_extract(payload_json, '$.decision')
60+ ELSE NULL
61+ END AS decision,
62+ ROW_NUMBER() OVER (
63+ PARTITION BY json_extract(payload_json, '$.requestId')
64+ ORDER BY created_at DESC, activity_id DESC
65+ ) AS row_number
66+ FROM projection_thread_activities
67+ WHERE kind = 'approval.resolved'
68+ AND json_extract(payload_json, '$.requestId') IS NOT NULL
69+ ) AS resolved
70+ WHERE resolved.row_number = 1
71+ )
72+ UPDATE projection_pending_approvals
73+ SET
74+ status = 'resolved',
75+ decision = (
76+ SELECT latest_resolutions.decision
77+ FROM latest_resolutions
78+ WHERE latest_resolutions.request_id = projection_pending_approvals.request_id
79+ ),
80+ resolved_at = (
81+ SELECT latest_resolutions.resolved_at
82+ FROM latest_resolutions
83+ WHERE latest_resolutions.request_id = projection_pending_approvals.request_id
84+ )
85+ WHERE EXISTS (
86+ SELECT 1
87+ FROM latest_resolutions
88+ WHERE latest_resolutions.request_id = projection_pending_approvals.request_id
89+ )
90+ `;
91+
92+ yield* sql`
93+ WITH latest_response_events AS (
94+ SELECT
95+ response.request_id,
96+ response.resolved_at,
97+ response.decision
98+ FROM (
99+ SELECT
100+ json_extract(payload_json, '$.requestId') AS request_id,
101+ occurred_at AS resolved_at,
102+ CASE
103+ WHEN json_extract(payload_json, '$.decision') IN (
104+ 'accept',
105+ 'acceptForSession',
106+ 'decline',
107+ 'cancel'
108+ )
109+ THEN json_extract(payload_json, '$.decision')
110+ ELSE NULL
111+ END AS decision,
112+ ROW_NUMBER() OVER (
113+ PARTITION BY json_extract(payload_json, '$.requestId')
114+ ORDER BY occurred_at DESC, sequence DESC
115+ ) AS row_number
116+ FROM orchestration_events
117+ WHERE event_type = 'thread.approval-response-requested'
118+ AND json_extract(payload_json, '$.requestId') IS NOT NULL
119+ ) AS response
120+ WHERE response.row_number = 1
121+ )
122+ UPDATE projection_pending_approvals
123+ SET
124+ status = 'resolved',
125+ decision = (
126+ SELECT latest_response_events.decision
127+ FROM latest_response_events
128+ WHERE latest_response_events.request_id = projection_pending_approvals.request_id
129+ ),
130+ resolved_at = (
131+ SELECT latest_response_events.resolved_at
132+ FROM latest_response_events
133+ WHERE latest_response_events.request_id = projection_pending_approvals.request_id
134+ )
135+ WHERE EXISTS (
136+ SELECT 1
137+ FROM latest_response_events
138+ WHERE latest_response_events.request_id = projection_pending_approvals.request_id
139+ )
140+ `;
141+
142+ yield* sql`
143+ WITH latest_stale_failures AS (
144+ SELECT
145+ failure.request_id,
146+ failure.resolved_at
147+ FROM (
148+ SELECT
149+ json_extract(payload_json, '$.requestId') AS request_id,
150+ created_at AS resolved_at,
151+ ROW_NUMBER() OVER (
152+ PARTITION BY json_extract(payload_json, '$.requestId')
153+ ORDER BY created_at DESC, activity_id DESC
154+ ) AS row_number
155+ FROM projection_thread_activities
156+ WHERE kind = 'provider.approval.respond.failed'
157+ AND json_extract(payload_json, '$.requestId') IS NOT NULL
158+ AND (
159+ lower(COALESCE(json_extract(payload_json, '$.detail'), ''))
160+ LIKE '%stale pending approval request%'
161+ OR lower(COALESCE(json_extract(payload_json, '$.detail'), ''))
162+ LIKE '%unknown pending approval request%'
163+ OR lower(COALESCE(json_extract(payload_json, '$.detail'), ''))
164+ LIKE '%unknown pending permission request%'
165+ )
166+ ) AS failure
167+ WHERE failure.row_number = 1
168+ )
169+ UPDATE projection_pending_approvals
170+ SET
171+ status = 'resolved',
172+ decision = NULL,
173+ resolved_at = (
174+ SELECT latest_stale_failures.resolved_at
175+ FROM latest_stale_failures
176+ WHERE latest_stale_failures.request_id = projection_pending_approvals.request_id
177+ )
178+ WHERE status = 'pending'
179+ AND EXISTS (
180+ SELECT 1
181+ FROM latest_stale_failures
182+ WHERE latest_stale_failures.request_id = projection_pending_approvals.request_id
183+ )
184+ `;
185+
186+ yield* sql`
187+ UPDATE projection_threads
188+ SET
189+ latest_user_message_at = (
190+ SELECT MAX(message.created_at)
191+ FROM projection_thread_messages AS message
192+ WHERE message.thread_id = projection_threads.thread_id
193+ AND message.role = 'user'
194+ ),
195+ pending_approval_count = COALESCE((
196+ SELECT COUNT(*)
197+ FROM projection_pending_approvals
198+ WHERE projection_pending_approvals.thread_id = projection_threads.thread_id
199+ AND projection_pending_approvals.status = 'pending'
200+ ), 0),
201+ pending_user_input_count = COALESCE((
202+ WITH latest_user_input_states AS (
203+ SELECT
204+ latest.request_id,
205+ latest.kind,
206+ latest.detail
207+ FROM (
208+ SELECT
209+ json_extract(activity.payload_json, '$.requestId') AS request_id,
210+ activity.kind,
211+ lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) AS detail,
212+ ROW_NUMBER() OVER (
213+ PARTITION BY json_extract(activity.payload_json, '$.requestId')
214+ ORDER BY activity.created_at DESC, activity.activity_id DESC
215+ ) AS row_number
216+ FROM projection_thread_activities AS activity
217+ WHERE activity.thread_id = projection_threads.thread_id
218+ AND json_extract(activity.payload_json, '$.requestId') IS NOT NULL
219+ AND activity.kind IN (
220+ 'user-input.requested',
221+ 'user-input.resolved',
222+ 'provider.user-input.respond.failed'
223+ )
224+ ) AS latest
225+ WHERE latest.row_number = 1
226+ )
227+ SELECT COUNT(*)
228+ FROM latest_user_input_states
229+ WHERE latest_user_input_states.kind = 'user-input.requested'
230+ OR (
231+ latest_user_input_states.kind = 'provider.user-input.respond.failed'
232+ AND latest_user_input_states.detail NOT LIKE '%stale pending user-input request%'
233+ AND latest_user_input_states.detail NOT LIKE '%unknown pending user-input request%'
234+ )
235+ ), 0),
236+ has_actionable_proposed_plan = COALESCE((
237+ SELECT CASE
238+ WHEN projection_threads.latest_turn_id IS NOT NULL
239+ AND EXISTS (
240+ SELECT 1
241+ FROM projection_thread_proposed_plans AS latest_turn_plan_exists
242+ WHERE latest_turn_plan_exists.thread_id = projection_threads.thread_id
243+ AND latest_turn_plan_exists.turn_id = projection_threads.latest_turn_id
244+ )
245+ THEN CASE
246+ WHEN (
247+ SELECT latest_turn_plan.implemented_at
248+ FROM projection_thread_proposed_plans AS latest_turn_plan
249+ WHERE latest_turn_plan.thread_id = projection_threads.thread_id
250+ AND latest_turn_plan.turn_id = projection_threads.latest_turn_id
251+ ORDER BY latest_turn_plan.updated_at DESC, latest_turn_plan.plan_id DESC
252+ LIMIT 1
253+ ) IS NULL
254+ THEN 1
255+ ELSE 0
256+ END
257+ WHEN EXISTS (
258+ SELECT 1
259+ FROM projection_thread_proposed_plans AS any_plan
260+ WHERE any_plan.thread_id = projection_threads.thread_id
261+ )
262+ THEN CASE
263+ WHEN (
264+ SELECT latest_plan.implemented_at
265+ FROM projection_thread_proposed_plans AS latest_plan
266+ WHERE latest_plan.thread_id = projection_threads.thread_id
267+ ORDER BY latest_plan.updated_at DESC, latest_plan.plan_id DESC
268+ LIMIT 1
269+ ) IS NULL
270+ THEN 1
271+ ELSE 0
272+ END
273+ ELSE 0
274+ END
275+ ), 0)
276+ `;
277+});