mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 03:39:51 -10:00
feat(slice-05): workflow_specs manifest type extension — Item 6 cross-primitive bundling part 4a (Checkpoint 7.3e) (OPR.0.3.2.5)
Adds optional workflow_specs[] (string[] of relative-safe paths to workflow
spec YAML files inside the bundle) to BOTH v1 LegacyBundleManifest and v2
PodBundleManifest schemas. Same shape as the existing skills[] field —
operator declares paths in bundle manifest, the routing layer (lands in
the next checkpoints) copies the files to the operator workflow-specs
library on install.
Per orch ruling Candidate A on the Item-6 completeness scope-check: ship
all 4 PRD-named cross-primitive kinds (skills shipped 7.1, plugins shipped
7.3b-d, workflow_specs starting now 7.3e, context_packs 7.3f, agent_images
7.3g, gstack HYBRID proof case 7.4). Per cont.18 ratification reachability
test: workflow_specs source primitives are present on main (packages/
daemon/src/domain/{workflow-spec-cache,workflow-runtime,workflow-types,
spec-library-workflow-scanner}.ts + packages/daemon/src/routes/workflow.ts);
v0-if-reachable branch applies.
Type-layer additions (mirroring skills pattern exactly):
- validateWorkflowSpecsBlock: array-typing + per-entry string + isRelativeSafePath
- normalizeWorkflowSpecsBlock: defensive copy + string-filter + empty-undefined
- Field added to PodBundleManifest (workflowSpecs?: string[]) + LegacyBundleManifest
- Validator wired into validatePodBundleManifest + validateLegacyBundleManifest
- Normalizer wired into normalizeLegacyBundleManifest
- Serializer wired into serializePodBundleManifest + serializeLegacyBundleManifest
Schema-version-aware contract: YAML key is snake_case workflow_specs;
typed field is camelCase workflowSpecs (matches the established skills/
plugins camelCase contract per the 77bed48b inspect-camelCase repair).
No schema bump; additive optional fields per PRD §v0 Out line 215.
Tests: 49/49 across packages/daemon/test/bundle-types.test.ts (was 41,
+8 workflow_specs tests mirroring the skills test shape — missing
backward-compat / valid-array / non-array-rejected / non-string-entry-
rejected / unsafe-path-rejected / round-trip-preserves /
missing-round-trips-cleanly).
Pre-commit scope verified: 2 files, +119/-0, .mcp.json correctly excluded
(banked cont.29 commit-scope-hygiene applied).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
e7e5192c18
commit
4f50d12735
2 files changed
+119
No files matched your search
@@ -174,6 +174,37 @@ function normalizeSkillsBlock(raw: unknown): string[] | undefined {
|
||||
return result.length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
/** Validate optional workflow_specs[] block. Appends to errors if present-but-malformed.
|
||||
* Item 6 / slice-05 Checkpoint 7.3e. Same shape as skills[]: array of relative-safe paths
|
||||
* to workflow spec YAML files inside the bundle. */
|
||||
function validateWorkflowSpecsBlock(raw: unknown, errors: string[]): void {
|
||||
if (raw === undefined) return;
|
||||
if (!Array.isArray(raw)) {
|
||||
errors.push("workflow_specs must be an array");
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const entry = raw[i];
|
||||
if (typeof entry !== "string") {
|
||||
errors.push(`workflow_specs[${i}] must be a string`);
|
||||
continue;
|
||||
}
|
||||
if (!isRelativeSafePath(entry)) {
|
||||
errors.push(`workflow_specs[${i}] path is not safe: '${entry}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize raw workflow_specs[] block (defensive copy + string filter). */
|
||||
function normalizeWorkflowSpecsBlock(raw: unknown): string[] | undefined {
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
const result: string[] = [];
|
||||
for (const entry of raw) {
|
||||
if (typeof entry === "string" && entry.length > 0) result.push(entry);
|
||||
}
|
||||
return result.length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compatibility block — operator-declared install-time requirements for a
|
||||
* bundle artifact. All fields optional; missing block keeps backward compat
|
||||
@@ -290,6 +321,8 @@ export interface PodBundleManifest {
|
||||
skills?: string[];
|
||||
/** Item 6 cross-primitive bundling: plugin references to install via the plugin primitive (HYBRID-mode bundles reference existing plugins rather than forking content). */
|
||||
plugins?: BundlePluginReference[];
|
||||
/** Item 6 cross-primitive bundling: workflow spec YAML paths to route to the operator workflow-specs library on install (Checkpoint 7.3e). */
|
||||
workflowSpecs?: string[];
|
||||
}
|
||||
|
||||
export function validatePodBundleManifest(raw: unknown): { valid: boolean; errors: string[] } {
|
||||
@@ -320,6 +353,7 @@ export function validatePodBundleManifest(raw: unknown): { valid: boolean; error
|
||||
validateCompatibilityBlock(m["compatibility"], errors);
|
||||
validateSkillsBlock(m["skills"], errors);
|
||||
validatePluginsBlock(m["plugins"], errors);
|
||||
validateWorkflowSpecsBlock(m["workflow_specs"], errors);
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
@@ -352,6 +386,7 @@ export function serializePodBundleManifest(manifest: PodBundleManifest): string
|
||||
if (manifest.compatibility) doc["compatibility"] = compatibilityToYamlRecord(manifest.compatibility);
|
||||
if (manifest.skills && manifest.skills.length > 0) doc["skills"] = manifest.skills;
|
||||
if (manifest.plugins && manifest.plugins.length > 0) doc["plugins"] = manifest.plugins.map((p) => ({ id: p.id, source: { kind: p.source.kind, path: p.source.path } }));
|
||||
if (manifest.workflowSpecs && manifest.workflowSpecs.length > 0) doc["workflow_specs"] = manifest.workflowSpecs;
|
||||
return stringifyYaml(doc);
|
||||
}
|
||||
|
||||
@@ -393,6 +428,8 @@ export interface LegacyBundleManifest {
|
||||
skills?: string[];
|
||||
/** Item 6 cross-primitive bundling: plugin references to install via the plugin primitive (HYBRID-mode bundles reference existing plugins rather than forking content). */
|
||||
plugins?: BundlePluginReference[];
|
||||
/** Item 6 cross-primitive bundling: workflow spec YAML paths to route to the operator workflow-specs library on install (Checkpoint 7.3e). */
|
||||
workflowSpecs?: string[];
|
||||
}
|
||||
|
||||
/** Validation options */
|
||||
@@ -487,6 +524,7 @@ export function validateLegacyBundleManifest(
|
||||
validateCompatibilityBlock(m["compatibility"], errors);
|
||||
validateSkillsBlock(m["skills"], errors);
|
||||
validatePluginsBlock(m["plugins"], errors);
|
||||
validateWorkflowSpecsBlock(m["workflow_specs"], errors);
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
@@ -541,6 +579,9 @@ export function normalizeLegacyBundleManifest(raw: unknown): LegacyBundleManifes
|
||||
const plugins = normalizePluginsBlock(m["plugins"]);
|
||||
if (plugins) result.plugins = plugins;
|
||||
|
||||
const workflowSpecs = normalizeWorkflowSpecsBlock(m["workflow_specs"]);
|
||||
if (workflowSpecs) result.workflowSpecs = workflowSpecs;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -574,6 +615,7 @@ export function serializeLegacyBundleManifest(manifest: LegacyBundleManifest): s
|
||||
|
||||
if (manifest.skills && manifest.skills.length > 0) doc["skills"] = manifest.skills;
|
||||
if (manifest.plugins && manifest.plugins.length > 0) doc["plugins"] = manifest.plugins.map((p) => ({ id: p.id, source: { kind: p.source.kind, path: p.source.path } }));
|
||||
if (manifest.workflowSpecs && manifest.workflowSpecs.length > 0) doc["workflow_specs"] = manifest.workflowSpecs;
|
||||
|
||||
return stringifyYaml(doc);
|
||||
}
|
||||
@@ -533,6 +533,83 @@ describe("Bundle types", () => {
|
||||
const normalized = normalizeBundleManifest(parsed);
|
||||
expect(normalized.plugins).toBeUndefined();
|
||||
});
|
||||
|
||||
// -- Item 6 workflow_specs block tests (slice-05 Checkpoint 7.3e) --
|
||||
|
||||
it("missing workflow_specs block passes validation (backward compat)", () => {
|
||||
const raw = { ...VALID_RAW };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("workflow_specs as a string array of safe relative paths passes validation", () => {
|
||||
const raw = { ...VALID_RAW, workflow_specs: ["workflows/onboarding.yaml", "workflows/release.yaml"] };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("workflow_specs as a non-array rejected", () => {
|
||||
const raw = { ...VALID_RAW, workflow_specs: "workflows/a.yaml" };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("workflow_specs must be an array"))).toBe(true);
|
||||
});
|
||||
|
||||
it("non-string workflow_specs entry rejected", () => {
|
||||
const raw = { ...VALID_RAW, workflow_specs: [123, "workflows/ok.yaml"] };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("workflow_specs[0]"))).toBe(true);
|
||||
});
|
||||
|
||||
it("unsafe workflow_specs path (dot-dot traversal) rejected", () => {
|
||||
const raw = { ...VALID_RAW, workflow_specs: ["../escape/spec.yaml"] };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("workflow_specs[0]"))).toBe(true);
|
||||
expect(result.errors.some((e) => e.includes("not safe"))).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trip preserves workflow_specs through serialize → parse → normalize", () => {
|
||||
const manifest: BundleManifest = {
|
||||
schemaVersion: 1,
|
||||
name: "rt-workflow-specs",
|
||||
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": "7".repeat(64), "packages/pkg/package.yaml": "8".repeat(64) },
|
||||
},
|
||||
workflowSpecs: ["workflows/onboarding.yaml", "workflows/release.yaml"],
|
||||
};
|
||||
const yaml = serializeBundleManifest(manifest);
|
||||
expect(yaml).toContain("workflow_specs:");
|
||||
expect(yaml).toContain("workflows/onboarding.yaml");
|
||||
const parsed = parseBundleManifest(yaml);
|
||||
const validation = validateBundleManifest(parsed);
|
||||
expect(validation.valid).toBe(true);
|
||||
const normalized = normalizeBundleManifest(parsed);
|
||||
expect(normalized.workflowSpecs).toBeDefined();
|
||||
expect(normalized.workflowSpecs).toEqual(["workflows/onboarding.yaml", "workflows/release.yaml"]);
|
||||
});
|
||||
|
||||
it("missing workflow_specs round-trips cleanly (no field emitted in YAML)", () => {
|
||||
const manifest: BundleManifest = {
|
||||
schemaVersion: 1,
|
||||
name: "no-workflow-specs",
|
||||
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("workflow_specs:");
|
||||
const parsed = parseBundleManifest(yaml);
|
||||
const normalized = normalizeBundleManifest(parsed);
|
||||
expect(normalized.workflowSpecs).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRelativeSafePath", () => {
|
||||
|
||||
Reference in new issue
Block a user