cloving 1.4.0 → 1.5.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 (167) hide show
  1. package/README.md +100 -15
  2. package/dist/src/broker/actor/authority-health.d.ts +9 -0
  3. package/dist/src/broker/actor/authority-health.js +24 -0
  4. package/dist/src/broker/actor/authority-runtime.d.ts +4 -2
  5. package/dist/src/broker/actor/authority-runtime.js +29 -20
  6. package/dist/src/broker/actor/authority-session.d.ts +6 -19
  7. package/dist/src/broker/actor/authority-session.js +66 -88
  8. package/dist/src/broker/actor/authority-settings.d.ts +2 -1
  9. package/dist/src/broker/actor/authority-settings.js +4 -1
  10. package/dist/src/broker/actor/authority-upgrades.d.ts +5 -1
  11. package/dist/src/broker/actor/authority-upgrades.js +2 -1
  12. package/dist/src/broker/actor/authority-watchdog.d.ts +6 -2
  13. package/dist/src/broker/actor/authority-watchdog.js +31 -9
  14. package/dist/src/broker/actor/authority.js +3 -1
  15. package/dist/src/broker/actor/projection-memory.d.ts +43 -0
  16. package/dist/src/broker/actor/projection-memory.js +207 -0
  17. package/dist/src/broker/actor/runtime-parts.d.ts +5 -0
  18. package/dist/src/broker/actor/runtime-parts.js +8 -3
  19. package/dist/src/broker/authority-restore.js +1 -1
  20. package/dist/src/broker/effect-upgrade.d.ts +1 -0
  21. package/dist/src/broker/effect-upgrade.js +4 -1
  22. package/dist/src/cli/arguments.js +1 -0
  23. package/dist/src/cli/client.d.ts +3 -1
  24. package/dist/src/cli/client.js +4 -4
  25. package/dist/src/cli/command-requests.d.ts +30 -1
  26. package/dist/src/cli/command-requests.js +43 -2
  27. package/dist/src/cli/commands.js +57 -48
  28. package/dist/src/cli/config-notice.js +5 -2
  29. package/dist/src/cli/doctor-retention.d.ts +2 -0
  30. package/dist/src/cli/doctor-retention.js +93 -0
  31. package/dist/src/cli/doctor.js +5 -0
  32. package/dist/src/cli/init-prompt.js +1 -1
  33. package/dist/src/cli/mcp-server.js +4 -4
  34. package/dist/src/cli/mcp-tools.d.ts +14 -14
  35. package/dist/src/cli/mcp-tools.js +212 -50
  36. package/dist/src/cli/queue-arguments.d.ts +15 -0
  37. package/dist/src/cli/queue-arguments.js +31 -0
  38. package/dist/src/cli/queue-render.d.ts +3 -0
  39. package/dist/src/cli/queue-render.js +26 -0
  40. package/dist/src/cli/reload-render.d.ts +2 -0
  41. package/dist/src/cli/reload-render.js +12 -0
  42. package/dist/src/cli/render.js +37 -8
  43. package/dist/src/cli/report-github-reader.js +1 -1
  44. package/dist/src/cli/runtime-arguments.d.ts +17 -3
  45. package/dist/src/cli/runtime-arguments.js +23 -19
  46. package/dist/src/cli/toolchain.d.ts +1 -0
  47. package/dist/src/cli/toolchain.js +1 -0
  48. package/dist/src/cli/usage.d.ts +2 -2
  49. package/dist/src/cli/usage.js +40 -5
  50. package/dist/src/cli/watch-api-document.d.ts +430 -0
  51. package/dist/src/cli/watch-api-document.js +291 -0
  52. package/dist/src/cli/watch-api-run.d.ts +56 -0
  53. package/dist/src/cli/watch-api-run.js +112 -0
  54. package/dist/src/cli/watch-api.d.ts +17 -0
  55. package/dist/src/cli/watch-api.js +230 -0
  56. package/dist/src/cli/watch-header-view.d.ts +1 -0
  57. package/dist/src/cli/watch-header-view.js +9 -1
  58. package/dist/src/cli/watch-model.d.ts +1 -0
  59. package/dist/src/cli/watch-model.js +3 -0
  60. package/dist/src/cli/watch-page.js +3 -1
  61. package/dist/src/cli/watch-reads.d.ts +1 -0
  62. package/dist/src/cli/watch-reads.js +6 -5
  63. package/dist/src/cli/watch-render.d.ts +1 -0
  64. package/dist/src/cli/watch-server.js +18 -4
  65. package/dist/src/cli.js +50 -43
  66. package/dist/src/configuration-display.js +2 -0
  67. package/dist/src/configuration-schema.d.ts +5 -0
  68. package/dist/src/daemon/claude-recovery.js +13 -9
  69. package/dist/src/daemon/claude-worker.d.ts +3 -3
  70. package/dist/src/daemon/claude-worker.js +10 -11
  71. package/dist/src/daemon/daemon-adapters.d.ts +7 -0
  72. package/dist/src/daemon/daemon-adapters.js +7 -3
  73. package/dist/src/daemon/daemon-loops.d.ts +2 -0
  74. package/dist/src/daemon/daemon-loops.js +17 -11
  75. package/dist/src/daemon/daemon.js +71 -60
  76. package/dist/src/daemon/github-attach.js +77 -4
  77. package/dist/src/daemon/github-calls.js +1 -1
  78. package/dist/src/daemon/github-methods.js +6 -2
  79. package/dist/src/daemon/issue-queue.d.ts +3 -0
  80. package/dist/src/daemon/issue-queue.js +66 -73
  81. package/dist/src/daemon/merge-turns.d.ts +10 -0
  82. package/dist/src/daemon/merge-turns.js +37 -0
  83. package/dist/src/daemon/queue-control.d.ts +13 -0
  84. package/dist/src/daemon/queue-control.js +86 -0
  85. package/dist/src/daemon/queue-journal.d.ts +20 -0
  86. package/dist/src/daemon/queue-journal.js +89 -0
  87. package/dist/src/daemon/record-archive.d.ts +19 -0
  88. package/dist/src/daemon/record-archive.js +82 -0
  89. package/dist/src/daemon/record-sweep.d.ts +24 -0
  90. package/dist/src/daemon/record-sweep.js +147 -0
  91. package/dist/src/daemon/retrying-turn.js +4 -4
  92. package/dist/src/daemon/settings-reload.d.ts +26 -0
  93. package/dist/src/daemon/settings-reload.js +82 -0
  94. package/dist/src/daemon/turn-details.d.ts +4 -0
  95. package/dist/src/daemon/turn-details.js +33 -0
  96. package/dist/src/daemon/worker-profile.d.ts +1 -0
  97. package/dist/src/daemon/worker-profile.js +4 -0
  98. package/dist/src/daemon/workflow-generations.d.ts +7 -4
  99. package/dist/src/daemon/workflow-generations.js +22 -14
  100. package/dist/src/daemon/workflow-reload.d.ts +41 -0
  101. package/dist/src/daemon/workflow-reload.js +244 -0
  102. package/dist/src/daemon/workflow-replay.d.ts +2 -1
  103. package/dist/src/daemon/workflow-replay.js +7 -3
  104. package/dist/src/doctor-report.d.ts +2 -2
  105. package/dist/src/doctor-report.js +2 -0
  106. package/dist/src/private-config.schema.json +31 -0
  107. package/dist/src/project-settings.d.ts +15 -1
  108. package/dist/src/project-settings.js +33 -1
  109. package/dist/src/queue-state.d.ts +26 -0
  110. package/dist/src/queue-state.js +34 -0
  111. package/dist/src/record-retention.d.ts +46 -0
  112. package/dist/src/record-retention.js +81 -0
  113. package/dist/src/service/active-tickets.js +5 -0
  114. package/dist/src/service/dispatcher.d.ts +4 -0
  115. package/dist/src/service/dispatcher.js +33 -0
  116. package/dist/src/service/queue-handlers.d.ts +4 -0
  117. package/dist/src/service/queue-handlers.js +32 -0
  118. package/dist/src/service/registry.d.ts +28 -2
  119. package/dist/src/service/registry.js +48 -0
  120. package/dist/src/service/replies.d.ts +16 -9
  121. package/dist/src/service/replies.js +5 -2
  122. package/dist/src/service/run-handlers.js +15 -10
  123. package/dist/src/service/service.d.ts +7 -3
  124. package/dist/src/service/service.js +16 -31
  125. package/dist/src/service/worker-totals.d.ts +4 -0
  126. package/dist/src/service/worker-totals.js +7 -0
  127. package/dist/src/service/workflow-reload.d.ts +35 -0
  128. package/dist/src/service/workflow-reload.js +52 -0
  129. package/dist/src/storage/decision-snapshots.d.ts +1 -2
  130. package/dist/src/storage/decision-snapshots.js +1 -7
  131. package/dist/src/storage/domain-changes.d.ts +1 -1
  132. package/dist/src/storage/domain-changes.js +1 -0
  133. package/dist/src/storage/domain-schema.d.ts +4 -1
  134. package/dist/src/storage/domain-schema.js +6 -2
  135. package/dist/src/storage/effect-schema.d.ts +1 -0
  136. package/dist/src/storage/effect-schema.js +4 -0
  137. package/dist/src/storage/projection-changes.d.ts +12 -0
  138. package/dist/src/storage/projection-changes.js +194 -0
  139. package/dist/src/storage/projection-lookups.d.ts +7 -0
  140. package/dist/src/storage/projection-lookups.js +30 -0
  141. package/dist/src/workflow-declaration.d.ts +1 -0
  142. package/dist/src/workflow-declaration.js +3 -0
  143. package/dist/tools/compile-workflows.js +3 -0
  144. package/dist/tools/project-configuration.d.ts +7 -2
  145. package/dist/tools/project-configuration.js +30 -1
  146. package/dist/tools/self-host-processes.js +37 -13
  147. package/dist/tools/self-host-project.d.ts +2 -0
  148. package/dist/tools/self-host-project.js +21 -3
  149. package/dist/tools/self-host.js +34 -24
  150. package/dist/tools/workflow-candidate-program.d.ts +4 -2
  151. package/dist/tools/workflow-candidate-program.js +30 -11
  152. package/dist/tools/workflow-candidate-source.d.ts +1 -0
  153. package/dist/tools/workflow-candidate-source.js +11 -3
  154. package/package.json +2 -2
  155. package/templates/develop/README.md +81 -4
  156. package/templates/develop/develop/flow-tests.ts +1 -1
  157. package/templates/develop/develop/interruption-tests.ts +157 -0
  158. package/templates/develop/develop/local.ts +1 -0
  159. package/templates/develop/develop/option-tests.ts +138 -9
  160. package/templates/develop/develop/red-green.ts +55 -0
  161. package/templates/develop/develop/report.ts +31 -11
  162. package/templates/develop/develop/round.ts +1 -0
  163. package/templates/develop/develop/scenarios.ts +27 -0
  164. package/templates/develop/develop/turn.ts +26 -6
  165. package/templates/develop/develop.declaration.json +1 -1
  166. package/templates/develop/develop.test.ts +4 -0
  167. package/templates/develop/template.json +29 -2
