axstack 0.53.1 → 0.55.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.
@@ -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,41 @@
1
+ // Bind supplied placement facts to a plan. No reads, dispatch or authority grant.
2
+ import { canonical, record, text } from './fleet-config.js';
3
+ import { recommend } from './fleet-route.js';
4
+
5
+ export function bindFleet(input, saved, configDigest, now, capabilitiesText, resolved, author) {
6
+ const hold = (code) => { throw Object.assign(new Error(code), { exitCode: 2 }); };
7
+ if (saved?.schemaVersion !== 1) hold('schema-version: recommendation');
8
+ if (configDigest !== input?.configDigest) hold('config-digest-mismatch');
9
+ let current;
10
+ try { current = recommend(input, now); } catch (error) { hold(error.message); }
11
+ if (canonical(saved) !== canonical(current)) hold('recommendation-mismatch');
12
+ const selected = current.selected;
13
+ if (!selected) hold('no-executable-route');
14
+ // Coordinator enablement/lifecycle is a later capability, never inferred here.
15
+ if (selected.routeKind === 'remote-author-coordinator') hold('remote-author-not-enabled');
16
+ const digest = selected.destinationCapabilitiesDigest;
17
+ if (typeof digest !== 'string' || !/^[a-f0-9]{64}$/.test(digest)) hold('capabilities-digest-missing');
18
+ if (new Bun.CryptoHasher('sha256').update(capabilitiesText).digest('hex') !== digest) hold('capabilities-digest-mismatch');
19
+ const request = current.request;
20
+ const { model, providerInstanceId, effortOption } = resolved;
21
+ if (request.providerInstanceId !== providerInstanceId || request.model !== model
22
+ || request.runtimeMode !== 'full-access' || (request.role === 'author') !== author
23
+ || !record(request.options) || request.options[effortOption.id] !== effortOption.value) hold('settings-mismatch');
24
+ const catalog = JSON.parse(capabilitiesText);
25
+ const instance = catalog.providers.find((p) => p.providerInstanceId === providerInstanceId);
26
+ const models = instance.models.filter((m) => m.id === model);
27
+ if (models.length !== 1) hold('capabilities-model-ambiguous');
28
+ const options = Object.entries(request.options).sort(([a], [b]) => a.localeCompare(b)).map(([id, value]) => {
29
+ const definitions = models[0].options.filter((o) => o.id === id);
30
+ if (!text(id) || !['string', 'boolean'].includes(typeof value) || definitions.length !== 1) hold('unsupported-option');
31
+ const definition = definitions[0];
32
+ const values = definition.values ?? definition.options?.map((v) => v.id);
33
+ if (!values?.includes(value)) hold(`unsupported-option: ${id}`);
34
+ return { id, value };
35
+ });
36
+ return {
37
+ options, destination: { environmentId: selected.environmentId, projectId: selected.projectId },
38
+ fleetBinding: { configDigest, destinationSnapshotId: selected.destinationSnapshotId,
39
+ destinationCapabilitiesDigest: digest },
40
+ };
41
+ }
@@ -0,0 +1,150 @@
1
+ import { canonical, evidence, fail, record, text, time } from './fleet-config.js';
2
+
3
+ const terminal = new Set(['completed', 'failed', 'cancelled', 'canceled', 'rejected']);
4
+ const states = new Set([...terminal, 'pending', 'queued', 'running', 'waiting', 'blocked']);
5
+ const qualified = (value) => text(value) && /^.+:.+$/.test(value);
6
+
7
+ export function assignmentCounts(input, hosts) {
8
+ const byEnvironment = Object.fromEntries(hosts.map((h) => [h.environmentId, 0]));
9
+ const inventory = new Map();
10
+ const clients = new Map();
11
+ const pendingTasks = new Map();
12
+ for (const a of input.assignments) {
13
+ if (!record(a) || !qualified(a.assignmentId) || !text(a.environmentId) || !states.has(a.state)) fail('assignment-schema');
14
+ const previous = inventory.get(a.assignmentId);
15
+ if (previous && (previous.environmentId !== a.environmentId || previous.state !== a.state)) fail('assignment-conflict');
16
+ inventory.set(a.assignmentId, a);
17
+ }
18
+ const count = (environmentId) => {
19
+ if (Object.hasOwn(byEnvironment, environmentId)) byEnvironment[environmentId]++;
20
+ };
21
+ if (input.inventoryStatus === 'known') {
22
+ for (const a of inventory.values()) if (!terminal.has(a.state)) count(a.environmentId);
23
+ }
24
+ for (const p of input.pendingAssignments) {
25
+ if (!record(p) || !text(p.clientRequestId) || !text(p.environmentId)
26
+ || (p.taskId !== undefined && !qualified(p.taskId))) fail('pending-assignment-schema');
27
+ const previous = clients.get(p.clientRequestId);
28
+ if (previous && (previous.environmentId !== p.environmentId || previous.taskId !== p.taskId)) fail('assignment-conflict');
29
+ if (previous) continue;
30
+ clients.set(p.clientRequestId, p);
31
+ if (p.taskId !== undefined) {
32
+ const known = input.inventoryStatus === 'known' ? inventory.get(p.taskId) : null;
33
+ const pendingEnvironment = pendingTasks.get(p.taskId);
34
+ if ((known && known.environmentId !== p.environmentId)
35
+ || (pendingEnvironment && pendingEnvironment !== p.environmentId)) fail('assignment-conflict');
36
+ if (known || pendingEnvironment) continue;
37
+ pendingTasks.set(p.taskId, p.environmentId);
38
+ }
39
+ count(p.environmentId);
40
+ }
41
+ return { byEnvironment, inventoryStatus: input.inventoryStatus,
42
+ inventoryObservedAt: input.inventoryObservedAt ?? null, pendingRecords: clients.size,
43
+ provenance: input.inventoryStatus === 'known' ? 'inventory-plus-pending' : 'pending-only' };
44
+ }
45
+
46
+ // Scale the shortest decimal representation, avoiding binary multiplication overshoot.
47
+ function fractionBp(fraction) {
48
+ const [mantissa, exponent = '0'] = String(fraction).split('e');
49
+ const [whole, decimals = ''] = mantissa.split('.');
50
+ const numerator = BigInt(whole + decimals);
51
+ const scale = decimals.length - Number(exponent) - 4;
52
+ if (scale <= 0) return Number(numerator * (10n ** BigInt(-scale)));
53
+ const denominator = 10n ** BigInt(scale);
54
+ return Number((numerator + denominator - 1n) / denominator);
55
+ }
56
+
57
+ export function telemetry(sample, now) {
58
+ const provenance = Object.fromEntries(['source', 'scope', 'durationMs', 'intervalMs', 'windowStartedAt',
59
+ 'windowEndedAt', 'windowKind', 'receivedAt', 'timeoutMs'].filter((key) => sample?.[key] !== undefined)
60
+ .map((key) => [key, sample[key]]));
61
+ const ageMs = time(sample?.receivedAt) ? now - sample.receivedAt : null;
62
+ const unknown = (reason) => ({ ...provenance, status: 'unknown', reason, ageMs });
63
+ if (!sample) return unknown('missing');
64
+ if (sample.timedOut === true) return unknown('timeout');
65
+ if (sample.status !== 'ok') return unknown(text(sample.status) ? sample.status : 'failed');
66
+ if (!time(sample.receivedAt)) return unknown('invalid-received-time');
67
+ if (ageMs < 0) return unknown('future');
68
+ if (ageMs > 15000) return unknown('stale');
69
+ if (!Number.isSafeInteger(sample.durationMs) || sample.durationMs < 5000
70
+ || sample.scope !== 'whole-host' || !text(sample.source)) return unknown('unusable-provenance');
71
+ if (sample.windowStartedAt !== undefined && (!time(sample.windowStartedAt)
72
+ || sample.receivedAt - sample.windowStartedAt < sample.durationMs)) return unknown('inconsistent-window');
73
+ if ((sample.intervalMs !== undefined && (!Number.isSafeInteger(sample.intervalMs)
74
+ || sample.intervalMs < 5000 || sample.intervalMs > 60000 || sample.intervalMs > sample.durationMs))
75
+ || (sample.windowEndedAt !== undefined && (!time(sample.windowEndedAt)
76
+ || !time(sample.windowStartedAt) || sample.windowEndedAt > sample.receivedAt
77
+ || sample.windowEndedAt - sample.windowStartedAt < sample.durationMs))) return unknown('inconsistent-window');
78
+ const toBp = (name) => {
79
+ const points = sample[`${name}Bp`];
80
+ const fraction = sample[`${name}Fraction`];
81
+ // Exactly one representation: conflicting fields must not silently win.
82
+ if (points !== undefined && fraction !== undefined) return null;
83
+ if (fraction !== undefined) return typeof fraction === 'number' && Number.isFinite(fraction)
84
+ && fraction >= 0 && fraction <= 1 ? fractionBp(fraction) : null;
85
+ return Number.isInteger(points) && points >= 0 && points <= 10000 ? points : null;
86
+ };
87
+ const cpuBp = toBp('cpu'); const ramBp = toBp('ram');
88
+ if (cpuBp === null || ramBp === null) return unknown('malformed');
89
+ // If the collector supplies latest RAM as well as an average, retain spikes.
90
+ const latest = sample.ramLatestBp;
91
+ if (latest !== undefined && (!Number.isInteger(latest) || latest < 0 || latest > 10000)) return unknown('malformed');
92
+ return { status: 'measured', ageMs, cpuBp, ramBp, ramLatestBp: latest ?? null,
93
+ loadBp: Math.max(cpuBp, ramBp, latest ?? 0), receivedAt: sample.receivedAt,
94
+ source: sample.source, scope: sample.scope, durationMs: sample.durationMs,
95
+ windowStartedAt: sample.windowStartedAt ?? null, windowEndedAt: sample.windowEndedAt ?? null,
96
+ intervalMs: sample.intervalMs ?? null, windowKind: sample.windowKind ?? null,
97
+ timeoutMs: sample.timeoutMs ?? null };
98
+ }
99
+
100
+ const versionsMatch = (a, b) => record(a) && record(b) && text(a.t3) && text(a.axstack)
101
+ && a.t3 === b.t3 && a.axstack === b.axstack;
102
+
103
+ export function routeReasons(route, host, destination, facts, input, now) {
104
+ const reasons = [];
105
+ const add = (code) => { if (!reasons.includes(code)) reasons.push(code); };
106
+ const remote = route.environmentId !== input.origin.environmentId;
107
+ const r = input.request;
108
+ if (Object.hasOwn(input.exclusions.hosts, host.id)) add('excluded-host');
109
+ if (Object.hasOwn(input.exclusions.routes, route.id)) add('excluded-route');
110
+ if (!route.enabled) add('route-disabled');
111
+ if (route.kind === 'remote-author-coordinator' && r.role !== 'author') add('route-role-mismatch');
112
+ if (!evidence(route.evidence, host.environmentId, now)) add('evidence-unproven');
113
+ if (!facts) add('route-unproven');
114
+ else {
115
+ if (facts.reachable !== true) add(facts.reachable === false ? 'offline' : 'route-unproven');
116
+ if (facts.authorized !== true) add(facts.authorized === false ? 'unauthorized' : 'route-unproven');
117
+ if (remote && (!time(facts.expiresAt) || facts.expiresAt <= now)) add('link-expired');
118
+ if (!evidence(facts.evidence, host.environmentId, now)) add('evidence-unproven');
119
+ }
120
+ if (destination?.status !== 'known') add('destination-unproven');
121
+ else {
122
+ if (!time(destination.observedAt) || destination.observedAt > now
123
+ || !evidence(destination.evidence, host.environmentId, now)) add('evidence-unproven');
124
+ if (!text(destination.snapshotId) || facts?.destinationSnapshotId !== destination.snapshotId) add('target-snapshot-mismatch');
125
+ if (!record(destination.project) || !text(destination.project.id)) add('project-missing');
126
+ else if (destination.project.repositoryId !== r.repositoryId) add('project-mismatch');
127
+ const e = destination.execution;
128
+ if (!record(e) || e.available !== true) add('execution-unproven');
129
+ else if (e.providerInstanceId !== r.providerInstanceId || e.model !== r.model
130
+ || e.runtimeMode !== r.runtimeMode || canonical(e.options) !== canonical(r.options)) add('execution-mismatch');
131
+ }
132
+ const required = [...r.requiredCapabilities, ...(r.role === 'author' ? ['worktree-isolation'] : [])];
133
+ for (const name of required) {
134
+ for (const capabilities of [host.capabilities, destination?.capabilities]) {
135
+ const fact = capabilities?.[name];
136
+ if (fact?.value !== true || !evidence(fact.evidence, host.environmentId, now)) add('capability-unproven');
137
+ }
138
+ }
139
+ if (r.role === 'author' && remote) {
140
+ if (route.kind !== 'remote-author-coordinator') add('remote-author-unsupported');
141
+ else {
142
+ const proof = route.authorProof;
143
+ if (proof?.gatesPassed !== true || proof.originEnvironmentId !== input.origin.environmentId
144
+ || proof.environmentId !== host.environmentId || !evidence(proof.evidence, host.environmentId, now)
145
+ || !versionsMatch(proof.originVersions, input.origin.versions)
146
+ || !versionsMatch(proof.destinationVersions, destination?.versions)) add('remote-author-unproven');
147
+ }
148
+ }
149
+ return reasons;
150
+ }
@@ -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
+ }
@@ -0,0 +1,78 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { sampleMain, sampleHelp } from './fleet-sample-cli.js';
3
+ import { recommend, parseFleetConfig } from './fleet-route.js';
4
+
5
+ const help = `Usage: bun fleet-route.js recommend --input <snapshot.json> --now <epoch-ms> [--json]
6
+ [--task <name>] [--config <fleet-routing.json>] [--config-digest <sha256>]
7
+
8
+ --input Saved schema-1 facts; no collection or dispatch
9
+ --now Required nonnegative integer evaluation time; no ambient clock
10
+ --task Override the saved request's task preference
11
+ --config Reviewed account-local config (default: ~/.config/haoshoku/fleet-routing.json)
12
+ --config-digest Run-pinned SHA-256; otherwise required in saved input.configDigest
13
+ --json Emit complete recommendation JSON; errors are JSON on stderr
14
+ --help Show this interface
15
+
16
+ Reads only the supplied input and config. Writes stdout/stderr only.
17
+ No network, authentication, dispatch, config changes or retained state.
18
+ Required session capabilities must be proven on the actual destination session;
19
+ an origin catalog or transferred credentials cannot establish them.
20
+ Exclusions with reasons are supplied in input.exclusions.hosts/routes.
21
+ Use sample or sample-recommend for bounded collection.
22
+ Exit codes: 0 recommendation/help, 1 input/config error, 2 no executable route.
23
+ `;
24
+
25
+ const fail = (code) => { throw new Error(code); };
26
+ function readInput(path) {
27
+ let value;
28
+ try { value = readFileSync(path, 'utf8'); } catch { fail('missing-input'); }
29
+ try { return JSON.parse(value); } catch { fail('input-json'); }
30
+ }
31
+
32
+ function defaultConfigPath() {
33
+ return process.env.HOME ? `${process.env.HOME}/.config/haoshoku/fleet-routing.json` : null;
34
+ }
35
+
36
+ export async function main(argv) {
37
+ try {
38
+ if (argv.length === 1 && argv[0] === '--help') {
39
+ process.stdout.write(help + sampleHelp);
40
+ return 0;
41
+ }
42
+ if (['sample', 'sample-recommend'].includes(argv[0])) return await sampleMain(argv, defaultConfigPath());
43
+ if (argv[0] !== 'recommend') fail('unexpected-command');
44
+ const args = {};
45
+ for (let i = 1; i < argv.length; i++) {
46
+ const flag = argv[i];
47
+ if (!['--input', '--now', '--task', '--config', '--config-digest', '--json'].includes(flag)) fail('unexpected-argument');
48
+ if (Object.hasOwn(args, flag)) fail('duplicate-argument');
49
+ if (flag === '--json') { args[flag] = true; continue; }
50
+ const value = argv[++i];
51
+ if (!value?.trim() || value.startsWith('--')) fail('missing-argument');
52
+ args[flag] = value;
53
+ }
54
+ if (!args['--input'] || !args['--now']) fail('missing-argument');
55
+ if (!/^\d+$/.test(args['--now'])) fail('evaluation-time');
56
+ const now = Number(args['--now']);
57
+ const input = readInput(args['--input']);
58
+ if (!input || typeof input !== 'object' || Array.isArray(input) || input.schemaVersion !== 1) fail('schema-version: input');
59
+ let configText;
60
+ const configPath = args['--config'] ?? defaultConfigPath();
61
+ try {
62
+ if (!configPath) fail('missing-config');
63
+ configText = readFileSync(configPath, 'utf8');
64
+ } catch { fail('missing-config'); }
65
+ const configDigest = args['--config-digest'] ?? input.configDigest;
66
+ parseFleetConfig(configText, configDigest);
67
+ if (input.configDigest !== undefined && input.configDigest !== configDigest) fail('config-digest-mismatch');
68
+ if (input.configText !== undefined && input.configText !== configText) fail('config-snapshot-mismatch');
69
+ const result = recommend({ ...input, configText, configDigest,
70
+ request: args['--task'] ? { ...input.request, task: args['--task'] } : input.request }, now);
71
+ process.stdout.write(args['--json'] ? `${JSON.stringify(result)}\n`
72
+ : `${result.stage}: ${result.selected?.hostId ?? 'no host'} (${result.reason})\n`);
73
+ return result.selected ? 0 : 2;
74
+ } catch (error) {
75
+ process.stderr.write(`${JSON.stringify({ error: { code: error.message.split(':')[0], message: error.message } })}\n`);
76
+ return 1;
77
+ }
78
+ }
@@ -0,0 +1,85 @@
1
+ // Pure supplied-facts API. No clock, filesystem, networking or dispatch.
2
+ import { parseFleetConfig, validateInput } from './fleet-config.js';
3
+ import { assignmentCounts, routeReasons, telemetry } from './fleet-facts.js';
4
+ export { parseFleetConfig } from './fleet-config.js';
5
+
6
+ export function recommend(input, now) {
7
+ validateInput(input, now);
8
+ const config = parseFleetConfig(input.configText, input.configDigest);
9
+ const counts = assignmentCounts(input, config.hosts);
10
+ const preference = Object.hasOwn(config.taskOrders, input.request.task)
11
+ ? config.taskOrders[input.request.task] : config.defaultOrder;
12
+ const eligible = [];
13
+ const excluded = [];
14
+ for (const hostId of preference) {
15
+ const host = config.hosts.find((h) => h.id === hostId);
16
+ const destination = input.destinations.find((d) => d.environmentId === host.environmentId);
17
+ const routes = config.routes.filter((r) => r.environmentId === host.environmentId
18
+ && r.originEnvironmentId === input.origin.environmentId);
19
+ let accepted = null;
20
+ for (const route of routes) {
21
+ const facts = input.routeFacts.find((f) => f.routeId === route.id);
22
+ const reasons = routeReasons(route, host, destination, facts, input, now);
23
+ if (reasons.length) {
24
+ excluded.push({ hostId, environmentId: host.environmentId, routeId: route.id, reasons,
25
+ exclusion: (Object.hasOwn(input.exclusions.hosts, hostId) ? input.exclusions.hosts[hostId] : null)
26
+ ?? (Object.hasOwn(input.exclusions.routes, route.id) ? input.exclusions.routes[route.id] : null),
27
+ evidence: { configuredRoute: route.evidence ?? null, observedRoute: facts?.evidence ?? null,
28
+ destination: destination?.evidence ?? null, destinationSnapshotId: destination?.snapshotId ?? null,
29
+ configuredCapabilities: host.capabilities, observedCapabilities: destination?.capabilities ?? null } });
30
+ } else if (!accepted) {
31
+ accepted = { hostId, environmentId: host.environmentId, projectId: destination.project.id,
32
+ repositoryId: destination.project.repositoryId, routeId: route.id, routeKind: route.kind,
33
+ destinationSnapshotId: destination.snapshotId, destinationObservedAt: destination.observedAt,
34
+ ...(destination.capabilitiesDigest !== undefined
35
+ ? { destinationCapabilitiesDigest: destination.capabilitiesDigest } : {}),
36
+ destinationEvidence: destination.evidence, routeEvidence: facts.evidence, versions: destination.versions,
37
+ execution: destination.execution, capabilities: destination.capabilities,
38
+ fallbackLimitBp: host.fallbackLimitBp, assignmentCount: counts.byEnvironment[host.environmentId],
39
+ telemetry: telemetry(input.telemetry.find((s) => s.environmentId === host.environmentId), now) };
40
+ }
41
+ }
42
+ if (accepted) eligible.push(accepted);
43
+ if (!routes.length) excluded.push({ hostId, environmentId: host.environmentId, routeId: null, reasons: ['route-missing'], exclusion: null });
44
+ }
45
+ const pool = eligible.filter((h) => config.pool.includes(h.hostId));
46
+ const measured = (h) => h.telemetry.status === 'measured';
47
+ const rank = (h) => preference.indexOf(h.hostId);
48
+ const byLoad = (a, b) => a.telemetry.loadBp - b.telemetry.loadBp || rank(a) - rank(b);
49
+ const under = pool.filter((h) => measured(h) && h.telemetry.loadBp <= config.balanceLimitBp);
50
+ let stage; let reason; let selected = null;
51
+ if (!eligible.length) { stage = 'no-route'; reason = 'no-executable-route'; }
52
+ else if (under.length) {
53
+ stage = 'balance'; reason = 'pool-under-limit';
54
+ selected = under.sort((a, b) => a.assignmentCount - b.assignmentCount || byLoad(a, b))[0];
55
+ } else if (pool.length && pool.every((h) => !measured(h))) {
56
+ stage = 'pool-unknown'; reason = 'pool-all-unknown'; selected = pool[0];
57
+ } else {
58
+ stage = 'fallback';
59
+ selected = eligible.find((h) => measured(h) && h.telemetry.loadBp <= h.fallbackLimitBp);
60
+ if (selected) reason = 'first-within-limit';
61
+ else {
62
+ const observed = eligible.filter(measured).sort(byLoad);
63
+ selected = observed[0] ?? eligible[0];
64
+ reason = observed.length ? 'least-loaded-over-limit' : 'all-telemetry-unknown';
65
+ }
66
+ }
67
+ const flags = {
68
+ countsUnknown: input.inventoryStatus === 'unknown', poolEmpty: pool.length === 0,
69
+ poolPartlyUnknown: pool.some(measured) && pool.some((h) => !measured(h)),
70
+ telemetryUnknown: eligible.some((h) => !measured(h)),
71
+ overloaded: selected ? measured(selected) && selected.telemetry.loadBp > selected.fallbackLimitBp : false,
72
+ localAuthorOnly: input.request.role === 'author' && eligible.length > 0
73
+ && eligible.every((h) => h.environmentId === input.origin.environmentId),
74
+ };
75
+ // Copy supplied nested facts so callers cannot mutate input through the result.
76
+ return structuredClone({ schemaVersion: 1, configDigest: input.configDigest, evaluatedAt: now,
77
+ originEnvironmentId: input.origin.environmentId, request: input.request,
78
+ stage, reason: flags.localAuthorOnly ? 'author-local-only' : reason, decisionReason: reason,
79
+ selected, flags, counts, eligible, excluded });
80
+ }
81
+
82
+ if (import.meta.main) {
83
+ const { main } = await import('./fleet-route-cli.js');
84
+ process.exitCode = await main(process.argv.slice(2));
85
+ }
@@ -0,0 +1,81 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { parseFleetConfig, recommend } from './fleet-route.js';
3
+ import { sampleFleet } from './fleet-sample.js';
4
+ import { sampleOptions, validateManifest } from './fleet-sample-manifest.js';
5
+ export const sampleHelp = `
6
+ Usage: bun fleet-route.js sample --fleet <manifest.json> --config-digest <sha256> [--config <path>] [--json]
7
+ bun fleet-route.js sample-recommend --fleet <manifest.json> --input <snapshot.json>
8
+ --config-digest <sha256> [--config <path>] [--json]
9
+ --fleet Schema-1 manifest; exact host/environment joins; local or SSH transport
10
+ --interval-ms Integer 5000..60000; default 5000; minimum gap between two reads
11
+ --timeout-ms Per-host whole-collection deadline; default 10000; interval+1000..120000
12
+ --config Same reviewed config/default path as recommend; explicit digest required
13
+ --input sample-recommend only: C1 access/project/capability/assignment facts
14
+ --json Full sample report or {input,evaluatedAt,recommendation} replay envelope
15
+
16
+ Sample commands read Linux /proc/stat and /proc/meminfo locally or through bounded,
17
+ noninteractive SSH argv to validated MagicDNS short names with existing identities.
18
+ They use collector receipt time, collect hosts concurrently, and write stdout/stderr only.
19
+ Window duration brackets transport plus reads and interval; it is not native history.
20
+ Unknown telemetry never grants a route. No dispatch, config mutation or retained state.
21
+ Exit codes: 0 sample/recommendation, 1 input/config error, 2 no executable route.
22
+ `;
23
+ const fail = (code) => { throw Error(code); };
24
+ function read(path, name, json = true) {
25
+ let text;
26
+ try { text = readFileSync(path, 'utf8'); } catch { fail(`missing-${name}`); }
27
+ if (!json) return text;
28
+ try { return JSON.parse(text); } catch { fail(`${name}-json`); }
29
+ }
30
+ export async function sampleMain(argv, defaultConfigPath) {
31
+ const command = argv[0]; const args = {};
32
+ const flags = ['--fleet', '--config', '--config-digest', '--interval-ms', '--timeout-ms', '--json',
33
+ ...(command === 'sample-recommend' ? ['--input', '--task'] : [])];
34
+ for (let i = 1; i < argv.length; i++) {
35
+ const flag = argv[i];
36
+ if (!flags.includes(flag)) fail('unexpected-argument');
37
+ if (Object.hasOwn(args, flag)) fail('duplicate-argument');
38
+ if (flag === '--json') { args[flag] = true; continue; }
39
+ const value = argv[++i];
40
+ if (!value?.trim() || value.startsWith('--')) fail('missing-argument');
41
+ args[flag] = value;
42
+ }
43
+ if (!args['--fleet'] || (command === 'sample-recommend' && !args['--input'])) fail('missing-argument');
44
+ const numeric = (name, fallback, reason) => {
45
+ if (args[name] === undefined) return fallback;
46
+ if (!/^\d+$/.test(args[name])) fail(reason);
47
+ return Number(args[name]);
48
+ };
49
+ const options = sampleOptions({ intervalMs: numeric('--interval-ms', 5000, 'sample-interval'),
50
+ timeoutMs: numeric('--timeout-ms', 10000, 'sample-timeout') });
51
+ const configPath = args['--config'] ?? defaultConfigPath;
52
+ const configText = read(configPath, 'config', false);
53
+ const configDigest = args['--config-digest'];
54
+ const config = parseFleetConfig(configText, configDigest);
55
+ const manifest = validateManifest(read(args['--fleet'], 'manifest'), config.hosts);
56
+ let input;
57
+ if (command === 'sample-recommend') {
58
+ input = read(args['--input'], 'input');
59
+ if (input?.configDigest !== undefined && input.configDigest !== configDigest) fail('config-digest-mismatch');
60
+ if (input?.configText !== undefined && input.configText !== configText) fail('config-snapshot-mismatch');
61
+ input = { ...input, configText, configDigest, request: args['--task']
62
+ ? { ...input.request, task: args['--task'] } : input?.request };
63
+ recommend(input, Date.now()); // Validate supplied security facts before any network read.
64
+ }
65
+ const controller = new AbortController();
66
+ const cancel = () => controller.abort();
67
+ process.once('SIGINT', cancel); process.once('SIGTERM', cancel);
68
+ let samples;
69
+ try { samples = await sampleFleet(manifest, config.hosts, options, { signal: controller.signal }); }
70
+ finally { process.removeListener('SIGINT', cancel); process.removeListener('SIGTERM', cancel); }
71
+ const evaluatedAt = Date.now();
72
+ if (command === 'sample') {
73
+ process.stdout.write(`${JSON.stringify({ ...samples, telemetry: samples.telemetry.map((s) => ({ ...s, ageMs: evaluatedAt - s.receivedAt })),
74
+ configDigest, collectedAt: evaluatedAt })}\n`);
75
+ return 0;
76
+ }
77
+ input = { ...input, telemetry: samples.telemetry };
78
+ const recommendation = recommend(input, evaluatedAt);
79
+ process.stdout.write(`${JSON.stringify({ schemaVersion: 1, input, evaluatedAt, recommendation })}\n`);
80
+ return recommendation.selected ? 0 : 2;
81
+ }
@@ -0,0 +1,59 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ export const PROC_COMMAND = "cat /proc/stat && printf '\\n---AXSTACK-MEM---\\n' && cat /proc/meminfo";
3
+ export function sshArgv(host) {
4
+ return ['ssh', '-T', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', '-o', 'IdentitiesOnly=yes',
5
+ '-o', 'ConnectTimeout=3', '-o', 'ConnectionAttempts=1', '-o', 'ServerAliveInterval=2',
6
+ '-o', 'ServerAliveCountMax=1', '-o', 'ControlMaster=no', '-o', 'ControlPath=none',
7
+ '-o', 'ClearAllForwardings=yes', '-o', 'PermitLocalCommand=no',
8
+ ...(host.user ? ['-l', host.user] : []), '--', host.destination, PROC_COMMAND];
9
+ }
10
+ export function wait(ms, signal) {
11
+ return new Promise((resolve, reject) => {
12
+ const cancel = () => { clearTimeout(timer); signal.removeEventListener('abort', cancel); reject(signal.reason); };
13
+ const timer = setTimeout(() => { signal.removeEventListener('abort', cancel); resolve(); }, ms);
14
+ signal.addEventListener('abort', cancel, { once: true });
15
+ if (signal.aborted) cancel();
16
+ });
17
+ }
18
+ async function limited(stream, signal) {
19
+ const reader = stream.getReader(); const chunks = []; let size = 0;
20
+ const cancel = () => { void reader.cancel(); };
21
+ signal.addEventListener('abort', cancel, { once: true });
22
+ try {
23
+ while (true) {
24
+ if (signal.aborted) throw signal.reason;
25
+ const { done, value } = await reader.read();
26
+ if (done) break;
27
+ size += value.byteLength;
28
+ if (size > 65536) throw Error('malformed');
29
+ chunks.push(value);
30
+ }
31
+ return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks));
32
+ } finally {
33
+ signal.removeEventListener('abort', cancel);
34
+ await reader.cancel();
35
+ reader.releaseLock();
36
+ }
37
+ }
38
+ export async function readProc(host, signal, spawn = Bun.spawn) {
39
+ if (host.transport === 'local') {
40
+ const stat = await readFile('/proc/stat', { encoding: 'utf8', signal });
41
+ const mem = await readFile('/proc/meminfo', { encoding: 'utf8', signal });
42
+ return `${stat}\n---AXSTACK-MEM---\n${mem}`;
43
+ }
44
+ const child = spawn(sshArgv(host), { stdin: 'ignore', stdout: 'pipe', stderr: 'ignore' });
45
+ const cancel = () => child.kill('SIGKILL');
46
+ signal.addEventListener('abort', cancel, { once: true });
47
+ try {
48
+ if (signal.aborted) { cancel(); throw signal.reason; }
49
+ const output = await limited(child.stdout, signal);
50
+ const code = await child.exited;
51
+ if (signal.aborted) throw signal.reason;
52
+ if (code !== 0) throw Error('failed');
53
+ return output;
54
+ } finally {
55
+ signal.removeEventListener('abort', cancel);
56
+ child.kill('SIGKILL');
57
+ await child.exited;
58
+ }
59
+ }
@@ -0,0 +1,34 @@
1
+ import { record } from './fleet-config.js';
2
+ const fail = (reason) => { throw Error(reason); };
3
+ const keys = (obj, allowed) => record(obj) && Object.keys(obj).every((k) => allowed.includes(k));
4
+ export function validateManifest(manifest, hosts) {
5
+ if (manifest?.schemaVersion !== 1) fail('schema-version: manifest');
6
+ if (!keys(manifest, ['schemaVersion', 'hosts']) || !Array.isArray(manifest.hosts)
7
+ || !manifest.hosts.length || manifest.hosts.length > 64) fail('manifest-schema');
8
+ const ids = new Set(); const destinations = new Set(); let local = false;
9
+ for (const h of manifest.hosts) {
10
+ if (!keys(h, ['hostId', 'environmentId', 'transport', 'destination', 'user'])) fail('manifest-schema');
11
+ if (!hosts.some((c) => c.id === h.hostId && c.environmentId === h.environmentId)) fail('manifest-host');
12
+ if (ids.has(h.hostId)) fail('manifest-duplicate');
13
+ ids.add(h.hostId);
14
+ if (!['local', 'ssh'].includes(h.transport)) fail('manifest-transport');
15
+ if (h.transport === 'local') {
16
+ if (h.destination !== undefined || h.user !== undefined) fail('manifest-schema');
17
+ if (local) fail('manifest-duplicate');
18
+ local = true;
19
+ } else {
20
+ // MagicDNS short names only. SSH aliases, IPs, ports and shell syntax are rejected.
21
+ if (typeof h.destination !== 'string' || !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(h.destination)
22
+ || /^\d+$/.test(h.destination)) fail('manifest-destination');
23
+ if (h.user !== undefined && (typeof h.user !== 'string' || !/^[a-z_][a-z0-9_-]{0,31}$/.test(h.user))) fail('manifest-user');
24
+ if (destinations.has(h.destination)) fail('manifest-duplicate');
25
+ destinations.add(h.destination);
26
+ }
27
+ }
28
+ return manifest;
29
+ }
30
+ export function sampleOptions({ intervalMs = 5000, timeoutMs = 10000 } = {}) {
31
+ if (!Number.isSafeInteger(intervalMs) || intervalMs < 5000 || intervalMs > 60000) fail('sample-interval');
32
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < intervalMs + 1000 || timeoutMs > 120000) fail('sample-timeout');
33
+ return { intervalMs, timeoutMs };
34
+ }