@gavin-hu/my-pi-agent 0.19.0 → 0.21.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 +19 -12
- package/README.zh-CN.md +15 -9
- package/docs/extensions/README.md +2 -1
- package/docs/extensions/board.md +329 -151
- package/docs/extensions/bytefly.md +427 -273
- package/docs/extensions/file-browse.md +46 -44
- package/docs/extensions/job.md +1 -2
- package/docs/extensions/plan.md +86 -24
- package/docs/extensions/serve.md +129 -0
- package/docs/extensions/session-bus.md +23 -10
- package/docs/extensions/team.md +56 -28
- package/docs/extensions/wechat.md +2 -1
- package/docs/extensions/worktree.md +30 -15
- package/extensions/board/commands.ts +48 -17
- package/extensions/board/format.ts +107 -9
- package/extensions/board/index.ts +14 -2
- package/extensions/board/join.ts +31 -10
- package/extensions/board/page.ts +202 -156
- package/extensions/board/result.ts +10 -24
- package/extensions/board/rows.ts +1 -1
- package/extensions/board/schema.ts +31 -10
- package/extensions/board/seat.ts +9 -10
- package/extensions/board/snapshot.ts +7 -5
- package/extensions/board/tools.ts +211 -34
- package/extensions/board/transport/router.ts +27 -110
- package/extensions/board/transport/server.ts +76 -56
- package/extensions/board/types.ts +57 -0
- package/extensions/board/view/client.ts +24 -13
- package/extensions/board/view/dialog.ts +43 -0
- package/extensions/board/view/keys.ts +28 -24
- package/extensions/board/view/live.ts +6 -178
- package/extensions/board/view/names.ts +91 -0
- package/extensions/board/view/page.ts +60 -89
- package/extensions/board/view/render.ts +228 -200
- package/extensions/board/view/styles.ts +72 -49
- package/extensions/board/view/team.ts +63 -41
- package/extensions/bytefly/footer/budget.ts +6 -5
- package/extensions/bytefly/footer/collect/entries.ts +12 -4
- package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
- package/extensions/bytefly/footer/config.ts +19 -11
- package/extensions/bytefly/footer/price/price.ts +30 -8
- package/extensions/bytefly/footer/register.ts +5 -5
- package/extensions/bytefly/footer/render/footer.ts +18 -4
- package/extensions/bytefly/footer/render/format.ts +18 -0
- package/extensions/bytefly/footer/render/key.ts +46 -0
- package/extensions/bytefly/footer/render/layout.ts +13 -3
- package/extensions/bytefly/footer/render/lines.ts +23 -7
- package/extensions/bytefly/footer/render/segments.ts +93 -31
- package/extensions/bytefly/footer/types.ts +29 -5
- package/extensions/bytefly/footer/unpriced.ts +42 -17
- package/extensions/bytefly/header/component.ts +4 -20
- package/extensions/bytefly/panel/commands.ts +28 -1
- package/extensions/bytefly/panel/filter.ts +68 -0
- package/extensions/bytefly/panel/format.ts +16 -15
- package/extensions/bytefly/panel/join.ts +56 -13
- package/extensions/bytefly/panel/prices.ts +43 -0
- package/extensions/bytefly/panel/settings.ts +72 -23
- package/extensions/bytefly/panel/tui/entries.ts +26 -8
- package/extensions/bytefly/panel/tui/frame.ts +130 -93
- package/extensions/bytefly/panel/tui/keys.ts +352 -0
- package/extensions/bytefly/panel/tui/list-view.ts +25 -46
- package/extensions/bytefly/panel/tui/panes.ts +76 -0
- package/extensions/bytefly/panel/tui/section-view.ts +126 -0
- package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
- package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
- package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
- package/extensions/bytefly/panel/tui.ts +306 -259
- package/extensions/check/commands.ts +2 -1
- package/extensions/check/render.ts +10 -1
- package/extensions/file-browse/config/config.ts +4 -19
- package/extensions/file-browse/files/classify.ts +136 -0
- package/extensions/file-browse/files/files.ts +8 -547
- package/extensions/file-browse/files/index.ts +90 -0
- package/extensions/file-browse/files/list.ts +261 -0
- package/extensions/file-browse/files/tree.ts +101 -0
- package/extensions/file-browse/index.ts +27 -48
- package/extensions/file-browse/live/live.ts +1 -1
- package/extensions/file-browse/transport/router.ts +12 -6
- package/extensions/file-browse/transport/server.ts +61 -28
- package/extensions/file-browse/view/chrome.ts +4 -4
- package/extensions/file-browse/view/client.ts +3 -1
- package/extensions/file-browse/view/emit.ts +51 -0
- package/extensions/file-browse/view/html.ts +78 -46
- package/extensions/file-browse/view/live-client.ts +2 -2
- package/extensions/file-browse/view/markdown.ts +23 -26
- package/extensions/file-browse/view/palette.ts +2 -2
- package/extensions/file-browse/view/raw-html.ts +27 -22
- package/extensions/file-browse/view/tree.ts +1 -1
- package/extensions/file-browse/view/urls.ts +20 -6
- package/extensions/goal/tui.ts +12 -2
- package/extensions/job/runtime.ts +4 -4
- package/extensions/job/tools.ts +19 -7
- package/extensions/job/tui.ts +2 -28
- package/extensions/plan/commands.ts +2 -2
- package/extensions/plan/feedback.ts +88 -18
- package/extensions/plan/format.ts +10 -3
- package/extensions/plan/index.ts +24 -2
- package/extensions/plan/plan-files.ts +88 -0
- package/extensions/plan/plan-text.ts +114 -0
- package/extensions/plan/plans.ts +19 -23
- package/extensions/plan/policy.ts +10 -5
- package/extensions/plan/runtime.ts +43 -4
- package/extensions/plan/todos.ts +51 -3
- package/extensions/plan/tools/exit.ts +10 -1
- package/extensions/plan/tools/todo-seed.ts +4 -2
- package/extensions/plan/tools/write.ts +12 -1
- package/extensions/plan/tools.ts +2 -2
- package/extensions/plan/types.ts +15 -0
- package/extensions/plan/web-blocks.ts +160 -0
- package/extensions/plan/web-chrome.ts +7 -5
- package/extensions/plan/web-client.ts +271 -325
- package/extensions/plan/web-collab.ts +7 -7
- package/extensions/plan/web-comments.ts +57 -16
- package/extensions/plan/web-composer.ts +91 -0
- package/extensions/plan/web-draft.ts +40 -0
- package/extensions/plan/web-keys.ts +258 -0
- package/extensions/plan/web-markdown.ts +1 -1
- package/extensions/plan/web-outline.ts +212 -0
- package/extensions/plan/web-page.ts +59 -86
- package/extensions/plan/web-payload.ts +188 -0
- package/extensions/plan/web-plan.ts +160 -0
- package/extensions/plan/web-router.ts +0 -1
- package/extensions/plan/web-settle.ts +201 -0
- package/extensions/plan/web-share-session.ts +24 -55
- package/extensions/plan/web-share.ts +19 -37
- package/extensions/plan/web-write.ts +112 -0
- package/extensions/plan/web.ts +77 -130
- package/extensions/rewind/commands/review.ts +2 -1
- package/extensions/serve/config.ts +37 -0
- package/extensions/serve/index.ts +71 -0
- package/extensions/serve/registry.ts +251 -0
- package/extensions/session-bus/join-request.ts +16 -9
- package/extensions/session-bus/result.ts +1 -27
- package/extensions/session-bus/rows.ts +7 -3
- package/extensions/session-bus/runtime.ts +100 -38
- package/extensions/session-bus/store.ts +14 -2
- package/extensions/session-bus/tools.ts +5 -4
- package/extensions/session-bus/tui.ts +1 -1
- package/extensions/session-bus/types.ts +10 -1
- package/extensions/subagent/commands.ts +2 -1
- package/extensions/team/commands.ts +44 -9
- package/extensions/team/format.ts +14 -0
- package/extensions/team/index.ts +2 -1
- package/extensions/team/tools.ts +30 -9
- package/extensions/team/types.ts +17 -0
- package/extensions/todo/index.ts +4 -1
- package/extensions/todo/nudge.ts +8 -2
- package/extensions/todo/tui.ts +21 -3
- package/extensions/wechat/bridge.ts +43 -172
- package/extensions/wechat/intake.ts +245 -0
- package/extensions/worktree/commands.ts +24 -17
- package/extensions/worktree/exit.ts +12 -6
- package/extensions/worktree/git.ts +22 -14
- package/extensions/worktree/guard.ts +15 -285
- package/extensions/worktree/handoff.ts +10 -2
- package/extensions/worktree/index.ts +2 -1
- package/extensions/worktree/lifecycle.ts +2 -0
- package/extensions/worktree/registry.ts +20 -11
- package/extensions/worktree/root-tools.ts +13 -3
- package/extensions/worktree/runtime.ts +8 -2
- package/extensions/worktree/screen.ts +15 -8
- package/extensions/worktree/shell.ts +303 -0
- package/extensions/worktree/state.ts +1 -1
- package/extensions/worktree/status.ts +42 -9
- package/extensions/worktree/tools.ts +19 -13
- package/lib/README.md +9 -7
- package/lib/board/card.ts +192 -3
- package/lib/board/links.ts +26 -2
- package/lib/board/page-pointer.ts +111 -32
- package/lib/board/round-text.ts +11 -2
- package/lib/board/round.ts +25 -20
- package/lib/board/rules.ts +144 -11
- package/lib/board/session.ts +84 -18
- package/lib/board/store.ts +268 -55
- package/lib/board/view.ts +114 -24
- package/lib/bus-request.ts +83 -0
- package/lib/format.ts +22 -0
- package/lib/http/index.ts +11 -7
- package/lib/http/mount.ts +142 -0
- package/lib/http/port.ts +25 -0
- package/lib/http/serve.ts +170 -43
- package/lib/http/server.ts +3 -3
- package/lib/http/sink.ts +34 -9
- package/lib/plan-entry.ts +20 -13
- package/lib/policy.ts +14 -1
- package/lib/requirements.ts +2 -2
- package/lib/session-label.ts +23 -7
- package/lib/session-roster.ts +74 -3
- package/lib/session-tool.ts +37 -21
- package/lib/spawn.ts +37 -3
- package/lib/stray-fields.ts +45 -0
- package/lib/tool-names.ts +13 -0
- package/lib/tui/README.md +6 -3
- package/lib/tui/glyphs.ts +3 -6
- package/lib/tui/render-cache.ts +29 -0
- package/lib/tui/tool.ts +30 -1
- package/lib/web/README.md +52 -2
- package/lib/web/client-runtime.ts +149 -0
- package/lib/web/index.ts +4 -2
- package/lib/web/markup.ts +106 -2
- package/lib/web/names.ts +42 -0
- package/package.json +3 -2
- package/extensions/board/config.ts +0 -36
- package/extensions/board/view/actions.ts +0 -306
- package/extensions/board/writes.ts +0 -99
- package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
- package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
- package/extensions/job/process.ts +0 -35
package/lib/board/store.ts
CHANGED
|
@@ -20,18 +20,29 @@
|
|
|
20
20
|
* a board is tens of files, so the read is cheaper than the second source of truth a
|
|
21
21
|
* cached index would be.
|
|
22
22
|
*
|
|
23
|
-
* Nothing prunes.
|
|
24
|
-
* `
|
|
23
|
+
* Nothing prunes by itself. `cards/` is the board and `archive/` is where a finished card
|
|
24
|
+
* is set aside by `archive` — its file moved rather than rewritten, so the record is
|
|
25
|
+
* unchanged and the board stops paying for it — and `ended/` keeps the record of a `drop`
|
|
26
|
+
* alone. The page shows the store's size, and `drop` is the only deletion.
|
|
25
27
|
*/
|
|
26
28
|
|
|
27
29
|
import { link, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
28
|
-
import { join } from "node:path";
|
|
30
|
+
import { basename, join } from "node:path";
|
|
29
31
|
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
30
32
|
import { resolveEffectiveCwd } from "../env.ts";
|
|
31
33
|
import { hasErrorCode, messageOf } from "../errors.ts";
|
|
32
34
|
import { shortenPath } from "../format.ts";
|
|
33
35
|
import { repoKeyFor } from "../repo-key.ts";
|
|
34
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
type Card,
|
|
38
|
+
CARD_STATUSES,
|
|
39
|
+
CARD_VERSION,
|
|
40
|
+
cardRef,
|
|
41
|
+
type EndedCard,
|
|
42
|
+
isCard,
|
|
43
|
+
type Priority,
|
|
44
|
+
priorityRank,
|
|
45
|
+
} from "./card.ts";
|
|
35
46
|
import { indexOf } from "./links.ts";
|
|
36
47
|
import { type Actor, applyOp, type CardOp, createCard } from "./rules.ts";
|
|
37
48
|
|
|
@@ -41,6 +52,17 @@ export const BOARD_DIR_NAME = "board";
|
|
|
41
52
|
/** The subdirectory one board's cards live in. */
|
|
42
53
|
export const CARDS_DIR_NAME = "cards";
|
|
43
54
|
|
|
55
|
+
/** The subdirectory a dropped card leaves its record in. */
|
|
56
|
+
export const ENDED_DIR_NAME = "ended";
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The subdirectory a finished card is set aside in.
|
|
60
|
+
*
|
|
61
|
+
* A second pile, not a second state: the file is moved here unchanged, so its notes, plan,
|
|
62
|
+
* and landed commit stay readable, and the board's own reads stop opening it.
|
|
63
|
+
*/
|
|
64
|
+
export const ARCHIVE_DIR_NAME = "archive";
|
|
65
|
+
|
|
44
66
|
/** Card files are the user's work; only their owner reads them. */
|
|
45
67
|
const FILE_MODE = 0o600;
|
|
46
68
|
|
|
@@ -64,27 +86,72 @@ export interface CreateInput {
|
|
|
64
86
|
|
|
65
87
|
/** What a mutation did, or why it was refused. */
|
|
66
88
|
export type MutateResult =
|
|
67
|
-
| { ok: true; card: Card; changed: boolean; dropped: boolean }
|
|
89
|
+
| { ok: true; card: Card; changed: boolean; dropped: boolean; archived: boolean }
|
|
68
90
|
| { ok: false; error: string; card?: Card };
|
|
69
91
|
|
|
92
|
+
/**
|
|
93
|
+
* A card file the reader refused, and why.
|
|
94
|
+
*
|
|
95
|
+
* The lanes skip these, so without this the board reads as if they were never there:
|
|
96
|
+
* "nobody has planned anything" instead of "this build cannot read your work".
|
|
97
|
+
*/
|
|
98
|
+
export interface Skip {
|
|
99
|
+
/** The file name, e.g. `0042.json`. */
|
|
100
|
+
file: string;
|
|
101
|
+
/** The id the file name spells, when it spells one, so a caller can match a refusal. */
|
|
102
|
+
id?: string;
|
|
103
|
+
/** What is wrong, phrased for a reader: `bad JSON`, `version 2`. */
|
|
104
|
+
why: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
70
107
|
/** One repository's board, as the runtime, the router, and the tool use it. */
|
|
71
108
|
export interface BoardStore {
|
|
72
109
|
/** The board's own directory, for `where` and for a refusal that names it. */
|
|
73
110
|
readonly dir: string;
|
|
74
111
|
/** Create the directories and sweep a temp file a dead process left. */
|
|
75
112
|
ensure(): Promise<void>;
|
|
76
|
-
/** Every card, in lane order: todo, doing, blocked, done. */
|
|
113
|
+
/** Every card on the board, in lane order: todo, doing, blocked, done. */
|
|
77
114
|
list(): Promise<Card[]>;
|
|
78
|
-
/**
|
|
115
|
+
/**
|
|
116
|
+
* The board in one read: the cards, and the files it refused.
|
|
117
|
+
*
|
|
118
|
+
* A page's snapshot needs both, and asking twice reads and parses every card twice to
|
|
119
|
+
* answer one frame; `list` and `skipped` are the two halves of this for a caller that
|
|
120
|
+
* wants only one.
|
|
121
|
+
*/
|
|
122
|
+
scan(): Promise<{ cards: Card[]; skipped: Skip[] }>;
|
|
123
|
+
/**
|
|
124
|
+
* The cards set aside in the archive, in the same order, and the files there this build
|
|
125
|
+
* refused. Read only when a caller asks: no other read opens the pile.
|
|
126
|
+
*/
|
|
127
|
+
archived(): Promise<{ cards: Card[]; skipped: Skip[] }>;
|
|
128
|
+
/** How many card files the archive holds, without opening one: the count a listing prints. */
|
|
129
|
+
archivedCount(): Promise<number>;
|
|
130
|
+
/** One card by its unpadded id, from whichever pile its file lies in. */
|
|
79
131
|
read(id: string): Promise<Card | undefined>;
|
|
80
132
|
/** Add a card, allocating the next id. */
|
|
81
133
|
create(input: CreateInput, actor: Actor, now: number): Promise<Card>;
|
|
82
|
-
/**
|
|
134
|
+
/**
|
|
135
|
+
* Apply one operation, re-checking it if the card changed while it was read. A card is
|
|
136
|
+
* found in whichever pile it lies in, and written back to that one.
|
|
137
|
+
*/
|
|
83
138
|
mutate(id: string, op: CardOp, actor: Actor, now: number): Promise<MutateResult>;
|
|
84
|
-
/** Bytes the
|
|
139
|
+
/** Bytes the board's cards occupy on disk; the archive is not counted. */
|
|
85
140
|
bytes(): Promise<number>;
|
|
141
|
+
/** Card files this build refused to read, and why. */
|
|
142
|
+
skipped(): Promise<Skip[]>;
|
|
86
143
|
/** A short, user-facing name for the directory. */
|
|
87
144
|
where(): string;
|
|
145
|
+
/**
|
|
146
|
+
* The repository root this board belongs to, and the directory itself outside one.
|
|
147
|
+
*
|
|
148
|
+
* A board is keyed by a hash, so this is the one part of its identity a reader can
|
|
149
|
+
* say out loud: the page names the repository from it, and offers the whole path on
|
|
150
|
+
* hover, which is what tells two clones of one repository apart.
|
|
151
|
+
*/
|
|
152
|
+
root(): string;
|
|
153
|
+
/** The root's last segment — `my-pi-agent` — which is all a tab has room for. */
|
|
154
|
+
name(): string;
|
|
88
155
|
}
|
|
89
156
|
|
|
90
157
|
/** Where one repository's board lives. */
|
|
@@ -103,37 +170,75 @@ export async function openBoard(
|
|
|
103
170
|
// environment carries the active root, so the two are reconciled before the
|
|
104
171
|
// repository lookup — the same path `plan` and `session-bus` take.
|
|
105
172
|
const repo = await repoKeyFor(pi, resolveEffectiveCwd(cwd));
|
|
106
|
-
return createStore(boardDirFor(agentDir, repo.key));
|
|
173
|
+
return createStore(boardDirFor(agentDir, repo.key), { root: repo.root });
|
|
107
174
|
}
|
|
108
175
|
|
|
109
176
|
/** Build a store over one directory. Exported for tests and for a second backend. */
|
|
110
|
-
export function createStore(dir: string): BoardStore {
|
|
177
|
+
export function createStore(dir: string, options: { root?: string } = {}): BoardStore {
|
|
178
|
+
// A store built over a directory by hand has no repository to name, so it names its
|
|
179
|
+
// own directory: the page then says something true instead of something blank.
|
|
180
|
+
const root = options.root ?? dir;
|
|
111
181
|
const cardsDir = join(dir, CARDS_DIR_NAME);
|
|
182
|
+
const endedDir = join(dir, ENDED_DIR_NAME);
|
|
183
|
+
const archiveDir = join(dir, ARCHIVE_DIR_NAME);
|
|
112
184
|
|
|
113
185
|
const ensure = async (): Promise<void> => {
|
|
114
186
|
await mkdir(cardsDir, { recursive: true, mode: DIR_MODE });
|
|
115
187
|
await sweepTemps(cardsDir);
|
|
188
|
+
// An archived card that was edited leaves a temp file in its own pile, and a process
|
|
189
|
+
// that died mid-write there is the same mess as one on the board.
|
|
190
|
+
await sweepTemps(archiveDir);
|
|
116
191
|
};
|
|
117
192
|
|
|
118
|
-
|
|
119
|
-
|
|
193
|
+
/** Every card file of one pile, read once: what it shows and what it refused. */
|
|
194
|
+
const scanPile = async (pileDir: string): Promise<{ cards: Card[]; skipped: Skip[] }> => {
|
|
195
|
+
const entries = await readdirSafe(pileDir);
|
|
120
196
|
const cards: Card[] = [];
|
|
197
|
+
const skipped: Skip[] = [];
|
|
121
198
|
for (const entry of entries) {
|
|
122
|
-
const
|
|
123
|
-
if (card) cards.push(card);
|
|
199
|
+
const read = await readCardFile(pileDir, entry);
|
|
200
|
+
if ("card" in read) cards.push(read.card);
|
|
201
|
+
// Anything that is not a card-named file is not the board's to explain.
|
|
202
|
+
else if (entry.endsWith(".json")) skipped.push(read.skip);
|
|
124
203
|
}
|
|
125
|
-
|
|
204
|
+
// Sorted by file name so two reads of one board say the same thing in the same order.
|
|
205
|
+
return { cards: sortForLane(cards), skipped: skipped.sort((a, b) => a.file.localeCompare(b.file)) };
|
|
126
206
|
};
|
|
127
207
|
|
|
128
|
-
const
|
|
129
|
-
|
|
208
|
+
const scan = (): Promise<{ cards: Card[]; skipped: Skip[] }> => scanPile(cardsDir);
|
|
209
|
+
|
|
210
|
+
const list = async (): Promise<Card[]> => (await scan()).cards;
|
|
211
|
+
|
|
212
|
+
const archived = async (): Promise<{ cards: Card[]; skipped: Skip[] }> => scanPile(archiveDir);
|
|
213
|
+
|
|
214
|
+
// A directory listing and a name pattern, with no file opened: a plain `list` prints this
|
|
215
|
+
// count so the archive it left out cannot read as history that was deleted.
|
|
216
|
+
const archivedCount = async (): Promise<number> =>
|
|
217
|
+
(await readdirSafe(archiveDir)).filter((entry) => CARD_FILE.test(entry)).length;
|
|
218
|
+
|
|
219
|
+
const skipped = async (): Promise<Skip[]> => (await scan()).skipped;
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* One card and the pile its file is in, the board first.
|
|
223
|
+
*
|
|
224
|
+
* A card is where it lies: the archive is not a state a record carries, so every read by
|
|
225
|
+
* id has to look in both places, and every write goes back to the one it was read from.
|
|
226
|
+
*/
|
|
227
|
+
const locate = async (id: string): Promise<{ card: Card; pile: string } | undefined> => {
|
|
228
|
+
for (const pile of [cardsDir, archiveDir]) {
|
|
229
|
+
const result = await readCardFile(pile, fileNameFor(id));
|
|
230
|
+
if ("card" in result) return { card: result.card, pile };
|
|
231
|
+
}
|
|
232
|
+
return undefined;
|
|
130
233
|
};
|
|
131
234
|
|
|
235
|
+
const read = async (id: string): Promise<Card | undefined> => (await locate(id))?.card;
|
|
236
|
+
|
|
132
237
|
const create = async (input: CreateInput, actor: Actor, now: number): Promise<Card> => {
|
|
133
238
|
await ensure();
|
|
134
239
|
let lastError: unknown;
|
|
135
240
|
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
|
136
|
-
const id = await nextCardId(cardsDir);
|
|
241
|
+
const id = await nextCardId(cardsDir, endedDir, archiveDir);
|
|
137
242
|
const card = createCard(id, input, actor, now);
|
|
138
243
|
const temp = tempPath(cardsDir);
|
|
139
244
|
await writeFile(temp, serialize(card), { mode: FILE_MODE });
|
|
@@ -155,22 +260,44 @@ export function createStore(dir: string): BoardStore {
|
|
|
155
260
|
const mutate = async (id: string, op: CardOp, actor: Actor, now: number): Promise<MutateResult> => {
|
|
156
261
|
let changedUnderneath = false;
|
|
157
262
|
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
|
158
|
-
const
|
|
159
|
-
if (!
|
|
263
|
+
const found = await locate(id);
|
|
264
|
+
if (!found) {
|
|
265
|
+
if (changedUnderneath) return { ok: false, error: `${cardRef(id)} was dropped.` };
|
|
266
|
+
// A file that is there and refused is not a card that does not exist: the
|
|
267
|
+
// reader is told which file to look at instead of being sent to add one.
|
|
268
|
+
const skip = (await scan()).skipped.find((entry) => entry.id === id);
|
|
160
269
|
return {
|
|
161
270
|
ok: false,
|
|
162
|
-
error:
|
|
271
|
+
error:
|
|
272
|
+
skip === undefined
|
|
273
|
+
? `${cardRef(id)} does not exist.`
|
|
274
|
+
: `${cardRef(id)} is there and this build cannot read it (${skip.file}: ${skip.why}).`,
|
|
163
275
|
};
|
|
164
276
|
}
|
|
277
|
+
const { card, pile } = found;
|
|
165
278
|
// The preconditions are checked against the card as it is *now*, and against the
|
|
166
279
|
// board as it is now, so an operation re-applied to a card that changed is refused
|
|
167
280
|
// rather than forced.
|
|
168
281
|
const result = applyOp(card, op, actor, now, indexOf(await list()));
|
|
169
282
|
if (!result.ok) return { ok: false, error: result.error, card };
|
|
170
|
-
if (!result.changed)
|
|
283
|
+
if (!result.changed) {
|
|
284
|
+
return { ok: true, card: result.card, changed: false, dropped: false, archived: false };
|
|
285
|
+
}
|
|
171
286
|
if (op.kind === "drop") {
|
|
172
|
-
|
|
173
|
-
|
|
287
|
+
// The record lands first: a crash between the two leaves a card that still
|
|
288
|
+
// exists with a note explaining it, never a card that is gone unexplained.
|
|
289
|
+
await writeEnded(endedDir, result.card, actor, now);
|
|
290
|
+
await rm(join(pile, fileNameFor(id)), { force: true });
|
|
291
|
+
return { ok: true, card: result.card, changed: true, dropped: true, archived: false };
|
|
292
|
+
}
|
|
293
|
+
if (op.kind === "archive") {
|
|
294
|
+
// A card the board does not hold is already aside, and two sessions asking at
|
|
295
|
+
// once are one move rather than a second one.
|
|
296
|
+
if (pile === archiveDir) {
|
|
297
|
+
return { ok: true, card: result.card, changed: false, dropped: false, archived: false };
|
|
298
|
+
}
|
|
299
|
+
await moveCard(cardsDir, archiveDir, id);
|
|
300
|
+
return { ok: true, card: result.card, changed: true, dropped: false, archived: true };
|
|
174
301
|
}
|
|
175
302
|
// Last look before the rename: a writer that landed in between is the
|
|
176
303
|
// re-read the next pass applies against.
|
|
@@ -179,8 +306,8 @@ export function createStore(dir: string): BoardStore {
|
|
|
179
306
|
changedUnderneath = true;
|
|
180
307
|
continue;
|
|
181
308
|
}
|
|
182
|
-
await writeCardFile(
|
|
183
|
-
return { ok: true, card: result.card, changed: true, dropped: false };
|
|
309
|
+
await writeCardFile(pile, id, result.card);
|
|
310
|
+
return { ok: true, card: result.card, changed: true, dropped: false, archived: false };
|
|
184
311
|
}
|
|
185
312
|
return {
|
|
186
313
|
ok: false,
|
|
@@ -200,7 +327,22 @@ export function createStore(dir: string): BoardStore {
|
|
|
200
327
|
return total;
|
|
201
328
|
};
|
|
202
329
|
|
|
203
|
-
return {
|
|
330
|
+
return {
|
|
331
|
+
dir,
|
|
332
|
+
ensure,
|
|
333
|
+
list,
|
|
334
|
+
scan,
|
|
335
|
+
archived,
|
|
336
|
+
archivedCount,
|
|
337
|
+
read,
|
|
338
|
+
create,
|
|
339
|
+
mutate,
|
|
340
|
+
bytes,
|
|
341
|
+
skipped,
|
|
342
|
+
where: () => shortenPath(dir),
|
|
343
|
+
root: () => root,
|
|
344
|
+
name: () => basename(root),
|
|
345
|
+
};
|
|
204
346
|
}
|
|
205
347
|
|
|
206
348
|
/** `<id>.json`, zero-padded to four digits so a directory lists in card order. */
|
|
@@ -211,58 +353,129 @@ function fileNameFor(id: string): string {
|
|
|
211
353
|
/** The card files the reader accepts. */
|
|
212
354
|
const CARD_FILE = /^(\d{4,})\.json$/;
|
|
213
355
|
|
|
214
|
-
/** One card file, or
|
|
215
|
-
async function readCardFile(
|
|
356
|
+
/** One card file: the card, or the reason this build refused it. */
|
|
357
|
+
async function readCardFile(pileDir: string, entry: string): Promise<{ card: Card } | { skip: Skip }> {
|
|
216
358
|
const match = CARD_FILE.exec(entry);
|
|
217
|
-
if (!match) return
|
|
218
|
-
const
|
|
219
|
-
|
|
359
|
+
if (!match) return { skip: { file: entry, why: "not a card id" } };
|
|
360
|
+
const id = String(Number(match[1]));
|
|
361
|
+
const text = await readFile(join(pileDir, entry), "utf-8").catch(() => undefined);
|
|
362
|
+
if (text === undefined) return { skip: { file: entry, id, why: "cannot be read" } };
|
|
220
363
|
let parsed: unknown;
|
|
221
364
|
try {
|
|
222
365
|
parsed = JSON.parse(text);
|
|
223
366
|
} catch {
|
|
224
|
-
return
|
|
367
|
+
return { skip: { file: entry, id, why: "bad JSON" } };
|
|
368
|
+
}
|
|
369
|
+
// A version above this build's is the one refusal a reader can act on: the file was
|
|
370
|
+
// written by a newer build, so the board is not empty, it is not readable here.
|
|
371
|
+
const version = typeof parsed === "object" && parsed !== null ? (parsed as { v?: unknown }).v : undefined;
|
|
372
|
+
if (typeof version === "number" && version !== CARD_VERSION) {
|
|
373
|
+
return { skip: { file: entry, id, why: `version ${version}` } };
|
|
225
374
|
}
|
|
226
|
-
if (!isCard(parsed)) return
|
|
375
|
+
if (!isCard(parsed)) return { skip: { file: entry, id, why: "not a card record" } };
|
|
227
376
|
// The file name is the id's canonical spelling: a padded id inside the record
|
|
228
377
|
// (or a record moved under another name) is a disagreement, not a card.
|
|
229
|
-
if (parsed.id !== String(Number(match[1])))
|
|
230
|
-
|
|
378
|
+
if (parsed.id !== String(Number(match[1]))) {
|
|
379
|
+
return { skip: { file: entry, id, why: `names card ${parsed.id}` } };
|
|
380
|
+
}
|
|
381
|
+
return { card: parsed };
|
|
231
382
|
}
|
|
232
383
|
|
|
233
|
-
/**
|
|
234
|
-
async function
|
|
235
|
-
const entries = await readdirSafe(
|
|
384
|
+
/** The highest id a directory holds, or 0 when it holds none. */
|
|
385
|
+
async function highestId(dir: string): Promise<number> {
|
|
386
|
+
const entries = await readdirSafe(dir);
|
|
236
387
|
let highest = 0;
|
|
237
388
|
for (const entry of entries) {
|
|
238
389
|
const match = CARD_FILE.exec(entry);
|
|
239
390
|
if (match) highest = Math.max(highest, Number(match[1]));
|
|
240
391
|
}
|
|
241
|
-
return
|
|
392
|
+
return highest;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* `1`, `2`, `3` … as the highest id the board has ever used plus one.
|
|
397
|
+
*
|
|
398
|
+
* Ended and archived cards count: a dropped card's record keeps its id spent, and an
|
|
399
|
+
* archived card's file is still named by it, so a note or a handoff that names `#29`
|
|
400
|
+
* cannot come to mean a different feature tomorrow.
|
|
401
|
+
*/
|
|
402
|
+
async function nextCardId(cardsDir: string, endedDir: string, archiveDir: string): Promise<string> {
|
|
403
|
+
return String(Math.max(await highestId(cardsDir), await highestId(endedDir), await highestId(archiveDir)) + 1);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Write the record of a card that has ended.
|
|
408
|
+
*
|
|
409
|
+
* Exclusive like a create, so the id ends once: a retry that finds the record already
|
|
410
|
+
* there is the same end, not a second one.
|
|
411
|
+
*/
|
|
412
|
+
async function writeEnded(endedDir: string, card: Card, actor: Actor, now: number): Promise<void> {
|
|
413
|
+
await mkdir(endedDir, { recursive: true, mode: DIR_MODE });
|
|
414
|
+
const ended: EndedCard = {
|
|
415
|
+
v: 1,
|
|
416
|
+
id: card.id,
|
|
417
|
+
title: card.title,
|
|
418
|
+
status: card.status,
|
|
419
|
+
by: actor.label,
|
|
420
|
+
...(card.owner === undefined ? {} : { owner: card.owner }),
|
|
421
|
+
at: now,
|
|
422
|
+
why: "dropped",
|
|
423
|
+
};
|
|
424
|
+
const temp = tempPath(endedDir);
|
|
425
|
+
await writeFile(temp, serialize(ended), { mode: FILE_MODE });
|
|
426
|
+
try {
|
|
427
|
+
await link(temp, join(endedDir, fileNameFor(card.id)));
|
|
428
|
+
} catch (error) {
|
|
429
|
+
if (!hasErrorCode(error, "EEXIST")) throw error;
|
|
430
|
+
} finally {
|
|
431
|
+
await rm(temp, { force: true });
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Move one card's file into the archive.
|
|
437
|
+
*
|
|
438
|
+
* A rename, not a write: the record does not change, and the pile it lies in is the whole
|
|
439
|
+
* of what an archive means.
|
|
440
|
+
*/
|
|
441
|
+
async function moveCard(from: string, to: string, id: string): Promise<void> {
|
|
442
|
+
await mkdir(to, { recursive: true, mode: DIR_MODE });
|
|
443
|
+
await renameInto(join(from, fileNameFor(id)), join(to, fileNameFor(id)));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Rename one file over or onto another, with the fallback this platform needs.
|
|
448
|
+
*
|
|
449
|
+
* Windows refuses a rename onto an open file, so removing the target first is the only
|
|
450
|
+
* way through there; every caller has already read what it is replacing, which is what
|
|
451
|
+
* bounds what the removal can lose.
|
|
452
|
+
*/
|
|
453
|
+
async function renameInto(source: string, target: string): Promise<void> {
|
|
454
|
+
try {
|
|
455
|
+
await rename(source, target);
|
|
456
|
+
} catch (error) {
|
|
457
|
+
if (!hasErrorCode(error, "EPERM") && !hasErrorCode(error, "EACCES") && !hasErrorCode(error, "EEXIST")) throw error;
|
|
458
|
+
await rm(target, { force: true });
|
|
459
|
+
await rename(source, target);
|
|
460
|
+
}
|
|
242
461
|
}
|
|
243
462
|
|
|
244
463
|
/** Write one card: a temp file in the same directory, then a rename over the old one. */
|
|
245
|
-
async function writeCardFile(
|
|
246
|
-
const temp = tempPath(
|
|
464
|
+
async function writeCardFile(dir: string, id: string, card: Card): Promise<void> {
|
|
465
|
+
const temp = tempPath(dir);
|
|
247
466
|
await writeFile(temp, serialize(card), { mode: FILE_MODE });
|
|
248
|
-
const target = join(cardsDir, fileNameFor(id));
|
|
249
467
|
try {
|
|
250
|
-
await
|
|
468
|
+
await renameInto(temp, join(dir, fileNameFor(id)));
|
|
251
469
|
} catch (error) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
if (!hasErrorCode(error, "EPERM") && !hasErrorCode(error, "EACCES") && !hasErrorCode(error, "EEXIST")) {
|
|
255
|
-
await rm(temp, { force: true });
|
|
256
|
-
throw error;
|
|
257
|
-
}
|
|
258
|
-
await rm(target, { force: true });
|
|
259
|
-
await rename(temp, target);
|
|
470
|
+
await rm(temp, { force: true });
|
|
471
|
+
throw error;
|
|
260
472
|
}
|
|
261
473
|
}
|
|
262
474
|
|
|
263
475
|
/** One card's bytes, ending in a newline so the file reads well in an editor. */
|
|
264
|
-
|
|
265
|
-
|
|
476
|
+
/** One record as the bytes a file holds: tab-indented JSON with a closing newline. */
|
|
477
|
+
function serialize(record: Card | EndedCard): string {
|
|
478
|
+
return `${JSON.stringify(record, null, "\t")}\n`;
|
|
266
479
|
}
|
|
267
480
|
|
|
268
481
|
/** A temp path in the cards directory: never matched by the reader's own pattern. */
|
package/lib/board/view.ts
CHANGED
|
@@ -8,21 +8,31 @@
|
|
|
8
8
|
*
|
|
9
9
|
* `where` is the store's own path, shortened, so a reader can tell which directory
|
|
10
10
|
* a shared board is filed under — and so nothing here needs a "remote" variant that
|
|
11
|
-
* no backend produces.
|
|
11
|
+
* no backend produces. `repo` and `root` are the same board said aloud: the repository
|
|
12
|
+
* root's own name, which a tab can carry, and its whole path for the hover title.
|
|
12
13
|
*
|
|
13
14
|
* Pure: the caller supplies the cards, the clock, and the sizes it read.
|
|
14
15
|
*/
|
|
15
16
|
|
|
16
|
-
import { type RosterSession, rosterStateOf } from "../session-roster.ts";
|
|
17
|
-
import {
|
|
17
|
+
import { type RosterSession, type RosterState, rosterStateOf } from "../session-roster.ts";
|
|
18
|
+
import {
|
|
19
|
+
type Card,
|
|
20
|
+
CARD_STATUSES,
|
|
21
|
+
type CardStatus,
|
|
22
|
+
laneSince,
|
|
23
|
+
priorityOf,
|
|
24
|
+
type Priority,
|
|
25
|
+
signatureOf,
|
|
26
|
+
} from "./card.ts";
|
|
27
|
+
import { blockersOf, indexOf, lanesText, openDependentsOf } from "./links.ts";
|
|
18
28
|
import type { Actor } from "./rules.ts";
|
|
19
29
|
import { buildRound, type RoundSummary } from "./round.ts";
|
|
20
30
|
|
|
21
31
|
/** One session on the repository's bus, as the Team tab lists it. */
|
|
22
32
|
export interface ViewTeammate {
|
|
23
33
|
label: string;
|
|
24
|
-
/** `live` while its heartbeat holds, `quiet` once the record has aged out. */
|
|
25
|
-
state:
|
|
34
|
+
/** `live` while its heartbeat holds, `quiet` once the record has aged out, `gone` once its process is not running. */
|
|
35
|
+
state: RosterState;
|
|
26
36
|
/** When it last proved it was there. */
|
|
27
37
|
seen: number;
|
|
28
38
|
/** Messages waiting in its inbox. */
|
|
@@ -33,10 +43,47 @@ export interface ViewTeammate {
|
|
|
33
43
|
you: boolean;
|
|
34
44
|
}
|
|
35
45
|
|
|
46
|
+
/**
|
|
47
|
+
* One card as the page meets it: the stored card, plus the two answers it would
|
|
48
|
+
* otherwise compute for itself.
|
|
49
|
+
*
|
|
50
|
+
* Both are derived from the board, not stored on the card, so a client that did the
|
|
51
|
+
* same work would be a second copy of a rule — and the copies drift, which is how the
|
|
52
|
+
* page came to disagree with the tool about what a card waits for.
|
|
53
|
+
*/
|
|
54
|
+
export interface ViewCard extends Card {
|
|
55
|
+
/** The priority it acts on: a card that never set one is ordinary. */
|
|
56
|
+
priority: Priority;
|
|
57
|
+
/**
|
|
58
|
+
* The unfinished cards it waits for, as `#2 (doing)`; absent when nothing blocks it.
|
|
59
|
+
*
|
|
60
|
+
* `undefined` is the whole fact — a waiting card is one with a `waits` — because a
|
|
61
|
+
* boolean beside it would be one more thing that can disagree with the string.
|
|
62
|
+
*/
|
|
63
|
+
waits?: string;
|
|
64
|
+
/**
|
|
65
|
+
* The unfinished cards waiting on it, as `#9 (todo)`; absent when none do.
|
|
66
|
+
*
|
|
67
|
+
* The other side of `waits`: what leaving this card undone holds up. Derived here
|
|
68
|
+
* rather than in the page's script for the same reason, and filtered to the unfinished
|
|
69
|
+
* for the same reason a finished blocker does not count.
|
|
70
|
+
*/
|
|
71
|
+
dependents?: string;
|
|
72
|
+
/**
|
|
73
|
+
* When the card entered the lane it is in now, or absent when its history does not say.
|
|
74
|
+
*
|
|
75
|
+
* A timestamp rather than a phrase: the page already formats ages, and a wording
|
|
76
|
+
* decided here would be a second copy of that rule. Absent is the honest answer for a
|
|
77
|
+
* card written before the log existed, so the page draws no age rather than guessing
|
|
78
|
+
* one from `updatedAt`, which moves on every write.
|
|
79
|
+
*/
|
|
80
|
+
since?: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
36
83
|
/** One lane and the cards in it, in the order the page draws them. */
|
|
37
84
|
export interface ViewLane {
|
|
38
85
|
status: CardStatus;
|
|
39
|
-
cards:
|
|
86
|
+
cards: ViewCard[];
|
|
40
87
|
}
|
|
41
88
|
|
|
42
89
|
/** The per-lane tallies the toolbar shows. */
|
|
@@ -50,13 +97,16 @@ export interface ViewCounts {
|
|
|
50
97
|
|
|
51
98
|
/** Everything the page is allowed to know. */
|
|
52
99
|
export interface BoardView {
|
|
53
|
-
/**
|
|
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
|
+
*/
|
|
54
106
|
me: { id?: string; label: string };
|
|
55
|
-
/** The session the page is served from, when the page knows it: who is reading. */
|
|
56
|
-
session?: { id?: string; label: string };
|
|
57
107
|
lanes: ViewLane[];
|
|
58
108
|
counts: ViewCounts;
|
|
59
|
-
round: RoundSummary
|
|
109
|
+
round: RoundSummary<ViewCard>;
|
|
60
110
|
/**
|
|
61
111
|
* The sessions on this repository's bus, or absent when there is no bus here.
|
|
62
112
|
*
|
|
@@ -66,54 +116,94 @@ export interface BoardView {
|
|
|
66
116
|
teammates?: ViewTeammate[];
|
|
67
117
|
/** Bytes the store occupies, so a board that is never pruned says so. */
|
|
68
118
|
bytes: number;
|
|
119
|
+
/**
|
|
120
|
+
* Card files the reader refused, so the footer can say the board is not empty.
|
|
121
|
+
*
|
|
122
|
+
* Zero is the ordinary answer and draws nothing; a count above it is the one fact
|
|
123
|
+
* that separates "no work here" from "this build cannot read your work".
|
|
124
|
+
*/
|
|
125
|
+
skipped: number;
|
|
69
126
|
/** Changes when a card's revision, a teammate's state, or its mail does. */
|
|
70
127
|
sig: string;
|
|
128
|
+
/** The root's last segment, e.g. `my-pi-agent`: what the tab and the footer lead with. */
|
|
129
|
+
repo: string;
|
|
130
|
+
/** The repository root, absolute, so the footer can put the whole path on hover. */
|
|
131
|
+
root: string;
|
|
71
132
|
/** The store's directory, shortened. */
|
|
72
133
|
where: string;
|
|
73
134
|
}
|
|
74
135
|
|
|
75
136
|
/** What the server read before building one snapshot. */
|
|
76
137
|
export interface ViewInput {
|
|
138
|
+
/** The page's own actor, which is both who it writes as and who is looking. */
|
|
77
139
|
me: Actor;
|
|
78
|
-
/**
|
|
79
|
-
* The session the page is served from, when it has one.
|
|
80
|
-
*
|
|
81
|
-
* The page writes as its seat — a fixed name — so the reader's own row on the Team
|
|
82
|
-
* tab is marked from the session, not from `me`: the seat is what a card records,
|
|
83
|
-
* and the session is who is looking.
|
|
84
|
-
*/
|
|
85
|
-
session?: Actor;
|
|
86
140
|
cards: readonly Card[];
|
|
87
141
|
now: number;
|
|
88
142
|
bytes: number;
|
|
143
|
+
/** Card files the reader refused; absent reads as none, which is the ordinary board. */
|
|
144
|
+
skipped?: number;
|
|
89
145
|
where: string;
|
|
146
|
+
/** The repository's name, from `BoardStore.name()`. */
|
|
147
|
+
repo: string;
|
|
148
|
+
/** The repository root, from `BoardStore.root()`. */
|
|
149
|
+
root: string;
|
|
90
150
|
/** The bus's records, when the page could read them at all. */
|
|
91
151
|
roster?: readonly RosterSession[];
|
|
92
152
|
}
|
|
93
153
|
|
|
94
154
|
/** Build the page's snapshot. */
|
|
95
155
|
export function buildView(input: ViewInput): BoardView {
|
|
156
|
+
// One index for the whole snapshot: a card's lane and its blockers are read from the
|
|
157
|
+
// same board, so a dependency and its dependent cannot be told two different stories.
|
|
158
|
+
const index = indexOf(input.cards);
|
|
159
|
+
// One mapped list feeds both halves of the snapshot: the rollup's groups carry the same
|
|
160
|
+
// card the lane does, so a client reading `round.unassigned` is not handed a card
|
|
161
|
+
// missing the facts every other card has.
|
|
162
|
+
const cards = input.cards.map((card) => viewCardOf(card, index));
|
|
96
163
|
const lanes = CARD_STATUSES.map((status) => ({
|
|
97
164
|
status,
|
|
98
|
-
cards:
|
|
165
|
+
cards: cards.filter((card) => card.status === status),
|
|
99
166
|
}));
|
|
100
|
-
const reader = input.
|
|
167
|
+
const reader = input.me;
|
|
101
168
|
const teammates = input.roster === undefined ? undefined : teammatesOf(input.roster, reader.label, input.now);
|
|
102
169
|
return {
|
|
103
170
|
me: input.me.id === undefined ? { label: input.me.label } : { id: input.me.id, label: input.me.label },
|
|
104
|
-
...(input.session === undefined ? {} : { session: input.session }),
|
|
105
171
|
lanes,
|
|
106
172
|
counts: countOf(lanes),
|
|
107
|
-
round: buildRound(
|
|
173
|
+
round: buildRound(cards, input.now),
|
|
108
174
|
...(teammates === undefined ? {} : { teammates }),
|
|
109
175
|
bytes: input.bytes,
|
|
176
|
+
skipped: input.skipped ?? 0,
|
|
110
177
|
// The roster enters the signature at its coarse facts only: a heartbeat every ten
|
|
111
|
-
// seconds is not news, while a join, a quiet and a letter are.
|
|
112
|
-
|
|
178
|
+
// seconds is not news, while a join, a quiet and a letter are. A file that became
|
|
179
|
+
// readable, or unreadable, is news too: the footer counts it, and a frame that is
|
|
180
|
+
// not pushed leaves the page saying the board holds less than it does.
|
|
181
|
+
sig: signatureOf(input.cards) + rosterSignature(teammates) + `|skipped:${input.skipped ?? 0}`,
|
|
182
|
+
repo: input.repo,
|
|
183
|
+
root: input.root,
|
|
113
184
|
where: input.where,
|
|
114
185
|
};
|
|
115
186
|
}
|
|
116
187
|
|
|
188
|
+
/**
|
|
189
|
+
* One card for the page: what it carries, plus what it acts on.
|
|
190
|
+
*
|
|
191
|
+
* The card is copied rather than annotated — the caller's cards are the store's, and a
|
|
192
|
+
* derived field written onto one would outlive the read that made it.
|
|
193
|
+
*/
|
|
194
|
+
function viewCardOf(card: Card, index: ReadonlyMap<string, Card>): ViewCard {
|
|
195
|
+
const waits = lanesText(blockersOf(card, index));
|
|
196
|
+
const dependents = lanesText(openDependentsOf(card.id, index));
|
|
197
|
+
const since = laneSince(card);
|
|
198
|
+
return {
|
|
199
|
+
...card,
|
|
200
|
+
priority: priorityOf(card),
|
|
201
|
+
...(waits === "" ? {} : { waits }),
|
|
202
|
+
...(dependents === "" ? {} : { dependents }),
|
|
203
|
+
...(since === undefined ? {} : { since }),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
117
207
|
/** The roster as the page shows it, ordered so a repaint never reorders it. */
|
|
118
208
|
function teammatesOf(roster: readonly RosterSession[], meLabel: string, now: number): ViewTeammate[] {
|
|
119
209
|
return roster
|