@vercel/factory 0.0.17 → 0.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/api-contracts.d.mts +665 -715
  3. package/dist/api-contracts.mjs +91 -4
  4. package/dist/api-contracts.mjs.map +1 -1
  5. package/dist/api-task-graph.mjs +14 -5
  6. package/dist/api-task-graph.mjs.map +1 -1
  7. package/dist/api.d.mts +58 -11
  8. package/dist/api.mjs +715 -399
  9. package/dist/api.mjs.map +1 -1
  10. package/dist/approval-access.d.mts +1 -1
  11. package/dist/approval-contracts.d.mts +3 -4
  12. package/dist/approval-contracts.mjs +1 -2
  13. package/dist/approval-contracts.mjs.map +1 -1
  14. package/dist/blob/index.d.mts +1 -1
  15. package/dist/budget.mjs +20 -3
  16. package/dist/budget.mjs.map +1 -1
  17. package/dist/change-verification/brief.d.mts +1 -1
  18. package/dist/change-verification/dispatch.mjs +8 -1
  19. package/dist/change-verification/dispatch.mjs.map +1 -1
  20. package/dist/changes/eve-record-change.mjs +4 -1
  21. package/dist/changes/eve-record-change.mjs.map +1 -1
  22. package/dist/client-stream.d.mts +19 -2
  23. package/dist/client-stream.mjs +115 -53
  24. package/dist/client-stream.mjs.map +1 -1
  25. package/dist/client-transcript.d.mts +25 -2
  26. package/dist/client-transcript.mjs +95 -13
  27. package/dist/client-transcript.mjs.map +1 -1
  28. package/dist/client.d.mts +145 -19
  29. package/dist/client.mjs +89 -16
  30. package/dist/client.mjs.map +1 -1
  31. package/dist/code-review/contracts.d.mts +1 -0
  32. package/dist/code-review/eve-post-review.d.mts +4 -2
  33. package/dist/code-review/eve-post-review.mjs +8 -0
  34. package/dist/code-review/eve-post-review.mjs.map +1 -1
  35. package/dist/code-review/github-reporter.d.mts +6 -2
  36. package/dist/code-review/github-reporter.mjs +86 -35
  37. package/dist/code-review/github-reporter.mjs.map +1 -1
  38. package/dist/code-review/preparation.d.mts +2 -1
  39. package/dist/code-review/preparation.mjs +2 -1
  40. package/dist/code-review/preparation.mjs.map +1 -1
  41. package/dist/code-review/stall.mjs +11 -2
  42. package/dist/code-review/stall.mjs.map +1 -1
  43. package/dist/connectors/github.d.mts +1 -1
  44. package/dist/connectors/index.d.mts +1 -1
  45. package/dist/connectors/slack.d.mts +1 -1
  46. package/dist/delivery-metrics/metrics.d.mts +1 -1
  47. package/dist/dispatch.mjs +3 -3
  48. package/dist/dispatch.mjs.map +1 -1
  49. package/dist/eve/index.d.mts +1 -1
  50. package/dist/eve/index.mjs +8 -1
  51. package/dist/eve/index.mjs.map +1 -1
  52. package/dist/eve/task-execution.d.mts +4 -4
  53. package/dist/eve/transcript.d.mts +3 -1
  54. package/dist/eve/transcript.mjs.map +1 -1
  55. package/dist/finding-remediation/admission.d.mts +1 -1
  56. package/dist/finding-remediation/brief.d.mts +1 -1
  57. package/dist/github-app.d.mts +1 -1
  58. package/dist/github-publication.d.mts +1 -1
  59. package/dist/github.d.mts +9 -1
  60. package/dist/github.mjs +29 -11
  61. package/dist/github.mjs.map +1 -1
  62. package/dist/index.d.mts +1 -1
  63. package/dist/intake-contracts.d.mts +1 -0
  64. package/dist/intake-contracts.mjs +2 -1
  65. package/dist/intake-contracts.mjs.map +1 -1
  66. package/dist/integrations/github-issues.d.mts +64 -0
  67. package/dist/integrations/github-issues.mjs +175 -0
  68. package/dist/integrations/github-issues.mjs.map +1 -0
  69. package/dist/integrations/github.d.mts +2 -1
  70. package/dist/integrations/github.mjs +2 -1
  71. package/dist/multi-repository-changes/coordination.mjs +1 -1
  72. package/dist/planning/reconcile.mjs +15 -2
  73. package/dist/planning/reconcile.mjs.map +1 -1
  74. package/dist/postgres/index.d.mts +2 -1
  75. package/dist/postgres/index.mjs +11 -5
  76. package/dist/postgres/index.mjs.map +1 -1
  77. package/dist/postgres/indexed-codec.mjs +137 -0
  78. package/dist/postgres/indexed-codec.mjs.map +1 -0
  79. package/dist/postgres/indexed-schema.mjs +99 -0
  80. package/dist/postgres/indexed-schema.mjs.map +1 -0
  81. package/dist/postgres/indexed.d.mts +28 -0
  82. package/dist/postgres/indexed.mjs +277 -0
  83. package/dist/postgres/indexed.mjs.map +1 -0
  84. package/dist/postgres/record-predicates.mjs +23 -0
  85. package/dist/postgres/record-predicates.mjs.map +1 -0
  86. package/dist/presets/software-development/deepsec-brief.d.mts +1 -1
  87. package/dist/presets/software-development/dispatch.mjs +1 -1
  88. package/dist/presets/software-development/dispatch.mjs.map +1 -1
  89. package/dist/presets/software-development/recovery.mjs +9 -2
  90. package/dist/presets/software-development/recovery.mjs.map +1 -1
  91. package/dist/pull-requests/eve-fetch.d.mts +4 -1
  92. package/dist/pull-requests/eve-fetch.mjs +9 -0
  93. package/dist/pull-requests/eve-fetch.mjs.map +1 -1
  94. package/dist/pull-requests/github-publisher.d.mts +1 -1
  95. package/dist/pull-requests/github-publisher.mjs +19 -7
  96. package/dist/pull-requests/github-publisher.mjs.map +1 -1
  97. package/dist/repositories.mjs +20 -13
  98. package/dist/repositories.mjs.map +1 -1
  99. package/dist/sandbox/execution-run-ledger.d.mts +2 -2
  100. package/dist/sandbox/execution-run-ledger.mjs.map +1 -1
  101. package/dist/sandbox/execution.d.mts +2 -2
  102. package/dist/sandbox/execution.mjs.map +1 -1
  103. package/dist/schema/catalog.mjs +1 -1
  104. package/dist/schema/change.d.mts +1 -1
  105. package/dist/schema/communication.d.mts +2 -2
  106. package/dist/schema/factory-config.d.mts +1 -1
  107. package/dist/schema/outbox.d.mts +6 -6
  108. package/dist/schema/session-summary.d.mts +218 -0
  109. package/dist/schema/session-summary.mjs +68 -0
  110. package/dist/schema/session-summary.mjs.map +1 -0
  111. package/dist/schema/session.d.mts +39 -59
  112. package/dist/schema/session.mjs +6 -6
  113. package/dist/schema/session.mjs.map +1 -1
  114. package/dist/schema/task-graph.d.mts +24 -24
  115. package/dist/schema/transcript.d.mts +141 -3
  116. package/dist/schema/transcript.mjs +19 -3
  117. package/dist/schema/transcript.mjs.map +1 -1
  118. package/dist/schema/transitions.d.mts +1 -1
  119. package/dist/schema/transitions.mjs +1 -1
  120. package/dist/schema/transitions.mjs.map +1 -1
  121. package/dist/signals.d.mts +1 -1
  122. package/dist/stall.mjs +10 -2
  123. package/dist/stall.mjs.map +1 -1
  124. package/dist/storage.d.mts +10 -1
  125. package/dist/storage.mjs +7 -1
  126. package/dist/store/approvals.d.mts +14 -0
  127. package/dist/store/approvals.mjs +78 -0
  128. package/dist/store/approvals.mjs.map +1 -0
  129. package/dist/store/conversations.d.mts +59 -0
  130. package/dist/store/conversations.mjs +549 -0
  131. package/dist/store/conversations.mjs.map +1 -0
  132. package/dist/store/driver.d.mts +1 -1
  133. package/dist/store/driver.mjs.map +1 -1
  134. package/dist/store/engine.d.mts +38 -16
  135. package/dist/store/engine.mjs +229 -106
  136. package/dist/store/engine.mjs.map +1 -1
  137. package/dist/store/indexed-engine.d.mts +57 -0
  138. package/dist/store/indexed-engine.mjs +155 -0
  139. package/dist/store/indexed-engine.mjs.map +1 -0
  140. package/dist/store/indexed-memory.d.mts +7 -0
  141. package/dist/store/indexed-memory.mjs +200 -0
  142. package/dist/store/indexed-memory.mjs.map +1 -0
  143. package/dist/store/indexed-projections.d.mts +280 -0
  144. package/dist/store/indexed-projections.mjs +481 -0
  145. package/dist/store/indexed-projections.mjs.map +1 -0
  146. package/dist/store/indexed.d.mts +101 -0
  147. package/dist/store/indexed.mjs +19 -0
  148. package/dist/store/indexed.mjs.map +1 -0
  149. package/dist/store/iterate.d.mts +9 -0
  150. package/dist/store/iterate.mjs +23 -0
  151. package/dist/store/iterate.mjs.map +1 -0
  152. package/dist/store/query-scope.mjs +62 -0
  153. package/dist/store/query-scope.mjs.map +1 -0
  154. package/dist/store/query.d.mts +32 -0
  155. package/dist/store/query.mjs +103 -0
  156. package/dist/store/query.mjs.map +1 -0
  157. package/dist/store/session-history.mjs +24 -0
  158. package/dist/store/session-history.mjs.map +1 -0
  159. package/dist/store/slack-pr-notifications.d.mts +2 -2
  160. package/dist/store/slack-pr-notifications.mjs.map +1 -1
  161. package/dist/store/state.d.mts +26 -0
  162. package/dist/store/state.mjs +95 -0
  163. package/dist/store/state.mjs.map +1 -0
  164. package/dist/store/task-effects.d.mts +1 -1
  165. package/dist/store/task-graphs.d.mts +1 -1
  166. package/dist/store/task-messages.d.mts +1 -1
  167. package/dist/store/task-work.d.mts +1 -1
  168. package/dist/store/task-work.mjs +1 -1
  169. package/dist/store/transcripts.mjs +308 -0
  170. package/dist/store/transcripts.mjs.map +1 -0
  171. package/dist/sweep.d.mts +1 -1
  172. package/dist/sweep.mjs +12 -7
  173. package/dist/sweep.mjs.map +1 -1
  174. package/dist/task-communication.mjs +15 -1
  175. package/dist/task-communication.mjs.map +1 -1
  176. package/dist/task-protocols.d.mts +3 -3
  177. package/dist/tasks.d.mts +1 -1
  178. package/dist/vercel.d.mts +1 -1
  179. package/dist/work-triage/find-existing-work.d.mts +1 -1
  180. package/dist/work-triage/find-existing-work.mjs +12 -2
  181. package/dist/work-triage/find-existing-work.mjs.map +1 -1
  182. package/docs/index.md +4 -0
  183. package/docs/recipes/persistence-recovery.md +15 -9
  184. package/docs/recipes/postgres-indexed-engine.md +108 -0
  185. package/docs/recipes/postgres-indexed-storage.md +145 -0
  186. package/package.json +3 -1
@@ -1,6 +1,6 @@
1
1
  import { TaskId } from "./schema/id.mjs";
2
- import { RepositorySlug } from "./schema/repository.mjs";
3
2
  import { Task } from "./schema/task.mjs";
3
+ import { RepositorySlug } from "./schema/repository.mjs";
4
4
  import { FactoryStores } from "./store/engine.mjs";
5
5
  import { ApprovalView } from "./approval-contracts.mjs";
6
6
  //#region src/approval-access.d.ts
@@ -1,9 +1,8 @@
1
- import { TaskId } from "./schema/id.mjs";
2
- import { RepositorySlug } from "./schema/repository.mjs";
3
1
  import { JsonValue } from "./schema/work.mjs";
4
2
  import { Task, TaskKind } from "./schema/task.mjs";
5
3
  import "./workflows.mjs";
6
4
  import { ReceiptKind, ReceiptPhase, ReceiptState } from "./schema/receipt.mjs";
