opencastle 0.26.1 → 0.27.1

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 (226) hide show
  1. package/README.md +7 -1
  2. package/bin/cli.mjs +10 -0
  3. package/dist/cli/agents.d.ts +3 -0
  4. package/dist/cli/agents.d.ts.map +1 -0
  5. package/dist/cli/agents.js +161 -0
  6. package/dist/cli/agents.js.map +1 -0
  7. package/dist/cli/baselines.d.ts +3 -0
  8. package/dist/cli/baselines.d.ts.map +1 -0
  9. package/dist/cli/baselines.js +128 -0
  10. package/dist/cli/baselines.js.map +1 -0
  11. package/dist/cli/convoy/engine.d.ts +68 -2
  12. package/dist/cli/convoy/engine.d.ts.map +1 -1
  13. package/dist/cli/convoy/engine.js +2102 -26
  14. package/dist/cli/convoy/engine.js.map +1 -1
  15. package/dist/cli/convoy/engine.test.js +1572 -70
  16. package/dist/cli/convoy/engine.test.js.map +1 -1
  17. package/dist/cli/convoy/events.d.ts +4 -1
  18. package/dist/cli/convoy/events.d.ts.map +1 -1
  19. package/dist/cli/convoy/events.js +74 -13
  20. package/dist/cli/convoy/events.js.map +1 -1
  21. package/dist/cli/convoy/events.test.js +154 -27
  22. package/dist/cli/convoy/events.test.js.map +1 -1
  23. package/dist/cli/convoy/expertise.d.ts +16 -0
  24. package/dist/cli/convoy/expertise.d.ts.map +1 -0
  25. package/dist/cli/convoy/expertise.js +121 -0
  26. package/dist/cli/convoy/expertise.js.map +1 -0
  27. package/dist/cli/convoy/expertise.test.d.ts +2 -0
  28. package/dist/cli/convoy/expertise.test.d.ts.map +1 -0
  29. package/dist/cli/convoy/expertise.test.js +96 -0
  30. package/dist/cli/convoy/expertise.test.js.map +1 -0
  31. package/dist/cli/convoy/export.test.js +1 -0
  32. package/dist/cli/convoy/export.test.js.map +1 -1
  33. package/dist/cli/convoy/formula.d.ts +19 -0
  34. package/dist/cli/convoy/formula.d.ts.map +1 -0
  35. package/dist/cli/convoy/formula.js +142 -0
  36. package/dist/cli/convoy/formula.js.map +1 -0
  37. package/dist/cli/convoy/formula.test.d.ts +2 -0
  38. package/dist/cli/convoy/formula.test.d.ts.map +1 -0
  39. package/dist/cli/convoy/formula.test.js +342 -0
  40. package/dist/cli/convoy/formula.test.js.map +1 -0
  41. package/dist/cli/convoy/gates.d.ts +128 -0
  42. package/dist/cli/convoy/gates.d.ts.map +1 -0
  43. package/dist/cli/convoy/gates.js +606 -0
  44. package/dist/cli/convoy/gates.js.map +1 -0
  45. package/dist/cli/convoy/gates.test.d.ts +2 -0
  46. package/dist/cli/convoy/gates.test.d.ts.map +1 -0
  47. package/dist/cli/convoy/gates.test.js +976 -0
  48. package/dist/cli/convoy/gates.test.js.map +1 -0
  49. package/dist/cli/convoy/health.d.ts +11 -0
  50. package/dist/cli/convoy/health.d.ts.map +1 -1
  51. package/dist/cli/convoy/health.js +54 -0
  52. package/dist/cli/convoy/health.js.map +1 -1
  53. package/dist/cli/convoy/health.test.js +56 -1
  54. package/dist/cli/convoy/health.test.js.map +1 -1
  55. package/dist/cli/convoy/issues.d.ts +8 -0
  56. package/dist/cli/convoy/issues.d.ts.map +1 -0
  57. package/dist/cli/convoy/issues.js +98 -0
  58. package/dist/cli/convoy/issues.js.map +1 -0
  59. package/dist/cli/convoy/issues.test.d.ts +2 -0
  60. package/dist/cli/convoy/issues.test.d.ts.map +1 -0
  61. package/dist/cli/convoy/issues.test.js +107 -0
  62. package/dist/cli/convoy/issues.test.js.map +1 -0
  63. package/dist/cli/convoy/knowledge.d.ts +5 -0
  64. package/dist/cli/convoy/knowledge.d.ts.map +1 -0
  65. package/dist/cli/convoy/knowledge.js +116 -0
  66. package/dist/cli/convoy/knowledge.js.map +1 -0
  67. package/dist/cli/convoy/knowledge.test.d.ts +2 -0
  68. package/dist/cli/convoy/knowledge.test.d.ts.map +1 -0
  69. package/dist/cli/convoy/knowledge.test.js +87 -0
  70. package/dist/cli/convoy/knowledge.test.js.map +1 -0
  71. package/dist/cli/convoy/lessons.d.ts +17 -0
  72. package/dist/cli/convoy/lessons.d.ts.map +1 -0
  73. package/dist/cli/convoy/lessons.js +149 -0
  74. package/dist/cli/convoy/lessons.js.map +1 -0
  75. package/dist/cli/convoy/lessons.test.d.ts +2 -0
  76. package/dist/cli/convoy/lessons.test.d.ts.map +1 -0
  77. package/dist/cli/convoy/lessons.test.js +135 -0
  78. package/dist/cli/convoy/lessons.test.js.map +1 -0
  79. package/dist/cli/convoy/lock.d.ts +13 -0
  80. package/dist/cli/convoy/lock.d.ts.map +1 -0
  81. package/dist/cli/convoy/lock.js +88 -0
  82. package/dist/cli/convoy/lock.js.map +1 -0
  83. package/dist/cli/convoy/lock.test.d.ts +2 -0
  84. package/dist/cli/convoy/lock.test.d.ts.map +1 -0
  85. package/dist/cli/convoy/lock.test.js +136 -0
  86. package/dist/cli/convoy/lock.test.js.map +1 -0
  87. package/dist/cli/convoy/merge.d.ts +4 -0
  88. package/dist/cli/convoy/merge.d.ts.map +1 -1
  89. package/dist/cli/convoy/merge.js +18 -1
  90. package/dist/cli/convoy/merge.js.map +1 -1
  91. package/dist/cli/convoy/merge.test.js +6 -7
  92. package/dist/cli/convoy/merge.test.js.map +1 -1
  93. package/dist/cli/convoy/partition.d.ts +51 -0
  94. package/dist/cli/convoy/partition.d.ts.map +1 -0
  95. package/dist/cli/convoy/partition.js +186 -0
  96. package/dist/cli/convoy/partition.js.map +1 -0
  97. package/dist/cli/convoy/partition.test.d.ts +2 -0
  98. package/dist/cli/convoy/partition.test.d.ts.map +1 -0
  99. package/dist/cli/convoy/partition.test.js +315 -0
  100. package/dist/cli/convoy/partition.test.js.map +1 -0
  101. package/dist/cli/convoy/pipeline.test.js +6 -0
  102. package/dist/cli/convoy/pipeline.test.js.map +1 -1
  103. package/dist/cli/convoy/store.d.ts +47 -5
  104. package/dist/cli/convoy/store.d.ts.map +1 -1
  105. package/dist/cli/convoy/store.js +525 -19
  106. package/dist/cli/convoy/store.js.map +1 -1
  107. package/dist/cli/convoy/store.test.js +1345 -12
  108. package/dist/cli/convoy/store.test.js.map +1 -1
  109. package/dist/cli/convoy/types.d.ts +156 -2
  110. package/dist/cli/convoy/types.d.ts.map +1 -1
  111. package/dist/cli/destroy.d.ts +3 -0
  112. package/dist/cli/destroy.d.ts.map +1 -0
  113. package/dist/cli/destroy.js +69 -0
  114. package/dist/cli/destroy.js.map +1 -0
  115. package/dist/cli/destroy.test.d.ts +2 -0
  116. package/dist/cli/destroy.test.d.ts.map +1 -0
  117. package/dist/cli/destroy.test.js +116 -0
  118. package/dist/cli/destroy.test.js.map +1 -0
  119. package/dist/cli/gitignore.d.ts +9 -0
  120. package/dist/cli/gitignore.d.ts.map +1 -1
  121. package/dist/cli/gitignore.js +29 -0
  122. package/dist/cli/gitignore.js.map +1 -1
  123. package/dist/cli/plan.d.ts +3 -0
  124. package/dist/cli/plan.d.ts.map +1 -0
  125. package/dist/cli/plan.js +288 -0
  126. package/dist/cli/plan.js.map +1 -0
  127. package/dist/cli/run/adapters/claude.d.ts +2 -0
  128. package/dist/cli/run/adapters/claude.d.ts.map +1 -1
  129. package/dist/cli/run/adapters/claude.js +89 -49
  130. package/dist/cli/run/adapters/claude.js.map +1 -1
  131. package/dist/cli/run/adapters/claude.test.d.ts +2 -0
  132. package/dist/cli/run/adapters/claude.test.d.ts.map +1 -0
  133. package/dist/cli/run/adapters/claude.test.js +205 -0
  134. package/dist/cli/run/adapters/claude.test.js.map +1 -0
  135. package/dist/cli/run/adapters/copilot.d.ts +1 -0
  136. package/dist/cli/run/adapters/copilot.d.ts.map +1 -1
  137. package/dist/cli/run/adapters/copilot.js +84 -46
  138. package/dist/cli/run/adapters/copilot.js.map +1 -1
  139. package/dist/cli/run/adapters/copilot.test.d.ts +2 -0
  140. package/dist/cli/run/adapters/copilot.test.d.ts.map +1 -0
  141. package/dist/cli/run/adapters/copilot.test.js +195 -0
  142. package/dist/cli/run/adapters/copilot.test.js.map +1 -0
  143. package/dist/cli/run/adapters/cursor.d.ts +1 -0
  144. package/dist/cli/run/adapters/cursor.d.ts.map +1 -1
  145. package/dist/cli/run/adapters/cursor.js +83 -47
  146. package/dist/cli/run/adapters/cursor.js.map +1 -1
  147. package/dist/cli/run/adapters/cursor.test.d.ts +2 -0
  148. package/dist/cli/run/adapters/cursor.test.d.ts.map +1 -0
  149. package/dist/cli/run/adapters/cursor.test.js +129 -0
  150. package/dist/cli/run/adapters/cursor.test.js.map +1 -0
  151. package/dist/cli/run/adapters/opencode.d.ts +1 -0
  152. package/dist/cli/run/adapters/opencode.d.ts.map +1 -1
  153. package/dist/cli/run/adapters/opencode.js +81 -47
  154. package/dist/cli/run/adapters/opencode.js.map +1 -1
  155. package/dist/cli/run/adapters/opencode.test.d.ts +2 -0
  156. package/dist/cli/run/adapters/opencode.test.d.ts.map +1 -0
  157. package/dist/cli/run/adapters/opencode.test.js +119 -0
  158. package/dist/cli/run/adapters/opencode.test.js.map +1 -0
  159. package/dist/cli/run/executor.js +1 -1
  160. package/dist/cli/run/executor.js.map +1 -1
  161. package/dist/cli/run/schema.d.ts.map +1 -1
  162. package/dist/cli/run/schema.js +245 -4
  163. package/dist/cli/run/schema.js.map +1 -1
  164. package/dist/cli/run/schema.test.js +669 -0
  165. package/dist/cli/run/schema.test.js.map +1 -1
  166. package/dist/cli/run.d.ts.map +1 -1
  167. package/dist/cli/run.js +362 -22
  168. package/dist/cli/run.js.map +1 -1
  169. package/dist/cli/types.d.ts +85 -2
  170. package/dist/cli/types.d.ts.map +1 -1
  171. package/dist/cli/types.js.map +1 -1
  172. package/dist/cli/watch.d.ts +15 -0
  173. package/dist/cli/watch.d.ts.map +1 -0
  174. package/dist/cli/watch.js +279 -0
  175. package/dist/cli/watch.js.map +1 -0
  176. package/package.json +1 -1
  177. package/src/cli/agents.ts +177 -0
  178. package/src/cli/baselines.ts +143 -0
  179. package/src/cli/convoy/engine.test.ts +1839 -70
  180. package/src/cli/convoy/engine.ts +2417 -38
  181. package/src/cli/convoy/events.test.ts +179 -38
  182. package/src/cli/convoy/events.ts +88 -16
  183. package/src/cli/convoy/expertise.test.ts +128 -0
  184. package/src/cli/convoy/expertise.ts +163 -0
  185. package/src/cli/convoy/export.test.ts +1 -0
  186. package/src/cli/convoy/formula.test.ts +405 -0
  187. package/src/cli/convoy/formula.ts +174 -0
  188. package/src/cli/convoy/gates.test.ts +1169 -0
  189. package/src/cli/convoy/gates.ts +774 -0
  190. package/src/cli/convoy/health.test.ts +64 -2
  191. package/src/cli/convoy/health.ts +80 -2
  192. package/src/cli/convoy/issues.test.ts +143 -0
  193. package/src/cli/convoy/issues.ts +136 -0
  194. package/src/cli/convoy/knowledge.test.ts +101 -0
  195. package/src/cli/convoy/knowledge.ts +132 -0
  196. package/src/cli/convoy/lessons.test.ts +188 -0
  197. package/src/cli/convoy/lessons.ts +164 -0
  198. package/src/cli/convoy/lock.test.ts +181 -0
  199. package/src/cli/convoy/lock.ts +103 -0
  200. package/src/cli/convoy/merge.test.ts +6 -7
  201. package/src/cli/convoy/merge.ts +19 -1
  202. package/src/cli/convoy/partition.test.ts +423 -0
  203. package/src/cli/convoy/partition.ts +232 -0
  204. package/src/cli/convoy/pipeline.test.ts +6 -0
  205. package/src/cli/convoy/store.test.ts +1512 -14
  206. package/src/cli/convoy/store.ts +676 -30
  207. package/src/cli/convoy/types.ts +170 -1
  208. package/src/cli/destroy.test.ts +141 -0
  209. package/src/cli/destroy.ts +88 -0
  210. package/src/cli/gitignore.ts +36 -0
  211. package/src/cli/plan.ts +316 -0
  212. package/src/cli/run/adapters/claude.test.ts +234 -0
  213. package/src/cli/run/adapters/claude.ts +45 -5
  214. package/src/cli/run/adapters/copilot.test.ts +224 -0
  215. package/src/cli/run/adapters/copilot.ts +34 -4
  216. package/src/cli/run/adapters/cursor.test.ts +144 -0
  217. package/src/cli/run/adapters/cursor.ts +33 -2
  218. package/src/cli/run/adapters/opencode.test.ts +135 -0
  219. package/src/cli/run/adapters/opencode.ts +30 -2
  220. package/src/cli/run/executor.ts +1 -1
  221. package/src/cli/run/schema.test.ts +758 -0
  222. package/src/cli/run/schema.ts +300 -25
  223. package/src/cli/run.ts +341 -21
  224. package/src/cli/types.ts +86 -1
  225. package/src/cli/watch.ts +298 -0
  226. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
