dsh-foreman-node 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,573 @@
1
+ import { FrameDedupe, encodeFrame, parseFrame } from "./protocol.js";
2
+ import s from "@deepseek-ai/schemastery";
3
+ import { MessageId, freezeMessage } from "@deepseek-ai/dsh-llm";
4
+ // src/client.ts
5
+ /**
6
+ * The device's outbound connection to its hub.
7
+ *
8
+ * The direction is deliberate: a device dials out, so a machine behind NAT or a company firewall can take work. The
9
+ * connection is never the device's to command — it reports what it holds, sends frames the hub asked for, and
10
+ * reconnects with exponential backoff on its own.
11
+ *
12
+ * A frame is never built here by hand: the protocol module owns every shape, so the device and the hub cannot drift.
13
+ * Nothing in this file writes a ledger, reads a policy, or opens a database — a device has none of those.
14
+ */
15
+ const OPEN = 1;
16
+ /** A counter for frame ids; unique within one connection is enough, since the hub drops repeats by id. */
17
+ let counter = 0;
18
+ /** A fresh frame id. Time-based so two processes do not collide, with a counter so two frames in one millisecond differ. */
19
+ const nextFrameId = (prefix) => `${prefix}:${Date.now().toString(36)}:${(counter++).toString(36)}`;
20
+ /**
21
+ * Keeps one connection to the hub open, reconnecting with backoff.
22
+ *
23
+ * The class owns the socket's lifecycle and nothing else: what a frame means is the plugin's business, and what
24
+ * happens when the connection is refused is the hub's. `start` and `stop` are idempotent.
25
+ */
26
+ var NodeClient = class {
27
+ input;
28
+ socket;
29
+ stopped = false;
30
+ reconnectTimer;
31
+ heartbeatTimer;
32
+ delay;
33
+ dedupe;
34
+ connect;
35
+ log;
36
+ constructor(input) {
37
+ this.input = input;
38
+ this.delay = input.reconnectMs ?? 1e3;
39
+ this.dedupe = input.dedupe ?? new FrameDedupe(/* @__PURE__ */ new Set());
40
+ this.connect = input.connect ?? ((url) => new WebSocket(url));
41
+ this.log = input.log ?? (() => {});
42
+ }
43
+ /** Open the connection, or do nothing if it is already open or opening. */
44
+ start() {
45
+ if (this.stopped || this.socket !== void 0) return;
46
+ this.open();
47
+ }
48
+ /** Close the connection and stop reconnecting; a late socket event does nothing. */
49
+ stop() {
50
+ this.stopped = true;
51
+ if (this.reconnectTimer !== void 0) clearTimeout(this.reconnectTimer);
52
+ if (this.heartbeatTimer !== void 0) clearInterval(this.heartbeatTimer);
53
+ this.socket?.close();
54
+ this.socket = void 0;
55
+ }
56
+ /** Send a frame now if the connection is open. Returns whether it went out, so a caller can retry later. */
57
+ send(frame) {
58
+ if (this.socket === void 0 || this.socket.readyState !== OPEN) return false;
59
+ try {
60
+ this.socket.send(encodeFrame(frame));
61
+ return true;
62
+ } catch (error) {
63
+ this.log(`dsh-foreman-node could not send ${frame.type}: ${String(error)}`);
64
+ return false;
65
+ }
66
+ }
67
+ /** Report an outcome on an assignment, which is the only way a hub learns what a job did. */
68
+ report(assignmentId, report) {
69
+ const frame = {
70
+ v: 1,
71
+ type: "report",
72
+ frameId: nextFrameId("report"),
73
+ assignmentId,
74
+ state: report.state,
75
+ summary: report.summary,
76
+ ...report.question === void 0 ? {} : { question: report.question }
77
+ };
78
+ this.send(frame);
79
+ }
80
+ /** Whether the connection is open right now. */
81
+ connected() {
82
+ return this.socket !== void 0 && this.socket.readyState === OPEN;
83
+ }
84
+ open() {
85
+ let socket;
86
+ try {
87
+ socket = this.connect(this.input.url);
88
+ } catch (error) {
89
+ this.log(`dsh-foreman-node could not open ${this.input.url}: ${String(error)}`);
90
+ this.scheduleReconnect();
91
+ return;
92
+ }
93
+ this.socket = socket;
94
+ socket.addEventListener("open", () => {
95
+ if (this.stopped) return;
96
+ this.delay = this.input.reconnectMs ?? 1e3;
97
+ this.hello();
98
+ this.startHeartbeat();
99
+ });
100
+ socket.addEventListener("message", (event) => {
101
+ if (this.stopped) return;
102
+ const text = typeof event.data === "string" ? event.data : String(event.data);
103
+ this.receive(text);
104
+ });
105
+ socket.addEventListener("error", () => {
106
+ this.log("dsh-foreman-node connection error");
107
+ });
108
+ socket.addEventListener("close", () => {
109
+ if (this.stopped) return;
110
+ if (this.heartbeatTimer !== void 0) clearInterval(this.heartbeatTimer);
111
+ this.socket = void 0;
112
+ this.scheduleReconnect();
113
+ });
114
+ }
115
+ hello() {
116
+ const frame = {
117
+ v: 1,
118
+ type: "hello",
119
+ frameId: nextFrameId("hello"),
120
+ deviceId: this.input.deviceId,
121
+ token: this.input.token,
122
+ version: this.input.version,
123
+ roots: this.input.roots(),
124
+ running: this.input.running()
125
+ };
126
+ this.send(frame);
127
+ }
128
+ startHeartbeat() {
129
+ if (this.heartbeatTimer !== void 0) clearInterval(this.heartbeatTimer);
130
+ this.heartbeatTimer = setInterval(() => {
131
+ const frame = {
132
+ v: 1,
133
+ type: "heartbeat",
134
+ frameId: nextFrameId("heartbeat"),
135
+ deviceId: this.input.deviceId,
136
+ running: this.input.running().length
137
+ };
138
+ this.send(frame);
139
+ }, this.input.heartbeatMs ?? 15e3);
140
+ this.heartbeatTimer.unref?.();
141
+ }
142
+ receive(text) {
143
+ let frame;
144
+ try {
145
+ frame = parseFrame(text);
146
+ } catch (error) {
147
+ this.log(`dsh-foreman-node refused a frame: ${String(error)}`);
148
+ return;
149
+ }
150
+ if (!this.dedupe.take(frame.frameId)) return;
151
+ if (frame.type === "ack") return;
152
+ if (frame.type === "error") {
153
+ this.log(`dsh-foreman-node was refused by the hub: ${frame.reason}`);
154
+ return;
155
+ }
156
+ this.input.onFrame(frame);
157
+ }
158
+ /** Acknowledge a frame once its side effect is done, so the hub can stop resending it. */
159
+ ack(frame) {
160
+ const ack = {
161
+ v: 1,
162
+ type: "ack",
163
+ frameId: nextFrameId("ack"),
164
+ of: frame.frameId
165
+ };
166
+ this.send(ack);
167
+ }
168
+ /** Tell the hub a frame's work reached its Session, which is what lets it mark the assignment started. */
169
+ accepted(assignmentId) {
170
+ const frame = {
171
+ v: 1,
172
+ type: "accepted",
173
+ frameId: nextFrameId("accepted"),
174
+ assignmentId
175
+ };
176
+ this.send(frame);
177
+ }
178
+ scheduleReconnect() {
179
+ if (this.stopped) return;
180
+ const wait = this.delay;
181
+ this.delay = Math.min(this.delay * 2, this.input.maxReconnectMs ?? 6e4);
182
+ this.reconnectTimer = setTimeout(() => {
183
+ this.reconnectTimer = void 0;
184
+ if (!this.stopped) this.open();
185
+ }, wait);
186
+ this.reconnectTimer.unref?.();
187
+ this.log(`dsh-foreman-node reconnecting in ${wait} ms`);
188
+ }
189
+ };
190
+ // src/report-tool.ts
191
+ /**
192
+ * The `foreman_report` tool a device registers on a job Session.
193
+ *
194
+ * Reporting is a hard requirement rather than a convenience: in the domain, "waiting for a person" is a structured
195
+ * fact that a report carries, and inferring it from a turn ending is an explicitly rejected approach. So a device
196
+ * states outcomes through this tool and nothing else.
197
+ *
198
+ * `dropped` is not in its enum. Deciding that work should not be done is a judgement, a device has no judgement, and
199
+ * the hub's ledger already refuses that state from a device (see the protocol). A device that believes the same thing
200
+ * must report `blocked` and let a person decide.
201
+ *
202
+ * The schema is written as JSON Schema and validated here rather than through `zod`, so this package stays free of
203
+ * runtime dependencies beyond the DSH services it is a peer plugin of.
204
+ */
205
+ /** What a device may report; the same set the protocol's `NodeReportState` names. */
206
+ const REPORT_STATES = [
207
+ "progress",
208
+ "ready",
209
+ "blocked",
210
+ "failed"
211
+ ];
212
+ /** The JSON Schema a model sees, which is also what `execute` validates against. */
213
+ const PARAMETERS = {
214
+ type: "object",
215
+ additionalProperties: false,
216
+ required: ["state", "summary"],
217
+ properties: {
218
+ state: {
219
+ type: "string",
220
+ enum: [...REPORT_STATES],
221
+ description: "progress while working, ready when the result is in, blocked when a person must act, failed when the work cannot be done."
222
+ },
223
+ summary: {
224
+ type: "string",
225
+ minLength: 1,
226
+ description: "What was done, or what is needed, in one or two sentences."
227
+ },
228
+ question: {
229
+ type: "string",
230
+ minLength: 1,
231
+ description: "One concrete question for a person, with state=blocked."
232
+ }
233
+ }
234
+ };
235
+ /** Thrown for arguments this tool refuses; the model sees the message and can correct itself. */
236
+ var ReportRefusal = class extends Error {
237
+ constructor(message) {
238
+ super(message);
239
+ this.name = "ReportRefusal";
240
+ }
241
+ };
242
+ /** Validate the arguments to a report, refusing `dropped`, an unknown state, or an empty summary. */
243
+ function readReport(args) {
244
+ const state = args["state"];
245
+ if (typeof state !== "string") throw new ReportRefusal("state must be a string");
246
+ if (state === "dropped") throw new ReportRefusal("a device may not report dropped: deciding that work should not be done is the hub's judgement, so report blocked instead");
247
+ if (!REPORT_STATES.includes(state)) throw new ReportRefusal(`state must be one of ${REPORT_STATES.join(", ")}, not ${state}`);
248
+ const summary = typeof args["summary"] === "string" ? args["summary"].trim() : "";
249
+ if (summary === "") throw new ReportRefusal("summary must say what was done or what is needed");
250
+ const question = typeof args["question"] === "string" ? args["question"].trim() : void 0;
251
+ if (args["question"] !== void 0 && (question === void 0 || question === "")) throw new ReportRefusal("question must be a question when given");
252
+ return {
253
+ state,
254
+ summary,
255
+ ...question === void 0 ? {} : { question }
256
+ };
257
+ }
258
+ /**
259
+ * Build the `foreman_report` tool for one assignment.
260
+ *
261
+ * The assignment id is bound here rather than passed by the model: a job Session reports on the assignment it was
262
+ * started for, and a model should not be able to name a different one.
263
+ */
264
+ function foremanReportTool(host, assignmentId) {
265
+ return {
266
+ name: "foreman_report",
267
+ description: "Report this job to the foreman: progress, ready, blocked or failed, with a one-sentence summary and, when a person must answer, one question.",
268
+ parameters: PARAMETERS,
269
+ async execute(args) {
270
+ host.report(assignmentId, readReport(args));
271
+ return { reported: true };
272
+ }
273
+ };
274
+ }
275
+ // \0@oxc-project+runtime@0.156.0/helpers/esm/usingCtx.js
276
+ function _usingCtx() {
277
+ var r = "function" == typeof SuppressedError ? SuppressedError : function(r, e) {
278
+ var n = Error();
279
+ return n.name = "SuppressedError", n.error = r, n.suppressed = e, n;
280
+ }, e = {}, n = [];
281
+ function using(r, e) {
282
+ if (null != e) {
283
+ if (Object(e) !== e) throw new TypeError("using declarations can only be used with objects, functions, null, or undefined.");
284
+ if (r) var o = e[Symbol.asyncDispose || Symbol["for"]("Symbol.asyncDispose")];
285
+ if (void 0 === o && (o = e[Symbol.dispose || Symbol["for"]("Symbol.dispose")], r)) var t = o;
286
+ if ("function" != typeof o) throw new TypeError("Object is not disposable.");
287
+ t && (o = function o() {
288
+ try {
289
+ t.call(e);
290
+ } catch (r) {
291
+ return Promise.reject(r);
292
+ }
293
+ }), n.push({
294
+ v: e,
295
+ d: o,
296
+ a: r
297
+ });
298
+ } else r && n.push({
299
+ d: e,
300
+ a: r
301
+ });
302
+ return e;
303
+ }
304
+ return {
305
+ e,
306
+ u: using.bind(null, !1),
307
+ a: using.bind(null, !0),
308
+ d: function d() {
309
+ var o, t = this.e, s = 0;
310
+ function next() {
311
+ for (; o = n.pop();) try {
312
+ if (!o.a && 1 === s) return s = 0, n.push(o), Promise.resolve().then(next);
313
+ if (o.d) {
314
+ var r = o.d.call(o.v);
315
+ if (o.a) return s |= 2, Promise.resolve(r).then(next, err);
316
+ } else s |= 1;
317
+ } catch (r) {
318
+ return err(r);
319
+ }
320
+ if (1 === s) return t !== e ? Promise.reject(t) : Promise.resolve();
321
+ if (t !== e) throw t;
322
+ }
323
+ function err(n) {
324
+ return t = t !== e ? new r(n, t) : n, next();
325
+ }
326
+ return next();
327
+ }
328
+ };
329
+ }
330
+ // src/session.ts
331
+ /** The permission preset a job runs under: bounded to the directory the device declared. */
332
+ const JOB_PRESET = "workspace-write";
333
+ /** The Session a job runs in, named the way a hub names its own worker: `job-<assignmentId>`. */
334
+ const jobSessionId = (assignmentId) => `job-${assignmentId}`;
335
+ /** The message id of one delivery, derived from the hub's request id so a redelivery adds nothing. */
336
+ const requestMessage = (requestId) => MessageId(`dsh-foreman-node:${requestId}`);
337
+ /**
338
+ * Keeps the device's job Sessions and delivers work into them.
339
+ *
340
+ * A Session is created only when it does not exist yet, so a redelivered `assign` reuses the Session and the work
341
+ * already in it instead of starting a second one. Delivery is idempotent by request id: the hub retrying a frame
342
+ * that it never saw acknowledged cannot append the task twice.
343
+ */
344
+ var JobSessions = class {
345
+ ctx;
346
+ /** Jobs by assignment id, so `running()` reports what this device actually holds rather than what it was told. */
347
+ jobs = /* @__PURE__ */ new Map();
348
+ /** Assignments whose Session already carries the report tool, so a redelivered assign registers it only once. */
349
+ reported = /* @__PURE__ */ new Set();
350
+ constructor(ctx) {
351
+ this.ctx = ctx;
352
+ }
353
+ /**
354
+ * Resolve or create the `job-<assignmentId>` Session in the given directory, attach its `foreman_report` tool, and
355
+ * register it so a heartbeat counts it.
356
+ *
357
+ * The tool is attached here rather than left to the caller because it is the only way a device can say what
358
+ * happened: without it a job Session's model has no `progress`/`ready`/`blocked`/`failed` channel, the hub waits
359
+ * forever, and the whole design's "never infer state from session events" requirement is moot. Registration is
360
+ * idempotent per assignment, so a redelivered `assign` does not stack a second tool on the same Session.
361
+ *
362
+ * @returns the Session id.
363
+ */
364
+ async ensure(input, onReport) {
365
+ const id = jobSessionId(input.assignmentId);
366
+ const workspace = await this.ctx.workspaceRegistry.create(input.directory);
367
+ let result = await this.ctx.sessionController.resolveAgent(id);
368
+ if ("error" in result && result.error.code === "session/not-found") {
369
+ await this.ctx.sessionController.create({
370
+ sessionId: id,
371
+ workspaceId: workspace.id
372
+ });
373
+ result = await this.ctx.sessionController.resolveAgent(id);
374
+ }
375
+ if ("error" in result) throw result.error;
376
+ await workspace.attachSession(id);
377
+ this.ctx.permissionPresets.set(result.agent.session, JOB_PRESET);
378
+ await this.ctx.sessionController.rename({
379
+ sessionId: id,
380
+ title: input.title
381
+ });
382
+ if (onReport !== void 0 && !this.reported.has(input.assignmentId)) {
383
+ this.reported.add(input.assignmentId);
384
+ try {
385
+ this.reportTools(result.agent, input.assignmentId, onReport);
386
+ } catch (error) {
387
+ this.reported.delete(input.assignmentId);
388
+ throw new Error(`could not attach foreman_report to ${id}: ${String(error)}`);
389
+ }
390
+ }
391
+ this.jobs.set(input.assignmentId, id);
392
+ return id;
393
+ }
394
+ /**
395
+ * Deliver a task into its job Session, once per request id.
396
+ *
397
+ * `queue` rather than `steer` on the first delivery: the job's task should start a turn, not interrupt one that is
398
+ * already running. A redelivery finds the message already recorded and adds nothing.
399
+ */
400
+ async deliver(sessionId, text, requestId) {
401
+ const result = await this.ctx.sessionController.resolveAgent(sessionId);
402
+ if ("error" in result) throw result.error;
403
+ const agent = result.agent;
404
+ if (await this.holds(agent, requestId)) return;
405
+ agent.followup(freezeMessage({
406
+ id: requestMessage(requestId),
407
+ role: "user",
408
+ content: [{
409
+ type: "text",
410
+ text
411
+ }],
412
+ source: {
413
+ kind: "dsh-foreman-node",
414
+ form: "relay"
415
+ }
416
+ }));
417
+ await this.ctx.sessionPersistence.flush();
418
+ }
419
+ /** Whether the Session already holds the delivery for `requestId`, in its live inbox or its stored history. */
420
+ async holds(agent, requestId) {
421
+ const id = requestMessage(requestId);
422
+ if ([...agent.inbox.nextTurn, ...agent.inbox.nextStep].some((message) => message.id === id)) return true;
423
+ try {
424
+ try {
425
+ var _usingCtx$1 = _usingCtx();
426
+ await this.ctx.sessionPersistence.flush();
427
+ const handle = _usingCtx$1.a(await this.ctx.sessionPersistence.open(agent.id, "read"));
428
+ for (let offset = 0;;) {
429
+ const { events } = await handle.read(offset, 512);
430
+ if (events.length === 0) return false;
431
+ for (const event of events) if (event.type === "user/message" && event.data.id === id) return true;
432
+ offset += events.length;
433
+ }
434
+ } catch (_) {
435
+ _usingCtx$1.e = _;
436
+ } finally {
437
+ await _usingCtx$1.d();
438
+ }
439
+ } catch (error) {
440
+ this.ctx.logger.warn("dsh-foreman-node could not read %s history: %s", agent.id, String(error));
441
+ return false;
442
+ }
443
+ }
444
+ /** The assignment ids this device currently holds a job for: what a heartbeat and a reconnected hello report. */
445
+ running() {
446
+ return [...this.jobs.keys()];
447
+ }
448
+ /** Forget a job whose assignment the hub stopped, so this device stops reporting it as running. */
449
+ forget(assignmentId) {
450
+ this.jobs.delete(assignmentId);
451
+ }
452
+ /** Attach the `foreman_report` tool to a job Session, disposed with it. */
453
+ reportTools(agent, assignmentId, onReport) {
454
+ const registrar = agent.ctx.tools;
455
+ const tool = foremanReportTool({ report: onReport }, assignmentId);
456
+ registrar.register({
457
+ name: tool.name,
458
+ description: tool.description,
459
+ parameters: tool.parameters,
460
+ output: {
461
+ schema: {
462
+ type: "object",
463
+ additionalProperties: true
464
+ },
465
+ render: (_args, value) => [{
466
+ type: "text",
467
+ text: JSON.stringify(value)
468
+ }]
469
+ },
470
+ execute: (args, execution) => tool.execute(args, execution)
471
+ });
472
+ }
473
+ };
474
+ // src/index.ts
475
+ /** Version reported in the hello, so the hub can tell which device build is talking. */
476
+ const VERSION = "0.1.0";
477
+ /**
478
+ * Configuration of one device, from the `dsh-foreman-node` entry in the profile's patch.
479
+ *
480
+ * The annotation names the schema type through the global `Schemastery` namespace rather than letting it be inferred:
481
+ * an inferred type here names a `cosmokit` symbol the emitted declaration cannot reference portably.
482
+ */
483
+ const Config = s.object({
484
+ url: s.string().required().description("The hub's address, such as wss://foreman.example.com. The device dials out to it."),
485
+ deviceId: s.string().required().description("This device's id, as registered on the hub with `Devices.add`."),
486
+ token: s.string().required().role("secret").description("This device's token; it is sent in the hello frame and nowhere else."),
487
+ roots: s.array(s.object({
488
+ project: s.string().required(),
489
+ path: s.string().required()
490
+ })).default([]).description("Projects this device can work on: a logical name and this machine's own absolute path, kept verbatim."),
491
+ heartbeatMs: s.natural().default(15e3).description("How often to tell the hub this device is alive, in milliseconds."),
492
+ reconnectMs: s.natural().default(1e3).description("Delay before the first reconnect attempt, in milliseconds.")
493
+ });
494
+ const name = "dsh-foreman-node";
495
+ /** DSH services a job needs; all are the host's own, and none is a foreman one. */
496
+ const inject = [
497
+ "sessionController",
498
+ "sessionPersistence",
499
+ "workspaceRegistry",
500
+ "permissionPresets",
501
+ "tools",
502
+ "agents"
503
+ ];
504
+ /**
505
+ * Start the device.
506
+ *
507
+ * The plugin returns once the connection is attempted, not once it succeeds: a device must boot and stay usable
508
+ * while its hub is unreachable, and the client keeps retrying in the background.
509
+ */
510
+ async function apply(ctx, config) {
511
+ const jobs = new JobSessions(ctx);
512
+ const client = new NodeClient({
513
+ url: config.url,
514
+ deviceId: config.deviceId,
515
+ token: config.token,
516
+ version: VERSION,
517
+ roots: () => config.roots.map((root) => ({
518
+ project: root.project,
519
+ path: root.path
520
+ })),
521
+ running: () => jobs.running(),
522
+ heartbeatMs: config.heartbeatMs,
523
+ reconnectMs: config.reconnectMs,
524
+ log: (text) => ctx.logger.warn("%s", text),
525
+ onFrame: (frame) => {
526
+ receive(ctx, jobs, client, frame);
527
+ }
528
+ });
529
+ client.start();
530
+ ctx.effect(() => () => {
531
+ client.stop();
532
+ });
533
+ ctx.logger.info("dsh-foreman-node %s is dialling %s as %s", VERSION, config.url, config.deviceId);
534
+ }
535
+ /**
536
+ * Handle one frame from the hub.
537
+ *
538
+ * `assign` creates or reuses the job Session and delivers the task; `input` steers more text into the same Session;
539
+ * `stop` ends the job. Each is acknowledged so the hub stops resending it, and each is idempotent on this side, so a
540
+ * redelivery after a dropped ack does no harm.
541
+ */
542
+ async function receive(ctx, jobs, client, frame) {
543
+ try {
544
+ if (frame.type === "assign") {
545
+ const sessionId = await jobs.ensure({
546
+ assignmentId: frame.assignmentId,
547
+ directory: frame.directory,
548
+ title: `job ${frame.assignmentId}`
549
+ }, (assignmentId, report) => client.report(assignmentId, report));
550
+ await jobs.deliver(sessionId, frame.text, frame.frameId);
551
+ client.accepted(frame.assignmentId);
552
+ client.ack(frame);
553
+ return;
554
+ }
555
+ if (frame.type === "input") {
556
+ const sessionId = `job-${frame.assignmentId}`;
557
+ await jobs.deliver(sessionId, frame.text, frame.frameId);
558
+ client.ack(frame);
559
+ return;
560
+ }
561
+ if (frame.type === "stop") {
562
+ jobs.forget(frame.assignmentId);
563
+ client.ack(frame);
564
+ }
565
+ } catch (error) {
566
+ ctx.logger.warn("dsh-foreman-node could not apply a %s frame: %s", frame.type, String(error));
567
+ if (frame.type === "assign" || frame.type === "input") client.report(frame.assignmentId, {
568
+ state: "blocked",
569
+ summary: `this device could not start the job: ${String(error)}`
570
+ });
571
+ }
572
+ }
573
+ export { Config, JobSessions, NodeClient, REPORT_STATES, VERSION, apply, foremanReportTool, inject, name };
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The foreman protocol: one JSON frame per WebSocket text message, versioned by `v`.
3
+ *
4
+ * This file is the only definition of a frame. `dsh-foreman` (the hub) depends on
5
+ * this package and imports it, so the two sides cannot drift; nothing here may
6
+ * import a DSH service, the core domain, or a database.
7
+ */
8
+ /** Bumped whenever a frame's shape changes incompatibly; a peer on another version is refused at handshake. */
9
+ declare const FOREMAN_PROTOCOL_VERSION = 1;
10
+ /** Largest frame accepted, in bytes of UTF-8. A larger one is refused, never truncated. */
11
+ declare const MAX_FRAME_BYTES: number;
12
+ /** A project a device can work on: the hub's logical name and the device's own absolute path. */
13
+ interface Root {
14
+ /** The project name both sides agree on; the hub never sees the device path before a job is assigned. */
15
+ project: string;
16
+ /** An absolute path on the device. Kept verbatim, including spaces and non-ASCII characters. */
17
+ path: string;
18
+ }
19
+ /** A file travelling with a task, base64-encoded. The device writes it where its job session can read it. */
20
+ interface FrameAttachment {
21
+ id: string;
22
+ mediaType?: string;
23
+ name?: string;
24
+ bytes: string;
25
+ }
26
+ /**
27
+ * What a device may report. `dropped` is deliberately absent: deciding that work
28
+ * should not be done is a judgement, and a device has none.
29
+ */
30
+ type NodeReportState = 'progress' | 'ready' | 'blocked' | 'failed';
31
+ interface Base {
32
+ v: number;
33
+ frameId: string;
34
+ }
35
+ /** First frame a device sends on every connection; it states what it is running so the hub can align, rather than guess. */
36
+ interface HelloFrame extends Base {
37
+ type: 'hello';
38
+ deviceId: string;
39
+ token: string;
40
+ version: string;
41
+ roots: Root[];
42
+ running: string[];
43
+ }
44
+ interface HeartbeatFrame extends Base {
45
+ type: 'heartbeat';
46
+ deviceId: string;
47
+ running: number;
48
+ }
49
+ /** A task for the device. `text` is finished on the hub side: no resource pointer, no hub path. */
50
+ interface AssignFrame extends Base {
51
+ type: 'assign';
52
+ assignmentId: string;
53
+ ticketId: string;
54
+ /** An absolute path on the device, already mapped through its declared roots. */
55
+ directory: string;
56
+ text: string;
57
+ attachments: FrameAttachment[];
58
+ permissionPreset: string;
59
+ }
60
+ interface InputFrame extends Base {
61
+ type: 'input';
62
+ assignmentId: string;
63
+ text: string;
64
+ attachments: FrameAttachment[];
65
+ }
66
+ interface StopFrame extends Base {
67
+ type: 'stop';
68
+ assignmentId: string;
69
+ reason: string;
70
+ }
71
+ interface AcceptedFrame extends Base {
72
+ type: 'accepted';
73
+ assignmentId: string;
74
+ }
75
+ interface ReportFrame extends Base {
76
+ type: 'report';
77
+ assignmentId: string;
78
+ state: NodeReportState;
79
+ summary: string;
80
+ question?: string;
81
+ }
82
+ interface AckFrame extends Base {
83
+ type: 'ack';
84
+ /** The `frameId` of the frame being acknowledged. */
85
+ of: string;
86
+ }
87
+ /** Sent by the hub before it closes a connection it refuses, so the device can log why. */
88
+ interface ErrorFrame extends Base {
89
+ type: 'error';
90
+ reason: string;
91
+ }
92
+ type Frame = HelloFrame | HeartbeatFrame | AssignFrame | InputFrame | StopFrame | AcceptedFrame | ReportFrame | AckFrame | ErrorFrame;
93
+ /** A frame the receiver cannot accept: wrong version, unknown type, malformed JSON, or over the size cap. */
94
+ declare class FrameError extends Error {
95
+ constructor(message: string);
96
+ }
97
+ declare function encodeFrame(frame: Frame): string;
98
+ /**
99
+ * Parses one frame, refusing anything this build cannot honour.
100
+ * @throws FrameError for oversized text, malformed JSON, another protocol version, or an unknown type.
101
+ */
102
+ declare function parseFrame(text: string): Frame;
103
+ /** Remembers the frame ids a peer has already acted on, so a retried frame does nothing twice. */
104
+ declare class FrameDedupe {
105
+ private readonly seen;
106
+ constructor(seen?: Set<string>);
107
+ /** Returns true the first time a frame id is seen, false for a repeat. */
108
+ take(frameId: string): boolean;
109
+ }
110
+ export { AcceptedFrame, AckFrame, AssignFrame, ErrorFrame, FOREMAN_PROTOCOL_VERSION, Frame, FrameAttachment, FrameDedupe, FrameError, HeartbeatFrame, HelloFrame, InputFrame, MAX_FRAME_BYTES, NodeReportState, ReportFrame, Root, StopFrame, encodeFrame, parseFrame };