@xenosystem/blocks 0.3.0 → 0.4.1
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/chunk-WE6A2DTY.js +68 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/ops/index.d.ts +2235 -0
- package/dist/ops/index.js +3757 -0
- package/dist/xterm-R3GIKSHA.js +83 -0
- package/package.json +21 -3
|
@@ -0,0 +1,2235 @@
|
|
|
1
|
+
import { PanelModule, PanelManifest } from '@xenosystem/panel-sdk';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
import { ThrottleClock } from '@xenosystem/tree-core';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The `xeno.core.console` contract.
|
|
7
|
+
*
|
|
8
|
+
* ## The gap every incumbent has: text lines, not records
|
|
9
|
+
*
|
|
10
|
+
* Every surveyed console stores a rendered string. That makes filtering a substring match, makes a
|
|
11
|
+
* structured payload impossible to expand, and makes multiplexing two sources into one view a
|
|
12
|
+
* concatenation rather than a join. A **record** carries its own level, source, scope and payload,
|
|
13
|
+
* so all three become trivial.
|
|
14
|
+
*
|
|
15
|
+
* @module
|
|
16
|
+
*/
|
|
17
|
+
/** Severity. Ordered from least to most severe — `LEVEL_ORDER` is the index. */
|
|
18
|
+
type XenoLogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error';
|
|
19
|
+
/** Severity order, least to most severe. */
|
|
20
|
+
declare const LEVEL_ORDER: readonly XenoLogLevel[];
|
|
21
|
+
/** One structured log record. */
|
|
22
|
+
interface XenoLogRecord {
|
|
23
|
+
/** Stable id — identity for `recordActivate`, selection and de-duplication bookkeeping. */
|
|
24
|
+
id: string;
|
|
25
|
+
/** Epoch ms. */
|
|
26
|
+
ts: number;
|
|
27
|
+
/** Severity. */
|
|
28
|
+
level: XenoLogLevel;
|
|
29
|
+
/** The human line. */
|
|
30
|
+
message: string;
|
|
31
|
+
/**
|
|
32
|
+
* Which stream this came from.
|
|
33
|
+
*
|
|
34
|
+
* **Multiplexing is the feature that makes this serve a BUILDER** rather than one app's stdout:
|
|
35
|
+
* panel logs, graph execution and host events land in one view, each filterable on its own.
|
|
36
|
+
*/
|
|
37
|
+
source?: string;
|
|
38
|
+
/** A finer grouping within a source — a node id, a panel id, a request id. */
|
|
39
|
+
scope?: string;
|
|
40
|
+
/** Structured payload, rendered as an expandable tree. Redacted before display. */
|
|
41
|
+
data?: unknown;
|
|
42
|
+
/**
|
|
43
|
+
* How many identical records this row stands for.
|
|
44
|
+
*
|
|
45
|
+
* Set by the panel's ADJACENT-RUN collapsing, never by the host.
|
|
46
|
+
*/
|
|
47
|
+
count?: number;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A partial update to ONE record, addressed by its `id`.
|
|
51
|
+
*
|
|
52
|
+
* ## Why an update-by-id channel exists at all
|
|
53
|
+
*
|
|
54
|
+
* **A record whose text grows is not expressible by `append` or `replace`.** One record per token
|
|
55
|
+
* is wrong — `collapseAdjacent` will not merge them, because it requires an identical `message`,
|
|
56
|
+
* so a streamed sentence renders as one row per token. `replace` of the whole buffer per token is
|
|
57
|
+
* the thousand-array-copies non-starter this module's header already refuses. A growing line
|
|
58
|
+
* therefore needs a third channel, and every streaming producer has one: a build log, a
|
|
59
|
+
* long-running job's output, a download progress line, a model response.
|
|
60
|
+
*
|
|
61
|
+
* ## What a patch may NOT carry
|
|
62
|
+
*
|
|
63
|
+
* `id` and `count` are absent from this interface by design, and stripped at runtime as well.
|
|
64
|
+
*
|
|
65
|
+
* - 🔴 **`id`** is the virtualized row key AND the argument the panel hands back through
|
|
66
|
+
* `recordActivate`. A patch that could move it would remount the row mid-stream — losing its
|
|
67
|
+
* expansion state and its scroll anchor — and would silently repoint the host's reveal hook at a
|
|
68
|
+
* different line.
|
|
69
|
+
* - **`count`** belongs to the panel, not the host ({@link XenoLogRecord.count} says so), and a
|
|
70
|
+
* host-set count would be overwritten by the next projection anyway.
|
|
71
|
+
*
|
|
72
|
+
* ⚠️ A patch cannot UNSET a field. `{ scope: undefined }` is indistinguishable from an absent key
|
|
73
|
+
* in JavaScript, so an absent key always means "leave this alone".
|
|
74
|
+
*/
|
|
75
|
+
interface XenoLogPatch {
|
|
76
|
+
/** New severity. An unrecognised level is ignored — it would corrupt the level counts. */
|
|
77
|
+
level?: XenoLogLevel;
|
|
78
|
+
/** Replace the message outright. */
|
|
79
|
+
message?: string;
|
|
80
|
+
/**
|
|
81
|
+
* Concatenate onto the message — the streaming channel.
|
|
82
|
+
*
|
|
83
|
+
* Applied AFTER `message`, so one patch can reset a line and start streaming into it again.
|
|
84
|
+
*/
|
|
85
|
+
appendMessage?: string;
|
|
86
|
+
/** Move the record to another stream. */
|
|
87
|
+
source?: string;
|
|
88
|
+
/** Re-scope it — a request id that only became known once the response arrived. */
|
|
89
|
+
scope?: string;
|
|
90
|
+
/** Replace the structured payload. Redacted like everything else; send `null` to empty it. */
|
|
91
|
+
data?: unknown;
|
|
92
|
+
/**
|
|
93
|
+
* Restate the timestamp.
|
|
94
|
+
*
|
|
95
|
+
* ⚠️ Does NOT reorder the buffer. Records sit in arrival order, which is what makes a console
|
|
96
|
+
* readable; a patch that could move a line would make the view jump under a reader's eyes.
|
|
97
|
+
*/
|
|
98
|
+
ts?: number;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* An append-oriented update.
|
|
102
|
+
*
|
|
103
|
+
* **A console that re-pushes its whole buffer per line is a non-starter** — at a thousand lines a
|
|
104
|
+
* second that is a thousand array copies. `append` is the hot path for a new line, `patch` is the
|
|
105
|
+
* hot path for a line that is still GROWING; `replace` exists for a source that genuinely
|
|
106
|
+
* restarts, and `clear` for one that resets.
|
|
107
|
+
*/
|
|
108
|
+
interface XenoLogDelta {
|
|
109
|
+
/** Monotonic revision. A delta whose rev is not greater than the current one is dropped. */
|
|
110
|
+
rev?: number;
|
|
111
|
+
/** Records to append, oldest first. The common case. */
|
|
112
|
+
append?: XenoLogRecord[];
|
|
113
|
+
/**
|
|
114
|
+
* Partial updates keyed by record id — the hot path for a line that is still being written.
|
|
115
|
+
*
|
|
116
|
+
* Applied AFTER `append`, so a single delta can open a record and deliver its first chunk.
|
|
117
|
+
*
|
|
118
|
+
* 🔴 **A patch for an id the buffer does not hold is DROPPED, never upserted.** A console record
|
|
119
|
+
* cannot be invented from a partial: it would have no `ts` to place it in an arrival-ordered
|
|
120
|
+
* buffer and no `level` to filter it by, so an upsert would have to FABRICATE a severity — and a
|
|
121
|
+
* log line whose severity the panel made up is the one lie a console must never tell. A producer
|
|
122
|
+
* that can deliver out of order must `append` the record first (an empty message is fine), which
|
|
123
|
+
* is what "opening a stream" means everywhere else. Dropped patches are counted and shown,
|
|
124
|
+
* because a silently ignored wire is indistinguishable from a dead one.
|
|
125
|
+
*/
|
|
126
|
+
patch?: Record<string, XenoLogPatch>;
|
|
127
|
+
/** Replace the whole buffer (a source restarted). */
|
|
128
|
+
replace?: XenoLogRecord[];
|
|
129
|
+
/** Drop everything. `clear` wins over `append` in the same delta. */
|
|
130
|
+
clear?: boolean;
|
|
131
|
+
/** Restrict `replace`/`clear` to one source, leaving the others intact. */
|
|
132
|
+
source?: string;
|
|
133
|
+
}
|
|
134
|
+
/** A named stream, for the per-source filter UI. */
|
|
135
|
+
interface XenoLogSource {
|
|
136
|
+
id: string;
|
|
137
|
+
/** Display label. Defaults to the id. */
|
|
138
|
+
label?: string;
|
|
139
|
+
/** Whether its records are currently shown. */
|
|
140
|
+
enabled: boolean;
|
|
141
|
+
/** How many records it has contributed. */
|
|
142
|
+
count: number;
|
|
143
|
+
}
|
|
144
|
+
/** The active filter. */
|
|
145
|
+
interface XenoLogFilter {
|
|
146
|
+
/** Minimum severity to show. */
|
|
147
|
+
minLevel?: XenoLogLevel;
|
|
148
|
+
/** Case-insensitive substring over message, source and scope. */
|
|
149
|
+
search?: string;
|
|
150
|
+
/** Sources to hide. Absent ⇒ all shown. */
|
|
151
|
+
mutedSources?: string[];
|
|
152
|
+
}
|
|
153
|
+
/** The panel's serialized state. **Records are never persisted** — a log is not a document. */
|
|
154
|
+
interface ConsolePanelState {
|
|
155
|
+
filter: XenoLogFilter;
|
|
156
|
+
follow: boolean;
|
|
157
|
+
collapse: boolean;
|
|
158
|
+
}
|
|
159
|
+
/** What the controller exposes to its view. */
|
|
160
|
+
interface ConsoleViewState {
|
|
161
|
+
/** Visible rows, after filtering and collapsing. */
|
|
162
|
+
rows: XenoLogRecord[];
|
|
163
|
+
/** Every known source, for the filter chips. */
|
|
164
|
+
sources: XenoLogSource[];
|
|
165
|
+
/** The active filter. */
|
|
166
|
+
filter: XenoLogFilter;
|
|
167
|
+
/** Counts per level across the WHOLE buffer, so a filter chip can show what it would reveal. */
|
|
168
|
+
counts: Record<XenoLogLevel, number>;
|
|
169
|
+
/** Total records held (before filtering). */
|
|
170
|
+
total: number;
|
|
171
|
+
/** Following the tail. */
|
|
172
|
+
follow: boolean;
|
|
173
|
+
/** Collapsing adjacent duplicates. */
|
|
174
|
+
collapse: boolean;
|
|
175
|
+
/** Records dropped by the ring buffer since the last clear. */
|
|
176
|
+
dropped: number;
|
|
177
|
+
/**
|
|
178
|
+
* Patches that addressed no record, since the last clear.
|
|
179
|
+
*
|
|
180
|
+
* Surfaced rather than swallowed: a producer that patches an id it never appended sees nothing
|
|
181
|
+
* happen, and "the wire is mis-keyed" and "the wire is dead" look identical from the outside.
|
|
182
|
+
* This is the one number that tells them apart.
|
|
183
|
+
*/
|
|
184
|
+
droppedPatches: number;
|
|
185
|
+
}
|
|
186
|
+
/** What the panel emits when a record is opened. */
|
|
187
|
+
interface XenoLogActivate {
|
|
188
|
+
recordId: string;
|
|
189
|
+
source?: string;
|
|
190
|
+
scope?: string;
|
|
191
|
+
}
|
|
192
|
+
/** Is `level` at least as severe as `min`? */
|
|
193
|
+
declare function meetsLevel(level: XenoLogLevel, min: XenoLogLevel | undefined): boolean;
|
|
194
|
+
/** Does a record match a search term? Searches message, source and scope — not the payload. */
|
|
195
|
+
declare function matchesSearch$1(record: XenoLogRecord, term: string): boolean;
|
|
196
|
+
/**
|
|
197
|
+
* Collapse ADJACENT identical records into one row carrying a count.
|
|
198
|
+
*
|
|
199
|
+
* Adjacent-run, not global: a hundred identical errors in a row are one row with `×100`, but the
|
|
200
|
+
* same error recurring after other output stays a separate row — collapsing globally would hide
|
|
201
|
+
* *when* it happened, which is usually the whole question.
|
|
202
|
+
*
|
|
203
|
+
* Two records are identical when their level, message, source and scope all match. The payload is
|
|
204
|
+
* deliberately excluded: two records that read the same but carry different data are still the same
|
|
205
|
+
* event repeating, and hashing payloads on the hot path would cost more than the feature saves.
|
|
206
|
+
*
|
|
207
|
+
* @param records - Records in display order.
|
|
208
|
+
* @returns Collapsed rows; each carries `count` (always ≥ 1).
|
|
209
|
+
*/
|
|
210
|
+
declare function collapseAdjacent(records: readonly XenoLogRecord[]): XenoLogRecord[];
|
|
211
|
+
/**
|
|
212
|
+
* How much already-clean tail is re-scanned together with an appended fragment.
|
|
213
|
+
*
|
|
214
|
+
* 🔴 **This constant IS the security invariant of streaming.** Redaction runs per fragment, and a
|
|
215
|
+
* secret does not respect a fragment boundary: `"token=eyJhbGciOi"` is not token-shaped and
|
|
216
|
+
* `"JIUzI1NiJ9.eyJzdWIiOjF9.sig"` is not either, but concatenated they are a JWT. Redacting each
|
|
217
|
+
* arrival in isolation would therefore assemble, in the buffer, a credential neither half could be
|
|
218
|
+
* caught as — the half-clean record. So an append is redacted over `[tail of what is already
|
|
219
|
+
* there] + [the new fragment]`, and the window has to be at least as long as the longest thing a
|
|
220
|
+
* detector must see whole.
|
|
221
|
+
*
|
|
222
|
+
* 1024 is far above what the shape detectors need: they fire EAGERLY, so a run of opaque
|
|
223
|
+
* characters is masked the moment it reaches 40 and never gets the chance to grow. The window
|
|
224
|
+
* exists for the value-driven pass, which matches an exact registered secret and therefore needs
|
|
225
|
+
* the whole of it in view — which is why the controller widens the window to the longest secret
|
|
226
|
+
* the host declares rather than trusting this number alone.
|
|
227
|
+
*/
|
|
228
|
+
declare const REDACTION_CARRY = 1024;
|
|
229
|
+
/** Marks a message whose head was dropped by {@link truncateLogMessage}. */
|
|
230
|
+
declare const MESSAGE_ELISION = "\u2026";
|
|
231
|
+
/**
|
|
232
|
+
* Append `addition` to `existing`, re-redacting across the join.
|
|
233
|
+
*
|
|
234
|
+
* The already-clean head is left untouched — it was scanned when it arrived, and rescanning the
|
|
235
|
+
* whole message on every fragment turns a streamed line into O(n²) work, which is the same
|
|
236
|
+
* complaint this module's header makes about re-pushing the buffer.
|
|
237
|
+
*
|
|
238
|
+
* ⚠️ `redact` must be IDEMPOTENT, because the carry window is scanned once per fragment. The SDK's
|
|
239
|
+
* `redactForLog` is: it replaces a match with `«redacted»`, which matches neither detector.
|
|
240
|
+
*
|
|
241
|
+
* @param existing - Text already in the buffer, already redacted.
|
|
242
|
+
* @param addition - The newly arrived fragment, NOT yet redacted.
|
|
243
|
+
* @param redact - The redactor to run over the join window.
|
|
244
|
+
* @param carry - Window size; see {@link REDACTION_CARRY}.
|
|
245
|
+
* @returns The joined, redacted text.
|
|
246
|
+
*
|
|
247
|
+
* @example
|
|
248
|
+
* ```ts
|
|
249
|
+
* // Neither half is token-shaped; the join is, and is caught.
|
|
250
|
+
* spliceRedactedAppend('token=eyJhbGciOi', 'JIUzI1NiJ9.eyJzdWIiOjF9.sig', redactForLog)
|
|
251
|
+
* // → 'token=«redacted»'
|
|
252
|
+
* ```
|
|
253
|
+
*/
|
|
254
|
+
declare function spliceRedactedAppend(existing: string, addition: string, redact: (text: string) => string, carry?: number): string;
|
|
255
|
+
/**
|
|
256
|
+
* Cap a message, keeping the NEWEST text.
|
|
257
|
+
*
|
|
258
|
+
* 🔴 The ring buffer bounds how many records are held; nothing bounded how large ONE of them could
|
|
259
|
+
* get. A producer streaming into a single record forever is an unbounded allocation inside a
|
|
260
|
+
* structure whose whole promise is that it is bounded — and it is invisible, because the record
|
|
261
|
+
* count never moves. Keeping the tail matches the ring buffer's own rule: a console is a window on
|
|
262
|
+
* recent output.
|
|
263
|
+
*
|
|
264
|
+
* ⚠️ Order matters where this is used: a message is redacted and only THEN truncated, so the cap
|
|
265
|
+
* counts redacted characters. Truncating first would measure raw text — and `«redacted»` is ten
|
|
266
|
+
* characters where the JWT it replaced was two hundred — leaving a retained window whose real
|
|
267
|
+
* length depends on how much of it happened to be a secret. That window is exactly what the next
|
|
268
|
+
* fragment's redaction scan is drawn from, so it must not vary. The other half of the same
|
|
269
|
+
* guarantee is the controller's clamp: a cap shorter than {@link REDACTION_CARRY} would let
|
|
270
|
+
* truncation eat the window outright.
|
|
271
|
+
*
|
|
272
|
+
* @param text - The message.
|
|
273
|
+
* @param max - Maximum length including the elision marker.
|
|
274
|
+
* @returns `text`, or its last `max - 1` characters behind {@link MESSAGE_ELISION}.
|
|
275
|
+
*/
|
|
276
|
+
declare function truncateLogMessage(text: string, max: number): string;
|
|
277
|
+
/** Format a record as one copyable line. */
|
|
278
|
+
declare function formatRecord(record: XenoLogRecord): string;
|
|
279
|
+
/** Stringify a payload without throwing on a cycle. */
|
|
280
|
+
declare function safeJson(value: unknown): string;
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The Console controller.
|
|
284
|
+
*
|
|
285
|
+
* ## 🔴 Redaction is not optional here
|
|
286
|
+
*
|
|
287
|
+
* This panel is the **single most likely place in the catalog for a secret to be displayed**: a log
|
|
288
|
+
* line is exactly where a token ends up, and unlike a log FILE this one is on screen. Every record
|
|
289
|
+
* is passed through `@xenosystem/panel-sdk`'s `redactForLog` **before it enters the buffer**, so the
|
|
290
|
+
* unredacted value is never stored, never searched, never copied, and never reaches
|
|
291
|
+
* `recordActivate`. Redacting at render time would leave the secret in memory and in the clipboard.
|
|
292
|
+
*
|
|
293
|
+
* 🔴 **A streamed record is redacted across every fragment boundary, not per fragment.** A secret
|
|
294
|
+
* split by an `appendMessage` — `"eyJhbGciOi"` then `"JIUzI1NiJ9.…"` — is invisible to a redactor
|
|
295
|
+
* that only ever sees one half, and would land in the buffer fully assembled: the half-clean
|
|
296
|
+
* record. `spliceRedactedAppend` is where that is prevented, and the message cap is clamped so
|
|
297
|
+
* truncation can never shorten the window it depends on.
|
|
298
|
+
*
|
|
299
|
+
* @module
|
|
300
|
+
*/
|
|
301
|
+
|
|
302
|
+
/** The host seam. */
|
|
303
|
+
interface ConsoleHostBridge {
|
|
304
|
+
emit(portId: string, value: unknown): void;
|
|
305
|
+
/** Known secret values, re-read per record so a rotated vault needs no re-wiring. */
|
|
306
|
+
getSecrets?: () => Iterable<string>;
|
|
307
|
+
}
|
|
308
|
+
/** Construction options. */
|
|
309
|
+
interface ConsoleControllerOptions {
|
|
310
|
+
host: ConsoleHostBridge;
|
|
311
|
+
initial?: Partial<ConsolePanelState>;
|
|
312
|
+
/** Ring-buffer capacity. Default 5000 — a console is not an archive. */
|
|
313
|
+
maxRecords?: number;
|
|
314
|
+
/**
|
|
315
|
+
* Maximum characters in ONE record's message. Default 65536.
|
|
316
|
+
*
|
|
317
|
+
* 🔴 `maxRecords` bounds how many lines are held and bounded nothing else until streaming
|
|
318
|
+
* existed: a producer appending into a single record forever grows without limit while the
|
|
319
|
+
* record count never moves, so the leak is invisible to every counter the panel shows. Clamped
|
|
320
|
+
* to at least twice {@link REDACTION_CARRY} — a cap short enough to eat the redaction window
|
|
321
|
+
* would let truncation re-open the split-secret hole the window exists to close.
|
|
322
|
+
*/
|
|
323
|
+
maxMessageChars?: number;
|
|
324
|
+
/** Collapse adjacent duplicates. Default `true`. */
|
|
325
|
+
collapse?: boolean;
|
|
326
|
+
/** Follow the tail. Default `true`. */
|
|
327
|
+
follow?: boolean;
|
|
328
|
+
}
|
|
329
|
+
/** The Console panel controller. */
|
|
330
|
+
declare class ConsoleController {
|
|
331
|
+
private readonly host;
|
|
332
|
+
private readonly listeners;
|
|
333
|
+
private readonly maxRecords;
|
|
334
|
+
private readonly maxMessageChars;
|
|
335
|
+
private records;
|
|
336
|
+
/**
|
|
337
|
+
* Record id → ABSOLUTE position, where absolute = array index + {@link origin}.
|
|
338
|
+
*
|
|
339
|
+
* Patching by id has to be O(1) or streaming is worse than the thing it replaces: a linear scan
|
|
340
|
+
* of a 5 000-record buffer per fragment, at a thousand fragments a second, is five million
|
|
341
|
+
* comparisons a second to update one line. (`runs/src/controller.ts` keeps the same index for the
|
|
342
|
+
* same reason.)
|
|
343
|
+
*
|
|
344
|
+
* The absolute/origin split is what makes the ring buffer cheap. The buffer trims from the FRONT,
|
|
345
|
+
* so storing raw array indices would mean decrementing every entry on every trim — O(n) per
|
|
346
|
+
* append once the buffer is full. An offset moves the whole map in one addition instead.
|
|
347
|
+
*
|
|
348
|
+
* ⚠️ Records with no id, or an empty one, are not indexed and therefore cannot be patched. That
|
|
349
|
+
* is the honest outcome: `id` is what "addressed" means here, and there is nothing else to
|
|
350
|
+
* address them by.
|
|
351
|
+
*/
|
|
352
|
+
private index;
|
|
353
|
+
/** Absolute position of `records[0]`. */
|
|
354
|
+
private origin;
|
|
355
|
+
private filter;
|
|
356
|
+
private follow;
|
|
357
|
+
private collapse;
|
|
358
|
+
private rev;
|
|
359
|
+
private dropped;
|
|
360
|
+
private droppedPatches;
|
|
361
|
+
private snapshot;
|
|
362
|
+
constructor(options: ConsoleControllerOptions);
|
|
363
|
+
subscribe: (listener: () => void) => (() => void);
|
|
364
|
+
getState: () => ConsoleViewState;
|
|
365
|
+
private notify;
|
|
366
|
+
/**
|
|
367
|
+
* Apply a delta.
|
|
368
|
+
*
|
|
369
|
+
* @param delta - Append / patch / replace / clear.
|
|
370
|
+
* @returns `true` if applied; `false` if dropped as stale.
|
|
371
|
+
*/
|
|
372
|
+
apply(delta: XenoLogDelta): boolean;
|
|
373
|
+
/**
|
|
374
|
+
* Apply the `patch` map of a delta.
|
|
375
|
+
*
|
|
376
|
+
* ⚠️ Every field is checked, not cast. A wire is a user-editable connection, so the wrong shape
|
|
377
|
+
* arriving is a normal mis-wire rather than an exceptional condition — and the failure mode here
|
|
378
|
+
* is not a throw but silent corruption: a `level` of `"shout"` would add a key to the level
|
|
379
|
+
* counts that no filter can ever match and no chip can ever reveal.
|
|
380
|
+
*/
|
|
381
|
+
private applyPatches;
|
|
382
|
+
/**
|
|
383
|
+
* Fold a patch into a record, redacting what the patch brought in.
|
|
384
|
+
*
|
|
385
|
+
* 🔴 The append path redacts across the JOIN, not the fragment — {@link spliceRedactedAppend}
|
|
386
|
+
* carries the reasoning. Truncation runs last so the cap counts redacted characters; see
|
|
387
|
+
* {@link truncateLogMessage} for why measuring raw text would make the retained window vary.
|
|
388
|
+
*
|
|
389
|
+
* ⚠️ `source` is deliberately NOT redacted, matching `sanitize` on the append path: it is a
|
|
390
|
+
* stream name the host chose, and redacting it would break the per-source mute chips it keys.
|
|
391
|
+
*/
|
|
392
|
+
private merge;
|
|
393
|
+
/** Array position of `id`, or `-1` when it is unknown or has been evicted. */
|
|
394
|
+
private positionOf;
|
|
395
|
+
/**
|
|
396
|
+
* Index a record.
|
|
397
|
+
*
|
|
398
|
+
* ⚠️ Last-wins when a host re-uses an id, which the contract forbids but nothing prevents. For a
|
|
399
|
+
* PATCH that is the right answer — a producer that re-used an id is talking about the line it
|
|
400
|
+
* just wrote, not one that scrolled past — and it also keeps eviction correct, because the ring
|
|
401
|
+
* trims from the front and so retires the older twin first.
|
|
402
|
+
*/
|
|
403
|
+
private remember;
|
|
404
|
+
/** Drop an index entry, but only if it still points at the position being evicted. */
|
|
405
|
+
private forget;
|
|
406
|
+
/**
|
|
407
|
+
* Rebuild the whole index.
|
|
408
|
+
*
|
|
409
|
+
* Only for `clear` and `replace`, which remove from the middle and so invalidate every position
|
|
410
|
+
* after the hole. Those are cold paths — a source restarting — and paying O(n) there is what buys
|
|
411
|
+
* O(1) on the hot ones.
|
|
412
|
+
*/
|
|
413
|
+
private reindex;
|
|
414
|
+
/** Append records directly — sugar for the common case. */
|
|
415
|
+
append(records: readonly XenoLogRecord[]): boolean;
|
|
416
|
+
/**
|
|
417
|
+
* Redact a record on the way IN.
|
|
418
|
+
*
|
|
419
|
+
* Not at render: redacting late leaves the secret in the buffer, in search results, and in
|
|
420
|
+
* whatever `copy` puts on the clipboard.
|
|
421
|
+
*/
|
|
422
|
+
private sanitize;
|
|
423
|
+
/** Replace the filter. */
|
|
424
|
+
setFilter(filter: XenoLogFilter): void;
|
|
425
|
+
/** Set the search term. */
|
|
426
|
+
setSearch(search: string): void;
|
|
427
|
+
/** Set the minimum severity. */
|
|
428
|
+
setLevel(minLevel: XenoLogLevel | undefined): boolean;
|
|
429
|
+
/** Show or hide one source. */
|
|
430
|
+
toggleSource(source: string, enabled?: boolean): void;
|
|
431
|
+
/** The visible rows, filtered and (optionally) collapsed. */
|
|
432
|
+
rows(): XenoLogRecord[];
|
|
433
|
+
/** Every source seen, with its contribution count and current visibility. */
|
|
434
|
+
sources(): XenoLogSource[];
|
|
435
|
+
/** Follow the tail. Auto-disabled by the view when the user scrolls up. */
|
|
436
|
+
setFollow(follow: boolean): void;
|
|
437
|
+
/** Collapse adjacent duplicates. */
|
|
438
|
+
setCollapse(collapse: boolean): void;
|
|
439
|
+
/** Drop every record. Emits nothing — clearing a view is not an event a host acts on. */
|
|
440
|
+
clear(): void;
|
|
441
|
+
/**
|
|
442
|
+
* A record was opened.
|
|
443
|
+
*
|
|
444
|
+
* **This is what makes the panel useful in a builder**: clicking a log line tells the host which
|
|
445
|
+
* node or panel produced it, so it can reveal the offender.
|
|
446
|
+
*/
|
|
447
|
+
activate(recordId: string): boolean;
|
|
448
|
+
/** The copyable text of a record — already redacted, because the buffer is. */
|
|
449
|
+
copyText(recordId: string): string | null;
|
|
450
|
+
/** The copyable text of everything currently visible. */
|
|
451
|
+
copyVisible(): string;
|
|
452
|
+
/** Ask the host to open a context menu. */
|
|
453
|
+
requestContextMenu(recordId: string, x: number, y: number): void;
|
|
454
|
+
/** The raw buffer, for `get_records`. */
|
|
455
|
+
all(): XenoLogRecord[];
|
|
456
|
+
/** Serialize. Filter and toggles — **never the records**; a log is not a document. */
|
|
457
|
+
serialize(): ConsolePanelState;
|
|
458
|
+
/** Restore. Does not emit. */
|
|
459
|
+
deserialize(state: Partial<ConsolePanelState>): void;
|
|
460
|
+
/** Drop listeners and the buffer. */
|
|
461
|
+
dispose(): void;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* The `PanelModule` — view wired inside the package (one React copy).
|
|
466
|
+
*
|
|
467
|
+
* @module
|
|
468
|
+
*/
|
|
469
|
+
|
|
470
|
+
/** Everything a renderer needs: the controller plus the resolved config. */
|
|
471
|
+
interface ConsoleRenderContext {
|
|
472
|
+
controller: ConsoleController;
|
|
473
|
+
config: {
|
|
474
|
+
rowHeight: number;
|
|
475
|
+
showTimestamps: boolean;
|
|
476
|
+
emptyHint?: string;
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
/** Options for {@link createConsolePanel}. */
|
|
480
|
+
interface CreateConsolePanelOptions {
|
|
481
|
+
/**
|
|
482
|
+
* Known secret values, re-read per record.
|
|
483
|
+
*
|
|
484
|
+
* Supply it when the host has a vault: the shape-driven backstop catches an unregistered token,
|
|
485
|
+
* but an exact value the host already knows is caught even when it looks ordinary.
|
|
486
|
+
*/
|
|
487
|
+
getSecrets?: () => Iterable<string>;
|
|
488
|
+
/** Override the view. Rarely needed — mounting inside the package keeps React singular. */
|
|
489
|
+
render?: (root: HTMLElement, context: ConsoleRenderContext) => () => void;
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Build the Console panel module.
|
|
493
|
+
*
|
|
494
|
+
* @param options - Secret source and optional renderer override.
|
|
495
|
+
* @returns The module.
|
|
496
|
+
*/
|
|
497
|
+
declare function createConsolePanel(options?: CreateConsolePanelOptions): PanelModule;
|
|
498
|
+
/** The default Console panel module — view already wired. Register THIS. */
|
|
499
|
+
declare const consolePanel: PanelModule;
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* The `xeno.core.console` manifest.
|
|
503
|
+
*
|
|
504
|
+
* Capabilities: `storage.local` only. The panel receives records; it never opens a stream itself.
|
|
505
|
+
*
|
|
506
|
+
* @module
|
|
507
|
+
*/
|
|
508
|
+
|
|
509
|
+
/** The canonical manifest id. */
|
|
510
|
+
declare const CONSOLE_PANEL_ID = "xeno.core.console";
|
|
511
|
+
/** The `xeno.core.console` manifest. */
|
|
512
|
+
declare const consoleManifest: PanelManifest;
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* The Console panel view — virtualized, follow-tail, per-source chips.
|
|
516
|
+
*
|
|
517
|
+
* Composes `@xenosystem/workbench/primitives`; the host must ensure `@xenosystem/workbench/primitives.css` is
|
|
518
|
+
* present.
|
|
519
|
+
*
|
|
520
|
+
* @module
|
|
521
|
+
*/
|
|
522
|
+
|
|
523
|
+
/** Props for {@link ConsolePanelView}. */
|
|
524
|
+
interface ConsolePanelViewProps {
|
|
525
|
+
controller: ConsoleController;
|
|
526
|
+
rowHeight?: number;
|
|
527
|
+
showTimestamps?: boolean;
|
|
528
|
+
emptyHint?: string;
|
|
529
|
+
}
|
|
530
|
+
/** The Console panel view. */
|
|
531
|
+
declare function ConsolePanelView({ controller, rowHeight, showTimestamps, emptyHint, }: ConsolePanelViewProps): ReactNode;
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* The `xeno.core.runs` contract.
|
|
535
|
+
*
|
|
536
|
+
* ## 🔴 The boundary: runs is NOT history and NOT versions
|
|
537
|
+
*
|
|
538
|
+
* Three genuinely different things that keep getting conflated:
|
|
539
|
+
*
|
|
540
|
+
* | Panel | Models | Ordered by | The user question |
|
|
541
|
+
* |---|---|---|---|
|
|
542
|
+
* | **`xeno.core.runs`** | *executions* — things that started, progressed and ended | when they ran | "did it work, and what happened?" |
|
|
543
|
+
* | `xeno.core.history` | an *undo stack* — reversible document edits | position in a stack, with a cursor | "how do I get back?" |
|
|
544
|
+
* | `xeno.core.versions` | *document snapshots* | when they were saved | "what did this look like then?" |
|
|
545
|
+
*
|
|
546
|
+
* A run is **not undoable** and cancelling one is not going back. The conflation is not
|
|
547
|
+
* hypothetical: xeno-workflow's execution-history panel lives in a directory literally named
|
|
548
|
+
* `components/history/`, and its failed-runs panel is a *second panel* for what is one archetype
|
|
549
|
+
* filtered to `status: 'failed'` (§ landmine L2 below).
|
|
550
|
+
*
|
|
551
|
+
* @module
|
|
552
|
+
*/
|
|
553
|
+
/**
|
|
554
|
+
* Run lifecycle — **the single source the whole contract is derived from.**
|
|
555
|
+
*
|
|
556
|
+
* The vocabulary unions what the incumbents each had: workflow contributed
|
|
557
|
+
* `running | success | error | cancelled` plus trigger and duration, browser's downloads
|
|
558
|
+
* contributed `paused` and progress, post's approvals contributed multi-step lifecycle, and
|
|
559
|
+
* `queued` / `blocked` come from every queue that has ever had a backlog. `requested`,
|
|
560
|
+
* `awaiting-decision` and `awaiting-permission` join them from the same survey: a hosted run is
|
|
561
|
+
* asked for before any queue accepts it, an approvals queue stops on a human, and a sandboxed one
|
|
562
|
+
* stops on a grant. xeno-agent-interface's own run lifecycle carries `starting`,
|
|
563
|
+
* `waiting_for_user` and `waiting_for_permission` for exactly these three — the names here are the
|
|
564
|
+
* generic form of that, because a build waiting on a reviewer and a download waiting on a folder
|
|
565
|
+
* grant are the same fact.
|
|
566
|
+
*
|
|
567
|
+
* 🔴 **Widening this union used to be a THREE-PLACE edit with nothing making the places agree.**
|
|
568
|
+
* The union was written out here, restated as a hardcoded literal in `RunsController.getState` to
|
|
569
|
+
* seed `counts`, and restated a third time in `ALL_STATUSES`. Either restatement could silently
|
|
570
|
+
* fall behind: a status missing from the `counts` seed made `counts[status] += 1` evaluate
|
|
571
|
+
* `undefined + 1`, so a real status rendered as a `NaN` chip and its runs vanished from the
|
|
572
|
+
* filter. Everything is now derived from this one tuple.
|
|
573
|
+
*/
|
|
574
|
+
declare const RUN_STATUSES: readonly ["requested", "queued", "running", "paused", "blocked", "awaiting-decision", "awaiting-permission", "succeeded", "failed", "cancelled"];
|
|
575
|
+
/** Run lifecycle. */
|
|
576
|
+
type XenoRunStatus = (typeof RUN_STATUSES)[number];
|
|
577
|
+
/**
|
|
578
|
+
* What a status says about whether the run will move on its own.
|
|
579
|
+
*
|
|
580
|
+
* Three classes, not two, and the third is the one consumers keep getting wrong:
|
|
581
|
+
*
|
|
582
|
+
* - `active` — it will progress without anyone doing anything.
|
|
583
|
+
* - `waiting` — non-terminal, but nothing happens until something OUTSIDE the run acts.
|
|
584
|
+
* - `terminal` — finished, whatever the outcome.
|
|
585
|
+
*
|
|
586
|
+
* A dashboard that folds `waiting` into `active` reports a healthy pipeline while every run in it
|
|
587
|
+
* is stopped on a permission prompt nobody has seen.
|
|
588
|
+
*/
|
|
589
|
+
type XenoRunLifecycle = 'active' | 'waiting' | 'terminal';
|
|
590
|
+
/**
|
|
591
|
+
* Which class is this status in?
|
|
592
|
+
*
|
|
593
|
+
* 🔴 An unrecognised string answers `waiting`, not `active` and not `terminal`. A host one version
|
|
594
|
+
* ahead of this build sends a status nothing here has heard of, and every other answer is worse:
|
|
595
|
+
* `terminal` files a still-executing run into the success-rate denominator and offers Retry for
|
|
596
|
+
* work that is still running; `active` claims it is progressing. `waiting` is the honest reading —
|
|
597
|
+
* it has not finished and it is not moving by itself as far as we can tell.
|
|
598
|
+
*/
|
|
599
|
+
declare function lifecycleOf(status: XenoRunStatus): XenoRunLifecycle;
|
|
600
|
+
/**
|
|
601
|
+
* Statuses a run cannot leave. **Derived from {@link RUN_LIFECYCLE}, never restated.**
|
|
602
|
+
*
|
|
603
|
+
* **Terminal vs non-terminal belongs in the contract, not in each consumer.** workflow's
|
|
604
|
+
* `analyticsModel` computes success rate over terminal runs only — a rate that counted the three
|
|
605
|
+
* runs still executing would tick downward every time work started, which is the opposite of what
|
|
606
|
+
* it means. Every consumer needs that distinction, so it is defined once.
|
|
607
|
+
*/
|
|
608
|
+
declare const TERMINAL_STATUSES: ReadonlySet<XenoRunStatus>;
|
|
609
|
+
/** Has this run finished, one way or another? */
|
|
610
|
+
declare function isTerminal(status: XenoRunStatus): boolean;
|
|
611
|
+
/** Is this run still going to change on its own? */
|
|
612
|
+
declare function isActive(status: XenoRunStatus): boolean;
|
|
613
|
+
/** Is this run stopped on something outside itself — a person, a grant, an upstream job? */
|
|
614
|
+
declare function isWaiting(status: XenoRunStatus): boolean;
|
|
615
|
+
/** A typed failure. Never a bare string — a consumer must be able to branch and to group. */
|
|
616
|
+
interface XenoRunError {
|
|
617
|
+
/** Machine code, and the key the failure view groups by. */
|
|
618
|
+
code: string;
|
|
619
|
+
/** Host-authored sentence, rendered verbatim. */
|
|
620
|
+
message: string;
|
|
621
|
+
/** Extra context (a stack, a node id, a response body excerpt). */
|
|
622
|
+
detail?: string;
|
|
623
|
+
/** Whether retrying might succeed. Drives whether Retry is offered at all. */
|
|
624
|
+
retryable?: boolean;
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* One step of a run — **recursively**.
|
|
628
|
+
*
|
|
629
|
+
* Modelled as a nested list rather than a second panel: workflow expands a run to per-node
|
|
630
|
+
* input/output, post shows multi-step approval progress. Same shape, one panel.
|
|
631
|
+
*
|
|
632
|
+
* 🔴 **A step owns steps, so the model is a TREE and not two levels.** Until 0.2.0 a step was a
|
|
633
|
+
* leaf, which made real nested execution unrepresentable — a workflow whose node is itself a
|
|
634
|
+
* workflow, a build that fans out to sub-builds, a run that spawns a child run with its own steps.
|
|
635
|
+
* Every one of those had to be flattened by the host into a single list of siblings, which throws
|
|
636
|
+
* away the only thing the user was looking for: *which* stage the failure is under. The recursion
|
|
637
|
+
* is generic on purpose; nothing here knows what a step contains.
|
|
638
|
+
*
|
|
639
|
+
* ⚠️ **Sibling ids must be unique.** A step is addressed by the path of ids from its run down to
|
|
640
|
+
* it ({@link XenoRunPath}), so two siblings sharing an id make that address ambiguous; resolution
|
|
641
|
+
* takes the first and the projection still renders both, but a patch aimed at the second will
|
|
642
|
+
* never reach it. Uniqueness is the host's to guarantee — nothing can recover it after the fact.
|
|
643
|
+
*/
|
|
644
|
+
interface XenoRunStep {
|
|
645
|
+
id: string;
|
|
646
|
+
label: string;
|
|
647
|
+
status: XenoRunStatus;
|
|
648
|
+
startedAt?: number;
|
|
649
|
+
endedAt?: number;
|
|
650
|
+
/** `0`–`1`. */
|
|
651
|
+
progress?: number;
|
|
652
|
+
error?: XenoRunError;
|
|
653
|
+
/** Pre-formatted detail lines — the host formats, the panel places. */
|
|
654
|
+
detail?: Record<string, string>;
|
|
655
|
+
/**
|
|
656
|
+
* Nested steps.
|
|
657
|
+
*
|
|
658
|
+
* ⚠️ Nothing stops a host handing back a structure that points at one of its own ancestors —
|
|
659
|
+
* step objects are shared by reference and a producer that reuses one builds a cycle without
|
|
660
|
+
* meaning to. The projection guards against it ({@link XenoRunTruncation}); a consumer walking
|
|
661
|
+
* this field itself must do the same, or the walk does not terminate.
|
|
662
|
+
*/
|
|
663
|
+
steps?: XenoRunStep[];
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* The address of one step: the run id, then each step id down the tree.
|
|
667
|
+
*
|
|
668
|
+
* A bare `stepId` is only unambiguous while the model is two levels deep. Once a step owns steps,
|
|
669
|
+
* `'compile'` may name a step under `plan`, under `verify`, or both.
|
|
670
|
+
*/
|
|
671
|
+
type XenoRunPath = readonly string[];
|
|
672
|
+
/**
|
|
673
|
+
* Separator for the string form of a {@link XenoRunPath}.
|
|
674
|
+
*
|
|
675
|
+
* 🔴 **NUL, and it is not decoration.** Any printable separator collides: joined with a space,
|
|
676
|
+
* `('a b', 'c')` and `('a', 'b c')` produce the same key. That is not hypothetical — `xeno-shell`
|
|
677
|
+
* shipped it, where `core/acl.ts` joined grant keys with a space while `mountBroker.grantKeyOf`
|
|
678
|
+
* used NUL, so two different grants could authorise each other. Both use NUL now, and so does this.
|
|
679
|
+
*/
|
|
680
|
+
declare const RUN_PATH_SEPARATOR = "\0";
|
|
681
|
+
/**
|
|
682
|
+
* Path → the key used for expansion state and as a React key.
|
|
683
|
+
*
|
|
684
|
+
* ⚠️ A one-element path encodes to the id itself, unchanged. That is what makes a `RunsPanelState`
|
|
685
|
+
* serialized before 0.2.0 — `expanded: ['r1']`, a list of bare run ids — restore correctly: those
|
|
686
|
+
* strings ARE the encoded run-level paths, byte for byte.
|
|
687
|
+
*/
|
|
688
|
+
declare function encodeRunPath(path: XenoRunPath): string;
|
|
689
|
+
/** The inverse of {@link encodeRunPath}. */
|
|
690
|
+
declare function decodeRunPath(key: string): string[];
|
|
691
|
+
/** Why a subtree was cut short in the projection. */
|
|
692
|
+
type XenoRunTruncation = 'cycle' | 'depth';
|
|
693
|
+
/** Default depth cap for the projection. Deep enough for real nesting, shallow enough to bound. */
|
|
694
|
+
declare const DEFAULT_MAX_STEP_DEPTH = 32;
|
|
695
|
+
/** One run. */
|
|
696
|
+
interface XenoRun {
|
|
697
|
+
/** Stable id — identity for patches, actions and activation. */
|
|
698
|
+
id: string;
|
|
699
|
+
/** Display label. */
|
|
700
|
+
label: string;
|
|
701
|
+
status: XenoRunStatus;
|
|
702
|
+
/**
|
|
703
|
+
* Epoch ms. **Runs are ordered by this, newest first** — it is the sort key, and it is required.
|
|
704
|
+
*
|
|
705
|
+
* 🔴 It is *when the run entered the list*, which is not the same as when it began executing.
|
|
706
|
+
* That distinction used to be missing, and the gap showed as a contradiction: a `queued` run has
|
|
707
|
+
* not started, yet the field is mandatory, so every host put its enqueue time here anyway and
|
|
708
|
+
* the panel then reported a run that waited ten minutes and ran for two seconds as having taken
|
|
709
|
+
* ten minutes and two seconds. The field's meaning is now the one every host was already using;
|
|
710
|
+
* {@link XenoRun.executionStartedAt} carries the other fact, and {@link durationOf} prefers it.
|
|
711
|
+
*
|
|
712
|
+
* ⚠️ Keeping it required is deliberate. Ordering is what this panel IS, and a sort key that may
|
|
713
|
+
* be absent has to be defaulted somewhere — which is how a queued run ends up at the epoch, at
|
|
714
|
+
* the bottom of the list, exactly where nobody looks for the thing they just asked for.
|
|
715
|
+
*/
|
|
716
|
+
startedAt: number;
|
|
717
|
+
/**
|
|
718
|
+
* Epoch ms at which work actually began — absent while the run is still `requested` or `queued`.
|
|
719
|
+
*
|
|
720
|
+
* Absent means "no execution start is known", which {@link durationOf} treats exactly as it did
|
|
721
|
+
* before this field existed: it falls back to `startedAt`. A host that never sets it sees no
|
|
722
|
+
* change at all.
|
|
723
|
+
*/
|
|
724
|
+
executionStartedAt?: number;
|
|
725
|
+
/** Epoch ms. Present iff terminal. */
|
|
726
|
+
endedAt?: number;
|
|
727
|
+
/** `0`–`1`. Absent means indeterminate, which is different from `0`. */
|
|
728
|
+
progress?: number;
|
|
729
|
+
/** Which stream produced it — the multiplex key, as in the console. */
|
|
730
|
+
source?: string;
|
|
731
|
+
/** What started it (`'manual'`, `'schedule'`, `'webhook'`, a user id). */
|
|
732
|
+
trigger?: string;
|
|
733
|
+
/** Sub-runs. */
|
|
734
|
+
steps?: XenoRunStep[];
|
|
735
|
+
/** Set when `status === 'failed'`. */
|
|
736
|
+
error?: XenoRunError;
|
|
737
|
+
/** How many times this has already been retried. */
|
|
738
|
+
retryCount?: number;
|
|
739
|
+
/** The host permits cancellation. Absent ⇒ inferred from the status. */
|
|
740
|
+
canCancel?: boolean;
|
|
741
|
+
/** The host permits retry. Absent ⇒ inferred from the status and `error.retryable`. */
|
|
742
|
+
canRetry?: boolean;
|
|
743
|
+
/** Pre-formatted extra fields for the row. */
|
|
744
|
+
fields?: Record<string, string>;
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* One step's worth of change, addressed by path.
|
|
748
|
+
*
|
|
749
|
+
* 🔴 **The path is an ARRAY and never a joined string, at the contract boundary.** A joined key
|
|
750
|
+
* has to pick a separator, and every printable choice collides with an id that contains it — see
|
|
751
|
+
* {@link RUN_PATH_SEPARATOR}. Producers build these ids from node names, file paths and user text,
|
|
752
|
+
* so "no id will contain a slash" is a promise nobody can keep. An array cannot collide.
|
|
753
|
+
*/
|
|
754
|
+
interface XenoRunStepPatch {
|
|
755
|
+
/** Which run the step belongs to. */
|
|
756
|
+
runId: string;
|
|
757
|
+
/** Step ids from the run's own `steps` down to the target. Must be non-empty. */
|
|
758
|
+
path: XenoRunPath;
|
|
759
|
+
/** Merged shallowly onto the addressed step. `id` is identity and is never overwritten. */
|
|
760
|
+
patch: Partial<XenoRunStep>;
|
|
761
|
+
}
|
|
762
|
+
/** New steps for a run, or for a step inside one. */
|
|
763
|
+
interface XenoRunStepAppend {
|
|
764
|
+
/** Which run. */
|
|
765
|
+
runId: string;
|
|
766
|
+
/** Path to the PARENT step. Absent or empty ⇒ the run's own top-level `steps`. */
|
|
767
|
+
path?: XenoRunPath;
|
|
768
|
+
/**
|
|
769
|
+
* The steps to add.
|
|
770
|
+
*
|
|
771
|
+
* ⚠️ Upsert, not blind push: a step whose id already exists among that parent's children is
|
|
772
|
+
* REPLACED where it stands. A producer re-announcing a step it already announced is ordinary —
|
|
773
|
+
* a reconnect, a resend, a retry — and appending blindly turns each of those into a duplicate
|
|
774
|
+
* row and a second, unreachable address for the same work.
|
|
775
|
+
*/
|
|
776
|
+
steps: XenoRunStep[];
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* An append/patch-oriented update.
|
|
780
|
+
*
|
|
781
|
+
* **Live progress must not thrash.** A downloads-style panel re-pushing twenty rows at 60 Hz is the
|
|
782
|
+
* failure mode: 1 200 array copies a second, every one of them discarding structural sharing. So
|
|
783
|
+
* `patch` addresses runs BY ID and carries only what changed.
|
|
784
|
+
*
|
|
785
|
+
* 🔴 **`patch` alone did not finish the job, and per-step streaming defeated it.** It is applied as
|
|
786
|
+
* a shallow spread, so the only way to move one step's progress bar was to resend the run's whole
|
|
787
|
+
* `steps` array — reintroducing exactly the array copying the delta exists to prevent, one level
|
|
788
|
+
* down, and discarding structural sharing for every untouched sibling and subtree. `patchSteps`
|
|
789
|
+
* and `appendSteps` address a step directly and copy only the spine from the run down to it.
|
|
790
|
+
*/
|
|
791
|
+
interface XenoRunDelta {
|
|
792
|
+
/** Monotonic revision. A delta whose rev has not advanced is dropped. */
|
|
793
|
+
rev?: number;
|
|
794
|
+
/** New runs. */
|
|
795
|
+
append?: XenoRun[];
|
|
796
|
+
/** Partial updates, keyed by run id. The hot path for progress. */
|
|
797
|
+
patch?: Record<string, Partial<XenoRun>>;
|
|
798
|
+
/**
|
|
799
|
+
* New steps, addressed to a run or to a step inside one. Applied before {@link patchSteps}, so a
|
|
800
|
+
* producer may announce a step and set its progress in a single delta.
|
|
801
|
+
*/
|
|
802
|
+
appendSteps?: XenoRunStepAppend[];
|
|
803
|
+
/**
|
|
804
|
+
* Per-step updates. The hot path for a nested run.
|
|
805
|
+
*
|
|
806
|
+
* ⚠️ Kept out of `patch` rather than folded into it. `patch` is keyed by run id; a step id that
|
|
807
|
+
* happened to equal a run id would silently address the wrong thing, and the two are minted by
|
|
808
|
+
* different producers with no reason to be distinct.
|
|
809
|
+
*/
|
|
810
|
+
patchSteps?: XenoRunStepPatch[];
|
|
811
|
+
/** Ids to drop. */
|
|
812
|
+
remove?: string[];
|
|
813
|
+
/** Replace the whole list (a source restarted). */
|
|
814
|
+
replace?: XenoRun[];
|
|
815
|
+
/** Drop everything. Wins over `append` in the same delta. */
|
|
816
|
+
clear?: boolean;
|
|
817
|
+
/** Restrict `replace`/`clear` to one source. */
|
|
818
|
+
source?: string;
|
|
819
|
+
}
|
|
820
|
+
/** The active filter. */
|
|
821
|
+
interface XenoRunFilter {
|
|
822
|
+
/** Statuses to show. Absent/empty ⇒ all. */
|
|
823
|
+
statuses?: XenoRunStatus[];
|
|
824
|
+
/** Sources to hide. */
|
|
825
|
+
mutedSources?: string[];
|
|
826
|
+
/** Case-insensitive substring over label, source, trigger and error code. */
|
|
827
|
+
search?: string;
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* An intent the panel emits. The panel never executes or cancels anything itself.
|
|
831
|
+
*
|
|
832
|
+
* ⚠️ `stepId` and `stepPath` are two views of one target and are always emitted together.
|
|
833
|
+
* `stepId` is the LAST element of `stepPath`, which is what it already meant when the model was
|
|
834
|
+
* two levels deep — so a host reading only `stepId` behaves exactly as it did, and a host that
|
|
835
|
+
* reads `stepPath` can tell `verify ▸ compile` from `plan ▸ compile`.
|
|
836
|
+
*/
|
|
837
|
+
interface XenoRunAction {
|
|
838
|
+
runId: string;
|
|
839
|
+
action: 'cancel' | 'retry';
|
|
840
|
+
/** The targeted step's own id — the last element of {@link stepPath}. */
|
|
841
|
+
stepId?: string;
|
|
842
|
+
/** The full address of the targeted step, from the run's own `steps` down. */
|
|
843
|
+
stepPath?: XenoRunPath;
|
|
844
|
+
}
|
|
845
|
+
/** A run was opened — the reveal hook, same idea as the console's `recordActivate`. */
|
|
846
|
+
interface XenoRunActivate {
|
|
847
|
+
runId: string;
|
|
848
|
+
source?: string;
|
|
849
|
+
/** The activated step's own id — the last element of {@link stepPath}. */
|
|
850
|
+
stepId?: string;
|
|
851
|
+
/** The full address of the activated step. */
|
|
852
|
+
stepPath?: XenoRunPath;
|
|
853
|
+
}
|
|
854
|
+
/** Aggregate counters, computed over TERMINAL runs where that is the meaningful denominator. */
|
|
855
|
+
interface XenoRunStats {
|
|
856
|
+
total: number;
|
|
857
|
+
/** Runs that have finished. */
|
|
858
|
+
terminal: number;
|
|
859
|
+
/** Still going to change on its own. */
|
|
860
|
+
active: number;
|
|
861
|
+
/**
|
|
862
|
+
* Non-terminal, but stopped on something outside the run — a person, a grant, an upstream job.
|
|
863
|
+
*
|
|
864
|
+
* Counted separately from `active` because folding the two together is how a dashboard reports a
|
|
865
|
+
* busy pipeline in which nothing is moving. `total` is therefore `terminal + active + waiting`
|
|
866
|
+
* for any run whose status this build recognises.
|
|
867
|
+
*/
|
|
868
|
+
waiting: number;
|
|
869
|
+
succeeded: number;
|
|
870
|
+
failed: number;
|
|
871
|
+
cancelled: number;
|
|
872
|
+
/**
|
|
873
|
+
* Successes ÷ terminal runs, or `null` when nothing has finished.
|
|
874
|
+
*
|
|
875
|
+
* `null` rather than `0`: "no runs have finished" and "every finished run failed" are different
|
|
876
|
+
* facts, and showing 0 % for the first is a lie the user acts on.
|
|
877
|
+
*/
|
|
878
|
+
successRate: number | null;
|
|
879
|
+
/** Mean duration of terminal runs with both timestamps, in ms. `null` when none. */
|
|
880
|
+
meanDurationMs: number | null;
|
|
881
|
+
}
|
|
882
|
+
/** The panel's serialized state. **Runs are never persisted** — an execution log is not a document. */
|
|
883
|
+
interface RunsPanelState {
|
|
884
|
+
filter: XenoRunFilter;
|
|
885
|
+
/**
|
|
886
|
+
* Encoded paths ({@link encodeRunPath}) of everything expanded — runs and steps alike.
|
|
887
|
+
*
|
|
888
|
+
* ⚠️ A run-level entry encodes to the bare run id, so state written before 0.2.0 restores
|
|
889
|
+
* unchanged. Entries that no longer resolve are kept rather than pruned: a run that has scrolled
|
|
890
|
+
* out of the ring buffer and comes back should come back expanded.
|
|
891
|
+
*/
|
|
892
|
+
expanded: string[];
|
|
893
|
+
}
|
|
894
|
+
/** Shared by every row of the projection. */
|
|
895
|
+
interface XenoRunRowBase {
|
|
896
|
+
/**
|
|
897
|
+
* Unique within one projection — safe as a React key.
|
|
898
|
+
*
|
|
899
|
+
* Normally the encoded path. ⚠️ It is NOT the address: two siblings sharing an id would produce
|
|
900
|
+
* one key for two rows, and React would drop one of them silently, so a duplicate gets a
|
|
901
|
+
* disambiguating suffix. Address a row by {@link XenoRunRowBase.stepPath}, never by its key.
|
|
902
|
+
*/
|
|
903
|
+
key: string;
|
|
904
|
+
/** The run this row belongs to, whether it is the run row or a step under it. */
|
|
905
|
+
runId: string;
|
|
906
|
+
/** The owning run — present on every row, because a step's actions are run-scoped. */
|
|
907
|
+
run: XenoRun;
|
|
908
|
+
/** `0` for the run row, `1` for its own steps, and so on. */
|
|
909
|
+
depth: number;
|
|
910
|
+
/** Step ids from the run down to this row. Empty on a run row. */
|
|
911
|
+
stepPath: XenoRunPath;
|
|
912
|
+
/** Does this row own steps? True even when the subtree was cut — the children exist. */
|
|
913
|
+
hasChildren: boolean;
|
|
914
|
+
/** Are its children currently in the projection? */
|
|
915
|
+
expanded: boolean;
|
|
916
|
+
/**
|
|
917
|
+
* Set when this row's children were NOT walked, and why.
|
|
918
|
+
*
|
|
919
|
+
* 🔴 A cut subtree is surfaced rather than swallowed. A cycle silently rendered as a leaf is
|
|
920
|
+
* indistinguishable from a step that genuinely has no children, and a user debugging a nested
|
|
921
|
+
* run would read "nothing under here" as a fact about their pipeline.
|
|
922
|
+
*/
|
|
923
|
+
truncated?: XenoRunTruncation;
|
|
924
|
+
}
|
|
925
|
+
/** A run's own row. */
|
|
926
|
+
interface XenoRunRunRow extends XenoRunRowBase {
|
|
927
|
+
kind: 'run';
|
|
928
|
+
depth: 0;
|
|
929
|
+
}
|
|
930
|
+
/** One step's row, at any depth. */
|
|
931
|
+
interface XenoRunStepRow extends XenoRunRowBase {
|
|
932
|
+
kind: 'step';
|
|
933
|
+
step: XenoRunStep;
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* One rendered line — a run or a step at any depth.
|
|
937
|
+
*
|
|
938
|
+
* 🔴 **The tree is flattened HERE, in the controller, and not by a recursive render.** A recursive
|
|
939
|
+
* component cannot be windowed: virtualization needs to answer "what is the nth visible row?" in
|
|
940
|
+
* O(1), and with the tree walked inside the view that question has no answer without walking it
|
|
941
|
+
* again. Flattening also gives the depth cap and the cycle guard one home instead of one per
|
|
942
|
+
* consumer — this panel already ships to more than one host.
|
|
943
|
+
*/
|
|
944
|
+
type XenoRunRow = XenoRunRunRow | XenoRunStepRow;
|
|
945
|
+
/** What the controller exposes to its view. */
|
|
946
|
+
interface RunsViewState {
|
|
947
|
+
/** Visible runs, newest first. */
|
|
948
|
+
runs: XenoRun[];
|
|
949
|
+
/**
|
|
950
|
+
* The same runs flattened to rows, with the expanded steps spliced in beneath each.
|
|
951
|
+
*
|
|
952
|
+
* Derived from `runs` and the expansion set; `runs` is unchanged and still the whole runs. A
|
|
953
|
+
* consumer that only ever rendered `runs` is unaffected.
|
|
954
|
+
*/
|
|
955
|
+
rows: XenoRunRow[];
|
|
956
|
+
/** Every source seen, for the filter chips. */
|
|
957
|
+
sources: {
|
|
958
|
+
id: string;
|
|
959
|
+
label: string;
|
|
960
|
+
enabled: boolean;
|
|
961
|
+
count: number;
|
|
962
|
+
}[];
|
|
963
|
+
filter: XenoRunFilter;
|
|
964
|
+
/** Counts per status across the WHOLE list, so a chip shows what it would reveal. */
|
|
965
|
+
counts: Record<XenoRunStatus, number>;
|
|
966
|
+
/** Aggregates. */
|
|
967
|
+
stats: XenoRunStats;
|
|
968
|
+
/** Run ids whose steps are expanded. */
|
|
969
|
+
expanded: string[];
|
|
970
|
+
/** Total held before filtering. */
|
|
971
|
+
total: number;
|
|
972
|
+
/** Runs dropped by the ring buffer. */
|
|
973
|
+
dropped: number;
|
|
974
|
+
}
|
|
975
|
+
/**
|
|
976
|
+
* Every status, in lifecycle order. **The tuple itself** — this used to be a hand-kept copy of it.
|
|
977
|
+
*
|
|
978
|
+
* ⚠️ The order is the reading order of a run's life and the render order of the filter chips, which
|
|
979
|
+
* is why {@link RUN_STATUSES} is declared as an ordered tuple rather than derived from the
|
|
980
|
+
* lifecycle table's keys.
|
|
981
|
+
*/
|
|
982
|
+
declare const ALL_STATUSES: readonly XenoRunStatus[];
|
|
983
|
+
/**
|
|
984
|
+
* May this run be cancelled?
|
|
985
|
+
*
|
|
986
|
+
* An explicit `canCancel` wins; otherwise only a non-terminal run can be. A Cancel button on a
|
|
987
|
+
* finished run is worse than no button — it implies the run is still doing something.
|
|
988
|
+
*/
|
|
989
|
+
declare function canCancel(run: XenoRun): boolean;
|
|
990
|
+
/**
|
|
991
|
+
* May this run be retried?
|
|
992
|
+
*
|
|
993
|
+
* An explicit `canRetry` wins; otherwise a failed or cancelled run may be, unless the error says
|
|
994
|
+
* the failure is not retryable.
|
|
995
|
+
*/
|
|
996
|
+
declare function canRetry(run: XenoRun): boolean;
|
|
997
|
+
/**
|
|
998
|
+
* How long the WORK took — elapsed for a running one, final for a terminal one. `null` if
|
|
999
|
+
* unknowable.
|
|
1000
|
+
*
|
|
1001
|
+
* Measured from {@link XenoRun.executionStartedAt} when the host reports one, and from
|
|
1002
|
+
* `startedAt` otherwise. ⚠️ The fallback is what makes this unchanged for every existing host: a
|
|
1003
|
+
* run with no `executionStartedAt` produces exactly the number it produced before the field
|
|
1004
|
+
* existed. A host that does set it stops reporting queue time as run time.
|
|
1005
|
+
*/
|
|
1006
|
+
declare function durationOf(run: XenoRun, now: number): number | null;
|
|
1007
|
+
/**
|
|
1008
|
+
* How long the run WAITED before work began, in ms. `null` when that is not knowable.
|
|
1009
|
+
*
|
|
1010
|
+
* The other half of the split, and the one a queue owner actually asks for. A run still waiting
|
|
1011
|
+
* reports its wait so far; a run that never reported an execution start reports `null` rather than
|
|
1012
|
+
* `0`, because "it never waited" and "we were never told" are different facts and only one of them
|
|
1013
|
+
* is worth putting on a dashboard.
|
|
1014
|
+
*/
|
|
1015
|
+
declare function queueDurationOf(run: XenoRun, now: number): number | null;
|
|
1016
|
+
/** Does a run match a search term? */
|
|
1017
|
+
declare function matchesSearch(run: XenoRun, term: string): boolean;
|
|
1018
|
+
/** Compute aggregates over a run list. */
|
|
1019
|
+
declare function computeStats(runs: readonly XenoRun[]): XenoRunStats;
|
|
1020
|
+
/** Format a duration compactly: `820ms`, `4.2s`, `3m 05s`, `1h 12m`. */
|
|
1021
|
+
declare function formatDuration(ms: number): string;
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* The Runs controller.
|
|
1025
|
+
*
|
|
1026
|
+
* Intents only: the panel emits `cancel` / `retry` and the host performs them. It never executes,
|
|
1027
|
+
* never cancels, never retries — and, critically, **never optimistically flips a status**. A run
|
|
1028
|
+
* shown as `cancelled` because the panel assumed so, when the host actually refused, is a lie the
|
|
1029
|
+
* user acts on.
|
|
1030
|
+
*
|
|
1031
|
+
* @module
|
|
1032
|
+
*/
|
|
1033
|
+
|
|
1034
|
+
/** The host seam. */
|
|
1035
|
+
interface RunsHostBridge {
|
|
1036
|
+
emit(portId: string, value: unknown): void;
|
|
1037
|
+
/** Wall clock, injectable for tests. */
|
|
1038
|
+
now?: () => number;
|
|
1039
|
+
/** Timer seam, injectable for tests. */
|
|
1040
|
+
clock?: ThrottleClock;
|
|
1041
|
+
}
|
|
1042
|
+
/** Construction options. */
|
|
1043
|
+
interface RunsControllerOptions {
|
|
1044
|
+
host: RunsHostBridge;
|
|
1045
|
+
initial?: Partial<RunsPanelState>;
|
|
1046
|
+
/** Ring capacity. Default 500 — a runs list is recent activity, not an archive. */
|
|
1047
|
+
maxRuns?: number;
|
|
1048
|
+
/**
|
|
1049
|
+
* Minimum ms between re-projections. Default 100.
|
|
1050
|
+
*
|
|
1051
|
+
* The failure mode this exists for: twenty download rows patching at 60 Hz is 1 200 notifications
|
|
1052
|
+
* a second, every one of them re-filtering and re-sorting the whole list.
|
|
1053
|
+
*/
|
|
1054
|
+
throttleMs?: number;
|
|
1055
|
+
/**
|
|
1056
|
+
* How deep below a run the row projection may walk. Default 32.
|
|
1057
|
+
*
|
|
1058
|
+
* A bound, not a preference — see `flattenRunRows`. Nesting past this is cut and the cut is
|
|
1059
|
+
* reported on the row, never silently rendered as a leaf.
|
|
1060
|
+
*/
|
|
1061
|
+
maxDepth?: number;
|
|
1062
|
+
}
|
|
1063
|
+
/** The Runs panel controller. */
|
|
1064
|
+
declare class RunsController {
|
|
1065
|
+
private readonly host;
|
|
1066
|
+
private readonly listeners;
|
|
1067
|
+
private readonly maxRuns;
|
|
1068
|
+
private readonly maxDepth;
|
|
1069
|
+
private readonly now;
|
|
1070
|
+
private readonly notifyThrottled;
|
|
1071
|
+
/** Runs by id — patching by id is O(1), which is the whole point of the delta. */
|
|
1072
|
+
private readonly byId;
|
|
1073
|
+
private order;
|
|
1074
|
+
private filter;
|
|
1075
|
+
private expanded;
|
|
1076
|
+
private rev;
|
|
1077
|
+
private dropped;
|
|
1078
|
+
private snapshot;
|
|
1079
|
+
constructor(options: RunsControllerOptions);
|
|
1080
|
+
subscribe: (listener: () => void) => (() => void);
|
|
1081
|
+
getState: () => RunsViewState;
|
|
1082
|
+
/** Invalidate and notify immediately. */
|
|
1083
|
+
private flush;
|
|
1084
|
+
/** Invalidate now, notify on the throttle — the hot path. */
|
|
1085
|
+
private notify;
|
|
1086
|
+
/** Emit any pending notification immediately. Call before reading in a test. */
|
|
1087
|
+
flushNow(): void;
|
|
1088
|
+
/**
|
|
1089
|
+
* Apply a delta.
|
|
1090
|
+
*
|
|
1091
|
+
* @param delta - append / patch / remove / replace / clear.
|
|
1092
|
+
* @returns `true` if applied; `false` if dropped as stale.
|
|
1093
|
+
*/
|
|
1094
|
+
apply(delta: XenoRunDelta): boolean;
|
|
1095
|
+
private insert;
|
|
1096
|
+
/**
|
|
1097
|
+
* Merge a patch into one step of one run.
|
|
1098
|
+
*
|
|
1099
|
+
* 🔴 The run object is replaced but its untouched subtrees are SHARED — `patchStepAtPath` copies
|
|
1100
|
+
* only the spine. This is the whole reason per-step addressing exists: the alternative a host had
|
|
1101
|
+
* was resending `steps` wholesale on every tick, which is the array-copy storm the delta was
|
|
1102
|
+
* designed to prevent, reintroduced one level down.
|
|
1103
|
+
*
|
|
1104
|
+
* @returns `true` when the address resolved and the patch landed.
|
|
1105
|
+
*/
|
|
1106
|
+
private applyStepPatch;
|
|
1107
|
+
/**
|
|
1108
|
+
* Add steps to a run, or to a step inside one.
|
|
1109
|
+
*
|
|
1110
|
+
* An unresolvable PARENT is refused rather than created, for the same reason a patch for an
|
|
1111
|
+
* unknown run is: a parent conjured from an append has no label and no status, so it renders as
|
|
1112
|
+
* a row that will never resolve into anything.
|
|
1113
|
+
*
|
|
1114
|
+
* @returns `true` when the parent resolved and the steps landed.
|
|
1115
|
+
*/
|
|
1116
|
+
private applyStepAppend;
|
|
1117
|
+
/** Ring-buffer the OLDEST runs away, never the newest. */
|
|
1118
|
+
private trim;
|
|
1119
|
+
/** Every run, newest first. */
|
|
1120
|
+
all(): XenoRun[];
|
|
1121
|
+
/** Replace the filter. */
|
|
1122
|
+
setFilter(filter: XenoRunFilter): void;
|
|
1123
|
+
/** Set the search term. */
|
|
1124
|
+
setSearch(search: string): void;
|
|
1125
|
+
/**
|
|
1126
|
+
* Show only these statuses. Empty/absent shows everything.
|
|
1127
|
+
*
|
|
1128
|
+
* **A dead-letter view is `statuses: ['failed']` plus a search — not a second panel.** That is
|
|
1129
|
+
* exactly how one incumbent ended up maintaining two panels for one archetype.
|
|
1130
|
+
*/
|
|
1131
|
+
setStatuses(statuses: readonly XenoRunStatus[]): void;
|
|
1132
|
+
/** Show or hide one source. */
|
|
1133
|
+
toggleSource(source: string, enabled?: boolean): void;
|
|
1134
|
+
/** The visible runs, newest first. */
|
|
1135
|
+
visible(): XenoRun[];
|
|
1136
|
+
/** Every source seen. */
|
|
1137
|
+
sources(): RunsViewState['sources'];
|
|
1138
|
+
/**
|
|
1139
|
+
* Ask the host to cancel a run.
|
|
1140
|
+
*
|
|
1141
|
+
* Refuses a run that cannot be cancelled, and **does not flip the status locally**: the run
|
|
1142
|
+
* becomes `cancelled` when the host says so, not when the panel asks.
|
|
1143
|
+
*/
|
|
1144
|
+
cancel(runId: string, step?: string | XenoRunPath): boolean;
|
|
1145
|
+
/** Ask the host to retry a run. */
|
|
1146
|
+
retry(runId: string, step?: string | XenoRunPath): boolean;
|
|
1147
|
+
/**
|
|
1148
|
+
* Clear TERMINAL runs from the view.
|
|
1149
|
+
*
|
|
1150
|
+
* Only terminal ones: clearing a running download would remove the row while the transfer
|
|
1151
|
+
* continues, and the user would have no way back to it.
|
|
1152
|
+
*
|
|
1153
|
+
* @returns How many were removed.
|
|
1154
|
+
*/
|
|
1155
|
+
clearTerminal(): number;
|
|
1156
|
+
/** A run was opened — the reveal hook. */
|
|
1157
|
+
activate(runId: string, step?: string | XenoRunPath): boolean;
|
|
1158
|
+
/**
|
|
1159
|
+
* Expand or collapse a run, or one step inside it.
|
|
1160
|
+
*
|
|
1161
|
+
* @param runId - The run.
|
|
1162
|
+
* @param stepPath - Address of a step within it. Absent ⇒ the run's own row.
|
|
1163
|
+
* @returns `true` when the target existed and its state flipped.
|
|
1164
|
+
*
|
|
1165
|
+
* ⚠️ The key for a run is its bare id, so a `RunsPanelState` written before step expansion
|
|
1166
|
+
* existed restores unchanged — those stored strings ARE the encoded run-level paths.
|
|
1167
|
+
*
|
|
1168
|
+
* 🔴 A step path is resolved before it is stored. An address that names nothing would otherwise
|
|
1169
|
+
* accumulate in a set that is serialized, so a host with a churning step tree would grow its
|
|
1170
|
+
* persisted state without bound and never be able to tell which entries still meant anything.
|
|
1171
|
+
*/
|
|
1172
|
+
toggleExpanded(runId: string, stepPath?: XenoRunPath): boolean;
|
|
1173
|
+
/** Ask the host to open a context menu. */
|
|
1174
|
+
requestContextMenu(runId: string, x: number, y: number): void;
|
|
1175
|
+
/** Serialize. Filter and expansion — **never the runs**; an execution log is not a document. */
|
|
1176
|
+
serialize(): RunsPanelState;
|
|
1177
|
+
/** Restore. Does not emit. */
|
|
1178
|
+
deserialize(state: Partial<RunsPanelState>): void;
|
|
1179
|
+
/** Drop listeners, timers and the buffer. */
|
|
1180
|
+
dispose(): void;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/**
|
|
1184
|
+
* The `PanelModule` — view wired inside the package (one React copy).
|
|
1185
|
+
*
|
|
1186
|
+
* @module
|
|
1187
|
+
*/
|
|
1188
|
+
|
|
1189
|
+
/** Everything a renderer needs: the controller plus the resolved config. */
|
|
1190
|
+
interface RunsRenderContext {
|
|
1191
|
+
controller: RunsController;
|
|
1192
|
+
config: {
|
|
1193
|
+
showStats: boolean;
|
|
1194
|
+
showProgress: boolean;
|
|
1195
|
+
rowHeight: number;
|
|
1196
|
+
emptyHint?: string;
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
/** Options for {@link createRunsPanel}. */
|
|
1200
|
+
interface CreateRunsPanelOptions {
|
|
1201
|
+
/** Override the view. Rarely needed — mounting inside the package keeps React singular. */
|
|
1202
|
+
render?: (root: HTMLElement, context: RunsRenderContext) => () => void;
|
|
1203
|
+
}
|
|
1204
|
+
/**
|
|
1205
|
+
* Build the Runs panel module.
|
|
1206
|
+
*
|
|
1207
|
+
* @param options - Optional renderer override.
|
|
1208
|
+
* @returns The module.
|
|
1209
|
+
*/
|
|
1210
|
+
declare function createRunsPanel(options?: CreateRunsPanelOptions): PanelModule;
|
|
1211
|
+
/** The default Runs panel module — view already wired. Register THIS. */
|
|
1212
|
+
declare const runsPanel: PanelModule;
|
|
1213
|
+
|
|
1214
|
+
/**
|
|
1215
|
+
* The `xeno.core.runs` manifest.
|
|
1216
|
+
*
|
|
1217
|
+
* Capabilities: `storage.local` only. The panel emits intents; the host executes and cancels.
|
|
1218
|
+
*
|
|
1219
|
+
* @module
|
|
1220
|
+
*/
|
|
1221
|
+
|
|
1222
|
+
/** The canonical manifest id. */
|
|
1223
|
+
declare const RUNS_PANEL_ID = "xeno.core.runs";
|
|
1224
|
+
/** The `xeno.core.runs` manifest. */
|
|
1225
|
+
declare const runsManifest: PanelManifest;
|
|
1226
|
+
|
|
1227
|
+
/**
|
|
1228
|
+
* Step-tree operations: resolve an address, patch through it, append under it, and flatten the
|
|
1229
|
+
* whole thing to rows.
|
|
1230
|
+
*
|
|
1231
|
+
* 🔴 **Every function here copies the SPINE and shares everything else.** That is the entire point.
|
|
1232
|
+
* The delta shape exists because re-pushing a list at 60 Hz costs 1 200 array copies a second, and
|
|
1233
|
+
* a step tree reintroduces that failure one level down: a `structuredClone` of the run, or a
|
|
1234
|
+
* rebuild of `steps` on every progress tick, is exactly as expensive as resending the list and
|
|
1235
|
+
* throws away structural sharing for every untouched sibling and subtree. Patching a step four
|
|
1236
|
+
* levels down copies four arrays and four objects — the path — and nothing else.
|
|
1237
|
+
*
|
|
1238
|
+
* @module
|
|
1239
|
+
*/
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* Follow an address to the step it names.
|
|
1243
|
+
*
|
|
1244
|
+
* ⚠️ First match wins when siblings share an id. Not a preference — there is nothing else to do
|
|
1245
|
+
* with an ambiguous address, and inventing a rule ("the last one", "the running one") would make
|
|
1246
|
+
* which step a patch reaches depend on the order the host happened to emit them in.
|
|
1247
|
+
*
|
|
1248
|
+
* @param steps - The list to search, i.e. the parent's children.
|
|
1249
|
+
* @param path - Step ids, root first. An empty path resolves to nothing.
|
|
1250
|
+
* @returns The step, or `null` when any segment is missing.
|
|
1251
|
+
*/
|
|
1252
|
+
declare function resolveStepPath(steps: readonly XenoRunStep[] | undefined, path: XenoRunPath): XenoRunStep | null;
|
|
1253
|
+
/**
|
|
1254
|
+
* Merge a patch into the step at `path`, copying only the spine.
|
|
1255
|
+
*
|
|
1256
|
+
* @param steps - The run's own `steps`.
|
|
1257
|
+
* @param path - Address of the target, run-relative.
|
|
1258
|
+
* @param patch - Fields to merge. `id` is identity and is never overwritten.
|
|
1259
|
+
* @returns A new `steps` array, or `null` when the path does not resolve.
|
|
1260
|
+
*
|
|
1261
|
+
* 🔴 Returning `null` rather than creating the missing step is the same invariant the run-level
|
|
1262
|
+
* `patch` already holds: *a patch for something unknown is ignored, never upserted*. Inventing a
|
|
1263
|
+
* step from a partial is how a tree fills with half-formed nodes after a reconnect — and a step
|
|
1264
|
+
* conjured that way has no status, so it renders as a row nothing can ever complete.
|
|
1265
|
+
*/
|
|
1266
|
+
declare function patchStepAtPath(steps: readonly XenoRunStep[] | undefined, path: XenoRunPath, patch: Partial<XenoRunStep>): XenoRunStep[] | null;
|
|
1267
|
+
/**
|
|
1268
|
+
* Add steps under `path`, copying only the spine.
|
|
1269
|
+
*
|
|
1270
|
+
* @param steps - The run's own `steps`.
|
|
1271
|
+
* @param path - Address of the PARENT. Empty means the run itself.
|
|
1272
|
+
* @param incoming - Steps to add; one whose id already exists there replaces it in place.
|
|
1273
|
+
* @returns A new `steps` array, or `null` when the parent path does not resolve.
|
|
1274
|
+
*
|
|
1275
|
+
* ⚠️ Upsert-by-id, and replacing *in place* rather than moving the step to the end: the order of a
|
|
1276
|
+
* run's steps is the order the work happened in, and a re-announced step that jumps to the bottom
|
|
1277
|
+
* of its list rewrites that history in front of the user while they are reading it.
|
|
1278
|
+
*/
|
|
1279
|
+
declare function appendStepsAtPath(steps: readonly XenoRunStep[] | undefined, path: XenoRunPath, incoming: readonly XenoRunStep[]): XenoRunStep[] | null;
|
|
1280
|
+
/** Options for {@link flattenRunRows}. */
|
|
1281
|
+
interface FlattenOptions {
|
|
1282
|
+
/** Encoded paths that are open. See {@link encodeRunPath}. */
|
|
1283
|
+
expanded: ReadonlySet<string>;
|
|
1284
|
+
/** How deep below a run the walk may go. Default {@link DEFAULT_MAX_STEP_DEPTH}. */
|
|
1285
|
+
maxDepth?: number;
|
|
1286
|
+
}
|
|
1287
|
+
/**
|
|
1288
|
+
* Project runs and their expanded steps into one flat, ordered list of rows.
|
|
1289
|
+
*
|
|
1290
|
+
* 🔴 **Two independent things stop this walk, and both are load-bearing.**
|
|
1291
|
+
*
|
|
1292
|
+
* A **depth cap** bounds a legitimately deep tree — a build of builds of builds — so a pathological
|
|
1293
|
+
* but acyclic structure cannot produce a projection the view spends a whole frame budget on.
|
|
1294
|
+
*
|
|
1295
|
+
* A **cycle guard** bounds an illegal one. Steps are plain objects handed over by reference, and a
|
|
1296
|
+
* producer that reuses one — a retry that re-attaches the parent's own step object, a memoised
|
|
1297
|
+
* builder — creates a structure that is finite in memory and infinite to walk. The depth cap alone
|
|
1298
|
+
* would technically terminate, but it would emit 32 rows of the same three steps repeating, which
|
|
1299
|
+
* reads as real data. Tracking ancestor IDENTITY — not id, which a host may legitimately repeat
|
|
1300
|
+
* across branches — cuts it at the first repeat and says so.
|
|
1301
|
+
*
|
|
1302
|
+
* ⚠️ Both cuts are only ever reached through an EXPANDED row: a collapsed subtree is never walked,
|
|
1303
|
+
* so a cycle in a collapsed run costs nothing at all.
|
|
1304
|
+
*
|
|
1305
|
+
* @param runs - Visible runs, already filtered and ordered.
|
|
1306
|
+
* @param options - Expansion set and depth cap.
|
|
1307
|
+
* @returns Rows in render order: each run, then its open subtree, then the next run.
|
|
1308
|
+
*/
|
|
1309
|
+
declare function flattenRunRows(runs: readonly XenoRun[], options: FlattenOptions): XenoRunRow[];
|
|
1310
|
+
|
|
1311
|
+
/**
|
|
1312
|
+
* The Runs panel view.
|
|
1313
|
+
*
|
|
1314
|
+
* Composes `@xenosystem/workbench/primitives`; the host must ensure `@xenosystem/workbench/primitives.css` is
|
|
1315
|
+
* present. `ProportionBar` carries progress — it is exactly the right mark, and it means the panel
|
|
1316
|
+
* ships no chart code of its own.
|
|
1317
|
+
*
|
|
1318
|
+
* @module
|
|
1319
|
+
*/
|
|
1320
|
+
|
|
1321
|
+
/** Props for {@link RunsPanelView}. */
|
|
1322
|
+
interface RunsPanelViewProps {
|
|
1323
|
+
controller: RunsController;
|
|
1324
|
+
showStats?: boolean;
|
|
1325
|
+
showProgress?: boolean;
|
|
1326
|
+
emptyHint?: string;
|
|
1327
|
+
/** Injectable clock, so a duration column is testable. */
|
|
1328
|
+
now?: () => number;
|
|
1329
|
+
}
|
|
1330
|
+
/** The Runs panel view. */
|
|
1331
|
+
declare function RunsPanelView({ controller, showStats, showProgress, emptyHint, now, }: RunsPanelViewProps): ReactNode;
|
|
1332
|
+
|
|
1333
|
+
/**
|
|
1334
|
+
* The Terminal contract.
|
|
1335
|
+
*
|
|
1336
|
+
* The panel owns the **emulator surface**. The host owns the **PTY**. Nothing here spawns,
|
|
1337
|
+
* signals, or kills a process, and there is no type in this file through which a process handle
|
|
1338
|
+
* could travel.
|
|
1339
|
+
*
|
|
1340
|
+
* @module
|
|
1341
|
+
*/
|
|
1342
|
+
/** A terminal instance's identity. Multi-instance is a first-class requirement, not a later mode. */
|
|
1343
|
+
type XenoTerminalId = string;
|
|
1344
|
+
/** Where a terminal is in its lifecycle, as the HOST reports it. */
|
|
1345
|
+
type XenoTerminalStatus =
|
|
1346
|
+
/** No session requested yet. */
|
|
1347
|
+
'idle'
|
|
1348
|
+
/** The panel has asked; the host has not confirmed. */
|
|
1349
|
+
| 'starting'
|
|
1350
|
+
/** Live. */
|
|
1351
|
+
| 'running'
|
|
1352
|
+
/** The process ended. */
|
|
1353
|
+
| 'exited'
|
|
1354
|
+
/** The host could not start or keep it. */
|
|
1355
|
+
| 'failed';
|
|
1356
|
+
/** A terminal's host-reported state. */
|
|
1357
|
+
interface XenoTerminalSession {
|
|
1358
|
+
id: XenoTerminalId;
|
|
1359
|
+
/** Tab label. */
|
|
1360
|
+
title?: string;
|
|
1361
|
+
status: XenoTerminalStatus;
|
|
1362
|
+
/** Working directory, for the title bar. */
|
|
1363
|
+
cwd?: string;
|
|
1364
|
+
/** Exit code, when `status === 'exited'`. */
|
|
1365
|
+
exitCode?: number | null;
|
|
1366
|
+
/** Signal that ended it. */
|
|
1367
|
+
signal?: string;
|
|
1368
|
+
/** Host-authored sentence, rendered verbatim. */
|
|
1369
|
+
message?: string;
|
|
1370
|
+
/**
|
|
1371
|
+
* Whether the host has a real PTY behind this session.
|
|
1372
|
+
*
|
|
1373
|
+
* `false` means a pipe fallback: no TTY, so resize is a no-op and line editing happens in the
|
|
1374
|
+
* shell rather than the terminal. The surveyed `xeno-shell` degrades this way when `node-pty`
|
|
1375
|
+
* fails to load, and it tells the user; `xeno-agent-interface` fails outright instead. Carrying
|
|
1376
|
+
* the flag is what lets a panel say which one is happening.
|
|
1377
|
+
*/
|
|
1378
|
+
pty?: boolean;
|
|
1379
|
+
}
|
|
1380
|
+
/** A write into a terminal, addressed by instance. */
|
|
1381
|
+
interface XenoTerminalData {
|
|
1382
|
+
id: XenoTerminalId;
|
|
1383
|
+
/** Raw bytes as a string, escape sequences intact. Never pre-parsed, never sanitized. */
|
|
1384
|
+
data: string;
|
|
1385
|
+
}
|
|
1386
|
+
/** Everything the panel asks the host to do. */
|
|
1387
|
+
type XenoTerminalIntent =
|
|
1388
|
+
/**
|
|
1389
|
+
* Start a session.
|
|
1390
|
+
*
|
|
1391
|
+
* `cols`/`rows` are **always a real measurement** — see {@link measuredSize}. The panel does not
|
|
1392
|
+
* ask until it can say how big the terminal is.
|
|
1393
|
+
*/
|
|
1394
|
+
{
|
|
1395
|
+
type: 'start';
|
|
1396
|
+
id: XenoTerminalId;
|
|
1397
|
+
cols: number;
|
|
1398
|
+
rows: number;
|
|
1399
|
+
cwd?: string;
|
|
1400
|
+
}
|
|
1401
|
+
/** User input, straight through — control characters included. */
|
|
1402
|
+
| {
|
|
1403
|
+
type: 'input';
|
|
1404
|
+
id: XenoTerminalId;
|
|
1405
|
+
data: string;
|
|
1406
|
+
}
|
|
1407
|
+
/** The emulator resized. */
|
|
1408
|
+
| {
|
|
1409
|
+
type: 'resize';
|
|
1410
|
+
id: XenoTerminalId;
|
|
1411
|
+
cols: number;
|
|
1412
|
+
rows: number;
|
|
1413
|
+
}
|
|
1414
|
+
/** Ask the host to end the session. */
|
|
1415
|
+
| {
|
|
1416
|
+
type: 'stop';
|
|
1417
|
+
id: XenoTerminalId;
|
|
1418
|
+
}
|
|
1419
|
+
/**
|
|
1420
|
+
* Ask the host to replay its buffer.
|
|
1421
|
+
*
|
|
1422
|
+
* The reattach path: a panel remounted into a live session has an empty screen and the scrollback
|
|
1423
|
+
* lives wherever the host kept it.
|
|
1424
|
+
*/
|
|
1425
|
+
| {
|
|
1426
|
+
type: 'snapshot';
|
|
1427
|
+
id: XenoTerminalId;
|
|
1428
|
+
};
|
|
1429
|
+
/** Persisted panel state. */
|
|
1430
|
+
interface TerminalPanelState {
|
|
1431
|
+
/** Which instance was in front. */
|
|
1432
|
+
activeId?: XenoTerminalId | null;
|
|
1433
|
+
/** Font size override. */
|
|
1434
|
+
fontSize?: number;
|
|
1435
|
+
}
|
|
1436
|
+
/** One terminal, as the view sees it. */
|
|
1437
|
+
interface TerminalInstanceView {
|
|
1438
|
+
session: XenoTerminalSession;
|
|
1439
|
+
active: boolean;
|
|
1440
|
+
/** Last measured grid, or `null` before the first fit. */
|
|
1441
|
+
cols: number | null;
|
|
1442
|
+
rows: number | null;
|
|
1443
|
+
}
|
|
1444
|
+
/** What the view renders. */
|
|
1445
|
+
interface TerminalViewState {
|
|
1446
|
+
instances: TerminalInstanceView[];
|
|
1447
|
+
activeId: XenoTerminalId | null;
|
|
1448
|
+
fontSize: number;
|
|
1449
|
+
/** Search query, when the find bar is open. */
|
|
1450
|
+
search: string | null;
|
|
1451
|
+
}
|
|
1452
|
+
/** The xterm theme, in xterm's own shape. */
|
|
1453
|
+
interface XenoTerminalTheme {
|
|
1454
|
+
background?: string;
|
|
1455
|
+
foreground?: string;
|
|
1456
|
+
cursor?: string;
|
|
1457
|
+
cursorAccent?: string;
|
|
1458
|
+
selectionBackground?: string;
|
|
1459
|
+
black?: string;
|
|
1460
|
+
red?: string;
|
|
1461
|
+
green?: string;
|
|
1462
|
+
yellow?: string;
|
|
1463
|
+
blue?: string;
|
|
1464
|
+
magenta?: string;
|
|
1465
|
+
cyan?: string;
|
|
1466
|
+
white?: string;
|
|
1467
|
+
brightBlack?: string;
|
|
1468
|
+
brightRed?: string;
|
|
1469
|
+
brightGreen?: string;
|
|
1470
|
+
brightYellow?: string;
|
|
1471
|
+
brightBlue?: string;
|
|
1472
|
+
brightMagenta?: string;
|
|
1473
|
+
brightCyan?: string;
|
|
1474
|
+
brightWhite?: string;
|
|
1475
|
+
}
|
|
1476
|
+
/**
|
|
1477
|
+
* The terminal palette's KEYS. The VALUES live one rung down — `@xenosystem/elements/tokens` `terminal`,
|
|
1478
|
+
* emitted by elements-react's theme as `--xeno-terminal-<kebab>` — because a block may carry no colour
|
|
1479
|
+
* literal (the no-chrome gate). Until 2026-09-18 this file held all twenty-one as hex; it now reads them
|
|
1480
|
+
* from the element the terminal mounts in (`readTerminalTheme`). A key whose var is absent is simply not
|
|
1481
|
+
* set, and xterm falls back to its own default for it — visible, never a wrong colour.
|
|
1482
|
+
*
|
|
1483
|
+
* **A complete sixteen-colour palette, not a partial one.** The surveyed `xeno-shell` set five keys and
|
|
1484
|
+
* let the other eleven fall back, so a `ls --color` in that terminal rendered in xterm's blues and greens
|
|
1485
|
+
* rather than the product's. The token set is complete; this list is what makes the read complete too.
|
|
1486
|
+
*/
|
|
1487
|
+
declare const XENO_TERMINAL_THEME_KEYS: readonly ["background", "foreground", "cursor", "cursorAccent", "selectionBackground", "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "brightBlack", "brightRed", "brightGreen", "brightYellow", "brightBlue", "brightMagenta", "brightCyan", "brightWhite"];
|
|
1488
|
+
/** `background` → `--xeno-terminal-background`, `brightBlack` → `--xeno-terminal-bright-black`. */
|
|
1489
|
+
declare function terminalThemeVar(key: keyof XenoTerminalTheme): string;
|
|
1490
|
+
/**
|
|
1491
|
+
* Read the palette from the custom properties in scope at `element` (the `.xeno` theme). Keys whose
|
|
1492
|
+
* var is not set are omitted. Pure over `getComputedStyle`, so it is testable with a stub.
|
|
1493
|
+
*/
|
|
1494
|
+
declare function readTerminalTheme(element: {
|
|
1495
|
+
ownerDocument?: {
|
|
1496
|
+
defaultView?: {
|
|
1497
|
+
getComputedStyle(el: unknown): {
|
|
1498
|
+
getPropertyValue(name: string): string;
|
|
1499
|
+
};
|
|
1500
|
+
} | null;
|
|
1501
|
+
} | null;
|
|
1502
|
+
} | null | undefined): XenoTerminalTheme;
|
|
1503
|
+
/** The emulator seam — everything the panel needs from xterm, and nothing more. */
|
|
1504
|
+
interface XenoTerminalEmulator {
|
|
1505
|
+
/** Attach to a DOM element. */
|
|
1506
|
+
open(element: HTMLElement): void;
|
|
1507
|
+
/** Write host output. */
|
|
1508
|
+
write(data: string): void;
|
|
1509
|
+
/** Clear the screen and scrollback. */
|
|
1510
|
+
clear(): void;
|
|
1511
|
+
/** Re-fit to the element. Returns the new grid, or `null` if it could not measure. */
|
|
1512
|
+
fit(): {
|
|
1513
|
+
cols: number;
|
|
1514
|
+
rows: number;
|
|
1515
|
+
} | null;
|
|
1516
|
+
/** Current grid. */
|
|
1517
|
+
size(): {
|
|
1518
|
+
cols: number;
|
|
1519
|
+
rows: number;
|
|
1520
|
+
};
|
|
1521
|
+
/** User keystrokes. Returns an unsubscribe. */
|
|
1522
|
+
onData(handler: (data: string) => void): () => void;
|
|
1523
|
+
/** Grid changes. Returns an unsubscribe. */
|
|
1524
|
+
onResize(handler: (size: {
|
|
1525
|
+
cols: number;
|
|
1526
|
+
rows: number;
|
|
1527
|
+
}) => void): () => void;
|
|
1528
|
+
/** Find in the scrollback. Optional — not every emulator has a search addon. */
|
|
1529
|
+
search?(query: string, direction: 'next' | 'previous'): boolean;
|
|
1530
|
+
/** Change font size. */
|
|
1531
|
+
setFontSize?(size: number): void;
|
|
1532
|
+
/** Release everything. */
|
|
1533
|
+
dispose(): void;
|
|
1534
|
+
}
|
|
1535
|
+
/**
|
|
1536
|
+
* Is a measured grid usable?
|
|
1537
|
+
*
|
|
1538
|
+
* **The third answer to a problem both incumbents got wrong.** `xeno-agent-interface` spawns every
|
|
1539
|
+
* PTY at a fixed 80×24 and lets the first resize correct it, so a full-screen TUI or a progress bar
|
|
1540
|
+
* renders once at the wrong size. `xeno-shell` calls `fit()` synchronously right after `open()` —
|
|
1541
|
+
* before layout has necessarily settled — and its own comment admits the follow-up resize is a race
|
|
1542
|
+
* patch. Neither waits for a real measurement.
|
|
1543
|
+
*
|
|
1544
|
+
* This panel does: no `start` intent is emitted until `fit()` reports a plausible grid, exactly as
|
|
1545
|
+
* the viewport panel refuses to report a zero-sized surface. The problem is the same one and so is
|
|
1546
|
+
* the fix.
|
|
1547
|
+
*
|
|
1548
|
+
* @param size - A measured grid, or `null`.
|
|
1549
|
+
* @returns Whether it can be sent to a PTY.
|
|
1550
|
+
*/
|
|
1551
|
+
declare function measuredSize(size: {
|
|
1552
|
+
cols: number;
|
|
1553
|
+
rows: number;
|
|
1554
|
+
} | null): boolean;
|
|
1555
|
+
/** Is this session live enough to accept input? */
|
|
1556
|
+
declare function isLive(status: XenoTerminalStatus): boolean;
|
|
1557
|
+
/** A short label for a session's state, or `null` while it is simply running. */
|
|
1558
|
+
declare function statusLabel(session: XenoTerminalSession): string | null;
|
|
1559
|
+
|
|
1560
|
+
/**
|
|
1561
|
+
* The Terminal controller — instances, the emulator seam, and intents.
|
|
1562
|
+
*
|
|
1563
|
+
* Zero dependency on xterm. The emulator arrives through {@link XenoTerminalEmulator}, which keeps
|
|
1564
|
+
* this file testable without a DOM and keeps 345 KB of third-party runtime out of the package's
|
|
1565
|
+
* own graph. See the doc pack for the bundling rationale.
|
|
1566
|
+
*
|
|
1567
|
+
* @module
|
|
1568
|
+
*/
|
|
1569
|
+
|
|
1570
|
+
/** The host seam. */
|
|
1571
|
+
interface TerminalHostBridge {
|
|
1572
|
+
emit(portId: string, value: unknown): void;
|
|
1573
|
+
makeId?: () => string;
|
|
1574
|
+
}
|
|
1575
|
+
/** Construction options. */
|
|
1576
|
+
interface TerminalControllerOptions {
|
|
1577
|
+
host: TerminalHostBridge;
|
|
1578
|
+
/** Scrollback lines held by the emulator. Default 5000. */
|
|
1579
|
+
scrollback?: number;
|
|
1580
|
+
/** Font size. Default 12. */
|
|
1581
|
+
fontSize?: number;
|
|
1582
|
+
/**
|
|
1583
|
+
* Ask the host to stop a session when its instance is closed in the UI. Default `true`.
|
|
1584
|
+
*
|
|
1585
|
+
* **This is a policy, and it must be chosen rather than inherited.** The two surveyed
|
|
1586
|
+
* implementations chose opposite defaults: `xeno-agent-interface` deliberately decouples PTY
|
|
1587
|
+
* lifetime from component lifetime ("each tab owns its process until the tab is explicitly
|
|
1588
|
+
* closed") while `xeno-shell` couples them through a React `key`, so switching connection kills
|
|
1589
|
+
* the session. Closing a tab is an explicit user act, so stopping is the honest default —
|
|
1590
|
+
* unmounting is not, and never stops anything here.
|
|
1591
|
+
*/
|
|
1592
|
+
stopOnClose?: boolean;
|
|
1593
|
+
}
|
|
1594
|
+
/** The Terminal panel controller. */
|
|
1595
|
+
declare class TerminalController {
|
|
1596
|
+
private readonly host;
|
|
1597
|
+
private readonly makeId;
|
|
1598
|
+
private readonly stopOnClose;
|
|
1599
|
+
readonly scrollback: number;
|
|
1600
|
+
private instances;
|
|
1601
|
+
private order;
|
|
1602
|
+
private activeId;
|
|
1603
|
+
private fontSize;
|
|
1604
|
+
private search;
|
|
1605
|
+
private readonly listeners;
|
|
1606
|
+
private snapshot;
|
|
1607
|
+
constructor(options: TerminalControllerOptions);
|
|
1608
|
+
subscribe: (listener: () => void) => (() => void);
|
|
1609
|
+
getState: () => TerminalViewState;
|
|
1610
|
+
private notify;
|
|
1611
|
+
private emit;
|
|
1612
|
+
/**
|
|
1613
|
+
* Open a terminal instance.
|
|
1614
|
+
*
|
|
1615
|
+
* **No `start` intent is emitted here.** The panel cannot say how big the terminal is until an
|
|
1616
|
+
* emulator has attached and measured, and a PTY started at a guessed size renders its first
|
|
1617
|
+
* screen wrong. `start` goes out from {@link attach}, once.
|
|
1618
|
+
*
|
|
1619
|
+
* @param id - An explicit id, or one is minted.
|
|
1620
|
+
* @param session - Opening session fields.
|
|
1621
|
+
* @returns The instance id.
|
|
1622
|
+
*/
|
|
1623
|
+
open(id?: XenoTerminalId, session?: Partial<XenoTerminalSession>): XenoTerminalId;
|
|
1624
|
+
/**
|
|
1625
|
+
* Close an instance.
|
|
1626
|
+
*
|
|
1627
|
+
* @param id - The instance.
|
|
1628
|
+
* @returns Whether it existed.
|
|
1629
|
+
*/
|
|
1630
|
+
close(id: XenoTerminalId): boolean;
|
|
1631
|
+
/** Bring an instance to the front. */
|
|
1632
|
+
setActive(id: XenoTerminalId | null): void;
|
|
1633
|
+
/** Instance ids, in open order. */
|
|
1634
|
+
ids(): XenoTerminalId[];
|
|
1635
|
+
/**
|
|
1636
|
+
* Attach an emulator to an instance and wire it up.
|
|
1637
|
+
*
|
|
1638
|
+
* Emits `start` **only once a real grid has been measured** — the fix for a mistake both
|
|
1639
|
+
* incumbents make in opposite directions (a fixed 80×24 guess, or a synchronous fit before
|
|
1640
|
+
* layout settles).
|
|
1641
|
+
*
|
|
1642
|
+
* @param id - The instance.
|
|
1643
|
+
* @param emulator - The emulator.
|
|
1644
|
+
* @param element - Where it mounts.
|
|
1645
|
+
* @returns A detach function.
|
|
1646
|
+
*/
|
|
1647
|
+
attach(id: XenoTerminalId, emulator: XenoTerminalEmulator, element: HTMLElement): () => void;
|
|
1648
|
+
/**
|
|
1649
|
+
* Re-fit an instance, and start it if it has never been measured before.
|
|
1650
|
+
*
|
|
1651
|
+
* @param id - The instance.
|
|
1652
|
+
* @returns The measured grid, or `null`.
|
|
1653
|
+
*/
|
|
1654
|
+
fit(id: XenoTerminalId): {
|
|
1655
|
+
cols: number;
|
|
1656
|
+
rows: number;
|
|
1657
|
+
} | null;
|
|
1658
|
+
/**
|
|
1659
|
+
* Detach an instance's emulator.
|
|
1660
|
+
*
|
|
1661
|
+
* **Never stops the session.** A panel can unmount because a tab was hidden, a layout changed,
|
|
1662
|
+
* or React StrictMode double-invoked an effect; killing a shell for any of those would lose work
|
|
1663
|
+
* the user did not ask to lose.
|
|
1664
|
+
*
|
|
1665
|
+
* @param id - The instance.
|
|
1666
|
+
*/
|
|
1667
|
+
detach(id: XenoTerminalId): void;
|
|
1668
|
+
/** Ask the host to replay its buffer — the reattach path. */
|
|
1669
|
+
requestSnapshot(id: XenoTerminalId): boolean;
|
|
1670
|
+
/**
|
|
1671
|
+
* Write host output into a terminal.
|
|
1672
|
+
*
|
|
1673
|
+
* @param payload - `{id, data}`.
|
|
1674
|
+
* @returns Whether the instance exists.
|
|
1675
|
+
*/
|
|
1676
|
+
write(payload: XenoTerminalData): boolean;
|
|
1677
|
+
/**
|
|
1678
|
+
* Update a session's host-reported state.
|
|
1679
|
+
*
|
|
1680
|
+
* The panel never sets `running` or `exited` itself — it asked, and it waits to be told.
|
|
1681
|
+
*
|
|
1682
|
+
* @param session - Fields to merge, keyed by `id`.
|
|
1683
|
+
* @returns Whether the instance exists.
|
|
1684
|
+
*/
|
|
1685
|
+
setSession(session: Partial<XenoTerminalSession> & {
|
|
1686
|
+
id: XenoTerminalId;
|
|
1687
|
+
}): boolean;
|
|
1688
|
+
/** Replace a terminal's contents — used when a snapshot arrives. */
|
|
1689
|
+
replace(id: XenoTerminalId, data: string): boolean;
|
|
1690
|
+
/** Set the font size on every attached emulator. */
|
|
1691
|
+
setFontSize(size: number): void;
|
|
1692
|
+
/** Open, update, or close the find bar. */
|
|
1693
|
+
setSearch(query: string | null): void;
|
|
1694
|
+
/** Run a search in the active terminal. */
|
|
1695
|
+
find(direction?: 'next' | 'previous'): boolean;
|
|
1696
|
+
/** The active instance's session, if any. */
|
|
1697
|
+
active(): XenoTerminalSession | null;
|
|
1698
|
+
/**
|
|
1699
|
+
* Serialize.
|
|
1700
|
+
*
|
|
1701
|
+
* Which tab was in front, and the font size. **Never the scrollback** — terminal output routinely
|
|
1702
|
+
* contains tokens, connection strings and keys that a user pasted or a tool printed, and `.xapp`
|
|
1703
|
+
* is a plain JSON file. `XENO AUTH - SPEC.md` L9 keeps secrets out of exactly this kind of store.
|
|
1704
|
+
*/
|
|
1705
|
+
serialize(): TerminalPanelState;
|
|
1706
|
+
/** Restore view preferences. Sessions are not restored — the host re-declares them. */
|
|
1707
|
+
deserialize(state: unknown): void;
|
|
1708
|
+
/**
|
|
1709
|
+
* Tear down.
|
|
1710
|
+
*
|
|
1711
|
+
* Detaches every emulator and **stops nothing**. Sessions outlive the panel by design; the host
|
|
1712
|
+
* decides when a process dies.
|
|
1713
|
+
*/
|
|
1714
|
+
dispose(): void;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
/**
|
|
1718
|
+
* The `PanelModule` — view wired inside the package (one React copy).
|
|
1719
|
+
*
|
|
1720
|
+
* @module
|
|
1721
|
+
*/
|
|
1722
|
+
|
|
1723
|
+
/** Everything a renderer needs: the controller plus the resolved config. */
|
|
1724
|
+
interface TerminalRenderContext {
|
|
1725
|
+
controller: TerminalController;
|
|
1726
|
+
config: {
|
|
1727
|
+
multiInstance: boolean;
|
|
1728
|
+
};
|
|
1729
|
+
}
|
|
1730
|
+
/** Options for {@link createTerminalPanel}. */
|
|
1731
|
+
interface CreateTerminalPanelOptions {
|
|
1732
|
+
/**
|
|
1733
|
+
* Build an emulator.
|
|
1734
|
+
*
|
|
1735
|
+
* Defaults to the dynamically-imported xterm adapter, so a consumer that never mounts a terminal
|
|
1736
|
+
* never downloads one. A host with its own xterm instance passes a seam over that instead.
|
|
1737
|
+
*/
|
|
1738
|
+
createEmulator?: () => Promise<XenoTerminalEmulator>;
|
|
1739
|
+
/** Override the view. */
|
|
1740
|
+
render?: (root: HTMLElement, context: TerminalRenderContext) => () => void;
|
|
1741
|
+
}
|
|
1742
|
+
/**
|
|
1743
|
+
* Build the Terminal panel module.
|
|
1744
|
+
*
|
|
1745
|
+
* @param options - Emulator factory and optional renderer override.
|
|
1746
|
+
* @returns The module.
|
|
1747
|
+
*/
|
|
1748
|
+
declare function createTerminalPanel(options?: CreateTerminalPanelOptions): PanelModule;
|
|
1749
|
+
/** The default Terminal panel module — view wired, xterm lazily imported. Register THIS. */
|
|
1750
|
+
declare const terminalPanel: PanelModule;
|
|
1751
|
+
|
|
1752
|
+
/**
|
|
1753
|
+
* The `xeno.core.terminal` manifest.
|
|
1754
|
+
*
|
|
1755
|
+
* Capabilities: `storage.local` only — and this is the panel where that deserves an explanation.
|
|
1756
|
+
*
|
|
1757
|
+
* A terminal's natural host binding is process I/O, which is the most powerful thing any panel in
|
|
1758
|
+
* the catalog sits next to. There is deliberately **no `process.*` capability**, here or in the
|
|
1759
|
+
* SDK: the panel emits intents and renders a stream, and the host spawns, signals and kills. That
|
|
1760
|
+
* boundary is what lets this panel run unchanged in an `iframe-quickjs` sandbox — where it can only
|
|
1761
|
+
* ever be a host-brokered stream, which is exactly what it already is everywhere else.
|
|
1762
|
+
*
|
|
1763
|
+
* @module
|
|
1764
|
+
*/
|
|
1765
|
+
|
|
1766
|
+
/** The canonical manifest id. */
|
|
1767
|
+
declare const TERMINAL_PANEL_ID = "xeno.core.terminal";
|
|
1768
|
+
/** The `xeno.core.terminal` manifest. */
|
|
1769
|
+
declare const terminalManifest: PanelManifest;
|
|
1770
|
+
|
|
1771
|
+
/**
|
|
1772
|
+
* The Terminal panel view.
|
|
1773
|
+
*
|
|
1774
|
+
* **Where primitives fit and where they do not.** The tab strip, status chips and find bar use
|
|
1775
|
+
* `Toolbar`/`Badge`/`SearchField`/`TextButton`. The terminal body does not use a primitive at all —
|
|
1776
|
+
* it is a bare `<div>` that xterm owns outright, and wrapping it in anything that sets padding,
|
|
1777
|
+
* overflow or transform breaks the emulator's own measurement. That div is deliberately plain.
|
|
1778
|
+
*
|
|
1779
|
+
* @module
|
|
1780
|
+
*/
|
|
1781
|
+
|
|
1782
|
+
/** Props for {@link TerminalPanelView}. */
|
|
1783
|
+
interface TerminalPanelViewProps {
|
|
1784
|
+
controller: TerminalController;
|
|
1785
|
+
/**
|
|
1786
|
+
* Build an emulator. Async so the default xterm adapter can be dynamically imported.
|
|
1787
|
+
*
|
|
1788
|
+
* Omit it and the panel renders its chrome with an explanatory empty state rather than a blank
|
|
1789
|
+
* rectangle — a terminal with no emulator is a configuration mistake, and it should say so.
|
|
1790
|
+
*/
|
|
1791
|
+
createEmulator?: () => Promise<XenoTerminalEmulator>;
|
|
1792
|
+
multiInstance?: boolean;
|
|
1793
|
+
}
|
|
1794
|
+
/** The Terminal panel view. */
|
|
1795
|
+
declare function TerminalPanelView({ controller, createEmulator, multiInstance, }: TerminalPanelViewProps): ReactNode;
|
|
1796
|
+
|
|
1797
|
+
/**
|
|
1798
|
+
* The Diff contract.
|
|
1799
|
+
*
|
|
1800
|
+
* The host computes the comparison; the panel renders it and emits decisions. No diff engine and
|
|
1801
|
+
* no editor ships in this package — see the doc pack.
|
|
1802
|
+
*
|
|
1803
|
+
* @module
|
|
1804
|
+
*/
|
|
1805
|
+
/** What happened to a line. */
|
|
1806
|
+
type XenoDiffLineKind = 'context' | 'added' | 'removed';
|
|
1807
|
+
/**
|
|
1808
|
+
* A run of text within a line, for word-level marks.
|
|
1809
|
+
*
|
|
1810
|
+
* Optional throughout. `xeno-docs` computes word-level segments with its own LCS; Monaco produces
|
|
1811
|
+
* inline marks internally; the inline tool card produces none at all. A panel that *required*
|
|
1812
|
+
* segments would exclude two of the three surveyed producers, and a panel that could not render
|
|
1813
|
+
* them would throw away the one thing the prose diff does better than the code diff.
|
|
1814
|
+
*/
|
|
1815
|
+
interface XenoDiffSegment {
|
|
1816
|
+
text: string;
|
|
1817
|
+
kind: 'equal' | 'insert' | 'delete';
|
|
1818
|
+
}
|
|
1819
|
+
/** One rendered line. */
|
|
1820
|
+
interface XenoDiffLine {
|
|
1821
|
+
kind: XenoDiffLineKind;
|
|
1822
|
+
/** 1-based line number in the original. Absent on an added line. */
|
|
1823
|
+
oldLineNumber?: number;
|
|
1824
|
+
/** 1-based line number in the modified file. Absent on a removed line. */
|
|
1825
|
+
newLineNumber?: number;
|
|
1826
|
+
text: string;
|
|
1827
|
+
/** Word-level marks within `text`. Rendered when present, ignored when not. */
|
|
1828
|
+
segments?: XenoDiffSegment[];
|
|
1829
|
+
}
|
|
1830
|
+
/**
|
|
1831
|
+
* How a hunk's decision stands, **as the host reports it**.
|
|
1832
|
+
*
|
|
1833
|
+
* - `undecided` — nothing asked.
|
|
1834
|
+
* - `pending` — the panel emitted a decision; the host has not confirmed.
|
|
1835
|
+
* - `applied` — the host committed it.
|
|
1836
|
+
* - `failed` — the host tried and could not.
|
|
1837
|
+
* - `conflict` — **the file changed underneath the review.** Distinct from `failed` because the
|
|
1838
|
+
* remedy is different: a failure may be retried as-is, a conflict means the hunk no longer
|
|
1839
|
+
* describes the file and the decision has to be made again against fresh content.
|
|
1840
|
+
*/
|
|
1841
|
+
type XenoDiffDecisionState = 'undecided' | 'pending' | 'applied' | 'failed' | 'conflict';
|
|
1842
|
+
/** What a reviewer decided about a hunk. */
|
|
1843
|
+
type XenoDiffAction = 'accept' | 'reject' | 'comment';
|
|
1844
|
+
/** A decision, as the host reports it back. */
|
|
1845
|
+
interface XenoDiffDecision {
|
|
1846
|
+
hunkId: string;
|
|
1847
|
+
path: string;
|
|
1848
|
+
action: XenoDiffAction;
|
|
1849
|
+
state: XenoDiffDecisionState;
|
|
1850
|
+
comment?: string;
|
|
1851
|
+
/** Host-authored sentence, rendered verbatim. Where a conflict explains itself. */
|
|
1852
|
+
message?: string;
|
|
1853
|
+
updatedAt?: number;
|
|
1854
|
+
}
|
|
1855
|
+
/** A contiguous change region. */
|
|
1856
|
+
interface XenoDiffHunk {
|
|
1857
|
+
/**
|
|
1858
|
+
* Stable within the current model.
|
|
1859
|
+
*
|
|
1860
|
+
* The surveyed host **re-derives hunk ids from immutable content snapshots on every request
|
|
1861
|
+
* rather than trusting a stale id** — the panel round-trips whatever it was given and never
|
|
1862
|
+
* synthesizes one, so that discipline survives the trip through the UI.
|
|
1863
|
+
*/
|
|
1864
|
+
id: string;
|
|
1865
|
+
oldStart: number;
|
|
1866
|
+
oldLines: number;
|
|
1867
|
+
newStart: number;
|
|
1868
|
+
newLines: number;
|
|
1869
|
+
lines: XenoDiffLine[];
|
|
1870
|
+
/** Optional header (`@@ -1,7 +1,9 @@`, or a function signature). */
|
|
1871
|
+
header?: string;
|
|
1872
|
+
}
|
|
1873
|
+
/** Why a file has no rendered hunks. Never conflated with "no changes". */
|
|
1874
|
+
type XenoDiffFileOmission = 'binary' | 'tooLarge' | 'unreadable' | 'identical';
|
|
1875
|
+
/** One file's comparison. */
|
|
1876
|
+
interface XenoDiffFile {
|
|
1877
|
+
path: string;
|
|
1878
|
+
/** Previous path, when renamed. */
|
|
1879
|
+
oldPath?: string;
|
|
1880
|
+
status: 'added' | 'removed' | 'modified' | 'renamed';
|
|
1881
|
+
hunks: XenoDiffHunk[];
|
|
1882
|
+
/** Language hint for a syntax seam. Never used to load anything by itself. */
|
|
1883
|
+
language?: string;
|
|
1884
|
+
/**
|
|
1885
|
+
* Why `hunks` is empty, when it is empty for a reason other than equality.
|
|
1886
|
+
*
|
|
1887
|
+
* 🔴 The find-panel landmine, in a second costume: an empty hunk list rendered as "no changes"
|
|
1888
|
+
* is a wrong answer wearing the costume of an empty one. A binary file, a file past the diff
|
|
1889
|
+
* engine's size ceiling, and a file that is genuinely identical must not look alike — the first
|
|
1890
|
+
* two mean "not compared", and a reviewer who reads them as "unchanged" approves something
|
|
1891
|
+
* nobody looked at.
|
|
1892
|
+
*/
|
|
1893
|
+
omitted?: XenoDiffFileOmission;
|
|
1894
|
+
/** Counts, when the host knows them. */
|
|
1895
|
+
additions?: number;
|
|
1896
|
+
deletions?: number;
|
|
1897
|
+
}
|
|
1898
|
+
/** Whether the comparison has been computed at all. */
|
|
1899
|
+
type XenoDiffStatus = 'idle' | 'computing' | 'ready' | 'failed';
|
|
1900
|
+
/** The whole comparison. */
|
|
1901
|
+
interface XenoDiffModel {
|
|
1902
|
+
files: XenoDiffFile[];
|
|
1903
|
+
status?: XenoDiffStatus;
|
|
1904
|
+
/** Host-authored sentence for `failed`. */
|
|
1905
|
+
message?: string;
|
|
1906
|
+
/** Labels for the two sides. */
|
|
1907
|
+
oldLabel?: string;
|
|
1908
|
+
newLabel?: string;
|
|
1909
|
+
/** Monotonic revision; an older model is ignored. */
|
|
1910
|
+
rev?: number;
|
|
1911
|
+
}
|
|
1912
|
+
/** How the comparison is laid out. */
|
|
1913
|
+
type XenoDiffView = 'unified' | 'split';
|
|
1914
|
+
/** A decision intent. The panel decides nothing itself. */
|
|
1915
|
+
interface XenoDiffDecisionIntent {
|
|
1916
|
+
path: string;
|
|
1917
|
+
hunkId: string;
|
|
1918
|
+
action: XenoDiffAction;
|
|
1919
|
+
comment?: string;
|
|
1920
|
+
}
|
|
1921
|
+
/** "Reveal this hunk in the editor." */
|
|
1922
|
+
interface XenoDiffNavigate {
|
|
1923
|
+
path: string;
|
|
1924
|
+
hunkId: string;
|
|
1925
|
+
/** Line to reveal, in the modified file where one exists. */
|
|
1926
|
+
line?: number;
|
|
1927
|
+
}
|
|
1928
|
+
/** The syntax-highlighting seam. */
|
|
1929
|
+
interface XenoDiffHighlighter {
|
|
1930
|
+
/**
|
|
1931
|
+
* Turn a line of code into segments.
|
|
1932
|
+
*
|
|
1933
|
+
* Deliberately the same `XenoDiffSegment`-shaped idea as word marks, but with a token class
|
|
1934
|
+
* instead of a change kind, so the renderer composes the two without either knowing about the
|
|
1935
|
+
* other.
|
|
1936
|
+
*
|
|
1937
|
+
* @param text - The line.
|
|
1938
|
+
* @param language - The file's language hint.
|
|
1939
|
+
* @returns Token runs, or `null` to render plain.
|
|
1940
|
+
*/
|
|
1941
|
+
highlight(text: string, language?: string): {
|
|
1942
|
+
text: string;
|
|
1943
|
+
token?: string;
|
|
1944
|
+
}[] | null;
|
|
1945
|
+
}
|
|
1946
|
+
/** Persisted panel state. */
|
|
1947
|
+
interface DiffPanelState {
|
|
1948
|
+
view?: XenoDiffView;
|
|
1949
|
+
/** Collapsed file paths. */
|
|
1950
|
+
collapsed?: string[];
|
|
1951
|
+
wrap?: boolean;
|
|
1952
|
+
showWhitespace?: boolean;
|
|
1953
|
+
}
|
|
1954
|
+
/** A file as the view receives it. */
|
|
1955
|
+
interface DiffFileView {
|
|
1956
|
+
file: XenoDiffFile;
|
|
1957
|
+
collapsed: boolean;
|
|
1958
|
+
/** Decisions for this file's hunks, keyed by hunk id. */
|
|
1959
|
+
decisions: Record<string, XenoDiffDecision>;
|
|
1960
|
+
additions: number;
|
|
1961
|
+
deletions: number;
|
|
1962
|
+
}
|
|
1963
|
+
/** What the view renders. */
|
|
1964
|
+
interface DiffViewState {
|
|
1965
|
+
files: DiffFileView[];
|
|
1966
|
+
status: XenoDiffStatus;
|
|
1967
|
+
message: string | null;
|
|
1968
|
+
view: XenoDiffView;
|
|
1969
|
+
wrap: boolean;
|
|
1970
|
+
showWhitespace: boolean;
|
|
1971
|
+
oldLabel: string;
|
|
1972
|
+
newLabel: string;
|
|
1973
|
+
/** Totals across every file. */
|
|
1974
|
+
totals: {
|
|
1975
|
+
files: number;
|
|
1976
|
+
additions: number;
|
|
1977
|
+
deletions: number;
|
|
1978
|
+
hunks: number;
|
|
1979
|
+
};
|
|
1980
|
+
/** Decision progress, when any decision exists. */
|
|
1981
|
+
review: {
|
|
1982
|
+
decided: number;
|
|
1983
|
+
total: number;
|
|
1984
|
+
conflicts: number;
|
|
1985
|
+
pending: number;
|
|
1986
|
+
} | null;
|
|
1987
|
+
/** Genuinely nothing changed — as opposed to nothing computed. */
|
|
1988
|
+
identical: boolean;
|
|
1989
|
+
/** The currently focused hunk, for keyboard navigation. */
|
|
1990
|
+
cursor: {
|
|
1991
|
+
path: string;
|
|
1992
|
+
hunkId: string;
|
|
1993
|
+
} | null;
|
|
1994
|
+
}
|
|
1995
|
+
/**
|
|
1996
|
+
* Is a decision settled enough to render as done?
|
|
1997
|
+
*
|
|
1998
|
+
* Only `applied`. A `pending` decision has been asked for and not confirmed; rendering it as
|
|
1999
|
+
* accepted is the same class of lie as a consent panel flipping a switch before the host answers.
|
|
2000
|
+
*
|
|
2001
|
+
* @param state - The decision state.
|
|
2002
|
+
* @returns Whether the host has confirmed it.
|
|
2003
|
+
*/
|
|
2004
|
+
declare function isSettled(state: XenoDiffDecisionState): boolean;
|
|
2005
|
+
/**
|
|
2006
|
+
* Does this decision need the reviewer's attention again?
|
|
2007
|
+
*
|
|
2008
|
+
* `conflict` does and `failed` does; they are separated because the remedies differ.
|
|
2009
|
+
*
|
|
2010
|
+
* @param state - The decision state.
|
|
2011
|
+
* @returns Whether the reviewer must act.
|
|
2012
|
+
*/
|
|
2013
|
+
declare function needsAttention(state: XenoDiffDecisionState): boolean;
|
|
2014
|
+
/**
|
|
2015
|
+
* Count added and removed lines in a file.
|
|
2016
|
+
*
|
|
2017
|
+
* Derived when the host does not supply counts, because a header that says "+0 −0" over a file
|
|
2018
|
+
* full of changes is worse than no header.
|
|
2019
|
+
*
|
|
2020
|
+
* @param file - The file.
|
|
2021
|
+
* @returns Additions and deletions.
|
|
2022
|
+
*/
|
|
2023
|
+
declare function countChanges(file: XenoDiffFile): {
|
|
2024
|
+
additions: number;
|
|
2025
|
+
deletions: number;
|
|
2026
|
+
};
|
|
2027
|
+
/**
|
|
2028
|
+
* Pair a hunk's lines into left/right rows for a split view.
|
|
2029
|
+
*
|
|
2030
|
+
* Removed and added lines align pairwise; the shorter side is padded with `null` so both columns
|
|
2031
|
+
* stay in step. A naive split that renders each column independently drifts as soon as a hunk has
|
|
2032
|
+
* unequal counts, and the two halves then disagree about which line is which.
|
|
2033
|
+
*
|
|
2034
|
+
* @param hunk - The hunk.
|
|
2035
|
+
* @returns Aligned rows.
|
|
2036
|
+
*/
|
|
2037
|
+
declare function splitRows(hunk: XenoDiffHunk): {
|
|
2038
|
+
left: XenoDiffLine | null;
|
|
2039
|
+
right: XenoDiffLine | null;
|
|
2040
|
+
}[];
|
|
2041
|
+
/** Every hunk in the model, flattened in file order — the traversal order for next/previous. */
|
|
2042
|
+
declare function flattenHunks(model: XenoDiffModel): {
|
|
2043
|
+
path: string;
|
|
2044
|
+
hunkId: string;
|
|
2045
|
+
}[];
|
|
2046
|
+
/** A short label for why a file has no hunks. */
|
|
2047
|
+
declare function omissionLabel(omission: XenoDiffFileOmission): string;
|
|
2048
|
+
|
|
2049
|
+
/**
|
|
2050
|
+
* The Diff controller — the model, the decisions, and the hunk cursor.
|
|
2051
|
+
*
|
|
2052
|
+
* @module
|
|
2053
|
+
*/
|
|
2054
|
+
|
|
2055
|
+
/** The host seam. */
|
|
2056
|
+
interface DiffHostBridge {
|
|
2057
|
+
emit(portId: string, value: unknown): void;
|
|
2058
|
+
}
|
|
2059
|
+
/** Construction options. */
|
|
2060
|
+
interface DiffControllerOptions {
|
|
2061
|
+
host: DiffHostBridge;
|
|
2062
|
+
/** Opening layout. Default `unified`. */
|
|
2063
|
+
view?: XenoDiffView;
|
|
2064
|
+
/** Offer accept/reject. Default `false` — most diffs are read-only. */
|
|
2065
|
+
review?: boolean;
|
|
2066
|
+
}
|
|
2067
|
+
/** The Diff panel controller. */
|
|
2068
|
+
declare class DiffController {
|
|
2069
|
+
private readonly host;
|
|
2070
|
+
private readonly reviewEnabled;
|
|
2071
|
+
private model;
|
|
2072
|
+
private rev;
|
|
2073
|
+
private decisions;
|
|
2074
|
+
private view;
|
|
2075
|
+
private collapsed;
|
|
2076
|
+
private wrap;
|
|
2077
|
+
private showWhitespace;
|
|
2078
|
+
private cursor;
|
|
2079
|
+
private readonly listeners;
|
|
2080
|
+
private snapshot;
|
|
2081
|
+
constructor(options: DiffControllerOptions);
|
|
2082
|
+
subscribe: (listener: () => void) => (() => void);
|
|
2083
|
+
getState: () => DiffViewState;
|
|
2084
|
+
private notify;
|
|
2085
|
+
private key;
|
|
2086
|
+
/**
|
|
2087
|
+
* Install a comparison.
|
|
2088
|
+
*
|
|
2089
|
+
* @param model - The host-computed diff.
|
|
2090
|
+
* @returns Whether it was applied (an older `rev` is ignored).
|
|
2091
|
+
*/
|
|
2092
|
+
setModel(model: XenoDiffModel): boolean;
|
|
2093
|
+
/** Set the comparison's status without replacing the model. */
|
|
2094
|
+
setStatus(status: XenoDiffStatus, message?: string): void;
|
|
2095
|
+
/**
|
|
2096
|
+
* Record a host-reported decision outcome.
|
|
2097
|
+
*
|
|
2098
|
+
* The **only** way a decision reaches `applied`. The panel writes `pending` when it asks, and
|
|
2099
|
+
* nothing else.
|
|
2100
|
+
*
|
|
2101
|
+
* @param decision - The outcome.
|
|
2102
|
+
* @returns Whether it was recorded.
|
|
2103
|
+
*/
|
|
2104
|
+
setDecision(decision: XenoDiffDecision): boolean;
|
|
2105
|
+
/** Record several at once. */
|
|
2106
|
+
setDecisions(decisions: readonly XenoDiffDecision[]): void;
|
|
2107
|
+
/**
|
|
2108
|
+
* Decide a hunk.
|
|
2109
|
+
*
|
|
2110
|
+
* Emits an intent and marks the decision `pending`. **It does not apply anything and does not
|
|
2111
|
+
* render as accepted.** The surveyed host runs a two-phase confirm and a checkpointed filesystem
|
|
2112
|
+
* transaction whose outcome may be `applied`, `failed` or `conflict`; a panel that showed a tick
|
|
2113
|
+
* on click would be asserting the outcome of work that has not started.
|
|
2114
|
+
*
|
|
2115
|
+
* @param path - The file.
|
|
2116
|
+
* @param hunkId - The hunk.
|
|
2117
|
+
* @param action - accept, reject or comment.
|
|
2118
|
+
* @param comment - For `comment`, or a note on either.
|
|
2119
|
+
* @returns Whether an intent was emitted.
|
|
2120
|
+
*/
|
|
2121
|
+
decide(path: string, hunkId: string, action: XenoDiffAction, comment?: string): boolean;
|
|
2122
|
+
/** Clear a decision locally so it can be re-made — the remedy for a conflict. */
|
|
2123
|
+
clearDecision(path: string, hunkId: string): boolean;
|
|
2124
|
+
/** Every recorded decision. */
|
|
2125
|
+
allDecisions(): XenoDiffDecision[];
|
|
2126
|
+
/** Move to the next hunk, across file boundaries. */
|
|
2127
|
+
nextHunk(): {
|
|
2128
|
+
path: string;
|
|
2129
|
+
hunkId: string;
|
|
2130
|
+
} | null;
|
|
2131
|
+
/** Move to the previous hunk. */
|
|
2132
|
+
previousHunk(): {
|
|
2133
|
+
path: string;
|
|
2134
|
+
hunkId: string;
|
|
2135
|
+
} | null;
|
|
2136
|
+
private moveCursor;
|
|
2137
|
+
/** Point at a specific hunk. */
|
|
2138
|
+
focusHunk(path: string, hunkId: string): boolean;
|
|
2139
|
+
private emitNavigate;
|
|
2140
|
+
/** Unified or split. */
|
|
2141
|
+
setView(view: XenoDiffView): void;
|
|
2142
|
+
/** Collapse or expand a file. */
|
|
2143
|
+
setCollapsed(path: string, collapsed: boolean): void;
|
|
2144
|
+
/** Soft-wrap long lines. */
|
|
2145
|
+
setWrap(wrap: boolean): void;
|
|
2146
|
+
/** Render whitespace characters. */
|
|
2147
|
+
setShowWhitespace(show: boolean): void;
|
|
2148
|
+
/** Ask the host for a context menu. */
|
|
2149
|
+
requestContextMenu(path: string, hunkId: string, x: number, y: number): void;
|
|
2150
|
+
/**
|
|
2151
|
+
* Serialize.
|
|
2152
|
+
*
|
|
2153
|
+
* View preferences only. **Never the model and never the decisions** — the model is a comparison
|
|
2154
|
+
* of content that has almost certainly moved on, and a restored decision would claim a review
|
|
2155
|
+
* outcome the host never confirmed.
|
|
2156
|
+
*/
|
|
2157
|
+
serialize(): DiffPanelState;
|
|
2158
|
+
/** Restore view preferences. */
|
|
2159
|
+
deserialize(state: unknown): void;
|
|
2160
|
+
/** Tear down. */
|
|
2161
|
+
dispose(): void;
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
/**
|
|
2165
|
+
* The `PanelModule` — view wired inside the package (one React copy).
|
|
2166
|
+
*
|
|
2167
|
+
* @module
|
|
2168
|
+
*/
|
|
2169
|
+
|
|
2170
|
+
/** Everything a renderer needs: the controller plus the resolved config. */
|
|
2171
|
+
interface DiffRenderContext {
|
|
2172
|
+
controller: DiffController;
|
|
2173
|
+
config: {
|
|
2174
|
+
review: boolean;
|
|
2175
|
+
};
|
|
2176
|
+
}
|
|
2177
|
+
/** Options for {@link createDiffPanel}. */
|
|
2178
|
+
interface CreateDiffPanelOptions {
|
|
2179
|
+
/**
|
|
2180
|
+
* Syntax seam.
|
|
2181
|
+
*
|
|
2182
|
+
* Deliberately an injected interface rather than a bundled editor. Monaco is ~4 MB and the
|
|
2183
|
+
* surveyed apps already ship it; a shared panel that bundled its own would be a second copy in
|
|
2184
|
+
* every consumer. No highlighter simply means plain text — never a missing panel.
|
|
2185
|
+
*/
|
|
2186
|
+
highlighter?: XenoDiffHighlighter;
|
|
2187
|
+
/** Override the view. */
|
|
2188
|
+
render?: (root: HTMLElement, context: DiffRenderContext) => () => void;
|
|
2189
|
+
}
|
|
2190
|
+
/**
|
|
2191
|
+
* Build the Diff panel module.
|
|
2192
|
+
*
|
|
2193
|
+
* @param options - Highlighter and optional renderer override.
|
|
2194
|
+
* @returns The module.
|
|
2195
|
+
*/
|
|
2196
|
+
declare function createDiffPanel(options?: CreateDiffPanelOptions): PanelModule;
|
|
2197
|
+
/** The default Diff panel module — view already wired, no highlighter. Register THIS. */
|
|
2198
|
+
declare const diffPanel: PanelModule;
|
|
2199
|
+
|
|
2200
|
+
/**
|
|
2201
|
+
* The `xeno.core.diff` manifest.
|
|
2202
|
+
*
|
|
2203
|
+
* Capabilities: `storage.local` only. The panel reads no file and writes no file — the host
|
|
2204
|
+
* computes the comparison and the host applies every decision, inside its own transaction.
|
|
2205
|
+
*
|
|
2206
|
+
* @module
|
|
2207
|
+
*/
|
|
2208
|
+
|
|
2209
|
+
/** The canonical manifest id. */
|
|
2210
|
+
declare const DIFF_PANEL_ID = "xeno.core.diff";
|
|
2211
|
+
/** The `xeno.core.diff` manifest. */
|
|
2212
|
+
declare const diffManifest: PanelManifest;
|
|
2213
|
+
|
|
2214
|
+
/**
|
|
2215
|
+
* The Diff panel view.
|
|
2216
|
+
*
|
|
2217
|
+
* **Where primitives fit and where they do not.** Toolbar, badges, section headers and the empty
|
|
2218
|
+
* states are primitives. The diff body is not: a two-column, line-numbered, monospaced grid whose
|
|
2219
|
+
* rows must align across both columns has no primitive, and the one thing it must never be is a
|
|
2220
|
+
* `RowList` — those rows are variable-height and this grid's correctness depends on them not being.
|
|
2221
|
+
*
|
|
2222
|
+
* @module
|
|
2223
|
+
*/
|
|
2224
|
+
|
|
2225
|
+
/** Props for {@link DiffPanelView}. */
|
|
2226
|
+
interface DiffPanelViewProps {
|
|
2227
|
+
controller: DiffController;
|
|
2228
|
+
/** Optional syntax seam. No highlighter means plain text, never a missing panel. */
|
|
2229
|
+
highlighter?: XenoDiffHighlighter;
|
|
2230
|
+
review?: boolean;
|
|
2231
|
+
}
|
|
2232
|
+
/** The Diff panel view. */
|
|
2233
|
+
declare function DiffPanelView({ controller, highlighter, review }: DiffPanelViewProps): ReactNode;
|
|
2234
|
+
|
|
2235
|
+
export { ALL_STATUSES, CONSOLE_PANEL_ID, ConsoleController, type ConsoleControllerOptions, type ConsoleHostBridge, type ConsolePanelState, ConsolePanelView, type ConsolePanelViewProps, type ConsoleRenderContext, type ConsoleViewState, type CreateConsolePanelOptions, type CreateDiffPanelOptions, type CreateRunsPanelOptions, type CreateTerminalPanelOptions, DEFAULT_MAX_STEP_DEPTH, DIFF_PANEL_ID, DiffController, type DiffControllerOptions, type DiffFileView, type DiffHostBridge, type DiffPanelState, DiffPanelView, type DiffPanelViewProps, type DiffRenderContext, type DiffViewState, type FlattenOptions, LEVEL_ORDER, MESSAGE_ELISION, REDACTION_CARRY, RUNS_PANEL_ID, RUN_PATH_SEPARATOR, RUN_STATUSES, RunsController, type RunsControllerOptions, type RunsHostBridge, type RunsPanelState, RunsPanelView, type RunsPanelViewProps, type RunsRenderContext, type RunsViewState, TERMINAL_PANEL_ID, TERMINAL_STATUSES, TerminalController, type TerminalControllerOptions, type TerminalHostBridge, type TerminalInstanceView, type TerminalPanelState, TerminalPanelView, type TerminalPanelViewProps, type TerminalRenderContext, type TerminalViewState, XENO_TERMINAL_THEME_KEYS, type XenoDiffAction, type XenoDiffDecision, type XenoDiffDecisionIntent, type XenoDiffDecisionState, type XenoDiffFile, type XenoDiffFileOmission, type XenoDiffHighlighter, type XenoDiffHunk, type XenoDiffLine, type XenoDiffLineKind, type XenoDiffModel, type XenoDiffNavigate, type XenoDiffSegment, type XenoDiffStatus, type XenoDiffView, type XenoLogActivate, type XenoLogDelta, type XenoLogFilter, type XenoLogLevel, type XenoLogPatch, type XenoLogRecord, type XenoLogSource, type XenoRun, type XenoRunAction, type XenoRunActivate, type XenoRunDelta, type XenoRunError, type XenoRunFilter, type XenoRunLifecycle, type XenoRunPath, type XenoRunRow, type XenoRunRunRow, type XenoRunStats, type XenoRunStatus, type XenoRunStep, type XenoRunStepAppend, type XenoRunStepPatch, type XenoRunStepRow, type XenoRunTruncation, type XenoTerminalData, type XenoTerminalEmulator, type XenoTerminalId, type XenoTerminalIntent, type XenoTerminalSession, type XenoTerminalStatus, type XenoTerminalTheme, appendStepsAtPath, canCancel, canRetry, collapseAdjacent, computeStats, consoleManifest, consolePanel, countChanges, createConsolePanel, createDiffPanel, createRunsPanel, createTerminalPanel, decodeRunPath, diffManifest, diffPanel, durationOf, encodeRunPath, flattenHunks, flattenRunRows, formatDuration, formatRecord, isActive, isLive, isSettled, isTerminal, isWaiting, lifecycleOf, matchesSearch as matchesRunSearch, matchesSearch$1 as matchesSearch, measuredSize, meetsLevel, needsAttention, omissionLabel, patchStepAtPath, queueDurationOf, readTerminalTheme, resolveStepPath, runsManifest, runsPanel, safeJson, spliceRedactedAppend, splitRows, statusLabel, terminalManifest, terminalPanel, terminalThemeVar, truncateLogMessage };
|