@goodandready/dsh-agent-orchestrator 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/routes.js ADDED
@@ -0,0 +1,277 @@
1
+ /**
2
+ * HTTP REST API Routes for DSH Multi-Agent Orchestrator.
3
+ *
4
+ * Implements:
5
+ * - Fail-closed origin/host/loopback check on state mutating routes (/dispatch, /delegate, /cancel, /config, /snapshots/save-as-preset) (Issue #113)
6
+ * - Strict body payload limit (1MB max, 413 Payload Too Large on overflow) (Issue #113)
7
+ * - Safe read-only /status route (no mutations, public metrics only) (Issue #113)
8
+ * - Snapshots listing & One-Click Save as Preset (Issue #109)
9
+ */
10
+
11
+ import { readModelSelection, fetchModelCatalog } from './pipeline/model-selection.js'
12
+ import { executeDirectDelegation } from './pipeline/delegation.js'
13
+ import { rejectUntrustedRequest, parseBoundedJsonBody } from './http-guard.js'
14
+ import { SnapshotManager } from './pipeline/snapshots.js'
15
+ import { validateScenario } from './pipeline/scenarios.js'
16
+
17
+ function jsonReply(res, data, status = 200) {
18
+ res.statusCode = status
19
+ res.setHeader('Content-Type', 'application/json; charset=utf-8')
20
+ res.end(JSON.stringify(data))
21
+ }
22
+
23
+ export function registerOrchestratorRoutes(ctx, services) {
24
+ const { store, runner, getConfig, updateConfig, callLlm, snapshotManager = new SnapshotManager() } = services
25
+
26
+ // 1. Status & Global Metrics (Read-only status overview)
27
+ // Conscious architectural choice (Issue #113): Public read-only endpoint returning non-sensitive metrics
28
+ ctx.effect(() => ctx.webServer.register({
29
+ kind: 'exact',
30
+ path: '/dsh-agent-orchestrator/status',
31
+ handler: (req, res) => {
32
+ jsonReply(res, {
33
+ activePipelines: store.getActivePipelines(),
34
+ metrics: store.getMetrics(),
35
+ recentPipelines: store.getAllPipelines().slice(0, 10),
36
+ })
37
+ },
38
+ }), 'dsh-agent-orchestrator: status route')
39
+
40
+ // 2. Dispatch a new pipeline (multi-stage DAG) - Guarded (Issue #113)
41
+ ctx.effect(() => ctx.webServer.register({
42
+ kind: 'exact',
43
+ path: '/dsh-agent-orchestrator/dispatch',
44
+ handler: async (req, res) => {
45
+ if (req.method !== 'POST') {
46
+ return jsonReply(res, { error: 'Method not allowed' }, 405)
47
+ }
48
+ if (rejectUntrustedRequest(req, res)) return
49
+
50
+ let body
51
+ try {
52
+ body = await parseBoundedJsonBody(req)
53
+ } catch (err) {
54
+ return jsonReply(res, { error: err.message }, err.statusCode || 400)
55
+ }
56
+
57
+ try {
58
+ const pipeline = await runner.startPipeline({
59
+ taskTitle: body.taskTitle || body.title || 'Manual Pipeline Run',
60
+ taskDescription: body.taskDescription || body.description || body.prompt || '',
61
+ scenarioId: body.scenarioId || 'auto',
62
+ taskId: body.taskId, // optional linked kanban task
63
+ })
64
+ jsonReply(res, { success: true, pipeline })
65
+ } catch (err) {
66
+ jsonReply(res, { error: err?.message || String(err) }, 400)
67
+ }
68
+ },
69
+ }), 'dsh-agent-orchestrator: dispatch route')
70
+
71
+ // 3. Direct Specialist Delegation (single subagent) - Guarded (Issue #113)
72
+ ctx.effect(() => ctx.webServer.register({
73
+ kind: 'exact',
74
+ path: '/dsh-agent-orchestrator/delegate',
75
+ handler: async (req, res) => {
76
+ if (req.method !== 'POST') {
77
+ return jsonReply(res, { error: 'Method not allowed' }, 405)
78
+ }
79
+ if (rejectUntrustedRequest(req, res)) return
80
+
81
+ let body
82
+ try {
83
+ body = await parseBoundedJsonBody(req)
84
+ } catch (err) {
85
+ return jsonReply(res, { error: err.message }, err.statusCode || 400)
86
+ }
87
+
88
+ if (!body.task) {
89
+ return jsonReply(res, { error: 'Missing required field: task' }, 400)
90
+ }
91
+
92
+ try {
93
+ const selection = readModelSelection(ctx)
94
+ const result = await executeDirectDelegation({
95
+ roleId: body.roleId || 'architecture',
96
+ task: body.task,
97
+ context: body.context || '',
98
+ roles: getConfig().roles,
99
+ config: getConfig(),
100
+ callLlm,
101
+ allowedRoutes: selection.allowedRoutes,
102
+ })
103
+ jsonReply(res, { success: true, deliverable: result })
104
+ } catch (err) {
105
+ jsonReply(res, { error: err?.message || String(err) }, 500)
106
+ }
107
+ },
108
+ }), 'dsh-agent-orchestrator: direct delegate route')
109
+
110
+ // 4. Pipeline details by ID
111
+ ctx.effect(() => ctx.webServer.register({
112
+ kind: 'prefix',
113
+ path: '/dsh-agent-orchestrator/pipeline',
114
+ handler: (req, res) => {
115
+ const parts = req.url.split('?')[0].split('/')
116
+ const pipelineId = parts[parts.length - 1]
117
+ const pipeline = store.getPipeline(pipelineId)
118
+ if (!pipeline) {
119
+ return jsonReply(res, { error: 'Pipeline not found' }, 404)
120
+ }
121
+ jsonReply(res, { pipeline })
122
+ },
123
+ }), 'dsh-agent-orchestrator: pipeline detail route')
124
+
125
+ // 4b. Cancel a running pipeline - Guarded (Issue #113)
126
+ ctx.effect(() => ctx.webServer.register({
127
+ kind: 'exact',
128
+ path: '/dsh-agent-orchestrator/cancel',
129
+ handler: async (req, res) => {
130
+ if (req.method !== 'POST') {
131
+ return jsonReply(res, { error: 'Method not allowed' }, 405)
132
+ }
133
+ if (rejectUntrustedRequest(req, res)) return
134
+
135
+ let body
136
+ try {
137
+ body = await parseBoundedJsonBody(req)
138
+ } catch (err) {
139
+ return jsonReply(res, { error: err.message }, err.statusCode || 400)
140
+ }
141
+
142
+ const pipelineId = body.pipelineId
143
+ const p = store.getPipeline(pipelineId)
144
+ if (p && (p.status === 'running' || p.status === 'pending')) {
145
+ store.updatePipeline(pipelineId, {
146
+ status: 'failed',
147
+ completedAt: Date.now(),
148
+ error: 'Cancelled by user',
149
+ })
150
+ return jsonReply(res, { success: true })
151
+ }
152
+ jsonReply(res, { success: false, error: 'Pipeline not running or not found' }, 404)
153
+ },
154
+ }), 'dsh-agent-orchestrator: cancel route')
155
+
156
+ // 5. Configuration (roles, scenarios, kanbanSync) - Guarded (Issue #113)
157
+ ctx.effect(() => ctx.webServer.register({
158
+ kind: 'exact',
159
+ path: '/dsh-agent-orchestrator/config',
160
+ handler: async (req, res) => {
161
+ if (req.method === 'POST') {
162
+ if (rejectUntrustedRequest(req, res)) return
163
+ let body
164
+ try {
165
+ body = await parseBoundedJsonBody(req)
166
+ } catch (err) {
167
+ return jsonReply(res, { error: err.message }, err.statusCode || 400)
168
+ }
169
+ if (body?.scenarios && typeof body.scenarios === 'object') {
170
+ for (const [sId, sc] of Object.entries(body.scenarios)) {
171
+ try {
172
+ validateScenario(sc)
173
+ } catch (vErr) {
174
+ return jsonReply(res, { error: `Invalid scenario "${sId}": ${vErr.message}` }, 400)
175
+ }
176
+ }
177
+ }
178
+ await updateConfig(body)
179
+ return jsonReply(res, { success: true, config: getConfig() })
180
+ }
181
+ jsonReply(res, { config: getConfig() })
182
+ },
183
+ }), 'dsh-agent-orchestrator: config route')
184
+
185
+ // 6. Available models query with allowedModels integration
186
+ ctx.effect(() => ctx.webServer.register({
187
+ kind: 'exact',
188
+ path: '/dsh-agent-orchestrator/models',
189
+ handler: async (req, res) => {
190
+ const selection = readModelSelection(ctx)
191
+ const catalog = await fetchModelCatalog(ctx, {
192
+ allowedRoutes: selection.allowedRoutes,
193
+ })
194
+
195
+ jsonReply(res, {
196
+ models: catalog,
197
+ authorizedConstraint: Boolean(selection.allowedRoutes && selection.allowedRoutes.length > 0),
198
+ })
199
+ },
200
+ }), 'dsh-agent-orchestrator: models route')
201
+
202
+ // 6b. Live Model & Subagent Catalog query (Issue #76)
203
+ ctx.effect(() => ctx.webServer.register({
204
+ kind: 'exact',
205
+ path: '/dsh-agent-orchestrator/catalog/models',
206
+ handler: async (req, res) => {
207
+ try {
208
+ const urlObj = new URL(req.url, 'http://localhost')
209
+ const provider = urlObj.searchParams.get('provider') || undefined
210
+ const capability = urlObj.searchParams.get('capability') || undefined
211
+
212
+ const catalog = await fetchModelCatalog(ctx, { provider, capability })
213
+ jsonReply(res, {
214
+ total: catalog.length,
215
+ catalog,
216
+ })
217
+ } catch (err) {
218
+ jsonReply(res, { error: err?.message || String(err) }, 500)
219
+ }
220
+ },
221
+ }), 'dsh-agent-orchestrator: catalog models route')
222
+
223
+ // 7. Dispatch Snapshots Listing (Issue #109)
224
+ ctx.effect(() => ctx.webServer.register({
225
+ kind: 'exact',
226
+ path: '/dsh-agent-orchestrator/snapshots',
227
+ handler: (req, res) => {
228
+ try {
229
+ const snapshots = snapshotManager.listSnapshots()
230
+ jsonReply(res, { snapshots, total: snapshots.length })
231
+ } catch (err) {
232
+ jsonReply(res, { error: err?.message || String(err) }, 500)
233
+ }
234
+ },
235
+ }), 'dsh-agent-orchestrator: snapshots list route')
236
+
237
+ // 8. One-Click Save as Preset (Issue #109) - Guarded (Issue #113)
238
+ ctx.effect(() => ctx.webServer.register({
239
+ kind: 'exact',
240
+ path: '/dsh-agent-orchestrator/snapshots/save-as-preset',
241
+ handler: async (req, res) => {
242
+ if (req.method !== 'POST') {
243
+ return jsonReply(res, { error: 'Method not allowed' }, 405)
244
+ }
245
+ if (rejectUntrustedRequest(req, res)) return
246
+
247
+ let body
248
+ try {
249
+ body = await parseBoundedJsonBody(req)
250
+ } catch (err) {
251
+ return jsonReply(res, { error: err.message }, err.statusCode || 400)
252
+ }
253
+
254
+ const { snapshotId, name, description } = body || {}
255
+ if (!snapshotId) {
256
+ return jsonReply(res, { error: 'Missing required field: snapshotId' }, 400)
257
+ }
258
+ if (typeof snapshotId !== 'string' || !/^[a-zA-Z0-9_-]{1,64}$/.test(snapshotId)) {
259
+ return jsonReply(res, { error: 'Invalid or unsafe snapshotId' }, 400)
260
+ }
261
+
262
+ try {
263
+ const preset = snapshotManager.saveAsPreset(snapshotId, { name, description })
264
+ validateScenario(preset)
265
+
266
+ // Idempotently persist to configured scenarios
267
+ const currentScenarios = getConfig().scenarios || {}
268
+ currentScenarios[preset.id] = preset
269
+ await updateConfig({ scenarios: currentScenarios })
270
+
271
+ jsonReply(res, { success: true, preset })
272
+ } catch (err) {
273
+ jsonReply(res, { error: err?.message || String(err) }, 400)
274
+ }
275
+ },
276
+ }), 'dsh-agent-orchestrator: save-as-preset route')
277
+ }
package/lib/store.js ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Pipeline & Metrics Store for DSH Multi-Agent Orchestrator.
3
+ *
4
+ * In-memory state with JSON persistence for recovery across turns.
5
+ */
6
+
7
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
8
+ import { join, dirname } from 'node:path'
9
+ import { homedir } from 'node:os'
10
+
11
+ export class OrchestratorStore {
12
+ constructor(options = {}) {
13
+ const opts = typeof options === 'string' ? { storageDir: options } : (options || {})
14
+ const baseDir = opts.storageDir || join(homedir(), '.dsh')
15
+ this.storagePath = join(baseDir, 'orchestrator-pipelines.json')
16
+ this.logger = opts.logger || null
17
+ this.pipelines = new Map()
18
+ this.globalMetrics = {
19
+ totalPipelines: 0,
20
+ completedPipelines: 0,
21
+ failedPipelines: 0,
22
+ totalPromptTokens: 0,
23
+ totalCacheHitTokens: 0,
24
+ totalCacheMissTokens: 0,
25
+ overallHitRatio: 0,
26
+ totalDurationMs: 0,
27
+ }
28
+ this._load()
29
+ }
30
+
31
+ _load() {
32
+ try {
33
+ if (existsSync(this.storagePath)) {
34
+ const raw = readFileSync(this.storagePath, 'utf8')
35
+ const data = JSON.parse(raw)
36
+ if (Array.isArray(data.pipelines)) {
37
+ for (const p of data.pipelines) {
38
+ this.pipelines.set(p.pipelineId, p)
39
+ }
40
+ }
41
+ if (data.globalMetrics) {
42
+ this.globalMetrics = { ...this.globalMetrics, ...data.globalMetrics }
43
+ }
44
+ }
45
+ } catch (e) {
46
+ if (this.logger?.warn) this.logger.warn('[dsh-agent-orchestrator] Store load warning:', e?.message || e)
47
+ }
48
+ }
49
+
50
+ save() {
51
+ try {
52
+ mkdirSync(dirname(this.storagePath), { recursive: true })
53
+ const serialized = {
54
+ pipelines: Array.from(this.pipelines.values()).slice(-50), // keep last 50
55
+ globalMetrics: this.globalMetrics,
56
+ updatedAt: Date.now(),
57
+ }
58
+ writeFileSync(this.storagePath, JSON.stringify(serialized, null, 2), 'utf8')
59
+ } catch (e) {
60
+ if (this.logger?.warn) this.logger.warn('[dsh-agent-orchestrator] Store save warning:', e?.message || e)
61
+ }
62
+ }
63
+
64
+ recordPipeline(pipelineData) {
65
+ this.pipelines.set(pipelineData.pipelineId, pipelineData)
66
+ this.globalMetrics.totalPipelines++
67
+ this.save()
68
+ }
69
+
70
+ updatePipeline(pipelineId, updateFnOrPartial) {
71
+ const existing = this.pipelines.get(pipelineId)
72
+ if (existing) {
73
+ const updated = typeof updateFnOrPartial === 'function'
74
+ ? (updateFnOrPartial(existing) || existing)
75
+ : { ...existing, ...updateFnOrPartial }
76
+ this.pipelines.set(pipelineId, updated)
77
+
78
+ if (updated.status === 'completed' && existing.status !== 'completed') {
79
+ this.globalMetrics.completedPipelines++
80
+ this.globalMetrics.totalDurationMs += (updated.durationMs || 0)
81
+ } else if (updated.status === 'failed' && existing.status !== 'failed') {
82
+ this.globalMetrics.failedPipelines++
83
+ }
84
+
85
+ this.save()
86
+ return updated
87
+ }
88
+ return null
89
+ }
90
+
91
+ updateStage(pipelineId, stageId, updateFnOrPartial) {
92
+ const pipeline = this.pipelines.get(pipelineId)
93
+ if (!pipeline) return null
94
+
95
+ if (!pipeline.stateMap) pipeline.stateMap = {}
96
+ const existing = pipeline.stateMap[stageId] || { id: stageId, status: 'pending' }
97
+ const updated = typeof updateFnOrPartial === 'function'
98
+ ? (updateFnOrPartial(existing) || existing)
99
+ : { ...existing, ...updateFnOrPartial }
100
+
101
+ pipeline.stateMap[stageId] = updated
102
+
103
+ if (Array.isArray(pipeline.stages)) {
104
+ const idx = pipeline.stages.findIndex((s) => s.id === stageId)
105
+ if (idx !== -1) {
106
+ pipeline.stages[idx] = { ...pipeline.stages[idx], ...updated }
107
+ }
108
+ }
109
+
110
+ this.save()
111
+ return updated
112
+ }
113
+
114
+ appendStageLog(pipelineId, stageId, logDelta) {
115
+ const pipeline = this.pipelines.get(pipelineId)
116
+ if (!pipeline) return
117
+ if (!pipeline.stageLogs) pipeline.stageLogs = {}
118
+ if (!pipeline.stageLogs[stageId]) pipeline.stageLogs[stageId] = ''
119
+ pipeline.stageLogs[stageId] += String(logDelta || '')
120
+ }
121
+
122
+ recordCompletion(pipelineId, summary) {
123
+ const p = this.pipelines.get(pipelineId)
124
+ if (p) {
125
+ p.status = summary.success ? 'completed' : 'failed'
126
+ p.completedAt = Date.now()
127
+ p.durationMs = summary.durationMs || 0
128
+ p.stateMap = summary.stateMap || {}
129
+ p.artifacts = summary.artifacts || {}
130
+
131
+ if (summary.success) {
132
+ this.globalMetrics.completedPipelines++
133
+ } else {
134
+ this.globalMetrics.failedPipelines++
135
+ }
136
+
137
+ this.globalMetrics.totalDurationMs += p.durationMs
138
+
139
+ // Aggregate cache metrics across all stages
140
+ for (const node of Object.values(summary.stateMap || {})) {
141
+ const m = node.metrics
142
+ if (m) {
143
+ this.globalMetrics.totalPromptTokens += m.promptTokens || 0
144
+ this.globalMetrics.totalCacheHitTokens += m.cacheHitTokens || 0
145
+ this.globalMetrics.totalCacheMissTokens += m.cacheMissTokens || 0
146
+ }
147
+ }
148
+
149
+ if (this.globalMetrics.totalPromptTokens > 0) {
150
+ this.globalMetrics.overallHitRatio = parseFloat(
151
+ (this.globalMetrics.totalCacheHitTokens / this.globalMetrics.totalPromptTokens).toFixed(4)
152
+ )
153
+ }
154
+
155
+ this.save()
156
+ }
157
+ }
158
+
159
+ getPipeline(pipelineId) {
160
+ return this.pipelines.get(pipelineId) || null
161
+ }
162
+
163
+ getActivePipelines() {
164
+ return Array.from(this.pipelines.values()).filter(
165
+ (p) => p.status === 'running' || p.status === 'pending'
166
+ )
167
+ }
168
+
169
+ getAllPipelines() {
170
+ return Array.from(this.pipelines.values()).sort((a, b) => b.createdAt - a.createdAt)
171
+ }
172
+
173
+ getMetrics() {
174
+ return { ...this.globalMetrics }
175
+ }
176
+ }
package/package.json ADDED
@@ -0,0 +1,94 @@
1
+ {
2
+ "name": "@goodandready/dsh-agent-orchestrator",
3
+ "version": "0.1.6",
4
+ "description": "Multi-agent task decomposition, DAG workflow orchestration, and prompt caching optimizer for DeepSeek Harness.",
5
+ "type": "module",
6
+ "main": "./lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./package.json": "./package.json",
11
+ "./cordis.patch.yml": "./cordis.patch.yml"
12
+ },
13
+ "files": [
14
+ "lib/",
15
+ "cordis.patch.yml",
16
+ "README.md",
17
+ "README.zh.md",
18
+ "README.ru.md",
19
+ "LICENSE"
20
+ ],
21
+ "scripts": {
22
+ "test": "node --test test/*.test.mjs"
23
+ },
24
+ "keywords": [
25
+ "dsh",
26
+ "dsh-plugin",
27
+ "deepseek-harness",
28
+ "orchestrator",
29
+ "multi-agent",
30
+ "prompt-caching",
31
+ "dag-workflow",
32
+ "kanban"
33
+ ],
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/GooDAnDReaDY/dsh-agent-orchestrator.git"
37
+ },
38
+ "homepage": "https://github.com/GooDAnDReaDY/dsh-agent-orchestrator#readme",
39
+ "bugs": {
40
+ "url": "https://github.com/GooDAnDReaDY/dsh-agent-orchestrator/issues"
41
+ },
42
+ "license": "MIT",
43
+ "engines": {
44
+ "node": ">=20"
45
+ },
46
+ "dsh": {
47
+ "bundle": {
48
+ "patch": "./cordis.patch.yml"
49
+ },
50
+ "client": {
51
+ "platform": "web",
52
+ "inject": [
53
+ "@deepseek-ai/dsh-client-locale",
54
+ "@deepseek-ai/dsh-client-ui-slots"
55
+ ]
56
+ }
57
+ },
58
+ "peerDependencies": {
59
+ "@deepseek-ai/cordis": "^4.0.1",
60
+ "@deepseek-ai/dsh-settings": ">=0.1.0-rc.6 <1.0.0",
61
+ "@deepseek-ai/dsh-host-webserver": ">=0.1.0-rc.6 <1.0.0",
62
+ "@deepseek-ai/dsh-llm": ">=0.1.0-rc.6 <1.0.0",
63
+ "@deepseek-ai/dsh-tools": ">=0.1.0-rc.6 <1.0.0",
64
+ "@deepseek-ai/dsh-session": ">=0.1.0-rc.6 <1.0.0",
65
+ "@deepseek-ai/schemastery": "^3.18.1"
66
+ },
67
+ "peerDependenciesMeta": {
68
+ "@deepseek-ai/cordis": {
69
+ "optional": true
70
+ },
71
+ "@deepseek-ai/dsh-settings": {
72
+ "optional": true
73
+ },
74
+ "@deepseek-ai/dsh-host-webserver": {
75
+ "optional": true
76
+ },
77
+ "@deepseek-ai/dsh-llm": {
78
+ "optional": true
79
+ },
80
+ "@deepseek-ai/dsh-tools": {
81
+ "optional": true
82
+ },
83
+ "@deepseek-ai/dsh-session": {
84
+ "optional": true
85
+ },
86
+ "@deepseek-ai/schemastery": {
87
+ "optional": true
88
+ }
89
+ },
90
+ "publishConfig": {
91
+ "registry": "https://registry.npmjs.org",
92
+ "access": "public"
93
+ }
94
+ }