@feng0531/dsh-execution-bus 0.12.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -264
- package/cordis.patch.yml +1 -1
- package/dist/error-codes.d.ts +14 -0
- package/dist/error-codes.js +15 -0
- package/dist/mode.d.ts +2 -2
- package/dist/mode.js +7 -7
- package/dist/plugin.d.ts +2 -6
- package/dist/plugin.js +28 -232
- package/dist/preset.d.ts +1 -1
- package/dist/preset.js +4 -6
- package/dist/product.d.ts +91 -195
- package/dist/product.js +66 -146
- package/dist/runtime/executor.d.ts +18 -0
- package/dist/runtime/executor.js +239 -0
- package/dist/runtime/plan.d.ts +30 -0
- package/dist/runtime/plan.js +185 -0
- package/dist/runtime/service.d.ts +66 -0
- package/dist/runtime/service.js +185 -0
- package/dist/runtime/store.d.ts +45 -0
- package/dist/runtime/store.js +102 -0
- package/docs/DECLARATIVE-RUNTIME.md +44 -0
- package/docs/FLOW-TEST.md +7 -54
- package/docs/INTEGRATION.md +4 -2
- package/docs/OPTIMIZATION.md +2 -0
- package/docs/PRODUCT.md +15 -40
- package/docs/RETROSPECTIVE.md +51 -0
- package/docs/host-compatibility.md +11 -74
- package/docs/protocol.md +27 -120
- package/docs/releases/0.11.1.md +2 -0
- package/docs/releases/0.12.1.md +30 -0
- package/docs/releases/1.0.0.md +33 -0
- package/docs/validation.md +23 -61
- package/package.json +4 -6
- package/dist/bus.d.ts +0 -148
- package/dist/bus.js +0 -773
- package/dist/diagnostics.d.ts +0 -41
- package/dist/diagnostics.js +0 -32
- package/dist/execution.d.ts +0 -70
- package/dist/execution.js +0 -193
- package/dist/handlers.d.ts +0 -25
- package/dist/handlers.js +0 -194
- package/dist/json.d.ts +0 -4
- package/dist/json.js +0 -46
- package/dist/limits.d.ts +0 -20
- package/dist/limits.js +0 -23
- package/dist/protocol.d.ts +0 -62
- package/dist/protocol.js +0 -54
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { ERRORS } from "../error-codes.js";
|
|
2
|
+
/** 声明式运行时只允许调用这些宿主工具;新增工具需同时更新幂等性判断。 */
|
|
3
|
+
export const RUNTIME_TOOLS = Object.freeze([
|
|
4
|
+
"read",
|
|
5
|
+
"glob",
|
|
6
|
+
"grep",
|
|
7
|
+
"edit",
|
|
8
|
+
"write",
|
|
9
|
+
"pwsh",
|
|
10
|
+
]);
|
|
11
|
+
export const IDEMPOTENT_TOOLS = new Set([
|
|
12
|
+
"read",
|
|
13
|
+
"glob",
|
|
14
|
+
"grep",
|
|
15
|
+
]);
|
|
16
|
+
export const RUNTIME_LIMITS = Object.freeze({
|
|
17
|
+
steps: 32,
|
|
18
|
+
planBytes: 64 * 1024,
|
|
19
|
+
idChars: 64,
|
|
20
|
+
retry: 3,
|
|
21
|
+
timeoutMinMs: 1000,
|
|
22
|
+
timeoutMaxMs: 600000,
|
|
23
|
+
concurrencyMax: 4,
|
|
24
|
+
goalChars: 500,
|
|
25
|
+
resultBytes: 64 * 1024,
|
|
26
|
+
});
|
|
27
|
+
const PLAN_KEYS = new Set(["goal", "steps", "concurrency"]);
|
|
28
|
+
const STEP_KEYS = new Set([
|
|
29
|
+
"id",
|
|
30
|
+
"tool",
|
|
31
|
+
"input",
|
|
32
|
+
"depends_on",
|
|
33
|
+
"retry",
|
|
34
|
+
"timeout_ms",
|
|
35
|
+
]);
|
|
36
|
+
const ID_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
37
|
+
function invalid() {
|
|
38
|
+
throw Error(ERRORS.INVALID_PLAN);
|
|
39
|
+
}
|
|
40
|
+
function isPlainObject(value) {
|
|
41
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
42
|
+
return false;
|
|
43
|
+
const proto = Object.getPrototypeOf(value);
|
|
44
|
+
return proto === Object.prototype || proto === null;
|
|
45
|
+
}
|
|
46
|
+
export function parsePlan(raw) {
|
|
47
|
+
if (!isPlainObject(raw))
|
|
48
|
+
invalid();
|
|
49
|
+
for (const key of Reflect.ownKeys(raw)) {
|
|
50
|
+
if (typeof key !== "string" || !PLAN_KEYS.has(key))
|
|
51
|
+
invalid();
|
|
52
|
+
const descriptor = Object.getOwnPropertyDescriptor(raw, key);
|
|
53
|
+
if (!("value" in descriptor) || !descriptor.enumerable)
|
|
54
|
+
invalid();
|
|
55
|
+
}
|
|
56
|
+
let bytes = 0;
|
|
57
|
+
try {
|
|
58
|
+
bytes = Buffer.byteLength(JSON.stringify(raw));
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
invalid();
|
|
62
|
+
}
|
|
63
|
+
if (bytes > RUNTIME_LIMITS.planBytes)
|
|
64
|
+
throw Error(ERRORS.PLAN_TOO_LARGE);
|
|
65
|
+
const goal = raw.goal;
|
|
66
|
+
if (goal !== undefined) {
|
|
67
|
+
if (typeof goal !== "string" ||
|
|
68
|
+
goal.length > RUNTIME_LIMITS.goalChars)
|
|
69
|
+
invalid();
|
|
70
|
+
}
|
|
71
|
+
const concurrency = raw.concurrency ?? 2;
|
|
72
|
+
if (typeof concurrency !== "number" ||
|
|
73
|
+
!Number.isSafeInteger(concurrency) ||
|
|
74
|
+
concurrency < 1 ||
|
|
75
|
+
concurrency > RUNTIME_LIMITS.concurrencyMax)
|
|
76
|
+
invalid();
|
|
77
|
+
if (!Array.isArray(raw.steps))
|
|
78
|
+
invalid();
|
|
79
|
+
if (raw.steps.length < 1 || raw.steps.length > RUNTIME_LIMITS.steps)
|
|
80
|
+
invalid();
|
|
81
|
+
const ids = new Set();
|
|
82
|
+
const steps = raw.steps.map((value) => {
|
|
83
|
+
const step = parseStep(value);
|
|
84
|
+
if (ids.has(step.id))
|
|
85
|
+
throw Error(ERRORS.PLAN_DUPLICATE_ID);
|
|
86
|
+
ids.add(step.id);
|
|
87
|
+
return step;
|
|
88
|
+
});
|
|
89
|
+
for (const step of steps) {
|
|
90
|
+
for (const dep of step.dependsOn) {
|
|
91
|
+
if (dep === step.id)
|
|
92
|
+
invalid();
|
|
93
|
+
if (!ids.has(dep))
|
|
94
|
+
throw Error(ERRORS.PLAN_UNKNOWN_DEP);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
assertAcyclic(steps);
|
|
98
|
+
return {
|
|
99
|
+
...(goal === undefined ? {} : { goal: goal }),
|
|
100
|
+
steps,
|
|
101
|
+
concurrency,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function parseStep(value) {
|
|
105
|
+
if (!isPlainObject(value))
|
|
106
|
+
invalid();
|
|
107
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
108
|
+
if (typeof key !== "string" || !STEP_KEYS.has(key))
|
|
109
|
+
invalid();
|
|
110
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
111
|
+
if (!("value" in descriptor) || !descriptor.enumerable)
|
|
112
|
+
invalid();
|
|
113
|
+
}
|
|
114
|
+
const id = value.id;
|
|
115
|
+
if (typeof id !== "string" ||
|
|
116
|
+
!ID_PATTERN.test(id) ||
|
|
117
|
+
id.length > RUNTIME_LIMITS.idChars)
|
|
118
|
+
invalid();
|
|
119
|
+
const tool = value.tool;
|
|
120
|
+
if (typeof tool !== "string" || !RUNTIME_TOOLS.includes(tool))
|
|
121
|
+
throw Error(ERRORS.PLAN_UNKNOWN_TOOL);
|
|
122
|
+
if (!isPlainObject(value.input))
|
|
123
|
+
invalid();
|
|
124
|
+
const dependsOn = value.depends_on ?? [];
|
|
125
|
+
if (!Array.isArray(dependsOn) || dependsOn.length > RUNTIME_LIMITS.steps)
|
|
126
|
+
invalid();
|
|
127
|
+
const deps = [];
|
|
128
|
+
for (const dep of dependsOn) {
|
|
129
|
+
if (typeof dep !== "string" || !ID_PATTERN.test(dep))
|
|
130
|
+
invalid();
|
|
131
|
+
if (deps.includes(dep))
|
|
132
|
+
invalid();
|
|
133
|
+
deps.push(dep);
|
|
134
|
+
}
|
|
135
|
+
const retry = value.retry ?? 0;
|
|
136
|
+
if (typeof retry !== "number" ||
|
|
137
|
+
!Number.isSafeInteger(retry) ||
|
|
138
|
+
retry < 0 ||
|
|
139
|
+
retry > RUNTIME_LIMITS.retry)
|
|
140
|
+
invalid();
|
|
141
|
+
if (retry > 0 && !IDEMPOTENT_TOOLS.has(tool))
|
|
142
|
+
throw Error(ERRORS.PLAN_RETRY_DENIED);
|
|
143
|
+
const timeoutMs = value.timeout_ms ?? 120000;
|
|
144
|
+
if (typeof timeoutMs !== "number" ||
|
|
145
|
+
!Number.isSafeInteger(timeoutMs) ||
|
|
146
|
+
timeoutMs < RUNTIME_LIMITS.timeoutMinMs ||
|
|
147
|
+
timeoutMs > RUNTIME_LIMITS.timeoutMaxMs)
|
|
148
|
+
invalid();
|
|
149
|
+
return {
|
|
150
|
+
id,
|
|
151
|
+
tool: tool,
|
|
152
|
+
input: structuredClone(value.input),
|
|
153
|
+
dependsOn: deps,
|
|
154
|
+
retry,
|
|
155
|
+
timeoutMs,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
function assertAcyclic(steps) {
|
|
159
|
+
const indegree = new Map();
|
|
160
|
+
const children = new Map();
|
|
161
|
+
for (const step of steps) {
|
|
162
|
+
indegree.set(step.id, step.dependsOn.length);
|
|
163
|
+
for (const dep of step.dependsOn) {
|
|
164
|
+
const list = children.get(dep) ?? [];
|
|
165
|
+
list.push(step.id);
|
|
166
|
+
children.set(dep, list);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const ready = [...indegree.entries()]
|
|
170
|
+
.filter(([, degree]) => degree === 0)
|
|
171
|
+
.map(([id]) => id);
|
|
172
|
+
let visited = 0;
|
|
173
|
+
while (ready.length) {
|
|
174
|
+
const id = ready.pop();
|
|
175
|
+
visited++;
|
|
176
|
+
for (const child of children.get(id) ?? []) {
|
|
177
|
+
const degree = (indegree.get(child) ?? 0) - 1;
|
|
178
|
+
indegree.set(child, degree);
|
|
179
|
+
if (degree === 0)
|
|
180
|
+
ready.push(child);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (visited !== steps.length)
|
|
184
|
+
throw Error(ERRORS.PLAN_CYCLE);
|
|
185
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Context } from "@deepseek-ai/cordis";
|
|
2
|
+
import type { ToolRunContext } from "@deepseek-ai/dsh-tools";
|
|
3
|
+
import { RunStore } from "./store.js";
|
|
4
|
+
import type { RunRecord, StepRecord } from "./store.js";
|
|
5
|
+
export interface RunSummary {
|
|
6
|
+
runId: string;
|
|
7
|
+
state: RunRecord["state"];
|
|
8
|
+
goal?: string;
|
|
9
|
+
error?: string;
|
|
10
|
+
createdAt: number;
|
|
11
|
+
updatedAt: number;
|
|
12
|
+
finishedAt?: number;
|
|
13
|
+
durationMs: number;
|
|
14
|
+
steps: Array<{
|
|
15
|
+
id: string;
|
|
16
|
+
tool: string;
|
|
17
|
+
state: StepRecord["state"];
|
|
18
|
+
attempts: number;
|
|
19
|
+
error?: string;
|
|
20
|
+
}>;
|
|
21
|
+
}
|
|
22
|
+
/** 每个预设作用域一个运行时服务:运行记录落盘、按会话隔离、可取消/恢复。 */
|
|
23
|
+
export declare class RuntimeService {
|
|
24
|
+
private readonly ctx;
|
|
25
|
+
private readonly store;
|
|
26
|
+
private readonly active;
|
|
27
|
+
constructor(ctx: Context, store?: RunStore);
|
|
28
|
+
dispose(): void;
|
|
29
|
+
run(raw: unknown, exec: ToolRunContext, background?: boolean): Promise<RunSummary | {
|
|
30
|
+
runId: string;
|
|
31
|
+
state: "running";
|
|
32
|
+
}>;
|
|
33
|
+
status(raw: unknown, exec: ToolRunContext): RunSummary | {
|
|
34
|
+
runs: {
|
|
35
|
+
updatedAt: number;
|
|
36
|
+
stepCount: number;
|
|
37
|
+
goal?: string | undefined;
|
|
38
|
+
runId: string;
|
|
39
|
+
state: import("./store.js").RunState;
|
|
40
|
+
}[];
|
|
41
|
+
};
|
|
42
|
+
result(raw: unknown, exec: ToolRunContext): {
|
|
43
|
+
results: Record<string, import("./store.js").StoredResult>;
|
|
44
|
+
finished: boolean;
|
|
45
|
+
runId: string;
|
|
46
|
+
state: RunRecord["state"];
|
|
47
|
+
goal?: string;
|
|
48
|
+
error?: string;
|
|
49
|
+
createdAt: number;
|
|
50
|
+
updatedAt: number;
|
|
51
|
+
finishedAt?: number;
|
|
52
|
+
durationMs: number;
|
|
53
|
+
steps: Array<{
|
|
54
|
+
id: string;
|
|
55
|
+
tool: string;
|
|
56
|
+
state: StepRecord["state"];
|
|
57
|
+
attempts: number;
|
|
58
|
+
error?: string;
|
|
59
|
+
}>;
|
|
60
|
+
};
|
|
61
|
+
cancel(raw: unknown, exec: ToolRunContext): RunSummary;
|
|
62
|
+
resume(raw: unknown, exec: ToolRunContext): Promise<RunSummary>;
|
|
63
|
+
private sessionId;
|
|
64
|
+
private owned;
|
|
65
|
+
private executor;
|
|
66
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { ERRORS } from "../error-codes.js";
|
|
3
|
+
import { RuntimeExecutor } from "./executor.js";
|
|
4
|
+
import { parsePlan } from "./plan.js";
|
|
5
|
+
import { RunStore } from "./store.js";
|
|
6
|
+
const MAX_ACTIVE_RUNS = 8;
|
|
7
|
+
const RUN_KEYS = new Set(["plan", "async"]);
|
|
8
|
+
const RUN_ID_KEYS = new Set(["runId"]);
|
|
9
|
+
function summary(run) {
|
|
10
|
+
return {
|
|
11
|
+
runId: run.runId,
|
|
12
|
+
state: run.state,
|
|
13
|
+
...(run.goal === undefined ? {} : { goal: run.goal }),
|
|
14
|
+
...(run.error === undefined ? {} : { error: run.error }),
|
|
15
|
+
createdAt: run.createdAt,
|
|
16
|
+
updatedAt: run.updatedAt,
|
|
17
|
+
...(run.finishedAt === undefined ? {} : { finishedAt: run.finishedAt }),
|
|
18
|
+
durationMs: Math.max(0, (run.finishedAt ?? Date.now()) - run.createdAt),
|
|
19
|
+
steps: run.steps.map((step) => ({
|
|
20
|
+
id: step.id,
|
|
21
|
+
tool: step.tool,
|
|
22
|
+
state: step.state,
|
|
23
|
+
attempts: step.attempts,
|
|
24
|
+
...(step.error === undefined ? {} : { error: step.error }),
|
|
25
|
+
})),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function plain(raw, keys) {
|
|
29
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
30
|
+
throw Error(ERRORS.INVALID_PLAN);
|
|
31
|
+
const value = raw;
|
|
32
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
33
|
+
if (typeof key !== "string" || !keys.has(key))
|
|
34
|
+
throw Error(ERRORS.INVALID_PLAN);
|
|
35
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
36
|
+
if (!("value" in descriptor) || !descriptor.enumerable)
|
|
37
|
+
throw Error(ERRORS.INVALID_PLAN);
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
/** 每个预设作用域一个运行时服务:运行记录落盘、按会话隔离、可取消/恢复。 */
|
|
42
|
+
export class RuntimeService {
|
|
43
|
+
ctx;
|
|
44
|
+
store;
|
|
45
|
+
active = new Map();
|
|
46
|
+
constructor(ctx, store) {
|
|
47
|
+
this.ctx = ctx;
|
|
48
|
+
this.store = store ?? new RunStore();
|
|
49
|
+
this.store.markInterrupted();
|
|
50
|
+
this.store.prune(100);
|
|
51
|
+
}
|
|
52
|
+
dispose() {
|
|
53
|
+
for (const entry of this.active.values())
|
|
54
|
+
entry.controller.abort();
|
|
55
|
+
this.active.clear();
|
|
56
|
+
}
|
|
57
|
+
async run(raw, exec, background = false) {
|
|
58
|
+
const sessionId = this.sessionId(exec);
|
|
59
|
+
const input = plain(raw, RUN_KEYS);
|
|
60
|
+
const plan = parsePlan(input.plan);
|
|
61
|
+
if (input.async !== undefined && typeof input.async !== "boolean")
|
|
62
|
+
throw Error(ERRORS.INVALID_PLAN);
|
|
63
|
+
if (this.active.size >= MAX_ACTIVE_RUNS)
|
|
64
|
+
throw Error(ERRORS.RUNTIME_BUSY);
|
|
65
|
+
const executor = this.executor(exec);
|
|
66
|
+
const run = executor.createRun(sessionId, plan);
|
|
67
|
+
const controller = new AbortController();
|
|
68
|
+
const promise = executor
|
|
69
|
+
.execute(run, controller.signal)
|
|
70
|
+
.catch((error) => {
|
|
71
|
+
run.state = "failed";
|
|
72
|
+
run.error = ERRORS.RUNTIME_STEP_FAILED;
|
|
73
|
+
run.finishedAt = Date.now();
|
|
74
|
+
try {
|
|
75
|
+
this.store.save(run);
|
|
76
|
+
}
|
|
77
|
+
catch { }
|
|
78
|
+
throw error;
|
|
79
|
+
});
|
|
80
|
+
this.active.set(run.runId, { controller, promise });
|
|
81
|
+
void promise
|
|
82
|
+
.finally(() => this.active.delete(run.runId))
|
|
83
|
+
.catch(() => { });
|
|
84
|
+
if (background === true || input.async === true)
|
|
85
|
+
return { runId: run.runId, state: "running" };
|
|
86
|
+
return summary(await promise);
|
|
87
|
+
}
|
|
88
|
+
status(raw, exec) {
|
|
89
|
+
const sessionId = this.sessionId(exec);
|
|
90
|
+
const input = plain(raw, RUN_ID_KEYS);
|
|
91
|
+
if (input.runId === undefined) {
|
|
92
|
+
return {
|
|
93
|
+
runs: this.store
|
|
94
|
+
.list(20)
|
|
95
|
+
.filter((run) => run.ownerSessionId === sessionId)
|
|
96
|
+
.map((run) => ({
|
|
97
|
+
runId: run.runId,
|
|
98
|
+
state: run.state,
|
|
99
|
+
...(run.goal === undefined ? {} : { goal: run.goal }),
|
|
100
|
+
updatedAt: run.updatedAt,
|
|
101
|
+
stepCount: run.steps.length,
|
|
102
|
+
})),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const run = this.owned(input.runId, sessionId);
|
|
106
|
+
return summary(run);
|
|
107
|
+
}
|
|
108
|
+
result(raw, exec) {
|
|
109
|
+
const sessionId = this.sessionId(exec);
|
|
110
|
+
const input = plain(raw, RUN_ID_KEYS);
|
|
111
|
+
if (typeof input.runId !== "string")
|
|
112
|
+
throw Error(ERRORS.RUNTIME_NOT_FOUND);
|
|
113
|
+
const run = this.owned(input.runId, sessionId);
|
|
114
|
+
return {
|
|
115
|
+
...summary(run),
|
|
116
|
+
results: run.results,
|
|
117
|
+
finished: run.state !== "running" && run.state !== "interrupted",
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
cancel(raw, exec) {
|
|
121
|
+
const sessionId = this.sessionId(exec);
|
|
122
|
+
const input = plain(raw, RUN_ID_KEYS);
|
|
123
|
+
if (typeof input.runId !== "string")
|
|
124
|
+
throw Error(ERRORS.RUNTIME_NOT_FOUND);
|
|
125
|
+
const run = this.owned(input.runId, sessionId);
|
|
126
|
+
const active = this.active.get(run.runId);
|
|
127
|
+
if (active)
|
|
128
|
+
active.controller.abort();
|
|
129
|
+
else
|
|
130
|
+
this.executor(exec).cancel(run);
|
|
131
|
+
try {
|
|
132
|
+
this.store.save(run);
|
|
133
|
+
}
|
|
134
|
+
catch { }
|
|
135
|
+
return summary(run);
|
|
136
|
+
}
|
|
137
|
+
async resume(raw, exec) {
|
|
138
|
+
const sessionId = this.sessionId(exec);
|
|
139
|
+
const input = plain(raw, RUN_ID_KEYS);
|
|
140
|
+
if (typeof input.runId !== "string")
|
|
141
|
+
throw Error(ERRORS.RUNTIME_NOT_FOUND);
|
|
142
|
+
const run = this.owned(input.runId, sessionId);
|
|
143
|
+
if (run.state !== "interrupted")
|
|
144
|
+
throw Error(ERRORS.RUNTIME_INVALID_STATE);
|
|
145
|
+
const executor = this.executor(exec);
|
|
146
|
+
const controller = new AbortController();
|
|
147
|
+
const promise = executor.resume(run, controller.signal);
|
|
148
|
+
this.active.set(run.runId, { controller, promise });
|
|
149
|
+
void promise
|
|
150
|
+
.finally(() => this.active.delete(run.runId))
|
|
151
|
+
.catch(() => { });
|
|
152
|
+
return summary(await promise);
|
|
153
|
+
}
|
|
154
|
+
sessionId(exec) {
|
|
155
|
+
if (!exec.agent)
|
|
156
|
+
throw Error(ERRORS.OWNING_SESSION_REQUIRED);
|
|
157
|
+
return String(exec.agent.id);
|
|
158
|
+
}
|
|
159
|
+
owned(runId, sessionId) {
|
|
160
|
+
if (typeof runId !== "string")
|
|
161
|
+
throw Error(ERRORS.RUNTIME_NOT_FOUND);
|
|
162
|
+
const run = this.store.load(runId);
|
|
163
|
+
if (!run || run.ownerSessionId !== sessionId)
|
|
164
|
+
throw Error(ERRORS.RUNTIME_NOT_FOUND);
|
|
165
|
+
return run;
|
|
166
|
+
}
|
|
167
|
+
executor(exec) {
|
|
168
|
+
const self = this;
|
|
169
|
+
const invoke = async (tool, input, signal) => {
|
|
170
|
+
const result = await self.ctx.tools.execute({
|
|
171
|
+
name: tool,
|
|
172
|
+
callId: (String(exec.callId) + ":runtime:" + randomUUID()),
|
|
173
|
+
rootCallId: exec.rootCallId,
|
|
174
|
+
parent: exec.token,
|
|
175
|
+
agent: exec.agent,
|
|
176
|
+
arguments: input,
|
|
177
|
+
signal,
|
|
178
|
+
});
|
|
179
|
+
if (result.isError)
|
|
180
|
+
throw Error(ERRORS.RUNTIME_STEP_FAILED);
|
|
181
|
+
return result.value;
|
|
182
|
+
};
|
|
183
|
+
return new RuntimeExecutor(this.store, invoke);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Plan } from "./plan.js";
|
|
2
|
+
export type RunState = "running" | "succeeded" | "failed" | "cancelled" | "interrupted";
|
|
3
|
+
export type StepState = "pending" | "running" | "succeeded" | "failed" | "cancelled" | "unknown";
|
|
4
|
+
export interface StepRecord {
|
|
5
|
+
id: string;
|
|
6
|
+
tool: string;
|
|
7
|
+
state: StepState;
|
|
8
|
+
attempts: number;
|
|
9
|
+
startedAt?: number;
|
|
10
|
+
finishedAt?: number;
|
|
11
|
+
error?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface StoredResult {
|
|
14
|
+
value?: unknown;
|
|
15
|
+
truncated?: true;
|
|
16
|
+
bytes?: number;
|
|
17
|
+
}
|
|
18
|
+
export interface RunRecord {
|
|
19
|
+
version: 1;
|
|
20
|
+
runId: string;
|
|
21
|
+
ownerSessionId: string;
|
|
22
|
+
goal?: string;
|
|
23
|
+
plan: Plan;
|
|
24
|
+
state: RunState;
|
|
25
|
+
steps: StepRecord[];
|
|
26
|
+
results: Record<string, StoredResult>;
|
|
27
|
+
createdAt: number;
|
|
28
|
+
updatedAt: number;
|
|
29
|
+
finishedAt?: number;
|
|
30
|
+
error?: string;
|
|
31
|
+
}
|
|
32
|
+
export declare function defaultRunRoot(): string;
|
|
33
|
+
/** 纯文件持久化运行记录;原子写入,损坏记录忽略,不做跨进程锁。 */
|
|
34
|
+
export declare class RunStore {
|
|
35
|
+
readonly root: string;
|
|
36
|
+
constructor(root?: string);
|
|
37
|
+
pathFor(runId: string): string;
|
|
38
|
+
save(run: RunRecord): void;
|
|
39
|
+
load(runId: string): RunRecord | undefined;
|
|
40
|
+
list(limit?: number): RunRecord[];
|
|
41
|
+
remove(runId: string): void;
|
|
42
|
+
prune(max?: number): void;
|
|
43
|
+
/** 启动时把上次进程留下的 running 记录标记为 interrupted;不自动重跑副作用步骤。 */
|
|
44
|
+
markInterrupted(): number;
|
|
45
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { ERRORS } from "../error-codes.js";
|
|
5
|
+
const RUN_ID = /^run-[0-9a-z-]{8,80}$/;
|
|
6
|
+
const RECORD_BYTES = 4 * 1024 * 1024;
|
|
7
|
+
export function defaultRunRoot() {
|
|
8
|
+
if (process.env.XIAOHE_RUNTIME_DIR)
|
|
9
|
+
return path.resolve(process.env.XIAOHE_RUNTIME_DIR);
|
|
10
|
+
const home = process.env.DSH_HOME ?? path.join(os.homedir(), ".dsh");
|
|
11
|
+
return path.join(home, "tmp", "xiaohe-runtime", "runs");
|
|
12
|
+
}
|
|
13
|
+
/** 纯文件持久化运行记录;原子写入,损坏记录忽略,不做跨进程锁。 */
|
|
14
|
+
export class RunStore {
|
|
15
|
+
root;
|
|
16
|
+
constructor(root = defaultRunRoot()) {
|
|
17
|
+
this.root = root;
|
|
18
|
+
mkdirSync(this.root, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
pathFor(runId) {
|
|
21
|
+
if (!RUN_ID.test(runId))
|
|
22
|
+
throw Error(ERRORS.RUNTIME_NOT_FOUND);
|
|
23
|
+
return path.join(this.root, runId + ".json");
|
|
24
|
+
}
|
|
25
|
+
save(run) {
|
|
26
|
+
const target = this.pathFor(run.runId);
|
|
27
|
+
const json = JSON.stringify(run);
|
|
28
|
+
if (Buffer.byteLength(json) > RECORD_BYTES)
|
|
29
|
+
throw Error(ERRORS.RUNTIME_INVALID_STATE);
|
|
30
|
+
const temp = target + ".tmp";
|
|
31
|
+
writeFileSync(temp, json, "utf8");
|
|
32
|
+
renameSync(temp, target);
|
|
33
|
+
}
|
|
34
|
+
load(runId) {
|
|
35
|
+
let text;
|
|
36
|
+
try {
|
|
37
|
+
text = readFileSync(this.pathFor(runId), "utf8");
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const value = JSON.parse(text);
|
|
44
|
+
if (value?.version !== 1 || value.runId !== runId)
|
|
45
|
+
return undefined;
|
|
46
|
+
if (!Array.isArray(value.steps) || typeof value.plan !== "object")
|
|
47
|
+
return undefined;
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
list(limit = 20) {
|
|
55
|
+
let names = [];
|
|
56
|
+
try {
|
|
57
|
+
names = readdirSync(this.root);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
const runs = [];
|
|
63
|
+
for (const name of names) {
|
|
64
|
+
if (!name.endsWith(".json"))
|
|
65
|
+
continue;
|
|
66
|
+
const run = this.load(name.slice(0, -".json".length));
|
|
67
|
+
if (run)
|
|
68
|
+
runs.push(run);
|
|
69
|
+
}
|
|
70
|
+
runs.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
71
|
+
return runs.slice(0, limit);
|
|
72
|
+
}
|
|
73
|
+
remove(runId) {
|
|
74
|
+
rmSync(this.pathFor(runId), { force: true });
|
|
75
|
+
}
|
|
76
|
+
prune(max = 100) {
|
|
77
|
+
for (const run of this.list(10000).slice(max))
|
|
78
|
+
this.remove(run.runId);
|
|
79
|
+
}
|
|
80
|
+
/** 启动时把上次进程留下的 running 记录标记为 interrupted;不自动重跑副作用步骤。 */
|
|
81
|
+
markInterrupted() {
|
|
82
|
+
let count = 0;
|
|
83
|
+
for (const run of this.list(1000)) {
|
|
84
|
+
if (run.state !== "running")
|
|
85
|
+
continue;
|
|
86
|
+
run.state = "interrupted";
|
|
87
|
+
run.error = ERRORS.RUNTIME_INTERRUPTED;
|
|
88
|
+
run.finishedAt ??= Date.now();
|
|
89
|
+
for (const step of run.steps) {
|
|
90
|
+
if (step.state === "running") {
|
|
91
|
+
step.state = "unknown";
|
|
92
|
+
step.error = ERRORS.RUNTIME_INTERRUPTED;
|
|
93
|
+
step.finishedAt ??= Date.now();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
run.updatedAt = Date.now();
|
|
97
|
+
this.save(run);
|
|
98
|
+
count++;
|
|
99
|
+
}
|
|
100
|
+
return count;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# 声明式运行时(1.0 迁移中)
|
|
2
|
+
|
|
3
|
+
> 状态:**第一阶段已落地**(运行时内核 + 工具 + 落盘恢复 + 测试),旧 `execution_bus` 暂保留为兼容;后续版本移除旧总线并统一文档。
|
|
4
|
+
|
|
5
|
+
## 它是什么
|
|
6
|
+
|
|
7
|
+
模型不再在 `run_code` 里写循环/等待,而是提交一份**计划(plan)**:
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"goal": "读取两个文件并汇总",
|
|
12
|
+
"concurrency": 2,
|
|
13
|
+
"steps": [
|
|
14
|
+
{ "id": "a", "tool": "read", "input": { "file_path": "README.md" } },
|
|
15
|
+
{ "id": "b", "tool": "read", "input": { "file_path": "package.json" } },
|
|
16
|
+
{ "id": "c", "tool": "pwsh", "input": { "description": "汇总", "command": "echo ok" }, "depends_on": ["a", "b"] }
|
|
17
|
+
]
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
运行时负责:DAG 校验、独立步骤并行、只读步骤重试、每步超时、结果落盘、取消与重启后恢复。模型只表达意图和依赖,不写编排代码。
|
|
22
|
+
|
|
23
|
+
## 工具
|
|
24
|
+
|
|
25
|
+
| 工具 | 作用 |
|
|
26
|
+
|---|---|
|
|
27
|
+
| `runtime_run` | 提交计划;默认等待完成并返回步骤状态,`async:true` 立即返回 `runId` |
|
|
28
|
+
| `runtime_status` | 无 runId 列出最近运行;带 runId 返回步骤状态(不含结果) |
|
|
29
|
+
| `runtime_result` | 读取已保存的步骤结果(单步 >64KiB 只返回 truncated/bytes) |
|
|
30
|
+
| `runtime_cancel` | 停止调度、传播取消 |
|
|
31
|
+
| `runtime_resume` | 显式恢复 `interrupted` 运行(只读步骤重排,副作用在途步骤标 failed) |
|
|
32
|
+
|
|
33
|
+
## 语义与边界
|
|
34
|
+
|
|
35
|
+
- 允许工具:`read/glob/grep/edit/write/pwsh`;`retry` 只允许 `read/glob/grep`。
|
|
36
|
+
- 计划上限:32 步、64KiB、并发 1–4、单步超时 1s–600s。
|
|
37
|
+
- 运行记录保存在 `DSH_HOME/tmp/xiaohe-runtime/runs`(可用 `XIAOHE_RUNTIME_DIR` 覆盖),按会话隔离。
|
|
38
|
+
- 崩溃/重启后运行标记 `interrupted`;在途步骤 `unknown`。**不保证 exactly-once**:副作用可能已发生,恢复时副作用步骤标 `failed/RUNTIME_INTERRUPTED`,只读步骤可重排。
|
|
39
|
+
- 不做跨进程执行、不做分布式队列;运行时随宿主进程退出。
|
|
40
|
+
- 计划质量是新的失败来源;稳定、可枚举的流程适合,探索型任务仍用 PTC 原工具。
|
|
41
|
+
|
|
42
|
+
## 与旧 execution_bus 的关系
|
|
43
|
+
|
|
44
|
+
旧总线(`execution_bus` 工具)提供 requestId 去重、预算分离等待与宿主 Job 投影;声明式运行时提供更上层的计划编排与恢复。两者当前共存,文档与预设会在后续版本收敛到运行时,旧总线冻结为历史参考。
|
package/docs/FLOW-TEST.md
CHANGED
|
@@ -1,56 +1,9 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 流程测试说明
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
1. 安装候选包并在隔离 DSH 配置中加载 bundle,确认预设 **xiao-he-runtime** 出现且默认预设未变。
|
|
4
|
+
2. 在会话中调用 `runtime_run` 提交一个两步计划(read + echo),确认返回 `succeeded` 与步骤状态。
|
|
5
|
+
3. 提交 `async:true` 计划,用 `runtime_status`/`runtime_result` 取回;用 `runtime_cancel` 验证取消。
|
|
6
|
+
4. 重启宿主后检查 `runtime_status`:未完成运行应为 `interrupted`,用 `runtime_resume` 恢复只读步骤。
|
|
7
|
+
5. 验证普通 Agent 看不到 `runtime_*` 工具,宿主写策略拒绝仍生效。
|
|
4
8
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
**由聊天会话启动测试,不等于该聊天会话直接调用了 execution_bus。** 真实模型、GUI、PTC 进程和桌面沙箱结论不能由此替代。
|
|
8
|
-
|
|
9
|
-
## 如何运行
|
|
10
|
-
|
|
11
|
-
```powershell
|
|
12
|
-
npm ci --include=dev --ignore-scripts
|
|
13
|
-
npm run test:flow
|
|
14
|
-
npm run check
|
|
15
|
-
npm run verify:package
|
|
16
|
-
npm run test:install
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
| 命令 | 实际范围 |
|
|
20
|
-
|---|---|
|
|
21
|
-
| `test:flow` | 三个固定测试文件:早期流程回归、真实宿主工具组件、脚本适配器驱动的 AgentLoop |
|
|
22
|
-
| `check` | 编译并运行全部测试;包括后续 Job 故障、卸载、预设隔离等回归 |
|
|
23
|
-
| `verify:package` | 检查候选打包清单及导出,不安装日常宿主 |
|
|
24
|
-
| `test:install` | 在临时目录安装生产依赖并验证根入口/简化注册,结束后清理 |
|
|
25
|
-
| `metrics:sessions` | 只读扫描本机会话日志,汇总总线请求/Handler/复用标记/错误,用于采用率观察 |
|
|
26
|
-
|
|
27
|
-
`test:flow` 不是全部集成测试的别名;发布前必须运行 `check`。临时安装不是桌面插件管理器安装。完整验证层次见[验证范围](<validation.md>)。
|
|
28
|
-
|
|
29
|
-
`metrics:sessions` 只读取本地会话日志,不发送网络请求,也不代表模型效果对比;它是采用率与异常观察工具,不是 A/B 基准。
|
|
30
|
-
|
|
31
|
-
## 已覆盖的代表场景
|
|
32
|
-
|
|
33
|
-
- 正常受理与异步返回,两个会话不串线,同键冲突拒绝。
|
|
34
|
-
- 并发重复请求共用执行/Job;终态结果在宿主槽位满时仍可复用。
|
|
35
|
-
- 查询额度、附着容量、取消保留槽及过期结果清理。
|
|
36
|
-
- 合作取消、晚取消、未知结果不自动重跑。
|
|
37
|
-
- 深层/非 JSON 结果拒绝,结果和通知不可变,异步观察者拒绝隔离。
|
|
38
|
-
- 已受理 Handler 与替换注册并存,旧注销函数不破坏新注册。
|
|
39
|
-
- 宿主 read/glob/grep/edit/write/pwsh 真正经过工具管线;前置拒绝仍生效(搜索与命令的转发测试使用宿主工具桩,不代表真实工具实现)。
|
|
40
|
-
- Job 先行移除、等待/清理异常与卸载交错。
|
|
41
|
-
- 派生预设成功激活、原 ptc 声明不变、普通 Agent 不可见总线、write/edit 保留且宿主策略可拒绝。
|
|
42
|
-
- 真实 Node PTC 进程调用原 read 与总线;测试使用显式非约束策略,不代表桌面沙箱通过。
|
|
43
|
-
- 真实 Node PTC 预算到期后,已受理总线执行继续并可同 requestId 取回;外部中断仍请求取消执行。
|
|
44
|
-
|
|
45
|
-
以上是场景摘要,不表示真实模型/宿主 GUI 已通过。
|
|
46
|
-
|
|
47
|
-
## 测试之外仍需注意
|
|
48
|
-
|
|
49
|
-
- 宿主工具调用失败在转发层使用固定码(host.read 为 `HOST_READ_FAILED`,其余为 `HOST_TOOL_FAILED`),进入总线后统一结算为 `failed` / `HANDLER_FAILED`,错误原文不保存;取消后可能保守记为 `unknown`,不能把它当作取消已确认。
|
|
50
|
-
- 相同逻辑请求的取消是请求级取消,不能简单用 Promise.race 放弃执行来改成等待者级取消。
|
|
51
|
-
- 当前没有远端 Handler 传输或持久化恢复;测试没有提供这些能力。
|
|
52
|
-
- 预算分离等待依据 DSH 0.2.1-rc.2 的内部中断原因字符串;换宿主版本需重新验证,未知原因保守按取消处理。
|
|
53
|
-
|
|
54
|
-
## 要直接在聊天会话中试用
|
|
55
|
-
|
|
56
|
-
需要先通过宿主正常加载插件,并让目标 Agent 看得到 `execution_bus`。建议加载可选“萧何”预设,新建专门会话;不要全局注入工具。安装/加载需使用者自行授权,是否重启由使用者决定。只启动另一个 Node 进程、伪造 sessionId 或追加聊天结果,都不算恢复原工具调用。参见[使用说明](<../README.md>)。
|
|
9
|
+
边界:测试夹具使用测试 Agent 与工具桩,不代表真实模型、桌面 UI 或实际沙箱验收。
|