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.
@@ -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
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The `foreman_report` tool a device registers on a job Session.
3
+ *
4
+ * Reporting is a hard requirement rather than a convenience: in the domain, "waiting for a person" is a structured
5
+ * fact that a report carries, and inferring it from a turn ending is an explicitly rejected approach. So a device
6
+ * states outcomes through this tool and nothing else.
7
+ *
8
+ * `dropped` is not in its enum. Deciding that work should not be done is a judgement, a device has no judgement, and
9
+ * the hub's ledger already refuses that state from a device (see the protocol). A device that believes the same thing
10
+ * must report `blocked` and let a person decide.
11
+ *
12
+ * The schema is written as JSON Schema and validated here rather than through `zod`, so this package stays free of
13
+ * runtime dependencies beyond the DSH services it is a peer plugin of.
14
+ */
15
+
16
+ /** What a device may report; the same set the protocol's `NodeReportState` names. */
17
+ export const REPORT_STATES = ['progress', 'ready', 'blocked', 'failed'] as const
18
+ export type ReportState = typeof REPORT_STATES[number]
19
+
20
+ /** A report as the tool's caller gives it. */
21
+ export interface DeviceReport {
22
+ state: ReportState
23
+ summary: string
24
+ /** One question a person must answer; paired with `state: 'blocked'` in practice. */
25
+ question?: string
26
+ }
27
+
28
+ /** The host a tool reports through: the client that owns the connection, or a test. */
29
+ export interface ReportHost {
30
+ report(assignmentId: string, report: DeviceReport): void
31
+ }
32
+
33
+ /** The parts of a tool definition DSH registers: name, description, JSON Schema, and the executor. */
34
+ export interface DeviceTool {
35
+ name: string
36
+ description: string
37
+ parameters: Record<string, unknown>
38
+ execute(args: Record<string, unknown>, execution: { signal: AbortSignal }): Promise<unknown>
39
+ }
40
+
41
+ /** The JSON Schema a model sees, which is also what `execute` validates against. */
42
+ const PARAMETERS: Record<string, unknown> = {
43
+ type: 'object',
44
+ additionalProperties: false,
45
+ required: ['state', 'summary'],
46
+ properties: {
47
+ state: { type: 'string', enum: [...REPORT_STATES], description: 'progress while working, ready when the result is in, blocked when a person must act, failed when the work cannot be done.' },
48
+ summary: { type: 'string', minLength: 1, description: 'What was done, or what is needed, in one or two sentences.' },
49
+ question: { type: 'string', minLength: 1, description: 'One concrete question for a person, with state=blocked.' },
50
+ },
51
+ }
52
+
53
+ /** Thrown for arguments this tool refuses; the model sees the message and can correct itself. */
54
+ class ReportRefusal extends Error {
55
+ constructor(message: string) { super(message); this.name = 'ReportRefusal' }
56
+ }
57
+
58
+ /** Validate the arguments to a report, refusing `dropped`, an unknown state, or an empty summary. */
59
+ export function readReport(args: Record<string, unknown>): DeviceReport {
60
+ const state = args['state']
61
+ if (typeof state !== 'string') throw new ReportRefusal('state must be a string')
62
+ if (state === 'dropped') {
63
+ 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')
64
+ }
65
+ if (!(REPORT_STATES as readonly string[]).includes(state)) {
66
+ throw new ReportRefusal(`state must be one of ${REPORT_STATES.join(', ')}, not ${state}`)
67
+ }
68
+ const summary = typeof args['summary'] === 'string' ? args['summary'].trim() : ''
69
+ if (summary === '') throw new ReportRefusal('summary must say what was done or what is needed')
70
+ const question = typeof args['question'] === 'string' ? args['question'].trim() : undefined
71
+ if (args['question'] !== undefined && (question === undefined || question === '')) {
72
+ throw new ReportRefusal('question must be a question when given')
73
+ }
74
+ return { state: state as ReportState, summary, ...(question === undefined ? {} : { question }) }
75
+ }
76
+
77
+ /**
78
+ * Build the `foreman_report` tool for one assignment.
79
+ *
80
+ * The assignment id is bound here rather than passed by the model: a job Session reports on the assignment it was
81
+ * started for, and a model should not be able to name a different one.
82
+ */
83
+ export function foremanReportTool(host: ReportHost, assignmentId: string): DeviceTool {
84
+ return {
85
+ name: 'foreman_report',
86
+ 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.',
87
+ parameters: PARAMETERS,
88
+ async execute(args) {
89
+ host.report(assignmentId, readReport(args))
90
+ return { reported: true }
91
+ },
92
+ }
93
+ }
package/src/session.ts ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Job sessions on a device: one Session per assignment, in the directory the hub named.
3
+ *
4
+ * This is the device-side counterpart of `Sessions.ensure` on the hub, and it deliberately sets a different
5
+ * permission preset. A hub session runs with `danger-full-access` because a person is sitting in front of it and
6
+ * approves; a job session runs `workspace-write` inside a directory the device declared, because nobody is there to
7
+ * answer a prompt. Work that needs more is reported `blocked`, not silently elevated.
8
+ *
9
+ * Nothing here touches the hub's domain, its database, or its policy: a device has no ledger.
10
+ */
11
+ import type { Context } from '@deepseek-ai/cordis'
12
+ import type { Agent } from '@deepseek-ai/dsh-agent'
13
+ import type { SessionId } from '@deepseek-ai/dsh-session'
14
+ import type {} from '@deepseek-ai/dsh-api-session-controller'
15
+ import type {} from '@deepseek-ai/dsh-workspace'
16
+ import type {} from '@deepseek-ai/dsh-permission-presets'
17
+ import type {} from '@deepseek-ai/dsh-session-persistence'
18
+ import type {} from '@deepseek-ai/dsh-tools'
19
+ import { MessageId, freezeMessage } from '@deepseek-ai/dsh-llm'
20
+ import { foremanReportTool, type DeviceReport } from './report-tool.ts'
21
+
22
+ declare module '@deepseek-ai/dsh-llm' {
23
+ interface MessageSourceMap {
24
+ 'dsh-foreman-node': { kind: 'dsh-foreman-node'; form: 'relay' }
25
+ }
26
+ }
27
+
28
+ /** The permission preset a job runs under: bounded to the directory the device declared. */
29
+ export const JOB_PRESET = 'workspace-write'
30
+
31
+ /** The Session a job runs in, named the way a hub names its own worker: `job-<assignmentId>`. */
32
+ export const jobSessionId = (assignmentId: string): string => `job-${assignmentId}`
33
+
34
+ /** The message id of one delivery, derived from the hub's request id so a redelivery adds nothing. */
35
+ const requestMessage = (requestId: string): MessageId => MessageId(`dsh-foreman-node:${requestId}`)
36
+
37
+ export interface EnsureInput {
38
+ assignmentId: string
39
+ /** The absolute directory on this device; the hub mapped it through the device's declared roots. */
40
+ directory: string
41
+ title: string
42
+ }
43
+
44
+ /** What the tool registrar looks like, narrowed to what this file uses. */
45
+ export interface ToolRegistrar {
46
+ register(definition: { name: string; description: string; parameters: Record<string, unknown>
47
+ output: { schema: Record<string, unknown>; render: (args: unknown, value: unknown) => unknown }
48
+ execute: (args: unknown, execution: { signal: AbortSignal }) => Promise<unknown> }): unknown
49
+ }
50
+
51
+ /**
52
+ * Keeps the device's job Sessions and delivers work into them.
53
+ *
54
+ * A Session is created only when it does not exist yet, so a redelivered `assign` reuses the Session and the work
55
+ * already in it instead of starting a second one. Delivery is idempotent by request id: the hub retrying a frame
56
+ * that it never saw acknowledged cannot append the task twice.
57
+ */
58
+ export class JobSessions {
59
+ /** Jobs by assignment id, so `running()` reports what this device actually holds rather than what it was told. */
60
+ private readonly jobs = new Map<string, string>()
61
+ /** Assignments whose Session already carries the report tool, so a redelivered assign registers it only once. */
62
+ private readonly reported = new Set<string>()
63
+
64
+ constructor(private readonly ctx: Context) {}
65
+
66
+ /**
67
+ * Resolve or create the `job-<assignmentId>` Session in the given directory, attach its `foreman_report` tool, and
68
+ * register it so a heartbeat counts it.
69
+ *
70
+ * The tool is attached here rather than left to the caller because it is the only way a device can say what
71
+ * happened: without it a job Session's model has no `progress`/`ready`/`blocked`/`failed` channel, the hub waits
72
+ * forever, and the whole design's "never infer state from session events" requirement is moot. Registration is
73
+ * idempotent per assignment, so a redelivered `assign` does not stack a second tool on the same Session.
74
+ *
75
+ * @returns the Session id.
76
+ */
77
+ async ensure(input: EnsureInput, onReport?: (assignmentId: string, report: DeviceReport) => void): Promise<string> {
78
+ const id = jobSessionId(input.assignmentId)
79
+ const workspace = await this.ctx.workspaceRegistry.create(input.directory)
80
+ let result = await this.ctx.sessionController.resolveAgent(id as SessionId)
81
+ if ('error' in result && result.error.code === 'session/not-found') {
82
+ await this.ctx.sessionController.create({ sessionId: id as SessionId, workspaceId: workspace.id })
83
+ result = await this.ctx.sessionController.resolveAgent(id as SessionId)
84
+ }
85
+ if ('error' in result) throw result.error
86
+ await workspace.attachSession(id as SessionId)
87
+ // The one place the device's policy differs from the hub's: bounded, because no person is here to approve more.
88
+ this.ctx.permissionPresets.set(result.agent.session, JOB_PRESET)
89
+ await this.ctx.sessionController.rename({ sessionId: id as SessionId, title: input.title })
90
+ if (onReport !== undefined && !this.reported.has(input.assignmentId)) {
91
+ this.reported.add(input.assignmentId)
92
+ try { this.reportTools(result.agent as Agent, input.assignmentId, onReport) }
93
+ catch (error) {
94
+ // A Session that refuses the tool would never report: forget the guard so a later assign retries it.
95
+ this.reported.delete(input.assignmentId)
96
+ throw new Error(`could not attach foreman_report to ${id}: ${String(error)}`)
97
+ }
98
+ }
99
+ this.jobs.set(input.assignmentId, id)
100
+ return id
101
+ }
102
+
103
+ /**
104
+ * Deliver a task into its job Session, once per request id.
105
+ *
106
+ * `queue` rather than `steer` on the first delivery: the job's task should start a turn, not interrupt one that is
107
+ * already running. A redelivery finds the message already recorded and adds nothing.
108
+ */
109
+ async deliver(sessionId: string, text: string, requestId: string): Promise<void> {
110
+ const result = await this.ctx.sessionController.resolveAgent(sessionId as SessionId)
111
+ if ('error' in result) throw result.error
112
+ const agent = result.agent as Agent
113
+ if (await this.holds(agent, requestId)) return
114
+ const source = { kind: 'dsh-foreman-node' as const, form: 'relay' as const }
115
+ agent.followup(freezeMessage({ id: requestMessage(requestId), role: 'user' as const, content: [{ type: 'text' as const, text }], source }))
116
+ await this.ctx.sessionPersistence.flush()
117
+ }
118
+
119
+ /** Whether the Session already holds the delivery for `requestId`, in its live inbox or its stored history. */
120
+ async holds(agent: Agent, requestId: string): Promise<boolean> {
121
+ const id = requestMessage(requestId)
122
+ if ([...agent.inbox.nextTurn, ...agent.inbox.nextStep].some(message => message.id === id)) return true
123
+ try {
124
+ await this.ctx.sessionPersistence.flush()
125
+ await using handle = await this.ctx.sessionPersistence.open(agent.id, 'read')
126
+ for (let offset = 0; ;) {
127
+ const { events } = await handle.read(offset, 512)
128
+ if (events.length === 0) return false
129
+ for (const event of events) {
130
+ if (event.type === 'user/message' && (event.data as { id?: string }).id === id) return true
131
+ }
132
+ offset += events.length
133
+ }
134
+ } catch (error) {
135
+ // A Session whose history cannot be read is not proof of a delivery: deliver again rather than lose the task.
136
+ this.ctx.logger.warn('dsh-foreman-node could not read %s history: %s', agent.id, String(error))
137
+ return false
138
+ }
139
+ }
140
+
141
+ /** The assignment ids this device currently holds a job for: what a heartbeat and a reconnected hello report. */
142
+ running(): string[] { return [...this.jobs.keys()] }
143
+
144
+ /** Forget a job whose assignment the hub stopped, so this device stops reporting it as running. */
145
+ forget(assignmentId: string): void { this.jobs.delete(assignmentId) }
146
+
147
+ /** Attach the `foreman_report` tool to a job Session, disposed with it. */
148
+ reportTools(agent: Agent, assignmentId: string, onReport: (assignmentId: string, report: DeviceReport) => void): void {
149
+ const registrar = agent.ctx.tools
150
+ const tool = foremanReportTool({ report: onReport }, assignmentId)
151
+ registrar.register({ name: tool.name, description: tool.description, parameters: tool.parameters,
152
+ output: { schema: { type: 'object', additionalProperties: true },
153
+ render: (_args: unknown, value: unknown) => [{ type: 'text' as const, text: JSON.stringify(value) }] },
154
+ execute: (args: unknown, execution: { signal: AbortSignal }) => tool.execute(args as Record<string, unknown>, execution) })
155
+ }
156
+ }