axstack 0.54.0 → 0.56.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/docs/concepts.md +3 -1
- package/docs/installation.md +5 -2
- package/docs/workflows.md +3 -1
- package/package.json +1 -1
- package/profiles/presets/claude-only.json +9 -0
- package/profiles/presets/codex-only.json +9 -0
- package/profiles/presets/mixed.json +9 -0
- package/skills/axstack/references/fleet-dispatch.md +166 -0
- package/skills/axstack/references/fleet-route-schema.md +325 -0
- package/skills/axstack/references/fleet-verification.md +102 -0
- package/skills/axstack/references/lifecycle.md +19 -2
- package/skills/axstack/references/role-roster.md +11 -0
- package/skills/axstack/references/routing.md +14 -0
- package/skills/axstack/references/run-record.md +83 -0
- package/skills/axstack/references/t3-runtime.md +174 -7
- package/skills/axstack/references/ui-verification.md +67 -4
- package/skills/axstack/references/workspace-hygiene.md +8 -0
- package/skills/axstack/scripts/dispatch-plan.js +52 -8
- package/skills/axstack/scripts/fleet-config.js +78 -0
- package/skills/axstack/scripts/fleet-destination.js +39 -0
- package/skills/axstack/scripts/fleet-dispatch.js +47 -0
- package/skills/axstack/scripts/fleet-facts.js +161 -0
- package/skills/axstack/scripts/fleet-inventory.js +76 -0
- package/skills/axstack/scripts/fleet-proc.js +31 -0
- package/skills/axstack/scripts/fleet-receipt.js +130 -0
- package/skills/axstack/scripts/fleet-route-cli.js +78 -0
- package/skills/axstack/scripts/fleet-route.js +86 -0
- package/skills/axstack/scripts/fleet-sample-cli.js +81 -0
- package/skills/axstack/scripts/fleet-sample-io.js +59 -0
- package/skills/axstack/scripts/fleet-sample-manifest.js +34 -0
- package/skills/axstack/scripts/fleet-sample.js +48 -0
- package/skills/axstack/scripts/resolve-models.js +8 -4
- package/skills/axstack-cleanup/SKILL.md +21 -0
- package/skills/axstack-implement/SKILL.md +12 -5
- package/skills/axstack-review/references/review-candidate.md +1 -1
- package/skills/axstack-verify/SKILL.md +17 -6
- package/skills/axstack-watch/SKILL.md +3 -0
- package/skills/axstack-watch/references/watch-runtime.md +18 -5
- package/skills/voa/SKILL.md +96 -0
- package/skills/voa/references/observations.md +79 -0
- package/skills/voa/references/recovery.md +94 -0
- package/skills/voa/references/verification.md +35 -0
- package/skills/voa/scripts/cleanup.js +39 -0
- package/skills/voa/scripts/evaluate.js +122 -0
- package/skills/voa/scripts/observations.js +68 -0
- package/skills/voa/scripts/records.js +48 -0
- package/skills/voa/scripts/voa.js +43 -0
- package/src/installer.js +3 -2
- package/src/instructions.js +11 -3
- package/src/roles.js +2 -2
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Workspace hygiene for driver-owned T3 runs
|
|
2
2
|
|
|
3
|
+
For remote nonwriters, apply [host-qualified retrieval and retirement](fleet-dispatch.md#retrieval-and-retirement).
|
|
4
|
+
Resolve task status on origin and thread/run/settlement on the actual destination.
|
|
5
|
+
Archive and read back exact evidence on origin before retiring only the owned
|
|
6
|
+
T3-created reviewer checkout. Bind host/environment/path and candidate/base SHA;
|
|
7
|
+
never use an origin filesystem path as a destination path. Unknown liveness,
|
|
8
|
+
ownership or user takeover preserves that resource. Validation precedes deletion
|
|
9
|
+
in separate sequential calls; preserve unknown ignored artifacts.
|
|
10
|
+
|
|
3
11
|
This is a prompt contract, not a cleanup daemon. Follow [T3 runtime](t3-runtime.md)
|
|
4
12
|
for native identities, terminal run evidence and schema. Dispatched workers never
|
|
5
13
|
sweep or remove another session. A scheduled pass with recorded cleanup authority
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { inspectFleetReceipt } from './fleet-receipt.js';
|
|
3
|
+
import { bindFleet } from './fleet-dispatch.js';
|
|
2
4
|
|
|
3
|
-
const
|
|
5
|
+
const fleetFlags = ['--recommendation', '--fleet-input', '--now', '--config-digest'];
|
|
6
|
+
const flags = ['--role', '--roles', '--capabilities', '--kind', '--verify', '--out', ...fleetFlags];
|
|
4
7
|
const nonempty = (value) => typeof value === 'string' && value.trim().length > 0;
|
|
5
8
|
const help = `Usage: bun dispatch-plan.js --role <id> --roles <roles.json>
|
|
6
9
|
--capabilities <saved.json> --kind task|launch
|
|
@@ -13,6 +16,10 @@ Example: bun dispatch-plan.js --role axstack-author --roles roles.json --capabil
|
|
|
13
16
|
--kind task emits target and, for axstack-author, isolation:"worktree"; launch emits modelSelection
|
|
14
17
|
--verify Compare t3_thread_configuration JSON; print evidence then MATCH or each mismatch
|
|
15
18
|
--out Save the payload as full JSON here; stdout is an input/path/omitted-count summary
|
|
19
|
+
--recommendation Saved fleet-route recommendation; requires all following fleet flags
|
|
20
|
+
--fleet-input Saved complete selector input, including exact reviewed config bytes
|
|
21
|
+
--now Injected nonnegative epoch-ms evaluation time matching the recommendation
|
|
22
|
+
--config-digest Run-pinned config SHA-256; must match facts and recommendation
|
|
16
23
|
--help Show all flags and exit codes
|
|
17
24
|
|
|
18
25
|
Read-only inputs: reads supplied JSON, spawns adjacent resolve-models.js, writes stdout/stderr.
|
|
@@ -22,6 +29,14 @@ Large verification retains its exit code, prints mismatches and advises --out on
|
|
|
22
29
|
An unwritable --out still prints verification mismatches and retains exit 2.
|
|
23
30
|
An omitted provider label is unknown; exact instance/model/options/mode checks still apply.
|
|
24
31
|
Never dispatches or changes snapshots. Reuse recorded exact model pins as given.
|
|
32
|
+
Fleet --capabilities is the selected destination's raw saved catalog, digest-bound
|
|
33
|
+
by its destination facts. It is never the origin catalog by default.
|
|
34
|
+
Fleet verification requires environmentId/projectId and fleetBinding alongside
|
|
35
|
+
the effective T3 configuration. C3 collects that host-qualified evidence envelope.
|
|
36
|
+
Optional input.nativeDestination validates saved native forwarded reads.
|
|
37
|
+
Optional --verify fleetAttempt/fleetReceipt validates host-qualified terminal facts;
|
|
38
|
+
--out retains per-term receipt evidence. Synthetic bindings cannot authenticate reads.
|
|
39
|
+
Coordinator routes remain unsupported here. A plan grants no launch authority.
|
|
25
40
|
Exit codes: 0 ok, 1 error, 2 hold (including incomplete read-back).
|
|
26
41
|
`;
|
|
27
42
|
|
|
@@ -39,9 +54,12 @@ function argumentsFrom(args) {
|
|
|
39
54
|
if (flag in options) throw new Error(`duplicate argument ${flag}; pass it once`);
|
|
40
55
|
options[flag] = value;
|
|
41
56
|
}
|
|
42
|
-
for (const flag of
|
|
57
|
+
for (const flag of ['--role', '--roles', '--capabilities', '--kind']) {
|
|
43
58
|
if (!options[flag]) throw new Error(`expected ${flag}; pass ${flag} <value>`);
|
|
44
59
|
}
|
|
60
|
+
if (fleetFlags.some((flag) => options[flag])) {
|
|
61
|
+
for (const flag of fleetFlags) if (!options[flag]) throw new Error(`expected ${flag} for fleet binding`);
|
|
62
|
+
}
|
|
45
63
|
if (!['task', 'launch'].includes(options['--kind'])) throw new Error('expected --kind task|launch; pass --kind task or --kind launch');
|
|
46
64
|
return options;
|
|
47
65
|
}
|
|
@@ -60,7 +78,7 @@ function normalizedOptions(options) {
|
|
|
60
78
|
return JSON.stringify(entries.sort(([left], [right]) => left.localeCompare(right)));
|
|
61
79
|
}
|
|
62
80
|
|
|
63
|
-
function verify(configuration, provider, model, options) {
|
|
81
|
+
function verify(configuration, provider, model, options, fleet) {
|
|
64
82
|
const mismatches = [];
|
|
65
83
|
const selection = configuration?.modelSelection;
|
|
66
84
|
const compare = (field, expected, observed, equal = expected === observed) => {
|
|
@@ -77,7 +95,19 @@ function verify(configuration, provider, model, options) {
|
|
|
77
95
|
compare('options', options, selection?.options,
|
|
78
96
|
normalizedOptions(options) === normalizedOptions(selection?.options));
|
|
79
97
|
compare('runtimeMode', 'full-access', configuration?.runtimeMode);
|
|
80
|
-
|
|
98
|
+
if (fleet) {
|
|
99
|
+
for (const [field, expected] of Object.entries(fleet.destination)) compare(field, expected, configuration?.[field]);
|
|
100
|
+
for (const [field, expected] of Object.entries(fleet.fleetBinding)) compare(field, expected, configuration?.fleetBinding?.[field]);
|
|
101
|
+
}
|
|
102
|
+
let receiptTerms;
|
|
103
|
+
if (fleet && configuration?.fleetReceipt !== undefined) {
|
|
104
|
+
receiptTerms = inspectFleetReceipt(configuration.fleetReceipt, {
|
|
105
|
+
attempt: configuration.fleetAttempt, recommendation: fleet.recommendation,
|
|
106
|
+
recommendationJSON: fleet.recommendationJSON, now: fleet.now,
|
|
107
|
+
});
|
|
108
|
+
for (const term of receiptTerms) if (term.state !== 'pass') mismatches.push(`${term.code}: ${term.state}; evidence ${JSON.stringify(term.evidence)}`);
|
|
109
|
+
}
|
|
110
|
+
return { mismatches, ...(receiptTerms ? { receiptTerms } : {}), ...(selection?.provider == null ? {
|
|
81
111
|
providerEvidence: `provider: unknown; expected ${JSON.stringify(provider)}; observed missing; `
|
|
82
112
|
+ 'provider binding checked via instanceId and saved capabilities',
|
|
83
113
|
} : {}) };
|
|
@@ -136,16 +166,30 @@ function plan(argv, io) {
|
|
|
136
166
|
'--effort', role.thinkingOptionId, ...modelArgs], { stdout: 'pipe', stderr: 'pipe' });
|
|
137
167
|
if (resolved.stderr.length) io.stderr(resolved.stderr.toString());
|
|
138
168
|
if (resolved.exitCode !== 0) return resolved.exitCode === 1 ? 2 : 1;
|
|
139
|
-
const
|
|
140
|
-
const
|
|
169
|
+
const resolution = JSON.parse(resolved.stdout.toString());
|
|
170
|
+
const { model, effortOption, providerInstanceId } = resolution;
|
|
171
|
+
let fleet;
|
|
172
|
+
if (args['--recommendation']) {
|
|
173
|
+
if (!/^\d+$/.test(args['--now'])) throw new Error('evaluation-time');
|
|
174
|
+
const recommendationJSON = readFileSync(args['--recommendation'], 'utf8');
|
|
175
|
+
const recommendation = JSON.parse(recommendationJSON);
|
|
176
|
+
fleet = bindFleet(readJSON(args['--fleet-input']), recommendation,
|
|
177
|
+
args['--config-digest'], Number(args['--now']), readFileSync(args['--capabilities'], 'utf8'),
|
|
178
|
+
resolution, role.id === 'axstack-author');
|
|
179
|
+
fleet.recommendation = recommendation;
|
|
180
|
+
fleet.recommendationJSON = recommendationJSON;
|
|
181
|
+
fleet.now = Number(args['--now']);
|
|
182
|
+
}
|
|
183
|
+
const options = fleet?.options ?? (effortOption ? [effortOption] : []);
|
|
141
184
|
if (args['--verify']) {
|
|
142
|
-
const verification = verify(readJSON(args['--verify']), providerInstanceId, model, options);
|
|
185
|
+
const verification = verify(readJSON(args['--verify']), providerInstanceId, model, options, fleet);
|
|
143
186
|
emit(verification);
|
|
144
187
|
return verification.mismatches.length ? 2 : 0;
|
|
145
188
|
}
|
|
146
189
|
const selection = { [args['--kind'] === 'launch' ? 'instanceId' : 'providerInstanceId']: providerInstanceId,
|
|
147
|
-
model, options };
|
|
190
|
+
model, options, ...(args['--kind'] === 'task' ? fleet?.destination : {}) };
|
|
148
191
|
emit({ [args['--kind'] === 'launch' ? 'modelSelection' : 'target']: selection, runtimeMode: 'full-access',
|
|
192
|
+
...(fleet ? { fleetBinding: fleet.fleetBinding, ...(args['--kind'] === 'launch' ? fleet.destination : {}) } : {}),
|
|
149
193
|
...(args['--kind'] === 'task' && role.id === 'axstack-author' ? { isolation: 'worktree' } : {}) });
|
|
150
194
|
return 0;
|
|
151
195
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Configuration is reviewed data. This module does not read account-local files.
|
|
2
|
+
export function fail(code) { throw new Error(code); }
|
|
3
|
+
export const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
4
|
+
export const text = (value) => typeof value === 'string' && value.trim().length > 0;
|
|
5
|
+
export const time = (value) => Number.isSafeInteger(value) && value >= 0;
|
|
6
|
+
const bp = (value) => Number.isInteger(value) && value >= 0 && value <= 10000;
|
|
7
|
+
|
|
8
|
+
// Canonical comparison for JSON settings, independent of property insertion order.
|
|
9
|
+
export function canonical(value) {
|
|
10
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
11
|
+
if (record(value)) return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
|
|
12
|
+
return JSON.stringify(value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function evidence(value, environmentId, now) {
|
|
16
|
+
return record(value) && value.environmentId === environmentId && text(value.pointer)
|
|
17
|
+
&& time(value.observedAt) && value.observedAt <= now;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function parseFleetConfig(configText, expectedDigest) {
|
|
21
|
+
if (!text(configText)) fail('missing-config');
|
|
22
|
+
if (typeof expectedDigest !== 'string' || !/^[a-f0-9]{64}$/.test(expectedDigest)) fail('config-digest');
|
|
23
|
+
const digest = new Bun.CryptoHasher('sha256').update(configText).digest('hex');
|
|
24
|
+
if (digest !== expectedDigest) fail('config-digest-mismatch');
|
|
25
|
+
let config;
|
|
26
|
+
try { config = JSON.parse(configText); } catch { fail('config-json'); }
|
|
27
|
+
if (!record(config) || config.schemaVersion !== 1) fail('schema-version: config');
|
|
28
|
+
if (!Array.isArray(config.hosts) || config.hosts.length === 0) fail('config-host');
|
|
29
|
+
const ids = new Set();
|
|
30
|
+
const environments = new Set();
|
|
31
|
+
for (const host of config.hosts) {
|
|
32
|
+
if (!record(host) || !text(host.id) || !text(host.environmentId) || ids.has(host.id)
|
|
33
|
+
|| environments.has(host.environmentId) || !record(host.capabilities)) fail('config-host');
|
|
34
|
+
ids.add(host.id); environments.add(host.environmentId);
|
|
35
|
+
if (!bp(host.fallbackLimitBp)) fail('config-threshold');
|
|
36
|
+
}
|
|
37
|
+
if (!bp(config.balanceLimitBp)) fail('config-threshold');
|
|
38
|
+
const order = (value, complete) => Array.isArray(value) && value.every((id) => ids.has(id))
|
|
39
|
+
&& new Set(value).size === value.length && (!complete || value.length === ids.size);
|
|
40
|
+
if (!order(config.defaultOrder, true) || !order(config.pool, false) || !record(config.taskOrders)
|
|
41
|
+
|| Object.values(config.taskOrders).some((o) => !order(o, true))) fail('config-order');
|
|
42
|
+
if (!Array.isArray(config.routes)) fail('config-route');
|
|
43
|
+
const routeIds = new Set();
|
|
44
|
+
for (const route of config.routes) {
|
|
45
|
+
if (!record(route) || !text(route.id) || routeIds.has(route.id) || !text(route.originEnvironmentId)
|
|
46
|
+
|| !environments.has(route.environmentId) || typeof route.enabled !== 'boolean'
|
|
47
|
+
|| !['local', 'remote', 'remote-author-coordinator'].includes(route.kind)
|
|
48
|
+
|| (route.kind === 'local') !== (route.originEnvironmentId === route.environmentId)) fail('config-route');
|
|
49
|
+
routeIds.add(route.id);
|
|
50
|
+
}
|
|
51
|
+
return config;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function validateInput(input, now) {
|
|
55
|
+
if (!time(now)) fail('evaluation-time');
|
|
56
|
+
if (!record(input) || input.schemaVersion !== 1) fail('schema-version: input');
|
|
57
|
+
if (!record(input.origin) || !text(input.origin.environmentId) || !record(input.origin.versions)) fail('origin-schema');
|
|
58
|
+
const r = input.request;
|
|
59
|
+
if (!record(r) || !text(r.task) || !['reader', 'author'].includes(r.role)
|
|
60
|
+
|| !Array.isArray(r.requiredCapabilities) || !r.requiredCapabilities.every(text)
|
|
61
|
+
|| !text(r.repositoryId) || !text(r.providerInstanceId) || !text(r.model)
|
|
62
|
+
|| !record(r.options) || !text(r.runtimeMode)) fail('request-schema');
|
|
63
|
+
for (const key of ['destinations', 'routeFacts', 'telemetry', 'assignments', 'pendingAssignments']) {
|
|
64
|
+
if (!Array.isArray(input[key])) fail(`${key}-schema`);
|
|
65
|
+
}
|
|
66
|
+
if (!['known', 'unknown'].includes(input.inventoryStatus)
|
|
67
|
+
|| (input.inventoryStatus === 'known' && (!time(input.inventoryObservedAt) || input.inventoryObservedAt > now))) fail('inventory-schema');
|
|
68
|
+
if (!record(input.exclusions) || !record(input.exclusions.hosts) || !record(input.exclusions.routes)
|
|
69
|
+
|| !Object.values(input.exclusions.hosts).every(text) || !Object.values(input.exclusions.routes).every(text)) fail('exclusions-schema');
|
|
70
|
+
// Reject ambiguous joins, rather than choosing whichever duplicate arrived first.
|
|
71
|
+
for (const [key, id] of [['destinations', 'environmentId'], ['routeFacts', 'routeId'], ['telemetry', 'environmentId']]) {
|
|
72
|
+
const seen = new Set();
|
|
73
|
+
for (const value of input[key]) {
|
|
74
|
+
if (!record(value) || !text(value[id]) || seen.has(value[id])) fail(`${key}-schema`);
|
|
75
|
+
seen.add(value[id]);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Validate explicit saved native reads. These bindings do not authenticate input.
|
|
2
|
+
import { canonical, text, time } from './fleet-config.js';
|
|
3
|
+
|
|
4
|
+
export function inspectDestination(observation, recommendation, capabilitiesText, now) {
|
|
5
|
+
const terms = [];
|
|
6
|
+
const term = (code, pass, evidence, missing = evidence == null) => terms.push({ code,
|
|
7
|
+
state: pass ? 'pass' : missing ? 'unknown' : 'fail', evidence: evidence ?? null });
|
|
8
|
+
const s = recommendation.selected;
|
|
9
|
+
const o = observation ?? {};
|
|
10
|
+
for (const [name, tool, environmentId, args] of [
|
|
11
|
+
['environment', 't3_environment_read', s.environmentId, {}],
|
|
12
|
+
['project', 't3_project_read', s.environmentId, { projectId: s.projectId }],
|
|
13
|
+
['capabilities', 'orchestrator_capabilities', s.environmentId, {}],
|
|
14
|
+
['links', 't3_environment_links', recommendation.originEnvironmentId, {}],
|
|
15
|
+
]) {
|
|
16
|
+
const read = o[name];
|
|
17
|
+
term('destination-observation', read?.tool === tool && read?.environmentId === environmentId
|
|
18
|
+
&& canonical(read?.args) === canonical(args) && time(read?.observedAt)
|
|
19
|
+
&& read.observedAt <= now && text(read?.evidence), read);
|
|
20
|
+
}
|
|
21
|
+
term('destination-identity', o.environment?.value?.environmentId === s.environmentId,
|
|
22
|
+
o.environment?.value?.environmentId);
|
|
23
|
+
term('destination-project', o.project?.value?.id === s.projectId && text(o.project?.value?.workspaceRoot),
|
|
24
|
+
o.project?.value);
|
|
25
|
+
term('destination-repository', o.project?.value?.repositoryIdentity?.canonicalKey === s.repositoryId,
|
|
26
|
+
o.project?.value?.repositoryIdentity);
|
|
27
|
+
term('destination-catalog', o.capabilities?.valueText === capabilitiesText, o.capabilities?.valueText);
|
|
28
|
+
const catalog = JSON.parse(capabilitiesText);
|
|
29
|
+
const provider = catalog.providers?.find((v) => v.providerInstanceId === recommendation.request.providerInstanceId);
|
|
30
|
+
term('destination-execution', provider?.canRunChildTask === true
|
|
31
|
+
&& provider?.runtimeModes?.includes(recommendation.request.runtimeMode), provider,
|
|
32
|
+
provider?.canRunChildTask == null || !Array.isArray(provider?.runtimeModes));
|
|
33
|
+
const local = s.environmentId === recommendation.originEnvironmentId;
|
|
34
|
+
const link = o.links?.value?.links?.find((v) => v.environmentId === s.environmentId);
|
|
35
|
+
term('destination-access', o.links?.value?.environmentId === recommendation.originEnvironmentId
|
|
36
|
+
&& (local || (link?.status === 'reachable' && link?.access === recommendation.request.runtimeMode
|
|
37
|
+
&& Date.parse(link?.expiresAt) > now)), local ? o.links?.value : link);
|
|
38
|
+
return terms;
|
|
39
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Bind supplied placement facts to a plan. No reads, dispatch or authority grant.
|
|
2
|
+
import { inspectDestination } from './fleet-destination.js';
|
|
3
|
+
import { canonical, record, text } from './fleet-config.js';
|
|
4
|
+
import { recommend } from './fleet-route.js';
|
|
5
|
+
|
|
6
|
+
export function bindFleet(input, saved, configDigest, now, capabilitiesText, resolved, author) {
|
|
7
|
+
const hold = (code) => { throw Object.assign(new Error(code), { exitCode: 2 }); };
|
|
8
|
+
if (saved?.schemaVersion !== 1) hold('schema-version: recommendation');
|
|
9
|
+
if (configDigest !== input?.configDigest) hold('config-digest-mismatch');
|
|
10
|
+
let current;
|
|
11
|
+
try { current = recommend(input, now); } catch (error) { hold(error.message); }
|
|
12
|
+
if (canonical(saved) !== canonical(current)) hold('recommendation-mismatch');
|
|
13
|
+
const selected = current.selected;
|
|
14
|
+
if (!selected) hold('no-executable-route');
|
|
15
|
+
// Coordinator enablement/lifecycle is a later capability, never inferred here.
|
|
16
|
+
if (selected.routeKind === 'remote-author-coordinator') hold('remote-author-not-enabled');
|
|
17
|
+
const digest = selected.destinationCapabilitiesDigest;
|
|
18
|
+
if (typeof digest !== 'string' || !/^[a-f0-9]{64}$/.test(digest)) hold('capabilities-digest-missing');
|
|
19
|
+
if (new Bun.CryptoHasher('sha256').update(capabilitiesText).digest('hex') !== digest) hold('capabilities-digest-mismatch');
|
|
20
|
+
if (input.nativeDestination !== undefined) {
|
|
21
|
+
const mismatch = inspectDestination(input.nativeDestination, current, capabilitiesText, now)
|
|
22
|
+
.find((term) => term.state !== 'pass');
|
|
23
|
+
if (mismatch) hold(mismatch.code);
|
|
24
|
+
}
|
|
25
|
+
const request = current.request;
|
|
26
|
+
const { model, providerInstanceId, effortOption } = resolved;
|
|
27
|
+
if (request.providerInstanceId !== providerInstanceId || request.model !== model
|
|
28
|
+
|| request.runtimeMode !== 'full-access' || (request.role === 'author') !== author
|
|
29
|
+
|| !record(request.options) || request.options[effortOption.id] !== effortOption.value) hold('settings-mismatch');
|
|
30
|
+
const catalog = JSON.parse(capabilitiesText);
|
|
31
|
+
const instance = catalog.providers.find((p) => p.providerInstanceId === providerInstanceId);
|
|
32
|
+
const models = instance.models.filter((m) => m.id === model);
|
|
33
|
+
if (models.length !== 1) hold('capabilities-model-ambiguous');
|
|
34
|
+
const options = Object.entries(request.options).sort(([a], [b]) => a.localeCompare(b)).map(([id, value]) => {
|
|
35
|
+
const definitions = models[0].options.filter((o) => o.id === id);
|
|
36
|
+
if (!text(id) || !['string', 'boolean'].includes(typeof value) || definitions.length !== 1) hold('unsupported-option');
|
|
37
|
+
const definition = definitions[0];
|
|
38
|
+
const values = definition.values ?? definition.options?.map((v) => v.id);
|
|
39
|
+
if (!values?.includes(value)) hold(`unsupported-option: ${id}`);
|
|
40
|
+
return { id, value };
|
|
41
|
+
});
|
|
42
|
+
return {
|
|
43
|
+
options, destination: { environmentId: selected.environmentId, projectId: selected.projectId },
|
|
44
|
+
fleetBinding: { configDigest, destinationSnapshotId: selected.destinationSnapshotId,
|
|
45
|
+
destinationCapabilitiesDigest: digest },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { nativeAssignments } from './fleet-inventory.js';
|
|
2
|
+
import { canonical, evidence, fail, record, text, time } from './fleet-config.js';
|
|
3
|
+
|
|
4
|
+
const terminal = new Set(['completed', 'failed', 'cancelled', 'canceled', 'rejected']);
|
|
5
|
+
const states = new Set([...terminal, 'pending', 'queued', 'running', 'waiting', 'blocked']);
|
|
6
|
+
const qualified = (value) => text(value) && /^.+:.+$/.test(value);
|
|
7
|
+
|
|
8
|
+
export function assignmentCounts(input, hosts, now) {
|
|
9
|
+
if (input.nativeInventory !== undefined) {
|
|
10
|
+
const normalized = nativeAssignments(input.nativeInventory, hosts, now);
|
|
11
|
+
const counts = assignmentCounts(normalized, hosts);
|
|
12
|
+
return { ...counts, inventoryReasons: normalized.inventoryReasons,
|
|
13
|
+
inventoryEvidence: normalized.inventoryEvidence,
|
|
14
|
+
...(normalized.inventoryStatus === 'unknown' ? {
|
|
15
|
+
pendingLowerBound: counts.byEnvironment,
|
|
16
|
+
byEnvironment: Object.fromEntries(hosts.map((h) => [h.environmentId, null])),
|
|
17
|
+
} : {}) };
|
|
18
|
+
}
|
|
19
|
+
const byEnvironment = Object.fromEntries(hosts.map((h) => [h.environmentId, 0]));
|
|
20
|
+
const inventory = new Map();
|
|
21
|
+
const clients = new Map();
|
|
22
|
+
const pendingTasks = new Map();
|
|
23
|
+
for (const a of input.assignments) {
|
|
24
|
+
if (!record(a) || !qualified(a.assignmentId) || !text(a.environmentId) || !states.has(a.state)) fail('assignment-schema');
|
|
25
|
+
const previous = inventory.get(a.assignmentId);
|
|
26
|
+
if (previous && (previous.environmentId !== a.environmentId || previous.state !== a.state)) fail('assignment-conflict');
|
|
27
|
+
inventory.set(a.assignmentId, a);
|
|
28
|
+
}
|
|
29
|
+
const count = (environmentId) => {
|
|
30
|
+
if (Object.hasOwn(byEnvironment, environmentId)) byEnvironment[environmentId]++;
|
|
31
|
+
};
|
|
32
|
+
if (input.inventoryStatus === 'known') {
|
|
33
|
+
for (const a of inventory.values()) if (!terminal.has(a.state)) count(a.environmentId);
|
|
34
|
+
}
|
|
35
|
+
for (const p of input.pendingAssignments) {
|
|
36
|
+
if (!record(p) || !text(p.clientRequestId) || !text(p.environmentId)
|
|
37
|
+
|| (p.taskId !== undefined && !qualified(p.taskId))) fail('pending-assignment-schema');
|
|
38
|
+
const previous = clients.get(p.clientRequestId);
|
|
39
|
+
if (previous && (previous.environmentId !== p.environmentId || previous.taskId !== p.taskId)) fail('assignment-conflict');
|
|
40
|
+
if (previous) continue;
|
|
41
|
+
clients.set(p.clientRequestId, p);
|
|
42
|
+
if (p.taskId !== undefined) {
|
|
43
|
+
const known = input.inventoryStatus === 'known' ? inventory.get(p.taskId) : null;
|
|
44
|
+
const pendingEnvironment = pendingTasks.get(p.taskId);
|
|
45
|
+
if ((known && known.environmentId !== p.environmentId)
|
|
46
|
+
|| (pendingEnvironment && pendingEnvironment !== p.environmentId)) fail('assignment-conflict');
|
|
47
|
+
if (known || pendingEnvironment) continue;
|
|
48
|
+
pendingTasks.set(p.taskId, p.environmentId);
|
|
49
|
+
}
|
|
50
|
+
count(p.environmentId);
|
|
51
|
+
}
|
|
52
|
+
return { byEnvironment, inventoryStatus: input.inventoryStatus,
|
|
53
|
+
inventoryObservedAt: input.inventoryObservedAt ?? null, pendingRecords: clients.size,
|
|
54
|
+
provenance: input.inventoryStatus === 'known' ? 'inventory-plus-pending' : 'pending-only' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Scale the shortest decimal representation, avoiding binary multiplication overshoot.
|
|
58
|
+
function fractionBp(fraction) {
|
|
59
|
+
const [mantissa, exponent = '0'] = String(fraction).split('e');
|
|
60
|
+
const [whole, decimals = ''] = mantissa.split('.');
|
|
61
|
+
const numerator = BigInt(whole + decimals);
|
|
62
|
+
const scale = decimals.length - Number(exponent) - 4;
|
|
63
|
+
if (scale <= 0) return Number(numerator * (10n ** BigInt(-scale)));
|
|
64
|
+
const denominator = 10n ** BigInt(scale);
|
|
65
|
+
return Number((numerator + denominator - 1n) / denominator);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function telemetry(sample, now) {
|
|
69
|
+
const provenance = Object.fromEntries(['source', 'scope', 'durationMs', 'intervalMs', 'windowStartedAt',
|
|
70
|
+
'windowEndedAt', 'windowKind', 'receivedAt', 'timeoutMs'].filter((key) => sample?.[key] !== undefined)
|
|
71
|
+
.map((key) => [key, sample[key]]));
|
|
72
|
+
const ageMs = time(sample?.receivedAt) ? now - sample.receivedAt : null;
|
|
73
|
+
const unknown = (reason) => ({ ...provenance, status: 'unknown', reason, ageMs });
|
|
74
|
+
if (!sample) return unknown('missing');
|
|
75
|
+
if (sample.timedOut === true) return unknown('timeout');
|
|
76
|
+
if (sample.status !== 'ok') return unknown(text(sample.status) ? sample.status : 'failed');
|
|
77
|
+
if (!time(sample.receivedAt)) return unknown('invalid-received-time');
|
|
78
|
+
if (ageMs < 0) return unknown('future');
|
|
79
|
+
if (ageMs > 15000) return unknown('stale');
|
|
80
|
+
if (!Number.isSafeInteger(sample.durationMs) || sample.durationMs < 5000
|
|
81
|
+
|| sample.scope !== 'whole-host' || !text(sample.source)) return unknown('unusable-provenance');
|
|
82
|
+
if (sample.windowStartedAt !== undefined && (!time(sample.windowStartedAt)
|
|
83
|
+
|| sample.receivedAt - sample.windowStartedAt < sample.durationMs)) return unknown('inconsistent-window');
|
|
84
|
+
if ((sample.intervalMs !== undefined && (!Number.isSafeInteger(sample.intervalMs)
|
|
85
|
+
|| sample.intervalMs < 5000 || sample.intervalMs > 60000 || sample.intervalMs > sample.durationMs))
|
|
86
|
+
|| (sample.windowEndedAt !== undefined && (!time(sample.windowEndedAt)
|
|
87
|
+
|| !time(sample.windowStartedAt) || sample.windowEndedAt > sample.receivedAt
|
|
88
|
+
|| sample.windowEndedAt - sample.windowStartedAt < sample.durationMs))) return unknown('inconsistent-window');
|
|
89
|
+
const toBp = (name) => {
|
|
90
|
+
const points = sample[`${name}Bp`];
|
|
91
|
+
const fraction = sample[`${name}Fraction`];
|
|
92
|
+
// Exactly one representation: conflicting fields must not silently win.
|
|
93
|
+
if (points !== undefined && fraction !== undefined) return null;
|
|
94
|
+
if (fraction !== undefined) return typeof fraction === 'number' && Number.isFinite(fraction)
|
|
95
|
+
&& fraction >= 0 && fraction <= 1 ? fractionBp(fraction) : null;
|
|
96
|
+
return Number.isInteger(points) && points >= 0 && points <= 10000 ? points : null;
|
|
97
|
+
};
|
|
98
|
+
const cpuBp = toBp('cpu'); const ramBp = toBp('ram');
|
|
99
|
+
if (cpuBp === null || ramBp === null) return unknown('malformed');
|
|
100
|
+
// If the collector supplies latest RAM as well as an average, retain spikes.
|
|
101
|
+
const latest = sample.ramLatestBp;
|
|
102
|
+
if (latest !== undefined && (!Number.isInteger(latest) || latest < 0 || latest > 10000)) return unknown('malformed');
|
|
103
|
+
return { status: 'measured', ageMs, cpuBp, ramBp, ramLatestBp: latest ?? null,
|
|
104
|
+
loadBp: Math.max(cpuBp, ramBp, latest ?? 0), receivedAt: sample.receivedAt,
|
|
105
|
+
source: sample.source, scope: sample.scope, durationMs: sample.durationMs,
|
|
106
|
+
windowStartedAt: sample.windowStartedAt ?? null, windowEndedAt: sample.windowEndedAt ?? null,
|
|
107
|
+
intervalMs: sample.intervalMs ?? null, windowKind: sample.windowKind ?? null,
|
|
108
|
+
timeoutMs: sample.timeoutMs ?? null };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const versionsMatch = (a, b) => record(a) && record(b) && text(a.t3) && text(a.axstack)
|
|
112
|
+
&& a.t3 === b.t3 && a.axstack === b.axstack;
|
|
113
|
+
|
|
114
|
+
export function routeReasons(route, host, destination, facts, input, now) {
|
|
115
|
+
const reasons = [];
|
|
116
|
+
const add = (code) => { if (!reasons.includes(code)) reasons.push(code); };
|
|
117
|
+
const remote = route.environmentId !== input.origin.environmentId;
|
|
118
|
+
const r = input.request;
|
|
119
|
+
if (Object.hasOwn(input.exclusions.hosts, host.id)) add('excluded-host');
|
|
120
|
+
if (Object.hasOwn(input.exclusions.routes, route.id)) add('excluded-route');
|
|
121
|
+
if (!route.enabled) add('route-disabled');
|
|
122
|
+
if (route.kind === 'remote-author-coordinator' && r.role !== 'author') add('route-role-mismatch');
|
|
123
|
+
if (!evidence(route.evidence, host.environmentId, now)) add('evidence-unproven');
|
|
124
|
+
if (!facts) add('route-unproven');
|
|
125
|
+
else {
|
|
126
|
+
if (facts.reachable !== true) add(facts.reachable === false ? 'offline' : 'route-unproven');
|
|
127
|
+
if (facts.authorized !== true) add(facts.authorized === false ? 'unauthorized' : 'route-unproven');
|
|
128
|
+
if (remote && (!time(facts.expiresAt) || facts.expiresAt <= now)) add('link-expired');
|
|
129
|
+
if (!evidence(facts.evidence, host.environmentId, now)) add('evidence-unproven');
|
|
130
|
+
}
|
|
131
|
+
if (destination?.status !== 'known') add('destination-unproven');
|
|
132
|
+
else {
|
|
133
|
+
if (!time(destination.observedAt) || destination.observedAt > now
|
|
134
|
+
|| !evidence(destination.evidence, host.environmentId, now)) add('evidence-unproven');
|
|
135
|
+
if (!text(destination.snapshotId) || facts?.destinationSnapshotId !== destination.snapshotId) add('target-snapshot-mismatch');
|
|
136
|
+
if (!record(destination.project) || !text(destination.project.id)) add('project-missing');
|
|
137
|
+
else if (destination.project.repositoryId !== r.repositoryId) add('project-mismatch');
|
|
138
|
+
const e = destination.execution;
|
|
139
|
+
if (!record(e) || e.available !== true) add('execution-unproven');
|
|
140
|
+
else if (e.providerInstanceId !== r.providerInstanceId || e.model !== r.model
|
|
141
|
+
|| e.runtimeMode !== r.runtimeMode || canonical(e.options) !== canonical(r.options)) add('execution-mismatch');
|
|
142
|
+
}
|
|
143
|
+
const required = [...r.requiredCapabilities, ...(r.role === 'author' ? ['worktree-isolation'] : [])];
|
|
144
|
+
for (const name of required) {
|
|
145
|
+
for (const capabilities of [host.capabilities, destination?.capabilities]) {
|
|
146
|
+
const fact = capabilities?.[name];
|
|
147
|
+
if (fact?.value !== true || !evidence(fact.evidence, host.environmentId, now)) add('capability-unproven');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (r.role === 'author' && remote) {
|
|
151
|
+
if (route.kind !== 'remote-author-coordinator') add('remote-author-unsupported');
|
|
152
|
+
else {
|
|
153
|
+
const proof = route.authorProof;
|
|
154
|
+
if (proof?.gatesPassed !== true || proof.originEnvironmentId !== input.origin.environmentId
|
|
155
|
+
|| proof.environmentId !== host.environmentId || !evidence(proof.evidence, host.environmentId, now)
|
|
156
|
+
|| !versionsMatch(proof.originVersions, input.origin.versions)
|
|
157
|
+
|| !versionsMatch(proof.destinationVersions, destination?.versions)) add('remote-author-unproven');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return reasons;
|
|
161
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Normalize saved native observations only. No discovery, reservations or dispatch.
|
|
2
|
+
import { canonical, text, time } from './fleet-config.js';
|
|
3
|
+
|
|
4
|
+
const active = new Set(['queued', 'running', 'waiting']);
|
|
5
|
+
const terminal = new Set(['completed', 'failed', 'cancelled', 'interrupted']);
|
|
6
|
+
// JSON tuple encoding avoids collisions between IDs containing punctuation.
|
|
7
|
+
const identity = (environmentId, taskId) => JSON.stringify([environmentId, taskId]);
|
|
8
|
+
|
|
9
|
+
export function nativeAssignments(snapshot, hosts, now) {
|
|
10
|
+
const reasons = new Set();
|
|
11
|
+
const unknown = (code) => reasons.add(code);
|
|
12
|
+
const environments = new Set(hosts.map((h) => h.environmentId));
|
|
13
|
+
const tasks = new Map();
|
|
14
|
+
const assignments = new Map();
|
|
15
|
+
const pending = new Map();
|
|
16
|
+
if (snapshot?.complete !== true || !time(snapshot?.observedAt) || !time(now) || snapshot.observedAt > now || !text(snapshot?.evidence)
|
|
17
|
+
|| !Array.isArray(snapshot?.tasks) || !Array.isArray(snapshot?.pending)) unknown('inventory-incomplete');
|
|
18
|
+
for (const item of Array.isArray(snapshot?.tasks) ? snapshot.tasks : []) {
|
|
19
|
+
if (!text(item?.origin?.environmentId) || !text(item?.origin?.threadId) || !text(item?.task?.taskId)) {
|
|
20
|
+
unknown('origin-unknown'); continue;
|
|
21
|
+
}
|
|
22
|
+
const key = identity(item.origin.environmentId, item.task.taskId);
|
|
23
|
+
const previous = tasks.get(key);
|
|
24
|
+
if (previous && canonical(previous) !== canonical(item)) unknown('assignment-conflict');
|
|
25
|
+
tasks.set(key, item);
|
|
26
|
+
}
|
|
27
|
+
for (const [key, item] of tasks) {
|
|
28
|
+
const task = item.task;
|
|
29
|
+
const environmentId = task.remoteChild?.environmentId
|
|
30
|
+
?? (text(task.childThreadId) ? item.origin.environmentId : null);
|
|
31
|
+
if (!text(environmentId)) { unknown('destination-unknown'); continue; }
|
|
32
|
+
if (!environments.has(environmentId)) { unknown('host-unmapped'); continue; }
|
|
33
|
+
if (task.remoteChild && !text(task.remoteChild.threadId)) { unknown('destination-unknown'); continue; }
|
|
34
|
+
if (!active.has(task.status) && !terminal.has(task.status)) { unknown('task-state-unknown'); continue; }
|
|
35
|
+
let rootKey = key;
|
|
36
|
+
if (item.assignmentRoot) {
|
|
37
|
+
rootKey = identity(item.assignmentRoot.environmentId, item.assignmentRoot.taskId);
|
|
38
|
+
const root = tasks.get(rootKey);
|
|
39
|
+
if (!root || root.assignmentRoot) { unknown('assignment-root-unknown'); continue; }
|
|
40
|
+
const rootEnvironment = root.task.remoteChild?.environmentId
|
|
41
|
+
?? (text(root.task.childThreadId) ? root.origin.environmentId : null);
|
|
42
|
+
if (rootEnvironment !== environmentId) { unknown('assignment-conflict'); continue; }
|
|
43
|
+
}
|
|
44
|
+
const previous = assignments.get(rootKey);
|
|
45
|
+
const state = active.has(task.status) || task.hasPendingChildRuns === true
|
|
46
|
+
|| task.workState === 'waiting_for_children' ? 'running' : 'completed';
|
|
47
|
+
// A terminal coordinator with live descendants remains active.
|
|
48
|
+
assignments.set(rootKey, { assignmentId: `task:${rootKey}`, environmentId,
|
|
49
|
+
state: previous?.state === 'running' ? 'running' : state });
|
|
50
|
+
}
|
|
51
|
+
for (const item of Array.isArray(snapshot?.pending) ? snapshot.pending : []) {
|
|
52
|
+
if (!text(item?.origin?.environmentId) || !text(item?.origin?.threadId) || !text(item?.clientRequestId)) {
|
|
53
|
+
unknown('origin-unknown'); continue;
|
|
54
|
+
}
|
|
55
|
+
if (!environments.has(item.environmentId)) { unknown('host-unmapped'); continue; }
|
|
56
|
+
const key = JSON.stringify([item.origin.environmentId, item.origin.threadId, item.clientRequestId]);
|
|
57
|
+
const value = { clientRequestId: key, environmentId: item.environmentId };
|
|
58
|
+
if (item.taskId !== undefined) {
|
|
59
|
+
if (!text(item.taskId)) { unknown('origin-unknown'); continue; }
|
|
60
|
+
const taskKey = identity(item.origin.environmentId, item.taskId);
|
|
61
|
+
const known = tasks.get(taskKey);
|
|
62
|
+
const rootKey = known?.assignmentRoot
|
|
63
|
+
? identity(known.assignmentRoot.environmentId, known.assignmentRoot.taskId) : taskKey;
|
|
64
|
+
value.taskId = `task:${rootKey}`;
|
|
65
|
+
const reflected = assignments.get(rootKey);
|
|
66
|
+
if (reflected && reflected.environmentId !== item.environmentId) unknown('assignment-conflict');
|
|
67
|
+
}
|
|
68
|
+
const previous = pending.get(key);
|
|
69
|
+
if (previous && canonical(previous) !== canonical(value)) unknown('assignment-conflict');
|
|
70
|
+
pending.set(key, value);
|
|
71
|
+
}
|
|
72
|
+
return { inventoryStatus: reasons.size ? 'unknown' : 'known',
|
|
73
|
+
inventoryObservedAt: snapshot?.observedAt ?? null, inventoryReasons: [...reasons],
|
|
74
|
+
inventoryEvidence: snapshot?.evidence ?? null,
|
|
75
|
+
assignments: [...assignments.values()], pendingAssignments: [...pending.values()] };
|
|
76
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Linux aggregate CPU counters; guest/guest_nice are already in user/nice.
|
|
2
|
+
const malformed = () => { throw Error('malformed'); };
|
|
3
|
+
const bp = (n, d) => Number((n * 10000n + d - 1n) / d);
|
|
4
|
+
export function parseProc(value) {
|
|
5
|
+
if (typeof value !== 'string' || value.length > 65536) malformed();
|
|
6
|
+
const parts = value.split('\n---AXSTACK-MEM---\n');
|
|
7
|
+
if (parts.length !== 2) malformed();
|
|
8
|
+
const lines = parts[0].split('\n').filter((s) => /^cpu\s/.test(s));
|
|
9
|
+
if (lines.length !== 1) malformed();
|
|
10
|
+
const counters = lines[0].trim().split(/\s+/).slice(1);
|
|
11
|
+
if (counters.length < 8 || counters.length > 10
|
|
12
|
+
|| counters.some((v) => !/^\d+$/.test(v) || !Number.isSafeInteger(Number(v)))) malformed();
|
|
13
|
+
const memory = (key) => {
|
|
14
|
+
const rows = parts[1].split('\n').filter((s) => s.startsWith(`${key}:`));
|
|
15
|
+
const match = rows.length === 1 && rows[0].match(new RegExp(`^${key}:\\s+(\\d+) kB\\s*$`));
|
|
16
|
+
if (!match || !Number.isSafeInteger(Number(match[1]))) malformed();
|
|
17
|
+
return BigInt(match[1]);
|
|
18
|
+
};
|
|
19
|
+
const total = memory('MemTotal'); const available = memory('MemAvailable');
|
|
20
|
+
if (total <= 0n || available > total) malformed();
|
|
21
|
+
return { counters: counters.slice(0, 8).map(BigInt), total, used: total - available };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function pressure(first, last) {
|
|
25
|
+
const deltas = last.counters.map((v, i) => v - first.counters[i]);
|
|
26
|
+
const total = deltas.reduce((a, b) => a + b, 0n);
|
|
27
|
+
if (deltas.some((v) => v < 0n) || total <= 0n || first.total !== last.total) throw Error('inconsistent-counters');
|
|
28
|
+
const busy = total - deltas[3] - deltas[4]; // idle and iowait only; steal is busy.
|
|
29
|
+
return { cpuBp: bp(busy, total), ramBp: bp(first.used + last.used, 2n * last.total),
|
|
30
|
+
ramLatestBp: bp(last.used, last.total) };
|
|
31
|
+
}
|