@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
|
@@ -15,24 +15,33 @@ export const BoardParams = Type.Object({
|
|
|
15
15
|
action: Type.Union(
|
|
16
16
|
BOARD_ACTIONS.map((action) => Type.Literal(action)),
|
|
17
17
|
{
|
|
18
|
+
// The verbs and their meanings live in the tool description, built from
|
|
19
|
+
// `BOARD_ACTION_DOCS`; this says only what a caller needs to choose one.
|
|
18
20
|
description:
|
|
19
|
-
"`list` the board
|
|
20
|
-
"`claim` one for this session, `release` it, `reclaim` it from whoever holds it, `move` it to " +
|
|
21
|
-
"another lane, `note` it, `plan` it (record the plan file being executed), `edit` what it says " +
|
|
22
|
-
"(title, paths, priority, what it waits for), or `drop` it.",
|
|
21
|
+
"`list` reads the board, or one card when `card` names it; every other verb changes it. The tool description says what each one does.",
|
|
23
22
|
},
|
|
24
23
|
),
|
|
25
24
|
card: Type.Optional(
|
|
26
|
-
Type.String({
|
|
25
|
+
Type.String({
|
|
26
|
+
description:
|
|
27
|
+
'The card id for one card, e.g. "4" or "#4". `list` reads that card; every other action but `add` acts on it.',
|
|
28
|
+
}),
|
|
27
29
|
),
|
|
28
30
|
status: Type.Optional(
|
|
29
31
|
Type.Union(
|
|
30
32
|
CARD_STATUSES.map((status) => Type.Literal(status)),
|
|
31
33
|
{
|
|
32
|
-
description:
|
|
34
|
+
description:
|
|
35
|
+
"`list`: show only this lane, and not skip the `done` cards. `move`: the lane to move to (not `todo` — use `release`).",
|
|
33
36
|
},
|
|
34
37
|
),
|
|
35
38
|
),
|
|
39
|
+
blockedBy: Type.Optional(
|
|
40
|
+
Type.String({
|
|
41
|
+
description:
|
|
42
|
+
"`move` to `blocked`: what the card waits on — the decision a person has to make, one line, at most 200 characters.",
|
|
43
|
+
}),
|
|
44
|
+
),
|
|
36
45
|
title: Type.Optional(Type.String({ description: "`add` and `edit`: the feature's outcome, one line." })),
|
|
37
46
|
paths: Type.Optional(
|
|
38
47
|
Type.Array(Type.String(), {
|
|
@@ -64,7 +73,7 @@ export const BoardParams = Type.Object({
|
|
|
64
73
|
query: Type.Optional(
|
|
65
74
|
Type.String({
|
|
66
75
|
description:
|
|
67
|
-
"`list`: show only the cards whose title, path hint, or
|
|
76
|
+
"`list`: show only the cards whose title, holder, path hint, plan, or a note's text matches this text, case-insensitively.",
|
|
68
77
|
}),
|
|
69
78
|
),
|
|
70
79
|
round: Type.Optional(
|
|
@@ -72,4 +81,10 @@ export const BoardParams = Type.Object({
|
|
|
72
81
|
description: "`list`: also return the lead's rollup — owners, unassigned, unplanned, blocked, idle.",
|
|
73
82
|
}),
|
|
74
83
|
),
|
|
84
|
+
archived: Type.Optional(
|
|
85
|
+
Type.Boolean({
|
|
86
|
+
description:
|
|
87
|
+
"`list`: read the archive instead of the board — the finished cards `archive` set aside, which no other read opens. `query` and `mine` narrow it; `status` and `round` do not apply to a pile that is out of the board.",
|
|
88
|
+
}),
|
|
89
|
+
),
|
|
75
90
|
});
|
package/extensions/board/seat.ts
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The seat: the name the page serves as, and the actor
|
|
2
|
+
* The seat: the name the page serves as, and the actor it is shown as.
|
|
3
3
|
*
|
|
4
4
|
* A page outlives the session that opened it — a `/resume`, a rename, or a fresh
|
|
5
|
-
* session taking the page over would otherwise change who
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* same cards belong to it however many sessions have served it.
|
|
5
|
+
* session taking the page over would otherwise change who the page says it is. So the
|
|
6
|
+
* page is a *seat* rather than a session: one fixed name, one fixed id derived from it,
|
|
7
|
+
* and the same identity however many sessions have served it.
|
|
9
8
|
*
|
|
10
|
-
* The
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* The page writes nothing, so the id decides no ownership: it is what the page's own
|
|
10
|
+
* roster and the `you` chip are read against, and it keeps a page's reads the same
|
|
11
|
+
* across a handover. The board's tools write as the session — which is why the label is
|
|
12
|
+
* shared with the session serving the page and the ids are not.
|
|
14
13
|
*/
|
|
15
14
|
|
|
16
15
|
import type { Actor } from "../../lib/board/rules.ts";
|
|
@@ -22,7 +21,7 @@ export const DEFAULT_SEAT = "team-lead";
|
|
|
22
21
|
/** The prefix of a page's actor id, so one is never mistaken for a session id. */
|
|
23
22
|
export const SEAT_ID_PREFIX = "board:";
|
|
24
23
|
|
|
25
|
-
/** The actor a page
|
|
24
|
+
/** The actor a page reads as: the seat, and an id derived from it. */
|
|
26
25
|
export function pageActor(label: string = DEFAULT_SEAT): Actor {
|
|
27
26
|
const seat = normalizeLabel(label, DEFAULT_SEAT);
|
|
28
27
|
return { id: `${SEAT_ID_PREFIX}${seat.toLowerCase()}`, label: seat };
|
|
@@ -35,6 +35,7 @@ export async function viewOf(context: SnapshotContext): Promise<BoardView> {
|
|
|
35
35
|
cards: await context.store.list(),
|
|
36
36
|
now: context.now(),
|
|
37
37
|
bytes: await context.store.bytes(),
|
|
38
|
+
skipped: (await context.store.skipped()).length,
|
|
38
39
|
where: context.store.where(),
|
|
39
40
|
...(roster === undefined ? {} : { roster }),
|
|
40
41
|
});
|
|
@@ -20,17 +20,42 @@ import { type CardOp, holds } from "../../lib/board/rules.ts";
|
|
|
20
20
|
import { buildRound } from "../../lib/board/round.ts";
|
|
21
21
|
import type { BoardStore } from "../../lib/board/store.ts";
|
|
22
22
|
import { messageOf } from "../../lib/errors.ts";
|
|
23
|
+
import { revParse } from "../../lib/git/read.ts";
|
|
24
|
+
import { strayField } from "../../lib/stray-fields.ts";
|
|
25
|
+
import { createExecRunner } from "../../lib/git/runner.ts";
|
|
23
26
|
import { failedResult, textResult } from "../../lib/tool-result.ts";
|
|
24
27
|
import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
|
|
25
28
|
import { callLine, detailsOrLine } from "../../lib/tui/tool.ts";
|
|
26
29
|
import { formatRound } from "../../lib/board/round-text.ts";
|
|
27
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
archiveNote,
|
|
32
|
+
formatArchive,
|
|
33
|
+
formatArchived,
|
|
34
|
+
formatBoard,
|
|
35
|
+
formatCard,
|
|
36
|
+
formatDropped,
|
|
37
|
+
formatSkipped,
|
|
38
|
+
} from "./format.ts";
|
|
28
39
|
import { createTeamJoin, type TeamJoin } from "../../lib/session-tool.ts";
|
|
29
40
|
import { BoardResult, collapsedLine, resultInput } from "./result.ts";
|
|
30
41
|
import { createInput } from "./input.ts";
|
|
31
42
|
import type { BoardSession } from "../../lib/board/session.ts";
|
|
32
43
|
import { BoardParams } from "./schema.ts";
|
|
33
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
ACTION_FIELDS,
|
|
46
|
+
BOARD_ACTION_DOCS,
|
|
47
|
+
BOARD_ACTIONS,
|
|
48
|
+
BOARD_MUTATING_ACTIONS,
|
|
49
|
+
BOARD_TOOL,
|
|
50
|
+
type BoardAction,
|
|
51
|
+
type BoardDetails,
|
|
52
|
+
normalizeBoardAction,
|
|
53
|
+
} from "./types.ts";
|
|
54
|
+
|
|
55
|
+
/** The verbs as one sentence, in `BOARD_ACTIONS` order; the table owns the words. */
|
|
56
|
+
function verbHelp(): string {
|
|
57
|
+
return `${BOARD_ACTIONS.map((action) => `\`${action}\` ${BOARD_ACTION_DOCS[action]}`).join("; ")}.`;
|
|
58
|
+
}
|
|
34
59
|
|
|
35
60
|
/** The parameters as the schema validates them. */
|
|
36
61
|
type BoardArgs = Static<typeof BoardParams>;
|
|
@@ -71,8 +96,18 @@ function refusal(action: BoardAction, me: BoardDetails["me"], message: string, c
|
|
|
71
96
|
});
|
|
72
97
|
}
|
|
73
98
|
|
|
74
|
-
/**
|
|
75
|
-
|
|
99
|
+
/**
|
|
100
|
+
* The commit at `cwd`'s `HEAD`, shortened, or nothing when there is none.
|
|
101
|
+
*
|
|
102
|
+
* A finished card records what closed it, and the commit is the one fact a repository can
|
|
103
|
+
* answer that the card cannot. A directory that is not a repository, or one with no
|
|
104
|
+
* commits, answers nothing — the move still happens, because finishing a card must not
|
|
105
|
+
* depend on git.
|
|
106
|
+
*/
|
|
107
|
+
async function headCommit(pi: ExtensionAPI, cwd: string): Promise<{ commit: string } | undefined> {
|
|
108
|
+
const id = await revParse(createExecRunner(pi), cwd, "HEAD");
|
|
109
|
+
return id === undefined ? undefined : { commit: id.slice(0, 7) };
|
|
110
|
+
}
|
|
76
111
|
|
|
77
112
|
export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { team?: TeamJoin } = {}): void {
|
|
78
113
|
const team = deps.team ?? createTeamJoin(pi);
|
|
@@ -82,16 +117,9 @@ export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { t
|
|
|
82
117
|
description:
|
|
83
118
|
"The shared feature list for this repository: one card per feature, which any session reads and writes. " +
|
|
84
119
|
"A card carries the feature's outcome, the session that holds it, path hints, the plan it is executing, " +
|
|
85
|
-
|
|
86
|
-
"
|
|
87
|
-
|
|
88
|
-
"creates a `todo` card; `claim` takes one for this session; `release` gives it back; `reclaim` takes it " +
|
|
89
|
-
"from whoever holds it; `move` changes lane; `note` appends to its history; `plan` records the plan file " +
|
|
90
|
-
"being executed; `edit` fixes what the card says (title, paths, priority, and the cards it waits for); " +
|
|
91
|
-
"`drop` deletes a `todo`, a `done`, or one you hold. A card's steps do not belong on the board - they " +
|
|
92
|
-
"belong in the plan and in `todo`.",
|
|
93
|
-
promptSnippet:
|
|
94
|
-
"The shared feature list for this repository: list, add, claim, release, reclaim, move, note, plan, edit, drop.",
|
|
120
|
+
`its priority, what it waits for, and its notes. ${verbHelp()} A card's steps do not belong on the ` +
|
|
121
|
+
"board - they belong in the plan and in `todo`.",
|
|
122
|
+
promptSnippet: `The shared feature list for this repository: ${BOARD_ACTIONS.join(", ")}.`,
|
|
95
123
|
promptGuidelines: [
|
|
96
124
|
"Claim a card before working on it; release or finish it when you stop, and reclaim a card whose owner is gone.",
|
|
97
125
|
"Record the plan file with `board` action `plan` once the plan is approved, then execute it.",
|
|
@@ -117,11 +145,15 @@ export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { t
|
|
|
117
145
|
}
|
|
118
146
|
const me = runtime.who(ctx, ctx.cwd);
|
|
119
147
|
try {
|
|
148
|
+
// Before the join and the store: a refused call has done nothing, so it cannot
|
|
149
|
+
// have enlisted this session as a teammate on its way to the refusal.
|
|
150
|
+
const stray = strayField(action, args, ACTION_FIELDS);
|
|
151
|
+
if (stray !== undefined) return refusal(action, me, stray);
|
|
120
152
|
// Working a card makes this session a teammate, once: the board is the user's
|
|
121
153
|
// surface for the team, so nobody has to know the bus exists. A failed join is
|
|
122
154
|
// not a failed card write — the board works with no bus at all — and it is not
|
|
123
155
|
// marked, so the next write asks again.
|
|
124
|
-
if (
|
|
156
|
+
if (BOARD_MUTATING_ACTIONS.has(action) && !runtime.joined()) {
|
|
125
157
|
if ((await team.join(ctx)).ok) runtime.markJoined();
|
|
126
158
|
}
|
|
127
159
|
const store = await runtime.store(ctx.cwd);
|
|
@@ -147,7 +179,16 @@ export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { t
|
|
|
147
179
|
'move needs a target lane: "doing", "blocked", or "done". Use release to return a card to todo.',
|
|
148
180
|
);
|
|
149
181
|
}
|
|
150
|
-
|
|
182
|
+
// Only the lane that has something to say reads for it: a move to `done`
|
|
183
|
+
// records the commit that closed the card, and a board outside a
|
|
184
|
+
// repository simply has none to record.
|
|
185
|
+
const landed = status === "done" ? await headCommit(pi, ctx.cwd) : undefined;
|
|
186
|
+
return await mutate(store, action, me, now, args, {
|
|
187
|
+
kind: "move",
|
|
188
|
+
status,
|
|
189
|
+
...(args.blockedBy === undefined ? {} : { blockedBy: args.blockedBy }),
|
|
190
|
+
...(landed === undefined ? {} : { landed }),
|
|
191
|
+
});
|
|
151
192
|
}
|
|
152
193
|
case "note":
|
|
153
194
|
return await mutate(store, action, me, now, args, { kind: "note", text: args.text });
|
|
@@ -172,6 +213,8 @@ export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { t
|
|
|
172
213
|
}
|
|
173
214
|
case "drop":
|
|
174
215
|
return await dropCard(store, me, args, now);
|
|
216
|
+
case "archive":
|
|
217
|
+
return await archiveCards(store, me, args, now);
|
|
175
218
|
}
|
|
176
219
|
} catch (error) {
|
|
177
220
|
return refusal(action, me, messageOf(error));
|
|
@@ -204,7 +247,7 @@ export function registerTools(pi: ExtensionAPI, runtime: BoardSession, deps: { t
|
|
|
204
247
|
});
|
|
205
248
|
}
|
|
206
249
|
|
|
207
|
-
/** `list`: the board, one card,
|
|
250
|
+
/** `list`: the board, one card, the lead's rollup, or the archive. */
|
|
208
251
|
async function read(
|
|
209
252
|
store: BoardStore,
|
|
210
253
|
action: BoardAction,
|
|
@@ -212,13 +255,32 @@ async function read(
|
|
|
212
255
|
args: BoardArgs,
|
|
213
256
|
now: number,
|
|
214
257
|
): Promise<ReturnType<typeof textResult<BoardDetails>>> {
|
|
258
|
+
if (args.archived === true) return readArchive(store, action, me, args, now);
|
|
215
259
|
const cards = await store.list();
|
|
260
|
+
// Read once: a card file the reader refused is the difference between "not there"
|
|
261
|
+
// and "there, and this build cannot read it".
|
|
262
|
+
const skipped = await store.skipped();
|
|
263
|
+
// A directory listing, not a read: a listing says what it left aside without opening one.
|
|
264
|
+
const archived = await store.archivedCount();
|
|
216
265
|
if (args.card !== undefined) {
|
|
217
266
|
const id = normalizeCardId(args.card);
|
|
218
267
|
if (id === undefined) return refusal(action, me, `"${args.card}" is not a card id.`);
|
|
219
268
|
const card = cards.find((entry) => entry.id === id);
|
|
220
|
-
if (!card)
|
|
221
|
-
|
|
269
|
+
if (!card) {
|
|
270
|
+
// Gone and set aside are different answers, and one read by id tells them apart.
|
|
271
|
+
if ((await store.read(id)) !== undefined) {
|
|
272
|
+
return refusal(action, me, `${cardRef(id)} is archived; pass archived: true to read it.`);
|
|
273
|
+
}
|
|
274
|
+
const unreadable = skipped.find((entry) => entry.id === id);
|
|
275
|
+
return refusal(
|
|
276
|
+
action,
|
|
277
|
+
me,
|
|
278
|
+
unreadable === undefined
|
|
279
|
+
? `${cardRef(id)} does not exist.`
|
|
280
|
+
: `${cardRef(id)} is there and this build cannot read it (${unreadable.file}: ${unreadable.why}).`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
return textResult(formatCard(card, indexOf(cards), now), { action, me, card });
|
|
222
284
|
}
|
|
223
285
|
const listed = cards.filter(
|
|
224
286
|
(card) =>
|
|
@@ -228,9 +290,65 @@ async function read(
|
|
|
228
290
|
// session could not release.
|
|
229
291
|
(args.mine !== true || holds(card, me)),
|
|
230
292
|
);
|
|
231
|
-
|
|
293
|
+
// A finished card is history rather than work, and nothing prunes it — this
|
|
294
|
+
// repository's own board is mostly finished cards — so a listing that names no lane
|
|
295
|
+
// leaves them out and says how many. A named lane, `card`, and `round` are unchanged;
|
|
296
|
+
// the rollup counts the whole board, which is what makes it a rollup.
|
|
297
|
+
const hiddenDone = args.status === undefined ? listed.filter((card) => card.status === "done").length : 0;
|
|
298
|
+
const shown = hiddenDone === 0 ? listed : listed.filter((card) => card.status !== "done");
|
|
299
|
+
if (args.round !== true) {
|
|
300
|
+
return textResult(formatBoard(shown, { hiddenDone, archived, skipped }), { action, me, cards: shown });
|
|
301
|
+
}
|
|
232
302
|
const round = buildRound(cards, now);
|
|
233
|
-
|
|
303
|
+
// One line under the rollup, because a reader who cannot see a card file should know why,
|
|
304
|
+
// and one for the archive, because a rollup that silently left it out would be a board
|
|
305
|
+
// smaller than the one on disk.
|
|
306
|
+
const notes = [formatSkipped(skipped), archiveNote(archived)].filter((line) => line !== "");
|
|
307
|
+
const text = [formatRound(cards, round), ...notes].join("\n");
|
|
308
|
+
return textResult(text, { action, me, cards: listed, round });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* `list archived: true`: the cards set aside, which no board read opens.
|
|
313
|
+
*
|
|
314
|
+
* A lane and a rollup are answers about the board, so asking for the archive with either
|
|
315
|
+
* is refused rather than quietly ignored: the pile is one thing, and every card in it is
|
|
316
|
+
* finished. A query and `mine` narrow it, because both are questions about a card.
|
|
317
|
+
*/
|
|
318
|
+
async function readArchive(
|
|
319
|
+
store: BoardStore,
|
|
320
|
+
action: BoardAction,
|
|
321
|
+
me: BoardDetails["me"],
|
|
322
|
+
args: BoardArgs,
|
|
323
|
+
now: number,
|
|
324
|
+
): Promise<ReturnType<typeof textResult<BoardDetails>>> {
|
|
325
|
+
const wrong = (["status", "round"] as const).filter((field) => args[field] !== undefined);
|
|
326
|
+
if (wrong.length > 0) {
|
|
327
|
+
return refusal(
|
|
328
|
+
action,
|
|
329
|
+
me,
|
|
330
|
+
`archived does not read ${wrong.join(", ")}: the archive is one pile of finished cards, out of the board.`,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
const pile = await store.archived();
|
|
334
|
+
if (args.card !== undefined) {
|
|
335
|
+
const id = normalizeCardId(args.card);
|
|
336
|
+
if (id === undefined) return refusal(action, me, `"${args.card}" is not a card id.`);
|
|
337
|
+
const card = pile.cards.find((entry) => entry.id === id);
|
|
338
|
+
if (!card) {
|
|
339
|
+
const onBoard = (await store.list()).some((entry) => entry.id === id);
|
|
340
|
+
return refusal(
|
|
341
|
+
action,
|
|
342
|
+
me,
|
|
343
|
+
onBoard ? `${cardRef(id)} is on the board, not in the archive.` : `${cardRef(id)} is not in the archive.`,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
return textResult(formatCard(card, indexOf(pile.cards), now), { action, me, card });
|
|
347
|
+
}
|
|
348
|
+
const shown = pile.cards.filter(
|
|
349
|
+
(card) => (args.query === undefined || matchesQuery(card, args.query)) && (args.mine !== true || holds(card, me)),
|
|
350
|
+
);
|
|
351
|
+
return textResult(formatArchive(shown, pile.skipped), { action, me, cards: shown });
|
|
234
352
|
}
|
|
235
353
|
|
|
236
354
|
/** `add`: the only action that creates a card. */
|
|
@@ -263,6 +381,53 @@ async function mutate(
|
|
|
263
381
|
return textResult(formatCard(result.card), { action, me, card: result.card });
|
|
264
382
|
}
|
|
265
383
|
|
|
384
|
+
/**
|
|
385
|
+
* `archive`: a finished card leaves the board without being destroyed.
|
|
386
|
+
*
|
|
387
|
+
* One card, or — with no card named — every finished card, because clearing a board is
|
|
388
|
+
* the errand and a lead should not have to run sixty calls to do it. Each move is its own
|
|
389
|
+
* compare-and-set, so a sweep is best-effort: it reports what it actually set aside.
|
|
390
|
+
*/
|
|
391
|
+
async function archiveCards(
|
|
392
|
+
store: BoardStore,
|
|
393
|
+
me: BoardDetails["me"],
|
|
394
|
+
args: BoardArgs,
|
|
395
|
+
now: number,
|
|
396
|
+
): Promise<ReturnType<typeof textResult<BoardDetails>>> {
|
|
397
|
+
if (args.card !== undefined) {
|
|
398
|
+
const id = normalizeCardId(args.card);
|
|
399
|
+
if (id === undefined) return refusal("archive", me, `"${args.card}" is not a card id.`);
|
|
400
|
+
const result = await store.mutate(id, { kind: "archive" }, me, now);
|
|
401
|
+
if (!result.ok) return refusal("archive", me, result.error, result.card);
|
|
402
|
+
// Asked twice is one state, not an error: the card is where the caller wanted it.
|
|
403
|
+
if (!result.changed) return textResult(`${cardRef(id)} is already archived.`, { action: "archive", me });
|
|
404
|
+
return textResult(formatArchived([id]), { action: "archive", me, card: result.card, archived: [id] });
|
|
405
|
+
}
|
|
406
|
+
const finished = (await store.list()).filter((card) => card.status === "done");
|
|
407
|
+
const moved: string[] = [];
|
|
408
|
+
const failed: Array<{ card: string; error: string }> = [];
|
|
409
|
+
for (const card of finished) {
|
|
410
|
+
const result = await store.mutate(card.id, { kind: "archive" }, me, now);
|
|
411
|
+
if (result.ok && result.changed) moved.push(card.id);
|
|
412
|
+
// A sweep is best-effort, and a card that changed underneath it is the one thing it
|
|
413
|
+
// cannot report by silence: "every finished card" must not read as fewer, unexplained.
|
|
414
|
+
else if (!result.ok) failed.push({ card: card.id, error: result.error });
|
|
415
|
+
}
|
|
416
|
+
if (moved.length === 0 && failed.length === 0) {
|
|
417
|
+
return textResult("No finished cards to archive.", { action: "archive", me });
|
|
418
|
+
}
|
|
419
|
+
const text = [
|
|
420
|
+
...(moved.length === 0 ? [] : [formatArchived(moved)]),
|
|
421
|
+
...failed.map((entry) => `${cardRef(entry.card)} was not archived (${entry.error})`),
|
|
422
|
+
].join(" · ");
|
|
423
|
+
return textResult(text, {
|
|
424
|
+
action: "archive",
|
|
425
|
+
me,
|
|
426
|
+
...(moved.length === 0 ? {} : { archived: moved }),
|
|
427
|
+
...(failed.length === 0 ? {} : { failed }),
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
266
431
|
/** `drop`: the card and its notes leave together, so the report is the id. */
|
|
267
432
|
async function dropCard(
|
|
268
433
|
store: BoardStore,
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
* The board page's router: one request in, one response out, and every policy the
|
|
3
3
|
* served surface obeys decided here.
|
|
4
4
|
*
|
|
5
|
-
* The page
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* this module only decides the order and the sentences.
|
|
5
|
+
* The page reads. It cannot change a card, so what is left to decide is the `Host`
|
|
6
|
+
* allowlist, the token in the URL, and which reads exist — a method that would write is
|
|
7
|
+
* refused by the method list before a handler is reached. The guards themselves are
|
|
8
|
+
* `lib/http`'s — this module only decides the order and the sentences.
|
|
9
9
|
*
|
|
10
|
-
* Nothing here knows what a board is. The snapshot arrives as `view()
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Nothing here knows what a board is. The snapshot arrives as `view()` and the document
|
|
11
|
+
* as `page()`, both supplied by `page.ts`, so this file can be tested with a fake store
|
|
12
|
+
* and no server.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import {
|
|
@@ -18,72 +18,48 @@ import {
|
|
|
18
18
|
jsonError,
|
|
19
19
|
jsonResponse,
|
|
20
20
|
pageHeaders,
|
|
21
|
-
PAGE_METHODS,
|
|
22
21
|
parseRequestUrl,
|
|
23
|
-
postOnly,
|
|
24
|
-
requireJsonBody,
|
|
25
22
|
scriptHeaders,
|
|
26
23
|
type ServeResponse,
|
|
27
24
|
} from "../../../lib/http/index.ts";
|
|
28
25
|
import type { BoardView } from "../../../lib/board/view.ts";
|
|
29
|
-
import type { Card } from "../../../lib/board/card.ts";
|
|
30
26
|
import { THEME_JS, THEME_JS_PATH } from "../../../lib/web/index.ts";
|
|
31
27
|
import type { PageView } from "../view/page.ts";
|
|
32
28
|
|
|
33
|
-
/** The methods this surface serves: reads, writes, and the one deletion. */
|
|
34
|
-
const METHODS: readonly string[] = [...PAGE_METHODS, "DELETE"];
|
|
35
|
-
|
|
36
29
|
/**
|
|
37
|
-
*
|
|
30
|
+
* The methods this surface serves: the two reads and the stream.
|
|
38
31
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* function instead of remembering which token it was given.
|
|
32
|
+
* `PAGE_METHODS` also carries `POST`, because a page that writes needs it. This one does
|
|
33
|
+
* not, so a write is refused by the envelope — before the token is even read — and the
|
|
34
|
+
* `Allow` header says the truth about what a browser can do here.
|
|
43
35
|
*/
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
/** The verbs a card route accepts, in the order the page uses them. */
|
|
47
|
-
const CARD_VERBS = ["claim", "release", "reclaim", "assign", "move", "note", "plan", "edit"] as const;
|
|
48
|
-
|
|
49
|
-
/** One card verb. */
|
|
50
|
-
export type CardVerb = (typeof CARD_VERBS)[number];
|
|
51
|
-
|
|
52
|
-
/** One write the page asks for, already routed but not yet applied. */
|
|
53
|
-
export type PageWrite =
|
|
54
|
-
| { kind: "add"; body: Record<string, unknown> }
|
|
55
|
-
| { kind: "drop"; id: string }
|
|
56
|
-
| { kind: CardVerb; id: string; body: Record<string, unknown> };
|
|
57
|
-
|
|
58
|
-
/** What a write did, or why it was refused with the status the page shows. */
|
|
59
|
-
export type PageResult = { ok: true; card: Card } | { ok: false; status: number; error: string; card?: Card };
|
|
36
|
+
const METHODS: readonly string[] = ["GET", "HEAD"];
|
|
60
37
|
|
|
61
38
|
/** Everything the router needs from the running page. */
|
|
62
39
|
export interface BoardRouterContext {
|
|
63
|
-
/**
|
|
64
|
-
|
|
40
|
+
/** Whether a token is this page's. */
|
|
41
|
+
read: (given: string | null) => boolean;
|
|
65
42
|
/** The names this server answers to. */
|
|
66
43
|
hosts: () => readonly string[];
|
|
67
|
-
/** The document for one tab
|
|
68
|
-
page: (view: PageView
|
|
44
|
+
/** The document for one tab. */
|
|
45
|
+
page: (view: PageView) => Promise<string>;
|
|
69
46
|
/** The client script. */
|
|
70
47
|
script: string;
|
|
71
48
|
/** The snapshot the page renders, and the body of `/api/board`. */
|
|
72
49
|
view: () => Promise<BoardView>;
|
|
73
|
-
/** Apply one write as the serving session. */
|
|
74
|
-
write: (request: PageWrite) => Promise<PageResult>;
|
|
75
50
|
}
|
|
76
51
|
|
|
52
|
+
/** The cheap liveness route: a probe reads this, and never the snapshot. */
|
|
53
|
+
export const PING_PATH = "/api/ping";
|
|
54
|
+
|
|
77
55
|
/**
|
|
78
|
-
* Handle one request. `method`, `rawUrl`, and `headers` are the raw HTTP values
|
|
79
|
-
* `body` is the request body, which is only non-empty when the server caps it.
|
|
56
|
+
* Handle one request. `method`, `rawUrl`, and `headers` are the raw HTTP values.
|
|
80
57
|
*/
|
|
81
58
|
export async function handleBoardRequest(
|
|
82
59
|
ctx: BoardRouterContext,
|
|
83
60
|
method: string,
|
|
84
61
|
rawUrl: string,
|
|
85
62
|
headers: Record<string, string | string[] | undefined> = {},
|
|
86
|
-
body = "",
|
|
87
63
|
): Promise<ServeResponse> {
|
|
88
64
|
const envelope = envelopeRefusal({ method, headers, hosts: ctx.hosts(), allowed: METHODS });
|
|
89
65
|
if (envelope) return envelope;
|
|
@@ -92,81 +68,24 @@ export async function handleBoardRequest(
|
|
|
92
68
|
if (!parsed) return jsonError(400, "Malformed request URL.");
|
|
93
69
|
const { pathname, search } = parsed;
|
|
94
70
|
|
|
95
|
-
if (pathname === "/favicon.ico") return { status: 204, headers: { "Cache-Control": "no-store" } };
|
|
96
71
|
if (pathname === THEME_JS_PATH) return { status: 200, headers: scriptHeaders(), body: THEME_JS };
|
|
97
72
|
if (pathname === "/app.js") return { status: 200, headers: scriptHeaders(), body: ctx.script };
|
|
98
73
|
|
|
99
|
-
|
|
100
|
-
if (authority === undefined) return jsonError(403, "Invalid or missing token.");
|
|
74
|
+
if (!ctx.read(search.get("token"))) return jsonError(403, "Invalid or missing token.");
|
|
101
75
|
|
|
76
|
+
// The liveness answer, and nothing else: a session deciding whether this page is alive
|
|
77
|
+
// must not pay for the snapshot, which is a store read and a roster read on the same
|
|
78
|
+
// event loop the probe is racing.
|
|
79
|
+
if (pathname === PING_PATH) return jsonResponse(200, { ok: true });
|
|
102
80
|
if (pathname === "/api/board") return jsonResponse(200, await ctx.view());
|
|
103
81
|
if (pathname === "/" || pathname === "/index.html") {
|
|
104
|
-
|
|
105
|
-
return { status: 200, headers: pageHeaders(), body: await ctx.page(view, authority) };
|
|
82
|
+
return { status: 200, headers: pageHeaders(), body: await ctx.page(viewOf(search.get("view"))) };
|
|
106
83
|
}
|
|
107
84
|
if (pathname === "/events") return { status: 200, headers: eventHeaders(), stream: true };
|
|
108
|
-
|
|
109
|
-
// Everything below changes a card, so a credential that may only read stops here.
|
|
110
|
-
const readOnly = writeGuard(authority);
|
|
111
|
-
if (readOnly) return readOnly;
|
|
112
|
-
|
|
113
|
-
if (pathname === "/api/cards") {
|
|
114
|
-
const methodRefusal = postOnly(method, "add a card");
|
|
115
|
-
if (methodRefusal) return methodRefusal;
|
|
116
|
-
const jsonRefusal = requireJsonBody(headers["content-type"]);
|
|
117
|
-
if (jsonRefusal) return jsonRefusal;
|
|
118
|
-
const parsedBody = parseBody(body);
|
|
119
|
-
if (!parsedBody) return jsonError(400, "The request body must be a JSON object.");
|
|
120
|
-
return writeAnswer(await ctx.write({ kind: "add", body: parsedBody }));
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const route = /^\/api\/cards\/([^/]+)(?:\/([^/]+))?$/.exec(pathname);
|
|
124
|
-
if (!route) return jsonError(404, "That page does not exist.");
|
|
125
|
-
const id = decodeURIComponent(route[1] ?? "");
|
|
126
|
-
const verb = route[2];
|
|
127
|
-
|
|
128
|
-
if (verb === undefined) {
|
|
129
|
-
if (method.toUpperCase() !== "DELETE") return jsonError(405, "Use DELETE to drop a card.");
|
|
130
|
-
return writeAnswer(await ctx.write({ kind: "drop", id }));
|
|
131
|
-
}
|
|
132
|
-
if (!CARD_VERBS.includes(verb as CardVerb)) return jsonError(404, "That card action does not exist.");
|
|
133
|
-
const methodRefusal = postOnly(method, verb);
|
|
134
|
-
if (methodRefusal) return methodRefusal;
|
|
135
|
-
const jsonRefusal = requireJsonBody(headers["content-type"]);
|
|
136
|
-
if (jsonRefusal) return jsonRefusal;
|
|
137
|
-
const parsedBody = parseBody(body);
|
|
138
|
-
if (!parsedBody) return jsonError(400, "The request body must be a JSON object.");
|
|
139
|
-
return writeAnswer(await ctx.write({ kind: verb as CardVerb, id, body: parsedBody }));
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** The 403 for a credential that may read this page but not change it. */
|
|
143
|
-
function writeGuard(authority: Authority): ServeResponse | undefined {
|
|
144
|
-
if (authority === "write") return undefined;
|
|
145
|
-
return jsonError(403, "This page is read-only for a viewer.");
|
|
85
|
+
return jsonError(404, "That page does not exist.");
|
|
146
86
|
}
|
|
147
87
|
|
|
148
88
|
/** The tab a `?view=` names; anything else is the board. */
|
|
149
89
|
function viewOf(raw: string | null): PageView {
|
|
150
90
|
return raw === "team" ? "team" : "board";
|
|
151
91
|
}
|
|
152
|
-
|
|
153
|
-
/** A request body as an object, or undefined when it is not one. */
|
|
154
|
-
function parseBody(body: string): Record<string, unknown> | undefined {
|
|
155
|
-
let parsed: unknown;
|
|
156
|
-
try {
|
|
157
|
-
parsed = JSON.parse(body);
|
|
158
|
-
} catch {
|
|
159
|
-
return undefined;
|
|
160
|
-
}
|
|
161
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
|
|
162
|
-
return parsed as Record<string, unknown>;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/** A write's answer: the card, or the refusal and the card it left alone. */
|
|
166
|
-
function writeAnswer(result: PageResult): ServeResponse {
|
|
167
|
-
if (result.ok) return jsonResponse(200, { card: result.card });
|
|
168
|
-
return jsonResponse(
|
|
169
|
-
result.status,
|
|
170
|
-
result.card === undefined ? { error: result.error } : { error: result.error, card: result.card },
|
|
171
|
-
);
|
|
172
|
-
}
|