5
+ import { RepositorySlug } from "./schema/repository.mjs";
7
6
  import "./tasks.mjs";
8
7
  import "./receipts.mjs";
9
8
  import { z } from "zod";
@@ -678,7 +677,7 @@ declare const approvalPageSchema: z.ZodObject<{
678
677
  }, z.core.$strict>>;
679
678
  }, z.core.$strip>>>;
680
679
  totalCount: z.ZodOptional<z.ZodNumber>;
681
- nextCursor: z.ZodNullable<z.ZodType<`task_${string}`, string, z.core.$ZodTypeInternals<`task_${string}`, string>>>;
680
+ nextCursor: z.ZodNullable<z.ZodString>;
682
681
  }, z.core.$strip>;
683
682
  /** Authorized Task proposal with repository, finding, and decision context. */
684
683
  type ApprovalView = z.infer<typeof approvalViewSchema>;
@@ -691,7 +690,7 @@ interface ApprovalListOptions {
691
690
  readonly query?: string;
692
691
  readonly sort?: "created" | "priority" | "decided";
693
692
  readonly limit?: number;
694
- readonly cursor?: TaskId;
693
+ readonly cursor?: string;
695
694
  }
696
695
  //#endregion
697
696
  export { ApiPrincipal, ApprovalListOptions, ApprovalPage, ApprovalView, approvalPageSchema, approvalViewSchema };
@@ -1,4 +1,3 @@
1
- import { taskIdSchema } from "./schema/id.mjs";
2
1
  import { repositorySlugSchema } from "./schema/repository.mjs";
3
2
  import { taskSchema } from "./schema/task.mjs";
4
3
  import { receiptSchema } from "./schema/receipt.mjs";
@@ -19,7 +18,7 @@ const approvalPageSchema = z.object({
19
18
  actor: z.string().describe("Authenticated actor used for decision attribution."),
20
19
  items: z.array(approvalViewSchema).readonly().describe("Authorized approvals in the requested order."),
21
20
  totalCount: z.number().int().nonnegative().optional().describe("Number of authorized approvals matching all filters, before pagination."),
22
- nextCursor: taskIdSchema.nullable().describe("Continue this filtered listing after this Task ID.")
21
+ nextCursor: z.string().max(8192).nullable().describe("Opaque continuation bound to this approval query.")
23
22
  });
24
23
  //#endregion
25
24
  export { approvalPageSchema, approvalRequestSchema, approvalViewSchema };
@@ -1 +1 @@
1
- {"version":3,"file":"approval-contracts.mjs","names":[],"sources":["../src/approval-contracts.ts"],"sourcesContent":["import { z } from \"zod\";\nimport { taskIdSchema, type TaskId } from \"./schema/id\";\nimport { taskSchema } from \"./schema/task\";\nimport { receiptSchema } from \"./schema/receipt\";\nimport { findingSchema } from \"./schema/finding\";\nimport { repositorySlugSchema, type RepositorySlug } from \"./schema/repository\";\n\n/** Workspace owner and optional individually authenticated actor at the approval boundary. */\nexport interface ApiPrincipal {\n /** Workspace identity used for existing conversation ownership. */\n readonly id: string;\n /** Individually authenticated, provider-qualified actor; absent for anonymous/workload access. */\n readonly actor?: string;\n}\n\n/** Validates the stable human reason attached to an approval decision. */\nexport const approvalRequestSchema = z.strictObject({\n reason: z\n .string()\n .trim()\n .min(1)\n .max(2000)\n .optional()\n .describe(\"Reason for the decision; required for rejection. Retain unchanged for retries.\"),\n});\n/** Validates one authorized approval proposal and its immutable decision state. */\nexport const approvalViewSchema = z.object({\n task: taskSchema.describe(\"Task requiring or carrying a human decision.\"),\n repositories: z\n .array(repositorySlugSchema)\n .readonly()\n .describe(\"Configured repository names for this Task.\"),\n finding: findingSchema.nullable().describe(\"Finding that prompted the Task, when available.\"),\n decision: receiptSchema\n .nullable()\n .describe(\"Immutable decision receipt, or null before a decision.\"),\n});\n/** Validates a filtered, cursor-paginated page of approval proposals. */\nexport const approvalPageSchema = z.object({\n actor: z.string().describe(\"Authenticated actor used for decision attribution.\"),\n items: z\n .array(approvalViewSchema)\n .readonly()\n .describe(\"Authorized approvals in the requested order.\"),\n totalCount: z\n .number()\n .int()\n .nonnegative()\n .optional()\n .describe(\"Number of authorized approvals matching all filters, before pagination.\"),\n nextCursor: taskIdSchema\n .nullable()\n .describe(\"Continue this filtered listing after this Task ID.\"),\n});\n/** Authorized Task proposal with repository, finding, and decision context. */\nexport type ApprovalView = z.infer<typeof approvalViewSchema>;\n/** Filtered, cursor-paginated approval proposals for one authorized actor. */\nexport type ApprovalPage = z.infer<typeof approvalPageSchema>;\n/** Filters, ordering, and cursor controls for approval discovery. */\nexport interface ApprovalListOptions {\n readonly repository?: RepositorySlug;\n readonly status?: \"pending\" | \"decided\" | \"all\";\n readonly query?: string;\n readonly sort?: \"created\" | \"priority\" | \"decided\";\n readonly limit?: number;\n readonly cursor?: TaskId;\n}\n"],"mappings":";;;;;;;;AAgBA,MAAa,wBAAwB,EAAE,aAAa,EAClD,QAAQ,EACL,OAAO,CAAC,CACR,KAAK,CAAC,CACN,IAAI,CAAC,CAAC,CACN,IAAI,GAAI,CAAC,CACT,SAAS,CAAC,CACV,SAAS,gFAAgF,EAC9F,CAAC;;AAED,MAAa,qBAAqB,EAAE,OAAO;CACzC,MAAM,WAAW,SAAS,8CAA8C;CACxE,cAAc,EACX,MAAM,oBAAoB,CAAC,CAC3B,SAAS,CAAC,CACV,SAAS,4CAA4C;CACxD,SAAS,cAAc,SAAS,CAAC,CAAC,SAAS,iDAAiD;CAC5F,UAAU,cACP,SAAS,CAAC,CACV,SAAS,wDAAwD;AACtE,CAAC;;AAED,MAAa,qBAAqB,EAAE,OAAO;CACzC,OAAO,EAAE,OAAO,CAAC,CAAC,SAAS,oDAAoD;CAC/E,OAAO,EACJ,MAAM,kBAAkB,CAAC,CACzB,SAAS,CAAC,CACV,SAAS,8CAA8C;CAC1D,YAAY,EACT,OAAO,CAAC,CACR,IAAI,CAAC,CACL,YAAY,CAAC,CACb,SAAS,CAAC,CACV,SAAS,yEAAyE;CACrF,YAAY,aACT,SAAS,CAAC,CACV,SAAS,oDAAoD;AAClE,CAAC"}
1
+ {"version":3,"file":"approval-contracts.mjs","names":[],"sources":["../src/approval-contracts.ts"],"sourcesContent":["import { z } from \"zod\";\n\nimport { taskSchema } from \"./schema/task\";\nimport { receiptSchema } from \"./schema/receipt\";\nimport { findingSchema } from \"./schema/finding\";\nimport { repositorySlugSchema, type RepositorySlug } from \"./schema/repository\";\n\n/** Workspace owner and optional individually authenticated actor at the approval boundary. */\nexport interface ApiPrincipal {\n /** Workspace identity used for existing conversation ownership. */\n readonly id: string;\n /** Individually authenticated, provider-qualified actor; absent for anonymous/workload access. */\n readonly actor?: string;\n}\n\n/** Validates the stable human reason attached to an approval decision. */\nexport const approvalRequestSchema = z.strictObject({\n reason: z\n .string()\n .trim()\n .min(1)\n .max(2000)\n .optional()\n .describe(\"Reason for the decision; required for rejection. Retain unchanged for retries.\"),\n});\n/** Validates one authorized approval proposal and its immutable decision state. */\nexport const approvalViewSchema = z.object({\n task: taskSchema.describe(\"Task requiring or carrying a human decision.\"),\n repositories: z\n .array(repositorySlugSchema)\n .readonly()\n .describe(\"Configured repository names for this Task.\"),\n finding: findingSchema.nullable().describe(\"Finding that prompted the Task, when available.\"),\n decision: receiptSchema\n .nullable()\n .describe(\"Immutable decision receipt, or null before a decision.\"),\n});\n/** Validates a filtered, cursor-paginated page of approval proposals. */\nexport const approvalPageSchema = z.object({\n actor: z.string().describe(\"Authenticated actor used for decision attribution.\"),\n items: z\n .array(approvalViewSchema)\n .readonly()\n .describe(\"Authorized approvals in the requested order.\"),\n totalCount: z\n .number()\n .int()\n .nonnegative()\n .optional()\n .describe(\"Number of authorized approvals matching all filters, before pagination.\"),\n nextCursor: z\n .string()\n .max(8192)\n .nullable()\n .describe(\"Opaque continuation bound to this approval query.\"),\n});\n/** Authorized Task proposal with repository, finding, and decision context. */\nexport type ApprovalView = z.infer<typeof approvalViewSchema>;\n/** Filtered, cursor-paginated approval proposals for one authorized actor. */\nexport type ApprovalPage = z.infer<typeof approvalPageSchema>;\n/** Filters, ordering, and cursor controls for approval discovery. */\nexport interface ApprovalListOptions {\n readonly repository?: RepositorySlug;\n readonly status?: \"pending\" | \"decided\" | \"all\";\n readonly query?: string;\n readonly sort?: \"created\" | \"priority\" | \"decided\";\n readonly limit?: number;\n readonly cursor?: string;\n}\n"],"mappings":";;;;;;;AAgBA,MAAa,wBAAwB,EAAE,aAAa,EAClD,QAAQ,EACL,OAAO,CAAC,CACR,KAAK,CAAC,CACN,IAAI,CAAC,CAAC,CACN,IAAI,GAAI,CAAC,CACT,SAAS,CAAC,CACV,SAAS,gFAAgF,EAC9F,CAAC;;AAED,MAAa,qBAAqB,EAAE,OAAO;CACzC,MAAM,WAAW,SAAS,8CAA8C;CACxE,cAAc,EACX,MAAM,oBAAoB,CAAC,CAC3B,SAAS,CAAC,CACV,SAAS,4CAA4C;CACxD,SAAS,cAAc,SAAS,CAAC,CAAC,SAAS,iDAAiD;CAC5F,UAAU,cACP,SAAS,CAAC,CACV,SAAS,wDAAwD;AACtE,CAAC;;AAED,MAAa,qBAAqB,EAAE,OAAO;CACzC,OAAO,EAAE,OAAO,CAAC,CAAC,SAAS,oDAAoD;CAC/E,OAAO,EACJ,MAAM,kBAAkB,CAAC,CACzB,SAAS,CAAC,CACV,SAAS,8CAA8C;CAC1D,YAAY,EACT,OAAO,CAAC,CACR,IAAI,CAAC,CACL,YAAY,CAAC,CACb,SAAS,CAAC,CACV,SAAS,yEAAyE;CACrF,YAAY,EACT,OAAO,CAAC,CACR,IAAI,IAAI,CAAC,CACT,SAAS,CAAC,CACV,SAAS,mDAAmD;AACjE,CAAC"}
@@ -1,5 +1,5 @@
1
- import { MillisecondClock } from "../schema/common.mjs";
2
1
  import { StoreDriver } from "../store/driver.mjs";
2
+ import { MillisecondClock } from "../schema/common.mjs";
3
3
  import { ArtifactInput, ArtifactKind, ArtifactRef, ArtifactStore, ReadArtifactInput, artifactReference, createArtifactStore, parseArtifactRef } from "./artifacts.mjs";
4
4
  //#region src/blob/index.d.ts
5
5
  /** Blob content paired with the ETag required for compare-and-swap persistence. */
package/dist/budget.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { collectFactoryRecords } from "./store/iterate.mjs";
1
2
  import { factoryIdSchema } from "./schema/id.mjs";
2
3
  import { budgetMonthSchema } from "./schema/budget.mjs";
3
4
  import { createHash } from "node:crypto";
@@ -10,6 +11,16 @@ import { createHash } from "node:crypto";
10
11
  function deriveFactoryId(seed) {
11
12
  return factoryIdSchema.parse(`fac_${createHash("sha256").update(seed).digest("hex").slice(0, 32)}`);
12
13
  }
