@cannbot-plugin/cannbot-harness 0.3.2 → 0.3.4

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/index.js CHANGED
@@ -8,8 +8,12 @@ import z from "@deepseek-ai/schemastery";
8
8
  import { spawn } from "node:child_process";
9
9
  import { createHash, randomUUID } from "node:crypto";
10
10
  import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
11
- import { cp, lstat, mkdir, mkdtemp, open, readFile, realpath, rename, rm, unlink, writeFile } from "node:fs/promises";
11
+ import { access, cp, lstat, mkdir, mkdtemp, open, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
12
12
  import { homedir } from "node:os";
13
+ import { DatabaseSync } from "node:sqlite";
14
+ import { defineTool } from "@deepseek-ai/dsh-tools";
15
+ import { createInterface } from "node:readline";
16
+ import { parse } from "yaml";
13
17
  //#region src/workflows.ts
14
18
  const WORKFLOWS = [
15
19
  {
@@ -26,36 +30,6 @@ const WORKFLOWS = [
26
30
  id: "ops-registry-invoke",
27
31
  label: "registry",
28
32
  description: "registryDescription"
29
- },
30
- {
31
- id: "model-infer-optimize",
32
- label: "model",
33
- description: "modelDescription"
34
- },
35
- {
36
- id: "hccl-op-generator",
37
- label: "hccl",
38
- description: "hcclDescription"
39
- },
40
- {
41
- id: "triton-op-generator",
42
- label: "triton",
43
- description: "tritonDescription"
44
- },
45
- {
46
- id: "catlass-op-generator",
47
- label: "catlass",
48
- description: "catlassDescription"
49
- },
50
- {
51
- id: "ops-perf-optimize",
52
- label: "performance",
53
- description: "performanceDescription"
54
- },
55
- {
56
- id: "pypto-op-orchestrator",
57
- label: "pypto",
58
- description: "pyptoDescription"
59
33
  }
60
34
  ];
61
35
  //#endregion
@@ -123,7 +97,7 @@ function installWorkflowContext(ctx, config, content) {
123
97
  source,
124
98
  content: [{
125
99
  type: "text",
126
- text: renderSkillContent(skill)
100
+ text: renderSkillContent(skill) + "\n\nDSH 会话任务跟踪:在执行多步骤工作前,用 todo_write 建立具体的 PM 任务计划;需求确认、环境准备、工作流组装等启动前工作也要列入。开始、完成或受阻时及时更新计划,不要只在聊天中报告进展。命令返回或文件生成不等于任务验收通过。插件自动同步 [工作流] 节点状态;todo_write 只维护你自己的 PM 计划,勿复制或修改插件节点。在此插件内启动或续跑编排器使用 cannbot_workflow_run,查询使用 cannbot_workflow_status,节点由 DSH agent loop 执行;不要经 shell 改用其他 provider。若已有外部编排器在运行,不要重复启动;先核实并报告其状态。"
127
101
  }]
128
102
  })]
129
103
  };
@@ -132,11 +106,7 @@ function installWorkflowContext(ctx, config, content) {
132
106
  }
133
107
  //#endregion
134
108
  //#region src/client/plugin-update.ts
135
- const UPDATE_PACKAGES = [
136
- "cannbot-harness",
137
- "cannbot-dsh",
138
- "@cannbot-plugin/cannbot-harness"
139
- ];
109
+ const UPDATE_PACKAGES = ["cannbot-harness", "@cannbot-plugin/cannbot-harness"];
140
110
  /** Only stable npm releases of our two installation entries may be selected. */
141
111
  function newerVersion(candidate, current) {
142
112
  const next = /^(\d+)\.(\d+)\.(\d+)$/.exec(candidate);
@@ -146,7 +116,7 @@ function newerVersion(candidate, current) {
146
116
  const difference = Number(next[index]) - Number(old[index]);
147
117
  if (difference) return difference > 0;
148
118
  }
149
- return current.includes("-");
119
+ return /^\d+\.\d+\.\d+-/.test(current);
150
120
  }
151
121
  //#endregion
152
122
  //#region src/update-host.ts
@@ -242,39 +212,93 @@ function registerHostUpdater(ctx) {
242
212
  });
243
213
  }
244
214
  //#endregion
