@gavin-hu/my-pi-agent 0.19.0 → 0.20.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 +17 -11
- package/README.zh-CN.md +13 -8
- package/docs/extensions/README.md +1 -1
- package/docs/extensions/board.md +286 -123
- package/docs/extensions/bytefly.md +381 -223
- package/docs/extensions/file-browse.md +14 -7
- package/docs/extensions/job.md +1 -2
- package/docs/extensions/plan.md +56 -11
- package/docs/extensions/session-bus.md +23 -10
- package/docs/extensions/team.md +45 -17
- package/docs/extensions/wechat.md +2 -1
- package/docs/extensions/worktree.md +30 -15
- package/extensions/board/commands.ts +23 -5
- package/extensions/board/format.ts +107 -9
- package/extensions/board/join.ts +1 -1
- package/extensions/board/page.ts +170 -94
- package/extensions/board/result.ts +9 -0
- package/extensions/board/schema.ts +22 -7
- package/extensions/board/seat.ts +9 -10
- package/extensions/board/snapshot.ts +1 -0
- package/extensions/board/tools.ts +186 -21
- package/extensions/board/transport/router.ts +27 -108
- package/extensions/board/transport/server.ts +18 -29
- package/extensions/board/types.ts +67 -0
- package/extensions/board/view/client.ts +23 -13
- package/extensions/board/view/dialog.ts +43 -0
- package/extensions/board/view/keys.ts +28 -24
- package/extensions/board/view/live.ts +5 -171
- package/extensions/board/view/names.ts +91 -0
- package/extensions/board/view/page.ts +48 -88
- package/extensions/board/view/render.ts +213 -190
- package/extensions/board/view/styles.ts +65 -48
- 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 -8
- package/extensions/bytefly/footer/price/price.ts +30 -8
- package/extensions/bytefly/footer/register.ts +5 -5
- package/extensions/bytefly/footer/render/format.ts +18 -0
- package/extensions/bytefly/footer/render/lines.ts +23 -7
- package/extensions/bytefly/footer/render/segments.ts +117 -31
- package/extensions/bytefly/footer/types.ts +29 -5
- package/extensions/bytefly/footer/unpriced.ts +42 -17
- 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/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/transport/router.ts +0 -2
- package/extensions/file-browse/view/emit.ts +51 -0
- package/extensions/file-browse/view/html.ts +6 -7
- package/extensions/file-browse/view/markdown.ts +11 -13
- package/extensions/file-browse/view/raw-html.ts +18 -13
- package/extensions/job/runtime.ts +4 -4
- package/extensions/plan/feedback.ts +40 -16
- package/extensions/plan/format.ts +10 -3
- package/extensions/plan/index.ts +17 -1
- package/extensions/plan/plan-files.ts +88 -0
- package/extensions/plan/plans.ts +19 -23
- package/extensions/plan/policy.ts +10 -5
- package/extensions/plan/runtime.ts +31 -2
- 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/types.ts +15 -0
- package/extensions/plan/web-blocks.ts +89 -0
- package/extensions/plan/web-chrome.ts +3 -5
- package/extensions/plan/web-client.ts +179 -330
- package/extensions/plan/web-collab.ts +3 -3
- package/extensions/plan/web-comments.ts +18 -8
- package/extensions/plan/web-composer.ts +80 -0
- package/extensions/plan/web-draft.ts +40 -0
- package/extensions/plan/web-keys.ts +134 -0
- package/extensions/plan/web-outline.ts +192 -0
- package/extensions/plan/web-page.ts +4 -1
- package/extensions/plan/web-plan.ts +150 -0
- package/extensions/plan/web-router.ts +0 -1
- package/extensions/plan/web-settle.ts +194 -0
- package/extensions/rewind/commands/review.ts +2 -1
- 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/tui.ts +1 -1
- package/extensions/session-bus/types.ts +10 -1
- package/extensions/subagent/commands.ts +2 -1
- package/extensions/team/commands.ts +41 -8
- 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/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 +6 -5
- package/lib/board/card.ts +192 -3
- package/lib/board/links.ts +26 -2
- package/lib/board/page-pointer.ts +86 -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/store.ts +239 -52
- package/lib/board/view.ts +94 -10
- package/lib/format.ts +22 -0
- package/lib/http/serve.ts +3 -0
- package/lib/http/sink.ts +34 -9
- package/lib/plan-entry.ts +11 -0
- package/lib/policy.ts +14 -1
- package/lib/session-roster.ts +74 -3
- package/lib/spawn.ts +37 -3
- package/lib/stray-fields.ts +45 -0
- package/lib/tool-names.ts +13 -0
- package/lib/tui/glyphs.ts +2 -3
- package/lib/web/README.md +52 -2
- package/lib/web/client-runtime.ts +123 -0
- package/lib/web/index.ts +4 -2
- package/lib/web/markup.ts +88 -1
- package/lib/web/names.ts +42 -0
- package/package.json +1 -1
- 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/card.ts
CHANGED
|
@@ -30,12 +30,25 @@ export const MAX_PATH = 200;
|
|
|
30
30
|
/** Longest recorded plan path, in characters. */
|
|
31
31
|
export const MAX_PLAN = 300;
|
|
32
32
|
|
|
33
|
+
/** Longest sentence saying what a blocked card is waiting on: one line, so it fits a row. */
|
|
34
|
+
export const MAX_BLOCKED = 200;
|
|
35
|
+
|
|
33
36
|
/** Longest single note, in characters. */
|
|
34
37
|
export const MAX_NOTE = 1000;
|
|
35
38
|
|
|
36
39
|
/** How many notes one card keeps; the oldest drops past this, counted in `notesDropped`. */
|
|
37
40
|
export const MAX_NOTES = 8;
|
|
38
41
|
|
|
42
|
+
/**
|
|
43
|
+
* How many lane transitions one card keeps; the oldest drops past this, counted in
|
|
44
|
+
* `logDropped`.
|
|
45
|
+
*
|
|
46
|
+
* Bounded at write time rather than by a janitor, because nothing prunes a board: the
|
|
47
|
+
* log is the only record of when a lane was entered, so it has to stay small enough
|
|
48
|
+
* that every card can afford one.
|
|
49
|
+
*/
|
|
50
|
+
export const MAX_LOG = 8;
|
|
51
|
+
|
|
39
52
|
/** How many other cards one card may wait on. */
|
|
40
53
|
export const MAX_DEPENDS = 6;
|
|
41
54
|
|
|
@@ -69,6 +82,33 @@ export interface CardNote {
|
|
|
69
82
|
text: string;
|
|
70
83
|
}
|
|
71
84
|
|
|
85
|
+
/**
|
|
86
|
+
* One lane a card has been in, when it got there, and who moved it.
|
|
87
|
+
*
|
|
88
|
+
* `updatedAt` moves on every write, so it answers "when was this last touched", not
|
|
89
|
+
* "how long has this been blocked": a note filed against a blocked card resets it. A
|
|
90
|
+
* transition is recorded only when the lane actually changes, which is the fact the
|
|
91
|
+
* page needs and the one a reader has no way to reconstruct.
|
|
92
|
+
*/
|
|
93
|
+
export interface CardTransition {
|
|
94
|
+
status: CardStatus;
|
|
95
|
+
at: number;
|
|
96
|
+
by: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* What a finished card landed as.
|
|
101
|
+
*
|
|
102
|
+
* A `done` card cannot say which change closed it from its own text, and `done` is
|
|
103
|
+
* terminal, so the commit is recorded once, where the move that finished it happens.
|
|
104
|
+
*/
|
|
105
|
+
export interface CardLanded {
|
|
106
|
+
/** The commit at the move to `done`, as git resolves `HEAD` there. */
|
|
107
|
+
commit: string;
|
|
108
|
+
/** When the card was finished. */
|
|
109
|
+
at: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
72
112
|
/** One feature on the board. */
|
|
73
113
|
export interface Card {
|
|
74
114
|
/** Schema version; an unknown value is refused rather than guessed at. */
|
|
@@ -83,6 +123,25 @@ export interface Card {
|
|
|
83
123
|
paths: string[];
|
|
84
124
|
/** The plan file being executed, once one is recorded. */
|
|
85
125
|
plan?: string;
|
|
126
|
+
/**
|
|
127
|
+
* What a blocked card is waiting on: the decision, in one line.
|
|
128
|
+
*
|
|
129
|
+
* `blocked` exists to say a human has to decide something — a dependency is a
|
|
130
|
+
* different thing, and `waits` is what refuses a claim. Absent means the card was
|
|
131
|
+
* blocked without saying why, which is a state the tool allows and the page names.
|
|
132
|
+
*/
|
|
133
|
+
blockedBy?: string;
|
|
134
|
+
/** What closed a finished card: the commit at the move to `done`, and when. */
|
|
135
|
+
landed?: CardLanded;
|
|
136
|
+
/**
|
|
137
|
+
* When this card was last offered to the team, and nobody has taken it.
|
|
138
|
+
*
|
|
139
|
+
* An offer is a bus event, and an event leaves nothing behind: a card offered to three
|
|
140
|
+
* sessions that all ignored it read exactly like one nobody had looked at. This is the
|
|
141
|
+
* one fact an offer can leave, and every answer to it — a claim, an assignment, a
|
|
142
|
+
* release, a move — clears it, so a record here means the offer is still open.
|
|
143
|
+
*/
|
|
144
|
+
offeredAt?: number;
|
|
86
145
|
/** How urgent the feature is; absent reads as {@link DEFAULT_PRIORITY}. */
|
|
87
146
|
priority?: Priority;
|
|
88
147
|
/** Ids of the cards this one waits for; absent reads as none. */
|
|
@@ -91,6 +150,15 @@ export interface Card {
|
|
|
91
150
|
notes: CardNote[];
|
|
92
151
|
/** How many notes dropped off the front, so a reader knows history was elided. */
|
|
93
152
|
notesDropped: number;
|
|
153
|
+
/**
|
|
154
|
+
* The lanes this card has been in, oldest first: the last {@link MAX_LOG} transitions.
|
|
155
|
+
*
|
|
156
|
+
* Absent on a card written before this field existed, which is why every reader goes
|
|
157
|
+
* through {@link laneSince} rather than reading the list directly.
|
|
158
|
+
*/
|
|
159
|
+
log?: CardTransition[];
|
|
160
|
+
/** How many transitions dropped off the front, so a reader knows the log was elided. */
|
|
161
|
+
logDropped?: number;
|
|
94
162
|
/** Bumped per write; the compare-and-set guard a concurrent writer loses on. */
|
|
95
163
|
rev: number;
|
|
96
164
|
createdAt: number;
|
|
@@ -99,6 +167,47 @@ export interface Card {
|
|
|
99
167
|
by: string;
|
|
100
168
|
}
|
|
101
169
|
|
|
170
|
+
/**
|
|
171
|
+
* What a card left behind when it was dropped: the record that its id is spent.
|
|
172
|
+
*
|
|
173
|
+
* A card's id is how a note, a handoff, or another card names one feature, so an id
|
|
174
|
+
* that comes back is worse than an id that is gone — the same number would answer for
|
|
175
|
+
* two different features. The store keeps one of these per ended card, writes it
|
|
176
|
+
* before the card file goes, and never allocates its id again.
|
|
177
|
+
*/
|
|
178
|
+
export interface EndedCard {
|
|
179
|
+
/** Schema version for the record itself; a reader refuses what it does not know. */
|
|
180
|
+
v: 1;
|
|
181
|
+
/** The id that is now spent, without padding. */
|
|
182
|
+
id: string;
|
|
183
|
+
/** What the card was called, so a note that names its id still reads. */
|
|
184
|
+
title: string;
|
|
185
|
+
/** The lane it was in when it ended. */
|
|
186
|
+
status: CardStatus;
|
|
187
|
+
/** Who ended it. */
|
|
188
|
+
by: string;
|
|
189
|
+
/** The owner it had, when it had one. */
|
|
190
|
+
owner?: CardOwner;
|
|
191
|
+
/** When it ended. */
|
|
192
|
+
at: number;
|
|
193
|
+
/** Why it ended: the operation, which is `drop` today. */
|
|
194
|
+
why: "dropped";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Whether `value` is an ended-card record this build can trust. */
|
|
198
|
+
export function isEndedCard(value: unknown): value is EndedCard {
|
|
199
|
+
if (typeof value !== "object" || value === null) return false;
|
|
200
|
+
const ended = value as Record<string, unknown>;
|
|
201
|
+
if (ended.v !== CARD_VERSION) return false;
|
|
202
|
+
if (typeof ended.id !== "string" || !/^[0-9]+$/.test(ended.id)) return false;
|
|
203
|
+
if (typeof ended.title !== "string" || ended.title === "") return false;
|
|
204
|
+
if (!CARD_STATUSES.includes(ended.status as CardStatus)) return false;
|
|
205
|
+
if (typeof ended.by !== "string" || ended.by === "") return false;
|
|
206
|
+
if (!isOwner(ended.owner)) return false;
|
|
207
|
+
if (typeof ended.at !== "number" || !Number.isInteger(ended.at)) return false;
|
|
208
|
+
return ended.why === "dropped";
|
|
209
|
+
}
|
|
210
|
+
|
|
102
211
|
/** A rejected field, phrased for the model: what was wrong, not that something was. */
|
|
103
212
|
export type FieldError = { error: string };
|
|
104
213
|
|
|
@@ -163,25 +272,50 @@ export function dependsOf(card: Card): readonly string[] {
|
|
|
163
272
|
return card.dependsOn ?? [];
|
|
164
273
|
}
|
|
165
274
|
|
|
275
|
+
/**
|
|
276
|
+
* When the card entered the lane it is in now, or `undefined` when its history does not
|
|
277
|
+
* say.
|
|
278
|
+
*
|
|
279
|
+
* Two ways to know nothing: a card written before the log existed, and one whose entry
|
|
280
|
+
* for the current lane has dropped off the front of a full log. Both read the same to a
|
|
281
|
+
* caller — the page draws no age — rather than a guess from `updatedAt`, which is a
|
|
282
|
+
* different fact and would be wrong exactly when a card is being worked on.
|
|
283
|
+
*/
|
|
284
|
+
export function laneSince(card: Card): number | undefined {
|
|
285
|
+
const last = card.log?.at(-1);
|
|
286
|
+
return last !== undefined && last.status === card.status ? last.at : undefined;
|
|
287
|
+
}
|
|
288
|
+
|
|
166
289
|
/** Where a card's priority sits in {@link PRIORITIES}: the lane sort's first key after the lane. */
|
|
167
290
|
export function priorityRank(card: Card): number {
|
|
168
291
|
return PRIORITIES.indexOf(priorityOf(card));
|
|
169
292
|
}
|
|
170
293
|
|
|
171
294
|
/**
|
|
172
|
-
* Whether `query` names `card`: its title,
|
|
295
|
+
* Whether `query` names `card`: its title, its holder, a path hint, its plan, or a note.
|
|
173
296
|
*
|
|
174
297
|
* The page's filter and `board list`'s `query` are the same question, so they are the
|
|
175
|
-
* same rule here —
|
|
298
|
+
* same rule here — and the same *body*: this function's source is spliced into the
|
|
299
|
+
* browser bundle as `MATCHES_QUERY_JS`, which is why it is written with concatenation
|
|
300
|
+
* and no backtick. A second copy in the page is exactly what drifted before.
|
|
301
|
+
*
|
|
302
|
+
* The order is the answer's weight: a title hit is what a reader meant, then who holds
|
|
303
|
+
* it, then where it lives, and only then the reasoning — a plan path and the notes,
|
|
304
|
+
* which are the two fields that make a card findable by what it says.
|
|
176
305
|
*/
|
|
177
306
|
export function matchesQuery(card: Card, query: string): boolean {
|
|
178
307
|
const needle = query.trim().toLowerCase();
|
|
179
308
|
if (needle === "") return true;
|
|
180
309
|
if (card.title.toLowerCase().includes(needle)) return true;
|
|
181
310
|
if (card.owner !== undefined && card.owner.label.toLowerCase().includes(needle)) return true;
|
|
182
|
-
|
|
311
|
+
if (card.paths.some((path) => path.toLowerCase().includes(needle))) return true;
|
|
312
|
+
if (card.plan !== undefined && card.plan.toLowerCase().includes(needle)) return true;
|
|
313
|
+
return card.notes.some((note) => note.text.toLowerCase().includes(needle));
|
|
183
314
|
}
|
|
184
315
|
|
|
316
|
+
/** The same body, as the browser receives it: one rule for the tool and the filter box. */
|
|
317
|
+
export const MATCHES_QUERY_JS = String(matchesQuery);
|
|
318
|
+
|
|
185
319
|
/** One note's text: a single sanitized line, or the reason it cannot be one. */
|
|
186
320
|
export function normalizeNote(raw: unknown): { text: string } | FieldError {
|
|
187
321
|
if (typeof raw !== "string") return { error: "text is required." };
|
|
@@ -205,6 +339,22 @@ export function normalizePlanPath(raw: unknown): { path: string } | FieldError {
|
|
|
205
339
|
return { path };
|
|
206
340
|
}
|
|
207
341
|
|
|
342
|
+
/**
|
|
343
|
+
* The sentence a block records: one line, and nothing more.
|
|
344
|
+
*
|
|
345
|
+
* The board never resolves it against anything — it is the reader's explanation of what
|
|
346
|
+
* a person has to decide — so this only keeps it to a length a row can hold.
|
|
347
|
+
*/
|
|
348
|
+
export function normalizeBlockedBy(raw: unknown): { blockedBy: string } | FieldError {
|
|
349
|
+
if (typeof raw !== "string") return { error: "blockedBy must be text." };
|
|
350
|
+
const blockedBy = sanitizeLine(raw).trim();
|
|
351
|
+
if (blockedBy === "") return { error: "blockedBy must say what the card waits on." };
|
|
352
|
+
if (blockedBy.length > MAX_BLOCKED) {
|
|
353
|
+
return { error: `blockedBy must be at most ${MAX_BLOCKED} characters.` };
|
|
354
|
+
}
|
|
355
|
+
return { blockedBy };
|
|
356
|
+
}
|
|
357
|
+
|
|
208
358
|
/** A card id as a caller writes it (`4`, `#4`), or undefined when it is not one. */
|
|
209
359
|
export function normalizeCardId(raw: unknown): string | undefined {
|
|
210
360
|
if (typeof raw !== "string") return undefined;
|
|
@@ -250,17 +400,38 @@ export function isCard(value: unknown): value is Card {
|
|
|
250
400
|
if (!Array.isArray(card.paths) || card.paths.length > MAX_PATHS) return false;
|
|
251
401
|
if (!card.paths.every((path) => typeof path === "string")) return false;
|
|
252
402
|
if (card.plan !== undefined && typeof card.plan !== "string") return false;
|
|
403
|
+
if (card.blockedBy !== undefined && !isBlockedBy(card.blockedBy)) return false;
|
|
404
|
+
if (card.landed !== undefined && !isLanded(card.landed)) return false;
|
|
405
|
+
if (card.offeredAt !== undefined && (typeof card.offeredAt !== "number" || !Number.isInteger(card.offeredAt))) {
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
253
408
|
if (card.priority !== undefined && !PRIORITIES.includes(card.priority as Priority)) return false;
|
|
254
409
|
if (card.dependsOn !== undefined && !isDepends(card.dependsOn)) return false;
|
|
255
410
|
if (!Array.isArray(card.notes) || card.notes.length > MAX_NOTES) return false;
|
|
256
411
|
if (!card.notes.every(isNote)) return false;
|
|
257
412
|
if (typeof card.notesDropped !== "number") return false;
|
|
413
|
+
if (card.log !== undefined && (!Array.isArray(card.log) || card.log.length > MAX_LOG)) return false;
|
|
414
|
+
if (Array.isArray(card.log) && !card.log.every(isTransition)) return false;
|
|
415
|
+
if (card.logDropped !== undefined && !isCount(card.logDropped)) return false;
|
|
258
416
|
if (typeof card.rev !== "number" || !Number.isInteger(card.rev) || card.rev < 1) return false;
|
|
259
417
|
if (typeof card.createdAt !== "number" || typeof card.updatedAt !== "number") return false;
|
|
260
418
|
if (typeof card.by !== "string") return false;
|
|
261
419
|
return (card.status === "todo") === (card.owner === undefined);
|
|
262
420
|
}
|
|
263
421
|
|
|
422
|
+
/** A recorded block: one line the reader can hold, within the cap this build writes. */
|
|
423
|
+
function isBlockedBy(value: unknown): value is string {
|
|
424
|
+
return typeof value === "string" && value !== "" && value.length <= MAX_BLOCKED;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** A recorded landing: a commit id and the moment it closed the card. */
|
|
428
|
+
function isLanded(value: unknown): value is CardLanded {
|
|
429
|
+
if (typeof value !== "object" || value === null) return false;
|
|
430
|
+
const landed = value as Record<string, unknown>;
|
|
431
|
+
if (typeof landed.commit !== "string" || !/^[0-9a-f]{4,40}$/.test(landed.commit)) return false;
|
|
432
|
+
return typeof landed.at === "number" && Number.isInteger(landed.at);
|
|
433
|
+
}
|
|
434
|
+
|
|
264
435
|
function isOwner(value: unknown): boolean {
|
|
265
436
|
if (value === undefined) return true;
|
|
266
437
|
if (typeof value !== "object" || value === null) return false;
|
|
@@ -283,6 +454,24 @@ function isDepends(value: unknown): boolean {
|
|
|
283
454
|
return true;
|
|
284
455
|
}
|
|
285
456
|
|
|
457
|
+
/** A recorded lane change: a lane, a moment, and who moved it. */
|
|
458
|
+
function isTransition(value: unknown): value is CardTransition {
|
|
459
|
+
if (typeof value !== "object" || value === null) return false;
|
|
460
|
+
const entry = value as Record<string, unknown>;
|
|
461
|
+
return (
|
|
462
|
+
CARD_STATUSES.includes(entry.status as CardStatus) &&
|
|
463
|
+
typeof entry.at === "number" &&
|
|
464
|
+
Number.isInteger(entry.at) &&
|
|
465
|
+
typeof entry.by === "string" &&
|
|
466
|
+
entry.by !== ""
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** A count a reader can trust: a whole number that never ran backwards past zero. */
|
|
471
|
+
function isCount(value: unknown): value is number {
|
|
472
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
473
|
+
}
|
|
474
|
+
|
|
286
475
|
function isNote(value: unknown): boolean {
|
|
287
476
|
if (typeof value !== "object" || value === null) return false;
|
|
288
477
|
const note = value as Record<string, unknown>;
|
package/lib/board/links.ts
CHANGED
|
@@ -39,17 +39,41 @@ export function blockersOf(card: Card, board: BoardIndex): Card[] {
|
|
|
39
39
|
.filter((other): other is Card => other !== undefined && other.status !== "done");
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* A list of cards as a reader meets it: `#2 (doing), #3 (blocked)`.
|
|
44
|
+
*
|
|
45
|
+
* The lane is what turns ids into a decision — a finished card is about to start, a
|
|
46
|
+
* `doing` one is to leave alone — so it is never left out. One wording serves the
|
|
47
|
+
* refusal sentence, the page's reason line, and the mirror question of what a card is
|
|
48
|
+
* holding up: every one of them is a list of cards read to decide something, so a second
|
|
49
|
+
* formatter would be a second way for the same list to read.
|
|
50
|
+
*/
|
|
51
|
+
export function lanesText(cards: readonly Card[]): string {
|
|
52
|
+
return cards.map((card) => `${cardRef(card.id)} (${card.status})`).join(", ");
|
|
53
|
+
}
|
|
54
|
+
|
|
42
55
|
/** The cards that wait on `id`, oldest id first, so a report reads in board order. */
|
|
43
56
|
export function dependentsOf(id: string, board: BoardIndex): Card[] {
|
|
44
57
|
return [...board.values()].filter((card) => dependsOf(card).includes(id)).sort((a, b) => Number(a.id) - Number(b.id));
|
|
45
58
|
}
|
|
46
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The cards still waiting on `id`: the work this card is holding up.
|
|
62
|
+
*
|
|
63
|
+
* The mirror of {@link blockersOf}, and for the same reason — a finished dependent has
|
|
64
|
+
* stopped waiting, so it stops counting here and a card whose only dependents are done
|
|
65
|
+
* reads as one nothing waits on. `dependentsOf` keeps every edge, because the drop
|
|
66
|
+
* refusal is about a link that would be stranded rather than about work that is held up.
|
|
67
|
+
*/
|
|
68
|
+
export function openDependentsOf(id: string, board: BoardIndex): Card[] {
|
|
69
|
+
return dependentsOf(id, board).filter((card) => card.status !== "done");
|
|
70
|
+
}
|
|
71
|
+
|
|
47
72
|
/** The sentence a claim, a move, or an assignment is refused with while `card` waits, or undefined. */
|
|
48
73
|
export function waitingOn(card: Card, board: BoardIndex): string | undefined {
|
|
49
74
|
const blockers = blockersOf(card, board);
|
|
50
75
|
if (blockers.length === 0) return undefined;
|
|
51
|
-
|
|
52
|
-
return `${cardRef(card.id)} waits on ${named}.`;
|
|
76
|
+
return `${cardRef(card.id)} waits on ${lanesText(blockers)}.`;
|
|
53
77
|
}
|
|
54
78
|
|
|
55
79
|
/** The sentence a drop is refused with while another card waits on `id`, or undefined. */
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A page is one loopback server owned by one session's process, so nothing about it is
|
|
5
5
|
* discoverable without a note left where the repository's state already lives. That note
|
|
6
|
-
* is `<board>/page.json`: the port, the seat serving it, and
|
|
7
|
-
*
|
|
6
|
+
* is `<board>/page.json`: the port, the seat serving it, and the token the page reads by.
|
|
7
|
+
* The page cannot change a card, so the note carries nothing a reader could misuse.
|
|
8
8
|
*
|
|
9
9
|
* Two rules make the file safe to trust:
|
|
10
10
|
*
|
|
@@ -22,18 +22,25 @@
|
|
|
22
22
|
import { access, link, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
23
23
|
import { join } from "node:path";
|
|
24
24
|
import { hasErrorCode } from "../errors.ts";
|
|
25
|
+
import { abortableSleep } from "../timer.ts";
|
|
25
26
|
|
|
26
27
|
/** The file, beside the board's `cards/` directory. */
|
|
27
28
|
export const PAGE_FILE = "page.json";
|
|
28
29
|
|
|
29
|
-
/**
|
|
30
|
-
|
|
30
|
+
/**
|
|
31
|
+
* How long a probe waits before it calls the page gone.
|
|
32
|
+
*
|
|
33
|
+
* Long enough for a page that is busy answering a browser: the probe shares the event
|
|
34
|
+
* loop with the snapshot route, and a page declared dead while it is merely slow is taken
|
|
35
|
+
* over — two servers, one note.
|
|
36
|
+
*/
|
|
37
|
+
export const PROBE_MS = 1_500;
|
|
31
38
|
|
|
32
|
-
/** The port, the seat, and the credential
|
|
39
|
+
/** The port, the seat, and the credential the page reads by. */
|
|
33
40
|
export interface PagePointer {
|
|
34
41
|
port: number;
|
|
35
|
-
/**
|
|
36
|
-
|
|
42
|
+
/** The page's one token: every read, and nothing else to authorise. */
|
|
43
|
+
token: string;
|
|
37
44
|
/** The seat the page writes as. */
|
|
38
45
|
seat: { id?: string; label: string };
|
|
39
46
|
/** When the page started, so a reader can tell a fresh note from a stale one. */
|
|
@@ -41,11 +48,11 @@ export interface PagePointer {
|
|
|
41
48
|
}
|
|
42
49
|
|
|
43
50
|
/** The fetch a probe uses: the global one in production, a stub in a test. */
|
|
44
|
-
export type ProbeFetch = (url: string, init: { signal: AbortSignal }) => Promise<{ ok: boolean }>;
|
|
51
|
+
export type ProbeFetch = (url: string, init: { signal: AbortSignal }) => Promise<{ ok: boolean; status?: number }>;
|
|
45
52
|
|
|
46
|
-
/** The URL
|
|
47
|
-
export function
|
|
48
|
-
return `http://127.0.0.1:${pointer.port}/?token=${pointer.
|
|
53
|
+
/** The URL for this page, wherever it is opened from. */
|
|
54
|
+
export function urlOf(pointer: PagePointer): string {
|
|
55
|
+
return `http://127.0.0.1:${pointer.port}/?token=${pointer.token}`;
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
/**
|
|
@@ -126,9 +133,9 @@ export async function createPointer(dir: string, pointer: PagePointer): Promise<
|
|
|
126
133
|
* findable. The move is atomic, so the file this call holds is the file that was there;
|
|
127
134
|
* a note that is not the one proved dead goes back where it came from.
|
|
128
135
|
*/
|
|
129
|
-
export async function clearPointer(dir: string,
|
|
136
|
+
export async function clearPointer(dir: string, token: string): Promise<boolean> {
|
|
130
137
|
const pointer = await readPointer(dir);
|
|
131
|
-
if (pointer === undefined || pointer.
|
|
138
|
+
if (pointer === undefined || pointer.token !== token) return false;
|
|
132
139
|
const target = join(dir, PAGE_FILE);
|
|
133
140
|
const held = `${target}.gone-${process.pid}-${Math.random().toString(16).slice(2, 10)}`;
|
|
134
141
|
try {
|
|
@@ -138,7 +145,7 @@ export async function clearPointer(dir: string, viewToken: string): Promise<bool
|
|
|
138
145
|
return false;
|
|
139
146
|
}
|
|
140
147
|
const moved = await readPointerAt(held);
|
|
141
|
-
if (moved === undefined || moved.
|
|
148
|
+
if (moved === undefined || moved.token !== token) {
|
|
142
149
|
await putBack(held, target);
|
|
143
150
|
return false;
|
|
144
151
|
}
|
|
@@ -164,6 +171,14 @@ async function putBack(held: string, target: string): Promise<void> {
|
|
|
164
171
|
/** How many times a caller tries to own the note before it gives up for good. */
|
|
165
172
|
export const NOTE_ATTEMPTS = 3;
|
|
166
173
|
|
|
174
|
+
/** How long a page that has written its note but does not answer yet is given, in steps. */
|
|
175
|
+
export const YOUNG_POLLS = 8;
|
|
176
|
+
const YOUNG_POLL_MS = 75;
|
|
177
|
+
|
|
178
|
+
/** How many times a caller looks again at a name that is momentarily free. */
|
|
179
|
+
const EMPTY_LOOKS = 20;
|
|
180
|
+
const EMPTY_LOOK_MS = 25;
|
|
181
|
+
|
|
167
182
|
/** What one `/board` found where the note should be. */
|
|
168
183
|
export type PointerOutcome =
|
|
169
184
|
| { kind: "written"; took?: PagePointer }
|
|
@@ -191,46 +206,82 @@ export type PointerOutcome =
|
|
|
191
206
|
*/
|
|
192
207
|
export async function settlePointer(dir: string, note: PagePointer, probe?: ProbeFetch): Promise<PointerOutcome> {
|
|
193
208
|
let took: PagePointer | undefined;
|
|
194
|
-
|
|
209
|
+
let attempts = 0;
|
|
210
|
+
let looks = 0;
|
|
211
|
+
while (attempts < NOTE_ATTEMPTS) {
|
|
195
212
|
// A retry writes a fresh `startedAt`, so a reader can tell it from the first attempt.
|
|
196
|
-
const candidate =
|
|
213
|
+
const candidate = attempts === 0 ? note : { ...note, startedAt: note.startedAt + attempts };
|
|
197
214
|
if (await createPointer(dir, candidate))
|
|
198
215
|
return took === undefined ? { kind: "written" } : { kind: "written", took };
|
|
199
216
|
const winner = await readPointer(dir);
|
|
200
217
|
if (winner === undefined) {
|
|
201
|
-
// No note at all means
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
return { kind: "unreadable" };
|
|
218
|
+
// No note at all means a page is between clearing its note and writing the next one.
|
|
219
|
+
// That is a few microseconds of somebody else's work, not a takeover attempt of this
|
|
220
|
+
// caller's, so the name is chased without spending one; a file that stays, and cannot
|
|
221
|
+
// be read, is the caller's dead end.
|
|
222
|
+
if (await noteExists(dir)) return { kind: "unreadable" };
|
|
223
|
+
if (looks >= EMPTY_LOOKS) return { kind: "busy" };
|
|
224
|
+
looks += 1;
|
|
225
|
+
await abortableSleep(EMPTY_LOOK_MS);
|
|
226
|
+
continue;
|
|
206
227
|
}
|
|
207
|
-
|
|
228
|
+
attempts += 1;
|
|
229
|
+
if (await answerOrWait(winner, probe)) return { kind: "attached", pointer: winner };
|
|
208
230
|
// Only that exact note: a note a newer page wrote is not this caller's to take. A
|
|
209
231
|
// refused clear means somebody else got there first, so the next attempt re-reads.
|
|
210
|
-
if (await clearPointer(dir, winner.
|
|
232
|
+
if (await clearPointer(dir, winner.token)) took = winner;
|
|
211
233
|
}
|
|
212
234
|
const last = await readPointer(dir);
|
|
213
235
|
if (last !== undefined) return { kind: "stale", pointer: last };
|
|
214
236
|
return (await noteExists(dir)) ? { kind: "unreadable" } : { kind: "busy" };
|
|
215
237
|
}
|
|
216
238
|
|
|
239
|
+
/**
|
|
240
|
+
* Whether the winner answers, giving a page that is still coming up its full probe window.
|
|
241
|
+
*
|
|
242
|
+
* A note is written before its server can accept a connection, so the loser of a simultaneous
|
|
243
|
+
* start reads a note whose page is a few milliseconds behind it. One refused connection is
|
|
244
|
+
* therefore not proof of death: stealing that note serves a second page, and calling it stale
|
|
245
|
+
* reports a page that exists as one that did not start. The poll is bounded at the same
|
|
246
|
+
* 600 ms a probe itself may spend, so a note whose page is really gone is still taken over.
|
|
247
|
+
*/
|
|
248
|
+
async function answerOrWait(pointer: PagePointer, probe?: ProbeFetch): Promise<boolean> {
|
|
249
|
+
for (let attempt = 0; attempt < YOUNG_POLLS; attempt++) {
|
|
250
|
+
if (await probePage(pointer, probe)) return true;
|
|
251
|
+
await abortableSleep(YOUNG_POLL_MS);
|
|
252
|
+
}
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
|
|
217
256
|
/**
|
|
218
257
|
* Whether the page a pointer names is answering.
|
|
219
258
|
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
259
|
+
* The question goes to the cheap liveness route, not to the snapshot: a probe runs while
|
|
260
|
+
* another session may be serving a browser, and asking for the snapshot would make a busy
|
|
261
|
+
* page look like a dead one — the very race that costs a takeover and leaves two servers.
|
|
262
|
+
* A page from a build without that route answers 404 there and is asked for its snapshot
|
|
263
|
+
* once, so an upgrade mid-session attaches instead of stealing the seat.
|
|
264
|
+
*
|
|
265
|
+
* A page that answers with anything but a 200 is not one to attach to: a port reused by
|
|
266
|
+
* another program, a server shutting down, and a page that refuses the token all read the
|
|
267
|
+
* same, and all mean "serve your own".
|
|
223
268
|
*/
|
|
224
269
|
export async function probePage(
|
|
225
270
|
pointer: PagePointer,
|
|
226
271
|
fetchLike: ProbeFetch = (url, init) => fetch(url, init),
|
|
227
272
|
timeoutMs: number = PROBE_MS,
|
|
228
273
|
): Promise<boolean> {
|
|
229
|
-
|
|
230
|
-
|
|
274
|
+
const ask = (path: string): Promise<{ ok: boolean; status?: number }> =>
|
|
275
|
+
fetchLike(`http://127.0.0.1:${pointer.port}${path}?token=${pointer.token}`, {
|
|
231
276
|
signal: AbortSignal.timeout(timeoutMs),
|
|
232
277
|
});
|
|
233
|
-
|
|
278
|
+
try {
|
|
279
|
+
const ping = await ask("/api/ping");
|
|
280
|
+
if (ping.ok) return true;
|
|
281
|
+
// A page this build started always answers the ping; a 404 is one started by an
|
|
282
|
+
// older build, whose only liveness answer is its snapshot.
|
|
283
|
+
if (ping.status !== 404) return false;
|
|
284
|
+
return (await ask("/api/board")).ok;
|
|
234
285
|
} catch {
|
|
235
286
|
return false;
|
|
236
287
|
}
|
|
@@ -239,9 +290,12 @@ export async function probePage(
|
|
|
239
290
|
/** A parsed file as a pointer, or undefined when it is not one this build can trust. */
|
|
240
291
|
function asPointer(value: unknown): PagePointer | undefined {
|
|
241
292
|
if (typeof value !== "object" || value === null) return undefined;
|
|
242
|
-
const { port,
|
|
293
|
+
const { port, seat, startedAt } = value as Record<string, unknown>;
|
|
294
|
+
// A note written by the build with two tokens carries `viewToken`; it is this page's
|
|
295
|
+
// only credential now, so an upgrade reads it instead of refusing to serve its own note.
|
|
296
|
+
const { token = (value as { viewToken?: unknown }).viewToken } = value as { token?: unknown };
|
|
243
297
|
if (typeof port !== "number" || !Number.isInteger(port) || port <= 0 || port > 65_535) return undefined;
|
|
244
|
-
if (typeof
|
|
298
|
+
if (typeof token !== "string" || token === "") return undefined;
|
|
245
299
|
if (typeof startedAt !== "number") return undefined;
|
|
246
300
|
if (typeof seat !== "object" || seat === null) return undefined;
|
|
247
301
|
const { id, label } = seat as Record<string, unknown>;
|
|
@@ -249,7 +303,7 @@ function asPointer(value: unknown): PagePointer | undefined {
|
|
|
249
303
|
if (id !== undefined && typeof id !== "string") return undefined;
|
|
250
304
|
return {
|
|
251
305
|
port,
|
|
252
|
-
|
|
306
|
+
token,
|
|
253
307
|
seat: id === undefined ? { label } : { id, label },
|
|
254
308
|
startedAt,
|
|
255
309
|
};
|
package/lib/board/round-text.ts
CHANGED
|
@@ -47,11 +47,20 @@ export function formatRound(cards: readonly Card[], round: RoundSummary): string
|
|
|
47
47
|
return lines.join("\n");
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* `1 owner`, `2 owners`.
|
|
52
|
+
*
|
|
53
|
+
* Written as concatenation rather than a template, because the page's bundle carries
|
|
54
|
+
* this function's *source*: a backtick or an interpolation in it would end the string it
|
|
55
|
+
* is spliced into. `round-text.test.ts` pins both, and `PLURAL_JS` is how it crosses.
|
|
56
|
+
*/
|
|
51
57
|
export function plural(count: number, noun: string): string {
|
|
52
|
-
return
|
|
58
|
+
return count + " " + noun + (count === 1 ? "" : "s");
|
|
53
59
|
}
|
|
54
60
|
|
|
61
|
+
/** The same body, as the browser receives it: one wording for `1 card`, not two. */
|
|
62
|
+
export const PLURAL_JS = String(plural);
|
|
63
|
+
|
|
55
64
|
/** `#4 doing planned`, the brief a rollup group needs. */
|
|
56
65
|
function brief(card: Card): string {
|
|
57
66
|
const planned = card.status === "doing" ? (card.plan === undefined ? " unplanned" : " planned") : "";
|
package/lib/board/round.ts
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* know — nobody has touched it in half an hour — not that its owner's process is
|
|
9
9
|
* gone.
|
|
10
10
|
*
|
|
11
|
-
* The groups overlap on purpose. A `doing` card with no plan and no touch for
|
|
12
|
-
* hour is both `unplanned` and `idle`, and hiding it in one of the two would lose
|
|
11
|
+
* The groups overlap on purpose. A `doing` card with no plan and no touch for half
|
|
12
|
+
* an hour is both `unplanned` and `idle`, and hiding it in one of the two would lose
|
|
13
13
|
* half the reason a lead should look at it.
|
|
14
14
|
*
|
|
15
15
|
* Pure: no I/O and no clock of its own.
|
|
@@ -20,28 +20,33 @@ import type { Card } from "./card.ts";
|
|
|
20
20
|
/** How long a `doing` card may go untouched before the rollup calls it idle. */
|
|
21
21
|
export const IDLE_MS = 30 * 60 * 1_000;
|
|
22
22
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
23
|
+
/**
|
|
24
|
+
* One owner, the live cards it holds, and the ones it finished.
|
|
25
|
+
*
|
|
26
|
+
* `T` is what the caller handed in: the rollup never builds a card, so a snapshot whose
|
|
27
|
+
* cards carry derived facts hands them back on the same terms it received them.
|
|
28
|
+
*/
|
|
29
|
+
export interface RoundGroup<T extends Card = Card> {
|
|
25
30
|
label: string;
|
|
26
|
-
cards:
|
|
31
|
+
cards: T[];
|
|
27
32
|
/** This owner's finished cards: history, kept so a teammate who is done is not erased. */
|
|
28
|
-
done:
|
|
33
|
+
done: T[];
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
/** The lead's rollup of one board. */
|
|
32
|
-
export interface RoundSummary {
|
|
37
|
+
export interface RoundSummary<T extends Card = Card> {
|
|
33
38
|
/** Every owner with at least one unfinished card, ordered by label. */
|
|
34
|
-
owners: RoundGroup[];
|
|
39
|
+
owners: RoundGroup<T>[];
|
|
35
40
|
/** `todo` cards: nobody has taken them yet. */
|
|
36
|
-
unassigned:
|
|
41
|
+
unassigned: T[];
|
|
37
42
|
/** `doing` cards with no plan recorded: work started before it was planned. */
|
|
38
|
-
unplanned:
|
|
43
|
+
unplanned: T[];
|
|
39
44
|
/** `blocked` cards, and the note that says why. */
|
|
40
|
-
blocked:
|
|
45
|
+
blocked: T[];
|
|
41
46
|
/** `doing` cards untouched for {@link IDLE_MS}: possibly a dead owner. */
|
|
42
|
-
idle:
|
|
47
|
+
idle: T[];
|
|
43
48
|
/** Finished cards, oldest first, kept as history. */
|
|
44
|
-
done:
|
|
49
|
+
done: T[];
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
/**
|
|
@@ -55,13 +60,13 @@ export interface RoundSummary {
|
|
|
55
60
|
* whose labels differ only by case are one owner — a name is case-insensitive when
|
|
56
61
|
* it is a send target, so it is here too — and the first spelling seen is kept.
|
|
57
62
|
*/
|
|
58
|
-
export function buildRound(cards: readonly
|
|
59
|
-
const groups = new Map<string, RoundGroup
|
|
60
|
-
const unassigned:
|
|
61
|
-
const unplanned:
|
|
62
|
-
const blocked:
|
|
63
|
-
const idle:
|
|
64
|
-
const done:
|
|
63
|
+
export function buildRound<T extends Card = Card>(cards: readonly T[], now: number): RoundSummary<T> {
|
|
64
|
+
const groups = new Map<string, RoundGroup<T>>();
|
|
65
|
+
const unassigned: T[] = [];
|
|
66
|
+
const unplanned: T[] = [];
|
|
67
|
+
const blocked: T[] = [];
|
|
68
|
+
const idle: T[] = [];
|
|
69
|
+
const done: T[] = [];
|
|
65
70
|
|
|
66
71
|
for (const card of cards) {
|
|
67
72
|
if (card.status === "todo") unassigned.push(card);
|