@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/LICENSE +21 -0
- package/README.md +8 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +106004 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.mts +35 -0
- package/lib/index.mjs +1439 -0
- package/package.json +58 -0
- package/src/client/ecad.ts +147 -0
- package/src/client/hit-card.tsx +378 -0
- package/src/client/i18n.ts +148 -0
- package/src/client/index.ts +176 -0
- package/src/client/login-url.ts +91 -0
- package/src/client/parse.ts +153 -0
- package/src/client/progress.ts +161 -0
- package/src/client/stack-frame.tsx +269 -0
- package/src/client/theme.ts +228 -0
- package/src/client/trace-names.ts +308 -0
- package/src/client/trace-tree.ts +185 -0
- package/src/config.ts +203 -0
- package/src/index.ts +130 -0
- package/src/progress.ts +333 -0
- package/src/routes.ts +80 -0
- package/src/sse.ts +448 -0
- package/src/tools.ts +590 -0
- package/src/trace.ts +336 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Huaqiu EDA schematic & system-design generation DSH tool plugin (node half) —
|
|
3
|
+
* `@huaqiu/dsh-tool-schematic-gen`.
|
|
4
|
+
*
|
|
5
|
+
* Exposes two agent-visible tools that drive the online HQ-EDA CopilotKit
|
|
6
|
+
* agents (`schemagen`, `modular_circuit`) over SSE:
|
|
7
|
+
*
|
|
8
|
+
* generate_schematic_from_description description → KiCad schematic
|
|
9
|
+
* generate_system_module_graph description → module graph → KiCad zip
|
|
10
|
+
*
|
|
11
|
+
* ── Architectural boundary (migration plan §1/§8/§9) ───────────────────────────
|
|
12
|
+
* Self-contained DSH plugin: no `@hqedge/*` dependency, no HTTP proxy, and NO
|
|
13
|
+
* demo credentials. The eda.cn account is the `huaqiuAuth` capability
|
|
14
|
+
* (`getUserInfo()` → `x-user-id` / `x-user-token`); generated artifacts are
|
|
15
|
+
* stored in the user-wide `huaqiuArtifacts` service (in-process). The zip
|
|
16
|
+
* artifact is the single source of truth for system designs — never inlined.
|
|
17
|
+
*
|
|
18
|
+
* @module @huaqiu/dsh-tool-schematic-gen
|
|
19
|
+
*/
|
|
20
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
21
|
+
import type { HuaqiuAuthService } from '@huaqiu/dsh-auth'
|
|
22
|
+
import type { HuaqiuArtifacts } from '@huaqiu/dsh-artifacts'
|
|
23
|
+
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
24
|
+
import { createSchematicGenTools, type SchematicGenDeps } from './tools.js'
|
|
25
|
+
import { resolveConfig } from './config.js'
|
|
26
|
+
import { HTTP_TIMEOUT_MS } from './sse.js'
|
|
27
|
+
import { ProgressStore } from './progress.js'
|
|
28
|
+
import { createProgressHandler, PROGRESS_ROUTE_PREFIX } from './routes.js'
|
|
29
|
+
|
|
30
|
+
/** Plugin id — matches package.json. */
|
|
31
|
+
export const name = '@huaqiu/dsh-tool-schematic-gen'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Cordis services this half depends on.
|
|
35
|
+
*
|
|
36
|
+
* `webServer` carries the live-progress route that the browser card polls.
|
|
37
|
+
* It is already transitively required, because `@huaqiu/dsh-artifacts`
|
|
38
|
+
* (which we inject as `huaqiuArtifacts) declares it too.
|
|
39
|
+
*/
|
|
40
|
+
export const inject = ['tools', 'huaqiuAuth', 'huaqiuArtifacts', 'webServer'] as const
|
|
41
|
+
|
|
42
|
+
/** Console tag for filtering in logs. */
|
|
43
|
+
const LOG_TAG = '[dsh-schematic-gen]'
|
|
44
|
+
|
|
45
|
+
export interface SchematicGenPluginConfig {
|
|
46
|
+
/** Endpoint overrides, env-backed. */
|
|
47
|
+
copilotkitUrl?: string
|
|
48
|
+
exportZipUrl?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Host plugin body — register the two generation tools.
|
|
53
|
+
*
|
|
54
|
+
* @param ctx - real cordis context (node side).
|
|
55
|
+
* @returns disposer — unregisters both tools on plugin dispose.
|
|
56
|
+
*/
|
|
57
|
+
export function apply(ctx: Context, config: SchematicGenPluginConfig = {}): () => void {
|
|
58
|
+
if (!ctx.tools || typeof ctx.tools.register !== 'function') {
|
|
59
|
+
throw new Error('@huaqiu/dsh-tool-schematic-gen requires the DSH `tools` service (ctx.tools.register).')
|
|
60
|
+
}
|
|
61
|
+
if (!ctx.huaqiuAuth || !ctx.huaqiuAuth.auth || typeof ctx.huaqiuAuth.auth.getUserInfo !== 'function') {
|
|
62
|
+
throw new Error(
|
|
63
|
+
'@huaqiu/dsh-tool-schematic-gen requires the `huaqiuAuth` service (provided by @huaqiu/dsh-auth) — ' +
|
|
64
|
+
'the eda.cn account is never baked in.',
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
if (!ctx.huaqiuArtifacts || typeof ctx.huaqiuArtifacts.create !== 'function') {
|
|
68
|
+
throw new Error(
|
|
69
|
+
'@huaqiu/dsh-tool-schematic-gen requires the `huaqiuArtifacts` service (provided by @huaqiu/dsh-artifacts).',
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const auth: HuaqiuAuthService = ctx.huaqiuAuth
|
|
74
|
+
const artifacts: HuaqiuArtifacts = ctx.huaqiuArtifacts
|
|
75
|
+
|
|
76
|
+
// Fail fast at load time on a misconfigured endpoint override.
|
|
77
|
+
const configOverride: Record<string, string | undefined> = {}
|
|
78
|
+
if (config.copilotkitUrl) configOverride.HQ_EDA_COPILOTKIT_URL = config.copilotkitUrl
|
|
79
|
+
if (config.exportZipUrl) configOverride.HQ_EDA_EXPORT_ZIP_URL = config.exportZipUrl
|
|
80
|
+
const finalConfig = resolveConfig({ ...(typeof process !== 'undefined' ? process.env : undefined), ...configOverride })
|
|
81
|
+
|
|
82
|
+
// Live progress: an in-memory store the tool bodies write to and the browser
|
|
83
|
+
// card polls. Best-effort — when the webServer surface is missing the tools
|
|
84
|
+
// still generate, they simply cannot report progress.
|
|
85
|
+
const progress = new ProgressStore()
|
|
86
|
+
if (ctx.webServer && typeof ctx.webServer.register === 'function') {
|
|
87
|
+
ctx.effect(() => ctx.webServer.register({
|
|
88
|
+
kind: 'prefix',
|
|
89
|
+
path: PROGRESS_ROUTE_PREFIX,
|
|
90
|
+
handler: createProgressHandler(progress),
|
|
91
|
+
}))
|
|
92
|
+
} else {
|
|
93
|
+
// eslint-disable-next-line no-console
|
|
94
|
+
console.warn(LOG_TAG, 'webServer unavailable — live progress reporting is disabled')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const deps: SchematicGenDeps = {}
|
|
98
|
+
const env = {
|
|
99
|
+
config: finalConfig,
|
|
100
|
+
auth: auth.auth,
|
|
101
|
+
artifacts,
|
|
102
|
+
timeoutMs: HTTP_TIMEOUT_MS,
|
|
103
|
+
progress,
|
|
104
|
+
deps,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const disposers = createSchematicGenTools(env).map((tool) => ctx.tools.register(tool))
|
|
108
|
+
|
|
109
|
+
// eslint-disable-next-line no-console
|
|
110
|
+
console.log(LOG_TAG, 'registered agent tools', {
|
|
111
|
+
tools: disposers.length,
|
|
112
|
+
copilotkitUrl: finalConfig.copilotkitUrl,
|
|
113
|
+
exportZipUrl: finalConfig.exportZipUrl,
|
|
114
|
+
auth: 'huaqiuAuth',
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
return function dispose() {
|
|
118
|
+
for (const disposeTool of disposers) {
|
|
119
|
+
try {
|
|
120
|
+
disposeTool()
|
|
121
|
+
} catch {
|
|
122
|
+
// One failing unregister must not hide the others.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
progress.sweep()
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Exported for tests: the agent ids this plugin drives. */
|
|
130
|
+
export { agentIds } from './config.js'
|
package/src/progress.ts
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@huaqiu/dsh-tool-schematic-gen` — live run progress (node half).
|
|
3
|
+
*
|
|
4
|
+
* Long design runs take 10+ minutes. The tool body sits on the node half and
|
|
5
|
+
* returns once, so the browser card would otherwise show a frozen label for
|
|
6
|
+
* the whole run. This module is the node side of the fix:
|
|
7
|
+
*
|
|
8
|
+
* tool body ──pushTrace/updateState──▶ ProgressStore (keyed by callId)
|
|
9
|
+
* │
|
|
10
|
+
* ctx.webServer ◀──────┘ GET …/progress/<callId>
|
|
11
|
+
* │
|
|
12
|
+
* browser card polls
|
|
13
|
+
*
|
|
14
|
+
* **Why `callId` is the key.** `defineTool`'s second argument is a
|
|
15
|
+
* `ToolRunContext`, which extends `ToolExecutionInput` and therefore carries
|
|
16
|
+
* `callId`. The `tool.call.toolview` slot passes the very same string to the
|
|
17
|
+
* browser component as `ToolCallOwnerProps.callId` — documented in DSH as
|
|
18
|
+
* "stable across running and settled forms". So no hand-rolled correlation id
|
|
19
|
+
* has to travel through the tool result.
|
|
20
|
+
*
|
|
21
|
+
* **Two independent progress signals**, because the backend is not guaranteed
|
|
22
|
+
* to emit trace events:
|
|
23
|
+
* 1. `frames` — a real call stack, when AG-UI `CUSTOM` trace events arrive.
|
|
24
|
+
* 2. `stage` — a coarse ladder derived from the agent's own state keys,
|
|
25
|
+
* which always arrives via `STATE_SNAPSHOT`/`STATE_DELTA`.
|
|
26
|
+
* The card renders the stack when it exists and the ladder otherwise, and
|
|
27
|
+
* always shows the elapsed timer.
|
|
28
|
+
*
|
|
29
|
+
* @module @huaqiu/dsh-tool-schematic-gen
|
|
30
|
+
*/
|
|
31
|
+
import { pairTraceEvents, type TraceEvent, type TraceFrame } from './trace.js'
|
|
32
|
+
|
|
33
|
+
export type RunStatus = 'running' | 'completed' | 'failed'
|
|
34
|
+
export type RunKind = 'schematic' | 'system'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One item of the rolling todo list the system-design agent publishes
|
|
38
|
+
* (`SYSTEM_DESIGN_EVENT` / `kind: "todo_progress"`).
|
|
39
|
+
*/
|
|
40
|
+
export interface TodoItem {
|
|
41
|
+
content: string
|
|
42
|
+
status: 'pending' | 'in_progress' | 'completed'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A human-readable stage announcement from the system-design agent's
|
|
47
|
+
* `emitWorkflowProgress` middleware — e.g. "正在整理需求并生成系统设计方案。".
|
|
48
|
+
*
|
|
49
|
+
* This is the ONLY narrative progress signal the system agent gives us; the
|
|
50
|
+
* coarse ladder below is derived from state keys and says nothing about what
|
|
51
|
+
* the agent is actually doing right now.
|
|
52
|
+
*/
|
|
53
|
+
export interface ProgressNote {
|
|
54
|
+
phase: 'start' | 'complete' | 'error'
|
|
55
|
+
/** Tool/stage id, e.g. `design_plan_gen`. Empty when the agent omits it. */
|
|
56
|
+
stage: string
|
|
57
|
+
message: string
|
|
58
|
+
ts: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One rung of the coarse progress ladder. `key` is an i18n key suffix. */
|
|
62
|
+
export interface StageSpec {
|
|
63
|
+
key: string
|
|
64
|
+
reached(state: Record<string, unknown>): boolean
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A non-empty value: null/empty-string/empty-array/empty-object/false are not. */
|
|
68
|
+
function filled(v: unknown): boolean {
|
|
69
|
+
if (v === null || v === undefined || v === false) return false
|
|
70
|
+
if (typeof v === 'string') return v.trim().length > 0
|
|
71
|
+
if (Array.isArray(v)) return v.length > 0
|
|
72
|
+
if (typeof v === 'object') return Object.keys(v as Record<string, unknown>).length > 0
|
|
73
|
+
return true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Connections live one level down: `connect_result.connections`. */
|
|
77
|
+
function connectionsFilled(state: Record<string, unknown>): boolean {
|
|
78
|
+
const cr = state['connect_result']
|
|
79
|
+
if (cr && typeof cr === 'object') {
|
|
80
|
+
return filled((cr as Record<string, unknown>)['connections'])
|
|
81
|
+
}
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Stage ladders, ordered. Derived from the agent's own initial state
|
|
87
|
+
* (`emptySchematicState` / `emptySystemState` in config.ts), so a stage is
|
|
88
|
+
* "reached" exactly when the agent has written that part of the design.
|
|
89
|
+
*/
|
|
90
|
+
export const STAGE_LADDERS: Record<RunKind, readonly StageSpec[]> = {
|
|
91
|
+
schematic: [
|
|
92
|
+
{ key: 'requirement', reached: (s) => filled(s['requirement']) },
|
|
93
|
+
{ key: 'architecture', reached: (s) => filled(s['architecture']) },
|
|
94
|
+
{ key: 'circuit', reached: (s) => filled(s['circuit']) },
|
|
95
|
+
{ key: 'report', reached: (s) => filled(s['report']) || filled(s['reportStage']) },
|
|
96
|
+
{ key: 'output', reached: (s) => filled(s['schFiles']) },
|
|
97
|
+
],
|
|
98
|
+
system: [
|
|
99
|
+
{ key: 'plan', reached: (s) => filled(s['design_plan']) },
|
|
100
|
+
{ key: 'search', reached: (s) => filled(s['search_plan']) },
|
|
101
|
+
{ key: 'bom', reached: (s) => filled(s['bom_list']) },
|
|
102
|
+
{ key: 'modules', reached: (s) => filled(s['module_list']) },
|
|
103
|
+
{ key: 'connect', reached: connectionsFilled },
|
|
104
|
+
{ key: 'erc', reached: (s) => s['erc_passed'] === true },
|
|
105
|
+
{ key: 'export', reached: (s) => filled(s['module_graph']) },
|
|
106
|
+
],
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Resolve how far a run has got, purely from the agent state snapshot. */
|
|
110
|
+
export function stageOf(
|
|
111
|
+
kind: RunKind,
|
|
112
|
+
state: Record<string, unknown>,
|
|
113
|
+
): { index: number; total: number; key: string } | null {
|
|
114
|
+
const ladder = STAGE_LADDERS[kind]
|
|
115
|
+
if (!ladder || ladder.length === 0) return null
|
|
116
|
+
let reached = 0
|
|
117
|
+
for (const spec of ladder) {
|
|
118
|
+
if (!spec.reached(state)) break
|
|
119
|
+
reached += 1
|
|
120
|
+
}
|
|
121
|
+
// `index` is the stage currently IN PROGRESS (0-based); clamp to the last
|
|
122
|
+
// stage once everything is reached.
|
|
123
|
+
const index = Math.min(reached, ladder.length - 1)
|
|
124
|
+
return { index, total: ladder.length, key: ladder[index]!.key }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Hard cap on retained frames so a pathological stream cannot grow unbounded. */
|
|
128
|
+
export const MAX_FRAMES = 500
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Structural sink the tool bodies depend on, so `tools.ts` never imports the
|
|
132
|
+
* concrete store. Every method tolerates an unknown `callId`.
|
|
133
|
+
*/
|
|
134
|
+
export interface RunProgress {
|
|
135
|
+
start(callId: string, toolName: string, kind: RunKind): void
|
|
136
|
+
pushTrace(callId: string, events: readonly TraceEvent[]): void
|
|
137
|
+
updateState(callId: string, state: Record<string, unknown>): void
|
|
138
|
+
setTodos(callId: string, todos: readonly TodoItem[]): void
|
|
139
|
+
setNote(callId: string, note: ProgressNote): void
|
|
140
|
+
finish(callId: string): void
|
|
141
|
+
fail(callId: string, message: string): void
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The document the browser polls for. */
|
|
145
|
+
export interface ProgressDoc {
|
|
146
|
+
callId: string
|
|
147
|
+
toolName: string
|
|
148
|
+
kind: RunKind
|
|
149
|
+
status: RunStatus
|
|
150
|
+
startedAt: number
|
|
151
|
+
updatedAt: number
|
|
152
|
+
/** Real call stack, present only when the backend emits trace events. */
|
|
153
|
+
frames: TraceFrame[]
|
|
154
|
+
/** Coarse ladder, always present while state has been seen. */
|
|
155
|
+
stage: { index: number; total: number; key: string } | null
|
|
156
|
+
/** Rolling todo list. System-design only; `null` for schematic runs. */
|
|
157
|
+
todos: TodoItem[] | null
|
|
158
|
+
/** Latest stage announcement. System-design only; `null` until one arrives. */
|
|
159
|
+
note: ProgressNote | null
|
|
160
|
+
/** Failure text, set by `fail`. */
|
|
161
|
+
error: string | null
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface ProgressStoreOptions {
|
|
165
|
+
/** Age after which a run is forgotten. Default 30 min (matches the SSE budget). */
|
|
166
|
+
ttlMs?: number
|
|
167
|
+
now?: () => number
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
interface RunRecord {
|
|
171
|
+
doc: ProgressDoc
|
|
172
|
+
/** Raw trace events retained only long enough to re-pair frames. */
|
|
173
|
+
events: TraceEvent[]
|
|
174
|
+
state: Record<string, unknown>
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* In-memory progress registry. Deliberately NOT persisted: progress is only
|
|
179
|
+
* meaningful while the run is live, and a stale doc after a restart would be
|
|
180
|
+
* worse than none.
|
|
181
|
+
*/
|
|
182
|
+
export class ProgressStore {
|
|
183
|
+
private readonly runs = new Map<string, RunRecord>()
|
|
184
|
+
private readonly ttlMs: number
|
|
185
|
+
private readonly now: () => number
|
|
186
|
+
|
|
187
|
+
constructor(options: ProgressStoreOptions = {}) {
|
|
188
|
+
this.ttlMs = options.ttlMs ?? 30 * 60 * 1000
|
|
189
|
+
this.now = options.now ?? (() => Date.now())
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Register a run. Safe to call twice for the same id (idempotent). */
|
|
193
|
+
start(callId: string, toolName: string, kind: RunKind): void {
|
|
194
|
+
if (!callId) return
|
|
195
|
+
if (this.runs.has(callId)) return
|
|
196
|
+
const ts = this.now()
|
|
197
|
+
this.runs.set(callId, {
|
|
198
|
+
doc: {
|
|
199
|
+
callId,
|
|
200
|
+
toolName,
|
|
201
|
+
kind,
|
|
202
|
+
status: 'running',
|
|
203
|
+
startedAt: ts,
|
|
204
|
+
updatedAt: ts,
|
|
205
|
+
frames: [],
|
|
206
|
+
stage: null,
|
|
207
|
+
todos: null,
|
|
208
|
+
note: null,
|
|
209
|
+
error: null,
|
|
210
|
+
},
|
|
211
|
+
events: [],
|
|
212
|
+
state: {},
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Append trace events and re-pair the frame list. */
|
|
217
|
+
pushTrace(callId: string, events: readonly TraceEvent[]): void {
|
|
218
|
+
const rec = this.runs.get(callId)
|
|
219
|
+
if (!rec || events.length === 0) return
|
|
220
|
+
for (const ev of events) rec.events.push(ev)
|
|
221
|
+
if (rec.events.length > MAX_FRAMES * 4) {
|
|
222
|
+
// Keep the tail: newer spans are what the user is waiting on. Frames
|
|
223
|
+
// already derived from dropped starts simply stay unclosed.
|
|
224
|
+
rec.events.splice(0, rec.events.length - MAX_FRAMES * 4)
|
|
225
|
+
}
|
|
226
|
+
rec.doc.frames = pairTraceEvents(rec.events).slice(-MAX_FRAMES)
|
|
227
|
+
rec.doc.updatedAt = this.now()
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Merge an agent state snapshot and re-evaluate the stage ladder. */
|
|
231
|
+
updateState(callId: string, state: Record<string, unknown>): void {
|
|
232
|
+
const rec = this.runs.get(callId)
|
|
233
|
+
if (!rec) return
|
|
234
|
+
Object.assign(rec.state, state)
|
|
235
|
+
rec.doc.stage = stageOf(rec.doc.kind, rec.state)
|
|
236
|
+
rec.doc.updatedAt = this.now()
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Replace the todo list. Only ever set by the system-design agent. */
|
|
240
|
+
setTodos(callId: string, todos: readonly TodoItem[]): void {
|
|
241
|
+
const rec = this.runs.get(callId)
|
|
242
|
+
if (!rec) return
|
|
243
|
+
rec.doc.todos = todos.map((todo) => ({ ...todo }))
|
|
244
|
+
rec.doc.updatedAt = this.now()
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Record the latest stage announcement. */
|
|
248
|
+
setNote(callId: string, note: ProgressNote): void {
|
|
249
|
+
const rec = this.runs.get(callId)
|
|
250
|
+
if (!rec) return
|
|
251
|
+
// A later revision always wins, but never let an older one clobber it.
|
|
252
|
+
if (rec.doc.note && note.ts < rec.doc.note.ts) return
|
|
253
|
+
rec.doc.note = { ...note }
|
|
254
|
+
rec.doc.updatedAt = this.now()
|
|
255
|
+
// An `error` announcement is the closest thing to a live failure reason.
|
|
256
|
+
if (note.phase === 'error' && rec.doc.status === 'running') {
|
|
257
|
+
rec.doc.status = 'failed'
|
|
258
|
+
rec.doc.error = note.message
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
finish(callId: string): void {
|
|
263
|
+
const rec = this.runs.get(callId)
|
|
264
|
+
if (!rec) return
|
|
265
|
+
rec.doc.status = 'completed'
|
|
266
|
+
rec.doc.updatedAt = this.now()
|
|
267
|
+
// Close any span still open — the stream ended, so nothing is running.
|
|
268
|
+
for (const f of rec.doc.frames) {
|
|
269
|
+
if (f.status === 'running') {
|
|
270
|
+
f.status = 'finished'
|
|
271
|
+
f.finishedAt = rec.doc.updatedAt
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
fail(callId: string, message: string): void {
|
|
277
|
+
const rec = this.runs.get(callId)
|
|
278
|
+
if (!rec) return
|
|
279
|
+
rec.doc.status = 'failed'
|
|
280
|
+
rec.doc.error = message
|
|
281
|
+
rec.doc.updatedAt = this.now()
|
|
282
|
+
// Blame only the spans that never closed — an already-finished span
|
|
283
|
+
// succeeded before the failure surfaced.
|
|
284
|
+
for (const f of rec.doc.frames) {
|
|
285
|
+
if (f.status === 'running') {
|
|
286
|
+
f.status = 'failed'
|
|
287
|
+
f.finishedAt = rec.doc.updatedAt
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Snapshot for the HTTP route, or `null` when unknown/expired. */
|
|
293
|
+
get(callId: string): ProgressDoc | null {
|
|
294
|
+
const rec = this.runs.get(callId)
|
|
295
|
+
if (!rec) return null
|
|
296
|
+
if (this.now() - rec.doc.updatedAt > this.ttlMs) {
|
|
297
|
+
this.runs.delete(callId)
|
|
298
|
+
return null
|
|
299
|
+
}
|
|
300
|
+
return rec.doc
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Every live run. Used by the route when the caller has no callId. */
|
|
304
|
+
list(): ProgressDoc[] {
|
|
305
|
+
const out: ProgressDoc[] = []
|
|
306
|
+
for (const callId of [...this.runs.keys()]) {
|
|
307
|
+
const doc = this.get(callId)
|
|
308
|
+
if (doc) out.push(doc)
|
|
309
|
+
}
|
|
310
|
+
return out
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
delete(callId: string): void {
|
|
314
|
+
this.runs.delete(callId)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Drop expired runs. Returns how many were removed. */
|
|
318
|
+
sweep(): number {
|
|
319
|
+
const now = this.now()
|
|
320
|
+
let removed = 0
|
|
321
|
+
for (const [callId, rec] of [...this.runs]) {
|
|
322
|
+
if (now - rec.doc.updatedAt > this.ttlMs) {
|
|
323
|
+
this.runs.delete(callId)
|
|
324
|
+
removed += 1
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return removed
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
get size(): number {
|
|
331
|
+
return this.runs.size
|
|
332
|
+
}
|
|
333
|
+
}
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP adapter for live run progress (browser half of the progress flow).
|
|
3
|
+
*
|
|
4
|
+
* Browser → same-origin DSH webServer → `ctx.webServer` prefix route
|
|
5
|
+
* `/api/v1/huaqiu/schematic-gen/progress`. Follows the `@huaqiu/dsh-artifacts`
|
|
6
|
+
* precedent exactly (one prefix route, sub-paths parsed in the handler) because
|
|
7
|
+
* the DSH `WebRoute` supports exact or prefix matches only — there are no `:id`
|
|
8
|
+
* path params, and two prefix routes on the same base path would collide.
|
|
9
|
+
*
|
|
10
|
+
* GET /api/v1/huaqiu/schematic-gen/progress → every live run
|
|
11
|
+
* GET /api/v1/huaqiu/schematic-gen/progress/<callId> → one run's progress doc
|
|
12
|
+
*
|
|
13
|
+
* The `<callId>` is percent-decoded, because DSH tool call ids can contain
|
|
14
|
+
* characters (`|`, `:`) that a client may encode.
|
|
15
|
+
*/
|
|
16
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
17
|
+
import type { ProgressStore } from './progress.js'
|
|
18
|
+
|
|
19
|
+
export const PROGRESS_ROUTE_PREFIX = '/api/v1/huaqiu/schematic-gen/progress'
|
|
20
|
+
|
|
21
|
+
export type ProgressHandler = (
|
|
22
|
+
req: IncomingMessage,
|
|
23
|
+
res: ServerResponse,
|
|
24
|
+
) => Promise<void> | void
|
|
25
|
+
|
|
26
|
+
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
|
27
|
+
const payload = JSON.stringify(body)
|
|
28
|
+
res.writeHead(status, {
|
|
29
|
+
'content-type': 'application/json; charset=utf-8',
|
|
30
|
+
// Progress is live — never let a proxy serve a stale snapshot.
|
|
31
|
+
'cache-control': 'no-store',
|
|
32
|
+
})
|
|
33
|
+
res.end(payload)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Split the sub-path off the prefix. Returns `null` on a bad shape. */
|
|
37
|
+
function parsePath(req: IncomingMessage): { callId: string | null } | null {
|
|
38
|
+
const url = req.url ?? ''
|
|
39
|
+
const q = url.indexOf('?')
|
|
40
|
+
const pathname = q >= 0 ? url.slice(0, q) : url
|
|
41
|
+
if (!pathname.startsWith(PROGRESS_ROUTE_PREFIX)) return null
|
|
42
|
+
const rest = pathname.slice(PROGRESS_ROUTE_PREFIX.length)
|
|
43
|
+
if (rest === '') return { callId: null }
|
|
44
|
+
if (!rest.startsWith('/')) return null
|
|
45
|
+
const segs = rest.split('/').filter(Boolean)
|
|
46
|
+
if (segs.length !== 1) return null
|
|
47
|
+
let callId: string
|
|
48
|
+
try {
|
|
49
|
+
callId = decodeURIComponent(segs[0]!)
|
|
50
|
+
} catch {
|
|
51
|
+
return null // malformed percent-encoding
|
|
52
|
+
}
|
|
53
|
+
return { callId: callId.length > 0 ? callId : null }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createProgressHandler(store: ProgressStore): ProgressHandler {
|
|
57
|
+
return (req, res) => {
|
|
58
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
59
|
+
sendJson(res, 405, { error: 'method not allowed' })
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
const parsed = parsePath(req)
|
|
63
|
+
if (!parsed) {
|
|
64
|
+
sendJson(res, 404, { error: 'not found' })
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
if (parsed.callId === null) {
|
|
68
|
+
// No id: hand back every live run so a client without a callId (e.g. a
|
|
69
|
+
// card mounted on replay) can still find its run.
|
|
70
|
+
sendJson(res, 200, { runs: store.list() })
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
const doc = store.get(parsed.callId)
|
|
74
|
+
if (!doc) {
|
|
75
|
+
sendJson(res, 404, { error: 'no live run for this call id' })
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
sendJson(res, 200, doc)
|
|
79
|
+
}
|
|
80
|
+
}
|