@gavin-hu/my-pi-agent 0.21.0 → 0.22.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 +46 -31
- package/README.zh-CN.md +40 -26
- package/assets/screens/board.png +0 -0
- package/assets/screens/chrome.png +0 -0
- package/assets/screens/file-browse.png +0 -0
- package/assets/screens/plan-review.png +0 -0
- package/assets/screens/plan.png +0 -0
- package/docs/extensions/README.md +22 -24
- package/docs/extensions/board.md +361 -147
- package/docs/extensions/bytefly.md +152 -111
- package/docs/extensions/check.md +15 -17
- package/docs/extensions/context.md +35 -15
- package/docs/extensions/file-browse.md +30 -31
- package/docs/extensions/file-open.md +9 -11
- package/docs/extensions/file-read.md +12 -14
- package/docs/extensions/goal.md +15 -17
- package/docs/extensions/job.md +54 -42
- package/docs/extensions/memory.md +17 -19
- package/docs/extensions/plan.md +166 -126
- package/docs/extensions/question.md +16 -18
- package/docs/extensions/rewind.md +17 -19
- package/docs/extensions/serve.md +34 -18
- package/docs/extensions/session-bus.md +38 -16
- package/docs/extensions/subagent.md +23 -25
- package/docs/extensions/team.md +72 -33
- package/docs/extensions/todo.md +18 -18
- package/docs/extensions/watch.md +31 -31
- package/docs/extensions/web-access.md +2 -4
- package/docs/extensions/wechat.md +34 -35
- package/docs/extensions/worktree.md +56 -50
- package/extensions/board/commands.ts +56 -15
- package/extensions/board/format.ts +43 -24
- package/extensions/board/index.ts +5 -4
- package/extensions/board/input.ts +4 -9
- package/extensions/board/join.ts +4 -40
- package/extensions/board/lazy-page.ts +40 -0
- package/extensions/board/page.ts +13 -11
- package/extensions/board/result.ts +30 -16
- package/extensions/board/rows.ts +62 -25
- package/extensions/board/schema.ts +42 -10
- package/extensions/board/snapshot.ts +2 -1
- package/extensions/board/tone.ts +22 -0
- package/extensions/board/tools.ts +131 -44
- package/extensions/board/types.ts +36 -32
- package/extensions/board/view/card.ts +229 -0
- package/extensions/board/view/client.ts +33 -15
- package/extensions/board/view/dialog.ts +169 -6
- package/extensions/board/view/empty.ts +47 -0
- package/extensions/board/view/keys.ts +13 -2
- package/extensions/board/view/live.ts +89 -21
- package/extensions/board/view/names.ts +13 -4
- package/extensions/board/view/page.ts +23 -9
- package/extensions/board/view/render.ts +208 -271
- package/extensions/board/view/styles.ts +123 -31
- package/extensions/board/view/team.ts +297 -76
- package/extensions/bytefly/block/fallback.ts +1 -1
- package/extensions/bytefly/doctor/runtime.ts +5 -2
- package/extensions/bytefly/footer/collect/label.ts +60 -0
- package/extensions/bytefly/footer/collect/snapshot.ts +8 -1
- package/extensions/bytefly/footer/config.ts +4 -2
- package/extensions/bytefly/footer/register.ts +33 -4
- package/extensions/bytefly/footer/render/lines.ts +7 -4
- package/extensions/bytefly/footer/render/segments.ts +37 -10
- package/extensions/bytefly/footer/render/status.ts +0 -11
- package/extensions/bytefly/footer/types.ts +7 -0
- package/extensions/bytefly/header/config.ts +1 -1
- package/extensions/bytefly/index.ts +32 -6
- package/extensions/bytefly/mode/editor.ts +115 -0
- package/extensions/bytefly/panel/commands.ts +210 -120
- package/extensions/bytefly/panel/format.ts +4 -4
- package/extensions/bytefly/panel/settings.ts +73 -6
- package/extensions/bytefly/panel/tui/keys.ts +2 -6
- package/extensions/bytefly/update/cache.ts +1 -1
- package/extensions/bytefly/update/register.ts +4 -4
- package/extensions/bytefly/working/config.ts +5 -7
- package/extensions/bytefly/working/format.ts +10 -13
- package/extensions/bytefly/working/register.ts +22 -27
- package/extensions/check/commands.ts +3 -2
- package/extensions/check/{tui.ts → view/check-screens.ts} +14 -19
- package/extensions/context/commands.ts +2 -2
- package/extensions/context/{tui.ts → view/context-report.ts} +51 -19
- package/extensions/file-browse/index.ts +67 -36
- package/extensions/file-read/render.ts +4 -4
- package/extensions/goal/plan-route.ts +2 -2
- package/extensions/job/child.ts +527 -0
- package/extensions/job/clock.ts +142 -0
- package/extensions/job/commands.ts +4 -2
- package/extensions/job/config.ts +8 -1
- package/extensions/job/format.ts +22 -1
- package/extensions/job/log-writer.ts +13 -2
- package/extensions/job/registry.ts +63 -7
- package/extensions/job/render.ts +34 -0
- package/extensions/job/runtime.ts +296 -655
- package/extensions/job/session.ts +132 -19
- package/extensions/job/store.ts +7 -7
- package/extensions/job/tools.ts +3 -3
- package/extensions/job/types.ts +47 -0
- package/extensions/job/{tui.ts → view/jobs-screen.ts} +19 -73
- package/extensions/job/waiters.ts +44 -1
- package/extensions/memory/commands.ts +4 -2
- package/extensions/memory/{tui.ts → view/memory-picker.ts} +12 -12
- package/extensions/plan/commands.ts +12 -9
- package/extensions/plan/format.ts +8 -2
- package/extensions/plan/git-bash.ts +115 -16
- package/extensions/plan/index.ts +53 -52
- package/extensions/plan/plan-index.ts +4 -2
- package/extensions/plan/plans.ts +5 -54
- package/extensions/plan/policy.ts +8 -10
- package/extensions/plan/review-lazy.ts +85 -0
- package/extensions/plan/review.ts +2 -2
- package/extensions/plan/runtime.ts +49 -39
- package/extensions/plan/sections.ts +1 -1
- package/extensions/plan/steps.ts +3 -3
- package/extensions/plan/todos.ts +1 -1
- package/extensions/plan/tools/cancel.ts +27 -62
- package/extensions/plan/tools/enter.ts +55 -81
- package/extensions/plan/tools/exit.ts +221 -238
- package/extensions/plan/tools/refusal.ts +18 -0
- package/extensions/plan/tools/steps.ts +75 -92
- package/extensions/plan/tools/write.ts +77 -120
- package/extensions/plan/tools.ts +188 -30
- package/extensions/plan/tui.ts +5 -5
- package/extensions/plan/types.ts +48 -5
- package/extensions/plan/web-client.ts +3 -0
- package/extensions/plan/web-lazy.ts +53 -0
- package/extensions/plan/web-markdown.ts +9 -67
- package/extensions/plan/web-payload.ts +3 -3
- package/extensions/plan/web-router.ts +18 -9
- package/extensions/plan/web.ts +1 -12
- package/extensions/question/tools.ts +6 -2
- package/extensions/question/{tui.ts → view/question-screen.ts} +11 -11
- package/extensions/rewind/commands/commands.ts +6 -3
- package/extensions/rewind/state/runtime.ts +1 -1
- package/extensions/rewind/{view/status.ts → status.ts} +4 -4
- package/extensions/rewind/view/review-tui.ts +4 -4
- package/extensions/serve/config.ts +10 -8
- package/extensions/serve/index.ts +17 -3
- package/extensions/serve/registry.ts +26 -17
- package/extensions/session-bus/commands.ts +11 -6
- package/extensions/session-bus/join-request.ts +2 -1
- package/extensions/session-bus/mail-watch.ts +26 -17
- package/extensions/session-bus/protocol.ts +15 -3
- package/extensions/session-bus/resolve.ts +5 -7
- package/extensions/session-bus/runtime.ts +40 -12
- package/extensions/session-bus/store.ts +101 -19
- package/extensions/session-bus/types.ts +19 -0
- package/extensions/session-bus/{tui.ts → view/session-list.ts} +16 -16
- package/extensions/subagent/agents.ts +1 -1
- package/extensions/subagent/commands.ts +4 -2
- package/extensions/subagent/format.ts +15 -15
- package/extensions/subagent/index.ts +1 -1
- package/extensions/subagent/render.ts +3 -3
- package/extensions/subagent/{background-tui.ts → view/agents-board.ts} +11 -11
- package/extensions/team/commands.ts +24 -7
- package/extensions/team/dispatch.ts +18 -4
- package/extensions/team/format.ts +17 -2
- package/extensions/team/index.ts +9 -8
- package/extensions/team/schema.ts +6 -5
- package/extensions/team/tools.ts +107 -45
- package/extensions/team/types.ts +3 -2
- package/extensions/todo/commands.ts +4 -2
- package/extensions/todo/format.ts +11 -0
- package/extensions/todo/render.ts +57 -0
- package/extensions/todo/runtime.ts +1 -1
- package/extensions/todo/tools.ts +1 -1
- package/extensions/todo/view/todo-screen.ts +122 -0
- package/extensions/todo/widget.ts +47 -0
- package/extensions/watch/commands.ts +4 -2
- package/extensions/watch/format.ts +36 -7
- package/extensions/watch/render.ts +57 -0
- package/extensions/watch/runtime.ts +27 -22
- package/extensions/watch/tools.ts +4 -4
- package/extensions/watch/{tui.ts → view/watch-screen.ts} +18 -80
- package/extensions/web-access/fetch/tool.ts +12 -4
- package/extensions/wechat/commands/commands.ts +18 -14
- package/extensions/wechat/index.ts +72 -37
- package/extensions/wechat/state/credentials.ts +1 -1
- package/extensions/wechat/state/state.ts +1 -1
- package/extensions/wechat/text/status.ts +112 -0
- package/extensions/wechat/types.ts +2 -2
- package/extensions/wechat/view/status-screen.ts +4 -103
- package/extensions/worktree/commands.ts +11 -9
- package/extensions/worktree/enter.ts +2 -2
- package/extensions/worktree/exit.ts +2 -2
- package/extensions/worktree/index.ts +5 -5
- package/extensions/worktree/screen.ts +3 -3
- package/extensions/worktree/status.ts +2 -2
- package/extensions/worktree/tools.ts +113 -116
- package/lib/README.md +27 -13
- package/lib/board/actions.ts +111 -0
- package/lib/board/artifact.ts +149 -0
- package/lib/board/card.ts +163 -52
- package/lib/board/config.ts +58 -0
- package/lib/board/contract.ts +113 -0
- package/lib/board/join.ts +38 -0
- package/lib/board/lanes.ts +182 -0
- package/lib/board/lead.ts +130 -0
- package/lib/board/limits.ts +78 -0
- package/lib/board/links.ts +31 -10
- package/lib/board/page-pointer.ts +1 -1
- package/lib/board/read.ts +83 -0
- package/lib/board/reason.ts +102 -0
- package/lib/board/round-text.ts +68 -6
- package/lib/board/round.ts +31 -15
- package/lib/board/rules.ts +151 -307
- package/lib/board/seat.ts +35 -0
- package/lib/board/session.ts +136 -54
- package/lib/board/skipped.ts +22 -0
- package/lib/board/steps.ts +487 -0
- package/lib/board/store.ts +87 -30
- package/lib/board/tone.ts +92 -0
- package/lib/board/view.ts +70 -58
- package/{extensions/watch → lib}/child.ts +40 -21
- package/lib/env.ts +65 -7
- package/lib/format.ts +13 -0
- package/lib/http/index.ts +14 -12
- package/lib/http/port.ts +15 -10
- package/lib/lazy.ts +45 -0
- package/lib/plan-entry.ts +39 -10
- package/lib/plans/paths.ts +82 -0
- package/lib/policy.ts +9 -6
- package/lib/report.ts +3 -2
- package/lib/requirements.ts +8 -8
- package/lib/session-label.ts +111 -5
- package/lib/session-roster.ts +43 -1
- package/lib/session-tool.ts +5 -4
- package/lib/stray-fields.ts +7 -2
- package/lib/tool-names.ts +5 -10
- package/lib/tui/README.md +23 -8
- package/lib/tui/chrome.ts +16 -15
- package/lib/tui/cursor.ts +18 -3
- package/lib/tui/glyphs.ts +9 -7
- package/lib/tui/tokens.ts +33 -2
- package/lib/tui/tool.ts +3 -3
- package/lib/web/README.md +21 -0
- package/lib/web/components.ts +5 -1
- package/lib/web/index.ts +1 -0
- package/lib/web/markdown.ts +83 -0
- package/lib/web/markup.ts +2 -1
- package/lib/web/runtime.ts +9 -10
- package/package.json +6 -1
- package/skills/init/SKILL.md +5 -5
- package/skills/next/SKILL.md +3 -5
- package/extensions/board/seat.ts +0 -28
- package/extensions/todo/tui.ts +0 -212
- package/lib/tui/index.ts +0 -34
- package/skills/project-docs/SKILL.md +0 -133
- package/skills/project-docs/references/audit.md +0 -131
- package/skills/project-docs/references/changelog.md +0 -105
- package/skills/project-docs/references/discovery.md +0 -127
- package/skills/project-docs/references/readme.md +0 -80
- package/skills/project-docs/references/release.md +0 -84
- package/skills/project-docs/references/roadmap.md +0 -93
- package/skills/project-docs/scripts/state.ts +0 -316
package/lib/board/store.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The board store: one directory of card files per repository, and the only code
|
|
3
3
|
* that writes one.
|
|
4
4
|
*
|
|
5
|
-
* A board is a directory — `<
|
|
5
|
+
* A board is a directory — `<agent-dir>/board/<repo-key>/cards/0004.json` — so a
|
|
6
6
|
* reader needs no index, no daemon, and no dependency; the listing *is* the
|
|
7
7
|
* index. The key comes from `lib/repo-key.ts`, so every worktree of one repository
|
|
8
8
|
* shares one board, and a directory outside git keys on itself.
|
|
@@ -35,15 +35,17 @@ import { shortenPath } from "../format.ts";
|
|
|
35
35
|
import { repoKeyFor } from "../repo-key.ts";
|
|
36
36
|
import {
|
|
37
37
|
type Card,
|
|
38
|
-
CARD_STATUSES,
|
|
39
|
-
CARD_VERSION,
|
|
40
38
|
cardRef,
|
|
41
39
|
type EndedCard,
|
|
42
|
-
|
|
40
|
+
ENDED_VERSION,
|
|
43
41
|
type Priority,
|
|
44
42
|
priorityRank,
|
|
43
|
+
SUPPORTED_VERSIONS,
|
|
45
44
|
} from "./card.ts";
|
|
45
|
+
import { LANES } from "./lanes.ts";
|
|
46
|
+
import { readCard } from "./read.ts";
|
|
46
47
|
import { indexOf } from "./links.ts";
|
|
48
|
+
import { type BoardConfig, DEFAULT_BOARD_CONFIG, readBoardConfig } from "./config.ts";
|
|
47
49
|
import { type Actor, applyOp, type CardOp, createCard } from "./rules.ts";
|
|
48
50
|
|
|
49
51
|
/** The directory under the agent directory that holds every board. */
|
|
@@ -108,9 +110,26 @@ export interface Skip {
|
|
|
108
110
|
export interface BoardStore {
|
|
109
111
|
/** The board's own directory, for `where` and for a refusal that names it. */
|
|
110
112
|
readonly dir: string;
|
|
113
|
+
/**
|
|
114
|
+
* The repository key this board is filed under.
|
|
115
|
+
*
|
|
116
|
+
* Carried rather than read back off {@link dir}'s last segment: the same key is what
|
|
117
|
+
* `session-bus` files this repository's records under, so a caller that needs it must
|
|
118
|
+
* not learn it from the board's layout — otherwise regrouping the board's own files
|
|
119
|
+
* moves the roster to a directory no bus writes to, and the page reads "no record" for
|
|
120
|
+
* every peer instead of failing where someone would notice.
|
|
121
|
+
*/
|
|
122
|
+
readonly key: string;
|
|
123
|
+
/**
|
|
124
|
+
* The settings this board works by: its WIP limits and the name its two signatures carry.
|
|
125
|
+
*
|
|
126
|
+
* Read once when the store opened, and handed to every operation: the page shows the same
|
|
127
|
+
* numbers a refusal would, so a head cannot claim room a claim would refuse.
|
|
128
|
+
*/
|
|
129
|
+
config(): BoardConfig;
|
|
111
130
|
/** Create the directories and sweep a temp file a dead process left. */
|
|
112
131
|
ensure(): Promise<void>;
|
|
113
|
-
/** Every card on the board, in lane order:
|
|
132
|
+
/** Every card on the board, in lane order: backlog, plan, execute, blocked, done. */
|
|
114
133
|
list(): Promise<Card[]>;
|
|
115
134
|
/**
|
|
116
135
|
* The board in one read: the cards, and the files it refused.
|
|
@@ -170,14 +189,41 @@ export async function openBoard(
|
|
|
170
189
|
// environment carries the active root, so the two are reconciled before the
|
|
171
190
|
// repository lookup — the same path `plan` and `session-bus` take.
|
|
172
191
|
const repo = await repoKeyFor(pi, resolveEffectiveCwd(cwd));
|
|
173
|
-
|
|
192
|
+
// Read once per store: the settings are a setting, not a card, and a board that re-read
|
|
193
|
+
// a file on every write would spend a stat to answer a question nobody changed.
|
|
194
|
+
const config = readBoardConfig(resolveEffectiveCwd(cwd), { agentDir });
|
|
195
|
+
// The key is handed on, not left to be read off the directory it named: `session-bus`
|
|
196
|
+
// files this repository's records under the same key, so the two must not be able to
|
|
197
|
+
// disagree about it.
|
|
198
|
+
return createStore(boardDirFor(agentDir, repo.key), { root: repo.root, key: repo.key, config });
|
|
174
199
|
}
|
|
175
200
|
|
|
176
201
|
/** Build a store over one directory. Exported for tests and for a second backend. */
|
|
177
|
-
export function createStore(
|
|
202
|
+
export function createStore(
|
|
203
|
+
dir: string,
|
|
204
|
+
options: {
|
|
205
|
+
root?: string;
|
|
206
|
+
key?: string;
|
|
207
|
+
config?: BoardConfig;
|
|
208
|
+
/**
|
|
209
|
+
* A suite's seam: called once an operation is ready to write and before the re-read that
|
|
210
|
+
* decides whether it still may.
|
|
211
|
+
*
|
|
212
|
+
* A race between a write and a pile move cannot be made deterministic from outside — the
|
|
213
|
+
* window is inside one `mutate` — so a test stands here and lets the other write land. The
|
|
214
|
+
* product supplies nothing.
|
|
215
|
+
*/
|
|
216
|
+
beforeWrite?: (id: string, kind: CardOp["kind"]) => Promise<void> | void;
|
|
217
|
+
} = {},
|
|
218
|
+
): BoardStore {
|
|
219
|
+
// A store built over a directory by hand has no repository to ask, so it is keyed by
|
|
220
|
+
// that directory's own name: the one place this fallback lives, and the one reason it
|
|
221
|
+
// is not a proof about `openBoard`'s boards.
|
|
222
|
+
const key = options.key ?? basename(dir);
|
|
178
223
|
// A store built over a directory by hand has no repository to name, so it names its
|
|
179
224
|
// own directory: the page then says something true instead of something blank.
|
|
180
225
|
const root = options.root ?? dir;
|
|
226
|
+
const config = options.config ?? DEFAULT_BOARD_CONFIG;
|
|
181
227
|
const cardsDir = join(dir, CARDS_DIR_NAME);
|
|
182
228
|
const endedDir = join(dir, ENDED_DIR_NAME);
|
|
183
229
|
const archiveDir = join(dir, ARCHIVE_DIR_NAME);
|
|
@@ -278,11 +324,22 @@ export function createStore(dir: string, options: { root?: string } = {}): Board
|
|
|
278
324
|
// The preconditions are checked against the card as it is *now*, and against the
|
|
279
325
|
// board as it is now, so an operation re-applied to a card that changed is refused
|
|
280
326
|
// rather than forced.
|
|
281
|
-
const result = applyOp(card, op, actor, now, indexOf(await list()));
|
|
327
|
+
const result = applyOp(card, op, actor, now, indexOf(await list()), config);
|
|
282
328
|
if (!result.ok) return { ok: false, error: result.error, card };
|
|
283
329
|
if (!result.changed) {
|
|
284
330
|
return { ok: true, card: result.card, changed: false, dropped: false, archived: false };
|
|
285
331
|
}
|
|
332
|
+
// Last look before the write, asked for every path that writes: the card must still be
|
|
333
|
+
// where it was read and at the revision it had. A writer that landed in between is the
|
|
334
|
+
// re-read the next pass applies against — and the pile is part of the answer, because
|
|
335
|
+
// `archive` moves the file without moving its revision, so a write carrying a stale
|
|
336
|
+
// pile would recreate the card on the board beside its copy in the archive.
|
|
337
|
+
await options.beforeWrite?.(id, op.kind);
|
|
338
|
+
const current = await locate(id);
|
|
339
|
+
if (current === undefined || current.card.rev !== card.rev || current.pile !== pile) {
|
|
340
|
+
changedUnderneath = true;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
286
343
|
if (op.kind === "drop") {
|
|
287
344
|
// The record lands first: a crash between the two leaves a card that still
|
|
288
345
|
// exists with a note explaining it, never a card that is gone unexplained.
|
|
@@ -299,13 +356,6 @@ export function createStore(dir: string, options: { root?: string } = {}): Board
|
|
|
299
356
|
await moveCard(cardsDir, archiveDir, id);
|
|
300
357
|
return { ok: true, card: result.card, changed: true, dropped: false, archived: true };
|
|
301
358
|
}
|
|
302
|
-
// Last look before the rename: a writer that landed in between is the
|
|
303
|
-
// re-read the next pass applies against.
|
|
304
|
-
const current = await read(id);
|
|
305
|
-
if (!current || current.rev !== card.rev) {
|
|
306
|
-
changedUnderneath = true;
|
|
307
|
-
continue;
|
|
308
|
-
}
|
|
309
359
|
await writeCardFile(pile, id, result.card);
|
|
310
360
|
return { ok: true, card: result.card, changed: true, dropped: false, archived: false };
|
|
311
361
|
}
|
|
@@ -329,6 +379,8 @@ export function createStore(dir: string, options: { root?: string } = {}): Board
|
|
|
329
379
|
|
|
330
380
|
return {
|
|
331
381
|
dir,
|
|
382
|
+
key,
|
|
383
|
+
config: () => config,
|
|
332
384
|
ensure,
|
|
333
385
|
list,
|
|
334
386
|
scan,
|
|
@@ -366,19 +418,23 @@ async function readCardFile(pileDir: string, entry: string): Promise<{ card: Car
|
|
|
366
418
|
} catch {
|
|
367
419
|
return { skip: { file: entry, id, why: "bad JSON" } };
|
|
368
420
|
}
|
|
369
|
-
// A version
|
|
370
|
-
// written by a newer build, so the board is not empty, it is not readable here.
|
|
421
|
+
// A version this build does not read is the one refusal a reader can act on: the file was
|
|
422
|
+
// written by a newer build, so the board is not empty, it is not readable here. Every
|
|
423
|
+
// version `readCard` can map is read; the number is named rather than guessed at.
|
|
371
424
|
const version = typeof parsed === "object" && parsed !== null ? (parsed as { v?: unknown }).v : undefined;
|
|
372
|
-
if (typeof version === "number" &&
|
|
425
|
+
if (typeof version === "number" && !SUPPORTED_VERSIONS.some((known) => known === version)) {
|
|
373
426
|
return { skip: { file: entry, id, why: `version ${version}` } };
|
|
374
427
|
}
|
|
375
|
-
|
|
428
|
+
// The one gate a file read passes: an older spelling of the lane field is read as
|
|
429
|
+
// this build's, and everything else is a shape this build does not trust.
|
|
430
|
+
const card = readCard(parsed);
|
|
431
|
+
if (card === undefined) return { skip: { file: entry, id, why: "not a card record" } };
|
|
376
432
|
// The file name is the id's canonical spelling: a padded id inside the record
|
|
377
433
|
// (or a record moved under another name) is a disagreement, not a card.
|
|
378
|
-
if (
|
|
379
|
-
return { skip: { file: entry, id, why: `names card ${
|
|
434
|
+
if (card.id !== String(Number(match[1]))) {
|
|
435
|
+
return { skip: { file: entry, id, why: `names card ${card.id}` } };
|
|
380
436
|
}
|
|
381
|
-
return { card
|
|
437
|
+
return { card };
|
|
382
438
|
}
|
|
383
439
|
|
|
384
440
|
/** The highest id a directory holds, or 0 when it holds none. */
|
|
@@ -412,10 +468,10 @@ async function nextCardId(cardsDir: string, endedDir: string, archiveDir: string
|
|
|
412
468
|
async function writeEnded(endedDir: string, card: Card, actor: Actor, now: number): Promise<void> {
|
|
413
469
|
await mkdir(endedDir, { recursive: true, mode: DIR_MODE });
|
|
414
470
|
const ended: EndedCard = {
|
|
415
|
-
v:
|
|
471
|
+
v: ENDED_VERSION,
|
|
416
472
|
id: card.id,
|
|
417
473
|
title: card.title,
|
|
418
|
-
|
|
474
|
+
lane: card.lane,
|
|
419
475
|
by: actor.label,
|
|
420
476
|
...(card.owner === undefined ? {} : { owner: card.owner }),
|
|
421
477
|
at: now,
|
|
@@ -472,7 +528,6 @@ async function writeCardFile(dir: string, id: string, card: Card): Promise<void>
|
|
|
472
528
|
}
|
|
473
529
|
}
|
|
474
530
|
|
|
475
|
-
/** One card's bytes, ending in a newline so the file reads well in an editor. */
|
|
476
531
|
/** One record as the bytes a file holds: tab-indented JSON with a closing newline. */
|
|
477
532
|
function serialize(record: Card | EndedCard): string {
|
|
478
533
|
return `${JSON.stringify(record, null, "\t")}\n`;
|
|
@@ -503,21 +558,23 @@ async function readdirSafe(dir: string): Promise<string[]> {
|
|
|
503
558
|
}
|
|
504
559
|
|
|
505
560
|
/**
|
|
506
|
-
* Lane order, then the lead's order inside it, then
|
|
507
|
-
*
|
|
561
|
+
* Lane order, then the lead's order inside it, then the order the cards were added in: the
|
|
562
|
+
* id, which is allocated once and never reissued.
|
|
508
563
|
*
|
|
509
564
|
* Priority sorts inside a lane rather than beside it: a `high` card in Done is still
|
|
510
565
|
* history, and a lane that mixed the two would have to say so in its head.
|
|
566
|
+
*
|
|
567
|
+
* The id carries the age, and `updatedAt` deliberately does not: every write moves that
|
|
568
|
+
* stamp, so ordering by it makes a note, a claim, or a contract push a card to the end of
|
|
569
|
+
* its priority and reshuffle the listing a reader was working down.
|
|
511
570
|
*/
|
|
512
571
|
function sortForLane(cards: Card[]): Card[] {
|
|
513
|
-
const lane = (card: Card): number =>
|
|
572
|
+
const lane = (card: Card): number => LANES.indexOf(card.lane);
|
|
514
573
|
return cards.sort((a, b) => {
|
|
515
574
|
const byLane = lane(a) - lane(b);
|
|
516
575
|
if (byLane !== 0) return byLane;
|
|
517
576
|
const byPriority = priorityRank(a) - priorityRank(b);
|
|
518
577
|
if (byPriority !== 0) return byPriority;
|
|
519
|
-
const byAge = a.updatedAt - b.updatedAt;
|
|
520
|
-
if (byAge !== 0) return byAge;
|
|
521
578
|
return Number(a.id) - Number(b.id);
|
|
522
579
|
});
|
|
523
580
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What tone a lane, a reason, and a Team decision row wear.
|
|
3
|
+
*
|
|
4
|
+
* The page and the terminal each decide a colour, and before this table they decided it in
|
|
5
|
+
* seven places: a lane role in the lane table, a reason class in the browser bundle, two
|
|
6
|
+
* hand-written `[data-kind]` edge rules, a second `blocked` rule for the queue's why line,
|
|
7
|
+
* and a transcript that painted every lane the same accent. A tone stated once is the only
|
|
8
|
+
* version of that which cannot drift.
|
|
9
|
+
*
|
|
10
|
+
* A tone is a word the page's kit already names as a property (`var(--warn)`), so the page
|
|
11
|
+
* wears one directly and needs no map. The terminal speaks its own vocabulary — `ok`, `warn`,
|
|
12
|
+
* `bad`, `meta`, `faint` — and that bridge lives in `extensions/board/tone.ts`, because only
|
|
13
|
+
* the transcript speaks it.
|
|
14
|
+
*
|
|
15
|
+
* The rule behind the words, so a future kind is not a guess: **warn is a person who must act
|
|
16
|
+
* next** (approve, review, decide), **danger is a refusal or a loss** (blocked, gone), and
|
|
17
|
+
* anything merely true of the card — waiting, offered, executing, done, noted — is muted.
|
|
18
|
+
*
|
|
19
|
+
* Value-only: no I/O, no clock, no card.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { LaneRole } from "./lanes.ts";
|
|
23
|
+
import type { ReasonKind } from "./reason.ts";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A colour a board surface may wear.
|
|
27
|
+
*
|
|
28
|
+
* `dim` joins the lane roles because a Team queue row nobody has to act on yet is quieter
|
|
29
|
+
* than a muted one: drawn, not emphasised.
|
|
30
|
+
*/
|
|
31
|
+
export type BoardTone = LaneRole | "dim";
|
|
32
|
+
|
|
33
|
+
/** Every tone, in the order a rule is written for one. */
|
|
34
|
+
export const BOARD_TONES = ["muted", "dim", "warn", "accent", "danger", "success"] as const;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The tone a card's reason line wears, by the kind `reason.ts` reports.
|
|
38
|
+
*
|
|
39
|
+
* `waiting` is here even though `reasonFor` never returns it: the page's card face invents it
|
|
40
|
+
* for a card that names what it waits on (`view/card.ts`), so the table has to answer for it
|
|
41
|
+
* or that line would draw the default.
|
|
42
|
+
*/
|
|
43
|
+
export const REASON_TONE: Record<ReasonKind, BoardTone> = {
|
|
44
|
+
waiting: "muted",
|
|
45
|
+
blocked: "danger",
|
|
46
|
+
offered: "muted",
|
|
47
|
+
contract: "warn",
|
|
48
|
+
approval: "warn",
|
|
49
|
+
executing: "muted",
|
|
50
|
+
review: "warn",
|
|
51
|
+
done: "muted",
|
|
52
|
+
note: "muted",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The Team queue's decision kinds, in the order a lead reads them.
|
|
57
|
+
*
|
|
58
|
+
* One list for both facts a row needs: the position is the rank (a `gone` owner outranks a
|
|
59
|
+
* card merely sitting still), and {@link DECISION_TONE} is the colour. They were two lists — a
|
|
60
|
+
* `KIND_RANK` object in the bundle and a hand-written set of `[data-kind]` rules in the
|
|
61
|
+
* stylesheet — which is how `gone` came to be ranked second and drawn quietest.
|
|
62
|
+
*/
|
|
63
|
+
export const DECISION_KINDS = ["blocked", "gone", "approval", "waiting", "idle", "unplanned", "unassigned"] as const;
|
|
64
|
+
|
|
65
|
+
/** One reason a card is in the lead's queue. */
|
|
66
|
+
export type DecisionKind = (typeof DECISION_KINDS)[number];
|
|
67
|
+
|
|
68
|
+
/** The tone a decision row's edge, and a danger row's reason line, wear. */
|
|
69
|
+
export const DECISION_TONE: Record<DecisionKind, BoardTone> = {
|
|
70
|
+
blocked: "danger",
|
|
71
|
+
gone: "danger",
|
|
72
|
+
approval: "warn",
|
|
73
|
+
waiting: "dim",
|
|
74
|
+
idle: "warn",
|
|
75
|
+
unplanned: "dim",
|
|
76
|
+
unassigned: "dim",
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* {@link DECISION_KINDS} as the browser bundle's own source.
|
|
81
|
+
*
|
|
82
|
+
* The page ranks its queue by this list, and the bundle is a string, so the order travels as
|
|
83
|
+
* data the way `reasonFor` and `matchesQuery` do: one list in `lib/`, spliced in, never a
|
|
84
|
+
* second copy in the browser. `test` parses it back to the tuple.
|
|
85
|
+
*/
|
|
86
|
+
export const DECISION_RANK_JS = JSON.stringify(DECISION_KINDS);
|
|
87
|
+
|
|
88
|
+
/** {@link DECISION_TONE} as the bundle's own source, spliced in like the rank. */
|
|
89
|
+
export const DECISION_TONE_JS = JSON.stringify(DECISION_TONE);
|
|
90
|
+
|
|
91
|
+
/** {@link REASON_TONE} as the bundle's own source, spliced in like the rank. */
|
|
92
|
+
export const REASON_TONE_JS = JSON.stringify(REASON_TONE);
|
package/lib/board/view.ts
CHANGED
|
@@ -15,15 +15,10 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { type RosterSession, type RosterState, rosterStateOf } from "../session-roster.ts";
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
laneSince,
|
|
23
|
-
priorityOf,
|
|
24
|
-
type Priority,
|
|
25
|
-
signatureOf,
|
|
26
|
-
} from "./card.ts";
|
|
18
|
+
import { type Card, laneSince, priorityOf, type Priority, signatureOf } from "./card.ts";
|
|
19
|
+
import type { Skip } from "./store.ts";
|
|
20
|
+
import { LANE_RULES, LANES, type Lane, type LaneRole } from "./lanes.ts";
|
|
21
|
+
import { DEFAULT_WIP, limitOf, type WipLimits } from "./limits.ts";
|
|
27
22
|
import { blockersOf, indexOf, lanesText, openDependentsOf } from "./links.ts";
|
|
28
23
|
import type { Actor } from "./rules.ts";
|
|
29
24
|
import { buildRound, type RoundSummary } from "./round.ts";
|
|
@@ -39,7 +34,7 @@ export interface ViewTeammate {
|
|
|
39
34
|
mail: number;
|
|
40
35
|
/** Whether it is listening for mail; absent means its record does not say so. */
|
|
41
36
|
listening?: boolean;
|
|
42
|
-
/** Whether this is the
|
|
37
|
+
/** Whether this is the seat the page is read as. */
|
|
43
38
|
you: boolean;
|
|
44
39
|
}
|
|
45
40
|
|
|
@@ -55,14 +50,16 @@ export interface ViewCard extends Card {
|
|
|
55
50
|
/** The priority it acts on: a card that never set one is ordinary. */
|
|
56
51
|
priority: Priority;
|
|
57
52
|
/**
|
|
58
|
-
* The unfinished cards it waits for, as `#2 (
|
|
53
|
+
* The unfinished cards it waits for, as `#2 (execute) Patch the gateway`; absent when
|
|
54
|
+
* nothing blocks it.
|
|
59
55
|
*
|
|
60
56
|
* `undefined` is the whole fact — a waiting card is one with a `waits` — because a
|
|
61
57
|
* boolean beside it would be one more thing that can disagree with the string.
|
|
62
58
|
*/
|
|
63
59
|
waits?: string;
|
|
64
60
|
/**
|
|
65
|
-
* The unfinished cards waiting on it, as `#9 (
|
|
61
|
+
* The unfinished cards waiting on it, as `#9 (backlog) Ship the picker`; absent when none
|
|
62
|
+
* do.
|
|
66
63
|
*
|
|
67
64
|
* The other side of `waits`: what leaving this card undone holds up. Derived here
|
|
68
65
|
* rather than in the page's script for the same reason, and filtered to the unfinished
|
|
@@ -80,32 +77,31 @@ export interface ViewCard extends Card {
|
|
|
80
77
|
since?: number;
|
|
81
78
|
}
|
|
82
79
|
|
|
83
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* One lane, as the page draws it: the name, the words, the colour, the limit, and the cards.
|
|
82
|
+
*
|
|
83
|
+
* The page holds no lane table of its own — it renders this — so a lane's label, its empty
|
|
84
|
+
* word, and its head's `n/limit` cannot read one way here and another in the transcript.
|
|
85
|
+
*/
|
|
84
86
|
export interface ViewLane {
|
|
85
|
-
|
|
87
|
+
key: Lane;
|
|
88
|
+
/** The word a head, a row, and a sentence print. */
|
|
89
|
+
label: string;
|
|
90
|
+
/** What the tray says when it holds nothing. */
|
|
91
|
+
empty: string;
|
|
92
|
+
/** The colour role the lane's dot and its card faces take. */
|
|
93
|
+
role: LaneRole;
|
|
94
|
+
/** The WIP limit this lane is held to, for the heads that show `n/limit`; absent when it is not pulled. */
|
|
95
|
+
limit?: number;
|
|
96
|
+
count: number;
|
|
86
97
|
cards: ViewCard[];
|
|
87
98
|
}
|
|
88
99
|
|
|
89
|
-
/** The per-lane tallies the toolbar shows. */
|
|
90
|
-
export interface ViewCounts {
|
|
91
|
-
total: number;
|
|
92
|
-
todo: number;
|
|
93
|
-
doing: number;
|
|
94
|
-
blocked: number;
|
|
95
|
-
done: number;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
100
|
/** Everything the page is allowed to know. */
|
|
99
101
|
export interface BoardView {
|
|
100
|
-
/**
|
|
101
|
-
* The session a page write is made as, and the one the page is read as: its seat.
|
|
102
|
-
*
|
|
103
|
-
* The page is not a session that happens to be serving — it is a seat that outlives
|
|
104
|
-
* whichever session serves it — so the browser is told one identity and no other.
|
|
105
|
-
*/
|
|
106
|
-
me: { id?: string; label: string };
|
|
107
102
|
lanes: ViewLane[];
|
|
108
|
-
|
|
103
|
+
/** Every card on the board: the sum of the lanes, said once so a head need not add them up. */
|
|
104
|
+
total: number;
|
|
109
105
|
round: RoundSummary<ViewCard>;
|
|
110
106
|
/**
|
|
111
107
|
* The sessions on this repository's bus, or absent when there is no bus here.
|
|
@@ -119,10 +115,11 @@ export interface BoardView {
|
|
|
119
115
|
/**
|
|
120
116
|
* Card files the reader refused, so the footer can say the board is not empty.
|
|
121
117
|
*
|
|
122
|
-
*
|
|
123
|
-
* that separates "no work here" from "this build cannot read your work"
|
|
118
|
+
* An empty list is the ordinary answer and draws nothing; anything in it is the one fact
|
|
119
|
+
* that separates "no work here" from "this build cannot read your work" — and the file
|
|
120
|
+
* names and reasons are what let a reader open the file, so the page carries them whole.
|
|
124
121
|
*/
|
|
125
|
-
skipped:
|
|
122
|
+
skipped: readonly Skip[];
|
|
126
123
|
/** Changes when a card's revision, a teammate's state, or its mail does. */
|
|
127
124
|
sig: string;
|
|
128
125
|
/** The root's last segment, e.g. `my-pi-agent`: what the tab and the footer lead with. */
|
|
@@ -135,13 +132,19 @@ export interface BoardView {
|
|
|
135
132
|
|
|
136
133
|
/** What the server read before building one snapshot. */
|
|
137
134
|
export interface ViewInput {
|
|
138
|
-
/**
|
|
135
|
+
/**
|
|
136
|
+
* The page's own actor: the seat the snapshot is read as.
|
|
137
|
+
*
|
|
138
|
+
* The page is not a session that happens to be serving — it is a seat that outlives
|
|
139
|
+
* whichever session serves it — so this is the one identity the browser's roster is marked
|
|
140
|
+
* against, and the page shows no other.
|
|
141
|
+
*/
|
|
139
142
|
me: Actor;
|
|
140
143
|
cards: readonly Card[];
|
|
141
144
|
now: number;
|
|
142
145
|
bytes: number;
|
|
143
146
|
/** Card files the reader refused; absent reads as none, which is the ordinary board. */
|
|
144
|
-
skipped?:
|
|
147
|
+
skipped?: readonly Skip[];
|
|
145
148
|
where: string;
|
|
146
149
|
/** The repository's name, from `BoardStore.name()`. */
|
|
147
150
|
repo: string;
|
|
@@ -149,6 +152,8 @@ export interface ViewInput {
|
|
|
149
152
|
root: string;
|
|
150
153
|
/** The bus's records, when the page could read them at all. */
|
|
151
154
|
roster?: readonly RosterSession[];
|
|
155
|
+
/** The board's WIP limits, so a head can say `n/limit`; absent reads as the defaults. */
|
|
156
|
+
limits?: WipLimits;
|
|
152
157
|
}
|
|
153
158
|
|
|
154
159
|
/** Build the page's snapshot. */
|
|
@@ -160,25 +165,39 @@ export function buildView(input: ViewInput): BoardView {
|
|
|
160
165
|
// card the lane does, so a client reading `round.unassigned` is not handed a card
|
|
161
166
|
// missing the facts every other card has.
|
|
162
167
|
const cards = input.cards.map((card) => viewCardOf(card, index));
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
168
|
+
const limits = input.limits ?? DEFAULT_WIP;
|
|
169
|
+
const lanes = LANES.map((key) => {
|
|
170
|
+
const rule = LANE_RULES[key];
|
|
171
|
+
const inLane = cards.filter((card) => card.lane === key);
|
|
172
|
+
const limit = limitOf(key, limits);
|
|
173
|
+
return {
|
|
174
|
+
key,
|
|
175
|
+
label: rule.label,
|
|
176
|
+
empty: rule.empty,
|
|
177
|
+
role: rule.role,
|
|
178
|
+
...(limit === undefined ? {} : { limit }),
|
|
179
|
+
count: inLane.length,
|
|
180
|
+
cards: inLane,
|
|
181
|
+
};
|
|
182
|
+
});
|
|
167
183
|
const reader = input.me;
|
|
168
184
|
const teammates = input.roster === undefined ? undefined : teammatesOf(input.roster, reader.label, input.now);
|
|
169
185
|
return {
|
|
170
|
-
me: input.me.id === undefined ? { label: input.me.label } : { id: input.me.id, label: input.me.label },
|
|
171
186
|
lanes,
|
|
172
|
-
|
|
187
|
+
total: cards.length,
|
|
173
188
|
round: buildRound(cards, input.now),
|
|
174
189
|
...(teammates === undefined ? {} : { teammates }),
|
|
175
190
|
bytes: input.bytes,
|
|
176
|
-
skipped: input.skipped ??
|
|
191
|
+
skipped: input.skipped ?? [],
|
|
177
192
|
// The roster enters the signature at its coarse facts only: a heartbeat every ten
|
|
178
193
|
// seconds is not news, while a join, a quiet and a letter are. A file that became
|
|
179
|
-
// readable, or unreadable, is news too
|
|
180
|
-
//
|
|
181
|
-
|
|
194
|
+
// readable, or unreadable, is news too, and so is a *different* file becoming
|
|
195
|
+
// unreadable: the footer names them, so the signature carries the names and reasons
|
|
196
|
+
// rather than a count that a swap would leave unchanged.
|
|
197
|
+
sig:
|
|
198
|
+
signatureOf(input.cards) +
|
|
199
|
+
rosterSignature(teammates) +
|
|
200
|
+
`|skipped:${(input.skipped ?? []).map((entry) => `${entry.file}:${entry.why}`).join(",")}`,
|
|
182
201
|
repo: input.repo,
|
|
183
202
|
root: input.root,
|
|
184
203
|
where: input.where,
|
|
@@ -192,8 +211,11 @@ export function buildView(input: ViewInput): BoardView {
|
|
|
192
211
|
* derived field written onto one would outlive the read that made it.
|
|
193
212
|
*/
|
|
194
213
|
function viewCardOf(card: Card, index: ReadonlyMap<string, Card>): ViewCard {
|
|
195
|
-
|
|
196
|
-
|
|
214
|
+
// The page draws these two as its own lines, and a reader has to know *which* card: the
|
|
215
|
+
// lane alone is a state, and the title is the card. The transcript asks for no titles —
|
|
216
|
+
// a refusal is a row, and the sentence is the point there.
|
|
217
|
+
const waits = lanesText(blockersOf(card, index), { titles: true });
|
|
218
|
+
const dependents = lanesText(openDependentsOf(card.id, index), { titles: true });
|
|
197
219
|
const since = laneSince(card);
|
|
198
220
|
return {
|
|
199
221
|
...card,
|
|
@@ -228,13 +250,3 @@ function rosterSignature(teammates: readonly ViewTeammate[] | undefined): string
|
|
|
228
250
|
teammates.map((teammate) => `${teammate.label}:${teammate.state}:${teammate.mail}:${teammate.listening}`).join(",")
|
|
229
251
|
);
|
|
230
252
|
}
|
|
231
|
-
|
|
232
|
-
/** The tallies, summed from the lanes so the two can never disagree. */
|
|
233
|
-
function countOf(lanes: readonly ViewLane[]): ViewCounts {
|
|
234
|
-
const counts: ViewCounts = { total: 0, todo: 0, doing: 0, blocked: 0, done: 0 };
|
|
235
|
-
for (const lane of lanes) {
|
|
236
|
-
counts[lane.status] = lane.cards.length;
|
|
237
|
-
counts.total += lane.cards.length;
|
|
238
|
-
}
|
|
239
|
-
return counts;
|
|
240
|
-
}
|
|
@@ -1,23 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A command
|
|
2
|
+
* A spawned command's lifecycle: spawn, tree-kill, escalate, and drain.
|
|
3
3
|
*
|
|
4
|
-
* A command source is a
|
|
5
|
-
*
|
|
6
|
-
* SIGKILL grace, and the shutdown drain — over the injected `SpawnFn`
|
|
7
|
-
* `KillTreeFn` seams, so
|
|
8
|
-
* and
|
|
4
|
+
* A `job` or a `watch` command source is a process whose merged stdout and
|
|
5
|
+
* stderr feed its owner. This module owns the process side only — the spawn, the
|
|
6
|
+
* SIGTERM → SIGKILL grace, and the shutdown drain — over the injected `SpawnFn`
|
|
7
|
+
* and `KillTreeFn` seams, so each owner keeps its own status, repaint, and clock.
|
|
8
|
+
* The escalation has one body and one default (`KILL_GRACE_MS`); each owner passes
|
|
9
|
+
* its configured grace, so `job` escalates at its `killGraceMs` and `watch` at its
|
|
10
|
+
* own, and neither re-implements the SIGTERM → SIGKILL step.
|
|
9
11
|
*
|
|
10
12
|
* Value-only: the child handle and its escalation timer live on the caller's
|
|
11
13
|
* slot, never here, because Pi loads each extension with its own module cache.
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
defaultKillTree,
|
|
18
|
+
defaultSpawn,
|
|
19
|
+
type KillTreeFn,
|
|
20
|
+
type SpawnFn,
|
|
21
|
+
type SpawnedProcess,
|
|
22
|
+
type SpawnOptions,
|
|
23
|
+
} from "./spawn.ts";
|
|
15
24
|
|
|
16
25
|
/** Grace period before a stopped command is escalated to SIGKILL. */
|
|
17
26
|
export const KILL_GRACE_MS = 2000;
|
|
18
27
|
|
|
19
28
|
/**
|
|
20
|
-
* The process fields a
|
|
29
|
+
* The process fields a caller's record carries. The supervisor mutates them in
|
|
21
30
|
* place, so the runtime's record and the module's handle cannot disagree about
|
|
22
31
|
* whether a child is still live.
|
|
23
32
|
*/
|
|
@@ -27,7 +36,7 @@ export interface ChildSlot {
|
|
|
27
36
|
killTimer?: ReturnType<typeof setTimeout>;
|
|
28
37
|
}
|
|
29
38
|
|
|
30
|
-
/** How a child settled, for the sink that owns the
|
|
39
|
+
/** How a child settled, for the sink that owns the caller's status transition. */
|
|
31
40
|
export type ChildOutcome =
|
|
32
41
|
| { kind: "closed"; code: number | null; signal: string | null }
|
|
33
42
|
| { kind: "failed"; message: string };
|
|
@@ -37,7 +46,7 @@ export interface ChildSink {
|
|
|
37
46
|
onData(chunk: Buffer): void;
|
|
38
47
|
/**
|
|
39
48
|
* The child closed or failed to spawn. The sink owns the status, because only
|
|
40
|
-
* it knows whether the watcher was still running when that happened.
|
|
49
|
+
* it knows whether the job or watcher was still running when that happened.
|
|
41
50
|
*/
|
|
42
51
|
onSettled(outcome: ChildOutcome): void;
|
|
43
52
|
}
|
|
@@ -50,12 +59,19 @@ export interface ChildSupervisorOptions {
|
|
|
50
59
|
}
|
|
51
60
|
|
|
52
61
|
export interface ChildSupervisor {
|
|
53
|
-
/**
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Spawn `command` with the caller's options, or return the spawn error and
|
|
64
|
+
* leave the slot's child unset. The child is readable as `slot.child`.
|
|
65
|
+
*/
|
|
66
|
+
start(slot: ChildSlot, command: string, options: SpawnOptions, sink: ChildSink): string | undefined;
|
|
67
|
+
/**
|
|
68
|
+
* Send `signal` (SIGTERM by default) and escalate to SIGKILL when the command
|
|
69
|
+
* has not closed within the grace. A SIGKILL was already final, so it is not
|
|
70
|
+
* escalated.
|
|
71
|
+
*/
|
|
72
|
+
terminate(slot: ChildSlot, signal?: NodeJS.Signals): void;
|
|
73
|
+
/** Signal now, without arming an escalation. */
|
|
74
|
+
kill(slot: ChildSlot, signal: NodeJS.Signals): void;
|
|
59
75
|
/** Cancel a pending escalation. */
|
|
60
76
|
clearTimer(slot: ChildSlot): void;
|
|
61
77
|
/** Resolve when every slot's child has closed, or after `ms`, whichever comes first. */
|
|
@@ -74,7 +90,7 @@ export function createChildSupervisor(options: ChildSupervisorOptions = {}): Chi
|
|
|
74
90
|
}
|
|
75
91
|
};
|
|
76
92
|
|
|
77
|
-
const kill = (slot: ChildSlot, signal:
|
|
93
|
+
const kill = (slot: ChildSlot, signal: NodeJS.Signals): void => {
|
|
78
94
|
const pid = slot.pid;
|
|
79
95
|
try {
|
|
80
96
|
if (pid !== undefined) killTree(pid, signal, signal === "SIGKILL");
|
|
@@ -84,9 +100,12 @@ export function createChildSupervisor(options: ChildSupervisorOptions = {}): Chi
|
|
|
84
100
|
}
|
|
85
101
|
};
|
|
86
102
|
|
|
87
|
-
const terminate = (slot: ChildSlot): void => {
|
|
103
|
+
const terminate = (slot: ChildSlot, signal: NodeJS.Signals = "SIGTERM"): void => {
|
|
88
104
|
clearTimer(slot);
|
|
89
|
-
kill(slot,
|
|
105
|
+
kill(slot, signal);
|
|
106
|
+
// A SIGKILL is already final: arming an escalation behind it would only fire against
|
|
107
|
+
// a slot that is about to be cleared, and the doc above says it does not.
|
|
108
|
+
if (signal === "SIGKILL") return;
|
|
90
109
|
if (slot.pid === undefined && !slot.child) return;
|
|
91
110
|
slot.killTimer = setTimeout(() => {
|
|
92
111
|
slot.killTimer = undefined;
|
|
@@ -95,10 +114,10 @@ export function createChildSupervisor(options: ChildSupervisorOptions = {}): Chi
|
|
|
95
114
|
slot.killTimer.unref?.();
|
|
96
115
|
};
|
|
97
116
|
|
|
98
|
-
const start = (slot: ChildSlot, command: string,
|
|
117
|
+
const start = (slot: ChildSlot, command: string, options: SpawnOptions, sink: ChildSink): string | undefined => {
|
|
99
118
|
let child: SpawnedProcess;
|
|
100
119
|
try {
|
|
101
|
-
child = spawnFn(command,
|
|
120
|
+
child = spawnFn(command, options);
|
|
102
121
|
} catch (error) {
|
|
103
122
|
return error instanceof Error ? error.message : String(error);
|
|
104
123
|
}
|