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.
- package/README.md +100 -15
- package/dist/src/broker/actor/authority-health.d.ts +9 -0
- package/dist/src/broker/actor/authority-health.js +24 -0
- package/dist/src/broker/actor/authority-runtime.d.ts +4 -2
- package/dist/src/broker/actor/authority-runtime.js +29 -20
- package/dist/src/broker/actor/authority-session.d.ts +6 -19
- package/dist/src/broker/actor/authority-session.js +66 -88
- package/dist/src/broker/actor/authority-settings.d.ts +2 -1
- package/dist/src/broker/actor/authority-settings.js +4 -1
- package/dist/src/broker/actor/authority-upgrades.d.ts +5 -1
- package/dist/src/broker/actor/authority-upgrades.js +2 -1
- package/dist/src/broker/actor/authority-watchdog.d.ts +6 -2
- package/dist/src/broker/actor/authority-watchdog.js +31 -9
- package/dist/src/broker/actor/authority.js +3 -1
- package/dist/src/broker/actor/projection-memory.d.ts +43 -0
- package/dist/src/broker/actor/projection-memory.js +207 -0
- package/dist/src/broker/actor/runtime-parts.d.ts +5 -0
- package/dist/src/broker/actor/runtime-parts.js +8 -3
- package/dist/src/broker/authority-restore.js +1 -1
- package/dist/src/broker/effect-upgrade.d.ts +1 -0
- package/dist/src/broker/effect-upgrade.js +4 -1
- package/dist/src/cli/arguments.js +1 -0
- package/dist/src/cli/client.d.ts +3 -1
- package/dist/src/cli/client.js +4 -4
- package/dist/src/cli/command-requests.d.ts +30 -1
- package/dist/src/cli/command-requests.js +43 -2
- package/dist/src/cli/commands.js +57 -48
- package/dist/src/cli/config-notice.js +5 -2
- package/dist/src/cli/doctor-retention.d.ts +2 -0
- package/dist/src/cli/doctor-retention.js +93 -0
- package/dist/src/cli/doctor.js +5 -0
- package/dist/src/cli/init-prompt.js +1 -1
- package/dist/src/cli/mcp-server.js +4 -4
- package/dist/src/cli/mcp-tools.d.ts +14 -14
- package/dist/src/cli/mcp-tools.js +212 -50
- package/dist/src/cli/queue-arguments.d.ts +15 -0
- package/dist/src/cli/queue-arguments.js +31 -0
- package/dist/src/cli/queue-render.d.ts +3 -0
- package/dist/src/cli/queue-render.js +26 -0
- package/dist/src/cli/reload-render.d.ts +2 -0
- package/dist/src/cli/reload-render.js +12 -0
- package/dist/src/cli/render.js +37 -8
- package/dist/src/cli/report-github-reader.js +1 -1
- package/dist/src/cli/runtime-arguments.d.ts +17 -3
- package/dist/src/cli/runtime-arguments.js +23 -19
- package/dist/src/cli/toolchain.d.ts +1 -0
- package/dist/src/cli/toolchain.js +1 -0
- package/dist/src/cli/usage.d.ts +2 -2
- package/dist/src/cli/usage.js +40 -5
- package/dist/src/cli/watch-api-document.d.ts +430 -0
- package/dist/src/cli/watch-api-document.js +291 -0
- package/dist/src/cli/watch-api-run.d.ts +56 -0
- package/dist/src/cli/watch-api-run.js +112 -0
- package/dist/src/cli/watch-api.d.ts +17 -0
- package/dist/src/cli/watch-api.js +230 -0
- package/dist/src/cli/watch-header-view.d.ts +1 -0
- package/dist/src/cli/watch-header-view.js +9 -1
- package/dist/src/cli/watch-model.d.ts +1 -0
- package/dist/src/cli/watch-model.js +3 -0
- package/dist/src/cli/watch-page.js +3 -1
- package/dist/src/cli/watch-reads.d.ts +1 -0
- package/dist/src/cli/watch-reads.js +6 -5
- package/dist/src/cli/watch-render.d.ts +1 -0
- package/dist/src/cli/watch-server.js +18 -4
- package/dist/src/cli.js +50 -43
- package/dist/src/configuration-display.js +2 -0
- package/dist/src/configuration-schema.d.ts +5 -0
- package/dist/src/daemon/claude-recovery.js +13 -9
- package/dist/src/daemon/claude-worker.d.ts +3 -3
- package/dist/src/daemon/claude-worker.js +10 -11
- package/dist/src/daemon/daemon-adapters.d.ts +7 -0
- package/dist/src/daemon/daemon-adapters.js +7 -3
- package/dist/src/daemon/daemon-loops.d.ts +2 -0
- package/dist/src/daemon/daemon-loops.js +17 -11
- package/dist/src/daemon/daemon.js +71 -60
- package/dist/src/daemon/github-attach.js +77 -4
- package/dist/src/daemon/github-calls.js +1 -1
- package/dist/src/daemon/github-methods.js +6 -2
- package/dist/src/daemon/issue-queue.d.ts +3 -0
- package/dist/src/daemon/issue-queue.js +66 -73
- package/dist/src/daemon/merge-turns.d.ts +10 -0
- package/dist/src/daemon/merge-turns.js +37 -0
- package/dist/src/daemon/queue-control.d.ts +13 -0
- package/dist/src/daemon/queue-control.js +86 -0
- package/dist/src/daemon/queue-journal.d.ts +20 -0
- package/dist/src/daemon/queue-journal.js +89 -0
- package/dist/src/daemon/record-archive.d.ts +19 -0
- package/dist/src/daemon/record-archive.js +82 -0
- package/dist/src/daemon/record-sweep.d.ts +24 -0
- package/dist/src/daemon/record-sweep.js +147 -0
- package/dist/src/daemon/retrying-turn.js +4 -4
- package/dist/src/daemon/settings-reload.d.ts +26 -0
- package/dist/src/daemon/settings-reload.js +82 -0
- package/dist/src/daemon/turn-details.d.ts +4 -0
- package/dist/src/daemon/turn-details.js +33 -0
- package/dist/src/daemon/worker-profile.d.ts +1 -0
- package/dist/src/daemon/worker-profile.js +4 -0
- package/dist/src/daemon/workflow-generations.d.ts +7 -4
- package/dist/src/daemon/workflow-generations.js +22 -14
- package/dist/src/daemon/workflow-reload.d.ts +41 -0
- package/dist/src/daemon/workflow-reload.js +244 -0
- package/dist/src/daemon/workflow-replay.d.ts +2 -1
- package/dist/src/daemon/workflow-replay.js +7 -3
- package/dist/src/doctor-report.d.ts +2 -2
- package/dist/src/doctor-report.js +2 -0
- package/dist/src/private-config.schema.json +31 -0
- package/dist/src/project-settings.d.ts +15 -1
- package/dist/src/project-settings.js +33 -1
- package/dist/src/queue-state.d.ts +26 -0
- package/dist/src/queue-state.js +34 -0
- package/dist/src/record-retention.d.ts +46 -0
- package/dist/src/record-retention.js +81 -0
- package/dist/src/service/active-tickets.js +5 -0
- package/dist/src/service/dispatcher.d.ts +4 -0
- package/dist/src/service/dispatcher.js +33 -0
- package/dist/src/service/queue-handlers.d.ts +4 -0
- package/dist/src/service/queue-handlers.js +32 -0
- package/dist/src/service/registry.d.ts +28 -2
- package/dist/src/service/registry.js +48 -0
- package/dist/src/service/replies.d.ts +16 -9
- package/dist/src/service/replies.js +5 -2
- package/dist/src/service/run-handlers.js +15 -10
- package/dist/src/service/service.d.ts +7 -3
- package/dist/src/service/service.js +16 -31
- package/dist/src/service/worker-totals.d.ts +4 -0
- package/dist/src/service/worker-totals.js +7 -0
- package/dist/src/service/workflow-reload.d.ts +35 -0
- package/dist/src/service/workflow-reload.js +52 -0
- package/dist/src/storage/decision-snapshots.d.ts +1 -2
- package/dist/src/storage/decision-snapshots.js +1 -7
- package/dist/src/storage/domain-changes.d.ts +1 -1
- package/dist/src/storage/domain-changes.js +1 -0
- package/dist/src/storage/domain-schema.d.ts +4 -1
- package/dist/src/storage/domain-schema.js +6 -2
- package/dist/src/storage/effect-schema.d.ts +1 -0
- package/dist/src/storage/effect-schema.js +4 -0
- package/dist/src/storage/projection-changes.d.ts +12 -0
- package/dist/src/storage/projection-changes.js +194 -0
- package/dist/src/storage/projection-lookups.d.ts +7 -0
- package/dist/src/storage/projection-lookups.js +30 -0
- package/dist/src/workflow-declaration.d.ts +1 -0
- package/dist/src/workflow-declaration.js +3 -0
- package/dist/tools/compile-workflows.js +3 -0
- package/dist/tools/project-configuration.d.ts +7 -2
- package/dist/tools/project-configuration.js +30 -1
- package/dist/tools/self-host-processes.js +37 -13
- package/dist/tools/self-host-project.d.ts +2 -0
- package/dist/tools/self-host-project.js +21 -3
- package/dist/tools/self-host.js +34 -24
- package/dist/tools/workflow-candidate-program.d.ts +4 -2
- package/dist/tools/workflow-candidate-program.js +30 -11
- package/dist/tools/workflow-candidate-source.d.ts +1 -0
- package/dist/tools/workflow-candidate-source.js +11 -3
- package/package.json +2 -2
- package/templates/develop/README.md +81 -4
- package/templates/develop/develop/flow-tests.ts +1 -1
- package/templates/develop/develop/interruption-tests.ts +157 -0
- package/templates/develop/develop/local.ts +1 -0
- package/templates/develop/develop/option-tests.ts +138 -9
- package/templates/develop/develop/red-green.ts +55 -0
- package/templates/develop/develop/report.ts +31 -11
- package/templates/develop/develop/round.ts +1 -0
- package/templates/develop/develop/scenarios.ts +27 -0
- package/templates/develop/develop/turn.ts +26 -6
- package/templates/develop/develop.declaration.json +1 -1
- package/templates/develop/develop.test.ts +4 -0
- package/templates/develop/template.json +29 -2
|
@@ -13,8 +13,13 @@ import { worldDirectory } from "./host-environment.js";
|
|
|
13
13
|
import { pinnedWorkflowHandler } from "./workflow-replay.js";
|
|
14
14
|
import { scheduledDeclarations } from "./workflow-schedules.js";
|
|
15
15
|
import { scheduleConnection } from "./schedule-control.js";
|
|
16
|
+
import { queueConnection } from "./queue-control.js";
|
|
16
17
|
import { daemonLoops } from "./daemon-loops.js";
|
|
17
|
-
import {
|
|
18
|
+
import { authorityRuns, recordRetention } from "./record-sweep.js";
|
|
19
|
+
import { turnDetailsPlace } from "./turn-details.js";
|
|
20
|
+
import { defaultRetention } from "../record-retention.js";
|
|
21
|
+
import { clovingVersion } from "../cli/toolchain.js";
|
|
22
|
+
import { daemonRegistrations, queueWorkflowId, workflowReload, } from "./workflow-reload.js";
|
|
18
23
|
export const turnLeaseMilliseconds = 4 * 3_600_000;
|
|
19
24
|
export const loopMilliseconds = 250;
|
|
20
25
|
export const bridgeTiming = {
|
|
@@ -66,45 +71,6 @@ async function hosted(settings, hosting) {
|
|
|
66
71
|
},
|
|
67
72
|
};
|
|
68
73
|
}
|
|
69
|
-
async function daemonRegistrations(settings) {
|
|
70
|
-
const conflict = settings.workflowRefusals?.find((refusal) => refusal.codes.includes("queue-workflow-conflict"));
|
|
71
|
-
if (conflict !== undefined)
|
|
72
|
-
return {
|
|
73
|
-
table: [],
|
|
74
|
-
admitted: {
|
|
75
|
-
kind: "rejected",
|
|
76
|
-
detail: `queue-workflow-conflict: ${conflict.scope} ${conflict.label}`,
|
|
77
|
-
},
|
|
78
|
-
};
|
|
79
|
-
const table = ticketRegistrationTable({
|
|
80
|
-
repository: settings.repository,
|
|
81
|
-
command: settings.ticketCommand,
|
|
82
|
-
nextCommand: settings.nextCommand,
|
|
83
|
-
githubCommand: settings.githubCommand,
|
|
84
|
-
}, settings.declarations);
|
|
85
|
-
const admitted = await ticketRegistrations(settings.manifests, {
|
|
86
|
-
table,
|
|
87
|
-
commands: settings.commands ?? {},
|
|
88
|
-
...(settings.bundleDigest === undefined
|
|
89
|
-
? {}
|
|
90
|
-
: { bundleDigest: settings.bundleDigest }),
|
|
91
|
-
});
|
|
92
|
-
return {
|
|
93
|
-
table,
|
|
94
|
-
admitted: admitted.kind === "rejected"
|
|
95
|
-
? admitted
|
|
96
|
-
: {
|
|
97
|
-
...admitted,
|
|
98
|
-
registrations: admitted.registrations.map((registration) => ({
|
|
99
|
-
...registration,
|
|
100
|
-
...(settings.queueMilliseconds !== undefined &&
|
|
101
|
-
registration.workflowId === queueWorkflowId(settings)
|
|
102
|
-
? { admission: settings.queueLanes ?? 1, queue: true }
|
|
103
|
-
: {}),
|
|
104
|
-
})),
|
|
105
|
-
},
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
74
|
export async function startDaemon(givenSettings) {
|
|
109
75
|
const repository = await realpath(givenSettings.repository);
|
|
110
76
|
const scheduled = scheduledDeclarations(givenSettings, {
|
|
@@ -119,16 +85,56 @@ export async function startDaemon(givenSettings) {
|
|
|
119
85
|
repository,
|
|
120
86
|
});
|
|
121
87
|
}
|
|
88
|
+
function controlConnections() {
|
|
89
|
+
return { schedules: scheduleConnection(), queues: queueConnection() };
|
|
90
|
+
}
|
|
91
|
+
function retentionReads(settings, ports) {
|
|
92
|
+
return turnDetailsPlace({
|
|
93
|
+
directory: settings.directory,
|
|
94
|
+
retention: settings.retention ?? defaultRetention,
|
|
95
|
+
runs: async (runIds) => authorityRuns(await ports)(runIds),
|
|
96
|
+
now: settings.retentionClock ?? Date.now,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
function rejectedOpening(detail) {
|
|
100
|
+
return {
|
|
101
|
+
kind: "refused",
|
|
102
|
+
reason: detail.startsWith("queue-workflow-conflict")
|
|
103
|
+
? "queue-workflow-conflict"
|
|
104
|
+
: "workflow-rejected",
|
|
105
|
+
detail,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function workflowOptions(settings, parts) {
|
|
109
|
+
const { reloadCommand, loadBundle } = settings;
|
|
110
|
+
const { generations, turnSettings } = parts;
|
|
111
|
+
return {
|
|
112
|
+
workflowBundles: {
|
|
113
|
+
directory: join(settings.directory, "workflow-bundles"),
|
|
114
|
+
repository: settings.repository,
|
|
115
|
+
},
|
|
116
|
+
workflowRefusals: settings.workflowRefusals ?? [],
|
|
117
|
+
retainedWorkflow: generations.available,
|
|
118
|
+
...(settings.bundleDigest === undefined
|
|
119
|
+
? {}
|
|
120
|
+
: { bundleDigest: settings.bundleDigest }),
|
|
121
|
+
...(reloadCommand === undefined || loadBundle === undefined
|
|
122
|
+
? {}
|
|
123
|
+
: {
|
|
124
|
+
reload: workflowReload({
|
|
125
|
+
...settings,
|
|
126
|
+
reloadCommand,
|
|
127
|
+
loadBundle,
|
|
128
|
+
generations,
|
|
129
|
+
turnSettings,
|
|
130
|
+
}),
|
|
131
|
+
}),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
122
134
|
async function openDaemon(settings) {
|
|
123
135
|
const { table, admitted } = await daemonRegistrations(settings);
|
|
124
136
|
if (admitted.kind === "rejected")
|
|
125
|
-
return
|
|
126
|
-
kind: "refused",
|
|
127
|
-
reason: admitted.detail.startsWith("queue-workflow-conflict")
|
|
128
|
-
? "queue-workflow-conflict"
|
|
129
|
-
: "workflow-rejected",
|
|
130
|
-
detail: admitted.detail,
|
|
131
|
-
};
|
|
137
|
+
return rejectedOpening(admitted.detail);
|
|
132
138
|
const given = Promise.withResolvers();
|
|
133
139
|
const generations = workflowGenerations({
|
|
134
140
|
...settings,
|
|
@@ -139,23 +145,21 @@ async function openDaemon(settings) {
|
|
|
139
145
|
const { worker, github, runs, handoff, commands } = resources;
|
|
140
146
|
await mkdir(join(settings.directory, "state"), { recursive: true });
|
|
141
147
|
await mkdir(settings.runtimeDirectory, { recursive: true, mode: 0o700 });
|
|
142
|
-
const
|
|
148
|
+
const controls = controlConnections();
|
|
143
149
|
const opening = await startService({
|
|
144
|
-
|
|
150
|
+
version: clovingVersion,
|
|
151
|
+
schedule: controls.schedules.request,
|
|
152
|
+
queue: controls.queues.port,
|
|
145
153
|
...(settings.queueDailySpendLimitUsd === undefined
|
|
146
154
|
? {}
|
|
147
155
|
: { queueDailySpendLimitUsd: settings.queueDailySpendLimitUsd }),
|
|
148
156
|
workerHarness: worker.harness,
|
|
157
|
+
turnDetails: retentionReads(settings, given.promise),
|
|
149
158
|
commandEffects: commandEffects(join(settings.directory, "commands")),
|
|
150
159
|
stateDirectory: join(settings.directory, "state"),
|
|
151
160
|
runtimeDirectory: settings.runtimeDirectory,
|
|
152
|
-
|
|
153
|
-
directory: join(settings.directory, "workflow-bundles"),
|
|
154
|
-
repository: settings.repository,
|
|
155
|
-
},
|
|
156
|
-
workflowRefusals: settings.workflowRefusals ?? [],
|
|
161
|
+
...workflowOptions(settings, { ...resources, generations }),
|
|
157
162
|
workflows: admitted.registrations,
|
|
158
|
-
retainedWorkflow: generations.available,
|
|
159
163
|
delivery: deliveryRouter({
|
|
160
164
|
runs,
|
|
161
165
|
effects: { claude: worker, github, handoff, command: commands },
|
|
@@ -170,11 +174,11 @@ async function openDaemon(settings) {
|
|
|
170
174
|
await closeWorkers(resources);
|
|
171
175
|
return opening;
|
|
172
176
|
}
|
|
173
|
-
const running = { ...resources, service: opening.service
|
|
177
|
+
const running = { ...resources, ...controls, service: opening.service };
|
|
174
178
|
await recoverWorkers(running, given.promise);
|
|
175
179
|
return {
|
|
176
180
|
kind: "started",
|
|
177
|
-
daemon: await runningDaemon(settings, running),
|
|
181
|
+
daemon: await runningDaemon(settings, { ...running, ports: given.promise }),
|
|
178
182
|
};
|
|
179
183
|
}
|
|
180
184
|
async function recoverWorkers(resources, ports) {
|
|
@@ -200,13 +204,22 @@ async function closeWorkers(resources) {
|
|
|
200
204
|
}
|
|
201
205
|
async function runningDaemon(settings, resources) {
|
|
202
206
|
const { service, github } = resources;
|
|
207
|
+
const retention = await recordRetention({
|
|
208
|
+
directory: settings.directory,
|
|
209
|
+
retention: settings.retention ?? defaultRetention,
|
|
210
|
+
runs: authorityRuns(await resources.ports),
|
|
211
|
+
now: settings.retentionClock ?? Date.now,
|
|
212
|
+
log: (line) => process.stderr.write(line),
|
|
213
|
+
});
|
|
203
214
|
await github.scan();
|
|
204
215
|
const loops = await daemonLoops({
|
|
205
216
|
...settings,
|
|
206
217
|
scheduleConnection: resources.schedules,
|
|
218
|
+
queueConnection: resources.queues,
|
|
207
219
|
submit: service.worker.submit,
|
|
208
220
|
queueWorkflowId: queueWorkflowId(settings),
|
|
209
221
|
}).catch(async (error) => {
|
|
222
|
+
await retention.stop();
|
|
210
223
|
await closeWorkers(resources);
|
|
211
224
|
await service.close();
|
|
212
225
|
throw error;
|
|
@@ -214,12 +227,10 @@ async function runningDaemon(settings, resources) {
|
|
|
214
227
|
return {
|
|
215
228
|
socket: service.socket,
|
|
216
229
|
close: async () => {
|
|
230
|
+
await retention.stop();
|
|
217
231
|
await loops.stop();
|
|
218
232
|
await closeWorkers(resources);
|
|
219
233
|
await service.close();
|
|
220
234
|
},
|
|
221
235
|
};
|
|
222
236
|
}
|
|
223
|
-
function queueWorkflowId(settings) {
|
|
224
|
-
return settings.declarations?.find((declaration) => declaration.queue === true)?.workflowId;
|
|
225
|
-
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { rm } from "node:fs/promises";
|
|
1
|
+
import { rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { copiedAttachment, openedAttachment } from "./attachment-file.js";
|
|
4
4
|
import { attachmentFolder } from "./attachments.js";
|
|
@@ -6,6 +6,7 @@ import { repositoryName } from "./github-issue-methods.js";
|
|
|
6
6
|
import { issuePrivacy, operatorPrivateTexts } from "./github-issue-privacy.js";
|
|
7
7
|
import { field, repositoryPath, text, } from "./github-operation.js";
|
|
8
8
|
import { githubRead } from "./github-retry.js";
|
|
9
|
+
import { inTurn } from "./merge-turns.js";
|
|
9
10
|
function isList(value) {
|
|
10
11
|
return Array.isArray(value);
|
|
11
12
|
}
|
|
@@ -15,6 +16,12 @@ function validName(name) {
|
|
|
15
16
|
function validRelease(release) {
|
|
16
17
|
return /^[a-z][a-z0-9-]{0,63}$/.test(release) && !/^v\d/.test(release);
|
|
17
18
|
}
|
|
19
|
+
function placeOf(operation) {
|
|
20
|
+
const place = operation.payload["place"] ?? "comment";
|
|
21
|
+
if (place !== "comment" && place !== "description")
|
|
22
|
+
throw new Error("invalid-payload");
|
|
23
|
+
return place;
|
|
24
|
+
}
|
|
18
25
|
function attachmentOf(operation) {
|
|
19
26
|
const number = operation.payload["number"];
|
|
20
27
|
const name = text(operation, "name");
|
|
@@ -24,7 +31,7 @@ function attachmentOf(operation) {
|
|
|
24
31
|
throw new Error("invalid-payload");
|
|
25
32
|
if (!validName(name) || !validRelease(release) || comment.length > 4000)
|
|
26
33
|
throw new Error("invalid-payload");
|
|
27
|
-
return { number, name, release, comment };
|
|
34
|
+
return { number, name, release, comment, place: placeOf(operation) };
|
|
28
35
|
}
|
|
29
36
|
function attachedFile(operation, name) {
|
|
30
37
|
return openedAttachment({
|
|
@@ -129,6 +136,73 @@ async function commented(operation, attachment) {
|
|
|
129
136
|
`body=${body}`,
|
|
130
137
|
]);
|
|
131
138
|
}
|
|
139
|
+
function withSection(body, marks) {
|
|
140
|
+
const from = body.indexOf(marks.start);
|
|
141
|
+
const to = from < 0 ? -1 : body.indexOf(marks.end, from);
|
|
142
|
+
if (to >= 0)
|
|
143
|
+
return (body.slice(0, from) + marks.section + body.slice(to + marks.end.length));
|
|
144
|
+
const kept = body.trimEnd();
|
|
145
|
+
return kept === "" ? marks.section : `${kept}\n\n${marks.section}`;
|
|
146
|
+
}
|
|
147
|
+
async function pullBody(operation, number) {
|
|
148
|
+
const pull = JSON.parse(await githubRead(operation, [
|
|
149
|
+
"api",
|
|
150
|
+
`${repositoryPath}/pulls/${String(number)}`,
|
|
151
|
+
]));
|
|
152
|
+
const body = field(pull, "body");
|
|
153
|
+
return typeof body === "string" ? body : "";
|
|
154
|
+
}
|
|
155
|
+
function attachedUrl(body, marks) {
|
|
156
|
+
const from = body.indexOf(marks.start);
|
|
157
|
+
const to = from < 0 ? -1 : body.indexOf(marks.end, from);
|
|
158
|
+
const url = to < 0 ? "" : (body.slice(from, to).trimEnd().split("\n").at(-1) ?? "");
|
|
159
|
+
if (!/^https:\/\/github\.com\/user-attachments\/assets\/[\w-]+$/.test(url))
|
|
160
|
+
throw new Error("github-call-failed");
|
|
161
|
+
return url;
|
|
162
|
+
}
|
|
163
|
+
async function described(operation, attachment) {
|
|
164
|
+
const { number, name } = attachment;
|
|
165
|
+
const start = `<!-- cloving-attachment:${String(number)}:${name} -->`;
|
|
166
|
+
const end = `<!-- /cloving-attachment:${String(number)}:${name} -->`;
|
|
167
|
+
const section = `${start}\n${attachment.comment}\n\n\n${end}`;
|
|
168
|
+
const copy = join(operation.folder, name);
|
|
169
|
+
const text = join(operation.folder, "body.md");
|
|
170
|
+
const turn = {
|
|
171
|
+
key: `${operation.settings.repository}#pull-${String(number)}`,
|
|
172
|
+
signal: operation.signal,
|
|
173
|
+
};
|
|
174
|
+
try {
|
|
175
|
+
await copiedAttachment(attachment.file, copy);
|
|
176
|
+
await inTurn(turn, async () => {
|
|
177
|
+
const current = await pullBody(operation, number);
|
|
178
|
+
await writeFile(text, withSection(current, { start, end, section }), "utf8");
|
|
179
|
+
await operation.calls.gh([
|
|
180
|
+
"pr",
|
|
181
|
+
"edit",
|
|
182
|
+
String(number),
|
|
183
|
+
"--body-file",
|
|
184
|
+
text,
|
|
185
|
+
"--attach",
|
|
186
|
+
copy,
|
|
187
|
+
]);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
await rm(copy, { force: true });
|
|
192
|
+
await rm(text, { force: true });
|
|
193
|
+
}
|
|
194
|
+
return attachedUrl(await pullBody(operation, number), {
|
|
195
|
+
start,
|
|
196
|
+
end,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
async function delivered(operation, attachment) {
|
|
200
|
+
if (attachment.place === "description")
|
|
201
|
+
return await described(operation, attachment);
|
|
202
|
+
const url = await uploaded(operation, attachment);
|
|
203
|
+
await commented(operation, { ...attachment, url });
|
|
204
|
+
return url;
|
|
205
|
+
}
|
|
132
206
|
export async function attachAsset(operation) {
|
|
133
207
|
const attachment = attachmentOf(operation);
|
|
134
208
|
operatorPrivateTexts(operation.settings);
|
|
@@ -139,8 +213,7 @@ export async function attachAsset(operation) {
|
|
|
139
213
|
});
|
|
140
214
|
const file = await attachedFile(operation, attachment.name);
|
|
141
215
|
try {
|
|
142
|
-
const url = await
|
|
143
|
-
await commented(operation, { ...attachment, url });
|
|
216
|
+
const url = await delivered(operation, { ...attachment, file });
|
|
144
217
|
return { number: attachment.number, url };
|
|
145
218
|
}
|
|
146
219
|
finally {
|
|
@@ -24,7 +24,7 @@ function failureDetail(error, place) {
|
|
|
24
24
|
const text = typeof stderr === "string" ? stderr : "";
|
|
25
25
|
return {
|
|
26
26
|
detail: githubText(text, place).slice(-2_000),
|
|
27
|
-
status: Number(/\
|
|
27
|
+
status: Number(/\bHTTP (\d{3})\b/.exec(text)?.[1]),
|
|
28
28
|
};
|
|
29
29
|
}
|
|
30
30
|
export function githubText(text, settings) {
|
|
@@ -5,6 +5,7 @@ import { findingsOf, scoreOf, } from "./github-calls.js";
|
|
|
5
5
|
import { attachAsset } from "./github-attach.js";
|
|
6
6
|
import { closeIssue, partialIssue } from "./github-ticket-status.js";
|
|
7
7
|
import { merge } from "./github-merge.js";
|
|
8
|
+
import { inMergeTurn } from "./merge-turns.js";
|
|
8
9
|
import { captureDraft, createIssue, discard, repositoryName, } from "./github-issue-methods.js";
|
|
9
10
|
import { existingTree, defaultBranch, changeChecked, field, githubTiming, repositoryPath, text, } from "./github-operation.js";
|
|
10
11
|
import { workTree } from "./work-tree.js";
|
|
@@ -277,10 +278,13 @@ export const methods = new Map([
|
|
|
277
278
|
["wait-review", waitReview],
|
|
278
279
|
[
|
|
279
280
|
"merge",
|
|
280
|
-
(operation) =>
|
|
281
|
+
(operation) => inMergeTurn({
|
|
282
|
+
repository: operation.settings.repository,
|
|
283
|
+
signal: operation.signal,
|
|
284
|
+
}, () => merge(operation, {
|
|
281
285
|
number: integer(operation, "number"),
|
|
282
286
|
commit: text(operation, "commit"),
|
|
283
287
|
check: mergeCheck(operation),
|
|
284
|
-
}),
|
|
288
|
+
})),
|
|
285
289
|
],
|
|
286
290
|
]);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type QueuePort } from "../queue-state.ts";
|
|
1
2
|
interface QueueSettings {
|
|
2
3
|
readonly directory: string;
|
|
3
4
|
readonly runtimeDirectory: string;
|
|
@@ -8,5 +9,7 @@ interface QueueSettings {
|
|
|
8
9
|
}
|
|
9
10
|
export declare function issueQueue(settings: QueueSettings): {
|
|
10
11
|
readonly stop: () => Promise<void>;
|
|
12
|
+
readonly port: QueuePort;
|
|
13
|
+
readonly ready: Promise<void>;
|
|
11
14
|
};
|
|
12
15
|
export {};
|
|
@@ -1,57 +1,14 @@
|
|
|
1
1
|
import { queueStatus, spendNotice } from "./queue-status.js";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { readFile,
|
|
3
|
+
import { readFile, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { setTimeout as delay } from "node:timers/promises";
|
|
6
6
|
import { send } from "../cli/client.js";
|
|
7
7
|
import { record } from "../protocol.js";
|
|
8
8
|
import { runIdentity } from "../service/submission-handler.js";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const held = record(value);
|
|
13
|
-
if (typeof held?.["mark"] !== "string" ||
|
|
14
|
-
typeof held["startedAt"] !== "number" ||
|
|
15
|
-
!Number.isFinite(held["startedAt"]) ||
|
|
16
|
-
held["startedAt"] <= 0)
|
|
17
|
-
throw new Error("invalid-queue-commands");
|
|
18
|
-
return { mark: held["mark"], startedAt: held["startedAt"] };
|
|
19
|
-
}
|
|
20
|
-
function identifiers(value) {
|
|
21
|
-
if (!Array.isArray(value) ||
|
|
22
|
-
!value.every((id) => typeof id === "string" && id.length > 0))
|
|
23
|
-
throw new Error("invalid-queue-commands");
|
|
24
|
-
return value.map(String);
|
|
25
|
-
}
|
|
26
|
-
async function journal(directory) {
|
|
27
|
-
const file = join(directory, "queue-commands.json");
|
|
28
|
-
const text = await readFile(file, "utf8").catch((error) => {
|
|
29
|
-
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
30
|
-
return "[]";
|
|
31
|
-
throw error;
|
|
32
|
-
});
|
|
33
|
-
const value = JSON.parse(text);
|
|
34
|
-
const saved = record(value);
|
|
35
|
-
const commands = new Set(identifiers(Array.isArray(value) ? value : saved?.["commands"]));
|
|
36
|
-
const failures = Array.isArray(value) ? [] : identifiers(saved?.["failures"]);
|
|
37
|
-
if (failures.length > 3)
|
|
38
|
-
throw new Error("invalid-queue-commands");
|
|
39
|
-
const pending = {
|
|
40
|
-
commands,
|
|
41
|
-
failures,
|
|
42
|
-
retryCommandId: undefined,
|
|
43
|
-
selfUpdateHold: savedHold(saved?.["selfUpdateHold"]),
|
|
44
|
-
save: async () => {
|
|
45
|
-
await writeFile(`${file}.tmp`, JSON.stringify({
|
|
46
|
-
commands: [...commands],
|
|
47
|
-
failures,
|
|
48
|
-
selfUpdateHold: pending.selfUpdateHold,
|
|
49
|
-
}) + "\n", { mode: 0o600, flush: true });
|
|
50
|
-
await rename(`${file}.tmp`, file);
|
|
51
|
-
},
|
|
52
|
-
};
|
|
53
|
-
return pending;
|
|
54
|
-
}
|
|
9
|
+
import { queueFailureLimit } from "../queue-state.js";
|
|
10
|
+
import { queueJournal } from "./queue-journal.js";
|
|
11
|
+
import { queueControlPort, queueStopped, } from "./queue-control.js";
|
|
55
12
|
function terminal(run) {
|
|
56
13
|
return ["completed", "failed", "cancelled"].includes(String(run["state"]));
|
|
57
14
|
}
|
|
@@ -111,11 +68,18 @@ function handedOff(run) {
|
|
|
111
68
|
outcome?.["kind"] === "handoff_requested" &&
|
|
112
69
|
outcome["issueUpdate"] !== "failed");
|
|
113
70
|
}
|
|
114
|
-
|
|
71
|
+
function limited(pending) {
|
|
72
|
+
return pending.failures.length >= queueFailureLimit;
|
|
115
73
|
}
|
|
116
|
-
function
|
|
117
|
-
|
|
118
|
-
|
|
74
|
+
function stopNotice() {
|
|
75
|
+
let noticed = false;
|
|
76
|
+
return (pending) => {
|
|
77
|
+
const stopped = limited(pending);
|
|
78
|
+
if (stopped && !noticed)
|
|
79
|
+
process.stderr.write(`Queue stopped: 3 runs in a row ended without handoff: ${pending.failures.join(", ")}\n`);
|
|
80
|
+
noticed = stopped;
|
|
81
|
+
return stopped;
|
|
82
|
+
};
|
|
119
83
|
}
|
|
120
84
|
async function retired(pending, ending) {
|
|
121
85
|
const { commandId, run } = ending;
|
|
@@ -125,14 +89,12 @@ async function retired(pending, ending) {
|
|
|
125
89
|
else
|
|
126
90
|
pending.failures.push(runIdentity(commandId));
|
|
127
91
|
await pending.save();
|
|
128
|
-
|
|
129
|
-
if (handedOff(run))
|
|
92
|
+
if (handedOff(run) || limited(pending))
|
|
130
93
|
return;
|
|
131
94
|
const outcome = record(run["outcome"]);
|
|
132
95
|
process.stderr.write(`Queue run ended ${String(outcome?.["kind"] ?? run["state"])}${outcome?.["issueUpdate"] === "failed" ? "; issueUpdate: failed" : ""}: ${runIdentity(commandId)}; continuing\n`);
|
|
133
96
|
}
|
|
134
97
|
async function completed(settings, pending) {
|
|
135
|
-
checkFailureLimit(pending);
|
|
136
98
|
let pendingRuns = 0;
|
|
137
99
|
const endings = [];
|
|
138
100
|
const tracked = [...pending.commands].filter((commandId) => commandId !== pending.retryCommandId);
|
|
@@ -145,7 +107,8 @@ async function completed(settings, pending) {
|
|
|
145
107
|
endings.sort((first, second) => Number(first.run["terminalSequence"] ?? first.run["latestSequence"] ?? 0) -
|
|
146
108
|
Number(second.run["terminalSequence"] ?? second.run["latestSequence"] ?? 0));
|
|
147
109
|
for (const ending of endings)
|
|
148
|
-
|
|
110
|
+
if (!limited(pending))
|
|
111
|
+
await retired(pending, ending);
|
|
149
112
|
return pendingRuns;
|
|
150
113
|
}
|
|
151
114
|
async function submit(settings, pending) {
|
|
@@ -230,21 +193,49 @@ async function admission(settings, attempt) {
|
|
|
230
193
|
const file = join(settings.directory, "queue-submission");
|
|
231
194
|
await writeFile(file, "", { mode: 0o600 });
|
|
232
195
|
try {
|
|
233
|
-
if (!(await attempt.holding()) &&
|
|
196
|
+
if (!(await attempt.holding()) &&
|
|
197
|
+
!attempt.signal.aborted &&
|
|
198
|
+
attempt.pending.drain === undefined)
|
|
234
199
|
await submit(settings, attempt.pending);
|
|
235
200
|
}
|
|
236
201
|
finally {
|
|
237
202
|
await rm(file, { force: true });
|
|
238
203
|
}
|
|
239
204
|
}
|
|
240
|
-
async function
|
|
241
|
-
const
|
|
205
|
+
async function drained(pending, busy) {
|
|
206
|
+
const { drain } = pending;
|
|
207
|
+
if (drain === undefined || drain.drainedAt !== null || busy > 0)
|
|
208
|
+
return;
|
|
209
|
+
pending.drain = { ...drain, drainedAt: Date.now() };
|
|
210
|
+
await pending.save();
|
|
211
|
+
process.stderr.write("Queue drained\n");
|
|
212
|
+
}
|
|
213
|
+
async function admissible(settings, checks) {
|
|
214
|
+
const { pending, stop } = checks;
|
|
215
|
+
const pendingRuns = stop(pending) ? 0 : await completed(settings, pending);
|
|
216
|
+
if (stop(pending) && pending.drain === undefined)
|
|
217
|
+
return false;
|
|
218
|
+
const status = await queueStatus(settings);
|
|
219
|
+
await drained(pending, status.busy);
|
|
220
|
+
if (stop(pending) || pending.drain !== undefined)
|
|
221
|
+
return false;
|
|
222
|
+
const spend = checks.notice(status);
|
|
223
|
+
const updating = await checks.update(status.busy);
|
|
224
|
+
const lanes = settings.lanes ?? 1;
|
|
225
|
+
if (Math.max(pendingRuns, status.busy) >= lanes || status.busy < pendingRuns)
|
|
226
|
+
return false;
|
|
227
|
+
return !updating && !spend;
|
|
228
|
+
}
|
|
229
|
+
async function cycle(settings, run) {
|
|
230
|
+
const { signal, pending } = run;
|
|
242
231
|
if (settings.resetQueue === true) {
|
|
243
232
|
pending.failures.length = 0;
|
|
233
|
+
pending.drain = undefined;
|
|
244
234
|
await pending.save();
|
|
245
235
|
}
|
|
246
236
|
const update = updateHold(settings.directory, pending);
|
|
247
237
|
const notice = spendNotice();
|
|
238
|
+
const stop = stopNotice();
|
|
248
239
|
const holding = async (activeRuns = 0) => {
|
|
249
240
|
const updating = await update(activeRuns);
|
|
250
241
|
const spend = notice(await queueStatus(settings));
|
|
@@ -257,15 +248,7 @@ async function cycle(settings, signal) {
|
|
|
257
248
|
await delay(settings.intervalMilliseconds, undefined, { signal }).catch(() => undefined);
|
|
258
249
|
if (stopped())
|
|
259
250
|
return;
|
|
260
|
-
|
|
261
|
-
const lanes = settings.lanes ?? 1;
|
|
262
|
-
const status = await queueStatus(settings);
|
|
263
|
-
const spend = notice(status);
|
|
264
|
-
const updating = await update(status.busy);
|
|
265
|
-
if (Math.max(pendingRuns, status.busy) >= lanes ||
|
|
266
|
-
status.busy < pendingRuns)
|
|
267
|
-
continue;
|
|
268
|
-
if (updating || spend)
|
|
251
|
+
if (!(await admissible(settings, { pending, stop, notice, update })))
|
|
269
252
|
continue;
|
|
270
253
|
if (stopped())
|
|
271
254
|
return;
|
|
@@ -273,16 +256,26 @@ async function cycle(settings, signal) {
|
|
|
273
256
|
}
|
|
274
257
|
}
|
|
275
258
|
export function issueQueue(settings) {
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
259
|
+
const runtime = {
|
|
260
|
+
pending: undefined,
|
|
261
|
+
error: undefined,
|
|
262
|
+
stopping: new AbortController(),
|
|
263
|
+
};
|
|
264
|
+
const loaded = queueJournal(settings.directory).then((pending) => {
|
|
265
|
+
runtime.pending = pending;
|
|
266
|
+
return pending;
|
|
267
|
+
});
|
|
268
|
+
const running = loaded
|
|
269
|
+
.then((pending) => cycle(settings, { signal: runtime.stopping.signal, pending }))
|
|
270
|
+
.catch((error) => {
|
|
271
|
+
queueStopped(runtime, error);
|
|
281
272
|
});
|
|
282
273
|
return {
|
|
283
274
|
stop: async () => {
|
|
284
|
-
stopping.abort();
|
|
275
|
+
runtime.stopping.abort();
|
|
285
276
|
await running;
|
|
286
277
|
},
|
|
278
|
+
port: queueControlPort(runtime),
|
|
279
|
+
ready: loaded.then(() => undefined, () => undefined),
|
|
287
280
|
};
|
|
288
281
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface Turn {
|
|
2
|
+
readonly key: string;
|
|
3
|
+
readonly signal: AbortSignal;
|
|
4
|
+
}
|
|
5
|
+
export interface MergeTurn {
|
|
6
|
+
readonly repository: string;
|
|
7
|
+
readonly signal: AbortSignal;
|
|
8
|
+
}
|
|
9
|
+
export declare function inTurn<Result>(turn: Turn, work: () => Promise<Result>): Promise<Result>;
|
|
10
|
+
export declare function inMergeTurn<Result>(turn: MergeTurn, work: () => Promise<Result>): Promise<Result>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const turns = new Map();
|
|
2
|
+
function waited(previous, signal) {
|
|
3
|
+
signal.throwIfAborted();
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
const aborted = () => {
|
|
6
|
+
const reason = signal.reason;
|
|
7
|
+
reject(reason instanceof Error ? reason : new Error("merge-turn-aborted"));
|
|
8
|
+
};
|
|
9
|
+
signal.addEventListener("abort", aborted, { once: true });
|
|
10
|
+
previous.then(() => {
|
|
11
|
+
signal.removeEventListener("abort", aborted);
|
|
12
|
+
resolve(undefined);
|
|
13
|
+
}, reject);
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export async function inTurn(turn, work) {
|
|
17
|
+
const previous = turns.get(turn.key) ?? Promise.resolve(undefined);
|
|
18
|
+
const ended = Promise.withResolvers();
|
|
19
|
+
const queued = previous
|
|
20
|
+
.then(() => ended.promise)
|
|
21
|
+
.then(() => {
|
|
22
|
+
if (turns.get(turn.key) === queued)
|
|
23
|
+
turns.delete(turn.key);
|
|
24
|
+
return undefined;
|
|
25
|
+
});
|
|
26
|
+
turns.set(turn.key, queued);
|
|
27
|
+
try {
|
|
28
|
+
await waited(previous, turn.signal);
|
|
29
|
+
return await work();
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
ended.resolve(undefined);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function inMergeTurn(turn, work) {
|
|
36
|
+
return inTurn({ key: turn.repository, signal: turn.signal }, work);
|
|
37
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { QueuePort } from "../queue-state.ts";
|
|
2
|
+
import { type QueueJournal } from "./queue-journal.ts";
|
|
3
|
+
export interface QueueRuntime {
|
|
4
|
+
pending: QueueJournal | undefined;
|
|
5
|
+
error: string | undefined;
|
|
6
|
+
readonly stopping: AbortController;
|
|
7
|
+
}
|
|
8
|
+
export declare function queueConnection(): {
|
|
9
|
+
readonly port: QueuePort;
|
|
10
|
+
readonly connect: (port: QueuePort) => void;
|
|
11
|
+
};
|
|
12
|
+
export declare function queueStopped(runtime: QueueRuntime, error: unknown): string;
|
|
13
|
+
export declare function queueControlPort(runtime: QueueRuntime): QueuePort;
|