diff --git a/packages/api-client/src/schema.d.ts b/packages/api-client/src/schema.d.ts index 7cf448bb..93854382 100644 --- a/packages/api-client/src/schema.d.ts +++ b/packages/api-client/src/schema.d.ts @@ -1539,7 +1539,15 @@ export interface paths { delete?: never; options?: never; head?: never; - patch?: never; + /** + * Rename run + * @description Changes only the run display name. Supply expectedName for optimistic concurrency; omission supports last-writer-wins clients. + * + * Requires the runs:write scope. + * + * Accepts a scoped bearer token or interactive owner session. + */ + patch: operations["patch_api_v1_runs_runId"]; trace?: never; }; "/api/v1/runs/{runId}/trace": { @@ -2304,7 +2312,7 @@ export interface operations { /** @constant */ role: "owner"; }; - capabilities: ("projects:read" | "projects:write" | "jobs:read" | "jobs:write" | "runs:read" | "runs:invoke" | "runs:cancel" | "integrations:read" | "integrations:write" | "skills:read" | "skills:write")[]; + capabilities: ("projects:read" | "projects:write" | "jobs:read" | "jobs:write" | "runs:read" | "runs:invoke" | "runs:cancel" | "runs:write" | "integrations:read" | "integrations:write" | "skills:read" | "skills:write")[]; /** Format: date-time */ expiresAt: string; }; @@ -3606,7 +3614,7 @@ export interface operations { "application/json": { id: string; name: string; - scopes: ("projects:read" | "projects:write" | "jobs:read" | "jobs:write" | "runs:read" | "runs:invoke" | "runs:cancel" | "integrations:read" | "integrations:write" | "skills:read" | "skills:write")[]; + scopes: ("projects:read" | "projects:write" | "jobs:read" | "jobs:write" | "runs:read" | "runs:invoke" | "runs:cancel" | "runs:write" | "integrations:read" | "integrations:write" | "skills:read" | "skills:write")[]; created_at: string; expires_at: string; last_used_at?: string | null; @@ -3635,7 +3643,7 @@ export interface operations { content: { "application/json": { name: string; - scopes: (("projects:read" | "projects:write" | "jobs:read" | "jobs:write" | "runs:read" | "runs:invoke" | "runs:cancel" | "integrations:read" | "integrations:write" | "skills:read" | "skills:write") | ("flows:read" | "flows:write" | "runs:write"))[]; + scopes: (("projects:read" | "projects:write" | "jobs:read" | "jobs:write" | "runs:read" | "runs:invoke" | "runs:cancel" | "runs:write" | "integrations:read" | "integrations:write" | "skills:read" | "skills:write") | ("flows:read" | "flows:write"))[]; expiryDays: 7 | 30 | 90; }; }; @@ -3650,7 +3658,7 @@ export interface operations { "application/json": { id: string; name: string; - scopes: ("projects:read" | "projects:write" | "jobs:read" | "jobs:write" | "runs:read" | "runs:invoke" | "runs:cancel" | "integrations:read" | "integrations:write" | "skills:read" | "skills:write")[]; + scopes: ("projects:read" | "projects:write" | "jobs:read" | "jobs:write" | "runs:read" | "runs:invoke" | "runs:cancel" | "runs:write" | "integrations:read" | "integrations:write" | "skills:read" | "skills:write")[]; created_at: string; expires_at: string; last_used_at?: string | null; @@ -6296,6 +6304,48 @@ export interface operations { 503: components["responses"]["Error503"]; }; }; + patch_api_v1_runs_runId: { + parameters: { + query?: never; + header?: never; + path: { + runId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + name: string; + expectedName?: string; + }; + }; + }; + responses: { + /** @description Saved run display name */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + run_name: string; + }; + }; + }; + 400: components["responses"]["Error400"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + /** @description Run not found within the tenant */ + 404: components["responses"]["Error404"]; + /** @description stale_run_name: reload before retrying */ + 409: components["responses"]["Error409"]; + 415: components["responses"]["Error415"]; + 429: components["responses"]["Error429"]; + 500: components["responses"]["Error500"]; + 503: components["responses"]["Error503"]; + }; + }; get_api_v1_runs_runId_trace: { parameters: { query?: { diff --git a/packages/api/src/access-tokens.ts b/packages/api/src/access-tokens.ts index 2e5db7a1..6dfe2c12 100644 --- a/packages/api/src/access-tokens.ts +++ b/packages/api/src/access-tokens.ts @@ -2,9 +2,9 @@ import type { Env, OAuthProps } from "./types"; import { databaseFor } from "./postgres/database"; import { AccessTokenRepository } from "./postgres/access-token-repository"; -export const ACCESS_SCOPES = ["projects:read", "projects:write", "jobs:read", "jobs:write", "runs:read", "runs:invoke", "runs:cancel", "integrations:read", "integrations:write", "skills:read", "skills:write"] as const; +export const ACCESS_SCOPES = ["projects:read", "projects:write", "jobs:read", "jobs:write", "runs:read", "runs:invoke", "runs:cancel", "runs:write", "integrations:read", "integrations:write", "skills:read", "skills:write"] as const; export type AccessScope = typeof ACCESS_SCOPES[number]; -export type LegacyScope = "flows:read" | "flows:write" | "runs:write"; +export type LegacyScope = "flows:read" | "flows:write"; /** Legacy grants are accepted only at authentication time. New issuance never emits them. */ export function normalizeScopes(scopes: readonly string[]): string[] { @@ -12,7 +12,7 @@ export function normalizeScopes(scopes: readonly string[]): string[] { for (const scope of scopes) { if (scope === "flows:read") ["projects:read", "jobs:read", "integrations:read"].forEach(value => result.add(value)); else if (scope === "flows:write") ["projects:write", "jobs:write", "integrations:write"].forEach(value => result.add(value)); - else if (scope === "runs:write") ["runs:read", "runs:invoke", "runs:cancel"].forEach(value => result.add(value)); + else if (scope === "runs:write") ["runs:read", "runs:invoke", "runs:cancel", "runs:write"].forEach(value => result.add(value)); else if (ACCESS_SCOPES.includes(scope as AccessScope)) result.add(scope); } return [...result]; diff --git a/packages/api/src/api-contract.ts b/packages/api/src/api-contract.ts index 50fc4a02..5bf8f398 100644 --- a/packages/api/src/api-contract.ts +++ b/packages/api/src/api-contract.ts @@ -11,6 +11,7 @@ import type { ApiService } from "./flow-service"; import { jobInputSchema, manualInvocationSchema, + renameRunSchema, jobConditionsTestSchema, listRunsSchema, scopeSchema, @@ -108,7 +109,7 @@ const accessTokenBody = z.object({ name: z.string().trim().min(1).max(100), // Legacy names remain accepted at this boundary for migration; the service // immediately stores their canonical resource-scope expansion. - scopes: z.array(z.union([scopeSchema, z.enum(["flows:read", "flows:write", "runs:write"])] )).min(1), + scopes: z.array(z.union([scopeSchema, z.enum(["flows:read", "flows:write"])] )).min(1), expiryDays: z.union([z.literal(7), z.literal(30), z.literal(90)]), }); @@ -154,6 +155,7 @@ function route( // Legacy route labels are translated by resource and effect; they are never // accepted as newly issued scopes. function scopeForRoute(path: string, legacy: string): string | string[] { + if (legacy === "runs:write" && path === "/api/v1/runs/{runId}" ) return "runs:write"; if (legacy === "runs:read" && path === "/api/v1/search") return "jobs:read+runs:read"; if (legacy === "flows:read") { if (path.startsWith("/api/v1/projects") || path.includes("/jobs/") && path.endsWith("/project")) return "projects:read"; @@ -887,6 +889,11 @@ export const API_ROUTES: RouteContract[] = [ }, (service, { params }) => service.getRun(params.runId), ), + route("PATCH", "/api/v1/runs/{runId}", "runs:write", { + summary: "Rename run", + description: "Changes only the run display name. Supply expectedName for optimistic concurrency; omission supports last-writer-wins clients.", + responses: { "200": { description: "Saved run display name", content: { "application/json": { schema: { type: "object", required: ["run_name"], properties: { run_name: { type: "string" } }, additionalProperties: false } } } }, "404": { description: "Run not found within the tenant" }, "409": { description: "stale_run_name: reload before retrying" } }, + }, (service, { params, body }) => service.renameRun(params.runId, body), { body: renameRunSchema, parameters: z.object({ runId: z.uuid() }) }), route( "GET", "/api/v1/runs/{runId}/trace", diff --git a/packages/api/src/consent-api.ts b/packages/api/src/consent-api.ts index 6d7eacac..b3f7fbc9 100644 --- a/packages/api/src/consent-api.ts +++ b/packages/api/src/consent-api.ts @@ -47,7 +47,7 @@ export async function consentDecision(env: Env, session: Session, input: z.infer const client = await env.OAUTH_PROVIDER.lookupClient(parsed.clientId); if (!client || !client.redirectUris.includes(parsed.redirectUri)) return null; const allowed = new Set(normalizeScopes(parsed.scope.filter(scope => supported.includes(scope)))); - const legacyRequest = parsed.scope.some(scope => scope === "flows:read" || scope === "flows:write" || scope === "runs:write"); + const legacyRequest = parsed.scope.some(scope => scope === "flows:read" || scope === "flows:write"); const selected = normalizeScopes(input.scopes); const issuedScopes = legacyRequest ? input.scopes : selected; if (input.scopes.some(scope => !allowed.has(scope) && !allowed.has(normalizeScopes([scope])[0] ?? "") ) || selected.some(scope => !ACCESS_SCOPES.includes(scope as typeof ACCESS_SCOPES[number]))) return null; diff --git a/packages/api/src/flow-schemas.ts b/packages/api/src/flow-schemas.ts index db452d60..7b544b86 100644 --- a/packages/api/src/flow-schemas.ts +++ b/packages/api/src/flow-schemas.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { conditionsSchema, preparedWebhookSchema } from "./webhook-conditions"; -export const scopeSchema = z.enum(["projects:read", "projects:write", "jobs:read", "jobs:write", "runs:read", "runs:invoke", "runs:cancel", "integrations:read", "integrations:write", "skills:read", "skills:write"]); +export const scopeSchema = z.enum(["projects:read", "projects:write", "jobs:read", "jobs:write", "runs:read", "runs:invoke", "runs:cancel", "runs:write", "integrations:read", "integrations:write", "skills:read", "skills:write"]); export const runStateSchema = z.enum(["queued", "reserved", "starting", "running", "done", "blocked", "failed", "ignored", "succeeded", "stopping", "stopped"]); export const matchRuleSchema = z.object({ type: z.enum(["owner", "creator", "status", "label", "assignee"]), @@ -15,6 +15,7 @@ export const listRunsSchema = z.object({ limit: z.coerce.number().int().min(1).max(100).default(50), cursor: z.string().min(1).optional(), }).refine(value => !value.direction || value.sort, { message: "direction requires sort" }); export const runIdSchema = z.object({ runId: z.string().min(1) }); +export const renameRunSchema = z.object({ name: z.string().trim().min(1).max(120), expectedName: z.string().max(120).optional() }).strict(); const webhookCommonSchema = z.object({ conditions: conditionsSchema.optional() }); export const webhookTriggerConfigSchema = z.discriminatedUnion("provider", [ diff --git a/packages/api/src/flow-service.ts b/packages/api/src/flow-service.ts index 9fd97d1b..4aa7d57b 100644 --- a/packages/api/src/flow-service.ts +++ b/packages/api/src/flow-service.ts @@ -5,7 +5,7 @@ import { z } from "zod"; import { runPageResponse, fullRunResponse } from "./run-read-contracts"; import { triggerContextCatalog } from "./trigger-context"; import type { Env, OAuthProps } from "./types"; -import { canonicalizeVmImage, vmImageVerificationInput, type JobInput, type ManualInvocationInput } from "./flow-schemas"; +import { canonicalizeVmImage, renameRunSchema, vmImageVerificationInput, type JobInput, type ManualInvocationInput } from "./flow-schemas"; import { nextOccurrence, validateScheduleConfig } from "./schedule"; import { databaseFor } from "./postgres/database"; import { IdentityRepository } from "./postgres/identity-repository"; @@ -51,7 +51,7 @@ export function publicValue(schema: T, value: unknown): z.o return result.data; } -type Scope = "projects:read" | "projects:write" | "jobs:read" | "jobs:write" | "runs:read" | "runs:invoke" | "runs:cancel" | "integrations:read" | "integrations:write" | "skills:read" | "skills:write"; +type Scope = "projects:read" | "projects:write" | "jobs:read" | "jobs:write" | "runs:read" | "runs:invoke" | "runs:cancel" | "runs:write" | "integrations:read" | "integrations:write" | "skills:read" | "skills:write"; export class ApiService { authorizationDuration = 0; @@ -157,6 +157,14 @@ export class ApiService { async search(query: string) { await this.authorize("jobs:read"); await this.authorize("runs:read"); return publicValue(searchResponse, await new OperationsRepository(databaseFor(this.env), this.auth.tenantId).search(query)); } async triggerContextMetadata() { await this.authorize("jobs:read"); return triggerContextCatalog; } async getRunStatus(runId: string) { await this.authorize("runs:read"); const status = await new RunQueryRepository(databaseFor(this.env), this.auth.tenantId).status(runId); if (!status) throw new ServiceError(404, "not_found", "Run not found"); return status; } + async renameRun(runId: string, input: unknown) { + await this.authorize("runs:write"); + const parsed = renameRunSchema.parse(input); + const result = await new RunQueryRepository(databaseFor(this.env), this.auth.tenantId).rename(runId, parsed.name, parsed.expectedName); + if (result === "missing") throw new ServiceError(404, "not_found", "Run not found"); + if (result === "stale") throw new ServiceError(409, "stale_run_name", "This run was renamed. Reload it before saving."); + return result; + } async getRevisionTrace(runId: string, after: number, limit: number, revision?: string, before?: number, recent = false) { await this.authorize("runs:read"); const page = await new TraceRepository(databaseFor(this.env), this.auth.tenantId).revisionPage(runId, after, limit, revision, before, recent); if (!page) throw new ServiceError(404, "not_found", "Run not found"); return page; } async getRun(runId: string) { await this.authorize("runs:read"); @@ -349,6 +357,6 @@ export class ApiService { } async revokeAuthorizedClient(clientId: string) { await this.authorizeOwnerSession("jobs:write"); if (!this.env.OAUTH_PROVIDER) throw new ServiceError(503, "operation_failed", "OAuth management unavailable"); const grants: any[] = []; let cursor: string | undefined; do { const page = await this.env.OAUTH_PROVIDER.listUserGrants(this.auth.userId, { limit: 100, cursor }); grants.push(...page.items.filter(grant => grant.clientId === clientId)); cursor = page.cursor; } while (cursor); await Promise.all(grants.map(grant => this.env.OAUTH_PROVIDER!.revokeGrant(grant.id, this.auth.userId))); return { revoked: grants.length }; } async listAccessTokens() { await this.authorizeOwnerSession("jobs:read"); return publicValue(z.array(accessToken), (await new AccessTokenRepository(databaseFor(this.env), this.auth.tenantId).list()).map(token => ({ ...token, created_at: new Date(token.created_at).toISOString(), expires_at: new Date(token.expires_at).toISOString(), last_used_at: token.last_used_at ? new Date(token.last_used_at).toISOString() : null, revoked_at: token.revoked_at ? new Date(token.revoked_at).toISOString() : null, scopes: normalizeScopes(token.scopes) }))); } - async createAccessToken(input: any) { await this.authorizeOwnerSession("jobs:write"); const name = typeof input?.name === "string" ? input.name.trim() : "", requested: string[] = Array.isArray(input?.scopes) ? [...new Set(input.scopes.filter((scope: unknown): scope is string => typeof scope === "string"))] : [], legacy = new Set(["flows:read", "flows:write", "runs:write"]), scopes = normalizeScopes(requested), expiryDays = Number(input?.expiryDays); if (!name || name.length > 100 || !requested.length || requested.some(scope => !ACCESS_SCOPES.includes(scope as any) && !legacy.has(scope)) || !scopes.length || ![7, 30, 90].includes(expiryDays)) throw new ServiceError(400, "invalid_request", "A name, supported scopes, and expiryDays of 7, 30, or 90 are required."); const token = issueAccessToken(this.auth.tenantId), expiresAt = new Date(Date.now() + expiryDays * 86_400_000).toISOString(); const metadata = await new AccessTokenRepository(databaseFor(this.env), this.auth.tenantId).create({ name, scopes, expiresAt, digest: await accessTokenDigest(token), userId: this.auth.userId, sessionVersion: this.auth.sessionVersion }); if (!metadata) throw new ServiceError(401, "invalid_token", "The owner session is no longer active."); return publicValue(createdAccessToken, JSON.parse(JSON.stringify({ ...metadata, token }))); } + async createAccessToken(input: any) { await this.authorizeOwnerSession("jobs:write"); const name = typeof input?.name === "string" ? input.name.trim() : "", requested: string[] = Array.isArray(input?.scopes) ? [...new Set(input.scopes.filter((scope: unknown): scope is string => typeof scope === "string"))] : [], legacy = new Set(["flows:read", "flows:write"]), scopes = normalizeScopes(requested), expiryDays = Number(input?.expiryDays); if (!name || name.length > 100 || !requested.length || requested.some(scope => !ACCESS_SCOPES.includes(scope as any) && !legacy.has(scope)) || !scopes.length || ![7, 30, 90].includes(expiryDays)) throw new ServiceError(400, "invalid_request", "A name, supported scopes, and expiryDays of 7, 30, or 90 are required."); const token = issueAccessToken(this.auth.tenantId), expiresAt = new Date(Date.now() + expiryDays * 86_400_000).toISOString(); const metadata = await new AccessTokenRepository(databaseFor(this.env), this.auth.tenantId).create({ name, scopes, expiresAt, digest: await accessTokenDigest(token), userId: this.auth.userId, sessionVersion: this.auth.sessionVersion }); if (!metadata) throw new ServiceError(401, "invalid_token", "The owner session is no longer active."); return publicValue(createdAccessToken, JSON.parse(JSON.stringify({ ...metadata, token }))); } async revokeAccessToken(id: string) { await this.authorizeOwnerSession("jobs:write"); if (!await new AccessTokenRepository(databaseFor(this.env), this.auth.tenantId).revoke(id)) throw new ServiceError(404, "not_found", "Access token not found"); return { revoked: true }; } } diff --git a/packages/api/src/postgres/run-query-repository.ts b/packages/api/src/postgres/run-query-repository.ts index e5f0f6d1..34c6bffb 100644 --- a/packages/api/src/postgres/run-query-repository.ts +++ b/packages/api/src/postgres/run-query-repository.ts @@ -79,6 +79,20 @@ export class RunQueryRepository { if (!row) return null; return { ...row, created_at: row.created_at.toISOString(), updated_at: row.updated_at.toISOString(), started_at: row.started_at?.toISOString() ?? null }; } + async rename(runId: string, name: string, expectedName?: string): Promise<"missing" | "stale" | { run_name: string }> { + return this.database.transaction(async client => { + // Lifecycle writers lock job_runs before runs, so use the same order to + // avoid deadlocks while renames race state transitions. + const jobRun = (await client.query("SELECT id FROM app.job_runs WHERE tenant_id=$1 AND id=$2 FOR UPDATE", [this.tenantId, runId])).rows[0]; + if (!jobRun) return "missing"; + const current = (await client.query<{ run_name: string }>("SELECT run_name FROM app.runs WHERE tenant_id=$1 AND id=$2 FOR UPDATE", [this.tenantId, runId])).rows[0]; + if (!current) return "missing"; + if (expectedName !== undefined && current.run_name !== expectedName) return "stale"; + await client.query("UPDATE app.runs SET run_name=$3 WHERE tenant_id=$1 AND id=$2", [this.tenantId, runId, name]); + await client.query("UPDATE app.job_runs SET run_name=$3 WHERE tenant_id=$1 AND id=$2", [this.tenantId, runId, name]); + return { run_name: name }; + }); + } async activity(runId: string) { return (await this.database.pool.query("SELECT action,detail,created_at FROM app.run_activity WHERE tenant_id=$1 AND run_id=$2 ORDER BY created_at,id", [this.tenantId, runId])).rows; } async stop(runId: string) { return this.database.transaction(async client => { diff --git a/packages/api/src/protected-api.ts b/packages/api/src/protected-api.ts index 0d8b2cad..7ce3cb34 100644 --- a/packages/api/src/protected-api.ts +++ b/packages/api/src/protected-api.ts @@ -7,7 +7,7 @@ import { createMcpHandler, McpServer } from "@modelcontextprotocol/server"; import { createMcpHonoApp } from "@modelcontextprotocol/hono"; import { z } from "zod"; import { ApiService, ServiceError } from "./flow-service"; -import { jobConditionsTestSchema, jobIdSchema, jobInputSchema, listRunsSchema, manualInvocationSchema, runIdSchema } from "./flow-schemas"; +import { jobConditionsTestSchema, jobIdSchema, jobInputSchema, listRunsSchema, manualInvocationSchema, renameRunSchema, runIdSchema } from "./flow-schemas"; import type { Env, OAuthProps } from "./types"; import { executeAuth, validBrowserOrigin } from "./auth-api"; import { matchApiOperation } from "./api-contract"; @@ -109,6 +109,7 @@ function createMcpServer(service: ApiService): McpServer { tool("list_github_installations", "List connected GitHub App installations, including the installation IDs needed by GitHub Job triggers. Credentials are never returned.", z.object({}), () => service.listGitHubInstallations()); tool("list_runs", "List authoritative job runs by job ID, state, an optional case-insensitive literal substring of triggered context, and validated sort field/direction. Sorting is applied before cursor pagination with deterministic ID tie-breaking. Automatic webhook, schedule, and lifecycle signals coalesce, so query runs and the provider API for authoritative work. Context matches include only a bounded excerpt.", listRunsSchema, (input: any) => service.listRuns(queryOf(input))); tool("get_run", "Get a run with its rendered prompt, full triggered context, invocation metadata, execution metadata, output, and activity. For manual invocations, invocation.idempotency_key is the client-reusable value; invocation.claim_key is internal.", runIdSchema, ({ runId }: any) => service.getRun(runId)); + tool("rename_run", "Rename a run without changing its execution. Supply expectedName to reject stale edits; names are trimmed and must contain 1 to 120 characters.", runIdSchema.extend(renameRunSchema.shape), ({ runId, ...input }: any) => service.renameRun(runId, input)); tool("get_run_diagnostics", "Get credential-safe launch, prompt delivery, output capture, claim release, cleanup, and activity diagnostics for a run.", runIdSchema, ({ runId }: any) => service.getRunDiagnostics(runId)); tool("stop_run", "Stop an active run", runIdSchema, ({ runId }: any) => service.stopRun(runId)); tool("kill_run", "Force a run in any non-terminal state to stop immediately, release its concurrency slot, and clean up its backend asynchronously.", runIdSchema, ({ runId }: any) => service.killRun(runId)); diff --git a/packages/api/test/api-contract.test.ts b/packages/api/test/api-contract.test.ts index 02c2372d..13c59116 100644 --- a/packages/api/test/api-contract.test.ts +++ b/packages/api/test/api-contract.test.ts @@ -71,7 +71,7 @@ describe("versioned API contract", () => { for (const route of API_ROUTES) { const path = route.path.replace(/\{[^}]+\}/g, "42"); expect(matchApiOperation(route.method, path)?.route).toBe(route); - expect(isDeclaredApiOperation("PATCH", path)).toBe(false); + expect(isDeclaredApiOperation("PATCH", path)).toBe(route.path === "/api/v1/runs/{runId}"); expect( isDeclaredApiOperation(route.method, path + "/extra/extra/extra"), ).toBe(false); diff --git a/packages/api/test/rename-run-boundary.test.ts b/packages/api/test/rename-run-boundary.test.ts new file mode 100644 index 00000000..74f85476 --- /dev/null +++ b/packages/api/test/rename-run-boundary.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ApiService, ServiceError } from "../src/flow-service"; +import { protectedApiFetch } from "../src/protected-api"; +import { RunQueryRepository } from "../src/postgres/run-query-repository"; + +vi.mock("cloudflare:workers", () => ({ WorkerEntrypoint: class {} })); +afterEach(() => vi.restoreAllMocks()); + +async function call(transport: "REST" | "MCP", input: Record, scopes = ["runs:write"]) { + const request = new Request(`https://example.com${transport === "REST" ? "/api/v1/runs/00000000-0000-4000-8000-000000000001" : "/mcp"}`, { + method: transport === "REST" ? "PATCH" : "POST", + headers: { Host: "example.com", "Content-Type": "application/json", Accept: "application/json, text/event-stream" }, + body: JSON.stringify(transport === "REST" ? input : { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "rename_run", arguments: { runId: "00000000-0000-4000-8000-000000000001", ...input } } }), + }); + return protectedApiFetch(request, { APP_ORIGIN: "https://example.com", DATABASE: {} } as any, { tenantId: "tenant-a", userId: "owner", authMethod: "bearer", scopes } as any, {} as any); +} + +describe.each(["REST", "MCP"] as const)("%s run rename boundary", transport => { + it("trims and persists valid names, and rejects invalid names", async () => { + vi.spyOn(ApiService.prototype as any, "authorize").mockResolvedValue(undefined); + const rename = vi.spyOn(RunQueryRepository.prototype, "rename").mockResolvedValue({ run_name: "Renamed" }); + const ok = await call(transport, { name: " Renamed ", expectedName: "Original" }); + if (transport === "REST") { + expect(ok.status).toBe(200); expect(await ok.json()).toEqual({ run_name: "Renamed" }); + } else { + expect(ok.status).toBe(200); expect((await ok.text())).toContain('"run_name":"Renamed"'); + } + expect(rename).toHaveBeenCalledWith("00000000-0000-4000-8000-000000000001", "Renamed", "Original"); + for (const name of [" ", "x".repeat(121)]) { + const response = await call(transport, { name }); + if (transport === "REST") expect(response.status).toBe(400); + else expect((await response.text())).toContain("Input validation error"); + } + }); + + it("requires runs:write and returns stale and missing outcomes", async () => { + vi.spyOn(ApiService.prototype as any, "authorize").mockImplementation(function(this: any, ...args: any[]) { const scope = args[0] as string; if (!this.auth.scopes.includes(scope)) throw new ServiceError(403, "insufficient_scope", "Missing scope"); }); + vi.spyOn(RunQueryRepository.prototype, "rename").mockImplementation(async (_id, _name, expected) => expected === "stale" ? "stale" : "missing"); + const unauthorized = await call(transport, { name: "Name" }, ["runs:read"]); + if (transport === "REST") expect(unauthorized.status).toBe(403); + else expect((await unauthorized.text())).toContain("insufficient_scope"); + const stale = await call(transport, { name: "Name", expectedName: "stale" }); + if (transport === "REST") expect(stale.status).toBe(409); + else expect((await stale.text())).toContain("stale_run_name"); + const missing = await call(transport, { name: "Name" }); + if (transport === "REST") expect(missing.status).toBe(404); + else expect((await missing.text())).toContain("not_found"); + }); +}); diff --git a/packages/api/test/rename-run.postgres.test.ts b/packages/api/test/rename-run.postgres.test.ts new file mode 100644 index 00000000..9580fdbd --- /dev/null +++ b/packages/api/test/rename-run.postgres.test.ts @@ -0,0 +1,51 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { loadingFixture } from "./helpers/loading-fixture"; +import { ApiService } from "../src/flow-service"; +import { RunQueryRepository } from "../src/postgres/run-query-repository"; +import { RunRepository } from "../src/postgres/run-repository"; + +vi.mock("@cloudflare/workers-oauth-provider", () => ({ OAuthProvider: class {}, AuthorizationError: class extends Error {}, getOAuthApi: vi.fn() })); +vi.mock("cloudflare:workers", () => ({ WorkerEntrypoint: class {}, DurableObject: class {} })); + +const url = process.env.AUTH_TEST_DATABASE_URL; +describe.skipIf(!url)("run rename persistence (PostgreSQL)", () => { + let f: Awaited>; + beforeAll(async () => { f = await loadingFixture(url!); }); + afterAll(async () => f?.cleanup()); + + it("updates both name copies without changing execution data or elapsed-time timestamps", async () => { + const before = (await f.db.pool.query("SELECT r.run_name,r.state,r.created_at,r.updated_at,jr.run_name job_run_name,jr.state job_run_state,jr.created_at job_created_at,jr.updated_at job_updated_at,jr.started_at,jr.encrypted_prompt FROM app.runs r JOIN app.job_runs jr USING (tenant_id,id) WHERE r.tenant_id=$1 AND r.id=$2", [f.tenantId, f.runId])).rows[0]; + const service = new ApiService(f.env, { ...f.auth, scopes: ["runs:write"] }); + expect(await service.renameRun(f.runId, { name: " Persisted ", expectedName: before.run_name })).toEqual({ run_name: "Persisted" }); + const after = (await f.db.pool.query("SELECT r.run_name,r.state,r.created_at,r.updated_at,jr.run_name job_run_name,jr.state job_run_state,jr.created_at job_created_at,jr.updated_at job_updated_at,jr.started_at,jr.encrypted_prompt FROM app.runs r JOIN app.job_runs jr USING (tenant_id,id) WHERE r.tenant_id=$1 AND r.id=$2", [f.tenantId, f.runId])).rows[0]; + expect(after).toEqual({ ...before, run_name: "Persisted", job_run_name: "Persisted" }); + const concurrent = await Promise.all([ + new RunQueryRepository(f.db, f.tenantId).rename(f.runId, "Concurrent A", "Persisted"), + new RunQueryRepository(f.db, f.tenantId).rename(f.runId, "Concurrent B", "Persisted"), + ]); + expect(concurrent.filter(result => result === "stale")).toHaveLength(1); + const winner = concurrent.find(result => typeof result === "object"); + expect(winner).toBeDefined(); + await new RunQueryRepository(f.db, f.tenantId).rename(f.runId, "Persisted", winner!.run_name); + await expect(service.renameRun(f.runId, { name: "Stale", expectedName: before.run_name })).rejects.toMatchObject({ status: 409, code: "stale_run_name" }); + await expect(service.renameRun(crypto.randomUUID(), { name: "Missing" })).rejects.toMatchObject({ status: 404, code: "not_found" }); + expect(await new RunQueryRepository(f.db, f.otherTenant).rename(f.runId, "Cross tenant")).toBe("missing"); + }); + + it("keeps the renamed value through polling, completion, and finalization races", async () => { + const run: any = { tenantId: f.tenantId, id: f.runId, jobId: (await f.db.pool.query("SELECT job_id FROM app.job_runs WHERE tenant_id=$1 AND id=$2", [f.tenantId, f.runId])).rows[0].job_id, invocationId: "", state: "running", encryptedPrompt: "", executionConfig: {}, executionHandle: null, backendKind: "", capabilities: [], artifactState: "pending", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; + await f.db.pool.query("UPDATE app.job_runs SET state='starting' WHERE tenant_id=$1 AND id=$2", [f.tenantId, f.runId]); + await f.db.pool.query("UPDATE app.runs SET state='starting' WHERE tenant_id=$1 AND id=$2", [f.tenantId, f.runId]); + const repository = new RunRepository(f.db), renames = new RunQueryRepository(f.db, f.tenantId); + await repository.markLaunched(run, { backendKind: "exe-vm", id: "fixture-handle" }, "https://example.test/run", []); + await Promise.all([ + renames.rename(f.runId, "Lifecycle safe", "Persisted"), + repository.schedulePoll(run, "blocked", 3), + ]); + expect((await f.db.pool.query("SELECT r.run_name,jr.run_name job_run_name,jr.state FROM app.runs r JOIN app.job_runs jr USING (tenant_id,id) WHERE r.tenant_id=$1 AND r.id=$2", [f.tenantId, f.runId])).rows[0]).toMatchObject({ run_name: "Lifecycle safe", job_run_name: "Lifecycle safe", state: "blocked" }); + await repository.retryFinalization({ ...run, state: "running" }, "partial", "transient finalization failure"); + await repository.terminal({ ...run, state: "running" }, "succeeded", "stored", undefined, true); + expect((await new ApiService(f.env, f.auth).getRunStatus(f.runId)).run_name).toBe("Lifecycle safe"); + expect((await new ApiService(f.env, f.auth).getRun(f.runId)).run_name).toBe("Lifecycle safe"); + }); +}); diff --git a/packages/api/test/scope-redesign.test.ts b/packages/api/test/scope-redesign.test.ts index 2d760b9c..c1c3c6f9 100644 --- a/packages/api/test/scope-redesign.test.ts +++ b/packages/api/test/scope-redesign.test.ts @@ -5,8 +5,8 @@ import { API_ROUTES } from "../src/api-contract"; describe("GEN-2418 permission boundaries", () => { it("exposes independently issuable skill scopes and maps legacy grants without issuing them", () => { - expect(ACCESS_SCOPES).toEqual(["projects:read", "projects:write", "jobs:read", "jobs:write", "runs:read", "runs:invoke", "runs:cancel", "integrations:read", "integrations:write", "skills:read", "skills:write"]); - expect(normalizeScopes(["flows:read", "runs:write"])).toEqual(["projects:read", "jobs:read", "integrations:read", "runs:read", "runs:invoke", "runs:cancel"]); + expect(ACCESS_SCOPES).toEqual(["projects:read", "projects:write", "jobs:read", "jobs:write", "runs:read", "runs:invoke", "runs:cancel", "runs:write", "integrations:read", "integrations:write", "skills:read", "skills:write"]); + expect(normalizeScopes(["flows:read", "runs:write"])).toEqual(["projects:read", "jobs:read", "integrations:read", "runs:read", "runs:invoke", "runs:cancel", "runs:write"]); expect(ACCESS_SCOPES).not.toContain("flows:read" as never); }); diff --git a/packages/app/src/features/runs/detail.tsx b/packages/app/src/features/runs/detail.tsx index f1075fa2..5c1b93d4 100644 --- a/packages/app/src/features/runs/detail.tsx +++ b/packages/app/src/features/runs/detail.tsx @@ -15,6 +15,7 @@ export function RunDetail() { const tabPath = (name: "trace" | "info"): "/job-runs/$runId" | "/job-runs/$runId/info" => name === "trace" ? "/job-runs/$runId" : "/job-runs/$runId/info"; const status = useQuery(runStatusQuery(runId!)); const [revision, setRevision] = useState(""); + const [renaming, setRenaming] = useState(false), [nameDraft, setNameDraft] = useState(""), [nameExpected, setNameExpected] = useState(""), [nameError, setNameError] = useState(""); const [now, setNow] = useState(() => Date.now()); const active = !status.data || !["succeeded", "failed", "stopped"].includes(status.data.state) || status.data.finalizing; useEffect(() => { @@ -40,11 +41,24 @@ export function RunDetail() { const { error } = await api.POST("/api/v1/runs/{runId}/stop", { params: { path: { runId: runId! } } }); if (error) throw new Error(messageOf(error)); }, onSuccess: () => cache.invalidateQueries({ queryKey: ["runs", runId] }) }); + const rename = useMutation({ retry: false, mutationFn: async () => { + const name = nameDraft.trim(); + if (!name || name.length > 120) throw new Error("Run name must be between 1 and 120 characters."); + const { data, error } = await api.PATCH("/api/v1/runs/{runId}", { params: { path: { runId: runId! } }, body: { name, expectedName: nameExpected } }); + if (!data || error) throw new Error(messageOf(error)); + return data.run_name; + }, onSuccess: async savedName => { + cache.setQueryData(runStatusQuery(runId!).queryKey, previous => previous ? { ...previous, run_name: savedName } : previous); + setNameDraft(savedName); setNameError(""); setRenaming(false); + await Promise.all([cache.invalidateQueries({ queryKey: ["runs"] }), cache.invalidateQueries({ queryKey: ["search"] })]); + }, onError: error => setNameError(error.message) }); + const startRename = () => { const currentName = status.data?.run_name ?? ""; setNameExpected(currentName); setNameDraft(currentName); setNameError(""); rename.reset(); setRenaming(true); }; + const cancelRename = () => { setRenaming(false); setNameError(""); rename.reset(); }; return {status.isPending &&

Loading run…

}{status.error &&

{status.error.message}

} {status.data ? {status.data.job_name} : Projects} {status.data && <> - {status.data.state}{status.data.finalizing ? " · Finalizing trace and artifacts" : ""}

}>{["starting", "running", "blocked", "stopping"].includes(status.data.state) && }
+ { event.preventDefault(); setNameError(""); rename.mutate(); }}> { setNameDraft(event.target.value); setNameError(""); }} onKeyDown={event => { if (event.key === "Escape") { event.preventDefault(); cancelRename(); } }} />{nameError && {nameError}} : } description={

{status.data.state}{status.data.finalizing ? " · Finalizing trace and artifacts" : ""}

}>{["starting", "running", "blocked", "stopping"].includes(status.data.state) && }
} {!status.data && } {stop.error &&

{stop.error.message}

} diff --git a/packages/app/src/features/settings/api-key-create.tsx b/packages/app/src/features/settings/api-key-create.tsx index a9f3f592..a983e022 100644 --- a/packages/app/src/features/settings/api-key-create.tsx +++ b/packages/app/src/features/settings/api-key-create.tsx @@ -16,6 +16,7 @@ const scopeDescriptions: Record = { "runs:read": "View runs, traces, context, and diagnostics", "runs:invoke": "Start jobs", "runs:cancel": "Stop or cancel runs", + "runs:write": "Rename runs (includes legacy run access)", "integrations:read": "View integrations and provider resources", "integrations:write": "Create, configure, test, and remove integrations", "skills:read": "View shared skills and the jobs using them", diff --git a/packages/app/src/shared/ui.tsx b/packages/app/src/shared/ui.tsx index e9b8d4ca..9836b8cb 100644 --- a/packages/app/src/shared/ui.tsx +++ b/packages/app/src/shared/ui.tsx @@ -76,11 +76,11 @@ export function Select({ className = "", ...props }: ComponentProps<"select">) { export function Label({ className = "", ...props }: ComponentProps<"label">) { return