Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 56 additions & 4 deletions packages/amico-run/src/fleet_projection_verb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
freshnessBetween,
readProjection,
renderFleetStatus,
fleetProjectionCachePath,
type FleetProjection,
} from "@amicode/schema";
import { PREMIUM_CODE, readCodes } from "./premium.js";
Expand Down Expand Up @@ -72,6 +73,13 @@ export interface FleetProjectionDeps {
readFile?: (p: string) => string | null;
/** THE invocation seam (injectable): the publisher subprocess call. */
runPublisher?: (inv: PublisherInvocation) => PublisherResult;
/** #1106 (P3b-2): where the validated projection is cached for the
* consumers. Default: the stable convention path
* (`~/.amico/ops/fleet/projection.json` — the live-layout precedent). */
cachePath?: string;
/** #1106: the cache write (injectable). Default: mkdir -p + atomic
* tmp+rename, mirroring the extension's writeFleetConfig discipline. */
writeCache?: (p: string, content: string) => void;
}

function flagValue(argv: string[], name: string): string | undefined {
Expand Down Expand Up @@ -135,14 +143,30 @@ function bootstrap(reason: "entitlement" | "checkout", rendered: string, extra:
}

/** A section's carried value, with the base default applied when the section
* is absent (mode absent = standalone, posture absent = ok — the projection
* contract's additive-optional discipline; the base default is applied, not
* invented: the reader's render states it in provenance). */
* is absent (mode absent = standalone, posture absent = ok — the projection
* contract's additive-optional discipline; the base default is applied, not
* invented: the reader's render states it in provenance). */
function scalarOrBase(proj: FleetProjection, section: string, base: string): unknown {
const s = proj.sections?.[section];
return s?.value === undefined ? base : s.value;
}

/** A section's carried value when it is an object (topology), else undefined —
* absent stays absent, never an invented {} (#1106 machine fields). */
function objectValue(proj: FleetProjection, section: string): Record<string, unknown> | undefined {
const v = proj.sections?.[section]?.value;
if (v === null || typeof v !== "object" || Array.isArray(v)) return undefined;
return v as Record<string, unknown>;
}

/** A named object field of a carried value (topology.canonical), tolerantly. */
function objectField(obj: Record<string, unknown> | undefined, field: string): Record<string, unknown> | undefined {
if (obj === undefined) return undefined;
const v = obj[field];
if (v === null || typeof v !== "object" || Array.isArray(v)) return undefined;
return v as Record<string, unknown>;
}

/** `amico fleet status --projection` — resolve the checkout (the premium
* ladder), gate on the entitlement, invoke the publisher at the subprocess
* seam, read the result through the ONE fleet projection reader, and print
Expand Down Expand Up @@ -217,8 +241,10 @@ export function fleetProjectionStatus(argv: string[], deps: FleetProjectionDeps
}

let proj: FleetProjection;
let published: string;
try {
proj = readProjection(inv.outPath);
published = fs.readFileSync(inv.outPath, "utf8");
} catch (e) {
// The reader's LOUD rejection surfaces verbatim — a versioned contract
// refuses both directions, naming both versions (invariant 5).
Expand All @@ -229,6 +255,28 @@ export function fleetProjectionStatus(argv: string[], deps: FleetProjectionDeps
return fail([message], { checkout, out_path: inv.outPath });
}

// ── the stable projection-cache refresh (#1106, P3b-2) ──
// ONLY a projection the reader validated reaches the cache — a rejected
// contract version never clobbers the consumers' artifact. The cached
// bytes are the publisher's own output, verbatim.
const cachePath = deps.cachePath ?? fleetProjectionCachePath();
const writeCache =
deps.writeCache ??
((p: string, content: string) => {
fs.mkdirSync(path.dirname(p), { recursive: true });
const tmpFile = `${p}.tmp`;
fs.writeFileSync(tmpFile, content);
fs.renameSync(tmpFile, p);
});
writeCache(cachePath, published);

// ── the additive machine fields for script consumers (#1106) ──
// The installer (and any bash consumer) reads `role` + `canonical` from
// this JSON line instead of grepping the raw fleet.json; absent topology
// renders absent, never invented.
const topology = objectValue(proj, "topology");
const canonical = objectField(topology, "canonical");

const verdict = previous === null ? null : freshnessBetween(previous, proj);
const advisory = verdict === null ? "" : freshnessAdvisory(verdict);
const fresh = proj.freshness ?? {};
Expand All @@ -241,6 +289,9 @@ export function fleetProjectionStatus(argv: string[], deps: FleetProjectionDeps
checkout,
mode: scalarOrBase(proj, "mode", "standalone"),
posture: scalarOrBase(proj, "posture", "ok"),
...(topology === undefined ? {} : { role: topology.role }),
...(canonical === undefined ? {} : { canonical }),
cache_path: cachePath,
publisher: proj.publisher ?? {},
sections: proj.sections ?? {},
freshness: {
Expand All @@ -250,7 +301,8 @@ export function fleetProjectionStatus(argv: string[], deps: FleetProjectionDeps
},
summary: renderFleetStatus(proj, previous),
note: "read through the ONE fleet projection reader (@amicode/schema fleet_projection, contract v"
+ String(proj.contract_version) + ") — amicissimo parses and publishes, amicode consumes (spec §3 D1); provenance renders beside the data, never merged",
+ String(proj.contract_version) + ") — amicissimo parses and publishes, amicode consumes (spec §3 D1); provenance renders beside the data, never merged; the validated projection is cached for the P3b-2 consumers at "
+ cachePath,
},
code: 0,
};
Expand Down
86 changes: 85 additions & 1 deletion packages/amico-run/test/fleet_projection_verb.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { fleetVerb } from "../src/fleet_verb.js";
import { fleetProjectionStatus, FLEET_BOOTSTRAP_EXIT, type FleetProjectionDeps } from "../src/fleet_projection_verb.js";
import { fleetProjectionCachePath } from "@amicode/schema";

const E1 = "44444444-4444-4444-8444-444444444444";

// The committed fixture projection — a full document shaped on amicissimo's
// Python publisher fixtures (client role → fleet mode, health + locks present).
Expand All @@ -38,7 +41,9 @@ afterEach(() => rmSync(tmp, { recursive: true, force: true }));

/** A hermetic world: an entitlements file carrying the `amicissimo` code, a
* checkout dir, and a runPublisher that copies the fixture projection to the
* outPath the verb handed it — the real publisher's #414 contract, faked. */
* outPath the verb handed it — the real publisher's #414 contract, faked.
* The #1106 cache defaults INTO THE TMP DIR — a suite run never touches the
* machine's real ~/.amico/ops/fleet/projection.json. */
function grantedWorld(over: Partial<FleetProjectionDeps> = {}, fixture: string = FIXTURE.pathname) {
const entitlements = join(tmp, "entitlements.toml");
writeFileSync(entitlements, 'codes = ["amicissimo"]\n');
Expand All @@ -47,6 +52,7 @@ function grantedWorld(over: Partial<FleetProjectionDeps> = {}, fixture: string =
const deps: FleetProjectionDeps = {
readFile: (p) => (p === entitlements ? 'codes = ["amicissimo"]' : fixtureFileSafe(p, fixture)),
checkDir: (p) => p === checkout,
cachePath: join(tmp, "hermetic-cache.json"),
runPublisher: (inv) => {
calls.push(inv);
writeFileSync(inv.outPath, readFileSync(fixture, "utf8"));
Expand Down Expand Up @@ -204,6 +210,84 @@ describe("the bootstrap exception (no entitlement / no checkout)", () => {
});
});

// ── the stable projection-cache convention (#1106, P3b-2) ──────────────────────

describe("the stable projection-cache convention (#1106)", () => {
it("a successful status refreshes the cache at the known path with the published bytes", () => {
const w = grantedWorld();
const cachePath = join(tmp, "ops", "fleet", "projection.json");
const writes: Array<{ p: string; content: string }> = [];
const deps: FleetProjectionDeps = { ...w.deps, cachePath, writeCache: (p, content) => writes.push({ p, content }) };
const r = run(["--checkout", w.checkout, "--config", w.entitlements], deps);
expect(r.code).toBe(0);
expect(writes).toHaveLength(1);
expect(writes[0].p).toBe(cachePath);
expect(writes[0].content).toBe(readFileSync(FIXTURE.pathname, "utf8")); // the published bytes, verbatim
});

it("the default cachePath is the live-layout convention (~/.amico/ops/fleet/projection.json), never guessed per-call", () => {
const w = grantedWorld();
const writes: Array<{ p: string; content: string }> = [];
const { cachePath: _omit, ...rest } = w.deps; // hermetic default stays out — assert the convention path
const deps: FleetProjectionDeps = { ...rest, writeCache: (p, content) => writes.push({ p, content }) };
const r = run(["--checkout", w.checkout, "--config", w.entitlements], deps);
expect(r.code).toBe(0);
expect(writes[0].p).toBe(fleetProjectionCachePath());
});

it("only a projection the reader VALIDATED lands in the cache — a rejected contract version never clobbers it", () => {
const stale = join(tmp, "stale-projection.json");
writeFileSync(stale, JSON.stringify({ schema_version: 1, contract_version: 2, sections: {} }));
const w = grantedWorld({}, stale);
const writes: Array<{ p: string; content: string }> = [];
const deps: FleetProjectionDeps = { ...w.deps, cachePath: join(tmp, "cache.json"), writeCache: (p, content) => writes.push({ p, content }) };
const r = run(["--checkout", w.checkout, "--config", w.entitlements], deps);
expect(r.code).toBe(64); // the loud rejection
expect(writes).toHaveLength(0); // and the cache was never touched
});

it("the bootstrap exception (75) leaves the cache untouched — base-standalone is stated, not cached", () => {
const writes: Array<{ p: string; content: string }> = [];
const deps: FleetProjectionDeps = {
readFile: () => null, // no entitlements
checkDir: () => true,
writeCache: (p, content) => writes.push({ p, content }),
};
const r = run(["--config", join(tmp, "entitlements.toml")], deps);
expect(r.code).toBe(FLEET_BOOTSTRAP_EXIT);
expect(writes).toHaveLength(0);
});

it("the success JSON carries additive machine fields for script consumers: role + canonical + cache_path", () => {
const w = grantedWorld();
const cachePath = join(tmp, "cache.json");
const deps: FleetProjectionDeps = { ...w.deps, cachePath, writeCache: () => {} };
const r = run(["--checkout", w.checkout, "--config", w.entitlements], deps);
expect(r.code).toBe(0);
expect(r.json.role).toBe("client");
expect(r.json.canonical).toMatchObject({ host: "hq-hub-01.example.internal", port: 4096, sshAlias: "hq-hub-01" });
expect(r.json.cache_path).toBe(cachePath);
});

it("a projection without a topology section still succeeds and caches — the machine fields render absent, never invented", () => {
const bare = join(tmp, "bare-projection.json");
writeFileSync(bare, JSON.stringify({
schema_version: 1,
contract_version: 1,
publisher: { identity: "test", published_at: "2026-09-13T12:00:00Z" },
freshness: { counter: 1, hub_epoch: E1 },
sections: { mode: { value: "standalone" } },
}));
const w = grantedWorld({}, bare);
const cachePath = join(tmp, "cache.json");
const deps: FleetProjectionDeps = { ...w.deps, cachePath, writeCache: () => {} };
const r = run(["--checkout", w.checkout, "--config", w.entitlements], deps);
expect(r.code).toBe(0);
expect(r.json.role).toBeUndefined();
expect(r.json.canonical).toBeUndefined();
});
});

// ── the router: --projection routes within `amico fleet status` ────────────────

describe("the fleet verb router", () => {
Expand Down
43 changes: 31 additions & 12 deletions packages/extension/opencode-plugin/stack_state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,11 +308,20 @@ function buildLiveRunsBlock(): string {

// ── Fleet state ──────────────────────────────────────────────────────────────

function fleetConfigFile(override?: string): string {
// #1106 (fleet rearchitect P3b-2): the fleet role comes from the projection
// cache — <home>/.amico/ops/fleet/projection.json, refreshed by
// `amico fleet status --projection`. The machine-local fleet config file has
// exactly ONE parser (amicissimo's fleet authority, behind the CLI); this
// plugin is a projection consumer (it runs in opencode's Bun runtime and
// must stay dependency-free, so it reads the cached artifact
// shape-tolerantly — contract validation is the extension's reader's job,
// and the plugin never falls back to the raw file).

function fleetProjectionFile(override?: string): string {
if (override) return override;
const env = process.env.AMICO_FLEET_CONFIG;
const env = process.env.AMICO_FLEET_PROJECTION;
if (env && env.trim() !== "") return env.trim();
return path.join(os.homedir(), ".amico", "ops", "fleet", "fleet.json");
return path.join(os.homedir(), ".amico", "ops", "fleet", "projection.json");
}

function fleetStatusFile(override?: string): string {
Expand All @@ -322,12 +331,21 @@ function fleetStatusFile(override?: string): string {
return path.join(os.homedir(), ".amico", "ops", "fleet-status.json");
}

/** Fleet role from fleet.json — "server" | "client" | "standalone".
* No file = null (a standalone machine has no fleet to report). */
function readFleetRole(configPath?: string): string | null {
/** Fleet role from the projection cache's topology section —
* "server" | "client" | "standalone" (verbatim; anything else surfaces as
* itself). Absent/unreadable projection, or one with no topology section,
* = null (the base default — a standalone machine has nothing to report). */
function readFleetRoleFromProjection(projectionPath?: string): string | null {
try {
const parsed = JSON.parse(fs.readFileSync(fleetConfigFile(configPath), "utf8")) as Record<string, unknown>;
return typeof parsed.role === "string" && parsed.role !== "" ? parsed.role : null;
const parsed = JSON.parse(fs.readFileSync(fleetProjectionFile(projectionPath), "utf8")) as Record<string, unknown>;
const sections = parsed.sections;
if (typeof sections !== "object" || sections === null) return null;
const topology = (sections as Record<string, unknown>).topology;
if (typeof topology !== "object" || topology === null) return null;
const value = (topology as Record<string, unknown>).value;
if (typeof value !== "object" || value === null) return null;
const role = (value as Record<string, unknown>).role;
return typeof role === "string" && role !== "" ? role : null;
} catch {
return null;
}
Expand Down Expand Up @@ -371,9 +389,10 @@ function readFleetStatus(statusPath?: string): FleetStatusSummary | undefined {

/** Lean fleet line + on-demand pointers (the reader's choice: detail loads
* from fleet-status.json / the fleet skill only when relevant). Absent
* fleet.json (standalone or no fleet tooling) → "" — nothing to say. */
function buildFleetSection(opts: { configPath?: string; statusPath?: string } = {}): string {
const role = readFleetRole(opts.configPath);
* projection (standalone or no fleet tooling — or a projection with no
* topology section) → "" — nothing to say. */
function buildFleetSection(opts: { projectionPath?: string; statusPath?: string } = {}): string {
const role = readFleetRoleFromProjection(opts.projectionPath);
if (role === null) return "";

const roleText =
Expand All @@ -382,7 +401,7 @@ function buildFleetSection(opts: { configPath?: string; statusPath?: string } =
: role === "client"
? "**client** — rides the tunnel to the canonical server"
: `**${role}**`;
const lines = [`## Fleet (live)`, `Role: ${roleText} (\`~/.amico/ops/fleet/fleet.json\`).`];
const lines = [`## Fleet (live)`, `Role: ${roleText} (from the fleet projection at \`~/.amico/ops/fleet/projection.json\`).`];

const status = readFleetStatus(opts.statusPath);
if (status) {
Expand Down
Loading
Loading