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.
@@ -0,0 +1,69 @@
1
+ // src/protocol.ts
2
+ /**
3
+ * The foreman protocol: one JSON frame per WebSocket text message, versioned by `v`.
4
+ *
5
+ * This file is the only definition of a frame. `dsh-foreman` (the hub) depends on
6
+ * this package and imports it, so the two sides cannot drift; nothing here may
7
+ * import a DSH service, the core domain, or a database.
8
+ */
9
+ /** Bumped whenever a frame's shape changes incompatibly; a peer on another version is refused at handshake. */
10
+ const FOREMAN_PROTOCOL_VERSION = 1;
11
+ /** Largest frame accepted, in bytes of UTF-8. A larger one is refused, never truncated. */
12
+ const MAX_FRAME_BYTES = 262144;
13
+ /** A frame the receiver cannot accept: wrong version, unknown type, malformed JSON, or over the size cap. */
14
+ var FrameError = class extends Error {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = "FrameError";
18
+ }
19
+ };
20
+ const TYPES = /* @__PURE__ */ new Set([
21
+ "hello",
22
+ "heartbeat",
23
+ "assign",
24
+ "input",
25
+ "stop",
26
+ "accepted",
27
+ "report",
28
+ "ack",
29
+ "error"
30
+ ]);
31
+ function encodeFrame(frame) {
32
+ return JSON.stringify(frame);
33
+ }
34
+ /**
35
+ * Parses one frame, refusing anything this build cannot honour.
36
+ * @throws FrameError for oversized text, malformed JSON, another protocol version, or an unknown type.
37
+ */
38
+ function parseFrame(text) {
39
+ const size = Buffer.byteLength(text, "utf8");
40
+ if (size > 262144) throw new FrameError(`frame too large: ${size} bytes, limit ${MAX_FRAME_BYTES}`);
41
+ let value;
42
+ try {
43
+ value = JSON.parse(text);
44
+ } catch (error) {
45
+ throw new FrameError(`malformed frame: ${error instanceof Error ? error.message : String(error)}`);
46
+ }
47
+ if (typeof value !== "object" || value === null) throw new FrameError("a frame must be a JSON object");
48
+ const frame = value;
49
+ const version = frame["v"];
50
+ if (version !== 1) throw new FrameError(`unsupported protocol version ${String(version)}; this build speaks 1`);
51
+ const type = frame["type"];
52
+ if (typeof type !== "string" || !TYPES.has(type)) throw new FrameError(`unknown frame type ${String(type)}`);
53
+ if (typeof frame["frameId"] !== "string" || frame["frameId"] === "") throw new FrameError("a frame needs a frameId");
54
+ return frame;
55
+ }
56
+ /** Remembers the frame ids a peer has already acted on, so a retried frame does nothing twice. */
57
+ var FrameDedupe = class {
58
+ seen;
59
+ constructor(seen = /* @__PURE__ */ new Set()) {
60
+ this.seen = seen;
61
+ }
62
+ /** Returns true the first time a frame id is seen, false for a repeat. */
63
+ take(frameId) {
64
+ if (this.seen.has(frameId)) return false;
65
+ this.seen.add(frameId);
66
+ return true;
67
+ }
68
+ };
69
+ export { FOREMAN_PROTOCOL_VERSION, FrameDedupe, FrameError, MAX_FRAME_BYTES, encodeFrame, parseFrame };
package/package.json CHANGED
@@ -1,6 +1,96 @@
1
1
  {
2
2
  "name": "dsh-foreman-node",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Device side of dsh-foreman: connects out to a foreman hub, takes a job into a local DSH session, reports back. No ledger, no policy, no database.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "types": "lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "default": "./lib/index.js"
12
+ },
13
+ "./protocol": {
14
+ "types": "./lib/protocol.d.ts",
15
+ "default": "./lib/protocol.js"
16
+ },
17
+ "./cordis.patch.yml": "./cordis.patch.yml",
18
+ "./package.json": "./package.json"
19
+ },
20
+ "files": [
21
+ "lib",
22
+ "src",
23
+ "cordis.patch.yml",
24
+ "README.md",
25
+ "CHANGELOG.md",
26
+ "LICENSE"
27
+ ],
28
+ "engines": {
29
+ "node": "^22.19.0 || >=24.0.0"
30
+ },
31
+ "dsh": {
32
+ "bundle": {
33
+ "patch": "./cordis.patch.yml"
34
+ },
35
+ "engines": {
36
+ "dsh": "^0.2.1-alpha.2"
37
+ }
38
+ },
39
+ "scripts": {
40
+ "build": "tsdown",
41
+ "typecheck": "tsc --noEmit",
42
+ "test": "vitest run",
43
+ "check": "pnpm typecheck && pnpm test && pnpm build"
44
+ },
45
+ "devDependencies": {
46
+ "@deepseek-ai/cordis": "4.0.5-alpha.1",
47
+ "@deepseek-ai/cosmokit": "1.8.5",
48
+ "@deepseek-ai/dsh-agent": "0.2.1-alpha.2",
49
+ "@deepseek-ai/dsh-api-session-controller": "0.2.1-alpha.2",
50
+ "@deepseek-ai/dsh-llm": "0.2.1-alpha.2",
51
+ "@deepseek-ai/dsh-permission-presets": "0.2.1-alpha.2",
52
+ "@deepseek-ai/dsh-session": "0.2.1-alpha.2",
53
+ "@deepseek-ai/dsh-session-persistence": "0.2.1-alpha.2",
54
+ "@deepseek-ai/dsh-tools": "0.2.1-alpha.2",
55
+ "@deepseek-ai/dsh-workspace": "0.2.1-alpha.2",
56
+ "@deepseek-ai/schemastery": "3.18.5-alpha.1",
57
+ "@types/node": "22.20.0",
58
+ "tsdown": "0.22.14",
59
+ "typescript": "6.0.3",
60
+ "vitest": "4.1.8"
61
+ },
62
+ "peerDependencies": {
63
+ "@deepseek-ai/cordis": "4.0.5-alpha.1",
64
+ "@deepseek-ai/cosmokit": "1.8.5",
65
+ "@deepseek-ai/dsh-agent": "^0.2.1-alpha.2",
66
+ "@deepseek-ai/dsh-api-session-controller": "^0.2.1-alpha.2",
67
+ "@deepseek-ai/dsh-llm": "^0.2.1-alpha.2",
68
+ "@deepseek-ai/dsh-permission-presets": "^0.2.1-alpha.2",
69
+ "@deepseek-ai/dsh-session": "^0.2.1-alpha.2",
70
+ "@deepseek-ai/dsh-session-persistence": "^0.2.1-alpha.2",
71
+ "@deepseek-ai/dsh-tools": "^0.2.1-alpha.2",
72
+ "@deepseek-ai/dsh-workspace": "^0.2.1-alpha.2",
73
+ "@deepseek-ai/schemastery": "3.18.5-alpha.1"
74
+ },
75
+ "license": "MIT",
76
+ "author": "niechao136",
77
+ "repository": {
78
+ "type": "git",
79
+ "url": "git+https://github.com/niechao136/dsh-foreman-node.git"
80
+ },
81
+ "homepage": "https://github.com/niechao136/dsh-foreman-node#readme",
82
+ "bugs": {
83
+ "url": "https://github.com/niechao136/dsh-foreman-node/issues"
84
+ },
85
+ "keywords": [
86
+ "dsh",
87
+ "dsh-plugin",
88
+ "deepseek-harness",
89
+ "foreman",
90
+ "worker",
91
+ "device"
92
+ ],
93
+ "publishConfig": {
94
+ "access": "public"
95
+ }
96
+ }
package/src/client.ts ADDED
@@ -0,0 +1,209 @@
1
+ /**
2
+ * The device's outbound connection to its hub.
3
+ *
4
+ * The direction is deliberate: a device dials out, so a machine behind NAT or a company firewall can take work. The
5
+ * connection is never the device's to command — it reports what it holds, sends frames the hub asked for, and
6
+ * reconnects with exponential backoff on its own.
7
+ *
8
+ * A frame is never built here by hand: the protocol module owns every shape, so the device and the hub cannot drift.
9
+ * Nothing in this file writes a ledger, reads a policy, or opens a database — a device has none of those.
10
+ */
11
+ import { FOREMAN_PROTOCOL_VERSION, FrameDedupe, encodeFrame, parseFrame, type Frame, type HeartbeatFrame, type HelloFrame,
12
+ type ReportFrame, type Root, type AcceptedFrame, type AckFrame } from './protocol.ts'
13
+
14
+ /** What a socket has to offer; the global `WebSocket` satisfies it, and tests supply an object that does too. */
15
+ export interface SocketLike {
16
+ readyState: number
17
+ send(data: string): void
18
+ close(): void
19
+ addEventListener(type: 'open' | 'close' | 'error', listener: () => void): void
20
+ addEventListener(type: 'message', listener: (event: { data: unknown }) => void): void
21
+ }
22
+
23
+ /** Opens a socket; injectable so tests never touch the network. */
24
+ export type Connect = (url: string) => SocketLike
25
+
26
+ export interface NodeClientInput {
27
+ /** The hub's stable address, such as `wss://foreman.example.com`. */
28
+ url: string
29
+ deviceId: string
30
+ /** The device token; sent in the hello and nowhere else. */
31
+ token: string
32
+ version: string
33
+ /** The projects this device can work on, as its own absolute paths. Declared once and re-declared on every hello. */
34
+ roots: () => Root[]
35
+ /** The assignments this device currently holds. Reported on every hello so the hub aligns instead of guessing. */
36
+ running: () => string[]
37
+ /** Handles frames the hub sends. */
38
+ onFrame: (frame: Frame) => void
39
+ /** Milliseconds before the first reconnect; doubles up to `maxReconnectMs`. */
40
+ reconnectMs?: number
41
+ maxReconnectMs?: number
42
+ connect?: Connect
43
+ /** The heartbeat interval; a device that says nothing looks offline to the hub, which turns its work blocked. */
44
+ heartbeatMs?: number
45
+ log?: (text: string) => void
46
+ /** Frame ids the device has already acted on, so a redelivered frame does nothing twice. */
47
+ dedupe?: FrameDedupe
48
+ }
49
+
50
+ const OPEN = 1
51
+
52
+ /** A counter for frame ids; unique within one connection is enough, since the hub drops repeats by id. */
53
+ let counter = 0
54
+ /** A fresh frame id. Time-based so two processes do not collide, with a counter so two frames in one millisecond differ. */
55
+ export const nextFrameId = (prefix: string): string => `${prefix}:${Date.now().toString(36)}:${(counter++).toString(36)}`
56
+
57
+ /**
58
+ * Keeps one connection to the hub open, reconnecting with backoff.
59
+ *
60
+ * The class owns the socket's lifecycle and nothing else: what a frame means is the plugin's business, and what
61
+ * happens when the connection is refused is the hub's. `start` and `stop` are idempotent.
62
+ */
63
+ export class NodeClient {
64
+ private socket?: SocketLike
65
+ private stopped = false
66
+ private reconnectTimer?: ReturnType<typeof setTimeout>
67
+ private heartbeatTimer?: ReturnType<typeof setInterval>
68
+ private delay: number
69
+ private readonly dedupe: FrameDedupe
70
+ private readonly connect: Connect
71
+ private readonly log: (text: string) => void
72
+
73
+ constructor(private readonly input: NodeClientInput) {
74
+ this.delay = input.reconnectMs ?? 1_000
75
+ this.dedupe = input.dedupe ?? new FrameDedupe(new Set())
76
+ this.connect = input.connect ?? (url => new WebSocket(url) as unknown as SocketLike)
77
+ this.log = input.log ?? (() => {})
78
+ }
79
+
80
+ /** Open the connection, or do nothing if it is already open or opening. */
81
+ start(): void {
82
+ if (this.stopped || this.socket !== undefined) return
83
+ this.open()
84
+ }
85
+
86
+ /** Close the connection and stop reconnecting; a late socket event does nothing. */
87
+ stop(): void {
88
+ this.stopped = true
89
+ if (this.reconnectTimer !== undefined) clearTimeout(this.reconnectTimer)
90
+ if (this.heartbeatTimer !== undefined) clearInterval(this.heartbeatTimer)
91
+ this.socket?.close()
92
+ this.socket = undefined
93
+ }
94
+
95
+ /** Send a frame now if the connection is open. Returns whether it went out, so a caller can retry later. */
96
+ send(frame: Frame): boolean {
97
+ if (this.socket === undefined || this.socket.readyState !== OPEN) return false
98
+ try {
99
+ this.socket.send(encodeFrame(frame))
100
+ return true
101
+ } catch (error) {
102
+ this.log(`dsh-foreman-node could not send ${frame.type}: ${String(error)}`)
103
+ return false
104
+ }
105
+ }
106
+
107
+ /** Report an outcome on an assignment, which is the only way a hub learns what a job did. */
108
+ report(assignmentId: string, report: { state: 'progress' | 'ready' | 'blocked' | 'failed'; summary: string; question?: string }): void {
109
+ const frame: ReportFrame = { v: FOREMAN_PROTOCOL_VERSION, type: 'report', frameId: nextFrameId('report'), assignmentId,
110
+ state: report.state, summary: report.summary, ...(report.question === undefined ? {} : { question: report.question }) }
111
+ this.send(frame)
112
+ }
113
+
114
+ /** Whether the connection is open right now. */
115
+ connected(): boolean { return this.socket !== undefined && this.socket.readyState === OPEN }
116
+
117
+ private open(): void {
118
+ let socket: SocketLike
119
+ try {
120
+ socket = this.connect(this.input.url)
121
+ } catch (error) {
122
+ this.log(`dsh-foreman-node could not open ${this.input.url}: ${String(error)}`)
123
+ this.scheduleReconnect()
124
+ return
125
+ }
126
+ this.socket = socket
127
+ socket.addEventListener('open', () => {
128
+ if (this.stopped) return
129
+ this.delay = this.input.reconnectMs ?? 1_000
130
+ this.hello()
131
+ this.startHeartbeat()
132
+ })
133
+ socket.addEventListener('message', event => {
134
+ if (this.stopped) return
135
+ const text = typeof event.data === 'string' ? event.data : String(event.data)
136
+ this.receive(text)
137
+ })
138
+ socket.addEventListener('error', () => {
139
+ // A socket error is followed by a close; reconnecting there keeps one path.
140
+ this.log('dsh-foreman-node connection error')
141
+ })
142
+ socket.addEventListener('close', () => {
143
+ if (this.stopped) return
144
+ if (this.heartbeatTimer !== undefined) clearInterval(this.heartbeatTimer)
145
+ this.socket = undefined
146
+ this.scheduleReconnect()
147
+ })
148
+ }
149
+
150
+ private hello(): void {
151
+ const frame: HelloFrame = { v: FOREMAN_PROTOCOL_VERSION, type: 'hello', frameId: nextFrameId('hello'), deviceId: this.input.deviceId,
152
+ token: this.input.token, version: this.input.version, roots: this.input.roots(), running: this.input.running() }
153
+ this.send(frame)
154
+ }
155
+
156
+ private startHeartbeat(): void {
157
+ if (this.heartbeatTimer !== undefined) clearInterval(this.heartbeatTimer)
158
+ this.heartbeatTimer = setInterval(() => {
159
+ const frame: HeartbeatFrame = { v: FOREMAN_PROTOCOL_VERSION, type: 'heartbeat', frameId: nextFrameId('heartbeat'),
160
+ deviceId: this.input.deviceId, running: this.input.running().length }
161
+ this.send(frame)
162
+ }, this.input.heartbeatMs ?? 15_000)
163
+ this.heartbeatTimer.unref?.()
164
+ }
165
+
166
+ private receive(text: string): void {
167
+ let frame: Frame
168
+ try {
169
+ frame = parseFrame(text)
170
+ } catch (error) {
171
+ // A frame this build cannot read is a hub speaking another protocol: say so in the log and keep the socket,
172
+ // because a dropped connection would only make the same misreading happen again.
173
+ this.log(`dsh-foreman-node refused a frame: ${String(error)}`)
174
+ return
175
+ }
176
+ if (!this.dedupe.take(frame.frameId)) return
177
+ if (frame.type === 'ack') return
178
+ if (frame.type === 'error') {
179
+ this.log(`dsh-foreman-node was refused by the hub: ${frame.reason}`)
180
+ return
181
+ }
182
+ this.input.onFrame(frame)
183
+ }
184
+
185
+ /** Acknowledge a frame once its side effect is done, so the hub can stop resending it. */
186
+ ack(frame: Frame): void {
187
+ const ack: AckFrame = { v: FOREMAN_PROTOCOL_VERSION, type: 'ack', frameId: nextFrameId('ack'), of: frame.frameId }
188
+ this.send(ack)
189
+ }
190
+
191
+ /** Tell the hub a frame's work reached its Session, which is what lets it mark the assignment started. */
192
+ accepted(assignmentId: string): void {
193
+ const frame: AcceptedFrame = { v: FOREMAN_PROTOCOL_VERSION, type: 'accepted', frameId: nextFrameId('accepted'), assignmentId }
194
+ this.send(frame)
195
+ }
196
+
197
+ private scheduleReconnect(): void {
198
+ if (this.stopped) return
199
+ const wait = this.delay
200
+ // Back off exponentially, but never past the cap: a hub down for an hour must not delay a reconnect by an hour.
201
+ this.delay = Math.min(this.delay * 2, this.input.maxReconnectMs ?? 60_000)
202
+ this.reconnectTimer = setTimeout(() => {
203
+ this.reconnectTimer = undefined
204
+ if (!this.stopped) this.open()
205
+ }, wait)
206
+ this.reconnectTimer.unref?.()
207
+ this.log(`dsh-foreman-node reconnecting in ${wait} ms`)
208
+ }
209
+ }
package/src/index.ts ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * The device-side plugin entry.
3
+ *
4
+ * It is deliberately thin: one connection out to the hub, one job Session per assignment, one report tool per job.
5
+ * It imports no domain core, opens no database, and reads no policy, because a device holds no ledger — the hub is
6
+ * the only place work is recorded, and this side's job is to do it and say what happened.
7
+ */
8
+ import type { Context } from '@deepseek-ai/cordis'
9
+ import s from '@deepseek-ai/schemastery'
10
+ import type {} from '@deepseek-ai/dsh-api-session-controller'
11
+ import type {} from '@deepseek-ai/dsh-session-persistence'
12
+ import type {} from '@deepseek-ai/dsh-workspace'
13
+ import type {} from '@deepseek-ai/dsh-permission-presets'
14
+ import type {} from '@deepseek-ai/dsh-tools'
15
+ import { NodeClient } from './client.ts'
16
+ import { JobSessions } from './session.ts'
17
+ import type { Frame } from './protocol.ts'
18
+
19
+ /** Version reported in the hello, so the hub can tell which device build is talking. */
20
+ export const VERSION = '0.1.0'
21
+
22
+ /** One project this device can work on: the hub's logical name and this machine's own absolute path. */
23
+ export interface NodeRoot { project: string; path: string }
24
+
25
+ /** What the `dsh-foreman-node` entry in a profile's patch configures. */
26
+ export interface NodeConfig {
27
+ /** The hub's stable address; the device dials out to it. */
28
+ url: string
29
+ /** This device's id, as registered on the hub with `Devices.add`. */
30
+ deviceId: string
31
+ /** This device's token; sent in the hello and nowhere else. */
32
+ token: string
33
+ /** The projects this device can work on. */
34
+ roots: NodeRoot[]
35
+ /** How often to tell the hub this device is alive, in milliseconds. */
36
+ heartbeatMs: number
37
+ /** Delay before the first reconnect attempt, in milliseconds. */
38
+ reconnectMs: number
39
+ }
40
+
41
+ /**
42
+ * Configuration of one device, from the `dsh-foreman-node` entry in the profile's patch.
43
+ *
44
+ * The annotation names the schema type through the global `Schemastery` namespace rather than letting it be inferred:
45
+ * an inferred type here names a `cosmokit` symbol the emitted declaration cannot reference portably.
46
+ */
47
+ export const Config: Schemastery<NodeConfig> = s.object({
48
+ url: s.string().required().description('The hub\'s address, such as wss://foreman.example.com. The device dials out to it.'),
49
+ deviceId: s.string().required().description('This device\'s id, as registered on the hub with `Devices.add`.'),
50
+ token: s.string().required().role('secret').description('This device\'s token; it is sent in the hello frame and nowhere else.'),
51
+ roots: s.array(s.object({ project: s.string().required(), path: s.string().required() })).default([])
52
+ .description('Projects this device can work on: a logical name and this machine\'s own absolute path, kept verbatim.'),
53
+ heartbeatMs: s.natural().default(15_000).description('How often to tell the hub this device is alive, in milliseconds.'),
54
+ reconnectMs: s.natural().default(1_000).description('Delay before the first reconnect attempt, in milliseconds.'),
55
+ })
56
+
57
+ export const name = 'dsh-foreman-node'
58
+ /** DSH services a job needs; all are the host's own, and none is a foreman one. */
59
+ export const inject = ['sessionController', 'sessionPersistence', 'workspaceRegistry', 'permissionPresets', 'tools', 'agents']
60
+
61
+ /**
62
+ * Start the device.
63
+ *
64
+ * The plugin returns once the connection is attempted, not once it succeeds: a device must boot and stay usable
65
+ * while its hub is unreachable, and the client keeps retrying in the background.
66
+ */
67
+ export async function apply(ctx: Context, config: NodeConfig): Promise<void> {
68
+ const jobs = new JobSessions(ctx)
69
+ const client = new NodeClient({
70
+ url: config.url,
71
+ deviceId: config.deviceId,
72
+ token: config.token,
73
+ version: VERSION,
74
+ roots: () => config.roots.map(root => ({ project: root.project, path: root.path })),
75
+ running: () => jobs.running(),
76
+ heartbeatMs: config.heartbeatMs,
77
+ reconnectMs: config.reconnectMs,
78
+ log: text => ctx.logger.warn('%s', text),
79
+ onFrame: frame => { void receive(ctx, jobs, client, frame) },
80
+ })
81
+ client.start()
82
+ ctx.effect(() => () => { client.stop() })
83
+ ctx.logger.info('dsh-foreman-node %s is dialling %s as %s', VERSION, config.url, config.deviceId)
84
+ }
85
+
86
+ /**
87
+ * Handle one frame from the hub.
88
+ *
89
+ * `assign` creates or reuses the job Session and delivers the task; `input` steers more text into the same Session;
90
+ * `stop` ends the job. Each is acknowledged so the hub stops resending it, and each is idempotent on this side, so a
91
+ * redelivery after a dropped ack does no harm.
92
+ */
93
+ async function receive(ctx: Context, jobs: JobSessions, client: NodeClient, frame: Frame): Promise<void> {
94
+ try {
95
+ if (frame.type === 'assign') {
96
+ // The report tool is attached by `ensure`, which is why it needs the client: a job that cannot report back
97
+ // would leave the hub waiting with no way to learn what happened.
98
+ const sessionId = await jobs.ensure({ assignmentId: frame.assignmentId, directory: frame.directory, title: `job ${frame.assignmentId}` },
99
+ (assignmentId, report) => client.report(assignmentId, report))
100
+ await jobs.deliver(sessionId, frame.text, frame.frameId)
101
+ client.accepted(frame.assignmentId)
102
+ client.ack(frame)
103
+ return
104
+ }
105
+ if (frame.type === 'input') {
106
+ const sessionId = `job-${frame.assignmentId}`
107
+ await jobs.deliver(sessionId, frame.text, frame.frameId)
108
+ client.ack(frame)
109
+ return
110
+ }
111
+ if (frame.type === 'stop') {
112
+ // The job's Session is left in place: a person may still want to read what it did.
113
+ jobs.forget(frame.assignmentId)
114
+ client.ack(frame)
115
+ }
116
+ // Heartbeats and acks need no answer; a hello is the hub's own, which this side never sends.
117
+ } catch (error) {
118
+ // A frame that cannot be applied is reported blocked rather than swallowed: silence would leave the hub waiting.
119
+ ctx.logger.warn('dsh-foreman-node could not apply a %s frame: %s', frame.type, String(error))
120
+ if (frame.type === 'assign' || frame.type === 'input') {
121
+ client.report(frame.assignmentId, { state: 'blocked', summary: `this device could not start the job: ${String(error)}` })
122
+ }
123
+ }
124
+ }
125
+
126
+ export { NodeClient } from './client.ts'
127
+ export { JobSessions } from './session.ts'
128
+ export { foremanReportTool, REPORT_STATES } from './report-tool.ts'
129
+ export type { Frame, Root, FrameAttachment } from './protocol.ts'
@@ -0,0 +1,160 @@
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
+
9
+ /** Bumped whenever a frame's shape changes incompatibly; a peer on another version is refused at handshake. */
10
+ export const FOREMAN_PROTOCOL_VERSION = 1
11
+
12
+ /** Largest frame accepted, in bytes of UTF-8. A larger one is refused, never truncated. */
13
+ export const MAX_FRAME_BYTES = 256 * 1024
14
+
15
+ /** A project a device can work on: the hub's logical name and the device's own absolute path. */
16
+ export interface Root {
17
+ /** The project name both sides agree on; the hub never sees the device path before a job is assigned. */
18
+ project: string
19
+ /** An absolute path on the device. Kept verbatim, including spaces and non-ASCII characters. */
20
+ path: string
21
+ }
22
+
23
+ /** A file travelling with a task, base64-encoded. The device writes it where its job session can read it. */
24
+ export interface FrameAttachment {
25
+ id: string
26
+ mediaType?: string
27
+ name?: string
28
+ bytes: string
29
+ }
30
+
31
+ /**
32
+ * What a device may report. `dropped` is deliberately absent: deciding that work
33
+ * should not be done is a judgement, and a device has none.
34
+ */
35
+ export type NodeReportState = 'progress' | 'ready' | 'blocked' | 'failed'
36
+
37
+ interface Base {
38
+ v: number
39
+ frameId: string
40
+ }
41
+
42
+ /** First frame a device sends on every connection; it states what it is running so the hub can align, rather than guess. */
43
+ export interface HelloFrame extends Base {
44
+ type: 'hello'
45
+ deviceId: string
46
+ token: string
47
+ version: string
48
+ roots: Root[]
49
+ running: string[]
50
+ }
51
+
52
+ export interface HeartbeatFrame extends Base {
53
+ type: 'heartbeat'
54
+ deviceId: string
55
+ running: number
56
+ }
57
+
58
+ /** A task for the device. `text` is finished on the hub side: no resource pointer, no hub path. */
59
+ export interface AssignFrame extends Base {
60
+ type: 'assign'
61
+ assignmentId: string
62
+ ticketId: string
63
+ /** An absolute path on the device, already mapped through its declared roots. */
64
+ directory: string
65
+ text: string
66
+ attachments: FrameAttachment[]
67
+ permissionPreset: string
68
+ }
69
+
70
+ export interface InputFrame extends Base {
71
+ type: 'input'
72
+ assignmentId: string
73
+ text: string
74
+ attachments: FrameAttachment[]
75
+ }
76
+
77
+ export interface StopFrame extends Base {
78
+ type: 'stop'
79
+ assignmentId: string
80
+ reason: string
81
+ }
82
+
83
+ export interface AcceptedFrame extends Base {
84
+ type: 'accepted'
85
+ assignmentId: string
86
+ }
87
+
88
+ export interface ReportFrame extends Base {
89
+ type: 'report'
90
+ assignmentId: string
91
+ state: NodeReportState
92
+ summary: string
93
+ question?: string
94
+ }
95
+
96
+ export interface AckFrame extends Base {
97
+ type: 'ack'
98
+ /** The `frameId` of the frame being acknowledged. */
99
+ of: string
100
+ }
101
+
102
+ /** Sent by the hub before it closes a connection it refuses, so the device can log why. */
103
+ export interface ErrorFrame extends Base {
104
+ type: 'error'
105
+ reason: string
106
+ }
107
+
108
+ export type Frame =
109
+ | HelloFrame | HeartbeatFrame | AssignFrame | InputFrame | StopFrame
110
+ | AcceptedFrame | ReportFrame | AckFrame | ErrorFrame
111
+
112
+ /** A frame the receiver cannot accept: wrong version, unknown type, malformed JSON, or over the size cap. */
113
+ export class FrameError extends Error {
114
+ constructor(message: string) {
115
+ super(message)
116
+ this.name = 'FrameError'
117
+ }
118
+ }
119
+
120
+ const TYPES = new Set(['hello', 'heartbeat', 'assign', 'input', 'stop', 'accepted', 'report', 'ack', 'error'])
121
+
122
+ export function encodeFrame(frame: Frame): string {
123
+ return JSON.stringify(frame)
124
+ }
125
+
126
+ /**
127
+ * Parses one frame, refusing anything this build cannot honour.
128
+ * @throws FrameError for oversized text, malformed JSON, another protocol version, or an unknown type.
129
+ */
130
+ export function parseFrame(text: string): Frame {
131
+ const size = Buffer.byteLength(text, 'utf8')
132
+ if (size > MAX_FRAME_BYTES) throw new FrameError(`frame too large: ${size} bytes, limit ${MAX_FRAME_BYTES}`)
133
+ let value: unknown
134
+ try {
135
+ value = JSON.parse(text)
136
+ } catch (error) {
137
+ throw new FrameError(`malformed frame: ${error instanceof Error ? error.message : String(error)}`)
138
+ }
139
+ if (typeof value !== 'object' || value === null) throw new FrameError('a frame must be a JSON object')
140
+ const frame = value as Record<string, unknown>
141
+ const version = frame['v']
142
+ if (version !== FOREMAN_PROTOCOL_VERSION) {
143
+ throw new FrameError(`unsupported protocol version ${String(version)}; this build speaks ${FOREMAN_PROTOCOL_VERSION}`)
144
+ }
145
+ const type = frame['type']
146
+ if (typeof type !== 'string' || !TYPES.has(type)) throw new FrameError(`unknown frame type ${String(type)}`)
147
+ if (typeof frame['frameId'] !== 'string' || frame['frameId'] === '') throw new FrameError('a frame needs a frameId')
148
+ return frame as unknown as Frame
149
+ }
150
+
151
+ /** Remembers the frame ids a peer has already acted on, so a retried frame does nothing twice. */
152
+ export class FrameDedupe {
153
+ constructor(private readonly seen: Set<string> = new Set()) {}
154
+ /** Returns true the first time a frame id is seen, false for a repeat. */
155
+ take(frameId: string): boolean {
156
+ if (this.seen.has(frameId)) return false
157
+ this.seen.add(frameId)
158
+ return true
159
+ }
160
+ }