diff --git a/worker/src/routes/auth.ts b/worker/src/routes/auth.ts index 4d87260..10f3270 100644 --- a/worker/src/routes/auth.ts +++ b/worker/src/routes/auth.ts @@ -973,7 +973,17 @@ export async function handleAgentManifest(c: Context<{ Bindings: Env }>) { // keep the machine contract unchanged, just point callers at `migration-help`. const service = c.env.RAFT_CLIENT_ID || "hands-4cc7a2"; const DEPRECATION_PREFIX = `Deprecated — run raft integration invoke --service ${service} --action migration-help for Hands CLI installation and migration guidance. `; - const NEW_ACTIONS = new Set(["agent-login", "migration-help"]); + const NEW_ACTIONS = new Set([ + "agent-login", + "migration-help", + // Decision (2026-09, #167 follow-up / #175): these three are newly added actions, so they are + // registered here deliberately. This set is what stops the migration nudge from being + // prepended; without it a brand-new action would ship labelled "Deprecated". Membership is a + // statement about provenance (added after the migration), not about stability or support. + "list-builds", + "update-channel", + "delete-channel", + ]); const applyDeprecation = ( list: Array<{ name: string; description: string; [k: string]: unknown }>, ) => list.map((a) => (NEW_ACTIONS.has(a.name) ? a : { ...a, description: DEPRECATION_PREFIX + a.description })); @@ -1195,6 +1205,32 @@ export async function handleAgentManifest(c: Context<{ Bindings: Env }>) { description: "Create a release channel on an app (channels are never auto-created by publish). Requires app admin. Creating a channel activates nothing.", }, + { + name: "update-channel", + description: + "Change a release channel's settings. Requires app admin. Fields omitted from the body are left unchanged; a body with none of them is rejected with \"nothing to update\".", + endpoint: { method: "PATCH", path: "/api/apps/{app_id}/channels/{channel_id}" }, + parameters: { + app_id: { type: "string", in: "path", required: true, description: "App UUID." }, + channel_id: { type: "string", in: "path", required: true, description: "Channel UUID." }, + name: { type: "string", in: "body", required: false, description: "New display name." }, + bundle_id: { type: "string", in: "body", required: false, description: "New bundle id; an empty string clears it." }, + password: { type: "string", in: "body", required: false, description: "New shared password; an empty string clears it." }, + git_url: { type: "string", in: "body", required: false, description: "New git url; an empty string clears it." }, + enabled_product_types: { type: "array", in: "body", required: false, description: "Replacement product-type allowlist; stored as JSON." }, + metadata: { type: "object", in: "body", required: false, description: "Opaque metadata object; stored as JSON." }, + }, + }, + { + name: "delete-channel", + description: + "Delete a release channel. Requires app admin. Refused (not cascaded) while any build or release still references the channel.", + endpoint: { method: "DELETE", path: "/api/apps/{app_id}/channels/{channel_id}" }, + parameters: { + app_id: { type: "string", in: "path", required: true, description: "App UUID." }, + channel_id: { type: "string", in: "path", required: true, description: "Channel UUID." }, + }, + }, { name: "list-device-groups", description: "List app-scoped rollout device groups and their installation device ids. Requires app publisher.", @@ -1611,6 +1647,19 @@ export async function handleAgentManifest(c: Context<{ Bindings: Env }>) { bundle_id: { type: "string", in: "query", required: false, description: "Optional bundle-id assertion; it must match immutable build metadata, and is only a fallback when metadata is absent." }, }, }, + { + name: "list-builds", + description: + "List an app's builds, newest first, capped at 200, with the same product/channel/status filters the CLI uses. Requires app viewer.", + endpoint: { method: "GET", path: "/api/apps/{app_id}/builds" }, + parameters: { + app_id: { type: "string", in: "path", required: true, description: "App UUID." }, + product_type: { type: "string", in: "query", required: false, description: "Filter by product type (e.g. cli-binary)." }, + channel: { type: "string", in: "query", required: false, description: "Filter by channel id or slug." }, + status: { type: "string", in: "query", required: false, description: "Filter by build status." }, + version_name: { type: "string", in: "query", required: false, description: "Filter by version name." }, + }, + }, { name: "list-build-assets", description: diff --git a/worker/test/agent_login_routes.test.ts b/worker/test/agent_login_routes.test.ts index 678351d..fa9bb31 100644 --- a/worker/test/agent_login_routes.test.ts +++ b/worker/test/agent_login_routes.test.ts @@ -232,6 +232,41 @@ describe("manifest: actions retained + deprecated, migration-help added", () => expect(body.actions.find((a: any) => a.name === "migration-help").description.startsWith("Deprecated")).toBe(false); // machine contract unchanged: help still GET /api/agent/help expect(body.actions.find((a: any) => a.name === "help").endpoint).toEqual({ method: "GET", path: "/api/agent/help" }); + + // 2026-09 (#167 follow-up / #175): three actions added for surfaces that previously had no + // agent action. They are NEW, so the migration nudge must not label them deprecated. + for (const n of ["list-builds", "update-channel", "delete-channel"]) { + expect(names).toContain(n); + expect(body.actions.find((a: any) => a.name === n).description.startsWith("Deprecated")).toBe(false); + } + // Each maps to an endpoint the worker already serves, and declares the fields the handler reads. + const listBuilds = body.actions.find((a: any) => a.name === "list-builds"); + expect(listBuilds.endpoint).toEqual({ method: "GET", path: "/api/apps/{app_id}/builds" }); + expect(Object.keys(listBuilds.parameters).sort()).toEqual( + ["app_id", "channel", "product_type", "status", "version_name"].sort(), + ); + const updateChannel = body.actions.find((a: any) => a.name === "update-channel"); + expect(updateChannel.endpoint).toEqual({ method: "PATCH", path: "/api/apps/{app_id}/channels/{channel_id}" }); + // The handler reads six body fields; listing fewer silently hides settable fields. + expect(Object.keys(updateChannel.parameters).sort()).toEqual( + ["app_id", "bundle_id", "channel_id", "enabled_product_types", "git_url", "metadata", "name", "password"].sort(), + ); + const deleteChannel = body.actions.find((a: any) => a.name === "delete-channel"); + expect(deleteChannel.endpoint).toEqual({ method: "DELETE", path: "/api/apps/{app_id}/channels/{channel_id}" }); + // The Raft-side parser reads `parameters` only (a `params` key is ignored), so actions added + // here must use the key the parser actually reads. + for (const n of ["list-builds", "update-channel", "delete-channel"]) { + const a = body.actions.find((x: any) => x.name === n); + expect(a.parameters).toBeDefined(); + expect(a.params).toBeUndefined(); + } + + // Structured body fields must use the labels the Raft side recognises. `invoke` treats only + // `array` and `object` as structured types (slock commands/integration/invoke.ts:308-316), so a + // `string[]` or `string` label for a structured field silently loses the value. Assert the + // labels, not just the field names. + expect(updateChannel.parameters.enabled_product_types.type).toBe("array"); + expect(updateChannel.parameters.metadata.type).toBe("object"); }); it("migration-help endpoint returns install + login guidance", async () => {