@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,117 @@
1
+ import type { Forge, PullRequestReading, PullRequestRef, RepoName } from '@boxxy/model';
2
+
3
+ // A pull request as a test puts it on a forge. The url is the one GitHub gives.
4
+ export interface SeededPullRequest {
5
+ readonly ref: PullRequestRef;
6
+ readonly githubId: number;
7
+ readonly reading: Omit<PullRequestReading, 'githubId' | 'url'>;
8
+ }
9
+
10
+ export interface ReadHold {
11
+ readonly entered: Promise<void>;
12
+ readonly release: () => void;
13
+ }
14
+
15
+ export interface FakeForge extends Forge {
16
+ // every call, in order
17
+ readonly calls: readonly string[];
18
+ seed: (pullRequest: SeededPullRequest) => void;
19
+
20
+ // while down, every call rejects, as a forge that does not answer
21
+ setDown: (isDown: boolean) => void;
22
+
23
+ // the next `readPullRequest` waits until the test calls `release`; `entered` resolves once that
24
+ // read has started, so a test can change the store while the read is in flight
25
+ holdNextRead: () => ReadHold;
26
+ }
27
+
28
+ // A forge that keeps the pull requests a test seeds and answers as the `Forge` contract says.
29
+ export function createFakeForge(): FakeForge {
30
+ const pullRequests = new Map<string, SeededPullRequest>();
31
+
32
+ const calls: string[] = [];
33
+ let isDown = false;
34
+ let hold: { readonly entered: () => void; readonly released: Promise<void> } | null = null;
35
+
36
+ const answerRead = (ref: PullRequestRef): Promise<PullRequestReading | 'not-found'> => {
37
+ if (isDown) {
38
+ return Promise.reject(new Error('the fake forge is down'));
39
+ }
40
+
41
+ const found = pullRequests.get(toKey(ref));
42
+
43
+ if (found === undefined) {
44
+ return Promise.resolve('not-found');
45
+ }
46
+
47
+ return Promise.resolve({
48
+ ...found.reading,
49
+ githubId: found.githubId,
50
+ url: `https://github.com/${ref.repo}/pull/${ref.number}`,
51
+ });
52
+ };
53
+
54
+ return {
55
+ calls,
56
+ seed: (pullRequest) => {
57
+ pullRequests.set(toKey(pullRequest.ref), pullRequest);
58
+ },
59
+ setDown: (down) => {
60
+ isDown = down;
61
+ },
62
+ holdNextRead: () => {
63
+ const entered = Promise.withResolvers<undefined>();
64
+ const released = Promise.withResolvers<undefined>();
65
+
66
+ hold = {
67
+ entered: () => {
68
+ entered.resolve(undefined);
69
+ },
70
+ released: released.promise,
71
+ };
72
+
73
+ return {
74
+ entered: entered.promise,
75
+ release: () => {
76
+ released.resolve(undefined);
77
+ },
78
+ };
79
+ },
80
+ readPullRequest: async (ref) => {
81
+ calls.push(`readPullRequest ${toKey(ref)}`);
82
+
83
+ const held = hold;
84
+
85
+ hold = null;
86
+
87
+ if (held !== null) {
88
+ held.entered();
89
+
90
+ await held.released;
91
+ }
92
+
93
+ return answerRead(ref);
94
+ },
95
+ listOpenPullRequests: (repo: RepoName) => {
96
+ calls.push(`listOpenPullRequests ${repo}`);
97
+
98
+ if (isDown) {
99
+ return Promise.reject(new Error('the fake forge is down'));
100
+ }
101
+
102
+ const open = [...pullRequests.values()].filter(
103
+ (pullRequest) => pullRequest.ref.repo === repo && pullRequest.reading.state === 'open',
104
+ );
105
+
106
+ return Promise.resolve(
107
+ open
108
+ .map((pullRequest) => pullRequest.ref)
109
+ .toSorted((first, second) => first.number - second.number),
110
+ );
111
+ },
112
+ };
113
+ }
114
+
115
+ function toKey(ref: PullRequestRef): string {
116
+ return `${ref.repo}#${ref.number}`;
117
+ }
@@ -0,0 +1,303 @@
1
+ import type {
2
+ AgentSessionId,
3
+ AtcSessionId,
4
+ AttemptId,
5
+ AttemptRow,
6
+ Attention,
7
+ Clock,
8
+ Instant,
9
+ SessionReading,
10
+ StartOutcome,
11
+ WorkerKind,
12
+ } from '@boxxy/model';
13
+ import { brandSchemas } from '@boxxy/model';
14
+ import { match } from 'ts-pattern';
15
+
16
+ type FailableMethod = 'start' | 'suspend' | 'resume' | 'stop' | 'forget' | 'resumeKept';
17
+
18
+ export type FakeFailure =
19
+ | { readonly kind: 'refused'; readonly cause: 'capacity' | 'other'; readonly reason: string }
20
+ | { readonly kind: 'unknown'; readonly reason: string };
21
+
22
+ // What a test sets a session to. A field left out keeps the session's value.
23
+ export type FakeSession =
24
+ | {
25
+ readonly state: 'running';
26
+ readonly attention?: Attention;
27
+ readonly lastActivityAt?: Instant;
28
+ readonly lastOwnerAt?: Instant;
29
+ readonly agentSession?: AgentSessionId | null;
30
+ readonly usageLimitResetsAt?: Instant | null;
31
+ }
32
+ | {
33
+ readonly state: 'suspended';
34
+ readonly asked?: boolean;
35
+ readonly agentSession?: AgentSessionId | null;
36
+ }
37
+ | { readonly state: 'ended'; readonly agentSession?: AgentSessionId | null }
38
+ | { readonly state: 'absent' };
39
+
40
+ export interface FakeWorkerCall {
41
+ readonly method: keyof WorkerKind;
42
+
43
+ // the attempt the call was for, or the forgotten session
44
+ readonly subject: string;
45
+ }
46
+
47
+ export interface FakeWorker extends WorkerKind {
48
+ readonly calls: readonly FakeWorkerCall[];
49
+
50
+ // the session that the worker gave the attempt, or that its spawn name finds
51
+ setSession: (attempt: AttemptId, session: FakeSession) => void;
52
+ failNext: (method: FailableMethod, failure: FakeFailure) => void;
53
+ failAlways: (method: FailableMethod, failure: FakeFailure) => void;
54
+ }
55
+
56
+ interface Session {
57
+ id: AtcSessionId;
58
+ state: 'running' | 'suspended' | 'ended';
59
+ attention: Attention;
60
+ lastActivityAt: Instant;
61
+ lastOwnerAt: Instant;
62
+ agentSession: AgentSessionId | null;
63
+ usageLimitResetsAt: Instant | null;
64
+ asked: boolean;
65
+ }
66
+
67
+ // A worker host in memory. A started session runs and works, a stop suspends it as an imp
68
+ // target does, and every session gives the caller `fake-worker` with its own ID, as a host's
69
+ // issuer name and a machine ID.
70
+ interface FakeWorkerInput {
71
+ readonly clock: Clock;
72
+ }
73
+
74
+ export function createFakeWorker(input: FakeWorkerInput): FakeWorker {
75
+ const sessions = new Map<AtcSessionId, Session>();
76
+ const byAttempt = new Map<AttemptId, AtcSessionId>();
77
+
78
+ const calls: FakeWorkerCall[] = [];
79
+
80
+ const once = new Map<FailableMethod, FakeFailure>();
81
+ const always = new Map<FailableMethod, FakeFailure>();
82
+
83
+ let count = 0;
84
+
85
+ const takeFailure = (method: FailableMethod): FakeFailure | null => {
86
+ const failure = once.get(method) ?? always.get(method) ?? null;
87
+
88
+ once.delete(method);
89
+
90
+ return failure;
91
+ };
92
+
93
+ const findSession = (attempt: AttemptRow): Session | null => {
94
+ const id = attempt.atcSession ?? byAttempt.get(attempt.id) ?? null;
95
+
96
+ return id === null ? null : (sessions.get(id) ?? null);
97
+ };
98
+
99
+ const createSession = (): Session => {
100
+ count += 1;
101
+
102
+ const session: Session = {
103
+ id: brandSchemas.atcSessionId.parse(`atc-${count}`),
104
+ state: 'running',
105
+ attention: 'working',
106
+ lastActivityAt: input.clock.now(),
107
+ lastOwnerAt: input.clock.now(),
108
+ agentSession: brandSchemas.agentSessionId.parse(`agent-${count}`),
109
+ usageLimitResetsAt: null,
110
+ asked: false,
111
+ };
112
+
113
+ sessions.set(session.id, session);
114
+
115
+ return session;
116
+ };
117
+
118
+ const runSession = (session: Session): StartOutcome => {
119
+ Object.assign(session, {
120
+ state: 'running',
121
+ attention: 'working',
122
+ lastActivityAt: input.clock.now(),
123
+ });
124
+
125
+ return {
126
+ kind: 'started',
127
+ atcSession: session.id,
128
+ agentSession: session.agentSession,
129
+ caller: { issuer: 'fake-worker', subject: session.id },
130
+ };
131
+ };
132
+
133
+ return {
134
+ calls,
135
+ start: (request) => {
136
+ calls.push({ method: 'start', subject: request.idempotencyKey });
137
+
138
+ const failure = takeFailure('start');
139
+
140
+ if (failure !== null) {
141
+ return Promise.resolve(failure);
142
+ }
143
+
144
+ const known = byAttempt.get(request.idempotencyKey);
145
+ const session = (known === undefined ? null : sessions.get(known)) ?? createSession();
146
+
147
+ byAttempt.set(request.idempotencyKey, session.id);
148
+
149
+ return Promise.resolve(runSession(session));
150
+ },
151
+ reconcile: (attempt) => {
152
+ calls.push({ method: 'reconcile', subject: attempt.id });
153
+
154
+ return Promise.resolve(toReading(findSession(attempt)));
155
+ },
156
+ suspend: (attempt, ifIdleSince) => {
157
+ calls.push({ method: 'suspend', subject: attempt.id });
158
+
159
+ const failure = takeFailure('suspend');
160
+ const session = findSession(attempt);
161
+
162
+ if (failure !== null) {
163
+ return Promise.resolve({ kind: failure.kind, reason: failure.reason });
164
+ }
165
+
166
+ if (session === null) {
167
+ return Promise.resolve({ kind: 'refused', reason: 'no such session' });
168
+ }
169
+
170
+ if (ifIdleSince !== null && session.lastActivityAt > ifIdleSince) {
171
+ return Promise.resolve({ kind: 'active' });
172
+ }
173
+
174
+ Object.assign(session, { state: 'suspended', asked: true });
175
+
176
+ return Promise.resolve({ kind: 'suspended' });
177
+ },
178
+ resume: (attempt) => {
179
+ calls.push({ method: 'resume', subject: attempt.id });
180
+
181
+ const failure = takeFailure('resume');
182
+ const session = findSession(attempt);
183
+
184
+ if (failure !== null) {
185
+ return Promise.resolve(failure);
186
+ }
187
+
188
+ const outcome: StartOutcome =
189
+ session === null
190
+ ? { kind: 'refused', cause: 'other', reason: 'no such session' }
191
+ : runSession(session);
192
+
193
+ return Promise.resolve(outcome);
194
+ },
195
+ stop: (attempt) => {
196
+ calls.push({ method: 'stop', subject: attempt.id });
197
+
198
+ const failure = takeFailure('stop');
199
+ const session = findSession(attempt);
200
+
201
+ if (failure !== null) {
202
+ return Promise.resolve({ kind: failure.kind, reason: failure.reason });
203
+ }
204
+
205
+ if (session === null) {
206
+ return Promise.resolve({ kind: 'absent' });
207
+ }
208
+
209
+ if (session.state === 'running') {
210
+ Object.assign(session, { state: 'suspended', asked: true });
211
+ }
212
+
213
+ return Promise.resolve({ kind: 'stopped' });
214
+ },
215
+ forget: (id) => {
216
+ calls.push({ method: 'forget', subject: id });
217
+
218
+ const failure = takeFailure('forget');
219
+
220
+ if (failure !== null) {
221
+ return Promise.resolve({ kind: failure.kind, reason: failure.reason });
222
+ }
223
+
224
+ return Promise.resolve({ kind: sessions.delete(id) ? 'forgotten' : 'absent' });
225
+ },
226
+ resumeKept: (kept, request) => {
227
+ calls.push({ method: 'resumeKept', subject: request.idempotencyKey });
228
+
229
+ const failure = takeFailure('resumeKept');
230
+ const session = sessions.get(kept.atcSession) ?? null;
231
+
232
+ if (failure !== null) {
233
+ return Promise.resolve(failure);
234
+ }
235
+
236
+ if (session === null) {
237
+ return Promise.resolve({ kind: 'refused', cause: 'other', reason: 'no such session' });
238
+ }
239
+
240
+ byAttempt.set(request.idempotencyKey, session.id);
241
+
242
+ return Promise.resolve(runSession(session));
243
+ },
244
+ setSession: (attempt, update) => {
245
+ const id = byAttempt.get(attempt);
246
+ const session = (id === undefined ? null : sessions.get(id)) ?? createSession();
247
+
248
+ byAttempt.set(attempt, session.id);
249
+
250
+ match(update)
251
+ .with({ state: 'absent' }, () => {
252
+ sessions.delete(session.id);
253
+ })
254
+ .with({ state: 'running' }, (running) => {
255
+ Object.assign(session, { ...running, state: 'running' });
256
+ })
257
+ .with({ state: 'suspended' }, (suspended) => {
258
+ Object.assign(session, { asked: false, ...suspended, state: 'suspended' });
259
+ })
260
+ .with({ state: 'ended' }, (ended) => {
261
+ Object.assign(session, { ...ended, state: 'ended' });
262
+ })
263
+ .exhaustive();
264
+ },
265
+ failNext: (method, failure) => {
266
+ once.set(method, failure);
267
+ },
268
+ failAlways: (method, failure) => {
269
+ always.set(method, failure);
270
+ },
271
+ };
272
+ }
273
+
274
+ function toReading(session: Session | null): SessionReading {
275
+ if (session === null) {
276
+ return { kind: 'absent' };
277
+ }
278
+
279
+ return match(session.state)
280
+ .returnType<SessionReading>()
281
+ .with('running', () => ({
282
+ kind: 'running',
283
+ atcSession: session.id,
284
+ agentSession: session.agentSession,
285
+ caller: { issuer: 'fake-worker', subject: session.id },
286
+ attention: session.attention,
287
+ lastActivityAt: session.lastActivityAt,
288
+ lastOwnerAt: session.lastOwnerAt,
289
+ usageLimitResetsAt: session.usageLimitResetsAt,
290
+ }))
291
+ .with('suspended', () => ({
292
+ kind: 'suspended',
293
+ atcSession: session.id,
294
+ agentSession: session.agentSession,
295
+ asked: session.asked,
296
+ }))
297
+ .with('ended', () => ({
298
+ kind: 'ended',
299
+ atcSession: session.id,
300
+ agentSession: session.agentSession,
301
+ }))
302
+ .exhaustive();
303
+ }
@@ -0,0 +1,163 @@
1
+ import { collectSeatSync } from '@boxxy/model';
2
+ import type { StoreTx } from '@boxxy/model';
3
+ import { applyMemoryChange } from './apply-memory-change.ts';
4
+ import { buildMemoryReader } from './build-memory-reader.ts';
5
+ import { isSameHeldSeat } from './is-same-held-seat.ts';
6
+ import type { ClosableClaimStore, MemoryTables } from './types.ts';
7
+
8
+ export function createMemoryClaimStore(): ClosableClaimStore {
9
+ let committed = buildEmptyTables();
10
+ let turn: Promise<void> = Promise.resolve();
11
+ let isClosed = false;
12
+ let reads: Promise<unknown> = Promise.resolve();
13
+
14
+ return {
15
+ exclusive: async (work) => {
16
+ if (isClosed) {
17
+ throw new Error('the store is closed');
18
+ }
19
+
20
+ const previous = turn;
21
+ const next = Promise.withResolvers<void>();
22
+
23
+ turn = next.promise;
24
+
25
+ await previous;
26
+
27
+ try {
28
+ const draft = structuredClone(committed);
29
+
30
+ const result = await work(buildMemoryTx(draft));
31
+
32
+ committed = draft;
33
+
34
+ return result;
35
+ } finally {
36
+ next.resolve();
37
+ }
38
+ },
39
+ read: (work) => {
40
+ const snapshot = committed;
41
+
42
+ const reading = Promise.try(() => {
43
+ if (isClosed) {
44
+ throw new Error('the store is closed');
45
+ }
46
+
47
+ return work(buildMemoryReader(() => snapshot));
48
+ });
49
+
50
+ reads = Promise.allSettled([reads, reading]);
51
+
52
+ return reading;
53
+ },
54
+ close: async () => {
55
+ isClosed = true;
56
+
57
+ await Promise.all([reads, turn]);
58
+ },
59
+ };
60
+ }
61
+
62
+ function buildEmptyTables(): MemoryTables {
63
+ return {
64
+ items: [],
65
+ relations: [],
66
+ levers: [],
67
+ attempts: [],
68
+ attempt_sessions: [],
69
+ leases: [],
70
+ decisions: [],
71
+ evidence: [],
72
+ pull_requests: [],
73
+ runners: [],
74
+ notes: [],
75
+ notices: [],
76
+ outbox: [],
77
+ limits: [],
78
+ limit_seats: [],
79
+ };
80
+ }
81
+
82
+ function buildMemoryTx(tables: MemoryTables): StoreTx {
83
+ return {
84
+ ...buildMemoryReader(() => tables),
85
+ applyChange: (change) => Promise.try(() => applyMemoryChange(tables, change)),
86
+ markFactsSent: (ids, at) => {
87
+ for (const fact of tables.outbox) {
88
+ if (ids.includes(fact.factId) && fact.sentAt === null && fact.parkedAt === null) {
89
+ Object.assign(fact, { sentAt: at });
90
+ }
91
+ }
92
+
93
+ return Promise.resolve();
94
+ },
95
+ parkFacts: (ids, reason, at) => {
96
+ for (const fact of tables.outbox) {
97
+ if (ids.includes(fact.factId) && fact.sentAt === null) {
98
+ Object.assign(fact, { parkedAt: at, parkedReason: reason });
99
+ }
100
+ }
101
+
102
+ return Promise.resolve();
103
+ },
104
+ unparkFacts: () => {
105
+ const parked = tables.outbox.filter((fact) => fact.sentAt === null && fact.parkedAt !== null);
106
+
107
+ for (const fact of parked) {
108
+ Object.assign(fact, { parkedAt: null, parkedReason: null });
109
+ }
110
+
111
+ return Promise.resolve(parked.length);
112
+ },
113
+ writeHeartbeat: (runner, at) => {
114
+ const row = tables.runners.find((candidate) => candidate.id === runner);
115
+
116
+ if (row === undefined) {
117
+ tables.runners.push({ id: runner, heartbeatAt: at, paused: false });
118
+ } else {
119
+ Object.assign(row, { heartbeatAt: at });
120
+ }
121
+
122
+ return Promise.resolve();
123
+ },
124
+ syncLimits: (limits, at) => {
125
+ const sync = collectSeatSync({
126
+ limits,
127
+ held: tables.limit_seats.filter((seat) => seat.releasedAt === null),
128
+ attempts: tables.attempts,
129
+ at,
130
+ });
131
+
132
+ tables.limits = structuredClone([...limits]);
133
+
134
+ for (const seat of tables.limit_seats) {
135
+ if (sync.release.some((released) => isSameHeldSeat(released, seat))) {
136
+ Object.assign(seat, { releasedAt: at });
137
+ }
138
+ }
139
+
140
+ tables.limit_seats.push(...structuredClone([...sync.take]));
141
+
142
+ return Promise.resolve();
143
+ },
144
+ markPullRequestChecked: (ref, at) => {
145
+ for (const row of tables.pull_requests) {
146
+ if (row.repo === ref.repo && row.number === ref.number) {
147
+ Object.assign(row, { checkedAt: at });
148
+ }
149
+ }
150
+
151
+ return Promise.resolve();
152
+ },
153
+ markNoticeTried: (id, at, sent) => {
154
+ const row = tables.notices.find((candidate) => candidate.id === id);
155
+
156
+ if (row !== undefined) {
157
+ Object.assign(row, { tries: row.tries + 1, sentAt: sent ? at : row.sentAt });
158
+ }
159
+
160
+ return Promise.resolve();
161
+ },
162
+ };
163
+ }