@goodandready/dsh-agent-orchestrator 0.1.10 → 0.1.12
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/http-guard.js +68 -26
- package/lib/index.js +183 -41
- package/lib/routes.js +34 -5
- package/package.json +1 -1
package/lib/http-guard.js
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
* against drive-by CSRF, unauthorized cross-network access, and cross-origin tampering.
|
|
4
4
|
*
|
|
5
5
|
* Implements Issue #113:
|
|
6
|
-
* 1. Loopback / Origin / Host validation
|
|
7
|
-
* 2. Sec-Fetch-Site and Referer verification
|
|
8
|
-
* 3.
|
|
6
|
+
* 1. Loopback / Origin / Host validation with LAN reverse proxy (X-Forwarded-Host) support
|
|
7
|
+
* 2. Sec-Fetch-Site and Referer verification (Fetch Metadata is not authentication)
|
|
8
|
+
* 3. Explicit Bearer token authorization support (bridge path)
|
|
9
|
+
* 4. Body size limiter (default 1MB) with fail-closed rejection
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
export const DEFAULT_MAX_BODY_BYTES = 1024 * 1024 // 1MB
|
|
@@ -32,56 +33,97 @@ export function getClientIp(req) {
|
|
|
32
33
|
/**
|
|
33
34
|
* Checks whether an incoming HTTP request is safe for mutation or sensitive dispatch.
|
|
34
35
|
*
|
|
35
|
-
* Requirements:
|
|
36
|
-
* 1.
|
|
37
|
-
* -
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* -
|
|
43
|
-
* 4.
|
|
44
|
-
* -
|
|
36
|
+
* Requirements (Issue #113):
|
|
37
|
+
* 1. Authorization header:
|
|
38
|
+
* - Bearer token accepted if matching server tokens or valid non-empty token when no static secret enforced.
|
|
39
|
+
* 2. Sec-Fetch-Site header:
|
|
40
|
+
* - Must NOT be "cross-site" or "same-site" (fail-closed rejection).
|
|
41
|
+
* 3. Origin / Referer:
|
|
42
|
+
* - If present, must not be "null" or empty.
|
|
43
|
+
* - Parsed Origin host must match Host or X-Forwarded-Host.
|
|
44
|
+
* 4. Browser Same-Origin:
|
|
45
|
+
* - When Origin is present and matches Host / X-Forwarded-Host, request is permitted.
|
|
46
|
+
* 5. Non-browser / header-only callers:
|
|
47
|
+
* - Sec-Fetch-Site alone without matching Origin is NOT auth.
|
|
48
|
+
* - Without Origin or Authorization, only local loopback (127.0.0.1, ::1) callers are permitted.
|
|
45
49
|
*/
|
|
46
50
|
export function isTrustedWriteRequest(req) {
|
|
47
51
|
const headers = req.headers || {}
|
|
48
|
-
const
|
|
52
|
+
const rawHost = headers.host || ''
|
|
53
|
+
const forwardedHost = headers['x-forwarded-host'] || ''
|
|
54
|
+
const effectiveHost = forwardedHost || rawHost
|
|
55
|
+
|
|
56
|
+
// 1. Explicit Authorization header (Bridge / CLI / API token)
|
|
57
|
+
const authHeader = headers.authorization
|
|
58
|
+
const expectedToken = process.env.DSH_AUTH_TOKEN || process.env.DSH_TOKEN || process.env.ORCHESTRATOR_API_KEY
|
|
59
|
+
if (authHeader && typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) {
|
|
60
|
+
const token = authHeader.slice(7).trim()
|
|
61
|
+
if (token && (!expectedToken || token === expectedToken)) {
|
|
62
|
+
return true
|
|
63
|
+
}
|
|
64
|
+
}
|
|
49
65
|
|
|
66
|
+
// 2. Fetch Metadata check: reject cross-site and same-site requests upfront
|
|
67
|
+
const site = headers['sec-fetch-site']
|
|
68
|
+
if (site !== undefined && site) {
|
|
69
|
+
if (site !== 'same-origin' && site !== 'none') {
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 3. Origin check if present
|
|
50
75
|
const origin = headers.origin
|
|
51
76
|
if (origin !== undefined) {
|
|
52
77
|
if (origin === 'null' || !origin) return false
|
|
53
78
|
try {
|
|
54
79
|
const parsed = new URL(origin)
|
|
55
|
-
|
|
80
|
+
const originHost = parsed.host
|
|
81
|
+
if (!originHost) return false
|
|
82
|
+
if (originHost !== effectiveHost && originHost !== rawHost) {
|
|
83
|
+
return false
|
|
84
|
+
}
|
|
56
85
|
} catch {
|
|
57
86
|
return false
|
|
58
87
|
}
|
|
59
88
|
}
|
|
60
89
|
|
|
90
|
+
// 4. Referer check if present
|
|
61
91
|
const referer = headers.referer
|
|
62
92
|
if (referer !== undefined && referer) {
|
|
63
93
|
try {
|
|
64
94
|
const parsed = new URL(referer)
|
|
65
|
-
|
|
95
|
+
const refHost = parsed.host
|
|
96
|
+
if (!refHost) return false
|
|
97
|
+
if (refHost !== effectiveHost && refHost !== rawHost) {
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
66
100
|
} catch {
|
|
67
101
|
return false
|
|
68
102
|
}
|
|
69
103
|
}
|
|
70
104
|
|
|
71
|
-
|
|
72
|
-
if (
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (!isLoopbackAddress(ip)) {
|
|
105
|
+
// 5. If origin is present and matches effectiveHost/rawHost:
|
|
106
|
+
if (origin && (effectiveHost || rawHost)) {
|
|
107
|
+
try {
|
|
108
|
+
const parsed = new URL(origin)
|
|
109
|
+
if (parsed.host === effectiveHost || parsed.host === rawHost) {
|
|
110
|
+
return true
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
80
113
|
return false
|
|
81
114
|
}
|
|
82
115
|
}
|
|
83
116
|
|
|
84
|
-
|
|
117
|
+
// 6. If origin is absent, check for local loopback:
|
|
118
|
+
// Fetch metadata alone (e.g. Sec-Fetch-Site: same-origin) is NOT authentication.
|
|
119
|
+
// Header-only remote callers without Origin or Authorization are rejected.
|
|
120
|
+
const ip = getClientIp(req)
|
|
121
|
+
if (isLoopbackAddress(ip)) {
|
|
122
|
+
// Local loopback caller without origin (CLI, server scripts) is allowed.
|
|
123
|
+
return true
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return false
|
|
85
127
|
}
|
|
86
128
|
|
|
87
129
|
export function rejectUntrustedRequest(req, res) {
|
package/lib/index.js
CHANGED
|
@@ -8,10 +8,16 @@
|
|
|
8
8
|
import { randomUUID } from 'crypto'
|
|
9
9
|
// Safe peerDependency resolution with standalone fallback
|
|
10
10
|
function createSchemaStub(target = {}) {
|
|
11
|
-
|
|
11
|
+
const fn = (...args) => createSchemaStub({ ...target, args })
|
|
12
|
+
fn.shape = target.shape || {}
|
|
13
|
+
fn.meta = target.meta || {}
|
|
14
|
+
fn.volatile = () => createSchemaStub({ ...target, meta: { ...(target.meta || {}), volatile: true } })
|
|
15
|
+
return new Proxy(fn, {
|
|
12
16
|
get(t, prop) {
|
|
13
17
|
if (prop === 'shape') return t.shape || {}
|
|
14
|
-
|
|
18
|
+
if (prop === 'meta') return t.meta || {}
|
|
19
|
+
if (prop === 'volatile') return () => createSchemaStub({ ...target, meta: { ...(target.meta || {}), volatile: true } })
|
|
20
|
+
return (...args) => createSchemaStub({ ...target, [prop]: args[0] })
|
|
15
21
|
}
|
|
16
22
|
})
|
|
17
23
|
}
|
|
@@ -28,6 +34,18 @@ try {
|
|
|
28
34
|
number: () => createSchemaStub({ type: 'number' }),
|
|
29
35
|
union: () => createSchemaStub({ type: 'union' }),
|
|
30
36
|
array: () => createSchemaStub({ type: 'array' }),
|
|
37
|
+
any: () => createSchemaStub({ type: 'any' }),
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// DSH SettingsForms describe only publishes fields marked volatile. Without them the
|
|
42
|
+
// namespace is omitted and the configuration page stays unavailable.
|
|
43
|
+
if (typeof z.prototype?.volatile !== 'function') {
|
|
44
|
+
if (z.prototype) {
|
|
45
|
+
z.prototype.volatile = function volatile() {
|
|
46
|
+
if (this.meta && this.meta.volatile) return this
|
|
47
|
+
return typeof this.extra === 'function' ? this.extra('volatile', true) : this
|
|
48
|
+
}
|
|
31
49
|
}
|
|
32
50
|
}
|
|
33
51
|
|
|
@@ -57,20 +75,43 @@ import { syncDefaultPresets } from './pipeline/preset-sync.js'
|
|
|
57
75
|
export const name = '@goodandready/dsh-agent-orchestrator'
|
|
58
76
|
export const inject = ['settings', 'webServer', 'llm', 'tools', 'commands', 'systemPrompt', 'subagents']
|
|
59
77
|
|
|
78
|
+
export function isVolatileRef(value) {
|
|
79
|
+
return !!value && typeof value === 'object' && !Array.isArray(value) && typeof value.get === 'function'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function plainConfig(cfg) {
|
|
83
|
+
while (typeof cfg === 'function') {
|
|
84
|
+
cfg = cfg()
|
|
85
|
+
}
|
|
86
|
+
if (isVolatileRef(cfg)) return plainConfig(cfg.get())
|
|
87
|
+
if (!cfg || typeof cfg !== 'object') return cfg
|
|
88
|
+
if (Array.isArray(cfg)) {
|
|
89
|
+
return cfg.map((item) => plainConfig(item))
|
|
90
|
+
}
|
|
91
|
+
const out = {}
|
|
92
|
+
for (const key of Object.keys(cfg)) {
|
|
93
|
+
out[key] = plainConfig(cfg[key])
|
|
94
|
+
}
|
|
95
|
+
return out
|
|
96
|
+
}
|
|
97
|
+
|
|
60
98
|
export const Config = z.object({
|
|
61
|
-
enabled: z.boolean().default(true).description('Enable Multi-Agent Orchestrator pipeline dispatcher'),
|
|
62
|
-
defaultScenario: z.string().default('auto').description('Default complexity scenario (auto, hotfix, simple, medium, complex, enterprise)'),
|
|
63
|
-
disableNestedDelegation: z.boolean().default(false).description('Anti-Matryoshka Guard: Disallow subagents from spawning nested subagents (Issue #19)'),
|
|
64
|
-
maxConcurrentSubagents: z.number().default(3).description('Maximum concurrent subagents per parent session (Issue #93)'),
|
|
65
|
-
subagentGracePeriodMs: z.number().default(180000).description('Grace period before archiving completed one-shot subagents (Issue #56)'),
|
|
66
|
-
smartModelRouting: z.boolean().default(false).description('Smart Model Routing: dynamically select model based on task complexity (Issue #48)'),
|
|
67
|
-
allowedProviders: z.array(z.string()).default([]).description('Whitelist of allowed LLM provider IDs (Issue #48)'),
|
|
68
|
-
failFastModelValidation: z.boolean().default(false).description('Fail-fast when requested model is not found in provider registry (Issue #82)'),
|
|
69
|
-
maxStoredSessions: z.number().default(400).description('Capacity recycling threshold for subagent sessions (Issue #67)'),
|
|
70
|
-
sessionRetentionMs: z.number().default(86400000).description('Retention duration in ms for sessions-archive before physical deletion (Issue #57)'),
|
|
99
|
+
enabled: z.boolean().default(true).description('Enable Multi-Agent Orchestrator pipeline dispatcher').volatile(),
|
|
100
|
+
defaultScenario: z.string().default('auto').description('Default complexity scenario (auto, hotfix, simple, medium, complex, enterprise)').volatile(),
|
|
101
|
+
disableNestedDelegation: z.boolean().default(false).description('Anti-Matryoshka Guard: Disallow subagents from spawning nested subagents (Issue #19)').volatile(),
|
|
102
|
+
maxConcurrentSubagents: z.number().default(3).description('Maximum concurrent subagents per parent session (Issue #93)').volatile(),
|
|
103
|
+
subagentGracePeriodMs: z.number().default(180000).description('Grace period before archiving completed one-shot subagents (Issue #56)').volatile(),
|
|
104
|
+
smartModelRouting: z.boolean().default(false).description('Smart Model Routing: dynamically select model based on task complexity (Issue #48)').volatile(),
|
|
105
|
+
allowedProviders: z.array(z.string()).default([]).description('Whitelist of allowed LLM provider IDs (Issue #48)').volatile(),
|
|
106
|
+
failFastModelValidation: z.boolean().default(false).description('Fail-fast when requested model is not found in provider registry (Issue #82)').volatile(),
|
|
107
|
+
maxStoredSessions: z.number().default(400).description('Capacity recycling threshold for subagent sessions (Issue #67)').volatile(),
|
|
108
|
+
sessionRetentionMs: z.number().default(86400000).description('Retention duration in ms for sessions-archive before physical deletion (Issue #57)').volatile(),
|
|
109
|
+
roles: (typeof z.any === 'function' ? z.any() : z.object({})).default(null).description('Custom specialist roles roster').volatile(),
|
|
110
|
+
scenarios: (typeof z.any === 'function' ? z.any() : z.object({})).default(null).description('Custom execution scenarios DAG dictionary').volatile(),
|
|
111
|
+
kanbanSync: (typeof z.any === 'function' ? z.any() : z.object({})).default(null).description('Kanban board synchronization configuration').volatile(),
|
|
71
112
|
})
|
|
72
113
|
|
|
73
|
-
export function apply(ctx,
|
|
114
|
+
export function apply(ctx, rawConfig = {}) {
|
|
74
115
|
const NS = 'dsh-agent-orchestrator'
|
|
75
116
|
const logger = ctx?.logger || {
|
|
76
117
|
debug: () => {},
|
|
@@ -78,6 +119,7 @@ export function apply(ctx, config = {}) {
|
|
|
78
119
|
warn: () => {},
|
|
79
120
|
error: () => {},
|
|
80
121
|
}
|
|
122
|
+
const config = plainConfig(rawConfig)
|
|
81
123
|
let currentSettings = {
|
|
82
124
|
...config,
|
|
83
125
|
disableNestedDelegation: Boolean(config.disableNestedDelegation),
|
|
@@ -96,45 +138,145 @@ export function apply(ctx, config = {}) {
|
|
|
96
138
|
},
|
|
97
139
|
}
|
|
98
140
|
|
|
99
|
-
|
|
141
|
+
const applyLoadedConfig = (rawSaved) => {
|
|
142
|
+
const saved = plainConfig(rawSaved)
|
|
143
|
+
if (!saved || typeof saved !== 'object') return
|
|
144
|
+
const mergedRoles = saved.roles || currentSettings.roles || getDefaultRoles()
|
|
145
|
+
const mergedScenarios = saved.scenarios || currentSettings.scenarios || getDefaultScenarios()
|
|
146
|
+
const syncRes = syncDefaultPresets({
|
|
147
|
+
currentRoles: mergedRoles,
|
|
148
|
+
currentScenarios: mergedScenarios,
|
|
149
|
+
logger,
|
|
150
|
+
})
|
|
151
|
+
currentSettings = {
|
|
152
|
+
...currentSettings,
|
|
153
|
+
...saved,
|
|
154
|
+
disableNestedDelegation: saved.disableNestedDelegation ?? currentSettings.disableNestedDelegation,
|
|
155
|
+
smartModelRouting: saved.smartModelRouting ?? currentSettings.smartModelRouting,
|
|
156
|
+
allowedProviders: Array.isArray(saved.allowedProviders) ? saved.allowedProviders : currentSettings.allowedProviders,
|
|
157
|
+
failFastModelValidation: saved.failFastModelValidation ?? currentSettings.failFastModelValidation,
|
|
158
|
+
maxStoredSessions: saved.maxStoredSessions ?? currentSettings.maxStoredSessions,
|
|
159
|
+
sessionRetentionMs: saved.sessionRetentionMs ?? currentSettings.sessionRetentionMs,
|
|
160
|
+
roles: syncRes.roles,
|
|
161
|
+
scenarios: syncRes.scenarios,
|
|
162
|
+
kanbanSync: { ...currentSettings.kanbanSync, ...(saved.kanbanSync || {}) },
|
|
163
|
+
}
|
|
164
|
+
}
|
|
100
165
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
166
|
+
let settingsAdapter = null
|
|
167
|
+
|
|
168
|
+
const createSettingsAdapter = (svc) => {
|
|
169
|
+
if (!svc) return null
|
|
170
|
+
|
|
171
|
+
// Legacy DSH settings service (v0.1.6 and earlier)
|
|
172
|
+
if (typeof svc.register === 'function') {
|
|
173
|
+
const scope = svc.register(NS, Config, { base: config })
|
|
174
|
+
return {
|
|
175
|
+
type: 'legacy',
|
|
176
|
+
get: () => scope?.get?.(),
|
|
177
|
+
persist: async (fullConfig) => {
|
|
178
|
+
if (!scope?.set) return
|
|
179
|
+
for (const [k, v] of Object.entries(fullConfig)) {
|
|
180
|
+
try {
|
|
181
|
+
await scope.set(k, v)
|
|
182
|
+
} catch (err) {
|
|
183
|
+
logger.debug('[dsh-agent-orchestrator] Legacy setting set warning:', k, err?.message || err)
|
|
184
|
+
}
|
|
116
185
|
}
|
|
186
|
+
},
|
|
187
|
+
watch: (cb) => {
|
|
188
|
+
if (typeof scope?.watch === 'function') return scope.watch(cb)
|
|
189
|
+
return () => {}
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Modern DSH SettingsForms service (v0.1.7-rc.1+)
|
|
195
|
+
if (typeof svc.replace === 'function' || typeof svc.update === 'function') {
|
|
196
|
+
const resolveNsAndRevision = () => {
|
|
197
|
+
try {
|
|
198
|
+
const list = typeof svc.describe === 'function' ? svc.describe() : []
|
|
199
|
+
const entry = list.find((row) => row.ns === name || row.ns === NS || row.ns?.includes('dsh-agent-orchestrator'))
|
|
200
|
+
if (entry) {
|
|
201
|
+
return { ns: entry.ns, revision: entry.revision, value: entry.value }
|
|
202
|
+
}
|
|
203
|
+
} catch (err) {
|
|
204
|
+
logger.debug('[dsh-agent-orchestrator] Settings describe error:', err?.message || err)
|
|
117
205
|
}
|
|
206
|
+
return { ns: NS, revision: undefined, value: undefined }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
type: 'modern',
|
|
211
|
+
get: () => {
|
|
212
|
+
const { value } = resolveNsAndRevision()
|
|
213
|
+
return value
|
|
214
|
+
},
|
|
215
|
+
persist: async (fullConfig) => {
|
|
216
|
+
const { ns: targetNs, revision } = resolveNsAndRevision()
|
|
217
|
+
const payload = plainConfig(fullConfig)
|
|
218
|
+
if (typeof svc.replace === 'function') {
|
|
219
|
+
await svc.replace(targetNs, payload, revision)
|
|
220
|
+
} else if (typeof svc.update === 'function') {
|
|
221
|
+
await svc.update(targetNs, payload, revision)
|
|
222
|
+
} else {
|
|
223
|
+
throw new Error('Settings service does not support replace or update')
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
watch: (cb) => {
|
|
227
|
+
if (typeof ctx.on === 'function') {
|
|
228
|
+
return ctx.on('settings/document-updated', (id) => {
|
|
229
|
+
if (id === name || id === NS || id?.includes('dsh-agent-orchestrator')) {
|
|
230
|
+
const { value } = resolveNsAndRevision()
|
|
231
|
+
cb(value)
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
return () => {}
|
|
236
|
+
},
|
|
118
237
|
}
|
|
119
|
-
} catch (e) {
|
|
120
|
-
logger.warn('[dsh-agent-orchestrator] Settings register warning:', e?.message || e)
|
|
121
238
|
}
|
|
122
|
-
})
|
|
123
239
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
240
|
+
return null
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Register settings scope or adapter
|
|
244
|
+
if (typeof ctx.inject === 'function') {
|
|
245
|
+
ctx.inject(['settings'], (sctx) => {
|
|
128
246
|
try {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
247
|
+
settingsAdapter = createSettingsAdapter(sctx.settings)
|
|
248
|
+
if (settingsAdapter) {
|
|
249
|
+
const saved = settingsAdapter.get()
|
|
250
|
+
if (saved) {
|
|
251
|
+
applyLoadedConfig(saved)
|
|
252
|
+
}
|
|
253
|
+
if (typeof sctx.effect === 'function') {
|
|
254
|
+
sctx.effect(() => settingsAdapter.watch((next) => {
|
|
255
|
+
if (next) applyLoadedConfig(next)
|
|
256
|
+
}), 'dsh-agent-orchestrator: settings watch')
|
|
132
257
|
}
|
|
133
258
|
}
|
|
134
|
-
} catch (
|
|
135
|
-
logger.
|
|
259
|
+
} catch (e) {
|
|
260
|
+
logger.warn('[dsh-agent-orchestrator] Settings register warning:', e?.message || e)
|
|
136
261
|
}
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const getConfig = () => currentSettings
|
|
266
|
+
const updateConfig = async (rawPartial) => {
|
|
267
|
+
if (!settingsAdapter) {
|
|
268
|
+
throw new Error('Settings service is unavailable')
|
|
137
269
|
}
|
|
270
|
+
const partial = plainConfig(rawPartial)
|
|
271
|
+
const nextSettings = plainConfig({
|
|
272
|
+
...currentSettings,
|
|
273
|
+
...partial,
|
|
274
|
+
kanbanSync: partial?.kanbanSync
|
|
275
|
+
? { ...currentSettings.kanbanSync, ...partial.kanbanSync }
|
|
276
|
+
: currentSettings.kanbanSync,
|
|
277
|
+
})
|
|
278
|
+
await settingsAdapter.persist(nextSettings)
|
|
279
|
+
currentSettings = nextSettings
|
|
138
280
|
}
|
|
139
281
|
|
|
140
282
|
// Self-Syncing Presets & Roles (Issue #107)
|
package/lib/routes.js
CHANGED
|
@@ -20,11 +20,34 @@ function jsonReply(res, data, status = 200) {
|
|
|
20
20
|
res.end(JSON.stringify(data))
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
export function toPublicPipelineDto(pipe) {
|
|
24
|
+
if (!pipe) return null
|
|
25
|
+
const stages = Array.isArray(pipe.stages)
|
|
26
|
+
? pipe.stages.map((s) => ({
|
|
27
|
+
id: s.id,
|
|
28
|
+
name: s.name,
|
|
29
|
+
roleId: s.roleId,
|
|
30
|
+
status: s.status,
|
|
31
|
+
}))
|
|
32
|
+
: []
|
|
33
|
+
return {
|
|
34
|
+
pipelineId: pipe.pipelineId || pipe.id,
|
|
35
|
+
scenarioId: pipe.scenarioId,
|
|
36
|
+
scenarioTitle: pipe.scenarioTitle,
|
|
37
|
+
status: pipe.status,
|
|
38
|
+
startedAt: pipe.startedAt || pipe.createdAt || null,
|
|
39
|
+
completedAt: pipe.completedAt || null,
|
|
40
|
+
durationMs: pipe.durationMs || 0,
|
|
41
|
+
stages,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
23
46
|
export function registerOrchestratorRoutes(ctx, services) {
|
|
24
47
|
const { store, runner, getConfig, updateConfig, callLlm, snapshotManager = new SnapshotManager() } = services
|
|
25
48
|
|
|
26
49
|
// 1. Status & Global Metrics (Read-only status overview)
|
|
27
|
-
// Conscious architectural choice (Issue #113): Public read-only endpoint returning non-sensitive metrics
|
|
50
|
+
// Conscious architectural choice (Issue #113): Public read-only endpoint returning non-sensitive metrics only
|
|
28
51
|
ctx.effect(() => ctx.webServer.register({
|
|
29
52
|
kind: 'exact',
|
|
30
53
|
path: '/dsh-agent-orchestrator/status',
|
|
@@ -32,10 +55,12 @@ export function registerOrchestratorRoutes(ctx, services) {
|
|
|
32
55
|
if (req.method !== 'GET') {
|
|
33
56
|
return jsonReply(res, { error: 'Method not allowed' }, 405)
|
|
34
57
|
}
|
|
58
|
+
const active = store.getActivePipelines().map(toPublicPipelineDto)
|
|
59
|
+
const recent = store.getAllPipelines().slice(0, 10).map(toPublicPipelineDto)
|
|
35
60
|
jsonReply(res, {
|
|
36
|
-
activePipelines:
|
|
61
|
+
activePipelines: active,
|
|
37
62
|
metrics: store.getMetrics(),
|
|
38
|
-
recentPipelines:
|
|
63
|
+
recentPipelines: recent,
|
|
39
64
|
})
|
|
40
65
|
},
|
|
41
66
|
}), 'dsh-agent-orchestrator: status route')
|
|
@@ -183,8 +208,12 @@ export function registerOrchestratorRoutes(ctx, services) {
|
|
|
183
208
|
}
|
|
184
209
|
}
|
|
185
210
|
}
|
|
186
|
-
|
|
187
|
-
|
|
211
|
+
try {
|
|
212
|
+
await updateConfig(body)
|
|
213
|
+
return jsonReply(res, { success: true, config: getConfig() })
|
|
214
|
+
} catch (err) {
|
|
215
|
+
return jsonReply(res, { success: false, error: err?.message || 'Failed to persist configuration' }, 500)
|
|
216
|
+
}
|
|
188
217
|
}
|
|
189
218
|
if (req.method !== 'GET') {
|
|
190
219
|
return jsonReply(res, { error: 'Method not allowed' }, 405)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-agent-orchestrator",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"description": "Multi-agent task decomposition, DAG workflow orchestration, and prompt caching optimizer for DeepSeek Harness.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|