@gilbertgt/dsh-plan-orchestrator 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/LICENSE +9 -0
- package/README.md +32 -0
- package/compatibility.json +9 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +1242 -0
- package/lib/github-Cy4Pm35G.js +577 -0
- package/lib/index.js +3776 -0
- package/lib/planner-route-B9z5GziX.js +101 -0
- package/lib/publication-D352itYY.js +137 -0
- package/lib/rpc-server-CGs2ndXP.js +131 -0
- package/package.json +144 -0
- package/profiles/reviewer.cordis.yml +5 -0
- package/profiles/worker.cordis.yml +5 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,3776 @@
|
|
|
1
|
+
import { A as validatePlanArtifact, C as git, D as normalizeOwnedPath, E as extractPlanArtifact, O as parseValidationCommand, S as fullHead, _ as readJson, b as changedPaths, c as trustedIssueTexts, d as hashBytes, f as assertOwnedPaths, g as confined, h as atomicJson, i as ghRepository, k as schedulerPathIdentity, l as assertTrustedReceipts, m as RunStore, n as ghPreflight, o as prepareIssueBranch, p as disjointOwnership, s as remotePr, t as fetchIssue, u as receiptIndex, v as stateRoot, w as repoRoot, x as decodeUtf8Strict, y as assertRepoPathsConfined } from "./github-Cy4Pm35G.js";
|
|
2
|
+
import { n as validateFixedRoute, r as eligibleTransportFailure, t as installPlannerRoute } from "./planner-route-B9z5GziX.js";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
+
import { realpathSync } from "node:fs";
|
|
8
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
9
|
+
import { lstat, mkdir, readFile, readlink, rm, symlink, writeFile } from "node:fs/promises";
|
|
10
|
+
import { setSandboxMode } from "@deepseek-ai/dsh-sandbox-policy";
|
|
11
|
+
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
//#region node_modules/@deepseek-ai/cosmokit/lib/index.js
|
|
15
|
+
/** Return true when a value is `null` or `undefined`. */
|
|
16
|
+
function isNullable(value) {
|
|
17
|
+
return value === null || value === void 0;
|
|
18
|
+
}
|
|
19
|
+
/** Return true for non-array object values. */
|
|
20
|
+
function isPlainObject(data) {
|
|
21
|
+
return data && typeof data === "object" && !Array.isArray(data);
|
|
22
|
+
}
|
|
23
|
+
/** Filter object entries and return a new object. */
|
|
24
|
+
function filterKeys(object, filter) {
|
|
25
|
+
return Object.fromEntries(Object.entries(object).filter(([key, value]) => filter(key, value)));
|
|
26
|
+
}
|
|
27
|
+
/** Map object values while preserving the original key set. */
|
|
28
|
+
function mapValues(object, transform) {
|
|
29
|
+
return Object.fromEntries(Object.entries(object).map(([key, value]) => [key, transform(value, key)]));
|
|
30
|
+
}
|
|
31
|
+
/** Pick selected keys from an object, optionally including `undefined` values. */
|
|
32
|
+
function pick(source, keys, forced) {
|
|
33
|
+
if (!keys) return { ...source };
|
|
34
|
+
const result = {};
|
|
35
|
+
for (const key of keys) if (forced || source[key] !== void 0) result[key] = source[key];
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
/** Test values using `instanceof` with a `toStringTag` fallback. */
|
|
39
|
+
function is(type, value) {
|
|
40
|
+
if (arguments.length === 1) return (value) => is(type, value);
|
|
41
|
+
return type in globalThis && value instanceof globalThis[type] || Object.prototype.toString.call(value).slice(8, -1) === type;
|
|
42
|
+
}
|
|
43
|
+
function isArrayBufferLike(value) {
|
|
44
|
+
return is("ArrayBuffer", value) || is("SharedArrayBuffer", value);
|
|
45
|
+
}
|
|
46
|
+
function isArrayBufferSource(value) {
|
|
47
|
+
return isArrayBufferLike(value) || ArrayBuffer.isView(value);
|
|
48
|
+
}
|
|
49
|
+
/** Binary source detection and base64/hex conversion helpers. */
|
|
50
|
+
var Binary;
|
|
51
|
+
(function(Binary) {
|
|
52
|
+
Binary.is = isArrayBufferLike;
|
|
53
|
+
Binary.isSource = isArrayBufferSource;
|
|
54
|
+
function fromSource(source) {
|
|
55
|
+
if (ArrayBuffer.isView(source)) return source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength);
|
|
56
|
+
else return source;
|
|
57
|
+
}
|
|
58
|
+
Binary.fromSource = fromSource;
|
|
59
|
+
function toBase64(source) {
|
|
60
|
+
source = fromSource(source);
|
|
61
|
+
if (typeof Buffer !== "undefined") return Buffer.from(source).toString("base64");
|
|
62
|
+
let binary = "";
|
|
63
|
+
const bytes = new Uint8Array(source);
|
|
64
|
+
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
|
|
65
|
+
return btoa(binary);
|
|
66
|
+
}
|
|
67
|
+
Binary.toBase64 = toBase64;
|
|
68
|
+
function fromBase64(source) {
|
|
69
|
+
if (typeof Buffer !== "undefined") return fromSource(Buffer.from(source, "base64"));
|
|
70
|
+
return Uint8Array.from(atob(source), (c) => c.charCodeAt(0));
|
|
71
|
+
}
|
|
72
|
+
Binary.fromBase64 = fromBase64;
|
|
73
|
+
function toHex(source) {
|
|
74
|
+
source = fromSource(source);
|
|
75
|
+
if (typeof Buffer !== "undefined") return Buffer.from(source).toString("hex");
|
|
76
|
+
return Array.from(new Uint8Array(source), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
77
|
+
}
|
|
78
|
+
Binary.toHex = toHex;
|
|
79
|
+
function fromHex(source) {
|
|
80
|
+
if (typeof Buffer !== "undefined") return fromSource(Buffer.from(source, "hex"));
|
|
81
|
+
const hex = source.length % 2 === 0 ? source : source.slice(0, source.length - 1);
|
|
82
|
+
const buffer = [];
|
|
83
|
+
for (let i = 0; i < hex.length; i += 2) buffer.push(parseInt(`${hex[i]}${hex[i + 1]}`, 16));
|
|
84
|
+
return Uint8Array.from(buffer).buffer;
|
|
85
|
+
}
|
|
86
|
+
Binary.fromHex = fromHex;
|
|
87
|
+
})(Binary || (Binary = {}));
|
|
88
|
+
Binary.fromBase64;
|
|
89
|
+
Binary.toBase64;
|
|
90
|
+
Binary.fromHex;
|
|
91
|
+
Binary.toHex;
|
|
92
|
+
/** Deep-clone common JavaScript values while preserving prototypes and cycles. */
|
|
93
|
+
function clone(source, refs = /* @__PURE__ */ new Map()) {
|
|
94
|
+
if (!source || typeof source !== "object") return source;
|
|
95
|
+
if (is("Date", source)) return new Date(source.valueOf());
|
|
96
|
+
if (is("RegExp", source)) return new RegExp(source.source, source.flags);
|
|
97
|
+
if (isArrayBufferLike(source)) return source.slice(0);
|
|
98
|
+
if (ArrayBuffer.isView(source)) return source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength);
|
|
99
|
+
const cached = refs.get(source);
|
|
100
|
+
if (cached) return cached;
|
|
101
|
+
if (Array.isArray(source)) {
|
|
102
|
+
const result = [];
|
|
103
|
+
refs.set(source, result);
|
|
104
|
+
source.forEach((value, index) => {
|
|
105
|
+
result[index] = Reflect.apply(clone, null, [value, refs]);
|
|
106
|
+
});
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
const result = Object.create(Object.getPrototypeOf(source));
|
|
110
|
+
refs.set(source, result);
|
|
111
|
+
for (const key of Reflect.ownKeys(source)) {
|
|
112
|
+
const descriptor = { ...Reflect.getOwnPropertyDescriptor(source, key) };
|
|
113
|
+
if ("value" in descriptor) descriptor.value = Reflect.apply(clone, null, [descriptor.value, refs]);
|
|
114
|
+
Reflect.defineProperty(result, key, descriptor);
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
/** Deeply compare arrays, dates, regexps, buffers, and plain object fields. */
|
|
119
|
+
function deepEqual(a, b, strict) {
|
|
120
|
+
if (a === b) return true;
|
|
121
|
+
if (!strict && isNullable(a) && isNullable(b)) return true;
|
|
122
|
+
if (typeof a !== typeof b) return false;
|
|
123
|
+
if (typeof a !== "object") return false;
|
|
124
|
+
if (!a || !b) return false;
|
|
125
|
+
function check(test, then) {
|
|
126
|
+
return test(a) ? test(b) ? then(a, b) : false : test(b) ? false : void 0;
|
|
127
|
+
}
|
|
128
|
+
return check(Array.isArray, (a, b) => a.length === b.length && a.every((item, index) => deepEqual(item, b[index]))) ?? check(is("Date"), (a, b) => a.valueOf() === b.valueOf()) ?? check(is("RegExp"), (a, b) => a.source === b.source && a.flags === b.flags) ?? check(isArrayBufferLike, (a, b) => {
|
|
129
|
+
if (a.byteLength !== b.byteLength) return false;
|
|
130
|
+
const viewA = new Uint8Array(a);
|
|
131
|
+
const viewB = new Uint8Array(b);
|
|
132
|
+
for (let i = 0; i < viewA.length; i++) if (viewA[i] !== viewB[i]) return false;
|
|
133
|
+
return true;
|
|
134
|
+
}) ?? Object.keys({
|
|
135
|
+
...a,
|
|
136
|
+
...b
|
|
137
|
+
}).every((key) => deepEqual(a[key], b[key], strict));
|
|
138
|
+
}
|
|
139
|
+
/** Time constants plus parsing and formatting helpers. */
|
|
140
|
+
var Time;
|
|
141
|
+
(function(Time) {
|
|
142
|
+
Time.millisecond = 1;
|
|
143
|
+
Time.second = 1e3;
|
|
144
|
+
Time.minute = Time.second * 60;
|
|
145
|
+
Time.hour = Time.minute * 60;
|
|
146
|
+
Time.day = Time.hour * 24;
|
|
147
|
+
Time.week = Time.day * 7;
|
|
148
|
+
let timezoneOffset = (/* @__PURE__ */ new Date()).getTimezoneOffset();
|
|
149
|
+
function setTimezoneOffset(offset) {
|
|
150
|
+
timezoneOffset = offset;
|
|
151
|
+
}
|
|
152
|
+
Time.setTimezoneOffset = setTimezoneOffset;
|
|
153
|
+
function getTimezoneOffset() {
|
|
154
|
+
return timezoneOffset;
|
|
155
|
+
}
|
|
156
|
+
Time.getTimezoneOffset = getTimezoneOffset;
|
|
157
|
+
function getDateNumber(date = /* @__PURE__ */ new Date(), offset) {
|
|
158
|
+
if (typeof date === "number") date = new Date(date);
|
|
159
|
+
if (offset === void 0) offset = timezoneOffset;
|
|
160
|
+
return Math.floor((date.valueOf() / Time.minute - offset) / 1440);
|
|
161
|
+
}
|
|
162
|
+
Time.getDateNumber = getDateNumber;
|
|
163
|
+
function fromDateNumber(value, offset) {
|
|
164
|
+
const date = new Date(value * Time.day);
|
|
165
|
+
if (offset === void 0) offset = timezoneOffset;
|
|
166
|
+
return new Date(+date + offset * Time.minute);
|
|
167
|
+
}
|
|
168
|
+
Time.fromDateNumber = fromDateNumber;
|
|
169
|
+
const numeric = /\d+(?:\.\d+)?/.source;
|
|
170
|
+
const timeRegExp = new RegExp(`^${[
|
|
171
|
+
"w(?:eek(?:s)?)?",
|
|
172
|
+
"d(?:ay(?:s)?)?",
|
|
173
|
+
"h(?:our(?:s)?)?",
|
|
174
|
+
"m(?:in(?:ute)?(?:s)?)?",
|
|
175
|
+
"s(?:ec(?:ond)?(?:s)?)?"
|
|
176
|
+
].map((unit) => `(${numeric}${unit})?`).join("")}$`);
|
|
177
|
+
function parseTime(source) {
|
|
178
|
+
const capture = timeRegExp.exec(source);
|
|
179
|
+
if (!capture) return 0;
|
|
180
|
+
return (parseFloat(capture[1]) * Time.week || 0) + (parseFloat(capture[2]) * Time.day || 0) + (parseFloat(capture[3]) * Time.hour || 0) + (parseFloat(capture[4]) * Time.minute || 0) + (parseFloat(capture[5]) * Time.second || 0);
|
|
181
|
+
}
|
|
182
|
+
Time.parseTime = parseTime;
|
|
183
|
+
function parseDate(date) {
|
|
184
|
+
const parsed = parseTime(date);
|
|
185
|
+
if (parsed) date = Date.now() + parsed;
|
|
186
|
+
else if (/^\d{1,2}(:\d{1,2}){1,2}$/.test(date)) date = `${(/* @__PURE__ */ new Date()).toLocaleDateString()}-${date}`;
|
|
187
|
+
else if (/^\d{1,2}-\d{1,2}-\d{1,2}(:\d{1,2}){1,2}$/.test(date)) date = `${(/* @__PURE__ */ new Date()).getFullYear()}-${date}`;
|
|
188
|
+
return date ? new Date(date) : /* @__PURE__ */ new Date();
|
|
189
|
+
}
|
|
190
|
+
Time.parseDate = parseDate;
|
|
191
|
+
function format(ms) {
|
|
192
|
+
const abs = Math.abs(ms);
|
|
193
|
+
if (abs >= Time.day - Time.hour / 2) return Math.round(ms / Time.day) + "d";
|
|
194
|
+
else if (abs >= Time.hour - Time.minute / 2) return Math.round(ms / Time.hour) + "h";
|
|
195
|
+
else if (abs >= Time.minute - Time.second / 2) return Math.round(ms / Time.minute) + "m";
|
|
196
|
+
else if (abs >= Time.second) return Math.round(ms / Time.second) + "s";
|
|
197
|
+
return ms + "ms";
|
|
198
|
+
}
|
|
199
|
+
Time.format = format;
|
|
200
|
+
function toDigits(source, length = 2) {
|
|
201
|
+
return source.toString().padStart(length, "0");
|
|
202
|
+
}
|
|
203
|
+
Time.toDigits = toDigits;
|
|
204
|
+
function template(template, time = /* @__PURE__ */ new Date()) {
|
|
205
|
+
return template.replace("yyyy", time.getFullYear().toString()).replace("yy", time.getFullYear().toString().slice(2)).replace("MM", toDigits(time.getMonth() + 1)).replace("dd", toDigits(time.getDate())).replace("hh", toDigits(time.getHours())).replace("mm", toDigits(time.getMinutes())).replace("ss", toDigits(time.getSeconds())).replace("SSS", toDigits(time.getMilliseconds(), 3));
|
|
206
|
+
}
|
|
207
|
+
Time.template = template;
|
|
208
|
+
})(Time || (Time = {}));
|
|
209
|
+
//#endregion
|
|
210
|
+
//#region node_modules/@deepseek-ai/schemastery/lib/index.mjs
|
|
211
|
+
const kSchema = Symbol.for("schemastery");
|
|
212
|
+
const kValidationError = Symbol.for("ValidationError");
|
|
213
|
+
globalThis.__schemastery_index__ ??= 0;
|
|
214
|
+
globalThis.__schemastery_refs__ = void 0;
|
|
215
|
+
var ValidationError = class extends TypeError {
|
|
216
|
+
options;
|
|
217
|
+
name = "ValidationError";
|
|
218
|
+
constructor(message, options) {
|
|
219
|
+
let prefix = "$";
|
|
220
|
+
for (const segment of options.path || []) if (typeof segment === "string") prefix += "." + segment;
|
|
221
|
+
else if (typeof segment === "number") prefix += "[" + segment + "]";
|
|
222
|
+
else if (typeof segment === "symbol") prefix += `[Symbol(${segment.toString()})]`;
|
|
223
|
+
if (prefix.startsWith(".")) prefix = prefix.slice(1);
|
|
224
|
+
super((prefix === "$" ? "" : `${prefix} `) + message);
|
|
225
|
+
this.options = options;
|
|
226
|
+
}
|
|
227
|
+
static is(error) {
|
|
228
|
+
return !!error?.[kValidationError];
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
Object.defineProperty(ValidationError.prototype, kValidationError, { value: true });
|
|
232
|
+
const Schema = function(options) {
|
|
233
|
+
const schema = function(data, options = {}) {
|
|
234
|
+
return Schema.resolve(data, schema, options)[0];
|
|
235
|
+
};
|
|
236
|
+
if (options.refs) {
|
|
237
|
+
const refs = mapValues(options.refs, (options) => new Schema(options));
|
|
238
|
+
const getRef = (uid) => refs[uid];
|
|
239
|
+
for (const key in refs) {
|
|
240
|
+
const options = refs[key];
|
|
241
|
+
options.sKey = getRef(options.sKey);
|
|
242
|
+
options.inner = getRef(options.inner);
|
|
243
|
+
options.list = options.list && options.list.map(getRef);
|
|
244
|
+
options.dict = options.dict && mapValues(options.dict, getRef);
|
|
245
|
+
}
|
|
246
|
+
return refs[options.uid];
|
|
247
|
+
}
|
|
248
|
+
Object.assign(schema, options);
|
|
249
|
+
if (typeof schema.callback === "string") try {
|
|
250
|
+
schema.callback = new Function("return " + schema.callback)();
|
|
251
|
+
} catch {}
|
|
252
|
+
Object.defineProperty(schema, "uid", { value: globalThis.__schemastery_index__++ });
|
|
253
|
+
Object.setPrototypeOf(schema, Schema.prototype);
|
|
254
|
+
schema.meta ||= {};
|
|
255
|
+
schema.toString = schema.toString.bind(schema);
|
|
256
|
+
return schema;
|
|
257
|
+
};
|
|
258
|
+
Schema.prototype = Object.create(Function.prototype);
|
|
259
|
+
Schema.prototype[kSchema] = true;
|
|
260
|
+
Object.defineProperty(Schema.prototype, "~standard", { get() {
|
|
261
|
+
return {
|
|
262
|
+
version: 1,
|
|
263
|
+
vendor: "schemastery",
|
|
264
|
+
validate: (value) => {
|
|
265
|
+
try {
|
|
266
|
+
return { value: Schema.resolve(value, this, {})[0] };
|
|
267
|
+
} catch (error) {
|
|
268
|
+
if (ValidationError.is(error)) return { issues: [{
|
|
269
|
+
message: error.message,
|
|
270
|
+
path: error.options.path
|
|
271
|
+
}] };
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
} });
|
|
277
|
+
Schema.ValidationError = ValidationError;
|
|
278
|
+
Schema.prototype.toJSON = function toJSON() {
|
|
279
|
+
if (globalThis.__schemastery_refs__) {
|
|
280
|
+
globalThis.__schemastery_refs__[this.uid] ??= JSON.parse(JSON.stringify({ ...this }));
|
|
281
|
+
return this.uid;
|
|
282
|
+
}
|
|
283
|
+
globalThis.__schemastery_refs__ = { [this.uid]: { ...this } };
|
|
284
|
+
globalThis.__schemastery_refs__[this.uid] = JSON.parse(JSON.stringify({ ...this }));
|
|
285
|
+
const result = {
|
|
286
|
+
uid: this.uid,
|
|
287
|
+
refs: globalThis.__schemastery_refs__
|
|
288
|
+
};
|
|
289
|
+
globalThis.__schemastery_refs__ = void 0;
|
|
290
|
+
return result;
|
|
291
|
+
};
|
|
292
|
+
Schema.prototype.set = function set(key, value) {
|
|
293
|
+
this.dict[key] = value;
|
|
294
|
+
return this;
|
|
295
|
+
};
|
|
296
|
+
Schema.prototype.push = function push(value) {
|
|
297
|
+
this.list.push(value);
|
|
298
|
+
return this;
|
|
299
|
+
};
|
|
300
|
+
function mergeDesc(original, messages) {
|
|
301
|
+
const result = typeof original === "string" ? { "": original } : { ...original };
|
|
302
|
+
for (const locale in messages) {
|
|
303
|
+
const value = messages[locale];
|
|
304
|
+
if (value?.$description || value?.$desc) result[locale] = value.$description || value.$desc;
|
|
305
|
+
else if (typeof value === "string") result[locale] = value;
|
|
306
|
+
}
|
|
307
|
+
return result;
|
|
308
|
+
}
|
|
309
|
+
function getInner(value) {
|
|
310
|
+
return value?.$value ?? value?.$inner;
|
|
311
|
+
}
|
|
312
|
+
function extractKeys(data) {
|
|
313
|
+
return filterKeys(data ?? {}, (key) => !key.startsWith("$"));
|
|
314
|
+
}
|
|
315
|
+
Schema.prototype.i18n = function i18n(messages) {
|
|
316
|
+
const schema = Schema(this);
|
|
317
|
+
const desc = mergeDesc(schema.meta.description, messages);
|
|
318
|
+
if (Object.keys(desc).length) schema.meta.description = desc;
|
|
319
|
+
if (schema.dict) schema.dict = mapValues(schema.dict, (inner, key) => {
|
|
320
|
+
return inner.i18n(mapValues(messages, (data) => getInner(data)?.[key] ?? data?.[key]));
|
|
321
|
+
});
|
|
322
|
+
if (schema.list) schema.list = schema.list.map((inner, index) => {
|
|
323
|
+
return inner.i18n(mapValues(messages, (data = {}) => {
|
|
324
|
+
if (Array.isArray(getInner(data))) return getInner(data)[index];
|
|
325
|
+
if (Array.isArray(data)) return data[index];
|
|
326
|
+
return extractKeys(data);
|
|
327
|
+
}));
|
|
328
|
+
});
|
|
329
|
+
if (schema.inner) schema.inner = schema.inner.i18n(mapValues(messages, (data) => {
|
|
330
|
+
if (getInner(data)) return getInner(data);
|
|
331
|
+
return extractKeys(data);
|
|
332
|
+
}));
|
|
333
|
+
if (schema.sKey) schema.sKey = schema.sKey.i18n(mapValues(messages, (data) => data?.$key));
|
|
334
|
+
return schema;
|
|
335
|
+
};
|
|
336
|
+
Schema.prototype.extra = function extra(key, value) {
|
|
337
|
+
const schema = Schema(this);
|
|
338
|
+
schema.meta = {
|
|
339
|
+
...schema.meta,
|
|
340
|
+
[key]: value
|
|
341
|
+
};
|
|
342
|
+
return schema;
|
|
343
|
+
};
|
|
344
|
+
for (const key of [
|
|
345
|
+
"required",
|
|
346
|
+
"disabled",
|
|
347
|
+
"collapse",
|
|
348
|
+
"hidden",
|
|
349
|
+
"loose"
|
|
350
|
+
]) Object.assign(Schema.prototype, { [key](value = true) {
|
|
351
|
+
const schema = Schema(this);
|
|
352
|
+
schema.meta = {
|
|
353
|
+
...schema.meta,
|
|
354
|
+
[key]: value
|
|
355
|
+
};
|
|
356
|
+
return schema;
|
|
357
|
+
} });
|
|
358
|
+
Schema.prototype.deprecated = function deprecated() {
|
|
359
|
+
const schema = Schema(this);
|
|
360
|
+
schema.meta.badges ||= [];
|
|
361
|
+
schema.meta.badges.push({
|
|
362
|
+
text: "deprecated",
|
|
363
|
+
type: "danger"
|
|
364
|
+
});
|
|
365
|
+
return schema;
|
|
366
|
+
};
|
|
367
|
+
Schema.prototype.experimental = function experimental() {
|
|
368
|
+
const schema = Schema(this);
|
|
369
|
+
schema.meta.badges ||= [];
|
|
370
|
+
schema.meta.badges.push({
|
|
371
|
+
text: "experimental",
|
|
372
|
+
type: "warning"
|
|
373
|
+
});
|
|
374
|
+
return schema;
|
|
375
|
+
};
|
|
376
|
+
Schema.prototype.pattern = function pattern(regexp) {
|
|
377
|
+
const schema = Schema(this);
|
|
378
|
+
const pattern = pick(regexp, ["source", "flags"]);
|
|
379
|
+
schema.meta = {
|
|
380
|
+
...schema.meta,
|
|
381
|
+
pattern
|
|
382
|
+
};
|
|
383
|
+
return schema;
|
|
384
|
+
};
|
|
385
|
+
Schema.prototype.simplify = function simplify(value) {
|
|
386
|
+
if (deepEqual(value, this.meta.default, this.type === "dict")) return null;
|
|
387
|
+
if (isNullable(value)) return value;
|
|
388
|
+
if (this.type === "object" || this.type === "dict") {
|
|
389
|
+
const result = {};
|
|
390
|
+
for (const key in value) {
|
|
391
|
+
const item = (this.type === "object" ? this.dict[key] : this.inner)?.simplify(value[key]);
|
|
392
|
+
if (this.type === "dict" || !isNullable(item)) result[key] = item;
|
|
393
|
+
}
|
|
394
|
+
if (deepEqual(result, this.meta.default, this.type === "dict")) return null;
|
|
395
|
+
return result;
|
|
396
|
+
} else if (this.type === "array" || this.type === "tuple") {
|
|
397
|
+
const result = [];
|
|
398
|
+
value.forEach((value, index) => {
|
|
399
|
+
const schema = this.type === "array" ? this.inner : this.list[index];
|
|
400
|
+
const item = schema ? schema.simplify(value) : value;
|
|
401
|
+
result.push(item);
|
|
402
|
+
});
|
|
403
|
+
return result;
|
|
404
|
+
} else if (this.type === "intersect") {
|
|
405
|
+
const result = {};
|
|
406
|
+
for (const item of this.list) Object.assign(result, item.simplify(value));
|
|
407
|
+
return result;
|
|
408
|
+
} else if (this.type === "union") for (const schema of this.list) try {
|
|
409
|
+
Schema.resolve(value, schema, {});
|
|
410
|
+
return schema.simplify(value);
|
|
411
|
+
} catch {}
|
|
412
|
+
return value;
|
|
413
|
+
};
|
|
414
|
+
Schema.prototype.toString = function toString(inline) {
|
|
415
|
+
return formatters[this.type]?.(this, inline) ?? `Schema<${this.type}>`;
|
|
416
|
+
};
|
|
417
|
+
Schema.prototype.role = function role(role, extra) {
|
|
418
|
+
const schema = Schema(this);
|
|
419
|
+
schema.meta = {
|
|
420
|
+
...schema.meta,
|
|
421
|
+
role,
|
|
422
|
+
extra
|
|
423
|
+
};
|
|
424
|
+
return schema;
|
|
425
|
+
};
|
|
426
|
+
for (const key of [
|
|
427
|
+
"default",
|
|
428
|
+
"link",
|
|
429
|
+
"comment",
|
|
430
|
+
"description",
|
|
431
|
+
"max",
|
|
432
|
+
"min",
|
|
433
|
+
"step"
|
|
434
|
+
]) Object.assign(Schema.prototype, { [key](value) {
|
|
435
|
+
const schema = Schema(this);
|
|
436
|
+
schema.meta = {
|
|
437
|
+
...schema.meta,
|
|
438
|
+
[key]: value
|
|
439
|
+
};
|
|
440
|
+
return schema;
|
|
441
|
+
} });
|
|
442
|
+
const resolvers = {};
|
|
443
|
+
Schema.extend = function extend(type, resolve) {
|
|
444
|
+
resolvers[type] = resolve;
|
|
445
|
+
};
|
|
446
|
+
Schema.resolve = function resolve(data, schema, options = {}, strict = false) {
|
|
447
|
+
if (!schema) return [data];
|
|
448
|
+
if (options.ignore?.(data, schema)) return [data];
|
|
449
|
+
if (isNullable(data) && schema.type !== "lazy") {
|
|
450
|
+
if (schema.meta.required) throw new ValidationError(`missing required value`, options);
|
|
451
|
+
let current = schema;
|
|
452
|
+
let fallback = schema.meta.default;
|
|
453
|
+
while (current?.type === "intersect" && isNullable(fallback)) {
|
|
454
|
+
current = current.list[0];
|
|
455
|
+
fallback = current?.meta.default;
|
|
456
|
+
}
|
|
457
|
+
if (isNullable(fallback)) return [data];
|
|
458
|
+
data = clone(fallback);
|
|
459
|
+
}
|
|
460
|
+
const callback = resolvers[schema.type];
|
|
461
|
+
if (!callback) throw new ValidationError(`unsupported type "${schema.type}"`, options);
|
|
462
|
+
try {
|
|
463
|
+
return callback(data, schema, options, strict);
|
|
464
|
+
} catch (error) {
|
|
465
|
+
if (!schema.meta.loose) throw error;
|
|
466
|
+
return [schema.meta.default];
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
Schema.from = function from(source) {
|
|
470
|
+
if (isNullable(source)) return Schema.any();
|
|
471
|
+
else if ([
|
|
472
|
+
"string",
|
|
473
|
+
"number",
|
|
474
|
+
"boolean"
|
|
475
|
+
].includes(typeof source)) return Schema.const(source).required();
|
|
476
|
+
else if (source[kSchema]) return source;
|
|
477
|
+
else if (typeof source === "function") switch (source) {
|
|
478
|
+
case String: return Schema.string().required();
|
|
479
|
+
case Number: return Schema.number().required();
|
|
480
|
+
case Boolean: return Schema.boolean().required();
|
|
481
|
+
case Function: return Schema.function().required();
|
|
482
|
+
default: return Schema.is(source).required();
|
|
483
|
+
}
|
|
484
|
+
else throw new TypeError(`cannot infer schema from ${source}`);
|
|
485
|
+
};
|
|
486
|
+
Schema.lazy = function lazy(builder) {
|
|
487
|
+
const toJSON = () => {
|
|
488
|
+
if (!schema.inner[kSchema]) {
|
|
489
|
+
schema.inner = schema.builder();
|
|
490
|
+
schema.inner.meta = {
|
|
491
|
+
...schema.meta,
|
|
492
|
+
...schema.inner.meta
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
return schema.inner.toJSON();
|
|
496
|
+
};
|
|
497
|
+
const schema = new Schema({
|
|
498
|
+
type: "lazy",
|
|
499
|
+
builder,
|
|
500
|
+
inner: { toJSON }
|
|
501
|
+
});
|
|
502
|
+
return schema;
|
|
503
|
+
};
|
|
504
|
+
Schema.natural = function natural() {
|
|
505
|
+
return Schema.number().step(1).min(0);
|
|
506
|
+
};
|
|
507
|
+
Schema.percent = function percent() {
|
|
508
|
+
return Schema.number().step(.01).min(0).max(1).role("slider");
|
|
509
|
+
};
|
|
510
|
+
Schema.date = function date() {
|
|
511
|
+
return Schema.union([Schema.is(Date), Schema.transform(Schema.string().role("datetime"), (value, options) => {
|
|
512
|
+
const date = new Date(value);
|
|
513
|
+
if (isNaN(+date)) throw new ValidationError(`invalid date "${value}"`, options);
|
|
514
|
+
return date;
|
|
515
|
+
}, true)]);
|
|
516
|
+
};
|
|
517
|
+
Schema.regExp = function regExp(flag = "") {
|
|
518
|
+
return Schema.union([Schema.is(RegExp), Schema.transform(Schema.string().role("regexp", { flag }), (value, options) => {
|
|
519
|
+
try {
|
|
520
|
+
return new RegExp(value, flag);
|
|
521
|
+
} catch (e) {
|
|
522
|
+
throw new ValidationError(e.message, options);
|
|
523
|
+
}
|
|
524
|
+
}, true)]);
|
|
525
|
+
};
|
|
526
|
+
Schema.arrayBuffer = function arrayBuffer(encoding) {
|
|
527
|
+
return Schema.union([
|
|
528
|
+
Schema.is(ArrayBuffer),
|
|
529
|
+
Schema.is(SharedArrayBuffer),
|
|
530
|
+
Schema.transform(Schema.any(), (value, options) => {
|
|
531
|
+
if (Binary.isSource(value)) return Binary.fromSource(value);
|
|
532
|
+
throw new ValidationError(`expected ArrayBufferSource but got ${value}`, options);
|
|
533
|
+
}, true),
|
|
534
|
+
...encoding ? [Schema.transform(Schema.string(), (value, options) => {
|
|
535
|
+
try {
|
|
536
|
+
return encoding === "base64" ? Binary.fromBase64(value) : Binary.fromHex(value);
|
|
537
|
+
} catch (e) {
|
|
538
|
+
throw new ValidationError(e.message, options);
|
|
539
|
+
}
|
|
540
|
+
}, true)] : []
|
|
541
|
+
]);
|
|
542
|
+
};
|
|
543
|
+
Schema.extend("lazy", (data, schema, options, strict) => {
|
|
544
|
+
if (!schema.inner[kSchema]) {
|
|
545
|
+
schema.inner = schema.builder();
|
|
546
|
+
schema.inner.meta = {
|
|
547
|
+
...schema.meta,
|
|
548
|
+
...schema.inner.meta
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
return Schema.resolve(data, schema.inner, options, strict);
|
|
552
|
+
});
|
|
553
|
+
Schema.extend("any", (data) => {
|
|
554
|
+
return [data];
|
|
555
|
+
});
|
|
556
|
+
Schema.extend("never", (data, _, options) => {
|
|
557
|
+
throw new ValidationError(`expected nullable but got ${data}`, options);
|
|
558
|
+
});
|
|
559
|
+
Schema.extend("const", (data, { value }, options) => {
|
|
560
|
+
if (deepEqual(data, value)) return [value];
|
|
561
|
+
throw new ValidationError(`expected ${value} but got ${data}`, options);
|
|
562
|
+
});
|
|
563
|
+
function checkWithinRange(data, meta, description, options, skipMin = false) {
|
|
564
|
+
const { max = Infinity, min = -Infinity } = meta;
|
|
565
|
+
if (data > max) throw new ValidationError(`expected ${description} <= ${max} but got ${data}`, options);
|
|
566
|
+
if (data < min && !skipMin) throw new ValidationError(`expected ${description} >= ${min} but got ${data}`, options);
|
|
567
|
+
}
|
|
568
|
+
Schema.extend("string", (data, { meta }, options) => {
|
|
569
|
+
if (typeof data !== "string") throw new ValidationError(`expected string but got ${data}`, options);
|
|
570
|
+
if (meta.pattern) {
|
|
571
|
+
const regexp = new RegExp(meta.pattern.source, meta.pattern.flags);
|
|
572
|
+
if (!regexp.test(data)) throw new ValidationError(`expect string to match regexp ${regexp}`, options);
|
|
573
|
+
}
|
|
574
|
+
checkWithinRange(data.length, meta, "string length", options);
|
|
575
|
+
return [data];
|
|
576
|
+
});
|
|
577
|
+
function decimalShift(data, digits) {
|
|
578
|
+
const str = data.toString();
|
|
579
|
+
if (str.includes("e")) return data * Math.pow(10, digits);
|
|
580
|
+
const index = str.indexOf(".");
|
|
581
|
+
if (index === -1) return data * Math.pow(10, digits);
|
|
582
|
+
const frac = str.slice(index + 1);
|
|
583
|
+
const integer = str.slice(0, index);
|
|
584
|
+
if (frac.length <= digits) return +(integer + frac.padEnd(digits, "0"));
|
|
585
|
+
return +(integer + frac.slice(0, digits) + "." + frac.slice(digits));
|
|
586
|
+
}
|
|
587
|
+
function isMultipleOf(data, min, step) {
|
|
588
|
+
step = Math.abs(step);
|
|
589
|
+
if (!/^\d+\.\d+$/.test(step.toString())) return (data - min) % step === 0;
|
|
590
|
+
const index = step.toString().indexOf(".");
|
|
591
|
+
const digits = step.toString().slice(index + 1).length;
|
|
592
|
+
return Math.abs(decimalShift(data, digits) - decimalShift(min, digits)) % decimalShift(step, digits) === 0;
|
|
593
|
+
}
|
|
594
|
+
Schema.extend("number", (data, { meta }, options) => {
|
|
595
|
+
if (typeof data !== "number") throw new ValidationError(`expected number but got ${data}`, options);
|
|
596
|
+
checkWithinRange(data, meta, "number", options);
|
|
597
|
+
const { step } = meta;
|
|
598
|
+
if (step && !isMultipleOf(data, meta.min ?? 0, step)) throw new ValidationError(`expected number multiple of ${step} but got ${data}`, options);
|
|
599
|
+
return [data];
|
|
600
|
+
});
|
|
601
|
+
Schema.extend("boolean", (data, _, options) => {
|
|
602
|
+
if (typeof data === "boolean") return [data];
|
|
603
|
+
throw new ValidationError(`expected boolean but got ${data}`, options);
|
|
604
|
+
});
|
|
605
|
+
Schema.extend("bitset", (data, { bits, meta }, options) => {
|
|
606
|
+
let value = 0, keys = [];
|
|
607
|
+
if (typeof data === "number") {
|
|
608
|
+
value = data;
|
|
609
|
+
for (const key in bits) if (data & bits[key]) keys.push(key);
|
|
610
|
+
} else if (Array.isArray(data)) {
|
|
611
|
+
keys = data;
|
|
612
|
+
for (const key of keys) {
|
|
613
|
+
if (typeof key !== "string") throw new ValidationError(`expected string but got ${key}`, options);
|
|
614
|
+
if (key in bits) value |= bits[key];
|
|
615
|
+
}
|
|
616
|
+
} else throw new ValidationError(`expected number or array but got ${data}`, options);
|
|
617
|
+
if (value === meta.default) return [value];
|
|
618
|
+
return [value, keys];
|
|
619
|
+
});
|
|
620
|
+
Schema.extend("function", (data, _, options) => {
|
|
621
|
+
if (typeof data === "function") return [data];
|
|
622
|
+
throw new ValidationError(`expected function but got ${data}`, options);
|
|
623
|
+
});
|
|
624
|
+
Schema.extend("is", (data, { constructor }, options) => {
|
|
625
|
+
if (typeof constructor === "function") {
|
|
626
|
+
if (data instanceof constructor) return [data];
|
|
627
|
+
throw new ValidationError(`expected ${constructor.name} but got ${data}`, options);
|
|
628
|
+
} else {
|
|
629
|
+
if (isNullable(data)) throw new ValidationError(`expected ${constructor} but got ${data}`, options);
|
|
630
|
+
let prototype = Object.getPrototypeOf(data);
|
|
631
|
+
while (prototype) {
|
|
632
|
+
if (prototype.constructor?.name === constructor) return [data];
|
|
633
|
+
prototype = Object.getPrototypeOf(prototype);
|
|
634
|
+
}
|
|
635
|
+
throw new ValidationError(`expected ${constructor} but got ${data}`, options);
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
function property(data, key, schema, options) {
|
|
639
|
+
try {
|
|
640
|
+
const [value, adapted] = Schema.resolve(data[key], schema, {
|
|
641
|
+
...options,
|
|
642
|
+
path: [...options.path || [], key]
|
|
643
|
+
});
|
|
644
|
+
if (adapted !== void 0) data[key] = adapted;
|
|
645
|
+
return value;
|
|
646
|
+
} catch (e) {
|
|
647
|
+
if (!options?.autofix) throw e;
|
|
648
|
+
delete data[key];
|
|
649
|
+
return schema.meta.default;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
Schema.extend("array", (data, { inner, meta }, options) => {
|
|
653
|
+
if (!Array.isArray(data)) throw new ValidationError(`expected array but got ${data}`, options);
|
|
654
|
+
checkWithinRange(data.length, meta, "array length", options, !isNullable(inner.meta.default));
|
|
655
|
+
return [data.map((_, index) => property(data, index, inner, options))];
|
|
656
|
+
});
|
|
657
|
+
Schema.extend("dict", (data, { inner, sKey }, options, strict) => {
|
|
658
|
+
if (!isPlainObject(data)) throw new ValidationError(`expected object but got ${data}`, options);
|
|
659
|
+
const result = {};
|
|
660
|
+
for (const key in data) {
|
|
661
|
+
let rKey;
|
|
662
|
+
try {
|
|
663
|
+
rKey = Schema.resolve(key, sKey, options)[0];
|
|
664
|
+
} catch (error) {
|
|
665
|
+
if (strict) continue;
|
|
666
|
+
throw error;
|
|
667
|
+
}
|
|
668
|
+
result[rKey] = property(data, key, inner, options);
|
|
669
|
+
data[rKey] = data[key];
|
|
670
|
+
if (key !== rKey) delete data[key];
|
|
671
|
+
}
|
|
672
|
+
return [result];
|
|
673
|
+
});
|
|
674
|
+
Schema.extend("tuple", (data, { list }, options, strict) => {
|
|
675
|
+
if (!Array.isArray(data)) throw new ValidationError(`expected array but got ${data}`, options);
|
|
676
|
+
const result = list.map((inner, index) => property(data, index, inner, options));
|
|
677
|
+
if (strict) return [result];
|
|
678
|
+
result.push(...data.slice(list.length));
|
|
679
|
+
return [result];
|
|
680
|
+
});
|
|
681
|
+
function merge(result, data) {
|
|
682
|
+
for (const key in data) {
|
|
683
|
+
if (key in result) continue;
|
|
684
|
+
result[key] = data[key];
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
Schema.extend("object", (data, { dict }, options, strict) => {
|
|
688
|
+
if (!isPlainObject(data)) throw new ValidationError(`expected object but got ${data}`, options);
|
|
689
|
+
const result = {};
|
|
690
|
+
for (const key in dict) {
|
|
691
|
+
const value = property(data, key, dict[key], options);
|
|
692
|
+
if (!isNullable(value) || key in data) result[key] = value;
|
|
693
|
+
}
|
|
694
|
+
if (!strict) merge(result, data);
|
|
695
|
+
return [result];
|
|
696
|
+
});
|
|
697
|
+
Schema.extend("union", (data, { list, toString }, options, strict) => {
|
|
698
|
+
const messages = [];
|
|
699
|
+
for (const inner of list) try {
|
|
700
|
+
return Schema.resolve(data, inner, options, strict);
|
|
701
|
+
} catch (error) {
|
|
702
|
+
messages.push(error);
|
|
703
|
+
}
|
|
704
|
+
throw new ValidationError(`expected ${toString()} but got ${JSON.stringify(data)}`, options);
|
|
705
|
+
});
|
|
706
|
+
Schema.extend("intersect", (data, { list, toString }, options, strict) => {
|
|
707
|
+
if (!list.length) return [data];
|
|
708
|
+
let result;
|
|
709
|
+
for (const inner of list) {
|
|
710
|
+
const value = Schema.resolve(data, inner, options, true)[0];
|
|
711
|
+
if (isNullable(value)) continue;
|
|
712
|
+
if (isNullable(result)) result = value;
|
|
713
|
+
else if (typeof result !== typeof value) throw new ValidationError(`expected ${toString()} but got ${JSON.stringify(data)}`, options);
|
|
714
|
+
else if (typeof value === "object") merge(result ??= {}, value);
|
|
715
|
+
else if (result !== value) throw new ValidationError(`expected ${toString()} but got ${JSON.stringify(data)}`, options);
|
|
716
|
+
}
|
|
717
|
+
if (!strict && isPlainObject(data)) merge(result, data);
|
|
718
|
+
return [result];
|
|
719
|
+
});
|
|
720
|
+
Schema.extend("transform", (data, { inner, callback, preserve }, options) => {
|
|
721
|
+
const [result, adapted = data] = Schema.resolve(data, inner, options, true);
|
|
722
|
+
if (preserve) return [callback(result)];
|
|
723
|
+
else return [callback(result), callback(adapted)];
|
|
724
|
+
});
|
|
725
|
+
const formatters = {};
|
|
726
|
+
function defineMethod(name, keys, format) {
|
|
727
|
+
formatters[name] = format;
|
|
728
|
+
Object.assign(Schema, { [name](...args) {
|
|
729
|
+
const schema = new Schema({ type: name });
|
|
730
|
+
keys.forEach((key, index) => {
|
|
731
|
+
switch (key) {
|
|
732
|
+
case "sKey":
|
|
733
|
+
schema.sKey = args[index] ?? Schema.string();
|
|
734
|
+
break;
|
|
735
|
+
case "inner":
|
|
736
|
+
schema.inner = Schema.from(args[index]);
|
|
737
|
+
break;
|
|
738
|
+
case "list":
|
|
739
|
+
schema.list = args[index].map(Schema.from);
|
|
740
|
+
break;
|
|
741
|
+
case "dict":
|
|
742
|
+
schema.dict = mapValues(args[index], Schema.from);
|
|
743
|
+
break;
|
|
744
|
+
case "bits":
|
|
745
|
+
schema.bits = {};
|
|
746
|
+
for (const key in args[index]) {
|
|
747
|
+
if (typeof args[index][key] !== "number") continue;
|
|
748
|
+
schema.bits[key] = args[index][key];
|
|
749
|
+
}
|
|
750
|
+
break;
|
|
751
|
+
case "callback": {
|
|
752
|
+
const callback = schema.callback = args[index];
|
|
753
|
+
callback["toJSON"] ||= () => callback.toString();
|
|
754
|
+
break;
|
|
755
|
+
}
|
|
756
|
+
case "constructor": {
|
|
757
|
+
const constructor = schema.constructor = args[index];
|
|
758
|
+
if (typeof constructor === "function") constructor["toJSON"] ||= () => constructor["name"];
|
|
759
|
+
break;
|
|
760
|
+
}
|
|
761
|
+
default: schema[key] = args[index];
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
if (name === "object" || name === "dict") schema.meta.default = {};
|
|
765
|
+
else if (name === "array" || name === "tuple") schema.meta.default = [];
|
|
766
|
+
else if (name === "bitset") schema.meta.default = 0;
|
|
767
|
+
return schema;
|
|
768
|
+
} });
|
|
769
|
+
}
|
|
770
|
+
defineMethod("is", ["constructor"], ({ constructor }) => {
|
|
771
|
+
if (typeof constructor === "function") return constructor.name;
|
|
772
|
+
else return constructor;
|
|
773
|
+
});
|
|
774
|
+
defineMethod("any", [], () => "any");
|
|
775
|
+
defineMethod("never", [], () => "never");
|
|
776
|
+
defineMethod("const", ["value"], ({ value }) => typeof value === "string" ? JSON.stringify(value) : value);
|
|
777
|
+
defineMethod("string", [], () => "string");
|
|
778
|
+
defineMethod("number", [], () => "number");
|
|
779
|
+
defineMethod("boolean", [], () => "boolean");
|
|
780
|
+
defineMethod("bitset", ["bits"], () => "bitset");
|
|
781
|
+
defineMethod("function", [], () => "function");
|
|
782
|
+
defineMethod("array", ["inner"], ({ inner }) => `${inner.toString(true)}[]`);
|
|
783
|
+
defineMethod("dict", ["inner", "sKey"], ({ inner, sKey }) => `{ [key: ${sKey.toString()}]: ${inner.toString()} }`);
|
|
784
|
+
defineMethod("tuple", ["list"], ({ list }) => `[${list.map((inner) => inner.toString()).join(", ")}]`);
|
|
785
|
+
defineMethod("object", ["dict"], ({ dict }) => {
|
|
786
|
+
if (Object.keys(dict).length === 0) return "{}";
|
|
787
|
+
return `{ ${Object.entries(dict).map(([key, inner]) => {
|
|
788
|
+
return `${key}${inner.meta.required ? "" : "?"}: ${inner.toString()}`;
|
|
789
|
+
}).join(", ")} }`;
|
|
790
|
+
});
|
|
791
|
+
defineMethod("union", ["list"], ({ list }, inline) => {
|
|
792
|
+
const result = list.map(({ toString: format }) => format()).join(" | ");
|
|
793
|
+
return inline ? `(${result})` : result;
|
|
794
|
+
});
|
|
795
|
+
defineMethod("intersect", ["list"], ({ list }) => {
|
|
796
|
+
return `${list.map((inner) => inner.toString(true)).join(" & ")}`;
|
|
797
|
+
});
|
|
798
|
+
defineMethod("transform", [
|
|
799
|
+
"inner",
|
|
800
|
+
"callback",
|
|
801
|
+
"preserve"
|
|
802
|
+
], ({ inner }, isInner) => inner.toString(isInner));
|
|
803
|
+
//#endregion
|
|
804
|
+
//#region src/contract/settings.ts
|
|
805
|
+
const SETTINGS_NAMESPACE = "plan-orchestrator";
|
|
806
|
+
const ROLE_NAMES = [
|
|
807
|
+
"planner",
|
|
808
|
+
"worker",
|
|
809
|
+
"integrator",
|
|
810
|
+
"reviewer"
|
|
811
|
+
];
|
|
812
|
+
const SETTINGS_KEYS = /* @__PURE__ */ new Set([
|
|
813
|
+
"enabled",
|
|
814
|
+
"roles",
|
|
815
|
+
"planning",
|
|
816
|
+
"execution",
|
|
817
|
+
"review",
|
|
818
|
+
"recovery",
|
|
819
|
+
"externalIssue",
|
|
820
|
+
"workspaceOverrides"
|
|
821
|
+
]);
|
|
822
|
+
const ROUTE_KEYS = /* @__PURE__ */ new Set([
|
|
823
|
+
"mode",
|
|
824
|
+
"provider",
|
|
825
|
+
"model",
|
|
826
|
+
"reasoningEffort",
|
|
827
|
+
"maxTokens",
|
|
828
|
+
"fallbacks"
|
|
829
|
+
]);
|
|
830
|
+
const CHOICE_KEYS = /* @__PURE__ */ new Set([
|
|
831
|
+
"provider",
|
|
832
|
+
"model",
|
|
833
|
+
"reasoningEffort",
|
|
834
|
+
"maxTokens"
|
|
835
|
+
]);
|
|
836
|
+
const PLANNING_KEYS = /* @__PURE__ */ new Set([
|
|
837
|
+
"adaptiveResearch",
|
|
838
|
+
"strictReadOnly",
|
|
839
|
+
"maxInitialReadFiles",
|
|
840
|
+
"softInputTokens",
|
|
841
|
+
"progressiveDiscovery",
|
|
842
|
+
"requireExpansionReason"
|
|
843
|
+
]);
|
|
844
|
+
const EXECUTION_KEYS = /* @__PURE__ */ new Set([
|
|
845
|
+
"maxParallelWorkers",
|
|
846
|
+
"parallelMode",
|
|
847
|
+
"requireExplicitOwnership",
|
|
848
|
+
"keepFailedWorktrees",
|
|
849
|
+
"sdkProfile",
|
|
850
|
+
"roleTimeoutMs"
|
|
851
|
+
]);
|
|
852
|
+
const REVIEW_KEYS = /* @__PURE__ */ new Set([
|
|
853
|
+
"maxReviewRounds",
|
|
854
|
+
"protocolRetry",
|
|
855
|
+
"trustedValidation",
|
|
856
|
+
"outputCapBytes"
|
|
857
|
+
]);
|
|
858
|
+
const RECOVERY_KEYS = /* @__PURE__ */ new Set(["allowSafeResume"]);
|
|
859
|
+
const EXTERNAL_KEYS = /* @__PURE__ */ new Set(["enabled", "publishAfterPass"]);
|
|
860
|
+
const WORKSPACE_KEYS = /* @__PURE__ */ new Set(["roles"]);
|
|
861
|
+
const defaultRoute = () => ({
|
|
862
|
+
mode: "current",
|
|
863
|
+
fallbacks: []
|
|
864
|
+
});
|
|
865
|
+
const DEFAULT_SETTINGS = Object.freeze({
|
|
866
|
+
enabled: true,
|
|
867
|
+
roles: {
|
|
868
|
+
planner: defaultRoute(),
|
|
869
|
+
worker: defaultRoute(),
|
|
870
|
+
integrator: defaultRoute(),
|
|
871
|
+
reviewer: defaultRoute()
|
|
872
|
+
},
|
|
873
|
+
planning: {
|
|
874
|
+
adaptiveResearch: true,
|
|
875
|
+
strictReadOnly: true,
|
|
876
|
+
maxInitialReadFiles: 6,
|
|
877
|
+
softInputTokens: 2e4,
|
|
878
|
+
progressiveDiscovery: true,
|
|
879
|
+
requireExpansionReason: true
|
|
880
|
+
},
|
|
881
|
+
execution: {
|
|
882
|
+
maxParallelWorkers: 3,
|
|
883
|
+
parallelMode: "auto",
|
|
884
|
+
requireExplicitOwnership: true,
|
|
885
|
+
keepFailedWorktrees: true,
|
|
886
|
+
sdkProfile: "sdk",
|
|
887
|
+
roleTimeoutMs: 9e5
|
|
888
|
+
},
|
|
889
|
+
review: {
|
|
890
|
+
maxReviewRounds: 2,
|
|
891
|
+
protocolRetry: 1,
|
|
892
|
+
trustedValidation: true,
|
|
893
|
+
outputCapBytes: 16 * 1024 * 1024
|
|
894
|
+
},
|
|
895
|
+
recovery: { allowSafeResume: true },
|
|
896
|
+
externalIssue: {
|
|
897
|
+
enabled: false,
|
|
898
|
+
publishAfterPass: false
|
|
899
|
+
},
|
|
900
|
+
workspaceOverrides: {}
|
|
901
|
+
});
|
|
902
|
+
function object(value, where) {
|
|
903
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${where} must be an object`);
|
|
904
|
+
return value;
|
|
905
|
+
}
|
|
906
|
+
function strictKeys(value, allowed, where) {
|
|
907
|
+
const unknown = Object.keys(value).filter((key) => !allowed.has(key));
|
|
908
|
+
if (unknown.length > 0) throw new Error(`${where} has unknown field(s): ${unknown.join(", ")}`);
|
|
909
|
+
}
|
|
910
|
+
function bool(value, where) {
|
|
911
|
+
if (typeof value !== "boolean") throw new Error(`${where} must be boolean`);
|
|
912
|
+
return value;
|
|
913
|
+
}
|
|
914
|
+
function integer(value, where, min, max) {
|
|
915
|
+
if (!Number.isSafeInteger(value) || value < min || value > max) throw new Error(`${where} must be an integer from ${min} through ${max}`);
|
|
916
|
+
return value;
|
|
917
|
+
}
|
|
918
|
+
function optionalString(value, where, max = 256) {
|
|
919
|
+
if (value === void 0) return void 0;
|
|
920
|
+
if (typeof value !== "string" || value.length === 0 || value.length > max || value.includes("\0")) throw new Error(`${where} invalid`);
|
|
921
|
+
return value;
|
|
922
|
+
}
|
|
923
|
+
function routeChoice(value, where) {
|
|
924
|
+
const x = object(value, where);
|
|
925
|
+
strictKeys(x, CHOICE_KEYS, where);
|
|
926
|
+
const provider = optionalString(x.provider, `${where}.provider`);
|
|
927
|
+
const model = optionalString(x.model, `${where}.model`);
|
|
928
|
+
if (!provider || !model) throw new Error(`${where} needs provider and model`);
|
|
929
|
+
const reasoningEffort = optionalString(x.reasoningEffort, `${where}.reasoningEffort`, 64);
|
|
930
|
+
const maxTokens = x.maxTokens === void 0 ? void 0 : integer(x.maxTokens, `${where}.maxTokens`, 1, 2e6);
|
|
931
|
+
return {
|
|
932
|
+
provider,
|
|
933
|
+
model,
|
|
934
|
+
...reasoningEffort ? { reasoningEffort } : {},
|
|
935
|
+
...maxTokens ? { maxTokens } : {}
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
function roleRoute(value, where, workspace) {
|
|
939
|
+
const x = object(value, where);
|
|
940
|
+
strictKeys(x, ROUTE_KEYS, where);
|
|
941
|
+
const allowedModes = workspace ? [
|
|
942
|
+
"inherit",
|
|
943
|
+
"current",
|
|
944
|
+
"fixed"
|
|
945
|
+
] : ["current", "fixed"];
|
|
946
|
+
if (typeof x.mode !== "string" || !allowedModes.includes(x.mode)) throw new Error(`${where}.mode invalid`);
|
|
947
|
+
const mode = x.mode;
|
|
948
|
+
const provider = optionalString(x.provider, `${where}.provider`);
|
|
949
|
+
const model = optionalString(x.model, `${where}.model`);
|
|
950
|
+
const reasoningEffort = optionalString(x.reasoningEffort, `${where}.reasoningEffort`, 64);
|
|
951
|
+
const maxTokens = x.maxTokens === void 0 ? void 0 : integer(x.maxTokens, `${where}.maxTokens`, 1, 2e6);
|
|
952
|
+
if (mode === "fixed" && (!provider || !model)) throw new Error(`${where} fixed route needs provider and model`);
|
|
953
|
+
if (mode !== "fixed" && (provider !== void 0 || model !== void 0 || reasoningEffort !== void 0 || maxTokens !== void 0)) throw new Error(`${where} ${mode} route cannot carry fixed-route fields`);
|
|
954
|
+
if (!Array.isArray(x.fallbacks) || x.fallbacks.length > 8) throw new Error(`${where}.fallbacks invalid`);
|
|
955
|
+
return {
|
|
956
|
+
mode,
|
|
957
|
+
fallbacks: x.fallbacks.map((item, index) => routeChoice(item, `${where}.fallbacks[${index}]`)),
|
|
958
|
+
...provider ? { provider } : {},
|
|
959
|
+
...model ? { model } : {},
|
|
960
|
+
...reasoningEffort ? { reasoningEffort } : {},
|
|
961
|
+
...maxTokens ? { maxTokens } : {}
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
function section(value, allowed, where) {
|
|
965
|
+
const x = object(value, where);
|
|
966
|
+
strictKeys(x, allowed, where);
|
|
967
|
+
return x;
|
|
968
|
+
}
|
|
969
|
+
function validateSettings(value) {
|
|
970
|
+
const x = object(value, "settings");
|
|
971
|
+
strictKeys(x, SETTINGS_KEYS, "settings");
|
|
972
|
+
const rolesRaw = object(x.roles, "settings.roles");
|
|
973
|
+
strictKeys(rolesRaw, new Set(ROLE_NAMES), "settings.roles");
|
|
974
|
+
const roles = Object.fromEntries(ROLE_NAMES.map((role) => [role, roleRoute(rolesRaw[role], `settings.roles.${role}`, false)]));
|
|
975
|
+
const planning = section(x.planning, PLANNING_KEYS, "settings.planning");
|
|
976
|
+
const execution = section(x.execution, EXECUTION_KEYS, "settings.execution");
|
|
977
|
+
const review = section(x.review, REVIEW_KEYS, "settings.review");
|
|
978
|
+
const recovery = section(x.recovery, RECOVERY_KEYS, "settings.recovery");
|
|
979
|
+
const externalIssue = section(x.externalIssue, EXTERNAL_KEYS, "settings.externalIssue");
|
|
980
|
+
const workspaceRaw = object(x.workspaceOverrides, "settings.workspaceOverrides");
|
|
981
|
+
const workspaceOverrides = {};
|
|
982
|
+
if (Object.keys(workspaceRaw).length > 256) throw new Error("settings.workspaceOverrides has too many entries");
|
|
983
|
+
for (const [workspaceKey, raw] of Object.entries(workspaceRaw)) {
|
|
984
|
+
if (!/^[A-Za-z0-9_-]{16,128}$/.test(workspaceKey)) throw new Error(`invalid workspace override key: ${workspaceKey}`);
|
|
985
|
+
const override = object(raw, `settings.workspaceOverrides.${workspaceKey}`);
|
|
986
|
+
strictKeys(override, WORKSPACE_KEYS, `settings.workspaceOverrides.${workspaceKey}`);
|
|
987
|
+
const result = {};
|
|
988
|
+
if (override.roles !== void 0) {
|
|
989
|
+
const roleObject = object(override.roles, `settings.workspaceOverrides.${workspaceKey}.roles`);
|
|
990
|
+
strictKeys(roleObject, new Set(ROLE_NAMES), `settings.workspaceOverrides.${workspaceKey}.roles`);
|
|
991
|
+
result.roles = {};
|
|
992
|
+
for (const [role, route] of Object.entries(roleObject)) result.roles[role] = roleRoute(route, `settings.workspaceOverrides.${workspaceKey}.roles.${role}`, true);
|
|
993
|
+
}
|
|
994
|
+
workspaceOverrides[workspaceKey] = result;
|
|
995
|
+
}
|
|
996
|
+
if (bool(execution.requireExplicitOwnership, "settings.execution.requireExplicitOwnership") !== true) throw new Error("settings.execution.requireExplicitOwnership is a security invariant and must be true");
|
|
997
|
+
if (bool(review.trustedValidation, "settings.review.trustedValidation") !== true) throw new Error("settings.review.trustedValidation is a security invariant and must be true");
|
|
998
|
+
const parallelMode = execution.parallelMode;
|
|
999
|
+
if (![
|
|
1000
|
+
"auto",
|
|
1001
|
+
"serial",
|
|
1002
|
+
"worktree"
|
|
1003
|
+
].includes(String(parallelMode))) throw new Error("settings.execution.parallelMode invalid");
|
|
1004
|
+
return {
|
|
1005
|
+
enabled: bool(x.enabled, "settings.enabled"),
|
|
1006
|
+
roles,
|
|
1007
|
+
planning: {
|
|
1008
|
+
adaptiveResearch: bool(planning.adaptiveResearch, "settings.planning.adaptiveResearch"),
|
|
1009
|
+
strictReadOnly: bool(planning.strictReadOnly, "settings.planning.strictReadOnly"),
|
|
1010
|
+
maxInitialReadFiles: integer(planning.maxInitialReadFiles, "settings.planning.maxInitialReadFiles", 1, 32),
|
|
1011
|
+
softInputTokens: integer(planning.softInputTokens, "settings.planning.softInputTokens", 1e3, 2e6),
|
|
1012
|
+
progressiveDiscovery: bool(planning.progressiveDiscovery, "settings.planning.progressiveDiscovery"),
|
|
1013
|
+
requireExpansionReason: bool(planning.requireExpansionReason, "settings.planning.requireExpansionReason")
|
|
1014
|
+
},
|
|
1015
|
+
execution: {
|
|
1016
|
+
maxParallelWorkers: integer(execution.maxParallelWorkers, "settings.execution.maxParallelWorkers", 1, 8),
|
|
1017
|
+
parallelMode,
|
|
1018
|
+
requireExplicitOwnership: true,
|
|
1019
|
+
keepFailedWorktrees: bool(execution.keepFailedWorktrees, "settings.execution.keepFailedWorktrees"),
|
|
1020
|
+
sdkProfile: optionalString(execution.sdkProfile, "settings.execution.sdkProfile", 128),
|
|
1021
|
+
roleTimeoutMs: integer(execution.roleTimeoutMs, "settings.execution.roleTimeoutMs", 1e3, 36e5)
|
|
1022
|
+
},
|
|
1023
|
+
review: {
|
|
1024
|
+
maxReviewRounds: integer(review.maxReviewRounds, "settings.review.maxReviewRounds", 0, 5),
|
|
1025
|
+
protocolRetry: integer(review.protocolRetry, "settings.review.protocolRetry", 0, 1),
|
|
1026
|
+
trustedValidation: true,
|
|
1027
|
+
outputCapBytes: integer(review.outputCapBytes, "settings.review.outputCapBytes", 1024, 16 * 1024 * 1024)
|
|
1028
|
+
},
|
|
1029
|
+
recovery: { allowSafeResume: bool(recovery.allowSafeResume, "settings.recovery.allowSafeResume") },
|
|
1030
|
+
externalIssue: {
|
|
1031
|
+
enabled: bool(externalIssue.enabled, "settings.externalIssue.enabled"),
|
|
1032
|
+
publishAfterPass: bool(externalIssue.publishAfterPass, "settings.externalIssue.publishAfterPass")
|
|
1033
|
+
},
|
|
1034
|
+
workspaceOverrides
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
function mergeSettings(base, patch) {
|
|
1038
|
+
const roles = {
|
|
1039
|
+
...base.roles,
|
|
1040
|
+
...patch.roles ?? {}
|
|
1041
|
+
};
|
|
1042
|
+
return validateSettings({
|
|
1043
|
+
...base,
|
|
1044
|
+
...patch,
|
|
1045
|
+
roles,
|
|
1046
|
+
planning: {
|
|
1047
|
+
...base.planning,
|
|
1048
|
+
...patch.planning ?? {}
|
|
1049
|
+
},
|
|
1050
|
+
execution: {
|
|
1051
|
+
...base.execution,
|
|
1052
|
+
...patch.execution ?? {}
|
|
1053
|
+
},
|
|
1054
|
+
review: {
|
|
1055
|
+
...base.review,
|
|
1056
|
+
...patch.review ?? {}
|
|
1057
|
+
},
|
|
1058
|
+
recovery: {
|
|
1059
|
+
...base.recovery,
|
|
1060
|
+
...patch.recovery ?? {}
|
|
1061
|
+
},
|
|
1062
|
+
externalIssue: {
|
|
1063
|
+
...base.externalIssue,
|
|
1064
|
+
...patch.externalIssue ?? {}
|
|
1065
|
+
},
|
|
1066
|
+
workspaceOverrides: {
|
|
1067
|
+
...base.workspaceOverrides,
|
|
1068
|
+
...patch.workspaceOverrides ?? {}
|
|
1069
|
+
}
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
function effectiveRoleRoute(settings, role, workspaceKey) {
|
|
1073
|
+
const global = settings.roles[role];
|
|
1074
|
+
if (!workspaceKey) return structuredClone(global);
|
|
1075
|
+
const override = settings.workspaceOverrides[workspaceKey]?.roles?.[role];
|
|
1076
|
+
if (!override || override.mode === "inherit") return structuredClone(global);
|
|
1077
|
+
return structuredClone({
|
|
1078
|
+
...override,
|
|
1079
|
+
mode: override.mode
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
//#endregion
|
|
1083
|
+
//#region src/settings-service.ts
|
|
1084
|
+
const settingsEnvelope = Schema.any().default(DEFAULT_SETTINGS);
|
|
1085
|
+
function canonicalWorkspaceIdentity(cwd) {
|
|
1086
|
+
let canonical = resolve(cwd);
|
|
1087
|
+
try {
|
|
1088
|
+
canonical = realpathSync.native(canonical);
|
|
1089
|
+
} catch {}
|
|
1090
|
+
if (process.platform === "win32") canonical = canonical.toLocaleLowerCase("en-US");
|
|
1091
|
+
return canonical;
|
|
1092
|
+
}
|
|
1093
|
+
function workspaceKey(cwd) {
|
|
1094
|
+
return createHash("sha256").update(canonicalWorkspaceIdentity(cwd)).digest("base64url");
|
|
1095
|
+
}
|
|
1096
|
+
function registerSettings(ctx) {
|
|
1097
|
+
const handle = ctx.settings.register(SETTINGS_NAMESPACE, settingsEnvelope, {
|
|
1098
|
+
applies: "live",
|
|
1099
|
+
validate: (value) => {
|
|
1100
|
+
validateSettings(value);
|
|
1101
|
+
}
|
|
1102
|
+
});
|
|
1103
|
+
const get = () => validateSettings(handle.get() ?? DEFAULT_SETTINGS);
|
|
1104
|
+
const update = async (patch) => {
|
|
1105
|
+
const next = mergeSettings(get(), patch);
|
|
1106
|
+
await handle.update(next);
|
|
1107
|
+
};
|
|
1108
|
+
const effective = (cwd) => {
|
|
1109
|
+
const value = get();
|
|
1110
|
+
if (!cwd) return value;
|
|
1111
|
+
const key = workspaceKey(cwd);
|
|
1112
|
+
return {
|
|
1113
|
+
...value,
|
|
1114
|
+
roles: Object.fromEntries([
|
|
1115
|
+
"planner",
|
|
1116
|
+
"worker",
|
|
1117
|
+
"integrator",
|
|
1118
|
+
"reviewer"
|
|
1119
|
+
].map((role) => [role, effectiveRoleRoute(value, role, key)]))
|
|
1120
|
+
};
|
|
1121
|
+
};
|
|
1122
|
+
return {
|
|
1123
|
+
get,
|
|
1124
|
+
effective,
|
|
1125
|
+
update,
|
|
1126
|
+
watch: (fn) => handle.watch(() => fn(get())),
|
|
1127
|
+
writable: () => Boolean(ctx.settings.writable)
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
//#endregion
|
|
1131
|
+
//#region src/planning/native-plan-bridge.ts
|
|
1132
|
+
var NativePlanBridge = class {
|
|
1133
|
+
#staged = /* @__PURE__ */ new Map();
|
|
1134
|
+
key(sessionId, toolCallId) {
|
|
1135
|
+
return `${sessionId}\0${toolCallId}`;
|
|
1136
|
+
}
|
|
1137
|
+
stage(sessionId, toolCallId, plan, baselineHead, requireOwnership = true) {
|
|
1138
|
+
const { artifact, hash } = extractPlanArtifact(plan, requireOwnership);
|
|
1139
|
+
const staged = {
|
|
1140
|
+
sessionId,
|
|
1141
|
+
toolCallId,
|
|
1142
|
+
plan,
|
|
1143
|
+
artifact,
|
|
1144
|
+
hash,
|
|
1145
|
+
baselineHead,
|
|
1146
|
+
stagedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1147
|
+
};
|
|
1148
|
+
this.#staged.set(this.key(sessionId, toolCallId), staged);
|
|
1149
|
+
return staged;
|
|
1150
|
+
}
|
|
1151
|
+
get(sessionId, toolCallId) {
|
|
1152
|
+
return this.#staged.get(this.key(sessionId, toolCallId));
|
|
1153
|
+
}
|
|
1154
|
+
consume(sessionId, toolCallId) {
|
|
1155
|
+
const key = this.key(sessionId, toolCallId), value = this.#staged.get(key);
|
|
1156
|
+
if (value) this.#staged.delete(key);
|
|
1157
|
+
return value;
|
|
1158
|
+
}
|
|
1159
|
+
clearSession(sessionId) {
|
|
1160
|
+
for (const [k, v] of this.#staged) if (v.sessionId === sessionId) this.#staged.delete(k);
|
|
1161
|
+
}
|
|
1162
|
+
};
|
|
1163
|
+
function installExitPlanValidator(ctx, bridge, requireOwnership, isEnabled = () => true) {
|
|
1164
|
+
return ctx.on("tools/pre-execute", async (exec, next) => {
|
|
1165
|
+
if (exec.name !== "exit_plan_mode") return next();
|
|
1166
|
+
if (!isEnabled(exec.agent)) return next();
|
|
1167
|
+
const agent = exec.agent;
|
|
1168
|
+
const sessionId = String(agent?.session?.id ?? "");
|
|
1169
|
+
if (!sessionId) return {
|
|
1170
|
+
kind: "deny",
|
|
1171
|
+
reason: "Plan Orchestrator: exit_plan_mode has no calling session."
|
|
1172
|
+
};
|
|
1173
|
+
try {
|
|
1174
|
+
const head = await fullHead(await repoRoot(agent.session.header?.cwd ?? process.cwd()));
|
|
1175
|
+
bridge.stage(sessionId, String(exec.callId ?? ""), String(exec.arguments?.plan ?? ""), head, requireOwnership());
|
|
1176
|
+
} catch (error) {
|
|
1177
|
+
return {
|
|
1178
|
+
kind: "deny",
|
|
1179
|
+
reason: `Plan Orchestrator: invalid executable PlanArtifact/preflight: ${error.message}`
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
return next();
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
/** rc.1 exit_plan_mode succeeds only with the structured { approved: true } output. */
|
|
1186
|
+
function isNativePlanApproved(result) {
|
|
1187
|
+
if (!result || typeof result !== "object") return false;
|
|
1188
|
+
const value = result;
|
|
1189
|
+
if (value.isError) return false;
|
|
1190
|
+
return typeof value.value === "object" && value.value !== null && value.value.approved === true;
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Every settled native exit consumes its staged candidate. Runtime disable is
|
|
1194
|
+
* checked before returning an approved artifact so toggling Enabled OFF while
|
|
1195
|
+
* the native approval UI is open can never produce a delayed handoff.
|
|
1196
|
+
*/
|
|
1197
|
+
function consumeApprovedPlanResult(bridge, exec, result, isEnabled = () => true) {
|
|
1198
|
+
if (exec?.name !== "exit_plan_mode") return void 0;
|
|
1199
|
+
const sessionId = String(exec.agent?.session?.id ?? "");
|
|
1200
|
+
const callId = String(exec.callId ?? "");
|
|
1201
|
+
const staged = bridge.consume(sessionId, callId);
|
|
1202
|
+
if (!isEnabled(exec.agent) || !staged || !isNativePlanApproved(result)) return void 0;
|
|
1203
|
+
return staged;
|
|
1204
|
+
}
|
|
1205
|
+
//#endregion
|
|
1206
|
+
//#region src/planning/policy.ts
|
|
1207
|
+
const PLANNER_POLICY = `# Plan Orchestrator Contract
|
|
1208
|
+
You are the Planner. Plan mode is research-and-design only; do not mutate repository files.
|
|
1209
|
+
|
|
1210
|
+
## Evidence before plan
|
|
1211
|
+
Classify claims as Verified Fact, Needs Verification, Recommendation, or Decision Lock. Never invent paths, APIs, commands, tests, dependencies, or runtime behavior.
|
|
1212
|
+
|
|
1213
|
+
## Decisions and scope
|
|
1214
|
+
Prefer minimal sufficient architecture. No scope creep or speculative refactors. Ask the user only for product choices that cannot be discovered from the repository. A material unanswered choice blocks an executable plan. A Decision Lock may change only after new verified evidence and must record Previous Decision → New Evidence → Impact → Replacement Decision.
|
|
1215
|
+
|
|
1216
|
+
## Tasks
|
|
1217
|
+
Split by responsibility, not arbitrary size. Every task has exact repository-relative modify[] ownership, acceptance, validation, dependsOn, and parallelSafe. Mark parallelSafe true only after checking shared APIs, schemas, types, config, migrations, generated output, mutable state, signatures, fixtures, and ordering; uncertainty means false.
|
|
1218
|
+
|
|
1219
|
+
## Host validation safety
|
|
1220
|
+
validationCommands are not an arbitrary shell. Use only an existing package.json script through npm/pnpm/yarn/bun: \`<manager> test\` or \`<manager> run <script>\`; optional script arguments must follow \`--\`. Never emit curl/wget/npx, inline node/python/powershell, pipes, redirection, command substitution, chained shell commands, or an executable not represented by an existing package script.
|
|
1221
|
+
|
|
1222
|
+
## Final gates
|
|
1223
|
+
Before exit_plan_mode: Scope, Evidence, Decision Stability, Task Boundaries, Add-When-Needed, Acceptance & Validation, Packet & Context Efficiency, Execution & Review Readiness must all pass.
|
|
1224
|
+
The Markdown plan MUST contain exactly one \`\`\`json fence with a strict PlanArtifact version 1. Do not emit an executable PlanArtifact if unresolved evidence can change architecture/ownership/user-visible behavior/acceptance.`;
|
|
1225
|
+
function researchPolicy(planning) {
|
|
1226
|
+
const lines = ["## Research budget"];
|
|
1227
|
+
if (planning.adaptiveResearch) lines.push("Research depth is adaptive: if evidence is sufficient, stop; otherwise identify the smallest missing evidence that can change architecture, ownership, user-visible behavior, or acceptance, verify only that gap, then reassess.");
|
|
1228
|
+
else lines.push("Adaptive research is disabled: perform one bounded verification pass and stop unless a missing fact makes an executable plan unsafe.");
|
|
1229
|
+
lines.push(`Initial read budget: at most ${planning.maxInitialReadFiles} files before reassessing evidence sufficiency.`);
|
|
1230
|
+
lines.push(`Soft Planner input budget: ${planning.softInputTokens} tokens. Treat this as a stop/reassess threshold, not permission to omit required evidence.`);
|
|
1231
|
+
if (planning.progressiveDiscovery) lines.push(planning.requireExpansionReason ? "Progressive discovery is allowed only for a concrete blocker; record the expansion reason before reading beyond the initial targets." : "Progressive discovery is allowed for evidence gaps, but expand incrementally and stop when the gap is closed.");
|
|
1232
|
+
else lines.push("Progressive discovery is disabled: do not expand beyond the initial read set; if evidence remains insufficient, report the blocker instead of guessing.");
|
|
1233
|
+
lines.push("No-progress research stops with an explicit missing-evidence report.");
|
|
1234
|
+
return lines.join("\n");
|
|
1235
|
+
}
|
|
1236
|
+
const COMPACT_PLANNER_REMINDER = "Plan Mode remains active: evidence-before-plan, Decision Locks, exact modify[] ownership, configured research budget, strict PlanArtifact v1, package-script-only host validation, no repository mutation.";
|
|
1237
|
+
function plannerPolicyText(enabled, active, first, planning) {
|
|
1238
|
+
if (!enabled || !active) return "";
|
|
1239
|
+
if (!first) return COMPACT_PLANNER_REMINDER;
|
|
1240
|
+
if (!planning) return PLANNER_POLICY;
|
|
1241
|
+
return `${PLANNER_POLICY}\n\n${researchPolicy(planning)}`;
|
|
1242
|
+
}
|
|
1243
|
+
//#endregion
|
|
1244
|
+
//#region src/planning/read-only-guard.ts
|
|
1245
|
+
const MUTATION_NAMES = /^(write|write_file|edit|edit_file|apply_patch|patch|delete|move|rename|mkdir|bash|pwsh|shell|run_code)$/i;
|
|
1246
|
+
var PlannerReadOnlyGuard = class {
|
|
1247
|
+
#active = /* @__PURE__ */ new Map();
|
|
1248
|
+
#degraded = /* @__PURE__ */ new Map();
|
|
1249
|
+
activate(session, sandboxPolicy) {
|
|
1250
|
+
const sid = String(session.id);
|
|
1251
|
+
if (this.#active.has(sid)) return;
|
|
1252
|
+
try {
|
|
1253
|
+
const previousEffective = sandboxPolicy.resolve({ session }).mode, previousOverride = sandboxPolicy.overrideOf(session);
|
|
1254
|
+
setSandboxMode(session, "read-only");
|
|
1255
|
+
const markerCount = this.modeEvents(session).length;
|
|
1256
|
+
this.#active.set(sid, {
|
|
1257
|
+
previousEffective,
|
|
1258
|
+
previousOverride,
|
|
1259
|
+
markerCount
|
|
1260
|
+
});
|
|
1261
|
+
} catch (e) {
|
|
1262
|
+
this.#degraded.set(sid, `sandbox override unavailable: ${e.message}`);
|
|
1263
|
+
this.#active.set(sid, {
|
|
1264
|
+
previousEffective: "read-only",
|
|
1265
|
+
markerCount: -1
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
deactivate(session, sandboxPolicy) {
|
|
1270
|
+
const sid = String(session.id), owned = this.#active.get(sid);
|
|
1271
|
+
if (!owned) return;
|
|
1272
|
+
this.#active.delete(sid);
|
|
1273
|
+
try {
|
|
1274
|
+
if (owned.markerCount < 0) return;
|
|
1275
|
+
const events = this.modeEvents(session);
|
|
1276
|
+
const last = events.at(-1);
|
|
1277
|
+
if (!(events.length === owned.markerCount && last?.data?.mode === "read-only")) return;
|
|
1278
|
+
const target = owned.previousOverride ?? owned.previousEffective;
|
|
1279
|
+
if (sandboxPolicy.resolve({ session }).mode === "read-only") setSandboxMode(session, target);
|
|
1280
|
+
} catch (e) {
|
|
1281
|
+
this.#degraded.set(sid, `sandbox restore skipped: ${e.message}`);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
active(sessionId) {
|
|
1285
|
+
return this.#active.has(sessionId);
|
|
1286
|
+
}
|
|
1287
|
+
degraded(sessionId) {
|
|
1288
|
+
return this.#degraded.get(sessionId);
|
|
1289
|
+
}
|
|
1290
|
+
markDegraded(sessionId, reason) {
|
|
1291
|
+
this.#degraded.set(sessionId, reason);
|
|
1292
|
+
}
|
|
1293
|
+
install(ctx, isEnabled = () => true) {
|
|
1294
|
+
return ctx.on("tools/pre-execute", async (exec, next) => {
|
|
1295
|
+
if (!isEnabled(exec.agent)) return next();
|
|
1296
|
+
const sid = String(exec.agent?.session?.id ?? "");
|
|
1297
|
+
if (!this.active(sid)) return next();
|
|
1298
|
+
if (MUTATION_NAMES.test(String(exec.name))) return {
|
|
1299
|
+
kind: "deny",
|
|
1300
|
+
reason: "Plan Mode is strict read-only; repository mutation is blocked until approval."
|
|
1301
|
+
};
|
|
1302
|
+
return next();
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
modeEvents(session) {
|
|
1306
|
+
return (session.snapshotEvents?.() ?? []).filter((e) => e.type === "sandbox/mode");
|
|
1307
|
+
}
|
|
1308
|
+
};
|
|
1309
|
+
//#endregion
|
|
1310
|
+
//#region src/contract/events.ts
|
|
1311
|
+
function isPlanxEvent(event) {
|
|
1312
|
+
return Boolean(event && typeof event.type === "string" && event.type.startsWith("planx/") && event.data && typeof event.data.runId === "string");
|
|
1313
|
+
}
|
|
1314
|
+
function reducePlanxEvents(sessionId, events) {
|
|
1315
|
+
const rows = /* @__PURE__ */ new Map();
|
|
1316
|
+
for (const event of events) {
|
|
1317
|
+
if (!isPlanxEvent(event)) continue;
|
|
1318
|
+
const d = event.data;
|
|
1319
|
+
let v = rows.get(d.runId);
|
|
1320
|
+
if (event.type === "planx/run-approved") {
|
|
1321
|
+
v = {
|
|
1322
|
+
runId: d.runId,
|
|
1323
|
+
sessionId: String(d.sessionId ?? sessionId),
|
|
1324
|
+
phase: "APPROVED_PENDING",
|
|
1325
|
+
status: "APPROVED_PENDING",
|
|
1326
|
+
tasksTotal: d.tasksTotal,
|
|
1327
|
+
tasksDone: 0,
|
|
1328
|
+
activeTaskIds: [],
|
|
1329
|
+
reviewRound: 0,
|
|
1330
|
+
startedAt: d.at,
|
|
1331
|
+
updatedAt: d.at
|
|
1332
|
+
};
|
|
1333
|
+
rows.set(d.runId, v);
|
|
1334
|
+
continue;
|
|
1335
|
+
}
|
|
1336
|
+
if (!v) continue;
|
|
1337
|
+
if (event.type === "planx/run-phase") {
|
|
1338
|
+
v.phase = d.phase;
|
|
1339
|
+
v.status = d.phase;
|
|
1340
|
+
if (d.message) v.message = d.message;
|
|
1341
|
+
} else if (event.type === "planx/task-start") {
|
|
1342
|
+
if (!v.activeTaskIds.includes(d.taskId)) v.activeTaskIds.push(d.taskId);
|
|
1343
|
+
} else if (event.type === "planx/task-end") {
|
|
1344
|
+
v.activeTaskIds = v.activeTaskIds.filter((x) => x !== d.taskId);
|
|
1345
|
+
v.tasksDone = Math.min(v.tasksTotal, v.tasksDone + 1);
|
|
1346
|
+
} else if (event.type === "planx/review") v.reviewRound = d.round;
|
|
1347
|
+
else if (event.type === "planx/recovery") {
|
|
1348
|
+
v.status = d.status;
|
|
1349
|
+
if (d.message) v.message = d.message;
|
|
1350
|
+
} else if (event.type === "planx/run-terminal") {
|
|
1351
|
+
v.phase = d.phase;
|
|
1352
|
+
v.status = d.phase;
|
|
1353
|
+
v.activeTaskIds = [];
|
|
1354
|
+
if (d.message) v.message = d.message;
|
|
1355
|
+
}
|
|
1356
|
+
v.updatedAt = d.at;
|
|
1357
|
+
}
|
|
1358
|
+
return [...rows.values()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
1359
|
+
}
|
|
1360
|
+
//#endregion
|
|
1361
|
+
//#region src/git/fingerprints.ts
|
|
1362
|
+
async function fingerprintPath(root, path) {
|
|
1363
|
+
const abs = join(root, path);
|
|
1364
|
+
try {
|
|
1365
|
+
const st = await lstat(abs);
|
|
1366
|
+
if (st.isSymbolicLink()) return {
|
|
1367
|
+
kind: "symlink",
|
|
1368
|
+
target: await readlink(abs)
|
|
1369
|
+
};
|
|
1370
|
+
if (!st.isFile()) throw new Error(`owned path is not a regular file: ${path}`);
|
|
1371
|
+
const bytes = await readFile(abs);
|
|
1372
|
+
return {
|
|
1373
|
+
kind: "file",
|
|
1374
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
1375
|
+
bytes: bytes.length
|
|
1376
|
+
};
|
|
1377
|
+
} catch (error) {
|
|
1378
|
+
if (error?.code === "ENOENT") return { kind: "missing" };
|
|
1379
|
+
throw error;
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
async function snapshotDirty(root) {
|
|
1383
|
+
const head = await fullHead(root);
|
|
1384
|
+
const paths = {};
|
|
1385
|
+
for (const path of (await changedPaths(root)).sort()) paths[path] = await fingerprintPath(root, path);
|
|
1386
|
+
return {
|
|
1387
|
+
head,
|
|
1388
|
+
paths
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
async function ownedFingerprint(root, paths) {
|
|
1392
|
+
const values = [];
|
|
1393
|
+
for (const path of [...new Set(paths)].sort()) values.push([path, await fingerprintPath(root, path)]);
|
|
1394
|
+
return createHash("sha256").update(JSON.stringify(values)).digest("hex");
|
|
1395
|
+
}
|
|
1396
|
+
function deltaPaths(before, after) {
|
|
1397
|
+
if (before.head !== after.head) throw new Error(`HEAD drift: ${before.head} -> ${after.head}`);
|
|
1398
|
+
return [.../* @__PURE__ */ new Set([...Object.keys(before.paths), ...Object.keys(after.paths)])].filter((key) => JSON.stringify(before.paths[key] ?? { kind: "clean" }) !== JSON.stringify(after.paths[key] ?? { kind: "clean" })).sort();
|
|
1399
|
+
}
|
|
1400
|
+
function snapshotHash(snapshot) {
|
|
1401
|
+
return createHash("sha256").update(JSON.stringify(snapshot)).digest("hex");
|
|
1402
|
+
}
|
|
1403
|
+
//#endregion
|
|
1404
|
+
//#region src/recovery/reconcile.ts
|
|
1405
|
+
async function diagnoseResume(repo, manifest, checkpoint) {
|
|
1406
|
+
if (manifest.terminal) return {
|
|
1407
|
+
resumable: false,
|
|
1408
|
+
reason: "run is terminal"
|
|
1409
|
+
};
|
|
1410
|
+
if (await fullHead(repo) !== checkpoint.head) return {
|
|
1411
|
+
resumable: false,
|
|
1412
|
+
reason: "HEAD drift"
|
|
1413
|
+
};
|
|
1414
|
+
if (snapshotHash(await snapshotDirty(repo)) !== checkpoint.fingerprint) return {
|
|
1415
|
+
resumable: false,
|
|
1416
|
+
reason: "working tree fingerprint drift"
|
|
1417
|
+
};
|
|
1418
|
+
if (JSON.stringify([...manifest.ownership ?? []].sort()) !== JSON.stringify([...checkpoint.ownership].sort())) return {
|
|
1419
|
+
resumable: false,
|
|
1420
|
+
reason: "ownership checkpoint drift"
|
|
1421
|
+
};
|
|
1422
|
+
if (checkpoint.phase === "PREFLIGHT") return {
|
|
1423
|
+
resumable: true,
|
|
1424
|
+
reason: "checkpoint matches",
|
|
1425
|
+
resumeFrom: "PREFLIGHT",
|
|
1426
|
+
completedTaskIds: []
|
|
1427
|
+
};
|
|
1428
|
+
if (checkpoint.phase === "WORKERS" && checkpoint.safeBoundary) return {
|
|
1429
|
+
resumable: true,
|
|
1430
|
+
reason: "safe worker boundary matches",
|
|
1431
|
+
resumeFrom: "WORKERS",
|
|
1432
|
+
completedTaskIds: checkpoint.completedTaskIds ?? []
|
|
1433
|
+
};
|
|
1434
|
+
if (checkpoint.phase === "VALIDATING" || checkpoint.phase === "REVIEWING") return {
|
|
1435
|
+
resumable: true,
|
|
1436
|
+
reason: "final tree checkpoint matches",
|
|
1437
|
+
resumeFrom: "VALIDATING",
|
|
1438
|
+
completedTaskIds: checkpoint.completedTaskIds ?? []
|
|
1439
|
+
};
|
|
1440
|
+
return {
|
|
1441
|
+
resumable: false,
|
|
1442
|
+
reason: `phase ${checkpoint.phase} cannot be resumed without guessing mutation state`
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
function interruptManifest(manifest) {
|
|
1446
|
+
if (!manifest.terminal) {
|
|
1447
|
+
manifest.phase = "INTERRUPTED";
|
|
1448
|
+
manifest.terminal = false;
|
|
1449
|
+
manifest.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1450
|
+
}
|
|
1451
|
+
return manifest;
|
|
1452
|
+
}
|
|
1453
|
+
//#endregion
|
|
1454
|
+
//#region src/git/worktrees.ts
|
|
1455
|
+
function safeSegment(value) {
|
|
1456
|
+
if (/^[A-Za-z0-9._-]{1,120}$/.test(value)) return value;
|
|
1457
|
+
return createHash("sha256").update(value).digest("hex");
|
|
1458
|
+
}
|
|
1459
|
+
/** Engine lease ids may append -worktrees/-validation-* to the run UUID. All
|
|
1460
|
+
* physical leases still live under worktrees/<base-run-id>/ so terminal cleanup
|
|
1461
|
+
* owns one exact directory tree. */
|
|
1462
|
+
function worktreeRunKey(runId) {
|
|
1463
|
+
return safeSegment(/^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:-|$)/i.exec(runId)?.[1] ?? runId);
|
|
1464
|
+
}
|
|
1465
|
+
async function createWorktree(repo, runId, taskId, head, root = stateRoot()) {
|
|
1466
|
+
const runKey = worktreeRunKey(runId);
|
|
1467
|
+
const path = confined(root, "worktrees", runKey, safeSegment(`${runId}--${taskId}`));
|
|
1468
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
1469
|
+
await git(repo, [
|
|
1470
|
+
"worktree",
|
|
1471
|
+
"add",
|
|
1472
|
+
"--detach",
|
|
1473
|
+
path,
|
|
1474
|
+
head
|
|
1475
|
+
]);
|
|
1476
|
+
return {
|
|
1477
|
+
path,
|
|
1478
|
+
runId: runKey,
|
|
1479
|
+
taskId,
|
|
1480
|
+
baseHead: head
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
async function removeOwnedWorktree(repo, lease, force = false) {
|
|
1484
|
+
await git(repo, [
|
|
1485
|
+
"worktree",
|
|
1486
|
+
"remove",
|
|
1487
|
+
...force ? ["--force"] : [],
|
|
1488
|
+
lease.path
|
|
1489
|
+
]);
|
|
1490
|
+
await rm(lease.path, {
|
|
1491
|
+
recursive: true,
|
|
1492
|
+
force: true
|
|
1493
|
+
}).catch(() => {});
|
|
1494
|
+
}
|
|
1495
|
+
async function cleanupRunWorktrees(repo, runId, root = stateRoot()) {
|
|
1496
|
+
const runRoot = resolve(confined(root, "worktrees", worktreeRunKey(runId)));
|
|
1497
|
+
const listed = (await git(repo, [
|
|
1498
|
+
"worktree",
|
|
1499
|
+
"list",
|
|
1500
|
+
"--porcelain",
|
|
1501
|
+
"-z"
|
|
1502
|
+
])).stdout.toString("utf8").split("\0");
|
|
1503
|
+
for (const rec of listed) {
|
|
1504
|
+
const line = rec.split("\n").find((x) => x.startsWith("worktree "));
|
|
1505
|
+
if (!line) continue;
|
|
1506
|
+
const path = resolve(line.slice(9));
|
|
1507
|
+
if (path !== runRoot && !path.startsWith(runRoot + sep)) continue;
|
|
1508
|
+
await git(repo, [
|
|
1509
|
+
"worktree",
|
|
1510
|
+
"remove",
|
|
1511
|
+
"--force",
|
|
1512
|
+
path
|
|
1513
|
+
]).catch(() => {});
|
|
1514
|
+
await rm(path, {
|
|
1515
|
+
recursive: true,
|
|
1516
|
+
force: true
|
|
1517
|
+
}).catch(() => {});
|
|
1518
|
+
}
|
|
1519
|
+
await rm(runRoot, {
|
|
1520
|
+
recursive: true,
|
|
1521
|
+
force: true
|
|
1522
|
+
}).catch(() => {});
|
|
1523
|
+
}
|
|
1524
|
+
//#endregion
|
|
1525
|
+
//#region src/orchestration/service.ts
|
|
1526
|
+
var OrchestrationService = class {
|
|
1527
|
+
store;
|
|
1528
|
+
runner;
|
|
1529
|
+
#active = /* @__PURE__ */ new Map();
|
|
1530
|
+
#activeRun = /* @__PURE__ */ new Map();
|
|
1531
|
+
#pending = /* @__PURE__ */ new Map();
|
|
1532
|
+
#controllers = /* @__PURE__ */ new Map();
|
|
1533
|
+
#views = /* @__PURE__ */ new Map();
|
|
1534
|
+
#agents = /* @__PURE__ */ new Map();
|
|
1535
|
+
constructor(store, runner) {
|
|
1536
|
+
this.store = store;
|
|
1537
|
+
this.runner = runner;
|
|
1538
|
+
}
|
|
1539
|
+
approve(launch) {
|
|
1540
|
+
if (this.#pending.has(launch.sessionId) || this.#active.has(launch.sessionId)) return false;
|
|
1541
|
+
const runId = randomUUID();
|
|
1542
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1543
|
+
const view = {
|
|
1544
|
+
runId,
|
|
1545
|
+
sessionId: launch.sessionId,
|
|
1546
|
+
phase: "APPROVED_PENDING",
|
|
1547
|
+
status: "APPROVED_PENDING",
|
|
1548
|
+
tasksTotal: launch.artifact.tasks.length,
|
|
1549
|
+
tasksDone: 0,
|
|
1550
|
+
activeTaskIds: [],
|
|
1551
|
+
reviewRound: 0,
|
|
1552
|
+
startedAt: now,
|
|
1553
|
+
updatedAt: now
|
|
1554
|
+
};
|
|
1555
|
+
this.#views.set(runId, view);
|
|
1556
|
+
this.#agents.set(launch.sessionId, launch.agent);
|
|
1557
|
+
this.append(launch.agent.session, "planx/run-approved", {
|
|
1558
|
+
runId,
|
|
1559
|
+
sessionId: launch.sessionId,
|
|
1560
|
+
planHash: launch.planHash,
|
|
1561
|
+
tasksTotal: launch.artifact.tasks.length,
|
|
1562
|
+
at: now
|
|
1563
|
+
});
|
|
1564
|
+
const persisted = this.persistApproval(launch, runId, now);
|
|
1565
|
+
this.#pending.set(launch.sessionId, {
|
|
1566
|
+
runId,
|
|
1567
|
+
launch,
|
|
1568
|
+
persisted,
|
|
1569
|
+
cancelled: false
|
|
1570
|
+
});
|
|
1571
|
+
if (launch.agent.status === "idle") queueMicrotask(() => void this.onParentIdle(launch.sessionId));
|
|
1572
|
+
return runId;
|
|
1573
|
+
}
|
|
1574
|
+
shouldFence(sessionId) {
|
|
1575
|
+
return this.#pending.has(sessionId);
|
|
1576
|
+
}
|
|
1577
|
+
activeRun(sessionId) {
|
|
1578
|
+
return this.#pending.get(sessionId)?.runId ?? this.#activeRun.get(sessionId);
|
|
1579
|
+
}
|
|
1580
|
+
list(sessionId) {
|
|
1581
|
+
return [...this.#views.values()].filter((view) => !sessionId || view.sessionId === sessionId).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
1582
|
+
}
|
|
1583
|
+
async detail(runId) {
|
|
1584
|
+
const view = this.#views.get(runId);
|
|
1585
|
+
if (!view) return void 0;
|
|
1586
|
+
const dir = this.store.runDir(view.sessionId, runId);
|
|
1587
|
+
const [completion, telemetry, worktrees, checkpoint] = await Promise.all([
|
|
1588
|
+
readJson(join(dir, "completion.json")).catch(() => void 0),
|
|
1589
|
+
readJson(join(dir, "telemetry.json")).catch(() => void 0),
|
|
1590
|
+
readJson(join(dir, "worktrees.json")).catch(() => void 0),
|
|
1591
|
+
readJson(join(dir, "recovery.json")).catch(() => void 0)
|
|
1592
|
+
]);
|
|
1593
|
+
const latestReview = await this.latestReview(dir, view.reviewRound);
|
|
1594
|
+
return {
|
|
1595
|
+
...view,
|
|
1596
|
+
...completion,
|
|
1597
|
+
usage: completion?.usage ?? telemetry,
|
|
1598
|
+
review: latestReview?.verdict ?? completion?.review,
|
|
1599
|
+
worktrees: worktrees?.worktrees ?? [],
|
|
1600
|
+
recovery: checkpoint ? {
|
|
1601
|
+
phase: checkpoint.phase,
|
|
1602
|
+
safeBoundary: checkpoint.safeBoundary,
|
|
1603
|
+
completedTaskIds: checkpoint.completedTaskIds
|
|
1604
|
+
} : void 0
|
|
1605
|
+
};
|
|
1606
|
+
}
|
|
1607
|
+
recordEvent(kind, data) {
|
|
1608
|
+
const runId = String(data.runId ?? "");
|
|
1609
|
+
const view = this.#views.get(runId);
|
|
1610
|
+
if (!view) return;
|
|
1611
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1612
|
+
const session = this.#agents.get(view.sessionId)?.session;
|
|
1613
|
+
if (kind === "phase") {
|
|
1614
|
+
view.phase = String(data.phase);
|
|
1615
|
+
view.status = view.phase;
|
|
1616
|
+
view.reviewRound = Number(data.reviewRound ?? view.reviewRound);
|
|
1617
|
+
if (data.message) view.message = String(data.message);
|
|
1618
|
+
this.append(session, "planx/run-phase", {
|
|
1619
|
+
runId,
|
|
1620
|
+
phase: view.phase,
|
|
1621
|
+
message: data.message,
|
|
1622
|
+
reviewRound: view.reviewRound,
|
|
1623
|
+
at: now
|
|
1624
|
+
});
|
|
1625
|
+
} else if (kind === "task-start") {
|
|
1626
|
+
if (!view.activeTaskIds.includes(data.taskId)) view.activeTaskIds.push(data.taskId);
|
|
1627
|
+
this.append(session, "planx/task-start", {
|
|
1628
|
+
runId,
|
|
1629
|
+
taskId: String(data.taskId),
|
|
1630
|
+
at: now
|
|
1631
|
+
});
|
|
1632
|
+
} else if (kind === "task-end") {
|
|
1633
|
+
view.activeTaskIds = view.activeTaskIds.filter((id) => id !== data.taskId);
|
|
1634
|
+
view.tasksDone = Math.min(view.tasksTotal, view.tasksDone + 1);
|
|
1635
|
+
this.append(session, "planx/task-end", {
|
|
1636
|
+
runId,
|
|
1637
|
+
taskId: String(data.taskId),
|
|
1638
|
+
status: String(data.status ?? ""),
|
|
1639
|
+
at: now
|
|
1640
|
+
});
|
|
1641
|
+
} else if (kind === "validation") this.append(session, "planx/validation", {
|
|
1642
|
+
runId,
|
|
1643
|
+
commandId: String(data.commandId),
|
|
1644
|
+
status: String(data.status),
|
|
1645
|
+
at: now
|
|
1646
|
+
});
|
|
1647
|
+
else if (kind === "review") {
|
|
1648
|
+
view.reviewRound = Number(data.round ?? view.reviewRound);
|
|
1649
|
+
this.append(session, "planx/review", {
|
|
1650
|
+
runId,
|
|
1651
|
+
round: view.reviewRound,
|
|
1652
|
+
verdict: String(data.verdict),
|
|
1653
|
+
at: now
|
|
1654
|
+
});
|
|
1655
|
+
}
|
|
1656
|
+
view.updatedAt = now;
|
|
1657
|
+
}
|
|
1658
|
+
async cancel(runId, reason = "user requested stop") {
|
|
1659
|
+
const view = this.#views.get(runId);
|
|
1660
|
+
if (!view) return false;
|
|
1661
|
+
const sessionId = view.sessionId;
|
|
1662
|
+
const pending = this.#pending.get(sessionId);
|
|
1663
|
+
if (pending?.runId === runId) {
|
|
1664
|
+
pending.cancelled = true;
|
|
1665
|
+
let persistError;
|
|
1666
|
+
try {
|
|
1667
|
+
await pending.persisted;
|
|
1668
|
+
} catch (error) {
|
|
1669
|
+
persistError = error;
|
|
1670
|
+
}
|
|
1671
|
+
const controller = this.#controllers.get(runId);
|
|
1672
|
+
const active = this.#active.get(sessionId);
|
|
1673
|
+
if (controller && active) {
|
|
1674
|
+
if (!controller.signal.aborted) controller.abort(reason);
|
|
1675
|
+
await active.catch(() => {});
|
|
1676
|
+
} else await this.markCancelled(view, reason, persistError);
|
|
1677
|
+
if (this.#pending.get(sessionId) === pending) this.#pending.delete(sessionId);
|
|
1678
|
+
return true;
|
|
1679
|
+
}
|
|
1680
|
+
if (this.#activeRun.get(sessionId) !== runId) return false;
|
|
1681
|
+
const active = this.#active.get(sessionId);
|
|
1682
|
+
const controller = this.#controllers.get(runId);
|
|
1683
|
+
if (!active || !controller) return false;
|
|
1684
|
+
if (!controller.signal.aborted) controller.abort(reason);
|
|
1685
|
+
await active.catch(() => {});
|
|
1686
|
+
return true;
|
|
1687
|
+
}
|
|
1688
|
+
async cancelSession(sessionId, reason = "Plan Orchestrator disabled") {
|
|
1689
|
+
const runId = this.activeRun(sessionId);
|
|
1690
|
+
return runId ? this.cancel(runId, reason) : false;
|
|
1691
|
+
}
|
|
1692
|
+
async cancelAll(reason = "Plan Orchestrator disabled") {
|
|
1693
|
+
const sessionIds = /* @__PURE__ */ new Set([...this.#pending.keys(), ...this.#activeRun.keys()]);
|
|
1694
|
+
await Promise.allSettled([...sessionIds].map((sessionId) => this.cancelSession(sessionId, reason)));
|
|
1695
|
+
}
|
|
1696
|
+
async onParentIdle(sessionId) {
|
|
1697
|
+
const pending = this.#pending.get(sessionId);
|
|
1698
|
+
if (!pending || pending.cancelled || this.#active.has(sessionId)) return false;
|
|
1699
|
+
try {
|
|
1700
|
+
await pending.persisted;
|
|
1701
|
+
} catch (error) {
|
|
1702
|
+
if (this.#pending.get(sessionId) === pending) this.#pending.delete(sessionId);
|
|
1703
|
+
this.failView(pending.runId, "FAILED", `approval persistence failed: ${error.message}`);
|
|
1704
|
+
return false;
|
|
1705
|
+
}
|
|
1706
|
+
if (pending.cancelled || this.#pending.get(sessionId) !== pending) return false;
|
|
1707
|
+
const started = await this.start(pending.launch, pending.runId);
|
|
1708
|
+
if (pending.cancelled) {
|
|
1709
|
+
if (this.#pending.get(sessionId) === pending) this.#pending.delete(sessionId);
|
|
1710
|
+
return false;
|
|
1711
|
+
}
|
|
1712
|
+
if (this.#pending.get(sessionId) === pending) this.#pending.delete(sessionId);
|
|
1713
|
+
return started;
|
|
1714
|
+
}
|
|
1715
|
+
async reconcileSession(agent) {
|
|
1716
|
+
const sessionId = String(agent.session.id);
|
|
1717
|
+
this.#agents.set(sessionId, agent);
|
|
1718
|
+
for (const view of reducePlanxEvents(sessionId, agent.session.snapshotEvents?.() ?? [])) this.#views.set(view.runId, view);
|
|
1719
|
+
for (const original of await this.store.listSessionManifests(sessionId)) {
|
|
1720
|
+
if (original.terminal) continue;
|
|
1721
|
+
const manifest = interruptManifest(original);
|
|
1722
|
+
await this.store.writeManifest(manifest);
|
|
1723
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1724
|
+
const view = this.#views.get(manifest.runId) ?? {
|
|
1725
|
+
runId: manifest.runId,
|
|
1726
|
+
sessionId,
|
|
1727
|
+
phase: "INTERRUPTED",
|
|
1728
|
+
status: "INTERRUPTED",
|
|
1729
|
+
tasksTotal: 0,
|
|
1730
|
+
tasksDone: manifest.completedTaskIds?.length ?? 0,
|
|
1731
|
+
activeTaskIds: [],
|
|
1732
|
+
reviewRound: 0,
|
|
1733
|
+
startedAt: manifest.createdAt,
|
|
1734
|
+
updatedAt: now
|
|
1735
|
+
};
|
|
1736
|
+
view.phase = "INTERRUPTED";
|
|
1737
|
+
view.status = "INTERRUPTED";
|
|
1738
|
+
view.updatedAt = now;
|
|
1739
|
+
this.#views.set(manifest.runId, view);
|
|
1740
|
+
this.append(agent.session, "planx/recovery", {
|
|
1741
|
+
runId: manifest.runId,
|
|
1742
|
+
status: "INTERRUPTED",
|
|
1743
|
+
message: "Previous process ended before a terminal checkpoint.",
|
|
1744
|
+
at: now
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
return this.list(sessionId);
|
|
1748
|
+
}
|
|
1749
|
+
async resume(runId) {
|
|
1750
|
+
const view = this.#views.get(runId);
|
|
1751
|
+
if (!view || view.phase !== "INTERRUPTED" || this.#active.has(view.sessionId) || this.#pending.has(view.sessionId)) return {
|
|
1752
|
+
ok: false,
|
|
1753
|
+
reason: "run is not resumable in current state"
|
|
1754
|
+
};
|
|
1755
|
+
const agent = this.#agents.get(view.sessionId);
|
|
1756
|
+
if (!agent) return {
|
|
1757
|
+
ok: false,
|
|
1758
|
+
reason: "session agent unavailable"
|
|
1759
|
+
};
|
|
1760
|
+
const manifest = await this.store.readManifest(view.sessionId, runId);
|
|
1761
|
+
const dir = this.store.runDir(view.sessionId, runId);
|
|
1762
|
+
const checkpoint = await readJson(join(dir, "recovery.json")).catch(() => void 0);
|
|
1763
|
+
if (!checkpoint) return {
|
|
1764
|
+
ok: false,
|
|
1765
|
+
reason: "recovery checkpoint missing"
|
|
1766
|
+
};
|
|
1767
|
+
const diagnosis = await diagnoseResume(await repoRoot(agent.session.header?.cwd ?? process.cwd()), manifest, checkpoint);
|
|
1768
|
+
if (!diagnosis.resumable) return {
|
|
1769
|
+
ok: false,
|
|
1770
|
+
reason: diagnosis.reason
|
|
1771
|
+
};
|
|
1772
|
+
const artifact = await readJson(join(dir, "plan.json"));
|
|
1773
|
+
const launch = {
|
|
1774
|
+
sessionId: view.sessionId,
|
|
1775
|
+
agent,
|
|
1776
|
+
artifact,
|
|
1777
|
+
planHash: manifest.planHash,
|
|
1778
|
+
baselineHead: manifest.baselineHead,
|
|
1779
|
+
resumeFrom: diagnosis.resumeFrom,
|
|
1780
|
+
completedTaskIds: diagnosis.completedTaskIds,
|
|
1781
|
+
externalIssue: manifest.externalIssue ? {
|
|
1782
|
+
...manifest.externalIssue,
|
|
1783
|
+
publishAfterPass: Boolean(manifest.externalIssue.publishAfterPass)
|
|
1784
|
+
} : void 0
|
|
1785
|
+
};
|
|
1786
|
+
manifest.phase = diagnosis.resumeFrom;
|
|
1787
|
+
manifest.terminal = false;
|
|
1788
|
+
await this.store.writeManifest(manifest);
|
|
1789
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1790
|
+
this.append(agent.session, "planx/recovery", {
|
|
1791
|
+
runId,
|
|
1792
|
+
status: "RESUMING",
|
|
1793
|
+
message: `Safe resume from ${diagnosis.resumeFrom}`,
|
|
1794
|
+
at: now
|
|
1795
|
+
});
|
|
1796
|
+
await this.start(launch, runId);
|
|
1797
|
+
return {
|
|
1798
|
+
ok: true,
|
|
1799
|
+
resumeFrom: diagnosis.resumeFrom
|
|
1800
|
+
};
|
|
1801
|
+
}
|
|
1802
|
+
async cleanup(runId) {
|
|
1803
|
+
const view = this.#views.get(runId);
|
|
1804
|
+
if (!view || this.#active.has(view.sessionId)) return {
|
|
1805
|
+
ok: false,
|
|
1806
|
+
reason: "active or unknown run"
|
|
1807
|
+
};
|
|
1808
|
+
const manifest = await this.store.readManifest(view.sessionId, runId).catch(() => void 0);
|
|
1809
|
+
if (!manifest?.terminal) return {
|
|
1810
|
+
ok: false,
|
|
1811
|
+
reason: "only terminal runs may be cleaned"
|
|
1812
|
+
};
|
|
1813
|
+
if (manifest.repoRoot) await cleanupRunWorktrees(manifest.repoRoot, runId, this.store.root).catch(() => {});
|
|
1814
|
+
await this.store.removeRun(view.sessionId, runId);
|
|
1815
|
+
return { ok: true };
|
|
1816
|
+
}
|
|
1817
|
+
async start(launch, runId) {
|
|
1818
|
+
if (this.#active.has(launch.sessionId)) return false;
|
|
1819
|
+
const controller = new AbortController();
|
|
1820
|
+
this.#controllers.set(runId, controller);
|
|
1821
|
+
this.#activeRun.set(launch.sessionId, runId);
|
|
1822
|
+
let task;
|
|
1823
|
+
try {
|
|
1824
|
+
task = launch.agent.runMaintenance(async (signal) => this.run(launch, runId, AbortSignal.any([signal, controller.signal])));
|
|
1825
|
+
} catch (error) {
|
|
1826
|
+
this.#controllers.delete(runId);
|
|
1827
|
+
if (this.#activeRun.get(launch.sessionId) === runId) this.#activeRun.delete(launch.sessionId);
|
|
1828
|
+
throw error;
|
|
1829
|
+
}
|
|
1830
|
+
task = task.finally(() => {
|
|
1831
|
+
this.#active.delete(launch.sessionId);
|
|
1832
|
+
this.#controllers.delete(runId);
|
|
1833
|
+
if (this.#activeRun.get(launch.sessionId) === runId) this.#activeRun.delete(launch.sessionId);
|
|
1834
|
+
});
|
|
1835
|
+
this.#active.set(launch.sessionId, task);
|
|
1836
|
+
task.catch(() => {});
|
|
1837
|
+
return true;
|
|
1838
|
+
}
|
|
1839
|
+
async run(launch, runId, signal) {
|
|
1840
|
+
const initial = await this.store.readManifest(launch.sessionId, runId);
|
|
1841
|
+
initial.phase = launch.resumeFrom ?? "PREFLIGHT";
|
|
1842
|
+
initial.terminal = false;
|
|
1843
|
+
await this.store.writeManifest(initial);
|
|
1844
|
+
this.recordEvent("phase", {
|
|
1845
|
+
runId,
|
|
1846
|
+
phase: initial.phase
|
|
1847
|
+
});
|
|
1848
|
+
let terminalPhase = "COMPLETE";
|
|
1849
|
+
let terminalMessage;
|
|
1850
|
+
let thrown;
|
|
1851
|
+
try {
|
|
1852
|
+
await this.runner(launch, runId, signal);
|
|
1853
|
+
} catch (error) {
|
|
1854
|
+
thrown = error;
|
|
1855
|
+
terminalMessage = error.message;
|
|
1856
|
+
terminalPhase = signal.aborted ? "CANCELLED" : /(drift|ownership|blocked|inconclusive|unsafe|conflict|escape|checkpoint|tampered|stale)/i.test(terminalMessage) ? "BLOCKED" : "FAILED";
|
|
1857
|
+
}
|
|
1858
|
+
const latest = await this.store.readManifest(launch.sessionId, runId);
|
|
1859
|
+
latest.phase = terminalPhase;
|
|
1860
|
+
latest.terminal = true;
|
|
1861
|
+
await this.store.writeManifest(latest);
|
|
1862
|
+
this.recordEvent("phase", {
|
|
1863
|
+
runId,
|
|
1864
|
+
phase: terminalPhase,
|
|
1865
|
+
message: terminalMessage
|
|
1866
|
+
});
|
|
1867
|
+
this.append(launch.agent.session, "planx/run-terminal", {
|
|
1868
|
+
runId,
|
|
1869
|
+
phase: terminalPhase,
|
|
1870
|
+
...terminalMessage ? { message: terminalMessage } : {},
|
|
1871
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1872
|
+
});
|
|
1873
|
+
if (thrown !== void 0) throw thrown;
|
|
1874
|
+
}
|
|
1875
|
+
async persistApproval(launch, runId, now) {
|
|
1876
|
+
const dir = this.store.runDir(launch.sessionId, runId);
|
|
1877
|
+
const manifest = {
|
|
1878
|
+
schemaVersion: 1,
|
|
1879
|
+
runId,
|
|
1880
|
+
sessionId: launch.sessionId,
|
|
1881
|
+
planHash: launch.planHash,
|
|
1882
|
+
phase: "APPROVED_PENDING",
|
|
1883
|
+
terminal: false,
|
|
1884
|
+
createdAt: now,
|
|
1885
|
+
updatedAt: now,
|
|
1886
|
+
baselineHead: launch.baselineHead,
|
|
1887
|
+
ownership: [...new Set(launch.artifact.tasks.flatMap((task) => task.modify))].sort(),
|
|
1888
|
+
completedTaskIds: [],
|
|
1889
|
+
externalIssue: launch.externalIssue ? {
|
|
1890
|
+
issueNumber: launch.externalIssue.issueNumber,
|
|
1891
|
+
repository: launch.externalIssue.repository,
|
|
1892
|
+
revision: launch.externalIssue.revision,
|
|
1893
|
+
branch: launch.externalIssue.branch,
|
|
1894
|
+
publishAfterPass: launch.externalIssue.publishAfterPass
|
|
1895
|
+
} : void 0,
|
|
1896
|
+
artifacts: {}
|
|
1897
|
+
};
|
|
1898
|
+
await this.store.writeManifest(manifest);
|
|
1899
|
+
await atomicJson(join(dir, "plan.json"), launch.artifact);
|
|
1900
|
+
}
|
|
1901
|
+
async markCancelled(view, reason, persistError) {
|
|
1902
|
+
view.phase = "CANCELLED";
|
|
1903
|
+
view.status = "CANCELLED";
|
|
1904
|
+
view.message = persistError ? `${reason}; approval persistence failed: ${persistError.message}` : reason;
|
|
1905
|
+
view.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1906
|
+
this.append(this.#agents.get(view.sessionId)?.session, "planx/run-terminal", {
|
|
1907
|
+
runId: view.runId,
|
|
1908
|
+
phase: "CANCELLED",
|
|
1909
|
+
message: view.message,
|
|
1910
|
+
at: view.updatedAt
|
|
1911
|
+
});
|
|
1912
|
+
const manifest = await this.store.readManifest(view.sessionId, view.runId).catch(() => void 0);
|
|
1913
|
+
if (manifest) {
|
|
1914
|
+
manifest.phase = "CANCELLED";
|
|
1915
|
+
manifest.terminal = true;
|
|
1916
|
+
await this.store.writeManifest(manifest);
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
append(session, type, data) {
|
|
1920
|
+
if (!session || typeof session.append !== "function") throw new Error(`plan-orchestrator cannot persist ${type}: session append unavailable`);
|
|
1921
|
+
session.append(type, data);
|
|
1922
|
+
}
|
|
1923
|
+
failView(runId, phase, message) {
|
|
1924
|
+
const view = this.#views.get(runId);
|
|
1925
|
+
if (!view) return;
|
|
1926
|
+
view.phase = phase;
|
|
1927
|
+
view.status = phase;
|
|
1928
|
+
view.message = message;
|
|
1929
|
+
view.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1930
|
+
this.append(this.#agents.get(view.sessionId)?.session, "planx/run-terminal", {
|
|
1931
|
+
runId,
|
|
1932
|
+
phase,
|
|
1933
|
+
message,
|
|
1934
|
+
at: view.updatedAt
|
|
1935
|
+
});
|
|
1936
|
+
}
|
|
1937
|
+
async latestReview(dir, round) {
|
|
1938
|
+
for (let index = round; index >= 0; index--) {
|
|
1939
|
+
const value = await readJson(join(dir, `review-${index}.json`)).catch(() => void 0);
|
|
1940
|
+
if (value) return value;
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
};
|
|
1944
|
+
//#endregion
|
|
1945
|
+
//#region src/orchestration/enabled-parent-fence.ts
|
|
1946
|
+
/**
|
|
1947
|
+
* Runtime-gated parent fence. Enabled OFF is a hard execution boundary: every
|
|
1948
|
+
* event re-checks settings, pending and active plugin-owned runs are cancelled,
|
|
1949
|
+
* then the native DSH flow is allowed through unchanged.
|
|
1950
|
+
*/
|
|
1951
|
+
function installEnabledParentFence(ctx, service, isEnabled) {
|
|
1952
|
+
const cancelOwnedRun = async (agent) => {
|
|
1953
|
+
const sessionId = String(agent?.session?.id ?? "");
|
|
1954
|
+
if (!sessionId) return;
|
|
1955
|
+
await service.cancelSession(sessionId, "Plan Orchestrator disabled");
|
|
1956
|
+
};
|
|
1957
|
+
const preStep = ctx.on("agent/pre-step", async ({ agent }, next) => {
|
|
1958
|
+
if (!isEnabled(agent)) {
|
|
1959
|
+
await cancelOwnedRun(agent);
|
|
1960
|
+
return next();
|
|
1961
|
+
}
|
|
1962
|
+
const sessionId = String(agent.session.id);
|
|
1963
|
+
if (!service.shouldFence(sessionId)) return next();
|
|
1964
|
+
await next();
|
|
1965
|
+
return { kind: "reject" };
|
|
1966
|
+
}, { prepend: true });
|
|
1967
|
+
const status = ctx.on("agent/status", ({ agent, status }) => {
|
|
1968
|
+
if (!isEnabled(agent)) {
|
|
1969
|
+
cancelOwnedRun(agent).catch((error) => {
|
|
1970
|
+
ctx.logger?.warn?.("plan-orchestrator run cleanup after disable failed: %o", error);
|
|
1971
|
+
});
|
|
1972
|
+
return;
|
|
1973
|
+
}
|
|
1974
|
+
if (status === "idle") service.onParentIdle(String(agent.session.id)).catch((error) => {
|
|
1975
|
+
ctx.logger?.error?.("plan-orchestrator maintenance launch failed: %o", error);
|
|
1976
|
+
});
|
|
1977
|
+
});
|
|
1978
|
+
return () => {
|
|
1979
|
+
preStep?.();
|
|
1980
|
+
status?.();
|
|
1981
|
+
};
|
|
1982
|
+
}
|
|
1983
|
+
//#endregion
|
|
1984
|
+
//#region src/git/patches.ts
|
|
1985
|
+
async function capturePatch(root, baseHead, taskId, paths) {
|
|
1986
|
+
const files = [];
|
|
1987
|
+
for (const path of [...new Set(paths)].sort()) {
|
|
1988
|
+
const fp = await fingerprintPath(root, path);
|
|
1989
|
+
if (fp.kind === "missing") files.push({
|
|
1990
|
+
path,
|
|
1991
|
+
kind: "delete"
|
|
1992
|
+
});
|
|
1993
|
+
else if (fp.kind === "symlink") files.push({
|
|
1994
|
+
path,
|
|
1995
|
+
kind: "symlink",
|
|
1996
|
+
target: await readlink(join(root, path))
|
|
1997
|
+
});
|
|
1998
|
+
else {
|
|
1999
|
+
const b = await readFile(join(root, path));
|
|
2000
|
+
files.push({
|
|
2001
|
+
path,
|
|
2002
|
+
kind: "file",
|
|
2003
|
+
contentBase64: b.toString("base64"),
|
|
2004
|
+
sha256: createHash("sha256").update(b).digest("hex")
|
|
2005
|
+
});
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
const raw = {
|
|
2009
|
+
version: 1,
|
|
2010
|
+
baseHead,
|
|
2011
|
+
taskId,
|
|
2012
|
+
files
|
|
2013
|
+
};
|
|
2014
|
+
return {
|
|
2015
|
+
...raw,
|
|
2016
|
+
sha256: createHash("sha256").update(JSON.stringify(raw)).digest("hex")
|
|
2017
|
+
};
|
|
2018
|
+
}
|
|
2019
|
+
//#endregion
|
|
2020
|
+
//#region src/orchestration/integrator.ts
|
|
2021
|
+
function needsIntegrator(taskCount, hasWorktreePatch, crossTaskWiring, conflict) {
|
|
2022
|
+
return !(taskCount === 1 && !hasWorktreePatch && !crossTaskWiring && !conflict);
|
|
2023
|
+
}
|
|
2024
|
+
function verifyPatch(patch) {
|
|
2025
|
+
const { sha256, ...raw } = patch;
|
|
2026
|
+
if (createHash("sha256").update(JSON.stringify(raw)).digest("hex") !== sha256) throw new Error(`patch artifact hash mismatch for ${patch.taskId}`);
|
|
2027
|
+
for (const f of patch.files) if (f.kind === "file") {
|
|
2028
|
+
const bytes = Buffer.from(f.contentBase64 ?? "", "base64");
|
|
2029
|
+
if (createHash("sha256").update(bytes).digest("hex") !== f.sha256) throw new Error(`patch file hash mismatch: ${f.path}`);
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
function assertSymlinkTarget(root, path, target) {
|
|
2033
|
+
if (!target) throw new Error(`empty symlink target: ${path}`);
|
|
2034
|
+
const b = resolve(root), resolved = resolve(dirname(join(b, path)), target);
|
|
2035
|
+
if (resolved !== b && !resolved.startsWith(b + sep)) throw new Error(`symlink target escapes repository: ${path} -> ${target}`);
|
|
2036
|
+
}
|
|
2037
|
+
async function applyPatchArtifact(root, patch, unionOwnership) {
|
|
2038
|
+
verifyPatch(patch);
|
|
2039
|
+
const paths = patch.files.map((f) => f.path);
|
|
2040
|
+
assertOwnedPaths(paths, unionOwnership);
|
|
2041
|
+
await assertRepoPathsConfined(root, paths);
|
|
2042
|
+
for (const f of patch.files) {
|
|
2043
|
+
const target = join(root, f.path);
|
|
2044
|
+
if (f.kind === "delete") {
|
|
2045
|
+
await rm(target, { force: true });
|
|
2046
|
+
continue;
|
|
2047
|
+
}
|
|
2048
|
+
await mkdir(dirname(target), { recursive: true });
|
|
2049
|
+
if (f.kind === "symlink") {
|
|
2050
|
+
assertSymlinkTarget(root, f.path, f.target);
|
|
2051
|
+
await rm(target, { force: true });
|
|
2052
|
+
await symlink(f.target, target);
|
|
2053
|
+
continue;
|
|
2054
|
+
}
|
|
2055
|
+
await writeFile(target, Buffer.from(f.contentBase64, "base64"));
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
function integratorPrompt(planJson, handoffs, patchLocators) {
|
|
2059
|
+
return `Role: Integrator\nDo not redesign or expand scope. You may modify only union ownership. Account for every accepted Worker patch; missing/ambiguous artifacts are BLOCKED. Never commit/push/reset/clean.\n\nAuthoritative PlanArtifact:\n${planJson}\n\nBounded handoff:\n${handoffs.join("\n")}\n\nPatch locators:\n${patchLocators.join("\n")}`;
|
|
2060
|
+
}
|
|
2061
|
+
//#endregion
|
|
2062
|
+
//#region src/orchestration/scheduler.ts
|
|
2063
|
+
function buildWaves(plan, maxParallel = 3, mode = "auto", platform = process.platform) {
|
|
2064
|
+
new Map(plan.tasks.map((t) => [t.id, t]));
|
|
2065
|
+
const done = /* @__PURE__ */ new Set(), remaining = new Set(plan.tasks.map((t) => t.id)), waves = [];
|
|
2066
|
+
while (remaining.size) {
|
|
2067
|
+
const ready = plan.tasks.filter((t) => remaining.has(t.id) && t.dependsOn.every((d) => done.has(d)));
|
|
2068
|
+
if (!ready.length) throw new Error("scheduler deadlock");
|
|
2069
|
+
if (mode !== "serial" && maxParallel > 1) {
|
|
2070
|
+
const candidates = ready.filter((t) => t.parallelSafe);
|
|
2071
|
+
const selected = [];
|
|
2072
|
+
for (const t of candidates) {
|
|
2073
|
+
if (selected.length >= maxParallel) break;
|
|
2074
|
+
if (selected.every((s) => disjointOwnership(s.modify, t.modify, platform))) selected.push(t);
|
|
2075
|
+
}
|
|
2076
|
+
if (selected.length >= 2) {
|
|
2077
|
+
const ids = selected.map((t) => t.id);
|
|
2078
|
+
waves.push({
|
|
2079
|
+
mode: "parallel",
|
|
2080
|
+
taskIds: ids
|
|
2081
|
+
});
|
|
2082
|
+
for (const id of ids) {
|
|
2083
|
+
remaining.delete(id);
|
|
2084
|
+
done.add(id);
|
|
2085
|
+
}
|
|
2086
|
+
continue;
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
const t = ready[0];
|
|
2090
|
+
waves.push({
|
|
2091
|
+
mode: "serial",
|
|
2092
|
+
taskIds: [t.id]
|
|
2093
|
+
});
|
|
2094
|
+
remaining.delete(t.id);
|
|
2095
|
+
done.add(t.id);
|
|
2096
|
+
}
|
|
2097
|
+
return waves;
|
|
2098
|
+
}
|
|
2099
|
+
function recheckWave(plan, wave, platform = process.platform) {
|
|
2100
|
+
if (wave.mode === "serial") return true;
|
|
2101
|
+
const tasks = wave.taskIds.map((id) => plan.tasks.find((t) => t.id === id));
|
|
2102
|
+
return tasks.length >= 2 && tasks.every((t) => t.parallelSafe) && tasks.every((t, i) => tasks.slice(i + 1).every((o) => disjointOwnership(t.modify, o.modify, platform))) && tasks.every((t) => t.dependsOn.every((d) => !wave.taskIds.includes(d)));
|
|
2103
|
+
}
|
|
2104
|
+
//#endregion
|
|
2105
|
+
//#region src/orchestration/task-packet.ts
|
|
2106
|
+
function buildTaskPacket(plan, task, dependencyHandoff = []) {
|
|
2107
|
+
return [
|
|
2108
|
+
`Role: Worker`,
|
|
2109
|
+
`Task ID: ${task.id}`,
|
|
2110
|
+
`Objective: ${task.objective}`,
|
|
2111
|
+
`Global Decision Locks:\n${plan.decisionLocks.map((x) => `- ${x}`).join("\n") || "- none"}`,
|
|
2112
|
+
`Task Decision Locks:\n${task.decisionLocks.map((x) => `- ${x}`).join("\n") || "- none"}`,
|
|
2113
|
+
`Prioritized read targets (start here; expand only for a concrete blocker):\n${task.read.map((x) => `- ${x}`).join("\n") || "- none"}`,
|
|
2114
|
+
`Exact modify ownership (MUST NOT mutate outside):\n${task.modify.map((x) => `- ${x}`).join("\n")}`,
|
|
2115
|
+
`Required changes:\n${task.requiredChanges.map((x) => `- ${x}`).join("\n")}`,
|
|
2116
|
+
`Acceptance criteria:\n${task.acceptanceCriteria.map((x) => `- ${x}`).join("\n")}`,
|
|
2117
|
+
`Validation requested:\n${task.validation.map((x) => `- ${x}`).join("\n") || "- host validation follows"}`,
|
|
2118
|
+
`Dependency handoff:\n${dependencyHandoff.map((x) => `- ${x}`).join("\n") || "- none"}`,
|
|
2119
|
+
`Context expansion: if you need files outside initial read targets, record a concrete blocker and expansion reason. Do not read the Planner conversation.`,
|
|
2120
|
+
`Return only the configured structured completion contract.`
|
|
2121
|
+
].join("\n\n");
|
|
2122
|
+
}
|
|
2123
|
+
//#endregion
|
|
2124
|
+
//#region src/contract/role-result.ts
|
|
2125
|
+
const ROLE_RESULT_SCHEMA = {
|
|
2126
|
+
type: "object",
|
|
2127
|
+
additionalProperties: false,
|
|
2128
|
+
properties: {
|
|
2129
|
+
taskId: {
|
|
2130
|
+
type: "string",
|
|
2131
|
+
maxLength: 200
|
|
2132
|
+
},
|
|
2133
|
+
status: {
|
|
2134
|
+
type: "string",
|
|
2135
|
+
enum: [
|
|
2136
|
+
"COMPLETE",
|
|
2137
|
+
"BLOCKED",
|
|
2138
|
+
"FAILED"
|
|
2139
|
+
]
|
|
2140
|
+
},
|
|
2141
|
+
changed: {
|
|
2142
|
+
type: "array",
|
|
2143
|
+
items: {
|
|
2144
|
+
type: "string",
|
|
2145
|
+
maxLength: 512
|
|
2146
|
+
},
|
|
2147
|
+
maxItems: 200
|
|
2148
|
+
},
|
|
2149
|
+
validation: {
|
|
2150
|
+
type: "array",
|
|
2151
|
+
items: {
|
|
2152
|
+
type: "object",
|
|
2153
|
+
additionalProperties: false,
|
|
2154
|
+
properties: {
|
|
2155
|
+
id: {
|
|
2156
|
+
type: "string",
|
|
2157
|
+
maxLength: 200
|
|
2158
|
+
},
|
|
2159
|
+
status: {
|
|
2160
|
+
type: "string",
|
|
2161
|
+
enum: [
|
|
2162
|
+
"PASS",
|
|
2163
|
+
"FAIL",
|
|
2164
|
+
"INCONCLUSIVE"
|
|
2165
|
+
]
|
|
2166
|
+
},
|
|
2167
|
+
detail: {
|
|
2168
|
+
type: "string",
|
|
2169
|
+
maxLength: 2e3
|
|
2170
|
+
}
|
|
2171
|
+
},
|
|
2172
|
+
required: ["id", "status"]
|
|
2173
|
+
},
|
|
2174
|
+
maxItems: 30
|
|
2175
|
+
},
|
|
2176
|
+
remaining: {
|
|
2177
|
+
type: "array",
|
|
2178
|
+
items: {
|
|
2179
|
+
type: "string",
|
|
2180
|
+
maxLength: 1e3
|
|
2181
|
+
},
|
|
2182
|
+
maxItems: 30
|
|
2183
|
+
},
|
|
2184
|
+
contextExpansion: {
|
|
2185
|
+
type: "array",
|
|
2186
|
+
items: {
|
|
2187
|
+
type: "string",
|
|
2188
|
+
maxLength: 1e3
|
|
2189
|
+
},
|
|
2190
|
+
maxItems: 30
|
|
2191
|
+
}
|
|
2192
|
+
},
|
|
2193
|
+
required: [
|
|
2194
|
+
"taskId",
|
|
2195
|
+
"status",
|
|
2196
|
+
"changed",
|
|
2197
|
+
"validation",
|
|
2198
|
+
"remaining",
|
|
2199
|
+
"contextExpansion"
|
|
2200
|
+
]
|
|
2201
|
+
};
|
|
2202
|
+
const KEYS = /* @__PURE__ */ new Set([
|
|
2203
|
+
"taskId",
|
|
2204
|
+
"status",
|
|
2205
|
+
"changed",
|
|
2206
|
+
"validation",
|
|
2207
|
+
"remaining",
|
|
2208
|
+
"contextExpansion"
|
|
2209
|
+
]);
|
|
2210
|
+
const VKEYS = /* @__PURE__ */ new Set([
|
|
2211
|
+
"id",
|
|
2212
|
+
"status",
|
|
2213
|
+
"detail"
|
|
2214
|
+
]);
|
|
2215
|
+
function boundedStrings(value, name, maxItems, maxChars) {
|
|
2216
|
+
if (!Array.isArray(value) || value.length > maxItems) throw new Error(`${name} invalid`);
|
|
2217
|
+
for (const item of value) if (typeof item !== "string" || item.length > maxChars) throw new Error(`${name} invalid`);
|
|
2218
|
+
return value;
|
|
2219
|
+
}
|
|
2220
|
+
function validateRoleResult(value, taskId) {
|
|
2221
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("role result must be an object");
|
|
2222
|
+
const x = value;
|
|
2223
|
+
const unknown = Object.keys(x).filter((k) => !KEYS.has(k));
|
|
2224
|
+
if (unknown.length) throw new Error(`role result unknown fields: ${unknown.join(", ")}`);
|
|
2225
|
+
if (x.taskId !== taskId || typeof x.taskId !== "string" || x.taskId.length > 200) throw new Error("role taskId mismatch");
|
|
2226
|
+
if (![
|
|
2227
|
+
"COMPLETE",
|
|
2228
|
+
"BLOCKED",
|
|
2229
|
+
"FAILED"
|
|
2230
|
+
].includes(String(x.status))) throw new Error("role status invalid");
|
|
2231
|
+
boundedStrings(x.changed, "changed", 200, 512);
|
|
2232
|
+
boundedStrings(x.remaining, "remaining", 30, 1e3);
|
|
2233
|
+
boundedStrings(x.contextExpansion, "contextExpansion", 30, 1e3);
|
|
2234
|
+
if (!Array.isArray(x.validation) || x.validation.length > 30) throw new Error("validation invalid");
|
|
2235
|
+
for (const item of x.validation) {
|
|
2236
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("validation invalid");
|
|
2237
|
+
const v = item;
|
|
2238
|
+
if (Object.keys(v).some((k) => !VKEYS.has(k)) || typeof v.id !== "string" || v.id.length > 200 || ![
|
|
2239
|
+
"PASS",
|
|
2240
|
+
"FAIL",
|
|
2241
|
+
"INCONCLUSIVE"
|
|
2242
|
+
].includes(String(v.status)) || v.detail !== void 0 && (typeof v.detail !== "string" || v.detail.length > 2e3)) throw new Error("validation invalid");
|
|
2243
|
+
}
|
|
2244
|
+
const encoded = JSON.stringify(value);
|
|
2245
|
+
if (Buffer.byteLength(encoded, "utf8") > 128 * 1024) throw new Error("role result exceeds 128KiB");
|
|
2246
|
+
return value;
|
|
2247
|
+
}
|
|
2248
|
+
//#endregion
|
|
2249
|
+
//#region src/telemetry/usage.ts
|
|
2250
|
+
const num = (v) => Number.isFinite(v) ? Number(v) : void 0;
|
|
2251
|
+
function usageFromSession(ctx, role, session, route) {
|
|
2252
|
+
try {
|
|
2253
|
+
const state = ctx.sessionProjections?.stateOf?.(session, "tokenUsage");
|
|
2254
|
+
const totals = state?.totals ?? state;
|
|
2255
|
+
const uncached = num(totals?.uncachedInputTokens), read = num(totals?.cacheReadTokens), write = num(totals?.cacheWriteTokens), output = num(totals?.outputTokens);
|
|
2256
|
+
let pressure;
|
|
2257
|
+
try {
|
|
2258
|
+
pressure = num(ctx.get?.("tokenMeter")?.measure?.(session)?.totalTokens);
|
|
2259
|
+
} catch {}
|
|
2260
|
+
if ([
|
|
2261
|
+
uncached,
|
|
2262
|
+
read,
|
|
2263
|
+
write,
|
|
2264
|
+
output
|
|
2265
|
+
].every((v) => typeof v === "number")) return {
|
|
2266
|
+
role,
|
|
2267
|
+
uncachedInput: uncached,
|
|
2268
|
+
cacheRead: read,
|
|
2269
|
+
cacheWrite: write,
|
|
2270
|
+
output,
|
|
2271
|
+
input: uncached + read + write,
|
|
2272
|
+
provider: route?.provider,
|
|
2273
|
+
model: route?.model,
|
|
2274
|
+
pressureTokens: pressure,
|
|
2275
|
+
source: "dsh-token-projection"
|
|
2276
|
+
};
|
|
2277
|
+
} catch {}
|
|
2278
|
+
return {
|
|
2279
|
+
role,
|
|
2280
|
+
provider: route?.provider,
|
|
2281
|
+
model: route?.model,
|
|
2282
|
+
source: "unavailable"
|
|
2283
|
+
};
|
|
2284
|
+
}
|
|
2285
|
+
function usageInEvent(event) {
|
|
2286
|
+
if (event?.type === "assistant/message" && event.data?.usage) return event.data.usage;
|
|
2287
|
+
const stream = event?.data?.stream;
|
|
2288
|
+
if (!Array.isArray(stream)) return void 0;
|
|
2289
|
+
for (let i = stream.length - 1; i >= 0; i--) {
|
|
2290
|
+
const u = stream[i]?.usage ?? stream[i]?.chunk?.usage;
|
|
2291
|
+
if (u) return u;
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
function usageFromSdkEvents(role, events, route) {
|
|
2295
|
+
let uncached = 0, read = 0, write = 0, output = 0, samples = 0;
|
|
2296
|
+
const seen = /* @__PURE__ */ new Map();
|
|
2297
|
+
for (const event of events) {
|
|
2298
|
+
if (event?.type !== "assistant/message" && event?.type !== "assistant/attempt" && event?.type !== "llm/retry-started") continue;
|
|
2299
|
+
if (event.type === "llm/retry-started") {
|
|
2300
|
+
seen.delete(`${event.data?.turn}:${event.data?.step}`);
|
|
2301
|
+
continue;
|
|
2302
|
+
}
|
|
2303
|
+
const u = usageInEvent(event);
|
|
2304
|
+
if (!u) continue;
|
|
2305
|
+
const key = `${event.data?.turn}:${event.data?.step}`, next = {
|
|
2306
|
+
uncached: Number(u.inputTokens ?? 0),
|
|
2307
|
+
read: Number(u.cacheReadTokens ?? 0),
|
|
2308
|
+
write: Number(u.cacheWriteTokens ?? 0),
|
|
2309
|
+
output: Number(u.outputTokens ?? 0)
|
|
2310
|
+
}, prev = seen.get(key);
|
|
2311
|
+
if (prev) {
|
|
2312
|
+
uncached -= prev.uncached;
|
|
2313
|
+
read -= prev.read;
|
|
2314
|
+
write -= prev.write;
|
|
2315
|
+
output -= prev.output;
|
|
2316
|
+
} else samples++;
|
|
2317
|
+
uncached += next.uncached;
|
|
2318
|
+
read += next.read;
|
|
2319
|
+
write += next.write;
|
|
2320
|
+
output += next.output;
|
|
2321
|
+
seen.set(key, next);
|
|
2322
|
+
}
|
|
2323
|
+
return samples ? {
|
|
2324
|
+
role,
|
|
2325
|
+
uncachedInput: uncached,
|
|
2326
|
+
cacheRead: read,
|
|
2327
|
+
cacheWrite: write,
|
|
2328
|
+
output,
|
|
2329
|
+
input: uncached + read + write,
|
|
2330
|
+
turns: samples,
|
|
2331
|
+
provider: route?.provider,
|
|
2332
|
+
model: route?.model,
|
|
2333
|
+
source: "sdk-events"
|
|
2334
|
+
} : {
|
|
2335
|
+
role,
|
|
2336
|
+
provider: route?.provider,
|
|
2337
|
+
model: route?.model,
|
|
2338
|
+
source: "unavailable"
|
|
2339
|
+
};
|
|
2340
|
+
}
|
|
2341
|
+
//#endregion
|
|
2342
|
+
//#region src/runtime-policy.ts
|
|
2343
|
+
let timeoutResolver = () => 9e5;
|
|
2344
|
+
function configureRoleTimeoutResolver(resolver) {
|
|
2345
|
+
const previous = timeoutResolver;
|
|
2346
|
+
timeoutResolver = resolver;
|
|
2347
|
+
return () => {
|
|
2348
|
+
if (timeoutResolver === resolver) timeoutResolver = previous;
|
|
2349
|
+
};
|
|
2350
|
+
}
|
|
2351
|
+
function roleTimeoutMs(cwd) {
|
|
2352
|
+
const value = timeoutResolver(cwd);
|
|
2353
|
+
if (!Number.isSafeInteger(value) || value < 1e3 || value > 36e5) throw new Error("invalid Plan Orchestrator role timeout");
|
|
2354
|
+
return value;
|
|
2355
|
+
}
|
|
2356
|
+
function withRoleTimeout(cwd, signal) {
|
|
2357
|
+
return AbortSignal.any([signal, AbortSignal.timeout(roleTimeoutMs(cwd))]);
|
|
2358
|
+
}
|
|
2359
|
+
//#endregion
|
|
2360
|
+
//#region src/orchestration/ownership-guard.ts
|
|
2361
|
+
const FILE_MUTATORS = /^(?:write|write_file|edit|edit_file|delete|delete_file|move|move_file|rename|rename_file|mkdir|make_directory|apply_patch|patch)$/i;
|
|
2362
|
+
const PATH_KEYS = /* @__PURE__ */ new Set([
|
|
2363
|
+
"path",
|
|
2364
|
+
"file",
|
|
2365
|
+
"filePath",
|
|
2366
|
+
"file_path",
|
|
2367
|
+
"target",
|
|
2368
|
+
"destination",
|
|
2369
|
+
"dest",
|
|
2370
|
+
"to",
|
|
2371
|
+
"source",
|
|
2372
|
+
"src",
|
|
2373
|
+
"from",
|
|
2374
|
+
"newPath",
|
|
2375
|
+
"new_path",
|
|
2376
|
+
"oldPath",
|
|
2377
|
+
"old_path"
|
|
2378
|
+
]);
|
|
2379
|
+
function patchPaths(text) {
|
|
2380
|
+
const out = [];
|
|
2381
|
+
for (const line of text.split(/\r?\n/)) {
|
|
2382
|
+
let match = /^\*\*\* (?:Update|Delete|Add) File:\s+(.+?)\s*$/.exec(line);
|
|
2383
|
+
if (!match) match = /^(?:\+\+\+|---)\s+(?:[ab]\/)?(.+?)\s*$/.exec(line);
|
|
2384
|
+
const value = match?.[1];
|
|
2385
|
+
if (value && value !== "/dev/null") out.push(value);
|
|
2386
|
+
}
|
|
2387
|
+
return out;
|
|
2388
|
+
}
|
|
2389
|
+
function mutationTargetCandidates(toolName, args) {
|
|
2390
|
+
if (!FILE_MUTATORS.test(toolName)) return void 0;
|
|
2391
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) return [];
|
|
2392
|
+
const record = args;
|
|
2393
|
+
const found = [];
|
|
2394
|
+
for (const [key, value] of Object.entries(record)) {
|
|
2395
|
+
if (PATH_KEYS.has(key)) {
|
|
2396
|
+
if (typeof value === "string") found.push(value);
|
|
2397
|
+
else if (Array.isArray(value)) found.push(...value.filter((item) => typeof item === "string"));
|
|
2398
|
+
}
|
|
2399
|
+
if ((key === "patch" || key === "diff" || key === "input") && typeof value === "string") found.push(...patchPaths(value));
|
|
2400
|
+
}
|
|
2401
|
+
return [...new Set(found.filter(Boolean))];
|
|
2402
|
+
}
|
|
2403
|
+
function toolPathToRepoRelative(root, raw) {
|
|
2404
|
+
if (raw.includes("\0")) throw new Error("tool target contains NUL");
|
|
2405
|
+
const absolute = isAbsolute(raw) ? resolve(raw) : resolve(root, raw);
|
|
2406
|
+
const rel = relative(resolve(root), absolute);
|
|
2407
|
+
if (!rel || rel === ".") throw new Error(`tool target is not an exact file: ${raw}`);
|
|
2408
|
+
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) throw new Error(`tool target escapes repository: ${raw}`);
|
|
2409
|
+
return normalizeOwnedPath(rel.split(sep).join("/"));
|
|
2410
|
+
}
|
|
2411
|
+
function ownershipGuardReason(root, allowed, toolName, args) {
|
|
2412
|
+
const candidates = mutationTargetCandidates(toolName, args);
|
|
2413
|
+
if (candidates === void 0) return void 0;
|
|
2414
|
+
if (candidates.length === 0) return `Plan Orchestrator ownership guard cannot identify target path for mutating tool ${toolName}`;
|
|
2415
|
+
const allowedSet = new Set(allowed.map((path) => schedulerPathIdentity(path)));
|
|
2416
|
+
try {
|
|
2417
|
+
for (const raw of candidates) {
|
|
2418
|
+
const relativePath = toolPathToRepoRelative(root, raw);
|
|
2419
|
+
if (!allowedSet.has(schedulerPathIdentity(relativePath))) return `Plan Orchestrator ownership violation: ${relativePath}`;
|
|
2420
|
+
}
|
|
2421
|
+
} catch (error) {
|
|
2422
|
+
return `Plan Orchestrator ownership violation: ${error.message}`;
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
/**
|
|
2426
|
+
* Install a monotonic guard before the one-shot child is created. A local
|
|
2427
|
+
* one-shot child records its direct parent in SessionHeader.parentSession, so
|
|
2428
|
+
* the guard can protect even the first tool call without waiting for start()
|
|
2429
|
+
* to return the child handle.
|
|
2430
|
+
*/
|
|
2431
|
+
function installChildOwnershipGuard(ctx, parent, root, allowed) {
|
|
2432
|
+
if (!ctx.tools?.guard) throw new Error("tools.guard unavailable for exact ownership enforcement");
|
|
2433
|
+
const parentId = String(parent.session.id);
|
|
2434
|
+
return ctx.tools.guard((exec) => {
|
|
2435
|
+
const agent = exec.agent;
|
|
2436
|
+
if (!agent || String(agent.session?.header?.parentSession ?? "") !== parentId || agent.session?.header?.origin !== "subagent") return void 0;
|
|
2437
|
+
return ownershipGuardReason(root, allowed, String(exec.name ?? ""), exec.arguments);
|
|
2438
|
+
});
|
|
2439
|
+
}
|
|
2440
|
+
//#endregion
|
|
2441
|
+
//#region src/orchestration/native-backend.ts
|
|
2442
|
+
const OWNERSHIP_SAFE_MUTATION_TOOLS = [
|
|
2443
|
+
"read",
|
|
2444
|
+
"glob",
|
|
2445
|
+
"grep",
|
|
2446
|
+
"lsp",
|
|
2447
|
+
"web_search",
|
|
2448
|
+
"web_fetch",
|
|
2449
|
+
"write",
|
|
2450
|
+
"write_file",
|
|
2451
|
+
"edit",
|
|
2452
|
+
"edit_file",
|
|
2453
|
+
"delete",
|
|
2454
|
+
"delete_file",
|
|
2455
|
+
"move",
|
|
2456
|
+
"move_file",
|
|
2457
|
+
"rename",
|
|
2458
|
+
"rename_file",
|
|
2459
|
+
"apply_patch",
|
|
2460
|
+
"patch"
|
|
2461
|
+
];
|
|
2462
|
+
var NativeSpawnBackend = class {
|
|
2463
|
+
ctx;
|
|
2464
|
+
constructor(ctx) {
|
|
2465
|
+
this.ctx = ctx;
|
|
2466
|
+
}
|
|
2467
|
+
async run(req) {
|
|
2468
|
+
const started = Date.now();
|
|
2469
|
+
const release = req.ownership ? installChildOwnershipGuard(this.ctx, req.parent, req.ownership.root, req.ownership.paths) : () => {};
|
|
2470
|
+
let run;
|
|
2471
|
+
try {
|
|
2472
|
+
const cwd = req.parent?.session?.header?.cwd;
|
|
2473
|
+
const signal = req.ownership ? withRoleTimeout(cwd, req.signal) : req.signal;
|
|
2474
|
+
const toolFilter = req.ownership ? { allow: OWNERSHIP_SAFE_MUTATION_TOOLS } : req.toolFilter;
|
|
2475
|
+
run = await this.ctx.subagents.start("spawn", {
|
|
2476
|
+
label: `${req.role}:${req.taskId}`,
|
|
2477
|
+
prompt: [{
|
|
2478
|
+
type: "text",
|
|
2479
|
+
text: req.prompt
|
|
2480
|
+
}],
|
|
2481
|
+
parent: req.parent,
|
|
2482
|
+
signal,
|
|
2483
|
+
agentOptions: req.route,
|
|
2484
|
+
outputSchema: ROLE_RESULT_SCHEMA,
|
|
2485
|
+
maxDepth: 1,
|
|
2486
|
+
toolFilter,
|
|
2487
|
+
persona: req.persona
|
|
2488
|
+
});
|
|
2489
|
+
const result = await run.result;
|
|
2490
|
+
if (result.stopReason !== "completed") throw new Error(`subagent ${req.taskId} stopped: ${result.stopReason}${result.diagnostic ? `: ${result.diagnostic}` : ""}`);
|
|
2491
|
+
const valid = validateRoleResult(result.structured, req.taskId);
|
|
2492
|
+
const usage = run.localAgent ? usageFromSession(this.ctx, req.role, run.localAgent, req.route) : {
|
|
2493
|
+
role: req.role,
|
|
2494
|
+
provider: req.route.provider,
|
|
2495
|
+
model: req.route.model,
|
|
2496
|
+
source: "unavailable"
|
|
2497
|
+
};
|
|
2498
|
+
usage.durationMs = Date.now() - started;
|
|
2499
|
+
return {
|
|
2500
|
+
...valid,
|
|
2501
|
+
__usage: usage
|
|
2502
|
+
};
|
|
2503
|
+
} finally {
|
|
2504
|
+
try {
|
|
2505
|
+
if (run) await run.dispose();
|
|
2506
|
+
} finally {
|
|
2507
|
+
release();
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
};
|
|
2512
|
+
//#endregion
|
|
2513
|
+
//#region src/orchestration/sdk-backend.ts
|
|
2514
|
+
function extractEnvelope(text) {
|
|
2515
|
+
const value = text.trim();
|
|
2516
|
+
if (!value.startsWith("{") || !value.endsWith("}")) throw new Error("SDK worker must return one strict JSON envelope");
|
|
2517
|
+
const parsed = JSON.parse(value);
|
|
2518
|
+
if (JSON.stringify(parsed).length > 128 * 1024) throw new Error("SDK worker JSON envelope exceeds 128KiB");
|
|
2519
|
+
return parsed;
|
|
2520
|
+
}
|
|
2521
|
+
/**
|
|
2522
|
+
* Keep only process/runtime facts plus provider credentials likely required by
|
|
2523
|
+
* the selected route. This intentionally does not serialize or persist the env.
|
|
2524
|
+
*/
|
|
2525
|
+
function minimalSdkEnv(source = process.env) {
|
|
2526
|
+
const exact = /* @__PURE__ */ new Set([
|
|
2527
|
+
"PATH",
|
|
2528
|
+
"Path",
|
|
2529
|
+
"PATHEXT",
|
|
2530
|
+
"SystemRoot",
|
|
2531
|
+
"SYSTEMROOT",
|
|
2532
|
+
"COMSPEC",
|
|
2533
|
+
"HOME",
|
|
2534
|
+
"USERPROFILE",
|
|
2535
|
+
"APPDATA",
|
|
2536
|
+
"LOCALAPPDATA",
|
|
2537
|
+
"TEMP",
|
|
2538
|
+
"TMP",
|
|
2539
|
+
"TMPDIR",
|
|
2540
|
+
"LANG",
|
|
2541
|
+
"LC_ALL",
|
|
2542
|
+
"NODE_OPTIONS",
|
|
2543
|
+
"HTTPS_PROXY",
|
|
2544
|
+
"HTTP_PROXY",
|
|
2545
|
+
"NO_PROXY",
|
|
2546
|
+
"SSL_CERT_FILE",
|
|
2547
|
+
"SSL_CERT_DIR",
|
|
2548
|
+
"DSH_HOME"
|
|
2549
|
+
]);
|
|
2550
|
+
const prefix = /^(DSH_|OPENAI_|DEEPSEEK_|ANTHROPIC_|GOOGLE_|GEMINI_|COMMANDCODE_|CODEX_|AZURE_)/i;
|
|
2551
|
+
const env = {};
|
|
2552
|
+
for (const [key, value] of Object.entries(source)) if (value !== void 0 && (exact.has(key) || prefix.test(key))) env[key] = value;
|
|
2553
|
+
return env;
|
|
2554
|
+
}
|
|
2555
|
+
function packagedProfilePath(role) {
|
|
2556
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
2557
|
+
return join(basename(here) === "lib" ? resolve(here, "..") : resolve(here, "../.."), "profiles", `${role}.cordis.yml`);
|
|
2558
|
+
}
|
|
2559
|
+
function sdkHarnessOptions(req) {
|
|
2560
|
+
const patches = req.patches ?? [packagedProfilePath(req.role ?? "worker")];
|
|
2561
|
+
return {
|
|
2562
|
+
profile: req.profile,
|
|
2563
|
+
patches,
|
|
2564
|
+
cwd: req.cwd,
|
|
2565
|
+
processCwd: req.cwd,
|
|
2566
|
+
provider: req.route.provider,
|
|
2567
|
+
model: req.route.model,
|
|
2568
|
+
reasoningEffort: req.route.reasoningEffort,
|
|
2569
|
+
maxTokens: req.route.maxTokens,
|
|
2570
|
+
env: req.env ?? minimalSdkEnv()
|
|
2571
|
+
};
|
|
2572
|
+
}
|
|
2573
|
+
var SdkWorkspaceBackend = class {
|
|
2574
|
+
async run(req) {
|
|
2575
|
+
const signal = withRoleTimeout(req.cwd, req.signal);
|
|
2576
|
+
signal.throwIfAborted();
|
|
2577
|
+
const { DeepSeekHarness } = await import("@deepseek-ai/dsh-sdk-client");
|
|
2578
|
+
const harness = new DeepSeekHarness(sdkHarnessOptions(req));
|
|
2579
|
+
const started = Date.now();
|
|
2580
|
+
let abortClose;
|
|
2581
|
+
const abortPromise = new Promise((_, reject) => {
|
|
2582
|
+
const onAbort = () => {
|
|
2583
|
+
harness.close().catch(() => {});
|
|
2584
|
+
reject(new DOMException("SDK worker aborted", "AbortError"));
|
|
2585
|
+
};
|
|
2586
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2587
|
+
abortClose = () => signal.removeEventListener("abort", onAbort);
|
|
2588
|
+
});
|
|
2589
|
+
try {
|
|
2590
|
+
const result = await Promise.race([harness.run(req.prompt), abortPromise]);
|
|
2591
|
+
const valid = validateRoleResult(extractEnvelope(result.finalResponse), req.taskId);
|
|
2592
|
+
const usage = usageFromSdkEvents("worker", result.events, req.route);
|
|
2593
|
+
usage.durationMs = Date.now() - started;
|
|
2594
|
+
return {
|
|
2595
|
+
...valid,
|
|
2596
|
+
__usage: usage
|
|
2597
|
+
};
|
|
2598
|
+
} finally {
|
|
2599
|
+
abortClose?.();
|
|
2600
|
+
await harness.close().catch(() => {});
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
};
|
|
2604
|
+
//#endregion
|
|
2605
|
+
//#region src/orchestration/role-router.ts
|
|
2606
|
+
function routeChoices(route, current) {
|
|
2607
|
+
return [route.mode === "current" ? current : {
|
|
2608
|
+
provider: route.provider,
|
|
2609
|
+
model: route.model,
|
|
2610
|
+
reasoningEffort: route.reasoningEffort,
|
|
2611
|
+
maxTokens: route.maxTokens
|
|
2612
|
+
}, ...route.fallbacks].filter((v, i, a) => v.provider && v.model && a.findIndex((x) => x.provider === v.provider && x.model === v.model && x.reasoningEffort === v.reasoningEffort) === i);
|
|
2613
|
+
}
|
|
2614
|
+
async function preflightRoute(llm, choice) {
|
|
2615
|
+
if (typeof llm?.resolveCallConfig !== "function") throw new Error("llm.resolveCallConfig unavailable");
|
|
2616
|
+
await llm.resolveCallConfig(choice);
|
|
2617
|
+
return choice;
|
|
2618
|
+
}
|
|
2619
|
+
//#endregion
|
|
2620
|
+
//#region src/validation/runner.ts
|
|
2621
|
+
let configuredShell;
|
|
2622
|
+
/** Bind validation to the current DSH shell seam. There is deliberately no
|
|
2623
|
+
* child_process fallback: missing sandboxed shell execution is release-blocking. */
|
|
2624
|
+
function configureValidationShell(shell) {
|
|
2625
|
+
const previous = configuredShell;
|
|
2626
|
+
configuredShell = shell;
|
|
2627
|
+
return () => {
|
|
2628
|
+
if (configuredShell === shell) configuredShell = previous;
|
|
2629
|
+
};
|
|
2630
|
+
}
|
|
2631
|
+
function safe(value) {
|
|
2632
|
+
return value.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 160);
|
|
2633
|
+
}
|
|
2634
|
+
async function assertExistingPackageScript(cwd, command) {
|
|
2635
|
+
const parsed = parseValidationCommand(command);
|
|
2636
|
+
let pkg;
|
|
2637
|
+
try {
|
|
2638
|
+
pkg = JSON.parse(await readFile(join(cwd, "package.json"), "utf8"));
|
|
2639
|
+
} catch (error) {
|
|
2640
|
+
throw new Error(`validation requires readable package.json: ${error.message}`);
|
|
2641
|
+
}
|
|
2642
|
+
if (!pkg?.scripts || typeof pkg.scripts[parsed.script] !== "string" || !pkg.scripts[parsed.script].trim()) throw new Error(`validation package script does not exist: ${parsed.script}`);
|
|
2643
|
+
}
|
|
2644
|
+
async function runValidation(opts) {
|
|
2645
|
+
const shell = opts.shell ?? configuredShell;
|
|
2646
|
+
if (!shell?.resolve || !shell?.run) throw new Error("sandboxed DSH shell executor unavailable for host validation");
|
|
2647
|
+
await assertExistingPackageScript(opts.cwd, opts.command);
|
|
2648
|
+
const before = await snapshotDirty(opts.cwd);
|
|
2649
|
+
const head = await fullHead(opts.cwd);
|
|
2650
|
+
const start = (/* @__PURE__ */ new Date()).toISOString();
|
|
2651
|
+
const sandboxPolicy = {
|
|
2652
|
+
mode: "workspace-write",
|
|
2653
|
+
workspaceRoot: opts.cwd,
|
|
2654
|
+
...opts.sessionId ? { sessionId: opts.sessionId } : {}
|
|
2655
|
+
};
|
|
2656
|
+
const spec = shell.resolve({
|
|
2657
|
+
command: opts.command,
|
|
2658
|
+
workdir: opts.cwd,
|
|
2659
|
+
timeoutMs: opts.timeoutMs,
|
|
2660
|
+
stdoutMaxBytes: opts.capBytes,
|
|
2661
|
+
sandboxPolicy
|
|
2662
|
+
});
|
|
2663
|
+
const result = await shell.run(spec);
|
|
2664
|
+
const stdout = Buffer.from(String(result?.stdout?.text ?? ""), "utf8");
|
|
2665
|
+
const stderr = Buffer.from(String(result?.stderr?.text ?? ""), "utf8");
|
|
2666
|
+
const stdoutTruncated = Boolean(result?.stdout?.truncated);
|
|
2667
|
+
const stderrTruncated = Boolean(result?.stderr?.truncated);
|
|
2668
|
+
const timedOut = Boolean(result?.timedOut);
|
|
2669
|
+
const aborted = Boolean(result?.aborted);
|
|
2670
|
+
const exitCode = typeof result?.exitCode === "number" ? result.exitCode : null;
|
|
2671
|
+
const sandbox = result?.sandbox;
|
|
2672
|
+
const sandboxIncomplete = !sandbox || sandbox.runnerFailed === true || sandbox.enforcement !== "full";
|
|
2673
|
+
const sandboxDenied = Boolean(sandbox?.denied);
|
|
2674
|
+
const validationDir = join(opts.runDir, "validation");
|
|
2675
|
+
await mkdir(validationDir, { recursive: true });
|
|
2676
|
+
const prefix = `${safe(opts.phase)}-${safe(opts.runId)}-${safe(opts.commandId)}`;
|
|
2677
|
+
const stdoutPath = join(validationDir, `${prefix}.stdout.log`);
|
|
2678
|
+
const stderrPath = join(validationDir, `${prefix}.stderr.log`);
|
|
2679
|
+
await Promise.all([writeFile(stdoutPath, stdout), writeFile(stderrPath, stderr)]);
|
|
2680
|
+
const after = await snapshotDirty(opts.cwd);
|
|
2681
|
+
const mutated = snapshotHash(before) !== snapshotHash(after);
|
|
2682
|
+
const status = sandboxDenied || mutated ? "UNSAFE_MUTATION" : sandboxIncomplete || stdoutTruncated || stderrTruncated || timedOut || aborted ? "INCONCLUSIVE" : exitCode === 0 ? "PASS" : "FAIL";
|
|
2683
|
+
return {
|
|
2684
|
+
schemaVersion: 1,
|
|
2685
|
+
runId: opts.runId,
|
|
2686
|
+
phase: opts.phase,
|
|
2687
|
+
commandId: opts.commandId,
|
|
2688
|
+
command: opts.command,
|
|
2689
|
+
start,
|
|
2690
|
+
end: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2691
|
+
timeoutMs: opts.timeoutMs,
|
|
2692
|
+
exitCode: timedOut || aborted ? null : exitCode,
|
|
2693
|
+
status,
|
|
2694
|
+
stdout: {
|
|
2695
|
+
path: stdoutPath,
|
|
2696
|
+
sha256: hashBytes(stdout),
|
|
2697
|
+
bytes: stdout.length,
|
|
2698
|
+
truncated: stdoutTruncated
|
|
2699
|
+
},
|
|
2700
|
+
stderr: {
|
|
2701
|
+
path: stderrPath,
|
|
2702
|
+
sha256: hashBytes(stderr),
|
|
2703
|
+
bytes: stderr.length,
|
|
2704
|
+
truncated: stderrTruncated
|
|
2705
|
+
},
|
|
2706
|
+
boundHead: head,
|
|
2707
|
+
ownershipFingerprint: opts.ownershipFingerprint ?? snapshotHash(after),
|
|
2708
|
+
complete: !sandboxIncomplete && !sandboxDenied && !timedOut && !aborted && !stdoutTruncated && !stderrTruncated
|
|
2709
|
+
};
|
|
2710
|
+
}
|
|
2711
|
+
//#endregion
|
|
2712
|
+
//#region src/orchestration/reviewer.ts
|
|
2713
|
+
function parseReviewVerdict(text) {
|
|
2714
|
+
const first = text.split(/\r?\n/).find((l) => l.trim())?.trim();
|
|
2715
|
+
if ([...text.matchAll(/\[REVIEW:(PASS|FAIL)\]/g)].map((m) => m[1]).length !== 1 || !first || !/^\[REVIEW:(PASS|FAIL)\]$/.test(first)) return {
|
|
2716
|
+
kind: "PROTOCOL_INVALID",
|
|
2717
|
+
detail: "Reviewer must emit exactly one leading verdict marker."
|
|
2718
|
+
};
|
|
2719
|
+
return first === "[REVIEW:PASS]" ? {
|
|
2720
|
+
kind: "PASS",
|
|
2721
|
+
detail: text
|
|
2722
|
+
} : {
|
|
2723
|
+
kind: "FAIL",
|
|
2724
|
+
detail: text
|
|
2725
|
+
};
|
|
2726
|
+
}
|
|
2727
|
+
const REVIEW_CONTRACT = `You are an independent read-only Reviewer. Do not trust Worker/Integrator self-reports. Review the authoritative plan, actual current diff, owned-path state, trusted validation receipts, and only necessary fresh semantic evidence. Check requirement fidelity, Decision Locks, scope integrity, ownership, correctness, compatibility, regression, security/data risk, validation sufficiency, planned task completeness, unrequested changes, and the actual diff. First non-blank line must be exactly [REVIEW:PASS] or [REVIEW:FAIL]. Emit exactly one marker. On FAIL, identify only concrete Plan-required defects suitable for a targeted fix.`;
|
|
2728
|
+
//#endregion
|
|
2729
|
+
//#region src/telemetry/aggregate.ts
|
|
2730
|
+
function aggregateUsage(samples) {
|
|
2731
|
+
const sum = (k) => {
|
|
2732
|
+
const vals = samples.map((s) => s[k]).filter((v) => typeof v === "number");
|
|
2733
|
+
return vals.length && vals.length === samples.length ? vals.reduce((a, b) => a + b, 0) : void 0;
|
|
2734
|
+
};
|
|
2735
|
+
return {
|
|
2736
|
+
input: sum("input"),
|
|
2737
|
+
uncachedInput: sum("uncachedInput"),
|
|
2738
|
+
cacheRead: sum("cacheRead"),
|
|
2739
|
+
cacheWrite: sum("cacheWrite"),
|
|
2740
|
+
output: sum("output"),
|
|
2741
|
+
turns: sum("turns"),
|
|
2742
|
+
durationMs: sum("durationMs"),
|
|
2743
|
+
costUsd: void 0,
|
|
2744
|
+
samples
|
|
2745
|
+
};
|
|
2746
|
+
}
|
|
2747
|
+
//#endregion
|
|
2748
|
+
//#region src/orchestration/engine.ts
|
|
2749
|
+
const unionOwnership = (plan) => [...new Set(plan.tasks.flatMap((task) => task.modify))].sort();
|
|
2750
|
+
const currentRoute = (agent) => ({
|
|
2751
|
+
provider: agent.options?.provider,
|
|
2752
|
+
model: agent.options?.model,
|
|
2753
|
+
reasoningEffort: agent.options?.reasoningEffort,
|
|
2754
|
+
maxTokens: agent.options?.maxTokens
|
|
2755
|
+
});
|
|
2756
|
+
async function resolvedChoices(ctx, settings, role, agent) {
|
|
2757
|
+
const candidates = routeChoices(settings.roles[role], currentRoute(agent));
|
|
2758
|
+
const good = [];
|
|
2759
|
+
const failures = [];
|
|
2760
|
+
for (const choice of candidates) try {
|
|
2761
|
+
good.push(await preflightRoute(ctx.llm, choice));
|
|
2762
|
+
} catch (error) {
|
|
2763
|
+
failures.push(`${choice.provider}/${choice.model}: ${error.message}`);
|
|
2764
|
+
}
|
|
2765
|
+
if (good.length === 0) throw new Error(`no valid ${role} route${failures.length ? ` (${failures.join("; ")})` : ""}`);
|
|
2766
|
+
return good;
|
|
2767
|
+
}
|
|
2768
|
+
function continuationPacket(base, error) {
|
|
2769
|
+
return `${base}\n\nCONTINUATION AFTER PROVIDER/TRANSPORT FAILURE:\nThe previous attempt may have mutated the working tree. First inspect the actual current tree. Preserve valid existing changes, complete only remaining Plan-required work, and never reset/checkout/clean/stash or blindly replay the task. Previous failure: ${String(error?.code ?? error?.message ?? error)}`;
|
|
2770
|
+
}
|
|
2771
|
+
/**
|
|
2772
|
+
* Fresh fallbacks are allowed until mutation occurs. Once any run-owned mutation
|
|
2773
|
+
* is observed, exactly one continuation attempt on the next route is allowed.
|
|
2774
|
+
*/
|
|
2775
|
+
async function runMutationRole(root, allowed, routes, run) {
|
|
2776
|
+
const logicalBaseline = await snapshotDirty(root);
|
|
2777
|
+
let lastError;
|
|
2778
|
+
for (let index = 0; index < routes.length; index++) try {
|
|
2779
|
+
return await run(routes[index], false, lastError);
|
|
2780
|
+
} catch (error) {
|
|
2781
|
+
lastError = error;
|
|
2782
|
+
if (!eligibleTransportFailure(error)) throw error;
|
|
2783
|
+
const changed = deltaPaths(logicalBaseline, await snapshotDirty(root));
|
|
2784
|
+
assertOwnedPaths(changed, allowed);
|
|
2785
|
+
if (changed.length > 0) {
|
|
2786
|
+
if (index + 1 >= routes.length) throw error;
|
|
2787
|
+
return run(routes[index + 1], true, error);
|
|
2788
|
+
}
|
|
2789
|
+
if (index + 1 >= routes.length) throw error;
|
|
2790
|
+
}
|
|
2791
|
+
throw lastError;
|
|
2792
|
+
}
|
|
2793
|
+
async function updateCheckpoint(store, launch, runId, root, head, ownership, phase, options = {}) {
|
|
2794
|
+
const snapshot = await snapshotDirty(root);
|
|
2795
|
+
const checkpoint = {
|
|
2796
|
+
schemaVersion: 1,
|
|
2797
|
+
head,
|
|
2798
|
+
ownership: [...ownership].sort(),
|
|
2799
|
+
changedPaths: Object.keys(snapshot.paths).sort(),
|
|
2800
|
+
fingerprint: snapshotHash(snapshot),
|
|
2801
|
+
phase,
|
|
2802
|
+
role: options.role,
|
|
2803
|
+
completedTaskIds: options.completedTaskIds ?? [],
|
|
2804
|
+
safeBoundary: options.safeBoundary ?? false,
|
|
2805
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2806
|
+
};
|
|
2807
|
+
await atomicJson(join(store.runDir(launch.sessionId, runId), "recovery.json"), checkpoint);
|
|
2808
|
+
const manifest = await store.readManifest(launch.sessionId, runId);
|
|
2809
|
+
manifest.phase = phase;
|
|
2810
|
+
manifest.repoRoot = root;
|
|
2811
|
+
manifest.baselineHead = head;
|
|
2812
|
+
manifest.ownership = [...ownership].sort();
|
|
2813
|
+
manifest.completedTaskIds = checkpoint.completedTaskIds;
|
|
2814
|
+
await store.writeManifest(manifest);
|
|
2815
|
+
return checkpoint;
|
|
2816
|
+
}
|
|
2817
|
+
async function writeWorktreeManifest(runDir, records) {
|
|
2818
|
+
await atomicJson(join(runDir, "worktrees.json"), {
|
|
2819
|
+
schemaVersion: 1,
|
|
2820
|
+
worktrees: records
|
|
2821
|
+
});
|
|
2822
|
+
}
|
|
2823
|
+
function dirtyOwnershipConflict(runBaseline, tasks) {
|
|
2824
|
+
const dirty = new Set(Object.keys(runBaseline.paths));
|
|
2825
|
+
return tasks.some((task) => task.modify.some((path) => dirty.has(path)));
|
|
2826
|
+
}
|
|
2827
|
+
async function boundedTextDiff(root, paths, preExisting, capBytes = 256 * 1024) {
|
|
2828
|
+
const cleanPaths = paths.filter((path) => !preExisting.includes(path));
|
|
2829
|
+
const dirtyPaths = paths.filter((path) => preExisting.includes(path));
|
|
2830
|
+
let text = "";
|
|
2831
|
+
if (cleanPaths.length > 0) {
|
|
2832
|
+
const raw = decodeUtf8Strict((await git(root, [
|
|
2833
|
+
"diff",
|
|
2834
|
+
"--no-ext-diff",
|
|
2835
|
+
"--unified=3",
|
|
2836
|
+
"HEAD",
|
|
2837
|
+
"--",
|
|
2838
|
+
...cleanPaths
|
|
2839
|
+
], { maxBuffer: capBytes * 2 })).stdout);
|
|
2840
|
+
text += raw.length > capBytes ? `${raw.slice(0, capBytes)}\n[diff truncated by host]\n` : raw;
|
|
2841
|
+
}
|
|
2842
|
+
if (dirtyPaths.length > 0) text += `\nPre-existing dirty paths changed during this run; HEAD diff is not a clean attribution for these paths. Inspect current contents and the host fingerprints instead:\n${dirtyPaths.map((path) => `- ${path}`).join("\n")}\n`;
|
|
2843
|
+
return text || "(no textual diff; binary/new/deleted paths may still be present)";
|
|
2844
|
+
}
|
|
2845
|
+
function reviewerPrompt(plan, changed, preExisting, receipts, actualDiff) {
|
|
2846
|
+
return `${REVIEW_CONTRACT}\n\nAuthoritative PlanArtifact:\n${JSON.stringify(plan, null, 2)}\n\nRun-owned changed paths:\n${changed.map((path) => `- ${path}`).join("\n") || "- none"}\n\nPre-existing dirty paths (not attributable to this run unless their fingerprint changed after the run baseline):\n${preExisting.map((path) => `- ${path}`).join("\n") || "- none"}\n\nHost-observed actual diff/evidence:\n${actualDiff}\n\nTrusted validation receipt index:\n${JSON.stringify(receipts, null, 2)}\n\nUse read-only tools only when additional current-file evidence is necessary.`;
|
|
2847
|
+
}
|
|
2848
|
+
async function nativeReviewer(ctx, parent, prompt, routes, signal, label) {
|
|
2849
|
+
let last;
|
|
2850
|
+
for (let index = 0; index < routes.length; index++) {
|
|
2851
|
+
const route = routes[index];
|
|
2852
|
+
const started = Date.now();
|
|
2853
|
+
try {
|
|
2854
|
+
const run = await ctx.subagents.start("spawn", {
|
|
2855
|
+
label: `${label}-${index}`,
|
|
2856
|
+
prompt: [{
|
|
2857
|
+
type: "text",
|
|
2858
|
+
text: prompt
|
|
2859
|
+
}],
|
|
2860
|
+
parent,
|
|
2861
|
+
signal,
|
|
2862
|
+
agentOptions: route,
|
|
2863
|
+
maxDepth: 1,
|
|
2864
|
+
toolFilter: { allow: [
|
|
2865
|
+
"read",
|
|
2866
|
+
"glob",
|
|
2867
|
+
"grep",
|
|
2868
|
+
"lsp",
|
|
2869
|
+
"web_search",
|
|
2870
|
+
"web_fetch"
|
|
2871
|
+
] },
|
|
2872
|
+
persona: "Independent Reviewer. Strictly read-only. Review host evidence and current files; never mutate repository or external state."
|
|
2873
|
+
});
|
|
2874
|
+
try {
|
|
2875
|
+
const result = await run.result;
|
|
2876
|
+
if (result.stopReason !== "completed") {
|
|
2877
|
+
const error = /* @__PURE__ */ new Error(`${label} stopped: ${result.stopReason}${result.diagnostic ? `: ${result.diagnostic}` : ""}`);
|
|
2878
|
+
error.code = result.stopReason;
|
|
2879
|
+
throw error;
|
|
2880
|
+
}
|
|
2881
|
+
const text = result.output.filter((block) => block.type === "text").map((block) => block.text).join("\n");
|
|
2882
|
+
const usage = run.localAgent ? usageFromSession(ctx, "reviewer", run.localAgent, route) : {
|
|
2883
|
+
role: "reviewer",
|
|
2884
|
+
provider: route.provider,
|
|
2885
|
+
model: route.model,
|
|
2886
|
+
source: "unavailable"
|
|
2887
|
+
};
|
|
2888
|
+
usage.durationMs = Date.now() - started;
|
|
2889
|
+
return {
|
|
2890
|
+
text,
|
|
2891
|
+
usage
|
|
2892
|
+
};
|
|
2893
|
+
} finally {
|
|
2894
|
+
await run.dispose();
|
|
2895
|
+
}
|
|
2896
|
+
} catch (error) {
|
|
2897
|
+
last = error;
|
|
2898
|
+
if (!eligibleTransportFailure(error) || index === routes.length - 1) throw error;
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
throw last;
|
|
2902
|
+
}
|
|
2903
|
+
async function validateIsolated(root, head, leaseId, runDir, runId, changed, plan, settings, store, phase, emit) {
|
|
2904
|
+
if (plan.validationCommands.length === 0) return [];
|
|
2905
|
+
const fingerprint = await ownedFingerprint(root, changed);
|
|
2906
|
+
const patch = await capturePatch(root, head, "__validation__", changed);
|
|
2907
|
+
const lease = await createWorktree(root, leaseId, "__validation__", head, store.root);
|
|
2908
|
+
let clean = false;
|
|
2909
|
+
try {
|
|
2910
|
+
await applyPatchArtifact(lease.path, patch, changed);
|
|
2911
|
+
const receipts = [];
|
|
2912
|
+
for (const command of plan.validationCommands) {
|
|
2913
|
+
const receipt = await runValidation({
|
|
2914
|
+
cwd: lease.path,
|
|
2915
|
+
runDir,
|
|
2916
|
+
runId,
|
|
2917
|
+
phase,
|
|
2918
|
+
commandId: command.id,
|
|
2919
|
+
command: command.command,
|
|
2920
|
+
timeoutMs: command.timeoutMs,
|
|
2921
|
+
capBytes: Math.min(settings.review.outputCapBytes, 16 * 1024 * 1024),
|
|
2922
|
+
ownershipFingerprint: fingerprint
|
|
2923
|
+
});
|
|
2924
|
+
receipts.push(receipt);
|
|
2925
|
+
await atomicJson(join(runDir, "validation", `${phase.toLowerCase()}-${command.id}.receipt.json`), receipt);
|
|
2926
|
+
emit?.("validation", {
|
|
2927
|
+
runId,
|
|
2928
|
+
commandId: command.id,
|
|
2929
|
+
status: receipt.status
|
|
2930
|
+
});
|
|
2931
|
+
if (receipt.status !== "PASS") throw new Error(`${phase.toLowerCase()} validation ${command.id}: ${receipt.status}`);
|
|
2932
|
+
}
|
|
2933
|
+
await assertTrustedReceipts(receipts, head, fingerprint);
|
|
2934
|
+
clean = true;
|
|
2935
|
+
return receipts;
|
|
2936
|
+
} finally {
|
|
2937
|
+
if (clean || !settings.execution.keepFailedWorktrees) await removeOwnedWorktree(root, lease, true).catch(() => {});
|
|
2938
|
+
}
|
|
2939
|
+
}
|
|
2940
|
+
async function executeSerialTask(deps, launch, task, plan, root, workerRoutes, signal, handoffs, usage) {
|
|
2941
|
+
deps.emit?.("task-start", {
|
|
2942
|
+
runId: launch.__runId,
|
|
2943
|
+
taskId: task.id
|
|
2944
|
+
});
|
|
2945
|
+
const before = await snapshotDirty(root);
|
|
2946
|
+
const basePacket = buildTaskPacket(plan, task, handoffs);
|
|
2947
|
+
const backend = new NativeSpawnBackend(deps.ctx);
|
|
2948
|
+
const result = await runMutationRole(root, task.modify, workerRoutes, (route, continuation, error) => backend.run({
|
|
2949
|
+
parent: launch.agent,
|
|
2950
|
+
role: "worker",
|
|
2951
|
+
taskId: task.id,
|
|
2952
|
+
prompt: continuation ? continuationPacket(basePacket, error) : basePacket,
|
|
2953
|
+
route,
|
|
2954
|
+
signal,
|
|
2955
|
+
persona: "Fresh Worker. Implement only the assigned Plan task and exact modify ownership. Never commit, push, reset, clean, stash, or discard user work.",
|
|
2956
|
+
ownership: {
|
|
2957
|
+
root,
|
|
2958
|
+
paths: task.modify
|
|
2959
|
+
}
|
|
2960
|
+
}));
|
|
2961
|
+
if (result.status !== "COMPLETE") throw new Error(`worker ${task.id}: ${result.status}`);
|
|
2962
|
+
if (result.__usage) usage.push(result.__usage);
|
|
2963
|
+
const changed = deltaPaths(before, await snapshotDirty(root));
|
|
2964
|
+
assertOwnedPaths(changed, task.modify);
|
|
2965
|
+
return {
|
|
2966
|
+
task,
|
|
2967
|
+
result,
|
|
2968
|
+
changed
|
|
2969
|
+
};
|
|
2970
|
+
}
|
|
2971
|
+
async function executeWorktreeTask(deps, launch, runId, leaseRunId, runDir, root, head, task, plan, workerRoutes, signal, handoffs, usage, records, seedPatch) {
|
|
2972
|
+
deps.emit?.("task-start", {
|
|
2973
|
+
runId,
|
|
2974
|
+
taskId: task.id
|
|
2975
|
+
});
|
|
2976
|
+
const lease = await createWorktree(root, leaseRunId, task.id, head, deps.store.root);
|
|
2977
|
+
const record = {
|
|
2978
|
+
taskId: task.id,
|
|
2979
|
+
path: lease.path,
|
|
2980
|
+
baseHead: head,
|
|
2981
|
+
status: "ACTIVE"
|
|
2982
|
+
};
|
|
2983
|
+
records.push(record);
|
|
2984
|
+
await writeWorktreeManifest(runDir, records);
|
|
2985
|
+
let accepted = false;
|
|
2986
|
+
try {
|
|
2987
|
+
if (seedPatch) await applyPatchArtifact(lease.path, seedPatch, seedPatch.files.map((file) => file.path));
|
|
2988
|
+
const before = await snapshotDirty(lease.path);
|
|
2989
|
+
const basePacket = `${buildTaskPacket(plan, task, handoffs)}\n\nIMPORTANT: return exactly one JSON object matching the completion contract, with no markdown fence or prose.`;
|
|
2990
|
+
const backend = new SdkWorkspaceBackend();
|
|
2991
|
+
const result = await runMutationRole(lease.path, task.modify, workerRoutes, (route, continuation, error) => backend.run({
|
|
2992
|
+
cwd: lease.path,
|
|
2993
|
+
profile: deps.settings(root).execution.sdkProfile,
|
|
2994
|
+
taskId: task.id,
|
|
2995
|
+
prompt: continuation ? continuationPacket(basePacket, error) : basePacket,
|
|
2996
|
+
route,
|
|
2997
|
+
signal
|
|
2998
|
+
}));
|
|
2999
|
+
if (result.status !== "COMPLETE") throw new Error(`worker ${task.id}: ${result.status}`);
|
|
3000
|
+
if (result.__usage) usage.push(result.__usage);
|
|
3001
|
+
const after = await snapshotDirty(lease.path);
|
|
3002
|
+
const changed = deltaPaths(before, after);
|
|
3003
|
+
assertOwnedPaths(changed, task.modify);
|
|
3004
|
+
const patch = await capturePatch(lease.path, head, task.id, changed);
|
|
3005
|
+
await atomicJson(join(runDir, "patches", `${task.id}.json`), patch);
|
|
3006
|
+
record.status = "CAPTURED";
|
|
3007
|
+
record.fingerprint = snapshotHash(after);
|
|
3008
|
+
record.patchSha256 = patch.sha256;
|
|
3009
|
+
await writeWorktreeManifest(runDir, records);
|
|
3010
|
+
accepted = true;
|
|
3011
|
+
return {
|
|
3012
|
+
task,
|
|
3013
|
+
result,
|
|
3014
|
+
changed,
|
|
3015
|
+
patch
|
|
3016
|
+
};
|
|
3017
|
+
} catch (error) {
|
|
3018
|
+
record.status = "FAILED";
|
|
3019
|
+
await writeWorktreeManifest(runDir, records).catch(() => {});
|
|
3020
|
+
throw error;
|
|
3021
|
+
} finally {
|
|
3022
|
+
if (accepted || !deps.settings(root).execution.keepFailedWorktrees) {
|
|
3023
|
+
await removeOwnedWorktree(root, lease, true).catch(() => {});
|
|
3024
|
+
record.status = "CLEANED";
|
|
3025
|
+
await writeWorktreeManifest(runDir, records).catch(() => {});
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
function normalizeWaveForResume(wave, completed) {
|
|
3030
|
+
const taskIds = wave.taskIds.filter((id) => !completed.has(id));
|
|
3031
|
+
if (taskIds.length === 0) return void 0;
|
|
3032
|
+
return taskIds.length === 1 ? {
|
|
3033
|
+
mode: "serial",
|
|
3034
|
+
taskIds
|
|
3035
|
+
} : {
|
|
3036
|
+
...wave,
|
|
3037
|
+
taskIds
|
|
3038
|
+
};
|
|
3039
|
+
}
|
|
3040
|
+
function createOrchestratorRunner(deps) {
|
|
3041
|
+
return async (launch, runId, signal) => {
|
|
3042
|
+
launch.__runId = runId;
|
|
3043
|
+
const { ctx, store } = deps;
|
|
3044
|
+
const plan = launch.artifact;
|
|
3045
|
+
const root = await repoRoot(launch.agent.session.header?.cwd ?? process.cwd());
|
|
3046
|
+
const settings = deps.settings(root);
|
|
3047
|
+
const head = await fullHead(root);
|
|
3048
|
+
if (launch.baselineHead && launch.baselineHead !== head) throw new Error(`approved baseline HEAD drift: ${launch.baselineHead} -> ${head}`);
|
|
3049
|
+
const allOwned = unionOwnership(plan);
|
|
3050
|
+
await assertRepoPathsConfined(root, allOwned);
|
|
3051
|
+
const runDir = store.runDir(launch.sessionId, runId);
|
|
3052
|
+
await mkdir(join(runDir, "patches"), { recursive: true });
|
|
3053
|
+
const baselinePath = join(runDir, "baseline.json");
|
|
3054
|
+
let runBaseline;
|
|
3055
|
+
if (launch.resumeFrom) runBaseline = await readJson(baselinePath);
|
|
3056
|
+
else {
|
|
3057
|
+
runBaseline = await snapshotDirty(root);
|
|
3058
|
+
await atomicJson(baselinePath, runBaseline);
|
|
3059
|
+
await updateCheckpoint(store, launch, runId, root, head, allOwned, "PREFLIGHT", {
|
|
3060
|
+
safeBoundary: true,
|
|
3061
|
+
completedTaskIds: []
|
|
3062
|
+
});
|
|
3063
|
+
}
|
|
3064
|
+
const workerRoutes = await resolvedChoices(ctx, settings, "worker", launch.agent);
|
|
3065
|
+
const handoffPath = join(runDir, "handoffs.json");
|
|
3066
|
+
const handoffs = launch.resumeFrom ? await readJson(handoffPath).catch(() => []) : [];
|
|
3067
|
+
const usage = [];
|
|
3068
|
+
const worktreeRecords = await readJson(join(runDir, "worktrees.json")).then((v) => v.worktrees).catch(() => []);
|
|
3069
|
+
const completed = new Set(launch.completedTaskIds ?? []);
|
|
3070
|
+
let hadWorktree = worktreeRecords.some((record) => record.status === "CAPTURED" || record.status === "CLEANED");
|
|
3071
|
+
if (launch.resumeFrom !== "VALIDATING") {
|
|
3072
|
+
deps.emit?.("phase", {
|
|
3073
|
+
runId,
|
|
3074
|
+
sessionId: launch.sessionId,
|
|
3075
|
+
phase: "WORKERS"
|
|
3076
|
+
});
|
|
3077
|
+
const configuredWaves = buildWaves(plan, settings.execution.maxParallelWorkers, settings.execution.parallelMode);
|
|
3078
|
+
const leaseRunId = `${runId}-worktrees`;
|
|
3079
|
+
for (const configured of configuredWaves) {
|
|
3080
|
+
const wave = normalizeWaveForResume(configured, completed);
|
|
3081
|
+
if (!wave) continue;
|
|
3082
|
+
signal.throwIfAborted();
|
|
3083
|
+
const waveTasks = wave.taskIds.map((id) => plan.tasks.find((task) => task.id === id));
|
|
3084
|
+
if (!(wave.mode === "parallel" && recheckWave(plan, wave) && !dirtyOwnershipConflict(runBaseline, waveTasks))) {
|
|
3085
|
+
for (const task of waveTasks) {
|
|
3086
|
+
const outcome = await executeSerialTask(deps, launch, task, plan, root, workerRoutes, signal, handoffs, usage);
|
|
3087
|
+
handoffs.push(`${task.id}: host-observed=${outcome.changed.join(",") || "(none)"} status=${outcome.result.status}`);
|
|
3088
|
+
completed.add(task.id);
|
|
3089
|
+
deps.emit?.("task-end", {
|
|
3090
|
+
runId,
|
|
3091
|
+
taskId: task.id,
|
|
3092
|
+
status: outcome.result.status
|
|
3093
|
+
});
|
|
3094
|
+
await atomicJson(handoffPath, handoffs);
|
|
3095
|
+
await updateCheckpoint(store, launch, runId, root, head, allOwned, "WORKERS", {
|
|
3096
|
+
completedTaskIds: [...completed],
|
|
3097
|
+
safeBoundary: true
|
|
3098
|
+
});
|
|
3099
|
+
}
|
|
3100
|
+
continue;
|
|
3101
|
+
}
|
|
3102
|
+
hadWorktree = true;
|
|
3103
|
+
const mainBefore = await snapshotDirty(root);
|
|
3104
|
+
if (mainBefore.head !== head) throw new Error("HEAD drift before parallel wave");
|
|
3105
|
+
const priorRunDelta = deltaPaths(runBaseline, mainBefore);
|
|
3106
|
+
assertOwnedPaths(priorRunDelta, allOwned);
|
|
3107
|
+
const seedPatch = priorRunDelta.length > 0 ? await capturePatch(root, head, "__wave_seed__", priorRunDelta) : void 0;
|
|
3108
|
+
const outcomes = await Promise.all(waveTasks.map((task) => executeWorktreeTask(deps, launch, runId, leaseRunId, runDir, root, head, task, plan, workerRoutes, signal, handoffs, usage, worktreeRecords, seedPatch)));
|
|
3109
|
+
if (snapshotHash(await snapshotDirty(root)) !== snapshotHash(mainBefore)) throw new Error("working tree drift while parallel workers were isolated; refusing to apply patches");
|
|
3110
|
+
if (await fullHead(root) !== head) throw new Error("HEAD drift before applying parallel worker patches");
|
|
3111
|
+
for (const outcome of outcomes) {
|
|
3112
|
+
if (!outcome.patch) throw new Error(`missing patch artifact for ${outcome.task.id}`);
|
|
3113
|
+
await applyPatchArtifact(root, outcome.patch, allOwned);
|
|
3114
|
+
handoffs.push(`${outcome.task.id}: host-observed=${outcome.changed.join(",") || "(none)"} status=${outcome.result.status}`);
|
|
3115
|
+
completed.add(outcome.task.id);
|
|
3116
|
+
deps.emit?.("task-end", {
|
|
3117
|
+
runId,
|
|
3118
|
+
taskId: outcome.task.id,
|
|
3119
|
+
status: outcome.result.status
|
|
3120
|
+
});
|
|
3121
|
+
}
|
|
3122
|
+
await atomicJson(handoffPath, handoffs);
|
|
3123
|
+
await updateCheckpoint(store, launch, runId, root, head, allOwned, "WORKERS", {
|
|
3124
|
+
completedTaskIds: [...completed],
|
|
3125
|
+
safeBoundary: true
|
|
3126
|
+
});
|
|
3127
|
+
}
|
|
3128
|
+
if (completed.size !== plan.tasks.length) throw new Error(`worker stage incomplete: ${completed.size}/${plan.tasks.length}`);
|
|
3129
|
+
if (needsIntegrator(plan.tasks.length, hadWorktree, plan.tasks.length > 1, false)) {
|
|
3130
|
+
deps.emit?.("phase", {
|
|
3131
|
+
runId,
|
|
3132
|
+
sessionId: launch.sessionId,
|
|
3133
|
+
phase: "INTEGRATING"
|
|
3134
|
+
});
|
|
3135
|
+
const integratorBefore = await snapshotDirty(root);
|
|
3136
|
+
const routes = await resolvedChoices(ctx, settings, "integrator", launch.agent);
|
|
3137
|
+
const backend = new NativeSpawnBackend(ctx);
|
|
3138
|
+
const basePrompt = integratorPrompt(JSON.stringify(plan), handoffs, hadWorktree ? [join(runDir, "patches")] : []);
|
|
3139
|
+
const result = await runMutationRole(root, allOwned, routes, (route, continuation, error) => backend.run({
|
|
3140
|
+
parent: launch.agent,
|
|
3141
|
+
role: "integrator",
|
|
3142
|
+
taskId: "__integrator__",
|
|
3143
|
+
prompt: continuation ? continuationPacket(basePrompt, error) : basePrompt,
|
|
3144
|
+
route,
|
|
3145
|
+
signal,
|
|
3146
|
+
persona: "Integrator. Reconcile only Plan-required cross-task wiring. Never redesign, commit, push, reset, clean, stash, or touch files outside union ownership.",
|
|
3147
|
+
ownership: {
|
|
3148
|
+
root,
|
|
3149
|
+
paths: allOwned
|
|
3150
|
+
}
|
|
3151
|
+
}));
|
|
3152
|
+
if (result.status !== "COMPLETE") throw new Error(`integrator: ${result.status}`);
|
|
3153
|
+
if (result.__usage) usage.push(result.__usage);
|
|
3154
|
+
assertOwnedPaths(deltaPaths(integratorBefore, await snapshotDirty(root)), allOwned);
|
|
3155
|
+
}
|
|
3156
|
+
const afterMutation = await snapshotDirty(root);
|
|
3157
|
+
assertOwnedPaths(deltaPaths(runBaseline, afterMutation), allOwned);
|
|
3158
|
+
await updateCheckpoint(store, launch, runId, root, head, allOwned, "VALIDATING", {
|
|
3159
|
+
completedTaskIds: [...completed],
|
|
3160
|
+
safeBoundary: true
|
|
3161
|
+
});
|
|
3162
|
+
}
|
|
3163
|
+
let finalSnapshot = await snapshotDirty(root);
|
|
3164
|
+
let runDelta = deltaPaths(runBaseline, finalSnapshot);
|
|
3165
|
+
assertOwnedPaths(runDelta, allOwned);
|
|
3166
|
+
deps.emit?.("phase", {
|
|
3167
|
+
runId,
|
|
3168
|
+
sessionId: launch.sessionId,
|
|
3169
|
+
phase: "VALIDATING"
|
|
3170
|
+
});
|
|
3171
|
+
let receipts = await validateIsolated(root, head, `${runId}-validation-${Date.now()}`, runDir, runId, runDelta, plan, settings, store, "VALIDATING", deps.emit);
|
|
3172
|
+
await updateCheckpoint(store, launch, runId, root, head, allOwned, "REVIEWING", {
|
|
3173
|
+
completedTaskIds: [...completed],
|
|
3174
|
+
safeBoundary: true
|
|
3175
|
+
});
|
|
3176
|
+
deps.emit?.("phase", {
|
|
3177
|
+
runId,
|
|
3178
|
+
sessionId: launch.sessionId,
|
|
3179
|
+
phase: "REVIEWING"
|
|
3180
|
+
});
|
|
3181
|
+
const reviewRoutes = await resolvedChoices(ctx, settings, "reviewer", launch.agent);
|
|
3182
|
+
const preExisting = Object.keys(runBaseline.paths).sort();
|
|
3183
|
+
let fixRound = 0;
|
|
3184
|
+
while (true) {
|
|
3185
|
+
finalSnapshot = await snapshotDirty(root);
|
|
3186
|
+
runDelta = deltaPaths(runBaseline, finalSnapshot);
|
|
3187
|
+
assertOwnedPaths(runDelta, allOwned);
|
|
3188
|
+
const fingerprint = await ownedFingerprint(root, runDelta);
|
|
3189
|
+
await assertTrustedReceipts(receipts, head, fingerprint);
|
|
3190
|
+
const actualDiff = await boundedTextDiff(root, runDelta, preExisting);
|
|
3191
|
+
const prompt = reviewerPrompt(plan, runDelta, preExisting, receiptIndex(receipts), actualDiff);
|
|
3192
|
+
let reviewerText = "";
|
|
3193
|
+
let verdict = {
|
|
3194
|
+
kind: "PROTOCOL_INVALID",
|
|
3195
|
+
detail: ""
|
|
3196
|
+
};
|
|
3197
|
+
for (let protocolAttempt = 0; protocolAttempt <= settings.review.protocolRetry; protocolAttempt++) {
|
|
3198
|
+
const reviewed = await nativeReviewer(ctx, launch.agent, prompt, reviewRoutes, signal, `review-${fixRound}-${protocolAttempt}`);
|
|
3199
|
+
reviewerText = reviewed.text;
|
|
3200
|
+
usage.push(reviewed.usage);
|
|
3201
|
+
verdict = parseReviewVerdict(reviewerText);
|
|
3202
|
+
if (verdict.kind !== "PROTOCOL_INVALID") break;
|
|
3203
|
+
}
|
|
3204
|
+
if (verdict.kind === "PROTOCOL_INVALID") throw new Error("Reviewer protocol invalid after retry");
|
|
3205
|
+
deps.emit?.("review", {
|
|
3206
|
+
runId,
|
|
3207
|
+
round: fixRound,
|
|
3208
|
+
verdict: verdict.kind
|
|
3209
|
+
});
|
|
3210
|
+
await atomicJson(join(runDir, `review-${fixRound}.json`), {
|
|
3211
|
+
verdict,
|
|
3212
|
+
reviewerText
|
|
3213
|
+
});
|
|
3214
|
+
if (verdict.kind === "PASS") break;
|
|
3215
|
+
if (fixRound >= settings.review.maxReviewRounds) throw new Error("Reviewer failed after maxReviewRounds");
|
|
3216
|
+
fixRound += 1;
|
|
3217
|
+
deps.emit?.("phase", {
|
|
3218
|
+
runId,
|
|
3219
|
+
sessionId: launch.sessionId,
|
|
3220
|
+
phase: "FIXING",
|
|
3221
|
+
reviewRound: fixRound
|
|
3222
|
+
});
|
|
3223
|
+
const fixBefore = await snapshotDirty(root);
|
|
3224
|
+
const backend = new NativeSpawnBackend(ctx);
|
|
3225
|
+
const basePrompt = `Targeted fix only. Reviewer findings:\n${verdict.detail}\n\nAuthoritative PlanArtifact:\n${JSON.stringify(plan)}\n\nYou may modify only these paths:\n${allOwned.join("\n")}\nDo not redesign, expand scope, commit, push, reset, clean, stash, or discard user work.`;
|
|
3226
|
+
const result = await runMutationRole(root, allOwned, workerRoutes, (route, continuation, error) => backend.run({
|
|
3227
|
+
parent: launch.agent,
|
|
3228
|
+
role: "worker",
|
|
3229
|
+
taskId: `__fix_${fixRound}__`,
|
|
3230
|
+
prompt: continuation ? continuationPacket(basePrompt, error) : basePrompt,
|
|
3231
|
+
route,
|
|
3232
|
+
signal,
|
|
3233
|
+
persona: "Targeted fix Worker. Fix only Reviewer-identified Plan-required defects.",
|
|
3234
|
+
ownership: {
|
|
3235
|
+
root,
|
|
3236
|
+
paths: allOwned
|
|
3237
|
+
}
|
|
3238
|
+
}));
|
|
3239
|
+
if (result.status !== "COMPLETE") throw new Error(`targeted fix: ${result.status}`);
|
|
3240
|
+
if (result.__usage) usage.push(result.__usage);
|
|
3241
|
+
assertOwnedPaths(deltaPaths(fixBefore, await snapshotDirty(root)), allOwned);
|
|
3242
|
+
finalSnapshot = await snapshotDirty(root);
|
|
3243
|
+
runDelta = deltaPaths(runBaseline, finalSnapshot);
|
|
3244
|
+
assertOwnedPaths(runDelta, allOwned);
|
|
3245
|
+
deps.emit?.("phase", {
|
|
3246
|
+
runId,
|
|
3247
|
+
sessionId: launch.sessionId,
|
|
3248
|
+
phase: "VALIDATING",
|
|
3249
|
+
reviewRound: fixRound
|
|
3250
|
+
});
|
|
3251
|
+
receipts = await validateIsolated(root, head, `${runId}-fix-${fixRound}-${Date.now()}`, runDir, runId, runDelta, plan, settings, store, "FIXING", deps.emit);
|
|
3252
|
+
await updateCheckpoint(store, launch, runId, root, head, allOwned, "REVIEWING", {
|
|
3253
|
+
completedTaskIds: [...completed],
|
|
3254
|
+
safeBoundary: true
|
|
3255
|
+
});
|
|
3256
|
+
}
|
|
3257
|
+
finalSnapshot = await snapshotDirty(root);
|
|
3258
|
+
const finalDelta = deltaPaths(runBaseline, finalSnapshot);
|
|
3259
|
+
assertOwnedPaths(finalDelta, allOwned);
|
|
3260
|
+
const finalFingerprint = await ownedFingerprint(root, finalDelta);
|
|
3261
|
+
await assertTrustedReceipts(receipts, head, finalFingerprint);
|
|
3262
|
+
const usageSummary = aggregateUsage(usage);
|
|
3263
|
+
await atomicJson(join(runDir, "telemetry.json"), usageSummary);
|
|
3264
|
+
if (launch.externalIssue?.publishAfterPass) {
|
|
3265
|
+
const { publishExternalRun } = await import("./publication-D352itYY.js");
|
|
3266
|
+
await publishExternalRun({
|
|
3267
|
+
cwd: root,
|
|
3268
|
+
runDir,
|
|
3269
|
+
meta: launch.externalIssue,
|
|
3270
|
+
ownedPaths: finalDelta,
|
|
3271
|
+
expectedHead: head,
|
|
3272
|
+
expectedOwnershipFingerprint: finalFingerprint,
|
|
3273
|
+
receipts
|
|
3274
|
+
});
|
|
3275
|
+
}
|
|
3276
|
+
deps.emit?.("phase", {
|
|
3277
|
+
runId,
|
|
3278
|
+
sessionId: launch.sessionId,
|
|
3279
|
+
phase: "COMPLETE"
|
|
3280
|
+
});
|
|
3281
|
+
await atomicJson(join(runDir, "completion.json"), {
|
|
3282
|
+
runId,
|
|
3283
|
+
planHash: launch.planHash,
|
|
3284
|
+
review: "PASS",
|
|
3285
|
+
changedPaths: finalDelta,
|
|
3286
|
+
ownershipFingerprint: finalFingerprint,
|
|
3287
|
+
receipts: receiptIndex(receipts),
|
|
3288
|
+
usage: usageSummary,
|
|
3289
|
+
completedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3290
|
+
});
|
|
3291
|
+
try {
|
|
3292
|
+
launch.agent.inject(createUserMessage({
|
|
3293
|
+
content: [{
|
|
3294
|
+
type: "text",
|
|
3295
|
+
text: `Plan Orchestrator completed run ${runId}. Reviewer: PASS. Changed paths: ${finalDelta.join(", ") || "(none)"}. Validation: ${receipts.map((receipt) => `${receipt.commandId}=${receipt.status}`).join(", ") || "no host commands"}.`
|
|
3296
|
+
}],
|
|
3297
|
+
source: {
|
|
3298
|
+
kind: "plugin",
|
|
3299
|
+
plugin: "plan-orchestrator"
|
|
3300
|
+
}
|
|
3301
|
+
}));
|
|
3302
|
+
} catch {}
|
|
3303
|
+
};
|
|
3304
|
+
}
|
|
3305
|
+
//#endregion
|
|
3306
|
+
//#region src/orchestration/projection.ts
|
|
3307
|
+
const phaseSchema = z.enum([
|
|
3308
|
+
"IDLE",
|
|
3309
|
+
"PLANNING",
|
|
3310
|
+
"PLAN_VALIDATED",
|
|
3311
|
+
"APPROVED_PENDING",
|
|
3312
|
+
"PREFLIGHT",
|
|
3313
|
+
"WORKERS",
|
|
3314
|
+
"INTEGRATING",
|
|
3315
|
+
"VALIDATING",
|
|
3316
|
+
"REVIEWING",
|
|
3317
|
+
"FIXING",
|
|
3318
|
+
"COMPLETE",
|
|
3319
|
+
"BLOCKED",
|
|
3320
|
+
"FAILED",
|
|
3321
|
+
"INTERRUPTED",
|
|
3322
|
+
"CANCELLED"
|
|
3323
|
+
]);
|
|
3324
|
+
const runSchema = z.object({
|
|
3325
|
+
runId: z.string().min(1).max(200),
|
|
3326
|
+
sessionId: z.string().min(1).max(300),
|
|
3327
|
+
phase: phaseSchema,
|
|
3328
|
+
status: z.string().max(100),
|
|
3329
|
+
tasksTotal: z.number().int().nonnegative(),
|
|
3330
|
+
tasksDone: z.number().int().nonnegative(),
|
|
3331
|
+
activeTaskIds: z.array(z.string().max(200)).max(64),
|
|
3332
|
+
reviewRound: z.number().int().nonnegative(),
|
|
3333
|
+
message: z.string().max(2e3).optional(),
|
|
3334
|
+
startedAt: z.string().max(64),
|
|
3335
|
+
updatedAt: z.string().max(64)
|
|
3336
|
+
}).strict();
|
|
3337
|
+
const stateSchema = z.object({ runs: z.array(runSchema).max(20) }).strict();
|
|
3338
|
+
function replaceRun(state, run) {
|
|
3339
|
+
return { runs: [run, ...state.runs.filter((row) => row.runId !== run.runId)].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, 20) };
|
|
3340
|
+
}
|
|
3341
|
+
function find(state, runId) {
|
|
3342
|
+
return state.runs.find((run) => run.runId === runId);
|
|
3343
|
+
}
|
|
3344
|
+
const planOrchestratorProjectionDefinition = {
|
|
3345
|
+
key: "planOrchestrator",
|
|
3346
|
+
stateVersion: 1,
|
|
3347
|
+
stateSchema,
|
|
3348
|
+
init: () => ({ runs: [] }),
|
|
3349
|
+
apply: (state, event) => {
|
|
3350
|
+
const d = event.data;
|
|
3351
|
+
if (event.type === "planx/run-approved") return replaceRun(state, {
|
|
3352
|
+
runId: d.runId,
|
|
3353
|
+
sessionId: d.sessionId,
|
|
3354
|
+
phase: "APPROVED_PENDING",
|
|
3355
|
+
status: "APPROVED_PENDING",
|
|
3356
|
+
tasksTotal: d.tasksTotal,
|
|
3357
|
+
tasksDone: 0,
|
|
3358
|
+
activeTaskIds: [],
|
|
3359
|
+
reviewRound: 0,
|
|
3360
|
+
startedAt: d.at,
|
|
3361
|
+
updatedAt: d.at
|
|
3362
|
+
});
|
|
3363
|
+
if (typeof d?.runId !== "string") return state;
|
|
3364
|
+
const current = find(state, d.runId);
|
|
3365
|
+
if (!current) return state;
|
|
3366
|
+
let next = {
|
|
3367
|
+
...current,
|
|
3368
|
+
activeTaskIds: [...current.activeTaskIds]
|
|
3369
|
+
};
|
|
3370
|
+
switch (event.type) {
|
|
3371
|
+
case "planx/run-phase":
|
|
3372
|
+
next.phase = d.phase;
|
|
3373
|
+
next.status = d.phase;
|
|
3374
|
+
if (typeof d.reviewRound === "number") next.reviewRound = d.reviewRound;
|
|
3375
|
+
if (typeof d.message === "string") next.message = d.message;
|
|
3376
|
+
break;
|
|
3377
|
+
case "planx/task-start":
|
|
3378
|
+
if (!next.activeTaskIds.includes(d.taskId)) next.activeTaskIds.push(d.taskId);
|
|
3379
|
+
break;
|
|
3380
|
+
case "planx/task-end":
|
|
3381
|
+
next.activeTaskIds = next.activeTaskIds.filter((id) => id !== d.taskId);
|
|
3382
|
+
next.tasksDone = Math.min(next.tasksTotal, next.tasksDone + 1);
|
|
3383
|
+
break;
|
|
3384
|
+
case "planx/review":
|
|
3385
|
+
next.reviewRound = d.round;
|
|
3386
|
+
break;
|
|
3387
|
+
case "planx/recovery":
|
|
3388
|
+
next.status = d.status;
|
|
3389
|
+
if (typeof d.message === "string") next.message = d.message;
|
|
3390
|
+
break;
|
|
3391
|
+
case "planx/run-terminal":
|
|
3392
|
+
next.phase = d.phase;
|
|
3393
|
+
next.status = d.phase;
|
|
3394
|
+
next.activeTaskIds = [];
|
|
3395
|
+
if (typeof d.message === "string") next.message = d.message;
|
|
3396
|
+
break;
|
|
3397
|
+
default: return state;
|
|
3398
|
+
}
|
|
3399
|
+
next.updatedAt = d.at;
|
|
3400
|
+
return replaceRun(state, next);
|
|
3401
|
+
},
|
|
3402
|
+
wire: {
|
|
3403
|
+
viewSchema: stateSchema,
|
|
3404
|
+
view: (state) => state
|
|
3405
|
+
}
|
|
3406
|
+
};
|
|
3407
|
+
//#endregion
|
|
3408
|
+
//#region src/external/contract.ts
|
|
3409
|
+
function meta(value) {
|
|
3410
|
+
const e = value?.externalPlan;
|
|
3411
|
+
if (!e || e.version !== 1 || !Number.isSafeInteger(e.revision) || e.revision < 1 || typeof e.repository !== "string" || !/^[-A-Za-z0-9_.]+\/[-A-Za-z0-9_.]+$/.test(e.repository) || typeof e.baseCommit !== "string" || !/^[0-9a-f]{40}$/.test(e.baseCommit)) throw new Error("invalid externalPlan metadata");
|
|
3412
|
+
return {
|
|
3413
|
+
version: 1,
|
|
3414
|
+
revision: e.revision,
|
|
3415
|
+
repository: e.repository,
|
|
3416
|
+
baseCommit: e.baseCommit
|
|
3417
|
+
};
|
|
3418
|
+
}
|
|
3419
|
+
function validateExternalContract(value) {
|
|
3420
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("external contract object required");
|
|
3421
|
+
const externalPlan = meta(value);
|
|
3422
|
+
let planInput;
|
|
3423
|
+
if (value.plan && typeof value.plan === "object") planInput = value.plan;
|
|
3424
|
+
else {
|
|
3425
|
+
const { externalPlan: _ignored, ...rest } = value;
|
|
3426
|
+
planInput = rest;
|
|
3427
|
+
}
|
|
3428
|
+
return {
|
|
3429
|
+
externalPlan,
|
|
3430
|
+
plan: validatePlanArtifact(planInput, true)
|
|
3431
|
+
};
|
|
3432
|
+
}
|
|
3433
|
+
function canonical(value) {
|
|
3434
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
3435
|
+
if (value && typeof value === "object") return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(",")}}`;
|
|
3436
|
+
return JSON.stringify(value);
|
|
3437
|
+
}
|
|
3438
|
+
function selectTrustedRevision(contracts) {
|
|
3439
|
+
if (!contracts.length) throw new Error("no valid external contract");
|
|
3440
|
+
const max = Math.max(...contracts.map((c) => c.externalPlan.revision)), same = contracts.filter((c) => c.externalPlan.revision === max);
|
|
3441
|
+
if (new Set(same.map((c) => canonical(c))).size !== 1) throw new Error(`conflicting contracts at revision ${max}`);
|
|
3442
|
+
return same[0];
|
|
3443
|
+
}
|
|
3444
|
+
function extractExternalContracts(text) {
|
|
3445
|
+
const out = [];
|
|
3446
|
+
for (const m of text.matchAll(/```json\s*\n([\s\S]*?)\n```/gi)) try {
|
|
3447
|
+
out.push(validateExternalContract(JSON.parse(m[1])));
|
|
3448
|
+
} catch {}
|
|
3449
|
+
return out;
|
|
3450
|
+
}
|
|
3451
|
+
function validateCompletionEvidence(value) {
|
|
3452
|
+
const e = value?.externalCompletion;
|
|
3453
|
+
if (!e || e.version !== 1 || !Number.isSafeInteger(e.revision) || e.revision < 1 || typeof e.repository !== "string" || !Number.isSafeInteger(e.issue) || e.issue < 1 || !Number.isSafeInteger(e.pr) || e.pr < 1 || typeof e.head !== "string" || !/^[0-9a-f]{40}$/.test(e.head)) throw new Error("invalid external completion evidence");
|
|
3454
|
+
return { externalCompletion: {
|
|
3455
|
+
version: 1,
|
|
3456
|
+
revision: e.revision,
|
|
3457
|
+
repository: e.repository,
|
|
3458
|
+
issue: e.issue,
|
|
3459
|
+
pr: e.pr,
|
|
3460
|
+
head: e.head
|
|
3461
|
+
} };
|
|
3462
|
+
}
|
|
3463
|
+
function extractCompletionEvidence(text) {
|
|
3464
|
+
if (!text.includes("IMPLEMENTATION COMPLETE")) return [];
|
|
3465
|
+
const out = [];
|
|
3466
|
+
for (const m of text.matchAll(/```json\s*\n([\s\S]*?)\n```/gi)) try {
|
|
3467
|
+
out.push(validateCompletionEvidence(JSON.parse(m[1])));
|
|
3468
|
+
} catch {}
|
|
3469
|
+
return out;
|
|
3470
|
+
}
|
|
3471
|
+
//#endregion
|
|
3472
|
+
//#region src/external/continuation.ts
|
|
3473
|
+
function completionIsCurrent(evidence, revision, remoteHead) {
|
|
3474
|
+
return Boolean(evidence && evidence.revision === revision && evidence.head === remoteHead);
|
|
3475
|
+
}
|
|
3476
|
+
async function findCurrentCompletion(evidences, opts) {
|
|
3477
|
+
const matching = evidences.map((x) => x.externalCompletion).filter((x) => x.revision === opts.revision && x.repository === opts.repository && x.issue === opts.issue).sort((a, b) => b.pr - a.pr);
|
|
3478
|
+
for (const e of matching) {
|
|
3479
|
+
const head = await opts.remoteHead(e.pr);
|
|
3480
|
+
if (head && completionIsCurrent({
|
|
3481
|
+
revision: e.revision,
|
|
3482
|
+
head: e.head
|
|
3483
|
+
}, opts.revision, head)) return e;
|
|
3484
|
+
}
|
|
3485
|
+
}
|
|
3486
|
+
//#endregion
|
|
3487
|
+
//#region src/external/issue-command.ts
|
|
3488
|
+
function parseIssueInput(raw) {
|
|
3489
|
+
const m = /^\s*(\d+)\s*$/.exec(raw);
|
|
3490
|
+
if (!m) return void 0;
|
|
3491
|
+
const n = Number(m[1]);
|
|
3492
|
+
return Number.isSafeInteger(n) && n > 0 ? n : void 0;
|
|
3493
|
+
}
|
|
3494
|
+
const issueBranch = (n) => `issue-${n}-external-plan`;
|
|
3495
|
+
function hashPlan(plan) {
|
|
3496
|
+
return createHash("sha256").update(JSON.stringify(plan)).digest("hex");
|
|
3497
|
+
}
|
|
3498
|
+
function installIssueCommand(ctx, deps) {
|
|
3499
|
+
if (!ctx.commands?.register) throw new Error("commands service unavailable");
|
|
3500
|
+
return ctx.commands.register({
|
|
3501
|
+
name: "plan-issue",
|
|
3502
|
+
description: "Execute a trusted external PlanArtifact from a GitHub Issue",
|
|
3503
|
+
input: {
|
|
3504
|
+
hint: "<issue-number>",
|
|
3505
|
+
attachments: false
|
|
3506
|
+
},
|
|
3507
|
+
recordInput: false,
|
|
3508
|
+
handler: async ({ agent, rawInput }) => {
|
|
3509
|
+
try {
|
|
3510
|
+
const issueNumber = parseIssueInput(rawInput);
|
|
3511
|
+
if (!issueNumber) return {
|
|
3512
|
+
kind: "error",
|
|
3513
|
+
text: "Usage: /plan-issue <positive issue number>"
|
|
3514
|
+
};
|
|
3515
|
+
const cwd = agent.session.header?.cwd ?? process.cwd();
|
|
3516
|
+
const settings = deps.settings(cwd);
|
|
3517
|
+
if (!settings.enabled) return {
|
|
3518
|
+
kind: "error",
|
|
3519
|
+
text: "Plan Orchestrator is disabled in Settings → Plan Mode."
|
|
3520
|
+
};
|
|
3521
|
+
if (!settings.externalIssue.enabled) return {
|
|
3522
|
+
kind: "error",
|
|
3523
|
+
text: "External Issue Mode is disabled in Settings → Plan Mode."
|
|
3524
|
+
};
|
|
3525
|
+
const actual = await ghRepository(cwd);
|
|
3526
|
+
const repository = String(actual?.nameWithOwner ?? "");
|
|
3527
|
+
if (!repository) throw new Error("current GitHub repository identity unavailable");
|
|
3528
|
+
const texts = await trustedIssueTexts(cwd, repository, await fetchIssue(cwd, issueNumber));
|
|
3529
|
+
if (texts.length === 0) throw new Error("no issue body/comment from a repository writer, maintainer, or admin");
|
|
3530
|
+
const selected = selectTrustedRevision(texts.flatMap(extractExternalContracts).filter((contract) => contract.externalPlan.repository === repository));
|
|
3531
|
+
await ghPreflight(cwd, selected.externalPlan.repository);
|
|
3532
|
+
const current = await findCurrentCompletion(texts.flatMap(extractCompletionEvidence), {
|
|
3533
|
+
revision: selected.externalPlan.revision,
|
|
3534
|
+
repository: selected.externalPlan.repository,
|
|
3535
|
+
issue: issueNumber,
|
|
3536
|
+
remoteHead: async (pr) => {
|
|
3537
|
+
try {
|
|
3538
|
+
const remote = await remotePr(cwd, pr);
|
|
3539
|
+
return remote?.state === "OPEN" ? remote.headRefOid : void 0;
|
|
3540
|
+
} catch {
|
|
3541
|
+
return;
|
|
3542
|
+
}
|
|
3543
|
+
}
|
|
3544
|
+
});
|
|
3545
|
+
if (current) return {
|
|
3546
|
+
kind: "success",
|
|
3547
|
+
text: `Issue #${issueNumber} revision ${current.revision} is already complete at PR #${current.pr} (${current.head}).`
|
|
3548
|
+
};
|
|
3549
|
+
const branch = issueBranch(issueNumber);
|
|
3550
|
+
await prepareIssueBranch(cwd, branch, selected.externalPlan.baseCommit);
|
|
3551
|
+
const run = deps.orchestration.approve({
|
|
3552
|
+
sessionId: String(agent.session.id),
|
|
3553
|
+
agent,
|
|
3554
|
+
artifact: selected.plan,
|
|
3555
|
+
planHash: hashPlan(selected.plan),
|
|
3556
|
+
baselineHead: selected.externalPlan.baseCommit,
|
|
3557
|
+
externalIssue: {
|
|
3558
|
+
issueNumber,
|
|
3559
|
+
repository: selected.externalPlan.repository,
|
|
3560
|
+
revision: selected.externalPlan.revision,
|
|
3561
|
+
branch,
|
|
3562
|
+
publishAfterPass: Boolean(settings.externalIssue.publishAfterPass)
|
|
3563
|
+
}
|
|
3564
|
+
});
|
|
3565
|
+
if (!run) return {
|
|
3566
|
+
kind: "error",
|
|
3567
|
+
text: "Another Plan Orchestrator run is already active for this session."
|
|
3568
|
+
};
|
|
3569
|
+
return {
|
|
3570
|
+
kind: "success",
|
|
3571
|
+
text: `Accepted Issue #${issueNumber} external plan revision ${selected.externalPlan.revision} on ${branch}. Execution run ${run} will start automatically.${settings.externalIssue.publishAfterPass ? " PASS will publish/reuse a PR." : " Automatic PR publication is disabled."}`
|
|
3572
|
+
};
|
|
3573
|
+
} catch (e) {
|
|
3574
|
+
return {
|
|
3575
|
+
kind: "error",
|
|
3576
|
+
text: `External Issue Mode blocked: ${e.message}`
|
|
3577
|
+
};
|
|
3578
|
+
}
|
|
3579
|
+
}
|
|
3580
|
+
});
|
|
3581
|
+
}
|
|
3582
|
+
//#endregion
|
|
3583
|
+
//#region src/index.ts
|
|
3584
|
+
const name = "plan-orchestrator";
|
|
3585
|
+
const inject = [
|
|
3586
|
+
"settings",
|
|
3587
|
+
"tools",
|
|
3588
|
+
"llm",
|
|
3589
|
+
"sessions",
|
|
3590
|
+
"subagents",
|
|
3591
|
+
"systemPrompt",
|
|
3592
|
+
"sandboxPolicy",
|
|
3593
|
+
"sessionProjections",
|
|
3594
|
+
"shell"
|
|
3595
|
+
];
|
|
3596
|
+
const execFileP = promisify(execFile);
|
|
3597
|
+
const require = createRequire(import.meta.url);
|
|
3598
|
+
function packageVersion(id) {
|
|
3599
|
+
try {
|
|
3600
|
+
return require(`${id}/package.json`).version;
|
|
3601
|
+
} catch {
|
|
3602
|
+
return;
|
|
3603
|
+
}
|
|
3604
|
+
}
|
|
3605
|
+
async function commandAvailable(command, args) {
|
|
3606
|
+
try {
|
|
3607
|
+
await execFileP(command, args, {
|
|
3608
|
+
windowsHide: true,
|
|
3609
|
+
timeout: 5e3
|
|
3610
|
+
});
|
|
3611
|
+
return true;
|
|
3612
|
+
} catch {
|
|
3613
|
+
return false;
|
|
3614
|
+
}
|
|
3615
|
+
}
|
|
3616
|
+
function hasService(ctx, name) {
|
|
3617
|
+
try {
|
|
3618
|
+
return ctx.get?.(name) !== void 0;
|
|
3619
|
+
} catch {
|
|
3620
|
+
return false;
|
|
3621
|
+
}
|
|
3622
|
+
}
|
|
3623
|
+
function apply(ctx) {
|
|
3624
|
+
const c = ctx;
|
|
3625
|
+
const settings = registerSettings(c);
|
|
3626
|
+
const bridge = new NativePlanBridge();
|
|
3627
|
+
const readOnly = new PlannerReadOnlyGuard();
|
|
3628
|
+
const store = new RunStore();
|
|
3629
|
+
c.sessionProjections.register(planOrchestratorProjectionDefinition);
|
|
3630
|
+
let orchestration;
|
|
3631
|
+
orchestration = new OrchestrationService(store, createOrchestratorRunner({
|
|
3632
|
+
ctx: c,
|
|
3633
|
+
settings: (cwd) => settings.effective(cwd),
|
|
3634
|
+
store,
|
|
3635
|
+
emit: (kind, data) => orchestration?.recordEvent(kind, data)
|
|
3636
|
+
}));
|
|
3637
|
+
const isPlanActive = (agent) => {
|
|
3638
|
+
try {
|
|
3639
|
+
return Boolean(c.sessionProjections.stateOf(agent.session, "plan")?.active);
|
|
3640
|
+
} catch {
|
|
3641
|
+
return false;
|
|
3642
|
+
}
|
|
3643
|
+
};
|
|
3644
|
+
const isEnabled = (agent) => Boolean(agent && settings.effective(agent.session?.header?.cwd).enabled);
|
|
3645
|
+
const firstPolicySeen = /* @__PURE__ */ new WeakSet();
|
|
3646
|
+
c.effect(() => configureValidationShell(c.shell), "plan-orchestrator: sandbox validation runtime");
|
|
3647
|
+
c.effect(() => configureRoleTimeoutResolver((cwd) => settings.effective(cwd).execution.roleTimeoutMs), "plan-orchestrator: role timeout runtime");
|
|
3648
|
+
c.systemPrompt.section({
|
|
3649
|
+
name: "plan-orchestrator:policy",
|
|
3650
|
+
order: (c.systemPrompt.getSectionOrder?.("PLAN_POLICY") ?? 500) + 1,
|
|
3651
|
+
text: ({ agent }) => {
|
|
3652
|
+
if (!agent) return "";
|
|
3653
|
+
const effective = settings.effective(agent.session.header?.cwd);
|
|
3654
|
+
const session = agent.session;
|
|
3655
|
+
if (!effective.enabled) {
|
|
3656
|
+
firstPolicySeen.delete(session);
|
|
3657
|
+
return "";
|
|
3658
|
+
}
|
|
3659
|
+
const active = isPlanActive(agent);
|
|
3660
|
+
const first = !firstPolicySeen.has(session);
|
|
3661
|
+
const text = plannerPolicyText(true, active, first, effective.planning);
|
|
3662
|
+
if (text && first) firstPolicySeen.add(session);
|
|
3663
|
+
return text;
|
|
3664
|
+
}
|
|
3665
|
+
});
|
|
3666
|
+
c.effect(() => installExitPlanValidator(c, bridge, () => settings.get().execution.requireExplicitOwnership, isEnabled), "plan-orchestrator: validate native plan");
|
|
3667
|
+
c.effect(() => readOnly.install(c, isEnabled), "plan-orchestrator: strict read-only tool guard");
|
|
3668
|
+
c.effect(() => installPlannerRoute(c, (agent) => settings.effective(agent.session.header?.cwd).roles.planner, isPlanActive, isEnabled), "plan-orchestrator: planner route");
|
|
3669
|
+
c.effect(() => installEnabledParentFence(c, orchestration, isEnabled), "plan-orchestrator: parent fence");
|
|
3670
|
+
c.on("agent/pre-step", async ({ agent }, next) => {
|
|
3671
|
+
const decision = await next();
|
|
3672
|
+
const effective = settings.effective(agent.session.header?.cwd);
|
|
3673
|
+
if (!effective.enabled) {
|
|
3674
|
+
bridge.clearSession(String(agent.session.id));
|
|
3675
|
+
readOnly.deactivate(agent.session, c.sandboxPolicy);
|
|
3676
|
+
return decision;
|
|
3677
|
+
}
|
|
3678
|
+
if (decision.kind !== "reject" && effective.planning.strictReadOnly && isPlanActive(agent)) readOnly.activate(agent.session, c.sandboxPolicy);
|
|
3679
|
+
else if (!isPlanActive(agent)) readOnly.deactivate(agent.session, c.sandboxPolicy);
|
|
3680
|
+
return decision;
|
|
3681
|
+
}, { prepend: true });
|
|
3682
|
+
c.on("tools/result", (exec, result) => {
|
|
3683
|
+
const staged = consumeApprovedPlanResult(bridge, exec, result, isEnabled);
|
|
3684
|
+
if (!staged) return;
|
|
3685
|
+
const sessionId = String(exec.agent?.session?.id ?? "");
|
|
3686
|
+
orchestration.approve({
|
|
3687
|
+
sessionId,
|
|
3688
|
+
agent: exec.agent,
|
|
3689
|
+
artifact: staged.artifact,
|
|
3690
|
+
planHash: staged.hash,
|
|
3691
|
+
baselineHead: staged.baselineHead
|
|
3692
|
+
});
|
|
3693
|
+
});
|
|
3694
|
+
c.on("session/event", (session, event) => {
|
|
3695
|
+
if (event.type !== "plan/mode") return;
|
|
3696
|
+
firstPolicySeen.delete(session);
|
|
3697
|
+
if (!settings.effective(session.header?.cwd).enabled) {
|
|
3698
|
+
bridge.clearSession(String(session.id));
|
|
3699
|
+
readOnly.deactivate(session, c.sandboxPolicy);
|
|
3700
|
+
return;
|
|
3701
|
+
}
|
|
3702
|
+
if (!event.data.active) {
|
|
3703
|
+
bridge.clearSession(String(session.id));
|
|
3704
|
+
readOnly.deactivate(session, c.sandboxPolicy);
|
|
3705
|
+
}
|
|
3706
|
+
});
|
|
3707
|
+
c.on("agent/session-start", ({ agent }) => {
|
|
3708
|
+
if (!isEnabled(agent)) return;
|
|
3709
|
+
orchestration.reconcileSession(agent).catch((error) => c.logger?.warn?.("plan-orchestrator recovery reconcile failed: %o", error));
|
|
3710
|
+
});
|
|
3711
|
+
c.inject(["commands"], (scope) => scope.effect(() => installIssueCommand(scope, {
|
|
3712
|
+
settings: (cwd) => settings.effective(cwd),
|
|
3713
|
+
orchestration
|
|
3714
|
+
}), "plan-orchestrator: external issue command"));
|
|
3715
|
+
c.inject(["connection"], (scope) => scope.effect(() => {
|
|
3716
|
+
let disposed = false;
|
|
3717
|
+
let disposeTransport;
|
|
3718
|
+
import("./rpc-server-CGs2ndXP.js").then(({ registerRpc }) => {
|
|
3719
|
+
if (disposed) return;
|
|
3720
|
+
disposeTransport = registerRpc(scope.connection, {
|
|
3721
|
+
ctx: scope,
|
|
3722
|
+
isEnabled: () => settings.get().enabled,
|
|
3723
|
+
canResume: () => settings.get().recovery.allowSafeResume,
|
|
3724
|
+
runList: ({ sessionId }) => orchestration.list(sessionId),
|
|
3725
|
+
runDetail: ({ runId }) => orchestration.detail(runId),
|
|
3726
|
+
runCancel: ({ runId }) => orchestration.cancel(runId),
|
|
3727
|
+
runResume: ({ runId }) => orchestration.resume(runId),
|
|
3728
|
+
runCleanup: ({ runId }) => orchestration.cleanup(runId),
|
|
3729
|
+
externalPreflight: async (body) => {
|
|
3730
|
+
const cwd = typeof body.cwd === "string" ? await repoRoot(body.cwd) : void 0;
|
|
3731
|
+
if (!cwd || typeof body.repository !== "string") throw new Error("cwd and repository required");
|
|
3732
|
+
return ghPreflight(cwd, body.repository);
|
|
3733
|
+
},
|
|
3734
|
+
diagnostics: async (body) => {
|
|
3735
|
+
const cwd = typeof body.cwd === "string" ? body.cwd : void 0;
|
|
3736
|
+
let gitAvailable = await commandAvailable("git", ["--version"]);
|
|
3737
|
+
let repo = false;
|
|
3738
|
+
if (gitAvailable && cwd) try {
|
|
3739
|
+
await repoRoot(cwd);
|
|
3740
|
+
repo = true;
|
|
3741
|
+
} catch {}
|
|
3742
|
+
return {
|
|
3743
|
+
pluginVersion: "1.0.0",
|
|
3744
|
+
dshVersion: packageVersion("@deepseek-ai/dsh-plan-mode"),
|
|
3745
|
+
compatibility: {
|
|
3746
|
+
supported: ["0.1.5-rc.1"],
|
|
3747
|
+
preview: ["0.1.5-rc.2"]
|
|
3748
|
+
},
|
|
3749
|
+
nativePlanMode: hasService(scope, "planMode"),
|
|
3750
|
+
sdkAvailable: packageVersion("@deepseek-ai/dsh-sdk-client") !== void 0,
|
|
3751
|
+
gitAvailable,
|
|
3752
|
+
gitRepository: repo,
|
|
3753
|
+
lspAvailable: hasService(scope, "lsp"),
|
|
3754
|
+
ghAvailable: await commandAvailable("gh", ["--version"]),
|
|
3755
|
+
settingsWritable: settings.writable(),
|
|
3756
|
+
storage: store.root,
|
|
3757
|
+
readOnlyDegraded: typeof body.sessionId === "string" ? readOnly.degraded(body.sessionId) : void 0
|
|
3758
|
+
};
|
|
3759
|
+
}
|
|
3760
|
+
});
|
|
3761
|
+
}).catch((error) => scope.logger?.error?.("plan-orchestrator RPC registration failed: %o", error));
|
|
3762
|
+
return () => {
|
|
3763
|
+
disposed = true;
|
|
3764
|
+
disposeTransport?.();
|
|
3765
|
+
};
|
|
3766
|
+
}, "plan-orchestrator: rpc"));
|
|
3767
|
+
settings.watch((value) => {
|
|
3768
|
+
if (!value.enabled) {
|
|
3769
|
+
orchestration.cancelAll("Plan Orchestrator disabled in settings").catch((error) => c.logger?.warn?.("plan-orchestrator disable cancellation failed: %o", error));
|
|
3770
|
+
return;
|
|
3771
|
+
}
|
|
3772
|
+
for (const role of Object.values(value.roles)) if (role.mode === "fixed") validateFixedRoute(c.llm, role).catch((error) => c.logger?.warn?.("plan-orchestrator fixed route unavailable: %s", error.message));
|
|
3773
|
+
});
|
|
3774
|
+
}
|
|
3775
|
+
//#endregion
|
|
3776
|
+
export { apply, inject, name };
|