sortie-dogs 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -26
- package/dist/asset-version.d.ts +1 -1
- package/dist/asset-version.js +1 -1
- package/dist/core/acceptance-continuity.js +2 -2
- package/dist/core/initialize.js +45 -0
- package/dist/core/luna-fabric-contract.d.ts +54 -0
- package/dist/core/luna-fabric-contract.js +286 -0
- package/dist/core/luna-fabric-scheduler.d.ts +31 -0
- package/dist/core/luna-fabric-scheduler.js +138 -0
- package/dist/core/types.d.ts +37 -1
- package/dist/core/validate-schema.js +1 -1
- package/dist/core/validate-worktree-parallel.js +1 -1
- package/dist/core/worktree-commit-artifact.js +8 -3
- package/dist/core/worktree-integration-queue.js +6 -5
- package/dist/core/worktree-lifecycle.d.ts +9 -0
- package/dist/core/worktree-lifecycle.js +45 -14
- package/dist/core/worktree-parallel-dispatch.d.ts +42 -1
- package/dist/core/worktree-parallel-dispatch.js +1216 -50
- package/dist/index.d.ts +7 -3
- package/dist/index.js +3 -1
- package/dist/plugin/config.d.ts +4 -2
- package/dist/plugin/config.js +21 -8
- package/dist/plugin/continuation.d.ts +28 -2
- package/dist/plugin/continuation.js +107 -0
- package/dist/plugin/fast-lane.d.ts +1 -0
- package/dist/plugin/fast-lane.js +14 -3
- package/dist/plugin/gate.d.ts +1 -1
- package/dist/plugin/gate.js +1 -0
- package/dist/plugin/host-conformance.d.ts +132 -0
- package/dist/plugin/host-conformance.js +56 -0
- package/dist/plugin/index.d.ts +19 -0
- package/dist/plugin/index.js +818 -190
- package/dist/plugin/model-routing.d.ts +18 -21
- package/dist/plugin/model-routing.js +42 -29
- package/dist/plugin/task-result-repair.js +9 -3
- package/dist/runtime-assets.d.ts +13 -8
- package/dist/runtime-assets.js +313 -149
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ Requirements: Node.js 22.6 or newer, npm, and OpenCode.
|
|
|
24
24
|
|
|
25
25
|
Guides: [日本語](docs/guide-ja.md) · [简体中文](docs/guide-zh-CN.md) · [CLI testing](docs/cli-testing.md)
|
|
26
26
|
|
|
27
|
-
Release: [v0.
|
|
27
|
+
Release: [v0.8.0](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.8.0)
|
|
28
28
|
|
|
29
29
|
## Quick start
|
|
30
30
|
|
|
@@ -184,10 +184,14 @@ global file for durable global settings.
|
|
|
184
184
|
Remove the directory only when no Sortie run is active.
|
|
185
185
|
- `readOnlyTools` adds host-specific tool names that never change files, such as
|
|
186
186
|
MCP tools. Unknown tools are denied for a bound session by default.
|
|
187
|
-
- `dedicatedWorkerModel` selects the
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
187
|
+
- `dedicatedWorkerModel` selects the serial implementation target used by
|
|
188
|
+
`implementation`, `remediation`, `blocker-resolution`, `sol-worker-mk2a2`, and
|
|
189
|
+
`dog-worker`. It defaults to `openai/gpt-5.6-sol` with variant `medium`.
|
|
190
|
+
The installed `dog-luna-worker` fabric route remains fixed to
|
|
191
|
+
`openai/gpt-5.6-luna` with variant `max`; pointing the serial target at that
|
|
192
|
+
Luna model is invalid because it would collapse the two route identities. The
|
|
193
|
+
coordinator dispatches this role only for a ready descriptor of a prepared
|
|
194
|
+
`luna-fabric` run; a `sol-serial` run keeps `dog-worker`.
|
|
191
195
|
- `continuation` bounds the batch loop. After a terminal unit and its checkpoint,
|
|
192
196
|
`dog-coordinator` calls `sortie_compact_and_continue`, which compacts the run
|
|
193
197
|
and resumes the same root session on the next independent unit. Only a root
|
|
@@ -223,9 +227,9 @@ global file for durable global settings.
|
|
|
223
227
|
requests are not repeated.
|
|
224
228
|
- **Writes stay inside the assignment.** Exact source or operation manifests
|
|
225
229
|
gate edits and handoffs.
|
|
226
|
-
- **Autonomous
|
|
227
|
-
|
|
228
|
-
parallel contract.
|
|
230
|
+
- **Autonomous implementation routing.** An accepted scope with at least two safe independent
|
|
231
|
+
units defaults to Luna fabric without opt-in. An explicit serial/no-parallel request wins;
|
|
232
|
+
other work uses sequential workers. The separate parallel contract remains explicit.
|
|
229
233
|
- **Runtime overlap protection.** Active equal or ancestor write scopes are rejected
|
|
230
234
|
before mutation, and full validation waits until every parallel unit joins.
|
|
231
235
|
- **Evidence before completion.** Canonical validation, risk-based review, and
|
|
@@ -321,29 +325,40 @@ where the curve gives the most per unit of cost. Nobody selects a model for a
|
|
|
321
325
|
session the loop spawns, which is why delegated roles carry defaults and the
|
|
322
326
|
coordinator does not. Project-local routing can override this default.
|
|
323
327
|
|
|
324
|
-
The `implementation`, `remediation`, `blocker-resolution`,
|
|
325
|
-
roles always use the
|
|
326
|
-
`
|
|
327
|
-
|
|
328
|
-
is
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
328
|
+
The `implementation`, `remediation`, `blocker-resolution`, `sol-worker-mk2a2`,
|
|
329
|
+
and `dog-worker` roles always use the stable serial target,
|
|
330
|
+
`openai/gpt-5.6-sol` with the `medium` variant. `dedicatedWorkerModel` may move
|
|
331
|
+
that serial target when a host cannot serve it. The installed `dog-luna-worker`
|
|
332
|
+
route is separately fixed to `openai/gpt-5.6-luna` with the `max` variant. Its
|
|
333
|
+
shared worker contract requires one validated fabric descriptor: the coordinator
|
|
334
|
+
admits a v0.8 DAG contract with `sortie_admit_luna_fabric`, prepares it with
|
|
335
|
+
`sortie_prepare_luna_fabric`, and materializes only the current ready wave, with
|
|
336
|
+
at most five distinct Luna units. The complete DAG may contain up to 64 units.
|
|
337
|
+
After every active artifact is verified, `sortie_advance_luna_fabric_wave`
|
|
338
|
+
integrates them into a runtime-owned hidden candidate, cleans those worktrees,
|
|
339
|
+
and creates fresh worktrees from that exact snapshot. After the final wave,
|
|
340
|
+
`sortie_validate_luna_fabric_candidate` runs canonical validation once and
|
|
341
|
+
`sortie_accept_luna_fabric_candidate` records review before one target CAS.
|
|
342
|
+
Declared shared-path ownership serializes overlapping units
|
|
343
|
+
across waves; unowned overlap or any admission defect routes the whole job back
|
|
344
|
+
to one `dog-worker`. The fabric never duplicates one unit across lanes.
|
|
345
|
+
`modelRouting` cannot replace either fixed
|
|
346
|
+
route, and a serial override naming the Luna fabric model is invalid rather than
|
|
347
|
+
silently collapsing both identities. Version 0.7.0 routed `dog-worker` to Luna
|
|
348
|
+
Max; v0.8 intentionally preserves that history while splitting stable Sol and
|
|
349
|
+
fabric Luna roles. Other explicit routes try the preferred target, then ordered
|
|
350
|
+
fallbacks. Roles without a built-in default or explicit route keep OpenCode's
|
|
351
|
+
already selected model.
|
|
335
352
|
|
|
336
353
|
`dog-reviewer` and `dog-advisor` must never inherit the caller's model, because
|
|
337
354
|
review and strategy lose their value when they run on the model that produced
|
|
338
355
|
the candidate. Both default to `anthropic/claude-opus-5` when the catalog
|
|
339
356
|
declares it, and otherwise fall back to `openai/gpt-5.6-sol` with the `xhigh`
|
|
340
|
-
variant. That fallback
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
vendor: both roles stay fully configurable, so declare whichever model you can
|
|
346
|
-
actually serve.
|
|
357
|
+
variant. That fallback uses higher effort than the Sol Medium worker because
|
|
358
|
+
review has to be able to reject work the worker just produced. Moving
|
|
359
|
+
`dedicatedWorkerModel` does not change consultation policy. Nothing here requires
|
|
360
|
+
a particular vendor: both roles stay fully configurable, so declare whichever
|
|
361
|
+
model you can actually serve.
|
|
347
362
|
|
|
348
363
|
```json
|
|
349
364
|
{
|
package/dist/asset-version.d.ts
CHANGED
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
* Version of the installable runtime assets. Kept in its own module so the plugin can compare an
|
|
3
3
|
* installed project marker without importing every asset body.
|
|
4
4
|
*/
|
|
5
|
-
export declare const RUNTIME_ASSET_VERSION = "0.3.
|
|
5
|
+
export declare const RUNTIME_ASSET_VERSION = "0.3.69-luna-combined-validation-replay-v1";
|
|
6
6
|
export type RuntimeAssetVersion = typeof RUNTIME_ASSET_VERSION;
|
package/dist/asset-version.js
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
* Version of the installable runtime assets. Kept in its own module so the plugin can compare an
|
|
3
3
|
* installed project marker without importing every asset body.
|
|
4
4
|
*/
|
|
5
|
-
export const RUNTIME_ASSET_VERSION = "0.3.
|
|
5
|
+
export const RUNTIME_ASSET_VERSION = "0.3.69-luna-combined-validation-replay-v1";
|
|
@@ -38,13 +38,13 @@ export function inspectAcceptanceContinuity(handoff) {
|
|
|
38
38
|
raw.task_id.length === 0 || raw.task_id.length > MAX_TASK_ID || !Array.isArray(raw.criteria) ||
|
|
39
39
|
raw.criteria.length === 0 || raw.criteria.length > MAX_ACCEPTANCE_CRITERIA ||
|
|
40
40
|
!raw.criteria.every((criterion) => typeof criterion === "string" && criterion.length > 0 && criterion.length <= MAX_CRITERION) ||
|
|
41
|
-
|
|
41
|
+
typeof raw.fingerprint !== "string" ||
|
|
42
42
|
!FINGERPRINT.test(raw.fingerprint) || !(raw.parent_fingerprint === "none" ||
|
|
43
43
|
(typeof raw.parent_fingerprint === "string" && FINGERPRINT.test(raw.parent_fingerprint)))) {
|
|
44
44
|
return { ledger: undefined, error: "malformed" };
|
|
45
45
|
}
|
|
46
46
|
const criteria = normalizeAcceptanceCriteria(raw.criteria);
|
|
47
|
-
if (
|
|
47
|
+
if (acceptanceContinuityFingerprint(criteria) !== raw.fingerprint) {
|
|
48
48
|
return { ledger: undefined, error: "malformed" };
|
|
49
49
|
}
|
|
50
50
|
return {
|
package/dist/core/initialize.js
CHANGED
|
@@ -8,6 +8,8 @@ const { runtimeAssets } = assets;
|
|
|
8
8
|
const OPEN_CODE_DIRECTORY = ".opencode";
|
|
9
9
|
const VERSION_MARKER = `${OPEN_CODE_DIRECTORY}/sortie-dogs.version`;
|
|
10
10
|
const GLOBAL_VERSION_MARKER = "sortie-dogs.version";
|
|
11
|
+
const LUNA_FABRIC_CONTROL_IGNORE = `${OPEN_CODE_DIRECTORY}/.gitignore`;
|
|
12
|
+
const LUNA_FABRIC_CONTROL_FILE = "sortie-dogs-luna-fabric.json";
|
|
11
13
|
const LEGACY_RUNTIME_ASSETS = [
|
|
12
14
|
{
|
|
13
15
|
relativePath: ".opencode/agent/coordinator-mk2a2.md",
|
|
@@ -263,6 +265,44 @@ async function overwriteFileSafely(root, relativePath, content, beforeMutation)
|
|
|
263
265
|
await handle?.close();
|
|
264
266
|
}
|
|
265
267
|
}
|
|
268
|
+
async function appendFileSafely(root, relativePath, content) {
|
|
269
|
+
await assertSafeExistingPath(root, relativePath, true);
|
|
270
|
+
let handle;
|
|
271
|
+
try {
|
|
272
|
+
handle = await open(resolve(root, relativePath), constants.O_WRONLY | constants.O_APPEND | constants.O_NOFOLLOW);
|
|
273
|
+
if (!(await handle.stat()).isFile()) {
|
|
274
|
+
throw new ProjectInitializationError("conflict", "An initialization file path is not a regular file.");
|
|
275
|
+
}
|
|
276
|
+
await handle.writeFile(content, "utf8");
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
if (error.code === "ELOOP") {
|
|
280
|
+
throw new ProjectInitializationError("unsafe-path", "Initialization paths must not contain symbolic links.", {
|
|
281
|
+
cause: error,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
286
|
+
finally {
|
|
287
|
+
await handle?.close();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
async function ensureProjectLunaControlIgnore(root, createdFiles = [], modifiedFiles = [], createdDirectories = []) {
|
|
291
|
+
const relativePath = LUNA_FABRIC_CONTROL_IGNORE;
|
|
292
|
+
const present = await assertSafeExistingPath(root, relativePath, true);
|
|
293
|
+
if (!present) {
|
|
294
|
+
await ensureDirectory(root, OPEN_CODE_DIRECTORY, createdDirectories);
|
|
295
|
+
await createFile(resolve(root, relativePath), `${LUNA_FABRIC_CONTROL_FILE}\n`, createdFiles);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const current = await readFile(resolve(root, relativePath));
|
|
299
|
+
const text = current.toString("utf8");
|
|
300
|
+
if (new RegExp(`(?:^|\\r?\\n)${LUNA_FABRIC_CONTROL_FILE.replace(".", "\\.")}(?:\\r?\\n|$)`, "u").test(text))
|
|
301
|
+
return;
|
|
302
|
+
modifiedFiles.push({ relativePath, content: current });
|
|
303
|
+
const separator = text.endsWith("\n") ? "" : "\n";
|
|
304
|
+
await appendFileSafely(root, relativePath, `${separator}${LUNA_FABRIC_CONTROL_FILE}\n`);
|
|
305
|
+
}
|
|
266
306
|
const PROJECT_LAYOUT = {
|
|
267
307
|
assetPrefix: OPEN_CODE_DIRECTORY,
|
|
268
308
|
markerPath: VERSION_MARKER,
|
|
@@ -313,6 +353,8 @@ async function initializeRoot(requestedRoot, layout) {
|
|
|
313
353
|
const matches = (entry, index) => existing[index]?.equals(Buffer.from(entry.content)) ?? false;
|
|
314
354
|
const assetsMatch = assetEntries.every(matches);
|
|
315
355
|
if (markerText !== undefined && parseMarker(markerText.toString("utf8")) === version && assetsMatch) {
|
|
356
|
+
if (!layout.preserveAllLegacy)
|
|
357
|
+
await ensureProjectLunaControlIgnore(root);
|
|
316
358
|
return {
|
|
317
359
|
status: "unchanged",
|
|
318
360
|
version,
|
|
@@ -350,6 +392,9 @@ async function initializeRoot(requestedRoot, layout) {
|
|
|
350
392
|
const removedFiles = [];
|
|
351
393
|
const createdDirectories = [];
|
|
352
394
|
try {
|
|
395
|
+
if (!layout.preserveAllLegacy) {
|
|
396
|
+
await ensureProjectLunaControlIgnore(root, createdFiles, modifiedFiles, createdDirectories);
|
|
397
|
+
}
|
|
353
398
|
for (let index = 0; index < entries.length; index += 1) {
|
|
354
399
|
const entry = entries[index];
|
|
355
400
|
if (matches(entry, index))
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export declare const LUNA_FABRIC_CONTRACT_VERSION = "0.8.0";
|
|
2
|
+
export type LunaFabricExternalEffect = "release" | "publish" | "tag" | "version" | "remote-state" | "credential" | "irreversible";
|
|
3
|
+
export interface LunaFabricProvenance {
|
|
4
|
+
readonly source: "dog-coordinator";
|
|
5
|
+
readonly acceptance_fingerprint: string;
|
|
6
|
+
readonly target_branch: string;
|
|
7
|
+
readonly target_sha: string;
|
|
8
|
+
}
|
|
9
|
+
export interface LunaFabricValidation {
|
|
10
|
+
readonly level: "targeted" | "full";
|
|
11
|
+
readonly command: readonly string[];
|
|
12
|
+
}
|
|
13
|
+
export interface LunaFabricUnit {
|
|
14
|
+
readonly unit_id: string;
|
|
15
|
+
readonly acceptance_items: readonly string[];
|
|
16
|
+
readonly scope_read: readonly string[];
|
|
17
|
+
readonly scope_write: readonly string[];
|
|
18
|
+
readonly depends_on: readonly string[];
|
|
19
|
+
readonly validation: LunaFabricValidation;
|
|
20
|
+
readonly shared_path_keys: readonly string[];
|
|
21
|
+
readonly exclusive_resources: readonly string[];
|
|
22
|
+
readonly scheduler_order: number;
|
|
23
|
+
}
|
|
24
|
+
export interface LunaFabricSharedPath {
|
|
25
|
+
readonly key: string;
|
|
26
|
+
readonly path: string;
|
|
27
|
+
}
|
|
28
|
+
export interface LunaFabricContract {
|
|
29
|
+
readonly version: typeof LUNA_FABRIC_CONTRACT_VERSION;
|
|
30
|
+
readonly provenance: LunaFabricProvenance;
|
|
31
|
+
readonly acceptance_items: readonly string[];
|
|
32
|
+
readonly effects: readonly LunaFabricExternalEffect[];
|
|
33
|
+
readonly shared_paths: readonly LunaFabricSharedPath[];
|
|
34
|
+
readonly units: readonly LunaFabricUnit[];
|
|
35
|
+
}
|
|
36
|
+
export type LunaFabricSolReason = "malformed-contract" | "external-effect" | "fewer-than-two-units" | "invalid-scope" | "dependency-invalid" | "acceptance-unowned" | "shared-path-unowned" | "exclusive-resource-conflict" | "no-safe-parallel-width";
|
|
37
|
+
export interface LunaFabricDiagnostic {
|
|
38
|
+
readonly code: LunaFabricSolReason;
|
|
39
|
+
readonly pointer: string;
|
|
40
|
+
readonly detail: string;
|
|
41
|
+
}
|
|
42
|
+
export type LunaFabricAdmission = {
|
|
43
|
+
readonly route: "luna-fabric";
|
|
44
|
+
readonly contract: LunaFabricContract;
|
|
45
|
+
readonly contract_fingerprint: string;
|
|
46
|
+
readonly width: number;
|
|
47
|
+
readonly depth: number;
|
|
48
|
+
} | {
|
|
49
|
+
readonly route: "sol-serial";
|
|
50
|
+
readonly reason: LunaFabricSolReason;
|
|
51
|
+
readonly diagnostics: readonly LunaFabricDiagnostic[];
|
|
52
|
+
};
|
|
53
|
+
/** Pure automatic route decision. Unknown or unsafe input always resolves to the stable Sol route. */
|
|
54
|
+
export declare function admitLunaFabric(value: unknown): LunaFabricAdmission;
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { normalizeWorktreeScopePath, worktreeScopesOverlap } from "./worktree-scope.js";
|
|
3
|
+
export const LUNA_FABRIC_CONTRACT_VERSION = "0.8.0";
|
|
4
|
+
const IDS = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
|
|
5
|
+
const SHA = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u;
|
|
6
|
+
const FINGERPRINT = /^[a-f0-9]{64}$/u;
|
|
7
|
+
const EFFECTS = new Set([
|
|
8
|
+
"release", "publish", "tag", "version", "remote-state", "credential", "irreversible",
|
|
9
|
+
]);
|
|
10
|
+
const MAX_UNITS = 64;
|
|
11
|
+
function isRecord(value) {
|
|
12
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
function exactKeys(value, keys) {
|
|
15
|
+
const actual = Object.keys(value).sort();
|
|
16
|
+
const expected = [...keys].sort();
|
|
17
|
+
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
|
|
18
|
+
}
|
|
19
|
+
function text(value, maximum = 512) {
|
|
20
|
+
return typeof value === "string" && value.length > 0 && value.length <= maximum &&
|
|
21
|
+
!/[\u0000-\u001f\u007f]/u.test(value);
|
|
22
|
+
}
|
|
23
|
+
function stringList(value, maximum = 256) {
|
|
24
|
+
return Array.isArray(value) && value.length <= maximum && value.every((entry) => text(entry));
|
|
25
|
+
}
|
|
26
|
+
function unique(values) {
|
|
27
|
+
return new Set(values).size === values.length;
|
|
28
|
+
}
|
|
29
|
+
function exactPath(value) {
|
|
30
|
+
if (!text(value))
|
|
31
|
+
return false;
|
|
32
|
+
try {
|
|
33
|
+
return normalizeWorktreeScopePath(value) === value;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function canonical(value) {
|
|
40
|
+
if (Array.isArray(value))
|
|
41
|
+
return value.map(canonical);
|
|
42
|
+
if (!isRecord(value))
|
|
43
|
+
return value;
|
|
44
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
|
|
45
|
+
}
|
|
46
|
+
function contractFingerprint(contract) {
|
|
47
|
+
return createHash("sha256").update(JSON.stringify(canonical(contract))).digest("hex");
|
|
48
|
+
}
|
|
49
|
+
function malformed(pointer, detail) {
|
|
50
|
+
return {
|
|
51
|
+
route: "sol-serial",
|
|
52
|
+
reason: "malformed-contract",
|
|
53
|
+
diagnostics: [{ code: "malformed-contract", pointer, detail }],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function parseContract(value) {
|
|
57
|
+
if (!isRecord(value) || !exactKeys(value, [
|
|
58
|
+
"acceptance_items", "effects", "provenance", "shared_paths", "units", "version",
|
|
59
|
+
]))
|
|
60
|
+
return malformed("/", "contract shape must contain only the closed v0.8 fields");
|
|
61
|
+
if (value.version !== LUNA_FABRIC_CONTRACT_VERSION)
|
|
62
|
+
return malformed("/version", "unsupported version");
|
|
63
|
+
if (!isRecord(value.provenance) || !exactKeys(value.provenance, [
|
|
64
|
+
"acceptance_fingerprint", "source", "target_branch", "target_sha",
|
|
65
|
+
]) || value.provenance.source !== "dog-coordinator" ||
|
|
66
|
+
typeof value.provenance.acceptance_fingerprint !== "string" ||
|
|
67
|
+
!FINGERPRINT.test(value.provenance.acceptance_fingerprint) || !text(value.provenance.target_branch, 256) ||
|
|
68
|
+
typeof value.provenance.target_sha !== "string" || !SHA.test(value.provenance.target_sha)) {
|
|
69
|
+
return malformed("/provenance", "provenance must identify dog-coordinator, acceptance, and exact target");
|
|
70
|
+
}
|
|
71
|
+
if (!stringList(value.acceptance_items) || value.acceptance_items.length === 0 ||
|
|
72
|
+
!unique(value.acceptance_items))
|
|
73
|
+
return malformed("/acceptance_items", "acceptance items must be unique");
|
|
74
|
+
if (!Array.isArray(value.effects) || !unique(value.effects) ||
|
|
75
|
+
value.effects.some((effect) => typeof effect !== "string" || !EFFECTS.has(effect))) {
|
|
76
|
+
return malformed("/effects", "effects must use known classifications");
|
|
77
|
+
}
|
|
78
|
+
if (!Array.isArray(value.shared_paths) || value.shared_paths.length > 256) {
|
|
79
|
+
return malformed("/shared_paths", "shared path ownership must be bounded");
|
|
80
|
+
}
|
|
81
|
+
const sharedPaths = [];
|
|
82
|
+
const sharedKeys = new Set();
|
|
83
|
+
const sharedPathIdentities = new Set();
|
|
84
|
+
for (let index = 0; index < value.shared_paths.length; index += 1) {
|
|
85
|
+
const entry = value.shared_paths[index];
|
|
86
|
+
if (isRecord(entry) && typeof entry.path === "string" && !exactPath(entry.path)) {
|
|
87
|
+
return reject("invalid-scope", `/shared_paths/${index}/path`, "shared path must be normalized repository-relative");
|
|
88
|
+
}
|
|
89
|
+
if (!isRecord(entry) || !exactKeys(entry, ["key", "path"]) || !text(entry.key, 128) ||
|
|
90
|
+
!IDS.test(entry.key) || !exactPath(entry.path) || sharedKeys.has(entry.key) ||
|
|
91
|
+
sharedPathIdentities.has(entry.path.toLowerCase())) {
|
|
92
|
+
return malformed(`/shared_paths/${index}`, "shared path key and exact path must be unique");
|
|
93
|
+
}
|
|
94
|
+
sharedKeys.add(entry.key);
|
|
95
|
+
sharedPathIdentities.add(entry.path.toLowerCase());
|
|
96
|
+
sharedPaths.push({ key: entry.key, path: entry.path });
|
|
97
|
+
}
|
|
98
|
+
if (!Array.isArray(value.units) || value.units.length === 0 || value.units.length > MAX_UNITS) {
|
|
99
|
+
return malformed("/units", "unit count must be bounded");
|
|
100
|
+
}
|
|
101
|
+
const units = [];
|
|
102
|
+
const unitIDs = new Set();
|
|
103
|
+
const schedulerOrders = new Set();
|
|
104
|
+
for (let index = 0; index < value.units.length; index += 1) {
|
|
105
|
+
const unit = value.units[index];
|
|
106
|
+
if (isRecord(unit)) {
|
|
107
|
+
for (const field of ["scope_read", "scope_write"]) {
|
|
108
|
+
if (Array.isArray(unit[field]) && unit[field].some((path) => typeof path === "string" && !exactPath(path))) {
|
|
109
|
+
return reject("invalid-scope", `/units/${index}/${field}`, "scope must use normalized repository-relative paths");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (!isRecord(unit) || !exactKeys(unit, [
|
|
114
|
+
"acceptance_items", "depends_on", "exclusive_resources", "scheduler_order", "scope_read",
|
|
115
|
+
"scope_write", "shared_path_keys", "unit_id", "validation",
|
|
116
|
+
]) || !text(unit.unit_id, 128) || !IDS.test(unit.unit_id) || unitIDs.has(unit.unit_id) ||
|
|
117
|
+
!stringList(unit.acceptance_items) || unit.acceptance_items.length === 0 || !unique(unit.acceptance_items) ||
|
|
118
|
+
!stringList(unit.depends_on, MAX_UNITS) || !unique(unit.depends_on) ||
|
|
119
|
+
!stringList(unit.scope_read) || !unique(unit.scope_read) || !unit.scope_read.every(exactPath) ||
|
|
120
|
+
!stringList(unit.scope_write) || unit.scope_write.length === 0 || !unique(unit.scope_write) ||
|
|
121
|
+
!unit.scope_write.every(exactPath) || !stringList(unit.shared_path_keys) || !unique(unit.shared_path_keys) ||
|
|
122
|
+
!unit.shared_path_keys.every((key) => sharedKeys.has(key)) || !stringList(unit.exclusive_resources, 64) ||
|
|
123
|
+
!unique(unit.exclusive_resources) || !unit.exclusive_resources.every((resource) => IDS.test(resource)) ||
|
|
124
|
+
!Number.isInteger(unit.scheduler_order) || unit.scheduler_order < 0 ||
|
|
125
|
+
schedulerOrders.has(unit.scheduler_order) || !isRecord(unit.validation) ||
|
|
126
|
+
!exactKeys(unit.validation, ["command", "level"]) ||
|
|
127
|
+
(unit.validation.level !== "targeted" && unit.validation.level !== "full") ||
|
|
128
|
+
!Array.isArray(unit.validation.command) || unit.validation.command.length === 0 ||
|
|
129
|
+
unit.validation.command.length > 129 || !unit.validation.command.every((part) => text(part, 1000))) {
|
|
130
|
+
return malformed(`/units/${index}`, "unit identity, scope, ownership, resources, and validation must be exact");
|
|
131
|
+
}
|
|
132
|
+
unitIDs.add(unit.unit_id);
|
|
133
|
+
schedulerOrders.add(unit.scheduler_order);
|
|
134
|
+
units.push({
|
|
135
|
+
unit_id: unit.unit_id,
|
|
136
|
+
acceptance_items: Object.freeze([...unit.acceptance_items]),
|
|
137
|
+
scope_read: Object.freeze([...unit.scope_read]),
|
|
138
|
+
scope_write: Object.freeze([...unit.scope_write]),
|
|
139
|
+
depends_on: Object.freeze([...unit.depends_on]),
|
|
140
|
+
validation: Object.freeze({
|
|
141
|
+
level: unit.validation.level,
|
|
142
|
+
command: Object.freeze([...unit.validation.command]),
|
|
143
|
+
}),
|
|
144
|
+
shared_path_keys: Object.freeze([...unit.shared_path_keys]),
|
|
145
|
+
exclusive_resources: Object.freeze([...unit.exclusive_resources]),
|
|
146
|
+
scheduler_order: unit.scheduler_order,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return Object.freeze({
|
|
150
|
+
version: LUNA_FABRIC_CONTRACT_VERSION,
|
|
151
|
+
provenance: Object.freeze(value.provenance),
|
|
152
|
+
acceptance_items: Object.freeze([...value.acceptance_items]),
|
|
153
|
+
effects: Object.freeze([...value.effects]),
|
|
154
|
+
shared_paths: Object.freeze(sharedPaths.map((entry) => Object.freeze(entry))),
|
|
155
|
+
units: Object.freeze(units.map((unit) => Object.freeze(unit))),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
function dependencyClosure(units) {
|
|
159
|
+
const byID = new Map(units.map((unit) => [unit.unit_id, unit]));
|
|
160
|
+
if (units.some((unit) => unit.depends_on.some((dependency) => dependency === unit.unit_id || !byID.has(dependency)))) {
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
const closure = new Map();
|
|
164
|
+
const visiting = new Set();
|
|
165
|
+
const visit = (unitID) => {
|
|
166
|
+
if (visiting.has(unitID))
|
|
167
|
+
return undefined;
|
|
168
|
+
const cached = closure.get(unitID);
|
|
169
|
+
if (cached !== undefined)
|
|
170
|
+
return cached;
|
|
171
|
+
visiting.add(unitID);
|
|
172
|
+
const result = new Set();
|
|
173
|
+
for (const dependency of byID.get(unitID).depends_on) {
|
|
174
|
+
const nested = visit(dependency);
|
|
175
|
+
if (nested === undefined)
|
|
176
|
+
return undefined;
|
|
177
|
+
result.add(dependency);
|
|
178
|
+
for (const ancestor of nested)
|
|
179
|
+
result.add(ancestor);
|
|
180
|
+
}
|
|
181
|
+
visiting.delete(unitID);
|
|
182
|
+
closure.set(unitID, result);
|
|
183
|
+
return result;
|
|
184
|
+
};
|
|
185
|
+
for (const unit of units)
|
|
186
|
+
if (visit(unit.unit_id) === undefined)
|
|
187
|
+
return undefined;
|
|
188
|
+
return closure;
|
|
189
|
+
}
|
|
190
|
+
function writeRelatedOverlap(left, right) {
|
|
191
|
+
return left.scope_write.some((path) => [...right.scope_write, ...right.scope_read]
|
|
192
|
+
.some((other) => worktreeScopesOverlap(path, other))) ||
|
|
193
|
+
right.scope_write.some((path) => left.scope_read.some((other) => worktreeScopesOverlap(path, other)));
|
|
194
|
+
}
|
|
195
|
+
function sharedOwnerCovers(left, right, sharedPaths) {
|
|
196
|
+
const common = new Set(left.shared_path_keys.filter((key) => right.shared_path_keys.includes(key)));
|
|
197
|
+
return sharedPaths.some((entry) => common.has(entry.key) &&
|
|
198
|
+
[...left.scope_write, ...left.scope_read].some((path) => worktreeScopesOverlap(entry.path, path)) &&
|
|
199
|
+
[...right.scope_write, ...right.scope_read].some((path) => worktreeScopesOverlap(entry.path, path)));
|
|
200
|
+
}
|
|
201
|
+
function incomparable(left, right, closure) {
|
|
202
|
+
return !closure.get(left.unit_id).has(right.unit_id) && !closure.get(right.unit_id).has(left.unit_id);
|
|
203
|
+
}
|
|
204
|
+
function maximumSafeWidth(units, closure) {
|
|
205
|
+
let maximum = 1;
|
|
206
|
+
for (let leftIndex = 0; leftIndex < units.length; leftIndex += 1) {
|
|
207
|
+
const selected = [units[leftIndex]];
|
|
208
|
+
for (let rightIndex = leftIndex + 1; rightIndex < units.length; rightIndex += 1) {
|
|
209
|
+
const candidate = units[rightIndex];
|
|
210
|
+
if (selected.every((existing) => incomparable(existing, candidate, closure) &&
|
|
211
|
+
!writeRelatedOverlap(existing, candidate) &&
|
|
212
|
+
!existing.exclusive_resources.some((resource) => candidate.exclusive_resources.includes(resource)))) {
|
|
213
|
+
selected.push(candidate);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
maximum = Math.max(maximum, selected.length);
|
|
217
|
+
}
|
|
218
|
+
return maximum;
|
|
219
|
+
}
|
|
220
|
+
function dagDepth(units) {
|
|
221
|
+
const byID = new Map(units.map((unit) => [unit.unit_id, unit]));
|
|
222
|
+
const depths = new Map();
|
|
223
|
+
const depth = (unitID) => {
|
|
224
|
+
const cached = depths.get(unitID);
|
|
225
|
+
if (cached !== undefined)
|
|
226
|
+
return cached;
|
|
227
|
+
const dependencies = byID.get(unitID).depends_on;
|
|
228
|
+
const value = dependencies.length === 0 ? 1 : 1 + Math.max(...dependencies.map(depth));
|
|
229
|
+
depths.set(unitID, value);
|
|
230
|
+
return value;
|
|
231
|
+
};
|
|
232
|
+
return Math.max(...units.map((unit) => depth(unit.unit_id)));
|
|
233
|
+
}
|
|
234
|
+
function reject(reason, pointer, detail) {
|
|
235
|
+
return { route: "sol-serial", reason, diagnostics: [{ code: reason, pointer, detail }] };
|
|
236
|
+
}
|
|
237
|
+
/** Pure automatic route decision. Unknown or unsafe input always resolves to the stable Sol route. */
|
|
238
|
+
export function admitLunaFabric(value) {
|
|
239
|
+
const parsed = parseContract(value);
|
|
240
|
+
if ("route" in parsed)
|
|
241
|
+
return parsed;
|
|
242
|
+
if (parsed.effects.length > 0)
|
|
243
|
+
return reject("external-effect", "/effects", parsed.effects.join(","));
|
|
244
|
+
if (parsed.units.length < 2)
|
|
245
|
+
return reject("fewer-than-two-units", "/units", "fabric needs useful width");
|
|
246
|
+
const closure = dependencyClosure(parsed.units);
|
|
247
|
+
if (closure === undefined)
|
|
248
|
+
return reject("dependency-invalid", "/units", "dependency is unknown or cyclic");
|
|
249
|
+
const accepted = new Map(parsed.acceptance_items.map((item) => [item, 0]));
|
|
250
|
+
for (const unit of parsed.units) {
|
|
251
|
+
for (const item of unit.acceptance_items) {
|
|
252
|
+
if (!accepted.has(item))
|
|
253
|
+
return reject("acceptance-unowned", "/units", `unknown acceptance item: ${item}`);
|
|
254
|
+
accepted.set(item, accepted.get(item) + 1);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const invalidAcceptance = [...accepted].find(([, count]) => count !== 1);
|
|
258
|
+
if (invalidAcceptance !== undefined) {
|
|
259
|
+
return reject("acceptance-unowned", "/acceptance_items", `${invalidAcceptance[0]} ownership=${invalidAcceptance[1]}`);
|
|
260
|
+
}
|
|
261
|
+
for (let leftIndex = 0; leftIndex < parsed.units.length; leftIndex += 1) {
|
|
262
|
+
const left = parsed.units[leftIndex];
|
|
263
|
+
for (let rightIndex = leftIndex + 1; rightIndex < parsed.units.length; rightIndex += 1) {
|
|
264
|
+
const right = parsed.units[rightIndex];
|
|
265
|
+
if (!incomparable(left, right, closure))
|
|
266
|
+
continue;
|
|
267
|
+
if (writeRelatedOverlap(left, right) && !sharedOwnerCovers(left, right, parsed.shared_paths)) {
|
|
268
|
+
return reject("shared-path-unowned", `/units/${rightIndex}/scope_write`, `${left.unit_id}:${right.unit_id}`);
|
|
269
|
+
}
|
|
270
|
+
const resource = left.exclusive_resources.find((entry) => right.exclusive_resources.includes(entry));
|
|
271
|
+
if (resource !== undefined) {
|
|
272
|
+
return reject("exclusive-resource-conflict", `/units/${rightIndex}/exclusive_resources`, resource);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const width = maximumSafeWidth(parsed.units, closure);
|
|
277
|
+
if (width < 2)
|
|
278
|
+
return reject("no-safe-parallel-width", "/units", "no independent disjoint pair");
|
|
279
|
+
return Object.freeze({
|
|
280
|
+
route: "luna-fabric",
|
|
281
|
+
contract: parsed,
|
|
282
|
+
contract_fingerprint: contractFingerprint(parsed),
|
|
283
|
+
width,
|
|
284
|
+
depth: dagDepth(parsed.units),
|
|
285
|
+
});
|
|
286
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { LunaFabricContract } from "./luna-fabric-contract.js";
|
|
2
|
+
export declare const LUNA_FABRIC_MAX_ACTIVE = 5;
|
|
3
|
+
export interface LunaFabricWave {
|
|
4
|
+
readonly number: number;
|
|
5
|
+
readonly base_sha: string;
|
|
6
|
+
readonly unit_ids: readonly string[];
|
|
7
|
+
readonly lanes: Readonly<Record<string, number>>;
|
|
8
|
+
}
|
|
9
|
+
export interface LunaFabricSchedulerState {
|
|
10
|
+
readonly wave: number;
|
|
11
|
+
readonly base_sha: string;
|
|
12
|
+
readonly pending: readonly string[];
|
|
13
|
+
readonly completed: readonly string[];
|
|
14
|
+
readonly active: LunaFabricWave | null;
|
|
15
|
+
readonly lane_affinity: Readonly<Record<string, number>>;
|
|
16
|
+
}
|
|
17
|
+
export interface LunaFabricAdvance {
|
|
18
|
+
readonly candidate_base: string;
|
|
19
|
+
readonly completed_unit_ids: readonly string[];
|
|
20
|
+
}
|
|
21
|
+
export declare class LunaFabricScheduler {
|
|
22
|
+
private readonly contract;
|
|
23
|
+
private readonly byID;
|
|
24
|
+
constructor(contract: LunaFabricContract, base_sha: string, state?: LunaFabricSchedulerState);
|
|
25
|
+
private state;
|
|
26
|
+
snapshot(): LunaFabricSchedulerState;
|
|
27
|
+
nextWave(): LunaFabricWave | null;
|
|
28
|
+
advance(advancement: LunaFabricAdvance): LunaFabricSchedulerState;
|
|
29
|
+
private validateState;
|
|
30
|
+
}
|
|
31
|
+
export declare function createLunaFabricScheduler(contract: LunaFabricContract, base_sha: string, state?: LunaFabricSchedulerState): LunaFabricScheduler;
|