@@ -0,0 +1,291 @@
1
+ export const runStates = [
2
+ "queued",
3
+ "running",
4
+ "waiting_gate",
5
+ "paused",
6
+ "completed",
7
+ "failed",
8
+ "cancelled",
9
+ ];
10
+ export const runLimit = {
11
+ type: "integer",
12
+ minimum: 1,
13
+ maximum: 200,
14
+ default: 50,
15
+ };
16
+ const text = { type: "string" };
17
+ const nullableText = { type: ["string", "null"] };
18
+ const integer = { type: "integer" };
19
+ const nullableNumber = { type: ["number", "null"] };
20
+ const flag = { type: "boolean" };
21
+ function reference(name) {
22
+ return { $ref: `#/components/schemas/${name}` };
23
+ }
24
+ function closed(properties) {
25
+ return {
26
+ type: "object",
27
+ required: Object.keys(properties),
28
+ properties,
29
+ additionalProperties: false,
30
+ };
31
+ }
32
+ function listOf(items) {
33
+ return { type: "array", items };
34
+ }
35
+ function json(description, schema) {
36
+ return { description, content: { "application/json": { schema } } };
37
+ }
38
+ function problem(description) {
39
+ return {
40
+ description,
41
+ content: { "application/problem+json": { schema: reference("Problem") } },
42
+ };
43
+ }
44
+ const invalidQuery = problem("A query parameter is unknown, repeated or has an invalid value.");
45
+ const serviceUnavailable = problem("The Cloving service does not answer.");
46
+ const unknownRun = problem("The service has no run with this ID.");
47
+ const documentSchema = closed({
48
+ openapi: { const: "3.1.1" },
49
+ info: closed({ title: text, version: text }),
50
+ servers: listOf(closed({ url: text })),
51
+ tags: listOf(closed({ name: text, description: text })),
52
+ paths: { type: "object" },
53
+ components: { type: "object" },
54
+ });
55
+ const indexSchema = closed({
56
+ version: { const: 1 },
57
+ openapi: text,
58
+ endpoints: listOf(closed({ method: { const: "GET" }, path: text, operationId: text })),
59
+ });
60
+ const runParameters = [
61
+ {
62
+ name: "workflowId",
63
+ in: "query",
64
+ description: "Give only the runs of this workflow.",
65
+ schema: text,
66
+ },
67
+ {
68
+ name: "state",
69
+ in: "query",
70
+ description: "Give only the runs in this state.",
71
+ schema: { type: "string", enum: runStates },
72
+ },
73
+ {
74
+ name: "limit",
75
+ in: "query",
76
+ description: "The maximum number of runs on the page.",
77
+ schema: runLimit,
78
+ },
79
+ {
80
+ name: "cursor",
81
+ in: "query",
82
+ description: "The nextCursor value of the previous page.",
83
+ schema: text,
84
+ },
85
+ ];
86
+ const runId = {
87
+ name: "runId",
88
+ in: "path",
89
+ required: true,
90
+ description: "The ID of the run.",
91
+ schema: text,
92
+ };
93
+ const after = {
94
+ name: "after",
95
+ in: "query",
96
+ description: "Give only the events with a higher sequence.",
97
+ schema: { type: "integer", minimum: 0, default: 0 },
98
+ };
99
+ const runSummary = {
100
+ runId: text,
101
+ workflowId: text,
102
+ ticketId: nullableText,
103
+ title: nullableText,
104
+ state: { type: "string", enum: runStates },
105
+ outcome: nullableText,
106
+ currentStep: closed({ name: text, startedAt: nullableText }),
107
+ latestAt: nullableText,
108
+ pullRequest: {
109
+ oneOf: [closed({ number: integer, url: text }), { type: "null" }],
110
+ },
111
+ estimatedCostUsd: nullableNumber,
112
+ };
113
+ export const watchApiDocument = {
114
+ openapi: "3.1.1",
115
+ info: { title: "Cloving watch API", version: "1.0.0" },
116
+ servers: [{ url: "/api/v1" }],
117
+ tags: [
118
+ { name: "api", description: "The index and the contract of the API." },
119
+ { name: "service", description: "The state of the Cloving service." },
120
+ { name: "workflows", description: "The workflows of the service." },
121
+ { name: "runs", description: "The runs of the watch page." },
122
+ { name: "stream", description: "The change signals of the runs." },
123
+ ],
124
+ paths: {
125
+ "/": {
126
+ get: {
127
+ operationId: "getApiIndex",
128
+ tags: ["api"],
129
+ summary: "Give the version and the endpoints of the API.",
130
+ responses: {
131
+ "200": json("The index of the API.", indexSchema),
132
+ "400": invalidQuery,
133
+ },
134
+ },
135
+ },
136
+ "/openapi.json": {
137
+ get: {
138
+ operationId: "getOpenApiDocument",
139
+ tags: ["api"],
140
+ summary: "Give this OpenAPI document.",
141
+ responses: {
142
+ "200": json("The OpenAPI document of the API.", documentSchema),
143
+ "400": invalidQuery,
144
+ },
145
+ },
146
+ },
147
+ "/service": {
148
+ get: {
149
+ operationId: "getService",
150
+ tags: ["service"],
151
+ summary: "Tell if the Cloving service answers.",
152
+ responses: {
153
+ "200": json("The state of the service.", reference("Service")),
154
+ "400": invalidQuery,
155
+ },
156
+ },
157
+ },
158
+ "/workflows": {
159
+ get: {
160
+ operationId: "listWorkflows",
161
+ tags: ["workflows"],
162
+ summary: "Give the workflows of the service and their card fields.",
163
+ responses: {
164
+ "200": json("The workflows of the service.", closed({ workflows: listOf(reference("Workflow")) })),
165
+ "400": invalidQuery,
166
+ "503": serviceUnavailable,
167
+ },
168
+ },
169
+ },
170
+ "/runs": {
171
+ get: {
172
+ operationId: "listRuns",
173
+ tags: ["runs"],
174
+ summary: "Give the active runs and the runs with an event in the last 7 days, newest first.",
175
+ parameters: runParameters,
176
+ responses: {
177
+ "200": json("One page of runs.", reference("RunPage")),
178
+ "400": invalidQuery,
179
+ "503": serviceUnavailable,
180
+ },
181
+ },
182
+ },
183
+ "/runs/{runId}": {
184
+ get: {
185
+ operationId: "getRun",
186
+ tags: ["runs"],
187
+ summary: "Give one run with its phases, open decisions, reviews and agent turns, also when the run is older than 7 days.",
188
+ parameters: [runId],
189
+ responses: {
190
+ "200": json("The run.", closed({ run: reference("RunDetail") })),
191
+ "400": invalidQuery,
192
+ "404": unknownRun,
193
+ "503": serviceUnavailable,
194
+ },
195
+ },
196
+ },
197
+ "/runs/{runId}/events": {
198
+ get: {
199
+ operationId: "listRunEvents",
200
+ tags: ["runs"],
201
+ summary: "Give the events of one run after a sequence, the lowest sequence first.",
202
+ parameters: [runId, after],
203
+ responses: {
204
+ "200": json("The events of the run.", closed({ events: listOf(reference("Event")) })),
205
+ "400": invalidQuery,
206
+ "404": unknownRun,
207
+ "503": serviceUnavailable,
208
+ },
209
+ },
210
+ },
211
+ "/stream": {
212
+ get: {
213
+ operationId: "streamChanges",
214
+ tags: ["stream"],
215
+ summary: 'Send the event change with the data {"updatedAt":"<time>"} at the connection and at each change of a run.',
216
+ responses: {
217
+ "200": {
218
+ description: "Server-Sent Events.",
219
+ content: { "text/event-stream": { schema: text } },
220
+ },
221
+ "400": invalidQuery,
222
+ },
223
+ },
224
+ },
225
+ },
226
+ components: {
227
+ schemas: {
228
+ Service: closed({ up: flag, updatedAt: text }),
229
+ Workflow: closed({
230
+ workflowId: text,
231
+ activeCount: integer,
232
+ waitingCount: integer,
233
+ latestAt: nullableText,
234
+ inputs: listOf(reference("CardField")),
235
+ }),
236
+ CardField: closed({
237
+ name: text,
238
+ label: text,
239
+ multiline: flag,
240
+ optional: flag,
241
+ }),
242
+ RunSummary: closed(runSummary),
243
+ RunPage: closed({
244
+ runs: listOf(reference("RunSummary")),
245
+ nextCursor: nullableText,
246
+ }),
247
+ RunDetail: closed({
248
+ ...runSummary,
249
+ code: text,
250
+ detail: text,
251
+ round: integer,
252
+ spent: nullableNumber,
253
+ limit: nullableNumber,
254
+ decisions: listOf(reference("Decision")),
255
+ phases: listOf(reference("Phase")),
256
+ reviews: listOf(reference("Review")),
257
+ mergeCommit: nullableText,
258
+ turns: listOf(reference("Turn")),
259
+ }),
260
+ Decision: closed({ decisionId: text, type: text, prompt: text }),
261
+ Phase: closed({
262
+ name: text,
263
+ state: text,
264
+ startedAt: nullableText,
265
+ endedAt: nullableText,
266
+ }),
267
+ Review: closed({
268
+ round: integer,
269
+ commit: text,
270
+ score: { type: "number" },
271
+ }),
272
+ Turn: closed({
273
+ key: text,
274
+ harness: nullableText,
275
+ startedAt: text,
276
+ endedAt: nullableText,
277
+ outcome: text,
278
+ estimatedCostUsd: nullableNumber,
279
+ summary: text,
280
+ }),
281
+ Event: closed({ sequence: integer, type: text, recordedAt: text }),
282
+ Problem: closed({
283
+ type: text,
284
+ title: text,
285
+ status: integer,
286
+ detail: text,
287
+ code: text,
288
+ }),
289
+ },
290
+ },
291
+ };
@@ -0,0 +1,56 @@
1
+ import type { WatchEvent, WatchPhase, WatchReview, WatchRun } from "./watch-render.ts";
2
+ export interface ApiRun {
3
+ readonly runId: string;
4
+ readonly workflowId: string;
5
+ readonly ticketId: string | null;
6
+ readonly title: string | null;
7
+ readonly state: string;
8
+ readonly outcome: string | null;
9
+ readonly currentStep: {
10
+ readonly name: string;
11
+ readonly startedAt: string | null;
12
+ };
13
+ readonly latestAt: string | null;
14
+ readonly pullRequest: {
15
+ readonly number: number;
16
+ readonly url: string;
17
+ } | null;
18
+ readonly estimatedCostUsd: number | null;
19
+ }
20
+ interface ApiDecision {
21
+ readonly decisionId: string;
22
+ readonly type: string;
23
+ readonly prompt: string;
24
+ }
25
+ interface ApiTurn {
26
+ readonly key: string;
27
+ readonly harness: string | null;
28
+ readonly startedAt: string;
29
+ readonly endedAt: string | null;
30
+ readonly outcome: string;
31
+ readonly estimatedCostUsd: number | null;
32
+ readonly summary: string;
33
+ }
34
+ export interface ApiRunDetail extends ApiRun {
35
+ readonly code: string;
36
+ readonly detail: string;
37
+ readonly round: number;
38
+ readonly spent: number | null;
39
+ readonly limit: number | null;
40
+ readonly decisions: readonly ApiDecision[];
41
+ readonly phases: readonly WatchPhase[];
42
+ readonly reviews: readonly WatchReview[];
43
+ readonly mergeCommit: string | null;
44
+ readonly turns: readonly ApiTurn[];
45
+ }
46
+ export declare function runOf(run: WatchRun): ApiRun;
47
+ export declare function runDetailOf(run: WatchRun): ApiRunDetail;
48
+ export declare function eventsAfter(run: WatchRun, after: number): readonly WatchEvent[];
49
+ export interface RunKey {
50
+ readonly latestAt: string | null;
51
+ readonly runId: string;
52
+ }
53
+ export declare function runOrder(first: RunKey, second: RunKey): number;
54
+ export declare function cursorOf(run: RunKey): string;
55
+ export declare function keyOf(cursor: string): RunKey | null;
56
+ export {};
@@ -0,0 +1,112 @@
1
+ export function runOf(run) {
2
+ const { pullRequest, currentStep } = run;
3
+ return {
4
+ runId: run.runId,
5
+ workflowId: run.workflowId,
6
+ ticketId: run.ticketId,
7
+ title: run.title,
8
+ state: run.state,
9
+ outcome: run.outcome,
10
+ currentStep: { name: currentStep.name, startedAt: currentStep.startedAt },
11
+ latestAt: run.latestAt,
12
+ pullRequest: pullRequest === null
13
+ ? null
14
+ : { number: pullRequest.number, url: pullRequest.url },
15
+ estimatedCostUsd: run.estimatedCostUsd,
16
+ };
17
+ }
18
+ function decisionOf(decision) {
19
+ return {
20
+ decisionId: decision.decisionId,
21
+ type: decision.type,
22
+ prompt: decision.prompt,
23
+ };
24
+ }
25
+ function phaseOf(phase) {
26
+ return {
27
+ name: phase.name,
28
+ state: phase.state,
29
+ startedAt: phase.startedAt,
30
+ endedAt: phase.endedAt,
31
+ };
32
+ }
33
+ function reviewOf(review) {
34
+ return { round: review.round, commit: review.commit, score: review.score };
35
+ }
36
+ function turnOf(turn) {
37
+ return {
38
+ key: turn.key,
39
+ harness: turn.harness ?? null,
40
+ startedAt: turn.startedAt,
41
+ endedAt: turn.endedAt,
42
+ outcome: turn.outcome,
43
+ estimatedCostUsd: turn.estimatedCostUsd,
44
+ summary: turn.summary,
45
+ };
46
+ }
47
+ export function runDetailOf(run) {
48
+ return {
49
+ ...runOf(run),
50
+ code: run.code,
51
+ detail: run.detail,
52
+ round: run.round,
53
+ spent: run.spent,
54
+ limit: run.limit,
55
+ decisions: run.decisions.map(decisionOf),
56
+ phases: run.phases.map(phaseOf),
57
+ reviews: run.reviews.map(reviewOf),
58
+ mergeCommit: run.mergeCommit,
59
+ turns: run.turns.map(turnOf),
60
+ };
61
+ }
62
+ function eventOf(event) {
63
+ return {
64
+ sequence: event.sequence,
65
+ type: event.type,
66
+ recordedAt: event.recordedAt,
67
+ };
68
+ }
69
+ export function eventsAfter(run, after) {
70
+ return run.events.filter((event) => event.sequence > after).map(eventOf);
71
+ }
72
+ function instant(latestAt) {
73
+ const time = Date.parse(latestAt ?? "");
74
+ return Number.isNaN(time) ? Number.NEGATIVE_INFINITY : time;
75
+ }
76
+ function codeOrder(first, second) {
77
+ if (first === second)
78
+ return 0;
79
+ return first < second ? -1 : 1;
80
+ }
81
+ export function runOrder(first, second) {
82
+ const earlier = instant(first.latestAt);
83
+ const later = instant(second.latestAt);
84
+ if (earlier !== later)
85
+ return earlier > later ? -1 : 1;
86
+ return codeOrder(first.runId, second.runId);
87
+ }
88
+ export function cursorOf(run) {
89
+ return Buffer.from(JSON.stringify([run.latestAt, run.runId])).toString("base64url");
90
+ }
91
+ function parsedJson(text) {
92
+ try {
93
+ const value = JSON.parse(text);
94
+ return value;
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ }
100
+ export function keyOf(cursor) {
101
+ const value = parsedJson(Buffer.from(cursor, "base64url").toString("utf8"));
102
+ const entries = Array.isArray(value)
103
+ ? value.map((item) => item)
104
+ : [];
105
+ const [latestAt, runId] = entries;
106
+ if (entries.length !== 2 || typeof runId !== "string")
107
+ return null;
108
+ if (latestAt !== null && typeof latestAt !== "string")
109
+ return null;
110
+ const key = { latestAt, runId };
111
+ return cursorOf(key) === cursor ? key : null;
112
+ }
@@ -0,0 +1,17 @@
1
+ import type { WatchModel } from "./watch-render.ts";
2
+ export interface ApiReply {
3
+ readonly status: number;
4
+ readonly type: string;
5
+ readonly body: string;
6
+ readonly allow?: string;
7
+ }
8
+ export interface ApiRequest {
9
+ readonly method: string;
10
+ readonly url: URL;
11
+ readonly model: () => Promise<WatchModel>;
12
+ readonly run: (runId: string) => Promise<WatchModel>;
13
+ }
14
+ export type ApiAnswer = ApiReply | "stream";
15
+ export declare const watchApiPaths: readonly string[];
16
+ export declare function watchApiChange(): string;
17
+ export declare function watchApiReply(request: ApiRequest): Promise<ApiAnswer | null>;
@@ -0,0 +1,230 @@
1
+ import { STATUS_CODES } from "node:http";
2
+ import { runLimit, runStates, watchApiDocument } from "./watch-api-document.js";
3
+ import { cursorOf, eventsAfter, keyOf, runDetailOf, runOf, runOrder, } from "./watch-api-run.js";
4
+ const base = "/api/v1";
5
+ function found(body) {
6
+ return { status: 200, type: "application/json", body: JSON.stringify(body) };
7
+ }
8
+ function problem(status, detail) {
9
+ return {
10
+ status,
11
+ type: "application/problem+json",
12
+ body: JSON.stringify({
13
+ type: "about:blank",
14
+ title: STATUS_CODES[status],
15
+ status,
16
+ detail: detail.detail,
17
+ code: detail.code,
18
+ }),
19
+ };
20
+ }
21
+ const serviceUnavailable = problem(503, {
22
+ code: "service-unavailable",
23
+ detail: "The Cloving service does not answer.",
24
+ });
25
+ const unknownRun = problem(404, {
26
+ code: "not-found",
27
+ detail: "The service has no run with this ID.",
28
+ });
29
+ function apiIndex() {
30
+ return found({
31
+ version: 1,
32
+ openapi: `${base}/openapi.json`,
33
+ endpoints: Object.entries(watchApiDocument.paths).map(([path, item]) => ({
34
+ method: "GET",
35
+ path: `${base}${path}`,
36
+ operationId: item.get.operationId,
37
+ })),
38
+ });
39
+ }
40
+ async function service(request) {
41
+ const model = await request.model();
42
+ return found({ up: model.service === "up", updatedAt: model.updatedAt });
43
+ }
44
+ function inputOf(field) {
45
+ return {
46
+ name: field.name,
47
+ label: field.label,
48
+ multiline: field.multiline,
49
+ optional: field.optional,
50
+ };
51
+ }
52
+ function workflowOf(workflow, forms) {
53
+ const form = forms?.find((item) => item.workflowId === workflow.workflowId);
54
+ return {
55
+ workflowId: workflow.workflowId,
56
+ activeCount: workflow.activeCount,
57
+ waitingCount: workflow.waitingCount,
58
+ latestAt: workflow.latestAt,
59
+ inputs: (form?.fields ?? []).map(inputOf),
60
+ };
61
+ }
62
+ async function workflows(request) {
63
+ const model = await request.model();
64
+ if (model.service !== "up")
65
+ return serviceUnavailable;
66
+ return found({
67
+ workflows: model.workflows.map((workflow) => workflowOf(workflow, model.cardForms)),
68
+ });
69
+ }
70
+ function runQuery(url) {
71
+ const { searchParams } = url;
72
+ const cursor = searchParams.get("cursor");
73
+ return {
74
+ workflowId: searchParams.get("workflowId"),
75
+ state: searchParams.get("state"),
76
+ limit: Number(searchParams.get("limit") ?? runLimit.default),
77
+ after: cursor === null ? null : keyOf(cursor),
78
+ };
79
+ }
80
+ function selected(run, query) {
81
+ return ((query.workflowId === null || run.workflowId === query.workflowId) &&
82
+ (query.state === null || run.state === query.state) &&
83
+ (query.after === null || runOrder(run, query.after) > 0));
84
+ }
85
+ async function runs(request) {
86
+ const model = await request.model();
87
+ if (model.service !== "up")
88
+ return serviceUnavailable;
89
+ const query = runQuery(request.url);
90
+ const remaining = model.runs
91
+ .filter((run) => selected(run, query))
92
+ .toSorted(runOrder);
93
+ const page = remaining.slice(0, query.limit);
94
+ const last = page.at(-1);
95
+ return found({
96
+ runs: page.map(runOf),
97
+ nextCursor: remaining.length > page.length && last !== undefined
98
+ ? cursorOf(last)
99
+ : null,
100
+ });
101
+ }
102
+ function wholeNumber(value) {
103
+ return /^(0|[1-9]\d*)$/.test(value);
104
+ }
105
+ function withinLimit(value) {
106
+ const limit = Number(value);
107
+ return (wholeNumber(value) && limit >= runLimit.minimum && limit <= runLimit.maximum);
108
+ }
109
+ function unanswered(error) {
110
+ if (error instanceof Error && error.message === "service-unavailable")
111
+ return null;
112
+ throw error;
113
+ }
114
+ async function runReply(request, read) {
115
+ const model = await request
116
+ .run(read.path.get("runId") ?? "")
117
+ .catch(unanswered);
118
+ if (model === null)
119
+ return serviceUnavailable;
120
+ const [run] = model.runs;
121
+ return run === undefined ? unknownRun : found(read.body(run));
122
+ }
123
+ function runDetail(request, path) {
124
+ return runReply(request, {
125
+ path,
126
+ body: (run) => ({ run: runDetailOf(run) }),
127
+ });
128
+ }
129
+ function runEvents(request, path) {
130
+ const after = Number(request.url.searchParams.get("after") ?? 0);
131
+ return runReply(request, {
132
+ path,
133
+ body: (run) => ({ events: eventsAfter(run, after) }),
134
+ });
135
+ }
136
+ const runParameters = new Map([
137
+ ["workflowId", (value) => value !== ""],
138
+ ["state", (value) => runStates.some((state) => state === value)],
139
+ ["limit", withinLimit],
140
+ ["cursor", (value) => keyOf(value) !== null],
141
+ ]);
142
+ function fixed(reply) {
143
+ return { parameters: new Map(), reply: () => Promise.resolve(reply()) };
144
+ }
145
+ const routes = new Map([
146
+ ["/", fixed(apiIndex)],
147
+ ["/openapi.json", fixed(() => found(watchApiDocument))],
148
+ ["/service", { parameters: new Map(), reply: service }],
149
+ ["/workflows", { parameters: new Map(), reply: workflows }],
150
+ ["/runs", { parameters: runParameters, reply: runs }],
151
+ ["/runs/{runId}", { parameters: new Map(), reply: runDetail }],
152
+ [
153
+ "/runs/{runId}/events",
154
+ { parameters: new Map([["after", wholeNumber]]), reply: runEvents },
155
+ ],
156
+ [
157
+ "/stream",
158
+ { parameters: new Map(), reply: () => Promise.resolve("stream") },
159
+ ],
160
+ ]);
161
+ export const watchApiPaths = [...routes.keys()];
162
+ export function watchApiChange() {
163
+ const change = { updatedAt: new Date().toISOString() };
164
+ return `event: change\ndata: ${JSON.stringify(change)}\n\n`;
165
+ }
166
+ const placeholder = /^\{(\w+)\}$/;
167
+ function decoded(segment) {
168
+ try {
169
+ return decodeURIComponent(segment);
170
+ }
171
+ catch {
172
+ return "";
173
+ }
174
+ }
175
+ function matchedPath(template, path) {
176
+ const parts = template.split("/");
177
+ const segments = path.split("/");
178
+ if (parts.length !== segments.length)
179
+ return null;
180
+ const pairs = parts.map((part, index) => [part, segments[index] ?? ""]);
181
+ if (pairs.some(([part, segment]) => !placeholder.test(part) && part !== segment))
182
+ return null;
183
+ const values = pairs.flatMap(([part, segment]) => {
184
+ const name = placeholder.exec(part)?.[1];
185
+ return name === undefined ? [] : [[name, decoded(segment)]];
186
+ });
187
+ if (values.some(([, value]) => value === ""))
188
+ return null;
189
+ return new Map(values);
190
+ }
191
+ function routeOf(path) {
192
+ for (const [template, route] of routes) {
193
+ const values = matchedPath(template, path);
194
+ if (values !== null)
195
+ return { route, values };
196
+ }
197
+ return null;
198
+ }
199
+ function refusedParameter(url, parameters) {
200
+ const names = [...url.searchParams.keys()];
201
+ const refused = names.find((name, index) => names.indexOf(name) !== index ||
202
+ parameters.get(name)?.(url.searchParams.get(name) ?? "") !== true);
203
+ return refused ?? null;
204
+ }
205
+ export async function watchApiReply(request) {
206
+ const { pathname } = request.url;
207
+ if (pathname !== base && !pathname.startsWith(`${base}/`))
208
+ return null;
209
+ if (request.method !== "GET")
210
+ return {
211
+ ...problem(405, {
212
+ code: "method-not-allowed",
213
+ detail: "The API accepts only GET.",
214
+ }),
215
+ allow: "GET",
216
+ };
217
+ const matched = routeOf(pathname.slice(base.length) || "/");
218
+ if (matched === null)
219
+ return problem(404, {
220
+ code: "not-found",
221
+ detail: "The API has no resource at this path.",
222
+ });
223
+ const refused = refusedParameter(request.url, matched.route.parameters);
224
+ if (refused !== null)
225
+ return problem(400, {
226
+ code: "invalid-query",
227
+ detail: `The query parameter ${refused} is unknown, repeated or has an invalid value.`,
228
+ });
229
+ return matched.route.reply(request, matched.values);
230
+ }