dw-sdlc 0.13.0 → 0.15.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/dist/landed.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Whether a local branch is Landed: its work is on the base, as the guard's `guard_landed`
2
2
  // (plugins/sdlc/hooks/lib-git.sh) judges it. The guard keeps its own bash version; docs/adr/0015 says why there
3
3
  // are two, and the shared table of scenarios, plugins/sdlc/test/landed-scenarios.ts, holds them to one meaning.
4
- import { spawnSync } from "node:child_process";
4
+ import { exec, SLOW_LIMIT_MS } from "./exec.js";
5
5
  import { repositoryFreeEnv } from "./git-env.js";
6
6
  /** The base commits read, newest first: a branch that forked further back is kept rather than read for seconds. */
7
7
  const SCAN = 5000;
@@ -9,18 +9,17 @@ const SCAN = 5000;
9
9
  function localEnv() {
10
10
  return { ...repositoryFreeEnv(), GIT_NO_LAZY_FETCH: "1" };
11
11
  }
12
- function git(root, args, input) {
13
- const result = spawnSync("git", ["-C", root, ...args], {
14
- encoding: "utf8",
12
+ function git(root, args, input, limitMs) {
13
+ const result = exec("git", ["-C", root, ...args], {
15
14
  env: localEnv(),
16
- input,
17
- maxBuffer: 64 * 1024 * 1024
15
+ ...(input === undefined ? {} : { input }),
16
+ ...(limitMs === undefined ? {} : { limitMs })
18
17
  });
19
18
  return { status: result.status ?? 1, stdout: result.stdout ?? "" };
20
19
  }
21
20
  /** What `git <args>` prints, or undefined when it exits non-zero. */
22
- function answer(root, args, input) {
23
- const ran = git(root, args, input);
21
+ function answer(root, args, input, limitMs) {
22
+ const ran = git(root, args, input, limitMs);
24
23
  return ran.status === 0 ? ran.stdout.trim() : undefined;
25
24
  }
26
25
  /** The patch-id of the whole diff from `from` to `to`; undefined for an empty diff or one git could not finish. */
@@ -44,10 +43,9 @@ function baseHoldsPatch(root, range, patch) {
44
43
  "git patch-id --verbatim"
45
44
  ].join(" | ")
46
45
  ].join("; ");
47
- const result = spawnSync("bash", ["-c", pipeline, "bash", root, String(SCAN), range], {
48
- encoding: "utf8",
46
+ const result = exec("bash", ["-c", pipeline, "bash", root, String(SCAN), range], {
49
47
  env: localEnv(),
50
- maxBuffer: 64 * 1024 * 1024
48
+ limitMs: SLOW_LIMIT_MS
51
49
  });
52
50
  if (result.status !== 0) {
53
51
  return false;
@@ -77,7 +75,7 @@ export function isCommitLanded(root, baseRef, tip) {
77
75
  return false;
78
76
  }
79
77
  const range = `${fork}..${baseRef}`;
80
- const trees = answer(root, ["log", "--format=%T", `--max-count=${SCAN}`, range]) ?? "";
78
+ const trees = answer(root, ["log", "--format=%T", `--max-count=${SCAN}`, range], undefined, SLOW_LIMIT_MS) ?? "";
81
79
  if (trees.split("\n").includes(tree)) {
82
80
  return true;
83
81
  }
@@ -3,7 +3,7 @@ import { type PrState, REVIEW } from "./state.ts";
3
3
  export { REVIEW };
4
4
  export type Status = "act" | "wait" | "park" | "done";
5
5
  export type Actor = "script" | "agent" | "dev";
6
- export type Action = "done" | "stop" | "wait" | "fix-conflicts" | "update-branch" | "fix-ci" | "address-review" | "review" | "review-limit" | "mark-reviewed" | "unverified" | "ci-stuck" | "awaiting-reply" | "mark-ready" | "awaiting-author" | "awaiting-approval" | "ready-to-merge" | "merge";
6
+ export type Action = "done" | "stop" | "wait" | "fix-conflicts" | "update-branch" | "fix-ci" | "address-review" | "review" | "review-limit" | "mark-reviewed" | "unverified" | "ci-stuck" | "awaiting-reply" | "awaiting-review" | "mark-ready" | "awaiting-author" | "awaiting-approval" | "partial-read" | "ready-to-merge" | "merge";
7
7
  /** The Actions the script performs itself, each one gh command. */
8
8
  export type ScriptAction = "update-branch" | "mark-ready" | "mark-reviewed" | "merge";
9
9
  /**
package/dist/pr/decide.js CHANGED
@@ -46,6 +46,20 @@ function failure({ name, link, log }) {
46
46
  function threads(pr, lastByViewer) {
47
47
  return pr.unresolvedThreads.filter((thread) => thread.lastByViewer === lastByViewer);
48
48
  }
49
+ /**
50
+ * Whether the decision is changes requested and every reviewer asking for them has been answered by a
51
+ * push. One review still unanswered, or none in the reply to judge, keeps the agent's turn.
52
+ */
53
+ function reviewAnswered({ reviewDecision, changeRequests }) {
54
+ return (reviewDecision === "CHANGES_REQUESTED" &&
55
+ changeRequests.length > 0 &&
56
+ changeRequests.every(({ answered }) => answered));
57
+ }
58
+ /** The names, `a`, `a and b` or `a, b and c`. */
59
+ function namesOf(names) {
60
+ const last = names.at(-1);
61
+ return names.length < 2 ? names.join("") : `${names.slice(0, -1).join(", ")} and ${last}`;
62
+ }
49
63
  function listed({ id, path, line, author, body }) {
50
64
  return { id, path, line, author, body };
51
65
  }
@@ -200,7 +214,7 @@ const ROWS = [
200
214
  })
201
215
  },
202
216
  {
203
- when: (pr) => pr.reviewDecision === "CHANGES_REQUESTED",
217
+ when: (pr) => pr.reviewDecision === "CHANGES_REQUESTED" && !reviewAnswered(pr),
204
218
  answer: () => ({ status: "act", action: "address-review", actor: "agent", reason: "changes requested" })
205
219
  },
206
220
  {
@@ -273,6 +287,17 @@ const ROWS = [
273
287
  details: { threads: threads(pr, true).map(listed) }
274
288
  })
275
289
  },
290
+ {
291
+ // After awaiting-reply, so the pull request's own Review and CI run first and the reviewer gets a
292
+ // verified fix. Only the reviewer can act: their review stands until they review again or dismiss it.
293
+ when: reviewAnswered,
294
+ answer: (pr) => ({
295
+ status: "park",
296
+ action: "awaiting-review",
297
+ actor: "dev",
298
+ reason: `changes requested by ${namesOf(pr.changeRequests.map(({ reviewer }) => reviewer))}, answered by a push: the next review is the reviewer's`
299
+ })
300
+ },
276
301
  {
277
302
  when: (pr) => pr.draft && pr.viewerDidAuthor,
278
303
  answer: () => ({ status: "act", action: "mark-ready", actor: "script", reason: "still a draft" })
@@ -295,6 +320,17 @@ const ROWS = [
295
320
  reason: "an approving review is required"
296
321
  })
297
322
  },
323
+ {
324
+ // A failure or an open thread past what the read holds shows in no row above.
325
+ when: (pr) => pr.cutOff.length > 0,
326
+ answer: (pr, _, policy) => ({
327
+ status: "park",
328
+ action: "partial-read",
329
+ actor: "dev",
330
+ reason: `the read holds only ${pr.cutOff.join(" and ")}: the merge is the dev's`,
331
+ command: devCommand(pr, "merge", policy)
332
+ })
333
+ },
298
334
  {
299
335
  when: (pr) => !pr.viewerDidAuthor,
300
336
  answer: (pr, _, policy) => readyToMerge(pr, policy, "another author's pull request: the merge is the dev's"),
package/dist/pr/read.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // The read the pr commands and recap make: gh as a child process, its auth, the pull request a command names,
2
2
  // its state from one GraphQL request with the compares that tell whether its head carries an earlier Review
3
3
  // and that size its changes, and beside them the default branch and the clock the decision reads.
4
- import { spawnSync } from "node:child_process";
4
+ import { exec, TimedOut } from "../exec.js";
5
5
  import { behindOf, changedOf, changesOf, compareArgs, sameChanges } from "./delta.js";
6
6
  import { awaitsReview, parseDefaultBranch, parsePr, parseView, QUERY, ReplyError } from "./state.js";
7
7
  /** What stops a pr command or recap before it answers: exit 2, the problem and its remedy on one line. */
@@ -42,8 +42,20 @@ function authProblem(stderr) {
42
42
  const marked = stderr.split("\n").find((line) => line.trimStart().startsWith("X "));
43
43
  return marked === undefined ? firstLine(stderr) : marked.trim().slice(2);
44
44
  }
45
+ /** `gh args` as a child process; past its limit it stops the command as any other failed read does. */
46
+ function reachGh(args) {
47
+ try {
48
+ return exec("gh", args);
49
+ }
50
+ catch (error) {
51
+ if (error instanceof TimedOut) {
52
+ throw new CannotRun(error.message, REMEDIES.read);
53
+ }
54
+ throw error;
55
+ }
56
+ }
45
57
  export function gh(args) {
46
- const result = spawnSync("gh", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
58
+ const result = reachGh(args);
47
59
  if (result.error !== undefined) {
48
60
  if ("code" in result.error && result.error.code === "ENOENT") {
49
61
  throw new CannotRun("gh is not on PATH", REMEDIES.missing);
@@ -10,7 +10,7 @@ export declare const MARK_STATE = "failure";
10
10
  export declare const READ_MARK = "read, findings open";
11
11
  /** How the description of the success the dev's `--accept` posts reads: the head, taken with its Findings open. */
12
12
  export declare const ACCEPTED = "accepted by the dev, findings open";
13
- export declare const QUERY = "query($owner: String!, $name: String!, $number: Int!) {\n repository(owner: $owner, name: $name) {\n pullRequest(number: $number) {\n url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName baseRefName baseRefOid\n baseRef { target { oid } } viewerDidAuthor mergeCommit { oid }\n reviewThreads(first: 100) { nodes { id isResolved isOutdated path line\n comments(first: 1) { nodes { author { login } body } }\n latest: comments(last: 1) { nodes { viewerDidAuthor } } } }\n commits(last: 1) { nodes { commit { committedDate statusCheckRollup {\n contexts(first: 50) { nodes {\n ... on CheckRun { name status conclusion detailsUrl startedAt isRequired(pullRequestNumber: $number) }\n ... on StatusContext { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } } } } }\n history: commits(last: 100) { nodes { commit { oid parents { totalCount } status { context(name: \"dw-sdlc/review\") { state description } } } } }\n }\n }\n}";
13
+ export declare const QUERY = "query($owner: String!, $name: String!, $number: Int!) {\n repository(owner: $owner, name: $name) {\n pullRequest(number: $number) {\n url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName baseRefName baseRefOid\n baseRef { target { oid } } viewerDidAuthor mergeCommit { oid }\n latestReviews(first: 100) { nodes { state author { login } commit { oid } } }\n reviewThreads(first: 100) { pageInfo { hasNextPage } nodes { id isResolved isOutdated path line\n comments(first: 1) { nodes { author { login } body } }\n latest: comments(last: 1) { nodes { viewerDidAuthor } } } }\n commits(last: 1) { nodes { commit { committedDate statusCheckRollup {\n contexts(first: 50) { pageInfo { hasNextPage } nodes {\n ... on CheckRun { name status conclusion detailsUrl startedAt isRequired(pullRequestNumber: $number) }\n ... on StatusContext { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } } } } }\n history: commits(last: 100) { nodes { commit { oid parents { totalCount } status { context(name: \"dw-sdlc/review\") { state description } } } } }\n }\n }\n}";
14
14
  export type Bucket = "pass" | "fail" | "pending" | "skipping";
15
15
  export type CiCheck = {
16
16
  name: string;
@@ -33,6 +33,11 @@ export type Thread = {
33
33
  declare const STATES: readonly ["OPEN", "CLOSED", "MERGED"];
34
34
  declare const MERGEABLE: readonly ["MERGEABLE", "CONFLICTING", "UNKNOWN"];
35
35
  declare const REVIEW_DECISIONS: readonly ["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"];
36
+ /** A reviewer whose latest review requests changes, and whether a push has answered it. */
37
+ export type ChangeRequest = {
38
+ reviewer: string;
39
+ answered: boolean;
40
+ };
36
41
  export type PrState = {
37
42
  url: string;
38
43
  repo: string;
@@ -57,6 +62,10 @@ export type PrState = {
57
62
  checks: CiCheck[];
58
63
  /** The threads neither resolved nor outdated. */
59
64
  unresolvedThreads: Thread[];
65
+ /** Each reviewer's latest review that requests changes, in the order GitHub lists them. */
66
+ changeRequests: ChangeRequest[];
67
+ /** What the read holds of each list with more after it, as `the first 50 checks`; empty when it holds all. */
68
+ cutOff: string[];
60
69
  /** The oids of the pull request's last 100 commits, oldest first, the head last. */
61
70
  commits: string[];
62
71
  /**
package/dist/pr/state.js CHANGED
@@ -12,16 +12,20 @@ export const MARK_STATE = "failure";
12
12
  export const READ_MARK = "read, findings open";
13
13
  /** How the description of the success the dev's `--accept` posts reads: the head, taken with its Findings open. */
14
14
  export const ACCEPTED = "accepted by the dev, findings open";
15
+ /** How many checks and review threads the one read holds; each list's `pageInfo` says when more follow. */
16
+ const CHECKS_READ = 50;
17
+ const THREADS_READ = 100;
15
18
  export const QUERY = `query($owner: String!, $name: String!, $number: Int!) {
16
19
  repository(owner: $owner, name: $name) {
17
20
  pullRequest(number: $number) {
18
21
  url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName baseRefName baseRefOid
19
22
  baseRef { target { oid } } viewerDidAuthor mergeCommit { oid }
20
- reviewThreads(first: 100) { nodes { id isResolved isOutdated path line
23
+ latestReviews(first: 100) { nodes { state author { login } commit { oid } } }
24
+ reviewThreads(first: ${THREADS_READ}) { pageInfo { hasNextPage } nodes { id isResolved isOutdated path line
21
25
  comments(first: 1) { nodes { author { login } body } }
22
26
  latest: comments(last: 1) { nodes { viewerDidAuthor } } } }
23
27
  commits(last: 1) { nodes { commit { committedDate statusCheckRollup {
24
- contexts(first: 50) { nodes {
28
+ contexts(first: ${CHECKS_READ}) { pageInfo { hasNextPage } nodes {
25
29
  ... on CheckRun { name status conclusion detailsUrl startedAt isRequired(pullRequestNumber: $number) }
26
30
  ... on StatusContext { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } } } } }
27
31
  history: commits(last: 100) { nodes { commit { oid parents { totalCount } status { context(name: "${REVIEW}") { state description } } } } }
@@ -131,6 +135,10 @@ function nodes(node, key) {
131
135
  }
132
136
  return value.map((item, index) => object(item, `${at}[${index}]`));
133
137
  }
138
+ /** Whether a connection has more nodes after the ones the reply holds. */
139
+ function hasMore(node, key) {
140
+ return boolean(child(child(node, key), "pageInfo"), "hasNextPage");
141
+ }
134
142
  /** The first object of a connection's `nodes`; an empty connection does not parse. */
135
143
  function firstNode(node, key) {
136
144
  const [first] = nodes(node, key);
@@ -246,6 +254,27 @@ function lastReadOf(history, head) {
246
254
  }
247
255
  return { commit: last.oid, clean: last.read === "clean", mergesOnly: mergesOnlyAfter(index) };
248
256
  }
257
+ /**
258
+ * Whether a non-merge commit follows `commit` in the history, oldest first, as `lastReadOf` judges a read
259
+ * mark. A commit outside the history, after a force-push or past the last 100, is not answered.
260
+ */
261
+ function answeredAfter(history, commit) {
262
+ const index = history.findIndex(({ oid }) => oid === commit);
263
+ return index >= 0 && history.slice(index + 1).some(({ merge }) => !merge);
264
+ }
265
+ /** The reviewers whose latest review requests changes, each judged against the history. */
266
+ function changeRequestsOf(pr, history) {
267
+ return nodes(pr, "latestReviews")
268
+ .filter((review) => string(review, "state") === "CHANGES_REQUESTED")
269
+ .map((review) => {
270
+ const author = childOrNull(review, "author");
271
+ const commit = childOrNull(review, "commit");
272
+ return {
273
+ reviewer: author === null ? "ghost" : string(author, "login"),
274
+ answered: answeredAfter(history, commit === null ? null : string(commit, "oid"))
275
+ };
276
+ });
277
+ }
249
278
  function readMarksOf(history) {
250
279
  const success = history.findLastIndex(({ posted }) => posted === "clean" || posted === "off");
251
280
  const count = history.slice(success + 1).filter(({ posted }) => posted === "findings").length;
@@ -293,6 +322,11 @@ export function parsePr(reply, repo) {
293
322
  unresolvedThreads: nodes(pr, "reviewThreads")
294
323
  .filter((thread) => !boolean(thread, "isResolved") && !boolean(thread, "isOutdated"))
295
324
  .map(threadOf),
325
+ changeRequests: changeRequestsOf(pr, history),
326
+ cutOff: [
327
+ ...(rollup !== null && hasMore(rollup, "contexts") ? [`the first ${CHECKS_READ} checks`] : []),
328
+ ...(hasMore(pr, "reviewThreads") ? [`the first ${THREADS_READ} threads`] : [])
329
+ ],
296
330
  commits: history.map(({ oid }) => oid),
297
331
  lastRead: lastReadOf(history, head),
298
332
  marks: readMarksOf(history)
@@ -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
- import { spawnSync } from "node:child_process";
4
3
  import { DEFAULT_GUARDRAILS, matchesEnvGlob, resolveConfig } from "./agentic-config.js";
4
+ import { exec } from "./exec.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. */
@@ -57,7 +57,7 @@ const GH_DENY = ["gh repo delete", "gh repo delete *"];
57
57
  export const RETIRED_DENY = ["Bash(git branch -D *)", "Bash(rtk git branch -D *)"];
58
58
  const ENV_NAME = /^\.env(\..+)?$|^\.envrc$/;
59
59
  function git(root, args) {
60
- const result = spawnSync("git", ["-C", root, ...args], { encoding: "utf8" });
60
+ const result = exec("git", ["-C", root, ...args]);
61
61
  return result.status === 0 ? result.stdout.trim() : undefined;
62
62
  }
63
63
  function listedIn(globs, file) {
@@ -0,0 +1,89 @@
1
+ import { type Worker } from "./worker/herdr.ts";
2
+ import { type LockVerdict } from "./worktree-lock.ts";
3
+ /** A live terminal of another session, or the sweeping one's own, with the place herdr shows it at. */
4
+ type Seen = {
5
+ address: string;
6
+ name: string;
7
+ folder: string;
8
+ place: string;
9
+ };
10
+ /** A Worker with the pid of the program `hc get` reports in its terminal, which a Claude Code lock names. */
11
+ export type Located = Worker & {
12
+ pid: number | undefined;
13
+ };
14
+ export type Terminals = {
15
+ own: Seen | undefined;
16
+ workers: Located[];
17
+ others: Seen[];
18
+ };
19
+ /** What holds a worktree: the terminal of this session, another session's, or a Worker of this one. */
20
+ export type Holder = {
21
+ role: "own";
22
+ terminal: Seen;
23
+ } | {
24
+ role: "other";
25
+ terminal: Seen;
26
+ } | {
27
+ role: "worker";
28
+ worker: Located;
29
+ };
30
+ /** The terminal to stop and remove, or only to remove once it has finished. */
31
+ export type TerminalPlan = {
32
+ kind: "terminal";
33
+ name: string;
34
+ reason: string;
35
+ stop: boolean;
36
+ tree?: string;
37
+ };
38
+ /** The address of the terminal the Sweep runs in, or undefined outside herdr. */
39
+ export declare function sweepingTerminal(): string | undefined;
40
+ /**
41
+ * Reads herdr for the sweeping terminal `own`: its Workers, finished ones included, and the other live
42
+ * terminals. An `hc` that fails stops the Sweep, since a terminal it cannot see must not count as absent.
43
+ */
44
+ export declare function readTerminals(own: string): Terminals;
45
+ /** A Worker whose terminal is live but whose folder is gone, with no worktree of `roots` around it, could commit nothing more. */
46
+ export declare function workerWithoutFolder({ workers }: Terminals, roots: string[]): Worker[];
47
+ /** The terminals in the folder of each of `trees`; the main checkout `main` takes those not in a linked one. */
48
+ export declare function holdersOf({ own, workers, others }: Terminals, main: string, trees: string[]): Map<string, Holder[]>;
49
+ /** How the Sweep says a Worker that is working, blocked or yet to report is, or undefined for one that is done. */
50
+ export declare function atWork({ status }: Worker): string | undefined;
51
+ /** What the terminals in a worktree keep it for, or null when only Workers at the end of their turn are in it. */
52
+ export declare function whyHeld(holders: Holder[]): {
53
+ reason: string;
54
+ command: string | null;
55
+ } | null;
56
+ /** The terminals of the Workers done in a worktree that goes, to stop and remove with it. */
57
+ export declare function stopsOf(holders: Holder[], tree: string): TerminalPlan[];
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 removeTerminal(item: {
60
+ name: string;
61
+ stop?: boolean;
62
+ }): string | undefined;
63
+ /** A Worker the Sweep keeps, with why. */
64
+ export type KeptWorker = {
65
+ name: string;
66
+ reason: string;
67
+ };
68
+ /** The Workers no worktree of this Sweep decides: a finished one is removed, one whose folder is gone goes under the status rule. */
69
+ export declare function outsideWorktrees(terminals: Terminals, roots: string[]): {
70
+ remove: TerminalPlan[];
71
+ keep: KeptWorker[];
72
+ };
73
+ /** The command that stops or removes the terminal of a planned terminal by hand. */
74
+ export declare function byHand(item: {
75
+ name: string;
76
+ stop?: boolean;
77
+ }): string;
78
+ /** The Worker in `holders` whose program is the process a live lock names, when it may be stopped. */
79
+ export declare function lockHolder(pid: number, holders: Holder[]): Located | undefined;
80
+ /** The lock of the worktree at `path` read again once its Worker is stopped, given the program a moment to end. */
81
+ export declare function lockAfterStop(root: string, path: string): LockVerdict;
82
+ type KeptTerminal = {
83
+ name: string;
84
+ reason: string;
85
+ command: string | null;
86
+ };
87
+ /** The terminals in a worktree that stays which the Sweep lists as kept: Workers, and another session's terminals. */
88
+ export declare function keptIn(holders: Holder[], tree: string): KeptTerminal[];
89
+ export {};
@@ -0,0 +1,262 @@
1
+ // What a Sweep does with herdr terminals: it stops and removes only a Worker of its own whose turn has ended,
2
+ // in a worktree that goes in the same Sweep or is already gone. docs/adr/0015 sets the bounds.
3
+ import { existsSync, realpathSync } from "node:fs";
4
+ import { isObject, parseObject } from "./json-text.js";
5
+ import { shellLine } from "./pr/decide.js";
6
+ import { CannotRun } from "./pr/read.js";
7
+ import { worktreesOf } from "./sweep-trees.js";
8
+ import { call, hcObject, isLive, Refusal, said, workersOf } from "./worker/herdr.js";
9
+ import { lockVerdict } from "./worktree-lock.js";
10
+ /** Statuses of a Worker that is not at the end of its turn, with how the Sweep says it. */
11
+ const AT_WORK = {
12
+ working: "working",
13
+ blocked: "blocked, waiting for the dev",
14
+ unknown: "yet to report"
15
+ };
16
+ /** The address of the terminal the Sweep runs in, or undefined outside herdr. */
17
+ export function sweepingTerminal() {
18
+ const id = process.env.HC_TERMINAL_ID;
19
+ return id === undefined || id === "" ? undefined : id;
20
+ }
21
+ function word(value) {
22
+ return typeof value === "string" && value !== "" ? value : undefined;
23
+ }
24
+ /**
25
+ * Each live terminal with the folder it works in, `hc get`'s foreground. An `hc get` that fails stops the
26
+ * Sweep, since a terminal in a folder it cannot see must not count as absent; one gone since the listing is dropped.
27
+ */
28
+ function foldersOf(list) {
29
+ return list.flatMap((worker) => {
30
+ if (!isLive(worker)) {
31
+ return [{ ...worker, pid: undefined }];
32
+ }
33
+ const got = call("hc", ["get", worker.address, "--json"]);
34
+ if (got.status === 3) {
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 }];
44
+ });
45
+ }
46
+ /** The live terminals `hc ls` lists, each with the folder it works in. */
47
+ function liveTerminals() {
48
+ const { terminals } = hcObject(["ls", "--json"]);
49
+ if (!Array.isArray(terminals)) {
50
+ throw new Refusal("hc ls answered no list of terminals", "check hc --version");
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");
57
+ }
58
+ places.set(address, word(entry.where));
59
+ const name = word(entry.name) ?? address;
60
+ return [{ name, address, state: "live", status: word(entry.status) ?? "unknown", folder: entry.cwd }];
61
+ });
62
+ return foldersOf(listed).map(({ name, address, folder }) => ({
63
+ name,
64
+ address,
65
+ folder,
66
+ place: places.get(address) ?? folder
67
+ }));
68
+ }
69
+ /**
70
+ * Reads herdr for the sweeping terminal `own`: its Workers, finished ones included, and the other live
71
+ * terminals. An `hc` that fails stops the Sweep, since a terminal it cannot see must not count as absent.
72
+ */
73
+ export function readTerminals(own) {
74
+ try {
75
+ const workers = foldersOf(workersOf(own, true));
76
+ const live = liveTerminals();
77
+ const addresses = new Set(workers.map(({ address }) => address));
78
+ return {
79
+ own: live.find(({ address }) => address === own),
80
+ workers,
81
+ others: live.filter(({ address }) => address !== own && !addresses.has(address))
82
+ };
83
+ }
84
+ catch (error) {
85
+ if (error instanceof Refusal) {
86
+ throw new CannotRun(error.message, error.remedy ?? "check hc, then call again");
87
+ }
88
+ throw error;
89
+ }
90
+ }
91
+ function canonical(path) {
92
+ try {
93
+ return realpathSync(path);
94
+ }
95
+ catch {
96
+ return path;
97
+ }
98
+ }
99
+ function isInside(folder, root) {
100
+ return folder === root || folder.startsWith(root.endsWith("/") ? root : `${root}/`);
101
+ }
102
+ /** The deepest of `roots` whose folder holds `folder`, or undefined. */
103
+ function treeOf(folder, roots) {
104
+ const real = canonical(folder);
105
+ const holding = roots.filter((root) => isInside(folder, root) || isInside(real, canonical(root)));
106
+ return holding.toSorted((a, b) => b.length - a.length)[0];
107
+ }
108
+ /** A Worker whose terminal is live but whose folder is gone, with no worktree of `roots` around it, could commit nothing more. */
109
+ export function workerWithoutFolder({ workers }, roots) {
110
+ const standing = roots.filter((root) => existsSync(root));
111
+ return workers.filter((worker) => isLive(worker) && !existsSync(worker.folder) && treeOf(worker.folder, standing) === undefined);
112
+ }
113
+ /** The terminals in the folder of each of `trees`; the main checkout `main` takes those not in a linked one. */
114
+ export function holdersOf({ own, workers, others }, main, trees) {
115
+ const held = new Map();
116
+ const standing = [main, ...trees].filter((root) => existsSync(root));
117
+ const place = (folder, holder) => {
118
+ const tree = treeOf(folder, standing);
119
+ if (tree !== undefined && tree !== main) {
120
+ held.set(tree, [...(held.get(tree) ?? []), holder]);
121
+ }
122
+ };
123
+ if (own !== undefined) {
124
+ place(own.folder, { role: "own", terminal: own });
125
+ }
126
+ for (const terminal of others) {
127
+ place(terminal.folder, { role: "other", terminal });
128
+ }
129
+ for (const worker of workers) {
130
+ if (isLive(worker)) {
131
+ place(worker.folder, { role: "worker", worker });
132
+ }
133
+ }
134
+ return held;
135
+ }
136
+ /** How the Sweep says a Worker that is working, blocked or yet to report is, or undefined for one that is done. */
137
+ export function atWork({ status }) {
138
+ return AT_WORK[status];
139
+ }
140
+ /** What the terminals in a worktree keep it for, or null when only Workers at the end of their turn are in it. */
141
+ export function whyHeld(holders) {
142
+ const reasons = [];
143
+ const kills = [];
144
+ for (const holder of holders) {
145
+ if (holder.role === "own") {
146
+ reasons.push(`the terminal this Sweep runs in, ${holder.terminal.address}, is in it`);
147
+ }
148
+ else if (holder.role === "other") {
149
+ const { address, place } = holder.terminal;
150
+ reasons.push(`another session's terminal ${address} is in it, at ${place}`);
151
+ kills.push(shellLine(["hc", "kill", address]));
152
+ }
153
+ else {
154
+ const at = atWork(holder.worker);
155
+ if (at !== undefined) {
156
+ reasons.push(`its Worker ${holder.worker.name} (${holder.worker.address}) is ${at}`);
157
+ }
158
+ }
159
+ }
160
+ return reasons.length === 0
161
+ ? null
162
+ : { reason: reasons.join("; "), command: kills.length === 0 ? null : kills.join(" && ") };
163
+ }
164
+ /** The terminals of the Workers done in a worktree that goes, to stop and remove with it. */
165
+ export function stopsOf(holders, tree) {
166
+ return holders.flatMap((holder) => holder.role === "worker"
167
+ ? [
168
+ {
169
+ kind: "terminal",
170
+ name: holder.worker.address,
171
+ reason: `Worker ${holder.worker.name} is ${holder.worker.status}: stopped, then removed with its worktree`,
172
+ stop: true,
173
+ tree
174
+ }
175
+ ]
176
+ : []);
177
+ }
178
+ /** Stops, if `item` asks, and removes its terminal; the words of the `hc` that refused, or undefined when it is gone. */
179
+ export function removeTerminal(item) {
180
+ try {
181
+ if (item.stop === true) {
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)}`;
191
+ }
192
+ catch (error) {
193
+ if (error instanceof Refusal) {
194
+ return error.message;
195
+ }
196
+ throw error;
197
+ }
198
+ }
199
+ /** The Workers no worktree of this Sweep decides: a finished one is removed, one whose folder is gone goes under the status rule. */
200
+ export function outsideWorktrees(terminals, roots) {
201
+ const remove = [];
202
+ const keep = [];
203
+ for (const worker of terminals.workers.filter((one) => !isLive(one))) {
204
+ remove.push({ kind: "terminal", name: worker.address, reason: `Worker ${worker.name} is finished`, stop: false });
205
+ }
206
+ for (const worker of workerWithoutFolder(terminals, roots)) {
207
+ const at = atWork(worker);
208
+ if (at === undefined) {
209
+ const reason = `Worker ${worker.name} is ${worker.status} and its folder ${worker.folder} is gone: stopped, then removed`;
210
+ remove.push({ kind: "terminal", name: worker.address, reason, stop: true });
211
+ }
212
+ else {
213
+ keep.push({ name: worker.address, reason: `Worker ${worker.name} is ${at}, its folder ${worker.folder} is gone` });
214
+ }
215
+ }
216
+ return { remove, keep };
217
+ }
218
+ /** The command that stops or removes the terminal of a planned terminal by hand. */
219
+ export function byHand(item) {
220
+ return item.stop === true
221
+ ? `${shellLine(["hc", "kill", item.name])}; ${shellLine(["hc", "rm", item.name])}`
222
+ : shellLine(["hc", "rm", item.name]);
223
+ }
224
+ /** The Worker in `holders` whose program is the process a live lock names, when it may be stopped. */
225
+ export function lockHolder(pid, holders) {
226
+ for (const holder of holders) {
227
+ if (holder.role === "worker" && holder.worker.pid === pid && atWork(holder.worker) === undefined) {
228
+ return holder.worker;
229
+ }
230
+ }
231
+ return undefined;
232
+ }
233
+ /** The lock of the worktree at `path` read again once its Worker is stopped, given the program a moment to end. */
234
+ export function lockAfterStop(root, path) {
235
+ let verdict = { state: "none" };
236
+ for (let attempt = 0; attempt < 15; attempt++) {
237
+ verdict = lockVerdict(path, worktreesOf(root).find((tree) => tree.path === path)?.lock);
238
+ if (verdict.state !== "stays") {
239
+ return verdict;
240
+ }
241
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
242
+ }
243
+ return verdict;
244
+ }
245
+ /** The terminals in a worktree that stays which the Sweep lists as kept: Workers, and another session's terminals. */
246
+ export function keptIn(holders, tree) {
247
+ return holders.flatMap((holder) => {
248
+ if (holder.role === "other") {
249
+ const { address, place } = holder.terminal;
250
+ const reason = `another session's terminal, in the worktree at ${tree}, at ${place}`;
251
+ return [{ name: address, reason, command: shellLine(["hc", "kill", address]) }];
252
+ }
253
+ if (holder.role === "worker") {
254
+ const { name, address, status } = holder.worker;
255
+ const at = atWork(holder.worker);
256
+ return [
257
+ { name: address, reason: `Worker ${name} is ${at ?? `${status}, but its worktree stays`}`, command: null }
258
+ ];
259
+ }
260
+ return [];
261
+ });
262
+ }
@@ -0,0 +1,17 @@
1
+ /** `git <args>` at `root`, or a stop naming its problem. */
2
+ export declare function must(root: string, args: string[]): string;
3
+ export type Tree = {
4
+ path: string;
5
+ head: string;
6
+ branch: string | undefined;
7
+ lock: string | undefined;
8
+ prunable: boolean;
9
+ };
10
+ /** Every worktree of the repository, the main checkout first. */
11
+ export declare function worktreesOf(root: string): Tree[];
12
+ /** The files `git status` lists in the worktree at `path`, ignored ones aside, or why it cannot be read. */
13
+ export declare function dirtyFiles(path: string): string[] | {
14
+ error: string;
15
+ };
16
+ /** The operation paused in the worktree at `path`: its git dir holds the state git resumes it from. */
17
+ export declare function pausedOperation(path: string): string | undefined;