@tsplc/debug 0.0.0-stage → 0.2.17
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/format.js +121 -0
- package/dist/format.js.map +11 -0
- package/dist/index.js +1681 -0
- package/dist/index.js.map +24 -0
- package/dist/manifest.js +201 -0
- package/dist/manifest.js.map +10 -0
- package/dist/paths.js +138 -0
- package/dist/paths.js.map +10 -0
- package/package.json +46 -4
- package/src/adapter.ts +59 -0
- package/src/api.ts +182 -0
- package/src/debugger.ts +509 -0
- package/src/hardware.ts +125 -0
- package/src/http.ts +155 -0
- package/src/index.ts +8 -0
- package/src/jobs.ts +115 -0
- package/src/login.ts +284 -0
- package/src/manifest.ts +271 -0
- package/src/opcua.ts +69 -0
- package/src/paths.ts +185 -0
- package/src/telemetry.ts +110 -0
- package/src/timeline.ts +78 -0
- package/src/trace.ts +45 -0
- package/src/ui/bits.ts +41 -0
- package/src/ui/client.ts +48 -0
- package/src/ui/dev.ts +579 -0
- package/src/ui/dom.ts +168 -0
- package/src/ui/fixture.ts +548 -0
- package/src/ui/format.ts +125 -0
- package/src/ui/hardware.ts +368 -0
- package/src/ui/header.ts +179 -0
- package/src/ui/index.ts +20 -0
- package/src/ui/jobs.ts +249 -0
- package/src/ui/main.ts +74 -0
- package/src/ui/overview.ts +123 -0
- package/src/ui/page.ts +178 -0
- package/src/ui/recent.ts +322 -0
- package/src/ui/requests.ts +81 -0
- package/src/ui/store.ts +227 -0
- package/src/ui/tree.ts +156 -0
- package/src/ui/variables.ts +469 -0
- package/src/watch.ts +32 -0
- package/README.md +0 -3
package/src/debugger.ts
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
import { telemetryBatch, type TelemetryReply } from "@tsplc/server/telemetry";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import type { TargetAdapter, TargetRead, TargetSample } from "./adapter.ts";
|
|
6
|
+
import type {
|
|
7
|
+
Interest,
|
|
8
|
+
JobState,
|
|
9
|
+
RequestTrace,
|
|
10
|
+
Sample,
|
|
11
|
+
Session,
|
|
12
|
+
StreamEvents,
|
|
13
|
+
TimelineEntry,
|
|
14
|
+
WatchGroups,
|
|
15
|
+
} from "./api.ts";
|
|
16
|
+
import { deviceReads, deviceState, healthReads } from "./hardware.ts";
|
|
17
|
+
import { busyOf, busyRead, detailReads, jobState, type TracedJob } from "./jobs.ts";
|
|
18
|
+
import type { DebugJob, DebugManifest } from "./manifest.ts";
|
|
19
|
+
import { createPaths } from "./paths.ts";
|
|
20
|
+
import { createTelemetryFollower, type ReceivedRecord } from "./telemetry.ts";
|
|
21
|
+
import { createTimeline, type Entry, timelineEntries } from "./timeline.ts";
|
|
22
|
+
import { createTraceDecoder } from "./trace.ts";
|
|
23
|
+
import { loadWatchGroups } from "./watch.ts";
|
|
24
|
+
|
|
25
|
+
export interface DebuggerOptions {
|
|
26
|
+
manifest: DebugManifest;
|
|
27
|
+
/** Owned by the debugger from here on: closing the debugger closes it. */
|
|
28
|
+
adapter: TargetAdapter;
|
|
29
|
+
/** Saved watch groups live here. */
|
|
30
|
+
stateDirectory: string;
|
|
31
|
+
/** `debug.watch` from the config. */
|
|
32
|
+
watch: Record<string, string[]>;
|
|
33
|
+
/** The app server, whose telemetry and request trace are pulled every second. */
|
|
34
|
+
server?: { url: string; token: string };
|
|
35
|
+
release?: Session["release"];
|
|
36
|
+
/** Entries from before the debugger started, such as the agent's deployments. */
|
|
37
|
+
timeline?: readonly TimelineEntry[];
|
|
38
|
+
projectRoot?: string;
|
|
39
|
+
log: (message: string) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A request the API refuses, with its HTTP status. */
|
|
43
|
+
export class DebugError extends Error {
|
|
44
|
+
constructor(
|
|
45
|
+
message: string,
|
|
46
|
+
readonly status: number,
|
|
47
|
+
) {
|
|
48
|
+
super(message);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type StreamMessage = {
|
|
53
|
+
[Name in keyof StreamEvents]: { event: Name; data: StreamEvents[Name] };
|
|
54
|
+
}[keyof StreamEvents];
|
|
55
|
+
|
|
56
|
+
const pullMs = 1000;
|
|
57
|
+
/** Without telemetry, the build id is read through the controller this often. */
|
|
58
|
+
const buildCheckMs = 10_000;
|
|
59
|
+
/** Device health, raw bytes and channels change rarely enough for this. */
|
|
60
|
+
const hardwareMs = 5000;
|
|
61
|
+
/** Busy jobs whose details each poll reads; the rest show as busy only. */
|
|
62
|
+
const detailedJobs = 16;
|
|
63
|
+
const requestsRemembered = 1000;
|
|
64
|
+
|
|
65
|
+
const requestTrace = z.object({
|
|
66
|
+
seq: z.number().int(),
|
|
67
|
+
service: z.string(),
|
|
68
|
+
job: z.number().int().optional(),
|
|
69
|
+
sequence: z.number().int().optional(),
|
|
70
|
+
startedAt: z.string(),
|
|
71
|
+
durationMs: z.number().optional(),
|
|
72
|
+
status: z.enum(["pending", "ok", "error", "timeout"]),
|
|
73
|
+
httpStatus: z.number().int().optional(),
|
|
74
|
+
error: z.string().optional(),
|
|
75
|
+
input: z.unknown().optional(),
|
|
76
|
+
output: z.unknown().optional(),
|
|
77
|
+
}) satisfies z.ZodType<RequestTrace>;
|
|
78
|
+
const requestsReply = z.object({ requests: z.array(requestTrace), latest: z.number().int() });
|
|
79
|
+
const telemetryReply = z.object({
|
|
80
|
+
batches: z.array(telemetryBatch.extend({ seq: z.number().int(), receivedAt: z.string() })),
|
|
81
|
+
latest: z.number().int(),
|
|
82
|
+
}) satisfies z.ZodType<TelemetryReply>;
|
|
83
|
+
|
|
84
|
+
const message = (error: unknown) => (error instanceof Error ? error.message : String(error));
|
|
85
|
+
|
|
86
|
+
/** Counts how many streams show each key. */
|
|
87
|
+
const createShown = <Key>() => {
|
|
88
|
+
const counts = new Map<Key, number>();
|
|
89
|
+
return {
|
|
90
|
+
has: (key: Key) => counts.has(key),
|
|
91
|
+
add: (keys: readonly Key[]) => {
|
|
92
|
+
for (const key of keys) counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
93
|
+
return () => {
|
|
94
|
+
for (const key of keys) {
|
|
95
|
+
const count = counts.get(key)! - 1;
|
|
96
|
+
if (count) counts.set(key, count);
|
|
97
|
+
else counts.delete(key);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The debugger for one build on one controller (docs/design/debugging.md). It reads what the
|
|
106
|
+
* pages show through the adapter, follows the app server's telemetry and requests, and keeps the
|
|
107
|
+
* recent window of both. Everything it offers is read-only.
|
|
108
|
+
*/
|
|
109
|
+
export const createDebugger = async (options: DebuggerOptions) => {
|
|
110
|
+
const { manifest, adapter } = options;
|
|
111
|
+
const paths = createPaths(manifest);
|
|
112
|
+
const trace = createTraceDecoder(manifest, paths);
|
|
113
|
+
const telemetry = createTelemetryFollower(manifest);
|
|
114
|
+
const timeline = createTimeline();
|
|
115
|
+
const watchGroups = await loadWatchGroups(
|
|
116
|
+
join(options.stateDirectory, "watch.json"),
|
|
117
|
+
options.watch,
|
|
118
|
+
);
|
|
119
|
+
const listeners = new Set<(message: StreamMessage) => void>();
|
|
120
|
+
const controller = new AbortController();
|
|
121
|
+
const { signal } = controller;
|
|
122
|
+
let controllerBuild: string | null = null;
|
|
123
|
+
|
|
124
|
+
const emit = (message: StreamMessage) => {
|
|
125
|
+
for (const listener of listeners) listener(message);
|
|
126
|
+
};
|
|
127
|
+
const record = (entry: TimelineEntry) => {
|
|
128
|
+
timeline.add(entry);
|
|
129
|
+
emit({ event: "timeline", data: entry });
|
|
130
|
+
};
|
|
131
|
+
for (const entry of options.timeline ?? []) timeline.add(entry);
|
|
132
|
+
|
|
133
|
+
const session = (): Session => ({
|
|
134
|
+
build: {
|
|
135
|
+
manifest: manifest.build.id,
|
|
136
|
+
controller: controllerBuild,
|
|
137
|
+
matches: controllerBuild === manifest.build.id,
|
|
138
|
+
},
|
|
139
|
+
connection: { ...adapter.connection(), endpoint: adapter.endpoint },
|
|
140
|
+
reading: { method: adapter.method, intervalMs: adapter.samplingMs },
|
|
141
|
+
tasks: telemetry.tasks(),
|
|
142
|
+
clock: telemetry.clock(),
|
|
143
|
+
...(options.release && { release: options.release }),
|
|
144
|
+
...(options.projectRoot && { projectRoot: options.projectRoot }),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const setBuild = (build: string) => {
|
|
148
|
+
if (build === controllerBuild) return;
|
|
149
|
+
controllerBuild = build;
|
|
150
|
+
emit({ event: "session", data: session() });
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// --- Values by TypeScript path ---------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
const unavailable = (path: string, status: string): Sample => ({
|
|
156
|
+
path,
|
|
157
|
+
value: null,
|
|
158
|
+
quality: "unavailable",
|
|
159
|
+
status,
|
|
160
|
+
receivedAt: new Date().toISOString(),
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
/** A path's target, or the sample it has without reading the controller. */
|
|
164
|
+
const locate = (path: string): { target: string; type: string } | Sample => {
|
|
165
|
+
const resolved = paths.resolve(path);
|
|
166
|
+
if (!resolved) return unavailable(path, "Unknown path");
|
|
167
|
+
if (resolved.kind === "constant")
|
|
168
|
+
return {
|
|
169
|
+
path,
|
|
170
|
+
value: resolved.value,
|
|
171
|
+
quality: "good",
|
|
172
|
+
status: "Constant",
|
|
173
|
+
receivedAt: new Date().toISOString(),
|
|
174
|
+
};
|
|
175
|
+
if (!resolved.readable) return unavailable(path, "Not readable");
|
|
176
|
+
return resolved;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const targetRead = ({ target, type }: { target: string; type: string }): TargetRead => ({
|
|
180
|
+
target,
|
|
181
|
+
type: paths.typeOf(type),
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const sampleOf = (path: string, type: string, sample: TargetSample): Sample => ({
|
|
185
|
+
path,
|
|
186
|
+
...sample,
|
|
187
|
+
value: paths.decode(type, sample.value),
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
/** Reads the leaves `patterns` match each sampling interval, until the returned function is called. */
|
|
191
|
+
const watch = (patterns: readonly string[], listener: (sample: Sample) => void) => {
|
|
192
|
+
const watched = new Map<string, { path: string; type: string }[]>();
|
|
193
|
+
const reads: TargetRead[] = [];
|
|
194
|
+
for (const path of paths.expand(patterns)) {
|
|
195
|
+
const located = locate(path);
|
|
196
|
+
if (!("target" in located)) {
|
|
197
|
+
listener(located);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (!watched.has(located.target)) reads.push(targetRead(located));
|
|
201
|
+
watched.set(located.target, [...(watched.get(located.target) ?? []), { path, ...located }]);
|
|
202
|
+
}
|
|
203
|
+
if (!reads.length) return () => {};
|
|
204
|
+
return adapter.subscribe(reads, (target, received) => {
|
|
205
|
+
for (const { path, type } of watched.get(target) ?? [])
|
|
206
|
+
listener(sampleOf(path, type, received));
|
|
207
|
+
});
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const values = async (patterns: readonly string[]) => {
|
|
211
|
+
const connection = adapter.connection();
|
|
212
|
+
if (connection.state !== "connected")
|
|
213
|
+
throw new DebugError(connection.error ?? "The controller is not connected", 503);
|
|
214
|
+
const located = paths.expand(patterns).map((path) => ({ path, located: locate(path) }));
|
|
215
|
+
const read = await adapter.read(
|
|
216
|
+
located.flatMap(({ located }) => ("target" in located ? [targetRead(located)] : [])),
|
|
217
|
+
);
|
|
218
|
+
return located.map(({ path, located }) => {
|
|
219
|
+
if (!("target" in located)) return located;
|
|
220
|
+
const sample = read.get(located.target);
|
|
221
|
+
return sample ? sampleOf(path, located.type, sample) : unavailable(path, "Not read");
|
|
222
|
+
});
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const stopBuild = adapter.subscribe(
|
|
226
|
+
[{ target: manifest.build.target, type: { kind: "scalar", iec: "STRING" } }],
|
|
227
|
+
(_, sample) => {
|
|
228
|
+
if (sample.quality === "good" && typeof sample.value === "string") setBuild(sample.value);
|
|
229
|
+
},
|
|
230
|
+
buildCheckMs,
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
// --- Jobs and devices, read only while shown -------------------------------------------------
|
|
234
|
+
|
|
235
|
+
const latest = new Map<string, TargetSample>();
|
|
236
|
+
const traced = new Map<number, TracedJob>();
|
|
237
|
+
const failures = new Map<number, NonNullable<JobState["failure"]>>();
|
|
238
|
+
/** The server's traces by `<job id>:<service>:<sequence>`, newest last. */
|
|
239
|
+
const requests = new Map<string, RequestTrace>();
|
|
240
|
+
let unread = new Set<number>();
|
|
241
|
+
const shownJobs = createShown<number>();
|
|
242
|
+
const shownHealth = createShown<string>();
|
|
243
|
+
const shownDevices = createShown<string>();
|
|
244
|
+
/** The JSON of what each stream last got, so unchanged states aren't sent again. */
|
|
245
|
+
const sent = new Map<string, string>();
|
|
246
|
+
|
|
247
|
+
const jobOf = (job: DebugJob) =>
|
|
248
|
+
jobState(job, {
|
|
249
|
+
value: (target) => latest.get(target),
|
|
250
|
+
traced: traced.get(job.id),
|
|
251
|
+
failure: failures.get(job.id),
|
|
252
|
+
unread: unread.has(job.id),
|
|
253
|
+
clock: telemetry.clock(),
|
|
254
|
+
now: Date.now(),
|
|
255
|
+
request: ({ job, service, sequence }) => requests.get(`${job}:${service}:${sequence}`),
|
|
256
|
+
});
|
|
257
|
+
const deviceOf = (id: string) =>
|
|
258
|
+
deviceState(
|
|
259
|
+
manifest.devices.find((device) => device.id === id)!,
|
|
260
|
+
manifest.io,
|
|
261
|
+
(target) => latest.get(target),
|
|
262
|
+
);
|
|
263
|
+
const send = (key: string, message: StreamMessage) => {
|
|
264
|
+
const json = JSON.stringify(message.data);
|
|
265
|
+
if (sent.get(key) === json) return;
|
|
266
|
+
sent.set(key, json);
|
|
267
|
+
emit(message);
|
|
268
|
+
};
|
|
269
|
+
const updateJob = (job: DebugJob) => send(`job:${job.id}`, { event: "job", data: jobOf(job) });
|
|
270
|
+
const updateDevice = (id: string) =>
|
|
271
|
+
send(`device:${id}`, { event: "device", data: deviceOf(id) });
|
|
272
|
+
|
|
273
|
+
/** Re-subscribes `reads` when they change, telling `update` which owners each sample is for. */
|
|
274
|
+
const reader = <Owner>(intervalMs: number, update: (owner: Owner) => void) => {
|
|
275
|
+
let current = { key: "", stop: () => {} };
|
|
276
|
+
return (reads: { owner: Owner; read: TargetRead }[]) => {
|
|
277
|
+
const key = reads.map(({ read }) => read.target).join("\n");
|
|
278
|
+
if (key === current.key) return;
|
|
279
|
+
current.stop();
|
|
280
|
+
const owners = new Map<string, Owner[]>();
|
|
281
|
+
for (const { owner, read } of reads)
|
|
282
|
+
owners.set(read.target, [...(owners.get(read.target) ?? []), owner]);
|
|
283
|
+
current = {
|
|
284
|
+
key,
|
|
285
|
+
stop: reads.length
|
|
286
|
+
? adapter.subscribe(
|
|
287
|
+
[...new Map(reads.map(({ read }) => [read.target, read])).values()],
|
|
288
|
+
(target, sample) => {
|
|
289
|
+
latest.set(target, sample);
|
|
290
|
+
for (const owner of owners.get(target) ?? []) update(owner);
|
|
291
|
+
schedule();
|
|
292
|
+
},
|
|
293
|
+
intervalMs,
|
|
294
|
+
)
|
|
295
|
+
: () => {},
|
|
296
|
+
};
|
|
297
|
+
};
|
|
298
|
+
};
|
|
299
|
+
const readJobs = reader<DebugJob>(adapter.samplingMs, updateJob);
|
|
300
|
+
const readDevices = reader<string>(hardwareMs, updateDevice);
|
|
301
|
+
|
|
302
|
+
/** Reads `busy` of the shown jobs, and the details of the busy ones. */
|
|
303
|
+
const plan = () => {
|
|
304
|
+
const shown = manifest.jobs.filter((job) => shownJobs.has(job.id));
|
|
305
|
+
const busy = shown.filter((job) =>
|
|
306
|
+
busyOf(job, (target) => latest.get(target), traced.get(job.id)),
|
|
307
|
+
);
|
|
308
|
+
unread = new Set(busy.slice(detailedJobs).map(({ id }) => id));
|
|
309
|
+
readJobs([
|
|
310
|
+
...shown.map((job) => ({ owner: job, read: busyRead(job) })),
|
|
311
|
+
...busy
|
|
312
|
+
.slice(0, detailedJobs)
|
|
313
|
+
.flatMap((job) => detailReads(job).map((read) => ({ owner: job, read }))),
|
|
314
|
+
]);
|
|
315
|
+
readDevices(
|
|
316
|
+
manifest.devices.flatMap((device) =>
|
|
317
|
+
(shownDevices.has(device.id)
|
|
318
|
+
? deviceReads(device, manifest.io, paths.typeOf)
|
|
319
|
+
: shownHealth.has(device.id)
|
|
320
|
+
? healthReads(device)
|
|
321
|
+
: []
|
|
322
|
+
).map((read) => ({ owner: device.id, read })),
|
|
323
|
+
),
|
|
324
|
+
);
|
|
325
|
+
};
|
|
326
|
+
let planned = false;
|
|
327
|
+
const schedule = () => {
|
|
328
|
+
if (planned) return;
|
|
329
|
+
planned = true;
|
|
330
|
+
queueMicrotask(() => {
|
|
331
|
+
planned = false;
|
|
332
|
+
plan();
|
|
333
|
+
});
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
/** Reads what `interest` shows until the returned function is called. */
|
|
337
|
+
const show = (interest: Omit<Interest, "watch">) => {
|
|
338
|
+
const stops = [
|
|
339
|
+
shownJobs.add(interest.jobs),
|
|
340
|
+
shownHealth.add(interest.health),
|
|
341
|
+
shownDevices.add(interest.devices),
|
|
342
|
+
];
|
|
343
|
+
schedule();
|
|
344
|
+
return () => {
|
|
345
|
+
for (const stop of stops) stop();
|
|
346
|
+
schedule();
|
|
347
|
+
};
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
// --- The app server ---------------------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
const receive = ({ build, task, sequence, at, record: received }: ReceivedRecord) => {
|
|
353
|
+
if (received.kind === "trace") {
|
|
354
|
+
// Event sites of another build would decode to the wrong source.
|
|
355
|
+
if (build !== manifest.build.id) return;
|
|
356
|
+
const event = trace.decode(received, task, sequence);
|
|
357
|
+
if (event) record(timelineEntries.trace(at, event));
|
|
358
|
+
const site = trace.job(received);
|
|
359
|
+
if (!site) return;
|
|
360
|
+
const startedAt = site.started ? at : traced.get(site.job.id)?.startedAt;
|
|
361
|
+
traced.set(site.job.id, { busy: site.started, ...(startedAt && { startedAt }) });
|
|
362
|
+
updateJob(site.job);
|
|
363
|
+
schedule();
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
record(timelineEntries.app(at, task, sequence, received));
|
|
367
|
+
if (received.kind !== "exception" || received.handled) return;
|
|
368
|
+
const job = manifest.jobs.find(
|
|
369
|
+
({ name, instance }) => name === received.function && instance === received.instance,
|
|
370
|
+
);
|
|
371
|
+
if (!job) return;
|
|
372
|
+
failures.set(job.id, {
|
|
373
|
+
at,
|
|
374
|
+
code: received.code,
|
|
375
|
+
...(received.name && { name: received.name }),
|
|
376
|
+
source: received.source,
|
|
377
|
+
});
|
|
378
|
+
updateJob(job);
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const pull = async (url: URL, token: string) => {
|
|
382
|
+
const response = await fetch(url, { headers: { authorization: `Bearer ${token}` }, signal });
|
|
383
|
+
if (!response.ok) throw new Error(`The server answered ${response.status}`);
|
|
384
|
+
return response.json();
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const pullTelemetry = async ({ url, token }: { url: string; token: string }) => {
|
|
388
|
+
const reply = telemetryReply.parse(
|
|
389
|
+
await pull(new URL(`/tsplc/telemetry?since=${telemetry.since()}`, url), token),
|
|
390
|
+
);
|
|
391
|
+
const received = telemetry.accept(reply);
|
|
392
|
+
for (const item of received) receive(item);
|
|
393
|
+
const build = telemetry.build();
|
|
394
|
+
if (build) setBuild(build);
|
|
395
|
+
// Scan rates and the clock estimate change with every batch.
|
|
396
|
+
if (reply.batches.length) emit({ event: "session", data: session() });
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
/** Follows the server's ring, asking again from before the oldest call still pending. */
|
|
400
|
+
const pullRequests = ({ url, token }: { url: string; token: string }) => {
|
|
401
|
+
let newest = 0;
|
|
402
|
+
const pending = new Map<number, Entry<"request">>();
|
|
403
|
+
return async () => {
|
|
404
|
+
const since = pending.size ? Math.min(...pending.keys()) - 1 : newest;
|
|
405
|
+
const body = requestsReply.parse(
|
|
406
|
+
await pull(new URL(`/tsplc/debug/requests?since=${since}`, url), token),
|
|
407
|
+
);
|
|
408
|
+
// A restarted server counts from 1 again.
|
|
409
|
+
if (body.latest < newest) {
|
|
410
|
+
newest = 0;
|
|
411
|
+
pending.clear();
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const oldest = body.requests[0]?.seq;
|
|
415
|
+
if (oldest !== undefined)
|
|
416
|
+
for (const seq of pending.keys()) if (seq < oldest) pending.delete(seq);
|
|
417
|
+
for (const request of body.requests) {
|
|
418
|
+
const waiting = pending.get(request.seq);
|
|
419
|
+
if (waiting && request.status === "pending") continue;
|
|
420
|
+
if (!waiting && request.seq <= newest) continue;
|
|
421
|
+
const entry = waiting ?? timelineEntries.request(request);
|
|
422
|
+
entry.request = request;
|
|
423
|
+
newest = Math.max(newest, request.seq);
|
|
424
|
+
if (request.status === "pending") pending.set(request.seq, entry);
|
|
425
|
+
else pending.delete(request.seq);
|
|
426
|
+
if (!waiting) timeline.add(entry);
|
|
427
|
+
emit({ event: "request", data: request });
|
|
428
|
+
emit({ event: "timeline", data: entry });
|
|
429
|
+
if (request.job === undefined || request.sequence === undefined) continue;
|
|
430
|
+
requests.set(`${request.job}:${request.service}:${request.sequence}`, request);
|
|
431
|
+
for (const key of [...requests.keys()].slice(0, -requestsRemembered)) requests.delete(key);
|
|
432
|
+
const job = manifest.jobs.find(({ id }) => id === request.job);
|
|
433
|
+
if (job) updateJob(job);
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
// --- Background work -------------------------------------------------------------------------
|
|
439
|
+
|
|
440
|
+
const stopConnection = adapter.onConnection((connection) => {
|
|
441
|
+
record(timelineEntries.connection(connection.since, connection.state, connection.error));
|
|
442
|
+
emit({ event: "session", data: session() });
|
|
443
|
+
if (connection.state !== "connected") {
|
|
444
|
+
for (const [target, sample] of latest)
|
|
445
|
+
latest.set(target, {
|
|
446
|
+
...sample,
|
|
447
|
+
quality: "unavailable",
|
|
448
|
+
status: connection.error ?? "The controller is not connected",
|
|
449
|
+
});
|
|
450
|
+
for (const job of manifest.jobs) updateJob(job);
|
|
451
|
+
for (const device of manifest.devices) updateDevice(device.id);
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
const repeat = async (name: string, intervalMs: number, work: () => Promise<void>) => {
|
|
456
|
+
let failure: string | undefined;
|
|
457
|
+
while (!signal.aborted) {
|
|
458
|
+
try {
|
|
459
|
+
await work();
|
|
460
|
+
failure = undefined;
|
|
461
|
+
} catch (error) {
|
|
462
|
+
if (signal.aborted) return;
|
|
463
|
+
if (message(error) !== failure) options.log(`${name} failed: ${message(error)}`);
|
|
464
|
+
failure = message(error);
|
|
465
|
+
}
|
|
466
|
+
await delay(intervalMs, undefined, { signal }).catch(() => undefined);
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const { server } = options;
|
|
471
|
+
const running = server
|
|
472
|
+
? [
|
|
473
|
+
repeat("Reading the server's telemetry", pullMs, () => pullTelemetry(server)),
|
|
474
|
+
repeat("Reading the server's requests", pullMs, pullRequests(server)),
|
|
475
|
+
]
|
|
476
|
+
: [];
|
|
477
|
+
|
|
478
|
+
return {
|
|
479
|
+
manifest,
|
|
480
|
+
paths,
|
|
481
|
+
session,
|
|
482
|
+
values,
|
|
483
|
+
jobs: () => manifest.jobs.map(jobOf),
|
|
484
|
+
hardware: () => manifest.devices.map(({ id }) => deviceOf(id)),
|
|
485
|
+
timeline: (since?: string) => timeline.since(since),
|
|
486
|
+
watchGroups: () => watchGroups.groups(),
|
|
487
|
+
saveWatchGroups: (groups: WatchGroups) => watchGroups.save(groups),
|
|
488
|
+
/** Adds an entry from another source, such as the agent's audit log. */
|
|
489
|
+
record,
|
|
490
|
+
watch,
|
|
491
|
+
show,
|
|
492
|
+
/** Calls `listener` with every other stream event until the returned function is called. */
|
|
493
|
+
subscribe: (listener: (message: StreamMessage) => void) => {
|
|
494
|
+
listeners.add(listener);
|
|
495
|
+
return () => listeners.delete(listener);
|
|
496
|
+
},
|
|
497
|
+
close: async () => {
|
|
498
|
+
controller.abort();
|
|
499
|
+
stopBuild();
|
|
500
|
+
readJobs([]);
|
|
501
|
+
readDevices([]);
|
|
502
|
+
stopConnection();
|
|
503
|
+
await Promise.all(running);
|
|
504
|
+
await adapter.close();
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
export type Debugger = Awaited<ReturnType<typeof createDebugger>>;
|
package/src/hardware.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { type TargetRead, type TargetSample, worstQuality } from "./adapter.ts";
|
|
2
|
+
import type { DeviceState } from "./api.ts";
|
|
3
|
+
import type { DebugDevice, DebugIo, DebugType } from "./manifest.ts";
|
|
4
|
+
|
|
5
|
+
/** Driver state names that mean the connection isn't working. */
|
|
6
|
+
const faultyState = /error|fault|fail|timeout|lost|closed/i;
|
|
7
|
+
|
|
8
|
+
/** Hand-configured status symbols have no type in the manifest; they show as read. */
|
|
9
|
+
const untyped: DebugType = { kind: "scalar", iec: "ANY" };
|
|
10
|
+
|
|
11
|
+
/** What a device's health is read from: the driver's own state, when the build names it. */
|
|
12
|
+
export const healthReads = (device: DebugDevice): TargetRead[] => {
|
|
13
|
+
const { state, states, diagnostic, message } = device.health ?? {};
|
|
14
|
+
return [
|
|
15
|
+
...(state
|
|
16
|
+
? [
|
|
17
|
+
{
|
|
18
|
+
target: state,
|
|
19
|
+
type: states
|
|
20
|
+
? { kind: "enum" as const, name: "state", members: states }
|
|
21
|
+
: { kind: "scalar" as const, iec: "DINT" },
|
|
22
|
+
},
|
|
23
|
+
]
|
|
24
|
+
: []),
|
|
25
|
+
...(diagnostic ? [{ target: diagnostic, type: { kind: "scalar" as const, iec: "BOOL" } }] : []),
|
|
26
|
+
...(message ? [{ target: message, type: { kind: "scalar" as const, iec: "STRING" } }] : []),
|
|
27
|
+
];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Everything a device's page shows: its health, raw assembly bytes, bound channels and status symbols. */
|
|
31
|
+
export const deviceReads = (
|
|
32
|
+
device: DebugDevice,
|
|
33
|
+
io: readonly DebugIo[],
|
|
34
|
+
typeOf: (id: string) => DebugType,
|
|
35
|
+
): TargetRead[] => [
|
|
36
|
+
...healthReads(device),
|
|
37
|
+
...device.assemblies.flatMap(({ raw, bytes }) =>
|
|
38
|
+
raw
|
|
39
|
+
? [
|
|
40
|
+
{
|
|
41
|
+
target: raw,
|
|
42
|
+
type: { kind: "array" as const, element: "BYTE", length: bytes, lower: 0 },
|
|
43
|
+
},
|
|
44
|
+
]
|
|
45
|
+
: [],
|
|
46
|
+
),
|
|
47
|
+
...io
|
|
48
|
+
.filter((binding) => binding.device === device.id)
|
|
49
|
+
.map(({ target, type }) => ({ target, type: typeOf(type) })),
|
|
50
|
+
...Object.values(device.status ?? {}).map((target) => ({ target, type: untyped })),
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const health = (
|
|
54
|
+
device: DebugDevice,
|
|
55
|
+
value: (target: string) => TargetSample | undefined,
|
|
56
|
+
): DeviceState["health"] => {
|
|
57
|
+
const { state, states, diagnostic, message } = device.health ?? {};
|
|
58
|
+
if (!state && !diagnostic) return { state: "unknown", quality: "unavailable" };
|
|
59
|
+
const stateSample = state ? value(state) : undefined;
|
|
60
|
+
const diagnosticSample = diagnostic ? value(diagnostic) : undefined;
|
|
61
|
+
const messageSample = message ? value(message) : undefined;
|
|
62
|
+
const quality = worstQuality([
|
|
63
|
+
...(state ? [stateSample] : []),
|
|
64
|
+
...(diagnostic ? [diagnosticSample] : []),
|
|
65
|
+
]);
|
|
66
|
+
const number = typeof stateSample?.value === "number" ? stateSample.value : undefined;
|
|
67
|
+
// The login names enum members the manifest may not list.
|
|
68
|
+
const name =
|
|
69
|
+
Object.entries(states ?? {}).find(([, member]) => member === number)?.[0] ??
|
|
70
|
+
(typeof stateSample?.value === "string" ? stateSample.value : undefined);
|
|
71
|
+
const faulted =
|
|
72
|
+
diagnosticSample?.value === true || (name !== undefined && faultyState.test(name));
|
|
73
|
+
return {
|
|
74
|
+
state: quality !== "good" ? "stale" : faulted ? "fault" : "ok",
|
|
75
|
+
...(number !== undefined && { value: number }),
|
|
76
|
+
...(name && { name }),
|
|
77
|
+
...(typeof diagnosticSample?.value === "boolean" && { diagnostic: diagnosticSample.value }),
|
|
78
|
+
...(typeof messageSample?.value === "string" && { message: messageSample.value }),
|
|
79
|
+
quality,
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A device's driver health, raw assembly bytes, bound channel values and status symbols. Health
|
|
85
|
+
* is "unknown" unless the manifest names the driver's own state.
|
|
86
|
+
*/
|
|
87
|
+
export const deviceState = (
|
|
88
|
+
device: DebugDevice,
|
|
89
|
+
io: readonly DebugIo[],
|
|
90
|
+
value: (target: string) => TargetSample | undefined,
|
|
91
|
+
): DeviceState => ({
|
|
92
|
+
id: device.id,
|
|
93
|
+
health: health(device, value),
|
|
94
|
+
assemblies: device.assemblies.map(({ direction, address, raw }) => {
|
|
95
|
+
const sample = raw ? value(raw) : undefined;
|
|
96
|
+
return {
|
|
97
|
+
direction,
|
|
98
|
+
...(address && { address }),
|
|
99
|
+
bytes:
|
|
100
|
+
Array.isArray(sample?.value) &&
|
|
101
|
+
sample.value.every((byte): byte is number => typeof byte === "number")
|
|
102
|
+
? sample.value
|
|
103
|
+
: null,
|
|
104
|
+
quality: sample?.quality ?? "unavailable",
|
|
105
|
+
};
|
|
106
|
+
}),
|
|
107
|
+
channels: io.flatMap((binding) =>
|
|
108
|
+
binding.device === device.id && binding.channel
|
|
109
|
+
? [
|
|
110
|
+
{
|
|
111
|
+
id: binding.channel,
|
|
112
|
+
path: binding.path,
|
|
113
|
+
value: value(binding.target)?.value ?? null,
|
|
114
|
+
quality: value(binding.target)?.quality ?? "unavailable",
|
|
115
|
+
},
|
|
116
|
+
]
|
|
117
|
+
: [],
|
|
118
|
+
),
|
|
119
|
+
status: Object.fromEntries(
|
|
120
|
+
Object.entries(device.status ?? {}).map(([label, target]) => [
|
|
121
|
+
label,
|
|
122
|
+
{ value: value(target)?.value ?? null, quality: value(target)?.quality ?? "unavailable" },
|
|
123
|
+
]),
|
|
124
|
+
),
|
|
125
|
+
});
|