@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/LICENSE +38 -0
- package/README.md +37 -8
- package/THIRD_PARTY_NOTICES.md +9 -7
- package/WORKFLOW_PUBLISHING.md +5 -1
- package/icon.svg +1 -1
- package/lib/client.js +550 -434
- package/lib/client.js.map +1 -1
- package/lib/index.js +1067 -147
- 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,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
|
|
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/
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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
|
-
|
|
539
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
...
|
|
837
|
-
|
|
838
|
-
...this.index
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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(() => {
|