cloving 1.1.0 → 1.2.0

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 (213) hide show
  1. package/README.md +121 -21
  2. package/assets/watch-fonts/fira-code-OFL.md +93 -0
  3. package/assets/watch-fonts/fira-code-latin-400-normal.woff2 +0 -0
  4. package/assets/watch-fonts/fira-code-latin-500-normal.woff2 +0 -0
  5. package/assets/watch-fonts/heebo-OFL.md +93 -0
  6. package/assets/watch-fonts/heebo-latin-500-normal.woff2 +0 -0
  7. package/assets/watch-fonts/heebo-latin-700-normal.woff2 +0 -0
  8. package/assets/watch-fonts/heebo-latin-800-normal.woff2 +0 -0
  9. package/assets/watch-fonts/manifest.json +112 -0
  10. package/assets/watch-fonts/space-grotesk-OFL.md +93 -0
  11. package/assets/watch-fonts/space-grotesk-latin-400-normal.woff2 +0 -0
  12. package/assets/watch-fonts/space-grotesk-latin-500-normal.woff2 +0 -0
  13. package/assets/watch-fonts/space-grotesk-latin-600-normal.woff2 +0 -0
  14. package/assets/watch-fonts/space-grotesk-latin-700-normal.woff2 +0 -0
  15. package/dist/src/authority/commands.d.ts +1 -0
  16. package/dist/src/authority/commands.js +25 -4
  17. package/dist/src/authority/dispatch-decision.d.ts +1 -0
  18. package/dist/src/authority/dispatch-decision.js +7 -4
  19. package/dist/src/authority/events.js +2 -2
  20. package/dist/src/authority/handoff-chain.d.ts +24 -0
  21. package/dist/src/authority/handoff-chain.js +46 -0
  22. package/dist/src/authority/reconciliation.d.ts +1 -0
  23. package/dist/src/authority/reconciliation.js +18 -11
  24. package/dist/src/authority/transitions.d.ts +1 -1
  25. package/dist/src/authority/transitions.js +3 -0
  26. package/dist/src/broker/actor/authority-queries.d.ts +4 -0
  27. package/dist/src/broker/actor/authority-queries.js +51 -2
  28. package/dist/src/broker/actor/delivery-intents.d.ts +1 -0
  29. package/dist/src/broker/actor/effect-delivery.js +9 -0
  30. package/dist/src/broker/authority-admission.js +13 -5
  31. package/dist/src/chats/analysis-batches.d.ts +18 -0
  32. package/dist/src/chats/analysis-batches.js +15 -1
  33. package/dist/src/chats/analysis-checkpoint.d.ts +29 -0
  34. package/dist/src/chats/analysis-checkpoint.js +31 -0
  35. package/dist/src/chats/analysis-input.d.ts +7 -1
  36. package/dist/src/chats/analysis-input.js +20 -6
  37. package/dist/src/chats/analysis-merge.d.ts +16 -0
  38. package/dist/src/chats/analysis-merge.js +134 -0
  39. package/dist/src/chats/analysis-prompt.d.ts +8 -0
  40. package/dist/src/chats/analysis-prompt.js +27 -1
  41. package/dist/src/chats/analysis-proposal.d.ts +2 -0
  42. package/dist/src/chats/analysis-proposal.js +9 -0
  43. package/dist/src/chats/analysis-shape.d.ts +1 -0
  44. package/dist/src/chats/analysis-shape.js +11 -4
  45. package/dist/src/chats/reference-mapping.d.ts +16 -0
  46. package/dist/src/chats/reference-mapping.js +153 -0
  47. package/dist/src/chats/workflow-generation-projection.d.ts +8 -0
  48. package/dist/src/chats/workflow-generation-projection.js +75 -0
  49. package/dist/src/chats/workflow-generation-source.d.ts +25 -0
  50. package/dist/src/chats/workflow-generation-source.js +124 -0
  51. package/dist/src/chats/workflow-generation-text.d.ts +23 -0
  52. package/dist/src/chats/workflow-generation-text.js +74 -0
  53. package/dist/src/chats/workflow-generation.d.ts +69 -0
  54. package/dist/src/chats/workflow-generation.js +142 -0
  55. package/dist/src/cli/render.d.ts +1 -0
  56. package/dist/src/cli/render.js +60 -13
  57. package/dist/src/cli/usage.js +5 -2
  58. package/dist/src/cli/watch-board-view.d.ts +12 -1
  59. package/dist/src/cli/watch-board-view.js +57 -11
  60. package/dist/src/cli/watch-board.d.ts +7 -1
  61. package/dist/src/cli/watch-board.js +42 -10
  62. package/dist/src/cli/watch-client.d.ts +1 -1
  63. package/dist/src/cli/watch-client.js +7 -3
  64. package/dist/src/cli/watch-model.d.ts +10 -4
  65. package/dist/src/cli/watch-model.js +35 -58
  66. package/dist/src/cli/watch-page.js +21 -4
  67. package/dist/src/cli/watch-phases.js +3 -3
  68. package/dist/src/cli/watch-pr-command.d.ts +5 -2
  69. package/dist/src/cli/watch-pr-command.js +41 -4
  70. package/dist/src/cli/watch-pr-identity.d.ts +14 -2
  71. package/dist/src/cli/watch-pr-identity.js +25 -0
  72. package/dist/src/cli/watch-pr-observer.d.ts +1 -0
  73. package/dist/src/cli/watch-pr-observer.js +48 -6
  74. package/dist/src/cli/watch-pull-request.d.ts +10 -0
  75. package/dist/src/cli/watch-pull-request.js +130 -0
  76. package/dist/src/cli/watch-reads.d.ts +7 -1
  77. package/dist/src/cli/watch-reads.js +42 -20
  78. package/dist/src/cli/watch-render.d.ts +14 -1
  79. package/dist/src/cli/watch-render.js +2 -1
  80. package/dist/src/cli/watch-server.js +33 -2
  81. package/dist/src/cli/watch-turn.js +1 -1
  82. package/dist/src/cli/watch-view.d.ts +7 -0
  83. package/dist/src/cli/watch-view.js +51 -3
  84. package/dist/src/daemon/analysis-worker.d.ts +0 -2
  85. package/dist/src/daemon/analysis-worker.js +71 -19
  86. package/dist/src/daemon/chats-batch-binding.d.ts +23 -0
  87. package/dist/src/daemon/chats-batch-binding.js +99 -0
  88. package/dist/src/daemon/chats-capture.d.ts +18 -0
  89. package/dist/src/daemon/chats-capture.js +194 -0
  90. package/dist/src/daemon/chats-checkpoints.d.ts +41 -0
  91. package/dist/src/daemon/chats-checkpoints.js +186 -0
  92. package/dist/src/daemon/chats-files.d.ts +29 -2
  93. package/dist/src/daemon/chats-files.js +142 -3
  94. package/dist/src/daemon/chats-follow-up.d.ts +38 -0
  95. package/dist/src/daemon/chats-follow-up.js +180 -0
  96. package/dist/src/daemon/chats-question.d.ts +15 -0
  97. package/dist/src/daemon/chats-question.js +93 -0
  98. package/dist/src/daemon/chats-worker.d.ts +1 -0
  99. package/dist/src/daemon/chats-worker.js +52 -51
  100. package/dist/src/daemon/claude-turn.d.ts +2 -1
  101. package/dist/src/daemon/claude-turn.js +1 -1
  102. package/dist/src/daemon/claude-worker.d.ts +0 -2
  103. package/dist/src/daemon/claude-worker.js +6 -11
  104. package/dist/src/daemon/daemon.d.ts +5 -1
  105. package/dist/src/daemon/daemon.js +38 -10
  106. package/dist/src/daemon/delivery-router.js +6 -0
  107. package/dist/src/daemon/github-calls.d.ts +4 -0
  108. package/dist/src/daemon/github-calls.js +16 -7
  109. package/dist/src/daemon/github-ci.d.ts +6 -0
  110. package/dist/src/daemon/github-ci.js +170 -0
  111. package/dist/src/daemon/github-merge-call.d.ts +14 -0
  112. package/dist/src/daemon/github-merge-call.js +73 -0
  113. package/dist/src/daemon/github-merge.d.ts +3 -5
  114. package/dist/src/daemon/github-merge.js +52 -95
  115. package/dist/src/daemon/github-methods.d.ts +28 -0
  116. package/dist/src/daemon/github-methods.js +80 -16
  117. package/dist/src/daemon/github-operation.d.ts +2 -0
  118. package/dist/src/daemon/github-retry.d.ts +6 -0
  119. package/dist/src/daemon/github-retry.js +49 -0
  120. package/dist/src/daemon/github-ticket-status.js +27 -1
  121. package/dist/src/daemon/github-worker.js +2 -1
  122. package/dist/src/daemon/handoff-worker.d.ts +10 -0
  123. package/dist/src/daemon/handoff-worker.js +232 -0
  124. package/dist/src/daemon/issue-queue.d.ts +1 -0
  125. package/dist/src/daemon/issue-queue.js +94 -42
  126. package/dist/src/daemon/notify-journal.d.ts +18 -0
  127. package/dist/src/daemon/notify-journal.js +108 -0
  128. package/dist/src/daemon/notify-message.js +2 -1
  129. package/dist/src/daemon/notify.js +176 -37
  130. package/dist/src/daemon/retrying-turn.d.ts +3 -0
  131. package/dist/src/daemon/retrying-turn.js +55 -9
  132. package/dist/src/daemon/ticket-dependency-waits.d.ts +18 -0
  133. package/dist/src/daemon/ticket-dependency-waits.js +60 -0
  134. package/dist/src/daemon/ticket-registration.d.ts +8 -4
  135. package/dist/src/daemon/ticket-registration.js +76 -22
  136. package/dist/src/daemon/ticket-source.d.ts +4 -1
  137. package/dist/src/daemon/ticket-source.js +8 -2
  138. package/dist/src/decision-visibility.d.ts +2 -0
  139. package/dist/src/decision-visibility.js +10 -0
  140. package/dist/src/execution-pin.d.ts +4 -0
  141. package/dist/src/execution-pin.js +23 -2
  142. package/dist/src/host/start-delivery.d.ts +2 -1
  143. package/dist/src/host/start-delivery.js +1 -1
  144. package/dist/src/host/step-bridge-host.d.ts +1 -0
  145. package/dist/src/host/step-bridge-host.js +16 -0
  146. package/dist/src/host/world-reconciliation.d.ts +1 -0
  147. package/dist/src/host/world-reconciliation.js +3 -0
  148. package/dist/src/host/world-runs.js +5 -0
  149. package/dist/src/issue-identity.d.ts +6 -0
  150. package/dist/src/issue-identity.js +23 -0
  151. package/dist/src/service/queue-runs.d.ts +3 -0
  152. package/dist/src/service/queue-runs.js +39 -0
  153. package/dist/src/service/registry.d.ts +8 -2
  154. package/dist/src/service/registry.js +20 -8
  155. package/dist/src/service/replies.d.ts +2 -0
  156. package/dist/src/service/run-handlers.js +44 -12
  157. package/dist/src/service/service.d.ts +3 -0
  158. package/dist/src/service/service.js +3 -1
  159. package/dist/src/service/submission-handler.d.ts +1 -0
  160. package/dist/src/service/submission-handler.js +92 -13
  161. package/dist/src/service/worker-totals.js +25 -1
  162. package/dist/src/step-bridge.d.ts +9 -0
  163. package/dist/src/steps/handoff.d.ts +2 -0
  164. package/dist/src/steps/handoff.js +13 -0
  165. package/dist/src/ticket-dependencies.d.ts +1 -0
  166. package/dist/src/ticket-dependencies.js +15 -0
  167. package/dist/src/ticket-status.js +7 -4
  168. package/dist/src/token-prices.d.ts +2 -0
  169. package/dist/src/token-prices.js +41 -9
  170. package/dist/src/worker-usage.d.ts +9 -0
  171. package/dist/src/worker-usage.js +1 -1
  172. package/dist/src/workflow-admission.d.ts +1 -0
  173. package/dist/src/workflow-candidate.js +1 -0
  174. package/dist/src/workflow-contracts.d.ts +2 -0
  175. package/dist/src/workflow-contracts.js +8 -3
  176. package/dist/src/workflow-declaration.d.ts +11 -0
  177. package/dist/src/workflow-declaration.js +71 -15
  178. package/dist/src/workflow-discovery.d.ts +18 -0
  179. package/dist/src/workflow-discovery.js +33 -0
  180. package/dist/src/workflow-refusals.d.ts +6 -0
  181. package/dist/src/workflow-refusals.js +39 -0
  182. package/dist/src/workflows.d.ts +2 -1
  183. package/dist/src/workflows.js +1 -0
  184. package/dist/tools/admit-workflow.d.ts +13 -0
  185. package/dist/tools/admit-workflow.js +94 -0
  186. package/dist/tools/self-host-project.d.ts +17 -0
  187. package/dist/tools/self-host-project.js +177 -0
  188. package/dist/tools/self-host.js +27 -81
  189. package/dist/tools/workflow-bundle-dependencies.d.ts +5 -0
  190. package/dist/tools/workflow-bundle-dependencies.js +93 -0
  191. package/dist/tools/workflow-bundles.d.ts +20 -0
  192. package/dist/tools/workflow-bundles.js +173 -0
  193. package/dist/tools/workflow-candidate-counts.d.ts +8 -0
  194. package/dist/tools/workflow-candidate-counts.js +251 -0
  195. package/dist/tools/workflow-candidate-program.d.ts +11 -0
  196. package/dist/tools/workflow-candidate-program.js +78 -0
  197. package/dist/tools/workflow-candidate-source.d.ts +6 -0
  198. package/dist/tools/workflow-candidate-source.js +107 -0
  199. package/dist/tools/workflow-candidate-syntax.d.ts +15 -0
  200. package/dist/tools/workflow-candidate-syntax.js +219 -0
  201. package/dist/tools/workflow-folder-capture.d.ts +17 -0
  202. package/dist/tools/workflow-folder-capture.js +216 -0
  203. package/dist/tools/workflow-folders.d.ts +33 -0
  204. package/dist/tools/workflow-folders.js +150 -0
  205. package/dist/tools/workflow-source-effects.d.ts +10 -0
  206. package/dist/tools/workflow-source-effects.js +213 -0
  207. package/dist/workflows/analyze-chats.d.ts +6 -1
  208. package/dist/workflows/analyze-chats.declaration.json +14 -1
  209. package/dist/workflows/analyze-chats.js +188 -11
  210. package/dist/workflows/develop-prd.js +71 -3
  211. package/dist/workflows/develop-ticket.js +5 -0
  212. package/dist/workflows/ticket-prompts.js +12 -2
  213. package/package.json +21 -3
