dw-sdlc 0.21.0 → 0.21.1
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/dist/agentic-config.d.ts +16 -0
- package/dist/agentic-config.js +28 -23
- package/dist/cannot-run.d.ts +27 -0
- package/dist/cannot-run.js +48 -0
- package/dist/catalogue.js +2 -1
- package/dist/checks/agents-map.js +7 -8
- package/dist/checks/check.d.ts +10 -8
- package/dist/checks/check.js +9 -7
- package/dist/checks/decision-records.js +3 -2
- package/dist/checks/file-size-ratchet.js +13 -17
- package/dist/checks/index.d.ts +6 -5
- package/dist/checks/index.js +10 -9
- package/dist/checks/markdown-refs.js +1 -1
- package/dist/checks/primitives.js +5 -5
- package/dist/checks/routed-docs.js +1 -3
- package/dist/checks/skill-sections.js +12 -20
- package/dist/checks/tree.js +7 -7
- package/dist/cli.d.ts +2 -1
- package/dist/cli.js +26 -5
- package/dist/commands/check.js +27 -42
- package/dist/commands/config.js +22 -27
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +11 -18
- package/dist/commands/gate.js +35 -40
- package/dist/commands/lead-start.js +28 -42
- package/dist/commands/pr-reviewed.js +41 -59
- package/dist/commands/pr-ship.js +71 -89
- package/dist/commands/primitives.js +38 -40
- package/dist/commands/recap.js +23 -74
- package/dist/commands/references-setup.js +28 -76
- package/dist/commands/references-sync.js +29 -53
- package/dist/commands/setup.d.ts +2 -7
- package/dist/commands/setup.js +38 -90
- package/dist/commands/sweep.js +46 -157
- package/dist/commands/worker-ls.js +15 -31
- package/dist/commands/worker-report.js +36 -52
- package/dist/commands/worker-start.js +43 -57
- package/dist/drift.js +2 -1
- package/dist/exec.d.ts +20 -0
- package/dist/exec.js +37 -4
- package/dist/gate-cache.js +2 -1
- package/dist/git.d.ts +32 -0
- package/dist/git.js +60 -0
- package/dist/json-schema.js +1 -3
- package/dist/landed.js +11 -22
- package/dist/paused-operation.d.ts +2 -0
- package/dist/paused-operation.js +17 -0
- package/dist/pins.js +2 -1
- package/dist/pr/decide.d.ts +4 -16
- package/dist/pr/decide.js +48 -84
- package/dist/pr/delta.d.ts +3 -4
- package/dist/pr/delta.js +10 -19
- package/dist/pr/read.d.ts +27 -30
- package/dist/pr/read.js +94 -182
- package/dist/pr/reply.js +3 -5
- package/dist/pr/route.d.ts +11 -3
- package/dist/pr/route.js +5 -5
- package/dist/pr/standing.d.ts +138 -0
- package/dist/pr/standing.js +181 -0
- package/dist/pr/state.d.ts +10 -69
- package/dist/pr/state.js +42 -72
- package/dist/pr/upstream.d.ts +1 -6
- package/dist/pr/upstream.js +2 -2
- package/dist/primitives-diff.js +1 -1
- package/dist/recap-next.js +34 -47
- package/dist/recap-primitives.js +1 -1
- package/dist/references-key.d.ts +0 -1
- package/dist/references-key.js +3 -4
- package/dist/run-command.d.ts +4 -1
- package/dist/run-command.js +32 -11
- package/dist/setup-gate.d.ts +1 -7
- package/dist/setup-rules.js +2 -6
- package/dist/setup-writer.d.ts +32 -0
- package/dist/setup-writer.js +60 -0
- package/dist/shell-line.d.ts +2 -0
- package/dist/shell-line.js +5 -0
- package/dist/sweep-removal.d.ts +31 -0
- package/dist/sweep-removal.js +133 -0
- package/dist/sweep-terminals.d.ts +1 -1
- package/dist/sweep-terminals.js +21 -57
- package/dist/sweep-trees.d.ts +19 -3
- package/dist/sweep-trees.js +67 -31
- package/dist/worker/herdr.d.ts +67 -23
- package/dist/worker/herdr.js +126 -77
- package/dist/worker/launch.js +32 -45
- package/dist/worker/origin.d.ts +1 -1
- package/dist/worker/origin.js +9 -11
- package/dist/worker/tabs.js +11 -7
- package/dist/working-tree.d.ts +0 -8
- package/dist/working-tree.js +6 -22
- package/dist/worktree-lock.js +1 -1
- package/package.json +1 -1
- package/dist/git-env.d.ts +0 -2
- package/dist/git-env.js +0 -10
package/dist/references-key.d.ts
CHANGED
|
@@ -15,7 +15,6 @@ export type KeyProblem = {
|
|
|
15
15
|
path: string;
|
|
16
16
|
message: string;
|
|
17
17
|
};
|
|
18
|
-
export declare function isObject(value: unknown): value is Record<string, unknown>;
|
|
19
18
|
/** The References `value` declares, or the first problem in it, named by its path under the key. */
|
|
20
19
|
export declare function parseReferences(value: unknown): {
|
|
21
20
|
references: Reference[];
|
package/dist/references-key.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// and `test/references-key.test.ts` runs one case list through both. The first problem stops the parse.
|
|
4
4
|
import { existsSync, readFileSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
+
import { messageOf } from "./cannot-run.js";
|
|
7
|
+
import { isObject } from "./json-text.js";
|
|
6
8
|
/** The committed file the References are read from; no local layer adds to it. */
|
|
7
9
|
export const REFERENCES_FILE = ".ai/references.json";
|
|
8
10
|
/** The committed agentic config: a `references` key in it is refused, so the file stays the one list. */
|
|
@@ -30,9 +32,6 @@ function kind(value) {
|
|
|
30
32
|
}
|
|
31
33
|
return typeof value === "object" ? "an object" : `a ${typeof value}`;
|
|
32
34
|
}
|
|
33
|
-
export function isObject(value) {
|
|
34
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
35
|
-
}
|
|
36
35
|
function nonEmpty(value, path) {
|
|
37
36
|
if (typeof value !== "string") {
|
|
38
37
|
throw new KeyError(path, `must be a string, not ${kind(value)}`);
|
|
@@ -153,7 +152,7 @@ function readObject(path, name) {
|
|
|
153
152
|
value = JSON.parse(readFileSync(path, "utf8"));
|
|
154
153
|
}
|
|
155
154
|
catch (error) {
|
|
156
|
-
return { problem: `${name}: not JSON: ${
|
|
155
|
+
return { problem: `${name}: not JSON: ${messageOf(error)}` };
|
|
157
156
|
}
|
|
158
157
|
return isObject(value) ? { value } : { problem: `${name}: not a JSON object` };
|
|
159
158
|
}
|
package/dist/run-command.d.ts
CHANGED
|
@@ -17,7 +17,10 @@ export type Ran = {
|
|
|
17
17
|
tail: string[] | null;
|
|
18
18
|
};
|
|
19
19
|
export declare const TAIL_LINES = 50;
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Runs `body` with SIGINT and SIGTERM held, so a signal stops the command running and the Gate
|
|
22
|
+
* reports it, and SIGHUP passed on to the command.
|
|
23
|
+
*/
|
|
21
24
|
export declare function holdingSignals<T>(body: () => Promise<T>): Promise<T>;
|
|
22
25
|
/** The signal that stopped the run, once one has: one the Gate got or one that ended a command. */
|
|
23
26
|
export declare function stoppedBy(): NodeJS.Signals | undefined;
|
package/dist/run-command.js
CHANGED
|
@@ -1,39 +1,58 @@
|
|
|
1
|
-
// How the Gate runs one command, a Step or a `gate.checks.run` script:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// How the Gate runs one command, a Step or a `gate.checks.run` script: `sh -c` from the repository
|
|
2
|
+
// root in a process group of its own, stdin closed, output captured or passed through; and how SIGINT
|
|
3
|
+
// and SIGTERM stop it, every process it started and every command after it.
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { closeSync, fstatSync, mkdtempSync, openSync, readSync, rmSync } from "node:fs";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
import { join } from "node:path";
|
|
8
|
+
import { signalGroup } from "./exec.js";
|
|
8
9
|
export const TAIL_LINES = 50;
|
|
9
10
|
/** What the shell answers for a command it cannot find, and for one it cannot execute. */
|
|
10
11
|
const NOT_RUNNABLE = { 127: "was not found", 126: "could not be executed" };
|
|
11
12
|
const STOPPING = ["SIGINT", "SIGTERM"];
|
|
12
|
-
/** The command
|
|
13
|
+
/** The process group the running command leads, which a signal the Gate gets is passed on to. */
|
|
13
14
|
let running;
|
|
14
15
|
/** The first SIGINT or SIGTERM the Gate got; no command starts after it. */
|
|
15
16
|
let received;
|
|
16
17
|
/**
|
|
17
|
-
* The SIGINT or SIGTERM that ended a command; no command starts after it either.
|
|
18
|
-
* command and the Gate together
|
|
18
|
+
* The SIGINT or SIGTERM that ended a command; no command starts after it either. A signal sent to
|
|
19
|
+
* the command and the Gate together can show in the command's exit before the Gate's own handler runs.
|
|
19
20
|
*/
|
|
20
21
|
let commandEndedBy;
|
|
21
22
|
function release() {
|
|
22
23
|
for (const signal of STOPPING) {
|
|
23
24
|
process.off(signal, stop);
|
|
24
25
|
}
|
|
26
|
+
process.off("SIGHUP", hangUp);
|
|
27
|
+
}
|
|
28
|
+
/** Sends `signal` to every process of the running command's group. */
|
|
29
|
+
function signalCommand(signal) {
|
|
30
|
+
if (running !== undefined) {
|
|
31
|
+
signalGroup(running, signal);
|
|
32
|
+
}
|
|
25
33
|
}
|
|
26
34
|
function stop(signal) {
|
|
27
35
|
if (received !== undefined) {
|
|
28
|
-
// A second signal ends the Gate at once, as it would with no handler
|
|
36
|
+
// A second signal ends the Gate at once, as it would with no handler, and kills the command, which
|
|
37
|
+
// ignored the first.
|
|
38
|
+
signalCommand("SIGKILL");
|
|
29
39
|
release();
|
|
30
40
|
process.kill(process.pid, signal);
|
|
31
41
|
return;
|
|
32
42
|
}
|
|
33
43
|
received = signal;
|
|
34
|
-
|
|
44
|
+
signalCommand(signal);
|
|
45
|
+
}
|
|
46
|
+
/** A hangup ends the Gate as with no handler, passed on first: a terminal's hangup misses the command's session. */
|
|
47
|
+
function hangUp() {
|
|
48
|
+
signalCommand("SIGHUP");
|
|
49
|
+
release();
|
|
50
|
+
process.kill(process.pid, "SIGHUP");
|
|
35
51
|
}
|
|
36
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* Runs `body` with SIGINT and SIGTERM held, so a signal stops the command running and the Gate
|
|
54
|
+
* reports it, and SIGHUP passed on to the command.
|
|
55
|
+
*/
|
|
37
56
|
export async function holdingSignals(body) {
|
|
38
57
|
running = undefined;
|
|
39
58
|
received = undefined;
|
|
@@ -42,6 +61,7 @@ export async function holdingSignals(body) {
|
|
|
42
61
|
for (const signal of STOPPING) {
|
|
43
62
|
process.on(signal, stop);
|
|
44
63
|
}
|
|
64
|
+
process.on("SIGHUP", hangUp);
|
|
45
65
|
try {
|
|
46
66
|
return await body();
|
|
47
67
|
}
|
|
@@ -104,8 +124,9 @@ function run(command, root, stdio) {
|
|
|
104
124
|
running = undefined;
|
|
105
125
|
resolve({ ...answered, seconds: (performance.now() - started) / 1000 });
|
|
106
126
|
};
|
|
107
|
-
|
|
108
|
-
|
|
127
|
+
// A session of its own makes the command lead a process group, which every process it starts joins.
|
|
128
|
+
const child = spawn(command, { shell: true, cwd: root, stdio, detached: true });
|
|
129
|
+
running = child.pid;
|
|
109
130
|
// A signal that cannot be delivered is an error too; only a child with no pid never started.
|
|
110
131
|
child.on("error", (error) => {
|
|
111
132
|
if (child.pid === undefined) {
|
package/dist/setup-gate.d.ts
CHANGED
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
import type { NodePackageManager } from "./agentic-config.ts";
|
|
2
|
-
|
|
3
|
-
export type Change = {
|
|
4
|
-
text: string;
|
|
5
|
-
detail?: string;
|
|
6
|
-
} | {
|
|
7
|
-
refusal: string;
|
|
8
|
-
};
|
|
2
|
+
import type { Change } from "./setup-writer.ts";
|
|
9
3
|
/**
|
|
10
4
|
* package.json with the package pinned exactly as a devDependency, in name order, where neither list
|
|
11
5
|
* names it. A new dependency needs the owner's yes, so without `add` the file is refused.
|
package/dist/setup-rules.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The permission rules the setup writes: the lease exception and the irreversible set for the user
|
|
2
2
|
// file, and a repository's deny backstop for a machine without jq.
|
|
3
3
|
import { DEFAULT_GUARDRAILS, matchesEnvGlob, resolveConfig } from "./agentic-config.js";
|
|
4
|
-
import {
|
|
4
|
+
import { asked } from "./git.js";
|
|
5
5
|
/** The auto-mode exception that lets a lease push through the classifier's default force-push block. */
|
|
6
6
|
export const LEASE_RULE = "Force push with --force-with-lease or --force-if-includes to a branch that is not master, main or production: allowed. The guardrails plugin's hook refuses a bare --force, -f or +refspec and any force to a protected branch, so the lease form is the safe one and needs no prompt.";
|
|
7
7
|
/** The irreversible set for the user file; `git stash clear` also in the rtk form a permission rule sees once rtk rewrites it. */
|
|
@@ -56,10 +56,6 @@ const GH_DENY = ["gh repo delete", "gh repo delete *"];
|
|
|
56
56
|
*/
|
|
57
57
|
export const RETIRED_DENY = ["Bash(git branch -D *)", "Bash(rtk git branch -D *)"];
|
|
58
58
|
const ENV_NAME = /^\.env(\..+)?$|^\.envrc$/;
|
|
59
|
-
function git(root, args) {
|
|
60
|
-
const result = exec("git", ["-C", root, ...args]);
|
|
61
|
-
return result.status === 0 ? result.stdout.trim() : undefined;
|
|
62
|
-
}
|
|
63
59
|
function listedIn(globs, file) {
|
|
64
60
|
return globs.some((glob) => matchesEnvGlob(glob, file));
|
|
65
61
|
}
|
|
@@ -67,7 +63,7 @@ function isEnvName(file) {
|
|
|
67
63
|
return ENV_NAME.test(file.slice(file.lastIndexOf("/") + 1));
|
|
68
64
|
}
|
|
69
65
|
function gitFiles(root, ...flags) {
|
|
70
|
-
const listed =
|
|
66
|
+
const listed = asked(root, ["-c", "core.quotePath=false", "ls-files", ...flags, "--exclude-standard"]) ?? "";
|
|
71
67
|
return listed.split("\n").filter((file) => file !== "");
|
|
72
68
|
}
|
|
73
69
|
/**
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** `by hand` is an edit the setup names and leaves to the user; `skipped` is a file it has no reason to write. */
|
|
2
|
+
export type Outcome = "wrote" | "unchanged" | "skipped" | "refused" | "by hand";
|
|
3
|
+
export type Step = {
|
|
4
|
+
file: string;
|
|
5
|
+
outcome: Outcome;
|
|
6
|
+
detail?: string;
|
|
7
|
+
};
|
|
8
|
+
/** A file's text after the step and what writing it does, or why the step leaves the file alone. */
|
|
9
|
+
export type Change = {
|
|
10
|
+
text: string;
|
|
11
|
+
detail?: string | undefined;
|
|
12
|
+
} | {
|
|
13
|
+
refusal: string;
|
|
14
|
+
};
|
|
15
|
+
export declare class Writer {
|
|
16
|
+
readonly steps: Step[];
|
|
17
|
+
private readonly dryRun;
|
|
18
|
+
constructor(dryRun: boolean);
|
|
19
|
+
private record;
|
|
20
|
+
/**
|
|
21
|
+
* The file at `path`, recorded under `label`, as `change` leaves it, or as it leaves the text the file
|
|
22
|
+
* holds now: written when the text differs, unchanged when it does not, else refused.
|
|
23
|
+
*/
|
|
24
|
+
apply(label: string, path: string, change: Change | ((current: string | undefined) => Change)): void;
|
|
25
|
+
/** The file at `path`, made by `make` when nothing sits there, a dangling symlink included; never replaced. */
|
|
26
|
+
create(label: string, path: string, make: (path: string) => void): void;
|
|
27
|
+
refuse(label: string, detail: string): void;
|
|
28
|
+
skip(label: string, detail: string): void;
|
|
29
|
+
byHand(label: string, detail: string): void;
|
|
30
|
+
}
|
|
31
|
+
/** One line a step: what it did, or would do under `dryRun`, the file, and why where it says. */
|
|
32
|
+
export declare function stepLines(steps: Step[], dryRun: boolean): string[];
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// How `dw-sdlc setup` and `dw-sdlc references setup` write a file: only when its text would change,
|
|
2
|
+
// nothing under --dry-run, and one line a file saying what the step did or would do.
|
|
3
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
export class Writer {
|
|
6
|
+
steps = [];
|
|
7
|
+
dryRun;
|
|
8
|
+
constructor(dryRun) {
|
|
9
|
+
this.dryRun = dryRun;
|
|
10
|
+
}
|
|
11
|
+
record(file, outcome, detail) {
|
|
12
|
+
this.steps.push(detail === undefined ? { file, outcome } : { file, outcome, detail });
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The file at `path`, recorded under `label`, as `change` leaves it, or as it leaves the text the file
|
|
16
|
+
* holds now: written when the text differs, unchanged when it does not, else refused.
|
|
17
|
+
*/
|
|
18
|
+
apply(label, path, change) {
|
|
19
|
+
const current = existsSync(path) ? readFileSync(path, "utf8") : undefined;
|
|
20
|
+
const next = typeof change === "function" ? change(current) : change;
|
|
21
|
+
if ("refusal" in next) {
|
|
22
|
+
this.record(label, "refused", next.refusal);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (next.text === current) {
|
|
26
|
+
this.record(label, "unchanged", undefined);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (!this.dryRun) {
|
|
30
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
31
|
+
writeFileSync(path, next.text);
|
|
32
|
+
}
|
|
33
|
+
this.record(label, "wrote", next.detail);
|
|
34
|
+
}
|
|
35
|
+
/** The file at `path`, made by `make` when nothing sits there, a dangling symlink included; never replaced. */
|
|
36
|
+
create(label, path, make) {
|
|
37
|
+
const absent = lstatSync(path, { throwIfNoEntry: false }) === undefined;
|
|
38
|
+
if (absent && !this.dryRun) {
|
|
39
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
40
|
+
make(path);
|
|
41
|
+
}
|
|
42
|
+
this.record(label, absent ? "wrote" : "unchanged", undefined);
|
|
43
|
+
}
|
|
44
|
+
refuse(label, detail) {
|
|
45
|
+
this.record(label, "refused", detail);
|
|
46
|
+
}
|
|
47
|
+
skip(label, detail) {
|
|
48
|
+
this.record(label, "skipped", detail);
|
|
49
|
+
}
|
|
50
|
+
byHand(label, detail) {
|
|
51
|
+
this.record(label, "by hand", detail);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** One line a step: what it did, or would do under `dryRun`, the file, and why where it says. */
|
|
55
|
+
export function stepLines(steps, dryRun) {
|
|
56
|
+
return steps.map(({ file, outcome, detail }) => {
|
|
57
|
+
const verb = outcome === "wrote" && dryRun ? "would write" : outcome;
|
|
58
|
+
return `${verb.padEnd(11)} ${file}${detail === undefined ? "" : ` — ${detail}`}`;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// A command as the line a dev pastes into a POSIX shell: what an answer hands the dev to run.
|
|
2
|
+
/** The words as one POSIX shell command line, each word that needs it single-quoted. */
|
|
3
|
+
export function shellLine(words) {
|
|
4
|
+
return words.map((word) => (/^[\w./:=@%+,-]+$/.test(word) ? word : `'${word.replaceAll("'", `'\\''`)}'`)).join(" ");
|
|
5
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { StaleLock } from "./worktree-lock.ts";
|
|
2
|
+
export type Item = {
|
|
3
|
+
kind: "branch" | "worktree" | "lock" | "terminal";
|
|
4
|
+
name: string;
|
|
5
|
+
reason: string;
|
|
6
|
+
};
|
|
7
|
+
export type Kept = Item & {
|
|
8
|
+
command: string | null;
|
|
9
|
+
dev: boolean;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* An item to remove: `at` the commit it was judged at, a worktree's HEAD or a branch's tip; a worktree's
|
|
13
|
+
* `holds` branch, `stale` lock, `workerLock` its Worker's program holds; a terminal's `stop`, `tree`.
|
|
14
|
+
*/
|
|
15
|
+
export type Planned = Item & {
|
|
16
|
+
at?: string;
|
|
17
|
+
holds?: string;
|
|
18
|
+
stale?: StaleLock;
|
|
19
|
+
stop?: boolean;
|
|
20
|
+
tree?: string;
|
|
21
|
+
workerLock?: {
|
|
22
|
+
pid: number;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
export type Judged = {
|
|
26
|
+
base: string;
|
|
27
|
+
removed: Planned[];
|
|
28
|
+
kept: Kept[];
|
|
29
|
+
};
|
|
30
|
+
/** Removes each item judged to go, in order; a worktree that stays keeps the branch it holds as well. */
|
|
31
|
+
export declare function apply({ base, removed, kept }: Judged, root: string): Judged;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// How a Sweep carries out what it judged: each item it plans to remove, removed in order, and the one git or
|
|
2
|
+
// hc refuses, or that outlives its time limit, kept with why. docs/adr/0015 sets the bounds: it never forces.
|
|
3
|
+
import { SLOW_LIMIT_MS, TimedOut } from "./exec.js";
|
|
4
|
+
import { git } from "./git.js";
|
|
5
|
+
import { byHand, lockAfterStop, takeDown } from "./sweep-terminals.js";
|
|
6
|
+
import { forcedDelete, forcedRemoval, tipOf, unlockCommand, whyUnclean, worktreesOf } from "./sweep-trees.js";
|
|
7
|
+
/** Why the worktree `item` stays after all, read again just before it goes: what changed since it was judged. */
|
|
8
|
+
function whyChanged(root, item) {
|
|
9
|
+
const now = worktreesOf(root).find(({ path }) => path === item.name);
|
|
10
|
+
if (now === undefined) {
|
|
11
|
+
return { reason: "git no longer lists it", command: null };
|
|
12
|
+
}
|
|
13
|
+
if (now.head !== item.at) {
|
|
14
|
+
return { reason: `its HEAD moved to ${now.head.slice(0, 9)} since the Sweep judged it`, command: null };
|
|
15
|
+
}
|
|
16
|
+
if (now.branch !== item.holds) {
|
|
17
|
+
const on = now.branch === undefined ? "a detached HEAD" : `${now.branch} checked out`;
|
|
18
|
+
return { reason: `it has ${on} since the Sweep judged it`, command: null };
|
|
19
|
+
}
|
|
20
|
+
if (now.lock !== item.stale?.held) {
|
|
21
|
+
return { reason: "its lock changed since the Sweep judged it", command: null };
|
|
22
|
+
}
|
|
23
|
+
return whyUnclean(item.name, item.stale !== undefined);
|
|
24
|
+
}
|
|
25
|
+
/** The command that removes `item` by hand, for one the Sweep did not remove. */
|
|
26
|
+
function byHandOf(item) {
|
|
27
|
+
if (item.kind === "terminal") {
|
|
28
|
+
return byHand(item);
|
|
29
|
+
}
|
|
30
|
+
return item.kind === "worktree" ? forcedRemoval(item.name, item.stale !== undefined) : forcedDelete(item.name);
|
|
31
|
+
}
|
|
32
|
+
/** Stops, if it asks, and removes the terminal of `item`; why it stays, in hc's words, or undefined once it is gone. */
|
|
33
|
+
function removeTerminalOf(item, unstopped) {
|
|
34
|
+
const failed = takeDown(item);
|
|
35
|
+
if (failed === undefined) {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
if (item.tree !== undefined) {
|
|
39
|
+
unstopped.set(item.tree, `its Worker's terminal ${item.name} stays: ${failed}`);
|
|
40
|
+
}
|
|
41
|
+
return { reason: failed, command: byHandOf(item) };
|
|
42
|
+
}
|
|
43
|
+
/** Removes the worktree of `item` once it still passes, its stale lock lifted just before; why it stays, or undefined. */
|
|
44
|
+
function removeTree(root, item, unstopped) {
|
|
45
|
+
const unstoppable = unstopped.get(item.name);
|
|
46
|
+
if (unstoppable !== undefined) {
|
|
47
|
+
return { reason: unstoppable, command: null };
|
|
48
|
+
}
|
|
49
|
+
if (item.workerLock !== undefined) {
|
|
50
|
+
const now = lockAfterStop(root, item.name);
|
|
51
|
+
if (now.state === "stays") {
|
|
52
|
+
return { reason: `its Worker's terminal was stopped, but ${now.reason}`, command: now.command };
|
|
53
|
+
}
|
|
54
|
+
if (now.state === "stale") {
|
|
55
|
+
item.stale = now;
|
|
56
|
+
}
|
|
57
|
+
delete item.workerLock;
|
|
58
|
+
}
|
|
59
|
+
const changed = whyChanged(root, item);
|
|
60
|
+
if (changed !== null) {
|
|
61
|
+
return changed;
|
|
62
|
+
}
|
|
63
|
+
if (item.stale !== undefined) {
|
|
64
|
+
const lifted = git(root, ["worktree", "unlock", "--", item.name]);
|
|
65
|
+
if ("error" in lifted) {
|
|
66
|
+
return { reason: lifted.error, command: unlockCommand(item.name) };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
let result;
|
|
70
|
+
try {
|
|
71
|
+
result = git(root, ["worktree", "remove", "--", item.name], { limitMs: SLOW_LIMIT_MS });
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (!(error instanceof TimedOut)) {
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
result = { error: error.message, said: error.message };
|
|
78
|
+
}
|
|
79
|
+
if (!("error" in result)) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
const relocked = item.stale === undefined ? undefined : git(root, ["worktree", "lock", "--reason", item.stale.held, "--", item.name]);
|
|
83
|
+
const lost = relocked !== undefined && "error" in relocked ? `; its lock could not be put back: ${relocked.error}` : "";
|
|
84
|
+
return { reason: `${result.error}${lost}`, command: byHandOf(item) };
|
|
85
|
+
}
|
|
86
|
+
/** Deletes the branch of `item` while it still points at the commit judged Landed; why it stays, or undefined. */
|
|
87
|
+
function removeBranch(root, item, heldBy) {
|
|
88
|
+
const path = heldBy.get(item.name);
|
|
89
|
+
if (path !== undefined) {
|
|
90
|
+
return { reason: `checked out in the worktree at ${path}`, command: null };
|
|
91
|
+
}
|
|
92
|
+
// Read just before the delete: git branch -D, the delete the guard lets an agent make, takes no expected tip.
|
|
93
|
+
const now = tipOf(root, item.name);
|
|
94
|
+
if (now !== undefined && now !== item.at) {
|
|
95
|
+
return { reason: `it moved to ${now.slice(0, 9)} since the Sweep judged it`, command: null };
|
|
96
|
+
}
|
|
97
|
+
const result = git(root, ["branch", "-D", "--", item.name]);
|
|
98
|
+
return "error" in result ? { reason: result.error, command: byHandOf(item) } : undefined;
|
|
99
|
+
}
|
|
100
|
+
/** Removes `item`; why it stays, in git's or hc's words or as the time limit it hit, or undefined once it is gone. */
|
|
101
|
+
function removal(root, item, heldBy, unstopped) {
|
|
102
|
+
try {
|
|
103
|
+
if (item.kind === "terminal") {
|
|
104
|
+
return removeTerminalOf(item, unstopped);
|
|
105
|
+
}
|
|
106
|
+
return item.kind === "worktree" ? removeTree(root, item, unstopped) : removeBranch(root, item, heldBy);
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
if (error instanceof TimedOut) {
|
|
110
|
+
return { reason: error.message, command: byHandOf(item) };
|
|
111
|
+
}
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Removes each item judged to go, in order; a worktree that stays keeps the branch it holds as well. */
|
|
116
|
+
export function apply({ base, removed, kept }, root) {
|
|
117
|
+
const gone = [];
|
|
118
|
+
const refused = [];
|
|
119
|
+
const heldBy = new Map();
|
|
120
|
+
const unstopped = new Map();
|
|
121
|
+
for (const item of removed) {
|
|
122
|
+
const why = removal(root, item, heldBy, unstopped);
|
|
123
|
+
if (why === undefined) {
|
|
124
|
+
gone.push(item);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (item.kind === "worktree" && item.holds !== undefined) {
|
|
128
|
+
heldBy.set(item.holds, item.name);
|
|
129
|
+
}
|
|
130
|
+
refused.push({ kind: item.kind, name: item.name, ...why, dev: true });
|
|
131
|
+
}
|
|
132
|
+
return { base, removed: gone, kept: [...kept, ...refused] };
|
|
133
|
+
}
|
|
@@ -56,7 +56,7 @@ export declare function whyHeld(holders: Holder[]): {
|
|
|
56
56
|
/** The terminals of the Workers done in a worktree that goes, to stop and remove with it. */
|
|
57
57
|
export declare function stopsOf(holders: Holder[], tree: string): TerminalPlan[];
|
|
58
58
|
/** Stops, if `item` asks, and removes its terminal; the words of the `hc` that refused, or undefined when it is gone. */
|
|
59
|
-
export declare function
|
|
59
|
+
export declare function takeDown(item: {
|
|
60
60
|
name: string;
|
|
61
61
|
stop?: boolean;
|
|
62
62
|
}): string | undefined;
|
package/dist/sweep-terminals.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
// What a Sweep does with herdr terminals: it stops and removes only a Worker of its own whose turn has ended,
|
|
2
2
|
// in a worktree that goes in the same Sweep or is already gone. docs/adr/0015 sets the bounds.
|
|
3
3
|
import { existsSync, realpathSync } from "node:fs";
|
|
4
|
-
import {
|
|
5
|
-
import { shellLine } from "./
|
|
6
|
-
import { CannotRun } from "./pr/read.js";
|
|
4
|
+
import { CannotRun } from "./cannot-run.js";
|
|
5
|
+
import { shellLine } from "./shell-line.js";
|
|
7
6
|
import { worktreesOf } from "./sweep-trees.js";
|
|
8
|
-
import {
|
|
7
|
+
import { isLive, listTerminals, readTerminal, removeTerminal, stopTerminal, workersOf } from "./worker/herdr.js";
|
|
9
8
|
import { lockVerdict } from "./worktree-lock.js";
|
|
10
9
|
/** Statuses of a Worker that is not at the end of its turn, with how the Sweep says it. */
|
|
11
10
|
const AT_WORK = {
|
|
@@ -18,53 +17,30 @@ export function sweepingTerminal() {
|
|
|
18
17
|
const id = process.env.HC_TERMINAL_ID;
|
|
19
18
|
return id === undefined || id === "" ? undefined : id;
|
|
20
19
|
}
|
|
21
|
-
function word(value) {
|
|
22
|
-
return typeof value === "string" && value !== "" ? value : undefined;
|
|
23
|
-
}
|
|
24
20
|
/**
|
|
25
|
-
* Each
|
|
26
|
-
* Sweep, since a terminal in a folder it cannot see must
|
|
21
|
+
* Each Worker with the folder it works in and its program's pid, as `hc get` reports them. One gone since the
|
|
22
|
+
* listing is dropped; an `hc get` that fails stops the Sweep, since a terminal in a folder it cannot see must
|
|
23
|
+
* not count as absent.
|
|
27
24
|
*/
|
|
28
|
-
function
|
|
29
|
-
return
|
|
25
|
+
function located(workers) {
|
|
26
|
+
return workers.flatMap((worker) => {
|
|
30
27
|
if (!isLive(worker)) {
|
|
31
28
|
return [{ ...worker, pid: undefined }];
|
|
32
29
|
}
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
return [];
|
|
36
|
-
}
|
|
37
|
-
const parsed = got.status === 0 ? parseObject(got.stdout) : undefined;
|
|
38
|
-
if (parsed === undefined) {
|
|
39
|
-
throw new Refusal(`hc get ${worker.address} failed: ${got.status === 0 ? "its reply is no JSON object" : said(got)}`);
|
|
40
|
-
}
|
|
41
|
-
const { cwd, pid } = isObject(parsed.foreground) ? parsed.foreground : { cwd: undefined, pid: undefined };
|
|
42
|
-
const there = typeof cwd === "string" && cwd !== "" ? { ...worker, folder: cwd } : worker;
|
|
43
|
-
return [{ ...there, pid: typeof pid === "number" ? pid : undefined }];
|
|
30
|
+
const reading = readTerminal(worker.address);
|
|
31
|
+
return "gone" in reading ? [] : [{ ...worker, folder: reading.folder ?? worker.folder, pid: reading.pid }];
|
|
44
32
|
});
|
|
45
33
|
}
|
|
46
34
|
/** The live terminals `hc ls` lists, each with the folder it works in. */
|
|
47
35
|
function liveTerminals() {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const places = new Map();
|
|
53
|
-
const listed = terminals.flatMap((entry) => {
|
|
54
|
-
const address = isObject(entry) ? word(entry.address) : undefined;
|
|
55
|
-
if (!isObject(entry) || address === undefined || typeof entry.cwd !== "string") {
|
|
56
|
-
throw new Refusal("hc ls answered a terminal without an address or a folder", "check hc --version");
|
|
36
|
+
return listTerminals().flatMap(({ address, name, folder, place }) => {
|
|
37
|
+
const reading = readTerminal(address);
|
|
38
|
+
if ("gone" in reading) {
|
|
39
|
+
return [];
|
|
57
40
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
return [{ name, address, state: "live", status: word(entry.status) ?? "unknown", folder: entry.cwd }];
|
|
41
|
+
const working = reading.folder ?? folder;
|
|
42
|
+
return [{ address, name, folder: working, place: place ?? working }];
|
|
61
43
|
});
|
|
62
|
-
return foldersOf(listed).map(({ name, address, folder }) => ({
|
|
63
|
-
name,
|
|
64
|
-
address,
|
|
65
|
-
folder,
|
|
66
|
-
place: places.get(address) ?? folder
|
|
67
|
-
}));
|
|
68
44
|
}
|
|
69
45
|
/**
|
|
70
46
|
* Reads herdr for the sweeping terminal `own`: its Workers, finished ones included, and the other live
|
|
@@ -72,7 +48,7 @@ function liveTerminals() {
|
|
|
72
48
|
*/
|
|
73
49
|
export function readTerminals(own) {
|
|
74
50
|
try {
|
|
75
|
-
const workers =
|
|
51
|
+
const workers = located(workersOf(own, true));
|
|
76
52
|
const live = liveTerminals();
|
|
77
53
|
const addresses = new Set(workers.map(({ address }) => address));
|
|
78
54
|
return {
|
|
@@ -82,10 +58,7 @@ export function readTerminals(own) {
|
|
|
82
58
|
};
|
|
83
59
|
}
|
|
84
60
|
catch (error) {
|
|
85
|
-
|
|
86
|
-
throw new CannotRun(error.message, error.remedy ?? "check hc, then call again");
|
|
87
|
-
}
|
|
88
|
-
throw error;
|
|
61
|
+
throw error instanceof CannotRun ? error.orRemedy("check hc, then call again") : error;
|
|
89
62
|
}
|
|
90
63
|
}
|
|
91
64
|
function canonical(path) {
|
|
@@ -176,21 +149,12 @@ export function stopsOf(holders, tree) {
|
|
|
176
149
|
: []);
|
|
177
150
|
}
|
|
178
151
|
/** Stops, if `item` asks, and removes its terminal; the words of the `hc` that refused, or undefined when it is gone. */
|
|
179
|
-
export function
|
|
152
|
+
export function takeDown(item) {
|
|
180
153
|
try {
|
|
181
|
-
|
|
182
|
-
const killed = call("hc", ["kill", item.name]);
|
|
183
|
-
// 11: the terminal finished between the read and the stop.
|
|
184
|
-
if (killed.status !== 0 && killed.status !== 11) {
|
|
185
|
-
return `hc kill ${item.name} failed: ${said(killed)}`;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
const removed = call("hc", ["rm", item.name]);
|
|
189
|
-
// 3: the terminal is gone already.
|
|
190
|
-
return removed.status === 0 || removed.status === 3 ? undefined : `hc rm ${item.name} failed: ${said(removed)}`;
|
|
154
|
+
return (item.stop === true ? stopTerminal(item.name) : undefined) ?? removeTerminal(item.name);
|
|
191
155
|
}
|
|
192
156
|
catch (error) {
|
|
193
|
-
if (error instanceof
|
|
157
|
+
if (error instanceof CannotRun) {
|
|
194
158
|
return error.message;
|
|
195
159
|
}
|
|
196
160
|
throw error;
|
package/dist/sweep-trees.d.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
/**
|
|
2
|
-
export declare function
|
|
1
|
+
/** The forced delete of `branch`, the dev's to run. */
|
|
2
|
+
export declare function forcedDelete(branch: string): string;
|
|
3
|
+
/** The forced removal of the worktree at `path`, the dev's to run; git wants --force twice for one still locked. */
|
|
4
|
+
export declare function forcedRemoval(path: string, locked?: boolean): string;
|
|
5
|
+
/** The unlock of the worktree at `path`, the dev's to run. */
|
|
6
|
+
export declare function unlockCommand(path: string): string;
|
|
7
|
+
/** The commit the local `branch` points at, or undefined once it is gone. */
|
|
8
|
+
export declare function tipOf(root: string, branch: string): string | undefined;
|
|
3
9
|
/** The local branches of the repository at `root`. */
|
|
4
10
|
export declare function branchesOf(root: string): string[];
|
|
5
11
|
export type Tree = {
|
|
@@ -9,14 +15,24 @@ export type Tree = {
|
|
|
9
15
|
lock: string | undefined;
|
|
10
16
|
prunable: boolean;
|
|
11
17
|
};
|
|
12
|
-
/** Every worktree of the repository, the main checkout first. */
|
|
18
|
+
/** Every worktree of the repository, the main checkout first; -z leaves a path or a lock's reason unquoted. */
|
|
13
19
|
export declare function worktreesOf(root: string): Tree[];
|
|
14
20
|
/** The files `git status` lists in the worktree at `path`, ignored ones aside, or why it cannot be read. */
|
|
15
21
|
export declare function dirtyFiles(path: string): string[] | {
|
|
16
22
|
error: string;
|
|
17
23
|
};
|
|
24
|
+
/** What in the worktree at `path` a removal could lose, a paused operation or a file git status lists, or null. */
|
|
25
|
+
export declare function whyUnclean(path: string, locked: boolean): {
|
|
26
|
+
reason: string;
|
|
27
|
+
command: string | null;
|
|
28
|
+
} | null;
|
|
18
29
|
/** The operation paused in the worktree at `path`: its git dir holds the state git resumes it from. */
|
|
19
30
|
export declare function pausedOperation(path: string): string | undefined;
|
|
31
|
+
/** The branch git keeps from a delete for the worktree `tree`, and why: checked out there, or a rebase of it paused there. */
|
|
32
|
+
export declare function heldBranch(tree: Tree): {
|
|
33
|
+
branch: string;
|
|
34
|
+
reason: string;
|
|
35
|
+
} | undefined;
|
|
20
36
|
/** The dev's way off `branch`, checked out in the main checkout at `root`: null until it is Landed and nothing there could be lost. */
|
|
21
37
|
export declare function switchOff(root: string, base: {
|
|
22
38
|
name: string;
|