@goodandready/dsh-agent-orchestrator 0.1.9 → 0.1.11
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/client.js +12 -3
- package/lib/http-guard.js +68 -26
- package/lib/index.js +199 -49
- package/lib/pipeline/dag-engine.js +44 -4
- package/lib/pipeline/delegation.js +11 -1
- package/lib/pipeline/intent.js +4 -1
- package/lib/pipeline/model-selection.js +0 -1
- package/lib/pipeline/snapshots.js +9 -2
- package/lib/pipeline/worker-pool.js +1 -0
- package/lib/routes.js +56 -5
- package/lib/store.js +16 -3
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -897,7 +897,14 @@ window.__ModuleLoader__.load({
|
|
|
897
897
|
if (res.ok) {
|
|
898
898
|
const data = await res.json()
|
|
899
899
|
const active = (data.activePipelines || [])[0] || null
|
|
900
|
-
setActivePipeline(
|
|
900
|
+
setActivePipeline((prev) => {
|
|
901
|
+
if (!prev && !active) return null
|
|
902
|
+
if (!prev || !active) return active
|
|
903
|
+
if (prev.pipelineId === active.pipelineId && prev.status === active.status && prev.durationMs === active.durationMs) {
|
|
904
|
+
return prev
|
|
905
|
+
}
|
|
906
|
+
return active
|
|
907
|
+
})
|
|
901
908
|
|
|
902
909
|
// If no active, check recent completed/failed within last 60 seconds
|
|
903
910
|
if (!active && data.recentPipelines && data.recentPipelines.length > 0) {
|
|
@@ -917,12 +924,14 @@ window.__ModuleLoader__.load({
|
|
|
917
924
|
}
|
|
918
925
|
}, [dismissedId])
|
|
919
926
|
|
|
927
|
+
const hasActive = Boolean(activePipeline)
|
|
928
|
+
|
|
920
929
|
useEffect(() => {
|
|
921
930
|
pollStatus()
|
|
922
|
-
const intervalMs =
|
|
931
|
+
const intervalMs = hasActive ? 2000 : 5000
|
|
923
932
|
const timer = setInterval(pollStatus, intervalMs)
|
|
924
933
|
return () => clearInterval(timer)
|
|
925
|
-
}, [
|
|
934
|
+
}, [hasActive, pollStatus])
|
|
926
935
|
|
|
927
936
|
const handleCancel = async (pipelineId) => {
|
|
928
937
|
try {
|
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
|
|
|
@@ -58,16 +76,19 @@ export const name = '@goodandready/dsh-agent-orchestrator'
|
|
|
58
76
|
export const inject = ['settings', 'webServer', 'llm', 'tools', 'commands', 'systemPrompt', 'subagents']
|
|
59
77
|
|
|
60
78
|
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)'),
|
|
79
|
+
enabled: z.boolean().default(true).description('Enable Multi-Agent Orchestrator pipeline dispatcher').volatile(),
|
|
80
|
+
defaultScenario: z.string().default('auto').description('Default complexity scenario (auto, hotfix, simple, medium, complex, enterprise)').volatile(),
|
|
81
|
+
disableNestedDelegation: z.boolean().default(false).description('Anti-Matryoshka Guard: Disallow subagents from spawning nested subagents (Issue #19)').volatile(),
|
|
82
|
+
maxConcurrentSubagents: z.number().default(3).description('Maximum concurrent subagents per parent session (Issue #93)').volatile(),
|
|
83
|
+
subagentGracePeriodMs: z.number().default(180000).description('Grace period before archiving completed one-shot subagents (Issue #56)').volatile(),
|
|
84
|
+
smartModelRouting: z.boolean().default(false).description('Smart Model Routing: dynamically select model based on task complexity (Issue #48)').volatile(),
|
|
85
|
+
allowedProviders: z.array(z.string()).default([]).description('Whitelist of allowed LLM provider IDs (Issue #48)').volatile(),
|
|
86
|
+
failFastModelValidation: z.boolean().default(false).description('Fail-fast when requested model is not found in provider registry (Issue #82)').volatile(),
|
|
87
|
+
maxStoredSessions: z.number().default(400).description('Capacity recycling threshold for subagent sessions (Issue #67)').volatile(),
|
|
88
|
+
sessionRetentionMs: z.number().default(86400000).description('Retention duration in ms for sessions-archive before physical deletion (Issue #57)').volatile(),
|
|
89
|
+
roles: (typeof z.any === 'function' ? z.any() : z.object({})).default(null).description('Custom specialist roles roster').volatile(),
|
|
90
|
+
scenarios: (typeof z.any === 'function' ? z.any() : z.object({})).default(null).description('Custom execution scenarios DAG dictionary').volatile(),
|
|
91
|
+
kanbanSync: (typeof z.any === 'function' ? z.any() : z.object({})).default(null).description('Kanban board synchronization configuration').volatile(),
|
|
71
92
|
})
|
|
72
93
|
|
|
73
94
|
export function apply(ctx, config = {}) {
|
|
@@ -96,45 +117,142 @@ export function apply(ctx, config = {}) {
|
|
|
96
117
|
},
|
|
97
118
|
}
|
|
98
119
|
|
|
99
|
-
|
|
120
|
+
const applyLoadedConfig = (saved) => {
|
|
121
|
+
if (!saved || typeof saved !== 'object') return
|
|
122
|
+
const mergedRoles = saved.roles || currentSettings.roles || getDefaultRoles()
|
|
123
|
+
const mergedScenarios = saved.scenarios || currentSettings.scenarios || getDefaultScenarios()
|
|
124
|
+
const syncRes = syncDefaultPresets({
|
|
125
|
+
currentRoles: mergedRoles,
|
|
126
|
+
currentScenarios: mergedScenarios,
|
|
127
|
+
logger,
|
|
128
|
+
})
|
|
129
|
+
currentSettings = {
|
|
130
|
+
...currentSettings,
|
|
131
|
+
...saved,
|
|
132
|
+
disableNestedDelegation: saved.disableNestedDelegation ?? currentSettings.disableNestedDelegation,
|
|
133
|
+
smartModelRouting: saved.smartModelRouting ?? currentSettings.smartModelRouting,
|
|
134
|
+
allowedProviders: Array.isArray(saved.allowedProviders) ? saved.allowedProviders : currentSettings.allowedProviders,
|
|
135
|
+
failFastModelValidation: saved.failFastModelValidation ?? currentSettings.failFastModelValidation,
|
|
136
|
+
maxStoredSessions: saved.maxStoredSessions ?? currentSettings.maxStoredSessions,
|
|
137
|
+
sessionRetentionMs: saved.sessionRetentionMs ?? currentSettings.sessionRetentionMs,
|
|
138
|
+
roles: syncRes.roles,
|
|
139
|
+
scenarios: syncRes.scenarios,
|
|
140
|
+
kanbanSync: { ...currentSettings.kanbanSync, ...(saved.kanbanSync || {}) },
|
|
141
|
+
}
|
|
142
|
+
}
|
|
100
143
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
144
|
+
let settingsAdapter = null
|
|
145
|
+
|
|
146
|
+
const createSettingsAdapter = (svc) => {
|
|
147
|
+
if (!svc) return null
|
|
148
|
+
|
|
149
|
+
// Legacy DSH settings service (v0.1.6 and earlier)
|
|
150
|
+
if (typeof svc.register === 'function') {
|
|
151
|
+
const scope = svc.register(NS, Config, { base: config })
|
|
152
|
+
return {
|
|
153
|
+
type: 'legacy',
|
|
154
|
+
get: () => scope?.get?.(),
|
|
155
|
+
persist: async (fullConfig) => {
|
|
156
|
+
if (!scope?.set) return
|
|
157
|
+
for (const [k, v] of Object.entries(fullConfig)) {
|
|
158
|
+
try {
|
|
159
|
+
await scope.set(k, v)
|
|
160
|
+
} catch (err) {
|
|
161
|
+
logger.debug('[dsh-agent-orchestrator] Legacy setting set warning:', k, err?.message || err)
|
|
162
|
+
}
|
|
116
163
|
}
|
|
164
|
+
},
|
|
165
|
+
watch: (cb) => {
|
|
166
|
+
if (typeof scope?.watch === 'function') return scope.watch(cb)
|
|
167
|
+
return () => {}
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Modern DSH SettingsForms service (v0.1.7-rc.1+)
|
|
173
|
+
if (typeof svc.replace === 'function' || typeof svc.update === 'function') {
|
|
174
|
+
const resolveNsAndRevision = () => {
|
|
175
|
+
try {
|
|
176
|
+
const list = typeof svc.describe === 'function' ? svc.describe() : []
|
|
177
|
+
const entry = list.find((row) => row.ns === name || row.ns === NS || row.ns?.includes('dsh-agent-orchestrator'))
|
|
178
|
+
if (entry) {
|
|
179
|
+
return { ns: entry.ns, revision: entry.revision, value: entry.value }
|
|
180
|
+
}
|
|
181
|
+
} catch (err) {
|
|
182
|
+
logger.debug('[dsh-agent-orchestrator] Settings describe error:', err?.message || err)
|
|
117
183
|
}
|
|
184
|
+
return { ns: NS, revision: undefined, value: undefined }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
type: 'modern',
|
|
189
|
+
get: () => {
|
|
190
|
+
const { value } = resolveNsAndRevision()
|
|
191
|
+
return value
|
|
192
|
+
},
|
|
193
|
+
persist: async (fullConfig) => {
|
|
194
|
+
const { ns: targetNs, revision } = resolveNsAndRevision()
|
|
195
|
+
if (typeof svc.replace === 'function') {
|
|
196
|
+
await svc.replace(targetNs, fullConfig, revision)
|
|
197
|
+
} else if (typeof svc.update === 'function') {
|
|
198
|
+
await svc.update(targetNs, fullConfig, revision)
|
|
199
|
+
} else {
|
|
200
|
+
throw new Error('Settings service does not support replace or update')
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
watch: (cb) => {
|
|
204
|
+
if (typeof ctx.on === 'function') {
|
|
205
|
+
return ctx.on('settings/document-updated', (id) => {
|
|
206
|
+
if (id === name || id === NS || id?.includes('dsh-agent-orchestrator')) {
|
|
207
|
+
const { value } = resolveNsAndRevision()
|
|
208
|
+
cb(value)
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
return () => {}
|
|
213
|
+
},
|
|
118
214
|
}
|
|
119
|
-
} catch (e) {
|
|
120
|
-
logger.warn('[dsh-agent-orchestrator] Settings register warning:', e?.message || e)
|
|
121
215
|
}
|
|
122
|
-
})
|
|
123
216
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
217
|
+
return null
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Register settings scope or adapter
|
|
221
|
+
if (typeof ctx.inject === 'function') {
|
|
222
|
+
ctx.inject(['settings'], (sctx) => {
|
|
128
223
|
try {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
224
|
+
settingsAdapter = createSettingsAdapter(sctx.settings)
|
|
225
|
+
if (settingsAdapter) {
|
|
226
|
+
const saved = settingsAdapter.get()
|
|
227
|
+
if (saved) {
|
|
228
|
+
applyLoadedConfig(saved)
|
|
229
|
+
}
|
|
230
|
+
if (typeof sctx.effect === 'function') {
|
|
231
|
+
sctx.effect(() => settingsAdapter.watch((next) => {
|
|
232
|
+
if (next) applyLoadedConfig(next)
|
|
233
|
+
}), 'dsh-agent-orchestrator: settings watch')
|
|
132
234
|
}
|
|
133
235
|
}
|
|
134
|
-
} catch (
|
|
135
|
-
logger.
|
|
236
|
+
} catch (e) {
|
|
237
|
+
logger.warn('[dsh-agent-orchestrator] Settings register warning:', e?.message || e)
|
|
136
238
|
}
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const getConfig = () => currentSettings
|
|
243
|
+
const updateConfig = async (partial) => {
|
|
244
|
+
if (!settingsAdapter) {
|
|
245
|
+
throw new Error('Settings service is unavailable')
|
|
246
|
+
}
|
|
247
|
+
const nextSettings = {
|
|
248
|
+
...currentSettings,
|
|
249
|
+
...partial,
|
|
250
|
+
kanbanSync: partial?.kanbanSync
|
|
251
|
+
? { ...currentSettings.kanbanSync, ...partial.kanbanSync }
|
|
252
|
+
: currentSettings.kanbanSync,
|
|
137
253
|
}
|
|
254
|
+
await settingsAdapter.persist(nextSettings)
|
|
255
|
+
currentSettings = nextSettings
|
|
138
256
|
}
|
|
139
257
|
|
|
140
258
|
// Self-Syncing Presets & Roles (Issue #107)
|
|
@@ -292,10 +410,22 @@ export function apply(ctx, config = {}) {
|
|
|
292
410
|
}
|
|
293
411
|
}
|
|
294
412
|
|
|
413
|
+
const activeAbortControllers = new Map()
|
|
414
|
+
|
|
295
415
|
/**
|
|
296
416
|
* Runner object to start, execute, and monitor pipelines
|
|
297
417
|
*/
|
|
298
418
|
const runner = {
|
|
419
|
+
cancelPipeline(pipelineId) {
|
|
420
|
+
const controller = activeAbortControllers.get(pipelineId)
|
|
421
|
+
if (controller) {
|
|
422
|
+
controller.abort()
|
|
423
|
+
activeAbortControllers.delete(pipelineId)
|
|
424
|
+
return true
|
|
425
|
+
}
|
|
426
|
+
return false
|
|
427
|
+
},
|
|
428
|
+
|
|
299
429
|
async startPipeline({ taskTitle, taskDescription, scenarioId, taskId }) {
|
|
300
430
|
const cfg = getConfig()
|
|
301
431
|
const plan = decomposeTask({
|
|
@@ -369,17 +499,24 @@ export function apply(ctx, config = {}) {
|
|
|
369
499
|
}
|
|
370
500
|
}
|
|
371
501
|
|
|
502
|
+
const abortController = new AbortController()
|
|
503
|
+
activeAbortControllers.set(plan.pipelineId, abortController)
|
|
504
|
+
|
|
372
505
|
executeDAG({
|
|
373
506
|
stages: plan.stages,
|
|
374
507
|
executor,
|
|
375
508
|
concurrency: 3,
|
|
509
|
+
signal: abortController.signal,
|
|
376
510
|
})
|
|
377
511
|
.then(async (dagResult) => {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
512
|
+
activeAbortControllers.delete(plan.pipelineId)
|
|
513
|
+
const currentP = store.getPipeline(plan.pipelineId)
|
|
514
|
+
if (currentP?.status === 'failed' && currentP?.error === 'Cancelled by user') {
|
|
515
|
+
return
|
|
516
|
+
}
|
|
517
|
+
store.recordCompletion(plan.pipelineId, {
|
|
518
|
+
...dagResult,
|
|
381
519
|
durationMs: Date.now() - pipelineData.startedAt,
|
|
382
|
-
artifacts: dagResult.artifacts,
|
|
383
520
|
})
|
|
384
521
|
|
|
385
522
|
// Sync to Kanban if enabled
|
|
@@ -399,7 +536,7 @@ export function apply(ctx, config = {}) {
|
|
|
399
536
|
if (syncConfig.createChecklist) {
|
|
400
537
|
const checklistItems = plan.stages.map((s) => ({
|
|
401
538
|
title: `${s.name} (${s.roleName})`,
|
|
402
|
-
completed: dagResult
|
|
539
|
+
completed: dagResult?.stateMap?.[s.id]?.status === 'completed',
|
|
403
540
|
}))
|
|
404
541
|
await kanbanBridge.syncStageChecklist(targetTaskId, checklistItems)
|
|
405
542
|
}
|
|
@@ -419,7 +556,7 @@ export function apply(ctx, config = {}) {
|
|
|
419
556
|
id: plan.pipelineId,
|
|
420
557
|
title: plan.taskTitle,
|
|
421
558
|
scenarioId: plan.scenarioId,
|
|
422
|
-
status: dagResult
|
|
559
|
+
status: dagResult?.success ? 'completed' : 'failed',
|
|
423
560
|
stages: plan.stages,
|
|
424
561
|
durationMs: Date.now() - pipelineData.startedAt,
|
|
425
562
|
})
|
|
@@ -428,9 +565,13 @@ export function apply(ctx, config = {}) {
|
|
|
428
565
|
}
|
|
429
566
|
})
|
|
430
567
|
.catch((dagErr) => {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
568
|
+
activeAbortControllers.delete(plan.pipelineId)
|
|
569
|
+
const currentP = store.getPipeline(plan.pipelineId)
|
|
570
|
+
if (currentP?.status === 'failed' && currentP?.error === 'Cancelled by user') {
|
|
571
|
+
return
|
|
572
|
+
}
|
|
573
|
+
store.recordCompletion(plan.pipelineId, {
|
|
574
|
+
success: false,
|
|
434
575
|
durationMs: Date.now() - pipelineData.startedAt,
|
|
435
576
|
error: dagErr?.message || String(dagErr),
|
|
436
577
|
})
|
|
@@ -813,8 +954,11 @@ export function apply(ctx, config = {}) {
|
|
|
813
954
|
}
|
|
814
955
|
}
|
|
815
956
|
|
|
957
|
+
let hasCommandsService = false
|
|
958
|
+
|
|
816
959
|
// Chat slash commands: /orchestrate and /orc via commands service
|
|
817
960
|
ctx.inject(['commands'], (cctx) => {
|
|
961
|
+
hasCommandsService = true
|
|
818
962
|
try {
|
|
819
963
|
if (typeof cctx.commands?.register !== 'function') return
|
|
820
964
|
|
|
@@ -904,6 +1048,7 @@ export function apply(ctx, config = {}) {
|
|
|
904
1048
|
|
|
905
1049
|
if (typeof cctx.effect === 'function') {
|
|
906
1050
|
cctx.effect(() => () => {
|
|
1051
|
+
hasCommandsService = false
|
|
907
1052
|
try {
|
|
908
1053
|
if (typeof unregister1 === 'function') unregister1()
|
|
909
1054
|
if (typeof unregister2 === 'function') unregister2()
|
|
@@ -938,6 +1083,11 @@ export function apply(ctx, config = {}) {
|
|
|
938
1083
|
const text = String(event.text || event.content || '').trim()
|
|
939
1084
|
const intent = detectOrchestratorIntent(text)
|
|
940
1085
|
|
|
1086
|
+
// Prevent duplicate dispatch: if commands service is active, slash commands are handled by cctx.commands (#148)
|
|
1087
|
+
if (hasCommandsService && intent.triggerKind === 'slash') {
|
|
1088
|
+
return
|
|
1089
|
+
}
|
|
1090
|
+
|
|
941
1091
|
if (intent.isTrigger && intent.action === 'on') {
|
|
942
1092
|
await handlePipelineDispatch({
|
|
943
1093
|
taskTitle: intent.taskTitle || 'Interactive Orchestrated Task',
|
|
@@ -149,12 +149,14 @@ export async function executeDAG(stagesOrOptions, executorArg, optionsArg = {})
|
|
|
149
149
|
let onNodeComplete = () => {}
|
|
150
150
|
let onNodeFail = () => {}
|
|
151
151
|
let onNodeBlocked = () => {}
|
|
152
|
+
let signal = null
|
|
152
153
|
|
|
153
154
|
if (Array.isArray(stagesOrOptions)) {
|
|
154
155
|
stages = stagesOrOptions
|
|
155
156
|
executor = executorArg
|
|
156
157
|
concurrency = optionsArg.concurrency || optionsArg.maxConcurrency || 4
|
|
157
158
|
initialContext = optionsArg.initialContext || {}
|
|
159
|
+
signal = optionsArg.signal || null
|
|
158
160
|
if (typeof optionsArg.onNodeStart === 'function') onNodeStart = optionsArg.onNodeStart
|
|
159
161
|
if (typeof optionsArg.onNodeComplete === 'function') onNodeComplete = optionsArg.onNodeComplete
|
|
160
162
|
if (typeof optionsArg.onNodeFail === 'function') onNodeFail = optionsArg.onNodeFail
|
|
@@ -164,6 +166,7 @@ export async function executeDAG(stagesOrOptions, executorArg, optionsArg = {})
|
|
|
164
166
|
executor = stagesOrOptions.executor
|
|
165
167
|
concurrency = stagesOrOptions.concurrency || stagesOrOptions.maxConcurrency || 4
|
|
166
168
|
initialContext = stagesOrOptions.initialContext || {}
|
|
169
|
+
signal = stagesOrOptions.signal || null
|
|
167
170
|
if (typeof stagesOrOptions.onNodeStart === 'function') onNodeStart = stagesOrOptions.onNodeStart
|
|
168
171
|
if (typeof stagesOrOptions.onNodeComplete === 'function') onNodeComplete = stagesOrOptions.onNodeComplete
|
|
169
172
|
if (typeof stagesOrOptions.onNodeFail === 'function') onNodeFail = stagesOrOptions.onNodeFail
|
|
@@ -220,13 +223,33 @@ export async function executeDAG(stagesOrOptions, executorArg, optionsArg = {})
|
|
|
220
223
|
function triggerNext() {
|
|
221
224
|
if (isDone) return
|
|
222
225
|
|
|
226
|
+
if (signal?.aborted) {
|
|
227
|
+
if (!isDone && runningCount === 0) {
|
|
228
|
+
isDone = true
|
|
229
|
+
for (const s of stages) {
|
|
230
|
+
const st = stateMap.get(s.id)
|
|
231
|
+
if (st.status === NODE_STATUS.PENDING) {
|
|
232
|
+
st.status = NODE_STATUS.SKIPPED
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
resolvePromise({
|
|
236
|
+
success: false,
|
|
237
|
+
cancelled: true,
|
|
238
|
+
durationMs: Date.now() - startTime,
|
|
239
|
+
stateMap: Object.fromEntries(stateMap),
|
|
240
|
+
artifacts: Object.fromEntries(artifacts),
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
|
|
223
246
|
// Propagate blockers first
|
|
224
247
|
if (markBlockedStages(stages, stateMap)) {
|
|
225
248
|
for (const s of stages) {
|
|
226
249
|
const st = stateMap.get(s.id)
|
|
227
250
|
if (st.status === NODE_STATUS.BLOCKED && !st.notifiedBlocked) {
|
|
228
251
|
st.notifiedBlocked = true
|
|
229
|
-
onNodeBlocked(st)
|
|
252
|
+
try { onNodeBlocked(st) } catch (_) { /* Safe ignore notification error */ }
|
|
230
253
|
}
|
|
231
254
|
}
|
|
232
255
|
}
|
|
@@ -241,7 +264,7 @@ export async function executeDAG(stagesOrOptions, executorArg, optionsArg = {})
|
|
|
241
264
|
nodeState.startedAt = Date.now()
|
|
242
265
|
runningCount++
|
|
243
266
|
|
|
244
|
-
onNodeStart(nodeState)
|
|
267
|
+
try { onNodeStart(nodeState) } catch (_) { /* Safe ignore notification error */ }
|
|
245
268
|
|
|
246
269
|
// Run task asynchronously
|
|
247
270
|
const upstreamOutputs = {}
|
|
@@ -266,17 +289,28 @@ export async function executeDAG(stagesOrOptions, executorArg, optionsArg = {})
|
|
|
266
289
|
|
|
267
290
|
artifacts.set(nextStage.id, nodeState.output)
|
|
268
291
|
runningCount--
|
|
269
|
-
|
|
292
|
+
try {
|
|
293
|
+
onNodeComplete(nodeState)
|
|
294
|
+
} catch (_) {
|
|
295
|
+
/* Callback error must not alter completed state or double-decrement runningCount */
|
|
296
|
+
}
|
|
270
297
|
triggerNext()
|
|
271
298
|
})
|
|
272
299
|
.catch((err) => {
|
|
300
|
+
if (nodeState.status === NODE_STATUS.COMPLETED) {
|
|
301
|
+
return
|
|
302
|
+
}
|
|
273
303
|
nodeState.status = NODE_STATUS.FAILED
|
|
274
304
|
nodeState.completedAt = Date.now()
|
|
275
305
|
nodeState.durationMs = nodeState.completedAt - nodeState.startedAt
|
|
276
306
|
nodeState.error = err?.message || String(err)
|
|
277
307
|
|
|
278
308
|
runningCount--
|
|
279
|
-
|
|
309
|
+
try {
|
|
310
|
+
onNodeFail(nodeState, err)
|
|
311
|
+
} catch (_) {
|
|
312
|
+
/* Callback error must not crash the engine */
|
|
313
|
+
}
|
|
280
314
|
triggerNext()
|
|
281
315
|
})
|
|
282
316
|
}
|
|
@@ -284,6 +318,12 @@ export async function executeDAG(stagesOrOptions, executorArg, optionsArg = {})
|
|
|
284
318
|
checkCompletion()
|
|
285
319
|
}
|
|
286
320
|
|
|
321
|
+
if (signal) {
|
|
322
|
+
signal.addEventListener('abort', () => {
|
|
323
|
+
triggerNext()
|
|
324
|
+
}, { once: true })
|
|
325
|
+
}
|
|
326
|
+
|
|
287
327
|
// Initial trigger
|
|
288
328
|
triggerNext()
|
|
289
329
|
})
|
|
@@ -152,8 +152,18 @@ export function normalizeRoleId(input, availableRoles = []) {
|
|
|
152
152
|
if (role.displayName && role.displayName.toLowerCase() === lower) return role.id
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
function matchesRoleAlias(str, alias) {
|
|
156
|
+
if (str === alias) return true
|
|
157
|
+
if (alias.length <= 3) {
|
|
158
|
+
const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
159
|
+
const regex = new RegExp('(^|[^a-z0-9а-яё])' + escaped + '([^a-z0-9а-яё]|$)', 'i')
|
|
160
|
+
return regex.test(str)
|
|
161
|
+
}
|
|
162
|
+
return str.includes(alias)
|
|
163
|
+
}
|
|
164
|
+
|
|
155
165
|
for (const [alias, id] of Object.entries(ROLE_INPUT_ALIASES)) {
|
|
156
|
-
if (lower
|
|
166
|
+
if (matchesRoleAlias(lower, alias)) {
|
|
157
167
|
const found = rolesList.find((r) => r.id === id)
|
|
158
168
|
if (found) return found.id
|
|
159
169
|
if (id === 'code') {
|
package/lib/pipeline/intent.js
CHANGED
|
@@ -51,12 +51,13 @@ export function detectOrchestratorIntent(text) {
|
|
|
51
51
|
if (slashMatch) {
|
|
52
52
|
const rest = (slashMatch[1] || '').trim()
|
|
53
53
|
if (rest.toLowerCase() === 'off') {
|
|
54
|
-
return { isTrigger: true, action: 'off' }
|
|
54
|
+
return { isTrigger: true, action: 'off', triggerKind: 'slash' }
|
|
55
55
|
}
|
|
56
56
|
const { scenarioId, taskTitle } = extractScenarioAndTask(rest)
|
|
57
57
|
return {
|
|
58
58
|
isTrigger: true,
|
|
59
59
|
action: 'on',
|
|
60
|
+
triggerKind: 'slash',
|
|
60
61
|
scenarioId,
|
|
61
62
|
taskTitle,
|
|
62
63
|
}
|
|
@@ -73,6 +74,7 @@ export function detectOrchestratorIntent(text) {
|
|
|
73
74
|
return {
|
|
74
75
|
isTrigger: true,
|
|
75
76
|
action: 'on',
|
|
77
|
+
triggerKind: 'nl',
|
|
76
78
|
scenarioId,
|
|
77
79
|
taskTitle,
|
|
78
80
|
}
|
|
@@ -89,6 +91,7 @@ export function detectOrchestratorIntent(text) {
|
|
|
89
91
|
return {
|
|
90
92
|
isTrigger: true,
|
|
91
93
|
action: 'on',
|
|
94
|
+
triggerKind: 'nl',
|
|
92
95
|
scenarioId,
|
|
93
96
|
taskTitle,
|
|
94
97
|
}
|
|
@@ -569,7 +569,6 @@ export async function fetchModelCatalog(ctx, options = {}) {
|
|
|
569
569
|
const defaultPool = [
|
|
570
570
|
{ provider: 'deepseek-official', model: 'deepseek-chat', maxTokens: 4096 },
|
|
571
571
|
{ provider: 'deepseek-official', model: 'deepseek-reasoner', maxTokens: 8192 },
|
|
572
|
-
{ provider: 'anthropic', model: 'claude-3-5-sonnet', maxTokens: 8192 },
|
|
573
572
|
]
|
|
574
573
|
for (const item of defaultPool) {
|
|
575
574
|
const capabilities = inferModelCapabilities(item.model, item.provider)
|
|
@@ -23,6 +23,7 @@ export class SnapshotManager {
|
|
|
23
23
|
this.baseDir = baseDir || path.join(os.homedir(), '.dsh', 'orchestrator-snapshots')
|
|
24
24
|
this.maxSnapshots = maxSnapshots
|
|
25
25
|
this.logger = logger || null
|
|
26
|
+
this._snapshotsCache = null
|
|
26
27
|
this._ensureDir()
|
|
27
28
|
}
|
|
28
29
|
|
|
@@ -65,6 +66,9 @@ export class SnapshotManager {
|
|
|
65
66
|
*/
|
|
66
67
|
listSnapshots() {
|
|
67
68
|
this._ensureDir()
|
|
69
|
+
if (this._snapshotsCache) {
|
|
70
|
+
return [...this._snapshotsCache]
|
|
71
|
+
}
|
|
68
72
|
try {
|
|
69
73
|
const files = fs.readdirSync(this.baseDir)
|
|
70
74
|
const snapshots = []
|
|
@@ -91,7 +95,8 @@ export class SnapshotManager {
|
|
|
91
95
|
return timeB - timeA
|
|
92
96
|
})
|
|
93
97
|
|
|
94
|
-
|
|
98
|
+
this._snapshotsCache = snapshots
|
|
99
|
+
return [...snapshots]
|
|
95
100
|
} catch (err) {
|
|
96
101
|
if (this.logger?.error) {
|
|
97
102
|
this.logger.error('[SnapshotManager] Error listing snapshots:', err?.message || err)
|
|
@@ -158,7 +163,7 @@ export class SnapshotManager {
|
|
|
158
163
|
const tmpPath = path.join(this.baseDir, `${snapshotId}.tmp-${Date.now()}`)
|
|
159
164
|
|
|
160
165
|
try {
|
|
161
|
-
fs.writeFileSync(tmpPath, JSON.stringify(record
|
|
166
|
+
fs.writeFileSync(tmpPath, JSON.stringify(record), 'utf8')
|
|
162
167
|
fs.renameSync(tmpPath, filePath)
|
|
163
168
|
} catch (err) {
|
|
164
169
|
try {
|
|
@@ -169,6 +174,7 @@ export class SnapshotManager {
|
|
|
169
174
|
throw err
|
|
170
175
|
}
|
|
171
176
|
|
|
177
|
+
this._snapshotsCache = null
|
|
172
178
|
// Enforce FIFO retention policy: if file count > maxSnapshots, prune oldest
|
|
173
179
|
this._enforceRetention()
|
|
174
180
|
|
|
@@ -179,6 +185,7 @@ export class SnapshotManager {
|
|
|
179
185
|
* Prunes oldest snapshot files when total exceeds maxSnapshots ceiling.
|
|
180
186
|
*/
|
|
181
187
|
_enforceRetention() {
|
|
188
|
+
this._snapshotsCache = null
|
|
182
189
|
try {
|
|
183
190
|
const files = fs.readdirSync(this.baseDir).filter((f) => f.endsWith('.json'))
|
|
184
191
|
if (files.length <= this.maxSnapshots) return
|
|
@@ -84,6 +84,7 @@ export async function executeStageWorker(stage, context) {
|
|
|
84
84
|
output: artContent || '',
|
|
85
85
|
})
|
|
86
86
|
}
|
|
87
|
+
priorArtifactsList.sort((a, b) => a.stageId.localeCompare(b.stageId))
|
|
87
88
|
const cumulativeArtifacts = formatCumulativeArtifacts(priorArtifactsList)
|
|
88
89
|
|
|
89
90
|
// 4. Assemble canonical prompt (Layer 1 + 2 + 3 in System, Layer 4 in User)
|
package/lib/routes.js
CHANGED
|
@@ -20,19 +20,47 @@ 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',
|
|
31
54
|
handler: (req, res) => {
|
|
55
|
+
if (req.method !== 'GET') {
|
|
56
|
+
return jsonReply(res, { error: 'Method not allowed' }, 405)
|
|
57
|
+
}
|
|
58
|
+
const active = store.getActivePipelines().map(toPublicPipelineDto)
|
|
59
|
+
const recent = store.getAllPipelines().slice(0, 10).map(toPublicPipelineDto)
|
|
32
60
|
jsonReply(res, {
|
|
33
|
-
activePipelines:
|
|
61
|
+
activePipelines: active,
|
|
34
62
|
metrics: store.getMetrics(),
|
|
35
|
-
recentPipelines:
|
|
63
|
+
recentPipelines: recent,
|
|
36
64
|
})
|
|
37
65
|
},
|
|
38
66
|
}), 'dsh-agent-orchestrator: status route')
|
|
@@ -112,6 +140,10 @@ export function registerOrchestratorRoutes(ctx, services) {
|
|
|
112
140
|
kind: 'prefix',
|
|
113
141
|
path: '/dsh-agent-orchestrator/pipeline',
|
|
114
142
|
handler: (req, res) => {
|
|
143
|
+
if (req.method !== 'GET') {
|
|
144
|
+
return jsonReply(res, { error: 'Method not allowed' }, 405)
|
|
145
|
+
}
|
|
146
|
+
if (rejectUntrustedRequest(req, res)) return
|
|
115
147
|
const parts = req.url.split('?')[0].split('/')
|
|
116
148
|
const pipelineId = parts[parts.length - 1]
|
|
117
149
|
const pipeline = store.getPipeline(pipelineId)
|
|
@@ -140,6 +172,7 @@ export function registerOrchestratorRoutes(ctx, services) {
|
|
|
140
172
|
}
|
|
141
173
|
|
|
142
174
|
const pipelineId = body.pipelineId
|
|
175
|
+
runner?.cancelPipeline?.(pipelineId)
|
|
143
176
|
const p = store.getPipeline(pipelineId)
|
|
144
177
|
if (p && (p.status === 'running' || p.status === 'pending')) {
|
|
145
178
|
store.updatePipeline(pipelineId, {
|
|
@@ -175,9 +208,17 @@ export function registerOrchestratorRoutes(ctx, services) {
|
|
|
175
208
|
}
|
|
176
209
|
}
|
|
177
210
|
}
|
|
178
|
-
|
|
179
|
-
|
|
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
|
+
}
|
|
180
217
|
}
|
|
218
|
+
if (req.method !== 'GET') {
|
|
219
|
+
return jsonReply(res, { error: 'Method not allowed' }, 405)
|
|
220
|
+
}
|
|
221
|
+
if (rejectUntrustedRequest(req, res)) return
|
|
181
222
|
jsonReply(res, { config: getConfig() })
|
|
182
223
|
},
|
|
183
224
|
}), 'dsh-agent-orchestrator: config route')
|
|
@@ -187,6 +228,9 @@ export function registerOrchestratorRoutes(ctx, services) {
|
|
|
187
228
|
kind: 'exact',
|
|
188
229
|
path: '/dsh-agent-orchestrator/models',
|
|
189
230
|
handler: async (req, res) => {
|
|
231
|
+
if (req.method !== 'GET') {
|
|
232
|
+
return jsonReply(res, { error: 'Method not allowed' }, 405)
|
|
233
|
+
}
|
|
190
234
|
const selection = readModelSelection(ctx)
|
|
191
235
|
const catalog = await fetchModelCatalog(ctx, {
|
|
192
236
|
allowedRoutes: selection.allowedRoutes,
|
|
@@ -204,6 +248,9 @@ export function registerOrchestratorRoutes(ctx, services) {
|
|
|
204
248
|
kind: 'exact',
|
|
205
249
|
path: '/dsh-agent-orchestrator/catalog/models',
|
|
206
250
|
handler: async (req, res) => {
|
|
251
|
+
if (req.method !== 'GET') {
|
|
252
|
+
return jsonReply(res, { error: 'Method not allowed' }, 405)
|
|
253
|
+
}
|
|
207
254
|
try {
|
|
208
255
|
const urlObj = new URL(req.url, 'http://localhost')
|
|
209
256
|
const provider = urlObj.searchParams.get('provider') || undefined
|
|
@@ -225,6 +272,10 @@ export function registerOrchestratorRoutes(ctx, services) {
|
|
|
225
272
|
kind: 'exact',
|
|
226
273
|
path: '/dsh-agent-orchestrator/snapshots',
|
|
227
274
|
handler: (req, res) => {
|
|
275
|
+
if (req.method !== 'GET') {
|
|
276
|
+
return jsonReply(res, { error: 'Method not allowed' }, 405)
|
|
277
|
+
}
|
|
278
|
+
if (rejectUntrustedRequest(req, res)) return
|
|
228
279
|
try {
|
|
229
280
|
const snapshots = snapshotManager.listSnapshots()
|
|
230
281
|
jsonReply(res, { snapshots, total: snapshots.length })
|
package/lib/store.js
CHANGED
|
@@ -12,9 +12,10 @@ export class OrchestratorStore {
|
|
|
12
12
|
constructor(options = {}) {
|
|
13
13
|
const opts = typeof options === 'string' ? { storageDir: options } : (options || {})
|
|
14
14
|
const baseDir = opts.storageDir || join(homedir(), '.dsh')
|
|
15
|
-
this.storagePath = join(baseDir, 'orchestrator-pipelines.json')
|
|
15
|
+
this.storagePath = opts.storagePath || join(baseDir, 'orchestrator-pipelines.json')
|
|
16
16
|
this.logger = opts.logger || null
|
|
17
17
|
this.pipelines = new Map()
|
|
18
|
+
this._saveTimer = null
|
|
18
19
|
this.globalMetrics = {
|
|
19
20
|
totalPipelines: 0,
|
|
20
21
|
completedPipelines: 0,
|
|
@@ -55,12 +56,23 @@ export class OrchestratorStore {
|
|
|
55
56
|
globalMetrics: this.globalMetrics,
|
|
56
57
|
updatedAt: Date.now(),
|
|
57
58
|
}
|
|
58
|
-
writeFileSync(this.storagePath, JSON.stringify(serialized
|
|
59
|
+
writeFileSync(this.storagePath, JSON.stringify(serialized), 'utf8')
|
|
59
60
|
} catch (e) {
|
|
60
61
|
if (this.logger?.warn) this.logger.warn('[dsh-agent-orchestrator] Store save warning:', e?.message || e)
|
|
61
62
|
}
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
saveDebounced(delayMs = 150) {
|
|
66
|
+
if (this._saveTimer) return
|
|
67
|
+
this._saveTimer = setTimeout(() => {
|
|
68
|
+
this._saveTimer = null
|
|
69
|
+
this.save()
|
|
70
|
+
}, delayMs)
|
|
71
|
+
if (this._saveTimer && typeof this._saveTimer.unref === 'function') {
|
|
72
|
+
this._saveTimer.unref()
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
64
76
|
recordPipeline(pipelineData) {
|
|
65
77
|
this.pipelines.set(pipelineData.pipelineId, pipelineData)
|
|
66
78
|
this.globalMetrics.totalPipelines++
|
|
@@ -107,7 +119,7 @@ export class OrchestratorStore {
|
|
|
107
119
|
}
|
|
108
120
|
}
|
|
109
121
|
|
|
110
|
-
this.
|
|
122
|
+
this.saveDebounced()
|
|
111
123
|
return updated
|
|
112
124
|
}
|
|
113
125
|
|
|
@@ -127,6 +139,7 @@ export class OrchestratorStore {
|
|
|
127
139
|
p.durationMs = summary.durationMs || 0
|
|
128
140
|
p.stateMap = summary.stateMap || {}
|
|
129
141
|
p.artifacts = summary.artifacts || {}
|
|
142
|
+
if (summary.error) p.error = summary.error
|
|
130
143
|
|
|
131
144
|
if (summary.success) {
|
|
132
145
|
this.globalMetrics.completedPipelines++
|
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.11",
|
|
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",
|