14
+ async function budgetTasks(stores) {
15
+ const pages = await Promise.all(["reservedUsd", "settledUsd"].map((field) => collectFactoryRecords(stores.tasks.list, {
16
+ limit: 100,
17
+ fields: [{
18
+ path: [field],
19
+ test: "exists"
20
+ }]
21
+ })));
22
+ return [...new Map(pages.flat().map((task) => [task.id, task])).values()];
23
+ }
13
24
  /** The budget month ("YYYY-MM", UTC) a timestamp falls into. */
14
25
  function budgetMonthOf(isoDateTime) {
15
26
  return budgetMonthSchema.parse(isoDateTime.slice(0, 7));
@@ -61,7 +72,13 @@ async function sweepSettlements(stores, options = {}) {
61
72
  const graceMs = (options.graceMinutes ?? 10) * 6e4;
62
73
  const nowMs = (options.now ?? (() => /* @__PURE__ */ new Date()))().getTime();
63
74
  const settled = [];
64
- for (const task of options.tasks ?? await stores.tasks.list()) {
75
+ for (const task of options.tasks ?? await collectFactoryRecords(stores.tasks.list, {
76
+ limit: 100,
77
+ fields: [{
78
+ path: ["reservedUsd"],
79
+ test: "exists"
80
+ }]
81
+ })) {
65
82
  if (task.reservedUsd === void 0 || task.settledUsd !== void 0) continue;
66
83
  if (!TERMINAL_TASK_STATES.has(task.state)) continue;
67
84
  if (nowMs - Date.parse(task.updatedAt) < graceMs) continue;
@@ -95,7 +112,7 @@ async function sweepBudget(stores, options) {
95
112
  month,
96
113
  limits: options.limits
97
114
  });
98
- const spendUsd = computeMonthSpendUsd(options.tasks ?? await stores.tasks.list(), month);
115
+ const spendUsd = computeMonthSpendUsd(options.tasks ?? await budgetTasks(stores), month);
99
116
  if (options.enforce === false) return {
100
117
  month,
101
118
  spendUsd,
@@ -142,6 +159,6 @@ async function sweepBudget(stores, options) {
142
159
  };
143
160
  }
144
161
  //#endregion
145
- export { budgetMonthOf, budgetStatus, canReserve, computeMonthSpendUsd, deriveFactoryId, sweepBudget, sweepSettlements };
162
+ export { budgetMonthOf, budgetStatus, budgetTasks, canReserve, computeMonthSpendUsd, deriveFactoryId, sweepBudget, sweepSettlements };
146
163
 
147
164
  //# sourceMappingURL=budget.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"budget.mjs","names":[],"sources":["../src/budget.ts"],"sourcesContent":["import { createHash } from \"node:crypto\";\nimport type { DateClock } from \"./schema/common\";\nimport { factoryIdSchema, type FactoryId, type TaskId } from \"./schema/id\";\nimport { budgetMonthSchema, type BudgetLimits, type BudgetMonth } from \"./schema/budget\";\nimport type { Task } from \"./schema/task\";\nimport type { FactoryStores } from \"./store/engine\";\n\n/**\n * Stable factory identity for budget rows. Nothing mints factory ids yet, so\n * the app derives one from a deployment-stable seed such as the Vercel\n * project id.\n */\nexport function deriveFactoryId(seed: string): FactoryId {\n return factoryIdSchema.parse(\n `fac_${createHash(\"sha256\").update(seed).digest(\"hex\").slice(0, 32)}`,\n );\n}\n\n/** The budget month (\"YYYY-MM\", UTC) a timestamp falls into. */\nexport function budgetMonthOf(isoDateTime: string): BudgetMonth {\n return budgetMonthSchema.parse(isoDateTime.slice(0, 7));\n}\n\n// Currency math runs in integer micro-dollars so decimal limits compare\n// exactly; 0.1 + 0.2 must never refuse an exact-fit reservation.\nfunction microsOf(usd: number): number {\n return Math.round(usd * 1_000_000);\n}\n\n/**\n * Monthly spend is derived from task rows, never stored: each task counts its\n * settled cost once that exists, otherwise its held reservation. A task\n * settles once, so a settled row never also counts its reservation.\n * Attribution uses the reservation time so tasks that wait across a month\n * boundary charge the month they actually ran in.\n */\nexport function computeMonthSpendUsd(tasks: readonly Task[], month: BudgetMonth): number {\n let spendMicros = 0;\n for (const task of tasks) {\n if (budgetMonthOf(task.reservedAt ?? task.createdAt) !== month) {\n continue;\n }\n spendMicros += microsOf(task.settledUsd ?? task.reservedUsd ?? 0);\n }\n return spendMicros / 1_000_000;\n}\n\n/** Derived monthly capacity state before another per-Task reservation. */\nexport type BudgetStatus = \"ok\" | \"warning\" | \"exhausted\";\n\n/**\n * True when one more per-task slice fits under the monthly total. Dispatch\n * refuses on this exact predicate and budgetStatus reports \"exhausted\" on its\n * negation, so refusing and reporting can never disagree.\n */\nexport function canReserve(\n spendUsd: number,\n limits: Pick<BudgetLimits, \"totalUsd\" | \"perTaskReservationUsd\">,\n): boolean {\n return microsOf(spendUsd) + microsOf(limits.perTaskReservationUsd) <= microsOf(limits.totalUsd);\n}\n\n/** Classify monthly spend using the same exact-fit predicate enforced by dispatch. */\nexport function budgetStatus(\n spendUsd: number,\n limits: Pick<BudgetLimits, \"totalUsd\" | \"perTaskReservationUsd\" | \"warningPercent\">,\n): BudgetStatus {\n if (!canReserve(spendUsd, limits)) {\n return \"exhausted\";\n }\n // Integer comparison: spend/total * 100 >= warningPercent without float\n // drift on decimal limits.\n if (microsOf(spendUsd) * 100 >= microsOf(limits.totalUsd) * limits.warningPercent) {\n return \"warning\";\n }\n return \"ok\";\n}\n\nconst TERMINAL_TASK_STATES = new Set<Task[\"state\"]>([\"succeeded\", \"failed\", \"cancelled\"]);\n\n/** Grace period, clock, and optional snapshot for terminal usage settlement. */\nexport interface SweepSettlementsOptions {\n /** How long a terminal task waits for late usage before the sweep settles it. */\n graceMinutes?: number;\n now?: DateClock;\n /** A dispatcher-wide snapshot avoids another complete task read. */\n tasks?: readonly Task[];\n}\n\n/** Task-local charges newly settled by one reconciliation pass. */\nexport interface SweepSettlementsResult {\n readonly settled: readonly { readonly taskId: TaskId; readonly totalUsd: number }[];\n}\n\n/** Task capability required to settle terminal reservations. */\nexport type SettlementSweepStores = Pick<FactoryStores, \"tasks\">;\n\n/**\n * Settle reserved terminal tasks the session hook did not: a task that ended\n * after its session (a change verified later) or whose session died. Settles\n * at the recorded usage, or at zero with a warning reason when none was\n * recorded within the grace window.\n */\nexport async function sweepSettlements(\n stores: SettlementSweepStores,\n options: SweepSettlementsOptions = {},\n): Promise<SweepSettlementsResult> {\n const graceMs = (options.graceMinutes ?? 10) * 60_000;\n const nowMs = (options.now ?? (() => new Date()))().getTime();\n const settled: { taskId: TaskId; totalUsd: number }[] = [];\n for (const task of options.tasks ?? (await stores.tasks.list())) {\n if (task.reservedUsd === undefined || task.settledUsd !== undefined) {\n continue;\n }\n if (!TERMINAL_TASK_STATES.has(task.state)) {\n continue;\n }\n if (nowMs - Date.parse(task.updatedAt) < graceMs) {\n continue;\n }\n // A concurrent writer (operator retry, another sweep) may move the task\n // between the list and the settle; skip it rather than aborting the pass.\n const totalUsd = task.usage?.costUsd ?? 0;\n try {\n const result = await stores.tasks.settle(task.id, {\n cost: { totalUsd },\n ...{\n reason:\n task.usage === undefined\n ? \"usage missing after ten minutes; settled at zero\"\n : \"measured usage settled by sweep\",\n },\n });\n // Report this settlement's amount, not the task's accumulated total.\n settled.push({ taskId: result.id, totalUsd });\n } catch {\n continue;\n }\n }\n return { settled };\n}\n\n/** Owner-only notification emitted when monthly spend crosses a budget threshold. */\nexport interface BudgetNotice {\n outcome: \"budget_warning\" | \"budget_exhausted\";\n month: BudgetMonth;\n spendUsd: number;\n limits: BudgetLimits;\n}\n\n/** Monthly limits, delivery hook, and optional snapshot used for budget reconciliation. */\nexport interface BudgetSweepOptions {\n factoryId: FactoryId;\n limits: BudgetLimits;\n /** Defaults to true. False reports spending without threshold checks or budget notices. */\n enforce?: boolean;\n /** Defaults to the current UTC month. */\n month?: BudgetMonth;\n now?: DateClock;\n /** Owner-only delivery; invoked at most once per month per outcome. */\n notify?: (notice: BudgetNotice) => Promise<void>;\n /** A dispatcher-wide snapshot avoids another complete task read. */\n tasks?: readonly Task[];\n}\n\n/**\n * Derived monthly spend and exact notice outcome produced by budget reconciliation.\n * `stamped` means this sweep recorded the threshold, `already_stamped` means no\n * new stamp was needed, and `delivery_failed` remains eligible for retry.\n */\nexport type BudgetSweepResult =\n | {\n readonly month: BudgetMonth;\n readonly spendUsd: number;\n readonly status: \"disabled\";\n readonly noticeState: \"not_applicable\";\n }\n | {\n readonly month: BudgetMonth;\n readonly spendUsd: number;\n readonly status: \"ok\";\n readonly noticeState: \"not_applicable\";\n }\n | {\n readonly month: BudgetMonth;\n readonly spendUsd: number;\n readonly status: \"warning\";\n readonly noticeState: \"stamped\" | \"already_stamped\" | \"delivery_failed\";\n }\n | {\n readonly month: BudgetMonth;\n readonly spendUsd: number;\n readonly status: \"exhausted\";\n readonly noticeState: \"stamped\" | \"already_stamped\" | \"delivery_failed\";\n };\n\n/** Budget and Task capabilities required to reconcile monthly budget thresholds. */\nexport type BudgetSweepStores = Pick<FactoryStores, \"budgets\" | \"tasks\">;\n\n/**\n * Ensure the month's budget row, derive spend, and record threshold crossings\n * write-once. Delivery comes first and the stamp only lands after it, so a\n * failed notification retries on the next sweep instead of being lost; the\n * write-once stamp still keeps each threshold to one message per month.\n */\nexport async function sweepBudget(\n stores: BudgetSweepStores,\n options: BudgetSweepOptions,\n): Promise<BudgetSweepResult> {\n const now = options.now ?? (() => new Date());\n const month = options.month ?? budgetMonthOf(now().toISOString());\n const budget = await stores.budgets.ensure({\n factoryId: options.factoryId,\n month,\n limits: options.limits,\n });\n const spendUsd = computeMonthSpendUsd(options.tasks ?? (await stores.tasks.list()), month);\n if (options.enforce === false)\n return { month, spendUsd, status: \"disabled\", noticeState: \"not_applicable\" };\n const status = budgetStatus(spendUsd, options.limits);\n\n async function crossThreshold(\n notice: \"warning\" | \"exhausted\",\n outcome: BudgetNotice[\"outcome\"],\n ): Promise<\"stamped\" | \"already_stamped\" | \"delivery_failed\"> {\n const field = notice === \"warning\" ? \"warningAt\" : \"exhaustedAt\";\n if (budget.notices[field] !== undefined) {\n return \"already_stamped\";\n }\n if (options.notify !== undefined) {\n try {\n await options.notify({ outcome, month, spendUsd, limits: options.limits });\n } catch {\n // Leave the notice unstamped so the next sweep retries delivery.\n return \"delivery_failed\";\n }\n }\n return (await stores.budgets.stampNotice(month, notice)).stamped\n ? \"stamped\"\n : \"already_stamped\";\n }\n\n if (status === \"exhausted\") {\n const noticeState = await crossThreshold(\"exhausted\", \"budget_exhausted\");\n // The warning threshold was necessarily crossed too; stamp it silently so\n // a later settle-down cannot deliver \"warning\" after \"exhausted\".\n if (budget.notices.warningAt === undefined) {\n await stores.budgets.stampNotice(month, \"warning\");\n }\n return { month, spendUsd, status, noticeState };\n }\n if (status === \"warning\") {\n const noticeState = await crossThreshold(\"warning\", \"budget_warning\");\n return { month, spendUsd, status, noticeState };\n }\n return { month, spendUsd, status, noticeState: \"not_applicable\" };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"budget.mjs","names":[],"sources":["../src/budget.ts"],"sourcesContent":["import { collectFactoryRecords } from \"./store/iterate\";\nimport { createHash } from \"node:crypto\";\nimport type { DateClock } from \"./schema/common\";\nimport { factoryIdSchema, type FactoryId, type TaskId } from \"./schema/id\";\nimport { budgetMonthSchema, type BudgetLimits, type BudgetMonth } from \"./schema/budget\";\nimport type { Task } from \"./schema/task\";\nimport type { FactoryStores } from \"./store/engine\";\n\n/**\n * Stable factory identity for budget rows. Nothing mints factory ids yet, so\n * the app derives one from a deployment-stable seed such as the Vercel\n * project id.\n */\nexport function deriveFactoryId(seed: string): FactoryId {\n return factoryIdSchema.parse(\n `fac_${createHash(\"sha256\").update(seed).digest(\"hex\").slice(0, 32)}`,\n );\n}\n\nexport async function budgetTasks(stores: Pick<FactoryStores, \"tasks\">): Promise<Task[]> {\n const pages = await Promise.all(\n [\"reservedUsd\", \"settledUsd\"].map((field) =>\n collectFactoryRecords(stores.tasks.list, {\n limit: 100,\n fields: [{ path: [field], test: \"exists\" }],\n }),\n ),\n );\n return [...new Map(pages.flat().map((task) => [task.id, task])).values()];\n}\n\n/** The budget month (\"YYYY-MM\", UTC) a timestamp falls into. */\nexport function budgetMonthOf(isoDateTime: string): BudgetMonth {\n return budgetMonthSchema.parse(isoDateTime.slice(0, 7));\n}\n\n// Currency math runs in integer micro-dollars so decimal limits compare\n// exactly; 0.1 + 0.2 must never refuse an exact-fit reservation.\nfunction microsOf(usd: number): number {\n return Math.round(usd * 1_000_000);\n}\n\n/**\n * Monthly spend is derived from task rows, never stored: each task counts its\n * settled cost once that exists, otherwise its held reservation. A task\n * settles once, so a settled row never also counts its reservation.\n * Attribution uses the reservation time so tasks that wait across a month\n * boundary charge the month they actually ran in.\n */\nexport function computeMonthSpendUsd(tasks: readonly Task[], month: BudgetMonth): number {\n let spendMicros = 0;\n for (const task of tasks) {\n if (budgetMonthOf(task.reservedAt ?? task.createdAt) !== month) {\n continue;\n }\n spendMicros += microsOf(task.settledUsd ?? task.reservedUsd ?? 0);\n }\n return spendMicros / 1_000_000;\n}\n\n/** Derived monthly capacity state before another per-Task reservation. */\nexport type BudgetStatus = \"ok\" | \"warning\" | \"exhausted\";\n\n/**\n * True when one more per-task slice fits under the monthly total. Dispatch\n * refuses on this exact predicate and budgetStatus reports \"exhausted\" on its\n * negation, so refusing and reporting can never disagree.\n */\nexport function canReserve(\n spendUsd: number,\n limits: Pick<BudgetLimits, \"totalUsd\" | \"perTaskReservationUsd\">,\n): boolean {\n return microsOf(spendUsd) + microsOf(limits.perTaskReservationUsd) <= microsOf(limits.totalUsd);\n}\n\n/** Classify monthly spend using the same exact-fit predicate enforced by dispatch. */\nexport function budgetStatus(\n spendUsd: number,\n limits: Pick<BudgetLimits, \"totalUsd\" | \"perTaskReservationUsd\" | \"warningPercent\">,\n): BudgetStatus {\n if (!canReserve(spendUsd, limits)) {\n return \"exhausted\";\n }\n // Integer comparison: spend/total * 100 >= warningPercent without float\n // drift on decimal limits.\n if (microsOf(spendUsd) * 100 >= microsOf(limits.totalUsd) * limits.warningPercent) {\n return \"warning\";\n }\n return \"ok\";\n}\n\nconst TERMINAL_TASK_STATES = new Set<Task[\"state\"]>([\"succeeded\", \"failed\", \"cancelled\"]);\n\n/** Grace period, clock, and optional snapshot for terminal usage settlement. */\nexport interface SweepSettlementsOptions {\n /** How long a terminal task waits for late usage before the sweep settles it. */\n graceMinutes?: number;\n now?: DateClock;\n /** A dispatcher-wide snapshot avoids another complete task read. */\n tasks?: readonly Task[];\n}\n\n/** Task-local charges newly settled by one reconciliation pass. */\nexport interface SweepSettlementsResult {\n readonly settled: readonly { readonly taskId: TaskId; readonly totalUsd: number }[];\n}\n\n/** Task capability required to settle terminal reservations. */\nexport type SettlementSweepStores = Pick<FactoryStores, \"tasks\">;\n\n/**\n * Settle reserved terminal tasks the session hook did not: a task that ended\n * after its session (a change verified later) or whose session died. Settles\n * at the recorded usage, or at zero with a warning reason when none was\n * recorded within the grace window.\n */\nexport async function sweepSettlements(\n stores: SettlementSweepStores,\n options: SweepSettlementsOptions = {},\n): Promise<SweepSettlementsResult> {\n const graceMs = (options.graceMinutes ?? 10) * 60_000;\n const nowMs = (options.now ?? (() => new Date()))().getTime();\n const settled: { taskId: TaskId; totalUsd: number }[] = [];\n for (const task of options.tasks ??\n (await collectFactoryRecords(stores.tasks.list, {\n limit: 100,\n fields: [{ path: [\"reservedUsd\"], test: \"exists\" }],\n }))) {\n if (task.reservedUsd === undefined || task.settledUsd !== undefined) {\n continue;\n }\n if (!TERMINAL_TASK_STATES.has(task.state)) {\n continue;\n }\n if (nowMs - Date.parse(task.updatedAt) < graceMs) {\n continue;\n }\n // A concurrent writer (operator retry, another sweep) may move the task\n // between the list and the settle; skip it rather than aborting the pass.\n const totalUsd = task.usage?.costUsd ?? 0;\n try {\n const result = await stores.tasks.settle(task.id, {\n cost: { totalUsd },\n ...{\n reason:\n task.usage === undefined\n ? \"usage missing after ten minutes; settled at zero\"\n : \"measured usage settled by sweep\",\n },\n });\n // Report this settlement's amount, not the task's accumulated total.\n settled.push({ taskId: result.id, totalUsd });\n } catch {\n continue;\n }\n }\n return { settled };\n}\n\n/** Owner-only notification emitted when monthly spend crosses a budget threshold. */\nexport interface BudgetNotice {\n outcome: \"budget_warning\" | \"budget_exhausted\";\n month: BudgetMonth;\n spendUsd: number;\n limits: BudgetLimits;\n}\n\n/** Monthly limits, delivery hook, and optional snapshot used for budget reconciliation. */\nexport interface BudgetSweepOptions {\n factoryId: FactoryId;\n limits: BudgetLimits;\n /** Defaults to true. False reports spending without threshold checks or budget notices. */\n enforce?: boolean;\n /** Defaults to the current UTC month. */\n month?: BudgetMonth;\n now?: DateClock;\n /** Owner-only delivery; invoked at most once per month per outcome. */\n notify?: (notice: BudgetNotice) => Promise<void>;\n /** A dispatcher-wide snapshot avoids another complete task read. */\n tasks?: readonly Task[];\n}\n\n/**\n * Derived monthly spend and exact notice outcome produced by budget reconciliation.\n * `stamped` means this sweep recorded the threshold, `already_stamped` means no\n * new stamp was needed, and `delivery_failed` remains eligible for retry.\n */\nexport type BudgetSweepResult =\n | {\n readonly month: BudgetMonth;\n readonly spendUsd: number;\n readonly status: \"disabled\";\n readonly noticeState: \"not_applicable\";\n }\n | {\n readonly month: BudgetMonth;\n readonly spendUsd: number;\n readonly status: \"ok\";\n readonly noticeState: \"not_applicable\";\n }\n | {\n readonly month: BudgetMonth;\n readonly spendUsd: number;\n readonly status: \"warning\";\n readonly noticeState: \"stamped\" | \"already_stamped\" | \"delivery_failed\";\n }\n | {\n readonly month: BudgetMonth;\n readonly spendUsd: number;\n readonly status: \"exhausted\";\n readonly noticeState: \"stamped\" | \"already_stamped\" | \"delivery_failed\";\n };\n\n/** Budget and Task capabilities required to reconcile monthly budget thresholds. */\nexport type BudgetSweepStores = Pick<FactoryStores, \"budgets\" | \"tasks\">;\n\n/**\n * Ensure the month's budget row, derive spend, and record threshold crossings\n * write-once. Delivery comes first and the stamp only lands after it, so a\n * failed notification retries on the next sweep instead of being lost; the\n * write-once stamp still keeps each threshold to one message per month.\n */\nexport async function sweepBudget(\n stores: BudgetSweepStores,\n options: BudgetSweepOptions,\n): Promise<BudgetSweepResult> {\n const now = options.now ?? (() => new Date());\n const month = options.month ?? budgetMonthOf(now().toISOString());\n const budget = await stores.budgets.ensure({\n factoryId: options.factoryId,\n month,\n limits: options.limits,\n });\n const spendUsd = computeMonthSpendUsd(options.tasks ?? (await budgetTasks(stores)), month);\n if (options.enforce === false)\n return { month, spendUsd, status: \"disabled\", noticeState: \"not_applicable\" };\n const status = budgetStatus(spendUsd, options.limits);\n\n async function crossThreshold(\n notice: \"warning\" | \"exhausted\",\n outcome: BudgetNotice[\"outcome\"],\n ): Promise<\"stamped\" | \"already_stamped\" | \"delivery_failed\"> {\n const field = notice === \"warning\" ? \"warningAt\" : \"exhaustedAt\";\n if (budget.notices[field] !== undefined) {\n return \"already_stamped\";\n }\n if (options.notify !== undefined) {\n try {\n await options.notify({ outcome, month, spendUsd, limits: options.limits });\n } catch {\n // Leave the notice unstamped so the next sweep retries delivery.\n return \"delivery_failed\";\n }\n }\n return (await stores.budgets.stampNotice(month, notice)).stamped\n ? \"stamped\"\n : \"already_stamped\";\n }\n\n if (status === \"exhausted\") {\n const noticeState = await crossThreshold(\"exhausted\", \"budget_exhausted\");\n // The warning threshold was necessarily crossed too; stamp it silently so\n // a later settle-down cannot deliver \"warning\" after \"exhausted\".\n if (budget.notices.warningAt === undefined) {\n await stores.budgets.stampNotice(month, \"warning\");\n }\n return { month, spendUsd, status, noticeState };\n }\n if (status === \"warning\") {\n const noticeState = await crossThreshold(\"warning\", \"budget_warning\");\n return { month, spendUsd, status, noticeState };\n }\n return { month, spendUsd, status, noticeState: \"not_applicable\" };\n}\n"],"mappings":"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"}
@@ -1,6 +1,6 @@
1
- import { Repository } from "../schema/repository.mjs";
2
1
  import { Task } from "../schema/task.mjs";
3
2
  import { Change } from "../schema/change.mjs";
3
+ import { Repository } from "../schema/repository.mjs";
4
4
  //#region src/change-verification/brief.d.ts
5
5
  /** Format the starting brief for independently verifying a recorded Change. */
6
6
  declare function formatChangeVerificationBrief(task: Task, repository: Repository, change: Change): string;
@@ -1,3 +1,4 @@
1
+ import { collectFactoryRecords } from "../store/iterate.mjs";
1
2
  import { parseAgentRouteBinding } from "../schema/agent-route.mjs";
2
3
  import { routeKey, routeTaskWork } from "../agent-routes.mjs";
3
4
  import { applyVerificationReport, verificationDelivery, verificationParentAttempt, verificationReportSchema } from "./result.mjs";
@@ -37,7 +38,13 @@ async function ensureChangeVerificationTasks(options) {
37
38
  id: "verification",
38
39
  version: 1
39
40
  });
40
- const tasks = options.tasks ?? await stores.tasks.list();
41
+ const tasks = options.tasks ?? await collectFactoryRecords(stores.tasks.list, {
42
+ limit: 100,
43
+ match: [{
44
+ state: "verifying",
45
+ work: { route: { id: "code-change" } }
46
+ }, { work: { route } }]
47
+ });
41
48
  const existingIds = new Set(tasks.map((task) => task.id));
42
49
  const result = {
43
50
  created: [],
@@ -1 +1 @@
1
- {"version":3,"file":"dispatch.mjs","names":[],"sources":["../../src/change-verification/dispatch.ts"],"sourcesContent":["import {\n parseAgentRouteBinding,\n routeKey,\n routeTaskWork,\n type AgentRouteBinding,\n} from \"../agent-routes\";\nimport type { TaskId } from \"../schema/id\";\nimport type { Task } from \"../schema/task\";\nimport type { FactoryStores } from \"../store/engine\";\nimport {\n applyVerificationReport,\n verificationDelivery,\n verificationParentAttempt,\n verificationReportSchema,\n} from \"./result\";\nimport type { PresentVerificationResultInput } from \"./eve-tool\";\n\n/** Store capabilities required to inspect and create verification Tasks. */\nexport type ChangeVerificationStores = Pick<FactoryStores, \"tasks\" | \"changes\" | \"signals\">;\n\n/** Verification Tasks admitted or found idempotently by one reconciliation pass. */\nexport interface EnsureChangeVerificationTasksResult {\n readonly created: readonly TaskId[];\n readonly existing: readonly TaskId[];\n}\n\n/** Stores, optional snapshot, and exact verifier route used by one admission pass. */\nexport interface EnsureChangeVerificationTasksOptions {\n readonly stores: ChangeVerificationStores;\n readonly tasks?: readonly Task[];\n readonly route?: AgentRouteBinding;\n /** Idempotent presentation callback, replayed from the saved outcome after interruption. */\n readonly presentResult?: (input: PresentVerificationResultInput) => Promise<void>;\n}\n\n/** One verifier snapshot and the workflow adapters needed to recover its saved result. */\nexport interface RecoverChangeVerificationResultOptions extends Omit<\n EnsureChangeVerificationTasksOptions,\n \"tasks\"\n> {\n readonly task: Task;\n}\n\n/**\n * Recover an exact verifier attempt before generic lifecycle policy replaces or escalates it.\n * Returns false when no eligible report exists; storage or presentation errors propagate so callers\n * can defer lifecycle changes and retry the same attempt.\n */\nexport async function recoverChangeVerificationResult(\n options: RecoverChangeVerificationResultOptions,\n): Promise<boolean> {\n const { stores, task } = options;\n const route = options.route ?? parseAgentRouteBinding({ id: \"verification\", version: 1 });\n if (\n !task.work.route ||\n routeKey(task.work.route) !== routeKey(route) ||\n ![\"running\", \"succeeded\", \"failed\"].includes(task.state)\n )\n return false;\n const report = await stores.signals.getByDelivery(verificationDelivery(task));\n if (!report) return false;\n const outcome = await applyVerificationReport(stores, report);\n if (!outcome.recorded) return false;\n const { output } = verificationReportSchema.parse(report.payload);\n await options.presentResult?.({\n taskId: task.id,\n changeId: output.changeId,\n verdict: output.verdict,\n summary: output.summary,\n });\n return true;\n}\n\n/** Admit one verification child per producer attempt and recover its recorded outcome. */\nexport async function ensureChangeVerificationTasks(\n options: EnsureChangeVerificationTasksOptions,\n): Promise<EnsureChangeVerificationTasksResult> {\n const { stores } = options;\n const route = options.route ?? parseAgentRouteBinding({ id: \"verification\", version: 1 });\n const tasks = options.tasks ?? (await stores.tasks.list());\n const existingIds = new Set(tasks.map((task) => task.id));\n const result: { created: TaskId[]; existing: TaskId[] } = { created: [], existing: [] };\n for (const task of tasks) {\n if (await recoverChangeVerificationResult({ ...options, task })) result.existing.push(task.id);\n }\n const sources = tasks.filter(\n (task) =>\n task.work.route?.id === \"code-change\" &&\n task.state === \"verifying\" &&\n task.changeId !== undefined,\n );\n\n for (const snapshot of sources) {\n const source = await stores.tasks.get(snapshot.id);\n if (\n !source ||\n source.state !== \"verifying\" ||\n source.attempt !== snapshot.attempt ||\n source.changeId !== snapshot.changeId\n )\n continue;\n const existing = tasks.find(\n (task) =>\n task.work.route !== undefined &&\n routeKey(task.work.route) === routeKey(route) &&\n task.parentTaskId === source.id &&\n task.changeId === source.changeId &&\n verificationParentAttempt(task) === source.attempt,\n );\n if (existing !== undefined) {\n if (!result.existing.includes(existing.id)) result.existing.push(existing.id);\n continue;\n }\n const verification = await stores.tasks.create({\n repositoryIds: source.repositoryIds,\n kind: \"verification\",\n work: routeTaskWork({\n route: route,\n title: `Verify change ${source.changeId}`,\n input: {\n changeId: source.changeId!,\n parentAttempt: source.attempt,\n },\n }),\n origin: {},\n parentTaskId: source.id,\n replyTo: source.replyTo,\n changeId: source.changeId,\n dedupeKey: `verification:${source.id}:${source.attempt}:${source.changeId}:${routeKey(route)}`,\n });\n (existingIds.has(verification.id) ? result.existing : result.created).push(verification.id);\n }\n\n return result;\n}\n"],"mappings":";;;;;;;;;AAgDA,eAAsB,gCACpB,SACkB;CAClB,MAAM,EAAE,QAAQ,SAAS;CACzB,MAAM,QAAQ,QAAQ,SAAS,uBAAuB;EAAE,IAAI;EAAgB,SAAS;CAAE,CAAC;CACxF,IACE,CAAC,KAAK,KAAK,SACX,SAAS,KAAK,KAAK,KAAK,MAAM,SAAS,KAAK,KAC5C,CAAC;EAAC;EAAW;EAAa;CAAQ,CAAC,CAAC,SAAS,KAAK,KAAK,GAEvD,OAAO;CACT,MAAM,SAAS,MAAM,OAAO,QAAQ,cAAc,qBAAqB,IAAI,CAAC;CAC5E,IAAI,CAAC,QAAQ,OAAO;CAEpB,IAAI,EAAC,MADiB,wBAAwB,QAAQ,MAAM,EAAA,CAC/C,UAAU,OAAO;CAC9B,MAAM,EAAE,WAAW,yBAAyB,MAAM,OAAO,OAAO;CAChE,MAAM,QAAQ,gBAAgB;EAC5B,QAAQ,KAAK;EACb,UAAU,OAAO;EACjB,SAAS,OAAO;EAChB,SAAS,OAAO;CAClB,CAAC;CACD,OAAO;AACT;;AAGA,eAAsB,8BACpB,SAC8C;CAC9C,MAAM,EAAE,WAAW;CACnB,MAAM,QAAQ,QAAQ,SAAS,uBAAuB;EAAE,IAAI;EAAgB,SAAS;CAAE,CAAC;CACxF,MAAM,QAAQ,QAAQ,SAAU,MAAM,OAAO,MAAM,KAAK;CACxD,MAAM,cAAc,IAAI,IAAI,MAAM,KAAK,SAAS,KAAK,EAAE,CAAC;CACxD,MAAM,SAAoD;EAAE,SAAS,CAAC;EAAG,UAAU,CAAC;CAAE;CACtF,KAAK,MAAM,QAAQ,OACjB,IAAI,MAAM,gCAAgC;EAAE,GAAG;EAAS;CAAK,CAAC,GAAG,OAAO,SAAS,KAAK,KAAK,EAAE;CAE/F,MAAM,UAAU,MAAM,QACnB,SACC,KAAK,KAAK,OAAO,OAAO,iBACxB,KAAK,UAAU,eACf,KAAK,aAAa,KAAA,CACtB;CAEA,KAAK,MAAM,YAAY,SAAS;EAC9B,MAAM,SAAS,MAAM,OAAO,MAAM,IAAI,SAAS,EAAE;EACjD,IACE,CAAC,UACD,OAAO,UAAU,eACjB,OAAO,YAAY,SAAS,WAC5B,OAAO,aAAa,SAAS,UAE7B;EACF,MAAM,WAAW,MAAM,MACpB,SACC,KAAK,KAAK,UAAU,KAAA,KACpB,SAAS,KAAK,KAAK,KAAK,MAAM,SAAS,KAAK,KAC5C,KAAK,iBAAiB,OAAO,MAC7B,KAAK,aAAa,OAAO,YACzB,0BAA0B,IAAI,MAAM,OAAO,OAC/C;EACA,IAAI,aAAa,KAAA,GAAW;GAC1B,IAAI,CAAC,OAAO,SAAS,SAAS,SAAS,EAAE,GAAG,OAAO,SAAS,KAAK,SAAS,EAAE;GAC5E;EACF;EACA,MAAM,eAAe,MAAM,OAAO,MAAM,OAAO;GAC7C,eAAe,OAAO;GACtB,MAAM;GACN,MAAM,cAAc;IACX;IACP,OAAO,iBAAiB,OAAO;IAC/B,OAAO;KACL,UAAU,OAAO;KACjB,eAAe,OAAO;IACxB;GACF,CAAC;GACD,QAAQ,CAAC;GACT,cAAc,OAAO;GACrB,SAAS,OAAO;GAChB,UAAU,OAAO;GACjB,WAAW,gBAAgB,OAAO,GAAG,GAAG,OAAO,QAAQ,GAAG,OAAO,SAAS,GAAG,SAAS,KAAK;EAC7F,CAAC;EACD,CAAC,YAAY,IAAI,aAAa,EAAE,IAAI,OAAO,WAAW,OAAO,QAAA,CAAS,KAAK,aAAa,EAAE;CAC5F;CAEA,OAAO;AACT"}
1
+ {"version":3,"file":"dispatch.mjs","names":[],"sources":["../../src/change-verification/dispatch.ts"],"sourcesContent":["import { collectFactoryRecords } from \"../store/iterate\";\nimport {\n parseAgentRouteBinding,\n routeKey,\n routeTaskWork,\n type AgentRouteBinding,\n} from \"../agent-routes\";\nimport type { TaskId } from \"../schema/id\";\nimport type { Task } from \"../schema/task\";\nimport type { FactoryStores } from \"../store/engine\";\nimport {\n applyVerificationReport,\n verificationDelivery,\n verificationParentAttempt,\n verificationReportSchema,\n} from \"./result\";\nimport type { PresentVerificationResultInput } from \"./eve-tool\";\n\n/** Store capabilities required to inspect and create verification Tasks. */\nexport type ChangeVerificationStores = Pick<FactoryStores, \"tasks\" | \"changes\" | \"signals\">;\n\n/** Verification Tasks admitted or found idempotently by one reconciliation pass. */\nexport interface EnsureChangeVerificationTasksResult {\n readonly created: readonly TaskId[];\n readonly existing: readonly TaskId[];\n}\n\n/** Stores, optional snapshot, and exact verifier route used by one admission pass. */\nexport interface EnsureChangeVerificationTasksOptions {\n readonly stores: ChangeVerificationStores;\n readonly tasks?: readonly Task[];\n readonly route?: AgentRouteBinding;\n /** Idempotent presentation callback, replayed from the saved outcome after interruption. */\n readonly presentResult?: (input: PresentVerificationResultInput) => Promise<void>;\n}\n\n/** One verifier snapshot and the workflow adapters needed to recover its saved result. */\nexport interface RecoverChangeVerificationResultOptions extends Omit<\n EnsureChangeVerificationTasksOptions,\n \"tasks\"\n> {\n readonly task: Task;\n}\n\n/**\n * Recover an exact verifier attempt before generic lifecycle policy replaces or escalates it.\n * Returns false when no eligible report exists; storage or presentation errors propagate so callers\n * can defer lifecycle changes and retry the same attempt.\n */\nexport async function recoverChangeVerificationResult(\n options: RecoverChangeVerificationResultOptions,\n): Promise<boolean> {\n const { stores, task } = options;\n const route = options.route ?? parseAgentRouteBinding({ id: \"verification\", version: 1 });\n if (\n !task.work.route ||\n routeKey(task.work.route) !== routeKey(route) ||\n ![\"running\", \"succeeded\", \"failed\"].includes(task.state)\n )\n return false;\n const report = await stores.signals.getByDelivery(verificationDelivery(task));\n if (!report) return false;\n const outcome = await applyVerificationReport(stores, report);\n if (!outcome.recorded) return false;\n const { output } = verificationReportSchema.parse(report.payload);\n await options.presentResult?.({\n taskId: task.id,\n changeId: output.changeId,\n verdict: output.verdict,\n summary: output.summary,\n });\n return true;\n}\n\n/** Admit one verification child per producer attempt and recover its recorded outcome. */\nexport async function ensureChangeVerificationTasks(\n options: EnsureChangeVerificationTasksOptions,\n): Promise<EnsureChangeVerificationTasksResult> {\n const { stores } = options;\n const route = options.route ?? parseAgentRouteBinding({ id: \"verification\", version: 1 });\n const tasks =\n options.tasks ??\n (await collectFactoryRecords(stores.tasks.list, {\n limit: 100,\n match: [{ state: \"verifying\", work: { route: { id: \"code-change\" } } }, { work: { route } }],\n }));\n const existingIds = new Set(tasks.map((task) => task.id));\n const result: { created: TaskId[]; existing: TaskId[] } = { created: [], existing: [] };\n for (const task of tasks) {\n if (await recoverChangeVerificationResult({ ...options, task })) result.existing.push(task.id);\n }\n const sources = tasks.filter(\n (task) =>\n task.work.route?.id === \"code-change\" &&\n task.state === \"verifying\" &&\n task.changeId !== undefined,\n );\n\n for (const snapshot of sources) {\n const source = await stores.tasks.get(snapshot.id);\n if (\n !source ||\n source.state !== \"verifying\" ||\n source.attempt !== snapshot.attempt ||\n source.changeId !== snapshot.changeId\n )\n continue;\n const existing = tasks.find(\n (task) =>\n task.work.route !== undefined &&\n routeKey(task.work.route) === routeKey(route) &&\n task.parentTaskId === source.id &&\n task.changeId === source.changeId &&\n verificationParentAttempt(task) === source.attempt,\n );\n if (existing !== undefined) {\n if (!result.existing.includes(existing.id)) result.existing.push(existing.id);\n continue;\n }\n const verification = await stores.tasks.create({\n repositoryIds: source.repositoryIds,\n kind: \"verification\",\n work: routeTaskWork({\n route: route,\n title: `Verify change ${source.changeId}`,\n input: {\n changeId: source.changeId!,\n parentAttempt: source.attempt,\n },\n }),\n origin: {},\n parentTaskId: source.id,\n replyTo: source.replyTo,\n changeId: source.changeId,\n dedupeKey: `verification:${source.id}:${source.attempt}:${source.changeId}:${routeKey(route)}`,\n });\n (existingIds.has(verification.id) ? result.existing : result.created).push(verification.id);\n }\n\n return result;\n}\n"],"mappings":";;;;;;;;;;AAiDA,eAAsB,gCACpB,SACkB;CAClB,MAAM,EAAE,QAAQ,SAAS;CACzB,MAAM,QAAQ,QAAQ,SAAS,uBAAuB;EAAE,IAAI;EAAgB,SAAS;CAAE,CAAC;CACxF,IACE,CAAC,KAAK,KAAK,SACX,SAAS,KAAK,KAAK,KAAK,MAAM,SAAS,KAAK,KAC5C,CAAC;EAAC;EAAW;EAAa;CAAQ,CAAC,CAAC,SAAS,KAAK,KAAK,GAEvD,OAAO;CACT,MAAM,SAAS,MAAM,OAAO,QAAQ,cAAc,qBAAqB,IAAI,CAAC;CAC5E,IAAI,CAAC,QAAQ,OAAO;CAEpB,IAAI,EAAC,MADiB,wBAAwB,QAAQ,MAAM,EAAA,CAC/C,UAAU,OAAO;CAC9B,MAAM,EAAE,WAAW,yBAAyB,MAAM,OAAO,OAAO;CAChE,MAAM,QAAQ,gBAAgB;EAC5B,QAAQ,KAAK;EACb,UAAU,OAAO;EACjB,SAAS,OAAO;EAChB,SAAS,OAAO;CAClB,CAAC;CACD,OAAO;AACT;;AAGA,eAAsB,8BACpB,SAC8C;CAC9C,MAAM,EAAE,WAAW;CACnB,MAAM,QAAQ,QAAQ,SAAS,uBAAuB;EAAE,IAAI;EAAgB,SAAS;CAAE,CAAC;CACxF,MAAM,QACJ,QAAQ,SACP,MAAM,sBAAsB,OAAO,MAAM,MAAM;EAC9C,OAAO;EACP,OAAO,CAAC;GAAE,OAAO;GAAa,MAAM,EAAE,OAAO,EAAE,IAAI,cAAc,EAAE;EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC;CAC7F,CAAC;CACH,MAAM,cAAc,IAAI,IAAI,MAAM,KAAK,SAAS,KAAK,EAAE,CAAC;CACxD,MAAM,SAAoD;EAAE,SAAS,CAAC;EAAG,UAAU,CAAC;CAAE;CACtF,KAAK,MAAM,QAAQ,OACjB,IAAI,MAAM,gCAAgC;EAAE,GAAG;EAAS;CAAK,CAAC,GAAG,OAAO,SAAS,KAAK,KAAK,EAAE;CAE/F,MAAM,UAAU,MAAM,QACnB,SACC,KAAK,KAAK,OAAO,OAAO,iBACxB,KAAK,UAAU,eACf,KAAK,aAAa,KAAA,CACtB;CAEA,KAAK,MAAM,YAAY,SAAS;EAC9B,MAAM,SAAS,MAAM,OAAO,MAAM,IAAI,SAAS,EAAE;EACjD,IACE,CAAC,UACD,OAAO,UAAU,eACjB,OAAO,YAAY,SAAS,WAC5B,OAAO,aAAa,SAAS,UAE7B;EACF,MAAM,WAAW,MAAM,MACpB,SACC,KAAK,KAAK,UAAU,KAAA,KACpB,SAAS,KAAK,KAAK,KAAK,MAAM,SAAS,KAAK,KAC5C,KAAK,iBAAiB,OAAO,MAC7B,KAAK,aAAa,OAAO,YACzB,0BAA0B,IAAI,MAAM,OAAO,OAC/C;EACA,IAAI,aAAa,KAAA,GAAW;GAC1B,IAAI,CAAC,OAAO,SAAS,SAAS,SAAS,EAAE,GAAG,OAAO,SAAS,KAAK,SAAS,EAAE;GAC5E;EACF;EACA,MAAM,eAAe,MAAM,OAAO,MAAM,OAAO;GAC7C,eAAe,OAAO;GACtB,MAAM;GACN,MAAM,cAAc;IACX;IACP,OAAO,iBAAiB,OAAO;IAC/B,OAAO;KACL,UAAU,OAAO;KACjB,eAAe,OAAO;IACxB;GACF,CAAC;GACD,QAAQ,CAAC;GACT,cAAc,OAAO;GACrB,SAAS,OAAO;GAChB,UAAU,OAAO;GACjB,WAAW,gBAAgB,OAAO,GAAG,GAAG,OAAO,QAAQ,GAAG,OAAO,SAAS,GAAG,SAAS,KAAK;EAC7F,CAAC;EACD,CAAC,YAAY,IAAI,aAAa,EAAE,IAAI,OAAO,WAAW,OAAO,QAAA,CAAS,KAAK,aAAa,EAAE;CAC5F;CAEA,OAAO;AACT"}
@@ -39,7 +39,10 @@ function createRecordChangeTool(stores, options = {}) {
39
39
  if (currentTask.state !== "running" && currentTask.changeId === void 0) throw new Error(`task ${taskId} has no recorded change`);
40
40
  const linkedChange = currentTask.changeId === void 0 ? void 0 : await stores.changes.get(currentTask.changeId);
41
41
  if (currentTask.changeId !== void 0 && linkedChange === null) throw new Error(`change ${currentTask.changeId} does not exist`);
42
- let change = linkedChange ?? (await stores.changes.list()).find((candidate) => candidate.taskId === taskId);
42
+ let change = linkedChange ?? (await stores.changes.list({
43
+ limit: 1,
44
+ match: { taskId }
45
+ })).items[0];
43
46
  if (change !== void 0 && (change.repositoryId !== repositoryId || change.prNumber !== prNumber || change.prUrl !== prUrl)) throw new Error("Pull request does not match the Task's recorded Change");
44
47
  if (currentTask.state === "running") {
45
48
  await requireTaskExecution(stores, ctx, taskId);
@@ -1 +1 @@
1
- {"version":3,"file":"eve-record-change.mjs","names":[],"sources":["../../src/changes/eve-record-change.ts"],"sourcesContent":["import { defineTool, type ToolContext, type ToolDefinition } from \"eve/tools\";\nimport { z } from \"zod\";\nimport type { ChangePresenter } from \"./presentation\";\nimport { changeLevelSchema, type Change } from \"../schema/change\";\nimport { repositoryIdSchema, taskIdSchema } from \"../schema/id\";\nimport type { Task } from \"../schema/task\";\nimport type { PullRequestPublicationGateResult } from \"../pull-requests/contracts\";\nimport type { FactoryStores } from \"../store/engine\";\nimport { requireTaskExecution, requireTaskExecutionIdentity } from \"../eve/task-execution\";\n\n/** Change and Task capabilities required by the record-change tool. */\nexport type RecordChangeToolStores = Pick<FactoryStores, \"changes\" | \"tasks\">;\n\nconst recordChangeInputSchema = z.object({\n taskId: taskIdSchema,\n repositoryId: repositoryIdSchema,\n prNumber: z.number().int().min(1),\n prUrl: z.url(),\n risk: changeLevelSchema.describe(\"Blast radius of the change if it is wrong.\"),\n confidence: changeLevelSchema.describe(\"How certain you are the change is correct.\"),\n summary: z.string().min(1).describe(\"One-sentence description of the change.\"),\n});\n\n/** Validated input accepted by the tool that links a pull request to its producing Task. */\nexport type RecordChangeInput = z.infer<typeof recordChangeInputSchema>;\n\n/** Configures policy gates and durable presentation for the software record-change tool. */\nexport interface RecordChangeToolOptions {\n /**\n * PR association gate, run as an Eve approval policy and again after\n * authentication before recording. Refusals include the gate's reason.\n * Gates must fail closed: when they cannot verify, they refuse.\n */\n recordChangeGate?: (input: RecordChangeInput) => Promise<PullRequestPublicationGateResult>;\n /** Whether recorded code changes wait for an independent verification task. Default: required. */\n verification?: \"required\" | \"disabled\";\n /** Present the pull request after record_change persists its durable state. */\n presentChange?: ChangePresenter;\n}\n\n/** Change and producing Task returned after a pull request is recorded. */\nexport interface RecordChangeResult {\n readonly change: Change;\n readonly task: Task;\n}\n\n/** Eve outcome tool that links a pull request to its producing Task. */\nexport interface RecordChangeTool extends ToolDefinition<RecordChangeInput, RecordChangeResult> {\n execute(input: RecordChangeInput, ctx: ToolContext): Promise<RecordChangeResult>;\n}\n\n/** Create the supplied software workflow's pull-request outcome tool. */\nexport function createRecordChangeTool(\n stores: RecordChangeToolStores,\n options: RecordChangeToolOptions = {},\n): RecordChangeTool {\n const recordChangeGate = options.recordChangeGate;\n const verification = options.verification ?? \"required\";\n return defineTool({\n description:\n verification === \"required\"\n ? \"Record the pull request produced for a code_change task. Creates the change, links it to the task, and moves the task to verifying.\"\n : \"Record the pull request produced for a code_change task. Creates the change and marks it ready for human review without independent verification.\",\n inputSchema: recordChangeInputSchema,\n ...(recordChangeGate === undefined\n ? {}\n : {\n approval: async ({ toolInput }: { toolInput?: unknown }) => {\n const parsed = recordChangeInputSchema.safeParse(toolInput);\n if (!parsed.success) {\n return {\n type: \"denied\" as const,\n reason: \"record_change input is malformed; fix the arguments and retry\",\n };\n }\n const verdict = await recordChangeGate(parsed.data);\n return verdict.ok\n ? (\"not-applicable\" as const)\n : { type: \"denied\" as const, reason: verdict.reason };\n },\n }),\n async execute(\n { taskId, repositoryId, prNumber, prUrl, risk, confidence, summary },\n ctx,\n ): Promise<RecordChangeResult> {\n const currentTask = await requireTaskExecutionIdentity(stores, ctx, taskId);\n if (\n currentTask.state !== \"running\" &&\n currentTask.state !== \"verifying\" &&\n currentTask.state !== \"succeeded\"\n ) {\n throw new Error(`task ${taskId} is ${currentTask.state}, not running`);\n }\n if (currentTask.repositoryIds.length !== 1 || currentTask.repositoryIds[0] !== repositoryId) {\n throw new Error(\"Repository does not match the authenticated Task\");\n }\n if (currentTask.state !== \"running\" && currentTask.changeId === undefined) {\n throw new Error(`task ${taskId} has no recorded change`);\n }\n const linkedChange =\n currentTask.changeId === undefined\n ? undefined\n : await stores.changes.get(currentTask.changeId);\n if (currentTask.changeId !== undefined && linkedChange === null) {\n throw new Error(`change ${currentTask.changeId} does not exist`);\n }\n let change =\n linkedChange ??\n (await stores.changes.list()).find((candidate) => candidate.taskId === taskId);\n\n if (\n change !== undefined &&\n (change.repositoryId !== repositoryId ||\n change.prNumber !== prNumber ||\n change.prUrl !== prUrl)\n ) {\n throw new Error(\"Pull request does not match the Task's recorded Change\");\n }\n if (currentTask.state === \"running\") {\n await requireTaskExecution(stores, ctx, taskId);\n if (recordChangeGate !== undefined) {\n const verdict = await recordChangeGate({\n taskId,\n repositoryId,\n prNumber,\n prUrl,\n risk,\n confidence,\n summary,\n });\n if (!verdict.ok) throw new Error(verdict.reason);\n }\n await requireTaskExecution(stores, ctx, taskId);\n }\n\n if (change === undefined) {\n if (currentTask.state !== \"running\") {\n throw new Error(`task ${taskId} has no recorded change`);\n }\n change = await stores.changes.create({\n repositoryId,\n taskId,\n prNumber,\n prUrl,\n author: \"factory\",\n risk,\n confidence,\n evidence: {\n reproVerified: false,\n testsAdded: 0,\n checksGreen: false,\n attackPassed: false,\n scopeMatches: false,\n },\n });\n }\n if (change === undefined) throw new Error(`task ${taskId} has no recorded change`);\n let recordedChange = change;\n\n if (currentTask.state === \"succeeded\") return { change: recordedChange, task: currentTask };\n\n if (currentTask.state === \"running\") {\n await requireTaskExecution(stores, ctx, taskId);\n await options.presentChange?.({\n taskId,\n changeId: recordedChange.id,\n verification,\n summary,\n });\n }\n\n let task: Task = currentTask;\n if (task.state === \"running\") {\n task = await stores.tasks.transition(taskId, \"verifying\", {\n expectFrom: \"running\",\n expectAttempt: currentTask.attempt,\n expectExecution: currentTask.execution,\n patch: { changeId: recordedChange.id },\n reason: summary,\n });\n }\n if (verification === \"required\") return { change: recordedChange, task };\n\n const completion = await requireTaskExecutionIdentity(stores, ctx, taskId);\n if (completion.state !== \"verifying\" || completion.changeId !== recordedChange.id) {\n throw new Error(\"Task is no longer awaiting this Change's completion\");\n }\n if (recordedChange.state === \"verifying\") {\n recordedChange = await stores.changes.transition(recordedChange.id, \"ready\", {\n reason: \"independent verification is not configured\",\n });\n }\n if (task.state === \"verifying\") {\n task = await stores.tasks.transition(taskId, \"succeeded\", {\n expectFrom: \"verifying\",\n expectAttempt: currentTask.attempt,\n expectExecution: currentTask.execution,\n reason: \"change recorded for human review without independent verification\",\n output: {\n changeId: recordedChange.id,\n prUrl: recordedChange.prUrl,\n summary,\n verification,\n },\n });\n }\n return { change: recordedChange, task };\n },\n });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"eve-record-change.mjs","names":[],"sources":["../../src/changes/eve-record-change.ts"],"sourcesContent":["import { defineTool, type ToolContext, type ToolDefinition } from \"eve/tools\";\nimport { z } from \"zod\";\nimport type { ChangePresenter } from \"./presentation\";\nimport { changeLevelSchema, type Change } from \"../schema/change\";\nimport { repositoryIdSchema, taskIdSchema } from \"../schema/id\";\nimport type { Task } from \"../schema/task\";\nimport type { PullRequestPublicationGateResult } from \"../pull-requests/contracts\";\nimport type { FactoryStores } from \"../store/engine\";\nimport { requireTaskExecution, requireTaskExecutionIdentity } from \"../eve/task-execution\";\n\n/** Change and Task capabilities required by the record-change tool. */\nexport type RecordChangeToolStores = Pick<FactoryStores, \"changes\" | \"tasks\">;\n\nconst recordChangeInputSchema = z.object({\n taskId: taskIdSchema,\n repositoryId: repositoryIdSchema,\n prNumber: z.number().int().min(1),\n prUrl: z.url(),\n risk: changeLevelSchema.describe(\"Blast radius of the change if it is wrong.\"),\n confidence: changeLevelSchema.describe(\"How certain you are the change is correct.\"),\n summary: z.string().min(1).describe(\"One-sentence description of the change.\"),\n});\n\n/** Validated input accepted by the tool that links a pull request to its producing Task. */\nexport type RecordChangeInput = z.infer<typeof recordChangeInputSchema>;\n\n/** Configures policy gates and durable presentation for the software record-change tool. */\nexport interface RecordChangeToolOptions {\n /**\n * PR association gate, run as an Eve approval policy and again after\n * authentication before recording. Refusals include the gate's reason.\n * Gates must fail closed: when they cannot verify, they refuse.\n */\n recordChangeGate?: (input: RecordChangeInput) => Promise<PullRequestPublicationGateResult>;\n /** Whether recorded code changes wait for an independent verification task. Default: required. */\n verification?: \"required\" | \"disabled\";\n /** Present the pull request after record_change persists its durable state. */\n presentChange?: ChangePresenter;\n}\n\n/** Change and producing Task returned after a pull request is recorded. */\nexport interface RecordChangeResult {\n readonly change: Change;\n readonly task: Task;\n}\n\n/** Eve outcome tool that links a pull request to its producing Task. */\nexport interface RecordChangeTool extends ToolDefinition<RecordChangeInput, RecordChangeResult> {\n execute(input: RecordChangeInput, ctx: ToolContext): Promise<RecordChangeResult>;\n}\n\n/** Create the supplied software workflow's pull-request outcome tool. */\nexport function createRecordChangeTool(\n stores: RecordChangeToolStores,\n options: RecordChangeToolOptions = {},\n): RecordChangeTool {\n const recordChangeGate = options.recordChangeGate;\n const verification = options.verification ?? \"required\";\n return defineTool({\n description:\n verification === \"required\"\n ? \"Record the pull request produced for a code_change task. Creates the change, links it to the task, and moves the task to verifying.\"\n : \"Record the pull request produced for a code_change task. Creates the change and marks it ready for human review without independent verification.\",\n inputSchema: recordChangeInputSchema,\n ...(recordChangeGate === undefined\n ? {}\n : {\n approval: async ({ toolInput }: { toolInput?: unknown }) => {\n const parsed = recordChangeInputSchema.safeParse(toolInput);\n if (!parsed.success) {\n return {\n type: \"denied\" as const,\n reason: \"record_change input is malformed; fix the arguments and retry\",\n };\n }\n const verdict = await recordChangeGate(parsed.data);\n return verdict.ok\n ? (\"not-applicable\" as const)\n : { type: \"denied\" as const, reason: verdict.reason };\n },\n }),\n async execute(\n { taskId, repositoryId, prNumber, prUrl, risk, confidence, summary },\n ctx,\n ): Promise<RecordChangeResult> {\n const currentTask = await requireTaskExecutionIdentity(stores, ctx, taskId);\n if (\n currentTask.state !== \"running\" &&\n currentTask.state !== \"verifying\" &&\n currentTask.state !== \"succeeded\"\n ) {\n throw new Error(`task ${taskId} is ${currentTask.state}, not running`);\n }\n if (currentTask.repositoryIds.length !== 1 || currentTask.repositoryIds[0] !== repositoryId) {\n throw new Error(\"Repository does not match the authenticated Task\");\n }\n if (currentTask.state !== \"running\" && currentTask.changeId === undefined) {\n throw new Error(`task ${taskId} has no recorded change`);\n }\n const linkedChange =\n currentTask.changeId === undefined\n ? undefined\n : await stores.changes.get(currentTask.changeId);\n if (currentTask.changeId !== undefined && linkedChange === null) {\n throw new Error(`change ${currentTask.changeId} does not exist`);\n }\n let change =\n linkedChange ?? (await stores.changes.list({ limit: 1, match: { taskId } })).items[0];\n\n if (\n change !== undefined &&\n (change.repositoryId !== repositoryId ||\n change.prNumber !== prNumber ||\n change.prUrl !== prUrl)\n ) {\n throw new Error(\"Pull request does not match the Task's recorded Change\");\n }\n if (currentTask.state === \"running\") {\n await requireTaskExecution(stores, ctx, taskId);\n if (recordChangeGate !== undefined) {\n const verdict = await recordChangeGate({\n taskId,\n repositoryId,\n prNumber,\n prUrl,\n risk,\n confidence,\n summary,\n });\n if (!verdict.ok) throw new Error(verdict.reason);\n }\n await requireTaskExecution(stores, ctx, taskId);\n }\n\n if (change === undefined) {\n if (currentTask.state !== \"running\") {\n throw new Error(`task ${taskId} has no recorded change`);\n }\n change = await stores.changes.create({\n repositoryId,\n taskId,\n prNumber,\n prUrl,\n author: \"factory\",\n risk,\n confidence,\n evidence: {\n reproVerified: false,\n testsAdded: 0,\n checksGreen: false,\n attackPassed: false,\n scopeMatches: false,\n },\n });\n }\n if (change === undefined) throw new Error(`task ${taskId} has no recorded change`);\n let recordedChange = change;\n\n if (currentTask.state === \"succeeded\") return { change: recordedChange, task: currentTask };\n\n if (currentTask.state === \"running\") {\n await requireTaskExecution(stores, ctx, taskId);\n await options.presentChange?.({\n taskId,\n changeId: recordedChange.id,\n verification,\n summary,\n });\n }\n\n let task: Task = currentTask;\n if (task.state === \"running\") {\n task = await stores.tasks.transition(taskId, \"verifying\", {\n expectFrom: \"running\",\n expectAttempt: currentTask.attempt,\n expectExecution: currentTask.execution,\n patch: { changeId: recordedChange.id },\n reason: summary,\n });\n }\n if (verification === \"required\") return { change: recordedChange, task };\n\n const completion = await requireTaskExecutionIdentity(stores, ctx, taskId);\n if (completion.state !== \"verifying\" || completion.changeId !== recordedChange.id) {\n throw new Error(\"Task is no longer awaiting this Change's completion\");\n }\n if (recordedChange.state === \"verifying\") {\n recordedChange = await stores.changes.transition(recordedChange.id, \"ready\", {\n reason: \"independent verification is not configured\",\n });\n }\n if (task.state === \"verifying\") {\n task = await stores.tasks.transition(taskId, \"succeeded\", {\n expectFrom: \"verifying\",\n expectAttempt: currentTask.attempt,\n expectExecution: currentTask.execution,\n reason: \"change recorded for human review without independent verification\",\n output: {\n changeId: recordedChange.id,\n prUrl: recordedChange.prUrl,\n summary,\n verification,\n },\n });\n }\n return { change: recordedChange, task };\n },\n });\n}\n"],"mappings":"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"}
@@ -1,16 +1,20 @@
1
1
  import { TaskId } from "./schema/id.mjs";
2
2
  import { AgentSessionId } from "./schema/session.mjs";
3
+ import { TranscriptBlock } from "./schema/transcript.mjs";
3
4
  import { Transcript } from "./client-transcript.mjs";
4
5
  import { FactoryClient } from "./client.mjs";
5
6
  import { SessionView } from "./api-contracts.mjs";
6
7
  //#region src/client-stream.d.ts
7
8
  /** Client operations required to hydrate and follow a Factory session stream. */
8
- type SessionStreamClient = Pick<FactoryClient, "getSessionTranscriptResponse" | "getSessionEventStreamResponse">;
9
+ type SessionStreamClient = Pick<FactoryClient, "getSessionTranscriptResponse" | "getSessionEventStreamResponse" | "getSessionHistory">;
9
10
  /** Callbacks and exact execution identity used while following a session stream. */
10
11
  interface FollowSessionStreamOptions {
11
12
  readonly sessionId: AgentSessionId;
12
13
  readonly executionId: string | null;
13
14
  readonly taskId?: TaskId;
15
+ /** Task bound to the conversation; absent for direct Foreman conversations. */
16
+ readonly sessionTaskId?: TaskId;
17
+ readonly historyNextCursor?: string | null;
14
18
  readonly history?: readonly NonNullable<SessionView["history"]>[number][];
15
19
  readonly transcript: Transcript;
16
20
  readonly signal: AbortSignal;
@@ -21,8 +25,21 @@ interface FollowSessionStreamOptions {
21
25
  readonly onError: (error: unknown) => void;
22
26
  readonly onConflict: () => Promise<void>;
23
27
  }
28
+ /** Loads one older page, preserving live cursor/state and execution ordering. */
29
+ declare function loadPreviousTranscriptPage(client: SessionStreamClient, input: {
30
+ sessionId: AgentSessionId;
31
+ transcript: Transcript;
32
+ signal: AbortSignal;
33
+ }): Promise<boolean>;
34
+ /** Expands one saved block without replacing newer live content. */
35
+ declare function expandTranscriptBlock(client: SessionStreamClient, input: {
36
+ sessionId: AgentSessionId;
37
+ transcript: Transcript;
38
+ block: TranscriptBlock;
39
+ signal: AbortSignal;
40
+ }): Promise<void>;
24
41
  /** Hydrates a transcript and follows its current execution until completion or disconnection. */
25
42
  declare function followSessionStream(client: SessionStreamClient, options: FollowSessionStreamOptions): Promise<void>;
26
43
  //#endregion
27
- export { FollowSessionStreamOptions, SessionStreamClient, followSessionStream };
44
+ export { FollowSessionStreamOptions, SessionStreamClient, expandTranscriptBlock, followSessionStream, loadPreviousTranscriptPage };
28
45
  //# sourceMappingURL=client-stream.d.mts.map
@@ -1,5 +1,5 @@
1
1
  import { readEvents } from "./client-events.mjs";
2
- import { transcriptSnapshotSchema } from "./schema/transcript.mjs";
2
+ import { transcriptBlockSchema, transcriptPageSchema } from "./schema/transcript.mjs";
3
3
  import { Transcript } from "./client-transcript.mjs";
4
4
  import { ApiError } from "./client.mjs";
5
5
  //#region src/client-stream.ts
@@ -26,7 +26,20 @@ async function readSnapshot(client, input) {
26
26
  ...input.history ? { history: true } : {},
27
27
  signal: requestSignal
28
28
  });
29
- input.transcript.hydrate(transcriptSnapshotSchema.parse(await response.json()));
29
+ if (response.status === 202) {
30
+ await response.body?.cancel();
31
+ await pause(input.pollIntervalMs, input.signal);
32
+ continue;
33
+ }
34
+ const page = transcriptPageSchema.parse(await response.json());
35
+ input.transcript.hydrate(page.snapshot, page.nextCursor === null);
36
+ input.transcript.historyPages.set(input.executionId, {
37
+ executionId: input.executionId,
38
+ taskId: input.taskId,
39
+ history: input.history,
40
+ nextCursor: page.nextCursor,
41
+ loaded: true
42
+ });
30
43
  return;
31
44
  } catch (error) {
32
45
  if (input.signal.aborted) return;
@@ -36,55 +49,86 @@ async function readSnapshot(client, input) {
36
49
  await pause(Math.min(15e3, input.pollIntervalMs * 2 ** Math.min(failures, 3)), input.signal);
37
50
  }
38
51
  }
39
- async function loadHistory(client, options) {
40
- const entries = options.history ?? [];
41
- const results = /* @__PURE__ */ new Map();
42
- let next = 0;
43
- await Promise.all(Array.from({ length: Math.min(4, entries.length) }, async () => {
44
- while (next < entries.length) {
45
- const index = next++;
46
- const entry = entries[index];
47
- if (!entry) throw new Error("Historical transcript index is unavailable");
48
- const id = entry.execution.sessionId;
49
- const previous = new Transcript(entry.message, entry.attachments);
50
- const header = {
51
- id: "run",
52
- kind: "status",
53
- title: entry.agent,
54
- text: entry.taskId ? `Earlier work · ${entry.taskId}` : "Earlier execution"
55
- };
56
- try {
57
- const historySignal = AbortSignal.any([options.signal, AbortSignal.timeout(15e3)]);
58
- await readSnapshot(client, {
59
- sessionId: options.sessionId,
60
- executionId: id,
61
- history: true,
62
- transcript: previous,
63
- signal: historySignal,
64
- pollIntervalMs: options.pollIntervalMs ?? 2e3
65
- });
66
- if (historySignal.aborted && !options.signal.aborted) throw new Error("Historical transcript loading timed out");
67
- } catch (error) {
68
- if (options.signal.aborted) return;
69
- if (error instanceof ApiError && [401, 403].includes(error.status)) throw error;
70
- previous.put({
71
- id: "unavailable",
72
- kind: "status",
73
- title: "History unavailable",
74
- text: "This execution's transcript could not be loaded."
75
- });
76
- }
77
- previous.blocks.unshift(header);
78
- results.set(index, {
79
- id,
80
- transcript: previous
52
+ /** Loads one older page, preserving live cursor/state and execution ordering. */
53
+ async function loadPreviousTranscriptPage(client, input) {
54
+ let reference = [...input.transcript.historyPages.values()].reverse().find((page) => !page.loaded || page.nextCursor !== null);
55
+ while (!reference && input.transcript.historyNextCursor && !input.signal.aborted) {
56
+ const page = await client.getSessionHistory(input.sessionId, {
57
+ cursor: input.transcript.historyNextCursor,
58
+ signal: input.signal
59
+ });
60
+ if (input.signal.aborted) return false;
61
+ const previous = new Map(input.transcript.historyPages);
62
+ input.transcript.historyPages.clear();
63
+ for (const entry of [...page.items].reverse()) {
64
+ if (entry.taskId && !input.transcript.sessionTaskId) continue;
65
+ input.transcript.historyPages.set(entry.execution.sessionId, {
66
+ executionId: entry.execution.sessionId,
67
+ history: true,
68
+ loaded: false,
69
+ nextCursor: null,
70
+ message: entry.message,
71
+ attachments: entry.attachments,
72
+ agent: entry.agent
81
73
  });
82
74
  }
83
- }));
84
- return entries.map((_, index) => {
85
- const result = results.get(index);
86
- if (!result) throw new Error("Historical transcript loading did not complete");
87
- return result;
75
+ for (const [key, value] of previous) input.transcript.historyPages.set(key, value);
76
+ input.transcript.historyNextCursor = page.nextCursor;
77
+ reference = [...input.transcript.historyPages.values()].reverse().find((entry) => !entry.loaded || entry.nextCursor !== null);
78
+ }
79
+ if (!reference) return false;
80
+ const response = await client.getSessionTranscriptResponse({
81
+ sessionId: input.sessionId,
82
+ executionId: reference.executionId,
83
+ taskId: reference.taskId,
84
+ history: reference.history,
85
+ cursor: reference.nextCursor ?? void 0,
86
+ limit: 30,
87
+ signal: input.signal
88
+ });
89
+ if (response.status === 202) {
90
+ await response.body?.cancel();
91
+ throw new Error("Earlier messages are still being saved. Try loading them again shortly.");
92
+ }
93
+ const page = transcriptPageSchema.parse(await response.json());
94
+ if (input.signal.aborted) return false;
95
+ const previous = new Transcript(reference.message, reference.attachments);
96
+ previous.hydrate(page.snapshot, page.nextCursor === null);
97
+ if (reference.history && !reference.loaded) previous.blocks.unshift({
98
+ id: "run",
99
+ kind: "status",
100
+ title: reference.agent ?? "Earlier execution",
101
+ text: reference.label ?? "Earlier execution"
102
+ });
103
+ input.transcript.prependPage(previous.blocks, reference.history ? reference.executionId : void 0, page.nextCursor === null);
104
+ input.transcript.historyPages.set(reference.executionId, {
105
+ ...reference,
106
+ loaded: true,
107
+ nextCursor: page.nextCursor
108
+ });
109
+ return true;
110
+ }
111
+ /** Expands one saved block without replacing newer live content. */
112
+ async function expandTranscriptBlock(client, input) {
113
+ if (!input.block.contentRef) return;
114
+ const references = [...input.transcript.historyPages.values()];
115
+ const reference = references.find((page) => page.history && input.block.id.startsWith(`history:${page.executionId}:`)) ?? references.find((page) => !page.history);
116
+ if (!reference) throw new Error("Transcript execution is unavailable");
117
+ const cursor = input.transcript.cursor;
118
+ const response = await client.getSessionTranscriptResponse({
119
+ sessionId: input.sessionId,
120
+ executionId: reference.executionId,
121
+ taskId: reference.taskId,
122
+ history: reference.history,
123
+ blockId: input.block.contentRef.blockId,
124
+ signal: input.signal
125
+ });
126
+ const block = transcriptBlockSchema.parse(await response.json());
127
+ if (input.signal.aborted) return;
128
+ if (!reference.history && input.transcript.cursor !== cursor) throw new Error("Transcript changed while loading content; try again");
129
+ input.transcript.put({
130
+ ...block,
131
+ id: input.block.id
88
132
  });
89
133
  }
90
134
  async function hydrateCurrent(client, options) {
@@ -135,16 +179,34 @@ async function hydrateCurrent(client, options) {
135
179
  /** Hydrates a transcript and follows its current execution until completion or disconnection. */
136
180
  async function followSessionStream(client, options) {
137
181
  const { sessionId, executionId, transcript, signal } = options;
138
- let history;
182
+ transcript.sessionTaskId = options.sessionTaskId;
183
+ transcript.historyNextCursor = options.historyNextCursor ?? null;
184
+ for (const entry of options.history ?? []) {
185
+ if (entry.taskId && !options.sessionTaskId) continue;
186
+ transcript.historyPages.set(entry.execution.sessionId, {
187
+ executionId: entry.execution.sessionId,
188
+ history: true,
189
+ nextCursor: null,
190
+ loaded: false,
191
+ message: entry.message,
192
+ attachments: entry.attachments,
193
+ agent: entry.agent,
194
+ label: entry.taskId ? `Earlier work · ${entry.taskId}` : "Earlier execution"
195
+ });
196
+ }
139
197
  let hydrated;
140
198
  try {
141
- [history, hydrated] = await Promise.all([loadHistory(client, options), hydrateCurrent(client, options)]);
199
+ hydrated = await hydrateCurrent(client, options);
200
+ if (executionId === null && !signal.aborted) await loadPreviousTranscriptPage(client, {
201
+ sessionId,
202
+ transcript,
203
+ signal
204
+ });
142
205
  } catch (error) {
143
206
  if (!signal.aborted) options.onError(error);
144
207
  return;
145
208
  }
146
209
  if (signal.aborted || !hydrated) return;
147
- transcript.prependHistory(history);
148
210
  if (executionId === null) transcript.state = "waiting";
149
211
  options.onHydrated();
150
212
  if (executionId === null || ["completed", "failed"].includes(transcript.state)) return;
@@ -205,6 +267,6 @@ async function followSessionStream(client, options) {
205
267
  }
206
268
  }
207
269
  //#endregion
208
- export { followSessionStream };
270
+ export { expandTranscriptBlock, followSessionStream, loadPreviousTranscriptPage };
209
271
 
210
272
  //# sourceMappingURL=client-stream.mjs.map