@boxxy/dispatch 0.1.0 → 0.1.3

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 (202) hide show
  1. package/README.md +4 -1
  2. package/dist/attempts/attempt-machine.d.ts +804 -0
  3. package/dist/attempts/attempt-moves.d.ts +2 -0
  4. package/dist/attempts/bind-attempt-session.d.ts +14 -0
  5. package/dist/attempts/build-attempt-change.d.ts +73 -0
  6. package/dist/attempts/build-attempt-fact.d.ts +3 -0
  7. package/dist/attempts/end-item-attempts.d.ts +18 -0
  8. package/dist/attempts/is-same-guard.d.ts +5 -0
  9. package/dist/attempts/pick-call-attempt.d.ts +10 -0
  10. package/dist/attempts/stop-stray-session.d.ts +11 -0
  11. package/dist/attempts/types.d.ts +142 -0
  12. package/dist/attempts/write-attempt-move.d.ts +7 -0
  13. package/dist/attempts/write-session-bind.d.ts +3 -0
  14. package/dist/claims/build-snapshot-hash.d.ts +2 -0
  15. package/dist/claims/claim-next.d.ts +6 -0
  16. package/dist/claims/pick-kept-session.d.ts +9 -0
  17. package/dist/claims/request-wake.d.ts +3 -0
  18. package/dist/claims/resume-claim.d.ts +3 -0
  19. package/dist/claims/start-claim.d.ts +3 -0
  20. package/dist/claims/types.d.ts +44 -0
  21. package/dist/closeouts/build-settle-plans.d.ts +17 -0
  22. package/dist/closeouts/close-out-attempt.d.ts +3 -0
  23. package/dist/closeouts/effect-handlers.d.ts +2 -0
  24. package/dist/closeouts/pick-settlement.d.ts +14 -0
  25. package/dist/closeouts/settle-attempt.d.ts +3 -0
  26. package/dist/closeouts/types.d.ts +50 -0
  27. package/dist/index.d.ts +38 -0
  28. package/dist/notices/constants.d.ts +4 -0
  29. package/dist/notices/create-null-notifier.d.ts +2 -0
  30. package/dist/notices/send-notices.d.ts +2 -0
  31. package/dist/notices/types.d.ts +11 -0
  32. package/dist/outbox/build-backoff-ms.d.ts +2 -0
  33. package/dist/outbox/build-initial-sender-state.d.ts +2 -0
  34. package/dist/outbox/build-outbox-status.d.ts +15 -0
  35. package/dist/outbox/constants.d.ts +2 -0
  36. package/dist/outbox/measure-wire-bytes.d.ts +2 -0
  37. package/dist/outbox/pick-sender-move.d.ts +10 -0
  38. package/dist/outbox/run-sender-once.d.ts +2 -0
  39. package/dist/outbox/take-batch.d.ts +3 -0
  40. package/dist/outbox/types.d.ts +61 -0
  41. package/dist/pull-requests/build-link-change.d.ts +11 -0
  42. package/dist/pull-requests/build-state-fact.d.ts +8 -0
  43. package/dist/pull-requests/collect-attempt-trailers.d.ts +2 -0
  44. package/dist/pull-requests/collect-seen-state.d.ts +7 -0
  45. package/dist/pull-requests/constants.d.ts +3 -0
  46. package/dist/pull-requests/link-pull-request.d.ts +2 -0
  47. package/dist/pull-requests/parse-pull-request-url.d.ts +2 -0
  48. package/dist/pull-requests/types.d.ts +37 -0
  49. package/dist/pull-requests/watch-pull-requests.d.ts +2 -0
  50. package/dist/readings/act-on-reading.d.ts +3 -0
  51. package/dist/readings/find-waiting-since.d.ts +9 -0
  52. package/dist/readings/pick-reading-action.d.ts +2 -0
  53. package/dist/readings/types.d.ts +79 -0
  54. package/dist/sessions/build-keep-writes.d.ts +9 -0
  55. package/dist/sessions/build-kept-clear.d.ts +2 -0
  56. package/dist/sessions/forget-sessions.d.ts +2 -0
  57. package/dist/sessions/is-kept-due.d.ts +2 -0
  58. package/dist/sessions/resume-kept-session.d.ts +6 -0
  59. package/dist/sessions/types.d.ts +19 -0
  60. package/dist/test-utils/apply-memory-change.d.ts +3 -0
  61. package/dist/test-utils/build-memory-reader.d.ts +3 -0
  62. package/dist/test-utils/build-mock-bind-change.d.ts +7 -0
  63. package/dist/test-utils/build-mock-claim-change.d.ts +2 -0
  64. package/dist/test-utils/build-mock-file-item-change.d.ts +2 -0
  65. package/dist/test-utils/build-mock-lever-change.d.ts +2 -0
  66. package/dist/test-utils/build-mock-new-id.d.ts +2 -0
  67. package/dist/test-utils/build-mock-note-change.d.ts +2 -0
  68. package/dist/test-utils/build-mock-pause-change.d.ts +2 -0
  69. package/dist/test-utils/build-mock-pull-request-change.d.ts +8 -0
  70. package/dist/test-utils/build-mock-relation-change.d.ts +7 -0
  71. package/dist/test-utils/build-mock-running-reading.d.ts +6 -0
  72. package/dist/test-utils/build-mock-settle-change.d.ts +7 -0
  73. package/dist/test-utils/build-mock-start-change.d.ts +2 -0
  74. package/dist/test-utils/claim-store-attempt-reader-cases.d.ts +2 -0
  75. package/dist/test-utils/claim-store-claim-cases.d.ts +2 -0
  76. package/dist/test-utils/claim-store-close-cases.d.ts +2 -0
  77. package/dist/test-utils/claim-store-contract.d.ts +2 -0
  78. package/dist/test-utils/claim-store-copy-cases.d.ts +2 -0
  79. package/dist/test-utils/claim-store-exclusive-cases.d.ts +2 -0
  80. package/dist/test-utils/claim-store-guard-cases.d.ts +2 -0
  81. package/dist/test-utils/claim-store-item-reader-cases.d.ts +2 -0
  82. package/dist/test-utils/claim-store-outbox-cases.d.ts +2 -0
  83. package/dist/test-utils/claim-store-race-cases.d.ts +2 -0
  84. package/dist/test-utils/claim-store-rollback-cases.d.ts +2 -0
  85. package/dist/test-utils/claim-store-seat-cases.d.ts +2 -0
  86. package/dist/test-utils/claim-store-session-cases.d.ts +2 -0
  87. package/dist/test-utils/collect-memory-limit-fills.d.ts +3 -0
  88. package/dist/test-utils/compare-text.d.ts +1 -0
  89. package/dist/test-utils/create-fake-backoff.d.ts +2 -0
  90. package/dist/test-utils/create-fake-clock.d.ts +5 -0
  91. package/dist/test-utils/create-fake-fact-sink.d.ts +8 -0
  92. package/dist/test-utils/create-fake-forge.d.ts +17 -0
  93. package/dist/test-utils/create-fake-worker.d.ts +42 -0
  94. package/dist/test-utils/create-memory-claim-store.d.ts +2 -0
  95. package/dist/test-utils/create-test-dispatch.d.ts +46 -0
  96. package/dist/test-utils/forge-contract.d.ts +15 -0
  97. package/dist/test-utils/is-same-held-seat.d.ts +2 -0
  98. package/dist/test-utils/types.d.ts +19 -0
  99. package/dist/test-utils/worker-contract.d.ts +14 -0
  100. package/dist/timeouts.d.ts +2 -0
  101. package/dist/types.d.ts +13 -0
  102. package/package.json +15 -2
  103. package/src/attempts/attempt-machine.ts +147 -0
  104. package/src/attempts/attempt-moves.ts +218 -0
  105. package/src/attempts/bind-attempt-session.ts +61 -0
  106. package/src/attempts/build-attempt-change.ts +540 -0
  107. package/src/attempts/build-attempt-fact.ts +135 -0
  108. package/src/attempts/end-item-attempts.ts +95 -0
  109. package/src/attempts/is-same-guard.ts +22 -0
  110. package/src/attempts/pick-call-attempt.ts +32 -0
  111. package/src/attempts/stop-stray-session.ts +48 -0
  112. package/src/attempts/types.ts +231 -0
  113. package/src/attempts/write-attempt-move.ts +41 -0
  114. package/src/attempts/write-session-bind.ts +47 -0
  115. package/src/claims/build-snapshot-hash.ts +9 -0
  116. package/src/claims/claim-next.ts +306 -0
  117. package/src/claims/pick-kept-session.ts +41 -0
  118. package/src/claims/request-wake.ts +17 -0
  119. package/src/claims/resume-claim.ts +56 -0
  120. package/src/claims/start-claim.ts +99 -0
  121. package/src/claims/types.ts +67 -0
  122. package/src/closeouts/build-settle-plans.ts +173 -0
  123. package/src/closeouts/close-out-attempt.ts +156 -0
  124. package/src/closeouts/effect-handlers.ts +232 -0
  125. package/src/closeouts/pick-settlement.ts +80 -0
  126. package/src/closeouts/settle-attempt.ts +161 -0
  127. package/src/closeouts/types.ts +87 -0
  128. package/src/index.ts +61 -0
  129. package/src/notices/constants.ts +7 -0
  130. package/src/notices/create-null-notifier.ts +6 -0
  131. package/src/notices/send-notices.ts +41 -0
  132. package/src/notices/types.ts +14 -0
  133. package/src/outbox/build-backoff-ms.ts +14 -0
  134. package/src/outbox/build-initial-sender-state.ts +14 -0
  135. package/src/outbox/build-outbox-status.ts +43 -0
  136. package/src/outbox/constants.ts +15 -0
  137. package/src/outbox/measure-wire-bytes.ts +31 -0
  138. package/src/outbox/pick-sender-move.ts +37 -0
  139. package/src/outbox/run-sender-once.ts +174 -0
  140. package/src/outbox/take-batch.ts +35 -0
  141. package/src/outbox/types.ts +76 -0
  142. package/src/pull-requests/build-link-change.ts +95 -0
  143. package/src/pull-requests/build-state-fact.ts +39 -0
  144. package/src/pull-requests/collect-attempt-trailers.ts +20 -0
  145. package/src/pull-requests/collect-seen-state.ts +19 -0
  146. package/src/pull-requests/constants.ts +5 -0
  147. package/src/pull-requests/link-pull-request.ts +119 -0
  148. package/src/pull-requests/parse-pull-request-url.ts +30 -0
  149. package/src/pull-requests/types.ts +63 -0
  150. package/src/pull-requests/watch-pull-requests.ts +308 -0
  151. package/src/readings/act-on-reading.ts +318 -0
  152. package/src/readings/find-waiting-since.ts +46 -0
  153. package/src/readings/pick-reading-action.ts +315 -0
  154. package/src/readings/types.ts +98 -0
  155. package/src/sessions/build-keep-writes.ts +55 -0
  156. package/src/sessions/build-kept-clear.ts +12 -0
  157. package/src/sessions/forget-sessions.ts +106 -0
  158. package/src/sessions/is-kept-due.ts +27 -0
  159. package/src/sessions/resume-kept-session.ts +25 -0
  160. package/src/sessions/types.ts +39 -0
  161. package/src/test-utils/apply-memory-change.ts +252 -0
  162. package/src/test-utils/build-memory-reader.ts +364 -0
  163. package/src/test-utils/build-mock-bind-change.ts +43 -0
  164. package/src/test-utils/build-mock-claim-change.ts +52 -0
  165. package/src/test-utils/build-mock-file-item-change.ts +20 -0
  166. package/src/test-utils/build-mock-lever-change.ts +18 -0
  167. package/src/test-utils/build-mock-new-id.ts +12 -0
  168. package/src/test-utils/build-mock-note-change.ts +18 -0
  169. package/src/test-utils/build-mock-pause-change.ts +19 -0
  170. package/src/test-utils/build-mock-pull-request-change.ts +63 -0
  171. package/src/test-utils/build-mock-relation-change.ts +40 -0
  172. package/src/test-utils/build-mock-running-reading.ts +19 -0
  173. package/src/test-utils/build-mock-settle-change.ts +70 -0
  174. package/src/test-utils/build-mock-start-change.ts +38 -0
  175. package/src/test-utils/claim-store-attempt-reader-cases.ts +357 -0
  176. package/src/test-utils/claim-store-claim-cases.ts +314 -0
  177. package/src/test-utils/claim-store-close-cases.ts +95 -0
  178. package/src/test-utils/claim-store-contract.ts +28 -0
  179. package/src/test-utils/claim-store-copy-cases.ts +100 -0
  180. package/src/test-utils/claim-store-exclusive-cases.ts +243 -0
  181. package/src/test-utils/claim-store-guard-cases.ts +383 -0
  182. package/src/test-utils/claim-store-item-reader-cases.ts +471 -0
  183. package/src/test-utils/claim-store-outbox-cases.ts +281 -0
  184. package/src/test-utils/claim-store-race-cases.ts +150 -0
  185. package/src/test-utils/claim-store-rollback-cases.ts +175 -0
  186. package/src/test-utils/claim-store-seat-cases.ts +270 -0
  187. package/src/test-utils/claim-store-session-cases.ts +289 -0
  188. package/src/test-utils/collect-memory-limit-fills.ts +15 -0
  189. package/src/test-utils/compare-text.ts +8 -0
  190. package/src/test-utils/create-fake-backoff.ts +16 -0
  191. package/src/test-utils/create-fake-clock.ts +20 -0
  192. package/src/test-utils/create-fake-fact-sink.ts +74 -0
  193. package/src/test-utils/create-fake-forge.ts +117 -0
  194. package/src/test-utils/create-fake-worker.ts +303 -0
  195. package/src/test-utils/create-memory-claim-store.ts +163 -0
  196. package/src/test-utils/create-test-dispatch.ts +597 -0
  197. package/src/test-utils/forge-contract.ts +171 -0
  198. package/src/test-utils/is-same-held-seat.ts +11 -0
  199. package/src/test-utils/types.ts +23 -0
  200. package/src/test-utils/worker-contract.ts +192 -0
  201. package/src/timeouts.ts +17 -0
  202. package/src/types.ts +32 -0
