Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,10 @@ internal/core/migrations/0028_workflow_snapshots.sql text eol=lf
internal/core/migrations/0029_workflow_runs.sql text eol=lf
internal/core/migrations/0033_personal_model_connections.sql text eol=lf
internal/core/migrations/0033_revision_resume.sql text eol=lf
# 0034 is the one migration since the allowlist lapsed that is already on main, so its checksum is
# recorded in databases this branch cannot reach; pinning it now would fail every one of them with
# migration_checksum_mismatch. It stays as it is. (0035_model_attempt_statistics.sql is in the same
# position for a different reason — it belongs to another PR, which has to make this call for it.)
# Everything introduced since main is pinned here instead, while no released database has recorded
# it yet; after merge that would no longer be true.
internal/core/migrations/0039_workflow_run_identity.sql text eol=lf
8 changes: 8 additions & 0 deletions api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -22305,10 +22305,18 @@
"name": {
"type": "string"
},
"projectId": {
"description": "The Project the run's Workspace provisions in. Required: a run that names none is refused with 409 workflow_run_project_required, never persisted without one. Frozen at creation for the run's whole lifetime. Authorized against the caller's own collaboration space: an unknown, archived or foreign Project is 404.",
"format": "uuid",
"type": "string"
},
"snapshotId": {
"type": "string"
}
},
"required": [
"projectId"
],
"type": "object"
}
}
Expand Down
3 changes: 2 additions & 1 deletion docs/agent-runs/issue-runs.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ Cloud in the transaction that takes over the previous phase's evidence.
[the delete-side limit](#limits)).

A run Workspace belongs to exactly this run (`workspaces.issue_run_id` and `issue_runs.workspace_id`
reference each other uniquely). Its creator and operation actor are the user who triggered the run, it
reference each other uniquely; a WorkflowRun's run Workspace likewise belongs only to it, see
[Workflows](../workflows/workflows.en.md#runs)). Its creator and operation actor are the user who triggered the run, it
is cloned from the Project's default branch, it never appears in any public Workspace list or endpoint,
and it accepts no other run or interactive session.

Expand Down
3 changes: 2 additions & 1 deletion docs/agent-runs/issue-runs.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ Cloud 在接管上一阶段证据的事务里写入。
- 创建操作停在 `blocked` 超过上限时,运行按 `failed`、`done` 收尾并保留残留责任,原创建操作失败、释放 Project 的操作槽(见 [创建 blocked 的有界放弃](startup-settlement.md#创建-blocked-的有界放弃))。
- **`done` 不代表运行 Workspace 已经删除**,只有删除操作成功才是(见 [删除侧的上限](#上限))。

运行 Workspace 只属于这一次运行(`workspaces.issue_run_id` 与 `issue_runs.workspace_id` 互相唯一引用),以触发运行的用户为创建者
运行 Workspace 只属于这一次运行(`workspaces.issue_run_id` 与 `issue_runs.workspace_id` 互相唯一引用;一次 WorkflowRun 的运行 Workspace 同样只属于它,
见 [工作流](../workflows/workflows.md#运行)),以触发运行的用户为创建者
和操作发起者,从 Project 默认分支克隆,不出现在任何公开的 Workspace 列表或接口里,也不接受其他运行或交互式会话。

## 后台循环
Expand Down
5 changes: 3 additions & 2 deletions docs/runtime/runtime-control.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,9 @@ idle ───────────────▶ acquiring ─────
**Lifecycle operations share the same write boundary.** As soon as a lifecycle operation is
registered it puts control into `maintenance` (recording the operation ID and incrementing the control
epoch), so page sessions cannot operate meanwhile. When the operation ends, control becomes `draining`
and settles by the rules above. After an Agent run's run Workspace is created, control becomes a
`maintenance` owned by that run, which bounds its sessions and deliveries from then on.
and settles by the rules above. After a run Workspace's creation operation succeeds, control becomes a
`maintenance` owned by the run that created it — the reservation records the run id, and both IssueRuns
and WorkflowRuns count — which bounds its sessions and deliveries from then on.

An administrator's ordinary operations cannot take over control someone else holds; force-stop is the
way to interrupt immediately.
Expand Down
4 changes: 2 additions & 2 deletions docs/runtime/runtime-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ idle ───────────────▶ acquiring ─────
- 幂等重放返回历史响应,但不会让已撤回的资格复活。每次状态变化都写入 `runtime_control_events`,保留持有者和原会话。

**生命周期操作与资格共用同一个写边界。** 生命周期操作一旦登记,就把资格置为 `maintenance`(记录操作 ID,控制代次加一),
页面会话在此期间不能操作;操作结束后资格进入 `draining`,按上面的规则结清。Agent 运行的运行 Workspace 创建成功后,资格转为
属于该运行的 `maintenance`,之后的会话和交付都以它为边界。
页面会话在此期间不能操作;操作结束后资格进入 `draining`,按上面的规则结清。运行 Workspace 的创建操作成功后,资格转为属于创建它的那次运行的
`maintenance`(预留记录运行 ID,IssueRun 与 WorkflowRun 都认),之后的会话和交付都以它为边界。

管理员的普通操作不能抢占别人持有的资格;需要立即打断时使用强停。

Expand Down
2 changes: 2 additions & 0 deletions docs/runtime/workspaces.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ by the Controller on its behalf.
**Run Workspaces.** Agent IssueRuns also use isolated Workspaces (`issue_run_id` set). Cloud creates
and deletes them under the identity of the user who triggered the run, through the same operations,
but they never appear in any public list or endpoint (see [Agent IssueRuns](../agent-runs/issue-runs.en.md)).
A run Workspace belongs to exactly one run: `issue_run_id` and `workflow_run_id` are mutually exclusive
columns, and the latter belongs to one [WorkflowRun](../workflows/workflows.en.md#runs).

## Durable operations

Expand Down
2 changes: 2 additions & 0 deletions docs/runtime/workspaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ Cloud 不直接调用 Substrate 或 Node。它只保存意图和证据,并按

**运行 Workspace。** Agent IssueRun 也使用 isolated Workspace(`issue_run_id` 非空),由 Cloud 以触发运行的用户
身份建删,走同一套操作,但不出现在任何公开列表或接口里(见 [Agent IssueRun](../agent-runs/issue-runs.md))。
运行 Workspace 恰好属于一次运行:`issue_run_id` 与 `workflow_run_id` 两列互斥,后者属于一次
[WorkflowRun](../workflows/workflows.md#运行)。

## 持久操作

Expand Down
9 changes: 9 additions & 0 deletions docs/workflows/workflows.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,15 @@ Workflows have three start entries, all sharing one admission gate:
If any check fails, no run, claimed interaction or activity is left behind. The executed version is frozen onto the
run, so the run still knows which version it executed after the workflow is edited or published again.

**A run's identity is frozen at creation.** A direct run must name its `projectId`: a missing one is `409
workflow_run_project_required`, an unusable, archived or foreign-space Project is refused as `404`, and one being
deleted is `409 resource_unavailable`. The run writes the initiating user (`created_by`) and that Project
(`project_id`) into its row and never changes them — a workflow document belongs to a tenant, not to a Project, so
there is nothing to default to, and the run's Workspace, its clone and its whole checkout land in the Project that
was named. A run Workspace belongs to the run that created it: `workspaces.workflow_run_id` is mutually exclusive
with an IssueRun's `issue_run_id`, and the `maintenance` reserved for it in runtime control is recorded against the
same run identity (see [Runtime control](../runtime/runtime-control.en.md)).

## Scheduling

WorkflowRuns are carried forward by a scheduling loop in the Cloud server (`AdvanceWorkflowRunsOnce`, every 10
Expand Down
6 changes: 6 additions & 0 deletions docs/workflows/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ Agent 节点不在图里写死 Agent CLI 或模型 ID,而是引用一个**执

任何一项不通过都不会留下运行、已认领的交互或活动。被执行的版本冻结在运行上,运行之后工作流再被编辑或发布,它仍然知道自己执行的是哪个版本。

**运行的身份在创建时冻结。** 直接创建运行时 `projectId` 必填:缺失返回 `409 workflow_run_project_required`,不可用、已归档或属于其他空间的 Project
按 `404` 拒绝,正在删除的返回 `409 resource_unavailable`。运行把发起人(`created_by`)和这个 Project(`project_id`)写进运行行,之后不再变化——工作流文档
属于租户而不是某个 Project,没有可以默认的对象,而运行的 Workspace、它的 clone 和整份检出都落在被指定的 Project 里。运行 Workspace 归属于创建它的那次运行:
`workspaces.workflow_run_id` 与 IssueRun 的 `issue_run_id` 互斥,运行时控制里为它预留的 `maintenance` 同样按运行身份记录(见
[运行时控制](../runtime/runtime-control.md))。

## 调度

WorkflowRun 由 Cloud server 里的调度循环推进(`AdvanceWorkflowRunsOnce`,每 10 秒一轮):
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/api/generated.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3247,6 +3247,8 @@ export type PostApiV1TenantsTidWorkflowsWfidRunsBodyInput = { [key: string]: unk
export type PostApiV1TenantsTidWorkflowsWfidRunsBody = {
input?: PostApiV1TenantsTidWorkflowsWfidRunsBodyInput;
name?: string;
/** The Project the run's Workspace provisions in. Required: a run that names none is refused with 409 workflow_run_project_required, never persisted without one. Frozen at creation for the run's whole lifetime. Authorized against the caller's own collaboration space: an unknown, archived or foreign Project is 404. */
projectId: string;
snapshotId?: string;
};

Expand Down
19 changes: 19 additions & 0 deletions frontend/src/features/workflows/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,13 @@ export function useWorkflowRun(
/** Input for running a workflow once against a published snapshot. */
export interface CreateWorkflowRunInput {
workflowId: string
/**
* The Project the run's Workspace provisions in. The contract requires it — a run that names
* none is refused with 409 `workflow_run_project_required` — so the mutation refuses a caller
* that has none instead of sending a body the contract does not describe. Optional here only
* because the run form that picks a Project is not built yet.
*/
projectId?: string
/** Optional display name; defaults to the workflow's name. */
name?: string
/** The exact snapshot to execute, or omit to pin the latest published one. */
Expand All @@ -382,6 +389,10 @@ export interface CreateWorkflowRunInput {
* idempotency key, so a retried run replays the stored response instead of
* minting a second execution.
*
* The run's Project is required by the contract — a run that names none is refused with 409
* `workflow_run_project_required` — and this editor states none yet: the run form that picks a
* Project is not built, so a run started here is refused until that lands.
*
* @param tenantId - Tenant the workflow belongs to.
* @returns The create mutation; `data` is the stored run.
*/
Expand All @@ -391,10 +402,18 @@ export function useCreateWorkflowRun(tenantId: string | undefined) {
return useMutation<WorkflowRun, ErrorType<ApiError>, CreateWorkflowRunInput>({
mutationFn: (input: CreateWorkflowRunInput) => {
if (!tenantId) throw new Error('cloud tenant not resolved')
// Refused here rather than sent: a run with no frozen Project is an identity-incomplete
// aggregate that can never provision the Workspace its agent nodes need, so the service
// answers this same code (409) and there is no value the client could put in the field
// instead — the caller either names a Project or names none.
if (input.projectId === undefined || input.projectId === '') {
throw new Error('workflow_run_project_required')
}
return postApiV1TenantsTidWorkflowsWfidRuns(
tenantId,
input.workflowId,
{
projectId: input.projectId,
...(input.name === undefined || input.name === '' ? {} : { name: input.name }),
...(input.snapshotId === undefined || input.snapshotId === ''
? {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1021,7 +1021,7 @@ describe('WorkflowEditor run tools', () => {
expect(screen.getByText('还没有运行记录。')).toBeInTheDocument()
})

it('creates a run from the toolbar once a snapshot is published', async () => {
it('starts no run from the toolbar, which cannot state the Project a run freezes', async () => {
const published: WorkflowSnapshot = {
id: 'snap-1',
tenantId: TEST_TENANT_ID,
Expand All @@ -1047,6 +1047,9 @@ describe('WorkflowEditor run tools', () => {
)
fireEvent.click(runButton)

await waitFor(() => expect(bodies).toEqual([{}]))
// The run form that picks the Project is not built yet, so the mutation refuses the run before
// the request is sent and this capture stays empty.
await waitFor(() => expect(runButton).toBeEnabled())
expect(bodies).toEqual([])
})
})
73 changes: 62 additions & 11 deletions frontend/src/features/workflows/editor/workflow-run-tools.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, renderHook, screen, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { beforeEach, describe, expect, it } from 'vitest'
import type { WorkflowRun, WorkflowSnapshot } from '@/api/generated.schemas'
import { useCreateWorkflowRun } from '@/features/workflows/api'
import { WorkflowRunTools } from '@/features/workflows/editor/workflow-run-tools'
import { routes } from '@/routes'
import {
Expand All @@ -15,6 +17,8 @@ import { renderRoutes } from '@/test/render'
import { stubViewportGeometry } from '@/test/viewport'

const WORKFLOW_ID = '44444444-4444-4444-4444-444444444444'
/** The Project a run freezes; the editor has none to state yet, so only the hook tests name one. */
const PROJECT_ID = '55555555-5555-5555-5555-555555555555'
/**
* The readable workspace slug the browser route carries. It is deliberately not
* `TEST_TENANT_ID`: the tenant UUID scopes API calls, and using it as the slug is
Expand Down Expand Up @@ -117,6 +121,49 @@ beforeEach(() => {
stubViewportGeometry()
})

/** A client whose mutations never retry, so one refusal settles once. */
function wrapper() {
const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
return function Wrapper({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={client}>{children}</QueryClientProvider>
}
}

/**
* The create mutation's own gate, pinned at the hook because the editor cannot reach it: a run must
* name the Project its Workspace provisions in, and the editor has none to state until the run form
* that picks one lands. A caller that states a Project still gets a run, and the Project it stated
* is what the request carries.
*/
describe('useCreateWorkflowRun', () => {
it('refuses a run that names no Project without sending it', async () => {
const bodies: unknown[] = []
installRunHandlers({ onCreate: (body) => bodies.push(body) })
const { result } = renderHook(() => useCreateWorkflowRun(TEST_TENANT_ID), {
wrapper: wrapper(),
})

result.current.mutate({ workflowId: WORKFLOW_ID })

await waitFor(() => expect(result.current.isError).toBe(true))
expect(result.current.error?.message).toBe('workflow_run_project_required')
expect(bodies).toEqual([])
})

it('sends the stated Project with the run', async () => {
const bodies: unknown[] = []
installRunHandlers({ onCreate: (body) => bodies.push(body) })
const { result } = renderHook(() => useCreateWorkflowRun(TEST_TENANT_ID), {
wrapper: wrapper(),
})

result.current.mutate({ workflowId: WORKFLOW_ID, projectId: PROJECT_ID })

await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(bodies).toEqual([{ projectId: PROJECT_ID }])
})
})

describe('WorkflowRunTools', () => {
it('disables the run button while no snapshot exists to execute', async () => {
renderRunTools({ snapshots: [] })
Expand All @@ -128,17 +175,20 @@ describe('WorkflowRunTools', () => {
expect(runButton).toHaveAttribute('title', '请先发布一个版本,才能运行这个工作流')
})

it('arms the run button once a snapshot exists, creating the run and navigating to it', async () => {
it('arms the run button once a snapshot exists, but starts no run it cannot state a Project for', async () => {
const bodies: unknown[] = []
const router = renderRunTools({ runs: [], onCreate: (body) => bodies.push(body) })
const runButton = await screen.findByRole('button', { name: '运行' })
await waitFor(() => expect(runButton).toBeEnabled())

fireEvent.click(runButton)

// The browser path carries the workspace slug, never the tenant UUID.
await waitFor(() => expect(router.state.location.pathname).toBe(`${EDITOR_PATH}/runs/run-new`))
expect(bodies).toEqual([{}])
// A run must name the Project its Workspace provisions in, and the editor has none to state
// yet: the refusal happens before the request, so nothing is created and nothing navigates.
// The button goes pending and comes back armed, which is where the refusal settles.
await waitFor(() => expect(runButton).toBeEnabled())
expect(bodies).toEqual([])
expect(router.state.location.pathname).toBe(EDITOR_PATH)
})

it('lists run history and opens a run from a row', async () => {
Expand Down Expand Up @@ -182,18 +232,19 @@ describe('workflow run navigation through the app routes', () => {
return { ...view, bodies }
}

it('creates one run and stays on the same workspace route instead of the UUID one', async () => {
it('starts no run, and stays on the same workspace route instead of the UUID one', async () => {
const { router, bodies } = renderAppAt(EDITOR_PATH)
const runButton = await screen.findByRole('button', { name: '运行' })
await waitFor(() => expect(runButton).toBeEnabled())

fireEvent.click(runButton)

// The destination is the real run detail page: a UUID workspace path would have
// been redirected to Issues by the Layout before this page ever mounted.
expect(await screen.findByRole('heading', { name: '运行详情' })).toBeInTheDocument()
expect(router.state.location.pathname).toBe(`${EDITOR_PATH}/runs/run-new`)
expect(bodies).toHaveLength(1)
// A UUID workspace path would have been redirected to Issues by the Layout before
// this page ever mounted, so the route is what proves the click stayed put.
await waitFor(() => expect(runButton).toBeEnabled())
expect(bodies).toHaveLength(0)
expect(router.state.location.pathname).toBe(EDITOR_PATH)
expect(screen.queryByRole('heading', { name: '运行详情' })).not.toBeInTheDocument()
})

it('opens an existing run from the history with the same slug and creates nothing', async () => {
Expand Down
Loading
Loading