245
- //#region src/content-lock.ts
246
- async function withContentLock(path, action) {
247
- await mkdir(dirname(path), { recursive: true });
248
- const owner = JSON.stringify({
249
- pid: process.pid,
250
- id: randomUUID()
215
+ //#region src/process-runner.mjs
216
+ /** Wait for termination before callers release locks or remove staging files. */
217
+ function runProcess(command, args, { signal, timeoutMs, graceMs = 1e3, onData, ...options } = {}) {
218
+ signal?.throwIfAborted();
219
+ return new Promise((resolve, reject) => {
220
+ const child = spawn(command, args, {
221
+ ...options,
222
+ detached: true
223
+ });
224
+ let stopped, escalation, timer;
225
+ const kill = (force) => {
226
+ if (!child.pid) return;
227
+ try {
228
+ process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
229
+ } catch {}
230
+ };
231
+ const stop = (reason) => {
232
+ if (stopped) return;
233
+ stopped = reason;
234
+ kill(false);
235
+ escalation = setTimeout(() => kill(true), graceMs);
236
+ };
237
+ const abort = () => stop(signal.reason ?? /* @__PURE__ */ new Error("Operation cancelled"));
238
+ const cleanup = () => {
239
+ clearTimeout(timer);
240
+ clearTimeout(escalation);
241
+ signal?.removeEventListener("abort", abort);
242
+ };
243
+ if (onData) {
244
+ child.stdout?.on("data", onData);
245
+ child.stderr?.on("data", onData);
246
+ }
247
+ signal?.addEventListener("abort", abort, { once: true });
248
+ if (signal?.aborted) abort();
249
+ if (timeoutMs !== void 0) timer = setTimeout(() => stop(/* @__PURE__ */ new Error("Initialization timed out")), timeoutMs);
250
+ child.on("error", (error) => {
251
+ cleanup();
252
+ reject(error);
253
+ });
254
+ child.on("close", (code, exitSignal) => {
255
+ if (stopped) kill(true);
256
+ cleanup();
257
+ if (stopped) reject(stopped);
258
+ else if (code !== 0) reject(/* @__PURE__ */ new Error(`Command failed: ${command} (exit ${code ?? exitSignal})`));
259
+ else resolve();
260
+ });
251
261
  });
252
- const deadline = Date.now() + 20 * 6e4;
253
- for (;;) try {
254
- const handle = await open(path, "wx", 384);
255
- await handle.writeFile(owner);
256
- await handle.close();
257
- break;
262
+ }
263
+ //#endregion
264
+ //#region packages/install/lib/file-lock.mjs
265
+ /** Never unlink this inode. The kernel releases the SQLite transaction on exit.
266
+ * The suffix separates legacy JSON locks from the database format. */
267
+ async function acquireFileLock(path, timeout = 0) {
268
+ await mkdir(dirname(path), { recursive: true });
269
+ const file = path + ".sqlite3";
270
+ await (await open(file, "a", 384)).close();
271
+ const database = new DatabaseSync(file);
272
+ const deadline = Date.now() + timeout;
273
+ try {
274
+ for (;;) try {
275
+ database.exec("BEGIN EXCLUSIVE");
276
+ break;
277
+ } catch (error) {
278
+ if (error.errcode !== 5) throw error;
279
+ if (Date.now() >= deadline) throw new Error("Another installation or content operation is still running");
280
+ await new Promise((resolve) => setTimeout(resolve, Math.min(250, Math.max(1, deadline - Date.now()))));
281
+ }
258
282
  } catch (error) {
259
- if (error.code !== "EEXIST") throw error;
260
- try {
261
- const previous = await readFile(path, "utf8"), record = JSON.parse(previous);
262
- if (Number.isSafeInteger(record.pid) && typeof record.id === "string") try {
263
- process.kill(record.pid, 0);
264
- } catch (dead) {
265
- if (dead.code === "ESRCH" && await readFile(path, "utf8") === previous) {
266
- await unlink(path);
267
- continue;
268
- }
269
- }
270
- } catch {}
271
- if (Date.now() > deadline) throw new Error("Another content operation is still running");
272
- await new Promise((done) => setTimeout(done, 250));
283
+ database.close();
284
+ throw error;
273
285
  }
286
+ let released = false;
287
+ return () => {
288
+ if (!released) {
289
+ released = true;
290
+ database.close();
291
+ }
292
+ };
293
+ }
294
+ //#endregion
295
+ //#region src/content-lock.ts
296
+ async function withContentLock(path, action) {
297
+ const release = await acquireFileLock(path, 20 * 6e4);
274
298
  try {
275
299
  return await action();
276
300
  } finally {
277
- if (await readFile(path, "utf8").catch(() => "") === owner) await unlink(path);
301
+ release();
278
302
  }
279
303
  }
280
304
  //#endregion
@@ -406,6 +430,12 @@ async function initializeTransaction(workspace, recoveryRoot, descriptor, resour
406
430
  } catch (recovery) {
407
431
  throw new Error(`${error.message}\n自动恢复未完成,备份保留在 ${directory}:${recovery.message}`);
408
432
  }
433
+ await rm(directory, {
434
+ recursive: true,
435
+ force: true,
436
+ maxRetries: 3,
437
+ retryDelay: 100
438
+ }).catch(() => {});
409
439
  throw new Error(`${error.message}\n已恢复本次初始化前的 Skills 和工作区配置。`);
410
440
  }
411
441
  await rm(journalPath);
@@ -457,6 +487,8 @@ var ContentManager = class {
457
487
  stamp = "";
458
488
  fetcher;
459
489
  registry = REGISTRY;
490
+ /** Workflows declared by the shipped defaults; registry catalogs may not reintroduce others. */
491
+ shipped;
460
492
  state = {
461
493
  phase: "idle",
462
494
  message: "",
@@ -466,6 +498,7 @@ var ContentManager = class {
466
498
  this.fetcher = fetcher;
467
499
  this.root = resolve(root);
468
500
  const entries = JSON.parse(readFileSync(defaults, "utf8")).workflows.map(checkedEntry);
501
+ this.shipped = new Set(entries.map((item) => item.id));
469
502
  this.index = {
470
503
  schemaVersion: 1,
471
504
  entries,
@@ -526,6 +559,10 @@ var ContentManager = class {
526
559
  await this.fetcher.extract(manifest.dist.tarball, directory, options);
527
560
  return directory;
528
561
  } catch (error) {
562
+ await rm(directory, {
563
+ recursive: true,
564
+ force: true
565
+ }).catch(() => {});
529
566
  if (String(error.message).includes("EINTEGRITY") || error.code === "EINTEGRITY") throw error;
530
567
  const registry = new URL(manifest.dist.tarball).hostname === "registry.npmjs.org" ? MIRROR : REGISTRY;
531
568
  const alternative = await this.fetcher.manifest(`${manifest.name}@${manifest.version}`, {
@@ -535,8 +572,16 @@ var ContentManager = class {
535
572
  validateTarball(alternative);
536
573
  if (alternative.name !== manifest.name || alternative.version !== manifest.version || alternative.dist.integrity !== manifest.dist.integrity) throw new Error("Registry mirror disagrees with the selected package integrity");
537
574
  const fresh = await mkdtemp(join(dirname(directory), `${manifest.version}-mirror-`));
538
- await this.fetcher.extract(alternative.dist.tarball, fresh, options);
539
- return fresh;
575
+ try {
576
+ await this.fetcher.extract(alternative.dist.tarball, fresh, options);
577
+ return fresh;
578
+ } catch (error) {
579
+ await rm(fresh, {
580
+ recursive: true,
581
+ force: true
582
+ }).catch(() => {});
583
+ throw error;
584
+ }
540
585
  }
541
586
  }
542
587
  descriptor(record) {
@@ -705,26 +750,30 @@ var ContentManager = class {
705
750
  });
706
751
  return this.pending;
707
752
  }
708
- installDependencies(catalogFile, resolvePackage) {
753
+ installDependencies(catalogFile, resolvePackage, replaceFromSource = false) {
709
754
  return this.operation("dependencies", async () => {
710
755
  const catalog = JSON.parse(await readFile(catalogFile, "utf8"));
711
756
  if (catalog.name !== "@cannbot-plugin/workflow-catalog" || !validVersion(catalog.version) || catalog.cannbotWorkflowCatalog?.schemaVersion !== 1 || !Array.isArray(catalog.cannbotWorkflowCatalog.workflows)) throw new Error("Invalid installed workflow catalog");
712
- const baseline = catalog.cannbotWorkflowCatalog.workflows.map(checkedEntry);
757
+ const baseline = catalog.cannbotWorkflowCatalog.workflows.map(checkedEntry).filter((item) => this.shipped.has(item.id));
713
758
  if (!baseline.length || new Set(baseline.map((item) => item.id)).size !== baseline.length) throw new Error("Invalid installed workflow entries");
714
- const newerCatalog = this.index.catalogVersion && newerVersion(this.index.catalogVersion, catalog.version);
759
+ const newerCatalog = !replaceFromSource && this.index.catalogVersion && newerVersion(this.index.catalogVersion, catalog.version);
715
760
  const entries = newerCatalog ? [...this.index.entries] : [...baseline];
716
761
  for (const old of this.index.entries) if (!entries.some((item) => item.id === old.id)) entries.push(old);
717
- await this.save({
762
+ const next = {
718
763
  ...this.index,
719
764
  entries,
720
- catalogVersion: newerCatalog ? this.index.catalogVersion : catalog.version
721
- });
765
+ catalogVersion: newerCatalog ? this.index.catalogVersion : catalog.version,
766
+ records: { ...this.index.records },
767
+ pinned: [...this.index.pinned]
768
+ };
722
769
  for (const entry of baseline.filter((item) => item.default)) {
723
- if (this.index.records[entry.id]) continue;
770
+ if (!replaceFromSource && this.index.records[entry.id]) continue;
724
771
  if (!catalog.dependencies?.[entry.package]) throw new Error(`Workflow dependency missing: ${entry.package}`);
725
772
  const packageFile = resolvePackage(entry.package);
726
773
  const manifest = JSON.parse(await readFile(packageFile, "utf8"));
727
774
  if (manifest.name !== entry.package || !validVersion(manifest.version)) throw new Error("Incorrect installed workflow dependency");
775
+ const identity = (replaceFromSource ? "source:" : "dependency:") + createHash("sha512").update(await readFile(join(dirname(packageFile), "workflow.json"))).digest("base64");
776
+ if (replaceFromSource && this.index.records[entry.id]?.integrity === identity) continue;
728
777
  const parent = join(this.root, "packages", entry.id);
729
778
  await mkdir(parent, { recursive: true });
730
779
  const directory = await mkdtemp(join(parent, `${manifest.version}-dependency-`));
@@ -732,7 +781,7 @@ var ContentManager = class {
732
781
  recursive: true,
733
782
  dereference: true
734
783
  });
735
- const integrity = "dependency:" + createHash("sha512").update(await readFile(join(directory, "workflow.json"))).digest("base64");
784
+ const integrity = identity;
736
785
  const record = {
737
786
  id: entry.id,
738
787
  package: entry.package,
@@ -742,15 +791,17 @@ var ContentManager = class {
742
791
  installedAt: (/* @__PURE__ */ new Date()).toISOString(),
743
792
  dependency: true
744
793
  };
745
- this.descriptor(record);
746
- await this.save({
747
- ...this.index,
748
- records: {
749
- ...this.index.records,
750
- [entry.id]: record
751
- }
752
- });
794
+ const previous = this.index;
795
+ try {
796
+ this.index = next;
797
+ this.descriptor(record);
798
+ } finally {
799
+ this.index = previous;
800
+ }
801
+ next.records[entry.id] = record;
802
+ if (replaceFromSource) next.pinned = next.pinned.filter((id) => id !== entry.id);
753
803
  }
804
+ await this.save(next);
754
805
  this.publish({
755
806
  phase: "ready",
756
807
  message: "Installed workflow dependencies are ready."
@@ -766,7 +817,7 @@ var ContentManager = class {
766
817
  });
767
818
  const catalog = await this.manifest(`${CATALOG_PACKAGE}@latest`);
768
819
  if (catalog.name !== "@cannbot-plugin/workflow-catalog" || !validVersion(catalog.version) || catalog.cannbotWorkflowCatalog?.schemaVersion !== 1 || !Array.isArray(catalog.cannbotWorkflowCatalog.workflows)) throw new Error("Unsupported workflow catalog");
769
- const entries = catalog.cannbotWorkflowCatalog.workflows.map(checkedEntry);
820
+ const entries = catalog.cannbotWorkflowCatalog.workflows.map(checkedEntry).filter((item) => this.shipped.has(item.id));
770
821
  if (!entries.length || new Set(entries.map((item) => item.id)).size !== entries.length) throw new Error("Invalid workflow catalog");
771
822
  for (const old of this.index.entries) if (this.index.records[old.id] && !entries.some((item) => item.id === old.id)) entries.push(old);
772
823
  await this.save({
@@ -820,27 +871,45 @@ var ContentManager = class {
820
871
  const parent = join(this.root, "packages", entry.id);
821
872
  await mkdir(parent, { recursive: true });
822
873
  let directory = await mkdtemp(join(parent, `${manifest.version}-`));
823
- directory = await this.extract(manifest, directory);
824
- const record = {
825
- id: entry.id,
826
- package: entry.package,
827
- version: manifest.version,
828
- integrity: manifest.dist.integrity,
829
- directory: relative(this.root, directory).split(sep).join("/"),
830
- installedAt: (/* @__PURE__ */ new Date()).toISOString()
831
- };
832
- this.descriptor(record);
833
- const history = { ...this.index.history };
834
- if (previous) history[entry.id] = [previous, ...(history[entry.id] ?? []).filter((item) => item.version !== previous.version)];
835
- await this.save({
836
- ...this.index,
837
- records: {
838
- ...this.index.records,
839
- [entry.id]: record
840
- },
841
- history,
842
- pinned: this.index.pinned.filter((item) => item !== entry.id)
843
- });
874
+ let committed = false;
875
+ try {
876
+ directory = await this.extract(manifest, directory);
877
+ const record = {
878
+ id: entry.id,
879
+ package: entry.package,
880
+ version: manifest.version,
881
+ integrity: manifest.dist.integrity,
882
+ directory: relative(this.root, directory).split(sep).join("/"),
883
+ installedAt: (/* @__PURE__ */ new Date()).toISOString()
884
+ };
885
+ this.descriptor(record);
886
+ const history = { ...this.index.history };
887
+ if (previous) history[entry.id] = [previous, ...(history[entry.id] ?? []).filter((item) => item.version !== previous.version)];
888
+ await this.save({
889
+ ...this.index,
890
+ records: {
891
+ ...this.index.records,
892
+ [entry.id]: record
893
+ },
894
+ history,
895
+ pinned: this.index.pinned.filter((item) => item !== entry.id)
896
+ });
897
+ committed = true;
898
+ } finally {
899
+ if (!committed) {
900
+ let retained = true;
901
+ try {
902
+ const active = JSON.parse(await readFile(join(this.root, "active.json"), "utf8"));
903
+ retained = Object.values(active.records).some((record) => inside(this.root, record.directory) === directory);
904
+ } catch (error) {
905
+ if (error.code === "ENOENT") retained = false;
906
+ }
907
+ if (!retained) await rm(directory, {
908
+ recursive: true,
909
+ force: true
910
+ }).catch(() => {});
911
+ }
912
+ }
844
913
  this.errors.delete(entry.id);
845
914
  } catch (error) {
846
915
  const message = String(error.message).slice(-500);
@@ -874,61 +943,30 @@ var ContentManager = class {
874
943
  });
875
944
  }
876
945
  async runInitializer(entry, args, cwd) {
877
- await new Promise((accept, reject) => {
878
- let output = "", stopped = false;
879
- const compatibility = join(import.meta.dirname, "../runtime/initializer-compat.cjs");
880
- const child = spawn(process.execPath, [
881
- "--require",
882
- compatibility,
883
- entry,
884
- ...args
885
- ], {
946
+ let output = "";
947
+ try {
948
+ await runProcess(process.execPath, [entry, ...args], {
886
949
  cwd,
887
- windowsHide: true,
888
- detached: process.platform !== "win32",
889
950
  stdio: [
890
951
  "ignore",
891
952
  "pipe",
892
953
  "pipe"
893
954
  ],
955
+ timeoutMs: 20 * 6e4,
894
956
  env: {
895
957
  ...process.env,
896
958
  ...process.versions.electron ? { ELECTRON_RUN_AS_NODE: "1" } : {},
897
- CANNBOT_INITIALIZER_WORKSPACE: cwd,
898
959
  GIT_TERMINAL_PROMPT: "0",
899
960
  GCM_INTERACTIVE: "never"
961
+ },
962
+ onData: (data) => {
963
+ output = (output + data).slice(-16e3);
964
+ this.publish({ log: output.slice(-6e3) });
900
965
  }
901
966
  });
902
- const log = (data) => {
903
- output = (output + data).slice(-16e3);
904
- this.publish({ log: output.slice(-6e3) });
905
- };
906
- child.stdout.on("data", log);
907
- child.stderr.on("data", log);
908
- const timer = setTimeout(() => {
909
- stopped = true;
910
- if (child.pid) if (process.platform === "win32") spawn("taskkill", [
911
- "/PID",
912
- String(child.pid),
913
- "/T",
914
- "/F"
915
- ], {
916
- windowsHide: true,
917
- stdio: "ignore"
918
- });
919
- else try {
920
- process.kill(-child.pid, "SIGTERM");
921
- } catch {}
922
- }, 20 * 6e4);
923
- child.on("error", (error) => {
924
- clearTimeout(timer);
925
- reject(error);
926
- });
927
- child.on("close", (code) => {
928
- clearTimeout(timer);
929
- code === 0 && !stopped ? accept() : reject(new Error(stopped ? "Initialization timed out" : output.slice(-1500) || "Initialization failed"));
930
- });
931
- });
967
+ } catch (error) {
968
+ throw new Error(`${error.message}\n${output.slice(-1500)}`);
969
+ }
932
970
  }
933
971
  async prepare(cwd, id) {
934
972
  if (!validId(id)) throw new Error("Invalid workflow");
@@ -1107,6 +1145,887 @@ function registerContentRoutes(ctx, content) {
1107
1145
  });
1108
1146
  }
1109
1147
  //#endregion
1148
+ //#region src/native-workflow-bootstrap.mjs
1149
+ /** Plugin-owned Python bootstrap. Paths and prompts travel as argv, never interpolated code. */
1150
+ const pythonBootstrap = String.raw`
1151
+ import concurrent.futures
1152
+ import json
1153
+ import os
1154
+ import sys
1155
+ import threading
1156
+ import uuid
1157
+
1158
+ # Import the installed workflow's shared scheduler, including its sibling modules.
1159
+ script = os.path.abspath(sys.argv.pop(1))
1160
+ sys.path.insert(0, os.path.dirname(script))
1161
+ sys.argv[0] = script
1162
+ import orchestrator
1163
+
1164
+ # Use the unchanged CLI parser for ordinary YAML/workspace/prompt arguments.
1165
+ args = orchestrator.parse_args()
1166
+ if args.provider is not None:
1167
+ raise SystemExit("The plugin supplies its execution callback; do not pass a CLI provider")
1168
+
1169
+ class PipeExecutor:
1170
+ def __init__(self, output):
1171
+ self.output = output
1172
+ self.lock = threading.Lock()
1173
+ self.pending = {}
1174
+ threading.Thread(target=self._receive, daemon=True).start()
1175
+
1176
+ def _receive(self):
1177
+ try:
1178
+ for line in sys.stdin:
1179
+ response = json.loads(line)
1180
+ with self.lock:
1181
+ future = self.pending.pop(response['id'], None)
1182
+ if future is not None:
1183
+ future.set_result(response)
1184
+ finally:
1185
+ # Host loss releases the Python scheduler even while awaiting approval.
1186
+ os._exit(2)
1187
+
1188
+ def execute(self, entry, work_dir, path):
1189
+ request_id = str(uuid.uuid4())
1190
+ future = concurrent.futures.Future()
1191
+ with self.lock:
1192
+ self.pending[request_id] = future
1193
+ self.output.write(json.dumps({'id': request_id, 'taskId': entry['task_id'],
1194
+ 'role': entry['agent'], 'phase': entry['_phase'],
1195
+ 'prompt': entry['prompt'], 'workDir': work_dir}) + '\n')
1196
+ self.output.flush()
1197
+ response = future.result()
1198
+ with open(path, 'w', encoding='utf-8') as output:
1199
+ json.dump(response, output, ensure_ascii=False)
1200
+ return ('executed' if entry['_phase'] == 'execute' else ''), not response.get('ok', False)
1201
+
1202
+ # Preserve one protocol descriptor; scheduler/subprocess logs all go to stderr.
1203
+ protocol = os.fdopen(os.dup(sys.stdout.fileno()), 'w', encoding='utf-8')
1204
+ os.dup2(sys.stderr.fileno(), sys.stdout.fileno())
1205
+ sys.stdout = sys.stderr
1206
+ executor = PipeExecutor(protocol)
1207
+ sys.exit(orchestrator.main(task_executor=executor.execute, args=args))
1208
+ `;
1209
+ //#endregion
1210
+ //#region src/workflow-todos.mjs
1211
+ const PREFIX$1 = "[工作流] ";
1212
+ const active = new Set([
1213
+ "running",
1214
+ "executed",
1215
+ "verifying",
1216
+ "awaiting_approval"
1217
+ ]);
1218
+ const labels = {
1219
+ pending: "待执行",
1220
+ running: "执行中",
1221
+ executed: "待验证",
1222
+ verifying: "验证中",
1223
+ awaiting_approval: "待审批",
1224
+ pass: "已完成",
1225
+ fail: "失败",
1226
+ skipped: "已跳过"
1227
+ };
1228
+ function externalWorkflowLaunch(event) {
1229
+ if (event.type !== "tool/call" || event.data.name !== "bash") return;
1230
+ try {
1231
+ const { command } = JSON.parse(event.data.arguments);
1232
+ if (!/(?:run_workflow|orchestrator)\.py(?:\s|["'])/.test(command)) return;
1233
+ const argument = (name) => {
1234
+ const match = command.match(new RegExp(`--${name}\\s+(?:"([^"$\\\\]+)"|'([^']+)'|([^\\s;|&<>"'$\\\\]+))`));
1235
+ const value = match?.[1] ?? match?.[2] ?? match?.[3];
1236
+ return value && isAbsolute(value) ? value : void 0;
1237
+ };
1238
+ const cwd = argument("work-dir"), yaml = argument("yaml");
1239
+ if (cwd && yaml) return {
1240
+ cwd,
1241
+ yaml,
1242
+ external: true
1243
+ };
1244
+ } catch {}
1245
+ }
1246
+ /** The native TodoItem has three states. Preserve richer scheduler states in its text. */
1247
+ async function readTaskNames(yaml) {
1248
+ const names = /* @__PURE__ */ new Map();
1249
+ try {
1250
+ const config = parse(await readFile(yaml, "utf8"));
1251
+ for (const node of config?.nodes ?? []) if (node?.id != null && typeof node.title === "string" && node.title.trim()) names.set(String(node.id), node.title.trim());
1252
+ } catch {}
1253
+ return names;
1254
+ }
1255
+ async function readCompletionOrder(cwd) {
1256
+ const order = [];
1257
+ try {
1258
+ for (const line of (await readFile(join(cwd, ".workflow/log.jsonl"), "utf8")).split("\n")) try {
1259
+ const event = JSON.parse(line);
1260
+ if (event.task_id != null && (["pass", "skipped"].includes(event.to) || event.verdict === "pass")) {
1261
+ const id = String(event.task_id), index = order.indexOf(id);
1262
+ if (index >= 0) order.splice(index, 1);
1263
+ order.push(id);
1264
+ }
1265
+ } catch {}
1266
+ } catch {}
1267
+ return order;
1268
+ }
1269
+ function workflowTodos(workflow, state, finished = false, taskNames = /* @__PURE__ */ new Map(), completionOrder = []) {
1270
+ const entries = Object.entries(state?.tasks ?? {});
1271
+ const completed = entries.filter(([, task]) => ["pass", "skipped"].includes(task?.status)).sort(([a], [b]) => completionOrder.indexOf(a) - completionOrder.indexOf(b));
1272
+ return [...entries.filter(([, task]) => !["pass", "skipped"].includes(task?.status)), ...completed].flatMap(([id, task]) => {
1273
+ if (!task || typeof task !== "object") return [];
1274
+ const detail = finished && active.has(task.status) ? finished === "paused" ? "已暂停" : "已中断" : labels[task.status] ?? "未知状态";
1275
+ const retry = Number.isSafeInteger(task.retries) && task.retries > 0 ? `,重试 ${task.retries}` : "";
1276
+ return [{
1277
+ content: `${PREFIX$1}${workflow || "当前工作流"} / ${taskNames.get(id) ?? id} · ${detail}${retry}`,
1278
+ status: ["pass", "skipped"].includes(task.status) ? "completed" : !finished && active.has(task.status) ? "in_progress" : "pending"
1279
+ }];
1280
+ });
1281
+ }
1282
+ /** Publish through DSH's existing durable todo projection, preserving non-workflow todos. */
1283
+ var WorkflowTodos = class {
1284
+ constructor(ctx) {
1285
+ this.ctx = ctx;
1286
+ this.sessions = /* @__PURE__ */ new Map();
1287
+ this.closed = false;
1288
+ this.restoring = /* @__PURE__ */ new Set();
1289
+ this.externalSessions = /* @__PURE__ */ new Map();
1290
+ this.externalRuns = /* @__PURE__ */ new Map();
1291
+ this.observer = setInterval(() => {
1292
+ for (const session of this.externalSessions.values()) this.restore(session, true);
1293
+ }, 1e3);
1294
+ this.observer.unref?.();
1295
+ this.releaseCreated = ctx.on?.("session/created", (session) => {
1296
+ this.restore(session);
1297
+ });
1298
+ for (const session of ctx.sessions?.list() ?? []) this.restore(session);
1299
+ this.release = ctx.on?.("session/event", (session, event) => {
1300
+ if (externalWorkflowLaunch(event)) {
1301
+ this.externalSessions.set(session.id, session);
1302
+ this.restore(session);
1303
+ }
1304
+ const entry = this.sessions.get(session.id);
1305
+ if (!entry || !["todo/write", "turn/start"].includes(event.type)) return;
1306
+ if (entry.queued) return;
1307
+ entry.queued = true;
1308
+ queueMicrotask(() => {
1309
+ entry.queued = false;
1310
+ this.sync(entry);
1311
+ });
1312
+ });
1313
+ }
1314
+ async restore(session, refresh = false) {
1315
+ if (this.closed || !this.ctx.sessionQuery || !session.header?.cwd || this.restoring.has(session.id)) return;
1316
+ this.restoring.add(session.id);
1317
+ try {
1318
+ const history = refresh ? { events: [] } : await this.ctx.sessionQuery.readSession(session.id);
1319
+ const calls = /* @__PURE__ */ new Map(), runs = refresh ? new Map(this.externalRuns.get(session.id) ?? []) : /* @__PURE__ */ new Map();
1320
+ for (const event of history.events) {
1321
+ if (event.seq < (session.inheritedEventCount ?? 0)) continue;
1322
+ const external = externalWorkflowLaunch(event);
1323
+ if (external) {
1324
+ runs.set(external.cwd, external);
1325
+ this.externalSessions.set(session.id, session);
1326
+ }
1327
+ if (event.type === "tool/call" && event.data.name === "cannbot_workflow_run") try {
1328
+ calls.set(event.data.callId, JSON.parse(event.data.arguments));
1329
+ } catch {}
1330
+ if (event.type !== "tool/result" || event.data.isError) continue;
1331
+ const message = event.data.message, args = calls.get(message?.toolCallId);
1332
+ if (!args) continue;
1333
+ for (const block of message.content ?? []) try {
1334
+ const result = JSON.parse(block.text);
1335
+ if (result.runId && typeof result.workDir === "string") {
1336
+ for (const [cwd, old] of runs) if (old.workflow === args.workflow) runs.delete(cwd);
1337
+ runs.set(result.workDir, {
1338
+ cwd: result.workDir,
1339
+ workflow: args.workflow
1340
+ });
1341
+ }
1342
+ } catch {}
1343
+ }
1344
+ const latest = [...runs.values()].at(-1);
1345
+ if (latest?.external) {
1346
+ runs.clear();
1347
+ runs.set(latest.cwd, latest);
1348
+ this.externalRuns.set(session.id, new Map(runs));
1349
+ } else {
1350
+ for (const [cwd, run] of runs) if (run.external) runs.delete(cwd);
1351
+ this.externalRuns.delete(session.id);
1352
+ this.externalSessions.delete(session.id);
1353
+ }
1354
+ const workspace = await realpath(session.header.cwd);
1355
+ for (const run of runs.values()) try {
1356
+ const path = await realpath(join(run.cwd, ".workflow/status.json"));
1357
+ const diff = relative(workspace, path);
1358
+ if (diff === ".." || diff.startsWith("../") || isAbsolute(diff)) continue;
1359
+ const state = JSON.parse(await readFile(path, "utf8"));
1360
+ if (run.external) {
1361
+ const yaml = await realpath(run.yaml);
1362
+ if (!withinWorkspace(workspace, yaml) || await realpath(state.workflow) !== yaml) continue;
1363
+ run.workflow = parse(await readFile(yaml, "utf8")).workflow || "外部工作流";
1364
+ }
1365
+ run.taskNames = await readTaskNames(state.workflow);
1366
+ run.completionOrder = await readCompletionOrder(run.cwd);
1367
+ const existing = this.sessions.get(session.id)?.runs.get(run.cwd);
1368
+ if (this.closed || existing && !existing.external) continue;
1369
+ if (run.external) {
1370
+ const entry = this.sessions.get(session.id);
1371
+ for (const [cwd, old] of entry?.runs ?? []) if (cwd !== run.cwd && old.workflow === run.workflow && (old.external || old.finished)) entry.runs.delete(cwd);
1372
+ }
1373
+ this.update({
1374
+ id: session.id,
1375
+ session
1376
+ }, run, state, !run.external);
1377
+ } catch {}
1378
+ } catch (error) {
1379
+ this.ctx.logger?.warn(`Cannot restore workflow todos for ${session.id}: ${error.message}`);
1380
+ } finally {
1381
+ this.restoring.delete(session.id);
1382
+ }
1383
+ }
1384
+ update(parent, run, state, finished = false) {
1385
+ if (this.closed || !parent.session || !this.ctx.sessionProjections?.stateOf) return;
1386
+ let entry = this.sessions.get(parent.id);
1387
+ if (!entry) {
1388
+ entry = {
1389
+ session: parent.session,
1390
+ runs: /* @__PURE__ */ new Map(),
1391
+ queued: false
1392
+ };
1393
+ this.sessions.set(parent.id, entry);
1394
+ }
1395
+ for (const [cwd, old] of entry.runs) if (cwd !== run.cwd && old.workflow === run.workflow && old.finished) entry.runs.delete(cwd);
1396
+ const previous = entry.runs.get(run.cwd);
1397
+ const completed = Object.keys(state?.tasks ?? {}).filter((id) => ["pass", "skipped"].includes(state.tasks[id]?.status));
1398
+ const completionOrder = (previous?.completionOrder ?? run.completionOrder ?? []).filter((id) => completed.includes(id));
1399
+ for (const id of completed) if (!completionOrder.includes(id)) completionOrder.push(id);
1400
+ entry.runs.set(run.cwd, {
1401
+ workflow: run.workflow,
1402
+ cwd: run.cwd,
1403
+ external: run.external,
1404
+ taskNames: run.taskNames,
1405
+ completionOrder,
1406
+ state,
1407
+ finished
1408
+ });
1409
+ this.sync(entry);
1410
+ }
1411
+ sync(entry) {
1412
+ if (this.closed || this.sessions.get(entry.session.id) !== entry) return;
1413
+ try {
1414
+ const current = this.ctx.sessionProjections.stateOf(entry.session, "todos");
1415
+ if (current === void 0) return;
1416
+ const existing = current ?? [];
1417
+ const names = [...entry.runs.values()].map((run) => run.workflow);
1418
+ const rows = [...entry.runs.values()].flatMap((run) => workflowTodos(names.filter((name) => name === run.workflow).length > 1 ? `${run.workflow} (${run.cwd})` : run.workflow, run.state, run.finished, run.taskNames, run.completionOrder));
1419
+ const todos = [...existing.filter((item) => !item.content.startsWith(PREFIX$1)), ...rows];
1420
+ if (JSON.stringify(todos) !== JSON.stringify(existing)) entry.session.append("todo/write", { todos });
1421
+ } catch {}
1422
+ }
1423
+ dispose() {
1424
+ this.closed = true;
1425
+ clearInterval(this.observer);
1426
+ this.externalSessions.clear();
1427
+ this.externalRuns.clear();
1428
+ this.release?.();
1429
+ this.releaseCreated?.();
1430
+ this.sessions.clear();
1431
+ }
1432
+ };
1433
+ function withinWorkspace(root, path) {
1434
+ const diff = relative(root, path);
1435
+ return diff !== ".." && !diff.startsWith("../") && !isAbsolute(diff);
1436
+ }
1437
+ //#endregion
1438
+ //#region src/native-workflow.mjs
1439
+ /** Python owns scheduling and verdicts; each dispatched node runs in DSH's native child loop. */
1440
+ function within(root, path) {
1441
+ const diff = relative(root, path);
1442
+ return diff !== ".." && !diff.startsWith("../") && !diff.startsWith("..\\") && !isAbsolute(diff);
1443
+ }
1444
+ var NativeWorkflow = class {
1445
+ constructor(ctx) {
1446
+ this.ctx = ctx;
1447
+ this.todos = new WorkflowTodos(ctx);
1448
+ this.runs = /* @__PURE__ */ new Map();
1449
+ this.closed = false;
1450
+ }
1451
+ async start(parent, { runner, yaml, cwd, resources, prompt, workflow = "", goalId }) {
1452
+ if (this.closed) throw new Error("DSH workflow runner is stopping");
1453
+ await mkdir(cwd, { recursive: true });
1454
+ cwd = await realpath(cwd);
1455
+ for (const run of this.runs.values()) if (run.cwd === cwd && run.code === null) throw new Error("A workflow is already running in this directory");
1456
+ const id = randomUUID(), controller = new AbortController();
1457
+ const run = {
1458
+ id,
1459
+ owner: parent.id,
1460
+ goalId,
1461
+ workflow,
1462
+ cwd,
1463
+ code: null,
1464
+ error: "",
1465
+ controller,
1466
+ process: null,
1467
+ done: null,
1468
+ log: "",
1469
+ finalState: void 0
1470
+ };
1471
+ this.runs.set(id, run);
1472
+ let file;
1473
+ try {
1474
+ run.taskNames = await readTaskNames(yaml);
1475
+ run.completionOrder = await readCompletionOrder(cwd);
1476
+ await mkdir(join(cwd, ".workflow"), { recursive: true });
1477
+ file = await open(join(cwd, ".workflow/dsh-runner.log"), "a");
1478
+ controller.signal.throwIfAborted();
1479
+ if (this.closed) throw new Error("DSH workflow runner is stopping");
1480
+ const argv = [
1481
+ "-c",
1482
+ pythonBootstrap,
1483
+ runner,
1484
+ "--yaml",
1485
+ yaml,
1486
+ "--work-dir",
1487
+ cwd
1488
+ ];
1489
+ if (prompt) argv.push("--prompt", prompt);
1490
+ const child = spawn("python3", argv, {
1491
+ cwd,
1492
+ stdio: [
1493
+ "pipe",
1494
+ "pipe",
1495
+ "pipe"
1496
+ ]
1497
+ });
1498
+ run.process = child;
1499
+ const pending = /* @__PURE__ */ new Set();
1500
+ const lines = createInterface({ input: child.stdout });
1501
+ let logging = Promise.resolve();
1502
+ const log = (text) => {
1503
+ run.log = (run.log + String(text)).slice(-8192);
1504
+ logging = logging.then(() => file.appendFile(text)).catch((error) => {
1505
+ run.error = String(error);
1506
+ });
1507
+ };
1508
+ child.stderr.on("data", log);
1509
+ child.stdin.on("error", (error) => {
1510
+ if (!controller.signal.aborted) run.error = String(error);
1511
+ });
1512
+ child.on("error", (error) => {
1513
+ run.error = String(error);
1514
+ });
1515
+ lines.on("line", (line) => {
1516
+ let request;
1517
+ try {
1518
+ request = JSON.parse(line);
1519
+ if (typeof request.id !== "string" || typeof request.prompt !== "string" || typeof request.taskId !== "string" || ![
1520
+ "execute",
1521
+ "verify",
1522
+ "advice"
1523
+ ].includes(request.phase) || resolve(request.workDir) !== cwd) throw new Error("Invalid Python dispatch");
1524
+ } catch (error) {
1525
+ run.error = String(error);
1526
+ controller.abort();
1527
+ child.kill();
1528
+ return;
1529
+ }
1530
+ const job = this.executeNode(parent, request, resources, controller.signal).catch((error) => ({
1531
+ ok: false,
1532
+ error: String(error)
1533
+ })).then((result) => {
1534
+ if (!child.stdin.destroyed && !controller.signal.aborted) child.stdin.write(JSON.stringify({
1535
+ id: request.id,
1536
+ ...result
1537
+ }) + "\n");
1538
+ });
1539
+ pending.add(job);
1540
+ job.finally(() => pending.delete(job));
1541
+ });
1542
+ run.done = new Promise((accept) => child.on("close", async (code, signal) => {
1543
+ run.todoStopped = true;
1544
+ clearTimeout(run.todoTimer);
1545
+ await run.todoPoll;
1546
+ controller.abort();
1547
+ lines.close();
1548
+ await Promise.allSettled([...pending]);
1549
+ await logging;
1550
+ await file.close().catch((error) => {
1551
+ run.error = String(error);
1552
+ });
1553
+ try {
1554
+ run.finalState = JSON.parse(await readFile(join(cwd, ".workflow/status.json"), "utf8"));
1555
+ } catch {}
1556
+ run.code = code ?? 2;
1557
+ if (signal) run.error ||= `Scheduler terminated by ${signal}`;
1558
+ this.todos.update(parent, run, run.finalState, run.paused ? "paused" : true);
1559
+ accept();
1560
+ }));
1561
+ this.trackTodos(parent, run);
1562
+ return {
1563
+ runId: id,
1564
+ workDir: cwd,
1565
+ status: "running"
1566
+ };
1567
+ } catch (error) {
1568
+ this.runs.delete(id);
1569
+ await file?.close();
1570
+ throw error;
1571
+ }
1572
+ }
1573
+ trackTodos(parent, run) {
1574
+ if (!parent.session || !this.ctx.sessionProjections?.stateOf) return;
1575
+ const poll = () => {
1576
+ run.todoPoll = (async () => {
1577
+ try {
1578
+ const state = JSON.parse(await readFile(join(run.cwd, ".workflow/status.json"), "utf8"));
1579
+ if (!run.todoStopped) this.todos.update(parent, run, state);
1580
+ } catch {}
1581
+ if (!run.todoStopped) run.todoTimer = setTimeout(poll, 1e3);
1582
+ })();
1583
+ };
1584
+ poll();
1585
+ }
1586
+ async executeNode(parent, request, resources, signal) {
1587
+ if (typeof request.role !== "string" || !/^[\w-]+$/.test(request.role)) throw new Error("Invalid workflow role");
1588
+ const role = await readFile(join(resources, "agents", request.role + ".md"), "utf8");
1589
+ signal.throwIfAborted();
1590
+ const activation = await this.ctx.subagents.startActivation({
1591
+ provider: "spawn",
1592
+ delivery: "caller",
1593
+ signal,
1594
+ label: `CANNBot ${request.taskId} · ${request.phase}`,
1595
+ request: {
1596
+ parent,
1597
+ cwd: request.workDir,
1598
+ prompt: [{
1599
+ type: "text",
1600
+ text: `Execute one Python-scheduled workflow node using the DSH agent loop.\nWorkflow resources: ${resources}\nSkills: ${join(resources, "skills")}\nRole instructions (frontmatter is metadata):\n${role}\n\nFollow the task's verdict callback instructions. Python owns dependencies, retries, approvals and status updates.\n` + request.prompt
1601
+ }]
1602
+ }
1603
+ });
1604
+ const cancel = () => {
1605
+ activation.dispose().catch(() => {});
1606
+ };
1607
+ signal.addEventListener("abort", cancel, { once: true });
1608
+ if (signal.aborted) cancel();
1609
+ try {
1610
+ const result = await activation.result;
1611
+ return {
1612
+ ok: !signal.aborted && result.stopReason === "completed",
1613
+ childSessionId: activation.childId,
1614
+ stopReason: result.stopReason,
1615
+ output: result.output,
1616
+ diagnostic: result.diagnostic
1617
+ };
1618
+ } finally {
1619
+ signal.removeEventListener("abort", cancel);
1620
+ await activation.dispose();
1621
+ }
1622
+ }
1623
+ owned(parent, id) {
1624
+ const run = this.runs.get(id);
1625
+ if (!run || run.owner !== parent.id) throw new Error("Workflow run is unavailable in this session");
1626
+ return run;
1627
+ }
1628
+ async status(parent, id, waitMs = 0) {
1629
+ const run = this.owned(parent, id);
1630
+ if (!Number.isInteger(waitMs) || waitMs < 0 || waitMs > 3e4) throw new Error("waitMs must be between 0 and 30000");
1631
+ let timer;
1632
+ if (run.done && run.code === null && waitMs) try {
1633
+ await Promise.race([run.done, new Promise((accept) => {
1634
+ timer = setTimeout(accept, waitMs);
1635
+ })]);
1636
+ } finally {
1637
+ clearTimeout(timer);
1638
+ }
1639
+ let state = run.finalState;
1640
+ if (run.code === null) try {
1641
+ state = JSON.parse(await readFile(join(run.cwd, ".workflow/status.json"), "utf8"));
1642
+ } catch (error) {
1643
+ if (error.code !== "ENOENT") throw error;
1644
+ }
1645
+ if (state) this.todos.update(parent, run, state, run.code !== null && (run.paused ? "paused" : true));
1646
+ return {
1647
+ runId: id,
1648
+ workDir: run.cwd,
1649
+ status: run.code === null ? "running" : run.paused ? "paused" : run.code === 0 ? "completed" : "failed",
1650
+ exitCode: run.code,
1651
+ error: run.error || void 0,
1652
+ state,
1653
+ log: run.log
1654
+ };
1655
+ }
1656
+ async stop(run) {
1657
+ run.controller.abort();
1658
+ run.process?.stdin.end();
1659
+ run.process?.kill("SIGTERM");
1660
+ const timer = setTimeout(() => run.process?.kill("SIGKILL"), 3e3);
1661
+ try {
1662
+ await run.done;
1663
+ } finally {
1664
+ clearTimeout(timer);
1665
+ }
1666
+ }
1667
+ async cancel(parent, id) {
1668
+ const run = this.owned(parent, id);
1669
+ if (run.code === null) await this.stop(run);
1670
+ return this.status(parent, id);
1671
+ }
1672
+ async dispose() {
1673
+ this.closed = true;
1674
+ await Promise.all([...this.runs.values()].filter((run) => run.code === null).map((run) => this.stop(run)));
1675
+ this.todos.dispose();
1676
+ }
1677
+ };
1678
+ //#endregion
1679
+ //#region src/goal-workflow-resume.mjs
1680
+ /** Recover successful starts belonging to this goal, excluding fork-inherited work. */
1681
+ function goalWorkflowRuns(events, goalId, inherited = 0) {
1682
+ const calls = /* @__PURE__ */ new Map(), runs = /* @__PURE__ */ new Map();
1683
+ let currentGoal, turn, hadGoal = false;
1684
+ for (const event of events) {
1685
+ if (event.seq < inherited) continue;
1686
+ if (event.type === "tool/call") turn = event.data.turn;
1687
+ if (event.type === "goal/change") {
1688
+ if (!hadGoal && event.data.operation === "create") {
1689
+ for (const call of calls.values()) if (call.turn === turn) call.goal = event.data.goal?.id;
1690
+ }
1691
+ currentGoal = event.data.goal?.id;
1692
+ hadGoal = true;
1693
+ }
1694
+ if (event.type === "tool/call" && event.data.name === "cannbot_workflow_run") try {
1695
+ calls.set(event.data.callId, {
1696
+ args: JSON.parse(event.data.arguments),
1697
+ goal: currentGoal,
1698
+ turn
1699
+ });
1700
+ } catch {}
1701
+ if (event.type !== "tool/result" || event.data.isError) continue;
1702
+ const message = event.data.message, call = calls.get(message?.toolCallId);
1703
+ if (!call) continue;
1704
+ for (const block of message.content ?? []) try {
1705
+ const result = JSON.parse(block.text);
1706
+ if (result.runId && typeof result.workDir === "string") runs.set(result.workDir, {
1707
+ call,
1708
+ workDir: result.workDir
1709
+ });
1710
+ } catch {}
1711
+ }
1712
+ return [...runs.values()].filter((run) => run.call.goal === goalId).map(({ call, workDir }) => ({
1713
+ workflow: call.args.workflow,
1714
+ yaml: call.args.yaml,
1715
+ workDir
1716
+ }));
1717
+ }
1718
+ function installGoalWorkflowResume(ctx, execution, start) {
1719
+ const pending = /* @__PURE__ */ new Map();
1720
+ const stopping = /* @__PURE__ */ new Map();
1721
+ ctx.on("goal/changed", ({ agent, change }) => {
1722
+ if (change.operation === "resume") pending.set(agent, change.ref);
1723
+ else pending.delete(agent);
1724
+ if (change.operation === "pause") {
1725
+ const previous = stopping.get(agent);
1726
+ const job = (async () => {
1727
+ await previous;
1728
+ const history = await ctx.sessionQuery.readSession(agent.id);
1729
+ const directories = new Set(goalWorkflowRuns(history.events, change.ref.id, agent.session.inheritedEventCount).map((run) => run.workDir));
1730
+ await Promise.all([...execution.runs.values()].filter((run) => run.owner === agent.id && run.code === null && (run.goalId === change.ref.id || directories.has(run.cwd))).map((run) => {
1731
+ run.paused = true;
1732
+ return execution.stop(run);
1733
+ }));
1734
+ })();
1735
+ stopping.set(agent, job);
1736
+ job.catch((error) => ctx.logger?.warn(`Cannot pause workflow: ${error}`));
1737
+ return job;
1738
+ }
1739
+ });
1740
+ ctx.on("agent/disposed", ({ agent }) => {
1741
+ pending.delete(agent);
1742
+ stopping.delete(agent);
1743
+ });
1744
+ ctx.on("dispose", () => {
1745
+ pending.clear();
1746
+ stopping.clear();
1747
+ });
1748
+ ctx.on("agent/pre-step", async ({ agent, signal }, next) => {
1749
+ const decision = await next();
1750
+ const ref = pending.get(agent);
1751
+ if (decision.kind !== "enter" || !ref) return decision;
1752
+ const valid = () => {
1753
+ const goal = ctx.goals.get(agent);
1754
+ return pending.get(agent) === ref && goal?.id === ref.id && goal.revision === ref.revision && goal.phase === "active";
1755
+ };
1756
+ if (!valid()) return decision;
1757
+ const notices = [];
1758
+ try {
1759
+ await stopping.get(agent);
1760
+ signal.throwIfAborted();
1761
+ if (!valid()) return decision;
1762
+ const runs = goalWorkflowRuns((await ctx.sessionQuery.readSession(agent.id)).events, ref.id, agent.session.inheritedEventCount);
1763
+ const workspace = await realpath(await ctx.workingDirectory.ensure(agent, signal));
1764
+ for (const args of runs) {
1765
+ signal.throwIfAborted();
1766
+ if (!valid()) break;
1767
+ const cwd = await realpath(args.workDir);
1768
+ if (!within(workspace, cwd)) throw new Error("Workflow directory escapes the session workspace");
1769
+ const live = [...execution.runs.values()].find((run) => run.cwd === cwd && run.code === null);
1770
+ if (live) {
1771
+ if (live.owner !== agent.id) throw new Error("Workflow directory is running in another session");
1772
+ notices.push({
1773
+ workflow: args.workflow,
1774
+ runId: live.id,
1775
+ status: "running",
1776
+ reused: true
1777
+ });
1778
+ continue;
1779
+ }
1780
+ const path = await realpath(join(cwd, ".workflow/status.json"));
1781
+ if (!within(workspace, path)) throw new Error("Workflow state escapes the session workspace");
1782
+ const state = JSON.parse(await readFile(path, "utf8"));
1783
+ const tasks = Object.values(state.tasks ?? {});
1784
+ if (!tasks.length || tasks.every((task) => ["pass", "skipped"].includes(task.status))) continue;
1785
+ signal.throwIfAborted();
1786
+ if (!valid()) break;
1787
+ const result = await start(args, agent, signal);
1788
+ const run = execution.runs.get(result.runId);
1789
+ if (run) run.goalId = ref.id;
1790
+ if (!valid()) {
1791
+ if (run && run.code === null) {
1792
+ run.paused = true;
1793
+ await execution.stop(run);
1794
+ }
1795
+ break;
1796
+ }
1797
+ notices.push({
1798
+ workflow: args.workflow,
1799
+ ...result
1800
+ });
1801
+ }
1802
+ } catch (error) {
1803
+ if (signal.aborted) throw error;
1804
+ notices.push({
1805
+ error: String(error),
1806
+ instruction: "Workflow recovery failed. Diagnose this error; do not assume a stale running state means the scheduler is alive."
1807
+ });
1808
+ }
1809
+ if (pending.get(agent) === ref) pending.delete(agent);
1810
+ if (!notices.length) return decision;
1811
+ return {
1812
+ ...decision,
1813
+ messages: decision.messages.map((message, index) => index === 0 ? {
1814
+ ...message,
1815
+ content: [...message.content, {
1816
+ type: "text",
1817
+ text: `CANNBot workflow recovery after goal resume: ${JSON.stringify(notices)}\nUse cannbot_workflow_status with the reported runId to track scheduling; do not replace it with shell sleep/poll loops.`
1818
+ }]
1819
+ } : message)
1820
+ };
1821
+ });
1822
+ }
1823
+ //#endregion
1824
+ //#region src/pm-progress.mjs
1825
+ const PREFIX = "[PM 当前操作] ";
1826
+ const isWorkflow = (item) => item.content.startsWith("[工作流] ");
1827
+ const isProgress = (item) => item.content.startsWith(PREFIX);
1828
+ /** Keep native PM plans across turns; show factual activity until PM supplies a plan. */
1829
+ var PmProgress = class {
1830
+ constructor(ctx) {
1831
+ this.ctx = ctx;
1832
+ this.entries = /* @__PURE__ */ new Map();
1833
+ this.closed = false;
1834
+ this.releases = [ctx.on("session/created", (session) => {
1835
+ this.restore(session);
1836
+ }), ctx.on("session/event", (session, event) => this.event(session, event))];
1837
+ for (const session of ctx.sessions?.list() ?? []) this.restore(session);
1838
+ }
1839
+ event(session, event, replay = false) {
1840
+ if (this.closed) return;
1841
+ if (![
1842
+ "user/message",
1843
+ "tool/call",
1844
+ "tool/result",
1845
+ "todo/write",
1846
+ "turn/start",
1847
+ "turn/end"
1848
+ ].includes(event.type)) return;
1849
+ let entry = this.entries.get(session.id);
1850
+ if (event.type === "user/message" && event.data.source?.kind === "cannbot-harness-workflow") {
1851
+ if (!entry) {
1852
+ entry = {
1853
+ session,
1854
+ plan: [],
1855
+ activity: "准备工作流",
1856
+ status: "in_progress",
1857
+ queued: false
1858
+ };
1859
+ this.entries.set(session.id, entry);
1860
+ }
1861
+ }
1862
+ if (!entry) return;
1863
+ if (event.type === "tool/call" && event.data.name !== "todo_write") try {
1864
+ const args = JSON.parse(event.data.arguments);
1865
+ if (typeof args.description === "string" && args.description.trim()) {
1866
+ entry.activity = args.description.trim().slice(0, 240);
1867
+ entry.status = "in_progress";
1868
+ entry.callId = event.data.callId;
1869
+ }
1870
+ } catch {}
1871
+ if (event.type === "tool/result" && event.data.message?.toolCallId === entry.callId && event.data.isError) {
1872
+ entry.status = "pending";
1873
+ entry.activity = `操作失败,待处理:${entry.activity}`;
1874
+ }
1875
+ if (event.type === "todo/write") {
1876
+ const rows = event.data.todos ?? [];
1877
+ const plan = rows.filter((item) => !isWorkflow(item) && !isProgress(item));
1878
+ if (!rows.some(isProgress) && (plan.length || !rows.length)) entry.plan = plan;
1879
+ }
1880
+ if (event.type === "turn/end") entry.status = "pending";
1881
+ if (event.type === "turn/start") entry.status = "in_progress";
1882
+ if (!replay) this.queue(entry);
1883
+ }
1884
+ queue(entry) {
1885
+ if (entry.queued || this.closed) return;
1886
+ entry.queued = true;
1887
+ queueMicrotask(() => {
1888
+ entry.queued = false;
1889
+ if (this.closed || this.entries.get(entry.session.id) !== entry) return;
1890
+ const current = this.ctx.sessionProjections.stateOf(entry.session, "todos");
1891
+ if (current === void 0) return;
1892
+ const workflow = (current ?? []).filter(isWorkflow);
1893
+ const rows = [...entry.plan, ...workflow];
1894
+ if (!rows.length) rows.push({
1895
+ content: PREFIX + entry.activity,
1896
+ status: entry.status
1897
+ });
1898
+ if (JSON.stringify(current) !== JSON.stringify(rows)) entry.session.append("todo/write", { todos: rows });
1899
+ });
1900
+ }
1901
+ async restore(session) {
1902
+ try {
1903
+ const history = await this.ctx.sessionQuery.readSession(session.id);
1904
+ if (this.closed || this.entries.has(session.id)) return;
1905
+ for (const event of history.events) if (event.seq >= (session.inheritedEventCount ?? 0)) this.event(session, event, true);
1906
+ const entry = this.entries.get(session.id);
1907
+ if (entry) this.queue(entry);
1908
+ } catch (error) {
1909
+ this.ctx.logger?.warn(`Cannot restore PM progress: ${error}`);
1910
+ }
1911
+ }
1912
+ dispose() {
1913
+ this.closed = true;
1914
+ for (const release of this.releases) release?.();
1915
+ this.entries.clear();
1916
+ }
1917
+ };
1918
+ //#endregion
1919
+ //#region src/native-workflow-tools.mjs
1920
+ /** Register generic Python workflow scheduling tools when DSH's native loop is available. */
1921
+ function installNativeWorkflowTools(ctx, content) {
1922
+ ctx.inject([
1923
+ "tools",
1924
+ "subagents",
1925
+ "workingDirectory",
1926
+ "sessionProjections",
1927
+ "sessions",
1928
+ "sessionQuery"
1929
+ ], (scoped) => {
1930
+ const execution = new NativeWorkflow(scoped);
1931
+ const progress = new PmProgress(scoped);
1932
+ let goals;
1933
+ scoped.on("dispose", () => execution.dispose());
1934
+ scoped.on("dispose", () => progress.dispose());
1935
+ async function start(args, agent, signal) {
1936
+ const workspace = await realpath(await scoped.workingDirectory.ensure(agent, signal));
1937
+ await content.prepare(workspace, args.workflow);
1938
+ const source = content.getSource();
1939
+ const plugin = source.catalog.plugins.find((item) => item.id === args.workflow);
1940
+ if (!plugin) throw new Error("Unknown workflow package");
1941
+ const resources = await realpath(resolve(source.assets, plugin.resourceDirectory ?? plugin.id));
1942
+ const runner = join(resources, "skills/workflow-orchestrator/scripts/orchestrator.py");
1943
+ await access(runner);
1944
+ const yaml = await realpath(isAbsolute(args.yaml) ? args.yaml : resolve(resources, args.yaml));
1945
+ if (!within(resources, yaml) && !within(workspace, yaml)) throw new Error("Workflow YAML must belong to the package or workspace");
1946
+ const cwd = resolve(workspace, args.workDir ?? ".");
1947
+ if (!within(workspace, cwd)) throw new Error("Workflow directory must be inside the session workspace");
1948
+ let ancestor = cwd;
1949
+ while (true) try {
1950
+ if (!within(workspace, await realpath(ancestor))) throw new Error("Workflow directory escapes the session workspace");
1951
+ break;
1952
+ } catch (error) {
1953
+ if (error.code !== "ENOENT") throw error;
1954
+ ancestor = resolve(ancestor, "..");
1955
+ }
1956
+ signal.throwIfAborted();
1957
+ return execution.start(agent, {
1958
+ runner,
1959
+ yaml,
1960
+ cwd,
1961
+ resources,
1962
+ prompt: args.prompt,
1963
+ workflow: args.workflow,
1964
+ goalId: goals?.get(agent)?.id
1965
+ });
1966
+ }
1967
+ scoped.inject(["goals"], (goalScope) => {
1968
+ goals = goalScope.goals;
1969
+ goalScope.on("dispose", () => {
1970
+ goals = void 0;
1971
+ });
1972
+ installGoalWorkflowResume(goalScope, execution, start);
1973
+ });
1974
+ const string = {
1975
+ type: "string",
1976
+ required: true
1977
+ };
1978
+ const specs = [
1979
+ [
1980
+ "cannbot_workflow_run",
1981
+ {
1982
+ workflow: string,
1983
+ yaml: string,
1984
+ prompt: { type: "string" },
1985
+ workDir: { type: "string" }
1986
+ },
1987
+ "Run or resume a YAML workflow from the selected CANNBot workflow package. Python owns scheduling, parallelism, retries and verdicts; nodes use the DSH native agent loop and inherit the session model. No CLI provider is needed. yaml is absolute or relative to package resources; workDir defaults to the session workspace. prompt is required for a new run."
1988
+ ],
1989
+ [
1990
+ "cannbot_workflow_status",
1991
+ {
1992
+ runId: string,
1993
+ waitMs: { type: "number" }
1994
+ },
1995
+ "Read Python workflow state and log. Optionally wait up to 30000 ms. Awaiting approvals remain governed by the Python workflow approval scripts."
1996
+ ],
1997
+ [
1998
+ "cannbot_workflow_cancel",
1999
+ { runId: string },
2000
+ "Stop this session’s scheduler and native node agents. Start the same YAML and working directory again to recover through the Python resume logic."
2001
+ ]
2002
+ ];
2003
+ for (const [name, parameters, description] of specs) scoped.tools.register(defineTool({
2004
+ name,
2005
+ parameters,
2006
+ description,
2007
+ output: {
2008
+ schema: {
2009
+ type: "object",
2010
+ properties: {},
2011
+ additionalProperties: true
2012
+ },
2013
+ render: (_args, value) => [{
2014
+ type: "text",
2015
+ text: JSON.stringify(value)
2016
+ }]
2017
+ },
2018
+ async execute(args, exec) {
2019
+ if (!exec.agent) throw new Error("A live DSH session is required");
2020
+ if (name === "cannbot_workflow_status") return execution.status(exec.agent, args.runId, args.waitMs);
2021
+ if (name === "cannbot_workflow_cancel") return execution.cancel(exec.agent, args.runId);
2022
+ if (["provider", "model"].some((key) => key in args)) throw new Error("Workflow nodes inherit the DSH session model");
2023
+ return start(args, exec.agent, exec.signal);
2024
+ }
2025
+ }));
2026
+ });
2027
+ }
2028
+ //#endregion
1110
2029
  //#region src/index.ts
1111
2030
  const inject = ["skills"];
1112
2031
  async function apply(ctx, config = {}) {
@@ -1117,6 +2036,7 @@ async function apply(ctx, config = {}) {
1117
2036
  await content.installDependencies(catalogFile, (name) => dependencies.resolve(`${name}/package.json`));
1118
2037
  registerContentRoutes(ctx, content);
1119
2038
  installWorkflowContext(ctx, config, content);
2039
+ installNativeWorkflowTools(ctx, content);
1120
2040
  ctx.skills.registerProvider((control) => {
1121
2041
  let observed = content.getSource().revision;
1122
2042
  const release = content.subscribe(() => {