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
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { queueControlLimit } from "./queue-journal.js";
|
|
2
|
+
const unavailable = {
|
|
3
|
+
kind: "unavailable",
|
|
4
|
+
detail: "The queue is not running.",
|
|
5
|
+
};
|
|
6
|
+
export function queueConnection() {
|
|
7
|
+
let connected;
|
|
8
|
+
return {
|
|
9
|
+
port: {
|
|
10
|
+
control: () => connected?.control(),
|
|
11
|
+
change: (request) => connected?.change(request) ?? Promise.resolve(unavailable),
|
|
12
|
+
},
|
|
13
|
+
connect: (port) => {
|
|
14
|
+
connected = port;
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function queueStopped(runtime, error) {
|
|
19
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
20
|
+
runtime.error = reason;
|
|
21
|
+
runtime.stopping.abort();
|
|
22
|
+
process.stderr.write(`Queue stopped: ${JSON.stringify(reason)}\n`);
|
|
23
|
+
return reason;
|
|
24
|
+
}
|
|
25
|
+
function stoppedChange(error) {
|
|
26
|
+
return {
|
|
27
|
+
kind: "unavailable",
|
|
28
|
+
detail: `The queue stopped: ${error}. Restart the service.`,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function recorded(pending, request) {
|
|
32
|
+
const prior = pending.controls.find((control) => control.commandId === request.commandId);
|
|
33
|
+
if (prior === undefined)
|
|
34
|
+
return undefined;
|
|
35
|
+
return {
|
|
36
|
+
kind: prior.command === request.command ? "replayed" : "conflicting-command",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function applied(pending, command) {
|
|
40
|
+
if (command === "drain") {
|
|
41
|
+
pending.drain ??= { startedAt: Date.now(), drainedAt: null };
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
pending.failures.length = 0;
|
|
45
|
+
pending.drain = undefined;
|
|
46
|
+
}
|
|
47
|
+
async function changed(runtime, request) {
|
|
48
|
+
const { pending, error } = runtime;
|
|
49
|
+
if (error !== undefined)
|
|
50
|
+
return stoppedChange(error);
|
|
51
|
+
if (pending === undefined)
|
|
52
|
+
return unavailable;
|
|
53
|
+
const prior = recorded(pending, request);
|
|
54
|
+
if (prior !== undefined)
|
|
55
|
+
return prior;
|
|
56
|
+
applied(pending, request.command);
|
|
57
|
+
pending.controls.push(request);
|
|
58
|
+
pending.controls.splice(0, pending.controls.length - queueControlLimit);
|
|
59
|
+
try {
|
|
60
|
+
await pending.save();
|
|
61
|
+
return { kind: "changed" };
|
|
62
|
+
}
|
|
63
|
+
catch (failure) {
|
|
64
|
+
return stoppedChange(queueStopped(runtime, failure));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function queueControlPort(runtime) {
|
|
68
|
+
let changing = Promise.resolve();
|
|
69
|
+
return {
|
|
70
|
+
control: () => {
|
|
71
|
+
const { pending, error } = runtime;
|
|
72
|
+
if (pending === undefined && error === undefined)
|
|
73
|
+
return undefined;
|
|
74
|
+
return {
|
|
75
|
+
failedRunIds: [...(pending?.failures ?? [])],
|
|
76
|
+
drain: pending?.drain,
|
|
77
|
+
error,
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
change: (request) => {
|
|
81
|
+
const next = changing.then(() => changed(runtime, request));
|
|
82
|
+
changing = next;
|
|
83
|
+
return next;
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type QueueCommand, type QueueDrain } from "../queue-state.ts";
|
|
2
|
+
export declare const queueControlLimit = 32;
|
|
3
|
+
export interface QueueControlRecord {
|
|
4
|
+
readonly commandId: string;
|
|
5
|
+
readonly command: QueueCommand;
|
|
6
|
+
}
|
|
7
|
+
export interface SelfUpdateHold {
|
|
8
|
+
readonly mark: string;
|
|
9
|
+
readonly startedAt: number;
|
|
10
|
+
}
|
|
11
|
+
export interface QueueJournal {
|
|
12
|
+
readonly commands: Set<string>;
|
|
13
|
+
readonly failures: string[];
|
|
14
|
+
readonly controls: QueueControlRecord[];
|
|
15
|
+
readonly save: () => Promise<void>;
|
|
16
|
+
retryCommandId: string | undefined;
|
|
17
|
+
selfUpdateHold: SelfUpdateHold | undefined;
|
|
18
|
+
drain: QueueDrain | undefined;
|
|
19
|
+
}
|
|
20
|
+
export declare function queueJournal(directory: string): Promise<QueueJournal>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { record } from "../protocol.js";
|
|
4
|
+
import { queueFailureLimit, } from "../queue-state.js";
|
|
5
|
+
export const queueControlLimit = 32;
|
|
6
|
+
function savedHold(value) {
|
|
7
|
+
if (value === undefined)
|
|
8
|
+
return undefined;
|
|
9
|
+
const held = record(value);
|
|
10
|
+
if (typeof held?.["mark"] !== "string" ||
|
|
11
|
+
typeof held["startedAt"] !== "number" ||
|
|
12
|
+
!Number.isFinite(held["startedAt"]) ||
|
|
13
|
+
held["startedAt"] <= 0)
|
|
14
|
+
throw new Error("invalid-queue-commands");
|
|
15
|
+
return { mark: held["mark"], startedAt: held["startedAt"] };
|
|
16
|
+
}
|
|
17
|
+
function instant(value) {
|
|
18
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
|
19
|
+
}
|
|
20
|
+
function savedDrain(value) {
|
|
21
|
+
if (value === undefined)
|
|
22
|
+
return undefined;
|
|
23
|
+
const drain = record(value);
|
|
24
|
+
const drainedAt = drain?.["drainedAt"];
|
|
25
|
+
if (!instant(drain?.["startedAt"]) ||
|
|
26
|
+
!(drainedAt === null || instant(drainedAt)))
|
|
27
|
+
throw new Error("invalid-queue-commands");
|
|
28
|
+
return { startedAt: drain["startedAt"], drainedAt };
|
|
29
|
+
}
|
|
30
|
+
function savedControls(value) {
|
|
31
|
+
if (value === undefined)
|
|
32
|
+
return [];
|
|
33
|
+
if (!Array.isArray(value) || value.length > queueControlLimit)
|
|
34
|
+
throw new Error("invalid-queue-commands");
|
|
35
|
+
return value.map((entry) => {
|
|
36
|
+
const control = record(entry);
|
|
37
|
+
const commandId = control?.["commandId"];
|
|
38
|
+
const command = control?.["command"];
|
|
39
|
+
if (typeof commandId !== "string" ||
|
|
40
|
+
commandId.length === 0 ||
|
|
41
|
+
(command !== "resume" && command !== "drain"))
|
|
42
|
+
throw new Error("invalid-queue-commands");
|
|
43
|
+
return { commandId, command };
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
function identifiers(value) {
|
|
47
|
+
if (!Array.isArray(value) ||
|
|
48
|
+
!value.every((id) => typeof id === "string" && id.length > 0))
|
|
49
|
+
throw new Error("invalid-queue-commands");
|
|
50
|
+
return value.map(String);
|
|
51
|
+
}
|
|
52
|
+
export async function queueJournal(directory) {
|
|
53
|
+
const file = join(directory, "queue-commands.json");
|
|
54
|
+
const text = await readFile(file, "utf8").catch((error) => {
|
|
55
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
56
|
+
return "[]";
|
|
57
|
+
throw error;
|
|
58
|
+
});
|
|
59
|
+
const value = JSON.parse(text);
|
|
60
|
+
const saved = record(value);
|
|
61
|
+
const commands = new Set(identifiers(Array.isArray(value) ? value : saved?.["commands"]));
|
|
62
|
+
const failures = Array.isArray(value) ? [] : identifiers(saved?.["failures"]);
|
|
63
|
+
if (failures.length > queueFailureLimit)
|
|
64
|
+
throw new Error("invalid-queue-commands");
|
|
65
|
+
let saving = Promise.resolve();
|
|
66
|
+
const write = async () => {
|
|
67
|
+
await writeFile(`${file}.tmp`, JSON.stringify({
|
|
68
|
+
commands: [...commands],
|
|
69
|
+
failures,
|
|
70
|
+
selfUpdateHold: pending.selfUpdateHold,
|
|
71
|
+
drain: pending.drain,
|
|
72
|
+
controls: pending.controls.length === 0 ? undefined : pending.controls,
|
|
73
|
+
}) + "\n", { mode: 0o600, flush: true });
|
|
74
|
+
await rename(`${file}.tmp`, file);
|
|
75
|
+
};
|
|
76
|
+
const pending = {
|
|
77
|
+
commands,
|
|
78
|
+
failures,
|
|
79
|
+
controls: savedControls(saved?.["controls"]),
|
|
80
|
+
retryCommandId: undefined,
|
|
81
|
+
selfUpdateHold: savedHold(saved?.["selfUpdateHold"]),
|
|
82
|
+
drain: savedDrain(saved?.["drain"]),
|
|
83
|
+
save: () => {
|
|
84
|
+
saving = saving.then(write, write);
|
|
85
|
+
return saving;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
return pending;
|
|
89
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface RecordFiles {
|
|
2
|
+
readonly move: (source: string, target: string) => Promise<void>;
|
|
3
|
+
readonly copy: (source: string, target: string) => Promise<void>;
|
|
4
|
+
}
|
|
5
|
+
export interface StoredFile {
|
|
6
|
+
readonly path: string;
|
|
7
|
+
readonly size: number;
|
|
8
|
+
readonly changedAt: number;
|
|
9
|
+
}
|
|
10
|
+
export declare const nodeFiles: RecordFiles;
|
|
11
|
+
export declare function failureReason(error: unknown): string;
|
|
12
|
+
export declare function storedFiles(folder: string): Promise<StoredFile[]>;
|
|
13
|
+
export declare function archiveReady(folder: string, settings: {
|
|
14
|
+
readonly configured: boolean;
|
|
15
|
+
}): Promise<string | undefined>;
|
|
16
|
+
export declare function archivedFolder(place: {
|
|
17
|
+
readonly source: string;
|
|
18
|
+
readonly target: string;
|
|
19
|
+
}, files: RecordFiles): Promise<void>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { access, cp, lstat, mkdir, readdir, rename, rm, stat, } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, relative } from "node:path";
|
|
4
|
+
export const nodeFiles = {
|
|
5
|
+
move: rename,
|
|
6
|
+
copy: (source, target) => cp(source, target, {
|
|
7
|
+
recursive: true,
|
|
8
|
+
errorOnExist: true,
|
|
9
|
+
force: false,
|
|
10
|
+
preserveTimestamps: true,
|
|
11
|
+
}),
|
|
12
|
+
};
|
|
13
|
+
export function failureReason(error) {
|
|
14
|
+
if (typeof error === "object" &&
|
|
15
|
+
error !== null &&
|
|
16
|
+
"code" in error &&
|
|
17
|
+
typeof error.code === "string")
|
|
18
|
+
return error.code;
|
|
19
|
+
return error instanceof Error ? error.message : String(error);
|
|
20
|
+
}
|
|
21
|
+
export async function storedFiles(folder) {
|
|
22
|
+
const found = [];
|
|
23
|
+
for (const entry of await readdir(folder, {
|
|
24
|
+
withFileTypes: true,
|
|
25
|
+
recursive: true,
|
|
26
|
+
})) {
|
|
27
|
+
if (!entry.isFile())
|
|
28
|
+
continue;
|
|
29
|
+
const path = join(entry.parentPath, entry.name);
|
|
30
|
+
const file = await lstat(path);
|
|
31
|
+
found.push({
|
|
32
|
+
path: relative(folder, path),
|
|
33
|
+
size: file.size,
|
|
34
|
+
changedAt: file.mtimeMs,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return found;
|
|
38
|
+
}
|
|
39
|
+
export async function archiveReady(folder, settings) {
|
|
40
|
+
try {
|
|
41
|
+
if (!settings.configured)
|
|
42
|
+
await mkdir(folder, { recursive: true });
|
|
43
|
+
if (!(await stat(folder)).isDirectory())
|
|
44
|
+
return "not-a-folder";
|
|
45
|
+
await access(folder, constants.W_OK);
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
return failureReason(error);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async function sameFiles(source, copy) {
|
|
53
|
+
const listed = async (folder) => JSON.stringify((await storedFiles(folder)).map((file) => [file.path, file.size]).sort());
|
|
54
|
+
return (await listed(source)) === (await listed(copy));
|
|
55
|
+
}
|
|
56
|
+
async function copied(place, files) {
|
|
57
|
+
const staged = `${place.target}.staged`;
|
|
58
|
+
await rm(staged, { recursive: true, force: true });
|
|
59
|
+
try {
|
|
60
|
+
await files.copy(place.source, staged);
|
|
61
|
+
if (!(await sameFiles(place.source, staged)))
|
|
62
|
+
throw new Error("archive-copy-incomplete");
|
|
63
|
+
await rename(staged, place.target);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
await rm(staged, { recursive: true, force: true });
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
await rm(place.source, { recursive: true, force: true });
|
|
70
|
+
}
|
|
71
|
+
export async function archivedFolder(place, files) {
|
|
72
|
+
await mkdir(dirname(place.target), { recursive: true });
|
|
73
|
+
try {
|
|
74
|
+
await files.move(place.source, place.target);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (failureReason(error) !== "EXDEV")
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
await copied(place, files);
|
|
82
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type RecordFiles } from "./record-archive.ts";
|
|
2
|
+
import { type RetentionSettings, type StoredRun } from "../record-retention.ts";
|
|
3
|
+
import type { HostPorts } from "../service/service.ts";
|
|
4
|
+
export interface SweepSettings {
|
|
5
|
+
readonly directory: string;
|
|
6
|
+
readonly retention: RetentionSettings;
|
|
7
|
+
readonly runs: (runIds: readonly string[]) => Promise<Readonly<Record<string, StoredRun>>>;
|
|
8
|
+
readonly now: () => number;
|
|
9
|
+
readonly log: (line: string) => void;
|
|
10
|
+
readonly files?: RecordFiles;
|
|
11
|
+
}
|
|
12
|
+
export interface RecordSweep {
|
|
13
|
+
readonly pass: () => Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
export interface RetentionLoop {
|
|
16
|
+
readonly stop: () => Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
export declare const retentionIntervalMilliseconds = 3600000;
|
|
19
|
+
export declare function archiveFolder(settings: Pick<SweepSettings, "directory" | "retention">): string;
|
|
20
|
+
export declare function recordSweep(settings: SweepSettings): RecordSweep;
|
|
21
|
+
export declare function recordRetention(settings: SweepSettings & {
|
|
22
|
+
readonly intervalMilliseconds?: number;
|
|
23
|
+
}): Promise<RetentionLoop>;
|
|
24
|
+
export declare function authorityRuns(ports: Pick<HostPorts, "runWait">): SweepSettings["runs"];
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { readdir, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { archivedFolder, archiveReady, failureReason, nodeFiles, storedFiles, } from "./record-archive.js";
|
|
4
|
+
import { folderRunId, retentionVerdict, sharedRecordFolders, } from "../record-retention.js";
|
|
5
|
+
export const retentionIntervalMilliseconds = 3_600_000;
|
|
6
|
+
const kinds = ["github", "journal"];
|
|
7
|
+
const batchSize = 200;
|
|
8
|
+
async function folders(directory) {
|
|
9
|
+
const found = [];
|
|
10
|
+
for (const kind of kinds)
|
|
11
|
+
for (const entry of await readdir(join(directory, kind), {
|
|
12
|
+
withFileTypes: true,
|
|
13
|
+
}).catch(() => []))
|
|
14
|
+
if (entry.isDirectory())
|
|
15
|
+
found.push(`${kind}/${entry.name}`);
|
|
16
|
+
return found.filter((path) => !sharedRecordFolders.includes(path)).sort();
|
|
17
|
+
}
|
|
18
|
+
async function newestChange(folder) {
|
|
19
|
+
return Math.max(Number.NEGATIVE_INFINITY, ...(await storedFiles(folder)).map((file) => file.changedAt));
|
|
20
|
+
}
|
|
21
|
+
async function startedRecord(folder) {
|
|
22
|
+
try {
|
|
23
|
+
const value = JSON.parse(await readFile(join(folder, "started.json"), "utf8"));
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function examined(directory, path) {
|
|
31
|
+
const folder = join(directory, path);
|
|
32
|
+
try {
|
|
33
|
+
return {
|
|
34
|
+
path,
|
|
35
|
+
started: await startedRecord(folder),
|
|
36
|
+
changedAt: await newestChange(folder),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return { path, started: undefined, changedAt: Number.NaN };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function archiveFolder(settings) {
|
|
44
|
+
return (settings.retention.archiveFolder ?? join(settings.directory, "archive"));
|
|
45
|
+
}
|
|
46
|
+
async function removed(settings, path) {
|
|
47
|
+
const source = join(settings.directory, path);
|
|
48
|
+
if (settings.retention.action === "delete") {
|
|
49
|
+
await rm(source, { recursive: true, force: true });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
await archivedFolder({ source, target: join(archiveFolder(settings), path) }, settings.files ?? nodeFiles);
|
|
53
|
+
}
|
|
54
|
+
function countLine(action, tally) {
|
|
55
|
+
const { counts, failures } = tally;
|
|
56
|
+
const moved = action === "delete" ? "deleted" : "archived";
|
|
57
|
+
const failed = failures.length === 0
|
|
58
|
+
? ""
|
|
59
|
+
: `, failed ${String(failures.length)} (${failures[0] ?? ""})`;
|
|
60
|
+
return `retention: ${moved} ${String(counts.due)}, kept ${String(counts.kept)}, skipped ${String(counts.skipped)} (active runs), unreadable ${String(counts.unreadable)}${failed}\n`;
|
|
61
|
+
}
|
|
62
|
+
async function unavailable(settings) {
|
|
63
|
+
if (settings.retention.action === "delete")
|
|
64
|
+
return false;
|
|
65
|
+
const reason = await archiveReady(archiveFolder(settings), {
|
|
66
|
+
configured: settings.retention.archiveFolder !== null,
|
|
67
|
+
});
|
|
68
|
+
if (reason === undefined)
|
|
69
|
+
return false;
|
|
70
|
+
settings.log(`retention: archive folder not available (${reason}), moved nothing\n`);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
async function handled(settings, folder) {
|
|
74
|
+
try {
|
|
75
|
+
await removed(settings, folder.path);
|
|
76
|
+
folder.tally.counts.due += 1;
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
folder.tally.failures.push(`${folder.path}: ${failureReason(error)}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async function nextBatch(sweep) {
|
|
83
|
+
const found = await folders(sweep.settings.directory);
|
|
84
|
+
const rest = found.filter((path) => path > sweep.cursor);
|
|
85
|
+
const batch = (rest.length === 0 ? found : rest).slice(0, batchSize);
|
|
86
|
+
sweep.cursor = batch.at(-1) ?? "";
|
|
87
|
+
return batch;
|
|
88
|
+
}
|
|
89
|
+
async function swept(sweep) {
|
|
90
|
+
const { settings } = sweep;
|
|
91
|
+
const days = settings.retention.days;
|
|
92
|
+
if (days === null || (await unavailable(settings)))
|
|
93
|
+
return;
|
|
94
|
+
const batch = await Promise.all((await nextBatch(sweep)).map((path) => examined(settings.directory, path)));
|
|
95
|
+
const runIds = [
|
|
96
|
+
...new Set(batch.flatMap((folder) => folderRunId(folder.started) ?? [])),
|
|
97
|
+
];
|
|
98
|
+
const runs = await settings.runs(runIds);
|
|
99
|
+
const now = settings.now();
|
|
100
|
+
const tally = {
|
|
101
|
+
counts: { due: 0, kept: 0, skipped: 0, unreadable: 0 },
|
|
102
|
+
failures: [],
|
|
103
|
+
};
|
|
104
|
+
for (const folder of batch) {
|
|
105
|
+
const verdict = retentionVerdict(folder, { runs, now, days });
|
|
106
|
+
if (verdict === "due")
|
|
107
|
+
await handled(settings, { path: folder.path, tally });
|
|
108
|
+
else
|
|
109
|
+
tally.counts[verdict] += 1;
|
|
110
|
+
}
|
|
111
|
+
settings.log(countLine(settings.retention.action, tally));
|
|
112
|
+
}
|
|
113
|
+
export function recordSweep(settings) {
|
|
114
|
+
const sweep = { settings, cursor: "" };
|
|
115
|
+
return {
|
|
116
|
+
pass: () => swept(sweep).catch((error) => {
|
|
117
|
+
settings.log(`retention: failed (${failureReason(error)})\n`);
|
|
118
|
+
}),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
export async function recordRetention(settings) {
|
|
122
|
+
const sweep = recordSweep(settings);
|
|
123
|
+
await sweep.pass();
|
|
124
|
+
let running = Promise.resolve();
|
|
125
|
+
const timer = setInterval(() => {
|
|
126
|
+
running = running.then(sweep.pass);
|
|
127
|
+
}, settings.intervalMilliseconds ?? retentionIntervalMilliseconds);
|
|
128
|
+
timer.unref();
|
|
129
|
+
return {
|
|
130
|
+
stop: async () => {
|
|
131
|
+
clearInterval(timer);
|
|
132
|
+
await running;
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
export function authorityRuns(ports) {
|
|
137
|
+
return async (runIds) => {
|
|
138
|
+
const found = [];
|
|
139
|
+
for (const runId of runIds) {
|
|
140
|
+
const target = await ports.runWait?.target(runId);
|
|
141
|
+
if (target === undefined)
|
|
142
|
+
continue;
|
|
143
|
+
found.push([runId, { status: target.status, endedAt: target.endedAt }]);
|
|
144
|
+
}
|
|
145
|
+
return Object.fromEntries(found);
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -153,10 +153,6 @@ function interruptedChild(attempts, completion) {
|
|
|
153
153
|
end.result === undefined);
|
|
154
154
|
}
|
|
155
155
|
async function resumedChild(attempts) {
|
|
156
|
-
const started = await readRecord(attempts.start.folder, "started.json");
|
|
157
|
-
const resume = await readResume(attempts.start.folder, started);
|
|
158
|
-
if (resume === undefined)
|
|
159
|
-
return { ...combined(attempts), reason: "claude-turn-interrupted" };
|
|
160
156
|
const locked = await resumeWorktree({
|
|
161
157
|
directory: attempts.start.directory,
|
|
162
158
|
folder: attempts.start.folder,
|
|
@@ -165,6 +161,10 @@ async function resumedChild(attempts) {
|
|
|
165
161
|
});
|
|
166
162
|
if (locked !== null)
|
|
167
163
|
return { ...combined(attempts), reason: locked.reason };
|
|
164
|
+
const started = await readRecord(attempts.start.folder, "started.json");
|
|
165
|
+
const resume = await readResume(attempts.start.folder, started);
|
|
166
|
+
if (resume === undefined)
|
|
167
|
+
return { ...combined(attempts), reason: "claude-turn-interrupted" };
|
|
168
168
|
const prepared = await preparedRetry(attempts);
|
|
169
169
|
if (typeof prepared !== "string")
|
|
170
170
|
return prepared;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type HarnessSettings } from "../harness-defaults.ts";
|
|
2
|
+
import { type TurnSettings } from "../project-settings.ts";
|
|
3
|
+
import type { SettingChanges } from "../service/workflow-reload.ts";
|
|
4
|
+
import { type TurnProfiles } from "../turn-profile.ts";
|
|
5
|
+
export type SettingDigests = Readonly<Record<string, string>>;
|
|
6
|
+
export interface ReloadedSettings {
|
|
7
|
+
readonly turnSettings: TurnSettings;
|
|
8
|
+
readonly settingDigests: SettingDigests;
|
|
9
|
+
}
|
|
10
|
+
export interface TurnSettingsSource {
|
|
11
|
+
readonly current: () => TurnSettings;
|
|
12
|
+
readonly replace: (next: TurnSettings) => void;
|
|
13
|
+
}
|
|
14
|
+
export declare function turnSettingsSource(initial: TurnSettings): TurnSettingsSource;
|
|
15
|
+
export declare function reloadedSettings(output: Readonly<Record<string, unknown>> | undefined): ReloadedSettings | undefined;
|
|
16
|
+
export declare function settingChanges(current: SettingDigests, next: SettingDigests): SettingChanges;
|
|
17
|
+
export declare function keptDigests(current: SettingDigests, change: {
|
|
18
|
+
readonly digests: SettingDigests;
|
|
19
|
+
readonly reloadedFields: readonly string[];
|
|
20
|
+
}): SettingDigests;
|
|
21
|
+
export declare function profileRefusal(settings: HarnessSettings & {
|
|
22
|
+
readonly profiles?: TurnProfiles;
|
|
23
|
+
}, turnSettings: TurnSettings): {
|
|
24
|
+
readonly code: string;
|
|
25
|
+
readonly detail: string;
|
|
26
|
+
} | undefined;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { turnHarness } from "../harness-defaults.js";
|
|
2
|
+
import { reloadedSettingFields, } from "../project-settings.js";
|
|
3
|
+
import { record } from "../protocol.js";
|
|
4
|
+
import { parseFallbackModels, parseTokenPrices } from "../token-prices.js";
|
|
5
|
+
import { isProfilesFile } from "../turn-profile.js";
|
|
6
|
+
import { checkedProfiles } from "./worker-profile.js";
|
|
7
|
+
export function turnSettingsSource(initial) {
|
|
8
|
+
let current = initial;
|
|
9
|
+
return {
|
|
10
|
+
current: () => current,
|
|
11
|
+
replace: (next) => {
|
|
12
|
+
current = next;
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function turnSettingsOf(value) {
|
|
17
|
+
const fields = record(value) ?? {};
|
|
18
|
+
const profiles = { profiles: fields["profiles"] };
|
|
19
|
+
if (!isProfilesFile(profiles))
|
|
20
|
+
return undefined;
|
|
21
|
+
try {
|
|
22
|
+
return {
|
|
23
|
+
profiles: profiles.profiles,
|
|
24
|
+
fallbackModels: parseFallbackModels(JSON.stringify(fields["fallbackModels"])),
|
|
25
|
+
tokenPrices: parseTokenPrices(JSON.stringify(fields["tokenPrices"])),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function digestsOf(value) {
|
|
33
|
+
const fields = record(value);
|
|
34
|
+
if (fields === undefined)
|
|
35
|
+
return undefined;
|
|
36
|
+
const entries = Object.entries(fields);
|
|
37
|
+
const digests = entries.filter((entry) => typeof entry[1] === "string");
|
|
38
|
+
return digests.length === entries.length
|
|
39
|
+
? Object.fromEntries(digests)
|
|
40
|
+
: undefined;
|
|
41
|
+
}
|
|
42
|
+
export function reloadedSettings(output) {
|
|
43
|
+
const turnSettings = turnSettingsOf(output?.["turnSettings"]);
|
|
44
|
+
const settingDigests = digestsOf(output?.["settingDigests"]);
|
|
45
|
+
return turnSettings === undefined || settingDigests === undefined
|
|
46
|
+
? undefined
|
|
47
|
+
: { turnSettings, settingDigests };
|
|
48
|
+
}
|
|
49
|
+
export function settingChanges(current, next) {
|
|
50
|
+
const changed = [...new Set([...Object.keys(current), ...Object.keys(next)])]
|
|
51
|
+
.filter((field) => current[field] !== next[field])
|
|
52
|
+
.sort();
|
|
53
|
+
return {
|
|
54
|
+
reloadedFields: changed.filter((field) => reloadedSettingFields.includes(field)),
|
|
55
|
+
restartFields: changed.filter((field) => !reloadedSettingFields.includes(field)),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export function keptDigests(current, change) {
|
|
59
|
+
return {
|
|
60
|
+
...current,
|
|
61
|
+
...Object.fromEntries(Object.entries(change.digests).filter(([field]) => change.reloadedFields.includes(field))),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function newHarness(settings, profiles) {
|
|
65
|
+
const started = new Set([{}, ...Object.values(settings.profiles ?? {})].map((profile) => turnHarness(settings, profile).worker));
|
|
66
|
+
return Object.values(profiles)
|
|
67
|
+
.map((profile) => turnHarness(settings, profile).worker)
|
|
68
|
+
.find((name) => !started.has(name));
|
|
69
|
+
}
|
|
70
|
+
export function profileRefusal(settings, turnSettings) {
|
|
71
|
+
try {
|
|
72
|
+
checkedProfiles(settings, turnSettings.profiles);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
const detail = String(Reflect.get(Object(error), "message"));
|
|
76
|
+
return { code: detail.replace(/:.*$/s, ""), detail };
|
|
77
|
+
}
|
|
78
|
+
const added = newHarness(settings, turnSettings.profiles);
|
|
79
|
+
return added === undefined
|
|
80
|
+
? undefined
|
|
81
|
+
: { code: "harness-no-worker", detail: `harness-no-worker: ${added}` };
|
|
82
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type SweepSettings } from "./record-sweep.ts";
|
|
2
|
+
import { type RunTurns, type TurnDetails } from "../record-retention.ts";
|
|
3
|
+
export type TurnDetailsSettings = Pick<SweepSettings, "directory" | "retention" | "runs" | "now">;
|
|
4
|
+
export declare function turnDetailsPlace(settings: TurnDetailsSettings): (turns: RunTurns) => Promise<TurnDetails | undefined>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { located } from "./operation-journal.js";
|
|
4
|
+
import { archiveFolder } from "./record-sweep.js";
|
|
5
|
+
import { turnDetails, } from "../record-retention.js";
|
|
6
|
+
function absent(error) {
|
|
7
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
8
|
+
}
|
|
9
|
+
async function present(journal, operationIds) {
|
|
10
|
+
const found = await Promise.all(operationIds.map((operationId) => stat(located(journal, operationId).folder).then(() => true, (error) => (absent(error) ? false : undefined))));
|
|
11
|
+
if (found.includes(undefined))
|
|
12
|
+
return undefined;
|
|
13
|
+
return found.filter(Boolean).length;
|
|
14
|
+
}
|
|
15
|
+
export function turnDetailsPlace(settings) {
|
|
16
|
+
return async ({ runId, operationIds }) => {
|
|
17
|
+
const archive = join(archiveFolder(settings), "journal");
|
|
18
|
+
const runs = await settings.runs([runId]);
|
|
19
|
+
const archived = await present(archive, operationIds);
|
|
20
|
+
const kept = await present(join(settings.directory, "journal"), operationIds);
|
|
21
|
+
if (archived === undefined || kept === undefined)
|
|
22
|
+
return undefined;
|
|
23
|
+
return turnDetails({
|
|
24
|
+
turns: operationIds.length,
|
|
25
|
+
kept,
|
|
26
|
+
archived: archived === 0 ? undefined : archive,
|
|
27
|
+
}, {
|
|
28
|
+
run: Object.hasOwn(runs, runId) ? runs[runId] : undefined,
|
|
29
|
+
retention: settings.retention,
|
|
30
|
+
now: settings.now(),
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -5,4 +5,5 @@ export interface WorkerProfiles extends HarnessSettings {
|
|
|
5
5
|
readonly profiles?: TurnProfiles;
|
|
6
6
|
readonly workflowId?: (runId: string) => Promise<string | undefined>;
|
|
7
7
|
}
|
|
8
|
+
export declare function checkedProfiles(settings: HarnessSettings, profiles: TurnProfiles): void;
|
|
8
9
|
export declare function selectedProfile(settings: WorkerProfiles, intent: ProtectedIntent): Promise<TurnProfile | string>;
|
|
@@ -20,6 +20,10 @@ function unsupportedProfile(profile, harness) {
|
|
|
20
20
|
? "profile-needs-claude"
|
|
21
21
|
: undefined;
|
|
22
22
|
}
|
|
23
|
+
export function checkedProfiles(settings, profiles) {
|
|
24
|
+
for (const profile of Object.values(profiles))
|
|
25
|
+
turnHarness(settings, profile);
|
|
26
|
+
}
|
|
23
27
|
export async function selectedProfile(settings, intent) {
|
|
24
28
|
if (intent.method === "run-turn")
|
|
25
29
|
return {};
|