dsh-archify-projects 0.0.0-stage → 0.1.0

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.
Files changed (148) hide show
  1. package/README.md +204 -2
  2. package/cordis.patch.yml +99 -0
  3. package/dist/client.js +2261 -0
  4. package/dist/client.js.map +1 -0
  5. package/docs/CONTAINER.md +197 -0
  6. package/docs/DESIGN.md +905 -0
  7. package/docs/HANDOVER.md +391 -0
  8. package/docs/PUBLISH.md +107 -0
  9. package/icon.svg +12 -0
  10. package/lib/host/archify-skill.js +116 -0
  11. package/lib/host/archify-skill.js.map +1 -0
  12. package/lib/host/config.js +95 -0
  13. package/lib/host/config.js.map +1 -0
  14. package/lib/host/context.js +28 -0
  15. package/lib/host/context.js.map +1 -0
  16. package/lib/host/git.js +185 -0
  17. package/lib/host/git.js.map +1 -0
  18. package/lib/host/http.js +184 -0
  19. package/lib/host/http.js.map +1 -0
  20. package/lib/host/index.js +90 -0
  21. package/lib/host/index.js.map +1 -0
  22. package/lib/host/policy.js +124 -0
  23. package/lib/host/policy.js.map +1 -0
  24. package/lib/host/routes.js +667 -0
  25. package/lib/host/routes.js.map +1 -0
  26. package/lib/host/runner.js +913 -0
  27. package/lib/host/runner.js.map +1 -0
  28. package/lib/host/store.js +370 -0
  29. package/lib/host/store.js.map +1 -0
  30. package/lib/host/workspace.js +65 -0
  31. package/lib/host/workspace.js.map +1 -0
  32. package/lib/shared/format.js +138 -0
  33. package/lib/shared/format.js.map +1 -0
  34. package/lib/shared/prompt.js +257 -0
  35. package/lib/shared/prompt.js.map +1 -0
  36. package/lib/shared/protocol.js +31 -0
  37. package/lib/shared/protocol.js.map +1 -0
  38. package/lib/shared/types.js +173 -0
  39. package/lib/shared/types.js.map +1 -0
  40. package/locale/en.json +6 -0
  41. package/locale/zh.json +6 -0
  42. package/package.json +98 -4
  43. package/skills/archify/LICENSE +22 -0
  44. package/skills/archify/SKILL.md +109 -0
  45. package/skills/archify/THIRD_PARTY_NOTICES.md +69 -0
  46. package/skills/archify/assets/JetBrainsMono-OFL.txt +93 -0
  47. package/skills/archify/assets/template.html +13709 -0
  48. package/skills/archify/bin/archify.mjs +6936 -0
  49. package/skills/archify/bin/delivery-update.mjs +91 -0
  50. package/skills/archify/bin/finalize.mjs +895 -0
  51. package/skills/archify/bin/open-artifact.mjs +146 -0
  52. package/skills/archify/bin/preview.mjs +969 -0
  53. package/skills/archify/bin/recover-output.mjs +41 -0
  54. package/skills/archify/bin/visual-check.mjs +2667 -0
  55. package/skills/archify/brand-marks/README.md +31 -0
  56. package/skills/archify/brand-marks/catalog.json +131 -0
  57. package/skills/archify/delta/architecture-delta.mjs +1313 -0
  58. package/skills/archify/examples/agent-run.lifecycle.json +55 -0
  59. package/skills/archify/examples/agent-tool-call.workflow.json +74 -0
  60. package/skills/archify/examples/async-job-roundtrip.sequence.json +56 -0
  61. package/skills/archify/examples/brand-aware-delivery.architecture.json +34 -0
  62. package/skills/archify/examples/cache-miss-request.sequence.json +77 -0
  63. package/skills/archify/examples/checkout-platform.base.architecture.json +32 -0
  64. package/skills/archify/examples/checkout-platform.head.architecture.json +32 -0
  65. package/skills/archify/examples/dataflow-product-analytics.html +13820 -0
  66. package/skills/archify/examples/deployment-release.lifecycle.json +48 -0
  67. package/skills/archify/examples/event-stream.dataflow.json +52 -0
  68. package/skills/archify/examples/holiday-planning.workflow.json +143 -0
  69. package/skills/archify/examples/incident-response.workflow.json +59 -0
  70. package/skills/archify/examples/lifecycle-agent-run.html +13765 -0
  71. package/skills/archify/examples/locales/de.json +425 -0
  72. package/skills/archify/examples/locales/es.json +440 -0
  73. package/skills/archify/examples/locales/fr.json +425 -0
  74. package/skills/archify/examples/locales/fr.partial.json +22 -0
  75. package/skills/archify/examples/locales/it.json +425 -0
  76. package/skills/archify/examples/locales/ja.json +425 -0
  77. package/skills/archify/examples/locales/ko.json +440 -0
  78. package/skills/archify/examples/locales/pt.json +425 -0
  79. package/skills/archify/examples/locales/ru.json +425 -0
  80. package/skills/archify/examples/product-analytics.dataflow.json +71 -0
  81. package/skills/archify/examples/production-deployment.architecture.json +51 -0
  82. package/skills/archify/examples/release-delivery.workflow.json +57 -0
  83. package/skills/archify/examples/sequence-cache-miss-request.html +13835 -0
  84. package/skills/archify/examples/web-app-rendered.html +13808 -0
  85. package/skills/archify/examples/web-app.architecture.json +41 -0
  86. package/skills/archify/examples/workflow-agent-tool-call-rendered.html +13826 -0
  87. package/skills/archify/migrations/workflow-v2.mjs +280 -0
  88. package/skills/archify/package.json +17 -0
  89. package/skills/archify/recipes/scenarios.mjs +430 -0
  90. package/skills/archify/references/architecture-layout-repair.md +18 -0
  91. package/skills/archify/references/authoring-contract.md +419 -0
  92. package/skills/archify/references/authoring-defaults.md +41 -0
  93. package/skills/archify/references/brand-marks.md +82 -0
  94. package/skills/archify/references/delivery-contract.md +588 -0
  95. package/skills/archify/references/repository-authoring.md +105 -0
  96. package/skills/archify/references/update-awareness.md +12 -0
  97. package/skills/archify/references/viewer-runtime.md +35 -0
  98. package/skills/archify/renderers/architecture/grid.mjs +62 -0
  99. package/skills/archify/renderers/architecture/labels.mjs +215 -0
  100. package/skills/archify/renderers/architecture/render-architecture.mjs +1001 -0
  101. package/skills/archify/renderers/architecture/routing.mjs +1157 -0
  102. package/skills/archify/renderers/dataflow/README.md +105 -0
  103. package/skills/archify/renderers/dataflow/render-dataflow.mjs +544 -0
  104. package/skills/archify/renderers/lifecycle/README.md +171 -0
  105. package/skills/archify/renderers/lifecycle/grid-routing.mjs +429 -0
  106. package/skills/archify/renderers/lifecycle/render-lifecycle.mjs +938 -0
  107. package/skills/archify/renderers/sequence/README.md +129 -0
  108. package/skills/archify/renderers/sequence/render-sequence.mjs +526 -0
  109. package/skills/archify/renderers/shared/atomic-output.mjs +1982 -0
  110. package/skills/archify/renderers/shared/brand-marks.mjs +670 -0
  111. package/skills/archify/renderers/shared/cli.mjs +451 -0
  112. package/skills/archify/renderers/shared/desktop-readability.mjs +132 -0
  113. package/skills/archify/renderers/shared/diagnostics.mjs +173 -0
  114. package/skills/archify/renderers/shared/engineering-profiles.mjs +157 -0
  115. package/skills/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
  116. package/skills/archify/renderers/shared/generated-validators.mjs +134 -0
  117. package/skills/archify/renderers/shared/geometry.mjs +1941 -0
  118. package/skills/archify/renderers/shared/i18n.mjs +603 -0
  119. package/skills/archify/renderers/shared/layout-report.mjs +42 -0
  120. package/skills/archify/renderers/shared/legend.mjs +217 -0
  121. package/skills/archify/renderers/shared/output-path.mjs +440 -0
  122. package/skills/archify/renderers/shared/path-semantics.mjs +1017 -0
  123. package/skills/archify/renderers/shared/portable-path.mjs +408 -0
  124. package/skills/archify/renderers/shared/repository-evidence.mjs +352 -0
  125. package/skills/archify/renderers/shared/repository-location.mjs +59 -0
  126. package/skills/archify/renderers/shared/route-quality.mjs +614 -0
  127. package/skills/archify/renderers/shared/sidecar-path.mjs +38 -0
  128. package/skills/archify/renderers/shared/spatial-grid.mjs +66 -0
  129. package/skills/archify/renderers/shared/text-fit.mjs +105 -0
  130. package/skills/archify/renderers/shared/utils.mjs +254 -0
  131. package/skills/archify/renderers/shared/validator.mjs +86 -0
  132. package/skills/archify/renderers/workflow/README.md +301 -0
  133. package/skills/archify/renderers/workflow/render-workflow.mjs +37 -0
  134. package/skills/archify/renderers/workflow/workflow-compiler.mjs +4913 -0
  135. package/skills/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
  136. package/skills/archify/schemas/README.md +256 -0
  137. package/skills/archify/schemas/architecture.schema.json +152 -0
  138. package/skills/archify/schemas/common.schema.json +181 -0
  139. package/skills/archify/schemas/dataflow.schema.json +254 -0
  140. package/skills/archify/schemas/lifecycle.schema.json +280 -0
  141. package/skills/archify/schemas/sequence.schema.json +234 -0
  142. package/skills/archify/schemas/workflow.schema.json +439 -0
  143. package/skills/archify/scripts/check-render-output.mjs +1388 -0
  144. package/skills/archify/scripts/check-update.mjs +1710 -0
  145. package/skills/archify/scripts/delivery-update-child.mjs +23 -0
  146. package/skills/archify/scripts/render-examples.mjs +26 -0
  147. package/skills/archify/scripts/update-contract.mjs +182 -0
  148. package/skills/archify/skill-release.json +10 -0