@@ -0,0 +1,232 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ import { send } from "../cli/client.js";
3
+ import { record } from "../protocol.js";
4
+ import { runIdentity } from "../service/submission-handler.js";
5
+ import { located, readRecord, writeRecord } from "./operation-journal.js";
6
+ const attempts = 10;
7
+ const uncertain = [
8
+ "outcome-unknown",
9
+ "service-unavailable",
10
+ "service-busy",
11
+ "storage-failure",
12
+ "authority-unavailable",
13
+ "resolver-timeout",
14
+ "resolver-failure",
15
+ ];
16
+ function own(intent) {
17
+ return (intent.kind === "protected-effect" &&
18
+ intent.capability === "handoff" &&
19
+ intent.method === "run" &&
20
+ intent.target !== undefined &&
21
+ intent.payload !== undefined &&
22
+ intent.leaseGeneration !== undefined);
23
+ }
24
+ function completed(runId) {
25
+ return {
26
+ kind: "completed",
27
+ worker: "handoff",
28
+ receipt: runId,
29
+ result: runId,
30
+ };
31
+ }
32
+ async function saved(folder) {
33
+ const stored = record(await readRecord(folder, "result.json"));
34
+ if (stored === undefined)
35
+ return undefined;
36
+ if (stored["kind"] === "completed" && typeof stored["result"] === "string")
37
+ return completed(stored["result"]);
38
+ if (stored["kind"] === "failed" && typeof stored["reason"] === "string")
39
+ return { kind: "failed", reason: stored["reason"] };
40
+ throw new Error("invalid-handoff-journal");
41
+ }
42
+ async function retained(folder, outcome) {
43
+ if (["completed", "failed"].includes(outcome.kind))
44
+ await writeRecord(folder, {
45
+ name: "result.json",
46
+ value: outcome,
47
+ permissions: "private",
48
+ });
49
+ return outcome;
50
+ }
51
+ function outcome(reply) {
52
+ if (reply.kind !== "error") {
53
+ const runId = reply.result["runId"];
54
+ return reply.result["kind"] === "run-admitted" && typeof runId === "string"
55
+ ? completed(runId)
56
+ : { kind: "unknown" };
57
+ }
58
+ if (reply.error.code === "workflow-active")
59
+ return undefined;
60
+ if (reply.error.code === "chain-limit")
61
+ return { kind: "failed", reason: "chain-limit" };
62
+ return uncertain.includes(reply.error.code)
63
+ ? { kind: "unknown" }
64
+ : { kind: "failed", reason: "handoff-input-refused" };
65
+ }
66
+ async function input(settings, intent) {
67
+ const text = await settings.document(intent.payload);
68
+ try {
69
+ return record(JSON.parse(text ?? "null"));
70
+ }
71
+ catch {
72
+ return undefined;
73
+ }
74
+ }
75
+ async function submit(settings, call) {
76
+ const { intent, signal } = call;
77
+ if (!own(intent))
78
+ return { kind: "failed", reason: "unsupported-effect" };
79
+ const { folder } = located(settings.journal, intent.operationId);
80
+ const known = await saved(folder);
81
+ if (known !== undefined)
82
+ return known;
83
+ const submission = await input(settings, intent);
84
+ if (submission === undefined)
85
+ return retained(folder, {
86
+ kind: "failed",
87
+ reason: "handoff-input-refused",
88
+ });
89
+ const previous = await busyAttempts(folder);
90
+ for (let busy = previous; busy < attempts; busy += 1) {
91
+ if (!(await allowed(settings, { intent, signal })))
92
+ return { kind: "unknown" };
93
+ const reply = await submitted(settings, {
94
+ intent,
95
+ submission,
96
+ signal,
97
+ folder,
98
+ });
99
+ if (reply === undefined)
100
+ return { kind: "unknown" };
101
+ const result = outcome(reply);
102
+ if (result !== undefined)
103
+ return retained(folder, result);
104
+ await writeRecord(folder, {
105
+ name: "attempts.json",
106
+ value: { busy: busy + 1 },
107
+ permissions: "private",
108
+ });
109
+ if (busy + 1 < attempts)
110
+ await delay(settings.retryMilliseconds ?? 1000, undefined, {
111
+ signal,
112
+ }).catch(() => undefined);
113
+ }
114
+ return retained(folder, { kind: "failed", reason: "handoff-start-timeout" });
115
+ }
116
+ async function submitted(settings, call) {
117
+ const { intent, submission, folder, signal } = call;
118
+ const marker = (status) => writeRecord(folder, {
119
+ name: "submission.json",
120
+ value: { processId: process.pid, pending: status === "pending" },
121
+ permissions: "private",
122
+ });
123
+ await marker("pending");
124
+ if (!(await allowed(settings, { intent, signal }))) {
125
+ await marker("settled");
126
+ return undefined;
127
+ }
128
+ const reply = await send(settings.runtimeDirectory, {
129
+ operation: "run",
130
+ parameters: {
131
+ workflowId: intent.target,
132
+ source: "input",
133
+ submission,
134
+ commandId: `handoff:${intent.operationId}`,
135
+ },
136
+ });
137
+ if (reply.kind !== "error" || reply.error.code !== "outcome-unknown")
138
+ await marker("settled");
139
+ return reply;
140
+ }
141
+ async function allowed(settings, call) {
142
+ try {
143
+ call.signal.throwIfAborted();
144
+ const status = await settings.runStatus(call.intent.runId);
145
+ call.signal.throwIfAborted();
146
+ return status === "running";
147
+ }
148
+ catch {
149
+ return false;
150
+ }
151
+ }
152
+ async function inFlight(folder) {
153
+ const marker = record(await readRecord(folder, "submission.json"));
154
+ if (marker?.["pending"] !== true)
155
+ return false;
156
+ const pid = marker["processId"];
157
+ if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0)
158
+ throw new Error("invalid-handoff-journal");
159
+ try {
160
+ process.kill(pid, 0);
161
+ return true;
162
+ }
163
+ catch (error) {
164
+ return !(error instanceof Error &&
165
+ "code" in error &&
166
+ error.code === "ESRCH");
167
+ }
168
+ }
169
+ async function busyAttempts(folder) {
170
+ const previous = record(await readRecord(folder, "attempts.json"))?.["busy"] ?? 0;
171
+ if (typeof previous !== "number" ||
172
+ !Number.isSafeInteger(previous) ||
173
+ previous < 0 ||
174
+ previous > attempts)
175
+ throw new Error("invalid-handoff-journal");
176
+ return previous;
177
+ }
178
+ async function observed(settings, intent) {
179
+ if (!own(intent))
180
+ return { kind: "failed", reason: "unsupported-effect" };
181
+ const known = await saved(located(settings.journal, intent.operationId).folder);
182
+ if (known !== undefined)
183
+ return known;
184
+ const reply = await send(settings.runtimeDirectory, {
185
+ operation: "status",
186
+ parameters: { runId: runIdentity(`handoff:${intent.operationId}`) },
187
+ });
188
+ if (reply.kind === "error")
189
+ return reply.error.code === "unknown-run"
190
+ ? {
191
+ kind: (await inFlight(located(settings.journal, intent.operationId).folder))
192
+ ? "unknown"
193
+ : "absent",
194
+ }
195
+ : { kind: "unknown" };
196
+ const runId = record(reply.result["run"])?.["runId"];
197
+ return typeof runId === "string" ? completed(runId) : { kind: "unknown" };
198
+ }
199
+ export function handoffWorker(settings) {
200
+ const pending = new Map();
201
+ const deliver = (intent, signal) => {
202
+ const known = pending.get(intent.operationId);
203
+ if (known !== undefined)
204
+ return known.result;
205
+ const stopping = new AbortController();
206
+ const result = submit(settings, {
207
+ intent,
208
+ signal: AbortSignal.any([signal, stopping.signal]),
209
+ }).finally(() => pending.delete(intent.operationId));
210
+ pending.set(intent.operationId, { stopping, result });
211
+ return result;
212
+ };
213
+ return {
214
+ deliver,
215
+ reconcile: (intent) => observed(settings, intent),
216
+ retry: async (intent, signal) => {
217
+ if (!own(intent))
218
+ return { kind: "failed", reason: "unsupported-effect" };
219
+ const { folder } = located(settings.journal, intent.operationId);
220
+ if (!(await inFlight(folder)))
221
+ return { kind: "unknown" };
222
+ return deliver(intent, signal);
223
+ },
224
+ stop: async (intent) => {
225
+ const active = pending.get(intent.operationId);
226
+ active?.stopping.abort();
227
+ await active?.result;
228
+ const known = await observed(settings, intent);
229
+ return known.kind === "absent" ? { kind: "stopped" } : known;
230
+ },
231
+ };
232
+ }
@@ -2,6 +2,7 @@ interface QueueSettings {
2
2
  readonly directory: string;
3
3
  readonly runtimeDirectory: string;
4
4
  readonly intervalMilliseconds: number;
5
+ readonly lanes?: number;
5
6
  readonly resetQueue?: boolean;
6
7
  }
