From 1ec842afd4773d1dc9b8fe5011abed26c627b9e6 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Tue, 3 Nov 2026 11:53:41 +0200 Subject: [PATCH 1/3] feat(versions): declare the app's workflow files The declare body now carries `workflows`: each `base44/workflows/**/*.jsonc` file, parsed as JSONC and sent raw, keyed by its path under the directory with `.jsonc` stripped (`daily-digest`, `sub/x`), the key the platform's `workflow_files_key` derives. Only `.jsonc`: to the platform a `.json` file there is an ordinary file, not a workflow. Always sent, `{}` for an app with none, since an absent field means a producer that cannot describe workflows. The directory is `workflowsDir` in config.jsonc, default `workflows`, beside `entitiesDir` and `agentsDir`. Requires an apper that accepts `workflows` on DeclareVersionRequest; an older one refuses the unknown field with a 422. Co-Authored-By: Claude Opus 5.5 --- docs/versions.md | 10 ++- packages/cli/src/core/project/schema.ts | 1 + packages/cli/src/core/project/target.ts | 6 +- packages/cli/src/core/version/api.ts | 1 + packages/cli/src/core/version/artifacts.ts | 28 ++++--- packages/cli/src/core/version/schema.ts | 4 + packages/cli/tests/cli/publish.spec.ts | 34 +++++++++ .../cli/tests/core/project-target.spec.ts | 1 + .../cli/tests/core/version-artifacts.spec.ts | 75 ++++++++++++++++++- .../base44/workflows/daily-digest.jsonc | 6 ++ .../publishable/base44/workflows/notes.json | 1 + 11 files changed, 150 insertions(+), 17 deletions(-) create mode 100644 packages/cli/tests/fixtures/publishable/base44/workflows/daily-digest.jsonc create mode 100644 packages/cli/tests/fixtures/publishable/base44/workflows/notes.json diff --git a/docs/versions.md b/docs/versions.md index 1d8f75ec6..08d63e595 100644 --- a/docs/versions.md +++ b/docs/versions.md @@ -1,8 +1,8 @@ # Versions -**Keywords:** versions, publish, deployment, rollback, artifact set, sha256, digest, staged upload, presigned, x-amz-checksum-sha256, entities, agents, backend functions, backend_bundles, actors, Durable Objects, functions-compiler, shards, raw payloads, provenance, commit, idempotency key, step, BASE44_VERSIONS_API, env gate, build sandbox +**Keywords:** versions, publish, deployment, rollback, artifact set, sha256, digest, staged upload, presigned, x-amz-checksum-sha256, entities, agents, backend functions, backend_bundles, actors, Durable Objects, functions-compiler, shards, raw payloads, workflows, provenance, commit, idempotency key, step, BASE44_VERSIONS_API, env gate, build sandbox -A **version** is one immutable thing a build produced — every frontend file, plus the entity and agent payloads the app declares. A **deployment** is a version made live at an environment. Recording one and serving one are separate acts, which is what makes a rollback a deploy of an older version rather than a second code path. +A **version** is one immutable thing a build produced — every frontend file, plus the entity, agent and workflow payloads the app declares. A **deployment** is a version made live at an environment. Recording one and serving one are separate acts, which is what makes a rollback a deploy of an older version rather than a second code path. This lives in `src/core/version/`: `artifacts.ts` (the build-output walk and the raw resource reads), `functions.ts` (compiling the backend functions), `actors.ts` (compiling the actors), `api.ts` (the three HTTP calls), `publish.ts` (orchestration, and the step names behind the shared tagger in `core/errors.ts`), `gate.ts` (the env gate), `schema.ts` (wire types). @@ -20,7 +20,7 @@ It is **not** `src/core/site/` — see [Deployments](deployments.md). That lane `createVersion(artifacts, options)` in `api.ts` — three calls, in this order and no other, so nothing is recorded until the bytes are in place: -1. **Declare.** `POST versions` with `assets` (path, size, digest per file), optionally a `site_worker` beside it, the compiled `backend_bundles` and `actors`, plus the raw `entities` and `agents` payloads and `source_commit`. The response carries a `session_id` and one presigned PUT per declared file, in declared order: the assets, the Worker's modules, one per bundle, then one per actor. Each kind comes after the ones before it, so an app gaining one moves no existing slot. +1. **Declare.** `POST versions` with `assets` (path, size, digest per file), optionally a `site_worker` beside it, the compiled `backend_bundles` and `actors`, plus the raw `entities`, `agents` and `workflows` payloads and `source_commit`. The response carries a `session_id` and one presigned PUT per declared file, in declared order: the assets, the Worker's modules, one per bundle, then one per actor. Each kind comes after the ones before it, so an app gaining one moves no existing slot. 2. **Upload.** `putPresigned` per file — the same PUT the static deployments lane uses, same ky retry policy. Each PUT sends the server's `Content-Type` **and** its `x-amz-checksum-sha256` verbatim; deriving either locally would 403 on any mapping difference. Uploads are paired with declared files **by position**, not by path: the frontend and the Worker's modules are separate namespaces, so the same name can appear in both and mean two different files. 3. **Finalize.** `POST versions/{session_id}/finalize`, no body. The set was fixed at declare, so there is nothing left for the caller to change. @@ -87,7 +87,9 @@ The practical consequence for this CLI: **send the checksum the server gave you, ## Resources go up raw -`collectResources` reads `entities/` and `agents/` with `readJsonFile` and sends the parsed payloads untouched, keyed by the file's path with the schema extension stripped — `entities/Todo.jsonc` → `Todo`, `agents/support/triage.jsonc` → `support/triage`. That is the same name the platform derives from the same file. +`collectResources` reads `entities/`, `agents/` and `workflows/` with `readJsonFile` and sends the parsed payloads untouched, keyed by the file's path with the schema extension stripped — `entities/Todo.jsonc` → `Todo`, `agents/support/triage.jsonc` → `support/triage`, `workflows/daily-digest.jsonc` → `daily-digest`. That is the same name the platform derives from the same file. + +Workflows differ in one rule: **only `.jsonc`**. To the platform a `.json` file under `workflows/` is an ordinary file, not a workflow, so the CLI skips it rather than declaring one. Nested files are sent under their relative path (`sub/x`); the server refuses a key that is not path-safe. `workflows` is always sent, `{}` for an app with none: an absent field is what a CLI from before workflows sends, and the platform refuses it for an app that has them. The directory is `workflowsDir` in `config.jsonc`, default `workflows`. Deliberately **not** `entityResource.readAll` / `agentResource.readAll`. The platform's own extractor and validation are authoritative, and this CLI's stricter entity schema refuses real Builder apps — which is exactly why `site deploy` reads no resources at all. Re-introducing the strict parse here would reproduce that block. diff --git a/packages/cli/src/core/project/schema.ts b/packages/cli/src/core/project/schema.ts index 862a6f0ca..84f273a7f 100644 --- a/packages/cli/src/core/project/schema.ts +++ b/packages/cli/src/core/project/schema.ts @@ -61,6 +61,7 @@ export const ProjectConfigSchema = z.object({ functionsDir: z.string().optional().default("functions"), actorsDir: z.string().optional().default("actors"), agentsDir: z.string().optional().default("agents"), + workflowsDir: z.string().optional().default("workflows"), agentSkillsDir: z.string().optional().default("agent-skills"), connectorsDir: z.string().optional().default("connectors"), authDir: z.string().optional().default("auth"), diff --git a/packages/cli/src/core/project/target.ts b/packages/cli/src/core/project/target.ts index 5857f24c4..17b5e8ce1 100644 --- a/packages/cli/src/core/project/target.ts +++ b/packages/cli/src/core/project/target.ts @@ -8,13 +8,14 @@ import type { ProjectWithPaths } from "@/core/project/types.js"; /** A project's resolved layout: where its build runs, and what it leaves behind. */ export interface BuildTarget { root: string; - /** Where `entitiesDir`, `agentsDir`, `functionsDir` and `actorsDir` are - * resolved from. */ + /** Where `entitiesDir`, `agentsDir`, `workflowsDir`, `functionsDir` and + * `actorsDir` are resolved from. */ configDir: string; buildCommand: string; outputDir: string; entitiesDir: string; agentsDir: string; + workflowsDir: string; functionsDir: string; actorsDir: string; } @@ -53,6 +54,7 @@ export async function resolveBuildTarget( ), entitiesDir: project?.entitiesDir ?? "entities", agentsDir: project?.agentsDir ?? "agents", + workflowsDir: project?.workflowsDir ?? "workflows", functionsDir: project?.functionsDir ?? "functions", actorsDir: project?.actorsDir ?? "actors", }; diff --git a/packages/cli/src/core/version/api.ts b/packages/cli/src/core/version/api.ts index 2976eeffa..e884ed31c 100644 --- a/packages/cli/src/core/version/api.ts +++ b/packages/cli/src/core/version/api.ts @@ -195,6 +195,7 @@ export async function createVersion( actors: artifacts.actors.map(declaredActor), entities: artifacts.entities, agents: artifacts.agents, + workflows: artifacts.workflows, source_commit: options.sourceCommit, }, "declaring a version", diff --git a/packages/cli/src/core/version/artifacts.ts b/packages/cli/src/core/version/artifacts.ts index 56104aeb4..a8618a44d 100644 --- a/packages/cli/src/core/version/artifacts.ts +++ b/packages/cli/src/core/version/artifacts.ts @@ -2,7 +2,10 @@ import { createHash } from "node:crypto"; import { join, resolve } from "node:path"; import { globby } from "globby"; import pMap from "p-map"; -import { CONFIG_FILE_EXTENSION_GLOB } from "@/core/consts.js"; +import { + CONFIG_FILE_EXTENSION, + CONFIG_FILE_EXTENSION_GLOB, +} from "@/core/consts.js"; import { InvalidInputError } from "@/core/errors.js"; import type { BuildTarget } from "@/core/project/target.js"; import type { FullStackBuild } from "@/core/site/full-stack.js"; @@ -132,17 +135,21 @@ async function describeSiteWorker( } /** - * The app's declared entities and agents, raw — deliberately not the validated - * resource readers, whose stricter entity schema refuses real Builder apps. + * The app's declared entities, agents and workflows, raw — deliberately not the + * validated resource readers, whose stricter entity schema refuses real Builder + * apps. * * Keyed the way the platform names the same file: `agents/support/triage.jsonc` * is `support/triage`. */ -async function readRawResources(dir: string): Promise> { +async function readRawResources( + dir: string, + extensions: string = CONFIG_FILE_EXTENSION_GLOB, +): Promise> { if (!(await pathExists(dir))) { return {}; } - const files = await globby(`**/*.${CONFIG_FILE_EXTENSION_GLOB}`, { + const files = await globby(`**/*.${extensions}`, { cwd: dir, onlyFiles: true, followSymbolicLinks: false, @@ -157,13 +164,16 @@ async function readRawResources(dir: string): Promise> { export async function collectResources( configDir: string, - dirs: { entitiesDir: string; agentsDir: string }, -): Promise> { - const [entities, agents] = await Promise.all([ + dirs: { entitiesDir: string; agentsDir: string; workflowsDir: string }, +): Promise> { + const [entities, agents, workflows] = await Promise.all([ readRawResources(join(configDir, dirs.entitiesDir)), readRawResources(join(configDir, dirs.agentsDir)), + // `.jsonc` only: to the platform a `.json` file in the workflows directory + // is an ordinary file, not a workflow, and sending it would declare one. + readRawResources(join(configDir, dirs.workflowsDir), CONFIG_FILE_EXTENSION), ]); - return { entities, agents }; + return { entities, agents, workflows }; } /** diff --git a/packages/cli/src/core/version/schema.ts b/packages/cli/src/core/version/schema.ts index a19b93d3e..76100c98d 100644 --- a/packages/cli/src/core/version/schema.ts +++ b/packages/cli/src/core/version/schema.ts @@ -98,6 +98,10 @@ export interface ArtifactSet { /** Raw payloads by name. The server normalizes and hashes them. */ entities: Record; agents: Record; + /** Always sent, `{}` when the app has none: an absent field says this + * producer cannot describe workflows, and the platform refuses that for an + * app that has them. */ + workflows: Record; } export interface CreateVersionProgress { diff --git a/packages/cli/tests/cli/publish.spec.ts b/packages/cli/tests/cli/publish.spec.ts index 73bd82031..5eda8956e 100644 --- a/packages/cli/tests/cli/publish.spec.ts +++ b/packages/cli/tests/cli/publish.spec.ts @@ -68,6 +68,31 @@ describe("publish command", () => { }); }); + it("sends the app's workflows raw, keyed by their path under workflows/", async () => { + t.givenEnv({ BASE44_VERSIONS_API: "1" }); + await t.givenLoggedInWithProject(fixture("publishable")); + mockPublishApi(); + + const result = await t.run("publish", "--no-build"); + + t.expectResult(result).toSucceed(); + // `notes.json` beside it is not a workflow: the platform takes `.jsonc` only. + expect(t.api.versionDeclareRequests[0]).toMatchObject({ + workflows: { + "daily-digest": { + name: "Daily digest", + trigger: { type: "schedule", cron: "0 9 * * *" }, + steps: [], + }, + }, + }); + expect( + Object.keys( + (t.api.versionDeclareRequests[0] as { workflows: object }).workflows, + ), + ).toEqual(["daily-digest"]); + }); + it("declares an app with no functions as having none", async () => { // Not an absent field: that is what an older CLI sends, and the platform // refuses it for an app whose functions it would then remove. @@ -606,6 +631,15 @@ describe("publish command, for an app with a server of its own", () => { }); }); + it("declares an app with no workflows as having none", async () => { + // Not an absent field: that is what an older CLI sends, and the platform + // refuses it for an app that has workflows. + const result = await publish(); + + t.expectResult(result).toSucceed(); + expect(t.api.versionDeclareRequests[0]).toMatchObject({ workflows: {} }); + }); + it("takes the assets from the Worker's own directory", async () => { // Collecting the project's build output instead would ask the platform to // serve the Worker's files from S3, past every route it owns. diff --git a/packages/cli/tests/core/project-target.spec.ts b/packages/cli/tests/core/project-target.spec.ts index 1931e1fb9..1fa193311 100644 --- a/packages/cli/tests/core/project-target.spec.ts +++ b/packages/cli/tests/core/project-target.spec.ts @@ -43,6 +43,7 @@ describe("resolveBuildTarget", () => { outputDir: resolve(root, "dist"), entitiesDir: "entities", agentsDir: "agents", + workflowsDir: "workflows", functionsDir: "functions", actorsDir: "actors", }); diff --git a/packages/cli/tests/core/version-artifacts.spec.ts b/packages/cli/tests/core/version-artifacts.spec.ts index c5c203002..4c2dea10c 100644 --- a/packages/cli/tests/core/version-artifacts.spec.ts +++ b/packages/cli/tests/core/version-artifacts.spec.ts @@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { InvalidInputError } from "@/core/errors.js"; +import { ConfigInvalidError, InvalidInputError } from "@/core/errors.js"; import { hashAsset } from "@/core/site/manifest.js"; import { collectArtifacts, @@ -126,7 +126,11 @@ describe("collectResources", () => { await rm(configDir, { recursive: true, force: true }); }); - const dirs = { entitiesDir: "entities", agentsDir: "agents" }; + const dirs = { + entitiesDir: "entities", + agentsDir: "agents", + workflowsDir: "workflows", + }; it("keys a payload by its path with the schema extension stripped", async () => { // The same name the platform derives from the same file, so a version made @@ -166,8 +170,74 @@ describe("collectResources", () => { expect(await collectResources(configDir, dirs)).toEqual({ entities: {}, agents: {}, + workflows: {}, }); }); + + it("keys a workflow by its path under the workflows directory", async () => { + // The platform's `workflow_files_key`: `workflows/daily-digest.jsonc` is + // `daily-digest`, and a nested file keeps its relative path. + await mkdir(join(configDir, "workflows", "sub"), { recursive: true }); + await writeFile( + join(configDir, "workflows", "daily-digest.jsonc"), + '{ "name": "Daily digest", "steps": [] }', + ); + await writeFile(join(configDir, "workflows", "sub", "x.jsonc"), "{}"); + + const { workflows } = await collectResources(configDir, dirs); + + expect(workflows).toEqual({ + "daily-digest": { name: "Daily digest", steps: [] }, + "sub/x": {}, + }); + }); + + it("reads a workflow as JSONC and sends it raw", async () => { + await mkdir(join(configDir, "workflows")); + await writeFile( + join(configDir, "workflows", "w.jsonc"), + '{\n // a comment\n "anything": { "goes": [1, 2,] },\n}', + ); + + const { workflows } = await collectResources(configDir, dirs); + + expect(workflows).toEqual({ w: { anything: { goes: [1, 2] } } }); + }); + + it("takes only .jsonc files as workflows", async () => { + // Unlike entities and agents: to the platform a `.json` file in the + // workflows directory is an ordinary file, not a workflow. + await mkdir(join(configDir, "workflows")); + await writeFile(join(configDir, "workflows", "real.jsonc"), "{}"); + await writeFile(join(configDir, "workflows", "notes.json"), "{}"); + + const { workflows } = await collectResources(configDir, dirs); + + expect(workflows).toEqual({ real: {} }); + }); + + it("fails on a workflow that does not parse, naming the file", async () => { + await mkdir(join(configDir, "workflows")); + const broken = join(configDir, "workflows", "broken.jsonc"); + await writeFile(broken, "{ not json"); + + const failure = collectResources(configDir, dirs); + + await expect(failure).rejects.toBeInstanceOf(ConfigInvalidError); + await expect(failure).rejects.toThrow(broken); + }); + + it("reads workflows from the configured directory", async () => { + await mkdir(join(configDir, "flows")); + await writeFile(join(configDir, "flows", "w.jsonc"), "{}"); + + const { workflows } = await collectResources(configDir, { + ...dirs, + workflowsDir: "flows", + }); + + expect(workflows).toEqual({ w: {} }); + }); }); describe("collectSiteWorker", () => { @@ -378,6 +448,7 @@ describe("collectArtifacts", () => { outputDir: join(projectRoot, "dist", "client"), entitiesDir: "entities", agentsDir: "agents", + workflowsDir: "workflows", functionsDir: "functions", actorsDir: "actors", }; diff --git a/packages/cli/tests/fixtures/publishable/base44/workflows/daily-digest.jsonc b/packages/cli/tests/fixtures/publishable/base44/workflows/daily-digest.jsonc new file mode 100644 index 000000000..fd2ca8440 --- /dev/null +++ b/packages/cli/tests/fixtures/publishable/base44/workflows/daily-digest.jsonc @@ -0,0 +1,6 @@ +{ + // Sent raw: the platform validates workflows, not the CLI. + "name": "Daily digest", + "trigger": { "type": "schedule", "cron": "0 9 * * *" }, + "steps": [], +} diff --git a/packages/cli/tests/fixtures/publishable/base44/workflows/notes.json b/packages/cli/tests/fixtures/publishable/base44/workflows/notes.json new file mode 100644 index 000000000..45d26bbfb --- /dev/null +++ b/packages/cli/tests/fixtures/publishable/base44/workflows/notes.json @@ -0,0 +1 @@ +{ "not": "a workflow" } From 8bf0c7cf1471bbfc37fd542389a1b74ef5055061 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Tue, 3 Nov 2026 14:44:11 +0200 Subject: [PATCH 2/3] feat(versions): send hidden workflow files too The platform counts any `.jsonc` under base44/workflows/ as a workflow, dot-files included, so `.draft.jsonc` is the workflow `.draft`. Skipping it here would record a version without a workflow the app has. Workflows only; entities and agents keep globby's default. Co-Authored-By: Claude Opus 5.5 --- docs/versions.md | 2 +- packages/cli/src/core/version/artifacts.ts | 13 +++++++++---- packages/cli/tests/core/version-artifacts.spec.ts | 11 +++++++++++ 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/docs/versions.md b/docs/versions.md index 08d63e595..f18ba05c8 100644 --- a/docs/versions.md +++ b/docs/versions.md @@ -89,7 +89,7 @@ The practical consequence for this CLI: **send the checksum the server gave you, `collectResources` reads `entities/`, `agents/` and `workflows/` with `readJsonFile` and sends the parsed payloads untouched, keyed by the file's path with the schema extension stripped — `entities/Todo.jsonc` → `Todo`, `agents/support/triage.jsonc` → `support/triage`, `workflows/daily-digest.jsonc` → `daily-digest`. That is the same name the platform derives from the same file. -Workflows differ in one rule: **only `.jsonc`**. To the platform a `.json` file under `workflows/` is an ordinary file, not a workflow, so the CLI skips it rather than declaring one. Nested files are sent under their relative path (`sub/x`); the server refuses a key that is not path-safe. `workflows` is always sent, `{}` for an app with none: an absent field is what a CLI from before workflows sends, and the platform refuses it for an app that has them. The directory is `workflowsDir` in `config.jsonc`, default `workflows`. +Workflows follow the platform's own reading of that directory, which differs in two rules: **only `.jsonc`**, and **dot-files count**. A `.json` file under `workflows/` is an ordinary file, not a workflow, so the CLI skips it; `.draft.jsonc` is the workflow `.draft`, so the CLI sends it. Nested files are sent under their relative path (`sub/x`); the server refuses a key that is not path-safe. `workflows` is always sent, `{}` for an app with none: an absent field is what a CLI from before workflows sends, and the platform refuses it for an app that has them. The directory is `workflowsDir` in `config.jsonc`, default `workflows`. Deliberately **not** `entityResource.readAll` / `agentResource.readAll`. The platform's own extractor and validation are authoritative, and this CLI's stricter entity schema refuses real Builder apps — which is exactly why `site deploy` reads no resources at all. Re-introducing the strict parse here would reproduce that block. diff --git a/packages/cli/src/core/version/artifacts.ts b/packages/cli/src/core/version/artifacts.ts index a8618a44d..68635e007 100644 --- a/packages/cli/src/core/version/artifacts.ts +++ b/packages/cli/src/core/version/artifacts.ts @@ -144,15 +144,17 @@ async function describeSiteWorker( */ async function readRawResources( dir: string, - extensions: string = CONFIG_FILE_EXTENSION_GLOB, + options: { extensions?: string; dot?: boolean } = {}, ): Promise> { if (!(await pathExists(dir))) { return {}; } + const extensions = options.extensions ?? CONFIG_FILE_EXTENSION_GLOB; const files = await globby(`**/*.${extensions}`, { cwd: dir, onlyFiles: true, followSymbolicLinks: false, + dot: options.dot ?? false, }); const payloads: Record = {}; for (const relativePath of files.sort()) { @@ -169,9 +171,12 @@ export async function collectResources( const [entities, agents, workflows] = await Promise.all([ readRawResources(join(configDir, dirs.entitiesDir)), readRawResources(join(configDir, dirs.agentsDir)), - // `.jsonc` only: to the platform a `.json` file in the workflows directory - // is an ordinary file, not a workflow, and sending it would declare one. - readRawResources(join(configDir, dirs.workflowsDir), CONFIG_FILE_EXTENSION), + // As the platform reads the same directory: a `.json` file there is an + // ordinary file, not a workflow, while a dot-file is one (`.draft`). + readRawResources(join(configDir, dirs.workflowsDir), { + extensions: CONFIG_FILE_EXTENSION, + dot: true, + }), ]); return { entities, agents, workflows }; } diff --git a/packages/cli/tests/core/version-artifacts.spec.ts b/packages/cli/tests/core/version-artifacts.spec.ts index 4c2dea10c..2d71bd50a 100644 --- a/packages/cli/tests/core/version-artifacts.spec.ts +++ b/packages/cli/tests/core/version-artifacts.spec.ts @@ -216,6 +216,17 @@ describe("collectResources", () => { expect(workflows).toEqual({ real: {} }); }); + it("takes a hidden .jsonc file as a workflow", async () => { + // The platform counts `.draft.jsonc` as the workflow `.draft`; skipping it + // here would leave the version without one the app has. + await mkdir(join(configDir, "workflows")); + await writeFile(join(configDir, "workflows", ".draft.jsonc"), "{}"); + + const { workflows } = await collectResources(configDir, dirs); + + expect(workflows).toEqual({ ".draft": {} }); + }); + it("fails on a workflow that does not parse, naming the file", async () => { await mkdir(join(configDir, "workflows")); const broken = join(configDir, "workflows", "broken.jsonc"); From 48ac1e49dde83b963be800a52f844c23b6ca724c Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Wed, 4 Nov 2026 14:53:21 +0200 Subject: [PATCH 3/3] docs(versions): actors and workflows are required on every declare apper #37480 makes both fields required on POST versions: a declare without either is a 422 invalid_request for any app, and the conditional 409s (actors_undeclared, workflows_undeclared) are gone. The CLI already always sends both, so only the wording changes. Co-Authored-By: Claude Opus 5.5 --- docs/versions.md | 4 ++-- packages/cli/src/core/version/schema.ts | 8 ++++---- packages/cli/tests/cli/publish.spec.ts | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/versions.md b/docs/versions.md index f18ba05c8..7b0a76f28 100644 --- a/docs/versions.md +++ b/docs/versions.md @@ -77,7 +77,7 @@ The compiler is bundled like the rest of the CLI, and imports `esbuild` and `@de The entry is always `base44/actors//entry.ts`, an `entry.js` included, and that path is not cosmetic: it is how the compiler knows the entry is an actor. That is why this does not go through `cfwBundleInput`, which flattens an entry that reaches nothing to `main.ts`. The files are the ones the entry reaches among its own folder and the shared modules, keyed at `base44/actors//` and `base44/shared/` wherever the project keeps them — which is how apper assembles an actor, so `../../shared/x.ts` resolves and a reach into another actor or a function does not. -`actors` is always sent, `[]` for an app with none, for the reason `backend_bundles` is: an absent field is what a CLI from before actors sends, and the platform refuses it (409 `actors_undeclared`) for an app that has them. One actor that fails to compile fails the publish at step `build`. The platform caps a version at 50 actors and the CLI refuses more before compiling any; the standalone binary refuses an app with actors as it does one with functions. +`actors` is always sent, `[]` for an app with none: the platform requires the field and refuses a declare without it (422 `invalid_request`), whether or not the app has actors. One actor that fails to compile fails the publish at step `build`. The platform caps a version at 50 actors and the CLI refuses more before compiling any; the standalone binary refuses an app with actors as it does one with functions. ## Why the digest is signed into the URL @@ -89,7 +89,7 @@ The practical consequence for this CLI: **send the checksum the server gave you, `collectResources` reads `entities/`, `agents/` and `workflows/` with `readJsonFile` and sends the parsed payloads untouched, keyed by the file's path with the schema extension stripped — `entities/Todo.jsonc` → `Todo`, `agents/support/triage.jsonc` → `support/triage`, `workflows/daily-digest.jsonc` → `daily-digest`. That is the same name the platform derives from the same file. -Workflows follow the platform's own reading of that directory, which differs in two rules: **only `.jsonc`**, and **dot-files count**. A `.json` file under `workflows/` is an ordinary file, not a workflow, so the CLI skips it; `.draft.jsonc` is the workflow `.draft`, so the CLI sends it. Nested files are sent under their relative path (`sub/x`); the server refuses a key that is not path-safe. `workflows` is always sent, `{}` for an app with none: an absent field is what a CLI from before workflows sends, and the platform refuses it for an app that has them. The directory is `workflowsDir` in `config.jsonc`, default `workflows`. +Workflows follow the platform's own reading of that directory, which differs in two rules: **only `.jsonc`**, and **dot-files count**. A `.json` file under `workflows/` is an ordinary file, not a workflow, so the CLI skips it; `.draft.jsonc` is the workflow `.draft`, so the CLI sends it. Nested files are sent under their relative path (`sub/x`); the server refuses a key that is not path-safe. `workflows` is always sent, `{}` for an app with none: the platform requires the field and refuses a declare without it (422 `invalid_request`), whether or not the app has workflows. The directory is `workflowsDir` in `config.jsonc`, default `workflows`. Deliberately **not** `entityResource.readAll` / `agentResource.readAll`. The platform's own extractor and validation are authoritative, and this CLI's stricter entity schema refuses real Builder apps — which is exactly why `site deploy` reads no resources at all. Re-introducing the strict parse here would reproduce that block. diff --git a/packages/cli/src/core/version/schema.ts b/packages/cli/src/core/version/schema.ts index 76100c98d..5dc0de3a2 100644 --- a/packages/cli/src/core/version/schema.ts +++ b/packages/cli/src/core/version/schema.ts @@ -93,14 +93,14 @@ export interface ArtifactSet { * platform this producer cannot describe functions, and it refuses that for * an app that has them. */ backendBundles: BackendBundleArtifact[]; - /** Always sent, empty when the app has none, for the same reason. */ + /** Always sent, empty when the app has none: the platform refuses a declare + * without it (422), for any app. */ actors: ActorArtifact[]; /** Raw payloads by name. The server normalizes and hashes them. */ entities: Record; agents: Record; - /** Always sent, `{}` when the app has none: an absent field says this - * producer cannot describe workflows, and the platform refuses that for an - * app that has them. */ + /** Always sent, `{}` when the app has none: the platform refuses a declare + * without it (422), for any app. */ workflows: Record; } diff --git a/packages/cli/tests/cli/publish.spec.ts b/packages/cli/tests/cli/publish.spec.ts index 5eda8956e..1ec75e796 100644 --- a/packages/cli/tests/cli/publish.spec.ts +++ b/packages/cli/tests/cli/publish.spec.ts @@ -427,8 +427,8 @@ describe.skipIf(process.env.CLI_TEST_RUNNER === "binary")( }); it("declares an app with no actors as having none", async () => { - // Not an absent field: that is what an older CLI sends, and the platform - // refuses it for an app whose actors it would then drop. + // Not an absent field: the platform refuses a declare without `actors`, + // for any app. t.givenEnv({ BASE44_VERSIONS_API: "1" }); await t.givenLoggedInWithProject(fixture("publishable")); t.api @@ -632,8 +632,8 @@ describe("publish command, for an app with a server of its own", () => { }); it("declares an app with no workflows as having none", async () => { - // Not an absent field: that is what an older CLI sends, and the platform - // refuses it for an app that has workflows. + // Not an absent field: the platform refuses a declare without `workflows`, + // for any app. const result = await publish(); t.expectResult(result).toSucceed();