@vgai/sdk 0.5.4 → 0.5.6
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/package.json +5 -2
- package/src/account.ts +58 -2
- package/src/generations.ts +6 -0
- package/src/project/build-discipline.ts +721 -0
- package/src/project/run-name.ts +53 -0
- package/src/project/session-journal.ts +495 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a run is CALLED — one owner of the bound, and of the fallback a run
|
|
3
|
+
* gets when nobody named it.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS. `vgai play --name <text>` and `npm run playtest --
|
|
6
|
+
* '{"name":"…"}'` both exist so a run is findable by what it was testing, and
|
|
7
|
+
* the measured answer to an optional field is that it stays empty: every play
|
|
8
|
+
* event in the foundry probe's 45-minute session journal reads `"name":null`.
|
|
9
|
+
* A label nobody supplies indexes nothing, so the run that DOES know what it
|
|
10
|
+
* was testing supplies it — a route-targeted playtest is named after its
|
|
11
|
+
* routes, and a full-suite run is named `playtest`. Nothing is taught and no
|
|
12
|
+
* habit is required; the default carries the information.
|
|
13
|
+
*
|
|
14
|
+
* WHAT STAYS UNNAMED, deliberately: interactive `vgai play` with no `--name`.
|
|
15
|
+
* A person pressing play is not testing a named thing, and inventing a label
|
|
16
|
+
* for it would put noise in exactly the directory this makes greppable.
|
|
17
|
+
*
|
|
18
|
+
* Pure — no filesystem, no clock. `editor-server.ts`'s `playRunSlug` is the
|
|
19
|
+
* OTHER half (slugging a name into a filename segment) and reads the same
|
|
20
|
+
* bound from here, so the two can never disagree about how long a run name
|
|
21
|
+
* may be.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Bound on a run's name — long enough to stay recognisable in a directory
|
|
25
|
+
* listing, short enough that the timestamp beside it is still readable. */
|
|
26
|
+
export const MAX_RUN_NAME = 40;
|
|
27
|
+
|
|
28
|
+
/** The name a full-suite run gets: it targets no route in particular, and
|
|
29
|
+
* "which run was that" is still a question worth being able to answer. */
|
|
30
|
+
export const FULL_SUITE_RUN_NAME = 'playtest';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The name for one playtest run.
|
|
34
|
+
*
|
|
35
|
+
* An explicit name always wins — it is the caller saying what this run was,
|
|
36
|
+
* and no derivation is better informed than that. Otherwise the routes name
|
|
37
|
+
* it, joined with `+` (a separator that survives being read back as a list,
|
|
38
|
+
* unlike the `-` that route names themselves use), bounded at
|
|
39
|
+
* {@link MAX_RUN_NAME}; and a run that named no route is
|
|
40
|
+
* {@link FULL_SUITE_RUN_NAME}.
|
|
41
|
+
*
|
|
42
|
+
* `null` is impossible by construction — every playtest run gets a name — but
|
|
43
|
+
* an explicit blank/whitespace name is treated as no name at all, the same
|
|
44
|
+
* case a missing one is.
|
|
45
|
+
*/
|
|
46
|
+
export function derivePlaytestRunName(
|
|
47
|
+
explicit: string | null | undefined,
|
|
48
|
+
routes: readonly string[],
|
|
49
|
+
): string {
|
|
50
|
+
if (typeof explicit === 'string' && explicit.trim() !== '') return explicit.trim();
|
|
51
|
+
if (routes.length === 0) return FULL_SUITE_RUN_NAME;
|
|
52
|
+
return routes.join('+').slice(0, MAX_RUN_NAME).replace(/\+$/, '');
|
|
53
|
+
}
|
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The editor session's console stream, as a STRUCTURED FILE an agent can read
|
|
3
|
+
* at any moment: `<project>/logs/editor-<sessionstamp>.jsonl`.
|
|
4
|
+
*
|
|
5
|
+
* WHY. Agents do not watch terminals; they read files. Every discipline signal
|
|
6
|
+
* the editor server already produces — a save that failed validation, a
|
|
7
|
+
* build-discipline tripwire crossing (`build-discipline.ts`), the session's own
|
|
8
|
+
* lifecycle — was delivered ONLY as a terminal print, which in detach mode goes
|
|
9
|
+
* to a raw capture nobody parses and in foreground scrolls past whoever was not
|
|
10
|
+
* looking. Same measured shape as the tripwires' own origin story: the
|
|
11
|
+
* mechanism was right and the delivery assumption was false.
|
|
12
|
+
*
|
|
13
|
+
* SO THE JOURNAL IS THE RECORD AND THE PRINTS ARE ITS RENDERERS. Every caller
|
|
14
|
+
* emits the event here first and renders second, from one emit point, so no
|
|
15
|
+
* line can reach a terminal without a durable twin on disk. Nothing new is
|
|
16
|
+
* measured for the journal's sake: each event is a fact one of those surfaces
|
|
17
|
+
* was about to print anyway.
|
|
18
|
+
*
|
|
19
|
+
* IT LIVES BESIDE `logs/play-*.jsonl`, in the project, deliberately: that is
|
|
20
|
+
* the established idiom for "durable evidence this session produced", the
|
|
21
|
+
* scaffold already gitignores `logs/`, and `build-discipline.ts` already skips
|
|
22
|
+
* `logs/` when dating source (so a journal line can never read as a source
|
|
23
|
+
* change and make a tripwire cry wolf on its own output).
|
|
24
|
+
*
|
|
25
|
+
* SCHEMA-LIGHT ON PURPOSE. One discriminated union, one writer, no zod: nothing
|
|
26
|
+
* reads a journal back through a validator — an agent reads it, and `JSON.parse`
|
|
27
|
+
* per line is the whole contract. Append-only, one object per line, no levels,
|
|
28
|
+
* no transports, no config.
|
|
29
|
+
*
|
|
30
|
+
* BOUNDS. Same rule the play logs follow (`/__editor/log-session`): prune to the
|
|
31
|
+
* newest `MAX_JOURNAL_FILES` when a new one is opened. Within a session the file
|
|
32
|
+
* grows unbounded, which is what an append-only record means.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { appendFileSync, mkdirSync, readdirSync, readFileSync, unlinkSync } from 'node:fs';
|
|
36
|
+
import { join } from 'node:path';
|
|
37
|
+
import type { TripwireTier } from './build-discipline';
|
|
38
|
+
|
|
39
|
+
/** `editor-` + an ISO instant with `:`/`.` flattened + `.jsonl` — the same
|
|
40
|
+
* lexicographic-order-is-chronological-order shape `play-*.jsonl` uses. */
|
|
41
|
+
const PREFIX = 'editor-';
|
|
42
|
+
const SUFFIX = '.jsonl';
|
|
43
|
+
|
|
44
|
+
/** Journals kept per project. Matches the play logs' own `MAX_LOG_FILES`. */
|
|
45
|
+
const MAX_JOURNAL_FILES = 20;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* One journal line's payload. `at` (ISO) is added by the writer, so a caller
|
|
49
|
+
* only ever describes WHAT happened.
|
|
50
|
+
*
|
|
51
|
+
* The tripwire arm reuses `build-discipline.ts`'s vocabulary verbatim — its
|
|
52
|
+
* `TripwireTier`, its tripwire names, and the same inputs its banners are
|
|
53
|
+
* computed from — so the JSONL and the printed banner can never disagree about
|
|
54
|
+
* what fired or how loud it was.
|
|
55
|
+
*/
|
|
56
|
+
export type SessionJournalEvent =
|
|
57
|
+
/** This server began serving this project (the process's own start). */
|
|
58
|
+
| { readonly kind: 'session-started'; readonly project: string; readonly pid: number }
|
|
59
|
+
/** `POST /__editor/open-project` switched the live session's project. */
|
|
60
|
+
| {
|
|
61
|
+
readonly kind: 'project-opened';
|
|
62
|
+
readonly project: string;
|
|
63
|
+
readonly previousProject: string | null;
|
|
64
|
+
}
|
|
65
|
+
/** The session is going down (best-effort: a SIGKILL logs nothing). */
|
|
66
|
+
| { readonly kind: 'session-shutdown' }
|
|
67
|
+
/** A save-validation verdict — the same facts `runFileValidation` reports. */
|
|
68
|
+
| {
|
|
69
|
+
readonly kind: 'validation';
|
|
70
|
+
/** Project-relative, forward-slash. */
|
|
71
|
+
readonly path: string;
|
|
72
|
+
/** The validatable kind the server classified this file as. */
|
|
73
|
+
readonly fileKind: string;
|
|
74
|
+
readonly ok: boolean;
|
|
75
|
+
readonly errors?: readonly string[];
|
|
76
|
+
readonly warnings?: readonly string[];
|
|
77
|
+
}
|
|
78
|
+
/** A build-discipline tripwire CROSSED a step (never a repeat at one tier). */
|
|
79
|
+
| {
|
|
80
|
+
readonly kind: 'tripwire';
|
|
81
|
+
readonly tripwire: 'commit-cadence';
|
|
82
|
+
readonly tier: TripwireTier;
|
|
83
|
+
readonly ageMs: number;
|
|
84
|
+
readonly fileCount: number;
|
|
85
|
+
}
|
|
86
|
+
| {
|
|
87
|
+
readonly kind: 'tripwire';
|
|
88
|
+
readonly tripwire: 'unplayed-session';
|
|
89
|
+
readonly tier: TripwireTier;
|
|
90
|
+
readonly servingForMs: number;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* A play-mode log session opened or closed.
|
|
94
|
+
*
|
|
95
|
+
* `name` is the run's OPTIONAL slug (`vgai play --name <text>`), `null` for
|
|
96
|
+
* an unnamed run — the same slug that goes in the log filename, so grepping
|
|
97
|
+
* the journal for a run and listing `logs/` for it are the same question.
|
|
98
|
+
*/
|
|
99
|
+
| {
|
|
100
|
+
readonly kind: 'play';
|
|
101
|
+
readonly action: 'start' | 'stop';
|
|
102
|
+
readonly name: string | null;
|
|
103
|
+
readonly logFile: string | null;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* THE TRANSPORT ARM. Everything below is one line per transport or
|
|
107
|
+
* presence fact, and it exists because the journal was BLIND to all of
|
|
108
|
+
* them: on 2026-08-09 a session refused five `vgai play` commands against
|
|
109
|
+
* a healthy connected tab and the whole file it left behind was
|
|
110
|
+
* `session-started` plus validation lines. A relay that can refuse has to
|
|
111
|
+
* be able to say who it refused, to which tab, and on what evidence.
|
|
112
|
+
*
|
|
113
|
+
* Ids are truncated to 8 characters (`clientId8`, `tabId8`, `requestId8`)
|
|
114
|
+
* — enough to correlate lines within one session, short enough that a
|
|
115
|
+
* human can scan a column of them. No payload bodies, ever: a state
|
|
116
|
+
* snapshot or a command argument list would turn an append-only record
|
|
117
|
+
* into a memory dump.
|
|
118
|
+
*/
|
|
119
|
+
/** A control connection opened (either transport). */
|
|
120
|
+
| {
|
|
121
|
+
readonly kind: 'client-connected';
|
|
122
|
+
readonly clientId8: string;
|
|
123
|
+
readonly transport: 'ws' | 'sse';
|
|
124
|
+
readonly participant: string | null;
|
|
125
|
+
}
|
|
126
|
+
/** A control connection closed, and how many pending commands it settled. */
|
|
127
|
+
| {
|
|
128
|
+
readonly kind: 'client-disconnected';
|
|
129
|
+
readonly clientId8: string;
|
|
130
|
+
readonly transport: 'ws' | 'sse';
|
|
131
|
+
/** WebSocket close code, or null for SSE (which has none). */
|
|
132
|
+
readonly code: number | null;
|
|
133
|
+
readonly reason: string | null;
|
|
134
|
+
readonly commandsSettled: number;
|
|
135
|
+
}
|
|
136
|
+
/** The server granted a socket the duplex control capability. */
|
|
137
|
+
| { readonly kind: 'duplex-granted'; readonly clientId8: string }
|
|
138
|
+
/** A command left the relay for one tab's command channel. */
|
|
139
|
+
| {
|
|
140
|
+
readonly kind: 'command-relayed';
|
|
141
|
+
readonly command: string;
|
|
142
|
+
readonly requestId8: string;
|
|
143
|
+
readonly tabId8: string | null;
|
|
144
|
+
readonly clientId8: string | null;
|
|
145
|
+
}
|
|
146
|
+
/** The tab said its command listener PICKED THE COMMAND UP. */
|
|
147
|
+
| { readonly kind: 'command-receipt'; readonly requestId8: string }
|
|
148
|
+
/** The command settled — by the tab's own answer or by the relay refusing. */
|
|
149
|
+
| {
|
|
150
|
+
readonly kind: 'command-result';
|
|
151
|
+
readonly requestId8: string;
|
|
152
|
+
readonly ok: boolean;
|
|
153
|
+
/** Present only when `ok` is false; the refusal/failure text. */
|
|
154
|
+
readonly error?: string;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The relay HELD a command instead of refusing it: the target tab is
|
|
158
|
+
* present by heartbeat but its command channel is not carrying right now
|
|
159
|
+
* (mid-reload, or a channel that has not answered an echo).
|
|
160
|
+
*/
|
|
161
|
+
| {
|
|
162
|
+
readonly kind: 'command-held';
|
|
163
|
+
readonly requestId8: string;
|
|
164
|
+
readonly tabId8: string;
|
|
165
|
+
readonly reason: 'no-channel' | 'unacknowledged';
|
|
166
|
+
}
|
|
167
|
+
/** The main-thread echo probe over a duplex socket. */
|
|
168
|
+
| {
|
|
169
|
+
readonly kind: 'echo-probe';
|
|
170
|
+
readonly clientId8: string;
|
|
171
|
+
readonly answered: boolean;
|
|
172
|
+
readonly waitedMs: number;
|
|
173
|
+
}
|
|
174
|
+
/** The blessed tab changed (or was chosen for the first time). */
|
|
175
|
+
| {
|
|
176
|
+
readonly kind: 'tab-blessed';
|
|
177
|
+
readonly tabId8: string;
|
|
178
|
+
readonly previousTabId8: string | null;
|
|
179
|
+
readonly reason: 'sticky' | 'oldest' | 'claimed';
|
|
180
|
+
}
|
|
181
|
+
/** An extra tab was told to yield. */
|
|
182
|
+
| { readonly kind: 'tab-yielded'; readonly tabId8: string }
|
|
183
|
+
/**
|
|
184
|
+
* PRESENCE, IN TAB VOCABULARY. These five say what the tab table saw, and
|
|
185
|
+
* they deliberately do NOT use socket words: a socket closing is not a tab
|
|
186
|
+
* leaving, and that conflation is what produced "editor tab lost —
|
|
187
|
+
* reopening" against a tab that had never gone anywhere.
|
|
188
|
+
*/
|
|
189
|
+
/** A tabId beat for the first time. */
|
|
190
|
+
| {
|
|
191
|
+
readonly kind: 'tab-appeared';
|
|
192
|
+
readonly tabId8: string;
|
|
193
|
+
readonly visibility: 'visible' | 'hidden';
|
|
194
|
+
}
|
|
195
|
+
/** Beats stopped arriving for longer than the notice threshold. */
|
|
196
|
+
| { readonly kind: 'tab-heartbeat-gap'; readonly tabId8: string; readonly sinceMs: number }
|
|
197
|
+
/** A gap closed — the same tab resumed beating. `gapMs` is its full length. */
|
|
198
|
+
| { readonly kind: 'tab-gap-closed'; readonly tabId8: string; readonly gapMs: number }
|
|
199
|
+
/** Same tabId, new epoch: the page reloaded. The tab never left. */
|
|
200
|
+
| { readonly kind: 'tab-reloaded'; readonly tabId8: string; readonly epochCount: number }
|
|
201
|
+
/** Absent past its grace: this tab is gone. */
|
|
202
|
+
| { readonly kind: 'tab-departed'; readonly tabId8: string; readonly absentMs: number }
|
|
203
|
+
/**
|
|
204
|
+
* WHAT THE TAB WAS HOLDING WHEN IT DIED WITHOUT A GOODBYE.
|
|
205
|
+
*
|
|
206
|
+
* Emitted beside `client-disconnected` on an ABNORMAL close (1006 — the
|
|
207
|
+
* socket ended with no close frame, which is what a killed renderer process
|
|
208
|
+
* leaves behind; an ordinary tab close sends one). Measured 2026-08-10: a
|
|
209
|
+
* game tab's renderer was killed repeatedly at Chrome's undocumented
|
|
210
|
+
* per-process ceiling and the whole session record said `1006` and nothing
|
|
211
|
+
* else — the recovery worked perfectly and the CAUSE was unrecorded.
|
|
212
|
+
*
|
|
213
|
+
* `census` is the tab's last resource profile and `censusAgeMs` how stale it
|
|
214
|
+
* was; both null for a tab that never reported one (an older page, a
|
|
215
|
+
* tunnelled tab). Numbers only — this stays a journal line, not a dump.
|
|
216
|
+
*/
|
|
217
|
+
| {
|
|
218
|
+
readonly kind: 'tab-death-profile';
|
|
219
|
+
readonly tabId8: string;
|
|
220
|
+
/** The abnormal close code that triggered the line. */
|
|
221
|
+
readonly code: number;
|
|
222
|
+
readonly censusAgeMs: number | null;
|
|
223
|
+
readonly census: {
|
|
224
|
+
readonly heapUsedMB: number | null;
|
|
225
|
+
readonly heapLimitMB: number | null;
|
|
226
|
+
readonly canvases: number;
|
|
227
|
+
readonly canvasMB: number;
|
|
228
|
+
readonly textures?: number;
|
|
229
|
+
readonly geometries?: number;
|
|
230
|
+
readonly programs?: number;
|
|
231
|
+
} | null;
|
|
232
|
+
}
|
|
233
|
+
/** Two epochs beating under one tabId — "Duplicate Tab" copied sessionStorage. */
|
|
234
|
+
| { readonly kind: 'tab-duplicated'; readonly tabId8: string }
|
|
235
|
+
/**
|
|
236
|
+
* The tab is present (its worker is beating) but its PAGE has never opened
|
|
237
|
+
* a command channel this page-load. A heartbeat proves the tab exists, not
|
|
238
|
+
* that the document works — a main thread that died after the inline
|
|
239
|
+
* bootstrap beats forever and can run nothing. Such a tab is passed over
|
|
240
|
+
* for blessing and named in the refusal.
|
|
241
|
+
*/
|
|
242
|
+
| {
|
|
243
|
+
readonly kind: 'tab-unresponsive';
|
|
244
|
+
readonly tabId8: string;
|
|
245
|
+
readonly noChannelForMs: number;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
/** A parsed journal line: the event plus when it was appended. */
|
|
249
|
+
export type SessionJournalLine = SessionJournalEvent & { readonly at: string };
|
|
250
|
+
|
|
251
|
+
/** An open journal. `append` never throws — a journal that breaks a save would
|
|
252
|
+
* be worse than a journal that misses a line. */
|
|
253
|
+
export interface SessionJournal {
|
|
254
|
+
/** Absolute path of the file being appended to. */
|
|
255
|
+
readonly path: string;
|
|
256
|
+
append(event: SessionJournalEvent): void;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** `editor-2026-08-09T12-34-56-789Z.jsonl` for a given instant. */
|
|
260
|
+
export function sessionJournalFilename(startedAt: Date): string {
|
|
261
|
+
return `${PREFIX}${startedAt.toISOString().replace(/[:.]/g, '-')}${SUFFIX}`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Open (create) this session's journal under `<projectRoot>/logs/`.
|
|
266
|
+
*
|
|
267
|
+
* `null` when the directory cannot be created or the first write fails —
|
|
268
|
+
* an unwritable project must degrade to "no journal", never to a crashed boot.
|
|
269
|
+
*/
|
|
270
|
+
export function openSessionJournal(
|
|
271
|
+
projectRoot: string,
|
|
272
|
+
startedAt: Date = new Date(),
|
|
273
|
+
): SessionJournal | null {
|
|
274
|
+
const logsDir = join(projectRoot, 'logs');
|
|
275
|
+
try {
|
|
276
|
+
mkdirSync(logsDir, { recursive: true });
|
|
277
|
+
} catch {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
pruneJournals(logsDir);
|
|
281
|
+
const path = join(logsDir, sessionJournalFilename(startedAt));
|
|
282
|
+
const journal: SessionJournal = {
|
|
283
|
+
path,
|
|
284
|
+
append(event) {
|
|
285
|
+
const line: SessionJournalLine = { at: new Date().toISOString(), ...event };
|
|
286
|
+
try {
|
|
287
|
+
appendFileSync(path, `${JSON.stringify(line)}\n`, 'utf-8');
|
|
288
|
+
} catch {
|
|
289
|
+
/* a journal that breaks the caller is worse than a missing line */
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
try {
|
|
294
|
+
appendFileSync(path, '', 'utf-8');
|
|
295
|
+
} catch {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
return journal;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Drop the oldest journals so opening one more stays at `MAX_JOURNAL_FILES`. */
|
|
302
|
+
function pruneJournals(logsDir: string): void {
|
|
303
|
+
try {
|
|
304
|
+
const files = readdirSync(logsDir)
|
|
305
|
+
.filter((f) => f.startsWith(PREFIX) && f.endsWith(SUFFIX))
|
|
306
|
+
.sort();
|
|
307
|
+
for (const f of files.slice(0, Math.max(0, files.length - MAX_JOURNAL_FILES + 1))) {
|
|
308
|
+
try {
|
|
309
|
+
unlinkSync(join(logsDir, f));
|
|
310
|
+
} catch {
|
|
311
|
+
/* already gone */
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
} catch {
|
|
315
|
+
/* no logs dir yet */
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* The newest journal in a project, or `null`.
|
|
321
|
+
*
|
|
322
|
+
* File-native on purpose (the same shape as the CLI's newest-play-log reader):
|
|
323
|
+
* the journal path is a fact about the PROJECT DIRECTORY, so `vgai status` and
|
|
324
|
+
* the boot pointer can name it without a server round-trip — and can still name
|
|
325
|
+
* it after the session that wrote it is gone.
|
|
326
|
+
*/
|
|
327
|
+
export function newestSessionJournal(projectRoot: string): string | null {
|
|
328
|
+
try {
|
|
329
|
+
const files = readdirSync(join(projectRoot, 'logs'))
|
|
330
|
+
.filter((f) => f.startsWith(PREFIX) && f.endsWith(SUFFIX))
|
|
331
|
+
.sort();
|
|
332
|
+
const newest = files.at(-1);
|
|
333
|
+
return newest ? join(projectRoot, 'logs', newest) : null;
|
|
334
|
+
} catch {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* The newest journal's last `limit` lines of the requested kinds, oldest first.
|
|
341
|
+
*
|
|
342
|
+
* WHY A READER EXISTS AT ALL. The journal was written as a file an agent
|
|
343
|
+
* "can read at any time", and the measured answer to that invitation was: it
|
|
344
|
+
* doesn't. The foundry probe's journal held the notice-tier tripwire crossing
|
|
345
|
+
* that predicted its own end-of-run batch commit, and nothing that agent ran
|
|
346
|
+
* ever rendered a line of it. So the record grows a RENDERER on the command
|
|
347
|
+
* agents already poll (`vgai status`) — same principle as the tripwires' own
|
|
348
|
+
* origin: the mechanism was right and the delivery assumption was false.
|
|
349
|
+
*
|
|
350
|
+
* Every failure is the same case — no project, no journal, unreadable file, a
|
|
351
|
+
* line that will not parse — and it is the empty list, never a throw. A status
|
|
352
|
+
* command that crashed on a malformed log line would be a worse outcome than
|
|
353
|
+
* one that says nothing about it.
|
|
354
|
+
*
|
|
355
|
+
* The whole file is read, which is what bounds this: a journal is one editor
|
|
356
|
+
* session's surface-worthy transitions (the happy-path silence rule keeps
|
|
357
|
+
* clean saves out), the pruner caps how many exist, and the alternative — a
|
|
358
|
+
* reverse streaming reader over a file measured in kilobytes — is machinery
|
|
359
|
+
* with nothing to buy.
|
|
360
|
+
*/
|
|
361
|
+
export function readRecentJournalEvents(
|
|
362
|
+
projectRoot: string,
|
|
363
|
+
kinds: readonly SessionJournalEvent['kind'][],
|
|
364
|
+
limit: number,
|
|
365
|
+
): SessionJournalLine[] {
|
|
366
|
+
const path = newestSessionJournal(projectRoot);
|
|
367
|
+
if (path === null || limit <= 0) return [];
|
|
368
|
+
let raw: string;
|
|
369
|
+
try {
|
|
370
|
+
raw = readFileSync(path, 'utf-8');
|
|
371
|
+
} catch {
|
|
372
|
+
return [];
|
|
373
|
+
}
|
|
374
|
+
const wanted = new Set<string>(kinds);
|
|
375
|
+
const matched: SessionJournalLine[] = [];
|
|
376
|
+
for (const line of raw.split('\n')) {
|
|
377
|
+
if (line === '') continue;
|
|
378
|
+
let parsed: SessionJournalLine;
|
|
379
|
+
try {
|
|
380
|
+
parsed = JSON.parse(line) as SessionJournalLine;
|
|
381
|
+
} catch {
|
|
382
|
+
continue; // a torn final line is not a reason to report nothing
|
|
383
|
+
}
|
|
384
|
+
if (parsed && typeof parsed === 'object' && wanted.has(parsed.kind)) matched.push(parsed);
|
|
385
|
+
}
|
|
386
|
+
return matched.slice(-limit);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* One journal line as a scannable terminal line: `HH:MM:SS`, the kind, and
|
|
391
|
+
* the few facts that line is about.
|
|
392
|
+
*
|
|
393
|
+
* ONE OWNER of this rendering, for `sessionJournalPointerLine`'s reason — the
|
|
394
|
+
* events already have exactly one wording as data, and a second prose form per
|
|
395
|
+
* caller is how a reader ends up believing there are two records.
|
|
396
|
+
*/
|
|
397
|
+
export function formatJournalLine(line: SessionJournalLine): string {
|
|
398
|
+
const at = line.at.slice(11, 19);
|
|
399
|
+
switch (line.kind) {
|
|
400
|
+
case 'tripwire':
|
|
401
|
+
return line.tripwire === 'commit-cadence'
|
|
402
|
+
? `journal: ${at} tripwire commit-cadence ${line.tier} (${line.fileCount} files, ${Math.round(line.ageMs / 60_000)}m)`
|
|
403
|
+
: `journal: ${at} tripwire unplayed-session ${line.tier} (${Math.round(line.servingForMs / 60_000)}m)`;
|
|
404
|
+
case 'validation':
|
|
405
|
+
return `journal: ${at} validation ${line.ok ? 'ok' : 'FAILED'} ${line.path}`;
|
|
406
|
+
case 'play':
|
|
407
|
+
return `journal: ${at} play ${line.action}${line.name === null ? '' : ` [${line.name}]`}`;
|
|
408
|
+
case 'session-started':
|
|
409
|
+
return `journal: ${at} session-started pid ${line.pid}`;
|
|
410
|
+
case 'project-opened':
|
|
411
|
+
return `journal: ${at} project-opened ${line.project}`;
|
|
412
|
+
case 'session-shutdown':
|
|
413
|
+
return `journal: ${at} session-shutdown`;
|
|
414
|
+
// The TRANSPORT arm. One short line each, and every one of them names the
|
|
415
|
+
// TAB or the request it is about — a column of ids is how these lines get
|
|
416
|
+
// correlated, and a paragraph per line is how a reader stops reading.
|
|
417
|
+
case 'client-connected':
|
|
418
|
+
return `journal: ${at} client-connected ${line.clientId8} (${line.transport})`;
|
|
419
|
+
case 'client-disconnected':
|
|
420
|
+
return `journal: ${at} client-disconnected ${line.clientId8} code ${line.code ?? '-'} settled ${line.commandsSettled}`;
|
|
421
|
+
case 'duplex-granted':
|
|
422
|
+
return `journal: ${at} duplex-granted ${line.clientId8}`;
|
|
423
|
+
case 'command-relayed':
|
|
424
|
+
return `journal: ${at} command-relayed ${line.command} ${line.requestId8} -> tab ${line.tabId8 ?? 'none'}`;
|
|
425
|
+
case 'command-receipt':
|
|
426
|
+
return `journal: ${at} command-receipt ${line.requestId8}`;
|
|
427
|
+
case 'command-result':
|
|
428
|
+
return `journal: ${at} command-result ${line.requestId8} ${line.ok ? 'ok' : `FAILED ${line.error ?? ''}`}`;
|
|
429
|
+
case 'command-held':
|
|
430
|
+
return `journal: ${at} command-held ${line.requestId8} tab ${line.tabId8} (${line.reason})`;
|
|
431
|
+
case 'echo-probe':
|
|
432
|
+
return `journal: ${at} echo-probe ${line.clientId8} ${line.answered ? 'answered' : 'UNANSWERED'} in ${line.waitedMs}ms`;
|
|
433
|
+
case 'tab-blessed':
|
|
434
|
+
return `journal: ${at} tab-blessed ${line.tabId8} (${line.reason})`;
|
|
435
|
+
case 'tab-yielded':
|
|
436
|
+
return `journal: ${at} tab-yielded ${line.tabId8}`;
|
|
437
|
+
case 'tab-appeared':
|
|
438
|
+
return `journal: ${at} tab-appeared ${line.tabId8} (${line.visibility})`;
|
|
439
|
+
case 'tab-heartbeat-gap':
|
|
440
|
+
return `journal: ${at} tab-heartbeat-gap ${line.tabId8} ${Math.round(line.sinceMs / 100) / 10}s`;
|
|
441
|
+
case 'tab-gap-closed':
|
|
442
|
+
return `journal: ${at} tab-gap-closed ${line.tabId8} after ${Math.round(line.gapMs / 100) / 10}s`;
|
|
443
|
+
case 'tab-reloaded':
|
|
444
|
+
return `journal: ${at} tab-reloaded ${line.tabId8} (page load ${line.epochCount})`;
|
|
445
|
+
case 'tab-departed':
|
|
446
|
+
return `journal: ${at} tab-departed ${line.tabId8} absent ${Math.round(line.absentMs / 100) / 10}s`;
|
|
447
|
+
case 'tab-duplicated':
|
|
448
|
+
return `journal: ${at} tab-duplicated ${line.tabId8}`;
|
|
449
|
+
case 'tab-unresponsive':
|
|
450
|
+
return `journal: ${at} tab-unresponsive ${line.tabId8} no channel for ${Math.round(line.noChannelForMs / 1000)}s`;
|
|
451
|
+
case 'tab-death-profile':
|
|
452
|
+
return `journal: ${at} tab-death-profile ${line.tabId8} code ${line.code} — ${tabDeathProfileBody(line)}`;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** The census half of a `tab-death-profile` line, split out so the switch above
|
|
457
|
+
* stays one expression per case. */
|
|
458
|
+
function tabDeathProfileBody(line: SessionJournalEvent & { kind: 'tab-death-profile' }): string {
|
|
459
|
+
const census = formatTabCensus(line.census);
|
|
460
|
+
if (line.censusAgeMs === null) return census;
|
|
461
|
+
return `${census} (sampled ${Math.round(line.censusAgeMs / 100) / 10}s before)`;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** The resource profile a `tab-death-profile` line and a `vgai status` tab row
|
|
465
|
+
* both quote. ONE wording, for `sessionJournalPointerLine`'s reason: two
|
|
466
|
+
* phrasings of the same numbers is how a reader ends up believing there are
|
|
467
|
+
* two measurements. Renderer counts appear only when the page could read them
|
|
468
|
+
* (no mounted render-debug adapter = the words are absent, never a zero). */
|
|
469
|
+
export function formatTabCensus(
|
|
470
|
+
census: (SessionJournalEvent & { kind: 'tab-death-profile' })['census'],
|
|
471
|
+
): string {
|
|
472
|
+
if (census === null) return 'no resource census';
|
|
473
|
+
const parts = [
|
|
474
|
+
census.heapUsedMB === null
|
|
475
|
+
? 'heap n/a'
|
|
476
|
+
: `heap ${census.heapUsedMB}MB${census.heapLimitMB === null ? '' : `/${census.heapLimitMB}MB`}`,
|
|
477
|
+
`canvas ${census.canvasMB}MB in ${census.canvases}`,
|
|
478
|
+
];
|
|
479
|
+
if (census.textures !== undefined) parts.push(`tex ${census.textures}`);
|
|
480
|
+
if (census.geometries !== undefined) parts.push(`geo ${census.geometries}`);
|
|
481
|
+
if (census.programs !== undefined) parts.push(`prog ${census.programs}`);
|
|
482
|
+
return parts.join(', ');
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* The ONE wording for "here is the journal" — printed by the editor server's
|
|
487
|
+
* boot block, by `vgai edit`'s ready/detach lines, and by `vgai status`.
|
|
488
|
+
*
|
|
489
|
+
* One owner of the sentence, for `build-discipline.ts`'s reason: three
|
|
490
|
+
* processes point at this file, and a second phrasing of the same pointer is
|
|
491
|
+
* how a reader ends up believing there are two things.
|
|
492
|
+
*/
|
|
493
|
+
export function sessionJournalPointerLine(journalPath: string): string {
|
|
494
|
+
return `Session journal: ${journalPath} — structured JSONL, read it any time`;
|
|
495
|
+
}
|