@huseynsnmz/pi-suite 0.0.0-stage → 0.2.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/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/README.md +88 -2
- package/SECURITY.md +5 -0
- package/package.json +112 -4
- package/packages/board/CHANGELOG.md +340 -0
- package/packages/board/CONTRIBUTING.md +7 -0
- package/packages/board/LICENSE +21 -0
- package/packages/board/README.md +88 -0
- package/packages/board/SECURITY.md +19 -0
- package/packages/board/docs/how-it-works.md +31 -0
- package/packages/board/docs/reference.md +278 -0
- package/packages/board/package.json +81 -0
- package/packages/board/runner/job-runner-legacy.mjs +68 -0
- package/packages/board/runner/job-runner.mjs +834 -0
- package/packages/board/runner/pty-runner-legacy.mjs +50 -0
- package/packages/board/runner/pty-runner.mjs +2175 -0
- package/packages/board/runner/state-coordinator.mjs +673 -0
- package/packages/board/runner/state-runner.mjs +203 -0
- package/packages/board/runner/title-runner.mjs +80 -0
- package/packages/board/src/commands/agent-board.ts +394 -0
- package/packages/board/src/commands/attach-decision.mjs +66 -0
- package/packages/board/src/commands/attach-flow.ts +251 -0
- package/packages/board/src/commands/bg.ts +103 -0
- package/packages/board/src/commands/board-model.ts +21 -0
- package/packages/board/src/core/atomic.mjs +184 -0
- package/packages/board/src/core/attach-delete.mjs +24 -0
- package/packages/board/src/core/attach-frame-proof.mjs +870 -0
- package/packages/board/src/core/attach-frame-reporter.mjs +648 -0
- package/packages/board/src/core/attach-frame-stream.mjs +266 -0
- package/packages/board/src/core/attach-view-proof.mjs +709 -0
- package/packages/board/src/core/auto-state.mjs +377 -0
- package/packages/board/src/core/background-runs.mjs +273 -0
- package/packages/board/src/core/code-refs-store.mjs +315 -0
- package/packages/board/src/core/code-refs.mjs +913 -0
- package/packages/board/src/core/config.mjs +77 -0
- package/packages/board/src/core/control-protocol.mjs +482 -0
- package/packages/board/src/core/coordinator-client.mjs +633 -0
- package/packages/board/src/core/coordinator-journal.mjs +282 -0
- package/packages/board/src/core/coordinator-protocol.mjs +14 -0
- package/packages/board/src/core/cwd-stats.mjs +114 -0
- package/packages/board/src/core/dashboard-render.mjs +10 -0
- package/packages/board/src/core/derive.mjs +117 -0
- package/packages/board/src/core/diagnostics.mjs +133 -0
- package/packages/board/src/core/double-press.mjs +26 -0
- package/packages/board/src/core/editor-state-reporter.mjs +112 -0
- package/packages/board/src/core/events.mjs +286 -0
- package/packages/board/src/core/evidence.mjs +245 -0
- package/packages/board/src/core/follow-up-queue.mjs +211 -0
- package/packages/board/src/core/foreground-preview-cache.mjs +117 -0
- package/packages/board/src/core/foreground-projection-cache.mjs +99 -0
- package/packages/board/src/core/frame-witness-update.mjs +35 -0
- package/packages/board/src/core/heuristics.mjs +313 -0
- package/packages/board/src/core/hold-input.mjs +27 -0
- package/packages/board/src/core/host-coordination.mjs +300 -0
- package/packages/board/src/core/host-crash.mjs +79 -0
- package/packages/board/src/core/host-probe.mjs +207 -0
- package/packages/board/src/core/host-protocol.mjs +73 -0
- package/packages/board/src/core/ids.mjs +35 -0
- package/packages/board/src/core/ime-cursor-coalesce.mjs +193 -0
- package/packages/board/src/core/invocation.mjs +43 -0
- package/packages/board/src/core/launch-options.mjs +418 -0
- package/packages/board/src/core/launch.mjs +157 -0
- package/packages/board/src/core/load-config.mjs +4 -0
- package/packages/board/src/core/locks.mjs +530 -0
- package/packages/board/src/core/paths.mjs +183 -0
- package/packages/board/src/core/pid.mjs +75 -0
- package/packages/board/src/core/prewarm-schedule.mjs +41 -0
- package/packages/board/src/core/process-identity-probe.mjs +228 -0
- package/packages/board/src/core/prompt-transport.mjs +13 -0
- package/packages/board/src/core/pty-attach-jiggle-controller.mjs +356 -0
- package/packages/board/src/core/pty-attach-jiggle-retry.mjs +109 -0
- package/packages/board/src/core/pty-attach-reconnect.mjs +61 -0
- package/packages/board/src/core/pty-attach-render.mjs +229 -0
- package/packages/board/src/core/pty-links.mjs +81 -0
- package/packages/board/src/core/pty-scroll.mjs +522 -0
- package/packages/board/src/core/pty-support.mjs +327 -0
- package/packages/board/src/core/repo.mjs +103 -0
- package/packages/board/src/core/rows.mjs +414 -0
- package/packages/board/src/core/screen-log-gc.mjs +198 -0
- package/packages/board/src/core/screen-log.mjs +160 -0
- package/packages/board/src/core/session-view.mjs +174 -0
- package/packages/board/src/core/slash-commands.mjs +52 -0
- package/packages/board/src/core/state-commands.mjs +757 -0
- package/packages/board/src/core/status-consistency.mjs +98 -0
- package/packages/board/src/core/steering-prompts.mjs +34 -0
- package/packages/board/src/core/steering.mjs +133 -0
- package/packages/board/src/core/stop-latch.mjs +41 -0
- package/packages/board/src/core/store.mjs +523 -0
- package/packages/board/src/core/suspend.mjs +36 -0
- package/packages/board/src/core/terminal-attach-client.mjs +981 -0
- package/packages/board/src/core/terminal-attach-protocol.mjs +460 -0
- package/packages/board/src/core/terminal-model.mjs +222 -0
- package/packages/board/src/core/terminal-query-sequences.mjs +239 -0
- package/packages/board/src/core/terminal-reply-guard.mjs +222 -0
- package/packages/board/src/core/terminal-snapshot.mjs +440 -0
- package/packages/board/src/core/test-knobs.mjs +28 -0
- package/packages/board/src/core/title.mjs +43 -0
- package/packages/board/src/core/types.mjs +414 -0
- package/packages/board/src/core/warm-host-sweeper.mjs +168 -0
- package/packages/board/src/core/worktree.mjs +65 -0
- package/packages/board/src/index.ts +303 -0
- package/packages/board/src/runtime/service.mjs +3222 -0
- package/packages/board/src/ui/chrome.ts +20 -0
- package/packages/board/src/ui/dashboard-decisions.mjs +55 -0
- package/packages/board/src/ui/dashboard-evidence.mjs +85 -0
- package/packages/board/src/ui/dashboard.ts +2237 -0
- package/packages/board/src/ui/double-left.ts +30 -0
- package/packages/board/src/ui/external-editor.ts +25 -0
- package/packages/board/src/ui/focus.ts +32 -0
- package/packages/board/src/ui/pty-attach.ts +3184 -0
- package/packages/board/src/ui/waiting.ts +30 -0
- package/packages/commands/LICENSE +21 -0
- package/packages/commands/README.md +19 -0
- package/packages/commands/package.json +22 -0
- package/packages/commands/src/index.ts +43 -0
- package/packages/commands/src/security-review.ts +109 -0
- package/packages/frame/CHANGELOG.md +113 -0
- package/packages/frame/CONTRIBUTING.md +7 -0
- package/packages/frame/LICENSE +22 -0
- package/packages/frame/README.md +105 -0
- package/packages/frame/SECURITY.md +16 -0
- package/packages/frame/docs/how-it-works.md +53 -0
- package/packages/frame/docs/reference.md +78 -0
- package/packages/frame/package.json +72 -0
- package/packages/frame/src/data/git.ts +90 -0
- package/packages/frame/src/data/github.ts +140 -0
- package/packages/frame/src/data/gitlab.ts +63 -0
- package/packages/frame/src/data/mode.ts +49 -0
- package/packages/frame/src/data/past-prompts.ts +57 -0
- package/packages/frame/src/data/runner.ts +32 -0
- package/packages/frame/src/data/snapshot.ts +223 -0
- package/packages/frame/src/data/start-notes.ts +104 -0
- package/packages/frame/src/data/telemetry.ts +337 -0
- package/packages/frame/src/data/todos.ts +162 -0
- package/packages/frame/src/data/totals.ts +94 -0
- package/packages/frame/src/data/visits.ts +45 -0
- package/packages/frame/src/index.ts +696 -0
- package/packages/frame/src/kit/LICENSE +21 -0
- package/packages/frame/src/kit/app/attribution.ts +30 -0
- package/packages/frame/src/kit/app/builder.ts +186 -0
- package/packages/frame/src/kit/app/claim.ts +27 -0
- package/packages/frame/src/kit/app/feature.ts +91 -0
- package/packages/frame/src/kit/app/host.ts +214 -0
- package/packages/frame/src/kit/app/renderers.ts +62 -0
- package/packages/frame/src/kit/app/scope.ts +125 -0
- package/packages/frame/src/kit/board/model.ts +337 -0
- package/packages/frame/src/kit/contract.ts +79 -0
- package/packages/frame/src/kit/contracts/accounts.ts +33 -0
- package/packages/frame/src/kit/contracts/ask.ts +120 -0
- package/packages/frame/src/kit/contracts/board.ts +6 -0
- package/packages/frame/src/kit/contracts/by.ts +22 -0
- package/packages/frame/src/kit/contracts/calls.ts +46 -0
- package/packages/frame/src/kit/contracts/events.ts +89 -0
- package/packages/frame/src/kit/contracts/mode.ts +26 -0
- package/packages/frame/src/kit/contracts/names.ts +12 -0
- package/packages/frame/src/kit/contracts/peers.ts +77 -0
- package/packages/frame/src/kit/contracts/prompts.ts +8 -0
- package/packages/frame/src/kit/contracts/screen.ts +75 -0
- package/packages/frame/src/kit/contracts/skills.ts +81 -0
- package/packages/frame/src/kit/contracts/sources.ts +81 -0
- package/packages/frame/src/kit/contracts/views.ts +42 -0
- package/packages/frame/src/kit/control.ts +59 -0
- package/packages/frame/src/kit/dark-theme.ts +41 -0
- package/packages/frame/src/kit/decode.ts +210 -0
- package/packages/frame/src/kit/events/log.ts +313 -0
- package/packages/frame/src/kit/format.ts +25 -0
- package/packages/frame/src/kit/host.ts +4 -0
- package/packages/frame/src/kit/index.ts +25 -0
- package/packages/frame/src/kit/legacy-tasks.ts +39 -0
- package/packages/frame/src/kit/overlay-images.ts +36 -0
- package/packages/frame/src/kit/paths.ts +16 -0
- package/packages/frame/src/kit/screen/client.ts +74 -0
- package/packages/frame/src/kit/screen/feature.ts +64 -0
- package/packages/frame/src/kit/screen/model.ts +182 -0
- package/packages/frame/src/kit/screen/view.ts +838 -0
- package/packages/frame/src/kit/settings/files.ts +90 -0
- package/packages/frame/src/kit/settings/setting.ts +57 -0
- package/packages/frame/src/kit/settings/store.ts +192 -0
- package/packages/frame/src/kit/sources/file.ts +129 -0
- package/packages/frame/src/kit/sources/follow.ts +155 -0
- package/packages/frame/src/kit/testing.ts +260 -0
- package/packages/frame/src/kit/totals.ts +72 -0
- package/packages/frame/src/kit/ui/entry.ts +75 -0
- package/packages/frame/src/kit/ui/frame.ts +212 -0
- package/packages/frame/src/kit/ui/glyphs.ts +132 -0
- package/packages/frame/src/kit/ui/scroll.ts +14 -0
- package/packages/frame/src/render/diff.ts +157 -0
- package/packages/frame/src/render/fit.ts +86 -0
- package/packages/frame/src/render/format.ts +108 -0
- package/packages/frame/src/render/frame.ts +106 -0
- package/packages/frame/src/render/glyphs.ts +189 -0
- package/packages/frame/src/render/keys.ts +43 -0
- package/packages/frame/src/render/lead.ts +37 -0
- package/packages/frame/src/render/paint.ts +135 -0
- package/packages/frame/src/render/safe.ts +19 -0
- package/packages/frame/src/render/segments.ts +735 -0
- package/packages/frame/src/render/streaming.ts +223 -0
- package/packages/frame/src/render/timing.ts +106 -0
- package/packages/frame/src/render/tools.ts +167 -0
- package/packages/frame/src/settings.ts +105 -0
- package/packages/frame/src/ui/editor.ts +272 -0
- package/packages/frame/src/ui/effort.ts +36 -0
- package/packages/frame/src/ui/footer.ts +165 -0
- package/packages/frame/src/ui/header.ts +362 -0
- package/packages/frame/src/ui/history-search.ts +23 -0
- package/packages/frame/src/ui/interrupt.ts +21 -0
- package/packages/frame/src/ui/outputs.ts +170 -0
- package/packages/frame/src/ui/pi-entries.ts +298 -0
- package/packages/frame/src/ui/previews.ts +72 -0
- package/packages/frame/src/ui/queue.ts +75 -0
- package/packages/frame/src/ui/screen.ts +59 -0
- package/packages/frame/src/ui/timings.ts +48 -0
- package/packages/frame/src/ui/todos.ts +205 -0
- package/packages/frame/src/ui/tool-frame.ts +595 -0
- package/packages/frame/src/ui/working.ts +25 -0
- package/packages/frame/src/view/state.ts +97 -0
- package/packages/frame/src/view/transcript.ts +175 -0
- package/packages/frame/src/view/view.ts +162 -0
- package/packages/memory/CONTRIBUTING.md +7 -0
- package/packages/memory/LICENSE +21 -0
- package/packages/memory/README.md +44 -0
- package/packages/memory/SECURITY.md +20 -0
- package/packages/memory/package.json +55 -0
- package/packages/memory/skills/review-memory/SKILL.md +48 -0
- package/packages/memory/src/index.ts +163 -0
- package/packages/memory/src/prompt.ts +25 -0
- package/packages/memory/src/review.ts +150 -0
- package/packages/memory/src/secrets.ts +22 -0
- package/packages/memory/src/store.ts +173 -0
- package/packages/modes/CHANGELOG.md +104 -0
- package/packages/modes/CONTRIBUTING.md +7 -0
- package/packages/modes/LICENSE +21 -0
- package/packages/modes/README.md +174 -0
- package/packages/modes/SECURITY.md +17 -0
- package/packages/modes/package.json +55 -0
- package/packages/modes/skills/review-permissions/SKILL.md +54 -0
- package/packages/modes/src/bash.ts +218 -0
- package/packages/modes/src/deny-off.ts +62 -0
- package/packages/modes/src/git-safety.ts +72 -0
- package/packages/modes/src/index.ts +338 -0
- package/packages/modes/src/interview.ts +206 -0
- package/packages/modes/src/judge-gate.ts +113 -0
- package/packages/modes/src/modes.ts +121 -0
- package/packages/modes/src/review/apply.mjs +156 -0
- package/packages/modes/src/review/collect.mjs +208 -0
- package/packages/modes/src/review/tools.ts +71 -0
- package/packages/modes/src/sandbox.ts +37 -0
- package/packages/router/CHANGELOG.md +8 -0
- package/packages/router/LICENSE +21 -0
- package/packages/router/README.md +90 -0
- package/packages/router/package.json +51 -0
- package/packages/router/src/config.ts +65 -0
- package/packages/router/src/index.ts +172 -0
- package/packages/router/src/judge.ts +51 -0
- package/packages/router/src/route.ts +67 -0
- package/packages/web/CHANGELOG.md +36 -0
- package/packages/web/LICENSE +21 -0
- package/packages/web/README.md +87 -0
- package/packages/web/SECURITY.md +13 -0
- package/packages/web/package.json +59 -0
- package/packages/web/src/config.ts +109 -0
- package/packages/web/src/dnsfilter.ts +97 -0
- package/packages/web/src/extract.ts +65 -0
- package/packages/web/src/index.ts +217 -0
- package/packages/web/src/markdown.ts +122 -0
- package/packages/web/src/net.ts +109 -0
- package/packages/web/src/reader.ts +73 -0
- package/packages/web/src/sanitize.ts +50 -0
- package/packages/web/src/search.ts +76 -0
- package/packages/web/src/secrets.ts +22 -0
- package/packages/web/src/validate.ts +186 -0
- package/src/index.ts +156 -0
- package/src/settings.ts +79 -0
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure attach view-proof coverage and safe-trailing classification (issue #222, step 23b2a).
|
|
3
|
+
*
|
|
4
|
+
* This module is deliberately inert: no sockets, files, timers, terminals, fs or full
|
|
5
|
+
* scrollback. It owns the FW-06 decisions that a future runner/subscription (23b2b) and
|
|
6
|
+
* attach consumer (23b2c) must share, so both can be unit-tested without a PTY:
|
|
7
|
+
*
|
|
8
|
+
* 1. `createSafeTrailingClassifier()` — a streaming, bounded, grammar-aware classifier for
|
|
9
|
+
* the EXHAUSTIVE §5 post-F allowlist. Ordinary output is split arbitrarily; incomplete
|
|
10
|
+
* CSI/OSC tails are carried across `push()` calls, and unknown, malformed or oversize
|
|
11
|
+
* input becomes sticky `unsafe`. A complete safe sequence remains an ordinary control
|
|
12
|
+
* fact (title/progress/query/keyboard mode/DECTCEM): it never becomes a proof and never
|
|
13
|
+
* erases what the control meant.
|
|
14
|
+
* 2. `createViewProofTracker()` — records ordered output segments (`feedOutput`), producer
|
|
15
|
+
* candidate boundaries (`openCandidate`/`abortCandidate`) and completed proofs
|
|
16
|
+
* (`completeProof`), then answers `coverageFor(snapshotSeq)` and bounded `replay()`.
|
|
17
|
+
*
|
|
18
|
+
* Coverage uses `proof.seq`, the preceding ORDINARY output segment sequence — never a
|
|
19
|
+
* child frameId, wall clock or socket arrival order. Exact coverage requires
|
|
20
|
+
* `proof.seq === snapshotSeq`. An older proof is accepted only while every segment after it
|
|
21
|
+
* (through the snapshot) is completely classified safe. Unsafe or pending output,
|
|
22
|
+
* an open/aborted producer candidate, gaps, malformed identity and evicted history stay
|
|
23
|
+
* unavailable/UNKNOWN; they are never stitched into a proof.
|
|
24
|
+
*
|
|
25
|
+
* The journal stores only bounded classification/proof metadata and dropped counts. It
|
|
26
|
+
* never stores output text, raw frames or a copied scrollback. Replay drop counts are
|
|
27
|
+
* deliberately split: aggregate counts are LIFETIME, per-entry counts are RANGE-LOCAL, and
|
|
28
|
+
* range completeness comes from the entries plus explicit gap/evicted status — never from a
|
|
29
|
+
* lifetime count alone.
|
|
30
|
+
*
|
|
31
|
+
* `snapshot().currentProof` is the last COMPLETED proof only. It is not coverage: output may
|
|
32
|
+
* have arrived after it, a candidate may be open, or the journal may be truncated. Callers
|
|
33
|
+
* that need a display decision must use `coverageFor(snapshotSeq)`.
|
|
34
|
+
*/
|
|
35
|
+
import { validateFrameMetadata } from "./attach-frame-proof.mjs";
|
|
36
|
+
|
|
37
|
+
export const VIEW_PROOF_PROTOCOL = 1;
|
|
38
|
+
/** Maximum carried/incomplete escape tail and complete classifiable sequence size, in UTF-8 BYTES. */
|
|
39
|
+
export const MAX_TRAILING_PENDING = 4096;
|
|
40
|
+
export const MAX_VIEW_PROOF_JOURNAL = 256;
|
|
41
|
+
export const MAX_VIEW_PROOF_DIAGNOSTICS = 64;
|
|
42
|
+
export const MAX_PROOFS_PER_SEQ = 16;
|
|
43
|
+
export const MAX_EVENTS_PER_SEQ = 16;
|
|
44
|
+
|
|
45
|
+
const ESC = "\u001b";
|
|
46
|
+
const BEL = "\u0007";
|
|
47
|
+
const CSI_FINAL = /[@-~]/;
|
|
48
|
+
const CSI_PARAM_OR_INTERMEDIATE = /[\x20-\x3f]/;
|
|
49
|
+
const DCS_INTRO = "\u001bP";
|
|
50
|
+
const SOS_INTRO = "\u001bX";
|
|
51
|
+
const PM_INTRO = "\u001b^";
|
|
52
|
+
const APC_INTRO = "\u001b_";
|
|
53
|
+
|
|
54
|
+
function utf8Bytes(text) {
|
|
55
|
+
return Buffer.byteLength(text, "utf8");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function defaultMetadata() {
|
|
59
|
+
return {
|
|
60
|
+
bracketedPaste: null,
|
|
61
|
+
colorSchemeNotifications: null,
|
|
62
|
+
keyboardProtocolPushes: 0,
|
|
63
|
+
keyboardProtocolPops: 0,
|
|
64
|
+
keyboardProtocolQueries: 0,
|
|
65
|
+
modifyOtherKeys: null,
|
|
66
|
+
cellSizeQueries: 0,
|
|
67
|
+
deviceAttributesQueries: 0,
|
|
68
|
+
paletteQueries: 0,
|
|
69
|
+
titles: 0,
|
|
70
|
+
progress: null,
|
|
71
|
+
programStatuses: 0,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function cloneMetadata(metadata) {
|
|
76
|
+
return { ...metadata, progress: metadata.progress === null ? null : { ...metadata.progress } };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function cloneEvent(event) {
|
|
80
|
+
return event.progress === undefined ? { ...event } : { ...event, progress: { ...event.progress } };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function boundEvents(events) {
|
|
84
|
+
const stored = events.slice(0, MAX_EVENTS_PER_SEQ).map(cloneEvent);
|
|
85
|
+
return { events: stored, droppedEvents: Math.max(0, events.length - stored.length) };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Classify one complete CSI body/final. Returns an event for an exact §5 allowlist item,
|
|
90
|
+
* otherwise `null` (caller marks the sequence unsafe). Plain SGR is intentionally absent:
|
|
91
|
+
* inside a producer frame rendition is annotation, but after F the allowlist is exhaustive.
|
|
92
|
+
*/
|
|
93
|
+
function classifyCsi(body, final) {
|
|
94
|
+
if (body === "?2004" && (final === "h" || final === "l")) return { family: "bracketed-paste", enabled: final === "h" };
|
|
95
|
+
if (body === "?2031" && (final === "h" || final === "l")) return { family: "color-scheme", enabled: final === "h" };
|
|
96
|
+
if (body === "16" && final === "t") return { family: "cell-size-query" };
|
|
97
|
+
if (body === "" && final === "c") return { family: "device-attributes" };
|
|
98
|
+
if (body === ">7" && final === "u") return { family: "keyboard-protocol-push" };
|
|
99
|
+
if (body === "?" && final === "u") return { family: "keyboard-protocol-query" };
|
|
100
|
+
if ((body === ">4;0" || body === ">4;2") && final === "m") return { family: "modify-other-keys", mode: body === ">4;0" ? 0 : 2 };
|
|
101
|
+
if (body === "<" && final === "u") return { family: "keyboard-protocol-pop" };
|
|
102
|
+
if (body === "?25" && (final === "h" || final === "l")) return { family: "dectcem", visible: final === "h" };
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Classify one complete OSC body (bytes between `ESC ]` and BEL/ST). OSC 8, OSC 52 and
|
|
108
|
+
* palette SETTERS are deliberately absent; only the literal §5 queries are accepted.
|
|
109
|
+
* Content-bearing controls retain bounded meaning (title/status byte counts, progress
|
|
110
|
+
* numbers, palette query text) so the caller can still act on them.
|
|
111
|
+
*/
|
|
112
|
+
function classifyOsc(body) {
|
|
113
|
+
if (body.startsWith("0;")) return { family: "osc-title", bytes: Buffer.byteLength(body.slice(2), "utf8") };
|
|
114
|
+
if (/^9;4;[0-9]+(?:;[0-9]+)?$/.test(body)) {
|
|
115
|
+
const parts = body.split(";");
|
|
116
|
+
const state = Number(parts[2]);
|
|
117
|
+
const percent = parts.length === 4 ? Number(parts[3]) : null;
|
|
118
|
+
return { family: "osc-progress", progress: { state, percent } };
|
|
119
|
+
}
|
|
120
|
+
if (body.startsWith("7501;")) return { family: "osc-program-status", bytes: Buffer.byteLength(body.slice(5), "utf8") };
|
|
121
|
+
if (/^4(;[0-9]+)*;\?$/.test(body) || /^(10|11);\?$/.test(body)) {
|
|
122
|
+
return { family: "osc-palette-query", query: body.slice(0, 64) };
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function applyEvent(metadata, event) {
|
|
128
|
+
switch (event.family) {
|
|
129
|
+
case "bracketed-paste":
|
|
130
|
+
metadata.bracketedPaste = event.enabled;
|
|
131
|
+
return;
|
|
132
|
+
case "color-scheme":
|
|
133
|
+
metadata.colorSchemeNotifications = event.enabled;
|
|
134
|
+
return;
|
|
135
|
+
case "keyboard-protocol-push":
|
|
136
|
+
metadata.keyboardProtocolPushes += 1;
|
|
137
|
+
return;
|
|
138
|
+
case "keyboard-protocol-pop":
|
|
139
|
+
metadata.keyboardProtocolPops += 1;
|
|
140
|
+
return;
|
|
141
|
+
case "keyboard-protocol-query":
|
|
142
|
+
metadata.keyboardProtocolQueries += 1;
|
|
143
|
+
return;
|
|
144
|
+
case "modify-other-keys":
|
|
145
|
+
metadata.modifyOtherKeys = event.mode;
|
|
146
|
+
return;
|
|
147
|
+
case "cell-size-query":
|
|
148
|
+
metadata.cellSizeQueries += 1;
|
|
149
|
+
return;
|
|
150
|
+
case "device-attributes":
|
|
151
|
+
metadata.deviceAttributesQueries += 1;
|
|
152
|
+
return;
|
|
153
|
+
case "osc-title":
|
|
154
|
+
metadata.titles += 1;
|
|
155
|
+
return;
|
|
156
|
+
case "osc-progress":
|
|
157
|
+
metadata.progress = { ...event.progress };
|
|
158
|
+
return;
|
|
159
|
+
case "osc-program-status":
|
|
160
|
+
metadata.programStatuses += 1;
|
|
161
|
+
return;
|
|
162
|
+
case "osc-palette-query":
|
|
163
|
+
metadata.paletteQueries += 1;
|
|
164
|
+
return;
|
|
165
|
+
default:
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Scan a CSI from its ESC byte. Returns `{end, body, final}`, `{pending}` or `{unsafe}`. */
|
|
171
|
+
function scanCsi(text, start, maxPending) {
|
|
172
|
+
let index = start + 2;
|
|
173
|
+
while (index < text.length) {
|
|
174
|
+
const character = text[index];
|
|
175
|
+
if (CSI_FINAL.test(character)) {
|
|
176
|
+
const body = text.slice(start + 2, index);
|
|
177
|
+
if (utf8Bytes(body) > maxPending) return { unsafe: "sequence-oversize" };
|
|
178
|
+
return { end: index + 1, body, final: character };
|
|
179
|
+
}
|
|
180
|
+
if (!CSI_PARAM_OR_INTERMEDIATE.test(character)) return { unsafe: "csi-malformed" };
|
|
181
|
+
index += 1;
|
|
182
|
+
}
|
|
183
|
+
const tail = text.slice(start);
|
|
184
|
+
if (utf8Bytes(tail) > maxPending) return { unsafe: "pending-overflow" };
|
|
185
|
+
return { pending: tail };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Scan an OSC from its ESC byte, accepting only BEL or ST terminators. */
|
|
189
|
+
function scanOsc(text, start, maxPending) {
|
|
190
|
+
let index = start + 2;
|
|
191
|
+
while (index < text.length) {
|
|
192
|
+
const character = text[index];
|
|
193
|
+
if (character === BEL) {
|
|
194
|
+
const body = text.slice(start + 2, index);
|
|
195
|
+
if (utf8Bytes(body) > maxPending) return { unsafe: "sequence-oversize" };
|
|
196
|
+
return { end: index + 1, body };
|
|
197
|
+
}
|
|
198
|
+
if (character === ESC) {
|
|
199
|
+
const next = text[index + 1];
|
|
200
|
+
if (next === undefined) {
|
|
201
|
+
const tail = text.slice(start);
|
|
202
|
+
if (utf8Bytes(tail) > maxPending) return { unsafe: "pending-overflow" };
|
|
203
|
+
return { pending: tail };
|
|
204
|
+
}
|
|
205
|
+
if (next !== "\\") return { unsafe: "osc-malformed" };
|
|
206
|
+
const body = text.slice(start + 2, index);
|
|
207
|
+
if (utf8Bytes(body) > maxPending) return { unsafe: "sequence-oversize" };
|
|
208
|
+
return { end: index + 2, body };
|
|
209
|
+
}
|
|
210
|
+
if (character < " " || character === "\u007f") return { unsafe: "osc-malformed" };
|
|
211
|
+
index += 1;
|
|
212
|
+
}
|
|
213
|
+
const tail = text.slice(start);
|
|
214
|
+
if (utf8Bytes(tail) > maxPending) return { unsafe: "pending-overflow" };
|
|
215
|
+
return { pending: tail };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Streaming exporter for the post-F no-grid control window.
|
|
220
|
+
*
|
|
221
|
+
* `push(chunk)` returns `{status, reason, events, droppedEvents, pendingBytes,
|
|
222
|
+
* cursorVisible, metadata}` where status is exactly `safe`, `pending` or `unsafe`, and
|
|
223
|
+
* `pendingBytes` is a UTF-8 byte count. `pending` is NOT safe: an incomplete escape tail has
|
|
224
|
+
* not been classified yet. Once `unsafe`, the classifier stays unsafe until `reset()` (the
|
|
225
|
+
* caller resets only on a new complete producer proof).
|
|
226
|
+
*/
|
|
227
|
+
export function createSafeTrailingClassifier({ maxPending = MAX_TRAILING_PENDING } = {}) {
|
|
228
|
+
if (!Number.isInteger(maxPending) || maxPending < 16) throw new Error("safe trailing classifier requires maxPending >= 16");
|
|
229
|
+
let pending = "";
|
|
230
|
+
let unsafeReason = null;
|
|
231
|
+
let cursorVisible = null;
|
|
232
|
+
let metadata = defaultMetadata();
|
|
233
|
+
|
|
234
|
+
const result = (status, events) => {
|
|
235
|
+
const bounded = boundEvents(events);
|
|
236
|
+
return {
|
|
237
|
+
status,
|
|
238
|
+
reason: unsafeReason,
|
|
239
|
+
events: bounded.events,
|
|
240
|
+
droppedEvents: bounded.droppedEvents,
|
|
241
|
+
pendingBytes: utf8Bytes(pending),
|
|
242
|
+
cursorVisible,
|
|
243
|
+
metadata: cloneMetadata(metadata),
|
|
244
|
+
};
|
|
245
|
+
};
|
|
246
|
+
const markUnsafe = (reason) => {
|
|
247
|
+
unsafeReason = reason;
|
|
248
|
+
pending = "";
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
function push(chunk) {
|
|
252
|
+
if (unsafeReason !== null) return result("unsafe", []);
|
|
253
|
+
if (typeof chunk !== "string") {
|
|
254
|
+
markUnsafe("invalid-input");
|
|
255
|
+
return result("unsafe", []);
|
|
256
|
+
}
|
|
257
|
+
const text = pending + chunk;
|
|
258
|
+
pending = "";
|
|
259
|
+
const events = [];
|
|
260
|
+
let index = 0;
|
|
261
|
+
while (index < text.length) {
|
|
262
|
+
if (text[index] !== ESC) {
|
|
263
|
+
markUnsafe("bare-byte");
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
const next = text[index + 1];
|
|
267
|
+
if (next === undefined) {
|
|
268
|
+
pending = text.slice(index);
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
if (next === "[") {
|
|
272
|
+
const scanned = scanCsi(text, index, maxPending);
|
|
273
|
+
if (scanned.unsafe !== undefined) {
|
|
274
|
+
markUnsafe(scanned.unsafe);
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
if (scanned.pending !== undefined) {
|
|
278
|
+
pending = scanned.pending;
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
const event = classifyCsi(scanned.body, scanned.final);
|
|
282
|
+
if (event === null) {
|
|
283
|
+
markUnsafe("csi-unknown");
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
events.push(event);
|
|
287
|
+
applyEvent(metadata, event);
|
|
288
|
+
if (event.family === "dectcem") cursorVisible = event.visible;
|
|
289
|
+
index = scanned.end;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (next === "]") {
|
|
293
|
+
const scanned = scanOsc(text, index, maxPending);
|
|
294
|
+
if (scanned.unsafe !== undefined) {
|
|
295
|
+
markUnsafe(scanned.unsafe);
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
if (scanned.pending !== undefined) {
|
|
299
|
+
pending = scanned.pending;
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
const event = classifyOsc(scanned.body);
|
|
303
|
+
if (event === null) {
|
|
304
|
+
markUnsafe("osc-unknown");
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
events.push(event);
|
|
308
|
+
applyEvent(metadata, event);
|
|
309
|
+
index = scanned.end;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (text.startsWith(DCS_INTRO, index)) markUnsafe("dcs");
|
|
313
|
+
else if (text.startsWith(SOS_INTRO, index) || text.startsWith(PM_INTRO, index) || text.startsWith(APC_INTRO, index)) markUnsafe("string-control");
|
|
314
|
+
else markUnsafe("escape-unknown");
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
if (unsafeReason !== null) return result("unsafe", events);
|
|
318
|
+
if (utf8Bytes(pending) > maxPending) {
|
|
319
|
+
markUnsafe("pending-overflow");
|
|
320
|
+
return result("unsafe", events);
|
|
321
|
+
}
|
|
322
|
+
return result(pending.length > 0 ? "pending" : "safe", events);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Start a new trailing window after a complete proof. The sticky unsafe/pending state is
|
|
327
|
+
* cleared and cursor visibility comes from the new proof (null means UNKNOWN); terminal
|
|
328
|
+
* metadata such as an already-set title or keyboard mode is retained because a later
|
|
329
|
+
* frame does not erase what those controls meant.
|
|
330
|
+
*/
|
|
331
|
+
/** @param {{ cursorVisible?: boolean }} [options] */
|
|
332
|
+
function reset({ cursorVisible: initialCursor = null } = {}) {
|
|
333
|
+
pending = "";
|
|
334
|
+
unsafeReason = null;
|
|
335
|
+
cursorVisible = typeof initialCursor === "boolean" ? initialCursor : null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function state() {
|
|
339
|
+
return {
|
|
340
|
+
status: unsafeReason !== null ? "unsafe" : pending.length > 0 ? "pending" : "safe",
|
|
341
|
+
reason: unsafeReason,
|
|
342
|
+
pendingBytes: utf8Bytes(pending),
|
|
343
|
+
cursorVisible,
|
|
344
|
+
metadata: cloneMetadata(metadata),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return { push, reset, state };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
// Proof tracker
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
const PROOF_FIELDS = new Set(["marker", "kind", "frameId", "seq", "sessionEpoch", "metadata", "cursorVisible"]);
|
|
356
|
+
|
|
357
|
+
function validateCandidate(record) {
|
|
358
|
+
if (record === null || typeof record !== "object" || Array.isArray(record)) return { ok: false, reason: "candidate-not-object" };
|
|
359
|
+
if (!Number.isInteger(record.seq) || record.seq < 1) return { ok: false, reason: "candidate-seq" };
|
|
360
|
+
if (!Number.isInteger(record.frameId) || record.frameId < 1) return { ok: false, reason: "candidate-frame-id" };
|
|
361
|
+
if (record.sessionEpoch !== undefined && (!Number.isInteger(record.sessionEpoch) || record.sessionEpoch < 0)) return { ok: false, reason: "candidate-session-epoch" };
|
|
362
|
+
return { ok: true, seq: record.seq, frameId: record.frameId, sessionEpoch: record.sessionEpoch ?? 0 };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function validateProof(record) {
|
|
366
|
+
if (record === null || typeof record !== "object" || Array.isArray(record)) return { ok: false, reason: "proof-not-object" };
|
|
367
|
+
if (record.reason !== undefined) return { ok: false, reason: "proof-reason" };
|
|
368
|
+
for (const key of Object.keys(record)) {
|
|
369
|
+
if (!PROOF_FIELDS.has(key)) return { ok: false, reason: `proof-unexpected-field:${key}` };
|
|
370
|
+
}
|
|
371
|
+
if (record.marker !== undefined && record.marker !== "F") return { ok: false, reason: "proof-marker" };
|
|
372
|
+
if (record.kind !== undefined && record.kind !== "F") return { ok: false, reason: "proof-kind" };
|
|
373
|
+
if (!Number.isInteger(record.seq) || record.seq < 1) return { ok: false, reason: "proof-seq" };
|
|
374
|
+
if (!Number.isInteger(record.frameId) || record.frameId < 1) return { ok: false, reason: "proof-frame-id" };
|
|
375
|
+
if (record.sessionEpoch !== undefined && (!Number.isInteger(record.sessionEpoch) || record.sessionEpoch < 0)) return { ok: false, reason: "proof-session-epoch" };
|
|
376
|
+
if (record.cursorVisible !== undefined && typeof record.cursorVisible !== "boolean") return { ok: false, reason: "proof-cursor-visible" };
|
|
377
|
+
if (record.metadata === undefined || record.metadata === null) return { ok: false, reason: "proof-metadata-missing" };
|
|
378
|
+
const validation = validateFrameMetadata(record.metadata);
|
|
379
|
+
if (!validation.ok) return { ok: false, reason: `proof-metadata:${validation.reason}` };
|
|
380
|
+
const sessionEpoch = record.sessionEpoch === undefined ? validation.value.sessionEpoch : record.sessionEpoch;
|
|
381
|
+
if (sessionEpoch !== validation.value.sessionEpoch) return { ok: false, reason: "proof-session-epoch-conflict" };
|
|
382
|
+
return {
|
|
383
|
+
ok: true,
|
|
384
|
+
marker: "F",
|
|
385
|
+
seq: record.seq,
|
|
386
|
+
frameId: record.frameId,
|
|
387
|
+
sessionEpoch,
|
|
388
|
+
cursorVisible: record.cursorVisible ?? null,
|
|
389
|
+
metadata: validation.value,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function validateAbort(record) {
|
|
394
|
+
const candidate = validateCandidate(record);
|
|
395
|
+
if (!candidate.ok) return candidate;
|
|
396
|
+
if (record.reason !== undefined && typeof record.reason !== "string") return { ok: false, reason: "abort-reason" };
|
|
397
|
+
return { ...candidate, reason: record.reason ?? null };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function cloneProof(proof) {
|
|
401
|
+
if (proof === null) return null;
|
|
402
|
+
return { ...proof, metadata: proof.metadata === null ? null : { ...proof.metadata } };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function cloneEntry(entry) {
|
|
406
|
+
return {
|
|
407
|
+
seq: entry.seq,
|
|
408
|
+
status: entry.status,
|
|
409
|
+
reason: entry.reason,
|
|
410
|
+
events: entry.events.map(cloneEvent),
|
|
411
|
+
droppedEvents: entry.droppedEvents,
|
|
412
|
+
droppedProofs: entry.droppedProofs,
|
|
413
|
+
pendingBytes: entry.pendingBytes,
|
|
414
|
+
cursorVisible: entry.cursorVisible,
|
|
415
|
+
proofs: entry.proofs.map(cloneProof),
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Create the ordered view-proof tracker. `feedOutput` must receive contiguous output
|
|
421
|
+
* segment sequence numbers (a first arbitrary number is accepted as the tracker's
|
|
422
|
+
* baseline); one missing number is a protocol error and keeps coverage UNKNOWN.
|
|
423
|
+
*/
|
|
424
|
+
export function createViewProofTracker({ maxJournal = MAX_VIEW_PROOF_JOURNAL, maxPending = MAX_TRAILING_PENDING, maxDiagnostics = MAX_VIEW_PROOF_DIAGNOSTICS } = {}) {
|
|
425
|
+
if (!Number.isInteger(maxJournal) || maxJournal < 1) throw new Error("view proof tracker requires maxJournal >= 1");
|
|
426
|
+
if (!Number.isInteger(maxDiagnostics) || maxDiagnostics < 1) throw new Error("view proof tracker requires maxDiagnostics >= 1");
|
|
427
|
+
const classifier = createSafeTrailingClassifier({ maxPending });
|
|
428
|
+
const journal = new Map();
|
|
429
|
+
const diagnostics = [];
|
|
430
|
+
let firstSeq = null;
|
|
431
|
+
let lastOutputSeq = 0;
|
|
432
|
+
let lastProofSeq = 0;
|
|
433
|
+
let lastFrameId = 0;
|
|
434
|
+
let candidate = null;
|
|
435
|
+
let candidateState = "idle"; // idle | open | aborted
|
|
436
|
+
let currentProof = null;
|
|
437
|
+
let previousComplete = null;
|
|
438
|
+
let gap = false;
|
|
439
|
+
let pendingSinceSeq = null;
|
|
440
|
+
let droppedSegments = 0;
|
|
441
|
+
let droppedProofs = 0;
|
|
442
|
+
let droppedEvents = 0;
|
|
443
|
+
|
|
444
|
+
const diagnostic = (reason, extra = {}) => {
|
|
445
|
+
diagnostics.push({ reason, ...extra });
|
|
446
|
+
if (diagnostics.length > maxDiagnostics) diagnostics.splice(0, diagnostics.length - maxDiagnostics);
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
const protocolError = (reason, extra = {}) => {
|
|
450
|
+
diagnostic(reason, extra);
|
|
451
|
+
return { status: "protocol-error", reason, ...extra };
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
const earliestRetainedSeq = () => (journal.size === 0 ? null : journal.keys().next().value);
|
|
455
|
+
|
|
456
|
+
function insertEntry(entry) {
|
|
457
|
+
journal.set(entry.seq, entry);
|
|
458
|
+
if (journal.size > maxJournal) {
|
|
459
|
+
const oldest = journal.keys().next().value;
|
|
460
|
+
const evicted = journal.get(oldest);
|
|
461
|
+
journal.delete(oldest);
|
|
462
|
+
droppedSegments += 1;
|
|
463
|
+
// Per-entry drops were already counted at truncation time; eviction adds only the
|
|
464
|
+
// proofs still retained in the evicted entry. Adding entry.droppedProofs here would
|
|
465
|
+
// double count the lifetime aggregate.
|
|
466
|
+
droppedProofs += evicted.proofs.length;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function findNewestProofAtOrBefore(snapshotSeq) {
|
|
471
|
+
let found = null;
|
|
472
|
+
for (const entry of journal.values()) {
|
|
473
|
+
for (const proof of entry.proofs) if (proof.seq <= snapshotSeq) found = proof;
|
|
474
|
+
}
|
|
475
|
+
if (found === null && currentProof !== null && currentProof.seq <= snapshotSeq) return { proof: currentProof, evicted: true };
|
|
476
|
+
return { proof: found, evicted: false };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function setPendingEntriesStatus(fromSeq, throughSeq, status, reason) {
|
|
480
|
+
for (const entry of journal.values()) {
|
|
481
|
+
if (entry.seq >= fromSeq && entry.seq <= throughSeq && entry.status === "pending") {
|
|
482
|
+
entry.status = status;
|
|
483
|
+
entry.reason = reason;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Feed one ordinary output segment. `seq` is the ordered output segment number, not a
|
|
490
|
+
* child frame id. The text is classified only; no copy of it is retained.
|
|
491
|
+
*/
|
|
492
|
+
function feedOutput(seq, data) {
|
|
493
|
+
if (!Number.isInteger(seq) || seq < 1) return protocolError("invalid-output-seq", { seq });
|
|
494
|
+
if (typeof data !== "string") return protocolError("invalid-output-data", { seq });
|
|
495
|
+
if (firstSeq === null) {
|
|
496
|
+
firstSeq = seq;
|
|
497
|
+
lastOutputSeq = seq;
|
|
498
|
+
} else if (seq <= lastOutputSeq) {
|
|
499
|
+
return protocolError("output-seq-regressed", { seq, lastOutputSeq });
|
|
500
|
+
} else if (seq !== lastOutputSeq + 1) {
|
|
501
|
+
gap = true;
|
|
502
|
+
const expected = lastOutputSeq + 1;
|
|
503
|
+
lastOutputSeq = seq;
|
|
504
|
+
insertEntry({ seq, status: "unknown", reason: "output-gap", events: [], droppedEvents: 0, droppedProofs: 0, pendingBytes: 0, cursorVisible: null, proofs: [] });
|
|
505
|
+
return protocolError("output-gap", { seq, expected });
|
|
506
|
+
} else {
|
|
507
|
+
lastOutputSeq = seq;
|
|
508
|
+
}
|
|
509
|
+
const wasPending = classifier.state().status === "pending";
|
|
510
|
+
const pendingFrom = wasPending ? pendingSinceSeq : null;
|
|
511
|
+
const classified = classifier.push(data);
|
|
512
|
+
if (pendingFrom !== null) {
|
|
513
|
+
if (classified.status === "safe") setPendingEntriesStatus(pendingFrom, seq - 1, "safe", null);
|
|
514
|
+
else if (classified.status === "unsafe") setPendingEntriesStatus(pendingFrom, seq - 1, "unsafe", classified.reason);
|
|
515
|
+
}
|
|
516
|
+
if (classified.status === "pending") {
|
|
517
|
+
if (pendingSinceSeq === null) pendingSinceSeq = seq;
|
|
518
|
+
} else {
|
|
519
|
+
pendingSinceSeq = null;
|
|
520
|
+
}
|
|
521
|
+
insertEntry({
|
|
522
|
+
seq,
|
|
523
|
+
status: classified.status,
|
|
524
|
+
reason: classified.reason,
|
|
525
|
+
events: classified.events,
|
|
526
|
+
droppedEvents: classified.droppedEvents,
|
|
527
|
+
droppedProofs: 0,
|
|
528
|
+
pendingBytes: classified.pendingBytes,
|
|
529
|
+
cursorVisible: classified.cursorVisible,
|
|
530
|
+
proofs: [],
|
|
531
|
+
});
|
|
532
|
+
droppedEvents += classified.droppedEvents;
|
|
533
|
+
return {
|
|
534
|
+
status: classified.status,
|
|
535
|
+
reason: classified.reason,
|
|
536
|
+
seq,
|
|
537
|
+
events: classified.events,
|
|
538
|
+
droppedEvents: classified.droppedEvents,
|
|
539
|
+
cursorVisible: classified.cursorVisible,
|
|
540
|
+
metadata: classified.metadata,
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** Record the S boundary of a producer candidate (preceding output segment seq). */
|
|
545
|
+
function openCandidate(record) {
|
|
546
|
+
const validation = validateCandidate(record);
|
|
547
|
+
if (!validation.ok) return protocolError(validation.reason);
|
|
548
|
+
const { seq, frameId, sessionEpoch } = validation;
|
|
549
|
+
if (firstSeq === null || seq > lastOutputSeq) return protocolError("candidate-seq-future", { seq, lastOutputSeq });
|
|
550
|
+
if (candidateState === "open") return protocolError("candidate-already-open", { openFrameId: candidate.frameId, frameId });
|
|
551
|
+
if (seq < lastProofSeq) return protocolError("candidate-seq-regressed", { seq, lastProofSeq });
|
|
552
|
+
if (frameId <= lastFrameId) return protocolError("frame-id-regressed", { frameId, lastFrameId });
|
|
553
|
+
lastFrameId = frameId;
|
|
554
|
+
previousComplete = cloneProof(currentProof);
|
|
555
|
+
candidate = { seq, frameId, sessionEpoch };
|
|
556
|
+
candidateState = "open";
|
|
557
|
+
return { status: "ok", candidate: { ...candidate } };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Abort an open candidate. The aborted frame can never become a proof. */
|
|
561
|
+
function abortCandidate(record) {
|
|
562
|
+
const validation = validateAbort(record);
|
|
563
|
+
if (!validation.ok) return protocolError(validation.reason);
|
|
564
|
+
const { seq, frameId, reason } = validation;
|
|
565
|
+
if (candidateState !== "open" || candidate === null) return protocolError("abort-without-candidate", { frameId });
|
|
566
|
+
if (candidate.frameId !== frameId) return protocolError("abort-frame-mismatch", { frameId, openFrameId: candidate.frameId });
|
|
567
|
+
if (seq < candidate.seq || seq > lastOutputSeq) return protocolError("abort-seq", { seq, candidateSeq: candidate.seq, lastOutputSeq });
|
|
568
|
+
candidate = null;
|
|
569
|
+
candidateState = "aborted";
|
|
570
|
+
diagnostic("candidate-aborted", { seq, frameId, reason });
|
|
571
|
+
return { status: "ok", candidateState };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Complete the open candidate. The accepted proof's `seq` is the preceding ordinary
|
|
576
|
+
* output segment. A new complete proof resets the trailing classifier and supersedes
|
|
577
|
+
* any same-seq proof already recorded for current coverage.
|
|
578
|
+
*/
|
|
579
|
+
function completeProof(record) {
|
|
580
|
+
const validation = validateProof(record);
|
|
581
|
+
if (!validation.ok) return protocolError(validation.reason);
|
|
582
|
+
const { seq, frameId, sessionEpoch, cursorVisible, metadata } = validation;
|
|
583
|
+
if (candidateState !== "open" || candidate === null) return protocolError("proof-without-candidate", { seq, frameId });
|
|
584
|
+
if (candidate.frameId !== frameId) return protocolError("proof-frame-mismatch", { frameId, openFrameId: candidate.frameId });
|
|
585
|
+
if (candidate.sessionEpoch !== sessionEpoch) return protocolError("proof-session-epoch-mismatch", { sessionEpoch, candidateSessionEpoch: candidate.sessionEpoch });
|
|
586
|
+
if (seq < candidate.seq) return protocolError("proof-seq-before-candidate", { seq, candidateSeq: candidate.seq });
|
|
587
|
+
if (seq > lastOutputSeq) return protocolError("proof-seq-future", { seq, lastOutputSeq });
|
|
588
|
+
if (seq < lastProofSeq) return protocolError("proof-seq-regressed", { seq, lastProofSeq });
|
|
589
|
+
const entry = journal.get(seq);
|
|
590
|
+
if (entry === undefined) return protocolError("proof-seq-not-observed", { seq });
|
|
591
|
+
const proof = { marker: "F", seq, frameId, sessionEpoch, metadata, cursorVisible };
|
|
592
|
+
entry.proofs.push(proof);
|
|
593
|
+
if (entry.proofs.length > MAX_PROOFS_PER_SEQ) {
|
|
594
|
+
entry.proofs.shift();
|
|
595
|
+
entry.droppedProofs += 1;
|
|
596
|
+
droppedProofs += 1;
|
|
597
|
+
}
|
|
598
|
+
lastProofSeq = seq;
|
|
599
|
+
currentProof = proof;
|
|
600
|
+
previousComplete = null;
|
|
601
|
+
candidate = null;
|
|
602
|
+
candidateState = "idle";
|
|
603
|
+
pendingSinceSeq = null;
|
|
604
|
+
classifier.reset({ cursorVisible });
|
|
605
|
+
return { status: "ok", proof: cloneProof(proof) };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Answer whether `snapshotSeq` is backed by a complete view proof plus only explicitly
|
|
610
|
+
* classified no-grid trailing controls. Returns `covered`, `unavailable` or `unknown`.
|
|
611
|
+
*/
|
|
612
|
+
function coverageFor(snapshotSeq) {
|
|
613
|
+
if (!Number.isInteger(snapshotSeq) || snapshotSeq < 1) return { status: "unknown", reason: "invalid-snapshot-seq", proof: null, throughSeq: null, cursorVisible: null };
|
|
614
|
+
if (firstSeq === null) return { status: "unknown", reason: "no-output", proof: null, throughSeq: null, cursorVisible: null };
|
|
615
|
+
if (gap) return { status: "unknown", reason: "output-gap", proof: null, throughSeq: null, cursorVisible: null };
|
|
616
|
+
if (snapshotSeq > lastOutputSeq) return { status: "unknown", reason: "snapshot-seq-future", proof: null, throughSeq: null, cursorVisible: null };
|
|
617
|
+
if (snapshotSeq < firstSeq) return { status: "unknown", reason: "snapshot-before-window", proof: null, throughSeq: null, cursorVisible: null };
|
|
618
|
+
if (candidateState === "open") {
|
|
619
|
+
return { status: "unavailable", reason: "candidate-open", proof: null, throughSeq: null, cursorVisible: null, previousComplete: cloneProof(previousComplete) };
|
|
620
|
+
}
|
|
621
|
+
if (candidateState === "aborted") {
|
|
622
|
+
return { status: "unavailable", reason: "candidate-aborted", proof: null, throughSeq: null, cursorVisible: null, previousComplete: cloneProof(previousComplete) };
|
|
623
|
+
}
|
|
624
|
+
const found = findNewestProofAtOrBefore(snapshotSeq);
|
|
625
|
+
if (found.proof === null) return { status: "unknown", reason: "no-proof", proof: null, throughSeq: null, cursorVisible: null };
|
|
626
|
+
const proof = found.proof;
|
|
627
|
+
if (proof.seq === snapshotSeq) {
|
|
628
|
+
return { status: "covered", reason: "exact-proof", proof: cloneProof(proof), throughSeq: snapshotSeq, cursorVisible: proof.cursorVisible };
|
|
629
|
+
}
|
|
630
|
+
const retained = earliestRetainedSeq();
|
|
631
|
+
let throughSeq = proof.seq;
|
|
632
|
+
let cursorVisible = proof.cursorVisible;
|
|
633
|
+
for (let seq = proof.seq + 1; seq <= snapshotSeq; seq += 1) {
|
|
634
|
+
const entry = journal.get(seq);
|
|
635
|
+
if (entry === undefined) {
|
|
636
|
+
const reason = retained !== null && seq < retained ? "journal-evicted" : found.evicted ? "proof-evicted" : "journal-gap";
|
|
637
|
+
return { status: "unknown", reason, proof: null, throughSeq, cursorVisible };
|
|
638
|
+
}
|
|
639
|
+
if (entry.status === "pending") {
|
|
640
|
+
return { status: "unavailable", reason: "trailing-pending", proof: null, throughSeq, cursorVisible, detail: "incomplete-escape", pendingBytes: entry.pendingBytes };
|
|
641
|
+
}
|
|
642
|
+
if (entry.status !== "safe") {
|
|
643
|
+
return { status: "unavailable", reason: "trailing-unsafe", proof: null, throughSeq, cursorVisible, detail: entry.reason };
|
|
644
|
+
}
|
|
645
|
+
if (entry.cursorVisible !== null) cursorVisible = entry.cursorVisible;
|
|
646
|
+
throughSeq = seq;
|
|
647
|
+
}
|
|
648
|
+
return { status: "covered", reason: "safe-trailing", proof: cloneProof(proof), throughSeq: snapshotSeq, cursorVisible };
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Bounded replay/catch-up query. Returns `ok` entries in seq order, or an explicit
|
|
653
|
+
* `evicted`/`gap`/`unknown` result — never a stitched or guessed history. Per-entry and
|
|
654
|
+
* aggregate `droppedProofs`/`droppedEvents` are surfaced on every result. Aggregate counts
|
|
655
|
+
* are LIFETIME totals; per-entry counts are RANGE-LOCAL. A fully retained range can still
|
|
656
|
+
* have a nonzero lifetime count, so range-completeness decisions must use the entries plus
|
|
657
|
+
* explicit gap/evicted status, not lifetime nonzero alone.
|
|
658
|
+
*/
|
|
659
|
+
function replay(fromSeq, throughSeq = lastOutputSeq) {
|
|
660
|
+
const drops = () => ({ droppedProofs, droppedEvents });
|
|
661
|
+
if (!Number.isInteger(fromSeq) || fromSeq < 1 || !Number.isInteger(throughSeq) || throughSeq < fromSeq) {
|
|
662
|
+
return { status: "unknown", reason: "invalid-range", ...drops(), entries: [] };
|
|
663
|
+
}
|
|
664
|
+
if (firstSeq === null) return { status: "unknown", reason: "no-output", ...drops(), entries: [] };
|
|
665
|
+
if (fromSeq < firstSeq) return { status: "unknown", reason: "before-window", ...drops(), entries: [] };
|
|
666
|
+
if (throughSeq > lastOutputSeq) return { status: "unknown", reason: "future-range", ...drops(), entries: [] };
|
|
667
|
+
const earliest = earliestRetainedSeq();
|
|
668
|
+
if (earliest === null || fromSeq < earliest) {
|
|
669
|
+
return { status: "evicted", earliestSeq: earliest, droppedSegments, ...drops(), entries: [] };
|
|
670
|
+
}
|
|
671
|
+
const entries = [];
|
|
672
|
+
for (let seq = fromSeq; seq <= throughSeq; seq += 1) {
|
|
673
|
+
const entry = journal.get(seq);
|
|
674
|
+
if (entry === undefined) return { status: "gap", missingSeq: seq, ...drops(), entries: [] };
|
|
675
|
+
if (entry.status === "unknown" && entry.reason === "output-gap") return { status: "gap", missingSeq: seq, ...drops(), entries: [] };
|
|
676
|
+
entries.push(cloneEntry(entry));
|
|
677
|
+
}
|
|
678
|
+
return { status: "ok", ...drops(), entries };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function snapshot() {
|
|
682
|
+
const earliest = earliestRetainedSeq();
|
|
683
|
+
return {
|
|
684
|
+
protocol: VIEW_PROOF_PROTOCOL,
|
|
685
|
+
firstSeq,
|
|
686
|
+
lastOutputSeq,
|
|
687
|
+
lastProofSeq,
|
|
688
|
+
lastFrameId,
|
|
689
|
+
gap,
|
|
690
|
+
candidateState,
|
|
691
|
+
candidate: candidate === null ? null : { ...candidate },
|
|
692
|
+
// Last COMPLETED proof only. This is not coverage: use coverageFor(snapshotSeq)
|
|
693
|
+
// to decide whether the current snapshot is proven.
|
|
694
|
+
currentProof: candidateState === "idle" ? cloneProof(currentProof) : null,
|
|
695
|
+
previousComplete: cloneProof(previousComplete),
|
|
696
|
+
journal: {
|
|
697
|
+
size: journal.size,
|
|
698
|
+
earliestSeq: earliest,
|
|
699
|
+
droppedSegments,
|
|
700
|
+
droppedProofs,
|
|
701
|
+
droppedEvents,
|
|
702
|
+
},
|
|
703
|
+
classifier: classifier.state(),
|
|
704
|
+
diagnostics: diagnostics.slice(),
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return { feedOutput, openCandidate, abortCandidate, completeProof, coverageFor, replay, snapshot };
|
|
709
|
+
}
|