dsh-foreman-node 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/LICENSE +21 -0
- package/README.md +78 -2
- package/cordis.patch.yml +5 -0
- package/lib/index.d.ts +235 -0
- package/lib/index.js +601 -0
- package/lib/protocol.d.ts +110 -0
- package/lib/protocol.js +69 -0
- package/package.json +94 -4
- package/src/client.ts +209 -0
- package/src/index.ts +159 -0
- package/src/protocol.ts +160 -0
- package/src/report-tool.ts +93 -0
- package/src/session.ts +156 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,601 @@
|
|
|
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
|
+
* Everything is optional on purpose. A bundle patch inserts this row the moment the package is added to a profile,
|
|
481
|
+
* so `apply` runs before anyone has filled the values in — and a schema that demanded them would turn the whole
|
|
482
|
+
* profile red with "invalid config" on a fresh install. `apply` treats a blank address as "not configured yet":
|
|
483
|
+
* it says what is missing and stops, instead of dialling nowhere or refusing to activate.
|
|
484
|
+
*
|
|
485
|
+
* The annotation names the schema type through the global `Schemastery` namespace rather than letting it be inferred:
|
|
486
|
+
* an inferred type here names a `cosmokit` symbol the emitted declaration cannot reference portably.
|
|
487
|
+
*/
|
|
488
|
+
const Config = s.object({
|
|
489
|
+
url: s.string().default("").description("The hub's address, such as wss://foreman.example.com. The device dials out to it."),
|
|
490
|
+
deviceId: s.string().default("").description("This device's id, as registered on the hub with `Devices.add`."),
|
|
491
|
+
token: s.string().default("").role("secret").description("This device's token; it is sent in the hello frame and nowhere else."),
|
|
492
|
+
roots: s.array(s.object({
|
|
493
|
+
project: s.string().required(),
|
|
494
|
+
path: s.string().required()
|
|
495
|
+
})).default([]).description("Projects this device can work on: a logical name and this machine's own absolute path, kept verbatim."),
|
|
496
|
+
heartbeatMs: s.natural().default(15e3).description("How often to tell the hub this device is alive, in milliseconds."),
|
|
497
|
+
reconnectMs: s.natural().default(1e3).description("Delay before the first reconnect attempt, in milliseconds.")
|
|
498
|
+
});
|
|
499
|
+
/**
|
|
500
|
+
* What this configuration still needs before the device can dial its hub, or undefined when it is complete.
|
|
501
|
+
*
|
|
502
|
+
* A missing value is reported by name so an operator can fill it in without reading the source; a value that is only
|
|
503
|
+
* whitespace counts as missing, because a device that dials a blank address retries forever and looks merely offline.
|
|
504
|
+
*/
|
|
505
|
+
function missingConfiguration(config) {
|
|
506
|
+
const missing = [];
|
|
507
|
+
if (config.url.trim() === "") missing.push("url");
|
|
508
|
+
if (config.deviceId.trim() === "") missing.push("deviceId");
|
|
509
|
+
if (config.token.trim() === "") missing.push("token");
|
|
510
|
+
if (missing.length === 0) return void 0;
|
|
511
|
+
return `dsh-foreman-node is not configured yet: ${missing.join(", ")} missing. Set them on the dsh-foreman-node row in the profile patch (the hub prints the token when you register the device).`;
|
|
512
|
+
}
|
|
513
|
+
const name = "dsh-foreman-node";
|
|
514
|
+
/** DSH services a job needs; all are the host's own, and none is a foreman one. */
|
|
515
|
+
const inject = [
|
|
516
|
+
"sessionController",
|
|
517
|
+
"sessionPersistence",
|
|
518
|
+
"workspaceRegistry",
|
|
519
|
+
"permissionPresets",
|
|
520
|
+
"tools",
|
|
521
|
+
"agents"
|
|
522
|
+
];
|
|
523
|
+
/**
|
|
524
|
+
* Start the device.
|
|
525
|
+
*
|
|
526
|
+
* A package that has just been installed has its row in the profile patch with no values in it yet. Activating
|
|
527
|
+
* cleanly and saying what is still missing keeps the whole profile healthy; throwing here is what made a fresh
|
|
528
|
+
* install report the profile as broken.
|
|
529
|
+
*
|
|
530
|
+
* The plugin returns once the connection is attempted, not once it succeeds: a device must boot and stay usable
|
|
531
|
+
* while its hub is unreachable, and the client keeps retrying in the background.
|
|
532
|
+
*/
|
|
533
|
+
async function apply(ctx, config) {
|
|
534
|
+
const missing = missingConfiguration(config);
|
|
535
|
+
if (missing !== void 0) {
|
|
536
|
+
ctx.logger.info("%s", missing);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
const jobs = new JobSessions(ctx);
|
|
540
|
+
const client = new NodeClient({
|
|
541
|
+
url: config.url,
|
|
542
|
+
deviceId: config.deviceId,
|
|
543
|
+
token: config.token,
|
|
544
|
+
version: VERSION,
|
|
545
|
+
roots: () => config.roots.map((root) => ({
|
|
546
|
+
project: root.project,
|
|
547
|
+
path: root.path
|
|
548
|
+
})),
|
|
549
|
+
running: () => jobs.running(),
|
|
550
|
+
heartbeatMs: config.heartbeatMs,
|
|
551
|
+
reconnectMs: config.reconnectMs,
|
|
552
|
+
log: (text) => ctx.logger.warn("%s", text),
|
|
553
|
+
onFrame: (frame) => {
|
|
554
|
+
receive(ctx, jobs, client, frame);
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
client.start();
|
|
558
|
+
ctx.effect(() => () => {
|
|
559
|
+
client.stop();
|
|
560
|
+
});
|
|
561
|
+
ctx.logger.info("dsh-foreman-node %s is dialling %s as %s", VERSION, config.url, config.deviceId);
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Handle one frame from the hub.
|
|
565
|
+
*
|
|
566
|
+
* `assign` creates or reuses the job Session and delivers the task; `input` steers more text into the same Session;
|
|
567
|
+
* `stop` ends the job. Each is acknowledged so the hub stops resending it, and each is idempotent on this side, so a
|
|
568
|
+
* redelivery after a dropped ack does no harm.
|
|
569
|
+
*/
|
|
570
|
+
async function receive(ctx, jobs, client, frame) {
|
|
571
|
+
try {
|
|
572
|
+
if (frame.type === "assign") {
|
|
573
|
+
const sessionId = await jobs.ensure({
|
|
574
|
+
assignmentId: frame.assignmentId,
|
|
575
|
+
directory: frame.directory,
|
|
576
|
+
title: `job ${frame.assignmentId}`
|
|
577
|
+
}, (assignmentId, report) => client.report(assignmentId, report));
|
|
578
|
+
await jobs.deliver(sessionId, frame.text, frame.frameId);
|
|
579
|
+
client.accepted(frame.assignmentId);
|
|
580
|
+
client.ack(frame);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
if (frame.type === "input") {
|
|
584
|
+
const sessionId = `job-${frame.assignmentId}`;
|
|
585
|
+
await jobs.deliver(sessionId, frame.text, frame.frameId);
|
|
586
|
+
client.ack(frame);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
if (frame.type === "stop") {
|
|
590
|
+
jobs.forget(frame.assignmentId);
|
|
591
|
+
client.ack(frame);
|
|
592
|
+
}
|
|
593
|
+
} catch (error) {
|
|
594
|
+
ctx.logger.warn("dsh-foreman-node could not apply a %s frame: %s", frame.type, String(error));
|
|
595
|
+
if (frame.type === "assign" || frame.type === "input") client.report(frame.assignmentId, {
|
|
596
|
+
state: "blocked",
|
|
597
|
+
summary: `this device could not start the job: ${String(error)}`
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
export { Config, JobSessions, NodeClient, REPORT_STATES, VERSION, apply, foremanReportTool, inject, missingConfiguration, name };
|