@gavin-hu/my-pi-agent 0.18.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 +66 -52
- package/README.zh-CN.md +56 -46
- package/docs/extensions/README.md +34 -0
- package/docs/extensions/board.md +507 -0
- package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +774 -574
- package/{extensions/check/README.md → docs/extensions/check.md} +83 -68
- package/{extensions/context/README.md → docs/extensions/context.md} +40 -23
- package/docs/extensions/file-browse.md +561 -0
- package/{extensions/file-open/README.md → docs/extensions/file-open.md} +31 -23
- package/{extensions/file-read/README.md → docs/extensions/file-read.md} +47 -55
- package/{extensions/goal/README.md → docs/extensions/goal.md} +115 -131
- package/{extensions/job/README.md → docs/extensions/job.md} +62 -52
- package/{extensions/memory/README.md → docs/extensions/memory.md} +71 -60
- package/{extensions/plan/README.md → docs/extensions/plan.md} +440 -322
- package/{extensions/question/README.md → docs/extensions/question.md} +54 -31
- package/{extensions/rewind/README.md → docs/extensions/rewind.md} +115 -122
- package/docs/extensions/session-bus.md +298 -0
- package/{extensions/subagent/README.md → docs/extensions/subagent.md} +165 -118
- package/docs/extensions/team.md +254 -0
- package/{extensions/todo/README.md → docs/extensions/todo.md} +85 -88
- package/{extensions/watch/README.md → docs/extensions/watch.md} +54 -36
- package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
- package/{extensions/wechat/README.md → docs/extensions/wechat.md} +260 -163
- package/{extensions/worktree/README.md → docs/extensions/worktree.md} +137 -79
- package/extensions/board/commands.ts +105 -0
- package/extensions/board/config.ts +36 -0
- package/extensions/board/format.ts +170 -0
- package/extensions/board/index.ts +40 -0
- package/extensions/board/input.ts +54 -0
- package/extensions/board/join.ts +42 -0
- package/extensions/board/page.ts +388 -0
- package/extensions/board/result.ts +108 -0
- package/extensions/board/rows.ts +48 -0
- package/extensions/board/schema.ts +90 -0
- package/extensions/board/seat.ts +28 -0
- package/extensions/board/snapshot.ts +42 -0
- package/extensions/board/tools.ts +444 -0
- package/extensions/board/transport/router.ts +91 -0
- package/extensions/board/transport/server.ts +138 -0
- package/extensions/board/types.ts +121 -0
- package/extensions/board/view/client.ts +51 -0
- package/extensions/board/view/dialog.ts +43 -0
- package/extensions/board/view/keys.ts +125 -0
- package/extensions/board/view/live.ts +231 -0
- package/extensions/board/view/names.ts +91 -0
- package/extensions/board/view/page.ts +118 -0
- package/extensions/board/view/render.ts +519 -0
- package/extensions/board/view/styles.ts +228 -0
- package/extensions/board/view/team.ts +327 -0
- 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 +67 -7
- 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-runtime.ts +0 -6
- package/extensions/bytefly/panel/settings.ts +75 -43
- 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/bytefly/picker/discovery.ts +0 -2
- package/extensions/bytefly/picker/types.ts +0 -2
- package/extensions/bytefly/update/check.ts +39 -0
- package/extensions/bytefly/update/format.ts +15 -0
- package/extensions/bytefly/update/register.ts +73 -34
- package/extensions/bytefly/update/state.ts +6 -3
- package/extensions/check/commands.ts +2 -1
- package/extensions/context/tui.ts +1 -1
- package/extensions/file-browse/{config.ts → config/config.ts} +1 -1
- package/extensions/file-browse/files/classify.ts +136 -0
- package/extensions/file-browse/files/files.ts +12 -0
- package/extensions/file-browse/files/index.ts +90 -0
- package/extensions/file-browse/files/list.ts +261 -0
- package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
- package/extensions/file-browse/files/tree.ts +101 -0
- package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
- package/extensions/file-browse/index.ts +4 -4
- package/extensions/file-browse/{live.ts → live/live.ts} +20 -69
- package/extensions/file-browse/{router.ts → transport/router.ts} +21 -17
- package/extensions/file-browse/transport/server.ts +110 -0
- package/extensions/file-browse/{chrome.ts → view/chrome.ts} +4 -4
- package/extensions/file-browse/{client.ts → view/client.ts} +1 -1
- package/extensions/file-browse/{diff-html.ts → view/diff-html.ts} +3 -3
- package/extensions/file-browse/view/emit.ts +51 -0
- package/extensions/file-browse/{html.ts → view/html.ts} +19 -18
- package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
- package/extensions/file-browse/{markdown.ts → view/markdown.ts} +18 -20
- package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +19 -14
- package/extensions/file-browse/{styles.ts → view/styles.ts} +29 -5
- package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
- package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
- package/extensions/file-read/schema.ts +2 -1
- package/extensions/job/runtime.ts +4 -4
- package/extensions/memory/store.ts +12 -5
- package/extensions/plan/blocks.ts +23 -2
- package/extensions/plan/commands.ts +21 -16
- package/extensions/plan/feedback.ts +185 -52
- package/extensions/plan/format.ts +10 -3
- package/extensions/plan/index.ts +23 -5
- package/extensions/plan/plan-files.ts +88 -0
- package/extensions/plan/plan-index.ts +43 -9
- package/extensions/plan/plan-text.ts +33 -23
- package/extensions/plan/plans.ts +27 -34
- package/extensions/plan/policy.ts +30 -17
- package/extensions/plan/review.ts +3 -27
- package/extensions/plan/runtime.ts +52 -11
- package/extensions/plan/sections.ts +112 -0
- package/extensions/plan/steps.ts +3 -22
- package/extensions/plan/todos.ts +51 -3
- package/extensions/plan/tools/exit.ts +20 -95
- package/extensions/plan/tools/todo-seed.ts +4 -2
- package/extensions/plan/tools/write.ts +12 -1
- package/extensions/plan/tui.ts +5 -34
- package/extensions/plan/types.ts +15 -8
- 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 -8
- package/extensions/plan/web-plan.ts +150 -0
- package/extensions/plan/web-router.ts +3 -58
- package/extensions/plan/web-settle.ts +194 -0
- package/extensions/plan/web-share-session.ts +24 -30
- package/extensions/plan/web-streams.ts +14 -14
- package/extensions/plan/web-tokens.ts +2 -2
- package/extensions/plan/web.ts +66 -120
- package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
- package/extensions/rewind/{review.ts → commands/review.ts} +15 -14
- package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
- package/extensions/rewind/{config.ts → config/config.ts} +1 -1
- package/extensions/rewind/index.ts +3 -3
- package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
- package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
- package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
- package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
- package/extensions/rewind/{format.ts → text/format.ts} +3 -3
- package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
- package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
- package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
- package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
- package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
- package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
- package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
- package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
- package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
- package/extensions/rewind/{status.ts → view/status.ts} +4 -4
- package/extensions/session-bus/commands.ts +17 -69
- package/extensions/session-bus/format.ts +22 -45
- package/extensions/session-bus/index.ts +13 -7
- package/extensions/session-bus/join-request.ts +40 -0
- package/extensions/session-bus/mail-watch.ts +134 -0
- package/extensions/session-bus/notice.ts +32 -4
- package/{lib/claims → extensions/session-bus}/overlap.ts +11 -18
- package/extensions/session-bus/protocol.ts +19 -49
- package/extensions/session-bus/resolve.ts +67 -0
- package/extensions/session-bus/result.ts +16 -51
- package/extensions/session-bus/rows.ts +11 -5
- package/extensions/session-bus/runtime.ts +220 -267
- package/extensions/session-bus/store.ts +55 -7
- package/extensions/session-bus/tools.ts +31 -66
- package/extensions/session-bus/tui.ts +5 -5
- package/extensions/session-bus/types.ts +23 -14
- package/extensions/subagent/agents.ts +9 -8
- package/extensions/subagent/commands.ts +2 -1
- package/extensions/subagent/invocation.ts +4 -2
- package/extensions/team/commands.ts +145 -0
- package/extensions/team/dispatch.ts +133 -0
- package/extensions/team/format.ts +119 -0
- package/extensions/team/index.ts +34 -0
- package/extensions/team/schema.ts +29 -0
- package/extensions/team/tools.ts +267 -0
- package/extensions/team/types.ts +65 -0
- package/extensions/todo/index.ts +4 -1
- package/extensions/todo/nudge.ts +8 -2
- package/extensions/web-access/fetch/page.ts +4 -1
- package/extensions/web-access/fetch/structured.ts +447 -0
- package/extensions/web-access/fetch/tool.ts +2 -1
- package/extensions/web-access/search/tool.ts +2 -1
- package/extensions/wechat/bridge.ts +106 -251
- package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
- package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
- package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
- package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
- package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
- package/extensions/wechat/dialog/channels.ts +136 -0
- package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +37 -37
- package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +2 -2
- package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
- package/extensions/wechat/index.ts +10 -10
- package/extensions/wechat/intake.ts +245 -0
- package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
- package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
- package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +100 -22
- package/extensions/wechat/{presence.ts → peer/presence.ts} +8 -7
- package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +1 -1
- package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
- package/extensions/wechat/{state.ts → state/state.ts} +14 -2
- package/extensions/wechat/{format.ts → text/format.ts} +3 -3
- package/extensions/wechat/{notices.ts → text/notices.ts} +8 -0
- package/extensions/wechat/tools.ts +29 -12
- package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
- package/extensions/wechat/{deliver.ts → transport/deliver.ts} +72 -28
- package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
- package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
- package/extensions/wechat/transport/outbound.ts +142 -0
- package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
- package/extensions/wechat/types.ts +10 -1
- package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
- package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
- 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 +18 -290
- 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 +11 -8
- package/lib/board/card.ts +479 -0
- package/lib/board/links.ts +141 -0
- package/lib/board/page-pointer.ts +310 -0
- package/lib/board/round-text.ts +81 -0
- package/lib/board/round.ts +94 -0
- package/lib/board/rules.ts +437 -0
- package/lib/board/session.ts +105 -0
- package/lib/board/store.ts +497 -0
- package/lib/board/view.ts +234 -0
- package/lib/errors.ts +12 -4
- package/lib/format.ts +35 -0
- package/lib/http/guard.ts +112 -0
- package/lib/http/index.ts +16 -5
- package/lib/http/serve.ts +237 -0
- package/lib/http/server.ts +12 -10
- package/lib/http/sink.ts +135 -0
- package/lib/http/token.ts +38 -0
- package/lib/plan-entry.ts +11 -0
- package/lib/policy.ts +25 -2
- package/lib/requirements.ts +6 -7
- package/lib/session-label.ts +64 -0
- package/lib/session-roster.ts +201 -0
- package/lib/session-tool.ts +184 -0
- package/lib/spawn.ts +37 -3
- package/lib/stray-fields.ts +45 -0
- package/lib/tool-names.ts +38 -2
- package/lib/tui/glyphs.ts +3 -2
- package/lib/web/README.md +57 -4
- package/lib/web/client-runtime.ts +123 -0
- package/lib/web/components.ts +5 -0
- package/lib/web/index.ts +4 -2
- package/lib/web/markup.ts +88 -1
- package/lib/web/names.ts +42 -0
- package/lib/web/runtime.ts +7 -1
- package/lib/web/tokens.ts +25 -2
- package/package.json +26 -14
- package/skills/next/SKILL.md +4 -3
- package/skills/project-docs/SKILL.md +133 -0
- package/skills/project-docs/references/audit.md +131 -0
- package/skills/project-docs/references/changelog.md +105 -0
- package/skills/project-docs/references/discovery.md +127 -0
- package/skills/project-docs/references/readme.md +80 -0
- package/skills/project-docs/references/release.md +84 -0
- package/skills/project-docs/references/roadmap.md +93 -0
- package/skills/project-docs/scripts/state.ts +316 -0
- package/skills/refactor/SKILL.md +100 -125
- package/skills/refactor/references/handoff.md +35 -0
- package/skills/refactor/references/signals.md +54 -0
- package/skills/refactor/scripts/inventory.ts +587 -0
- package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
- package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
- package/extensions/file-browse/README.md +0 -506
- package/extensions/file-browse/files.ts +0 -564
- package/extensions/file-browse/server.ts +0 -141
- package/extensions/job/process.ts +0 -35
- package/extensions/plan/split.ts +0 -87
- package/extensions/session-bus/README.md +0 -247
- package/extensions/session-bus/team.ts +0 -102
- package/extensions/wechat/outbound.ts +0 -100
- package/lib/plans/sections.ts +0 -172
- package/lib/plans/setting.ts +0 -52
- package/lib/plans/split.ts +0 -215
- package/lib/plans/store.ts +0 -19
- package/lib/plans/team.ts +0 -151
- package/skills/changelog/SKILL.md +0 -148
- package/skills/parallel-dev/SKILL.md +0 -150
- /package/extensions/file-browse/{rev.ts → live/rev.ts} +0 -0
- /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
- /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
- /package/extensions/file-browse/{live-client.ts → view/live-client.ts} +0 -0
- /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
- /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
- /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
- /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
- /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
- /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
- /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The review page's plan renderer: the DOM a frame draws, thrown away and rebuilt.
|
|
3
|
+
*
|
|
4
|
+
* The seventh capability out of the review client's template literal, and the one with the
|
|
5
|
+
* most to read: it builds every block, its comment rail, and the cards under it, then puts
|
|
6
|
+
* the reader back where they were. Reads arrive through getters and mutations leave through
|
|
7
|
+
* callbacks, so the client keeps every variable it owns — `activeIndex`, the quote a click
|
|
8
|
+
* is waiting for, the observer — and this function holds only the shape of one frame.
|
|
9
|
+
*
|
|
10
|
+
* Two rules it owns, both about a repaint not stealing the reader's place:
|
|
11
|
+
*
|
|
12
|
+
* - focus is restored only when it was inside the plan to begin with, so a frame the stream
|
|
13
|
+
* caused cannot pull the keyboard out of the page the reviewer is working in;
|
|
14
|
+
* - the active block's class is put back in place rather than by scrolling, so the arrow
|
|
15
|
+
* keys resume where they were instead of jumping to the top.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ClientNode } from "../../lib/web/client-runtime.ts";
|
|
19
|
+
import type { ComposerDeps } from "./web-composer.ts";
|
|
20
|
+
import { blockElements, type IslandBlock, setActive } from "./web-blocks.ts";
|
|
21
|
+
import type { SectionWatcher } from "./web-outline.ts";
|
|
22
|
+
|
|
23
|
+
/** What `el` has to answer for a frame. Spliced beside it; declared for the checker. */
|
|
24
|
+
type Build = (tag: string, className?: string, text?: string | null) => ClientNode;
|
|
25
|
+
declare const el: Build;
|
|
26
|
+
|
|
27
|
+
/** The quote a click on a block's control is waiting for: the reader's selection. */
|
|
28
|
+
export interface PendingQuote {
|
|
29
|
+
wrap: ClientNode;
|
|
30
|
+
quote: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** What a repaint reads, and the callbacks it changes the client's state through. */
|
|
34
|
+
export interface PlanRenderDeps {
|
|
35
|
+
planEl: ClientNode | null;
|
|
36
|
+
outlineList: ClientNode | null;
|
|
37
|
+
/** The element that had focus before this frame, or null when nothing did. */
|
|
38
|
+
focused: ClientNode | null;
|
|
39
|
+
blocks(): readonly IslandBlock[];
|
|
40
|
+
annotations(): readonly { anchor: number }[];
|
|
41
|
+
activeIndex(): number;
|
|
42
|
+
setActiveIndex(index: number): void;
|
|
43
|
+
pendingQuote(): PendingQuote | null;
|
|
44
|
+
clearPendingQuote(): void;
|
|
45
|
+
/** The composer lifecycle, for the close a repaint begins with. */
|
|
46
|
+
composer(): ComposerDeps;
|
|
47
|
+
/** The block's content, from the Markdown half. */
|
|
48
|
+
renderBlock(wrap: ClientNode, block: IslandBlock): void;
|
|
49
|
+
/** The cards under one block, and the loose ones that lost their anchor. */
|
|
50
|
+
renderComments(scope: ClientNode, line: number): void;
|
|
51
|
+
renderCommentCards(scope: ClientNode, list: readonly unknown[]): void;
|
|
52
|
+
/** The text a reader selected inside a block, used as the next comment's quote. */
|
|
53
|
+
selectionIn(wrap: ClientNode): string;
|
|
54
|
+
open(wrap: ClientNode, block: IslandBlock, quote: string): void;
|
|
55
|
+
/** The facts and the header chip, repainted after the blocks. */
|
|
56
|
+
facts(): void;
|
|
57
|
+
updateState(): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Draw the plan, and put the reader back. Returns the section watcher it installed.
|
|
62
|
+
*
|
|
63
|
+
* The client's own zero-argument entry is `renderAll()`: it builds the frame's deps and
|
|
64
|
+
* hands them here, which is how the fragments keep calling a name that takes nothing.
|
|
65
|
+
*/
|
|
66
|
+
export function renderPlanFrame(
|
|
67
|
+
deps: PlanRenderDeps,
|
|
68
|
+
closePlan: () => void,
|
|
69
|
+
observe: (
|
|
70
|
+
planEl: ClientNode | null,
|
|
71
|
+
list: ClientNode | null,
|
|
72
|
+
previous: SectionWatcher | null,
|
|
73
|
+
) => SectionWatcher | null,
|
|
74
|
+
previousObserver: SectionWatcher | null,
|
|
75
|
+
): SectionWatcher | null {
|
|
76
|
+
const { planEl } = deps;
|
|
77
|
+
if (!planEl) return previousObserver;
|
|
78
|
+
// Focus is only put back when it was inside this document to begin with.
|
|
79
|
+
const keepFocus = !!(deps.focused && deps.focused !== planEl && planEl.contains(deps.focused));
|
|
80
|
+
closePlan();
|
|
81
|
+
const scrollTop = planEl.scrollTop;
|
|
82
|
+
planEl.replaceChildren();
|
|
83
|
+
const anchored: Record<number, boolean> = {};
|
|
84
|
+
for (const block of deps.blocks()) {
|
|
85
|
+
if (block.kind === "blank") continue;
|
|
86
|
+
anchored[block.start] = true;
|
|
87
|
+
const wrap = el("div", "bf-block");
|
|
88
|
+
wrap.setAttribute("data-line", String(block.start));
|
|
89
|
+
wrap.setAttribute("data-kind", String(block.kind));
|
|
90
|
+
if (block.kind === "heading") wrap.id = "s-" + block.start;
|
|
91
|
+
deps.renderBlock(wrap, block);
|
|
92
|
+
const mine = deps.annotations().filter((annotation) => annotation.anchor === block.start);
|
|
93
|
+
if (mine.length > 0) wrap.classList.add("bf-block--commented");
|
|
94
|
+
const rail = el("div", "bf-block__rail");
|
|
95
|
+
const add = el("button", "bf-block__add");
|
|
96
|
+
add.type = "button";
|
|
97
|
+
add.textContent = "+";
|
|
98
|
+
add.setAttribute("aria-label", "Comment on this block");
|
|
99
|
+
add.addEventListener("click", function () {
|
|
100
|
+
// Read at click time, not at render time: a selection made after this frame is
|
|
101
|
+
// the quote the reader means.
|
|
102
|
+
const waiting = deps.pendingQuote();
|
|
103
|
+
const quoted = waiting && waiting.wrap === wrap ? waiting.quote : deps.selectionIn(wrap);
|
|
104
|
+
deps.clearPendingQuote();
|
|
105
|
+
deps.open(wrap, block, quoted);
|
|
106
|
+
});
|
|
107
|
+
rail.appendChild(add);
|
|
108
|
+
if (mine.length > 0) {
|
|
109
|
+
const badge = el("span", "bf-block__count");
|
|
110
|
+
badge.textContent = String(mine.length);
|
|
111
|
+
badge.title = mine.length === 1 ? "1 review comment" : mine.length + " review comments";
|
|
112
|
+
rail.appendChild(badge);
|
|
113
|
+
}
|
|
114
|
+
wrap.appendChild(rail);
|
|
115
|
+
wrap.addEventListener("focusin", function () {
|
|
116
|
+
const next = setActive(planEl, blockElements(planEl).indexOf(wrap));
|
|
117
|
+
if (next !== null) deps.setActiveIndex(next);
|
|
118
|
+
});
|
|
119
|
+
planEl.appendChild(wrap);
|
|
120
|
+
deps.renderComments(planEl, block.start);
|
|
121
|
+
}
|
|
122
|
+
const orphans = deps.annotations().filter((annotation) => !anchored[annotation.anchor]);
|
|
123
|
+
if (orphans.length > 0) {
|
|
124
|
+
const box = el("div", "bf-comments bf-comments--orphans");
|
|
125
|
+
const note = el("p");
|
|
126
|
+
note.textContent = "Comments not attached to a current block";
|
|
127
|
+
box.appendChild(note);
|
|
128
|
+
deps.renderCommentCards(box, orphans);
|
|
129
|
+
planEl.appendChild(box);
|
|
130
|
+
}
|
|
131
|
+
planEl.scrollTop = scrollTop;
|
|
132
|
+
// The DOM was rebuilt, so the active block's class is gone; restore it in place rather
|
|
133
|
+
// than scrolling, so the keyboard resumes where the reviewer was.
|
|
134
|
+
const activeIndex = deps.activeIndex();
|
|
135
|
+
const active = activeIndex >= 0 ? blockElements(planEl)[activeIndex] : null;
|
|
136
|
+
if (active) active.classList.add("bf-block--active");
|
|
137
|
+
// The focused element went with the old DOM, so put the tab stop back on the block's
|
|
138
|
+
// comment control: that is where adding, editing, or deleting left it.
|
|
139
|
+
if (keepFocus && active) {
|
|
140
|
+
const stop = active.querySelector(".bf-block__add");
|
|
141
|
+
if (stop) stop.focus();
|
|
142
|
+
}
|
|
143
|
+
deps.facts();
|
|
144
|
+
const observer = observe(planEl, deps.outlineList, previousObserver);
|
|
145
|
+
deps.updateState();
|
|
146
|
+
return observer;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The block renderer as source, for the client bundle. */
|
|
150
|
+
export const WEB_PLAN_JS = String(renderPlanFrame);
|
|
@@ -13,14 +13,13 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import {
|
|
16
|
+
envelopeRefusal,
|
|
16
17
|
eventHeaders,
|
|
17
|
-
hostHeaderName,
|
|
18
|
-
isAllowedHostHeader,
|
|
19
18
|
jsonError,
|
|
20
|
-
jsonHeaders,
|
|
21
19
|
jsonResponse,
|
|
22
20
|
pageHeaders,
|
|
23
21
|
parseRequestUrl,
|
|
22
|
+
postOnly,
|
|
24
23
|
scriptHeaders,
|
|
25
24
|
type ServeResponse,
|
|
26
25
|
} from "../../lib/http/index.ts";
|
|
@@ -84,12 +83,6 @@ function isResponse(value: PlanAnnotation[] | ServeResponse): value is ServeResp
|
|
|
84
83
|
return !Array.isArray(value);
|
|
85
84
|
}
|
|
86
85
|
|
|
87
|
-
/** The 405 for a route that only accepts POST, or nothing when the method is right. */
|
|
88
|
-
function postOnly(method: string, what: string): ServeResponse | undefined {
|
|
89
|
-
if (method.toUpperCase() === "POST") return undefined;
|
|
90
|
-
return jsonError(405, `Use POST to ${what}.`);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
86
|
/**
|
|
94
87
|
* The 403 for a route the caller's role may not use, or nothing when it may. Each
|
|
95
88
|
* route keeps its own sentence, because the reason differs: a reviewer posts
|
|
@@ -111,53 +104,6 @@ function linkIdOf(body: string): string | undefined {
|
|
|
111
104
|
return typeof id === "string" && id !== "" ? id : undefined;
|
|
112
105
|
}
|
|
113
106
|
|
|
114
|
-
/** Validate method and `Host`; returns an error response to send instead. */
|
|
115
|
-
function checkEnvelope(
|
|
116
|
-
ctx: ReviewContext,
|
|
117
|
-
method: string,
|
|
118
|
-
headers: Record<string, string | string[] | undefined>,
|
|
119
|
-
): ServeResponse | undefined {
|
|
120
|
-
const upper = method.toUpperCase();
|
|
121
|
-
if (upper !== "GET" && upper !== "HEAD" && upper !== "POST") {
|
|
122
|
-
return {
|
|
123
|
-
...jsonError(405, "Only GET, HEAD, and POST are served."),
|
|
124
|
-
headers: { ...jsonHeaders(), Allow: "GET, HEAD, POST" },
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
// Fail closed: a missing or non-string Host is rejected too, not just a
|
|
128
|
-
// foreign one, so the DNS-rebinding guard cannot be skipped.
|
|
129
|
-
if (!isAllowedHostHeader(headers, { hosts: ctx.hosts(), required: true })) {
|
|
130
|
-
return jsonError(403, "Unexpected Host header.");
|
|
131
|
-
}
|
|
132
|
-
return upper === "POST" ? checkOrigin(headers) : undefined;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Refuse a state-changing request another site made. A browser sends `Origin`
|
|
137
|
-
* on every POST and `Sec-Fetch-Site` with its own classification; a request
|
|
138
|
-
* with neither is a script or a test, which has no ambient credential to abuse.
|
|
139
|
-
*/
|
|
140
|
-
function checkOrigin(headers: Record<string, string | string[] | undefined>): ServeResponse | undefined {
|
|
141
|
-
const site = headers["sec-fetch-site"];
|
|
142
|
-
if (typeof site === "string" && site !== "" && site !== "same-origin" && site !== "none") {
|
|
143
|
-
return jsonError(403, "Cross-origin request refused.");
|
|
144
|
-
}
|
|
145
|
-
const origin = headers.origin;
|
|
146
|
-
if (origin === undefined) return undefined;
|
|
147
|
-
if (typeof origin !== "string" || origin === "") return jsonError(403, "Unexpected Origin header.");
|
|
148
|
-
let parsed: URL;
|
|
149
|
-
try {
|
|
150
|
-
parsed = new URL(origin);
|
|
151
|
-
} catch {
|
|
152
|
-
return jsonError(403, "Unexpected Origin header.");
|
|
153
|
-
}
|
|
154
|
-
const host = headers.host;
|
|
155
|
-
if (typeof host !== "string" || hostHeaderName(parsed.host) !== hostHeaderName(host)) {
|
|
156
|
-
return jsonError(403, "Cross-origin request refused.");
|
|
157
|
-
}
|
|
158
|
-
return undefined;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
107
|
/**
|
|
162
108
|
* Handle one request. `method`, `rawUrl`, and `headers` are the raw HTTP
|
|
163
109
|
* values; `body` is the request body for a POST.
|
|
@@ -169,14 +115,13 @@ export async function handleReviewRequest(
|
|
|
169
115
|
headers: Record<string, string | string[] | undefined> = {},
|
|
170
116
|
body = "",
|
|
171
117
|
): Promise<ServeResponse> {
|
|
172
|
-
const envelope =
|
|
118
|
+
const envelope = envelopeRefusal({ method, headers, hosts: ctx.hosts() });
|
|
173
119
|
if (envelope) return envelope;
|
|
174
120
|
|
|
175
121
|
const parsed = parseRequestUrl(rawUrl);
|
|
176
122
|
if (!parsed) return jsonError(400, "Malformed request URL.");
|
|
177
123
|
const { pathname, search } = parsed;
|
|
178
124
|
|
|
179
|
-
if (pathname === "/favicon.ico") return { status: 204, headers: { "Cache-Control": "no-store" } };
|
|
180
125
|
if (pathname === THEME_JS_PATH) return { status: 200, headers: scriptHeaders(), body: THEME_JS };
|
|
181
126
|
if (pathname === "/client.js") return { status: 200, headers: scriptHeaders(), body: REVIEW_CLIENT_JS };
|
|
182
127
|
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The review page's settlement: the four ways a review ends, and the one POST they share.
|
|
3
|
+
*
|
|
4
|
+
* The eighth capability out of the review client's template literal. Every write has the
|
|
5
|
+
* same shape — POST the annotations, read the answer, report it in the status line — and
|
|
6
|
+
* what differs is whether the review settles: the owner's save and approve finish it, a
|
|
7
|
+
* reviewer's post does not (the server keeps the review open so she can keep editing), and
|
|
8
|
+
* cancel is the owner's own decision and carries no body at all.
|
|
9
|
+
*
|
|
10
|
+
* The page owns its flags, its buttons, and its stream; this module owns the rules about
|
|
11
|
+
* them, so it reads through getters and reports changes through the callbacks it is given.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** What a settlement reads of the page, and the changes it reports. */
|
|
15
|
+
export interface SettleDeps {
|
|
16
|
+
/** The path a write goes to, without its query string. */
|
|
17
|
+
token(): string;
|
|
18
|
+
role(): string;
|
|
19
|
+
/** The comments this writer may write: her own when she is a reviewer, all when owner. */
|
|
20
|
+
mine(): readonly unknown[];
|
|
21
|
+
annotations(): readonly unknown[];
|
|
22
|
+
/** Whether the review has already ended, by any of the three routes. */
|
|
23
|
+
settled(): boolean;
|
|
24
|
+
setStatus(text: string, kind?: string): void;
|
|
25
|
+
/** The three buttons a conflict disables, and the fourth a close does. */
|
|
26
|
+
buttons(): {
|
|
27
|
+
approve: { disabled: boolean } | null;
|
|
28
|
+
save: { disabled: boolean } | null;
|
|
29
|
+
post: { disabled: boolean } | null;
|
|
30
|
+
};
|
|
31
|
+
/** The draft this tab's work lives in: cleared once the server has it. */
|
|
32
|
+
clearDraft(): void;
|
|
33
|
+
/** The page is no longer dirty: the server has everything this tab wrote. */
|
|
34
|
+
markClean(): void;
|
|
35
|
+
/** The server bound this reviewer's name to her link: adopt it. */
|
|
36
|
+
adoptName(name: string): void;
|
|
37
|
+
/** Put a reload button beside the controls: the page cannot recover on its own. */
|
|
38
|
+
offerReload(): void;
|
|
39
|
+
/** Record how the review ended, stop following it, and stop offering to change it. */
|
|
40
|
+
markSettled(kind: "saved" | "approved" | "closed"): void;
|
|
41
|
+
settleStream(): void;
|
|
42
|
+
lockActions(): void;
|
|
43
|
+
/** Repaint the header chip and the facts. */
|
|
44
|
+
updateState(): void;
|
|
45
|
+
/** Refuse to leave a half-written comment, saying why. */
|
|
46
|
+
confirmNoDraft(): boolean;
|
|
47
|
+
post(url: string, body: unknown): Promise<{ ok: boolean; status: number; body: unknown }>;
|
|
48
|
+
/** A body-less POST, for the close that carries nothing. */
|
|
49
|
+
send(url: string): Promise<unknown>;
|
|
50
|
+
/** What a body-less conflict says, when the server gave no sentence of its own. */
|
|
51
|
+
planMoved: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What a settlement reports back: the flag it set, and whether the page is finished. */
|
|
55
|
+
export interface SettleResult {
|
|
56
|
+
outcome: "ok" | "conflict" | "failed";
|
|
57
|
+
reason?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* POST the annotations, and read the answer on both branches.
|
|
62
|
+
*
|
|
63
|
+
* A 409 disables the three write buttons and offers a reload, because the page's island is
|
|
64
|
+
* out of date and no retry from here can fix that; anything else surfaces the server's own
|
|
65
|
+
* sentence, which is why the body is parsed on both branches rather than only on success.
|
|
66
|
+
*/
|
|
67
|
+
export function submitAnnotations(
|
|
68
|
+
deps: SettleDeps,
|
|
69
|
+
url: string,
|
|
70
|
+
okText: string,
|
|
71
|
+
failText: string,
|
|
72
|
+
onOk: (() => void) | null,
|
|
73
|
+
): Promise<SettleResult> {
|
|
74
|
+
const payload = deps.role() === "reviewer" ? deps.mine() : deps.annotations();
|
|
75
|
+
return deps
|
|
76
|
+
.post(url + "?token=" + encodeURIComponent(deps.token()), { annotations: payload })
|
|
77
|
+
.then((result) => {
|
|
78
|
+
if (result.ok) {
|
|
79
|
+
deps.markClean();
|
|
80
|
+
deps.clearDraft();
|
|
81
|
+
deps.setStatus(okText, "saved");
|
|
82
|
+
if (onOk) onOk();
|
|
83
|
+
deps.updateState();
|
|
84
|
+
// The server binds a reviewer's name to her link on the first post, so a
|
|
85
|
+
// second tab of the same link posts under the same name.
|
|
86
|
+
const body = result.body as { author?: unknown } | null;
|
|
87
|
+
if (deps.role() === "reviewer" && body && typeof body.author === "string" && body.author) {
|
|
88
|
+
deps.adoptName(body.author);
|
|
89
|
+
}
|
|
90
|
+
return { outcome: "ok" as const };
|
|
91
|
+
}
|
|
92
|
+
if (result.status === 409) {
|
|
93
|
+
const buttons = deps.buttons();
|
|
94
|
+
if (buttons.approve) buttons.approve.disabled = true;
|
|
95
|
+
if (buttons.save) buttons.save.disabled = true;
|
|
96
|
+
if (buttons.post) buttons.post.disabled = true;
|
|
97
|
+
deps.offerReload();
|
|
98
|
+
const body = result.body as { error?: unknown } | null;
|
|
99
|
+
const reason = body && typeof body.error === "string" ? body.error : deps.planMoved;
|
|
100
|
+
deps.setStatus(reason, "error");
|
|
101
|
+
return { outcome: "conflict" as const, reason };
|
|
102
|
+
}
|
|
103
|
+
const body = result.body as { error?: unknown } | null;
|
|
104
|
+
throw new Error(body && typeof body.error === "string" ? body.error : "server returned " + result.status);
|
|
105
|
+
})
|
|
106
|
+
.catch((error: { message?: string }) => {
|
|
107
|
+
const reason = failText + (error && error.message ? error.message : "");
|
|
108
|
+
deps.setStatus(reason, "error");
|
|
109
|
+
return { outcome: "failed" as const, reason };
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Save the annotations and finish the review: the owner's way to close an approved plan. */
|
|
114
|
+
export function saveReview(deps: SettleDeps): Promise<SettleResult> | null {
|
|
115
|
+
if (deps.settled()) return null;
|
|
116
|
+
if (deps.confirmNoDraft()) return null;
|
|
117
|
+
deps.setStatus("Saving\u2026");
|
|
118
|
+
return submitAnnotations(
|
|
119
|
+
deps,
|
|
120
|
+
"/api/feedback",
|
|
121
|
+
"Saved \u2014 return to Pi to continue.",
|
|
122
|
+
"Could not save: ",
|
|
123
|
+
function () {
|
|
124
|
+
deps.markSettled("saved");
|
|
125
|
+
deps.settleStream();
|
|
126
|
+
deps.lockActions();
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Post a reviewer's comments and keep the review open.
|
|
133
|
+
*
|
|
134
|
+
* A reviewer's write never settles the page: the server keeps the review open so she can
|
|
135
|
+
* keep editing her own comments, and closing the stream here would strand her.
|
|
136
|
+
*/
|
|
137
|
+
export function postReviewComments(deps: SettleDeps): Promise<SettleResult> | null {
|
|
138
|
+
if (deps.settled()) return null;
|
|
139
|
+
if (deps.confirmNoDraft()) return null;
|
|
140
|
+
deps.setStatus("Posting\u2026");
|
|
141
|
+
return submitAnnotations(
|
|
142
|
+
deps,
|
|
143
|
+
"/api/comment",
|
|
144
|
+
"Comments posted \u2014 the owner sees them now.",
|
|
145
|
+
"Could not post: ",
|
|
146
|
+
null,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Approve the plan, which finishes the review. */
|
|
151
|
+
export function approveReview(deps: SettleDeps): Promise<SettleResult> | null {
|
|
152
|
+
if (deps.settled()) return null;
|
|
153
|
+
deps.setStatus("Approving\u2026");
|
|
154
|
+
return submitAnnotations(
|
|
155
|
+
deps,
|
|
156
|
+
"/api/approve",
|
|
157
|
+
"Approved \u2014 return to Pi to continue.",
|
|
158
|
+
"Could not approve: ",
|
|
159
|
+
function () {
|
|
160
|
+
deps.markSettled("approved");
|
|
161
|
+
deps.settleStream();
|
|
162
|
+
deps.lockActions();
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Close the review without saving: only the owner may, and the page says so while it asks.
|
|
169
|
+
*
|
|
170
|
+
* The close carries no body, so it is the one write that does not go through
|
|
171
|
+
* `submitAnnotations`; its answer is a status line either way, because a tab that closed the
|
|
172
|
+
* review has nothing left to do with a failure.
|
|
173
|
+
*/
|
|
174
|
+
export function cancelReview(deps: SettleDeps): Promise<void> | null {
|
|
175
|
+
if (deps.role() !== "owner" || deps.settled()) return null;
|
|
176
|
+
if (deps.confirmNoDraft()) return null;
|
|
177
|
+
deps.markSettled("closed");
|
|
178
|
+
deps.settleStream();
|
|
179
|
+
deps.lockActions();
|
|
180
|
+
deps.setStatus("Closing\u2026");
|
|
181
|
+
deps.updateState();
|
|
182
|
+
return deps
|
|
183
|
+
.send("/api/cancel?token=" + encodeURIComponent(deps.token()))
|
|
184
|
+
.catch(function () {})
|
|
185
|
+
.then(function () {
|
|
186
|
+
deps.setStatus("Closed \u2014 return to Pi.");
|
|
187
|
+
deps.updateState();
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The settlement rules as source, for the client bundle. */
|
|
192
|
+
export const WEB_SETTLE_JS = [submitAnnotations, saveReview, postReviewComments, approveReview, cancelReview]
|
|
193
|
+
.map(String)
|
|
194
|
+
.join("\n\n");
|
|
@@ -3,13 +3,12 @@
|
|
|
3
3
|
* network, and the state the owner's page and `/plan share` read.
|
|
4
4
|
*
|
|
5
5
|
* Separate from `web.ts` because the review session's job is its own lifecycle —
|
|
6
|
-
* open, settle, close — while this module owns the
|
|
7
|
-
*
|
|
6
|
+
* open, settle, close — while this module owns the address, the links, and the
|
|
7
|
+
* listener's lifetime. It is handed the one request handler, the token
|
|
8
8
|
* hooks, and the frame hooks, so it never reaches into the session's maps.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
12
|
-
import { bindHost, createCloser, LOOPBACK_HOSTS } from "../../lib/http/index.ts";
|
|
11
|
+
import { LOOPBACK_HOSTS, type LoopbackHandler, type LoopbackServer, serveLoopback } from "../../lib/http/index.ts";
|
|
13
12
|
import { qrSvg } from "../../lib/qr.ts";
|
|
14
13
|
import type { ReviewFrame, ReviewRole } from "./types.ts";
|
|
15
14
|
import {
|
|
@@ -27,7 +26,9 @@ import {
|
|
|
27
26
|
/** What the share listener needs from the review session it belongs to. */
|
|
28
27
|
export interface ShareSessionDeps {
|
|
29
28
|
/** The one request handler: a loopback page and a shared page are the same page. */
|
|
30
|
-
handleRequest:
|
|
29
|
+
handleRequest: LoopbackHandler;
|
|
30
|
+
/** The body cap both listeners read under, so a shared page is not read differently. */
|
|
31
|
+
maxBodyBytes: number;
|
|
31
32
|
/** The owner's bound port, reused for the link when it is free. */
|
|
32
33
|
ownerPort: () => number;
|
|
33
34
|
/** The machine's interfaces, or a snapshot a test injected. */
|
|
@@ -65,8 +66,7 @@ export interface ShareSession {
|
|
|
65
66
|
export function createShareSession(deps: ShareSessionDeps): ShareSession {
|
|
66
67
|
let address = "";
|
|
67
68
|
let port = 0;
|
|
68
|
-
let server:
|
|
69
|
-
let closeServer: (() => Promise<void>) | undefined;
|
|
69
|
+
let server: LoopbackServer | undefined;
|
|
70
70
|
const links: LinkTable = createLinkTable({
|
|
71
71
|
mint: deps.mintReviewer,
|
|
72
72
|
revoke: deps.revokeReviewer,
|
|
@@ -87,24 +87,20 @@ export function createShareSession(deps: ShareSessionDeps): ShareSession {
|
|
|
87
87
|
|
|
88
88
|
/** Bind the shared listener on `chosen`, reusing the owner's port when it is free. */
|
|
89
89
|
const bind = async (chosen: string): Promise<void> => {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
|
|
90
|
+
try {
|
|
91
|
+
const bound = await serveLoopback({
|
|
92
|
+
host: chosen,
|
|
93
|
+
ports: [deps.ownerPort(), 0],
|
|
94
|
+
maxBodyBytes: deps.maxBodyBytes,
|
|
95
|
+
handler: deps.handleRequest,
|
|
96
|
+
});
|
|
97
|
+
server = bound;
|
|
98
|
+
address = chosen;
|
|
99
|
+
port = bound.port;
|
|
100
|
+
} catch {
|
|
101
|
+
// Neither the owner's port nor a free one answered: the caller reports it.
|
|
102
|
+
server = undefined;
|
|
99
103
|
}
|
|
100
|
-
if (bound === undefined) {
|
|
101
|
-
candidate.close();
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
server = candidate;
|
|
105
|
-
closeServer = createCloser(candidate);
|
|
106
|
-
address = chosen;
|
|
107
|
-
port = bound;
|
|
108
104
|
};
|
|
109
105
|
|
|
110
106
|
/**
|
|
@@ -176,21 +172,19 @@ export function createShareSession(deps: ShareSessionDeps): ShareSession {
|
|
|
176
172
|
// The reviewers are told before their listener goes away; the review itself
|
|
177
173
|
// stays open for its owner.
|
|
178
174
|
deps.endStreams("revoked", { role: "reviewer" });
|
|
179
|
-
const closer =
|
|
175
|
+
const closer = server;
|
|
180
176
|
server = undefined;
|
|
181
|
-
closeServer = undefined;
|
|
182
177
|
address = "";
|
|
183
178
|
port = 0;
|
|
184
179
|
reason = undefined;
|
|
185
|
-
if (closer) await closer();
|
|
180
|
+
if (closer) await closer.close();
|
|
186
181
|
return settle();
|
|
187
182
|
};
|
|
188
183
|
|
|
189
184
|
const close = async (): Promise<void> => {
|
|
190
|
-
const closer =
|
|
185
|
+
const closer = server;
|
|
191
186
|
server = undefined;
|
|
192
|
-
|
|
193
|
-
if (closer) await closer();
|
|
187
|
+
if (closer) await closer.close();
|
|
194
188
|
};
|
|
195
189
|
|
|
196
190
|
return { share, invite, revoke, stop, state: snapshot, hosts, close };
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
* when a closed tab ends the review.
|
|
4
4
|
*
|
|
5
5
|
* A frame carries only a kind, so the registry is the only thing that knows who
|
|
6
|
-
* is connected. It is keyed by
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* is connected. It is keyed by sink — a page may hold more than one stream — and
|
|
7
|
+
* records the caller it belongs to, so an owner-only frame, or one revoked link,
|
|
8
|
+
* can be addressed without the caller reaching into the map. Liveness is the
|
|
9
|
+
* sink's own (a write after the end is a no-op), so the registry never asks the
|
|
10
|
+
* socket whether it is still writable.
|
|
9
11
|
*
|
|
10
12
|
* The closed-tab rule lives here because it is a fact about streams: a reviewer's
|
|
11
13
|
* tab opening or closing changes nothing, while the owner's own page going away
|
|
@@ -13,7 +15,7 @@
|
|
|
13
15
|
* against that reload, so it takes the timer as a seam a test can drive by hand.
|
|
14
16
|
*/
|
|
15
17
|
|
|
16
|
-
import type {
|
|
18
|
+
import type { EventSink } from "../../lib/http/index.ts";
|
|
17
19
|
import type { ArmTimer } from "../../lib/timer.ts";
|
|
18
20
|
import type { ReviewFrame, ReviewRole } from "./types.ts";
|
|
19
21
|
|
|
@@ -44,9 +46,9 @@ export interface StreamRegistryDeps {
|
|
|
44
46
|
/** The stream set one running review holds. */
|
|
45
47
|
export interface StreamRegistry {
|
|
46
48
|
/** Register a stream, and stand down a pending closed-tab grace. */
|
|
47
|
-
add(
|
|
49
|
+
add(sink: EventSink, record: StreamRecord): void;
|
|
48
50
|
/** Forget a stream, arming the grace when it was the owner's last one. */
|
|
49
|
-
remove(
|
|
51
|
+
remove(sink: EventSink): void;
|
|
50
52
|
/** Tell the open pages something changed. */
|
|
51
53
|
broadcast(kind: ReviewFrame, only?: StreamFilter): void;
|
|
52
54
|
/** Send one last frame and end the stream with it. */
|
|
@@ -60,7 +62,7 @@ export interface StreamRegistry {
|
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
export function createStreams(deps: StreamRegistryDeps): StreamRegistry {
|
|
63
|
-
const clients = new Map<
|
|
65
|
+
const clients = new Map<EventSink, StreamRecord>();
|
|
64
66
|
let sawOwner = false;
|
|
65
67
|
let cancelDrop: (() => void) | undefined;
|
|
66
68
|
|
|
@@ -91,29 +93,27 @@ export function createStreams(deps: StreamRegistryDeps): StreamRegistry {
|
|
|
91
93
|
};
|
|
92
94
|
|
|
93
95
|
return {
|
|
94
|
-
add(
|
|
95
|
-
clients.set(
|
|
96
|
+
add(sink, record) {
|
|
97
|
+
clients.set(sink, record);
|
|
96
98
|
if (record.role === "owner") {
|
|
97
99
|
sawOwner = true;
|
|
98
100
|
cancel();
|
|
99
101
|
}
|
|
100
102
|
},
|
|
101
|
-
remove(
|
|
102
|
-
const record = clients.get(
|
|
103
|
-
clients.delete(
|
|
103
|
+
remove(sink) {
|
|
104
|
+
const record = clients.get(sink);
|
|
105
|
+
clients.delete(sink);
|
|
104
106
|
if (record?.role === "owner") scheduleDrop();
|
|
105
107
|
},
|
|
106
108
|
broadcast(kind, only) {
|
|
107
109
|
for (const [client, record] of clients) {
|
|
108
110
|
if (!matches(record, only)) continue;
|
|
109
|
-
if (client.writableEnded || client.destroyed) continue;
|
|
110
111
|
client.write(`data: ${kind}\n\n`);
|
|
111
112
|
}
|
|
112
113
|
},
|
|
113
114
|
end(kind, only) {
|
|
114
115
|
for (const [client, record] of clients) {
|
|
115
116
|
if (!matches(record, only)) continue;
|
|
116
|
-
if (client.writableEnded || client.destroyed) continue;
|
|
117
117
|
client.end(`data: ${kind}\n\n`);
|
|
118
118
|
}
|
|
119
119
|
},
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* carries only the name, so a shared name is a shared marker.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import { mintToken } from "../../lib/http/index.ts";
|
|
19
19
|
import type { ReviewCaller, ReviewRole } from "./types.ts";
|
|
20
20
|
|
|
21
21
|
/** The token → caller table one running review holds. */
|
|
@@ -38,7 +38,7 @@ export interface TokenTable {
|
|
|
38
38
|
const COLLISION_ATTEMPTS = 8;
|
|
39
39
|
|
|
40
40
|
/** A 128-bit token; the one credential a reviewer holds. */
|
|
41
|
-
const defaultToken = (): string =>
|
|
41
|
+
const defaultToken = (): string => mintToken();
|
|
42
42
|
|
|
43
43
|
/** Build the table for one review. `mint` is the generator a test replaces. */
|
|
44
44
|
export function createTokens(mint: () => string = defaultToken): TokenTable {
|