Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion worker/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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:
Expand Down
35 changes: 35 additions & 0 deletions worker/test/agent_login_routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading