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
|
@@ -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 };
|
package/lib/protocol.js
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
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,159 @@
|
|
|
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. Blank until someone fills it in. */
|
|
28
|
+
url: string
|
|
29
|
+
/** This device's id, as registered on the hub with `Devices.add`. Blank until someone fills it in. */
|
|
30
|
+
deviceId: string
|
|
31
|
+
/** This device's token; sent in the hello and nowhere else. Blank until someone fills it in. */
|
|
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
|
+
* Everything is optional on purpose. A bundle patch inserts this row the moment the package is added to a profile,
|
|
45
|
+
* so `apply` runs before anyone has filled the values in — and a schema that demanded them would turn the whole
|
|
46
|
+
* profile red with "invalid config" on a fresh install. `apply` treats a blank address as "not configured yet":
|
|
47
|
+
* it says what is missing and stops, instead of dialling nowhere or refusing to activate.
|
|
48
|
+
*
|
|
49
|
+
* The annotation names the schema type through the global `Schemastery` namespace rather than letting it be inferred:
|
|
50
|
+
* an inferred type here names a `cosmokit` symbol the emitted declaration cannot reference portably.
|
|
51
|
+
*/
|
|
52
|
+
export const Config: Schemastery<NodeConfig> = s.object({
|
|
53
|
+
url: s.string().default('').description('The hub\'s address, such as wss://foreman.example.com. The device dials out to it.'),
|
|
54
|
+
deviceId: s.string().default('').description('This device\'s id, as registered on the hub with `Devices.add`.'),
|
|
55
|
+
token: s.string().default('').role('secret').description('This device\'s token; it is sent in the hello frame and nowhere else.'),
|
|
56
|
+
roots: s.array(s.object({ project: s.string().required(), path: s.string().required() })).default([])
|
|
57
|
+
.description('Projects this device can work on: a logical name and this machine\'s own absolute path, kept verbatim.'),
|
|
58
|
+
heartbeatMs: s.natural().default(15_000).description('How often to tell the hub this device is alive, in milliseconds.'),
|
|
59
|
+
reconnectMs: s.natural().default(1_000).description('Delay before the first reconnect attempt, in milliseconds.'),
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* What this configuration still needs before the device can dial its hub, or undefined when it is complete.
|
|
64
|
+
*
|
|
65
|
+
* A missing value is reported by name so an operator can fill it in without reading the source; a value that is only
|
|
66
|
+
* whitespace counts as missing, because a device that dials a blank address retries forever and looks merely offline.
|
|
67
|
+
*/
|
|
68
|
+
export function missingConfiguration(config: NodeConfig): string | undefined {
|
|
69
|
+
const missing: string[] = []
|
|
70
|
+
if (config.url.trim() === '') missing.push('url')
|
|
71
|
+
if (config.deviceId.trim() === '') missing.push('deviceId')
|
|
72
|
+
if (config.token.trim() === '') missing.push('token')
|
|
73
|
+
if (missing.length === 0) return undefined
|
|
74
|
+
return `dsh-foreman-node is not configured yet: ${missing.join(', ')} ` +
|
|
75
|
+
`missing. Set them on the dsh-foreman-node row in the profile patch (the hub prints the token when you register the device).`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const name = 'dsh-foreman-node'
|
|
79
|
+
/** DSH services a job needs; all are the host's own, and none is a foreman one. */
|
|
80
|
+
export const inject = ['sessionController', 'sessionPersistence', 'workspaceRegistry', 'permissionPresets', 'tools', 'agents']
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Start the device.
|
|
84
|
+
*
|
|
85
|
+
* A package that has just been installed has its row in the profile patch with no values in it yet. Activating
|
|
86
|
+
* cleanly and saying what is still missing keeps the whole profile healthy; throwing here is what made a fresh
|
|
87
|
+
* install report the profile as broken.
|
|
88
|
+
*
|
|
89
|
+
* The plugin returns once the connection is attempted, not once it succeeds: a device must boot and stay usable
|
|
90
|
+
* while its hub is unreachable, and the client keeps retrying in the background.
|
|
91
|
+
*/
|
|
92
|
+
export async function apply(ctx: Context, config: NodeConfig): Promise<void> {
|
|
93
|
+
const missing = missingConfiguration(config)
|
|
94
|
+
if (missing !== undefined) {
|
|
95
|
+
ctx.logger.info('%s', missing)
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
const jobs = new JobSessions(ctx)
|
|
99
|
+
const client = new NodeClient({
|
|
100
|
+
url: config.url,
|
|
101
|
+
deviceId: config.deviceId,
|
|
102
|
+
token: config.token,
|
|
103
|
+
version: VERSION,
|
|
104
|
+
roots: () => config.roots.map(root => ({ project: root.project, path: root.path })),
|
|
105
|
+
running: () => jobs.running(),
|
|
106
|
+
heartbeatMs: config.heartbeatMs,
|
|
107
|
+
reconnectMs: config.reconnectMs,
|
|
108
|
+
log: text => ctx.logger.warn('%s', text),
|
|
109
|
+
onFrame: frame => { void receive(ctx, jobs, client, frame) },
|
|
110
|
+
})
|
|
111
|
+
client.start()
|
|
112
|
+
ctx.effect(() => () => { client.stop() })
|
|
113
|
+
ctx.logger.info('dsh-foreman-node %s is dialling %s as %s', VERSION, config.url, config.deviceId)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Handle one frame from the hub.
|
|
118
|
+
*
|
|
119
|
+
* `assign` creates or reuses the job Session and delivers the task; `input` steers more text into the same Session;
|
|
120
|
+
* `stop` ends the job. Each is acknowledged so the hub stops resending it, and each is idempotent on this side, so a
|
|
121
|
+
* redelivery after a dropped ack does no harm.
|
|
122
|
+
*/
|
|
123
|
+
async function receive(ctx: Context, jobs: JobSessions, client: NodeClient, frame: Frame): Promise<void> {
|
|
124
|
+
try {
|
|
125
|
+
if (frame.type === 'assign') {
|
|
126
|
+
// The report tool is attached by `ensure`, which is why it needs the client: a job that cannot report back
|
|
127
|
+
// would leave the hub waiting with no way to learn what happened.
|
|
128
|
+
const sessionId = await jobs.ensure({ assignmentId: frame.assignmentId, directory: frame.directory, title: `job ${frame.assignmentId}` },
|
|
129
|
+
(assignmentId, report) => client.report(assignmentId, report))
|
|
130
|
+
await jobs.deliver(sessionId, frame.text, frame.frameId)
|
|
131
|
+
client.accepted(frame.assignmentId)
|
|
132
|
+
client.ack(frame)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
if (frame.type === 'input') {
|
|
136
|
+
const sessionId = `job-${frame.assignmentId}`
|
|
137
|
+
await jobs.deliver(sessionId, frame.text, frame.frameId)
|
|
138
|
+
client.ack(frame)
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
if (frame.type === 'stop') {
|
|
142
|
+
// The job's Session is left in place: a person may still want to read what it did.
|
|
143
|
+
jobs.forget(frame.assignmentId)
|
|
144
|
+
client.ack(frame)
|
|
145
|
+
}
|
|
146
|
+
// Heartbeats and acks need no answer; a hello is the hub's own, which this side never sends.
|
|
147
|
+
} catch (error) {
|
|
148
|
+
// A frame that cannot be applied is reported blocked rather than swallowed: silence would leave the hub waiting.
|
|
149
|
+
ctx.logger.warn('dsh-foreman-node could not apply a %s frame: %s', frame.type, String(error))
|
|
150
|
+
if (frame.type === 'assign' || frame.type === 'input') {
|
|
151
|
+
client.report(frame.assignmentId, { state: 'blocked', summary: `this device could not start the job: ${String(error)}` })
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export { NodeClient } from './client.ts'
|
|
157
|
+
export { JobSessions } from './session.ts'
|
|
158
|
+
export { foremanReportTool, REPORT_STATES } from './report-tool.ts'
|
|
159
|
+
export type { Frame, Root, FrameAttachment } from './protocol.ts'
|