diff --git a/bun.lock b/bun.lock index f30cae5d4..bcac538c1 100644 --- a/bun.lock +++ b/bun.lock @@ -1566,6 +1566,7 @@ "name": "@corbits/workflow-source", "version": "0.0.1", "devDependencies": { + "@intx/hub-sessions": "workspace:*", "@types/bun": "catalog:", "typescript": "catalog:", }, @@ -3607,8 +3608,6 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@corbits/memory-hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], - "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], @@ -3631,8 +3630,6 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], - "@workbench/hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], - "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], diff --git a/packages/workflow-source/package.json b/packages/workflow-source/package.json index 11457ba64..5a8f07730 100644 --- a/packages/workflow-source/package.json +++ b/packages/workflow-source/package.json @@ -13,6 +13,7 @@ "test": "bun test" }, "devDependencies": { + "@intx/hub-sessions": "workspace:*", "@types/bun": "catalog:", "typescript": "catalog:" } diff --git a/packages/workflow-source/src/index.ts b/packages/workflow-source/src/index.ts index 199778f4f..c4c69a3ce 100644 --- a/packages/workflow-source/src/index.ts +++ b/packages/workflow-source/src/index.ts @@ -10,6 +10,11 @@ // definition back out of it through `parseWorkflowSourceEntry`, so the // bytes on disk have exactly one producer and one consumer. // +// `validate-push.test.ts` round-trips the rendered tree through the real +// `workflowKindHandler.validatePush`, so a renderer/validator drift fails a +// test here instead of surfacing as a push rejection on a workflow someone +// just created. +// // The entry is a JSON literal rather than a call into a builder: an // asset tree is a standalone codebase, so it can declare no workspace // dependency to evaluate, and the definition it carries is inert data. diff --git a/packages/workflow-source/src/validate-push.test.ts b/packages/workflow-source/src/validate-push.test.ts new file mode 100644 index 000000000..40f0305c3 --- /dev/null +++ b/packages/workflow-source/src/validate-push.test.ts @@ -0,0 +1,106 @@ +// Round-trip against the real upstream validator, not our renderer's +// comments about it. `workflowKindHandler.validatePush` +// (`vendor/intx/hub-sessions/src/workflow-kind.ts`) is the only consumer of +// the tree `renderWorkflowSourceTree` writes; nothing else in this repo +// checks the pair stays in sync, so a renderer/validator drift would +// otherwise surface only as a push rejection in production. + +import { expect, test } from "bun:test"; +import { workflowKindHandler } from "@intx/hub-sessions"; + +import { renderWorkflowSourceTree } from "./index"; + +const WORKFLOW_JSON = JSON.stringify({ id: "wf_agent_research-buddy" }); + +const encoder = new TextEncoder(); + +/** A minimal in-memory tree reader, matching the shape used by + * `workflow-run-kind.test.ts` upstream: `readBlob` resolves a root-relative + * POSIX path to its bytes. */ +function readBlobFor(tree: Readonly>) { + return async (path: string): Promise => { + const content = tree[path]; + if (content === undefined) throw new Error(`no such blob: ${path}`); + return encoder.encode(content); + }; +} + +const hubPrincipal = { kind: "hub" as const }; + +function push(tree: Readonly>) { + return workflowKindHandler.validatePush({ + repoId: { kind: "workflow", id: "ast_1" }, + ref: "refs/heads/main", + principal: hubPrincipal, + topLevelTreePaths: Object.keys(tree), + readBlob: readBlobFor(tree), + listDir: async () => [], + priorReadBlob: async () => null, + priorListDir: async () => [], + }); +} + +test("a rendered single-package tree passes the real validatePush", async () => { + const tree = renderWorkflowSourceTree({ + packageName: "@workbench-agent/research-buddy", + workflowJson: WORKFLOW_JSON, + }); + + const result = await push(tree); + + expect(result).toEqual({ ok: true }); +}); + +test("the renderer never emits an envelope-only capability-declarations.json", async () => { + const tree = renderWorkflowSourceTree({ + packageName: "@workbench-agent/research-buddy", + workflowJson: WORKFLOW_JSON, + }); + + expect(Object.keys(tree)).not.toContain("capability-declarations.json"); +}); + +test("the renderer never commits a node_modules directory", async () => { + const tree = renderWorkflowSourceTree({ + packageName: "@workbench-agent/research-buddy", + workflowJson: WORKFLOW_JSON, + }); + + expect(Object.keys(tree)).not.toContain("node_modules"); +}); + +test("the renderer never leaves an envelope-valid workflow.json beside the package.json", async () => { + const tree = renderWorkflowSourceTree({ + packageName: "@workbench-agent/research-buddy", + workflowJson: WORKFLOW_JSON, + }); + + // The renderer's only two paths are package.json and workflow.js; the + // retired workflow.json envelope path never appears in its output, so the + // ambiguous-tree rejection has no way to fire against what we emit. + expect(Object.keys(tree)).not.toContain("workflow.json"); +}); + +test("the renderer's package.json always declares a non-empty, contained interchange.workflow entry", async () => { + const tree = renderWorkflowSourceTree({ + packageName: "@workbench-agent/research-buddy", + workflowJson: WORKFLOW_JSON, + }); + const manifest = JSON.parse(tree["package.json"] as string) as { + interchange: { workflow: string }; + }; + + expect(manifest.interchange.workflow).toBe("./workflow.js"); +}); + +test("a tree missing package.json is rejected, matching the retired-envelope error", async () => { + const result = await push({ + "workflow.json": WORKFLOW_JSON, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toContain( + "workflow.json envelope form is no longer supported", + ); +});