@tsplc/debug 0.2.17 → 0.2.19

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/src/watch.ts CHANGED
@@ -6,27 +6,32 @@ import { z } from "zod";
6
6
  import type { WatchGroups } from "./api.ts";
7
7
 
8
8
  export const watchGroupsSchema = z.strictObject({
9
- groups: z.record(z.string().min(1), z.array(z.string().min(1))),
9
+ groups: z.record(z.string().nonempty(), z.array(z.string().nonempty())),
10
10
  });
11
11
 
12
12
  /**
13
13
  * Watch groups: the config's `debug.watch`, then groups saved in `file`. Saving keeps only the
14
14
  * groups that differ from the config, so the config stays their source.
15
15
  */
16
- export const loadWatchGroups = async (file: string, configured: Record<string, string[]>) => {
17
- let saved = existsSync(file)
18
- ? watchGroupsSchema.parse(JSON.parse(await readFile(file, "utf8"))).groups
19
- : {};
20
- const groups = (): WatchGroups => ({ groups: { ...configured, ...saved } });
21
- return {
22
- groups,
23
- save: async ({ groups: next }: WatchGroups) => {
24
- saved = Object.fromEntries(
25
- Object.entries(next).filter(([name, paths]) => !isDeepStrictEqual(configured[name], paths)),
26
- );
27
- await mkdir(dirname(file), { recursive: true });
28
- await writeFile(file, JSON.stringify({ groups: saved }, null, 2));
29
- return groups();
30
- },
31
- };
16
+ export const loadWatchGroups = async (
17
+ file: string,
18
+ configured: Record<string, string[]>,
19
+ ) => {
20
+ let saved = existsSync(file)
21
+ ? watchGroupsSchema.parse(JSON.parse(await readFile(file, "utf8"))).groups
22
+ : {};
23
+ const groups = (): WatchGroups => ({ groups: { ...configured, ...saved } });
24
+ return {
25
+ groups,
26
+ save: async ({ groups: next }: WatchGroups) => {
27
+ saved = Object.fromEntries(
28
+ Object.entries(next).filter(
29
+ ([name, paths]) => !isDeepStrictEqual(configured[name], paths),
30
+ ),
31
+ );
32
+ await mkdir(dirname(file), { recursive: true });
33
+ await writeFile(file, JSON.stringify({ groups: saved }, null, 2));
34
+ return groups();
35
+ },
36
+ };
32
37
  };
package/src/ui/dev.ts DELETED
@@ -1,579 +0,0 @@
1
- /**
2
- * Serves the debugger UI under `/debug/` against a fake conveyor line, for working on the UI without a PLC:
3
- * bun packages/debug/src/ui/dev.ts [--port 4870] [--token secret] [--mismatch] [--flaky]
4
- * `--flaky` closes the engineering login for 10 s every minute; `--mismatch` reports another controller build.
5
- */
6
- import { parseArgs } from "node:util";
7
- import type {
8
- AppRecord,
9
- DeviceState,
10
- JobState,
11
- Quality,
12
- RequestTrace,
13
- Sample,
14
- Session,
15
- StreamEvents,
16
- TimelineEntry,
17
- TraceEvent,
18
- } from "../api.ts";
19
- import { noLogin } from "../login.ts";
20
- import { fixtureManifest as manifest, fixtureQuality, fixtureValue } from "./fixture.ts";
21
- import { loadUi } from "./index.ts";
22
- import { ManifestTree } from "./tree.ts";
23
-
24
- const { values: args } = parseArgs({
25
- options: {
26
- port: { type: "string", default: "0" },
27
- token: { type: "string" },
28
- mismatch: { type: "boolean", default: false },
29
- flaky: { type: "boolean", default: false },
30
- },
31
- });
32
-
33
- const PREFIX = "/debug";
34
- const READ_INTERVAL_MS = 1000;
35
- const tree = new ManifestTree(manifest);
36
- const started = Date.now();
37
- const iso = (ms: number) => new Date(ms).toISOString();
38
- const hostToPlc = (ms: number) => ms - started + 86_400_000;
39
-
40
- const session: Session = {
41
- build: {
42
- manifest: manifest.build.id,
43
- controller: args.mismatch ? "7be03d11f5a2" : manifest.build.id,
44
- matches: !args.mismatch,
45
- },
46
- connection: {
47
- state: "connected",
48
- since: iso(started - 12 * 60_000),
49
- endpoint: "http://line-pc:4850",
50
- },
51
- reading: { method: "engineering login", intervalMs: READ_INTERVAL_MS },
52
- tasks: [
53
- { name: "MainTask", scan: 0, scansPerSecond: 99.9, dropped: 0 },
54
- { name: "ServiceTask", scan: 0, scansPerSecond: 20, dropped: 0 },
55
- ],
56
- clock: { offsetMs: 12.4, uncertaintyMs: 2.1 },
57
- release: { id: "2026-11-09.3", deployedAt: iso(started - 25 * 60_000), operation: "deploy" },
58
- projectRoot: "/srv/conveyor-line",
59
- };
60
-
61
- interface Subscriber {
62
- watch: string[];
63
- send<K extends keyof StreamEvents>(event: K, data: StreamEvents[K]): void;
64
- }
65
- const subscribers = new Set<Subscriber>();
66
- const broadcast = <K extends keyof StreamEvents>(event: K, data: StreamEvents[K]) => {
67
- for (const subscriber of subscribers) subscriber.send(event, data);
68
- };
69
-
70
- const timeline: TimelineEntry[] = [];
71
- const record = (entry: TimelineEntry) => {
72
- timeline.push(entry);
73
- broadcast("timeline", entry);
74
- };
75
-
76
- let groups: Record<string, string[]> = {
77
- "Diverter 3": [
78
- "program.diverters.binTrackers[3].bin",
79
- "program.diverters.binTrackers[3].count",
80
- "line.conveyors.inbound.cards[3].input.sensor",
81
- ],
82
- "Scanner 0": [
83
- "program.scanners.routeLookups[0].code",
84
- "program.scanners.routeLookups[0].lane",
85
- "program.scanners.routeLookups[0].pending",
86
- ],
87
- Parcels: ["program.tracking.parcels[0].weightKg", "program.tracking.parcels[0].state"],
88
- };
89
-
90
- const sample = (path: string, now: number): Sample | undefined => {
91
- const node = tree.resolve(path);
92
- if (!node || !node.readable || node.constant || tree.expandable(node)) return undefined;
93
- return {
94
- path,
95
- value: fixtureValue(path, node.type, now),
96
- ...fixtureQuality(path),
97
- receivedAt: iso(now),
98
- };
99
- };
100
-
101
- const scans = (now: number) => ({
102
- main: Math.floor((now - started) / 10) + 4_210_000,
103
- service: Math.floor((now - started) / 50) + 842_000,
104
- });
105
-
106
- let sequence = 0;
107
- const traced = (now: number): TraceEvent => {
108
- const site = manifest.events[Math.floor(Math.random() * manifest.events.length)]!;
109
- const scan = site.task === "MainTask" ? scans(now).main : scans(now).service;
110
- return {
111
- sequence: ++sequence,
112
- task: site.task,
113
- scan,
114
- plcTime: hostToPlc(now),
115
- site: site.id,
116
- value: site.text.includes("Present")
117
- ? 2
118
- : site.text.includes("Arriving")
119
- ? 1
120
- : fixtureValue(site.path, site.type, now),
121
- operands: site.operands.map(({ text, path, type, stable }) => ({
122
- text,
123
- path,
124
- stable,
125
- value: path ? fixtureValue(path, type, now) : Math.random() < 0.8,
126
- })),
127
- };
128
- };
129
- const traceEntry = (now: number): TimelineEntry => ({
130
- at: iso(now),
131
- approximate: true,
132
- source: "plc",
133
- kind: "trace",
134
- trace: traced(now),
135
- });
136
-
137
- const origin = (now: number, source: string) => ({
138
- source,
139
- scan: scans(now).service,
140
- time: hostToPlc(now),
141
- });
142
- const reported = (now: number): AppRecord => {
143
- const scanner = Math.floor(Math.random() * 4);
144
- const roll = Math.random();
145
- if (roll < 0.7)
146
- return {
147
- kind: "event",
148
- event: "bin scanned",
149
- properties: { barcode: `CON-MD3-${10000 + (Math.floor(now / 1000) % 90000)}-A`, scanner },
150
- ...origin(now, "src/scanners/route-lookup.ts:120"),
151
- };
152
- if (roll < 0.95)
153
- return {
154
- kind: "log",
155
- level: roll < 0.85 ? "info" : "warn",
156
- message: roll < 0.85 ? "route resolved" : "route lookup retried",
157
- properties: { scanner, lane: scanner % 3 },
158
- ...origin(now, "src/scanners/route-lookup.ts:131"),
159
- };
160
- return {
161
- kind: "exception",
162
- code: 2,
163
- name: "Timeout",
164
- handled: true,
165
- function: "RouteLookup.resolve",
166
- instance: `program.scanners.routeLookups[${scanner}]`,
167
- properties: {},
168
- ...origin(now, "src/scanners/route-lookup.ts:24"),
169
- };
170
- };
171
- const appEntry = (now: number, record: AppRecord): TimelineEntry => ({
172
- at: iso(now),
173
- approximate: true,
174
- source: "plc",
175
- kind: "app",
176
- task: "ServiceTask",
177
- sequence: ++sequence,
178
- record,
179
- });
180
-
181
- let requestSeq = 400;
182
- const requests = new Map<number, RequestTrace>();
183
- const traceRequest = (request: RequestTrace) => {
184
- requests.set(request.seq, request);
185
- broadcast("request", request);
186
- record({ at: request.startedAt, approximate: false, source: "server", kind: "request", request });
187
- };
188
-
189
- const resolveSteps = manifest.jobs[0]!.steps;
190
- const jobs = new Map<number, JobState>();
191
- const job = (state: Partial<JobState> & { id: number }): JobState => ({
192
- quality: "good",
193
- busy: false,
194
- ...state,
195
- });
196
-
197
- const stuckSince = started - 41_000;
198
- const stuckRequest: RequestTrace = {
199
- seq: 398,
200
- service: "routing.resolve",
201
- job: 4,
202
- sequence: 77,
203
- startedAt: iso(stuckSince),
204
- status: "pending",
205
- };
206
- const failedRequest: RequestTrace = {
207
- seq: 399,
208
- service: "bins.release",
209
- job: 5,
210
- sequence: 12,
211
- startedAt: iso(started - 95_000),
212
- durationMs: 212,
213
- status: "error",
214
- httpStatus: 503,
215
- error: "bins service unavailable",
216
- };
217
- const timedOut: RequestTrace = {
218
- seq: 397,
219
- service: "routing.resolve",
220
- job: 2,
221
- sequence: 31,
222
- startedAt: iso(started - 130_000),
223
- durationMs: 1500,
224
- status: "timeout",
225
- };
226
- for (const request of [timedOut, stuckRequest, failedRequest]) requests.set(request.seq, request);
227
-
228
- const failures: (AppRecord & { kind: "exception" })[] = [
229
- {
230
- kind: "exception",
231
- code: 2,
232
- name: "Timeout",
233
- handled: false,
234
- function: "RouteLookup.resolve",
235
- instance: "program.scanners.routeLookups[1]",
236
- properties: {},
237
- source: "src/scanners/route-lookup.ts:22",
238
- scan: 0,
239
- time: 0,
240
- },
241
- {
242
- kind: "exception",
243
- code: 3,
244
- name: "ServiceError",
245
- handled: false,
246
- function: "BinTracker.release",
247
- instance: "program.diverters.binTrackers[3]",
248
- properties: { bin: 3 },
249
- source: "src/diverters/bin-tracker.ts:55",
250
- scan: 0,
251
- time: 0,
252
- },
253
- ];
254
- /** When `timedOut` and `failedRequest` ended. */
255
- const failedAt = [started - 128_500, started - 94_788];
256
- jobs.set(
257
- 2,
258
- job({
259
- id: 2,
260
- startedAt: timedOut.startedAt,
261
- failure: { at: iso(failedAt[0]!), code: 2, name: "Timeout", source: failures[0]!.source },
262
- }),
263
- );
264
- jobs.set(3, job({ id: 3 }));
265
- jobs.set(
266
- 5,
267
- job({
268
- id: 5,
269
- startedAt: failedRequest.startedAt,
270
- failure: { at: iso(failedAt[1]!), code: 3, name: "ServiceError", source: failures[1]!.source },
271
- }),
272
- );
273
-
274
- let lookup: RequestTrace | undefined;
275
- const updateJobs = (now: number) => {
276
- const phase = (now - started) % 4100;
277
- if (phase < 2600 && !lookup) {
278
- lookup = {
279
- seq: ++requestSeq,
280
- service: "routing.resolve",
281
- job: 1,
282
- sequence: requestSeq - 300,
283
- startedAt: iso(now),
284
- status: "pending",
285
- };
286
- traceRequest(lookup);
287
- }
288
- if (phase >= 2600 && lookup) {
289
- traceRequest({
290
- ...lookup,
291
- status: "ok",
292
- httpStatus: 200,
293
- durationMs: now - Date.parse(lookup.startedAt),
294
- });
295
- lookup = undefined;
296
- }
297
- jobs.set(
298
- 1,
299
- lookup
300
- ? job({
301
- id: 1,
302
- busy: true,
303
- step: 1,
304
- current: { ...resolveSteps[1]! },
305
- startedAt: lookup.startedAt,
306
- elapsedMs: now - Date.parse(lookup.startedAt),
307
- request: {
308
- service: "routing.resolve",
309
- sequence: requestSeq - 300,
310
- trace: requests.get(requestSeq),
311
- },
312
- })
313
- : job({ id: 1, startedAt: requests.get(requestSeq)?.startedAt }),
314
- );
315
- jobs.set(
316
- 4,
317
- job({
318
- id: 4,
319
- busy: true,
320
- step: 1,
321
- current: { ...resolveSteps[1]! },
322
- startedAt: iso(stuckSince),
323
- elapsedMs: now - stuckSince,
324
- request: { service: "routing.resolve", sequence: 77, trace: stuckRequest },
325
- }),
326
- );
327
- for (const id of [1, 4]) broadcast("job", jobs.get(id)!);
328
- };
329
-
330
- const devices = (now: number): DeviceState[] => {
331
- const bit = (path: string) => (fixtureValue(path, "BOOL", now) ? 1 : 0);
332
- const sensors = Array.from({ length: 10 }, (_, index) =>
333
- bit(`line.conveyors.inbound.cards[${index}].input.sensor`),
334
- );
335
- const motors = Array.from({ length: 10 }, (_, index) =>
336
- bit(`line.conveyors.inbound.cards[${index}].output.motor`),
337
- );
338
- const pack = (bits: number[]) => bits.reduce((byte, value, index) => byte | (value << index), 0);
339
- const jam = bit("line.conveyors.inbound.cards[0].input.jam") ? 0 : 1;
340
- const resultId = Math.floor((now - started) / 1700) & 0xffff;
341
- const speed = 1450 + Math.round(8 * Math.sin(now / 900));
342
- const quality: Quality = session.connection.state === "connected" ? "good" : "unavailable";
343
- const bound = (device: string) =>
344
- manifest.io
345
- .filter((entry) => entry.device === device)
346
- .map((entry) => ({
347
- id: entry.channel!,
348
- path: entry.path,
349
- value: fixtureValue(entry.path, entry.type, now),
350
- quality,
351
- }));
352
- return [
353
- {
354
- id: "io-inbound-1",
355
- health: { state: "ok", value: 3, name: "Running", diagnostic: false, quality },
356
- assemblies: [
357
- {
358
- direction: "input",
359
- bytes: [pack(sensors.slice(0, 8)), pack(sensors.slice(8)) | (jam << 4), 0, 0],
360
- quality,
361
- },
362
- { direction: "output", bytes: [pack(motors.slice(0, 8)), pack(motors.slice(8))], quality },
363
- ],
364
- channels: bound("io-inbound-1"),
365
- status: { "Module power": { value: true, quality }, "Bus fault": { value: false, quality } },
366
- },
367
- {
368
- id: "scanner-1",
369
- health: { state: "unknown", quality: "unavailable" },
370
- assemblies: [
371
- {
372
- direction: "input",
373
- bytes: [0b101 | ((resultId & 1) << 1), 0, resultId & 0xff, resultId >> 8, 11, 0, 0, 0],
374
- quality,
375
- },
376
- { direction: "output", bytes: null, quality: "unavailable" },
377
- ],
378
- channels: [],
379
- status: {},
380
- },
381
- {
382
- id: "vfd-inbound",
383
- health: {
384
- state: "fault",
385
- value: 7,
386
- name: "Fault",
387
- diagnostic: true,
388
- message: "F07 DC link overvoltage",
389
- quality,
390
- },
391
- assemblies: [
392
- { direction: "input", bytes: [0x07, 0x02, speed & 0xff, speed >> 8], quality },
393
- { direction: "output", bytes: [0x06, 0x00, 1500 & 0xff, 1500 >> 8], quality },
394
- ],
395
- channels: bound("vfd-inbound"),
396
- status: {},
397
- },
398
- ];
399
- };
400
-
401
- const backlog = () => {
402
- const now = Date.now();
403
- record({
404
- at: session.release!.deployedAt,
405
- approximate: false,
406
- source: "agent",
407
- kind: "deploy",
408
- release: session.release!.id,
409
- operation: "deploy",
410
- build: manifest.build.id,
411
- user: "dana",
412
- host: "LINE-PC-02",
413
- });
414
- record({
415
- at: iso(now - 18 * 60_000),
416
- approximate: false,
417
- source: "agent",
418
- kind: "audit",
419
- command: "force",
420
- values: { "line.conveyors.inbound.cards[4].output.motor": "TRUE" },
421
- previous: { "line.conveyors.inbound.cards[4].output.motor": "FALSE" },
422
- user: "dana",
423
- host: "LINE-PC-02",
424
- });
425
- record({
426
- at: iso(now - 17 * 60_000),
427
- approximate: false,
428
- source: "agent",
429
- kind: "audit",
430
- command: "unforce",
431
- values: { "line.conveyors.inbound.cards[4].output.motor": "" },
432
- user: "dana",
433
- host: "LINE-PC-02",
434
- });
435
- record({
436
- at: session.connection.since,
437
- approximate: false,
438
- source: "debugger",
439
- kind: "connection",
440
- state: "connected",
441
- });
442
- for (const request of [timedOut, failedRequest, stuckRequest])
443
- record({
444
- at: request.startedAt,
445
- approximate: false,
446
- source: "server",
447
- kind: "request",
448
- request,
449
- });
450
- failures.forEach((failure, index) => record(appEntry(failedAt[index]!, failure)));
451
- for (let at = now - 180_000; at < now; at += 900 + Math.random() * 1200)
452
- record(Math.random() < 0.5 ? traceEntry(at) : appEntry(at, reported(at)));
453
- timeline.sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
454
- };
455
-
456
- const tick = () => {
457
- const now = Date.now();
458
- if (args.flaky) {
459
- const lost = (now - started) % 60_000 > 50_000;
460
- if (lost !== (session.connection.state === "unavailable")) {
461
- session.connection = lost
462
- ? { ...session.connection, state: "unavailable", since: iso(now), error: noLogin }
463
- : { state: "connected", since: iso(now), endpoint: session.connection.endpoint };
464
- broadcast("session", session);
465
- record({
466
- at: iso(now),
467
- approximate: false,
468
- source: "debugger",
469
- kind: "connection",
470
- state: session.connection.state,
471
- error: session.connection.error,
472
- });
473
- }
474
- }
475
- if (session.connection.state !== "connected") return;
476
- const { main, service } = scans(now);
477
- session.tasks = [
478
- { name: "MainTask", scan: main, scansPerSecond: 99.6 + Math.random() * 0.6, dropped: 0 },
479
- { name: "ServiceTask", scan: service, scansPerSecond: 19.9 + Math.random() * 0.2, dropped: 3 },
480
- ];
481
- for (const subscriber of subscribers)
482
- for (const path of subscriber.watch) {
483
- const next = sample(path, now);
484
- if (next) subscriber.send("sample", next);
485
- }
486
- if (Math.random() < 0.45) record(traceEntry(now));
487
- if (Math.random() < 0.6) record(appEntry(now, reported(now)));
488
- updateJobs(now);
489
- broadcast("session", session);
490
- for (const device of devices(now)) broadcast("device", device);
491
- };
492
-
493
- const stream = (request: Request, watch: string[]) => {
494
- const encoder = new TextEncoder();
495
- let subscriber: Subscriber | undefined;
496
- const body = new ReadableStream<Uint8Array>({
497
- start(controller) {
498
- const self: Subscriber = {
499
- watch,
500
- send(event, data) {
501
- try {
502
- controller.enqueue(
503
- encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`),
504
- );
505
- } catch {
506
- subscribers.delete(self);
507
- }
508
- },
509
- };
510
- subscriber = self;
511
- subscribers.add(self);
512
- self.send("session", session);
513
- for (const path of watch) {
514
- const next = sample(path, Date.now());
515
- if (next) self.send("sample", next);
516
- }
517
- },
518
- cancel() {
519
- if (subscriber) subscribers.delete(subscriber);
520
- },
521
- });
522
- request.signal.addEventListener("abort", () => subscriber && subscribers.delete(subscriber));
523
- return new Response(body, {
524
- headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-store" },
525
- });
526
- };
527
-
528
- const api = async (request: Request, route: string, url: URL): Promise<Response> => {
529
- const now = Date.now();
530
- const list = (name: string) => url.searchParams.get(name)?.split(",").filter(Boolean) ?? [];
531
- if (route === "session") return Response.json(session);
532
- if (route === "manifest") return Response.json(manifest);
533
- if (route === "stream") return stream(request, list("watch"));
534
- if (route === "values")
535
- return Response.json(list("paths").flatMap((path) => sample(path, now) ?? []));
536
- if (route === "jobs") return Response.json([...jobs.values()]);
537
- if (route === "hardware") return Response.json(devices(now));
538
- if (route === "timeline") {
539
- const since = url.searchParams.get("since");
540
- return Response.json(
541
- since
542
- ? timeline.filter((entry) => Date.parse(entry.at) > Date.parse(since))
543
- : timeline.slice(-1000),
544
- );
545
- }
546
- if (route === "watch" && request.method === "PUT") {
547
- groups = ((await request.json()) as { groups: Record<string, string[]> }).groups;
548
- return Response.json({ groups });
549
- }
550
- if (route === "watch") return Response.json({ groups });
551
- return new Response("Not found", { status: 404 });
552
- };
553
-
554
- backlog();
555
- setInterval(tick, READ_INTERVAL_MS);
556
- const ui = await loadUi();
557
- const server = Bun.serve({
558
- port: Number(args.port),
559
- // Server-Sent Event streams stay open for as long as the page does.
560
- idleTimeout: 0,
561
- fetch(request) {
562
- const url = new URL(request.url);
563
- if (url.pathname === "/") return Response.redirect(`${PREFIX}/${url.search}`, 302);
564
- if (url.pathname === PREFIX || url.pathname === `${PREFIX}/`)
565
- return new Response(ui.html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
566
- if (url.pathname === `${PREFIX}/ui.js`)
567
- return new Response(ui.script, {
568
- headers: { "Content-Type": "text/javascript; charset=utf-8" },
569
- });
570
- if (!url.pathname.startsWith(`${PREFIX}/api/`))
571
- return new Response("Not found", { status: 404 });
572
- if (args.token && url.searchParams.get("token") !== args.token)
573
- return new Response("Unauthorized", { status: 401 });
574
- return api(request, url.pathname.slice(`${PREFIX}/api/`.length), url);
575
- },
576
- });
577
- console.log(
578
- `Debugger UI with fixture data: ${server.url}debug/${args.token ? `?token=${args.token}` : ""}`,
579
- );