}
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 ; }
export function Page({ className = "", ...props }: ComponentProps<"main">) { return ; }
export function Breadcrumb({ children }: { children?: ReactNode }) { return ; }
-export function ResourceHeader({ type, title, description, children }: { type: "Project" | "Job" | "Run"; title: string; description?: ReactNode; children?: ReactNode }) {
+export function ResourceHeader({ type, title, titleActions, description, children }: { type: "Project" | "Job" | "Run"; title: string; titleActions?: ReactNode; description?: ReactNode; children?: ReactNode }) {
return
{type}
-
{title}
+
{title}
{titleActions}
{children}
{description &&
{description}
}
diff --git a/packages/app/test/runs.browser.test.mjs b/packages/app/test/runs.browser.test.mjs
index a20f0413..a21bb74f 100644
--- a/packages/app/test/runs.browser.test.mjs
+++ b/packages/app/test/runs.browser.test.mjs
@@ -121,6 +121,19 @@ test("stopping a run submits once, observes stopping state and keeps terminal fi
await page.getByText("stopped · Finalizing trace and artifacts", { exact: true }).waitFor(); assert.equal(await page.getByRole("button", { name: "Stop run", exact: true }).count(), 0);
await context.close();
});
+test("run rename supports keyboard save and cancel, validates names, and preserves drafts during polling", async () => {
+ const { context, page } = await setup(); let name = "Original run", saves = [];
+ await page.route(`**/api/v1/runs/${runId}/status`, route => route.fulfill({ json: { id: runId, job_id: jobId, job_name: "Job", run_name: name, state: "running", finalizing: false, trace_revision: "rev", artifact_state: "pending", created_at: time, updated_at: time, started_at: time, destination_url: null } }));
+ await page.route(`**/api/v1/runs/${runId}/trace-pages?**`, route => route.fulfill({ json: { items: [], nextCursor: null, revision: "rev", reset: false } }));
+ await page.route(`**/api/v1/runs/${runId}`, route => route.request().method() === "PATCH" ? (saves.push(route.request().postDataJSON()), name = saves.at(-1).name, route.fulfill({ json: { run_name: name } })) : route.fulfill({ json: {} }));
+ await page.goto(origin + "/job-runs/" + runId); await page.getByRole("heading", { name: "Original run", exact: true }).waitFor();
+ await page.getByRole("button", { name: "Rename", exact: true }).click(); const input = page.getByRole("textbox", { name: "Run name" }); await input.fill("Draft survives polling"); await page.waitForTimeout(2200); assert.equal(await input.inputValue(), "Draft survives polling");
+ await input.fill(" "); await page.keyboard.press("Enter"); await page.getByRole("alert").filter({ hasText: "between 1 and 120" }).waitFor(); assert.equal(saves.length, 0);
+ await page.getByRole("button", { name: "Cancel", exact: true }).click(); await page.getByRole("heading", { name: "Original run", exact: true }).waitFor();
+ await page.getByRole("button", { name: "Rename", exact: true }).click(); await page.getByRole("textbox", { name: "Run name" }).fill("Escape cancel"); await page.keyboard.press("Escape"); await page.getByRole("heading", { name: "Original run", exact: true }).waitFor();
+ await page.getByRole("button", { name: "Rename", exact: true }).click(); await page.getByRole("textbox", { name: "Run name" }).fill("Saved name"); await page.keyboard.press("Enter"); await page.getByRole("heading", { name: "Saved name", exact: true }).waitFor(); assert.deepEqual(saves, [{ name: "Saved name", expectedName: "Original run" }]);
+ await page.reload(); await page.getByRole("heading", { name: "Saved name", exact: true }).waitFor(); await context.close();
+});
test("run trace uses pull-to-load history, preserves the anchor, and never crashes on disclosure toggles", async () => {
const { context, page } = await setup(); const requests = [];
const event = (sequence, type = "assistant_message") => ({ id: `trace-${sequence}`, sequence, type, title: `Trace ${sequence}`, preview: `Trace ${sequence}`, display: {} });
diff --git a/packages/docs/api/authentication.mdx b/packages/docs/api/authentication.mdx
index f4305685..63b3cc26 100644
--- a/packages/docs/api/authentication.mdx
+++ b/packages/docs/api/authentication.mdx
@@ -30,12 +30,13 @@ An expired or completed request cannot be extended by resending email or recover
| `runs:read` | Inspect runs, context, traces, and diagnostics |
| `runs:invoke` | Invoke jobs |
| `runs:cancel` | Stop or cancel runs, including `kill` |
+| `runs:write` | Rename runs; legacy grants also retain invocation and cancellation access |
| `integrations:read` | View integration metadata and provider resources |
| `integrations:write` | Create, configure, test, and remove integrations |
Scopes are independent: write does not imply read. Job configuration can cause future execution through schedules, webhooks, and lifecycle triggers; deleting a job can remove its execution history and artifacts. Key and OAuth grant administration and trace replay always require an interactive owner session.
-Legacy `flows:read`, `flows:write`, and `runs:write` grants remain usable during migration and are normalized at authentication (`flows:read` → all three read domains, `flows:write` → all three write domains, and `runs:write` → `runs:invoke` plus `runs:cancel`). New API keys, OAuth consent, and discovery expose only the nine scopes. Existing clients should reauthorize before legacy support is removed; no new legacy grant is issued.
+Legacy `flows:read` and `flows:write` grants remain usable during migration and are normalized at authentication (`flows:read` → all three read domains, `flows:write` → all three write domains). `runs:write` is now an available scope for run renaming; grants using that name also retain invocation and cancellation access. New API keys, OAuth consent, and discovery expose 12 scopes. No new legacy `flows:*` grant is issued.
Authorization-code clients must use PKCE S256. Implicit and plain PKCE are disabled. Access tokens last 1 hour; refresh tokens can last 30 days. RFC 7009 revocation is advertised by discovery.
diff --git a/packages/docs/llms-full.txt b/packages/docs/llms-full.txt
index a0798289..d5d01118 100644
--- a/packages/docs/llms-full.txt
+++ b/packages/docs/llms-full.txt
@@ -61,7 +61,7 @@ Start Claude Code, enter `/mcp`, choose Factorize and authenticate when prompted
The client opens Factorize authorization in your browser. Sign in with your verified Factorize email/password and approve the requested scopes. For the full workflow, grant `flows:read`, `flows:write`, `runs:read`, and `runs:write`. Read-only access cannot create or invoke jobs. Return to your client after consent.
-Consent lists nine independent permissions: `projects:read`, `projects:write`, `jobs:read`, `jobs:write`, `runs:read`, `runs:invoke`, `runs:cancel`, `integrations:read`, and `integrations:write`. Write does not imply read. If you need to create an account or verify your email, finish those steps and restart client authorization; do not assume the signup screen will automatically resume the original request. No Linear or execution token belongs in the MCP client.
+Consent lists 12 permissions, including `runs:write` for renaming runs. Run write grants also retain invocation and cancellation access for compatibility. Write does not imply read. If you need to create an account or verify your email, finish those steps and restart client authorization; do not assume the signup screen will automatically resume the original request. No Linear or execution token belongs in the MCP client.
Ask **“List my Factorize jobs.”** The agent should call `list_jobs({})`. An empty `items` list is a successful connection to a workspace with no jobs. Next ask it to list execution integrations and follow the [first-job walkthrough](https://docs.factorize.sh/quickstart).
@@ -356,7 +356,7 @@ Source: https://docs.factorize.sh/troubleshooting/connection
| Browser consent fails or is cancelled | Sign in to the correct verified Factorize owner account, restart authorization from the client, and approve the requested scopes. Finish signup/email verification separately before restarting. |
| Client never connects | Confirm the exact URL `https://app.factorize.sh/mcp`, remote Streamable HTTP and OAuth support. Open the client's MCP status view and authenticate; use [client-specific instructions](https://docs.factorize.sh/mcp/overview). |
| `invalid_token` or access disappears | Reauthorize the client. Password resets/changes and sign-out invalidate delegated credentials. An expired access token needs client refresh or renewed login. |
-| `insufficient_scope` | Reconnect and grant `flows:read` for discovery, `flows:write` for job edits/diagnosis, `runs:read` for inspection and `runs:write` for invocation/stop. |
+| `insufficient_scope` | Reconnect and grant `flows:read` for discovery, `flows:write` for job edits/diagnosis, `runs:read` for inspection and `runs:write` for renaming, invocation, or stop. |
| `list_execution_configs` returns no items | In browser Settings, test and save an exe.dev or Amp integration in the same workspace. MCP cannot create credential-bearing connections. |
| Target exists but repository/model access fails | Test the saved execution integration and check execution-account repository/model access and VM tags. For exe.dev, `diagnose_exe_integration` uses the saved connection ID and creates/deletes a temporary VM. |
@@ -401,7 +401,7 @@ Connect to `https://app.factorize.sh/mcp` using [client authorization](https://d
## Inventory
-The required scope inventory below is authoritative. Legacy `flows:*` and `runs:write` grants are accepted only during migration and are mapped to the nine scopes at authentication.
+The required scope inventory below is authoritative. Legacy `flows:*` and `runs:write` grants expand at authentication; `runs:write` also authorizes renaming runs.
| Tool | Required scope | Input contract |
| --- | --- | --- |
@@ -424,6 +424,7 @@ The required scope inventory below is authoritative. Legacy `flows:*` and `runs:
| `move_job` | projects:write | Required `jobId` and destination `projectId`; optional `expectedUpdatedAt` |
| `list_runs` | runs:read | Optional `jobId`, `state`, `contextQuery`, `limit`, `cursor` |
| `get_run` | runs:read | Required string `runId` |
+| `rename_run` | runs:write | Required `runId` and `name`; optional `expectedName` |
| `get_run_diagnostics` | runs:read | Required string `runId` |
| `invoke_job` | runs:invoke | Required string `jobId`; optional `name`, `prompt`, `data`, `idempotencyKey` |
| `stop_run` | runs:cancel | Required string `runId` |
@@ -540,6 +541,8 @@ Allowed state filters: `queued`, `starting`, `running`, `done`, `blocked`, `fail
Retain the submitted key for retries. Current `get_run` returns only internal `invocation.claim_key`, not `invocation.idempotency_key`; never pass that internal field back. `get_run_diagnostics({"runId":"RUN_ID"})` returns a diagnostic object with `runId`, `jobId`, `state`, `execution`, `trace`, `artifact`, `harnessLog`, `activity` and timestamps. These cover launch/prompt/output/cleanup and trace evidence, not credentials. Missing runs return `not_found`.
+`rename_run({"runId":"RUN_ID","name":"Release review","expectedName":"First review"})` → `{"run_name":"Release review"}`. Names are trimmed and must contain 1–120 characters. `expectedName` is optional; when supplied, a changed current name returns `stale_run_name` so the caller can reload and reconcile. Missing or cross-tenant runs return `not_found`.
+
`stop_run({"runId":"RUN_ID"})` → `{"id":"RUN_ID","state":"stopping"}`. Current `kill_run({"runId":"RUN_ID"})` returns the same result and uses the same operation. Both accept starting/running/blocked runs; queued or terminal runs return `operation_failed`. Neither guarantees immediate termination/slot release. Poll `get_run` and inspect cleanup. See [stop/retry](https://docs.factorize.sh/work/stop-retry).
## Webhook investigation and conditions
@@ -629,12 +632,13 @@ An expired or completed request cannot be extended by resending email or recover
| `runs:read` | Inspect runs, context, traces, and diagnostics |
| `runs:invoke` | Invoke jobs |
| `runs:cancel` | Stop or cancel runs, including `kill` |
+| `runs:write` | Rename runs; legacy grants also retain invocation and cancellation access |
| `integrations:read` | View integration metadata and provider resources |
| `integrations:write` | Create, configure, test, and remove integrations |
Scopes are independent: write does not imply read. Job configuration can cause future execution through schedules, webhooks, and lifecycle triggers; deleting a job can remove its execution history and artifacts. Key and OAuth grant administration and trace replay always require an interactive owner session.
-Legacy `flows:read`, `flows:write`, and `runs:write` grants remain usable during migration and are normalized at authentication (`flows:read` → all three read domains, `flows:write` → all three write domains, and `runs:write` → `runs:invoke` plus `runs:cancel`). New API keys, OAuth consent, and discovery expose only the nine scopes. Existing clients should reauthorize before legacy support is removed; no new legacy grant is issued.
+Legacy `flows:read` and `flows:write` grants remain usable during migration and are normalized at authentication (`flows:read` → all three read domains, `flows:write` → all three write domains). `runs:write` is now an available scope for run renaming; grants using that name also retain invocation and cancellation access. New API keys, OAuth consent, and discovery expose 12 scopes. No new legacy `flows:*` grant is issued.
Authorization-code clients must use PKCE S256. Implicit and plain PKCE are disabled. Access tokens last 1 hour; refresh tokens can last 30 days. RFC 7009 revocation is advertised by discovery.
diff --git a/packages/docs/mcp/overview.mdx b/packages/docs/mcp/overview.mdx
index 28b70588..bc484f17 100644
--- a/packages/docs/mcp/overview.mdx
+++ b/packages/docs/mcp/overview.mdx
@@ -36,7 +36,7 @@ Start Claude Code, enter `/mcp`, choose Factorize and authenticate when prompted
The client opens Factorize authorization in your browser. Sign in with your verified Factorize email/password and approve the requested scopes. For the full workflow, grant `flows:read`, `flows:write`, `runs:read`, and `runs:write`. Read-only access cannot create or invoke jobs. Return to your client after consent.
-Consent lists nine independent permissions: `projects:read`, `projects:write`, `jobs:read`, `jobs:write`, `runs:read`, `runs:invoke`, `runs:cancel`, `integrations:read`, and `integrations:write`. Write does not imply read. If you need to create an account or verify your email, finish those steps and restart client authorization; do not assume the signup screen will automatically resume the original request. No Linear or execution token belongs in the MCP client.
+Consent lists 12 permissions, including `runs:write` for renaming runs. Run write grants also retain invocation and cancellation access for compatibility. Write does not imply read. If you need to create an account or verify your email, finish those steps and restart client authorization; do not assume the signup screen will automatically resume the original request. No Linear or execution token belongs in the MCP client.
Ask **“List my Factorize jobs.”** The agent should call `list_jobs({})`. An empty `items` list is a successful connection to a workspace with no jobs. Next ask it to list execution integrations and follow the [first-job walkthrough](/quickstart).
diff --git a/packages/docs/mcp/tools.mdx b/packages/docs/mcp/tools.mdx
index 2ba8dcbb..6f93b670 100644
--- a/packages/docs/mcp/tools.mdx
+++ b/packages/docs/mcp/tools.mdx
@@ -7,7 +7,7 @@ Connect to `https://app.factorize.sh/mcp` using [client authorization](/mcp/over
## Inventory
-The required scope inventory below is authoritative. Legacy `flows:*` and `runs:write` grants are accepted only during migration and are mapped to the nine scopes at authentication.
+The required scope inventory below is authoritative. Legacy `flows:*` and `runs:write` grants expand at authentication; `runs:write` also authorizes renaming runs.
| Tool | Required scope | Input contract |
| --- | --- | --- |
@@ -30,6 +30,7 @@ The required scope inventory below is authoritative. Legacy `flows:*` and `runs:
| `move_job` | projects:write | Required `jobId` and destination `projectId`; optional `expectedUpdatedAt` |
| `list_runs` | runs:read | Optional `jobId`, `state`, `contextQuery`, `limit`, `cursor` |
| `get_run` | runs:read | Required string `runId` |
+| `rename_run` | runs:write | Required `runId` and `name`; optional `expectedName` |
| `get_run_diagnostics` | runs:read | Required string `runId` |
| `invoke_job` | runs:invoke | Required string `jobId`; optional `name`, `prompt`, `data`, `idempotencyKey` |
| `stop_run` | runs:cancel | Required string `runId` |
@@ -146,6 +147,8 @@ Allowed state filters: `queued`, `starting`, `running`, `done`, `blocked`, `fail
Retain the submitted key for retries. Current `get_run` returns only internal `invocation.claim_key`, not `invocation.idempotency_key`; never pass that internal field back. `get_run_diagnostics({"runId":"RUN_ID"})` returns a diagnostic object with `runId`, `jobId`, `state`, `execution`, `trace`, `artifact`, `harnessLog`, `activity` and timestamps. These cover launch/prompt/output/cleanup and trace evidence, not credentials. Missing runs return `not_found`.
+`rename_run({"runId":"RUN_ID","name":"Release review","expectedName":"First review"})` → `{"run_name":"Release review"}`. Names are trimmed and must contain 1–120 characters. `expectedName` is optional; when supplied, a changed current name returns `stale_run_name` so the caller can reload and reconcile. Missing or cross-tenant runs return `not_found`.
+
`stop_run({"runId":"RUN_ID"})` → `{"id":"RUN_ID","state":"stopping"}`. Current `kill_run({"runId":"RUN_ID"})` returns the same result and uses the same operation. Both accept starting/running/blocked runs; queued or terminal runs return `operation_failed`. Neither guarantees immediate termination/slot release. Poll `get_run` and inspect cleanup. See [stop/retry](/work/stop-retry).
## Webhook investigation and conditions
diff --git a/packages/docs/openapi.yaml b/packages/docs/openapi.yaml
index 61834c3f..0679c2ab 100644
--- a/packages/docs/openapi.yaml
+++ b/packages/docs/openapi.yaml
@@ -1498,6 +1498,7 @@ paths:
- runs:read
- runs:invoke
- runs:cancel
+ - runs:write
- integrations:read
- integrations:write
- skills:read
@@ -3855,6 +3856,7 @@ paths:
- runs:read
- runs:invoke
- runs:cancel
+ - runs:write
- integrations:read
- integrations:write
- skills:read
@@ -3932,6 +3934,7 @@ paths:
- runs:read
- runs:invoke
- runs:cancel
+ - runs:write
- integrations:read
- integrations:write
- skills:read
@@ -4013,6 +4016,7 @@ paths:
- runs:read
- runs:invoke
- runs:cancel
+ - runs:write
- integrations:read
- integrations:write
- skills:read
@@ -4021,7 +4025,6 @@ paths:
enum:
- flows:read
- flows:write
- - runs:write
expiryDays:
anyOf:
- type: number
@@ -10208,6 +10211,80 @@ paths:
schema:
type: string
minLength: 1
+ patch:
+ summary: 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.
+ responses:
+ "200":
+ description: Saved run display name
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - run_name
+ properties:
+ run_name:
+ type: string
+ additionalProperties: false
+ "400":
+ $ref: "#/components/responses/Error400"
+ "401":
+ $ref: "#/components/responses/Error401"
+ "403":
+ $ref: "#/components/responses/Error403"
+ "404":
+ $ref: "#/components/responses/Error404"
+ description: Run not found within the tenant
+ "409":
+ $ref: "#/components/responses/Error409"
+ description: "stale_run_name: reload before retrying"
+ "415":
+ $ref: "#/components/responses/Error415"
+ "429":
+ $ref: "#/components/responses/Error429"
+ "500":
+ $ref: "#/components/responses/Error500"
+ "503":
+ $ref: "#/components/responses/Error503"
+ operationId: patch_api_v1_runs_runId
+ tags:
+ - runs
+ security:
+ - bearerAuth: []
+ - cookieAuth: []
+ x-required-scope: runs:write
+ x-owner-session-required: false
+ parameters:
+ - name: runId
+ in: path
+ required: true
+ schema:
+ type: string
+ format: uuid
+ pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ name:
+ type: string
+ minLength: 1
+ maxLength: 120
+ expectedName:
+ type: string
+ maxLength: 120
+ required:
+ - name
+ additionalProperties: false
/api/v1/runs/{runId}/trace:
get:
summary: Get paginated run trace events
diff --git a/packages/docs/troubleshooting/connection.mdx b/packages/docs/troubleshooting/connection.mdx
index 751458bc..580f337a 100644
--- a/packages/docs/troubleshooting/connection.mdx
+++ b/packages/docs/troubleshooting/connection.mdx
@@ -8,7 +8,7 @@ description: Recover authorization, scopes, and execution integrations.
| Browser consent fails or is cancelled | Sign in to the correct verified Factorize owner account, restart authorization from the client, and approve the requested scopes. Finish signup/email verification separately before restarting. |
| Client never connects | Confirm the exact URL `https://app.factorize.sh/mcp`, remote Streamable HTTP and OAuth support. Open the client's MCP status view and authenticate; use [client-specific instructions](/mcp/overview). |
| `invalid_token` or access disappears | Reauthorize the client. Password resets/changes and sign-out invalidate delegated credentials. An expired access token needs client refresh or renewed login. |
-| `insufficient_scope` | Reconnect and grant `flows:read` for discovery, `flows:write` for job edits/diagnosis, `runs:read` for inspection and `runs:write` for invocation/stop. |
+| `insufficient_scope` | Reconnect and grant `flows:read` for discovery, `flows:write` for job edits/diagnosis, `runs:read` for inspection and `runs:write` for renaming, invocation, or stop. |
| `list_execution_configs` returns no items | In browser Settings, test and save an exe.dev or Amp integration in the same workspace. MCP cannot create credential-bearing connections. |
| Target exists but repository/model access fails | Test the saved execution integration and check execution-account repository/model access and VM tags. For exe.dev, `diagnose_exe_integration` uses the saved connection ID and creates/deletes a temporary VM. |