@@ -1,3 +1,4 @@
1
+ import { copyFileSync } from 'node:fs'
1
2
  import { DatabaseSync } from 'node:sqlite'
2
3
  import type {
3
4
  ConvoyRecord,
@@ -9,12 +10,61 @@ import type {
9
10
  EventRecord,
10
11
  PipelineRecord,
11
12
  PipelineStatus,
13
+ DlqRecord,
14
+ ArtifactRecord,
15
+ AgentIdentityRecord,
16
+ TaskStepRecord,
12
17
  } from './types.js'
13
18
 
14
- const SCHEMA_VERSION = 4
19
+ const SCHEMA_VERSION = 9
20
+
21
+ // ── Size limits (bytes) ────────────────────────────────────────────────────────
22
+ const LIMIT_SPEC_YAML = 256 * 1024 // 256 KB
23
+ const LIMIT_OUTPUT = 1024 * 1024 // 1 MB (head 512KB + tail 512KB)
24
+ const LIMIT_OUTPUT_HALF = 512 * 1024 // 512 KB per half
25
+ const LIMIT_EVENT_DATA = 64 * 1024 // 64 KB
26
+ const LIMIT_SUMMARY = 4096 // 4 KB
27
+
28
+ export class FieldSizeLimitError extends Error {
29
+ constructor(field: string, actual: number, limit: number) {
30
+ super(`Field "${field}" exceeds size limit: ${actual} bytes > ${limit} bytes`)
31
+ this.name = 'FieldSizeLimitError'
32
+ }
33
+ }
34
+
35
+ function enforceLimit(value: string | null | undefined, field: string, limit: number): void {
36
+ if (value == null) return
37
+ const size = Buffer.byteLength(value, 'utf8')
38
+ if (size > limit) {
39
+ throw new FieldSizeLimitError(field, size, limit)
40
+ }
41
+ }
42
+
43
+ function truncateOutput(value: string | null | undefined): string | null {
44
+ if (value == null) return null
45
+ const size = Buffer.byteLength(value, 'utf8')
46
+ if (size <= LIMIT_OUTPUT) return value
47
+ // Head + tail truncation with marker
48
+ const head = value.slice(0, LIMIT_OUTPUT_HALF)
49
+ const tail = value.slice(-LIMIT_OUTPUT_HALF)
50
+ return head + '\n\n... [truncated: ' + size + ' bytes total, showing first/last 512KB] ...\n\n' + tail
51
+ }
52
+
53
+ export class ConvoyArtifactLimitError extends Error {
54
+ constructor(convoyId: string) {
55
+ super(`Convoy ${convoyId} has reached the maximum of 50 artifacts`)
56
+ this.name = 'ConvoyArtifactLimitError'
57
+ }
58
+ }
15
59
 
16
60
  export interface ConvoyStore {
17
- insertConvoy(record: Omit<ConvoyRecord, 'started_at' | 'finished_at' | 'total_tokens' | 'total_cost_usd' | 'pipeline_id'> & { pipeline_id?: string | null }): void
61
+ insertConvoy(
62
+ record: Omit<
63
+ ConvoyRecord,
64
+ | 'started_at' | 'finished_at' | 'total_tokens' | 'total_cost_usd'
65
+ | 'pipeline_id' | 'circuit_state' | 'review_tokens_total' | 'review_budget'
66
+ > & { pipeline_id?: string | null },
67
+ ): void
18
68
  getConvoy(id: string): ConvoyRecord | undefined
19
69
  getLatestConvoy(): ConvoyRecord | undefined
20
70
  updateConvoyStatus(
@@ -22,23 +72,54 @@ export interface ConvoyStore {
22
72
  status: ConvoyStatus,
23
73
  extra?: { started_at?: string; finished_at?: string; total_tokens?: number | null; total_cost_usd?: string | null },
24
74
  ): void
75
+ updateConvoyReviewTokens(convoyId: string, tokens: number): void
76
+ updateConvoyCircuitState(convoyId: string, state: string | null): void
25
77
  insertTask(
26
78
  record: Omit<
27
79
  TaskRecord,
28
- 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at' | 'prompt_tokens' | 'completion_tokens' | 'total_tokens' | 'cost_usd'
29
- >,
80
+ | 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at'
81
+ | 'prompt_tokens' | 'completion_tokens' | 'total_tokens' | 'cost_usd'
82
+ | 'on_exhausted' | 'injected' | 'provenance' | 'idempotency_key'
83
+ | 'current_step' | 'total_steps' | 'review_level' | 'review_verdict'
84
+ | 'review_tokens' | 'review_model' | 'panel_attempts' | 'dispute_id'
85
+ | 'drift_score' | 'drift_retried' | 'discovered_issues'
86
+ > & { outputs?: string | null; inputs?: string | null },
30
87
  ): void
88
+ insertInjectedTask(record: TaskRecord): void
31
89
  getTask(id: string, convoyId: string): TaskRecord | undefined
32
90
  getTasksByConvoy(convoyId: string): TaskRecord[]
91
+ getTaskByIdempotencyKey(convoyId: string, key: string): TaskRecord | undefined
92
+ getTaskByDisputeId(disputeId: string): TaskRecord | undefined
93
+ getDisputedTasks(convoyId?: string): TaskRecord[]
33
94
  updateTaskStatus(
34
95
  id: string,
35
96
  convoyId: string,
36
97
  status: ConvoyTaskStatus,
37
98
  extra?: Partial<
38
- Pick<TaskRecord, 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at' | 'retries' | 'prompt_tokens' | 'completion_tokens' | 'total_tokens' | 'cost_usd'>
99
+ Pick<
100
+ TaskRecord,
101
+ | 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at'
102
+ | 'retries' | 'prompt_tokens' | 'completion_tokens' | 'total_tokens' | 'cost_usd' | 'prompt'
103
+ >
39
104
  >,
40
105
  ): void
106
+ updateTaskReview(
107
+ taskId: string,
108
+ convoyId: string,
109
+ fields: Partial<Pick<TaskRecord, 'review_level' | 'review_verdict' | 'review_tokens' | 'review_model' | 'panel_attempts' | 'dispute_id'>>,
110
+ ): void
111
+ updateTaskDrift(
112
+ taskId: string,
113
+ convoyId: string,
114
+ fields: Partial<Pick<TaskRecord, 'drift_score' | 'drift_retried'>>,
115
+ ): void
116
+ updateTaskDisputeStatus(taskId: string, convoyId: string, status: ConvoyTaskStatus, disputeId: string): void
41
117
  getReadyTasks(convoyId: string): TaskRecord[]
118
+ insertTaskStep(record: Omit<TaskStepRecord, 'id'>): number
119
+ updateTaskStep(
120
+ id: number,
121
+ fields: Partial<Pick<TaskStepRecord, 'status' | 'exit_code' | 'output' | 'started_at' | 'finished_at'>>,
122
+ ): void
42
123
  insertWorker(record: Omit<WorkerRecord, 'finished_at' | 'last_heartbeat'>): void
43
124
  getWorker(id: string): WorkerRecord | undefined
44
125
  updateWorkerStatus(
@@ -46,8 +127,24 @@ export interface ConvoyStore {
46
127
  status: WorkerStatus,
47
128
  extra?: Partial<Pick<WorkerRecord, 'finished_at' | 'last_heartbeat' | 'pid'>>,
48
129
  ): void
49
- insertEvent(record: Omit<EventRecord, 'id'>): void
130
+ insertEvent(record: Omit<EventRecord, 'id'>): number
50
131
  getEvents(convoyId: string): EventRecord[]
132
+ insertDlqEntry(record: DlqRecord): void
133
+ listDlqEntries(convoyIdFilter?: string): DlqRecord[]
134
+ resolveDlqEntry(id: string, resolution: string): void
135
+ insertArtifact(record: ArtifactRecord): void
136
+ getArtifact(convoyId: string, name: string): ArtifactRecord | undefined
137
+ getArtifactsByTask(taskId: string): ArtifactRecord[]
138
+ deleteArtifactsOlderThan(days: number): number
139
+ insertAgentIdentity(record: AgentIdentityRecord): void
140
+ getAgentIdentities(agent: string, limit: number): AgentIdentityRecord[]
141
+ listAgentIdentitySummary(): Array<{ agent: string; task_count: number; latest_date: string }>
142
+ purgeAgentIdentities(agent: string): number
143
+ deleteAgentIdentitiesOlderThan(days: number): number
144
+ getScratchpadValue(key: string): string | null
145
+ setScratchpadValue(key: string, value: string): void
146
+ clearScratchpad(): void
147
+ clearScratchpadOlderThan(days: number): void
51
148
  insertPipeline(record: Omit<PipelineRecord, 'started_at' | 'finished_at' | 'total_tokens' | 'total_cost_usd'>): void
52
149
  getPipeline(id: string): PipelineRecord | undefined
53
150
  getLatestPipeline(): PipelineRecord | undefined
@@ -63,8 +160,10 @@ export interface ConvoyStore {
63
160
 
64
161
  class ConvoyStoreImpl implements ConvoyStore {
65
162
  private db: DatabaseSync
163
+ private dbPath: string
66
164
 
67
165
  constructor(dbPath: string) {
166
+ this.dbPath = dbPath
68
167
  this.db = new DatabaseSync(dbPath)
69
168
  this.db.exec('PRAGMA journal_mode = WAL')
70
169
  this.db.exec('PRAGMA synchronous = NORMAL')
@@ -76,18 +175,21 @@ class ConvoyStoreImpl implements ConvoyStore {
76
175
  if (version === 0) {
77
176
  this.db.exec(`
78
177
  CREATE TABLE IF NOT EXISTS convoy (
79
- id TEXT PRIMARY KEY,
80
- name TEXT NOT NULL,
81
- spec_hash TEXT NOT NULL,
82
- status TEXT NOT NULL DEFAULT 'pending',
83
- branch TEXT,
84
- created_at TEXT NOT NULL,
85
- started_at TEXT,
86
- finished_at TEXT,
87
- spec_yaml TEXT NOT NULL,
88
- total_tokens INTEGER,
89
- total_cost_usd TEXT,
90
- pipeline_id TEXT
178
+ id TEXT PRIMARY KEY,
179
+ name TEXT NOT NULL,
180
+ spec_hash TEXT NOT NULL,
181
+ status TEXT NOT NULL DEFAULT 'pending',
182
+ branch TEXT,
183
+ created_at TEXT NOT NULL,
184
+ started_at TEXT,
185
+ finished_at TEXT,
186
+ spec_yaml TEXT NOT NULL,
187
+ total_tokens INTEGER,
188
+ total_cost_usd TEXT,
189
+ pipeline_id TEXT,
190
+ circuit_state TEXT,
191
+ review_tokens_total INTEGER,
192
+ review_budget INTEGER
91
193
  );
92
194
 
93
195
  CREATE TABLE IF NOT EXISTS pipeline (
@@ -127,7 +229,41 @@ class ConvoyStoreImpl implements ConvoyStore {
127
229
  prompt_tokens INTEGER,
128
230
  completion_tokens INTEGER,
129
231
  total_tokens INTEGER,
130
- cost_usd TEXT
232
+ cost_usd TEXT,
233
+ gates TEXT,
234
+ on_exhausted TEXT NOT NULL DEFAULT 'dlq',
235
+ injected INTEGER NOT NULL DEFAULT 0,
236
+ provenance TEXT,
237
+ idempotency_key TEXT,
238
+ current_step INTEGER,
239
+ total_steps INTEGER,
240
+ review_level TEXT,
241
+ review_verdict TEXT,
242
+ review_tokens INTEGER,
243
+ review_model TEXT,
244
+ panel_attempts INTEGER NOT NULL DEFAULT 0,
245
+ dispute_id TEXT,
246
+ drift_score REAL,
247
+ drift_retried INTEGER NOT NULL DEFAULT 0,
248
+ outputs TEXT,
249
+ inputs TEXT,
250
+ discovered_issues TEXT
251
+ );
252
+
253
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_task_idempotency ON task(convoy_id, idempotency_key)
254
+ WHERE idempotency_key IS NOT NULL;
255
+
256
+ CREATE TABLE IF NOT EXISTS task_step (
257
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
258
+ task_id TEXT NOT NULL REFERENCES task(id),
259
+ step_index INTEGER NOT NULL,
260
+ prompt TEXT NOT NULL,
261
+ gates TEXT,
262
+ status TEXT NOT NULL DEFAULT 'pending',
263
+ exit_code INTEGER,
264
+ output TEXT,
265
+ started_at TEXT,
266
+ finished_at TEXT
131
267
  );
132
268
 
133
269
  CREATE TABLE IF NOT EXISTS worker (
@@ -152,6 +288,57 @@ class ConvoyStoreImpl implements ConvoyStore {
152
288
  data TEXT,
153
289
  created_at TEXT NOT NULL
154
290
  );
291
+
292
+ CREATE TABLE IF NOT EXISTS dlq (
293
+ id TEXT PRIMARY KEY,
294
+ convoy_id TEXT NOT NULL REFERENCES convoy(id),
295
+ task_id TEXT NOT NULL REFERENCES task(id),
296
+ agent TEXT NOT NULL,
297
+ failure_type TEXT NOT NULL,
298
+ error_output TEXT,
299
+ attempts INTEGER NOT NULL,
300
+ tokens_spent INTEGER,
301
+ escalation_task_id TEXT,
302
+ resolved INTEGER NOT NULL DEFAULT 0,
303
+ resolution TEXT,
304
+ created_at TEXT NOT NULL,
305
+ resolved_at TEXT
306
+ );
307
+
308
+ CREATE TABLE IF NOT EXISTS artifact (
309
+ id TEXT PRIMARY KEY,
310
+ convoy_id TEXT NOT NULL REFERENCES convoy(id),
311
+ task_id TEXT NOT NULL REFERENCES task(id),
312
+ name TEXT NOT NULL,
313
+ type TEXT NOT NULL,
314
+ content TEXT NOT NULL CHECK (length(content) <= 1048576),
315
+ created_at TEXT NOT NULL,
316
+ UNIQUE(convoy_id, name)
317
+ );
318
+
319
+ CREATE TABLE IF NOT EXISTS agent_identity (
320
+ id TEXT PRIMARY KEY,
321
+ agent TEXT NOT NULL,
322
+ convoy_id TEXT NOT NULL,
323
+ task_id TEXT NOT NULL,
324
+ summary TEXT NOT NULL,
325
+ created_at TEXT NOT NULL,
326
+ retention_days INTEGER NOT NULL DEFAULT 90
327
+ );
328
+
329
+ CREATE TABLE IF NOT EXISTS scratchpad (
330
+ key TEXT PRIMARY KEY,
331
+ value TEXT NOT NULL,
332
+ updated_at TEXT NOT NULL
333
+ );
334
+
335
+ CREATE TABLE IF NOT EXISTS engine_lock (
336
+ id INTEGER PRIMARY KEY,
337
+ pid INTEGER NOT NULL,
338
+ hostname TEXT NOT NULL,
339
+ started_at TEXT NOT NULL,
340
+ last_heartbeat TEXT NOT NULL
341
+ );
155
342
  `)
156
343
  this.db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
157
344
  version = SCHEMA_VERSION
@@ -191,13 +378,44 @@ class ConvoyStoreImpl implements ConvoyStore {
191
378
  this.db.exec('PRAGMA user_version = 4')
192
379
  version = 4
193
380
  }
381
+ if (version === 4) {
382
+ migrateSchema(this.db, this.dbPath, 4, 5)
383
+ version = 5
384
+ }
385
+ if (version === 5) {
386
+ migrateSchema(this.db, this.dbPath, 5, 6)
387
+ version = 6
388
+ }
389
+ if (version === 6) {
390
+ migrateSchema(this.db, this.dbPath, 6, 7)
391
+ version = 7
392
+ }
393
+ if (version === 7) {
394
+ migrateSchema(this.db, this.dbPath, 7, 8)
395
+ version = 8
396
+ }
397
+ if (version === 8) {
398
+ migrateSchema(this.db, this.dbPath, 8, 9)
399
+ version = 9
400
+ }
194
401
  }
195
402
 
196
- insertConvoy(record: Omit<ConvoyRecord, 'started_at' | 'finished_at' | 'total_tokens' | 'total_cost_usd' | 'pipeline_id'> & { pipeline_id?: string | null }): void {
403
+ insertConvoy(
404
+ record: Omit<
405
+ ConvoyRecord,
406
+ | 'started_at' | 'finished_at' | 'total_tokens' | 'total_cost_usd'
407
+ | 'pipeline_id' | 'circuit_state' | 'review_tokens_total' | 'review_budget'
408
+ > & { pipeline_id?: string | null },
409
+ ): void {
410
+ enforceLimit(record.spec_yaml, 'spec_yaml', LIMIT_SPEC_YAML)
197
411
  this.db
198
412
  .prepare(
199
- `INSERT INTO convoy (id, name, spec_hash, status, branch, created_at, started_at, finished_at, spec_yaml, pipeline_id)
200
- VALUES (:id, :name, :spec_hash, :status, :branch, :created_at, NULL, NULL, :spec_yaml, :pipeline_id)`,
413
+ `INSERT INTO convoy
414
+ (id, name, spec_hash, status, branch, created_at, started_at, finished_at,
415
+ spec_yaml, pipeline_id)
416
+ VALUES
417
+ (:id, :name, :spec_hash, :status, :branch, :created_at, NULL, NULL,
418
+ :spec_yaml, :pipeline_id)`,
201
419
  )
202
420
  .run({ ...record, pipeline_id: record.pipeline_id ?? null })
203
421
  }
@@ -242,24 +460,72 @@ class ConvoyStoreImpl implements ConvoyStore {
242
460
  this.db.prepare(`UPDATE convoy SET ${sets.join(', ')} WHERE id = :id`).run(params)
243
461
  }
244
462
 
463
+ updateConvoyReviewTokens(convoyId: string, tokens: number): void {
464
+ this.db
465
+ .prepare(
466
+ `UPDATE convoy
467
+ SET review_tokens_total = :tokens
468
+ WHERE id = :id`,
469
+ )
470
+ .run({ id: convoyId, tokens })
471
+ }
472
+
473
+ updateConvoyCircuitState(convoyId: string, state: string | null): void {
474
+ this.db
475
+ .prepare('UPDATE convoy SET circuit_state = :state WHERE id = :id')
476
+ .run({ id: convoyId, state: state ?? null })
477
+ }
478
+
245
479
  insertTask(
246
480
  record: Omit<
247
481
  TaskRecord,
248
- 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at' | 'prompt_tokens' | 'completion_tokens' | 'total_tokens' | 'cost_usd'
249
- >,
482
+ | 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at'
483
+ | 'prompt_tokens' | 'completion_tokens' | 'total_tokens' | 'cost_usd'
484
+ | 'on_exhausted' | 'injected' | 'provenance' | 'idempotency_key'
485
+ | 'current_step' | 'total_steps' | 'review_level' | 'review_verdict'
486
+ | 'review_tokens' | 'review_model' | 'panel_attempts' | 'dispute_id'
487
+ | 'drift_score' | 'drift_retried' | 'discovered_issues'
488
+ > & { outputs?: string | null; inputs?: string | null },
250
489
  ): void {
251
490
  this.db
252
491
  .prepare(
253
492
  `INSERT INTO task
254
493
  (id, convoy_id, phase, prompt, agent, adapter, model, timeout_ms, status,
255
494
  worker_id, worktree, output, exit_code, started_at, finished_at,
256
- retries, max_retries, files, depends_on)
495
+ retries, max_retries, files, depends_on, gates,
496
+ on_exhausted, injected, provenance, idempotency_key,
497
+ outputs, inputs)
257
498
  VALUES
258
499
  (:id, :convoy_id, :phase, :prompt, :agent, :adapter, :model, :timeout_ms, :status,
259
500
  NULL, NULL, NULL, NULL, NULL, NULL,
260
- :retries, :max_retries, :files, :depends_on)`,
501
+ :retries, :max_retries, :files, :depends_on, :gates,
502
+ 'dlq', 0, NULL, NULL,
503
+ :outputs, :inputs)`,
261
504
  )
262
- .run(record)
505
+ .run({ ...record, outputs: record.outputs ?? null, inputs: record.inputs ?? null })
506
+ }
507
+
508
+ insertInjectedTask(record: TaskRecord): void {
509
+ this.db
510
+ .prepare(
511
+ `INSERT INTO task
512
+ (id, convoy_id, phase, prompt, agent, adapter, model, timeout_ms, status,
513
+ worker_id, worktree, output, exit_code, started_at, finished_at,
514
+ retries, max_retries, files, depends_on, gates,
515
+ on_exhausted, injected, provenance, idempotency_key,
516
+ current_step, total_steps, review_level, review_verdict,
517
+ review_tokens, review_model, panel_attempts, dispute_id,
518
+ drift_score, drift_retried, outputs, inputs, discovered_issues)
519
+ VALUES
520
+ (:id, :convoy_id, :phase, :prompt, :agent, :adapter, :model, :timeout_ms, :status,
521
+ :worker_id, :worktree, :output, :exit_code, :started_at, :finished_at,
522
+ :retries, :max_retries, :files, :depends_on, :gates,
523
+ :on_exhausted, :injected, :provenance, :idempotency_key,
524
+ :current_step, :total_steps, :review_level, :review_verdict,
525
+ :review_tokens, :review_model, :panel_attempts, :dispute_id,
526
+ :drift_score, :drift_retried, :outputs, :inputs, :discovered_issues)`,
527
+ )
528
+ .run(record as unknown as Record<string, string | number | null>)
263
529
  }
264
530
 
265
531
  getTask(id: string, convoyId: string): TaskRecord | undefined {
@@ -274,17 +540,50 @@ class ConvoyStoreImpl implements ConvoyStore {
274
540
  .all({ convoy_id: convoyId }) as unknown as TaskRecord[]
275
541
  }
276
542
 
543
+ getTaskByIdempotencyKey(convoyId: string, key: string): TaskRecord | undefined {
544
+ return this.db
545
+ .prepare('SELECT * FROM task WHERE convoy_id = :convoy_id AND idempotency_key = :key')
546
+ .get({ convoy_id: convoyId, key }) as TaskRecord | undefined
547
+ }
548
+
549
+ getTaskByDisputeId(disputeId: string): TaskRecord | undefined {
550
+ return this.db
551
+ .prepare('SELECT * FROM task WHERE dispute_id = :dispute_id LIMIT 1')
552
+ .get({ dispute_id: disputeId }) as TaskRecord | undefined
553
+ }
554
+
555
+ getDisputedTasks(convoyId?: string): TaskRecord[] {
556
+ if (convoyId) {
557
+ return this.db
558
+ .prepare("SELECT * FROM task WHERE status = 'disputed' AND convoy_id = :convoy_id ORDER BY phase, id")
559
+ .all({ convoy_id: convoyId }) as unknown as TaskRecord[]
560
+ }
561
+ return this.db
562
+ .prepare("SELECT * FROM task WHERE status = 'disputed' ORDER BY convoy_id, phase, id")
563
+ .all({}) as unknown as TaskRecord[]
564
+ }
565
+
277
566
  updateTaskStatus(
278
567
  id: string,
279
568
  convoyId: string,
280
569
  status: ConvoyTaskStatus,
281
570
  extra?: Partial<
282
- Pick<TaskRecord, 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at' | 'retries' | 'prompt_tokens' | 'completion_tokens' | 'total_tokens' | 'cost_usd'>
571
+ Pick<
572
+ TaskRecord,
573
+ | 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at'
574
+ | 'retries' | 'prompt_tokens' | 'completion_tokens' | 'total_tokens' | 'cost_usd' | 'prompt'
575
+ >
283
576
  >,
284
577
  ): void {
578
+ if (extra?.output !== undefined) {
579
+ extra = { ...extra, output: truncateOutput(extra.output) }
580
+ }
285
581
  const sets = ['status = :status']
286
582
  const params: Record<string, string | number | null> = { id, convoy_id: convoyId, status }
287
- const extraFields = ['worker_id', 'worktree', 'output', 'exit_code', 'started_at', 'finished_at', 'retries', 'prompt_tokens', 'completion_tokens', 'total_tokens', 'cost_usd'] as const
583
+ const extraFields = [
584
+ 'worker_id', 'worktree', 'output', 'exit_code', 'started_at', 'finished_at',
585
+ 'retries', 'prompt_tokens', 'completion_tokens', 'total_tokens', 'cost_usd', 'prompt',
586
+ ] as const
288
587
 
289
588
  if (extra) {
290
589
  for (const field of extraFields) {
@@ -312,6 +611,88 @@ class ConvoyStoreImpl implements ConvoyStore {
312
611
  })
313
612
  }
314
613
 
614
+ insertTaskStep(record: Omit<TaskStepRecord, 'id'>): number {
615
+ this.db
616
+ .prepare(
617
+ `INSERT INTO task_step
618
+ (task_id, step_index, prompt, gates, status, exit_code, output, started_at, finished_at)
619
+ VALUES
620
+ (:task_id, :step_index, :prompt, :gates, :status, :exit_code, :output, :started_at, :finished_at)`,
621
+ )
622
+ .run(record)
623
+ const row = this.db.prepare('SELECT last_insert_rowid() AS id').get() as { id: number }
624
+ return row.id
625
+ }
626
+
627
+ updateTaskStep(
628
+ id: number,
629
+ fields: Partial<Pick<TaskStepRecord, 'status' | 'exit_code' | 'output' | 'started_at' | 'finished_at'>>,
630
+ ): void {
631
+ const sets: string[] = []
632
+ const params: Record<string, string | number | null> = { id }
633
+ const stepFields = ['status', 'exit_code', 'output', 'started_at', 'finished_at'] as const
634
+
635
+ for (const field of stepFields) {
636
+ if (field in fields && fields[field] !== undefined) {
637
+ sets.push(`${field} = :${field}`)
638
+ params[field] = fields[field] as string | number | null
639
+ }
640
+ }
641
+
642
+ if (sets.length === 0) return
643
+ this.db.prepare(`UPDATE task_step SET ${sets.join(', ')} WHERE id = :id`).run(params)
644
+ }
645
+
646
+ updateTaskReview(
647
+ taskId: string,
648
+ convoyId: string,
649
+ fields: Partial<Pick<TaskRecord, 'review_level' | 'review_verdict' | 'review_tokens' | 'review_model' | 'panel_attempts' | 'dispute_id'>>,
650
+ ): void {
651
+ const sets: string[] = []
652
+ const params: Record<string, string | number | null> = { id: taskId, convoy_id: convoyId }
653
+ const reviewFields = ['review_level', 'review_verdict', 'review_tokens', 'review_model', 'panel_attempts', 'dispute_id'] as const
654
+
655
+ for (const field of reviewFields) {
656
+ if (field in fields && fields[field] !== undefined) {
657
+ sets.push(`${field} = :${field}`)
658
+ params[field] = fields[field] as string | number | null
659
+ }
660
+ }
661
+
662
+ if (sets.length === 0) return
663
+ this.db.prepare(`UPDATE task SET ${sets.join(', ')} WHERE id = :id AND convoy_id = :convoy_id`).run(params)
664
+ }
665
+
666
+ updateTaskDrift(
667
+ taskId: string,
668
+ convoyId: string,
669
+ fields: Partial<Pick<TaskRecord, 'drift_score' | 'drift_retried'>>,
670
+ ): void {
671
+ const sets: string[] = []
672
+ const params: Record<string, string | number | null> = { id: taskId, convoy_id: convoyId }
673
+
674
+ if (fields.drift_score !== undefined) {
675
+ sets.push('drift_score = :drift_score')
676
+ params.drift_score = fields.drift_score
677
+ }
678
+ if (fields.drift_retried !== undefined) {
679
+ sets.push('drift_retried = :drift_retried')
680
+ params.drift_retried = fields.drift_retried
681
+ }
682
+
683
+ if (sets.length === 0) return
684
+ this.db.prepare(`UPDATE task SET ${sets.join(', ')} WHERE id = :id AND convoy_id = :convoy_id`).run(params)
685
+ }
686
+
687
+ updateTaskDisputeStatus(taskId: string, convoyId: string, status: ConvoyTaskStatus, disputeId: string): void {
688
+ this.db
689
+ .prepare(
690
+ `UPDATE task SET status = :status, dispute_id = :dispute_id
691
+ WHERE id = :id AND convoy_id = :convoy_id`,
692
+ )
693
+ .run({ id: taskId, convoy_id: convoyId, status, dispute_id: disputeId })
694
+ }
695
+
315
696
  insertWorker(record: Omit<WorkerRecord, 'finished_at' | 'last_heartbeat'>): void {
316
697
  this.db
317
698
  .prepare(
@@ -355,13 +736,16 @@ class ConvoyStoreImpl implements ConvoyStore {
355
736
  this.db.prepare(`UPDATE worker SET ${sets.join(', ')} WHERE id = :id`).run(params)
356
737
  }
357
738
 
358
- insertEvent(record: Omit<EventRecord, 'id'>): void {
739
+ insertEvent(record: Omit<EventRecord, 'id'>): number {
740
+ enforceLimit(record.data, 'event.data', LIMIT_EVENT_DATA)
359
741
  this.db
360
742
  .prepare(
361
743
  `INSERT INTO event (convoy_id, task_id, worker_id, type, data, created_at)
362
744
  VALUES (:convoy_id, :task_id, :worker_id, :type, :data, :created_at)`,
363
745
  )
364
746
  .run(record)
747
+ const row = this.db.prepare('SELECT last_insert_rowid() AS id').get() as { id: number }
748
+ return row.id
365
749
  }
366
750
 
367
751
  getEvents(convoyId: string): EventRecord[] {
@@ -370,7 +754,160 @@ class ConvoyStoreImpl implements ConvoyStore {
370
754
  .all({ convoy_id: convoyId }) as unknown as EventRecord[]
371
755
  }
372
756
 
757
+ insertDlqEntry(record: DlqRecord): void {
758
+ this.db
759
+ .prepare(
760
+ `INSERT INTO dlq
761
+ (id, convoy_id, task_id, agent, failure_type, error_output, attempts,
762
+ tokens_spent, escalation_task_id, resolved, resolution, created_at, resolved_at)
763
+ VALUES
764
+ (:id, :convoy_id, :task_id, :agent, :failure_type, :error_output, :attempts,
765
+ :tokens_spent, :escalation_task_id, :resolved, :resolution, :created_at, :resolved_at)`,
766
+ )
767
+ .run(record as unknown as Record<string, string | number | null>)
768
+ }
769
+
770
+ listDlqEntries(convoyIdFilter?: string): DlqRecord[] {
771
+ if (convoyIdFilter) {
772
+ return this.db
773
+ .prepare('SELECT * FROM dlq WHERE convoy_id = :convoy_id ORDER BY created_at DESC')
774
+ .all({ convoy_id: convoyIdFilter }) as unknown as DlqRecord[]
775
+ }
776
+ return this.db
777
+ .prepare('SELECT * FROM dlq ORDER BY created_at DESC')
778
+ .all() as unknown as DlqRecord[]
779
+ }
780
+
781
+ resolveDlqEntry(id: string, resolution: string): void {
782
+ this.db
783
+ .prepare(
784
+ `UPDATE dlq SET resolved = 1, resolution = :resolution, resolved_at = :resolved_at
785
+ WHERE id = :id`,
786
+ )
787
+ .run({ id, resolution, resolved_at: new Date().toISOString() })
788
+ }
789
+
790
+ insertArtifact(record: ArtifactRecord): void {
791
+ const count = (
792
+ this.db
793
+ .prepare('SELECT COUNT(*) AS cnt FROM artifact WHERE convoy_id = :convoy_id')
794
+ .get({ convoy_id: record.convoy_id }) as { cnt: number }
795
+ ).cnt
796
+ if (count >= 50) {
797
+ throw new ConvoyArtifactLimitError(record.convoy_id)
798
+ }
799
+ this.db
800
+ .prepare(
801
+ `INSERT INTO artifact (id, convoy_id, task_id, name, type, content, created_at)
802
+ VALUES (:id, :convoy_id, :task_id, :name, :type, :content, :created_at)`,
803
+ )
804
+ .run(record as unknown as Record<string, string | number | null>)
805
+ }
806
+
807
+ getArtifact(convoyId: string, name: string): ArtifactRecord | undefined {
808
+ return this.db
809
+ .prepare('SELECT * FROM artifact WHERE convoy_id = :convoy_id AND name = :name')
810
+ .get({ convoy_id: convoyId, name }) as ArtifactRecord | undefined
811
+ }
812
+
813
+ getArtifactsByTask(taskId: string): ArtifactRecord[] {
814
+ return this.db
815
+ .prepare('SELECT * FROM artifact WHERE task_id = :task_id ORDER BY created_at')
816
+ .all({ task_id: taskId }) as unknown as ArtifactRecord[]
817
+ }
818
+
819
+ deleteArtifactsOlderThan(days: number): number {
820
+ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString()
821
+ const result = this.db
822
+ .prepare(
823
+ `DELETE FROM artifact WHERE convoy_id IN (
824
+ SELECT id FROM convoy WHERE finished_at IS NOT NULL AND finished_at < :cutoff
825
+ )`,
826
+ )
827
+ .run({ cutoff })
828
+ return (result as unknown as { changes: number }).changes
829
+ }
830
+
831
+ insertAgentIdentity(record: AgentIdentityRecord): void {
832
+ const summarySize = Buffer.byteLength(record.summary, 'utf8')
833
+ const truncatedSummary = summarySize > LIMIT_SUMMARY
834
+ ? record.summary.slice(0, LIMIT_SUMMARY)
835
+ : record.summary
836
+ this.db
837
+ .prepare(
838
+ `INSERT INTO agent_identity
839
+ (id, agent, convoy_id, task_id, summary, created_at, retention_days)
840
+ VALUES
841
+ (:id, :agent, :convoy_id, :task_id, :summary, :created_at, :retention_days)`,
842
+ )
843
+ .run({ ...record, summary: truncatedSummary } as unknown as Record<string, string | number | null>)
844
+ }
845
+
846
+ getAgentIdentities(agent: string, limit: number): AgentIdentityRecord[] {
847
+ return this.db
848
+ .prepare(
849
+ 'SELECT * FROM agent_identity WHERE agent = :agent ORDER BY created_at DESC LIMIT :limit',
850
+ )
851
+ .all({ agent, limit }) as unknown as AgentIdentityRecord[]
852
+ }
853
+
854
+ listAgentIdentitySummary(): Array<{ agent: string; task_count: number; latest_date: string }> {
855
+ return this.db
856
+ .prepare(
857
+ `SELECT agent, COUNT(*) AS task_count, MAX(created_at) AS latest_date
858
+ FROM agent_identity GROUP BY agent ORDER BY agent`,
859
+ )
860
+ .all() as unknown as Array<{ agent: string; task_count: number; latest_date: string }>
861
+ }
862
+
863
+ purgeAgentIdentities(agent: string): number {
864
+ const result = this.db
865
+ .prepare('DELETE FROM agent_identity WHERE agent = :agent')
866
+ .run({ agent })
867
+ return (result as unknown as { changes: number }).changes
868
+ }
869
+
870
+ deleteAgentIdentitiesOlderThan(days: number): number {
871
+ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString()
872
+ const result = this.db
873
+ .prepare(
874
+ `DELETE FROM agent_identity
875
+ WHERE created_at < :cutoff
876
+ OR (retention_days IS NOT NULL
877
+ AND created_at < datetime('now', '-' || retention_days || ' days'))`,
878
+ )
879
+ .run({ cutoff })
880
+ return (result as unknown as { changes: number }).changes
881
+ }
882
+
883
+ getScratchpadValue(key: string): string | null {
884
+ const row = this.db
885
+ .prepare('SELECT value FROM scratchpad WHERE key = :key')
886
+ .get({ key }) as { value: string } | undefined
887
+ return row?.value ?? null
888
+ }
889
+
890
+ setScratchpadValue(key: string, value: string): void {
891
+ this.db
892
+ .prepare(
893
+ `INSERT INTO scratchpad (key, value, updated_at)
894
+ VALUES (:key, :value, :updated_at)
895
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
896
+ )
897
+ .run({ key, value, updated_at: new Date().toISOString() })
898
+ }
899
+
900
+ clearScratchpad(): void {
901
+ this.db.exec('DELETE FROM scratchpad')
902
+ }
903
+
904
+ clearScratchpadOlderThan(days: number): void {
905
+ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString()
906
+ this.db.prepare('DELETE FROM scratchpad WHERE updated_at < :cutoff').run({ cutoff })
907
+ }
908
+
373
909
  insertPipeline(record: Omit<PipelineRecord, 'started_at' | 'finished_at' | 'total_tokens' | 'total_cost_usd'>): void {
910
+ enforceLimit(record.spec_yaml, 'pipeline.spec_yaml', LIMIT_SPEC_YAML)
374
911
  this.db
375
912
  .prepare(
376
913
  `INSERT INTO pipeline (id, name, status, branch, spec_yaml, convoy_specs, created_at,
@@ -444,6 +981,115 @@ class ConvoyStoreImpl implements ConvoyStore {
444
981
  }
445
982
  }
446
983
 
984
+ export function migrateSchema(db: DatabaseSync, dbPath: string, fromVersion: number, toVersion: number): void {
985
+ for (let v = fromVersion; v < toVersion; v++) {
986
+ const backupPath = `${dbPath}.v${v}.bak`
987
+ copyFileSync(dbPath, backupPath)
988
+ db.exec('BEGIN')
989
+ try {
990
+ if (v === 4) {
991
+ db.exec(`
992
+ ALTER TABLE task ADD COLUMN gates TEXT;
993
+ ALTER TABLE task ADD COLUMN on_exhausted TEXT NOT NULL DEFAULT 'dlq';
994
+ ALTER TABLE task ADD COLUMN injected INTEGER NOT NULL DEFAULT 0;
995
+ ALTER TABLE task ADD COLUMN provenance TEXT;
996
+ ALTER TABLE task ADD COLUMN idempotency_key TEXT;
997
+ CREATE UNIQUE INDEX idx_task_idempotency ON task(convoy_id, idempotency_key)
998
+ WHERE idempotency_key IS NOT NULL;
999
+ ALTER TABLE convoy ADD COLUMN circuit_state TEXT;
1000
+ CREATE TABLE dlq (
1001
+ id TEXT PRIMARY KEY,
1002
+ convoy_id TEXT NOT NULL REFERENCES convoy(id),
1003
+ task_id TEXT NOT NULL REFERENCES task(id),
1004
+ agent TEXT NOT NULL,
1005
+ failure_type TEXT NOT NULL,
1006
+ error_output TEXT,
1007
+ attempts INTEGER NOT NULL,
1008
+ tokens_spent INTEGER,
1009
+ escalation_task_id TEXT,
1010
+ resolved INTEGER NOT NULL DEFAULT 0,
1011
+ resolution TEXT,
1012
+ created_at TEXT NOT NULL,
1013
+ resolved_at TEXT
1014
+ );
1015
+ `)
1016
+ }
1017
+ if (v === 5) {
1018
+ db.exec(`
1019
+ ALTER TABLE task ADD COLUMN current_step INTEGER;
1020
+ ALTER TABLE task ADD COLUMN total_steps INTEGER;
1021
+ ALTER TABLE task ADD COLUMN review_level TEXT;
1022
+ ALTER TABLE task ADD COLUMN review_verdict TEXT;
1023
+ ALTER TABLE task ADD COLUMN review_tokens INTEGER;
1024
+ ALTER TABLE task ADD COLUMN review_model TEXT;
1025
+ ALTER TABLE task ADD COLUMN panel_attempts INTEGER NOT NULL DEFAULT 0;
1026
+ ALTER TABLE task ADD COLUMN dispute_id TEXT;
1027
+ ALTER TABLE convoy ADD COLUMN review_tokens_total INTEGER;
1028
+ ALTER TABLE convoy ADD COLUMN review_budget INTEGER;
1029
+ CREATE TABLE task_step (
1030
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1031
+ task_id TEXT NOT NULL REFERENCES task(id),
1032
+ step_index INTEGER NOT NULL,
1033
+ prompt TEXT NOT NULL,
1034
+ gates TEXT,
1035
+ status TEXT NOT NULL DEFAULT 'pending',
1036
+ exit_code INTEGER,
1037
+ output TEXT,
1038
+ started_at TEXT,
1039
+ finished_at TEXT
1040
+ );
1041
+ `)
1042
+ }
1043
+ if (v === 6) {
1044
+ db.exec(`
1045
+ ALTER TABLE task ADD COLUMN drift_score REAL;
1046
+ ALTER TABLE task ADD COLUMN drift_retried INTEGER NOT NULL DEFAULT 0;
1047
+ `)
1048
+ }
1049
+ if (v === 7) {
1050
+ db.exec(`
1051
+ ALTER TABLE task ADD COLUMN outputs TEXT;
1052
+ ALTER TABLE task ADD COLUMN inputs TEXT;
1053
+ ALTER TABLE task ADD COLUMN discovered_issues TEXT;
1054
+ CREATE TABLE artifact (
1055
+ id TEXT PRIMARY KEY,
1056
+ convoy_id TEXT NOT NULL REFERENCES convoy(id),
1057
+ task_id TEXT NOT NULL REFERENCES task(id),
1058
+ name TEXT NOT NULL,
1059
+ type TEXT NOT NULL,
1060
+ content TEXT NOT NULL CHECK (length(content) <= 1048576),
1061
+ created_at TEXT NOT NULL,
1062
+ UNIQUE(convoy_id, name)
1063
+ );
1064
+ CREATE TABLE agent_identity (
1065
+ id TEXT PRIMARY KEY,
1066
+ agent TEXT NOT NULL,
1067
+ convoy_id TEXT NOT NULL,
1068
+ task_id TEXT NOT NULL,
1069
+ summary TEXT NOT NULL,
1070
+ created_at TEXT NOT NULL,
1071
+ retention_days INTEGER NOT NULL DEFAULT 90
1072
+ );
1073
+ `)
1074
+ }
1075
+ if (v === 8) {
1076
+ db.exec(`
1077
+ CREATE TABLE scratchpad (
1078
+ key TEXT PRIMARY KEY,
1079
+ value TEXT NOT NULL,
1080
+ updated_at TEXT NOT NULL
1081
+ );
1082
+ `)
1083
+ }
1084
+ db.exec('COMMIT')
1085
+ } catch (err) {
1086
+ try { db.exec('ROLLBACK') } catch { /* ignore */ }
1087
+ throw new Error(`Migration v${v}→v${v + 1} failed. Backup at ${backupPath}. Original error: ${(err as Error).message}`)
1088
+ }
1089
+ db.exec(`PRAGMA user_version = ${v + 1}`)
1090
+ }
1091
+ }
1092
+
447
1093
  export function createConvoyStore(dbPath: string): ConvoyStore {
448
1094
  return new ConvoyStoreImpl(dbPath)
449
1095
  }