@@ -0,0 +1,913 @@
1
+ /**
2
+ * Agent 编排:一次运行 = 一个 DSH 会话 + 一段结构化提示词 + 产物/概念回收。
3
+ *
4
+ * 走的是宿主已经有的东西,不新造轮子:
5
+ * - 会话由 `ctx.sessionController.create({ cwd, agentPreset })` 起(复用 DSH 的
6
+ * 模型/日志/审批),preset 就是 `cordis.patch.yml` 里那个受限组合;
7
+ * - 提示词经 `ctx.sessionController.prompt(...)` 进 agent 收件箱;
8
+ * - 结束靠 `agent.whenIdle()`,产物靠「标记优先 + 目录兜底」回收。
9
+ *
10
+ * 两种会话:
11
+ * - 出图:可写工作目录,跑 archify;
12
+ * - 提问:只读(read / glob / grep / skill),上下文只有这张图 + 概念表 + 代码库。
13
+ */
14
+ import { readFile, readdir, stat } from 'node:fs/promises';
15
+ import path from 'node:path';
16
+ import { buildAskPrompt, buildRunPrompt, parseArtifactMarkers, } from '../shared/prompt.js';
17
+ import { allDiagrams, DIAGRAM_KINDS, findDiagram, usageTotalOf, } from '../shared/types.js';
18
+ import { projectDir, workspaceRoot } from './config.js';
19
+ import { serviceOf, } from './context.js';
20
+ import { allowedToolNames, readonlyToolNames, tightenAgent, tightenReadOnlyAgent, } from './policy.js';
21
+ import { ensureProjectWorkspace } from './workspace.js';
22
+ /** 生成可读的运行 id(不追求全局唯一,台账里可区分即可)。 */
23
+ export function newRunId(now = new Date()) {
24
+ const stamp = now
25
+ .toISOString()
26
+ .replace(/[-:.TZ]/g, '')
27
+ .slice(0, 14);
28
+ const rand = Math.random().toString(36).slice(2, 6);
29
+ return `run_${stamp}_${rand}`;
30
+ }
31
+ /** 生成请求 id(prompt 的幂等标识)。 */
32
+ function newRequestId() {
33
+ return `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
34
+ }
35
+ /** 跑一次运行。 */
36
+ export class Runner {
37
+ deps;
38
+ busy = new Map();
39
+ constructor(deps) {
40
+ this.deps = deps;
41
+ }
42
+ /** 该项目是否已有任务在跑。 */
43
+ isBusy(projectId) {
44
+ return this.busy.has(projectId);
45
+ }
46
+ /** 等某个项目当前的运行结束(没有在跑就返回 undefined)。 */
47
+ whenSettled(projectId) {
48
+ return this.busy.get(projectId);
49
+ }
50
+ /**
51
+ * 起一次出图运行。
52
+ *
53
+ * `diagramIds` 里默认图用 `structure` / `domain`,自定义图用 `dgm_xxx`。
54
+ * 临时图不参与「更新图册」,只会在被显式创建/重画时进来。
55
+ */
56
+ async start(project, kind, diagramIds) {
57
+ if (this.isBusy(project.id)) {
58
+ throw new RunnerBusy(project.id);
59
+ }
60
+ const { store, config } = this.deps;
61
+ const diagrams = diagramIds
62
+ .map((id) => findDiagram(project, id))
63
+ .filter((d) => d !== undefined);
64
+ if (diagrams.length === 0)
65
+ throw new RunnerUnavailable('没有可运行的图');
66
+ const runId = newRunId();
67
+ const startedAt = new Date().toISOString();
68
+ const workspaceDir = await ensureProjectWorkspace(config, project.id);
69
+ const skillDir = await this.deps.skillDir();
70
+ const prompt = buildRunPrompt({
71
+ projectName: project.name,
72
+ repoPath: project.repoPath,
73
+ workspaceDir,
74
+ git: project.git,
75
+ diagrams: diagrams.map(toPromptDiagram),
76
+ concepts: project.concepts,
77
+ stamp: stampOf(new Date()),
78
+ archifySkillPath: skillDir,
79
+ previous: previousBranches(diagrams),
80
+ });
81
+ const sessionId = await this.openSession(workspaceDir, prompt, 'draw');
82
+ const record = {
83
+ id: runId,
84
+ kind,
85
+ status: 'running',
86
+ sessionId,
87
+ startedAt,
88
+ finishedAt: null,
89
+ error: null,
90
+ note: null,
91
+ diagramIds: diagrams.map((d) => d.id),
92
+ todos: [],
93
+ usage: null,
94
+ };
95
+ await store.patch(project.id, (current) => ({
96
+ ...current,
97
+ updatedAt: startedAt,
98
+ lastRun: record,
99
+ runs: pushRun(current.runs, record, config.keepRuns),
100
+ ...markStatuses(current, diagrams.map((d) => d.id), 'running'),
101
+ }));
102
+ const tracked = this.follow(project.id, runId, diagrams.map((d) => d.id)).finally(() => {
103
+ this.busy.delete(project.id);
104
+ });
105
+ this.busy.set(project.id, tracked);
106
+ return { runId, sessionId, kind };
107
+ }
108
+ /**
109
+ * 对一张图提问(只读会话)。
110
+ *
111
+ * 上下文只有三样:这张图的 prompt + 产物摘要、项目概念表、代码库根目录。
112
+ * 工具面是 read / glob / grep / skill(archify) —— 没有 shell、没有写工具。
113
+ */
114
+ async ask(project, diagramId, question) {
115
+ if (this.isBusy(project.id)) {
116
+ throw new RunnerBusy(project.id);
117
+ }
118
+ const { store, config } = this.deps;
119
+ const diagram = findDiagram(project, diagramId);
120
+ if (diagram === undefined)
121
+ throw new RunnerUnavailable('图不存在');
122
+ const workspaceDir = await ensureProjectWorkspace(config, project.id);
123
+ const candidate = await readCandidate(config, project, diagram);
124
+ const prompt = buildAskPrompt({
125
+ projectName: project.name,
126
+ repoPath: project.repoPath,
127
+ diagram: toPromptDiagram(diagram),
128
+ concepts: project.concepts,
129
+ candidate,
130
+ question,
131
+ });
132
+ const qaId = `qa_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
133
+ const createdAt = new Date().toISOString();
134
+ const pending = {
135
+ id: qaId,
136
+ diagramId: diagram.id,
137
+ question,
138
+ answer: '',
139
+ at: createdAt,
140
+ applied: false,
141
+ status: 'running',
142
+ todos: [],
143
+ usage: null,
144
+ };
145
+ await store.patch(project.id, (current) => ({
146
+ ...current,
147
+ updatedAt: createdAt,
148
+ qa: [...current.qa, pending],
149
+ }));
150
+ const run = (async () => {
151
+ const { ctx } = this.deps;
152
+ const sessionId = await this.openSession(workspaceDir, prompt, 'ask');
153
+ const agent = agentOf(ctx, sessionId);
154
+ let stop = () => { };
155
+ let text = '';
156
+ let usage = null;
157
+ try {
158
+ if (agent !== undefined) {
159
+ stop = this.watchQaTodos(project.id, qaId, agent);
160
+ await withTimeout(agent.whenIdle(), config.askTimeoutMs);
161
+ text = readFinalText(agent);
162
+ usage = usageOf(ctx, agent);
163
+ }
164
+ }
165
+ catch (err) {
166
+ const message = err instanceof Error ? err.message : String(err);
167
+ const failed = await this.finishQa(project.id, qaId, {
168
+ answer: `(提问失败:${message})`,
169
+ status: 'failed',
170
+ usage,
171
+ });
172
+ return { sessionId, entry: failed };
173
+ }
174
+ finally {
175
+ stop();
176
+ }
177
+ const entry = await this.finishQa(project.id, qaId, {
178
+ answer: text.length > 0 ? text : '(这次提问没有拿到回答:会话结束时没有 assistant 文本)',
179
+ status: 'done',
180
+ usage,
181
+ });
182
+ return { sessionId, entry };
183
+ })();
184
+ const tracked = run
185
+ .then(() => undefined)
186
+ .catch(() => undefined)
187
+ .finally(() => {
188
+ this.busy.delete(project.id);
189
+ });
190
+ this.busy.set(project.id, tracked);
191
+ return run;
192
+ }
193
+ /**
194
+ * 建会话并投递提示词。
195
+ *
196
+ * 优先 `sessionController`(宿主的标准会话命令面);没有这个服务时退回
197
+ * `agents.createSession` + `agent.followup`。两条路都用同一个 preset,
198
+ * 限制不会因为走哪条路而消失。
199
+ */
200
+ async openSession(workspaceDir, prompt, mode) {
201
+ const { ctx, config } = this.deps;
202
+ const allow = mode === 'ask' ? readonlyToolNames() : allowedToolNames();
203
+ const presetId = mode === 'ask' ? config.askPresetId : config.presetId;
204
+ const controller = serviceOf(ctx, 'sessionController');
205
+ if (controller !== undefined) {
206
+ const created = await controller.create({ cwd: workspaceDir, agentPreset: presetId });
207
+ // 会话刚建好、还没开 turn:这时候收口工具面最干净
208
+ this.tighten(created.sessionId, allow, mode);
209
+ if (typeof controller.prompt === 'function') {
210
+ await controller.prompt({
211
+ requestId: newRequestId(),
212
+ sessionId: created.sessionId,
213
+ mode: 'queue',
214
+ content: [{ type: 'text', text: prompt }],
215
+ }, new AbortController().signal);
216
+ }
217
+ return created.sessionId;
218
+ }
219
+ const agents = serviceOf(ctx, 'agents');
220
+ if (agents === undefined) {
221
+ throw new RunnerUnavailable('宿主没有 sessionController 也没有 agents 服务,无法起 agent 会话');
222
+ }
223
+ const created = await agents.createSession({
224
+ cwd: workspaceDir,
225
+ agentPreset: presetId,
226
+ reusable: () => false,
227
+ });
228
+ const agent = created.agent;
229
+ this.tighten(agent.id ?? null, allow, mode);
230
+ agent.followup({
231
+ id: newRequestId(),
232
+ content: [{ type: 'text', text: prompt }],
233
+ source: { kind: 'user' },
234
+ });
235
+ const sessionId = agent.id ?? null;
236
+ if (sessionId === null)
237
+ throw new RunnerUnavailable('用 agents.createSession 起的会话没有 id');
238
+ return sessionId;
239
+ }
240
+ /** 对该会话的 agent 作用域装工具白名单与守卫。 */
241
+ tighten(sessionId, allow, mode) {
242
+ const { ctx } = this.deps;
243
+ const warn = (message) => {
244
+ try {
245
+ ctx.logger.warn(`[dsh-archify-projects] ${message}`);
246
+ }
247
+ catch {
248
+ // 日志器不可用不影响运行
249
+ }
250
+ };
251
+ if (sessionId === null) {
252
+ warn('会话没有 id:跳过工具收口');
253
+ return;
254
+ }
255
+ const agent = agentOf(ctx, sessionId);
256
+ if (agent?.ctx === undefined) {
257
+ warn('拿不到 agent 作用域:工具白名单未生效(守卫同样未生效)');
258
+ return;
259
+ }
260
+ if (mode === 'ask') {
261
+ tightenReadOnlyAgent(agent.ctx, warn);
262
+ return;
263
+ }
264
+ tightenAgent(agent.ctx, allow, warn);
265
+ }
266
+ /** 问答会话期间盯 todo,写到 qa 条目上,页面才能看到进度。 */
267
+ watchQaTodos(projectId, qaId, agent) {
268
+ const { ctx, store } = this.deps;
269
+ let stopped = false;
270
+ let last = '';
271
+ const tick = async () => {
272
+ if (stopped)
273
+ return;
274
+ const todos = todosOf(ctx, agent);
275
+ if (todos === null)
276
+ return;
277
+ const signature = todos.map((todo) => `${todo.status}:${todo.content}`).join('|');
278
+ if (signature === last)
279
+ return;
280
+ last = signature;
281
+ await store
282
+ .patch(projectId, (current) => ({
283
+ ...current,
284
+ qa: current.qa.map((entry) => (entry.id === qaId ? { ...entry, todos } : entry)),
285
+ }))
286
+ .catch(() => undefined);
287
+ };
288
+ void tick();
289
+ const timer = setInterval(() => void tick(), 3000);
290
+ return () => {
291
+ stopped = true;
292
+ clearInterval(timer);
293
+ };
294
+ }
295
+ /** 收尾一条提问:写回答、状态与用量,并把用量并进项目累计。 */
296
+ async finishQa(projectId, qaId, outcome) {
297
+ const { store } = this.deps;
298
+ const now = new Date().toISOString();
299
+ let saved;
300
+ await store.patch(projectId, (current) => ({
301
+ ...current,
302
+ updatedAt: now,
303
+ usageTotal: current.usageTotal + usageTotalOf(outcome.usage),
304
+ qa: current.qa.map((entry) => {
305
+ if (entry.id !== qaId)
306
+ return entry;
307
+ saved = {
308
+ ...entry,
309
+ at: now,
310
+ answer: outcome.answer,
311
+ status: outcome.status,
312
+ usage: outcome.usage,
313
+ };
314
+ return saved;
315
+ }),
316
+ }));
317
+ return (saved ?? {
318
+ id: qaId,
319
+ diagramId: '',
320
+ question: '',
321
+ answer: outcome.answer,
322
+ at: now,
323
+ applied: false,
324
+ status: outcome.status,
325
+ todos: [],
326
+ usage: outcome.usage,
327
+ });
328
+ }
329
+ /**
330
+ * 运行期间盯 todo:每 3 秒读一次 `todos` projection 写进台账。
331
+ *
332
+ * 页面靠这份快照显示「正在:<当前步骤>」;读不到(宿主没挂 projection 或 agent
333
+ * 还没写 todo)就什么都不做,不制造假进度。
334
+ */
335
+ watchTodos(projectId, runId, agent) {
336
+ const { ctx, store } = this.deps;
337
+ let stopped = false;
338
+ let last = '';
339
+ const tick = async () => {
340
+ if (stopped)
341
+ return;
342
+ const todos = todosOf(ctx, agent);
343
+ if (todos === null)
344
+ return;
345
+ const signature = todos.map((todo) => `${todo.status}:${todo.content}`).join('|');
346
+ if (signature === last)
347
+ return;
348
+ last = signature;
349
+ await store
350
+ .patch(projectId, (current) => patchRunTodos(current, runId, todos))
351
+ .catch(() => undefined);
352
+ };
353
+ void tick();
354
+ const timer = setInterval(() => void tick(), 3000);
355
+ return () => {
356
+ stopped = true;
357
+ clearInterval(timer);
358
+ };
359
+ }
360
+ /** 后台等待结束并回收产物/概念。 */
361
+ follow(projectId, runId, diagramIds) {
362
+ const run = async () => {
363
+ const { ctx, store, config } = this.deps;
364
+ const project = await store.get(projectId);
365
+ if (project === undefined)
366
+ return;
367
+ const sessionId = project.lastRun?.sessionId ?? null;
368
+ if (sessionId === null) {
369
+ await this.fail(projectId, runId, '会话没有 id,无法跟踪运行');
370
+ return;
371
+ }
372
+ const agent = agentOf(ctx, sessionId);
373
+ const stop = agent === undefined ? () => { } : this.watchTodos(projectId, runId, agent);
374
+ try {
375
+ if (agent === undefined)
376
+ throw new RunnerUnavailable('会话已不在运行中,无法等待结果');
377
+ await withTimeout(agent.whenIdle(), config.runTimeoutMs);
378
+ const text = readFinalText(agent);
379
+ const markers = parseArtifactMarkers(text);
380
+ const found = await this.collectArtifacts(projectId, markers, diagramIds);
381
+ const concepts = await readConcepts(config, projectId);
382
+ await this.succeed(projectId, runId, found, diagramIds, concepts, usageOf(ctx, agent));
383
+ }
384
+ catch (err) {
385
+ await this.fail(projectId, runId, err instanceof Error ? err.message : String(err), usageOf(ctx, agent));
386
+ }
387
+ finally {
388
+ stop();
389
+ }
390
+ };
391
+ return run();
392
+ }
393
+ /** 回收产物:标记优先,找不到就扫 `.archify/` 最新目录。 */
394
+ async collectArtifacts(projectId, markers, diagramIds) {
395
+ const found = new Map();
396
+ for (const id of diagramIds) {
397
+ const marked = markers.get(id.toLowerCase()) ?? markers.get(legacyMarkerId(id).toLowerCase());
398
+ if (marked !== undefined && (await isFile(marked))) {
399
+ found.set(id, path.resolve(marked));
400
+ }
401
+ }
402
+ if (found.size === diagramIds.length)
403
+ return found;
404
+ const fallback = await discoverArtifacts(this.deps.config, projectId, diagramIds);
405
+ for (const id of diagramIds) {
406
+ if (found.has(id))
407
+ continue;
408
+ const candidate = fallback.get(id);
409
+ if (candidate !== undefined)
410
+ found.set(id, candidate);
411
+ }
412
+ return found;
413
+ }
414
+ async succeed(projectId, runId, found, diagramIds, concepts, usage) {
415
+ const { store, config } = this.deps;
416
+ const now = new Date().toISOString();
417
+ const root = workspaceRoot(config);
418
+ await store.patch(projectId, (current) => {
419
+ let next = { ...current, concepts: mergeConcepts(current.concepts, concepts) };
420
+ for (const id of diagramIds) {
421
+ const diagram = findDiagram(next, id);
422
+ if (diagram === undefined)
423
+ continue;
424
+ const html = found.get(id);
425
+ const updated = html === undefined
426
+ ? failedDiagram(diagram, '没有找到这次运行的产物文件')
427
+ : readyDiagram(diagram, html, root, next, now);
428
+ next = { ...next, ...replaceDiagram(next, diagram, updated) };
429
+ }
430
+ const lastRun = withUsage(finishRun(next, runId, now, found.size === diagramIds.length
431
+ ? { status: 'succeeded', error: null }
432
+ : { status: 'failed', error: '部分图谱没有产出' }), usage);
433
+ return {
434
+ ...next,
435
+ updatedAt: now,
436
+ usageTotal: next.usageTotal + usageTotalOf(usage),
437
+ lastRun,
438
+ runs: pushRun(next.runs, lastRun, config.keepRuns),
439
+ };
440
+ });
441
+ }
442
+ async fail(projectId, runId, message, usage = null) {
443
+ const { store, config } = this.deps;
444
+ const now = new Date().toISOString();
445
+ // 失败路径也要先看盘:agent 常常已经把自检通过的 HTML 落进 `.archify/`,
446
+ // 报错只是「等不到最终回复」(会话被宿主机重启掐断、超时、连接断)。
447
+ const salvaged = await discoverArtifacts(config, projectId, (await store.get(projectId))?.custom.map((d) => d.id) ?? []).catch(() => new Map());
448
+ const concepts = await readConcepts(config, projectId).catch(() => []);
449
+ const root = workspaceRoot(config);
450
+ await store.patch(projectId, (current) => {
451
+ let next = { ...current, concepts: mergeConcepts(current.concepts, concepts) };
452
+ for (const diagram of allDiagrams(current)) {
453
+ if (diagram.status !== 'running' && diagram.status !== 'queued')
454
+ continue;
455
+ const html = salvaged.get(diagram.id) ?? salvaged.get(legacyMarkerId(diagram.id));
456
+ next = {
457
+ ...next,
458
+ ...replaceDiagram(next, diagram, html === undefined
459
+ ? failedDiagram(diagram, message)
460
+ : readyDiagram(diagram, html, root, next, now)),
461
+ };
462
+ }
463
+ const lastRun = withUsage(finishRun(next, runId, now, { status: 'failed', error: message }), usage);
464
+ return {
465
+ ...next,
466
+ updatedAt: now,
467
+ usageTotal: next.usageTotal + usageTotalOf(usage),
468
+ lastRun,
469
+ runs: pushRun(next.runs, lastRun, config.keepRuns),
470
+ };
471
+ });
472
+ }
473
+ }
474
+ /** 正在跑时又点了一次。 */
475
+ export class RunnerBusy extends Error {
476
+ projectId;
477
+ constructor(projectId) {
478
+ super('这个项目已有任务在运行,等它结束再试');
479
+ this.name = 'RunnerBusy';
480
+ this.projectId = projectId;
481
+ }
482
+ }
483
+ /** 宿主缺少必要的 agent 服务。 */
484
+ export class RunnerUnavailable extends Error {
485
+ constructor(message) {
486
+ super(message);
487
+ this.name = 'RunnerUnavailable';
488
+ }
489
+ }
490
+ /** 把一条运行写进历史(同 id 覆盖,否则追加),并裁剪到上限。 */
491
+ export function pushRun(runs, record, keep) {
492
+ const withoutSame = runs.filter((run) => run.id !== record.id);
493
+ const next = [...withoutSame, record];
494
+ return next.length <= keep ? next : next.slice(next.length - keep);
495
+ }
496
+ /** 结束一条运行:补齐 status / finishedAt / error,其余字段沿用启动时的记录。 */
497
+ function finishRun(project, runId, now, outcome) {
498
+ const base = project.runs.find((run) => run.id === runId) ??
499
+ project.lastRun ?? {
500
+ id: runId,
501
+ kind: 'create',
502
+ sessionId: null,
503
+ startedAt: now,
504
+ finishedAt: null,
505
+ error: null,
506
+ note: null,
507
+ diagramIds: [],
508
+ todos: [],
509
+ usage: null,
510
+ };
511
+ return { ...base, status: outcome.status, finishedAt: now, error: outcome.error };
512
+ }
513
+ function stampOf(date) {
514
+ const pad = (n) => String(n).padStart(2, '0');
515
+ return (`${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` +
516
+ `-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`);
517
+ }
518
+ function toPromptDiagram(diagram) {
519
+ return {
520
+ id: diagram.id,
521
+ kind: diagram.kind,
522
+ title: diagram.title,
523
+ prompt: diagram.prompt,
524
+ persistence: diagram.persistence,
525
+ conceptIds: diagram.conceptIds,
526
+ renderer: diagram.renderer,
527
+ pathScope: diagram.pathScope,
528
+ };
529
+ }
530
+ function previousBranches(diagrams) {
531
+ const out = {};
532
+ for (const diagram of diagrams)
533
+ out[diagram.id] = diagram.branch;
534
+ return out;
535
+ }
536
+ /** 旧版本标记(structure/domain)与默认图 id 是同一个,这里只是留个可读入口。 */
537
+ function legacyMarkerId(id) {
538
+ return id;
539
+ }
540
+ function agentOf(ctx, sessionId) {
541
+ if (sessionId === null)
542
+ return undefined;
543
+ return serviceOf(ctx, 'agents')?.get(sessionId);
544
+ }
545
+ /** 把图替换回项目,同时保持 record/数组两条路径。 */
546
+ function replaceDiagram(project, current, next) {
547
+ if (DIAGRAM_KINDS.includes(current.kind)) {
548
+ return { diagrams: { ...project.diagrams, [current.kind]: next } };
549
+ }
550
+ return { custom: project.custom.map((d) => (d.id === current.id ? next : d)) };
551
+ }
552
+ function markStatuses(project, ids, status) {
553
+ let next = project;
554
+ for (const id of ids) {
555
+ const diagram = findDiagram(next, id);
556
+ if (diagram === undefined)
557
+ continue;
558
+ next = { ...next, ...replaceDiagram(next, diagram, { ...diagram, status, error: null }) };
559
+ }
560
+ return { diagrams: next.diagrams, custom: next.custom };
561
+ }
562
+ /** 产物就绪:保留图的元信息,只换产物字段。 */
563
+ export function readyDiagram(diagram, htmlPath, root, project, now) {
564
+ const specPath = path.join(path.dirname(htmlPath), 'candidate.json');
565
+ return {
566
+ ...diagram,
567
+ status: 'ready',
568
+ htmlPath: relativeTo(root, htmlPath),
569
+ specPath: relativeTo(root, specPath),
570
+ generatedAt: now,
571
+ branch: project.git.branch,
572
+ tree: project.git.tree,
573
+ error: null,
574
+ };
575
+ }
576
+ function failedDiagram(diagram, message) {
577
+ return {
578
+ ...diagram,
579
+ status: 'failed',
580
+ htmlPath: null,
581
+ specPath: null,
582
+ generatedAt: null,
583
+ branch: null,
584
+ tree: null,
585
+ error: message,
586
+ };
587
+ }
588
+ /** 相对路径统一用 posix 分隔符,URL 拼接才稳。 */
589
+ function relativeTo(root, target) {
590
+ return path.relative(root, target).split(path.sep).join('/');
591
+ }
592
+ async function isFile(target) {
593
+ try {
594
+ return (await stat(target)).isFile();
595
+ }
596
+ catch {
597
+ return false;
598
+ }
599
+ }
600
+ /** 从 agent 会话里取最后一条 assistant 文本。 */
601
+ function readFinalText(agent) {
602
+ const session = agent.session;
603
+ if (session === undefined || typeof session.deriveMessages !== 'function')
604
+ return '';
605
+ let messages;
606
+ try {
607
+ messages = session.deriveMessages();
608
+ }
609
+ catch {
610
+ return '';
611
+ }
612
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
613
+ const message = messages[i];
614
+ if (message?.role !== 'assistant' || !Array.isArray(message.content))
615
+ continue;
616
+ const text = message.content
617
+ .map((block) => {
618
+ const b = block;
619
+ return b.type === 'text' && typeof b.text === 'string' ? b.text : '';
620
+ })
621
+ .join('\n')
622
+ .trim();
623
+ if (text.length > 0)
624
+ return text;
625
+ }
626
+ return '';
627
+ }
628
+ /** 读 agent 写下的概念表(工作目录根的 concepts.json)。 */
629
+ export async function readConcepts(config, projectId) {
630
+ const file = path.join(projectDir(config, projectId), 'concepts.json');
631
+ let text;
632
+ try {
633
+ text = await readFile(file, 'utf8');
634
+ }
635
+ catch {
636
+ return [];
637
+ }
638
+ let parsed;
639
+ try {
640
+ parsed = JSON.parse(text);
641
+ }
642
+ catch {
643
+ return [];
644
+ }
645
+ const list = Array.isArray(parsed) ? parsed : (parsed.concepts ?? []);
646
+ if (!Array.isArray(list))
647
+ return [];
648
+ const now = new Date().toISOString();
649
+ const out = [];
650
+ for (const item of list) {
651
+ if (typeof item !== 'object' || item === null)
652
+ continue;
653
+ const raw = item;
654
+ const id = typeof raw.id === 'string' ? raw.id : null;
655
+ const name = typeof raw.name === 'string' ? raw.name : null;
656
+ if (id === null || name === null)
657
+ continue;
658
+ out.push({
659
+ id,
660
+ name,
661
+ summary: typeof raw.summary === 'string' ? raw.summary : '',
662
+ evidence: Array.isArray(raw.evidence)
663
+ ? raw.evidence.filter((v) => typeof v === 'string')
664
+ : [],
665
+ tags: Array.isArray(raw.tags)
666
+ ? raw.tags.filter((v) => typeof v === 'string')
667
+ : [],
668
+ updatedAt: typeof raw.updatedAt === 'string' ? raw.updatedAt : now,
669
+ });
670
+ }
671
+ return out;
672
+ }
673
+ /** 合并概念:同 id 覆盖,其余保留;按出现顺序去重。 */
674
+ export function mergeConcepts(existing, incoming) {
675
+ if (incoming.length === 0)
676
+ return existing;
677
+ const byId = new Map();
678
+ for (const concept of existing)
679
+ byId.set(concept.id, concept);
680
+ for (const concept of incoming)
681
+ byId.set(concept.id, concept);
682
+ return [...byId.values()];
683
+ }
684
+ /** 读一次 todos projection;没有服务或没写过 todo 时返回 null。 */
685
+ export function todosOf(ctx, agent) {
686
+ const session = agent.session;
687
+ if (session === undefined || session === null)
688
+ return null;
689
+ const projections = serviceOf(ctx, 'sessionProjections');
690
+ if (projections === undefined)
691
+ return null;
692
+ let values;
693
+ try {
694
+ values = projections.snapshot(session, ['todos']).values;
695
+ }
696
+ catch {
697
+ return null;
698
+ }
699
+ const raw = values?.todos;
700
+ if (!Array.isArray(raw))
701
+ return null;
702
+ const out = [];
703
+ for (const item of raw) {
704
+ if (typeof item !== 'object' || item === null)
705
+ continue;
706
+ const value = item;
707
+ if (typeof value.content !== 'string' || value.content.length === 0)
708
+ continue;
709
+ const status = value.status === 'in_progress' || value.status === 'completed' ? value.status : 'pending';
710
+ out.push({ content: value.content, status });
711
+ }
712
+ return out;
713
+ }
714
+ /** 读一次 tokenUsage projection;没有就返回 null。 */
715
+ export function usageOf(ctx, agent) {
716
+ if (agent === undefined)
717
+ return null;
718
+ const session = agent.session;
719
+ if (session === undefined || session === null)
720
+ return null;
721
+ const projections = serviceOf(ctx, 'sessionProjections');
722
+ if (projections === undefined)
723
+ return null;
724
+ let values;
725
+ try {
726
+ values = projections.snapshot(session, ['tokenUsage']).values;
727
+ }
728
+ catch {
729
+ return null;
730
+ }
731
+ const raw = values?.tokenUsage;
732
+ if (typeof raw !== 'object' || raw === null)
733
+ return null;
734
+ const source = raw;
735
+ const num = (key) => {
736
+ const n = Number(source[key]);
737
+ return Number.isFinite(n) && n > 0 ? n : 0;
738
+ };
739
+ const usage = {
740
+ uncachedInput: num('uncachedInputTokens'),
741
+ output: num('outputTokens'),
742
+ cacheRead: num('cacheReadTokens'),
743
+ cacheWrite: num('cacheWriteTokens'),
744
+ };
745
+ // 全 0 = 这次调用没真正发生(比如首轮就报错),别记成一条「用量 0」
746
+ return usageTotalOf(usage) === 0 ? null : usage;
747
+ }
748
+ /** 把 todo 写进某条运行记录(lastRun 与 runs[] 同步改)。 */
749
+ export function patchRunTodos(project, runId, todos) {
750
+ const stamp = (run) => (run.id === runId ? { ...run, todos } : run);
751
+ return {
752
+ ...project,
753
+ lastRun: project.lastRun === null ? null : stamp(project.lastRun),
754
+ runs: project.runs.map(stamp),
755
+ };
756
+ }
757
+ /** 给运行记录补上用量。 */
758
+ function withUsage(run, usage) {
759
+ return { ...run, usage };
760
+ }
761
+ /** 读这张图的 candidate.json(截断),给问答会话当上下文。 */
762
+ async function readCandidate(config, project, diagram) {
763
+ void project;
764
+ if (diagram.specPath === null)
765
+ return null;
766
+ const file = path.join(workspaceRoot(config), diagram.specPath);
767
+ try {
768
+ const text = await readFile(file, 'utf8');
769
+ return text.length > 6000 ? `${text.slice(0, 6000)}\n…(已截断)` : text;
770
+ }
771
+ catch {
772
+ return null;
773
+ }
774
+ }
775
+ /**
776
+ * 开库对账。宿主每次启动跑一次,做两件事:
777
+ *
778
+ * 1. **认领磁盘上的产物**:记录说不 ready、`.archify/` 下却已经有自检通过的 html
779
+ * (agent 跑到一半宿主被重启 / 页面被关的典型现场),把它认回来当 ready。
780
+ * 2. **收尸悬挂的运行**:上一次运行的 `running` 会原样留在台账里(进程被 kill 时
781
+ * 没有机会写 finishedAt)。不收拾就会永远「生成中」,按钮全禁用,用户只能手改 JSON。
782
+ */
783
+ export async function reconcileArtifacts(store, config) {
784
+ const adopted = [];
785
+ const root = workspaceRoot(config);
786
+ let projects = [];
787
+ try {
788
+ projects = await store.list();
789
+ }
790
+ catch {
791
+ return adopted;
792
+ }
793
+ for (const project of projects) {
794
+ const diagrams = allDiagrams(project);
795
+ const strayRuns = project.runs.filter((run) => run.status === 'running');
796
+ const missing = diagrams.filter((diagram) => diagram.status !== 'ready');
797
+ if (missing.length === 0 && strayRuns.length === 0)
798
+ continue;
799
+ const found = await discoverArtifacts(config, project.id, diagrams.map((d) => d.id)).catch(() => new Map());
800
+ const strayDiagrams = diagrams.filter((diagram) => diagram.status === 'queued' || diagram.status === 'running');
801
+ if (found.size === 0 && strayRuns.length === 0 && strayDiagrams.length === 0)
802
+ continue;
803
+ const now = new Date().toISOString();
804
+ await store.patch(project.id, (current) => {
805
+ let next = current;
806
+ for (const diagram of allDiagrams(current)) {
807
+ const html = found.get(diagram.id);
808
+ if (html !== undefined) {
809
+ next = {
810
+ ...next,
811
+ ...replaceDiagram(next, diagram, readyDiagram(diagram, html, root, next, now)),
812
+ };
813
+ continue;
814
+ }
815
+ if (diagram.status !== 'queued' && diagram.status !== 'running')
816
+ continue;
817
+ next = {
818
+ ...next,
819
+ ...replaceDiagram(next, diagram, failedDiagram(diagram, '上一次运行随宿主退出而中断,点「重新生成」重跑')),
820
+ };
821
+ }
822
+ const runs = current.runs.map((run) => run.status === 'running'
823
+ ? { ...run, status: 'failed', finishedAt: now, error: '宿主重启,本次运行中断' }
824
+ : run);
825
+ const lastRun = current.lastRun !== null && current.lastRun.status === 'running'
826
+ ? {
827
+ ...current.lastRun,
828
+ status: 'failed',
829
+ finishedAt: now,
830
+ error: '宿主重启,本次运行中断',
831
+ }
832
+ : current.lastRun;
833
+ return { ...next, updatedAt: now, runs, lastRun };
834
+ });
835
+ if (found.size > 0)
836
+ adopted.push(`${project.name}: 认领 ${found.size} 张图`);
837
+ if (strayRuns.length > 0)
838
+ adopted.push(`${project.name}: 收尸 ${strayRuns.length} 条悬挂运行`);
839
+ }
840
+ return adopted;
841
+ }
842
+ /**
843
+ * 目录兜底:扫 `<项目目录>/.archify/` 下的子目录,按目录名里的图 id / 旧 kind 关键词归类。
844
+ *
845
+ * 只在标记缺失时用。目录名必须以图 id 开头(prompt 里约定的),否则按旧版
846
+ * `structure-` / `domain-` 前缀兜底。
847
+ */
848
+ export async function discoverArtifacts(config, projectId, diagramIds) {
849
+ const result = new Map();
850
+ const archifyRoot = path.join(projectDir(config, projectId), '.archify');
851
+ let dirs;
852
+ try {
853
+ dirs = await readdir(archifyRoot, { withFileTypes: true }).then((entries) => entries
854
+ .filter((entry) => entry.isDirectory())
855
+ .map((entry) => entry.name)
856
+ .sort()
857
+ .reverse());
858
+ }
859
+ catch {
860
+ return result;
861
+ }
862
+ for (const dir of dirs) {
863
+ const id = diagramIdOfDirName(dir, diagramIds);
864
+ if (id === null || result.has(id))
865
+ continue;
866
+ const html = await firstHtmlIn(path.join(archifyRoot, dir));
867
+ if (html !== null)
868
+ result.set(id, html);
869
+ }
870
+ return result;
871
+ }
872
+ function diagramIdOfDirName(dir, diagramIds) {
873
+ const lower = dir.toLowerCase();
874
+ for (const id of diagramIds) {
875
+ if (lower.startsWith(`${id.toLowerCase()}-`))
876
+ return id;
877
+ }
878
+ if (lower.startsWith('domain'))
879
+ return 'domain';
880
+ if (lower.startsWith('structure') || lower.startsWith('architecture'))
881
+ return 'structure';
882
+ return null;
883
+ }
884
+ async function firstHtmlIn(dir) {
885
+ try {
886
+ const entries = await readdir(dir, { withFileTypes: true });
887
+ const html = entries.find((e) => e.isFile() && e.name.endsWith('.html'));
888
+ return html === undefined ? null : path.join(dir, html.name);
889
+ }
890
+ catch {
891
+ return null;
892
+ }
893
+ }
894
+ /** 超时包装。 */
895
+ export function withTimeout(promise, ms) {
896
+ return new Promise((resolve, reject) => {
897
+ const timer = setTimeout(() => {
898
+ reject(new Error(`等待 agent 结束超时(${ms} ms)`));
899
+ }, ms);
900
+ promise.then((value) => {
901
+ clearTimeout(timer);
902
+ resolve(value);
903
+ }, (err) => {
904
+ clearTimeout(timer);
905
+ reject(err instanceof Error ? err : new Error(String(err)));
906
+ });
907
+ });
908
+ }
909
+ /** 供测试使用:把树状态签名摆出来,别在测试里手抄格式。 */
910
+ export function treeOf(tree) {
911
+ return tree === null ? 'none' : `${tree.staged}:${tree.unstaged}:${tree.untracked}`;
912
+ }
913
+ //# sourceMappingURL=runner.js.map