@crouton-kit/crouter 0.3.18 → 0.3.20
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/dist/build-root.js +6 -0
- package/dist/builtin-memory/crouter-development/marketplaces.md +164 -0
- package/dist/builtin-memory/crouter-development/personas/base-prompt.md +58 -0
- package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +60 -0
- package/dist/builtin-memory/crouter-development/personas.md +117 -0
- package/dist/builtin-memory/crouter-development/plugins.md +163 -0
- package/dist/builtin-memory/design.md +60 -0
- package/dist/builtin-memory/development.md +116 -0
- package/dist/builtin-memory/planning.md +66 -0
- package/dist/builtin-memory/spec.md +99 -0
- package/dist/builtin-personas/design/PERSONA.md +1 -0
- package/dist/builtin-personas/design/orchestrator.md +2 -1
- package/dist/builtin-personas/developer/PERSONA.md +1 -0
- package/dist/builtin-personas/developer/orchestrator.md +2 -1
- package/dist/builtin-personas/explore/PERSONA.md +1 -0
- package/dist/builtin-personas/explore/orchestrator.md +4 -0
- package/dist/builtin-personas/general/PERSONA.md +1 -0
- package/dist/builtin-personas/general/orchestrator.md +4 -0
- package/dist/builtin-personas/lifecycle/resident.md +1 -1
- package/dist/builtin-personas/lifecycle/terminal.md +5 -2
- package/dist/builtin-personas/orchestration-kernel.md +15 -24
- package/dist/builtin-personas/plan/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/orchestrator.md +2 -1
- package/dist/builtin-personas/plan/reviewers/architecture-fit/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/reviewers/code-smells/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/reviewers/pattern-consistency/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/reviewers/requirements-coverage/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/reviewers/security/PERSONA.md +1 -0
- package/dist/builtin-personas/review/PERSONA.md +1 -0
- package/dist/builtin-personas/review/orchestrator.md +4 -0
- package/dist/builtin-personas/runtime-base.md +6 -3
- package/dist/builtin-personas/spec/PERSONA.md +1 -0
- package/dist/builtin-personas/spec/orchestrator.md +2 -1
- package/dist/builtin-personas/waiting.md +8 -0
- package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +1 -1
- package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +2 -2
- package/dist/builtin-views/_lib/states.mjs +161 -0
- package/dist/builtin-views/canvas/client.mjs +303 -0
- package/dist/builtin-views/canvas/view.mjs +576 -0
- package/dist/builtin-views/git-pr/client.mjs +440 -0
- package/dist/builtin-views/git-pr/view.mjs +675 -0
- package/dist/builtin-views/inbox/_lib/render.mjs +177 -0
- package/dist/builtin-views/inbox/sources/gmail.mjs +965 -0
- package/dist/builtin-views/inbox/sources/linkedin.mjs +427 -0
- package/dist/builtin-views/inbox/view.mjs +889 -0
- package/dist/builtin-views/linkedin/client.mjs +610 -0
- package/dist/builtin-views/linkedin/view.mjs +1171 -0
- package/dist/clients/attach/attach-cmd.d.ts +2 -0
- package/dist/clients/attach/attach-cmd.js +354 -0
- package/dist/clients/attach/chat-view.d.ts +77 -0
- package/dist/clients/attach/chat-view.js +450 -0
- package/dist/clients/attach/clipboard-image.d.ts +16 -0
- package/dist/clients/attach/clipboard-image.js +113 -0
- package/dist/clients/attach/config-load.d.ts +31 -0
- package/dist/clients/attach/config-load.js +113 -0
- package/dist/clients/attach/extension-dialogs.d.ts +29 -0
- package/dist/clients/attach/extension-dialogs.js +101 -0
- package/dist/clients/attach/input-controller.d.ts +54 -0
- package/dist/clients/attach/input-controller.js +204 -0
- package/dist/clients/attach/slash-commands.d.ts +36 -0
- package/dist/clients/attach/slash-commands.js +200 -0
- package/dist/clients/attach/view-socket.d.ts +48 -0
- package/dist/clients/attach/view-socket.js +126 -0
- package/dist/commands/attention.js +3 -3
- package/dist/commands/canvas-prune.js +1 -1
- package/dist/commands/daemon.js +3 -2
- package/dist/commands/human/prompts.js +1 -1
- package/dist/commands/human/queue.js +54 -8
- package/dist/commands/human/shared.d.ts +2 -0
- package/dist/commands/memory/find.d.ts +1 -0
- package/dist/commands/memory/find.js +180 -0
- package/dist/commands/memory/lint.d.ts +1 -0
- package/dist/commands/memory/lint.js +140 -0
- package/dist/commands/memory/list.d.ts +1 -0
- package/dist/commands/memory/list.js +79 -0
- package/dist/commands/memory/read.js +103 -0
- package/dist/commands/memory/shared.d.ts +30 -0
- package/dist/commands/memory/shared.js +122 -0
- package/dist/commands/memory/write.d.ts +1 -0
- package/dist/commands/memory/write.js +85 -0
- package/dist/commands/memory.d.ts +2 -0
- package/dist/commands/memory.js +27 -0
- package/dist/commands/node.d.ts +3 -2
- package/dist/commands/node.js +610 -61
- package/dist/commands/pkg/market-manage.js +1 -1
- package/dist/commands/push.js +6 -6
- package/dist/commands/revive.js +1 -1
- package/dist/commands/skill/author.js +1 -1
- package/dist/commands/skill/shared.d.ts +1 -8
- package/dist/commands/skill/shared.js +2 -55
- package/dist/commands/skill.js +9 -14
- package/dist/commands/sys/doctor.js +1 -1
- package/dist/commands/sys/update.js +1 -1
- package/dist/commands/view-cycle.d.ts +2 -0
- package/dist/commands/view-cycle.js +125 -0
- package/dist/commands/view-list.d.ts +2 -0
- package/dist/commands/view-list.js +66 -0
- package/dist/commands/view-new.d.ts +2 -0
- package/dist/commands/view-new.js +70 -0
- package/dist/commands/view-pick.d.ts +2 -0
- package/dist/commands/view-pick.js +119 -0
- package/dist/commands/view-run.d.ts +2 -0
- package/dist/commands/view-run.js +191 -0
- package/dist/commands/view.d.ts +2 -0
- package/dist/commands/view.js +29 -0
- package/dist/core/__tests__/broker-attach-limits.test.d.ts +1 -0
- package/dist/core/__tests__/broker-attach-limits.test.js +157 -0
- package/dist/core/__tests__/broker-attach-stream.test.d.ts +1 -0
- package/dist/core/__tests__/broker-attach-stream.test.js +125 -0
- package/dist/core/__tests__/broker-crash-teardown.test.d.ts +1 -0
- package/dist/core/__tests__/broker-crash-teardown.test.js +116 -0
- package/dist/core/__tests__/broker-dialogs.test.d.ts +1 -0
- package/dist/core/__tests__/broker-dialogs.test.js +126 -0
- package/dist/core/__tests__/broker-dormant-wake.test.d.ts +1 -0
- package/dist/core/__tests__/broker-dormant-wake.test.js +51 -0
- package/dist/core/__tests__/broker-lifecycle.test.d.ts +1 -0
- package/dist/core/__tests__/broker-lifecycle.test.js +87 -0
- package/dist/core/__tests__/broker-sdk-wiring.test.d.ts +1 -0
- package/dist/core/__tests__/broker-sdk-wiring.test.js +166 -0
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.d.ts +1 -0
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +102 -0
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +2 -36
- package/dist/core/__tests__/cascade-close.test.js +12 -2
- package/dist/core/__tests__/child-death-wake.test.d.ts +1 -0
- package/dist/core/__tests__/child-death-wake.test.js +245 -0
- package/dist/core/__tests__/context-intro.test.js +76 -62
- package/dist/core/__tests__/daemon-boot.test.js +14 -5
- package/dist/core/__tests__/daemon-liveness.test.js +34 -9
- package/dist/core/__tests__/detach-focus.test.d.ts +1 -0
- package/dist/core/__tests__/detach-focus.test.js +206 -0
- package/dist/core/__tests__/draw-style.test.d.ts +1 -0
- package/dist/core/__tests__/draw-style.test.js +258 -0
- package/dist/core/__tests__/fixtures/c3-custom-provider-ext.d.ts +2 -0
- package/dist/core/__tests__/fixtures/c3-custom-provider-ext.js +18 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +138 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +614 -0
- package/dist/core/__tests__/fixtures/fake-pi-host.js +1 -0
- package/dist/core/__tests__/flagship-lifecycle.test.js +7 -1
- package/dist/core/__tests__/frame-decoder-perf.test.d.ts +1 -0
- package/dist/core/__tests__/frame-decoder-perf.test.js +198 -0
- package/dist/core/__tests__/helpers/broker-clients.d.ts +43 -0
- package/dist/core/__tests__/helpers/broker-clients.js +178 -0
- package/dist/core/__tests__/helpers/harness.d.ts +9 -0
- package/dist/core/__tests__/helpers/harness.js +111 -1
- package/dist/core/__tests__/home-session.test.js +41 -8
- package/dist/core/__tests__/human-new-window-regression.test.d.ts +1 -0
- package/dist/core/__tests__/human-new-window-regression.test.js +101 -0
- package/dist/core/__tests__/kickoff.test.js +37 -3
- package/dist/core/__tests__/live-mutation-verbs.test.d.ts +1 -0
- package/dist/core/__tests__/live-mutation-verbs.test.js +175 -0
- package/dist/core/__tests__/live-mutation.test.js +6 -132
- package/dist/core/__tests__/memory.test.js +23 -115
- package/dist/core/__tests__/placement-focus.test.js +5 -0
- package/dist/core/__tests__/placement-teardown.test.js +54 -11
- package/dist/core/__tests__/relaunch.test.js +4 -3
- package/dist/core/__tests__/review-render-pane-regression.test.d.ts +1 -0
- package/dist/core/__tests__/review-render-pane-regression.test.js +133 -0
- package/dist/core/__tests__/spawn-root.test.js +10 -0
- package/dist/core/__tests__/spike-harness.test.js +1 -0
- package/dist/core/__tests__/wake-bearings.test.d.ts +1 -0
- package/dist/core/__tests__/wake-bearings.test.js +156 -0
- package/dist/core/__tests__/wake-origin.test.d.ts +1 -0
- package/dist/core/__tests__/wake-origin.test.js +110 -0
- package/dist/core/bootstrap.js +1 -1
- package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
- package/dist/core/canvas/browse/app.js +24 -2
- package/dist/core/canvas/browse/model.d.ts +38 -2
- package/dist/core/canvas/browse/model.js +134 -10
- package/dist/core/canvas/browse/render.d.ts +6 -12
- package/dist/core/canvas/browse/render.js +72 -104
- package/dist/core/canvas/canvas.js +16 -23
- package/dist/core/canvas/db.js +46 -0
- package/dist/core/canvas/index.d.ts +1 -0
- package/dist/core/canvas/index.js +1 -0
- package/dist/core/canvas/pid.d.ts +4 -0
- package/dist/core/canvas/pid.js +23 -0
- package/dist/core/canvas/render.d.ts +11 -2
- package/dist/core/canvas/render.js +69 -0
- package/dist/core/canvas/types.d.ts +91 -3
- package/dist/core/canvas/wakeups.d.ts +76 -0
- package/dist/core/canvas/wakeups.js +185 -0
- package/dist/core/config.js +4 -1
- package/dist/core/frontmatter.js +37 -124
- package/dist/core/memory-resolver.d.ts +49 -0
- package/dist/core/memory-resolver.js +141 -0
- package/dist/core/personas/loader.d.ts +7 -0
- package/dist/core/personas/loader.js +36 -5
- package/dist/core/personas/resolve.js +13 -3
- package/dist/core/predicate.d.ts +63 -0
- package/dist/core/predicate.js +189 -0
- package/dist/core/resolver.js +26 -5
- package/dist/core/runtime/bearings.d.ts +53 -12
- package/dist/core/runtime/bearings.js +132 -59
- package/dist/core/runtime/broker-cli.d.ts +1 -0
- package/dist/core/runtime/broker-cli.js +46 -0
- package/dist/core/runtime/broker-protocol.d.ts +332 -0
- package/dist/core/runtime/broker-protocol.js +153 -0
- package/dist/core/runtime/broker-sdk.d.ts +48 -0
- package/dist/core/runtime/broker-sdk.js +72 -0
- package/dist/core/runtime/broker.d.ts +55 -0
- package/dist/core/runtime/broker.js +1128 -0
- package/dist/core/runtime/close.js +35 -6
- package/dist/core/runtime/host.d.ts +53 -0
- package/dist/core/runtime/host.js +186 -0
- package/dist/core/runtime/kickoff.d.ts +2 -1
- package/dist/core/runtime/kickoff.js +91 -5
- package/dist/core/runtime/launch.d.ts +54 -4
- package/dist/core/runtime/launch.js +85 -5
- package/dist/core/runtime/lifecycle.js +23 -6
- package/dist/core/runtime/memory.d.ts +2 -42
- package/dist/core/runtime/memory.js +11 -162
- package/dist/core/runtime/nodes.d.ts +37 -0
- package/dist/core/runtime/nodes.js +60 -1
- package/dist/core/runtime/persona.js +21 -11
- package/dist/core/runtime/pi-vendored.d.ts +18 -0
- package/dist/core/runtime/pi-vendored.js +49 -0
- package/dist/core/runtime/placement.d.ts +32 -14
- package/dist/core/runtime/placement.js +206 -52
- package/dist/core/runtime/promote.d.ts +0 -6
- package/dist/core/runtime/promote.js +4 -12
- package/dist/core/runtime/{demote.d.ts → recycle.d.ts} +5 -5
- package/dist/core/runtime/{demote.js → recycle.js} +27 -11
- package/dist/core/runtime/reset.js +11 -8
- package/dist/core/runtime/revive.d.ts +2 -0
- package/dist/core/runtime/revive.js +34 -34
- package/dist/core/runtime/spawn.d.ts +23 -0
- package/dist/core/runtime/spawn.js +77 -23
- package/dist/core/runtime/stop-guard.d.ts +1 -1
- package/dist/core/runtime/stop-guard.js +6 -1
- package/dist/core/runtime/tmux-chrome.d.ts +1 -1
- package/dist/core/runtime/tmux-chrome.js +1 -1
- package/dist/core/runtime/tmux.d.ts +28 -0
- package/dist/core/runtime/tmux.js +80 -6
- package/dist/core/scope.d.ts +11 -0
- package/dist/core/scope.js +39 -0
- package/dist/core/spawn.d.ts +5 -0
- package/dist/core/spawn.js +20 -2
- package/dist/core/substrate/gate.d.ts +13 -0
- package/dist/core/substrate/gate.js +21 -0
- package/dist/core/substrate/index.d.ts +7 -0
- package/dist/core/substrate/index.js +18 -0
- package/dist/core/substrate/on-read.d.ts +14 -0
- package/dist/core/substrate/on-read.js +292 -0
- package/dist/core/substrate/render.d.ts +25 -0
- package/dist/core/substrate/render.js +256 -0
- package/dist/core/substrate/schema.d.ts +76 -0
- package/dist/core/substrate/schema.js +124 -0
- package/dist/core/substrate/session-cache.d.ts +30 -0
- package/dist/core/substrate/session-cache.js +77 -0
- package/dist/core/substrate/subject.d.ts +41 -0
- package/dist/core/substrate/subject.js +54 -0
- package/dist/core/tui/contract.d.ts +83 -0
- package/dist/core/tui/contract.js +8 -0
- package/dist/core/tui/draw.d.ts +96 -0
- package/dist/core/tui/draw.js +339 -0
- package/dist/core/tui/host.d.ts +29 -0
- package/dist/core/tui/host.js +379 -0
- package/dist/core/tui/loader.d.ts +16 -0
- package/dist/core/tui/loader.js +94 -0
- package/dist/core/{canvas/browse → tui}/terminal.js +7 -6
- package/dist/core/wake.d.ts +86 -0
- package/dist/core/wake.js +308 -0
- package/dist/daemon/crtrd.d.ts +29 -4
- package/dist/daemon/crtrd.js +662 -46
- package/dist/pi-extensions/__tests__/canvas-context-intro.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +171 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +19 -12
- package/dist/pi-extensions/canvas-commands.d.ts +3 -0
- package/dist/pi-extensions/canvas-commands.js +10 -0
- package/dist/pi-extensions/canvas-context-intro.d.ts +17 -0
- package/dist/pi-extensions/canvas-context-intro.js +55 -15
- package/dist/pi-extensions/canvas-doc-substrate.d.ts +44 -0
- package/dist/pi-extensions/canvas-doc-substrate.js +112 -0
- package/dist/pi-extensions/canvas-nav.d.ts +3 -0
- package/dist/pi-extensions/canvas-nav.js +87 -23
- package/dist/pi-extensions/canvas-stophook.js +17 -8
- package/dist/pi-extensions/canvas-view.d.ts +21 -0
- package/dist/pi-extensions/canvas-view.js +75 -0
- package/dist/prompts/skill.js +19 -26
- package/dist/prompts/view.d.ts +7 -0
- package/dist/prompts/view.js +101 -0
- package/dist/types.d.ts +4 -0
- package/dist/types.js +1 -0
- package/package.json +9 -4
- package/dist/commands/skill/find.d.ts +0 -4
- package/dist/commands/skill/find.js +0 -257
- package/dist/commands/skill/read.js +0 -91
- /package/dist/commands/{skill → memory}/read.d.ts +0 -0
- /package/dist/core/{canvas/browse → tui}/terminal.d.ts +0 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// `crtr view run <name>` — resolve, load, and host a view.
|
|
2
|
+
//
|
|
3
|
+
// Resolves the view across scopes (project→user→builtin), dynamically imports
|
|
4
|
+
// it, then hands it to the core/tui host. The host owns the screen and the
|
|
5
|
+
// non-TTY path: when stdin is NOT a TTY it prints view.dump(state) and exits 0,
|
|
6
|
+
// so `crtr view run <name> | cat` works anywhere. The interactive path is
|
|
7
|
+
// tmux-only (crtr is tmux-only) — outside tmux with a TTY we notify + no-op,
|
|
8
|
+
// never a non-tmux fallback. --port / --target forward verbatim onto
|
|
9
|
+
// host.options (camelCased, stringified); crtr does not interpret them.
|
|
10
|
+
//
|
|
11
|
+
// --window / --split open the view as a PERSISTENT MONITOR instead of taking
|
|
12
|
+
// over the current pane: --window in a new background (non-focus-stealing)
|
|
13
|
+
// tmux window, --split in a right-hand split of the current pane. Both shell a
|
|
14
|
+
// plain inner `crtr view run <name>` into the new pane, which hosts the view
|
|
15
|
+
// there and self-tags the pane with @crtr_view so the Alt+V then ]/[ view-cycle
|
|
16
|
+
// can flip it to the next/prev view in place. Both are tmux-only by nature; in
|
|
17
|
+
// a pipe (non-TTY) placement is meaningless, so they degrade to the static dump.
|
|
18
|
+
import { defineLeaf } from '../core/command.js';
|
|
19
|
+
import { InputError } from '../core/io.js';
|
|
20
|
+
import { runView } from '../core/tui/host.js';
|
|
21
|
+
import { resolveView, loadView, listViews } from '../core/tui/loader.js';
|
|
22
|
+
// Commands reach the tmux driver through placement.ts (the sanctioned
|
|
23
|
+
// model-over-driver seam, §5.1) — never `./tmux.js` directly.
|
|
24
|
+
import { inTmux, currentTmux, openNodeWindow, splitWindow, piCommand, setPaneOption, } from '../core/runtime/placement.js';
|
|
25
|
+
export const viewRunLeaf = defineLeaf({
|
|
26
|
+
name: 'run',
|
|
27
|
+
description: 'resolve, load, and host a view (in this pane, a new window, or a split)',
|
|
28
|
+
whenToUse: 'you want to OPEN a view by name — a full-screen raw-ANSI surface. By default it hosts in the current pane (tmux-only interactive; piped it prints a static snapshot). Pass --window to open it as a background monitor window, or --split to open it beside the current pane — both leave you where you are so a view becomes a live monitor you flip between with Alt+V then ]/[. Pass --port/--target to forward connection details. Use `crtr view list` first to see what is available, or `crtr view new` to scaffold one',
|
|
29
|
+
help: {
|
|
30
|
+
name: 'view run',
|
|
31
|
+
summary: 'host a view in the current pane, a new window (--window), or a split (--split); outside a TTY it prints the view\'s static dump and exits 0',
|
|
32
|
+
params: [
|
|
33
|
+
{ kind: 'positional', name: 'name', required: true, constraint: 'View id to run (its directory name). Resolves project→user→builtin.' },
|
|
34
|
+
{ kind: 'flag', name: 'window', type: 'bool', required: false, constraint: 'Open the view in a NEW background tmux window (non-focus-stealing) instead of the current pane — a persistent monitor. tmux-only; ignored (static dump) in a pipe. Mutually exclusive with --split.' },
|
|
35
|
+
{ kind: 'flag', name: 'split', type: 'bool', required: false, constraint: 'Open the view in a right-hand SPLIT of the current pane instead of taking it over — a side-by-side monitor. tmux-only; ignored (static dump) in a pipe. Mutually exclusive with --window.' },
|
|
36
|
+
{ kind: 'flag', name: 'port', type: 'int', required: false, constraint: 'Forwarded verbatim to the view as options.port (e.g. a CDP debugging port).' },
|
|
37
|
+
{ kind: 'flag', name: 'target', type: 'string', required: false, constraint: 'Forwarded verbatim to the view as options.target (e.g. a browser tab id).' },
|
|
38
|
+
],
|
|
39
|
+
output: [
|
|
40
|
+
{ name: 'hosted', type: 'string', required: false, constraint: 'For --window/--split: "window" or "split" — where the monitor opened. Absent for an in-pane host (which holds the terminal until you quit).' },
|
|
41
|
+
{ name: 'view', type: 'string', required: false, constraint: 'The view id opened (only on the --window/--split path).' },
|
|
42
|
+
{ name: 'window', type: 'string', required: false, constraint: 'The new tmux window id (--window only).' },
|
|
43
|
+
{ name: 'pane', type: 'string', required: false, constraint: 'The new tmux pane id (--window/--split).' },
|
|
44
|
+
],
|
|
45
|
+
outputKind: 'object',
|
|
46
|
+
effects: [
|
|
47
|
+
'Default (no flag) inside a TTY+tmux: takes over the current pane in raw mode (alt-screen) until you quit (q/Ctrl-C), and tags the pane @crtr_view=<name> so Alt+V then ]/[ can cycle it.',
|
|
48
|
+
'--window: opens a new background tmux window running the view (does not steal focus); returns immediately.',
|
|
49
|
+
'--split: opens a right-hand split of the current pane running the view; you stay in your pane.',
|
|
50
|
+
'Outside a TTY: writes the view\'s static dump to stdout and exits 0 (no raw mode); --window/--split degrade to this.',
|
|
51
|
+
'Forwards --port/--target onto host.options for the view; mutates nothing on the canvas.',
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
run: async (input) => {
|
|
55
|
+
const name = input['name'];
|
|
56
|
+
const port = input['port'];
|
|
57
|
+
const target = input['target'];
|
|
58
|
+
const asWindow = input['window'] === true;
|
|
59
|
+
const asSplit = input['split'] === true;
|
|
60
|
+
if (asWindow && asSplit) {
|
|
61
|
+
throw new InputError({
|
|
62
|
+
error: 'conflicting_flags',
|
|
63
|
+
message: '--window and --split are mutually exclusive',
|
|
64
|
+
next: 'Pass at most one: --window (new background window) or --split (split the current pane).',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const r = resolveView(name);
|
|
68
|
+
if (r === null) {
|
|
69
|
+
const avail = listViews().map((v) => v.id);
|
|
70
|
+
throw new InputError({
|
|
71
|
+
error: 'view_not_found',
|
|
72
|
+
message: `no view: ${name}`,
|
|
73
|
+
received: name,
|
|
74
|
+
next: avail.length > 0
|
|
75
|
+
? `Available views: ${avail.join(', ')}. Or scaffold one with \`crtr view new ${name}\`.`
|
|
76
|
+
: `No views found. Scaffold one with \`crtr view new ${name}\`.`,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
const options = {};
|
|
80
|
+
if (port !== undefined)
|
|
81
|
+
options.port = String(port);
|
|
82
|
+
if (target !== undefined)
|
|
83
|
+
options.target = target;
|
|
84
|
+
// --window / --split: open the view as a persistent monitor in a new pane.
|
|
85
|
+
if (asWindow || asSplit) {
|
|
86
|
+
// Placement is tmux-only. In a pipe (non-TTY) it is meaningless — degrade
|
|
87
|
+
// to the static dump (runView handles the non-TTY path internally).
|
|
88
|
+
if (!process.stdin.isTTY) {
|
|
89
|
+
const v = await loadView(r);
|
|
90
|
+
await runView(v, { options });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (!inTmux()) {
|
|
94
|
+
throw new InputError({
|
|
95
|
+
error: 'not_in_tmux',
|
|
96
|
+
message: '--window/--split are tmux-only — run crtr inside its tmux session',
|
|
97
|
+
next: `Open it from inside tmux, or drop the flag to host in the current pane (or pipe for a snapshot: \`crtr view run ${name} | cat\`).`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
const here = currentTmux();
|
|
101
|
+
if (here === null) {
|
|
102
|
+
throw new InputError({
|
|
103
|
+
error: 'no_tmux_location',
|
|
104
|
+
message: 'could not resolve the current tmux pane',
|
|
105
|
+
next: 'Run this inside a tmux pane.',
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
// The inner command re-enters this leaf with NO placement flag, so it
|
|
109
|
+
// hosts the view in its own (new) pane and self-tags it via @crtr_view.
|
|
110
|
+
const argv = ['view', 'run', name];
|
|
111
|
+
if (port !== undefined)
|
|
112
|
+
argv.push('--port', String(port));
|
|
113
|
+
if (target !== undefined)
|
|
114
|
+
argv.push('--target', target);
|
|
115
|
+
const command = piCommand(argv, 'crtr');
|
|
116
|
+
const cwd = process.cwd();
|
|
117
|
+
if (asWindow) {
|
|
118
|
+
const win = openNodeWindow({ session: here.session, name: `view:${name}`, cwd, env: {}, command });
|
|
119
|
+
if (win === null) {
|
|
120
|
+
throw new InputError({
|
|
121
|
+
error: 'window_open_failed',
|
|
122
|
+
message: `tmux could not open a window for view ${name}`,
|
|
123
|
+
next: 'Check the tmux server is reachable, then retry.',
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return { hosted: 'window', view: name, window: win.window, pane: win.pane };
|
|
127
|
+
}
|
|
128
|
+
// --split: a right-hand split (-h, the splitWindow default); -d keeps you put.
|
|
129
|
+
const pane = splitWindow(here.pane, { cwd, env: {}, command });
|
|
130
|
+
if (pane === null) {
|
|
131
|
+
throw new InputError({
|
|
132
|
+
error: 'split_failed',
|
|
133
|
+
message: `tmux could not split the pane for view ${name}`,
|
|
134
|
+
next: 'Check the tmux server is reachable, then retry.',
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return { hosted: 'split', view: name, pane };
|
|
138
|
+
}
|
|
139
|
+
const v = await loadView(r);
|
|
140
|
+
// The interactive path is tmux-only; the piped/non-TTY dump path works
|
|
141
|
+
// anywhere (runView handles it internally), so only guard when stdin is a
|
|
142
|
+
// TTY. Outside tmux with a TTY: notify + no-op — never a non-tmux fallback.
|
|
143
|
+
if (process.stdin.isTTY && !inTmux()) {
|
|
144
|
+
throw new InputError({
|
|
145
|
+
error: 'not_in_tmux',
|
|
146
|
+
message: 'crtr view is tmux-only — run it inside the crtr tmux session',
|
|
147
|
+
next: `Open it from inside tmux (e.g. the /view popup), or pipe for a static snapshot: \`crtr view run ${name} | cat\`.`,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
// Self-tag this pane with the view id (+ any forwarded --port/--target) so
|
|
151
|
+
// the Alt+V then ]/[ view-cycle can switch it to the next/prev view in place
|
|
152
|
+
// AND replay the connection params it was opened with. Best-effort; tmux-only.
|
|
153
|
+
const tagPane = (process.stdin.isTTY && inTmux())
|
|
154
|
+
? (process.env['TMUX_PANE'] ?? currentTmux()?.pane)
|
|
155
|
+
: undefined;
|
|
156
|
+
if (tagPane !== undefined && tagPane !== '') {
|
|
157
|
+
try {
|
|
158
|
+
setPaneOption(tagPane, '@crtr_view', name);
|
|
159
|
+
setPaneOption(tagPane, '@crtr_view_port', port !== undefined ? String(port) : '');
|
|
160
|
+
setPaneOption(tagPane, '@crtr_view_target', target ?? '');
|
|
161
|
+
}
|
|
162
|
+
catch { /* best-effort */ }
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
await runView(v, { options });
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
// On a clean quit (q) runView returns to the shell/pi that launched this
|
|
169
|
+
// pane — clear the monitor tag so a stray Alt+V ]/[ can't respawn-pane -k
|
|
170
|
+
// (and kill) whatever now owns the pane. When the cycle itself replaces
|
|
171
|
+
// this view it SIGKILLs us mid-await (this finally never runs); cycle + the
|
|
172
|
+
// new view-run re-tag, so that path stays correct.
|
|
173
|
+
if (tagPane !== undefined && tagPane !== '') {
|
|
174
|
+
try {
|
|
175
|
+
setPaneOption(tagPane, '@crtr_view', '');
|
|
176
|
+
}
|
|
177
|
+
catch { /* best-effort */ }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
render: (result) => {
|
|
182
|
+
const hosted = result['hosted'];
|
|
183
|
+
if (hosted === 'window') {
|
|
184
|
+
return `<view-opened mode="window" view="${result['view']}" window="${result['window']}" pane="${result['pane']}"/>`;
|
|
185
|
+
}
|
|
186
|
+
if (hosted === 'split') {
|
|
187
|
+
return `<view-opened mode="split" view="${result['view']}" pane="${result['pane']}"/>`;
|
|
188
|
+
}
|
|
189
|
+
return '';
|
|
190
|
+
},
|
|
191
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// `crtr view` — the TUI view library.
|
|
2
|
+
//
|
|
3
|
+
// Views are switchable raw-ANSI terminal surfaces, each a self-contained .mjs
|
|
4
|
+
// the host loads and runs. This branch assembles the leaves: `list` enumerates
|
|
5
|
+
// what's available, `run` hosts one (interactive in tmux, static dump when
|
|
6
|
+
// piped), `new` scaffolds a fresh view.mjs, and the hidden `pick` backs the
|
|
7
|
+
// /view popup. Each leaf owns its own help one level down.
|
|
8
|
+
import { defineBranch } from '../core/command.js';
|
|
9
|
+
import { viewListLeaf } from './view-list.js';
|
|
10
|
+
import { viewRunLeaf } from './view-run.js';
|
|
11
|
+
import { viewCycleLeaf } from './view-cycle.js';
|
|
12
|
+
import { viewNewLeaf } from './view-new.js';
|
|
13
|
+
import { viewPickLeaf } from './view-pick.js';
|
|
14
|
+
export function registerView() {
|
|
15
|
+
return defineBranch({
|
|
16
|
+
name: 'view',
|
|
17
|
+
rootEntry: {
|
|
18
|
+
concept: 'switchable raw-ANSI terminal views — full-screen surfaces hosted in a pane, each a self-contained module',
|
|
19
|
+
desc: 'list, run, and author TUI views',
|
|
20
|
+
useWhen: 'you want a live full-screen surface (a monitor, an inbox, a dashboard) rather than one-shot command output — run a view by name, list what exists, or scaffold a new one. Views have no pi session; they render + take keystrokes until you quit.',
|
|
21
|
+
},
|
|
22
|
+
help: {
|
|
23
|
+
name: 'view',
|
|
24
|
+
summary: 'host and author switchable raw-ANSI terminal views',
|
|
25
|
+
model: '`list` when you do not know which views exist — a flat roster (id/title/description/scope) across project→user→builtin. `run <name>` opens one full-screen in the current pane (tmux-only interactive; piped it prints the view\'s static dump and exits 0), forwarding --port/--target onto the view; pass --window/--split to open it as a persistent monitor (new window / split) you flip between with Alt+V then ]/[. `cycle` switches a monitor pane to the next/prev view in place (what those keys drive). `new <name>` scaffolds a runnable view.mjs stub you edit. `pick` is a hidden raw-ANSI picker the /view popup shells. Append `-h` at any leaf for its schema.',
|
|
26
|
+
},
|
|
27
|
+
children: [viewListLeaf, viewRunLeaf, viewCycleLeaf, viewNewLeaf, viewPickLeaf],
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/broker-attach-limits.test.ts
|
|
2
|
+
//
|
|
3
|
+
// T8 — the `crtr attach` acceptance gate, gates G4/G7/G8/G9 (controller
|
|
4
|
+
// arbitration, decoder overflow caps, backpressure shedding, and the load-bearing
|
|
5
|
+
// ONE-WRITER proof). Split out of broker-lifecycle.test.ts (see its header for
|
|
6
|
+
// the full file map); the tests are the original acceptance gate, unchanged, on
|
|
7
|
+
// their own isolated harness. Each test drives the REAL detached broker process +
|
|
8
|
+
// REAL view.sock with the enriched fake engine, using the PRODUCTION
|
|
9
|
+
// ViewSocketClient (helpers/broker-clients.ts) as the controller/observer — raw
|
|
10
|
+
// node:net only where the client lifecycle is awkward (G7 oversized line, G8
|
|
11
|
+
// stalled viewer). The engine is hosted IN-PROCESS by the broker, so engine pid
|
|
12
|
+
// == broker pid == node.pi_pid == boot.pid.
|
|
13
|
+
import { test, before, after, afterEach } from 'node:test';
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { createHarness, hasTmux } from './helpers/harness.js';
|
|
17
|
+
import { isPidAlive } from '../canvas/pid.js';
|
|
18
|
+
import { createAttachKit, delay, tok, frameHas, brokerLogText, lsofHolders, } from './helpers/broker-clients.js';
|
|
19
|
+
let h;
|
|
20
|
+
let root;
|
|
21
|
+
const kit = createAttachKit(() => h);
|
|
22
|
+
const { attach, attachUntil, connectRaw } = kit;
|
|
23
|
+
before(async () => {
|
|
24
|
+
if (!hasTmux())
|
|
25
|
+
return;
|
|
26
|
+
h = await createHarness({ sessionPrefix: 'crtr-brklim' });
|
|
27
|
+
root = h.spawnRoot('broker-attach-limits suite root');
|
|
28
|
+
});
|
|
29
|
+
after(async () => {
|
|
30
|
+
if (h !== undefined)
|
|
31
|
+
await h.dispose();
|
|
32
|
+
});
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
kit.closeAll();
|
|
35
|
+
});
|
|
36
|
+
const brokerPid = (id) => h.node(id).pi_pid;
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// G4 — arbitration + observer read. Guards: 2nd client is admitted observer, an
|
|
39
|
+
// observer prompt is rejected not_controller, BOTH clients receive the relay.
|
|
40
|
+
// Failure mode: two controllers, or an observer driving the engine, or fan-out
|
|
41
|
+
// that misses a viewer.
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
test('G4 — second client is observer; observer prompt → error{not_controller}; both receive the stream', { skip: !hasTmux() }, async () => {
|
|
44
|
+
const id = await h.spawnHeadlessChild(root, 'headless worker — G4');
|
|
45
|
+
const c1 = await attach(id, 'controller', 'g4-ctrl');
|
|
46
|
+
assert.equal(c1.welcome.role, 'controller', 'first client holds control');
|
|
47
|
+
const c2 = await attach(id, 'controller', 'g4-second'); // requests control; held → observer
|
|
48
|
+
assert.equal(c2.welcome.role, 'observer', 'second client is admitted read-only observer (first-attach-wins)');
|
|
49
|
+
c2.send({ type: 'prompt', text: 'observer must not drive' });
|
|
50
|
+
const err = await c2.waitFrame((f) => f.type === 'error', 'G4 observer prompt rejected');
|
|
51
|
+
assert.equal(err.code, 'not_controller', 'G4: observer prompt → error{not_controller}');
|
|
52
|
+
const token = tok('G4-BROADCAST');
|
|
53
|
+
c1.send({ type: 'prompt', text: token });
|
|
54
|
+
await c1.waitFrame((f) => f.type === 'agent_end' && frameHas(f, token), 'G4 controller received the stream');
|
|
55
|
+
await c2.waitFrame((f) => f.type === 'agent_end' && frameHas(f, token), 'G4 observer ALSO received the stream');
|
|
56
|
+
});
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// G7 — decoder overflow (guards C5 OOM). A client line over BROKER_READ_CAPS is
|
|
59
|
+
// cap-and-dropped; the broker survives and other clients are unaffected. Failure
|
|
60
|
+
// mode: an unbounded decoder buffer growing the broker to OOM.
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
test('G7 — an oversized client line is dropped (frame_overflow), the broker survives, other clients unaffected', { skip: !hasTmux() }, async () => {
|
|
63
|
+
const id = await h.spawnHeadlessChild(root, 'headless worker — G7');
|
|
64
|
+
const pid = brokerPid(id);
|
|
65
|
+
const boots = h.bootCount(id);
|
|
66
|
+
const survivor = await attach(id, 'observer', 'g7-survivor');
|
|
67
|
+
// 26 MiB with NO newline > BROKER_READ_CAPS.maxLineBytes (24 MiB) → the bounded
|
|
68
|
+
// FrameDecoder throws FrameOverflowError; the broker drops the peer.
|
|
69
|
+
const bad = await connectRaw(id, { read: true });
|
|
70
|
+
bad.writeRaw(Buffer.alloc(26 * 1024 * 1024, 0x78));
|
|
71
|
+
await bad.waitClosed('G7 oversized peer dropped by the broker');
|
|
72
|
+
await h.waitFor(() => /frame overflow/.test(brokerLogText(h, id)) || null, { label: 'G7 broker logged the frame-overflow drop' });
|
|
73
|
+
// The broker survives and the other client is unaffected: a fresh turn relays.
|
|
74
|
+
const token = tok('G7-AFTER');
|
|
75
|
+
h.fakeCmd(id, { cmd: 'stream', text: token });
|
|
76
|
+
await survivor.waitFrame((f) => f.type === 'agent_end' && frameHas(f, token), 'G7 survivor still receives live frames');
|
|
77
|
+
assert.equal(isPidAlive(pid), true, 'G7: the broker survived the overflow');
|
|
78
|
+
assert.equal(brokerPid(id), pid, 'G7: broker pid unchanged');
|
|
79
|
+
assert.equal(h.bootCount(id), boots, 'G7: no reboot');
|
|
80
|
+
});
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// G8 — backpressure leak (guards M1). A stalled (non-reading) viewer is shed at
|
|
83
|
+
// the HWM (32 MiB byte cap) while the broker + other viewers are unaffected.
|
|
84
|
+
// Failure mode: an indefinitely-growing per-viewer queue (broker OOM) or a slow
|
|
85
|
+
// viewer back-pressuring the shared engine.
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
test('G8 — a stalled viewer is dropped at the backpressure HWM; the broker + fast viewers are unaffected', { skip: !hasTmux() }, async () => {
|
|
88
|
+
const id = await h.spawnHeadlessChild(root, 'headless worker — G8');
|
|
89
|
+
const pid = brokerPid(id);
|
|
90
|
+
const fast = await attach(id, 'observer', 'g8-fast'); // reads normally
|
|
91
|
+
// A helloed viewer that NEVER reads (paused socket). It must be in the broadcast
|
|
92
|
+
// set before the flood; hello, then a short beat for the broker to process it.
|
|
93
|
+
const stalled = await connectRaw(id, { read: false });
|
|
94
|
+
stalled.send({ type: 'hello', role: 'observer', client_id: 'g8-stalled' });
|
|
95
|
+
await delay(400);
|
|
96
|
+
// A fast event stream: ~60 MiB across 240 message_update frames. The stalled
|
|
97
|
+
// viewer's per-viewer backlog crosses the 32 MiB byte cap → dropped; the fast
|
|
98
|
+
// viewer drains between frames (per-update yield) and survives.
|
|
99
|
+
//
|
|
100
|
+
// The DROP SIGNAL is the broker's own log line, NOT the stalled socket's 'close':
|
|
101
|
+
// a PAUSED node socket does not surface the peer FIN/close until it is resumed,
|
|
102
|
+
// so the broker-side `backpressure high-water mark exceeded` line is the
|
|
103
|
+
// deterministic, race-free proof that the slow viewer was shed at the HWM.
|
|
104
|
+
const token = tok('G8-FLOOD');
|
|
105
|
+
h.fakeCmd(id, { cmd: 'stream', text: token, updates: 240, padBytes: 256 * 1024, tool: false });
|
|
106
|
+
await h.waitFor(() => /backpressure high-water mark exceeded/.test(brokerLogText(h, id)) || null, { label: 'G8 broker shed the stalled viewer at the HWM', timeoutMs: 30_000 });
|
|
107
|
+
// The fast viewer drained the WHOLE stream (agent_end is the terminal frame) —
|
|
108
|
+
// proving only the stalled viewer was shed, while the fast one was unaffected.
|
|
109
|
+
await fast.waitFrame((f) => f.type === 'agent_end' && frameHas(f, token), 'G8 fast viewer received the whole stream', 30_000);
|
|
110
|
+
assert.equal(isPidAlive(pid), true, 'G8: the broker survived the slow-viewer flood');
|
|
111
|
+
assert.equal(brokerPid(id), pid, 'G8: broker pid unchanged (the engine was not back-pressured into a restart)');
|
|
112
|
+
});
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// G9 — the load-bearing ONE-WRITER assertion. Across attach→detach→reattach the
|
|
115
|
+
// broker pid AND engine pid (the same, in-process) are UNCHANGED and no second
|
|
116
|
+
// engine is ever spawned; the viewer holds ONLY the socket (never the .jsonl).
|
|
117
|
+
// Failure mode: a viewer that spawns/forks a second engine or opens the session
|
|
118
|
+
// .jsonl — the corruption the headless design exists to prevent.
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
test('G9 — one-writer: broker/engine pid stable across attach→detach→reattach; no second engine; viewer holds only the socket', { skip: !hasTmux() }, async () => {
|
|
121
|
+
const id = await h.spawnHeadlessChild(root, 'headless worker — G9');
|
|
122
|
+
const pid0 = brokerPid(id);
|
|
123
|
+
const boots0 = h.bootCount(id);
|
|
124
|
+
const c1 = await attach(id, 'controller', 'g9-a');
|
|
125
|
+
const a = tok('G9-A');
|
|
126
|
+
c1.send({ type: 'prompt', text: a });
|
|
127
|
+
await c1.waitFrame((f) => f.type === 'agent_end' && frameHas(f, a), 'G9 first turn relayed');
|
|
128
|
+
c1.send({ type: 'bye' });
|
|
129
|
+
c1.close();
|
|
130
|
+
await delay(300);
|
|
131
|
+
assert.equal(brokerPid(id), pid0, 'G9: broker/engine pid unchanged after detach');
|
|
132
|
+
assert.equal(isPidAlive(pid0), true, 'G9: the one engine is still alive after detach');
|
|
133
|
+
assert.equal(h.bootCount(id), boots0, 'G9: no second engine spawned across detach');
|
|
134
|
+
// attachUntil (not a fixed sleep) synchronizes on the controller handoff: the
|
|
135
|
+
// detach's controllerId=null lands a beat after close, so retry until the
|
|
136
|
+
// reattach is admitted controller before driving the second prompt.
|
|
137
|
+
const c2 = await attachUntil(id, 'controller', 'g9-b', (x) => x.welcome.role === 'controller', 'G9 reattach re-takes control');
|
|
138
|
+
assert.equal(c2.welcome.role, 'controller', 'G9: the reattached client drives the SAME engine as controller');
|
|
139
|
+
const b = tok('G9-B');
|
|
140
|
+
c2.send({ type: 'prompt', text: b });
|
|
141
|
+
await c2.waitFrame((f) => f.type === 'agent_end' && frameHas(f, b), 'G9 reattached controller drives the SAME engine');
|
|
142
|
+
assert.equal(brokerPid(id), pid0, 'G9: STILL the same broker/engine pid after reattach (one writer, never two)');
|
|
143
|
+
assert.equal(isPidAlive(pid0), true, 'G9: the single engine is alive across the full cycle');
|
|
144
|
+
assert.equal(h.bootCount(id), boots0, 'G9: exactly one engine boot across attach→detach→reattach');
|
|
145
|
+
// fd-check (best-effort): the viewer host (this test process) must NOT hold the
|
|
146
|
+
// session .jsonl — only the broker may. With the fake engine the .jsonl is not
|
|
147
|
+
// held open continuously, so the meaningful assertion is that the VIEWER never
|
|
148
|
+
// appears among its holders (and any holder that exists is the broker).
|
|
149
|
+
const jsonl = join(h.home, 'nodes', id, 'fake-session.jsonl');
|
|
150
|
+
const holders = lsofHolders(jsonl);
|
|
151
|
+
if (holders !== null) {
|
|
152
|
+
assert.ok(!holders.includes(process.pid), 'G9: the attach client (this process) does NOT hold the session .jsonl');
|
|
153
|
+
for (const holder of holders) {
|
|
154
|
+
assert.equal(holder, pid0, 'G9: the ONLY holder of the session .jsonl is the broker/engine');
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/broker-attach-stream.test.ts
|
|
2
|
+
//
|
|
3
|
+
// T8 — the `crtr attach` acceptance gate, gates G1/G1b/G2/G3 (controller drive,
|
|
4
|
+
// relay/coalescing, detach survival, catch-up snapshot). Split out of
|
|
5
|
+
// broker-lifecycle.test.ts (see its header for the full file map); the tests are
|
|
6
|
+
// the original acceptance gate, unchanged, on their own isolated harness. Each
|
|
7
|
+
// test drives the REAL detached broker process + REAL view.sock with the enriched
|
|
8
|
+
// fake engine, using the PRODUCTION ViewSocketClient (helpers/broker-clients.ts)
|
|
9
|
+
// as the controller/observer. The engine is hosted IN-PROCESS by the broker, so
|
|
10
|
+
// engine pid == broker pid == node.pi_pid == boot.pid; "engine pid unchanged" ==
|
|
11
|
+
// broker pid unchanged + no new boot. Each test's lead comment names its gate #
|
|
12
|
+
// and the failure mode it guards.
|
|
13
|
+
import { test, before, after, afterEach } from 'node:test';
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import { createHarness, hasTmux } from './helpers/harness.js';
|
|
16
|
+
import { isPidAlive } from '../canvas/pid.js';
|
|
17
|
+
import { createAttachKit, delay, tok, frameHas } from './helpers/broker-clients.js';
|
|
18
|
+
let h;
|
|
19
|
+
let root;
|
|
20
|
+
const kit = createAttachKit(() => h);
|
|
21
|
+
const { attach, attachUntil } = kit;
|
|
22
|
+
before(async () => {
|
|
23
|
+
if (!hasTmux())
|
|
24
|
+
return;
|
|
25
|
+
h = await createHarness({ sessionPrefix: 'crtr-brkstrm' });
|
|
26
|
+
root = h.spawnRoot('broker-attach-stream suite root');
|
|
27
|
+
});
|
|
28
|
+
after(async () => {
|
|
29
|
+
if (h !== undefined)
|
|
30
|
+
await h.dispose();
|
|
31
|
+
});
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
kit.closeAll();
|
|
34
|
+
});
|
|
35
|
+
const brokerPid = (id) => h.node(id).pi_pid;
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// G1 — controller drive + live relay. Guards: a controller's `prompt` runs the
|
|
38
|
+
// engine AND the streaming AgentSessionEvents are fanned out to the client
|
|
39
|
+
// VERBATIM. Failure mode: a broken relay/fan-out (viewer sees nothing).
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
test('G1 — controller prompt runs the engine and the streamed AgentSessionEvents relay to the client', { skip: !hasTmux() }, async () => {
|
|
42
|
+
const id = await h.spawnHeadlessChild(root, 'headless worker — G1');
|
|
43
|
+
const c = await attach(id, 'controller', 'g1-ctrl');
|
|
44
|
+
assert.equal(c.welcome.role, 'controller', 'first controller is admitted as controller');
|
|
45
|
+
const token = tok('G1-PROMPT');
|
|
46
|
+
c.send({ type: 'prompt', text: token });
|
|
47
|
+
// The full streaming turn relays: agent_end carrying the token proves the turn
|
|
48
|
+
// ran AND its terminal frame reached the client; the intermediate types prove
|
|
49
|
+
// the stream (not just a final blob) was fanned out.
|
|
50
|
+
await c.waitFrame((f) => f.type === 'agent_end' && frameHas(f, token), 'G1 agent_end carrying the prompt token');
|
|
51
|
+
for (const t of ['message_start', 'message_update', 'tool_execution_start', 'tool_execution_end', 'message_end', 'turn_end', 'agent_start', 'turn_start']) {
|
|
52
|
+
assert.ok(c.frames.some((f) => f.type === t), `G1: client received a relayed ${t} frame`);
|
|
53
|
+
}
|
|
54
|
+
assert.ok(c.frames.some((f) => f.type === 'message_start' && frameHas(f, token)), 'G1: a relayed message carries the prompt token');
|
|
55
|
+
});
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// G1b — message_update coalescing preserves ordering (regression for the F2
|
|
58
|
+
// attach typing-lag fix, 2026-06-09). The broker holds the latest message_update
|
|
59
|
+
// on a ~75ms timer; any OTHER event must flush it FIRST, so a viewer can never
|
|
60
|
+
// observe message_update AFTER its message_end (which would resurrect stale
|
|
61
|
+
// streaming text over the final message), and the LAST coalesced update must not
|
|
62
|
+
// be silently dropped at end-of-turn. Failure modes: a flush ordered after the
|
|
63
|
+
// non-update event, or a pending update discarded when message_end wins the race.
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
test('G1b — coalesced message_update never arrives after message_end; updates still relayed', { skip: !hasTmux() }, async () => {
|
|
66
|
+
const id = await h.spawnHeadlessChild(root, 'headless worker — G1b');
|
|
67
|
+
const c = await attach(id, 'controller', 'g1b-ctrl');
|
|
68
|
+
const token = tok('G1B');
|
|
69
|
+
// 12 updates at the fake engine's setImmediate pace — far faster than the 75ms
|
|
70
|
+
// coalesce window, so coalescing genuinely engages (fewer relayed than emitted).
|
|
71
|
+
h.fakeCmd(id, { cmd: 'stream', text: token, updates: 12 });
|
|
72
|
+
await c.waitFrame((f) => f.type === 'agent_end' && frameHas(f, token), 'G1b turn relayed');
|
|
73
|
+
const types = c.frames.map((f) => f.type);
|
|
74
|
+
const lastUpdate = types.lastIndexOf('message_update');
|
|
75
|
+
const msgEnd = types.indexOf('message_end');
|
|
76
|
+
assert.ok(lastUpdate >= 0, 'G1b: at least one message_update relayed (coalescing must not starve updates)');
|
|
77
|
+
assert.ok(msgEnd >= 0, 'G1b: message_end relayed');
|
|
78
|
+
assert.ok(lastUpdate < msgEnd, `G1b: a message_update arrived AFTER message_end (update@${lastUpdate} vs end@${msgEnd}) — the coalescer flushed out of order`);
|
|
79
|
+
// tool/turn/agent frames must also never precede a stale held update.
|
|
80
|
+
for (const t of ['tool_execution_start', 'turn_end', 'agent_end']) {
|
|
81
|
+
const i = types.indexOf(t);
|
|
82
|
+
assert.ok(i < 0 || lastUpdate < i, `G1b: message_update relayed after ${t}`);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// G2 — detach leaves the engine running. Guards: `bye`/close drops ONE listener,
|
|
87
|
+
// never the engine. Failure mode: a detach that disposes the broker/engine.
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
test('G2 — detach (bye+close) leaves the broker alive + the engine still emitting; engine pid unchanged', { skip: !hasTmux() }, async () => {
|
|
90
|
+
const id = await h.spawnHeadlessChild(root, 'headless worker — G2');
|
|
91
|
+
const pid = brokerPid(id);
|
|
92
|
+
const boots = h.bootCount(id);
|
|
93
|
+
const c = await attach(id, 'controller', 'g2-ctrl');
|
|
94
|
+
const p = tok('G2-PROMPT');
|
|
95
|
+
c.send({ type: 'prompt', text: p });
|
|
96
|
+
await c.waitFrame((f) => f.type === 'agent_end' && frameHas(f, p), 'G2 turn relayed before detach');
|
|
97
|
+
c.send({ type: 'bye' });
|
|
98
|
+
c.close();
|
|
99
|
+
await delay(300); // let the broker process the 'close'
|
|
100
|
+
assert.equal(isPidAlive(pid), true, 'G2: broker still alive after detach');
|
|
101
|
+
assert.equal(brokerPid(id), pid, 'G2: engine (== broker) pid UNCHANGED across detach');
|
|
102
|
+
assert.equal(h.bootCount(id), boots, 'G2: no reboot — no second engine spawned');
|
|
103
|
+
// Still emitting: drive a turn with no client, then a fresh observer's snapshot
|
|
104
|
+
// carries it (the engine kept running and processing after the detach).
|
|
105
|
+
const after = tok('G2-AFTER-DETACH');
|
|
106
|
+
h.fakeCmd(id, { cmd: 'stream', text: after });
|
|
107
|
+
const o = await attachUntil(id, 'observer', 'g2-obs', (a) => JSON.stringify(a.welcome.snapshot.messages).includes(after), 'G2 post-detach message in snapshot');
|
|
108
|
+
assert.ok(o.welcome.snapshot.messages.length > 0, 'G2: the engine produced a message after the detach');
|
|
109
|
+
assert.equal(isPidAlive(pid), true, 'G2: broker still alive after the post-detach turn');
|
|
110
|
+
});
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// G3 — catch-up snapshot. Guards: messages produced with NO viewer attached are
|
|
113
|
+
// in welcome.snapshot on reattach, and live events resume. Failure mode: a viewer
|
|
114
|
+
// that reattaches blind to history / gets no further events.
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
test('G3 — messages produced while detached appear in welcome.snapshot on reattach; live events resume', { skip: !hasTmux() }, async () => {
|
|
117
|
+
const id = await h.spawnHeadlessChild(root, 'headless worker — G3');
|
|
118
|
+
const past = tok('G3-DETACHED');
|
|
119
|
+
h.fakeCmd(id, { cmd: 'stream', text: past }); // produced with zero viewers attached
|
|
120
|
+
const o = await attachUntil(id, 'observer', 'g3-obs', (a) => JSON.stringify(a.welcome.snapshot.messages).includes(past), 'G3 detached message in snapshot');
|
|
121
|
+
assert.ok(JSON.stringify(o.welcome.snapshot.messages).includes(past), 'G3: welcome.snapshot.messages contains the message produced while detached');
|
|
122
|
+
const live = tok('G3-LIVE');
|
|
123
|
+
h.fakeCmd(id, { cmd: 'stream', text: live });
|
|
124
|
+
await o.waitFrame((f) => f.type === 'agent_end' && frameHas(f, live), 'G3 live events resume after reattach');
|
|
125
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/broker-crash-teardown.test.ts
|
|
2
|
+
//
|
|
3
|
+
// Broker crash / teardown / boot-failure — acceptance items 4–6 of the headless-
|
|
4
|
+
// broker migration plus the M-1/M-2 boot-failure regression. Split out of
|
|
5
|
+
// broker-lifecycle.test.ts (see its header for the full file map); the tests are
|
|
6
|
+
// the original acceptance gate, unchanged, on their own isolated harness:
|
|
7
|
+
// 4 survive a broker crash via grace-revive RESUME on the saved .jsonl → "CRASH"
|
|
8
|
+
// 5 tear down cleanly — close → shutdown frame → exit, socket unlinked → "clean teardown"
|
|
9
|
+
// 6 ONE-WRITER (R2) — never two engine pids alive across crash→grace-revive → "CRASH"
|
|
10
|
+
// M-1/M-2 a broker that dies before session_start is reaped, not stranded → "boot failure"
|
|
11
|
+
import { test, before, after } from 'node:test';
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { createHarness, hasTmux } from './helpers/harness.js';
|
|
16
|
+
import { isPidAlive } from '../canvas/pid.js';
|
|
17
|
+
import { FAIL_BEFORE_SESSION_START } from './fixtures/fake-engine.js';
|
|
18
|
+
// crtrd.ts module const (not exported). The fresh-pi-boot grace window the daemon
|
|
19
|
+
// waits before grace-reviving a pi observed dead. Reference: crtrd.ts
|
|
20
|
+
// `REVIVE_GRACE_MS = 20_000`.
|
|
21
|
+
const REVIVE_GRACE_MS = 20_000;
|
|
22
|
+
let h;
|
|
23
|
+
let root;
|
|
24
|
+
before(async () => {
|
|
25
|
+
if (!hasTmux())
|
|
26
|
+
return;
|
|
27
|
+
h = await createHarness({ sessionPrefix: 'crtr-brkcrash' });
|
|
28
|
+
root = h.spawnRoot('broker-crash suite root');
|
|
29
|
+
});
|
|
30
|
+
after(async () => {
|
|
31
|
+
if (h !== undefined)
|
|
32
|
+
await h.dispose();
|
|
33
|
+
});
|
|
34
|
+
// ===========================================================================
|
|
35
|
+
// Item 4 + 6 — broker CRASH → grace-revive RESUME on the saved .jsonl; ONE-
|
|
36
|
+
// WRITER: the crashed pid is dead BEFORE the revive launches and never
|
|
37
|
+
// resurrects, and the revived pid is distinct (never two engine pids at once).
|
|
38
|
+
// ===========================================================================
|
|
39
|
+
test('CRASH → grace-revive RESUME; one-writer (old pid dead before new, never resurrects)', { skip: !hasTmux() }, async () => {
|
|
40
|
+
const id = await h.spawnHeadlessChild(root, 'headless worker — crash');
|
|
41
|
+
const oldPid = h.node(id).pi_pid;
|
|
42
|
+
assert.equal(isPidAlive(oldPid), true, 'broker alive before the crash');
|
|
43
|
+
assert.equal(h.node(id).intent ?? null, null, 'fresh broker has a null intent (not refresh/idle-release)');
|
|
44
|
+
const boots = h.bootCount(id);
|
|
45
|
+
// Kill the broker out from under the daemon (a crash). Use a fixed clock so the
|
|
46
|
+
// grace window is exercised deterministically.
|
|
47
|
+
process.kill(oldPid, 'SIGKILL');
|
|
48
|
+
await h.waitFor(() => !isPidAlive(oldPid), { label: 'crashed broker pid is dead' });
|
|
49
|
+
// ONE-WRITER: the old engine pid is dead BEFORE any revive can launch.
|
|
50
|
+
assert.equal(isPidAlive(oldPid), false, 'crashed pid dead before the daemon revives');
|
|
51
|
+
const NOW = 5_000_000;
|
|
52
|
+
await h.tick(NOW); // pid dead, intent null → handleBrokerLiveness → handleLiveWindow marks pending
|
|
53
|
+
assert.equal(h.bootCount(id), boots, 'inside the grace window → NOT yet revived');
|
|
54
|
+
await h.tick(NOW + REVIVE_GRACE_MS + 1); // grace elapsed → reviveNode(resume:true)
|
|
55
|
+
const boot2 = await h.awaitBoot(id, { minCount: boots + 1 });
|
|
56
|
+
assert.equal(boot2.resuming, true, 'grace-revive RESUMES the saved .jsonl');
|
|
57
|
+
const newPid = h.node(id).pi_pid;
|
|
58
|
+
assert.ok(newPid != null && isPidAlive(newPid), 'the revived broker pid is alive');
|
|
59
|
+
assert.notEqual(newPid, oldPid, 'the revived pid is distinct from the crashed one');
|
|
60
|
+
assert.equal(isPidAlive(oldPid), false, 'one-writer: the crashed pid never resurrected');
|
|
61
|
+
});
|
|
62
|
+
// ===========================================================================
|
|
63
|
+
// Item 5 — clean teardown: close → hostFor(meta).teardown → `shutdown` frame →
|
|
64
|
+
// broker disposes + exits, socket unlinked, status canceled, daemon leaves it.
|
|
65
|
+
// ===========================================================================
|
|
66
|
+
test('clean teardown — node close → broker shutdown frame → exit, socket unlinked, not revived', { skip: !hasTmux() }, async () => {
|
|
67
|
+
const id = await h.spawnHeadlessChild(root, 'headless worker — teardown');
|
|
68
|
+
const pid = h.node(id).pi_pid;
|
|
69
|
+
const sock = h.brokerSock(id);
|
|
70
|
+
await h.waitFor(() => existsSync(sock), { label: 'broker created its view.sock' });
|
|
71
|
+
// Close via the REAL CLI (resolves the node from --node; the subprocess carries
|
|
72
|
+
// no TMUX_PANE). closeNode marks canceled BEFORE teardown (crash-safe order),
|
|
73
|
+
// then hostFor(broker).teardown sends the `shutdown` frame.
|
|
74
|
+
const res = h.cli(root, ['node', 'close', '--node', id]);
|
|
75
|
+
assert.equal(res.code, 0, `node close should exit 0\n--stderr--\n${res.stderr}`);
|
|
76
|
+
await h.waitFor(() => !isPidAlive(pid), { label: 'broker process exited on shutdown frame' });
|
|
77
|
+
await h.waitFor(() => !existsSync(sock), { label: 'broker unlinked its socket on exit' });
|
|
78
|
+
assert.equal(h.status(id), 'canceled', 'the closed node is canceled');
|
|
79
|
+
// listNodes only surfaces active|idle, so a canceled broker is never supervised.
|
|
80
|
+
await h.tick();
|
|
81
|
+
assert.equal(h.status(id), 'canceled', 'still canceled after a daemon tick');
|
|
82
|
+
assert.equal(isPidAlive(pid), false, 'the broker stays dead — never revived');
|
|
83
|
+
});
|
|
84
|
+
// ===========================================================================
|
|
85
|
+
// M-1 regression — a broker that DIES before session_start must surface a boot
|
|
86
|
+
// failure, not strand the node forever. Before the fix handleBrokerLiveness read
|
|
87
|
+
// pid==null UNCONDITIONALLY as "still booting", so a broker that threw before
|
|
88
|
+
// recording a pid/session left the node 'active' with no engine FOREVER and the
|
|
89
|
+
// parent waited on a dead child. The daemon must instead crash it past the boot
|
|
90
|
+
// grace + push a boot-failure up the spine. (Also proves M-2: the broker's fatal
|
|
91
|
+
// diagnostic reaches job/broker.log instead of /dev/null.)
|
|
92
|
+
// ===========================================================================
|
|
93
|
+
test('boot failure — a broker that dies before session_start is reaped, not stranded (review M-1/M-2)', { skip: !hasTmux() }, async () => {
|
|
94
|
+
// A headless child whose broker throws before session_start (no pid, no session
|
|
95
|
+
// EVER recorded). spawnHeadlessChildNoBoot does not awaitBoot (there is none).
|
|
96
|
+
const id = await h.spawnHeadlessChildNoBoot(root, `headless worker — boot fail ${FAIL_BEFORE_SESSION_START}`);
|
|
97
|
+
// M-2: the broker logs its fatal stack to job/broker.log and exits(1). Without
|
|
98
|
+
// the stdio redirect this diagnostic would be lost to /dev/null.
|
|
99
|
+
const brokerLog = join(h.home, 'nodes', id, 'job', 'broker.log');
|
|
100
|
+
await h.waitFor(() => existsSync(brokerLog) && /simulated pre-session_start boot failure/.test(readFileSync(brokerLog, 'utf8')), { label: 'broker logged its pre-session_start failure to job/broker.log' });
|
|
101
|
+
// The node is stuck 'active' with a null pid and a null session — the strand.
|
|
102
|
+
const n0 = h.node(id);
|
|
103
|
+
assert.equal(n0.pi_pid ?? null, null, 'no broker pid was ever recorded');
|
|
104
|
+
assert.equal(n0.pi_session_id ?? null, null, 'no session was ever recorded');
|
|
105
|
+
// Inside the boot grace the daemon LEAVES it (could be the sub-second boot gap).
|
|
106
|
+
const NOW = 9_000_000;
|
|
107
|
+
await h.tick(NOW);
|
|
108
|
+
assert.equal(h.status(id), 'active', 'inside the boot grace → daemon leaves it (boot gap)');
|
|
109
|
+
// Past the boot grace with STILL no pid and no session → crash + boot failure.
|
|
110
|
+
await h.tick(NOW + REVIVE_GRACE_MS + 1);
|
|
111
|
+
await h.waitForStatus(id, 'dead');
|
|
112
|
+
assert.equal(h.status(id), 'dead', 'a never-booted broker is reaped, not stranded active forever');
|
|
113
|
+
// …and the parent (an active subscriber of the child) was told up the spine.
|
|
114
|
+
const note = h.inbox(root).find((e) => /never started/.test(e.label ?? ''));
|
|
115
|
+
assert.ok(note !== undefined, 'the parent received a boot-failure notice up the spine');
|
|
116
|
+
});
|