@cannbot-plugin/cannbot-harness 0.3.3 → 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/LICENSE +38 -0
- package/README.md +37 -9
- package/THIRD_PARTY_NOTICES.md +9 -7
- package/WORKFLOW_PUBLISHING.md +5 -1
- package/icon.svg +1 -1
- package/lib/client.js +550 -435
- package/lib/client.js.map +1 -1
- package/lib/index.js +1067 -148
- package/official-plugins.json +45 -11
- package/package.json +10 -7
- package/workflow-defaults.json +23 -59
- package/runtime/initializer-compat.cjs +0 -25
- package/upstream.json +0 -61
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,
|
|
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,12 +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
|
-
"dsh-cannbot",
|
|
139
|
-
"@cannbot-plugin/cannbot-harness"
|
|
140
|
-
];
|
|
109
|
+
const UPDATE_PACKAGES = ["cannbot-harness", "@cannbot-plugin/cannbot-harness"];
|
|
141
110
|
/** Only stable npm releases of our two installation entries may be selected. */
|
|
142
111
|
function newerVersion(candidate, current) {
|
|
143
112
|
const next = /^(\d+)\.(\d+)\.(\d+)$/.exec(candidate);
|
|
@@ -147,7 +116,7 @@ function newerVersion(candidate, current) {
|
|
|
147
116
|
const difference = Number(next[index]) - Number(old[index]);
|
|
148
117
|
if (difference) return difference > 0;
|
|
149
118
|
}
|
|
150
|
-
return current
|
|
119
|
+
return /^\d+\.\d+\.\d+-/.test(current);
|
|
151
120
|
}
|
|
152
121
|
//#endregion
|
|
153
122
|
//#region src/update-host.ts
|
|
@@ -243,39 +212,93 @@ function registerHostUpdater(ctx) {
|
|
|
243
212
|
});
|
|
244
213
|
}
|
|
245
214
|
//#endregion
|
|
246
|
-
//#region src/
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
+
});
|
|
252
261
|
});
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
+
}
|
|
259
282
|
} catch (error) {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const previous = await readFile(path, "utf8"), record = JSON.parse(previous);
|
|
263
|
-
if (Number.isSafeInteger(record.pid) && typeof record.id === "string") try {
|
|
264
|
-
process.kill(record.pid, 0);
|
|
265
|
-
} catch (dead) {
|
|
266
|
-
if (dead.code === "ESRCH" && await readFile(path, "utf8") === previous) {
|
|
267
|
-
await unlink(path);
|
|
268
|
-
continue;
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
} catch {}
|
|
272
|
-
if (Date.now() > deadline) throw new Error("Another content operation is still running");
|
|
273
|
-
await new Promise((done) => setTimeout(done, 250));
|
|
283
|
+
database.close();
|
|
284
|
+
throw error;
|
|
274
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);
|
|
275
298
|
try {
|
|
276
299
|
return await action();
|
|
277
300
|
} finally {
|
|
278
|
-
|
|
301
|
+
release();
|
|
279
302
|
}
|
|
280
303
|
}
|
|
281
304
|
//#endregion
|
|
@@ -407,6 +430,12 @@ async function initializeTransaction(workspace, recoveryRoot, descriptor, resour
|
|
|
407
430
|
} catch (recovery) {
|
|
408
431
|
throw new Error(`${error.message}\n自动恢复未完成,备份保留在 ${directory}:${recovery.message}`);
|
|
409
432
|
}
|
|
433
|
+
await rm(directory, {
|
|
434
|
+
recursive: true,
|
|
435
|
+
force: true,
|
|
436
|
+
maxRetries: 3,
|
|
437
|
+
retryDelay: 100
|
|
438
|
+
}).catch(() => {});
|
|
410
439
|
throw new Error(`${error.message}\n已恢复本次初始化前的 Skills 和工作区配置。`);
|
|
411
440
|
}
|
|
412
441
|
await rm(journalPath);
|
|
@@ -458,6 +487,8 @@ var ContentManager = class {
|
|
|
458
487
|
stamp = "";
|
|
459
488
|
fetcher;
|
|
460
489
|
registry = REGISTRY;
|
|
490
|
+
/** Workflows declared by the shipped defaults; registry catalogs may not reintroduce others. */
|
|
491
|
+
shipped;
|
|
461
492
|
state = {
|
|
462
493
|
phase: "idle",
|
|
463
494
|
message: "",
|
|
@@ -467,6 +498,7 @@ var ContentManager = class {
|
|
|
467
498
|
this.fetcher = fetcher;
|
|
468
499
|
this.root = resolve(root);
|
|
469
500
|
const entries = JSON.parse(readFileSync(defaults, "utf8")).workflows.map(checkedEntry);
|
|
501
|
+
this.shipped = new Set(entries.map((item) => item.id));
|
|
470
502
|
this.index = {
|
|
471
503
|
schemaVersion: 1,
|
|
472
504
|
entries,
|
|
@@ -527,6 +559,10 @@ var ContentManager = class {
|
|
|
527
559
|
await this.fetcher.extract(manifest.dist.tarball, directory, options);
|
|
528
560
|
return directory;
|
|
529
561
|
} catch (error) {
|
|
562
|
+
await rm(directory, {
|
|
563
|
+
recursive: true,
|
|
564
|
+
force: true
|
|
565
|
+
}).catch(() => {});
|
|
530
566
|
if (String(error.message).includes("EINTEGRITY") || error.code === "EINTEGRITY") throw error;
|
|
531
567
|
const registry = new URL(manifest.dist.tarball).hostname === "registry.npmjs.org" ? MIRROR : REGISTRY;
|
|
532
568
|
const alternative = await this.fetcher.manifest(`${manifest.name}@${manifest.version}`, {
|
|
@@ -536,8 +572,16 @@ var ContentManager = class {
|
|
|
536
572
|
validateTarball(alternative);
|
|
537
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");
|
|
538
574
|
const fresh = await mkdtemp(join(dirname(directory), `${manifest.version}-mirror-`));
|
|
539
|
-
|
|
540
|
-
|
|
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
|
+
}
|
|
541
585
|
}
|
|
542
586
|
}
|
|
543
587
|
descriptor(record) {
|
|
@@ -706,26 +750,30 @@ var ContentManager = class {
|
|
|
706
750
|
});
|
|
707
751
|
return this.pending;
|
|
708
752
|
}
|
|
709
|
-
installDependencies(catalogFile, resolvePackage) {
|
|
753
|
+
installDependencies(catalogFile, resolvePackage, replaceFromSource = false) {
|
|
710
754
|
return this.operation("dependencies", async () => {
|
|
711
755
|
const catalog = JSON.parse(await readFile(catalogFile, "utf8"));
|
|
712
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");
|
|
713
|
-
const baseline = catalog.cannbotWorkflowCatalog.workflows.map(checkedEntry);
|
|
757
|
+
const baseline = catalog.cannbotWorkflowCatalog.workflows.map(checkedEntry).filter((item) => this.shipped.has(item.id));
|
|
714
758
|
if (!baseline.length || new Set(baseline.map((item) => item.id)).size !== baseline.length) throw new Error("Invalid installed workflow entries");
|
|
715
|
-
const newerCatalog = this.index.catalogVersion && newerVersion(this.index.catalogVersion, catalog.version);
|
|
759
|
+
const newerCatalog = !replaceFromSource && this.index.catalogVersion && newerVersion(this.index.catalogVersion, catalog.version);
|
|
716
760
|
const entries = newerCatalog ? [...this.index.entries] : [...baseline];
|
|
717
761
|
for (const old of this.index.entries) if (!entries.some((item) => item.id === old.id)) entries.push(old);
|
|
718
|
-
|
|
762
|
+
const next = {
|
|
719
763
|
...this.index,
|
|
720
764
|
entries,
|
|
721
|
-
catalogVersion: newerCatalog ? this.index.catalogVersion : catalog.version
|
|
722
|
-
|
|
765
|
+
catalogVersion: newerCatalog ? this.index.catalogVersion : catalog.version,
|
|
766
|
+
records: { ...this.index.records },
|
|
767
|
+
pinned: [...this.index.pinned]
|
|
768
|
+
};
|
|
723
769
|
for (const entry of baseline.filter((item) => item.default)) {
|
|
724
|
-
if (this.index.records[entry.id]) continue;
|
|
770
|
+
if (!replaceFromSource && this.index.records[entry.id]) continue;
|
|
725
771
|
if (!catalog.dependencies?.[entry.package]) throw new Error(`Workflow dependency missing: ${entry.package}`);
|
|
726
772
|
const packageFile = resolvePackage(entry.package);
|
|
727
773
|
const manifest = JSON.parse(await readFile(packageFile, "utf8"));
|
|
728
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;
|
|
729
777
|
const parent = join(this.root, "packages", entry.id);
|
|
730
778
|
await mkdir(parent, { recursive: true });
|
|
731
779
|
const directory = await mkdtemp(join(parent, `${manifest.version}-dependency-`));
|
|
@@ -733,7 +781,7 @@ var ContentManager = class {
|
|
|
733
781
|
recursive: true,
|
|
734
782
|
dereference: true
|
|
735
783
|
});
|
|
736
|
-
const integrity =
|
|
784
|
+
const integrity = identity;
|
|
737
785
|
const record = {
|
|
738
786
|
id: entry.id,
|
|
739
787
|
package: entry.package,
|
|
@@ -743,15 +791,17 @@ var ContentManager = class {
|
|
|
743
791
|
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
744
792
|
dependency: true
|
|
745
793
|
};
|
|
746
|
-
this.
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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);
|
|
754
803
|
}
|
|
804
|
+
await this.save(next);
|
|
755
805
|
this.publish({
|
|
756
806
|
phase: "ready",
|
|
757
807
|
message: "Installed workflow dependencies are ready."
|
|
@@ -767,7 +817,7 @@ var ContentManager = class {
|
|
|
767
817
|
});
|
|
768
818
|
const catalog = await this.manifest(`${CATALOG_PACKAGE}@latest`);
|
|
769
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");
|
|
770
|
-
const entries = catalog.cannbotWorkflowCatalog.workflows.map(checkedEntry);
|
|
820
|
+
const entries = catalog.cannbotWorkflowCatalog.workflows.map(checkedEntry).filter((item) => this.shipped.has(item.id));
|
|
771
821
|
if (!entries.length || new Set(entries.map((item) => item.id)).size !== entries.length) throw new Error("Invalid workflow catalog");
|
|
772
822
|
for (const old of this.index.entries) if (this.index.records[old.id] && !entries.some((item) => item.id === old.id)) entries.push(old);
|
|
773
823
|
await this.save({
|
|
@@ -821,27 +871,45 @@ var ContentManager = class {
|
|
|
821
871
|
const parent = join(this.root, "packages", entry.id);
|
|
822
872
|
await mkdir(parent, { recursive: true });
|
|
823
873
|
let directory = await mkdtemp(join(parent, `${manifest.version}-`));
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
...
|
|
838
|
-
|
|
839
|
-
...this.index
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
+
}
|
|
845
913
|
this.errors.delete(entry.id);
|
|
846
914
|
} catch (error) {
|
|
847
915
|
const message = String(error.message).slice(-500);
|
|
@@ -875,61 +943,30 @@ var ContentManager = class {
|
|
|
875
943
|
});
|
|
876
944
|
}
|
|
877
945
|
async runInitializer(entry, args, cwd) {
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
const child = spawn(process.execPath, [
|
|
882
|
-
"--require",
|
|
883
|
-
compatibility,
|
|
884
|
-
entry,
|
|
885
|
-
...args
|
|
886
|
-
], {
|
|
946
|
+
let output = "";
|
|
947
|
+
try {
|
|
948
|
+
await runProcess(process.execPath, [entry, ...args], {
|
|
887
949
|
cwd,
|
|
888
|
-
windowsHide: true,
|
|
889
|
-
detached: process.platform !== "win32",
|
|
890
950
|
stdio: [
|
|
891
951
|
"ignore",
|
|
892
952
|
"pipe",
|
|
893
953
|
"pipe"
|
|
894
954
|
],
|
|
955
|
+
timeoutMs: 20 * 6e4,
|
|
895
956
|
env: {
|
|
896
957
|
...process.env,
|
|
897
958
|
...process.versions.electron ? { ELECTRON_RUN_AS_NODE: "1" } : {},
|
|
898
|
-
CANNBOT_INITIALIZER_WORKSPACE: cwd,
|
|
899
959
|
GIT_TERMINAL_PROMPT: "0",
|
|
900
960
|
GCM_INTERACTIVE: "never"
|
|
961
|
+
},
|
|
962
|
+
onData: (data) => {
|
|
963
|
+
output = (output + data).slice(-16e3);
|
|
964
|
+
this.publish({ log: output.slice(-6e3) });
|
|
901
965
|
}
|
|
902
966
|
});
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
};
|
|
907
|
-
child.stdout.on("data", log);
|
|
908
|
-
child.stderr.on("data", log);
|
|
909
|
-
const timer = setTimeout(() => {
|
|
910
|
-
stopped = true;
|
|
911
|
-
if (child.pid) if (process.platform === "win32") spawn("taskkill", [
|
|
912
|
-
"/PID",
|
|
913
|
-
String(child.pid),
|
|
914
|
-
"/T",
|
|
915
|
-
"/F"
|
|
916
|
-
], {
|
|
917
|
-
windowsHide: true,
|
|
918
|
-
stdio: "ignore"
|
|
919
|
-
});
|
|
920
|
-
else try {
|
|
921
|
-
process.kill(-child.pid, "SIGTERM");
|
|
922
|
-
} catch {}
|
|
923
|
-
}, 20 * 6e4);
|
|
924
|
-
child.on("error", (error) => {
|
|
925
|
-
clearTimeout(timer);
|
|
926
|
-
reject(error);
|
|
927
|
-
});
|
|
928
|
-
child.on("close", (code) => {
|
|
929
|
-
clearTimeout(timer);
|
|
930
|
-
code === 0 && !stopped ? accept() : reject(new Error(stopped ? "Initialization timed out" : output.slice(-1500) || "Initialization failed"));
|
|
931
|
-
});
|
|
932
|
-
});
|
|
967
|
+
} catch (error) {
|
|
968
|
+
throw new Error(`${error.message}\n${output.slice(-1500)}`);
|
|
969
|
+
}
|
|
933
970
|
}
|
|
934
971
|
async prepare(cwd, id) {
|
|
935
972
|
if (!validId(id)) throw new Error("Invalid workflow");
|
|
@@ -1108,6 +1145,887 @@ function registerContentRoutes(ctx, content) {
|
|
|
1108
1145
|
});
|
|
1109
1146
|
}
|
|
1110
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
|
|
1111
2029
|
//#region src/index.ts
|
|
1112
2030
|
const inject = ["skills"];
|
|
1113
2031
|
async function apply(ctx, config = {}) {
|
|
@@ -1118,6 +2036,7 @@ async function apply(ctx, config = {}) {
|
|
|
1118
2036
|
await content.installDependencies(catalogFile, (name) => dependencies.resolve(`${name}/package.json`));
|
|
1119
2037
|
registerContentRoutes(ctx, content);
|
|
1120
2038
|
installWorkflowContext(ctx, config, content);
|
|
2039
|
+
installNativeWorkflowTools(ctx, content);
|
|
1121
2040
|
ctx.skills.registerProvider((control) => {
|
|
1122
2041
|
let observed = content.getSource().revision;
|
|
1123
2042
|
const release = content.subscribe(() => {
|