7
8
  export declare function issueQueue(settings: QueueSettings): {
@@ -76,6 +76,34 @@ async function recover(settings, pending) {
76
76
  await pending.save();
77
77
  }
78
78
  }
79
+ async function trackedRuns(runtimeDirectory, commands) {
80
+ if (commands.length === 0)
81
+ return [];
82
+ if (commands.length === 1) {
83
+ const commandId = String(commands[0]);
84
+ const run = await runStatus(runtimeDirectory, commandId);
85
+ if (run === undefined)
86
+ throw new Error("queue-run-missing");
87
+ return [{ commandId, run }];
88
+ }
89
+ const reply = await send(runtimeDirectory, {
90
+ operation: "status",
91
+ parameters: { runIds: commands.map(runIdentity) },
92
+ });
93
+ if (reply.kind === "error")
94
+ throw new Error(reply.error.code);
95
+ const runs = reply.result["runs"];
96
+ if (!Array.isArray(runs))
97
+ throw new Error("invalid-run-list");
98
+ return commands.map((commandId) => {
99
+ const run = runs
100
+ .map(record)
101
+ .find((item) => item?.["runId"] === runIdentity(commandId));
102
+ if (run === undefined)
103
+ throw new Error("queue-run-missing");
104
+ return { commandId, run };
105
+ });
106
+ }
79
107
  function handedOff(run) {
80
108
  const outcome = record(run["outcome"]);
81
109
  return (run["state"] === "completed" &&
@@ -88,33 +116,39 @@ function checkFailureLimit(pending) {
88
116
  if (pending.failures.length >= 3)
89
117
  throw new QueueFailureLimit(`3 runs in a row ended without handoff: ${pending.failures.join(", ")}`);
90
118
  }
119
+ async function retired(pending, ending) {
120
+ const { commandId, run } = ending;
121
+ pending.commands.delete(commandId);
122
+ if (handedOff(run))
123
+ pending.failures.length = 0;
124
+ else
125
+ pending.failures.push(runIdentity(commandId));
126
+ await pending.save();
127
+ checkFailureLimit(pending);
128
+ if (handedOff(run))
129
+ return;
130
+ const outcome = record(run["outcome"]);
131
+ process.stderr.write(`Queue run ended ${String(outcome?.["kind"] ?? run["state"])}${outcome?.["issueUpdate"] === "failed" ? "; issueUpdate: failed" : ""}: ${runIdentity(commandId)}; continuing\n`);
132
+ }
91
133
  async function completed(settings, pending) {
92
134
  checkFailureLimit(pending);
93
- let pendingRun = false;
135
+ let pendingRuns = 0;
136
+ const endings = [];
94
137
  const tracked = [...pending.commands].filter((commandId) => commandId !== pending.retryCommandId);
95
- for (const commandId of tracked) {
96
- const run = await runStatus(settings.runtimeDirectory, commandId);
97
- if (run === undefined)
98
- throw new Error("queue-run-missing");
99
- if (!terminal(run)) {
100
- pendingRun = true;
101
- continue;
102
- }
103
- pending.commands.delete(commandId);
104
- if (handedOff(run))
105
- pending.failures.length = 0;
138
+ for (const { commandId, run } of await trackedRuns(settings.runtimeDirectory, tracked)) {
139
+ if (terminal(run))
140
+ endings.push({ commandId, run });
106
141
  else
107
- pending.failures.push(runIdentity(commandId));
108
- await pending.save();
109
- checkFailureLimit(pending);
110
- if (!handedOff(run)) {
111
- const outcome = record(run["outcome"]);
112
- process.stderr.write(`Queue run ended ${String(outcome?.["kind"] ?? run["state"])}${outcome?.["issueUpdate"] === "failed" ? "; issueUpdate: failed" : ""}: ${runIdentity(commandId)}; continuing\n`);
113
- }
142
+ pendingRuns += 1;
114
143
  }
115
- return pendingRun;
144
+ endings.sort((first, second) => Number(first.run["terminalSequence"] ?? first.run["latestSequence"] ?? 0) -
145
+ Number(second.run["terminalSequence"] ?? second.run["latestSequence"] ?? 0));
146
+ for (const ending of endings)
147
+ await retired(pending, ending);
148
+ return pendingRuns;
116
149
  }
117
150
  async function active(runtimeDirectory) {
151
+ let count = 0;
118
152
  let after = "";
119
153
  do {
120
154
  const reply = await send(runtimeDirectory, {
@@ -126,17 +160,16 @@ async function active(runtimeDirectory) {
126
160
  const runs = reply.result["runs"];
127
161
  if (!Array.isArray(runs))
128
162
  throw new Error("invalid-run-list");
129
- if (runs.some((value) => {
163
+ count += runs.filter((value) => {
130
164
  const run = record(value);
131
165
  return run?.["workflowId"] === "develop-issue" && !terminal(run);
132
- }))
133
- return true;
166
+ }).length;
134
167
  const next = reply.result["next"];
135
168
  if (next !== null && (typeof next !== "string" || next <= after))
136
169
  throw new Error("invalid-run-page");
137
170
  after = next;
138
171
  } while (after !== null);
139
- return false;
172
+ return count;
140
173
  }
141
174
  async function submit(settings, pending) {
142
175
  const commandId = pending.retryCommandId ?? randomUUID();
@@ -163,19 +196,38 @@ async function submit(settings, pending) {
163
196
  process.stderr.write(`Queue ${reply.error.code}: command ${commandId}; will retry\n`);
164
197
  return;
165
198
  }
166
- if (!["no-ready-ticket", "workflow-active"].includes(reply.error.code))
199
+ if (![
200
+ "no-ready-ticket",
201
+ "workflow-active",
202
+ "ticket-active",
203
+ "ticket-dependency-open",
204
+ ].includes(reply.error.code))
167
205
  throw new Error(reply.error.code);
168
206
  pending.commands.delete(commandId);
169
207
  await pending.save();
170
208
  }
209
+ async function pendingUpdate(directory) {
210
+ return readFile(join(directory, "pending-self-update"), "utf8").catch((error) => {
211
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
212
+ return undefined;
213
+ throw error;
214
+ });
215
+ }
216
+ async function holdStarted(pending, observed) {
217
+ const { mark, activeRuns } = observed;
218
+ const timestamp = Number(mark);
219
+ if (!Number.isFinite(timestamp) || timestamp <= 0)
220
+ throw new Error("invalid-pending-self-update");
221
+ if (pending.selfUpdateHold?.mark !== mark || activeRuns > 0) {
222
+ pending.selfUpdateHold = { mark, startedAt: Date.now() };
223
+ await pending.save();
224
+ }
225
+ return pending.selfUpdateHold.startedAt;
226
+ }
171
227
  function updateHold(directory, pending) {
172
228
  let notice;
173
- return async () => {
174
- const mark = await readFile(join(directory, "pending-self-update"), "utf8").catch((error) => {
175
- if (error instanceof Error && "code" in error && error.code === "ENOENT")
176
- return undefined;
177
- throw error;
178
- });
229
+ return async (activeRuns = 0) => {
230
+ const mark = await pendingUpdate(directory);
179
231
  if (mark === undefined && pending.selfUpdateHold !== undefined) {
180
232
  pending.selfUpdateHold = undefined;
181
233
  await pending.save();
@@ -184,14 +236,8 @@ function updateHold(directory, pending) {
184
236
  notice = undefined;
185
237
  return false;
186
238
  }
187
- const timestamp = Number(mark);
188
- if (!Number.isFinite(timestamp) || timestamp <= 0)
189
- throw new Error("invalid-pending-self-update");
190
- if (pending.selfUpdateHold?.mark !== mark) {
191
- pending.selfUpdateHold = { mark, startedAt: Date.now() };
192
- await pending.save();
193
- }
194
- const holding = Date.now() - pending.selfUpdateHold.startedAt < 30 * 60 * 1000;
239
+ const startedAt = await holdStarted(pending, { mark, activeRuns });
240
+ const holding = activeRuns > 0 || Date.now() - startedAt < 30 * 60 * 1000;
195
241
  const line = holding
196
242
  ? "Queue holding for pending self-update\n"
197
243
  : "Queue continuing after pending self-update time limit\n";
@@ -227,11 +273,17 @@ async function cycle(settings, signal) {
227
273
  await delay(settings.intervalMilliseconds, undefined, { signal }).catch(() => undefined);
228
274
  if (stopped())
229
275
  return;
230
- if (await completed(settings, pending))
276
+ const pendingRuns = await completed(settings, pending);
277
+ const lanes = settings.lanes ?? 1;
278
+ if (pendingRuns >= lanes) {
279
+ await holding(pendingRuns);
231
280
  continue;
232
- if (await active(settings.runtimeDirectory))
281
+ }
282
+ const activeRuns = await active(settings.runtimeDirectory);
283
+ const held = await holding(activeRuns);
284
+ if (activeRuns >= lanes || activeRuns < pendingRuns)
233
285
  continue;
234
- if (await holding())
286
+ if (held)
235
287
  continue;
236
288
  if (stopped())
237
289
  return;
@@ -0,0 +1,18 @@
1
+ import type { Notification } from "./notify-message.ts";
2
+ export interface DeliveryEntry {
3
+ readonly key: string;
4
+ readonly message: Notification | null;
5
+ readonly state: "pending" | "sent" | "failed";
6
+ readonly attempts: number;
7
+ readonly lastAttemptAt: number | null;
8
+ readonly lastFailure: string | null;
9
+ }
10
+ export interface RecordedNotifications {
11
+ readonly entries: Map<string, DeliveryEntry>;
12
+ readonly retiredKeys: Set<string>;
13
+ }
14
+ export declare function recordedNotifications(file: string): Promise<RecordedNotifications | undefined>;
15
+ export declare function saveNotifications(file: string, journal: {
16
+ readonly entries: ReadonlyMap<string, DeliveryEntry>;
17
+ readonly retiredKeys: ReadonlySet<string>;
18
+ }): Promise<void>;
@@ -0,0 +1,108 @@
1
+ import { open, readFile, rename } from "node:fs/promises";
2
+ import { record } from "../protocol.js";
3
+ export async function recordedNotifications(file) {
4
+ const text = await readFile(file, "utf8").catch((error) => {
5
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
6
+ return undefined;
7
+ throw error;
8
+ });
9
+ if (text === undefined)
10
+ return undefined;
11
+ const value = JSON.parse(text);
12
+ const journal = record(value);
13
+ if (journal?.["version"] === 1 && Array.isArray(journal["entries"])) {
14
+ const entries = journal["entries"].map(deliveryEntry);
15
+ const found = new Map(entries.map((entry) => [entry.key, entry]));
16
+ if (found.size !== entries.length)
17
+ throw new Error("invalid-notify-record");
18
+ return {
19
+ entries: found,
20
+ retiredKeys: recordedKeys(journal["retiredKeys"] ?? []),
21
+ };
22
+ }
23
+ const keys = recordedKeys(value);
24
+ return {
25
+ retiredKeys: new Set(),
26
+ entries: new Map([...keys].map((key) => [
27
+ key,
28
+ {
29
+ key,
30
+ message: null,
31
+ state: "sent",
32
+ attempts: 0,
33
+ lastAttemptAt: Date.now(),
34
+ lastFailure: null,
35
+ },
36
+ ])),
37
+ };
38
+ }
39
+ function recordedKeys(value) {
40
+ if (!Array.isArray(value) ||
41
+ !value.every((key) => typeof key === "string"))
42
+ throw new Error("invalid-notify-record");
43
+ return new Set(value.map(String));
44
+ }
45
+ function notification(value) {
46
+ const message = record(value);
47
+ if (message === undefined)
48
+ return false;
49
+ return (["decision", "blocked", "failed"].includes(String(message["event"])) &&
50
+ ["event", "runId", "workflowId", "subject", "body"].every((field) => typeof message[field] === "string"));
51
+ }
52
+ function deliveryState(value) {
53
+ return value === "pending" || value === "sent" || value === "failed";
54
+ }
55
+ function attemptCount(value) {
56
+ return (typeof value === "number" &&
57
+ Number.isInteger(value) &&
58
+ value >= 0 &&
59
+ value <= 5);
60
+ }
61
+ function attemptTime(value) {
62
+ return (value === null ||
63
+ (typeof value === "number" && Number.isSafeInteger(value) && value >= 0));
64
+ }
65
+ function deliveryEntry(value) {
66
+ const { key, message, state, attempts, lastAttemptAt, lastFailure } = record(value) ?? {};
67
+ if (typeof key !== "string" ||
68
+ !deliveryState(state) ||
69
+ !attemptCount(attempts))
70
+ throw new Error("invalid-notify-record");
71
+ if (!attemptTime(lastAttemptAt) ||
72
+ !(lastFailure === null || typeof lastFailure === "string"))
73
+ throw new Error("invalid-notify-record");
74
+ const content = { message, state };
75
+ if (!entryMessage(content))
76
+ throw new Error("invalid-notify-record");
77
+ return {
78
+ key,
79
+ message: content.message,
80
+ state,
81
+ attempts,
82
+ lastAttemptAt,
83
+ lastFailure,
84
+ };
85
+ }
86
+ function entryMessage(entry) {
87
+ return entry.message === null
88
+ ? entry.state === "sent"
89
+ : notification(entry.message);
90
+ }
91
+ export async function saveNotifications(file, journal) {
92
+ const temporary = await open(`${file}.tmp`, "w", 0o600);
93
+ try {
94
+ await temporary.chmod(0o600);
95
+ await temporary.writeFile(JSON.stringify({
96
+ version: 1,
97
+ entries: [...journal.entries.values()],
98
+ ...(journal.retiredKeys.size > 0
99
+ ? { retiredKeys: [...journal.retiredKeys] }
100
+ : {}),
101
+ }) + "\n");
102
+ await temporary.sync();
103
+ }
104
+ finally {
105
+ await temporary.close();
106
+ }
107
+ await rename(`${file}.tmp`, file);
108
+ }
@@ -1,3 +1,4 @@
1
+ import { operationalDecision } from "../decision-visibility.js";
1
2
  import { decisionLines, runStatus } from "../cli/render.js";
2
3
  import { record } from "../protocol.js";
3
4
  function body(text, commands) {
@@ -17,7 +18,7 @@ function message(run, content) {
17
18
  };
18
19
  }
19
20
  export function decisionNotification(run, decision) {
20
- const lines = decisionLines(decision);
21
+ const lines = decisionLines(operationalDecision(decision, String(run["workflowId"])));
21
22
  const allowed = decision["allowed"];
22
23
  const count = Array.isArray(allowed) ? allowed.length : 0;
23
24
  const split = lines.length - count;