@@ -0,0 +1,252 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+ import { buildAppliedChange, parseNewFact, pickSeatTake, toJSONValue } from '@boxxy/model';
3
+ import type {
4
+ ApplyResult,
5
+ Instant,
6
+ RowWrite,
7
+ SeatRelease,
8
+ SeatTake,
9
+ StoreChange,
10
+ TableName,
11
+ } from '@boxxy/model';
12
+ import { match } from 'ts-pattern';
13
+ import { collectMemoryLimitFills } from './collect-memory-limit-fills.ts';
14
+ import { isSameHeldSeat } from './is-same-held-seat.ts';
15
+ import type { MemoryTables } from './types.ts';
16
+
17
+ // all or none: a refusal or a throw puts every table back as it was
18
+ export function applyMemoryChange(tables: MemoryTables, change: StoreChange): ApplyResult {
19
+ const before = structuredClone(tables);
20
+
21
+ try {
22
+ const result = writeMemoryChange(tables, change);
23
+
24
+ if (result.kind !== 'applied') {
25
+ Object.assign(tables, before);
26
+ }
27
+
28
+ return result;
29
+ } catch (error) {
30
+ Object.assign(tables, before);
31
+ throw error;
32
+ }
33
+ }
34
+
35
+ function writeMemoryChange(tables: MemoryTables, change: StoreChange): ApplyResult {
36
+ const facts = change.facts.map((fact) => parseNewFact(fact));
37
+
38
+ if (!hasLiveUnder(tables, change.under)) {
39
+ return { kind: 'stale-generation' };
40
+ }
41
+
42
+ const leverSeqs = tables.levers.map((lever) => lever.seq);
43
+ const noticeIds = tables.notices.map((notice) => notice.id);
44
+
45
+ const applied = buildAppliedChange(
46
+ { ...change, facts },
47
+ { lastLeverSeq: Math.max(0, ...leverSeqs), lastNoticeId: Math.max(0, ...noticeIds) },
48
+ );
49
+
50
+ for (const write of applied.writes) {
51
+ const refusal = applyMemoryWrite(tables, write, change.at);
52
+
53
+ if (refusal !== null) {
54
+ return refusal;
55
+ }
56
+ }
57
+
58
+ for (const fact of applied.facts) {
59
+ if (tables.outbox.some((row) => row.factId === fact.id)) {
60
+ throw new Error('UNIQUE constraint failed: outbox (factId)');
61
+ }
62
+
63
+ tables.outbox.push({
64
+ seq: tables.outbox.length + 1,
65
+ factId: fact.id,
66
+ type: fact.type,
67
+ entity: fact.entity,
68
+ actor: fact.actor,
69
+ at: change.at,
70
+ payload: toJSONValue(fact.payload),
71
+ sentAt: null,
72
+ parkedAt: null,
73
+ parkedReason: null,
74
+ });
75
+ }
76
+
77
+ return { kind: 'applied' };
78
+ }
79
+
80
+ function hasLiveUnder(tables: MemoryTables, under: StoreChange['under']): boolean {
81
+ if (under === undefined || under === null) {
82
+ return true;
83
+ }
84
+
85
+ const lease = tables.leases.find(
86
+ (row) =>
87
+ row.item === under.item &&
88
+ row.phase === under.phase &&
89
+ row.gen === under.gen &&
90
+ row.releasedAt === null,
91
+ );
92
+
93
+ if (lease === undefined) {
94
+ return false;
95
+ }
96
+
97
+ return (
98
+ under.holdGen === undefined ||
99
+ tables.attempts.some(
100
+ (attempt) => attempt.id === lease.attempt && attempt.holdGen === under.holdGen,
101
+ )
102
+ );
103
+ }
104
+
105
+ function applyMemoryWrite(tables: MemoryTables, write: RowWrite, at: Instant): ApplyResult | null {
106
+ return match(write)
107
+ .with({ op: 'take-seats' }, (seats) => takeMemorySeats(tables, seats.take, at))
108
+ .with({ op: 'release-seats' }, (seats) => {
109
+ releaseMemorySeats(tables, seats.release, at);
110
+
111
+ return null;
112
+ })
113
+ .with({ op: 'insert', table: 'leases' }, (insert) => {
114
+ const isHeld = tables.leases.some(
115
+ (row) =>
116
+ row.item === insert.row.item && row.phase === insert.row.phase && row.releasedAt === null,
117
+ );
118
+
119
+ if (isHeld) {
120
+ return { kind: 'lease-held' } as const;
121
+ }
122
+
123
+ insertMemoryRow(tables, insert.table, insert.row);
124
+
125
+ return null;
126
+ })
127
+ .with({ op: 'insert' }, (insert) => {
128
+ insertMemoryRow(tables, insert.table, insert.row);
129
+
130
+ return null;
131
+ })
132
+ .with({ op: 'update' }, (update) => {
133
+ const rows: object[] = tables[update.table];
134
+
135
+ const matched = rows.filter(
136
+ (row) => hasFields(row, update.key) && hasFields(row, update.expect),
137
+ );
138
+
139
+ if (matched.length === 0) {
140
+ const key = toJSONValue(update.key);
141
+
142
+ return { kind: 'guard-mismatch', table: update.table, key } as const;
143
+ }
144
+
145
+ for (const row of matched) {
146
+ Object.assign(row, structuredClone(update.set));
147
+
148
+ assertMemoryUnique(update.table, rows, row);
149
+ }
150
+
151
+ return null;
152
+ })
153
+ .exhaustive();
154
+ }
155
+
156
+ function takeMemorySeats(tables: MemoryTables, take: SeatTake, at: Instant): ApplyResult | null {
157
+ const answer = pickSeatTake(take, collectMemoryLimitFills(tables), at);
158
+
159
+ if (answer.kind !== 'take') {
160
+ return { kind: answer.kind, limit: answer.limit };
161
+ }
162
+
163
+ for (const seat of answer.seats) {
164
+ if (tables.limit_seats.some((held) => isSameHeldSeat(seat, held))) {
165
+ throw new Error('UNIQUE constraint failed: limit_seats (limit, attempt, holdGen)');
166
+ }
167
+
168
+ tables.limit_seats.push(seat);
169
+ }
170
+
171
+ return null;
172
+ }
173
+
174
+ function releaseMemorySeats(tables: MemoryTables, release: SeatRelease, at: Instant): void {
175
+ for (const seat of tables.limit_seats) {
176
+ if (
177
+ seat.attempt === release.attempt &&
178
+ seat.holdGen === release.holdGen &&
179
+ seat.releasedAt === null
180
+ ) {
181
+ Object.assign(seat, { releasedAt: at });
182
+ }
183
+ }
184
+ }
185
+
186
+ function insertMemoryRow(tables: MemoryTables, table: TableName, row: object): void {
187
+ const rows: object[] = tables[table];
188
+ const copy = structuredClone(row);
189
+
190
+ rows.push(copy);
191
+
192
+ assertMemoryUnique(table, rows, copy);
193
+ }
194
+
195
+ interface UniqueKey {
196
+ readonly fields: readonly string[];
197
+
198
+ // a partial unique index covers only the rows where this field is null
199
+ readonly live?: string;
200
+ }
201
+
202
+ // A kept session binds one agent session to a later attempt, so the agent session alone is no key.
203
+ const uniqueKeys: Readonly<Record<TableName, readonly UniqueKey[]>> = {
204
+ items: [{ fields: ['id'] }],
205
+ relations: [
206
+ { fields: ['source', 'target', 'type', 'addedAt'] },
207
+ { fields: ['source', 'target', 'type'], live: 'removedAt' },
208
+ ],
209
+ levers: [{ fields: ['seq'] }],
210
+ attempts: [{ fields: ['id'] }, { fields: ['item', 'phase', 'number'] }],
211
+ attempt_sessions: [{ fields: ['attempt', 'agentSession'] }],
212
+ leases: [{ fields: ['item', 'phase', 'gen'] }, { fields: ['item', 'phase'], live: 'releasedAt' }],
213
+ decisions: [{ fields: ['id'] }],
214
+ evidence: [{ fields: ['factId'] }, { fields: ['attempt', 'kind', 'ref'] }],
215
+ pull_requests: [{ fields: ['repo', 'number'] }],
216
+ runners: [{ fields: ['id'] }],
217
+ notes: [{ fields: ['item', 'seq'] }],
218
+ notices: [{ fields: ['id'] }],
219
+ };
220
+
221
+ function assertMemoryUnique(table: TableName, rows: readonly object[], row: object): void {
222
+ const fields = toFieldMap(row);
223
+
224
+ for (const key of uniqueKeys[table]) {
225
+ const hasClash = rows.some((other) => {
226
+ const otherFields = toFieldMap(other);
227
+
228
+ return (
229
+ other !== row &&
230
+ (key.live === undefined ||
231
+ (fields.get(key.live) === null && otherFields.get(key.live) === null)) &&
232
+ key.fields.every((field) => isDeepStrictEqual(otherFields.get(field), fields.get(field)))
233
+ );
234
+ });
235
+
236
+ if (hasClash) {
237
+ throw new Error(`UNIQUE constraint failed: ${table} (${key.fields.join(', ')})`);
238
+ }
239
+ }
240
+ }
241
+
242
+ function toFieldMap(row: object): Map<string, unknown> {
243
+ return new Map<string, unknown>(Object.entries(row));
244
+ }
245
+
246
+ function hasFields(row: object, fields: object): boolean {
247
+ const rowFields = toFieldMap(row);
248
+
249
+ return Object.entries(fields).every(([field, value]: [string, unknown]) =>
250
+ isDeepStrictEqual(rowFields.get(field), value),
251
+ );
252
+ }
@@ -0,0 +1,364 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+ import { buildItemViews } from '@boxxy/model';
3
+ import type {
4
+ AtcSessionId,
5
+ AttemptRow,
6
+ EvidenceRow,
7
+ Instant,
8
+ ItemDetailRows,
9
+ ItemRow,
10
+ ItemRules,
11
+ ItemSetRows,
12
+ ItemView,
13
+ ItemWithLevers,
14
+ OutboxFact,
15
+ PullRequestRow,
16
+ RelationRow,
17
+ StoreReader,
18
+ Wake,
19
+ } from '@boxxy/model';
20
+ import { collectMemoryLimitFills } from './collect-memory-limit-fills.ts';
21
+ import { compareText } from './compare-text.ts';
22
+ import type { MemoryTables } from './types.ts';
23
+
24
+ // SQLite hands out new objects on each read, so a caller that keeps or edits a row never touches
25
+ // the store's own rows; each read here works on a copy to match.
26
+ export function buildMemoryReader(getLiveTables: () => MemoryTables): StoreReader {
27
+ const getTables = () => structuredClone(getLiveTables());
28
+
29
+ const buildViews = (now: Instant, rules: ItemRules): ItemWithLevers[] =>
30
+ buildItemViews(toItemSetRows(getTables()), now, rules);
31
+
32
+ return {
33
+ readSnapshot: (now, rules) => {
34
+ const tables = getTables();
35
+ const open = buildViews(now, rules).filter((item) => isOpen(item));
36
+
37
+ return Promise.resolve({
38
+ storeVersion: tables.outbox.length,
39
+ items: open.map((item) => toItemView(item)),
40
+ relations: sortRelations(tables.relations.filter((row) => row.removedAt === null)),
41
+ levers: open
42
+ .flatMap((item) => item.levers)
43
+ .toSorted((first, second) => first.seq - second.seq),
44
+ limits: collectMemoryLimitFills(tables),
45
+ wakes: collectWakes(tables.attempts),
46
+ });
47
+ },
48
+ readItem: (id, now, rules) => {
49
+ const item = buildViews(now, rules).find((view) => view.id === id);
50
+ const view = item === undefined ? null : toItemView(item);
51
+
52
+ return Promise.resolve(view);
53
+ },
54
+ readItemDetail: (id, now, rules) => {
55
+ const tables = getTables();
56
+ const item = buildViews(now, rules).find((view) => view.id === id);
57
+
58
+ if (item === undefined) {
59
+ return Promise.resolve(null);
60
+ }
61
+
62
+ const attempts = sortAttempts(tables.attempts.filter((attempt) => attempt.item === id));
63
+
64
+ const attemptIds = new Set(attempts.map((attempt) => attempt.id));
65
+
66
+ const relations = sortRelations(
67
+ tables.relations.filter(
68
+ (row) => row.removedAt === null && (row.source === id || row.target === id),
69
+ ),
70
+ );
71
+
72
+ const detail: ItemDetailRows = {
73
+ item,
74
+ attempts,
75
+ relations,
76
+ evidence: sortEvidence(tables.evidence.filter((row) => attemptIds.has(row.attempt))),
77
+ pullRequests: sortPullRequests(
78
+ tables.pull_requests.filter((row) => attemptIds.has(row.attempt)),
79
+ ),
80
+ notes: tables.notes
81
+ .filter((note) => note.item === id)
82
+ .toSorted((first, second) => first.seq - second.seq),
83
+ };
84
+
85
+ return Promise.resolve(detail);
86
+ },
87
+ listItems: (filter, now, rules) =>
88
+ Promise.resolve(
89
+ buildViews(now, rules).filter(
90
+ (item) =>
91
+ (filter.state === undefined || item.derived.state === filter.state) &&
92
+ (filter.repo === undefined || item.repo === filter.repo),
93
+ ),
94
+ ),
95
+ listOpenIssues: (repo, now, rules) =>
96
+ Promise.resolve(
97
+ buildViews(now, rules)
98
+ .filter((item) => item.kind === 'core.issue' && item.repo === repo && isOpen(item))
99
+ .map((item) => toItemView(item)),
100
+ ),
101
+ readLastLeaseGen: (item, phase) => {
102
+ const gens = getTables()
103
+ .leases.filter((lease) => lease.item === item && lease.phase === phase)
104
+ .map((lease) => lease.gen);
105
+
106
+ return Promise.resolve(Math.max(0, ...gens));
107
+ },
108
+ readAttempt: (id) =>
109
+ Promise.resolve(getTables().attempts.find((attempt) => attempt.id === id) ?? null),
110
+ listEvidence: (attempt) =>
111
+ Promise.resolve(sortEvidence(getTables().evidence.filter((row) => row.attempt === attempt))),
112
+ listItemAttempts: (item) =>
113
+ Promise.resolve(
114
+ sortAttempts(getTables().attempts.filter((attempt) => attempt.item === item)),
115
+ ),
116
+ listLiveAttempts: () =>
117
+ Promise.resolve(sortAttempts(getTables().attempts.filter((attempt) => !isSettled(attempt)))),
118
+ listStrayAttempts: (since) =>
119
+ Promise.resolve(
120
+ sortAttempts(
121
+ getTables().attempts.filter(
122
+ (attempt) =>
123
+ attempt.strayedAt !== null &&
124
+ attempt.strayedAt >= since &&
125
+ attempt.sessionStoppedAt === null,
126
+ ),
127
+ ),
128
+ ),
129
+ listSucceededAttemptsWithoutPullRequest: () => {
130
+ const tables = getTables();
131
+
132
+ const linked = new Set(tables.pull_requests.map((row) => row.attempt));
133
+
134
+ return Promise.resolve(
135
+ sortAttempts(
136
+ tables.attempts.filter(
137
+ (attempt) => attempt.state === 'succeeded' && !linked.has(attempt.id),
138
+ ),
139
+ ),
140
+ );
141
+ },
142
+ findAttemptBySession: (session) =>
143
+ Promise.resolve(
144
+ sortAttempts(getTables().attempts).findLast((attempt) => attempt.atcSession === session) ??
145
+ null,
146
+ ),
147
+ findAttemptByAgentSession: (session) => {
148
+ const tables = getTables();
149
+
150
+ const bound = new Set(
151
+ tables.attempt_sessions
152
+ .filter((row) => row.agentSession === session)
153
+ .map((row) => row.attempt),
154
+ );
155
+
156
+ return Promise.resolve(
157
+ sortAttempts(tables.attempts).findLast((attempt) => bound.has(attempt.id))?.id ?? null,
158
+ );
159
+ },
160
+ findSessionByCaller: (caller) => {
161
+ const sessions = collectLatestBySession(getTables().attempts)
162
+ .filter((latest) => isDeepStrictEqual(latest.attempt.caller, caller))
163
+ .map((latest) => latest.session);
164
+
165
+ if (sessions.length > 1) {
166
+ return Promise.resolve('ambiguous');
167
+ }
168
+
169
+ return Promise.resolve(sessions[0] ?? null);
170
+ },
171
+ readPullRequestRow: (ref) =>
172
+ Promise.resolve(
173
+ getTables().pull_requests.find(
174
+ (row) => row.repo === ref.repo && row.number === ref.number,
175
+ ) ?? null,
176
+ ),
177
+ listWatchedPullRequests: () =>
178
+ Promise.resolve(
179
+ sortPullRequests(getTables().pull_requests.filter((row) => row.state === 'open')),
180
+ ),
181
+ listLimits: () => Promise.resolve(collectMemoryLimitFills(getTables())),
182
+ listLimitSeats: () => {
183
+ const held = getTables().limit_seats.filter((seat) => seat.releasedAt === null);
184
+
185
+ return Promise.resolve(
186
+ held.toSorted(
187
+ (first, second) =>
188
+ compareText(first.limit, second.limit) || compareText(first.attempt, second.attempt),
189
+ ),
190
+ );
191
+ },
192
+ listItemNotes: (item) =>
193
+ Promise.resolve(
194
+ getTables()
195
+ .notes.filter((note) => note.item === item)
196
+ .toSorted((first, second) => first.seq - second.seq),
197
+ ),
198
+ listUnsentNotices: (limit) =>
199
+ Promise.resolve(
200
+ getTables()
201
+ .notices.filter((notice) => notice.sentAt === null)
202
+ .toSorted((first, second) => first.id - second.id)
203
+ .slice(0, limit),
204
+ ),
205
+ listKeptSessions: () => {
206
+ const kept = sortItems(getTables().items).flatMap((item) =>
207
+ item.keptSession === null ? [] : [{ item: item.id, kept: item.keptSession }],
208
+ );
209
+
210
+ return Promise.resolve(
211
+ kept.toSorted((first, second) => first.kept.keptAt - second.kept.keptAt),
212
+ );
213
+ },
214
+ listSessionsToForget: (failedEndedBefore) => {
215
+ const tables = getTables();
216
+
217
+ const kept = new Set(
218
+ tables.items.flatMap((item) =>
219
+ item.keptSession === null ? [] : [item.keptSession.atcSession],
220
+ ),
221
+ );
222
+
223
+ return Promise.resolve(
224
+ collectLatestBySession(tables.attempts)
225
+ .filter((latest) => !kept.has(latest.session))
226
+ .map((latest) => latest.attempt)
227
+ .filter(
228
+ (attempt) =>
229
+ attempt.sessionForgottenAt === null &&
230
+ (attempt.strayedAt === null || attempt.sessionStoppedAt !== null) &&
231
+ (attempt.state === 'succeeded' ||
232
+ (attempt.state === 'failed' &&
233
+ attempt.endedAt !== null &&
234
+ attempt.endedAt <= failedEndedBefore)),
235
+ ),
236
+ );
237
+ },
238
+ readRunner: (id) => Promise.resolve(getTables().runners.find((row) => row.id === id) ?? null),
239
+ readUnsentFacts: (limit) =>
240
+ Promise.resolve(
241
+ getTables()
242
+ .outbox.filter((fact) => fact.sentAt === null && fact.parkedAt === null)
243
+ .slice(0, limit),
244
+ ),
245
+ readOutboxCounts: () => {
246
+ const waiting = getTables().outbox.filter((fact) => fact.sentAt === null);
247
+ const unsent = waiting.filter((fact) => fact.parkedAt === null);
248
+
249
+ return Promise.resolve({
250
+ unsent: unsent.length,
251
+ parked: waiting.length - unsent.length,
252
+ oldestUnsentAt: findOldestAt(unsent),
253
+ });
254
+ },
255
+ readItemBehind: (item) => {
256
+ const behind = getTables().outbox.filter(
257
+ (fact) => fact.sentAt === null && fact.entity === `boxxy:item:${item}`,
258
+ );
259
+
260
+ return Promise.resolve({ facts: behind.length, oldestAt: findOldestAt(behind) });
261
+ },
262
+ };
263
+ }
264
+
265
+ function toItemSetRows(tables: MemoryTables): ItemSetRows {
266
+ return {
267
+ items: sortItems(tables.items),
268
+ relations: tables.relations.filter((relation) => relation.removedAt === null),
269
+ levers: tables.levers.toSorted((first, second) => first.seq - second.seq),
270
+ attempts: sortAttempts(tables.attempts),
271
+ liveLeases: tables.leases.filter((lease) => lease.releasedAt === null),
272
+ pullRequests: tables.pull_requests,
273
+ };
274
+ }
275
+
276
+ function sortItems(items: readonly ItemRow[]): ItemRow[] {
277
+ return items.toSorted(
278
+ (first, second) => first.filedAt - second.filedAt || compareText(first.id, second.id),
279
+ );
280
+ }
281
+
282
+ function sortAttempts(attempts: readonly AttemptRow[]): AttemptRow[] {
283
+ return attempts.toSorted(
284
+ (first, second) => first.claimedAt - second.claimedAt || compareText(first.id, second.id),
285
+ );
286
+ }
287
+
288
+ function isOpen(item: ItemView): boolean {
289
+ return item.derived.state !== 'done' && item.derived.state !== 'archived';
290
+ }
291
+
292
+ function toItemView(item: ItemWithLevers): ItemView {
293
+ const { levers: _levers, ...view } = item;
294
+
295
+ return view;
296
+ }
297
+
298
+ function sortRelations(relations: readonly RelationRow[]): RelationRow[] {
299
+ return relations.toSorted(
300
+ (first, second) =>
301
+ first.addedAt - second.addedAt ||
302
+ compareText(first.source, second.source) ||
303
+ compareText(first.target, second.target),
304
+ );
305
+ }
306
+
307
+ function collectWakes(attempts: readonly AttemptRow[]): Wake[] {
308
+ const wakes = sortAttempts(attempts).flatMap((attempt) =>
309
+ attempt.state === 'sleeping' && attempt.wakeRequestedAt !== null
310
+ ? [
311
+ {
312
+ attempt: attempt.id,
313
+ item: attempt.item,
314
+ phase: attempt.phase,
315
+ pool: attempt.pool,
316
+ fields: attempt.fields,
317
+ requestedAt: attempt.wakeRequestedAt,
318
+ },
319
+ ]
320
+ : [],
321
+ );
322
+
323
+ return wakes.toSorted((first, second) => first.requestedAt - second.requestedAt);
324
+ }
325
+
326
+ function sortEvidence(rows: readonly EvidenceRow[]): EvidenceRow[] {
327
+ return rows.toSorted(
328
+ (first, second) => first.at - second.at || compareText(first.factId, second.factId),
329
+ );
330
+ }
331
+
332
+ function sortPullRequests(rows: readonly PullRequestRow[]): PullRequestRow[] {
333
+ return rows.toSorted(
334
+ (first, second) => compareText(first.repo, second.repo) || first.number - second.number,
335
+ );
336
+ }
337
+
338
+ function isSettled(attempt: AttemptRow): boolean {
339
+ return attempt.state === 'succeeded' || attempt.state === 'failed';
340
+ }
341
+
342
+ interface LatestOfSession {
343
+ readonly session: AtcSessionId;
344
+ readonly attempt: AttemptRow;
345
+ }
346
+
347
+ // the latest attempt of each atc session, in the order of those attempts
348
+ function collectLatestBySession(attempts: readonly AttemptRow[]): LatestOfSession[] {
349
+ const latest = new Map<AtcSessionId, AttemptRow>();
350
+
351
+ for (const attempt of sortAttempts(attempts)) {
352
+ if (attempt.atcSession !== null) {
353
+ latest.set(attempt.atcSession, attempt);
354
+ }
355
+ }
356
+
357
+ return sortAttempts([...latest.values()]).flatMap((attempt) =>
358
+ attempt.atcSession === null ? [] : [{ session: attempt.atcSession, attempt }],
359
+ );
360
+ }
361
+
362
+ function findOldestAt(facts: readonly OutboxFact[]): Instant | null {
363
+ return facts.toSorted((first, second) => first.at - second.at).at(0)?.at ?? null;
364
+ }
@@ -0,0 +1,43 @@
1
+ import { brandSchemas } from '@boxxy/model';
2
+ import type { AgentSessionId, AttemptRow, StoreChange } from '@boxxy/model';
3
+ import { buildMockFactId } from '@boxxy/model/test-utils/build-mock-fact-id';
4
+ import invariant from 'tiny-invariant';
5
+
6
+ interface BindInput {
7
+ readonly attempt: AttemptRow;
8
+ readonly agentSession: AgentSessionId;
9
+ }
10
+
11
+ // The session-start hook binds a Claude agent session to the attempt's atc session.
12
+ export function buildMockBindChange(input: BindInput): StoreChange {
13
+ const attempt = input.attempt;
14
+ const agentSession = input.agentSession;
15
+ const atcSession = attempt.atcSession;
16
+ const seenAt = brandSchemas.instant.parse(2500);
17
+
18
+ invariant(atcSession !== null, 'a bound attempt names its atc session');
19
+
20
+ const row = {
21
+ attempt: attempt.id,
22
+ agentSession,
23
+ atcSession,
24
+ product: 'claude_code',
25
+ source: 'hook',
26
+ seenAt,
27
+ } as const;
28
+
29
+ return {
30
+ at: seenAt,
31
+ under: { item: attempt.item, phase: attempt.phase, gen: attempt.leaseGen },
32
+ writes: [{ op: 'insert', table: 'attempt_sessions', row }],
33
+ facts: [
34
+ {
35
+ id: buildMockFactId(),
36
+ type: 'boxxy.attempt.session_bound',
37
+ entity: `boxxy:item:${attempt.item}`,
38
+ actor: `boxxy:hook:${attempt.id}`,
39
+ payload: row,
40
+ },
41
+ ],
42
+ };
43
+ }
@@ -0,0 +1,52 @@
1
+ import type { AttemptRow, LimitName, StoreChange } from '@boxxy/model';
2
+ import { buildMockFactId } from '@boxxy/model/test-utils/build-mock-fact-id';
3
+
4
+ // A claim that takes the lease and a seat in each named limit, and inserts the attempt as given.
5
+ export function buildMockClaimChange(
6
+ attempt: AttemptRow,
7
+ limits: readonly LimitName[] = [],
8
+ ): StoreChange {
9
+ return {
10
+ at: attempt.claimedAt,
11
+ writes: [
12
+ {
13
+ op: 'take-seats',
14
+ take: { limits, attempt: attempt.id, holdGen: attempt.holdGen, overCap: false },
15
+ },
16
+ {
17
+ op: 'insert',
18
+ table: 'leases',
19
+ row: {
20
+ item: attempt.item,
21
+ phase: attempt.phase,
22
+ attempt: attempt.id,
23
+ gen: attempt.leaseGen,
24
+ takenAt: attempt.claimedAt,
25
+ releasedAt: null,
26
+ },
27
+ },
28
+ { op: 'insert', table: 'attempts', row: attempt },
29
+ ],
30
+ facts: [
31
+ {
32
+ id: buildMockFactId(),
33
+ type: 'boxxy.attempt.claimed',
34
+ entity: `boxxy:item:${attempt.item}`,
35
+ actor: `boxxy:runner:${attempt.runner}`,
36
+ payload: {
37
+ attempt: attempt.id,
38
+ phase: attempt.phase,
39
+ number: attempt.number,
40
+ round: attempt.round,
41
+ mode: attempt.mode,
42
+ pool: attempt.pool,
43
+ fields: attempt.fields,
44
+ seats: limits.map((limit) => ({ limit })),
45
+ recipeHash: attempt.recipeHash,
46
+ skillsRef: null,
47
+ rulesVersion: attempt.rulesVersion,
48
+ },
49
+ },
50
+ ],
51
+ };
52
+ }