mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 03:39:51 -10:00
feat(slice-05): compatibility block in v1+v2 bundle manifests (Item 2 type layer) (OPR.0.3.2.5)
Mirrors the Item 1 type-layer pattern (82d39715) for compatibility: optional BundleCompatibility block with minDaemonVersion + minCliVersion + schemaVersion (reaffirmation). All fields optional; missing block is backward-compat (bundles install unchanged). Install-time version check + operator override flag land at Checkpoint 3.3 (this commit ships the manifest shape only). This commit ships: - BundleCompatibility interface: minDaemonVersion / minCliVersion / schemaVersion — all fields optional - Shared validateCompatibilityBlock helper (rejects non-object compatibility; rejects per-field type mismatch when present; missing block is valid) - Shared normalizeCompatibilityBlock helper (exported alongside the Item-1 provenance normalizer; v2 inspect projection in77bed48bset the precedent that the /api/bundles/inspect contract is one camelCase shape regardless of schema version) - compatibilityToYamlRecord helper for snake_case YAML emission - v1 + v2 manifest interfaces extended with optional compatibility - v1 + v2 validators check compatibility via the helper - v1 + v2 serializers emit snake_case compatibility block when present - v1 normalizer surfaces camelCase BundleCompatibility when present (v2 has no normalizer surface today; raw round-trip path preserved) - 12 new tests (7 v1 + 5 v2): missing-valid, full-valid, partial-valid (only minDaemonVersion), non-object-rejected, wrong-field-type- rejected, round-trip-preserves, missing-round-trips-cleanly - All 81 prior daemon bundle tests preserved; 93/93 green Diff: 3 files, +255 lines, all additive. PRD bounce conditions verified: - No schema bump (v1+v2 extended with optional field only) - No new SQLite migrations - No event types - No core-routing or bootstrap mutation - No sidebar entries touched - scope-and-versioning sections 1 and 3 coherence re-audit clean Watched risks applied: - Audit-every-layer: helpers added at module scope are function definitions, not constants reading env/host/version. Validator and normalizer take their inputs from the parsed manifest (no env reads at all). Verified: no top-level const reads of os, process.env, or version sources introduced. - Optional-field backward-compat both directions: missing compatibility is valid (pre-Item-2 bundles still install); manifests with compatibility are accepted by both validators; older daemons reading new manifests will ignore compatibility (no validator rejects unknown fields). Verified via missing-compatibility-valid test plus the existing validator behavior on unrecognized fields. Item-2 chain on this branch: -77bed48b(Item 1 close-out + B1 repair, CLEAR) <- prior tip - THIS COMMIT — Item 2 type layer - Next commit (3.2) — assembler capture + route create/inspect plumbing + CLI min-daemon-version / min-cli-version flags - Next commit (3.3) — install-time version check at /install + 3-part error + CLI skip-version-check operator override Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
77bed48ba6
commit
1fd11b762b
3 files changed
+255
No files matched your search
@@ -67,6 +67,66 @@ function provenanceToYamlRecord(p: BundleProvenance): Record<string, unknown> {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compatibility block — operator-declared install-time requirements for a
|
||||
* bundle artifact. All fields optional; missing block keeps backward compat
|
||||
* (bundles install unchanged). Install-time version check (Item 2 Checkpoint
|
||||
* 3.3) consults this block before bootstrap delegation; --skip-version-check
|
||||
* is the operator-explicit override.
|
||||
*/
|
||||
export interface BundleCompatibility {
|
||||
/** Minimum daemon version required to install this bundle (semver string). */
|
||||
minDaemonVersion?: string;
|
||||
/** Minimum CLI version required to install this bundle (semver string). */
|
||||
minCliVersion?: string;
|
||||
/** Schema version reaffirmed; mirrors root schemaVersion when set. */
|
||||
schemaVersion?: number;
|
||||
}
|
||||
|
||||
/** Validate optional compatibility block. Appends to errors if present-but-malformed. */
|
||||
function validateCompatibilityBlock(raw: unknown, errors: string[]): void {
|
||||
if (raw === undefined) return;
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
errors.push("compatibility must be an object");
|
||||
return;
|
||||
}
|
||||
const c = raw as Record<string, unknown>;
|
||||
if ("min_daemon_version" in c && typeof c["min_daemon_version"] !== "string") {
|
||||
errors.push("compatibility.min_daemon_version must be a string");
|
||||
}
|
||||
if ("min_cli_version" in c && typeof c["min_cli_version"] !== "string") {
|
||||
errors.push("compatibility.min_cli_version must be a string");
|
||||
}
|
||||
if ("schema_version" in c && typeof c["schema_version"] !== "number") {
|
||||
errors.push("compatibility.schema_version must be a number");
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialize a typed BundleCompatibility to the snake_case YAML record shape. */
|
||||
function compatibilityToYamlRecord(c: BundleCompatibility): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
if (c.minDaemonVersion !== undefined) out["min_daemon_version"] = c.minDaemonVersion;
|
||||
if (c.minCliVersion !== undefined) out["min_cli_version"] = c.minCliVersion;
|
||||
if (c.schemaVersion !== undefined) out["schema_version"] = c.schemaVersion;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize raw snake_case compatibility to typed camelCase BundleCompatibility.
|
||||
* Returns undefined when absent or empty. Exported alongside the provenance
|
||||
* normalizer so the v2 inspect-route projection can produce a single
|
||||
* camelCase shape for both manifest schemas.
|
||||
*/
|
||||
export function normalizeCompatibilityBlock(raw: unknown): BundleCompatibility | undefined {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
||||
const c = raw as Record<string, unknown>;
|
||||
const result: BundleCompatibility = {};
|
||||
if (typeof c["min_daemon_version"] === "string") result.minDaemonVersion = c["min_daemon_version"];
|
||||
if (typeof c["min_cli_version"] === "string") result.minCliVersion = c["min_cli_version"];
|
||||
if (typeof c["schema_version"] === "number") result.schemaVersion = c["schema_version"];
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize raw snake_case provenance (as parsed from YAML or received from
|
||||
* a request body) to typed camelCase BundleProvenance. Returns undefined when
|
||||
@@ -118,6 +178,7 @@ export interface PodBundleManifest {
|
||||
cultureFile?: string;
|
||||
integrity?: BundleIntegrity;
|
||||
provenance?: BundleProvenance;
|
||||
compatibility?: BundleCompatibility;
|
||||
}
|
||||
|
||||
export function validatePodBundleManifest(raw: unknown): { valid: boolean; errors: string[] } {
|
||||
@@ -145,6 +206,7 @@ export function validatePodBundleManifest(raw: unknown): { valid: boolean; error
|
||||
}
|
||||
|
||||
validateProvenanceBlock(m["provenance"], errors);
|
||||
validateCompatibilityBlock(m["compatibility"], errors);
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
@@ -174,6 +236,7 @@ export function serializePodBundleManifest(manifest: PodBundleManifest): string
|
||||
if (manifest.cultureFile) doc["culture_file"] = manifest.cultureFile;
|
||||
if (manifest.integrity) doc["integrity"] = { algorithm: manifest.integrity.algorithm, files: manifest.integrity.files };
|
||||
if (manifest.provenance) doc["provenance"] = provenanceToYamlRecord(manifest.provenance);
|
||||
if (manifest.compatibility) doc["compatibility"] = compatibilityToYamlRecord(manifest.compatibility);
|
||||
return stringifyYaml(doc);
|
||||
}
|
||||
|
||||
@@ -210,6 +273,7 @@ export interface LegacyBundleManifest {
|
||||
packages: LegacyBundlePackageEntry[];
|
||||
integrity?: BundleIntegrity;
|
||||
provenance?: BundleProvenance;
|
||||
compatibility?: BundleCompatibility;
|
||||
}
|
||||
|
||||
/** Validation options */
|
||||
@@ -301,6 +365,7 @@ export function validateLegacyBundleManifest(
|
||||
}
|
||||
|
||||
validateProvenanceBlock(m["provenance"], errors);
|
||||
validateCompatibilityBlock(m["compatibility"], errors);
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
@@ -346,6 +411,9 @@ export function normalizeLegacyBundleManifest(raw: unknown): LegacyBundleManifes
|
||||
const provenance = normalizeProvenanceBlock(m["provenance"]);
|
||||
if (provenance) result.provenance = provenance;
|
||||
|
||||
const compatibility = normalizeCompatibilityBlock(m["compatibility"]);
|
||||
if (compatibility) result.compatibility = compatibility;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -375,5 +443,7 @@ export function serializeLegacyBundleManifest(manifest: LegacyBundleManifest): s
|
||||
|
||||
if (manifest.provenance) doc["provenance"] = provenanceToYamlRecord(manifest.provenance);
|
||||
|
||||
if (manifest.compatibility) doc["compatibility"] = compatibilityToYamlRecord(manifest.compatibility);
|
||||
|
||||
return stringifyYaml(doc);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
isRelativeSafePath,
|
||||
type LegacyBundleManifest as BundleManifest,
|
||||
type BundleProvenance,
|
||||
type BundleCompatibility,
|
||||
} from "../src/domain/bundle-types.js";
|
||||
|
||||
const VALID_RAW = {
|
||||
@@ -251,6 +252,115 @@ describe("Bundle types", () => {
|
||||
const normalized = normalizeBundleManifest(parsed);
|
||||
expect(normalized.provenance).toBeUndefined();
|
||||
});
|
||||
|
||||
// -- Item 2 compatibility block tests (slice-05 Checkpoint 3.1) --
|
||||
|
||||
// C1: missing compatibility is valid (backward compat)
|
||||
it("missing compatibility block passes validation (backward compat)", () => {
|
||||
const raw = { ...VALID_RAW };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
// C2: full compatibility block passes validation
|
||||
it("full compatibility block passes validation", () => {
|
||||
const raw = {
|
||||
...VALID_RAW,
|
||||
compatibility: {
|
||||
min_daemon_version: "0.3.2",
|
||||
min_cli_version: "0.3.2",
|
||||
schema_version: 1,
|
||||
},
|
||||
};
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
// C3: partial compatibility (only min_daemon_version) passes — all fields optional
|
||||
it("partial compatibility block (only min_daemon_version) passes validation", () => {
|
||||
const raw = { ...VALID_RAW, compatibility: { min_daemon_version: "0.3.2" } };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
// C4: compatibility present but not an object rejected
|
||||
it("compatibility present but not an object rejected", () => {
|
||||
const raw1 = { ...VALID_RAW, compatibility: "0.3.2" };
|
||||
const r1 = validateBundleManifest(raw1);
|
||||
expect(r1.valid).toBe(false);
|
||||
expect(r1.errors.some((e) => e.includes("compatibility"))).toBe(true);
|
||||
|
||||
const raw2 = { ...VALID_RAW, compatibility: [] };
|
||||
const r2 = validateBundleManifest(raw2);
|
||||
expect(r2.valid).toBe(false);
|
||||
expect(r2.errors.some((e) => e.includes("compatibility"))).toBe(true);
|
||||
});
|
||||
|
||||
// C5: compatibility field with wrong type rejected
|
||||
it("compatibility field with wrong type rejected", () => {
|
||||
const raw1 = { ...VALID_RAW, compatibility: { min_daemon_version: 0.3 } };
|
||||
const r1 = validateBundleManifest(raw1);
|
||||
expect(r1.valid).toBe(false);
|
||||
expect(r1.errors.some((e) => e.includes("compatibility.min_daemon_version"))).toBe(true);
|
||||
|
||||
const raw2 = { ...VALID_RAW, compatibility: { schema_version: "1" } };
|
||||
const r2 = validateBundleManifest(raw2);
|
||||
expect(r2.valid).toBe(false);
|
||||
expect(r2.errors.some((e) => e.includes("compatibility.schema_version"))).toBe(true);
|
||||
});
|
||||
|
||||
// C6: round-trip preserves compatibility through serialize → parse → normalize
|
||||
it("round-trip preserves compatibility through serialize → parse → normalize", () => {
|
||||
const compatibility: BundleCompatibility = {
|
||||
minDaemonVersion: "0.3.2",
|
||||
minCliVersion: "0.3.2",
|
||||
schemaVersion: 1,
|
||||
};
|
||||
const manifest: BundleManifest = {
|
||||
schemaVersion: 1,
|
||||
name: "rt-compat",
|
||||
version: "1.0.0",
|
||||
createdAt: "2026-05-18T12:00:00Z",
|
||||
rigSpec: "rig.yaml",
|
||||
packages: [{ name: "pkg", version: "1.0.0", path: "packages/pkg", originalSource: "local:./pkg" }],
|
||||
integrity: {
|
||||
algorithm: "sha256",
|
||||
files: { "rig.yaml": "1".repeat(64), "packages/pkg/package.yaml": "2".repeat(64) },
|
||||
},
|
||||
compatibility,
|
||||
};
|
||||
|
||||
const yaml = serializeBundleManifest(manifest);
|
||||
expect(yaml).toContain("compatibility:");
|
||||
expect(yaml).toContain("min_daemon_version: 0.3.2");
|
||||
|
||||
const parsed = parseBundleManifest(yaml);
|
||||
const validation = validateBundleManifest(parsed);
|
||||
expect(validation.valid).toBe(true);
|
||||
|
||||
const normalized = normalizeBundleManifest(parsed);
|
||||
expect(normalized.compatibility).toBeDefined();
|
||||
expect(normalized.compatibility?.minDaemonVersion).toBe("0.3.2");
|
||||
expect(normalized.compatibility?.minCliVersion).toBe("0.3.2");
|
||||
expect(normalized.compatibility?.schemaVersion).toBe(1);
|
||||
});
|
||||
|
||||
// C7: missing compatibility round-trips cleanly (no field emitted in YAML)
|
||||
it("missing compatibility round-trips cleanly (no field emitted in YAML)", () => {
|
||||
const manifest: BundleManifest = {
|
||||
schemaVersion: 1,
|
||||
name: "no-compat",
|
||||
version: "1.0.0",
|
||||
createdAt: "2026-05-18T00:00:00Z",
|
||||
rigSpec: "rig.yaml",
|
||||
packages: [{ name: "pkg", version: "1.0", path: "packages/pkg", originalSource: "local:./pkg" }],
|
||||
};
|
||||
const yaml = serializeBundleManifest(manifest);
|
||||
expect(yaml).not.toContain("compatibility:");
|
||||
const parsed = parseBundleManifest(yaml);
|
||||
const normalized = normalizeBundleManifest(parsed);
|
||||
expect(normalized.compatibility).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRelativeSafePath", () => {
|
||||
|
||||
@@ -700,6 +700,81 @@ describe("PodBundleManifest validation", () => {
|
||||
expect(result.errors.some((e) => e.includes("provenance.source_host"))).toBe(true);
|
||||
});
|
||||
|
||||
// -- Item 2 compatibility block tests (slice-05 Checkpoint 3.1) --
|
||||
|
||||
it("v2: missing compatibility block passes validation (backward compat)", () => {
|
||||
const raw = {
|
||||
schema_version: 2, name: "no-compat", version: "1.0",
|
||||
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
|
||||
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
|
||||
};
|
||||
const result = validatePodBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("v2: full compatibility block passes validation", () => {
|
||||
const raw = {
|
||||
schema_version: 2, name: "with-compat", version: "1.0",
|
||||
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
|
||||
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
|
||||
compatibility: { min_daemon_version: "0.3.2", min_cli_version: "0.3.2", schema_version: 2 },
|
||||
};
|
||||
const result = validatePodBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("v2: compatibility present but not an object rejected", () => {
|
||||
const raw = {
|
||||
schema_version: 2, name: "bad-compat", version: "1.0",
|
||||
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
|
||||
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
|
||||
compatibility: "0.3.2",
|
||||
};
|
||||
const result = validatePodBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("compatibility"))).toBe(true);
|
||||
});
|
||||
|
||||
it("v2: compatibility field with wrong type rejected", () => {
|
||||
const raw = {
|
||||
schema_version: 2, name: "bad-compat-field", version: "1.0",
|
||||
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
|
||||
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
|
||||
compatibility: { min_daemon_version: 0.3 },
|
||||
};
|
||||
const result = validatePodBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("compatibility.min_daemon_version"))).toBe(true);
|
||||
});
|
||||
|
||||
it("v2: round-trip preserves compatibility through serialize -> parse", () => {
|
||||
const manifest: PodBundleManifest = {
|
||||
schemaVersion: 2, name: "rt-compat", version: "2.0",
|
||||
createdAt: "2026-05-18T00:00:00Z", rigSpec: "rig.yaml",
|
||||
agents: [{
|
||||
name: "impl", version: "1.0", path: "agents/impl",
|
||||
originalRef: "local:agents/impl", hash: "def",
|
||||
importEntries: [],
|
||||
}],
|
||||
compatibility: {
|
||||
minDaemonVersion: "0.3.2",
|
||||
minCliVersion: "0.3.2",
|
||||
schemaVersion: 2,
|
||||
},
|
||||
};
|
||||
const yaml = serializePodBundleManifest(manifest);
|
||||
expect(yaml).toContain("compatibility:");
|
||||
expect(yaml).toContain("min_daemon_version: 0.3.2");
|
||||
const parsed = parsePodBundleManifest(yaml);
|
||||
const validation = validatePodBundleManifest(parsed);
|
||||
expect(validation.valid).toBe(true);
|
||||
const m = parsed as Record<string, unknown>;
|
||||
const compat = m["compatibility"] as Record<string, unknown>;
|
||||
expect(compat["min_daemon_version"]).toBe("0.3.2");
|
||||
expect(compat["min_cli_version"]).toBe("0.3.2");
|
||||
expect(compat["schema_version"]).toBe(2);
|
||||
});
|
||||
|
||||
it("v2: round-trip preserves provenance through serialize -> parse", () => {
|
||||
const manifest: PodBundleManifest = {
|
||||
schemaVersion: 2, name: "rt-prov", version: "2.0",
|
||||
|
||||
Reference in new issue
Block a user