@huaqiu/dsh-tool-schematic-gen 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/sse.ts ADDED
@@ -0,0 +1,448 @@
1
+ /**
2
+ * `@huaqiu/dsh-tool-schematic-gen` — CopilotKit SSE consumption and the
3
+ * export-zip POST (dependency-free; `fetch` is injected).
4
+ *
5
+ * @module @huaqiu/dsh-tool-schematic-gen
6
+ */
7
+ import type { EdaAccount, SchematicGenConfig } from './config.js'
8
+ import { buildExportHeaders } from './config.js'
9
+ import {
10
+ collectTraceEvents, isTraceEventName, ToolCallTracker,
11
+ type TraceEvent,
12
+ } from './trace.js'
13
+ import type { ProgressNote, TodoItem } from './progress.js'
14
+
15
+ /**
16
+ * Overall SSE / zip budget — the eda.cn design agents routinely run 9–12+
17
+ * minutes per generation (observed runs exceeded the old 10-min cap), so
18
+ * allow 30 minutes before aborting the stream. This single constant drives
19
+ * both the backend SSE stream timeout and the agent-facing tool timeout hint
20
+ * (`TOOL_TIMEOUT_MS` in tools.ts references it).
21
+ */
22
+ export const HTTP_TIMEOUT_MS = 1_800_000
23
+
24
+ // ── STATE_DELTA application ──────────────────────────────────────────────────
25
+
26
+ /**
27
+ * Apply one `STATE_DELTA` op set to the accumulating state. Only **top-level**
28
+ * patches (`/key`) are applied; nested paths are ignored because the final
29
+ * `STATE_SNAPSHOT` is authoritative for the deliverable fields.
30
+ */
31
+ export function applyDelta(delta: unknown, state: Record<string, unknown>): void {
32
+ if (!Array.isArray(delta)) return
33
+ for (const op of delta) {
34
+ if (!op || typeof op !== 'object') continue
35
+ const record = op as { op?: unknown; path?: unknown; value?: unknown }
36
+ const path = typeof record.path === 'string' ? record.path : ''
37
+ const m = /^\/([^/]+)$/.exec(path)
38
+ if (!m) continue
39
+ const key = m[1]!
40
+ if (record.op === 'remove') delete state[key]
41
+ else if (record.op === 'add' || record.op === 'replace') state[key] = record.value
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Custom-event name the `modular_circuit` (system design) agent uses for
47
+ * everything that is NOT a tool call: a rolling todo list and human-readable
48
+ * stage announcements (`packages/agents/system-design/src/utils/progress.ts`).
49
+ */
50
+ export const SYSTEM_DESIGN_EVENT_NAME = 'SYSTEM_DESIGN_EVENT'
51
+
52
+ /** Read a string field that may be camelCase (AG-UI) or snake_case (older builds). */
53
+ function strField(rec: Record<string, unknown>, ...keys: string[]): string {
54
+ for (const key of keys) {
55
+ const v = rec[key]
56
+ if (typeof v === 'string' && v.length > 0) return v
57
+ }
58
+ return ''
59
+ }
60
+
61
+ /** Parse one `todo_progress` entry; anything else is dropped. */
62
+ function parseTodo(value: unknown): TodoItem | null {
63
+ if (!value || typeof value !== 'object') return null
64
+ const rec = value as Record<string, unknown>
65
+ const content = typeof rec['content'] === 'string' ? rec['content'] : ''
66
+ if (content.length === 0) return null
67
+ const status = rec['status']
68
+ return {
69
+ content,
70
+ status: status === 'completed' ? 'completed' : status === 'in_progress' ? 'in_progress' : 'pending',
71
+ }
72
+ }
73
+
74
+ /** Parse a design-stage announcement (`kind: "progress"`). */
75
+ function parseNote(value: Record<string, unknown>): ProgressNote | null {
76
+ const message = typeof value['message'] === 'string' ? value['message'].trim() : ''
77
+ if (message.length === 0) return null
78
+ const phase = value['phase']
79
+ const stage = typeof value['stage'] === 'string' ? value['stage'] : ''
80
+ return {
81
+ phase: phase === 'complete' ? 'complete' : phase === 'error' ? 'error' : 'start',
82
+ stage,
83
+ message,
84
+ ts: typeof value['ts'] === 'number' && Number.isFinite(value['ts']) ? value['ts'] : Date.now(),
85
+ }
86
+ }
87
+
88
+ export interface HandleEventResult {
89
+ text?: string
90
+ finished?: boolean
91
+ error?: string
92
+ /** Execution-trace events decoded from an AG-UI `CUSTOM` frame. */
93
+ trace?: TraceEvent[]
94
+ /** Set when `state` was mutated, so the caller can resample progress. */
95
+ stateChanged?: boolean
96
+ /** Rolling todo list from the system-design agent. */
97
+ todos?: TodoItem[]
98
+ /** Human-readable stage announcement from the system-design agent. */
99
+ note?: ProgressNote
100
+ /** `RUN_STARTED` — reset any per-run bookkeeping. */
101
+ runStarted?: boolean
102
+ }
103
+
104
+ /**
105
+ * Handle one decoded SSE event, mutating `state` and returning any text /
106
+ * lifecycle signals for the caller to aggregate.
107
+ *
108
+ * `tracker` is required only for agents that report progress through the
109
+ * standard AG-UI tool-call lifecycle instead of `CUSTOM` trace events —
110
+ * that is, `modular_circuit` (system design).
111
+ */
112
+ export function handleEvent(
113
+ evt: unknown,
114
+ state: Record<string, unknown>,
115
+ tracker?: ToolCallTracker,
116
+ ): HandleEventResult {
117
+ if (!evt || typeof evt !== 'object') return {}
118
+ const record = evt as {
119
+ type?: unknown
120
+ snapshot?: unknown
121
+ delta?: unknown
122
+ error?: unknown
123
+ message?: unknown
124
+ /** `CUSTOM` only — the custom event name, e.g. `SCHEMATIC_GENERATOR_TRACE`. */
125
+ name?: unknown
126
+ /** `CUSTOM` only — the payload; here one (or many) `TraceEvent`s. */
127
+ value?: unknown
128
+ }
129
+ const rec = evt as Record<string, unknown>
130
+ const type = record.type
131
+ if (type === 'STATE_SNAPSHOT') {
132
+ if (record.snapshot && typeof record.snapshot === 'object') {
133
+ Object.assign(state, record.snapshot)
134
+ }
135
+ return { stateChanged: true }
136
+ }
137
+ if (type === 'STATE_DELTA') {
138
+ applyDelta(record.delta, state)
139
+ return { stateChanged: true }
140
+ }
141
+ if (type === 'RUN_STARTED') {
142
+ tracker?.reset()
143
+ return { runStarted: true }
144
+ }
145
+ // ── standard AG-UI tool-call lifecycle ────────────────────────────────
146
+ // This is how `modular_circuit` reports its stack. The schematic agent uses
147
+ // CUSTOM trace events for the same purpose, so `tracker` is only supplied
148
+ // for the system tool and these branches stay inert otherwise.
149
+ if (type === 'TOOL_CALL_START' && tracker) {
150
+ const id = strField(rec, 'toolCallId', 'tool_call_id')
151
+ const name = strField(rec, 'toolCallName', 'tool_call_name') || 'unknown'
152
+ return { trace: [tracker.start(id, name)] }
153
+ }
154
+ if (type === 'TOOL_CALL_END' && tracker) {
155
+ const id = strField(rec, 'toolCallId', 'tool_call_id')
156
+ const ev = tracker.end(id)
157
+ return ev ? { trace: [ev] } : {}
158
+ }
159
+ if (type === 'CUSTOM') {
160
+ const name = typeof record.name === 'string' ? record.name : ''
161
+ // System-design progress: a todo list and/or a stage announcement. Neither
162
+ // is a TraceEvent, so it must be checked BEFORE the trace-name test —
163
+ // `SYSTEM_DESIGN_EVENT` does not end in `_TRACE` and would be dropped.
164
+ if (name === SYSTEM_DESIGN_EVENT_NAME) {
165
+ const value = record.value
166
+ if (value && typeof value === 'object') {
167
+ const payload = value as Record<string, unknown>
168
+ if (payload['kind'] === 'todo_progress' && Array.isArray(payload['todos'])) {
169
+ const todos: TodoItem[] = []
170
+ for (const item of payload['todos']) {
171
+ const todo = parseTodo(item)
172
+ if (todo) todos.push(todo)
173
+ }
174
+ return todos.length > 0 ? { todos } : {}
175
+ }
176
+ if (payload['kind'] === 'progress') {
177
+ const note = parseNote(payload)
178
+ return note ? { note } : {}
179
+ }
180
+ }
181
+ return {}
182
+ }
183
+ if (!isTraceEventName(name)) return {}
184
+ const events = collectTraceEvents(record.value)
185
+ return events.length > 0 ? { trace: events } : {}
186
+ }
187
+ if (type === 'TEXT_MESSAGE_CONTENT') {
188
+ return { text: typeof (evt as { delta?: unknown }).delta === 'string' ? (evt as { delta: string }).delta : '' }
189
+ }
190
+ if (type === 'RUN_FINISHED') {
191
+ return { finished: true }
192
+ }
193
+ if (type === 'RUN_ERROR') {
194
+ return { error: typeof record.error === 'string' ? record.error : (typeof record.message === 'string' ? record.message : 'unknown run error') }
195
+ }
196
+ return {}
197
+ }
198
+
199
+ /**
200
+ * Live stream accumulator. The optional callbacks fire as each event arrives
201
+ * rather than only at the end — that is what makes a 10-minute run report
202
+ * progress while it is still running.
203
+ */
204
+ interface Accumulator {
205
+ text: string
206
+ finished: boolean
207
+ error: string
208
+ trace: TraceEvent[]
209
+ /** Called with each batch of trace events, immediately as they decode. */
210
+ onTrace?: (events: TraceEvent[]) => void
211
+ /** Called after any state mutation, so the progress ladder can resample. */
212
+ onState?: (state: Record<string, unknown>) => void
213
+ /** Called with a fresh todo list from the system-design agent. */
214
+ onTodos?: (todos: TodoItem[]) => void
215
+ /** Called with each stage announcement from the system-design agent. */
216
+ onNote?: (note: ProgressNote) => void
217
+ /**
218
+ * Tool-call lifecycle → trace adapter. Present only for agents that report
219
+ * their stack through `TOOL_CALL_START`/`TOOL_CALL_END`.
220
+ */
221
+ tracker?: ToolCallTracker
222
+ }
223
+
224
+ /** Parse one `data: …` SSE block into event(s) and route them through `handleEvent`. */
225
+ function dispatchRaw(raw: string, state: Record<string, unknown>, acc: Accumulator): void {
226
+ const lines = raw.split(/\r?\n/)
227
+ for (const line of lines) {
228
+ const trimmed = line.trim()
229
+ if (!trimmed.startsWith('data:')) continue
230
+ const payload = trimmed.slice(5).trim()
231
+ if (!payload) continue
232
+ let evt: unknown
233
+ try {
234
+ evt = JSON.parse(payload)
235
+ } catch {
236
+ continue // keep-alives / comments are ignored
237
+ }
238
+ const r = handleEvent(evt, state, acc.tracker)
239
+ if (r.text) acc.text += r.text
240
+ if (r.finished) acc.finished = true
241
+ if (r.error) acc.error = r.error
242
+ if (r.trace && r.trace.length > 0) {
243
+ for (const ev of r.trace) acc.trace.push(ev)
244
+ acc.onTrace?.(r.trace)
245
+ }
246
+ if (r.todos && r.todos.length > 0) acc.onTodos?.(r.todos)
247
+ if (r.note) acc.onNote?.(r.note)
248
+ if (r.stateChanged) acc.onState?.(state)
249
+ }
250
+ }
251
+
252
+ /** Decode a chunk, split on SSE boundaries, dispatch complete events, return
253
+ * the unterminated remainder. */
254
+ function feed(chunkStr: string, state: Record<string, unknown>, leftover: string, acc: Accumulator): string {
255
+ const combined = leftover + chunkStr
256
+ const parts = combined.split(/\r?\n\r?\n/)
257
+ const newLeftover = parts.pop() || ''
258
+ for (const raw of parts) {
259
+ if (raw.trim().length === 0) continue
260
+ dispatchRaw(raw, state, acc)
261
+ }
262
+ return newLeftover
263
+ }
264
+
265
+ export interface ConsumeOptions {
266
+ signal?: AbortSignal | null
267
+ timeoutMs?: number
268
+ fetchImpl?: typeof fetch
269
+ /** Live execution-trace events, pushed as each `CUSTOM` frame decodes. */
270
+ onTrace?: (events: TraceEvent[]) => void
271
+ /** Called after every state mutation (snapshot or delta), for the stage ladder. */
272
+ onState?: (state: Record<string, unknown>) => void
273
+ /** Called with a fresh todo list from the system-design agent. */
274
+ onTodos?: (todos: TodoItem[]) => void
275
+ /** Called with each human-readable stage announcement. */
276
+ onNote?: (note: ProgressNote) => void
277
+ /**
278
+ * Invoked once when the design API rejects the request with HTTP 401, i.e. the
279
+ * supplied `x-user-token` is dead. Hook for reactive invalidation: the caller
280
+ * clears its credential cache so the next request re-resolves instead of
281
+ * replaying a dead token (spec §6.5).
282
+ */
283
+ onUnauthorized?: () => void
284
+ /**
285
+ * Synthesize trace events from the standard AG-UI `TOOL_CALL_START` /
286
+ * `TOOL_CALL_END` lifecycle.
287
+ *
288
+ * **Only enable this for `modular_circuit` (system design).** The schematic
289
+ * agent reports the same calls through `CUSTOM` trace events, so enabling
290
+ * both would double every row in the stack.
291
+ */
292
+ toolCallTrace?: boolean
293
+ }
294
+
295
+ /**
296
+ * POST to the CopilotKit endpoint and consume the SSE stream until it ends or
297
+ * the budget elapses, accumulating the agent state.
298
+ */
299
+ export async function consumeCopilotkit(
300
+ url: string,
301
+ body: Record<string, unknown>,
302
+ headers: Record<string, string>,
303
+ options: ConsumeOptions = {},
304
+ ): Promise<{ state: Record<string, unknown>; finished: boolean; text: string; trace: TraceEvent[] }> {
305
+ const { signal, timeoutMs = HTTP_TIMEOUT_MS, fetchImpl = fetch } = options
306
+ const controller = new AbortController()
307
+ const timer = setTimeout(
308
+ () => controller.abort(new Error('schematic-gen: the design agent did not finish within ' + timeoutMs + 'ms')),
309
+ timeoutMs,
310
+ )
311
+ let onAbort: (() => void) | null = null
312
+ if (signal) {
313
+ if (signal.aborted) controller.abort()
314
+ else {
315
+ onAbort = () => controller.abort()
316
+ signal.addEventListener('abort', onAbort, { once: true })
317
+ }
318
+ }
319
+
320
+ let res: Response
321
+ try {
322
+ res = await fetchImpl(url, {
323
+ method: 'POST',
324
+ headers,
325
+ body: JSON.stringify(body),
326
+ signal: controller.signal,
327
+ })
328
+ } catch (err) {
329
+ clearTimeout(timer)
330
+ if (onAbort && signal) signal.removeEventListener('abort', onAbort)
331
+ throw new Error('schematic-gen: failed to reach the design API: ' + String((err as Error)?.message || err))
332
+ }
333
+
334
+ if (!res || !res.ok) {
335
+ clearTimeout(timer)
336
+ if (onAbort && signal) signal.removeEventListener('abort', onAbort)
337
+ if (res && res.status === 401) {
338
+ try { options.onUnauthorized?.() } catch { /* invalidation is best-effort */ }
339
+ }
340
+ throw new Error('schematic-gen: the design API returned HTTP ' +
341
+ String(res && res.status) + ' (expected 200 with an SSE stream)')
342
+ }
343
+ if (!res.body || typeof res.body.getReader !== 'function') {
344
+ clearTimeout(timer)
345
+ if (onAbort && signal) signal.removeEventListener('abort', onAbort)
346
+ throw new Error('schematic-gen: the design API response had no stream body')
347
+ }
348
+
349
+ const reader = res.body.getReader()
350
+ const decoder = new TextDecoder()
351
+ let buf = ''
352
+ const state: Record<string, unknown> = {}
353
+ const acc: Accumulator = {
354
+ text: '',
355
+ finished: false,
356
+ error: '',
357
+ trace: [],
358
+ onTrace: options.onTrace,
359
+ onState: options.onState,
360
+ onTodos: options.onTodos,
361
+ onNote: options.onNote,
362
+ tracker: options.toolCallTrace ? new ToolCallTracker() : undefined,
363
+ }
364
+
365
+ try {
366
+ for (;;) {
367
+ const { done, value } = await reader.read()
368
+ if (done) break
369
+ buf = feed(decoder.decode(value, { stream: true }), state, buf, acc)
370
+ }
371
+ // Flush a final event not terminated by a blank line.
372
+ if (buf.length > 0) dispatchRaw(buf, state, acc)
373
+ } finally {
374
+ clearTimeout(timer)
375
+ if (onAbort && signal) signal.removeEventListener('abort', onAbort)
376
+ try { await reader.cancel() } catch { /* already done */ }
377
+ }
378
+
379
+ if (acc.error) {
380
+ throw new Error('schematic-gen: the design agent reported an error: ' + acc.error +
381
+ (acc.text ? ' — ' + acc.text.slice(0, 300) : ''))
382
+ }
383
+ return { state, finished: acc.finished, text: acc.text, trace: acc.trace }
384
+ }
385
+
386
+ export interface ExportZipOptions {
387
+ signal?: AbortSignal | null
388
+ timeoutMs?: number
389
+ fetchImpl?: typeof fetch
390
+ }
391
+
392
+ /**
393
+ * POST the module graph to the production export-zip route and return the zip
394
+ * as a Buffer. The route reads `req.json()` as the `MODULE_GRAPH` and responds
395
+ * with `application/zip`.
396
+ */
397
+ export async function exportModuleGraphZip(
398
+ exportZipUrl: string,
399
+ moduleGraph: Record<string, unknown>,
400
+ config: SchematicGenConfig,
401
+ account: EdaAccount,
402
+ options: ExportZipOptions = {},
403
+ ): Promise<Buffer> {
404
+ const { signal, timeoutMs = HTTP_TIMEOUT_MS, fetchImpl = fetch } = options
405
+ const controller = new AbortController()
406
+ const timer = setTimeout(
407
+ () => controller.abort(new Error('schematic-gen: the export-zip service did not respond within ' + timeoutMs + 'ms')),
408
+ timeoutMs,
409
+ )
410
+ let onAbort: (() => void) | null = null
411
+ if (signal) {
412
+ if (signal.aborted) controller.abort()
413
+ else {
414
+ onAbort = () => controller.abort()
415
+ signal.addEventListener('abort', onAbort, { once: true })
416
+ }
417
+ }
418
+
419
+ let res: Response
420
+ try {
421
+ res = await fetchImpl(exportZipUrl, {
422
+ method: 'POST',
423
+ headers: buildExportHeaders(config, account),
424
+ body: JSON.stringify(moduleGraph),
425
+ signal: controller.signal,
426
+ })
427
+ } catch (err) {
428
+ clearTimeout(timer)
429
+ if (onAbort && signal) signal.removeEventListener('abort', onAbort)
430
+ throw new Error('schematic-gen: failed to export the module graph to zip: ' +
431
+ String((err as Error)?.message || err))
432
+ } finally {
433
+ clearTimeout(timer)
434
+ if (onAbort && signal) signal.removeEventListener('abort', onAbort)
435
+ }
436
+
437
+ if (!res || !res.ok) {
438
+ let detail = ''
439
+ try {
440
+ const j = (await res.json()) as { error?: unknown }
441
+ detail = typeof j.error === 'string' ? j.error : ''
442
+ } catch { /* not JSON */ }
443
+ throw new Error('schematic-gen: the export-zip service returned HTTP ' +
444
+ String(res && res.status) + (detail ? ' — ' + detail : ''))
445
+ }
446
+ const ab = await res.arrayBuffer()
447
+ return Buffer.from(ab)
448
+ }