@ikon85/agent-workflow-kit 0.32.1 → 0.33.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/.agents/skills/orchestrate-wave/SKILL.md +54 -54
- package/.agents/skills/orchestrate-wave/references/builder-contract.md +16 -3
- package/.agents/skills/orchestrate-wave/references/dispatch-subagents.md +79 -0
- package/.agents/skills/orchestrate-wave/references/dispatch-workflow.md +58 -0
- package/.agents/skills/orchestrate-wave/references/report-contracts.md +42 -0
- package/.claude/skills/orchestrate-wave/SKILL.md +54 -54
- package/.claude/skills/orchestrate-wave/references/builder-contract.md +16 -3
- package/.claude/skills/orchestrate-wave/references/dispatch-subagents.md +79 -0
- package/.claude/skills/orchestrate-wave/references/dispatch-workflow.md +58 -0
- package/.claude/skills/orchestrate-wave/references/report-contracts.md +42 -0
- package/README.md +47 -0
- package/agent-workflow-kit.package.json +98 -6
- package/docs/adr/0002-capability-gated-orchestration.md +70 -0
- package/package.json +1 -1
- package/scripts/test_orchestrate_wave_contract.py +136 -0
- package/scripts/test_worktree_setup_base_guard.py +140 -0
- package/scripts/worktree-lifecycle/setup.py +40 -0
- package/src/lib/bundle.mjs +10 -0
- package/src/lib/capabilityMatrix.mjs +94 -0
- package/src/lib/reconcileReconReports.mjs +111 -0
- package/src/lib/reportValidator.mjs +186 -0
- package/src/lib/waveClaim.mjs +134 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Worktree reuse must refuse a stale base on BOTH reuse paths.
|
|
3
|
+
|
|
4
|
+
`setup.py create()` can reach an existing worktree two ways: the target is
|
|
5
|
+
already a registered worktree, or only its branch exists locally and
|
|
6
|
+
`git worktree add <target> <branch>` silently ignores `--base`. Reuse is safe
|
|
7
|
+
only when the reused HEAD is AT the base or cleanly behind it (fast-forwardable);
|
|
8
|
+
anything ahead or diverged builds the slice on a stale base and must STOP.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import importlib.util
|
|
12
|
+
import os
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
import unittest
|
|
17
|
+
from argparse import Namespace
|
|
18
|
+
from contextlib import contextmanager
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
REPO = Path(__file__).resolve().parent.parent
|
|
22
|
+
LIFECYCLE = REPO / "scripts/worktree-lifecycle"
|
|
23
|
+
|
|
24
|
+
PROFILE = """{
|
|
25
|
+
"worktreeLifecycle": {
|
|
26
|
+
"enabled": true,
|
|
27
|
+
"worktreeRoot": ".worktrees",
|
|
28
|
+
"branchTemplate": "{type}/{issue}-{slug}",
|
|
29
|
+
"pathTemplate": "{issue}-{slug}",
|
|
30
|
+
"setupSteps": []
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_setup():
|
|
37
|
+
sys.path.insert(0, str(LIFECYCLE))
|
|
38
|
+
try:
|
|
39
|
+
spec = importlib.util.spec_from_file_location(
|
|
40
|
+
"worktree_setup_base_guard", LIFECYCLE / "setup.py"
|
|
41
|
+
)
|
|
42
|
+
module = importlib.util.module_from_spec(spec)
|
|
43
|
+
spec.loader.exec_module(module)
|
|
44
|
+
return module
|
|
45
|
+
finally:
|
|
46
|
+
sys.path.remove(str(LIFECYCLE))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def git(repo: Path, *args: str) -> str:
|
|
50
|
+
result = subprocess.run(
|
|
51
|
+
["git", *args], cwd=repo, capture_output=True, text=True, check=True
|
|
52
|
+
)
|
|
53
|
+
return result.stdout.strip()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@contextmanager
|
|
57
|
+
def fixture():
|
|
58
|
+
"""A repo whose `main` carries two commits: base_first, then head."""
|
|
59
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
60
|
+
repo = Path(tmp).resolve() / "repo"
|
|
61
|
+
repo.mkdir()
|
|
62
|
+
git(repo, "init", "--initial-branch=main")
|
|
63
|
+
git(repo, "config", "user.email", "test@example.invalid")
|
|
64
|
+
git(repo, "config", "user.name", "Test User")
|
|
65
|
+
profile = repo / "docs/agents/workflow-capabilities.json"
|
|
66
|
+
profile.parent.mkdir(parents=True)
|
|
67
|
+
profile.write_text(PROFILE, encoding="utf-8")
|
|
68
|
+
(repo / "README.md").write_text("# fixture\n", encoding="utf-8")
|
|
69
|
+
git(repo, "add", "-A")
|
|
70
|
+
git(repo, "commit", "-m", "first")
|
|
71
|
+
first = git(repo, "rev-parse", "HEAD")
|
|
72
|
+
(repo / "README.md").write_text("# fixture 2\n", encoding="utf-8")
|
|
73
|
+
git(repo, "commit", "-am", "second")
|
|
74
|
+
second = git(repo, "rev-parse", "HEAD")
|
|
75
|
+
yield repo, first, second
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@contextmanager
|
|
79
|
+
def chdir(path: Path):
|
|
80
|
+
previous = Path.cwd()
|
|
81
|
+
os.chdir(path)
|
|
82
|
+
try:
|
|
83
|
+
yield
|
|
84
|
+
finally:
|
|
85
|
+
os.chdir(previous)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def args_for(base: str) -> Namespace:
|
|
89
|
+
return Namespace(
|
|
90
|
+
profile="docs/agents/workflow-capabilities.json",
|
|
91
|
+
base=base,
|
|
92
|
+
issue="173",
|
|
93
|
+
slug="wave-safety",
|
|
94
|
+
branch_type="feat",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class WorktreeReuseBaseGuard(unittest.TestCase):
|
|
99
|
+
def test_registered_worktree_reuse_stops_when_head_is_not_at_base(self):
|
|
100
|
+
setup = load_setup()
|
|
101
|
+
with fixture() as (repo, first, second):
|
|
102
|
+
target = repo / ".worktrees/173-wave-safety"
|
|
103
|
+
git(repo, "worktree", "add", "-b", "feat/173-wave-safety", str(target), second)
|
|
104
|
+
# base is the OLDER commit -> the registered worktree is ahead of it.
|
|
105
|
+
with chdir(repo):
|
|
106
|
+
with self.assertRaises(setup.LifecycleError) as stopped:
|
|
107
|
+
setup.create(args_for(first))
|
|
108
|
+
self.assertIn("stale", str(stopped.exception).lower())
|
|
109
|
+
|
|
110
|
+
def test_existing_branch_reuse_stops_when_branch_is_not_at_base(self):
|
|
111
|
+
setup = load_setup()
|
|
112
|
+
with fixture() as (repo, first, second):
|
|
113
|
+
git(repo, "branch", "feat/173-wave-safety", second)
|
|
114
|
+
target = repo / ".worktrees/173-wave-safety"
|
|
115
|
+
with chdir(repo):
|
|
116
|
+
with self.assertRaises(setup.LifecycleError) as stopped:
|
|
117
|
+
setup.create(args_for(first))
|
|
118
|
+
self.assertIn("stale", str(stopped.exception).lower())
|
|
119
|
+
self.assertFalse(target.exists(), "no worktree may be created on a stale base")
|
|
120
|
+
|
|
121
|
+
def test_registered_worktree_reuse_allows_head_at_base(self):
|
|
122
|
+
setup = load_setup()
|
|
123
|
+
with fixture() as (repo, _first, second):
|
|
124
|
+
target = repo / ".worktrees/173-wave-safety"
|
|
125
|
+
git(repo, "worktree", "add", "-b", "feat/173-wave-safety", str(target), second)
|
|
126
|
+
with chdir(repo):
|
|
127
|
+
self.assertEqual(setup.create(args_for(second)).resolve(), target.resolve())
|
|
128
|
+
|
|
129
|
+
def test_existing_branch_reuse_allows_a_branch_cleanly_behind_base(self):
|
|
130
|
+
setup = load_setup()
|
|
131
|
+
with fixture() as (repo, first, second):
|
|
132
|
+
git(repo, "branch", "feat/173-wave-safety", first)
|
|
133
|
+
target = repo / ".worktrees/173-wave-safety"
|
|
134
|
+
with chdir(repo):
|
|
135
|
+
self.assertEqual(setup.create(args_for(second)).resolve(), target.resolve())
|
|
136
|
+
self.assertTrue(target.is_dir())
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
unittest.main()
|
|
@@ -84,6 +84,39 @@ def execute_step(
|
|
|
84
84
|
raise LifecycleError(f"unsupported setup step kind: {kind!r}")
|
|
85
85
|
|
|
86
86
|
|
|
87
|
+
def commit_oid(repo: Path, rev: str) -> str | None:
|
|
88
|
+
result = run(["git", "rev-parse", "--verify", f"{rev}^{{commit}}"], cwd=repo, check=False)
|
|
89
|
+
return result.stdout.strip() if result.returncode == 0 else None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def ensure_reusable_base(main: Path, *, repo: Path, rev: str, base: str, label: str) -> None:
|
|
93
|
+
"""Reuse is safe only AT the base or cleanly behind it; anything else is stale.
|
|
94
|
+
|
|
95
|
+
Both reuse paths skip `git worktree add`'s base argument, so without this
|
|
96
|
+
guard a slice silently builds on whatever the old branch/worktree pointed at.
|
|
97
|
+
"""
|
|
98
|
+
base_oid = commit_oid(main, base)
|
|
99
|
+
if base_oid is None:
|
|
100
|
+
raise LifecycleError(f"base {base!r} is not resolvable")
|
|
101
|
+
current = commit_oid(repo, rev)
|
|
102
|
+
if current is None:
|
|
103
|
+
raise LifecycleError(f"{label}: HEAD is not resolvable")
|
|
104
|
+
if current == base_oid:
|
|
105
|
+
return
|
|
106
|
+
behind = run(
|
|
107
|
+
["git", "merge-base", "--is-ancestor", current, base_oid],
|
|
108
|
+
cwd=main,
|
|
109
|
+
check=False,
|
|
110
|
+
)
|
|
111
|
+
if behind.returncode == 0:
|
|
112
|
+
return
|
|
113
|
+
raise LifecycleError(
|
|
114
|
+
f"{label}: HEAD {current[:7]} is stale against base {base} ({base_oid[:7]}) — "
|
|
115
|
+
"it is ahead of or diverged from the base. Land or rebase that work, or remove "
|
|
116
|
+
"the worktree/branch, before reusing it."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
87
120
|
def remove_failed_worktree(main: Path, target: Path, branch: str, created_branch: bool) -> None:
|
|
88
121
|
run(["git", "worktree", "remove", "--force", str(target)], cwd=main, check=False)
|
|
89
122
|
if created_branch:
|
|
@@ -101,10 +134,17 @@ def create(args: argparse.Namespace) -> Path:
|
|
|
101
134
|
target = (main / profile.relative_path(args.issue, args.slug, args.branch_type)).resolve()
|
|
102
135
|
|
|
103
136
|
if target in registered_worktrees(main):
|
|
137
|
+
ensure_reusable_base(
|
|
138
|
+
main, repo=target, rev="HEAD", base=args.base, label=f"worktree {target}"
|
|
139
|
+
)
|
|
104
140
|
print(f"Worktree already exists: {target} ({branch})")
|
|
105
141
|
return target
|
|
106
142
|
|
|
107
143
|
branch_existed = local_branch_exists(main, branch)
|
|
144
|
+
if branch_existed:
|
|
145
|
+
ensure_reusable_base(
|
|
146
|
+
main, repo=main, rev=branch, base=args.base, label=f"branch {branch}"
|
|
147
|
+
)
|
|
108
148
|
command = ["git", "worktree", "add", str(target)]
|
|
109
149
|
command += [branch] if branch_existed else ["-b", branch, args.base]
|
|
110
150
|
run(command, cwd=main)
|
package/src/lib/bundle.mjs
CHANGED
|
@@ -72,6 +72,16 @@ export const HELPER_FILES = [
|
|
|
72
72
|
{ path: 'src/lib/release-preview.mjs', kind: 'script', mode: 0o644 },
|
|
73
73
|
{ path: 'src/lib/release-apply.mjs', kind: 'script', mode: 0o644 },
|
|
74
74
|
{ path: 'scripts/project-release.mjs', kind: 'script', mode: 0o755 },
|
|
75
|
+
// Canonical schema and semantic validator shared across orchestration paths.
|
|
76
|
+
// Library → 0o644; later helper-owning slices append their paths once present.
|
|
77
|
+
{ path: 'src/lib/reportValidator.mjs', kind: 'script', mode: 0o644 },
|
|
78
|
+
// Fail-closed orchestration selector and its host-inventory adapters.
|
|
79
|
+
{ path: 'src/lib/capabilityMatrix.mjs', kind: 'script', mode: 0o644 },
|
|
80
|
+
// Main-thread recon boundary shared by every orchestration path.
|
|
81
|
+
{ path: 'src/lib/reconcileReconReports.mjs', kind: 'script', mode: 0o644 },
|
|
82
|
+
// Atomic compare-and-set wave claim: two sessions cannot orchestrate the same
|
|
83
|
+
// wave. Library (imported by the Phase-0 claim protocol) → 0o644.
|
|
84
|
+
{ path: 'src/lib/waveClaim.mjs', kind: 'script', mode: 0o644 },
|
|
75
85
|
// GitHub-consumer automation: invokes the existing update command, then owns
|
|
76
86
|
// only the stable tested branch/pull-request upsert.
|
|
77
87
|
{ path: 'scripts/kit-update-pr.mjs', kind: 'script', mode: 0o755 },
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
const UNKNOWN = 'unknown';
|
|
2
|
+
|
|
3
|
+
const PATH_A_CAPABILITIES = [
|
|
4
|
+
'namedPhases',
|
|
5
|
+
'runIdentity',
|
|
6
|
+
'runtimeOutputValidation',
|
|
7
|
+
'journal',
|
|
8
|
+
'resume',
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const PATH_B_TOOLS = [
|
|
12
|
+
['spawn_agent', 'spawn'],
|
|
13
|
+
['wait_agent', 'wait'],
|
|
14
|
+
['list_agents', 'aggregate'],
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const TARGETS = Object.freeze({
|
|
18
|
+
A: Object.freeze({ path: 'A', kind: 'reference', value: 'references/dispatch-workflow.md' }),
|
|
19
|
+
B: Object.freeze({ path: 'B', kind: 'reference', value: 'references/dispatch-subagents.md' }),
|
|
20
|
+
C: Object.freeze({ path: 'C', kind: 'inline', value: 'path-c' }),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function triState(value) {
|
|
24
|
+
return typeof value === 'boolean' ? value : UNKNOWN;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function capacity(value) {
|
|
28
|
+
return Number.isInteger(value) && value >= 0 ? value : UNKNOWN;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalizeTool(tool) {
|
|
32
|
+
if (!tool || typeof tool !== 'object' || Array.isArray(tool)) return null;
|
|
33
|
+
return {
|
|
34
|
+
name: typeof tool.name === 'string' ? tool.name : UNKNOWN,
|
|
35
|
+
schema: tool.schema && typeof tool.schema === 'object' && !Array.isArray(tool.schema)
|
|
36
|
+
? tool.schema
|
|
37
|
+
: UNKNOWN,
|
|
38
|
+
callable: triState(tool.callable),
|
|
39
|
+
permitted: triState(tool.permitted),
|
|
40
|
+
capabilities: Array.isArray(tool.capabilities)
|
|
41
|
+
&& tool.capabilities.every((item) => typeof item === 'string')
|
|
42
|
+
? [...new Set(tool.capabilities)]
|
|
43
|
+
: UNKNOWN,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function adaptInventory(inventory) {
|
|
48
|
+
const source = inventory && typeof inventory === 'object' ? inventory : {};
|
|
49
|
+
const validContract = source.contractVersion === 1;
|
|
50
|
+
return {
|
|
51
|
+
contractVersion: 1,
|
|
52
|
+
tools: validContract && Array.isArray(source.tools)
|
|
53
|
+
? source.tools.map(normalizeTool).filter(Boolean)
|
|
54
|
+
: [],
|
|
55
|
+
effectiveConcurrency: validContract ? capacity(source.effectiveConcurrency) : UNKNOWN,
|
|
56
|
+
threadCapacity: validContract ? capacity(source.threadCapacity) : UNKNOWN,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const capabilityAdapter = Object.freeze({
|
|
61
|
+
claude: adaptInventory,
|
|
62
|
+
codex: adaptInventory,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
function proves(tool, capability) {
|
|
66
|
+
return tool?.callable === true
|
|
67
|
+
&& tool.permitted === true
|
|
68
|
+
&& tool.schema !== UNKNOWN
|
|
69
|
+
&& Array.isArray(tool.capabilities)
|
|
70
|
+
&& tool.capabilities.includes(capability);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function supportsPathA(inventory) {
|
|
74
|
+
const workflow = inventory.tools.find(({ name }) => name === 'Workflow');
|
|
75
|
+
return PATH_A_CAPABILITIES.every((capability) => proves(workflow, capability));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function supportsPathB(inventory) {
|
|
79
|
+
if (inventory.effectiveConcurrency === UNKNOWN || inventory.effectiveConcurrency < 2) return false;
|
|
80
|
+
if (inventory.threadCapacity === UNKNOWN || inventory.threadCapacity < 2) return false;
|
|
81
|
+
return PATH_B_TOOLS.every(([name, capability]) =>
|
|
82
|
+
inventory.tools.some((tool) => tool.name === name && proves(tool, capability)));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function classifyCapabilities(inventory) {
|
|
86
|
+
const normalized = adaptInventory(inventory);
|
|
87
|
+
if (supportsPathA(normalized)) return 'A';
|
|
88
|
+
if (supportsPathB(normalized)) return 'B';
|
|
89
|
+
return 'C';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function selectOrchestrationReference(inventory) {
|
|
93
|
+
return TARGETS[classifyCapabilities(inventory)];
|
|
94
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { validateReconReport } from './reportValidator.mjs';
|
|
2
|
+
|
|
3
|
+
const byPath = (left, right) => left.path.localeCompare(right.path);
|
|
4
|
+
const bySlice = (left, right) => left.localeCompare(right, 'en', { numeric: true });
|
|
5
|
+
const edgeKey = ({ from, to }) => `${from}:${to}`;
|
|
6
|
+
|
|
7
|
+
function validateReports(reports) {
|
|
8
|
+
if (!Array.isArray(reports)) throw new TypeError('recon reports must be an array');
|
|
9
|
+
const sliceIds = new Set();
|
|
10
|
+
for (const report of reports) {
|
|
11
|
+
const validation = validateReconReport(report);
|
|
12
|
+
if (!validation.ok) throw new Error(`invalid recon report: ${validation.errors.join('; ')}`);
|
|
13
|
+
if (sliceIds.has(report.sliceId)) throw new Error(`duplicate recon report for slice ${report.sliceId}`);
|
|
14
|
+
sliceIds.add(report.sliceId);
|
|
15
|
+
}
|
|
16
|
+
return sliceIds;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function buildGraph(reports, sliceIds) {
|
|
20
|
+
const edges = new Map();
|
|
21
|
+
const successors = new Map([...sliceIds].map((id) => [id, new Set()]));
|
|
22
|
+
const indegree = new Map([...sliceIds].map((id) => [id, 0]));
|
|
23
|
+
for (const { dependencyEdges } of reports) {
|
|
24
|
+
for (const edge of dependencyEdges) {
|
|
25
|
+
if (!sliceIds.has(edge.from) || !sliceIds.has(edge.to)) {
|
|
26
|
+
const unknown = !sliceIds.has(edge.from) ? edge.from : edge.to;
|
|
27
|
+
throw new Error(`dependency edge references unknown slice ${unknown}`);
|
|
28
|
+
}
|
|
29
|
+
if (edge.from === edge.to) throw new Error(`self dependency is not allowed for slice ${edge.from}`);
|
|
30
|
+
if (edges.has(edgeKey(edge))) continue;
|
|
31
|
+
edges.set(edgeKey(edge), edge);
|
|
32
|
+
successors.get(edge.from).add(edge.to);
|
|
33
|
+
indegree.set(edge.to, indegree.get(edge.to) + 1);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const ready = [...sliceIds].filter((id) => indegree.get(id) === 0).sort(bySlice);
|
|
37
|
+
const order = [];
|
|
38
|
+
while (ready.length > 0) {
|
|
39
|
+
const current = ready.shift();
|
|
40
|
+
order.push(current);
|
|
41
|
+
for (const next of [...successors.get(current)].sort(bySlice)) {
|
|
42
|
+
indegree.set(next, indegree.get(next) - 1);
|
|
43
|
+
if (indegree.get(next) === 0) ready.push(next);
|
|
44
|
+
}
|
|
45
|
+
ready.sort(bySlice);
|
|
46
|
+
}
|
|
47
|
+
if (order.length !== sliceIds.size) throw new Error('dependency graph contains a cycle');
|
|
48
|
+
return { edges: [...edges.values()].sort((a, b) => edgeKey(a).localeCompare(edgeKey(b))), successors, order };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function collectFiles(reports) {
|
|
52
|
+
const files = new Map();
|
|
53
|
+
for (const { sliceId, plannedFiles } of reports) {
|
|
54
|
+
for (const { path, role } of plannedFiles) {
|
|
55
|
+
const file = files.get(path) ?? { editors: new Set(), shared: false };
|
|
56
|
+
if (role === 'edit') file.editors.add(sliceId);
|
|
57
|
+
if (role === 'sharedMutable') file.shared = true;
|
|
58
|
+
files.set(path, file);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return files;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function canReach(from, to, successors) {
|
|
65
|
+
const pending = [...successors.get(from)];
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
while (pending.length > 0) {
|
|
68
|
+
const current = pending.pop();
|
|
69
|
+
if (current === to) return true;
|
|
70
|
+
if (seen.has(current)) continue;
|
|
71
|
+
seen.add(current);
|
|
72
|
+
pending.push(...successors.get(current));
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function orderedEditors(path, file, graph) {
|
|
78
|
+
const editors = [...file.editors];
|
|
79
|
+
if (file.shared && editors.length !== 1) {
|
|
80
|
+
const found = editors.sort(bySlice);
|
|
81
|
+
throw new Error(`${path} must have exactly one edit owner; found ${found.length ? found.join(', ') : '0'}`);
|
|
82
|
+
}
|
|
83
|
+
const position = new Map(graph.order.map((id, index) => [id, index]));
|
|
84
|
+
editors.sort((left, right) => position.get(left) - position.get(right));
|
|
85
|
+
for (let left = 0; left < editors.length; left += 1) {
|
|
86
|
+
for (let right = left + 1; right < editors.length; right += 1) {
|
|
87
|
+
if (!canReach(editors[left], editors[right], graph.successors)) {
|
|
88
|
+
throw new Error(`${path} edit owners are not totally ordered by dependencies`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return editors;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Reconcile schema-valid per-slice recon reports before any builder dispatch. */
|
|
96
|
+
export function reconcileReconReports(reports) {
|
|
97
|
+
const sliceIds = validateReports(reports);
|
|
98
|
+
const graph = buildGraph(reports, sliceIds);
|
|
99
|
+
const editOwners = [];
|
|
100
|
+
const overlaps = [];
|
|
101
|
+
for (const [path, file] of collectFiles(reports)) {
|
|
102
|
+
const sliceIdsForPath = orderedEditors(path, file, graph);
|
|
103
|
+
if (sliceIdsForPath.length > 0) editOwners.push({ path, sliceIds: sliceIdsForPath });
|
|
104
|
+
if (sliceIdsForPath.length > 1) overlaps.push({ path, editors: sliceIdsForPath });
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
editOwners: editOwners.sort(byPath),
|
|
108
|
+
overlaps: overlaps.sort(byPath),
|
|
109
|
+
dependencyEdges: graph.edges,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
const HASH_PATTERN = '^[0-9a-f]{40}$|^[0-9a-f]{64}$';
|
|
2
|
+
|
|
3
|
+
const stringArray = { type: 'array', items: { type: 'string' } };
|
|
4
|
+
|
|
5
|
+
export const RECON_REPORT_SCHEMA = {
|
|
6
|
+
type: 'object',
|
|
7
|
+
required: ['sliceId', 'plannedFiles', 'dependencyEdges'],
|
|
8
|
+
additionalProperties: false,
|
|
9
|
+
properties: {
|
|
10
|
+
sliceId: { type: 'string', pattern: '^[0-9]+$' },
|
|
11
|
+
plannedFiles: {
|
|
12
|
+
type: 'array',
|
|
13
|
+
items: {
|
|
14
|
+
type: 'object',
|
|
15
|
+
required: ['path', 'role'],
|
|
16
|
+
additionalProperties: false,
|
|
17
|
+
properties: {
|
|
18
|
+
path: { type: 'string' },
|
|
19
|
+
role: { type: 'string', enum: ['edit', 'consume', 'sharedMutable'] },
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
dependencyEdges: {
|
|
24
|
+
type: 'array',
|
|
25
|
+
items: {
|
|
26
|
+
type: 'object',
|
|
27
|
+
required: ['from', 'to'],
|
|
28
|
+
additionalProperties: false,
|
|
29
|
+
properties: {
|
|
30
|
+
from: { type: 'string', pattern: '^[0-9]+$' },
|
|
31
|
+
to: { type: 'string', pattern: '^[0-9]+$' },
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const BUILDER_REPORT_SCHEMA = {
|
|
39
|
+
type: 'object',
|
|
40
|
+
required: [
|
|
41
|
+
'status', 'filesTouched', 'testDecisions', 'commands',
|
|
42
|
+
'commitSha', 'stopItems', 'visualVerify',
|
|
43
|
+
],
|
|
44
|
+
additionalProperties: false,
|
|
45
|
+
properties: {
|
|
46
|
+
status: { type: 'string', enum: ['pass', 'stop'] },
|
|
47
|
+
filesTouched: stringArray,
|
|
48
|
+
testDecisions: stringArray,
|
|
49
|
+
commands: {
|
|
50
|
+
type: 'array',
|
|
51
|
+
items: {
|
|
52
|
+
type: 'object',
|
|
53
|
+
required: ['command', 'exitCode', 'summary'],
|
|
54
|
+
additionalProperties: false,
|
|
55
|
+
properties: {
|
|
56
|
+
command: { type: 'string' },
|
|
57
|
+
exitCode: { type: 'integer' },
|
|
58
|
+
summary: { type: 'string' },
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
commitSha: { anyOf: [{ type: 'string', pattern: HASH_PATTERN }, { type: 'null' }] },
|
|
63
|
+
stopItems: stringArray,
|
|
64
|
+
visualVerify: { type: 'string' },
|
|
65
|
+
},
|
|
66
|
+
oneOf: [
|
|
67
|
+
{
|
|
68
|
+
required: ['status', 'commitSha', 'stopItems'],
|
|
69
|
+
properties: {
|
|
70
|
+
status: { const: 'pass' },
|
|
71
|
+
commitSha: { type: 'string', pattern: HASH_PATTERN },
|
|
72
|
+
stopItems: { type: 'array', maxItems: 0 },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
required: ['status', 'commitSha', 'stopItems'],
|
|
77
|
+
properties: {
|
|
78
|
+
status: { const: 'stop' },
|
|
79
|
+
commitSha: { anyOf: [{ type: 'string', pattern: HASH_PATTERN }, { type: 'null' }] },
|
|
80
|
+
stopItems: { type: 'array', minItems: 1 },
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
function valueHasType(value, type) {
|
|
87
|
+
if (type === 'null') return value === null;
|
|
88
|
+
if (type === 'array') return Array.isArray(value);
|
|
89
|
+
if (type === 'object') return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
90
|
+
if (type === 'integer') return Number.isInteger(value);
|
|
91
|
+
return typeof value === type;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function validateCombinators(value, schema, path) {
|
|
95
|
+
const errors = [];
|
|
96
|
+
if (schema.anyOf && !schema.anyOf.some((branch) => collectErrors(value, branch, path).length === 0)) {
|
|
97
|
+
errors.push(`${path} must match at least one allowed schema`);
|
|
98
|
+
}
|
|
99
|
+
if (schema.oneOf) {
|
|
100
|
+
const matches = schema.oneOf.filter((branch) => collectErrors(value, branch, path).length === 0).length;
|
|
101
|
+
if (matches !== 1) errors.push(`${path} must match exactly one allowed schema`);
|
|
102
|
+
}
|
|
103
|
+
return errors;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function validateObject(value, schema, path) {
|
|
107
|
+
const errors = [];
|
|
108
|
+
for (const key of schema.required ?? []) {
|
|
109
|
+
if (!Object.hasOwn(value, key)) errors.push(`${path}.${key} is required`);
|
|
110
|
+
}
|
|
111
|
+
if (schema.additionalProperties === false) {
|
|
112
|
+
for (const key of Object.keys(value)) {
|
|
113
|
+
if (!Object.hasOwn(schema.properties ?? {}, key)) errors.push(`${path}.${key} is not allowed`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
for (const [key, childSchema] of Object.entries(schema.properties ?? {})) {
|
|
117
|
+
if (Object.hasOwn(value, key)) errors.push(...collectErrors(value[key], childSchema, `${path}.${key}`));
|
|
118
|
+
}
|
|
119
|
+
return errors;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function collectErrors(value, schema, path = '$') {
|
|
123
|
+
const errors = validateCombinators(value, schema, path);
|
|
124
|
+
if (schema.const !== undefined && value !== schema.const) errors.push(`${path} must equal ${schema.const}`);
|
|
125
|
+
if (schema.enum && !schema.enum.includes(value)) errors.push(`${path} has an unsupported value`);
|
|
126
|
+
if (schema.type && !valueHasType(value, schema.type)) return [...errors, `${path} must be ${schema.type}`];
|
|
127
|
+
if (schema.pattern && !new RegExp(schema.pattern).test(value)) errors.push(`${path} has an invalid format`);
|
|
128
|
+
if (Array.isArray(value)) {
|
|
129
|
+
if (schema.minItems !== undefined && value.length < schema.minItems) errors.push(`${path} has too few items`);
|
|
130
|
+
if (schema.maxItems !== undefined && value.length > schema.maxItems) errors.push(`${path} has too many items`);
|
|
131
|
+
if (schema.items) value.forEach((item, index) => errors.push(...collectErrors(item, schema.items, `${path}[${index}]`)));
|
|
132
|
+
}
|
|
133
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
134
|
+
errors.push(...validateObject(value, schema, path));
|
|
135
|
+
}
|
|
136
|
+
return errors;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function validateSchema(value, schema) {
|
|
140
|
+
const errors = collectErrors(value, schema);
|
|
141
|
+
return { ok: errors.length === 0, errors };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const validateReconReport = (report) => validateSchema(report, RECON_REPORT_SCHEMA);
|
|
145
|
+
export const validateBuilderReport = (report) => validateSchema(report, BUILDER_REPORT_SCHEMA);
|
|
146
|
+
|
|
147
|
+
function verifyCommit(report, gitFacts) {
|
|
148
|
+
if (!['sha1', 'sha256'].includes(gitFacts?.objectFormat)) return ['gitFacts.objectFormat is unsupported'];
|
|
149
|
+
if (report.commitSha === null) return [];
|
|
150
|
+
const errors = [];
|
|
151
|
+
const expectedLength = gitFacts.objectFormat === 'sha1' ? 40 : 64;
|
|
152
|
+
if (report.commitSha.length !== expectedLength) {
|
|
153
|
+
errors.push(`commitSha does not match ${gitFacts.objectFormat} object format`);
|
|
154
|
+
}
|
|
155
|
+
if (gitFacts.commitSha !== report.commitSha) errors.push('commit SHA does not match independently observed git facts');
|
|
156
|
+
if (gitFacts.baseIsAncestorOfCommit !== true) {
|
|
157
|
+
errors.push('integration base is not an ancestor of the builder commit');
|
|
158
|
+
}
|
|
159
|
+
return errors;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function verifyFilesAndCommands(report, gitFacts, allowlist, requiredCommands) {
|
|
163
|
+
const errors = [];
|
|
164
|
+
if (!Array.isArray(gitFacts?.changedFiles)) errors.push('gitFacts.changedFiles must be an array');
|
|
165
|
+
else if (gitFacts.changedFiles.some((path) => !allowlist.includes(path))) {
|
|
166
|
+
errors.push('git diff contains a path outside the allowlist');
|
|
167
|
+
}
|
|
168
|
+
const observedCommands = new Set(report.commands.map(({ command }) => command));
|
|
169
|
+
for (const command of requiredCommands) {
|
|
170
|
+
if (!observedCommands.has(command)) errors.push(`required command was not reported: ${command}`);
|
|
171
|
+
}
|
|
172
|
+
return errors;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function semanticVerify(report, { gitFacts, allowlist = [], requiredCommands = [] } = {}) {
|
|
176
|
+
const structural = validateBuilderReport(report);
|
|
177
|
+
const errors = [...structural.errors];
|
|
178
|
+
if (structural.ok) {
|
|
179
|
+
if (report.status === 'pass' && report.commands.some(({ exitCode }) => exitCode !== 0)) {
|
|
180
|
+
errors.push('PASS report contains a command with a nonzero exit');
|
|
181
|
+
}
|
|
182
|
+
errors.push(...verifyCommit(report, gitFacts));
|
|
183
|
+
errors.push(...verifyFilesAndCommands(report, gitFacts, allowlist, requiredCommands));
|
|
184
|
+
}
|
|
185
|
+
return { ok: errors.length === 0, errors };
|
|
186
|
+
}
|