diff --git a/.changeset/owner-manager-runs.md b/.changeset/owner-manager-runs.md new file mode 100644 index 00000000000..7cc006c2109 --- /dev/null +++ b/.changeset/owner-manager-runs.md @@ -0,0 +1,11 @@ +--- +'@nocobase/studio': minor +'@nocobase/app-plugin-agents': minor +'@nocobase/app-plugin-projects': minor +--- + +Require the issue owner to start or continue Studio project manager work, and ignore agent wakeups from comments on unstarted issues. Run the manager and its delegated steps as the current owner while preserving trigger audit information. Withdraw all queued agent work and request cancellation of held runs on owner transfers, without automatically restarting them. Correct legacy queued identities before claim and expose waiting reasons. + +Expose the existing transactional `Projects.activity` recorder for application issue events and deliver owner transfers to all work kinds before ordinary triggers, including executor clearing. Restore Studio wording for models, efforts, the start dialog and historical sub-issue activity. + +Preserve delegated execution identity throughout repeated retries, including handled inputs, and show the initiator of the current retry instead of copied retry history. diff --git a/packages/apps/studio/client/agents/issue-runs.tsx b/packages/apps/studio/client/agents/issue-runs.tsx index 61d5d322976..6d59df1c3de 100644 --- a/packages/apps/studio/client/agents/issue-runs.tsx +++ b/packages/apps/studio/client/agents/issue-runs.tsx @@ -5,6 +5,7 @@ * is the agents plugin's own (its namespace), filled into the item's labels here. */ import { useClientApplication } from '@nocobase/app-client'; +import { runTriggerActor } from './run-trigger.js'; import { useAuthentication } from '@nocobase/app-plugin-authentication/client'; import type { SubjectRun } from '@nocobase/app-plugin-agents/client/runs'; import { @@ -621,13 +622,14 @@ export function IssueRunTranscript({ : null; // The entry of the agent's list the run worked with: a coding tool and model, or a model service and model, and its effort. const effort = detail?.effort - ? ` · ${t(`agentForm.efforts.${detail.effort}`, { defaultValue: detail.effort })}` + ? ` · ${appT(`runTranscript.efforts.${detail.effort}`, { defaultValue: detail.effort })}` : ''; const used = detail?.tool - ? `${t(`tools.${detail.tool}`)} · ${detail.model ?? t('agents.defaultModel')}${effort}` + ? `${t(`tools.${detail.tool}`)} · ${detail.model ?? appT('runTranscript.defaultModel')}${effort}` : detail?.modelService ? `${detail.modelService} · ${detail.model ?? ''}${effort}` : null; + const triggerActor = detail ? runTriggerActor(detail) : undefined; const summary = detail ? ( <>

@@ -637,9 +639,15 @@ export function IssueRunTranscript({ })} {used ? ( - {` · ${t('modelEntries.used', { model: used })}`} + {` · ${appT('runTranscript.usedModel', { model: used })}`} ) : null} + {` · ${appT('runTranscript.actingAs', { actor: detail.actorName ?? detail.actorUserId })}`} + {triggerActor?.kind === 'system' + ? ` · ${appT('runTranscript.systemTrigger')}` + : triggerActor + ? ` · ${appT('runTranscript.triggeredBy', { person: triggerActor.name })}` + : ''} {duration !== null ? ` · ${t('transcript.took', { duration: `${duration}s` })}` : ''} diff --git a/packages/apps/studio/client/agents/run-trigger.ts b/packages/apps/studio/client/agents/run-trigger.ts new file mode 100644 index 00000000000..aa9aab38af7 --- /dev/null +++ b/packages/apps/studio/client/agents/run-trigger.ts @@ -0,0 +1,15 @@ +import type { RunDetail } from '@nocobase/app-plugin-agents/shared/runs'; + +/** Retries retain unhandled history, so select the input for this retry only. */ +export function runTriggerActor( + detail: Pick, +) { + return detail.retryOfRunId + ? (detail.inputs.find( + (input) => + input.type === 'retry' && + (input.payload as { retryOfRunId?: string } | null)?.retryOfRunId === + detail.retryOfRunId, + )?.actor ?? detail.inputs[0]?.actor) + : detail.inputs[0]?.actor; +} diff --git a/packages/apps/studio/client/extensions/nocobase-project-detail/labels.ts b/packages/apps/studio/client/extensions/nocobase-project-detail/labels.ts index 4cd172bc756..dbf8bc80154 100644 --- a/packages/apps/studio/client/extensions/nocobase-project-detail/labels.ts +++ b/packages/apps/studio/client/extensions/nocobase-project-detail/labels.ts @@ -8,7 +8,7 @@ export interface ProjectDetailLabels { readonly noLead: string; /** Beside the name of a project only its members see. */ readonly private: string; - readonly workflow: string; + readonly workflow?: string; }; readonly deletion: { readonly more: string; diff --git a/packages/apps/studio/client/issues/detail/activity-row.tsx b/packages/apps/studio/client/issues/detail/activity-row.tsx index 898e871e622..d2b10c7eb95 100644 --- a/packages/apps/studio/client/issues/detail/activity-row.tsx +++ b/packages/apps/studio/client/issues/detail/activity-row.tsx @@ -206,27 +206,41 @@ export function ActivityRow({ {via} ) : null} - {prAction - ? studioT(prAction) - : cannotImplement - ? studioT('studioAgents.activity.executorCannotImplement', { - name: - text(activity.details.name) ?? + {activity.action === 'studio_execution_wait' + ? studioT( + `issueProgress.waiting.${activity.details.reason === 'noOwner' ? 'noOwner' : 'notAllowed'}`, + { + agent: + text(activity.details.agent) ?? text(activity.details.agentId) ?? - t('common.unknown'), - }) - : activity.action === EXECUTOR_WAITS_ACTIVITY - ? studioT('studioAgents.activity.executorWaits', { + '', + owner: + text(activity.details.owner) ?? + text(activity.details.ownerUserId) ?? + '', + }, + ) + : prAction + ? studioT(prAction) + : cannotImplement + ? studioT('studioAgents.activity.executorCannotImplement', { name: - (text(activity.details.agentId) - ? agentName(text(activity.details.agentId) ?? '') - : null) ?? text(activity.details.name) ?? + text(activity.details.agentId) ?? t('common.unknown'), }) - : t(actionLabel(activity.action, activity.details), { - name: text(activity.details.name) ?? t('common.unknown'), - })} + : activity.action === EXECUTOR_WAITS_ACTIVITY + ? studioT('studioAgents.activity.executorWaits', { + name: + (text(activity.details.agentId) + ? agentName(text(activity.details.agentId) ?? '') + : null) ?? + text(activity.details.name) ?? + t('common.unknown'), + }) + : t(actionLabel(activity.action, activity.details), { + name: text(activity.details.name) ?? t('common.unknown'), + })} {prAction ? ( @@ -288,7 +302,7 @@ export function ActivityRow({ ) : null} {activity.action === 'status_changed' && activity.details.event === 'subtasks.done' ? ( - {t('activity.afterSubtasks')} + {studioT('runTranscript.afterSubtasks')} ) : null} {moved && event && eventTitle(event) ? ( {t('activity.afterEvent', { event: eventTitle(event) })} diff --git a/packages/apps/studio/client/issues/detail/comment-run-limit.tsx b/packages/apps/studio/client/issues/detail/comment-run-limit.tsx index 4c47ec92df8..4fff01546e0 100644 --- a/packages/apps/studio/client/issues/detail/comment-run-limit.tsx +++ b/packages/apps/studio/client/issues/detail/comment-run-limit.tsx @@ -1,6 +1,6 @@ /** - * An agent's comment the comment run limit held back (`GET /api/issueCommentRuns/:issueId`): a person who may edit the - * issue continues it once, which wakes the agents it names and restarts the count. The server checks and consumes its + * An agent's comment the comment run limit held back (`GET /api/issueCommentRuns/:issueId`): the owner with issue edit + * permission continues it once, which wakes the agents it names and restarts the count. The server checks and consumes its * token. */ import { ApiClientError, useApiClient } from '@nocobase/app-client'; @@ -36,7 +36,7 @@ export function IssueCommentRunLimit({ const notify = useNotify(); const queryClient = useQueryClient(); const continuingRef = useRef(false); - const allowed = canEditIssues(viewer); + const allowed = canEditIssues(viewer) && viewer?.userId === issue.ownerUserId; const key = ['studio', 'commentRunLimit', issue.id, issue.revision]; const path = `issueCommentRuns/${encodeURIComponent(issue.id)}`; const pending = useQuery({ diff --git a/packages/apps/studio/client/issues/detail/issue-progress.tsx b/packages/apps/studio/client/issues/detail/issue-progress.tsx index 36363923e0f..191a1bd0162 100644 --- a/packages/apps/studio/client/issues/detail/issue-progress.tsx +++ b/packages/apps/studio/client/issues/detail/issue-progress.tsx @@ -43,7 +43,7 @@ export function IssueProgressLine({ const viewer = useViewer(); const notify = useNotify(); const queryClient = useQueryClient(); - const canEdit = canEditIssues(viewer); + const canEdit = canEditIssues(viewer) && viewer?.userId === issue.ownerUserId; const progress = useQuery({ queryKey: [...issueProgressKey(issue.id), refresh], queryFn: async () => diff --git a/packages/apps/studio/client/issues/detail/labels.ts b/packages/apps/studio/client/issues/detail/labels.ts index 4e4a313a900..3e6f097da76 100644 --- a/packages/apps/studio/client/issues/detail/labels.ts +++ b/packages/apps/studio/client/issues/detail/labels.ts @@ -154,9 +154,9 @@ export function useIssuePageWording(): IssuePageWording { updated: t('properties.updated'), }, start: { - title: t('start.title'), - later: t('start.later'), - start: t('start.start'), + title: appT('issueStart.title'), + later: appT('issueStart.later'), + start: appT('issueStart.start'), }, }, richText: { diff --git a/packages/apps/studio/client/issues/detail/progress-text.ts b/packages/apps/studio/client/issues/detail/progress-text.ts index 875c6fbfe19..35c1bc9208a 100644 --- a/packages/apps/studio/client/issues/detail/progress-text.ts +++ b/packages/apps/studio/client/issues/detail/progress-text.ts @@ -18,6 +18,11 @@ export function progressSentence( now: number = Date.now(), ): string { const agent = progress.agent?.name ?? t('issueProgress.anAgent'); + if (progress.waiting) + return t(`issueProgress.waiting.${progress.waiting.reason}`, { + agent, + owner: progress.waiting.owner?.name ?? '', + }); switch (progress.state) { case 'working': { const parts = [ diff --git a/packages/apps/studio/client/locales/en-US.ts b/packages/apps/studio/client/locales/en-US.ts index b9754bd0a5c..297a288c46b 100644 --- a/packages/apps/studio/client/locales/en-US.ts +++ b/packages/apps/studio/client/locales/en-US.ts @@ -72,6 +72,24 @@ const enUS = { keepDraft: 'Keep panel draft', appendDraft: 'Append header question', }, + runTranscript: { + usedModel: 'Model: {{model}}', + defaultModel: 'Default model', + actingAs: 'Running as {{actor}}', + triggeredBy: 'Triggered by {{person}}', + systemTrigger: 'Triggered by the system', + afterSubtasks: 'After all sub-issues finished', + efforts: { + off: 'Off', + minimal: 'Minimal', + low: 'Low', + medium: 'Medium', + high: 'High', + xhigh: 'Extra high', + max: 'Maximum', + ultra: 'Ultra', + }, + }, runTranscriptFilters: { title: 'Event types', agent: 'Agent', @@ -1367,7 +1385,19 @@ const enUS = { issuePage: { arrange: 'Arrange', }, + issueStart: { title: 'Start now?', later: "Don't start now", start: 'Start' }, issueProgress: { + waiting: { + noOwner: 'Waiting for a valid issue owner', + notConfigured: + '{{agent}} waits: no preferred or fallback model is configured', + noRunner: + '{{agent}} waits: no online runner supports its preferred or fallback model', + ownerHasNoRunner: + '{{agent}} waits: owner {{owner}} has no online runner for its preferred or fallback model', + notAllowed: + '{{agent}} waits: owner {{owner}} cannot run this agent with the available runners', + }, title: 'Now', anAgent: 'An agent', working: '{{agent}} is working on {{step}}', diff --git a/packages/apps/studio/client/locales/zh-CN.ts b/packages/apps/studio/client/locales/zh-CN.ts index 7a1183f74ef..599bd868b4a 100644 --- a/packages/apps/studio/client/locales/zh-CN.ts +++ b/packages/apps/studio/client/locales/zh-CN.ts @@ -63,6 +63,24 @@ const zhCN: AppResource = { keepDraft: '保留面板草稿', appendDraft: '追加顶部问题', }, + runTranscript: { + usedModel: '使用模型:{{model}}', + defaultModel: '默认模型', + actingAs: '运行身份:{{actor}}', + triggeredBy: '触发人:{{person}}', + systemTrigger: '由系统触发', + afterSubtasks: '所有子任务完成后', + efforts: { + off: '关闭', + minimal: '最低', + low: '低', + medium: '中', + high: '高', + xhigh: '特高', + max: '最高', + ultra: '极高', + }, + }, runTranscriptFilters: { title: '事件类型', agent: 'Agent', @@ -1254,7 +1272,17 @@ const zhCN: AppResource = { issuePage: { arrange: '安排', }, + issueStart: { title: '现在开始?', later: '暂不开始', start: '开始' }, issueProgress: { + waiting: { + noOwner: '正在等待有效的任务负责人', + notConfigured: '{{agent}} 正在等待:尚未配置首选或备选模型', + noRunner: '{{agent}} 正在等待:没有能运行首选或备选模型的在线执行机', + ownerHasNoRunner: + '{{agent}} 正在等待:负责人 {{owner}} 没有能运行首选或备选模型的在线执行机', + notAllowed: + '{{agent}} 正在等待:负责人 {{owner}} 无权运行此 Agent,或可用执行机不满足运行要求', + }, title: '现在', anAgent: '某个 Agent', working: '{{agent}} 正在{{step}}', diff --git a/packages/apps/studio/client/projects/detail/labels.ts b/packages/apps/studio/client/projects/detail/labels.ts index 950b0ef0e24..e7f1c61ed35 100644 --- a/packages/apps/studio/client/projects/detail/labels.ts +++ b/packages/apps/studio/client/projects/detail/labels.ts @@ -55,7 +55,6 @@ export function useProjectPageWording(): ProjectPageWording { lead: t('projects.columns.lead'), noLead: t('projects.noLead'), private: t('projects.visibility.members'), - workflow: t('projects.workflow'), }, deletion: { more: t('projectMore.label'), diff --git a/packages/apps/studio/docs/cli/reference/project-manager.md b/packages/apps/studio/docs/cli/reference/project-manager.md index 0d91f6a41ce..45f84deebbc 100644 --- a/packages/apps/studio/docs/cli/reference/project-manager.md +++ b/packages/apps/studio/docs/cli/reference/project-manager.md @@ -22,7 +22,7 @@ nb-studio project-manager get Hand an issue to the project manager -Wakes the project manager on an open issue it executes, as the caller, to go on with it: for an issue it never worked on. Needs `pm.issues` edit on the issue. +Wakes the project manager on an open issue it executes, as the current issue owner. Only that owner may invoke it, and needs `pm.issues` edit on the issue. Other callers receive `ISSUE_OWNER_START_REQUIRED`. ```bash nb-studio project-manager start diff --git a/packages/apps/studio/e2e/issue-page.test.ts b/packages/apps/studio/e2e/issue-page.test.ts index 079f286df16..11b46b1578c 100644 --- a/packages/apps/studio/e2e/issue-page.test.ts +++ b/packages/apps/studio/e2e/issue-page.test.ts @@ -402,6 +402,11 @@ test.describe('issue page', () => { page, api, }, testInfo) => { + await api.patch(`projects/issues/${issue.id}`, { + statusKey: 'in_progress', + executor: null, + start: false, + }); const runner = new FakeRunner(api, `live-issue-${unique()}`); await runner.agent(); const runsPath = `agents/runs?subjectKind=issue&subjectId=${issue.id}`; @@ -492,6 +497,109 @@ test.describe('issue page', () => { ).toBeHidden(); }); + test('only the owner starts; another user comments as a trigger and the transcript names both', async ({ + page, + api, + browser, + context, + }) => { + test.setTimeout(120_000); + const admin = await apiAs(browser, 'admin'); + const suffix = unique(); + const triggerUser = { + email: `identity-trigger-${suffix}@example.com`, + password: 'identity-trigger-e2e-password', + }; + const triggerName = `Identity trigger ${suffix}`; + const runner = new FakeRunner(admin.api, `owner-identity-${unique()}`); + const settings = await admin.api.get<{ + firstChoiceAgentId: string | null; + fallbackAgentId: string | null; + }>('projectManager'); + try { + const choice = await runner.agent(); + await admin.api.patch('projectManager', { + firstChoiceAgentId: choice, + fallbackAgentId: null, + }); + await admin.api.post('users', { + ...triggerUser, + name: triggerName, + roleScopes: { studio: ['admin'] }, + }); + await signIn(context, triggerUser); + await open(page, `/issues/${issue.identifier}`); + await expect( + page + .getByRole('region', { name: '现在' }) + .getByRole('button', { name: '开始', exact: true }), + ).toHaveCount(0); + await expect( + api.post(`projectManager/issues/${issue.id}/start`), + ).rejects.toThrow('ISSUE_OWNER_START_REQUIRED'); + await signIn(context, 'alex'); + await open(page, `/issues/${issue.identifier}`); + await page + .getByRole('region', { name: '现在' }) + .getByRole('button', { name: '开始', exact: true }) + .click(); + await expect + .poll( + async () => + (await api.get(`projects/issues/${issue.id}`)).statusKey, + ) + .toBe('in_progress'); + const first = await runner.start(issue.identifier); + await runner.complete(first.runId, 'Ready for comments.'); + await signIn(context, triggerUser); + const trigger = await api.get<{ userId: string }>('projects/me'); + await api.post(`projects/issues/${issue.id}/comments`, { + content: `Please check the implementation, ${suffix}.`, + }); + const started = await runner.start(issue.identifier); + const detail = await admin.api.get<{ + actorUserId: string; + requestedByUserId: string; + }>(`agents/runs/${started.runId}`); + expect(detail.actorUserId).toBe(me.userId); + expect(detail.requestedByUserId).toBe(trigger.userId); + await open(page, `/issues/${issue.identifier}/runs/${started.runId}`); + await expect(page.getByTestId('run-model')).toContainText('使用模型:'); + await expect(page.getByText(/运行身份:Alex Turner/u)).toBeVisible(); + await expect( + page.getByText(new RegExp(`触发人:${triggerName}`, 'u')), + ).toBeVisible(); + await page.screenshot({ + path: test.info().outputPath('manager-identity-zh.png'), + }); + await api.patch('users/me/preferences', { + locale: 'en-US', + 'theme.mode': 'dark', + }); + await page.evaluate(() => { + localStorage.setItem('nocobase.locale', 'en-US'); + localStorage.setItem('nocobase:main:theme:color-scheme', 'dark'); + }); + await page.setViewportSize({ width: 375, height: 812 }); + await page.reload(); + await expect(page.getByTestId('run-model')).toContainText('Model:'); + await expect(page.getByText(/Running as Alex Turner/u)).toBeVisible(); + await expect( + page.getByText(new RegExp(`Triggered by ${triggerName}`, 'u')), + ).toBeVisible(); + expect(await horizontalOverflow(page)).toBeLessThanOrEqual(1); + await page.screenshot({ + path: test.info().outputPath('manager-identity-en-mobile.png'), + }); + } finally { + await admin.api.patch('projectManager', { + firstChoiceAgentId: settings.firstChoiceAgentId, + fallbackAgentId: settings.fallbackAgentId, + }); + await admin.close(); + } + }); + test('the project manager arranges an issue among the agents', async ({ page, }) => { diff --git a/packages/apps/studio/e2e/issues.test.ts b/packages/apps/studio/e2e/issues.test.ts index cfc03bc78a5..df2a135a9c7 100644 --- a/packages/apps/studio/e2e/issues.test.ts +++ b/packages/apps/studio/e2e/issues.test.ts @@ -63,6 +63,9 @@ test.describe('issues', () => { const working = await api.post('projects/issues', { title: `Agent 队列:处理中 ${unique()}`, ownerUserId: me.userId, + statusKey: 'in_progress', + executor: null, + start: false, }); await runner.wake(working); await runner.claim(working.identifier); @@ -70,12 +73,18 @@ test.describe('issues', () => { const queued = await api.post('projects/issues', { title: `Agent 队列:排队中 ${unique()}`, ownerUserId: me.userId, + statusKey: 'in_progress', + executor: null, + start: false, }); await runner.wake(queued); // An issue no agent touches. const blocker = await api.post('projects/issues', { title: `Agent 队列:无关任务 ${unique()}`, ownerUserId: me.userId, + statusKey: 'in_progress', + executor: null, + start: false, }); await open(page, '/issues?view=agent&mine=1'); diff --git a/packages/apps/studio/e2e/run-transcript.test.ts b/packages/apps/studio/e2e/run-transcript.test.ts index d771445d481..c232b81cdb0 100644 --- a/packages/apps/studio/e2e/run-transcript.test.ts +++ b/packages/apps/studio/e2e/run-transcript.test.ts @@ -18,6 +18,52 @@ interface ScrollViewport { test.use({ user: 'admin' }); test.setTimeout(120_000); +test('shows the current retry initiator after an unclaimed retry was cancelled', async ({ + page, + api, + browser, +}) => { + const me = await api.get<{ userId: string }>('projects/me'); + const runner = new FakeRunner(api, `retry-audit-${unique()}`); + await runner.agent(); + const issue = await api.post<{ id: string; identifier: string }>( + 'projects/issues', + { + title: `Retry audit ${unique()}`, + ownerUserId: me.userId, + statusKey: 'in_progress', + executor: null, + start: false, + }, + ); + await runner.wake(issue); + const [original] = await api.get<{ id: string }[]>( + `agents/runs?subjectKind=issue&subjectId=${issue.id}`, + ); + await api.post(`agents/runs/${original!.id}/cancel`, {}); + const other = await apiAs(browser, 'alex'); + try { + const first = await other.api.post<{ id: string }>( + `agents/runs/${original!.id}/retry`, + {}, + ); + await other.api.post(`agents/runs/${first.id}/cancel`, {}); + const second = await api.post<{ id: string }>( + `agents/runs/${first.id}/retry`, + {}, + ); + await open(page, `/issues/${issue.identifier}/runs/${second.id}`); + await expect( + page.getByText(/(?:触发人:|Triggered by )Super Admin/u), + ).toBeVisible(); + await expect( + page.getByText(/(?:触发人:|Triggered by )Alex Turner/u), + ).toHaveCount(0); + } finally { + await other.close(); + } +}); + test('filters a real 200-event run, preserves preferences and follows live failures', async ({ page, api, diff --git a/packages/apps/studio/e2e/runner-isolation.test.ts b/packages/apps/studio/e2e/runner-isolation.test.ts index 81a4093e3f7..811b2068741 100644 --- a/packages/apps/studio/e2e/runner-isolation.test.ts +++ b/packages/apps/studio/e2e/runner-isolation.test.ts @@ -15,6 +15,9 @@ test('parallel fake runners only claim the runs of their own agents', async ({ api.post('projects/issues', { title: `Concurrent mention ${index} ${unique()}`, ownerUserId: me.userId, + statusKey: 'in_progress', + executor: null, + start: false, }), ), ); diff --git a/packages/apps/studio/e2e/support/runner.ts b/packages/apps/studio/e2e/support/runner.ts index b81e59c9894..c703cf35a69 100644 --- a/packages/apps/studio/e2e/support/runner.ts +++ b/packages/apps/studio/e2e/support/runner.ts @@ -163,10 +163,13 @@ export class FakeRunner { ownerUserId: string, author = this.api, ) { - // In backlog nothing else starts: only the mention wakes this runner's agent. + // An active issue with no executor lets the mention wake only this runner's agent. const issue = await author.post('projects/issues', { title, ownerUserId, + statusKey: 'in_progress', + executor: null, + start: false, }); await this.wake(issue, author); const { run } = await this.claimRun(issue.identifier); diff --git a/packages/apps/studio/server/agents/comment-run-limit.ts b/packages/apps/studio/server/agents/comment-run-limit.ts index 02acf4e7d6b..ac2d8a8537e 100644 --- a/packages/apps/studio/server/agents/comment-run-limit.ts +++ b/packages/apps/studio/server/agents/comment-run-limit.ts @@ -1,6 +1,6 @@ /** * The loop guard of agents' comments (`work.ts`): once they started as many runs on an issue as the project manager's - * settings allow (`commentRunLimit`, 20 by default), the owner is told, and a person who may edit the issue continues + * settings allow (`commentRunLimit`, 20 by default), the owner is told, and that owner, with issue edit permission, continues * the comment held back once, which restarts the count. */ import type { Agents } from '@nocobase/app-plugin-agents/server/tokens'; @@ -90,7 +90,7 @@ export async function pendingCommentRun( readonly windowHours: number; } | null> { const issue = await projects.issueQueries.detail(viewer, issueId); - if (!mayContinue(viewer)) return null; + if (!mayContinue(viewer) || viewer.userId !== issue.ownerUserId) return null; const guard = await commentGuards(projects.tx.read()).findOne({ filter: { id: commentGuardId(issue.id) }, }); @@ -103,7 +103,7 @@ export async function pendingCommentRun( }; } -/** Consumes the token, restarts the count and wakes the held comment's agents for the person continuing it. */ +/** The owner consumes the token, restarts the count and wakes the held comment's agents. */ export async function continueCommentRun( projects: Projects, agents: Agents, @@ -119,6 +119,12 @@ export async function continueCommentRun( 'COMMENT_CONTINUE_FORBIDDEN', 'Only a person who may edit the issue may continue this comment.', ); + if (viewer.userId !== issue.ownerUserId) + throw studioError( + 'PERMISSION_DENIED', + 'ISSUE_OWNER_START_REQUIRED', + 'The current issue owner must continue this work.', + ); const id = commentGuardId(issue.id); const stale = () => studioError( diff --git a/packages/apps/studio/server/agents/execution-identity.ts b/packages/apps/studio/server/agents/execution-identity.ts new file mode 100644 index 00000000000..5e9ee91ecc1 --- /dev/null +++ b/packages/apps/studio/server/agents/execution-identity.ts @@ -0,0 +1,118 @@ +/** Only the project manager and work it handed out use the issue's current owner. */ +import type { + Agents, + SubjectBinding, +} from '@nocobase/app-plugin-agents/server/tokens'; +import type { Projects } from '@nocobase/app-plugin-projects/server/tokens'; +import type { DatabaseConnection } from '@nocobase/db'; +import { PROJECT_MANAGER_ID } from '../../shared/project-manager.js'; +import { AGENT_KIND } from './tx.js'; + +export function issueExecutionIdentity( + agents: Pick, + projects: () => Pick, +): NonNullable { + return async (conn, work) => { + const seen = new Set(); + const comments = await projects().commentQueries.list(conn, work.subjectId); + const byId = new Map(comments.map((comment) => [comment.id, comment])); + async function inChain( + agentId: string, + inputs: typeof work.inputs, + cause?: string, + ): Promise { + if (agentId === PROJECT_MANAGER_ID) return true; + if (cause && !seen.has(cause)) { + seen.add(cause); + const previous = await agents.runs.identityInputs(conn, cause); + if ( + previous?.run.subject.id === work.subjectId && + previous.run.subject.kind === 'issue' && + (await inChain( + previous.run.agentId, + previous.inputs, + previous.run.retryOfRunId ?? undefined, + )) + ) + return true; + } + for (const input of inputs) { + const payload = + input.payload && typeof input.payload === 'object' + ? (input.payload as Record) + : {}; + if (payload.ownerIdentity === true) return true; + if ( + typeof payload.retryOfRunId === 'string' && + (await inChain(agentId, [], payload.retryOfRunId)) + ) + return true; + if ( + typeof payload.causedByRunId === 'string' && + (await inChain(agentId, [], payload.causedByRunId)) + ) + return true; + // Old queued steps predate the explicit marker. Follow their recorded comment source, + // or the hand-over thread when old comments did not record a run. + let comment = + typeof payload.commentId === 'string' + ? byId.get(payload.commentId) + : undefined; + if ( + comment?.source?.runId && + (await inChain(agentId, [], comment.source.runId)) + ) + return true; + if (input.actor.kind !== 'agent') continue; + if (input.actor.id === PROJECT_MANAGER_ID) return true; + const visited = new Set(); + while (comment && !visited.has(comment.id)) { + visited.add(comment.id); + if ( + comment.authorType === AGENT_KIND && + comment.authorId === PROJECT_MANAGER_ID + ) + return true; + if ( + comment.source?.runId && + (await inChain(agentId, [], comment.source.runId)) + ) + return true; + comment = comment.parentId ? byId.get(comment.parentId) : undefined; + } + } + return false; + } + if (!(await inChain(work.agentId, work.inputs, work.causedByRunId))) + return undefined; + const issue = await projects().issueContext.contextFor( + conn, + work.subjectId, + { kind: AGENT_KIND }, + ); + // Missing subjects are rejected by claim admission with its precise reason. + if (!issue) return undefined; + const ownerId = issue?.owner.id; + if (!ownerId) return { actorUserId: null, reason: 'noOwner' }; + if (!(await validIssueOwner(conn, ownerId))) + return { actorUserId: null, reason: 'noOwner' }; + const agent = await agents.agents.findWorkable(conn, work.agentId); + if (!agent || !agents.agents.mayInvoke(agent, ownerId)) + return { actorUserId: null, reason: 'notAllowed' }; + return { actorUserId: ownerId }; + }; +} + +export async function validIssueOwner( + conn: DatabaseConnection, + id: string, +): Promise { + const user = await conn + .repository<{ + id: string; + disabledAt: string | null; + deletedAt: string | null; + }>('user') + .findOne({ filter: { id } }); + return Boolean(user && !user.disabledAt && !user.deletedAt); +} diff --git a/packages/apps/studio/server/agents/issue-subject.ts b/packages/apps/studio/server/agents/issue-subject.ts index bb3518e979b..644c0631434 100644 --- a/packages/apps/studio/server/agents/issue-subject.ts +++ b/packages/apps/studio/server/agents/issue-subject.ts @@ -49,6 +49,7 @@ import { PROJECT_TITLE, } from './catalog/triggers.js'; import { AGENT_KIND } from './tx.js'; +import { issueExecutionIdentity } from './execution-identity.js'; const SUBJECT_NOUN = 'issue'; @@ -623,13 +624,14 @@ export function issueSample(agents: Pick): SubjectSample { } export function issueBinding( - agents: Pick, + agents: Pick, projects: () => Pick, sink: SubjectBinding['sink'], queuedExpiryMs = 0, ): SubjectBinding { return { kind: ISSUE_SUBJECT, + actsAs: issueExecutionIdentity(agents, projects), ...(queuedExpiryMs > 0 ? { queuedExpiryMs } : {}), title: ISSUE_TITLE, groupTitle: PROJECT_TITLE, diff --git a/packages/apps/studio/server/agents/manager/progress.ts b/packages/apps/studio/server/agents/manager/progress.ts index f27dca246aa..54fbe7202bd 100644 --- a/packages/apps/studio/server/agents/manager/progress.ts +++ b/packages/apps/studio/server/agents/manager/progress.ts @@ -20,6 +20,11 @@ import { pullRequestsOfIssue } from '../../git/store.js'; import { ISSUE_SUBJECT } from '../catalog/triggers.js'; import { AGENT_KIND } from '../tx.js'; import { nextLineOf } from './texts.js'; +import { + issueExecutionIdentity, + validIssueOwner, +} from '../execution-identity.js'; +import { userName } from '../work.js'; /** How much of a step or a question the line carries. */ const LINE_LENGTH = 160; @@ -51,12 +56,41 @@ const HELD = new Set(['dispatched', 'running']); export async function issueProgress( deps: { - readonly agents: Pick; - readonly projects: Pick; + readonly agents: Pick< + Agents, + 'runs' | 'agents' | 'runners' | 'availability' + >; + readonly projects: Pick; }, conn: DatabaseConnection, issue: Issue, ): Promise { + async function waitingFor( + agentId: string, + userId: string, + ): Promise { + const owner = userId + ? { id: userId, name: await userName(conn, userId) } + : null; + if (!userId || !(await validIssueOwner(conn, userId))) + return { reason: 'noOwner', owner }; + const agent = await deps.agents.agents.findWorkable(conn, agentId); + if (!agent || !agent.modelEntries.length) + return { reason: 'notConfigured', owner }; + if (!deps.agents.agents.mayInvoke(agent, userId)) + return { reason: 'notAllowed', owner }; + const availability = ( + await deps.agents.availability(conn, [agent], userId) + ).get(agentId); + if (!availability || availability.online) return null; + const reason = + availability.runners.team + availability.runners.personal === 0 + ? 'noRunner' + : availability.personalOnly && availability.runners.mine === 0 + ? 'ownerHasNoRunner' + : 'notAllowed'; + return { reason, owner }; + } const subject = { kind: ISSUE_SUBJECT, id: issue.id }; const comments = await deps.projects.commentQueries.list(conn, issue.id); const managerComments = comments.filter( @@ -110,6 +144,15 @@ export async function issueProgress( run.runnerId && HELD.has(run.status) ? ((await deps.agents.runners.find(conn, run.runnerId))?.name ?? null) : null; + const identity = + run.status === 'queued' + ? await issueExecutionIdentity(deps.agents, () => deps.projects)(conn, { + subjectId: issue.id, + agentId: run.agentId, + causedByRunId: run.retryOfRunId ?? undefined, + inputs: detail?.inputs ?? [], + }) + : undefined; return { ...base, state: HELD.has(run.status) ? 'working' : 'queued', @@ -122,11 +165,25 @@ export async function issueProgress( : null, since: run.startedAt ?? run.dispatchedAt ?? run.createdAt, runner, + waiting: + run.status === 'queued' && identity + ? await waitingFor( + run.agentId, + run.dispatchedAt || run.attempt > 1 || run.executions?.length + ? run.actorUserId + : (identity.actorUserId ?? issue.ownerUserId), + ) + : null, }; } if (category === 'done' || category === 'closed') return { ...base, state: 'finished' }; if (issue.statusKey === 'backlog') return { ...base, state: 'notStarted' }; + if (managed && open) { + const waiting = await waitingFor(PROJECT_MANAGER_ID, issue.ownerUserId); + if (waiting?.reason === 'noOwner' || waiting?.reason === 'notAllowed') + return { ...base, state: 'queued', waiting }; + } const pending = (await pullRequestsOfIssue(conn, issue.id)) .map(({ pr }) => pr) .find((pr) => pr.state === 'open' && pr.ciState === 'pending'); diff --git a/packages/apps/studio/server/agents/manager/routes.ts b/packages/apps/studio/server/agents/manager/routes.ts index fe2332d651b..bed7ae4d62d 100644 --- a/packages/apps/studio/server/agents/manager/routes.ts +++ b/packages/apps/studio/server/agents/manager/routes.ts @@ -4,7 +4,7 @@ * - `GET /projectManager`: its settings and the agents it may run with, for anyone signed in; `PATCH /projectManager` * changes them, for a manager of agents (`agents.agents` manage). * - `POST /projectManager/issues/{issueId}/start`: hands an open issue the project manager executes to it to go on, - * as the caller, who may edit the issue: "Hand over to the project manager" on an issue it never worked on. + * as the owner, who must also be the caller and may edit the issue: "Hand over to the project manager" on an issue it never worked on. * - `GET /issueProgress/{issueId}`: what the issue waits for now, for whoever sees it. * - `GET /issueCommentRuns/{issueId}`, `POST …/continue`: an agent's comment the comment run limit held back, and * continuing it (`../comment-run-limit.ts`). @@ -214,7 +214,7 @@ export const projectManagerRoutes: AppApiRouteContribution = }), security: [{ cookieAuth: [] }, { apiKeyAuth: [] }], description: - 'Wakes the project manager on an open issue it executes, as the caller, to go on with it: for an issue it never worked on. Needs `pm.issues` edit on the issue.', + 'Wakes the project manager on an open issue it executes, as the current issue owner. Only that owner may invoke it, and needs `pm.issues` edit on the issue. Other callers receive `ISSUE_OWNER_START_REQUIRED`.', responses: { 200: dataResponse(z.object({ runId: z.string() })), 400: apiErrorResponse( @@ -237,7 +237,7 @@ export const projectManagerRoutes: AppApiRouteContribution = 'ACTION_FORBIDDEN', 'Only a person who may edit the issue may hand it over.', ); - const runId = await projects.tx.unit(async (tx) => { + const attempt = await projects.tx.unit(async (tx) => { const issue = await projects.issueQueries.detail( viewer, context.req.valid('param').issueId, @@ -256,17 +256,17 @@ export const projectManagerRoutes: AppApiRouteContribution = agents, projects: () => projects, }).startNow(tx, issue, { type: 'user', id: viewer.userId }); - if (!attempt.started || !attempt.runId) - throw studioError( - 'FAILED_PRECONDITION', - 'PROJECT_MANAGER_NOT_STARTED', - 'The project manager cannot take this issue now.', - { metadata: { reason: attempt.skipped ?? 'unknown' } }, - ); tx.emit({ type: 'issue.changed', issueId: issue.id }); - return attempt.runId; + return attempt; }); - return context.json({ data: { runId } }); + if (!attempt.started || !attempt.runId) + throw studioError( + 'FAILED_PRECONDITION', + 'PROJECT_MANAGER_NOT_STARTED', + 'The project manager cannot take this issue now.', + { metadata: { reason: attempt.skipped ?? 'unknown' } }, + ); + return context.json({ data: { runId: attempt.runId } }); }, ); progress.get( @@ -341,7 +341,7 @@ export const projectManagerRoutes: AppApiRouteContribution = ...cliRoute(false), security: [{ cookieAuth: [] }, { apiKeyAuth: [] }], description: - 'Needs pm.issues/edit on the issue. Wakes the agents the comment names as the caller and restarts the count; the token is consumed once, and failed preconditions leave it pending.', + 'Needs pm.issues/edit and the current issue owner. Wakes the agents the comment names and restarts the count; project manager work uses the owner identity. The token is consumed once, and failed preconditions leave it pending.', responses: { 200: dataResponse(z.object({ runIds: z.array(z.string()) })), ...apiErrorResponses, diff --git a/packages/apps/studio/server/agents/manager/schemas.ts b/packages/apps/studio/server/agents/manager/schemas.ts index c25b848c2d0..843b48cdbd6 100644 --- a/packages/apps/studio/server/agents/manager/schemas.ts +++ b/packages/apps/studio/server/agents/manager/schemas.ts @@ -39,6 +39,19 @@ export const ProjectManagerViewSchema: z.ZodType = z export const IssueProgressSchema: z.ZodType = z .object({ + waiting: z + .object({ + reason: z.enum([ + 'noOwner', + 'notConfigured', + 'noRunner', + 'ownerHasNoRunner', + 'notAllowed', + ]), + owner: z.object({ id: z.string(), name: z.string() }).nullable(), + }) + .nullable() + .optional(), state: z.enum([ 'working', 'queued', diff --git a/packages/apps/studio/server/agents/run-views.ts b/packages/apps/studio/server/agents/run-views.ts index a12936955a4..7b6c23c9be3 100644 --- a/packages/apps/studio/server/agents/run-views.ts +++ b/packages/apps/studio/server/agents/run-views.ts @@ -174,6 +174,11 @@ const IssueRunRowSchema: z.ZodType = z export interface IssueRunDetail { readonly id: string; + readonly actorUserId: string; + readonly actorName: string; + readonly requestedByUserId: string; + readonly requestedByName: string; + readonly ownerUserId: string | null; readonly agentId: string; readonly agent: string; readonly issue: { @@ -214,6 +219,23 @@ export interface IssueRunDetail { const IssueRunDetailSchema: z.ZodType = z .object({ id: z.string(), + actorUserId: z.string().meta({ + description: 'Execution identity, fixed when the run is first claimed.', + }), + actorName: z + .string() + .meta({ description: 'The name of the execution identity.' }), + requestedByUserId: z.string().meta({ + description: + 'The user associated with the chain origin; input actors identify the actual triggers, including system events.', + }), + requestedByName: z.string().meta({ + description: 'The name of the user associated with the chain origin.', + }), + ownerUserId: z + .string() + .nullable() + .meta({ description: 'The issue owner snapshot for this run.' }), agentId: z.string(), agent: z.string(), issue: z.object({ @@ -612,6 +634,11 @@ export const issueRunsRoutes: AppApiRouteContribution = ]; const data: IssueRunDetail = { id: run.id, + actorUserId: run.actorUserId, + actorName: detail.actorName ?? run.actorUserId, + requestedByUserId: run.requestedByUserId, + requestedByName: detail.requestedByName ?? run.requestedByUserId, + ownerUserId: run.ownerUserId, agentId: run.agentId, agent, issue: { diff --git a/packages/apps/studio/server/agents/work.ts b/packages/apps/studio/server/agents/work.ts index 4797b144ff5..0545fafe577 100644 --- a/packages/apps/studio/server/agents/work.ts +++ b/packages/apps/studio/server/agents/work.ts @@ -21,7 +21,7 @@ * - A person moving the issue into In progress starts the executing agent's work (`started` from backlog or a finished * status, else `statusChange`), unless they asked not to start now; moving it elsewhere tells a run that has not * ended. Finishing or closing the issue withdraws queued work instead. - * - A new owner: queued work the previous owner woke is withdrawn, and starts again as the new owner's. + * - A new owner: all queued agent work is withdrawn and held work is asked to stop. Resumption belongs to the handover flow. * - Moved to another project (`project-move.ts`): work starts in the new project's working directories * (`projectChanged`). A run claimed before the move works in the previous project's, so it is told to end and asked * to stop (left to end on its own when its agent moved the issue), and the new run starts once it has ended @@ -41,6 +41,7 @@ */ import { PROJECT_MANAGER_ID } from '../../shared/project-manager.js'; +import { studioError } from '../http/errors.js'; import { randomUUID } from 'node:crypto'; import type { ActorRef, RunInputType } from '@nocobase/agent-protocol'; @@ -85,6 +86,7 @@ import { import { readManagerSettings } from './manager/settings.js'; import { COMMENT_RUN_WINDOW_HOURS } from '../../shared/project-manager.js'; import { AGENT_KIND, agentsTx } from './tx.js'; +import { issueExecutionIdentity } from './execution-identity.js'; interface Wake { readonly agentId: string; @@ -92,6 +94,7 @@ interface Wake { readonly actorUserId: string; readonly triggerType: string; readonly freshSession?: boolean; + readonly causedByRunId?: string; readonly input: { readonly type: RunInputType; readonly actor: ActorRef; @@ -165,7 +168,10 @@ export interface AgentWork extends IssueWorkHandler { export function createAgentWork(deps: { readonly agents: Pick; - readonly projects: () => Pick; + readonly projects: () => Pick< + Projects, + 'subtasks' | 'issueContext' | 'commentQueries' | 'activity' + >; }): AgentWork { const { runs } = deps.agents; const subjectOf = (issue: Issue) => ({ kind: ISSUE_SUBJECT, id: issue.id }); @@ -182,7 +188,44 @@ export function createAgentWork(deps: { request.agentId, ); if (!agent) return { ...attempt, started: false, skipped: 'unavailable' }; - if (!deps.agents.agents.mayInvoke(agent, request.actorUserId)) + const identity = await issueExecutionIdentity(deps.agents, deps.projects)( + tx.conn, + { + subjectId: request.issue.id, + agentId: request.agentId, + causedByRunId: request.causedByRunId, + inputs: [request.input], + }, + ); + if (identity && !identity.actorUserId) { + if (!tx.rehearsal) { + await deps.projects().activity.record(tx.conn, { + issueId: request.issue.id, + actor: { type: request.input.actor.kind, id: request.input.actor.id }, + action: 'studio_execution_wait', + details: { + reason: identity.reason ?? 'noOwner', + trigger: request.triggerType, + agentId: request.agentId, + agent: agent.name, + ownerUserId: request.issue.ownerUserId, + owner: await userName(tx.conn, request.issue.ownerUserId), + }, + }); + tx.emit({ type: 'issue.changed', issueId: request.issue.id }); + } + return { + ...attempt, + started: false, + skipped: identity.reason === 'noOwner' ? 'unavailable' : 'denied', + }; + } + if ( + !deps.agents.agents.mayInvoke( + agent, + identity?.actorUserId ?? request.actorUserId, + ) + ) return { ...attempt, started: false, skipped: 'denied' }; // Held by unfinished issues: the release wakes the agent once nothing holds it. const blockers = await deps @@ -197,6 +240,8 @@ export function createAgentWork(deps: { agentId: agent.id, subject: subjectOf(request.issue), actorUserId: request.actorUserId, + requestedByUserId: request.actorUserId, + causedByRunId: request.causedByRunId, ownerUserId: request.issue.ownerUserId, priority: runPriorityOf(request.issue.priority), freshSession: request.freshSession, @@ -204,6 +249,10 @@ export function createAgentWork(deps: { ...request.input, payload: { trigger: request.triggerType, + ...(identity ? { ownerIdentity: true } : {}), + ...(request.causedByRunId + ? { causedByRunId: request.causedByRunId } + : {}), ...(request.input.payload && typeof request.input.payload === 'object' ? request.input.payload @@ -229,10 +278,15 @@ export function createAgentWork(deps: { tx: ProjectsTx, actor: Actor, issue: Issue, - ): Promise<{ readonly userId: string; readonly ref: ActorRef }> { + ): Promise<{ + readonly userId: string; + readonly ref: ActorRef; + readonly causedByRunId?: string; + }> { if (actor.type === 'user' && actor.id) return { userId: actor.id, + causedByRunId: actor.trace?.runId, ref: { kind: 'user', id: actor.id, @@ -241,6 +295,7 @@ export function createAgentWork(deps: { }; return { userId: issue.ownerUserId, + causedByRunId: actor.trace?.runId, ref: { kind: actor.type === AGENT_KIND ? 'agent' : 'system', id: actor.id ?? 'system', @@ -283,6 +338,16 @@ export function createAgentWork(deps: { ...(reason ? { skipped: reason } : {}), }); + function requireOwnerStart(issue: Issue, actor: Actor): void { + if (actor.type !== 'user' || actor.id !== issue.ownerUserId) + throw studioError( + 'PERMISSION_DENIED', + 'ISSUE_OWNER_START_REQUIRED', + 'The current issue owner must start the project manager.', + { metadata: { reason: 'ownerStartRequired' } }, + ); + } + /** Wakes each agent `held` names with the comment, for `userId`. */ async function wakeComment( tx: ProjectsTx, @@ -291,6 +356,7 @@ export function createAgentWork(deps: { userId: string, extra: Readonly>, ): Promise { + if (statusCategory(issue.statusKey) === 'unstarted') return []; const attempts: RunAttempt[] = []; for (const { agentId, triggerType } of held.targets) attempts.push( @@ -301,6 +367,10 @@ export function createAgentWork(deps: { agentId, issue, actorUserId: userId, + causedByRunId: + typeof extra.causedByRunId === 'string' + ? extra.causedByRunId + : undefined, triggerType, input: { type: 'comment', @@ -397,7 +467,11 @@ export function createAgentWork(deps: { tx: ProjectsTx, issue: Issue, agentId: string, - who: { readonly userId: string; readonly ref: ActorRef }, + who: { + readonly userId: string; + readonly ref: ActorRef; + readonly causedByRunId?: string; + }, triggerType: 'assigned' | 'started' = 'assigned', ): Promise { // In backlog, or finished: the work starts when the issue leaves backlog. @@ -406,6 +480,7 @@ export function createAgentWork(deps: { agentId, issue, actorUserId: who.userId, + causedByRunId: who.causedByRunId, triggerType, input: { type: 'signal', @@ -494,6 +569,18 @@ export function createAgentWork(deps: { return { async onRunFinished(tx, run) { if (run.subject.kind !== ISSUE_SUBJECT) return; + // An owner transfer cancels project-move continuation too; it must not restart work on its own. + const previous = await runs.identityInputs(tx.conn, run.id); + if ( + previous?.inputs.some( + (input) => + input.payload && + typeof input.payload === 'object' && + (input.payload as Record).trigger === + 'ownerChanged', + ) + ) + return; // Admission cancellations never restart old work; acknowledged project moves still do. if ( run.status === 'cancelled' && @@ -522,12 +609,45 @@ export function createAgentWork(deps: { .at(-1); if (!move) return; const agent = await deps.agents.agents.findWorkable(conn, run.agentId); - if (!agent || !deps.agents.agents.mayInvoke(agent, move.byUserId)) return; + if (!agent) return; + const identity = await issueExecutionIdentity(deps.agents, deps.projects)( + conn, + { + subjectId: issue.id, + agentId: run.agentId, + causedByRunId: run.id, + inputs: [], + }, + ); + if (identity && !identity.actorUserId) { + await deps.projects().activity.record(conn, { + issueId: issue.id, + actor: { type: move.by.kind, id: move.by.id }, + action: 'studio_execution_wait', + details: { + reason: identity.reason ?? 'noOwner', + trigger: PROJECT_CHANGED, + agentId: agent.id, + agent: agent.name, + ownerUserId: issue.owner.id, + owner: issue.owner.name, + }, + }); + return; + } + if ( + !deps.agents.agents.mayInvoke( + agent, + identity?.actorUserId ?? move.byUserId, + ) + ) + return; await runs.enqueue( { agentId: agent.id, subject: run.subject, actorUserId: move.byUserId, + causedByRunId: run.id, ownerUserId: issue.owner.id, priority: runPriorityOf(issue.priority as Priority), input: { @@ -546,6 +666,7 @@ export function createAgentWork(deps: { }, async startNow(tx, issue, actor) { + requireOwnerStart(issue, actor); const agentId = agentOf(issue); if (!agentId) throw new Error('No agent executes the issue.'); return assigned( @@ -559,8 +680,17 @@ export function createAgentWork(deps: { async onIssueChanged(tx, change) { const { before, after } = change; + // The owner-change hook withdrew queued work and requested cancellation; the waiting-for-takeover flow controls when work resumes. + if (before && before.ownerUserId !== after.ownerUserId) return []; const from = agentOf(before); const to = agentOf(after); + if ( + to === PROJECT_MANAGER_ID && + change.start && + !dormant(after) && + (!before || dormant(before) || to !== from) + ) + requireOwnerStart(after, change.actor); const who = await actorOf(tx, change.actor, after); if (from && from !== to) await stop( @@ -618,6 +748,7 @@ export function createAgentWork(deps: { agentId: to, issue: after, actorUserId: who.userId, + causedByRunId: who.causedByRunId, triggerType: started && fromDormant ? 'started' : 'statusChange', input: { type: 'statusChange', @@ -631,6 +762,7 @@ export function createAgentWork(deps: { async onCommentCreated(tx, change) { const { comment, issue, parent } = change; + if (statusCategory(issue.statusKey) === 'unstarted') return []; const authorId = change.actor.id; if (!authorId) return []; const byAgent = change.actor.type === AGENT_KIND; @@ -678,7 +810,10 @@ export function createAgentWork(deps: { triggerType, })), }; - if (!byAgent) return wakeComment(tx, issue, held, authorId, {}); + if (!byAgent) + return wakeComment(tx, issue, held, authorId, { + causedByRunId: change.actor.trace?.runId, + }); if (await commentLimited(tx, issue, held)) return held.targets.map((target) => skipped(target.agentId, issue, target.triggerType, 'limited'), @@ -686,6 +821,7 @@ export function createAgentWork(deps: { // Runs act for a person: the one the agent's run acts for, else the owner. return wakeComment(tx, issue, held, change.userId ?? issue.ownerUserId, { byAgent: true, + causedByRunId: change.actor.trace?.runId, }); }, @@ -694,32 +830,38 @@ export function createAgentWork(deps: { return wakeComment(tx, issue, held, userId, { continuedFrom }); }, - async onOwnerChanged(tx, { issue, from, to }) { - const withdrawn = await runs.withdrawQueued( - { subject: subjectOf(issue), actorUserId: from, byUserId: null }, + async onOwnerChanged(tx, { issue, from, to, actor }) { + if (tx.rehearsal) return []; + const subject = subjectOf(issue); + const who = await actorOf( + tx, + actor ?? { type: 'system', id: null }, + issue, + ); + // Preserve a transfer marker and every original input before cancellation sinks can run. + for (const run of await runs.openOn(tx.conn, subject)) + await runs.addInput( + run.id, + { + type: 'signal', + actor: who.ref, + text: `${issue.identifier} has a new owner. Stop this work; the new owner decides when to resume.`, + payload: { trigger: 'ownerChanged', from, to }, + }, + agentsTx(tx), + ); + await runs.withdrawQueued( + { + subject, + byUserId: actor?.type === 'user' ? actor.id : null, + detail: `${issue.identifier} is waiting for its new owner to take over.`, + }, agentsTx(tx), ); - const attempts: RunAttempt[] = []; - for (const agentId of new Set(withdrawn.map((run) => run.agentId))) - attempts.push( - await wake(tx, { - agentId, - issue, - actorUserId: to, - triggerType: 'ownerChanged', - input: { - type: 'signal', - actor: { - kind: 'user', - id: to, - name: await userName(tx.conn, to), - }, - text: `${issue.identifier} has a new owner; the work continues for them.`, - payload: { from, to }, - }, - }), - ); - return attempts; + for (const run of await runs.openOn(tx.conn, subject)) + if (run.status !== 'queued') + await runs.cancel(run.id, who.userId, agentsTx(tx)); + return []; }, async onBlocked(tx, { issue }) { diff --git a/packages/apps/studio/server/git/flow.ts b/packages/apps/studio/server/git/flow.ts index 9d81fdf9f05..071dd5f26db 100644 --- a/packages/apps/studio/server/git/flow.ts +++ b/packages/apps/studio/server/git/flow.ts @@ -37,6 +37,8 @@ import { } from '../../shared/git.js'; import { ISSUE_SUBJECT } from '../agents/catalog/triggers.js'; import { AGENT_KIND, agentsTx } from '../agents/tx.js'; +import { validIssueOwner } from '../agents/execution-identity.js'; +import { PROJECT_MANAGER_ID } from '../../shared/project-manager.js'; import type { StudioInboxPort } from '../inbox/port.js'; import { IN_REVIEW, @@ -59,7 +61,7 @@ import { export interface GitFlowDeps { readonly projects: () => Pick< Projects, - 'tx' | 'statusEvents' | 'issueContext' + 'tx' | 'statusEvents' | 'issueContext' | 'activity' >; readonly agents: () => Pick | undefined; readonly inbox: () => StudioInboxPort | undefined; @@ -271,7 +273,29 @@ export function createGitFlow(deps: GitFlowDeps): GitFlow { tx.conn, issue.executor.id, ); - if (!agent || !agents.agents.mayInvoke(agent, issue.owner.id)) continue; + if (!agent) continue; + const allowed = agents.agents.mayInvoke(agent, issue.owner.id); + if (agent.id === PROJECT_MANAGER_ID) { + const validOwner = await validIssueOwner(tx.conn, issue.owner.id); + if (!validOwner || !allowed) { + await deps.projects().activity.record(tx.conn, { + issueId: issue.id, + actor: { type: 'system', id: null }, + action: 'studio_execution_wait', + details: { + reason: validOwner ? 'notAllowed' : 'noOwner', + trigger: signal, + agentId: agent.id, + agent: agent.name, + ownerUserId: issue.owner.id, + owner: issue.owner.name, + }, + }); + tx.emit({ type: 'issue.changed', issueId: issue.id }); + continue; + } + } + if (!allowed) continue; await agents.runs.enqueue( { agentId: agent.id, diff --git a/packages/apps/studio/shared/project-manager.ts b/packages/apps/studio/shared/project-manager.ts index 7df7a94718c..5dabbc5def1 100644 --- a/packages/apps/studio/shared/project-manager.ts +++ b/packages/apps/studio/shared/project-manager.ts @@ -80,6 +80,15 @@ export type IssueProgressState = | 'idle'; export interface IssueProgress { + readonly waiting?: { + readonly reason: + | 'noOwner' + | 'notConfigured' + | 'noRunner' + | 'ownerHasNoRunner' + | 'notAllowed'; + readonly owner: { readonly id: string; readonly name: string } | null; + } | null; readonly state: IssueProgressState; readonly agent: { readonly id: string; readonly name: string } | null; readonly step: string | null; diff --git a/packages/apps/studio/tests/agents/bridge.test.ts b/packages/apps/studio/tests/agents/bridge.test.ts index ceca0a5a1e0..65a747ae562 100644 --- a/packages/apps/studio/tests/agents/bridge.test.ts +++ b/packages/apps/studio/tests/agents/bridge.test.ts @@ -97,7 +97,7 @@ describe('issues start work for the project manager', () => { expect.objectContaining({ type: 'signal', actor: { kind: 'user', id: 'alice', name: 'alice' }, - payload: { trigger: 'assigned' }, + payload: { trigger: 'assigned', ownerIdentity: true }, }), ]); }); @@ -176,10 +176,11 @@ describe('issues start work for the project manager', () => { }); }); - it("withdraws the previous owner's queued work and queues it again for the new owner", async () => { + it('withdraws queued work on transfer without changing identity or restarting', async () => { await h.createAgent(); const issue = await openIssue(alice()); const assigned = await assign(issue); + const [before] = await runsOf(issue.id); const { attempts } = await collectRunAttempts(() => h.projects.issues.update(alice(), issue.id, { revision: assigned.revision, @@ -189,11 +190,9 @@ describe('issues start work for the project manager', () => { const runs = await runsOf(issue.id); expect(runs.map((run) => [run.status, run.actorUserId]).sort()).toEqual([ ['cancelled', 'alice'], - ['queued', 'bob'], - ]); - expect(attempts).toEqual([ - expect.objectContaining({ triggerType: 'ownerChanged', started: true }), ]); + expect(runs[0].id).toBe(before.id); + expect(attempts).toEqual([]); }); }); @@ -226,7 +225,7 @@ describe('comments reach agents', () => { }); }); - it('queues another person’s comment separately behind the active work', async () => { + it('appends another person’s comment to the manager working as the owner', async () => { await h.createAgent(); const issue = await openIssue(alice()); await assign(issue); @@ -235,23 +234,25 @@ describe('comments reach agents', () => { content: 'Use the v2 API.', }); const work = await runsOf(issue.id); - expect(work).toHaveLength(2); - const next = work.find((run) => run.id !== payload.run.id)!; - expect(next).toMatchObject({ status: 'queued', actorUserId: 'bob' }); + expect(work).toHaveLength(1); + const next = work[0]; + expect(next).toMatchObject({ + id: payload.run.id, + status: 'dispatched', + actorUserId: 'alice', + }); expect( (await h.agents.runs.detail(payload.run.id)).inputs.some( (input) => input.text === 'Use the v2 API.', ), - ).toBe(false); + ).toBe(true); expect((await h.agents.runs.detail(next.id)).inputs.at(-1)).toMatchObject({ type: 'comment', text: 'Use the v2 API.', actor: { id: 'bob' }, }); const workload = await h.agents.runs.workload({ subjectKind: 'issue' }); - expect(workload.runs.find((run) => run.id === next.id)?.wait?.reason).toBe( - 'sameWorkActive', - ); + expect(workload.runs.find((run) => run.id === next.id)?.wait).toBeNull(); }); it('wakes a mentioned agent, and refuses mentioning one that takes no work', async () => { @@ -309,7 +310,7 @@ describe("an agent's comment", () => { return payload.run.id as string; } - it('wakes the agent it mentions, as the person its run acts for', async () => { + it('wakes a step the project manager mentions as the issue owner', async () => { const coordinator = PM; const worker = await h.createAgent({ name: 'Worker' }); const issue = await openIssue(alice(), { title: 'Split' }); @@ -320,7 +321,7 @@ describe("an agent's comment", () => { ); expect(triggered).toEqual([{ kind: 'agent', id: worker }]); const [run] = await runsOf(issue.id); - expect(run).toMatchObject({ agentId: worker, actorUserId: 'bob' }); + expect(run).toMatchObject({ agentId: worker, actorUserId: 'alice' }); expect((await h.agents.runs.detail(run!.id)).inputs.at(-1)).toMatchObject({ type: 'comment', actor: { kind: 'agent', id: coordinator, name: 'Project manager' }, @@ -511,6 +512,20 @@ describe("an agent's comment", () => { content: `${mention(worker, 'Worker')} again`, }); const pending = (await pendingCommentRun(h.projects, alice(), issue.id))!; + h.roles.set('bob', 'admin'); + expect( + await pendingCommentRun(h.projects, h.viewer('bob'), issue.id), + ).toBeNull(); + const refused = await h.request( + 'POST', + `/issueCommentRuns/${issue.id}/continue`, + { + user: 'bob', + body: { token: pending.token }, + }, + ); + expect(refused.status).toBe(403); + expect(refused.body.error.reason).toBe('ISSUE_OWNER_START_REQUIRED'); const attempts = await Promise.all( [0, 1].map(() => h.request('POST', `/issueCommentRuns/${issue.id}/continue`, { @@ -1514,6 +1529,28 @@ describe('an issue moved to another project', () => { ); } + it('does not restart a project move after its running agent is stopped by an owner transfer', async () => { + const { core, issue } = await movable(); + const payload = await h.claimOne(); + const runId = payload.run.id; + await start(runId); + await move(issue.id, core.id); + const current = await h.projects.issueQueries.detail(alice(), issue.id); + await h.projects.issues.update(alice(), issue.id, { + revision: current.revision, + ownerUserId: 'bob', + }); + expect((await h.runner(RUNNER_ROUTES.cancelAck, runId, {})).status).toBe( + 200, + ); + const runs = await runsOf(issue.id); + expect(runs).toHaveLength(1); + expect(runs[0]).toMatchObject({ + status: 'cancelled', + actorUserId: 'alice', + }); + }); + /** * The repositories the run's context lists as its project's working directories; the project manager checks none * of them out. diff --git a/packages/apps/studio/tests/agents/claim-admission.test.ts b/packages/apps/studio/tests/agents/claim-admission.test.ts index d729056054b..1507962a036 100644 --- a/packages/apps/studio/tests/agents/claim-admission.test.ts +++ b/packages/apps/studio/tests/agents/claim-admission.test.ts @@ -123,7 +123,7 @@ describe('claiming queued issue work', () => { }); }); - it('admits a mentioned agent on a backlog issue, and does not wake the project manager there', async () => { + it('does not wake any mentioned agent on a backlog issue', async () => { const harness = await setUp(); const coder = await harness.createAgent({ name: 'Coder' }); const issue = await harness.projects.issues.create( @@ -136,15 +136,9 @@ describe('claiming queued issue work', () => { const claimed = [await harness.claimOne(), await harness.claimOne()].filter( Boolean, ); - expect(claimed).toHaveLength(1); + expect(claimed).toHaveLength(0); const runs = await runsOf(harness, issue.id); - expect(runs.find((run) => run.agentId === coder)).toMatchObject({ - id: claimed[0].run.id, - status: 'dispatched', - }); - expect( - runs.find((run) => run.agentId === PROJECT_MANAGER_ID), - ).toBeUndefined(); + expect(runs).toEqual([]); }); it('cancels a run whose issue is gone', async () => { diff --git a/packages/apps/studio/tests/agents/comment-delta.test.ts b/packages/apps/studio/tests/agents/comment-delta.test.ts index 7336de2a8f0..41981f271c9 100644 --- a/packages/apps/studio/tests/agents/comment-delta.test.ts +++ b/packages/apps/studio/tests/agents/comment-delta.test.ts @@ -147,9 +147,11 @@ describe('an issue run', () => { }); const next = await h.claimOne(); expect(next.prompt.session).toBe('resume'); - expect(next.prompt.turn).toContain(`New comments on ${issue.identifier}`); + expect(next.prompt.turn).toContain( + `New input for issue ${issue.identifier}`, + ); expect(next.prompt.turn).toContain('Background from Bob.'); - // Alice's comment is this turn's input, so it is not repeated as news. + // Both comments merge into the owner's next run; neither is repeated as news. expect(next.prompt.turn.match(/Please continue\./gu)).toHaveLength(1); }); diff --git a/packages/apps/studio/tests/agents/delegation.test.ts b/packages/apps/studio/tests/agents/delegation.test.ts index 63d2fcfc148..c2cfe1b56f6 100644 --- a/packages/apps/studio/tests/agents/delegation.test.ts +++ b/packages/apps/studio/tests/agents/delegation.test.ts @@ -289,7 +289,7 @@ describe('delegate and report back', () => { const delegated = await delegate(); for (let round = 0; round <= WAKE_LIMIT; round += 1) { await moveIssue('carol', delegated.issueId, 'done'); - await moveIssue('carol', delegated.issueId, 'in_progress'); + await moveIssue('alice', delegated.issueId, 'in_progress'); } expect(await cards(delegated.conversationId)).toHaveLength(WAKE_LIMIT + 1); expect(await wakes(delegated.conversationId)).toHaveLength(WAKE_LIMIT); diff --git a/packages/apps/studio/tests/agents/manager-identity.test.ts b/packages/apps/studio/tests/agents/manager-identity.test.ts new file mode 100644 index 00000000000..78fcc6cd509 --- /dev/null +++ b/packages/apps/studio/tests/agents/manager-identity.test.ts @@ -0,0 +1,631 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + HEADERS, + PROTOCOL_VERSION, + RUNNER_ROUTES, +} from '@nocobase/agent-protocol'; +import { createBridgeHarness, type BridgeHarness } from './bridge-harness.js'; +import { PROJECT_MANAGER_ID as PM } from '../../shared/project-manager.js'; +import { runTriggerActor } from '../../client/agents/run-trigger.js'; + +let h: BridgeHarness; +beforeEach(async () => { + h = await createBridgeHarness(); + await h.addUser('alice'); + await h.addUser('bob'); + h.roles.set('bob', 'admin'); + await h.createAgent(); +}); +afterEach(async () => { + vi.restoreAllMocks(); + await h.close(); +}); +const runs = (id: string) => + h.agents.runs.list({ subjectKind: 'issue', subjectId: id }); +const issue = () => + h.projects.issues.create(h.viewer('alice'), { + title: 'Owner identity', + statusKey: 'in_progress', + start: false, + }); +const mention = (id: string) => `[@Agent](mention://agent/${id}) do the step`; + +describe('manager and delegated identities', () => { + it('keeps three handled retries and their subsequent delegation in the manager chain at enqueue and claim', async () => { + const task = await issue(); + const worker = await h.createAgent({ name: 'Worker' }); + const next = await h.createAgent({ name: 'Next' }); + await h.projects.comments.create( + { ...h.viewer('bob'), actor: { type: 'agent', id: PM } }, + task.id, + { content: mention(worker) }, + ); + let [run] = await runs(task.id); + await h.agents.runs.cancel(run.id, 'bob'); + for (let n = 0; n < 3; n++) { + await h.database + .connection() + .repository('agRunInputs') + .updateMany({ + filter: { runId: run.id }, + values: { handledAt: new Date().toISOString() }, + }); + run = await h.agents.runs.retry(run.id, 'bob'); + expect(run.actorUserId).toBe('alice'); + // Legacy incorrect actors must also be corrected by claim admission. + await h.database + .connection() + .repository('agRuns') + .updateMany({ + filter: { id: run.id }, + values: { actorUserId: 'bob' }, + }); + expect((await h.claimOne()).run.id).toBe(run.id); + expect((await h.agents.runs.get(run.id)).actorUserId).toBe('alice'); + if (n < 2) { + await h.agents.runs.cancel(run.id, 'bob'); + await h.runner(RUNNER_ROUTES.cancelAck, run.id, {}); + } + } + await h.projects.comments.create( + { + ...h.viewer('bob'), + actor: { type: 'agent', id: worker, trace: { runId: run.id } }, + }, + task.id, + { content: mention(next) }, + ); + const step = (await runs(task.id)).find((item) => item.agentId === next)!; + expect(step.actorUserId).toBe('alice'); + expect((await h.claimOne()).run.id).toBe(step.id); + expect((await h.agents.runs.get(step.id)).actorUserId).toBe('alice'); + }); + + it('selects this retry actor while retaining the earlier unhandled retry input', async () => { + const task = await issue(); + await h.projects.comments.create(h.viewer('alice'), task.id, { + content: 'Please work', + }); + const [original] = await runs(task.id); + await h.agents.runs.cancel(original.id, 'alice'); + const first = await h.agents.runs.retry(original.id, 'bob'); + await h.agents.runs.cancel(first.id, 'bob'); + const second = await h.agents.runs.retry(first.id, 'alice'); + const detail = await h.agents.runs.detail(second.id); + expect( + detail.inputs + .filter((input) => input.type === 'retry') + .map((input) => input.actor.id), + ).toEqual(['bob', 'alice']); + expect(runTriggerActor(detail)?.id).toBe('alice'); + }); + + it('preserves the retrying person for a direct worker through repeated handled retries', async () => { + const task = await issue(); + const worker = await h.createAgent({ name: 'Direct worker' }); + await h.projects.comments.create(h.viewer('bob'), task.id, { + content: mention(worker), + }); + let run = (await runs(task.id)).find((item) => item.agentId === worker)!; + for (let n = 0; n < 3; n++) { + await h.agents.runs.cancel(run.id, 'bob'); + await h.database + .connection() + .repository('agRunInputs') + .updateMany({ + filter: { runId: run.id }, + values: { handledAt: new Date().toISOString() }, + }); + run = await h.agents.runs.retry(run.id, 'bob'); + expect(run.actorUserId).toBe('bob'); + } + }); + + it('stops cyclic retry provenance and ignores a manager run on another subject', async () => { + const task = await issue(); + const worker = await h.createAgent({ name: 'Direct worker' }); + await h.projects.comments.create(h.viewer('bob'), task.id, { + content: mention(worker), + }); + const original = (await runs(task.id)).find( + (item) => item.agentId === worker, + )!; + const lookup = vi + .spyOn(h.agents.runs, 'identityInputs') + .mockImplementation(async (_conn, id) => ({ + run: { + ...original, + id, + retryOfRunId: id === 'first' ? 'second' : 'first', + }, + inputs: [], + })); + const resolve = h.agents.subjects.get('issue')!.actsAs!; + const work = { + subjectId: task.id, + agentId: worker, + inputs: [ + { + actor: { kind: 'user' as const, id: 'bob', name: 'Bob' }, + payload: { retryOfRunId: 'first' }, + }, + ], + }; + expect(await resolve(h.database.connection(), work)).toBeUndefined(); + expect(lookup).toHaveBeenCalledTimes(2); + lookup.mockResolvedValue({ + run: { + ...original, + agentId: PM, + subject: { ...original.subject, id: 'another-issue' }, + }, + inputs: [], + }); + expect(await resolve(h.database.connection(), work)).toBeUndefined(); + lookup.mockResolvedValue({ + run: { + ...original, + agentId: PM, + subject: { ...original.subject, kind: 'conversation' }, + }, + inputs: [], + }); + expect(await resolve(h.database.connection(), work)).toBeUndefined(); + }); + + it('requires the owner to start, then uses the owner for others commenting and retrying', async () => { + const task = await issue(); + expect( + ( + await h.request('POST', `/projectManager/issues/${task.id}/start`, { + user: 'bob', + }) + ).status, + ).toBe(403); + expect(await runs(task.id)).toHaveLength(0); + expect( + ( + await h.request('POST', `/projectManager/issues/${task.id}/start`, { + user: 'alice', + }) + ).status, + ).toBe(200); + await h.projects.comments.create(h.viewer('bob'), task.id, { + content: 'Please check this', + }); + const [run] = await runs(task.id); + expect(run).toMatchObject({ + agentId: PM, + actorUserId: 'alice', + requestedByUserId: 'alice', + ownerUserId: 'alice', + }); + expect( + (await h.agents.runs.detail(run.id)).inputs.map( + (input) => input.actor.id, + ), + ).toEqual(['alice', 'bob']); + await h.agents.runs.cancel(run.id, 'bob'); + const retried = await h.agents.runs.retry(run.id, 'bob'); + expect(retried).toMatchObject({ + actorUserId: 'alice', + requestedByUserId: 'bob', + }); + const claimed = await h.claimOne(); + h.roles.set('alice', 'none'); + expect( + (await h.request('GET', `/projects/issues/${task.id}`, { user: 'bob' })) + .status, + ).toBe(200); + expect( + ( + await h.request('GET', `/projects/issues/${task.id}`, { + runToken: claimed.cli.credential.content.token, + }) + ).status, + ).not.toBe(200); + }); + + it('never queues backlog comments, including owner and worker mentions', async () => { + const task = await h.projects.issues.create(h.viewer('alice'), { + title: 'Not started', + statusKey: 'backlog', + start: false, + }); + const worker = await h.createAgent({ name: 'Worker' }); + for (const user of ['alice', 'bob']) + await h.projects.comments.create(h.viewer(user), task.id, { + content: mention(worker), + }); + expect(await runs(task.id)).toEqual([]); + }); + + it('rejects a non-owner status start and rolls back the issue change', async () => { + const task = await h.projects.issues.create(h.viewer('alice'), { + title: 'Owner starts', + statusKey: 'backlog', + start: false, + }); + await expect( + h.projects.issues.update(h.viewer('bob'), task.id, { + revision: task.revision, + statusKey: 'in_progress', + start: true, + }), + ).rejects.toMatchObject({ reason: 'ISSUE_OWNER_START_REQUIRED' }); + expect( + (await h.projects.issueQueries.detail(h.viewer('alice'), task.id)) + .statusKey, + ).toBe('backlog'); + expect(await runs(task.id)).toEqual([]); + }); + + it('covers the delegated chain but preserves direct human worker mentions', async () => { + const task = await issue(); + const worker = await h.createAgent({ name: 'Worker' }); + const next = await h.createAgent({ name: 'Next' }); + await h.projects.comments.create(h.viewer('bob'), task.id, { + content: mention(worker), + }); + expect( + (await runs(task.id)).find((run) => run.agentId === worker)?.actorUserId, + ).toBe('bob'); + const delegated = await h.projects.comments.create( + { ...h.viewer('bob'), actor: { type: 'agent', id: PM } }, + task.id, + { content: mention(next) }, + ); + const chain = (await runs(task.id)).find((run) => run.agentId === next)!; + expect(chain.actorUserId).toBe('alice'); + await h.projects.comments.create( + { + ...h.viewer('alice'), + actor: { type: 'agent', id: next, trace: { runId: chain.id } }, + }, + task.id, + { parentId: delegated.comment.id, content: mention(worker) }, + ); + expect( + (await runs(task.id)).find( + (run) => run.agentId === worker && run.actorUserId === 'alice', + ), + ).toBeDefined(); + await h.addUser('carol'); + const current = await h.projects.issueQueries.detail( + h.viewer('bob'), + task.id, + ); + await h.projects.issues.update(h.viewer('bob'), task.id, { + revision: current.revision, + ownerUserId: 'carol', + }); + const switched = await runs(task.id); + expect( + switched.find( + (run) => run.agentId === worker && run.actorUserId === 'bob', + ), + ).toBeDefined(); + expect(switched.every((run) => run.status === 'cancelled')).toBe(true); + expect(switched.find((run) => run.id === chain.id)?.actorUserId).toBe( + 'alice', + ); + }); + + it('corrects legacy delegated data at claim and requests cancellation on transfer', async () => { + const task = await issue(); + const worker = await h.createAgent({ name: 'Worker' }); + await h.projects.comments.create( + { ...h.viewer('bob'), actor: { type: 'agent', id: PM } }, + task.id, + { content: mention(worker) }, + ); + const [run] = await runs(task.id); + const original = (await h.agents.runs.detail(run.id)).inputs; + // Simulate a pre-upgrade hand-over, without the new identity marker. + await h.database + .connection() + .repository('agRuns') + .updateMany({ filter: { id: run.id }, values: { actorUserId: 'bob' } }); + await h.database + .connection() + .repository('agRunInputs') + .updateMany({ + filter: { runId: run.id }, + values: { + payload: { + commentId: (original[0].payload as { commentId: string }).commentId, + }, + }, + }); + const payload = await h.claimOne(); + expect(payload.run.id).toBe(run.id); + expect((await h.agents.runs.get(run.id)).actorUserId).toBe('alice'); + const current = await h.projects.issueQueries.detail( + h.viewer('bob'), + task.id, + ); + await h.projects.issues.update(h.viewer('bob'), task.id, { + revision: current.revision, + ownerUserId: 'bob', + }); + expect((await h.agents.runs.get(run.id)).actorUserId).toBe('alice'); + expect((await h.agents.runs.get(run.id)).cancelRequestedAt).not.toBeNull(); + expect( + (await h.agents.runs.detail(run.id)).inputs.at(-1)?.payload, + ).toMatchObject({ trigger: 'ownerChanged' }); + expect(await runs(task.id)).toHaveLength(1); + expect((await h.runner(RUNNER_ROUTES.cancelAck, run.id, {})).status).toBe( + 200, + ); + }); + + it('withdraws every queued agent on transfer, retains inputs and stops held work even when the executor is cleared', async () => { + const task = await issue(); + await h.request('POST', `/projectManager/issues/${task.id}/start`, { + user: 'alice', + }); + const payload = await h.claimOne(); + const worker = await h.createAgent({ name: 'Direct worker' }); + await h.projects.comments.create(h.viewer('bob'), task.id, { + content: mention(worker), + }); + const before = await runs(task.id); + const queued = before.filter((run) => run.status === 'queued'); + expect(queued.length).toBeGreaterThan(0); + const inputs = await Promise.all( + queued.map((run) => h.agents.runs.detail(run.id)), + ); + const current = await h.projects.issueQueries.detail( + h.viewer('bob'), + task.id, + ); + await h.projects.issues.update(h.viewer('bob'), task.id, { + revision: current.revision, + ownerUserId: 'bob', + executor: null, + }); + const after = await runs(task.id); + expect(after).toHaveLength(before.length); + for (const previous of inputs) { + const now = await h.agents.runs.detail(previous.id); + expect(now.status).toBe('cancelled'); + expect(now.actorUserId).toBe(previous.actorUserId); + expect(now.inputs.slice(0, previous.inputs.length)).toEqual( + previous.inputs, + ); + } + expect(await h.agents.runs.get(payload.run.id)).toMatchObject({ + actorUserId: 'alice', + status: 'dispatched', + cancelRequestedAt: expect.any(String), + }); + expect( + (await h.agents.runs.detail(payload.run.id)).inputs.at(-1)?.actor.id, + ).toBe('bob'); + const acknowledged = await h.runner( + RUNNER_ROUTES.cancelAck, + payload.run.id, + {}, + ); + expect(acknowledged.status).toBe(200); + expect((await h.agents.runs.get(payload.run.id)).status).toBe('cancelled'); + await expect( + h.agents.runs.authenticateToken(payload.cli.credential.content.token), + ).rejects.toMatchObject({ code: 'RUN_TOKEN_INVALID' }); + expect(await runs(task.id)).toHaveLength(before.length); + }); + + it('follows recorded run provenance for user-approved steps and legacy queued inputs', async () => { + const task = await issue(); + const worker = await h.createAgent({ name: 'Worker' }); + const next = await h.createAgent({ name: 'Next' }); + await h.projects.comments.create( + { ...h.viewer('bob'), actor: { type: 'agent', id: PM } }, + task.id, + { content: mention(worker) }, + ); + const cause = (await runs(task.id)).find((run) => run.agentId === worker)!; + const result = await h.projects.comments.create( + { + ...h.viewer('bob'), + actor: { + type: 'user', + id: 'bob', + via: 'agent', + trace: { runId: cause.id, agentId: worker }, + }, + }, + task.id, + { content: mention(next) }, + ); + const step = (await runs(task.id)).find((run) => run.agentId === next)!; + expect(step.actorUserId).toBe('alice'); + await h.database + .connection() + .repository('agRunInputs') + .updateMany({ + filter: { runId: step.id }, + values: { payload: { commentId: result.comment.id } }, + }); + await h.addUser('carol'); + const current = await h.projects.issueQueries.detail( + h.viewer('bob'), + task.id, + ); + await h.projects.issues.update(h.viewer('bob'), task.id, { + revision: current.revision, + ownerUserId: 'carol', + }); + expect(await h.agents.runs.get(step.id)).toMatchObject({ + actorUserId: 'alice', + status: 'cancelled', + }); + }); + + it('retains the system source and reports no runner and inactive owner waiting', async () => { + const task = await issue(); + await h.agents.runs.enqueue({ + agentId: PM, + subject: { kind: 'issue', id: task.id }, + actorUserId: 'alice', + input: { + type: 'signal', + actor: { kind: 'system', id: 'system', name: 'Studio' }, + text: 'PR checks passed', + payload: { trigger: 'prChecksPassed' }, + }, + }); + const [run] = await runs(task.id); + expect(run.actorUserId).toBe('alice'); + expect((await h.agents.runs.detail(run.id)).inputs[0].actor.kind).toBe( + 'system', + ); + const queued = await h.request('GET', `/issueProgress/${task.id}`, { + user: 'bob', + }); + expect(queued.body.data.waiting.reason).toBe('noRunner'); + const manager = await h.agents.agents.findWorkable( + h.database.connection(), + PM, + ); + const registration = await h.agents.runners.createRegistrationToken('bob', { + trust: 'ownerOnly', + }); + const registered = await h.request('POST', '/agents/runners/register', { + body: { + registrationToken: registration.token, + name: 'Bob team runner', + hostname: 'test', + os: 'linux', + arch: 'x64', + version: '0.0.1', + protocolVersion: PROTOCOL_VERSION, + slots: 1, + features: ['input', 'checkout', 'directories', 'skills', 'secrets'], + tools: [ + { + kind: 'claude', + authenticated: true, + models: [{ id: manager!.modelEntries[0].model }], + }, + ], + }, + }); + expect(registered.status).toBe(200); + expect( + (await h.request('GET', `/issueProgress/${task.id}`, { user: 'bob' })) + .body.data.waiting.reason, + ).toBe('ownerHasNoRunner'); + const settings = await h.manager.settings(); + await h.manager.update('bob', { + firstChoiceAgentId: null, + fallbackAgentId: null, + }); + expect( + (await h.request('GET', `/issueProgress/${task.id}`, { user: 'bob' })) + .body.data.waiting.reason, + ).toBe('notConfigured'); + await h.manager.update('bob', { + firstChoiceAgentId: settings.firstChoiceAgentId, + fallbackAgentId: settings.fallbackAgentId, + }); + await h.database + .connection() + .repository('user') + .updateMany({ + filter: { id: 'alice' }, + values: { disabledAt: new Date().toISOString() }, + }); + const invalid = await h.request('GET', `/issueProgress/${task.id}`, { + user: 'bob', + }); + expect(invalid.body.data.waiting.reason).toBe('noOwner'); + await h.projects.comments.create(h.viewer('bob'), task.id, { + content: 'Try again', + }); + const activities = ( + await h.projects.issueQueries.activities(h.viewer('bob'), task.id, {}) + ).data; + expect(activities).toContainEqual( + expect.objectContaining({ + action: 'studio_execution_wait', + details: expect.objectContaining({ reason: 'noOwner' }), + }), + ); + const claim = await h.request('POST', '/agents/runners/claim', { + headers: { [HEADERS.runnerKey]: registered.body.data.runnerKey }, + body: { free: 1 }, + }); + expect(claim.status).toBe(200); + expect(claim.body.data.runs).toEqual([]); + expect((await h.agents.runs.get(run.id)).failureDetail).toBe('noOwner'); + }); + + it('retries a delegated step as the new owner even when every original input was handled', async () => { + const task = await issue(); + const worker = await h.createAgent({ name: 'Worker' }); + await h.projects.comments.create( + { ...h.viewer('bob'), actor: { type: 'agent', id: PM } }, + task.id, + { content: mention(worker) }, + ); + const [previous] = await runs(task.id); + await h.agents.runs.cancel(previous.id, 'bob'); + await h.database + .connection() + .repository('agRunInputs') + .updateMany({ + filter: { runId: previous.id }, + values: { handledAt: new Date().toISOString() }, + }); + const current = await h.projects.issueQueries.detail( + h.viewer('bob'), + task.id, + ); + await h.projects.issues.update(h.viewer('bob'), task.id, { + revision: current.revision, + ownerUserId: 'bob', + }); + const retry = await h.agents.runs.retry(previous.id, 'bob'); + expect(retry.actorUserId).toBe('bob'); + expect( + (await h.agents.runs.detail(retry.id)).inputs.map((input) => input.type), + ).toEqual(['retry']); + const claimed = await h.claimOne(); + expect(claimed.run.id).toBe(retry.id); + expect((await h.agents.runs.get(retry.id)).actorUserId).toBe('bob'); + }); + + it('checks owner invocation permission and records denial without fallback', async () => { + const task = await issue(); + const invoke = vi + .spyOn(h.agents.agents, 'mayInvoke') + .mockImplementation((_agent, userId) => userId !== 'alice'); + const started = await h.request( + 'POST', + `/projectManager/issues/${task.id}/start`, + { user: 'alice' }, + ); + expect(started.status).toBe(400); + expect(await runs(task.id)).toEqual([]); + expect(invoke).toHaveBeenCalledWith( + expect.objectContaining({ id: PM }), + 'alice', + ); + const now = await h.request('GET', `/issueProgress/${task.id}`, { + user: 'bob', + }); + expect(now.body.data.waiting.reason).toBe('notAllowed'); + expect( + (await h.projects.issueQueries.activities(h.viewer('bob'), task.id, {})) + .data, + ).toContainEqual( + expect.objectContaining({ + action: 'studio_execution_wait', + actorId: 'alice', + details: expect.objectContaining({ reason: 'notAllowed' }), + }), + ); + }); +}); diff --git a/packages/apps/studio/tests/cli/manifest.test.ts b/packages/apps/studio/tests/cli/manifest.test.ts index e413f9a5f4a..afc278a9fe2 100644 --- a/packages/apps/studio/tests/cli/manifest.test.ts +++ b/packages/apps/studio/tests/cli/manifest.test.ts @@ -207,6 +207,8 @@ describe('Studio’s command manifest', () => { const issue = await projects.issues.create(viewer, { title: 'Fix login', executor: null, + statusKey: 'in_progress', + start: false, }); await projects.comments.post({ type: 'user', id: me.user.id }, issue.id, { content: `[@Coder](mention://agent/${agentId}) fix it`, diff --git a/packages/apps/studio/tests/components/comment-run-limit.test.tsx b/packages/apps/studio/tests/components/comment-run-limit.test.tsx index 84b0aa396da..a63ad544a99 100644 --- a/packages/apps/studio/tests/components/comment-run-limit.test.tsx +++ b/packages/apps/studio/tests/components/comment-run-limit.test.tsx @@ -135,4 +135,11 @@ describe('continuing an agent comment', () => { expect(screen.queryByRole('button')).not.toBeInTheDocument(); expect(mocks.request).not.toHaveBeenCalled(); }); + + it('hides continuation from an editor who is not the owner', async () => { + mocks.viewer = { userId: 'bob' }; + await mount(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(mocks.request).not.toHaveBeenCalled(); + }); }); diff --git a/packages/apps/studio/tests/components/issue-progress.test.tsx b/packages/apps/studio/tests/components/issue-progress.test.tsx index d9723987b01..7a074c4c57f 100644 --- a/packages/apps/studio/tests/components/issue-progress.test.tsx +++ b/packages/apps/studio/tests/components/issue-progress.test.tsx @@ -27,6 +27,7 @@ const mocks = vi.hoisted(() => ({ request: vi.fn(), show: vi.fn(), editable: true, + userId: 'alice', })); vi.mock('@nocobase/app-client', async (original) => ({ ...(await original()), @@ -34,7 +35,7 @@ vi.mock('@nocobase/app-client', async (original) => ({ useToaster: () => ({ show: mocks.show }), })); vi.mock('@nocobase/app-plugin-projects/client/kit', () => ({ - useViewer: () => ({ userId: 'alice' }), + useViewer: () => ({ userId: mocks.userId }), })); vi.mock('@nocobase/app-plugin-projects/client/issues', () => ({ canEditIssues: () => mocks.editable, @@ -54,10 +55,11 @@ const progress = (overrides: Partial = {}): IssueProgress => ({ ...overrides, }); const issue = (statusKey: string) => - ({ id: 'i1', statusKey, revision: 1 }) as IssueDetail; + ({ id: 'i1', ownerUserId: 'alice', statusKey, revision: 1 }) as IssueDetail; beforeEach(() => { mocks.editable = true; + mocks.userId = 'alice'; mocks.request.mockReset(); mocks.show.mockReset(); }); @@ -227,3 +229,14 @@ describe('what an issue waits for', () => { expect(screen.queryByRole('button')).toBeNull(); }); }); + +it('hides start and hand-over from an editor who is not the owner', async () => { + mocks.userId = 'bob'; + await mount('backlog', progress()); + expect(screen.queryByRole('button', { name: 'Start' })).toBeNull(); + cleanup(); + await mount('in_progress', progress({ handOver: true })); + expect( + screen.queryByRole('button', { name: 'Hand over to the project manager' }), + ).toBeNull(); +}); diff --git a/packages/apps/studio/tests/components/issue-run-transcript.test.tsx b/packages/apps/studio/tests/components/issue-run-transcript.test.tsx index 401877ef349..1f94d8eb7b6 100644 --- a/packages/apps/studio/tests/components/issue-run-transcript.test.tsx +++ b/packages/apps/studio/tests/components/issue-run-transcript.test.tsx @@ -1,4 +1,5 @@ import agents from '@nocobase/app-plugin-agents/client'; +import type { RunDetail } from '@nocobase/app-plugin-agents/shared/runs'; import { createTestI18nRuntime, TestI18nProvider, @@ -26,6 +27,8 @@ const state = vi.hoisted(() => ({ basePath: '/main', events: [] as RunTranscriptEvent[], retriedRunId: null as string | null, + retryOfRunId: null as string | null, + inputs: [] as RunDetail['inputs'], })); vi.mock('@nocobase/app-client', async (original) => ({ ...(await original()), @@ -57,6 +60,9 @@ vi.mock('@nocobase/app-plugin-agents/client/runs', async (original) => ({ data: { id: 'run-1', retriedRunId: state.retriedRunId, + retryOfRunId: state.retryOfRunId, + actorUserId: 'alice', + actorName: 'Alice', agentId: 'coder', status: 'failed', createdAt: '2026-10-01T00:00:00.000Z', @@ -65,7 +71,7 @@ vi.mock('@nocobase/app-plugin-agents/client/runs', async (original) => ({ cancelRequestedAt: null, failureReason: 'toolProcess', failureDetail: 'The command failed', - inputs: [], + inputs: state.inputs, maxAttempts: 1, summary: 'Run summary', }, @@ -135,10 +141,40 @@ beforeEach(() => { state.basePath = '/main'; state.events = mixedRunEvents(); state.retriedRunId = null; + state.retryOfRunId = null; + state.inputs = []; }); afterEach(cleanup); describe('Studio issue run transcript', () => { + it.each(['en-US', 'zh-CN'])( + 'shows the current retry trigger rather than a copied retry (%s)', + async (locale) => { + state.retryOfRunId = 'bob-retry'; + state.inputs = ['Bob', 'Alice'].map((name, index) => ({ + id: `input-${index}`, + runId: 'run-1', + type: 'retry', + text: 'Retry', + actor: { kind: 'user', id: name.toLowerCase(), name }, + payload: { retryOfRunId: index === 0 ? 'original' : 'bob-retry' }, + createdAt: '2026-10-01T00:00:00.000Z', + handledAt: null, + deliveredAt: null, + })); + await setup(locale); + expect( + screen.getByText( + locale === 'zh-CN' ? /触发人:Alice/u : /Triggered by Alice/u, + ), + ).toBeInTheDocument(); + expect( + screen.queryByText( + locale === 'zh-CN' ? /触发人:Bob/u : /Triggered by Bob/u, + ), + ).not.toBeInTheDocument(); + }, + ); it.each(['en-US', 'zh-CN'])( 'links the replacement instead of offering another retry (%s)', async (locale) => { diff --git a/packages/apps/studio/tests/deploys/marks.test.ts b/packages/apps/studio/tests/deploys/marks.test.ts index 1661c3bc914..918c7358eaa 100644 --- a/packages/apps/studio/tests/deploys/marks.test.ts +++ b/packages/apps/studio/tests/deploys/marks.test.ts @@ -89,6 +89,8 @@ async function pushedIssue(title: string, sha: string): Promise { title, projectId, executor: null, + statusKey: 'in_progress', + start: false, }); // Alice hands it the step. await h.projects.comments.post({ type: 'user', id: 'alice' }, issue.id, { diff --git a/packages/apps/studio/tests/git/connections.test.ts b/packages/apps/studio/tests/git/connections.test.ts index 76e50f69a5b..cf38103b380 100644 --- a/packages/apps/studio/tests/git/connections.test.ts +++ b/packages/apps/studio/tests/git/connections.test.ts @@ -744,7 +744,15 @@ describe('nb-studio pr open', () => { 'studio.git/open-pr', ], }); - const { issue, resource } = await projectWithRepo(connection); + const { issue: created, resource } = await projectWithRepo( + connection, + 'in_progress', + ); + const issue = await h.projects.issues.update(alice(), created.id, { + revision: created.revision, + executor: null, + start: false, + }); await mentionAgent(h, 'alice', issue.id, agentId); beforeClaim?.(); const payload = await h.claimOne(); @@ -952,6 +960,8 @@ describe('nb-studio pr open', () => { }); const loose = await h.projects.issues.create(alice(), { title: 'Loose', + executor: null, + statusKey: 'in_progress', start: false, }); await mentionAgent(h, 'alice', loose.id, agentId); @@ -1000,6 +1010,8 @@ describe('nb-studio pr open', () => { const agentId = await h.createAgent({ actions: ['pm.issues/view'] }); const second = await h.projects.issues.create(alice(), { title: 'Second', + executor: null, + statusKey: 'in_progress', projectId: project.id, start: false, }); @@ -1012,6 +1024,8 @@ describe('nb-studio pr open', () => { h.preferences.set(`alice:${ATTRIBUTION_PREFERENCE}`, 'withAgent'); const third = await h.projects.issues.create(alice(), { title: 'Third', + executor: null, + statusKey: 'in_progress', projectId: project.id, start: false, }); diff --git a/packages/apps/studio/tests/git/git.test.ts b/packages/apps/studio/tests/git/git.test.ts index 29f2219d8b2..24bcc244f3f 100644 --- a/packages/apps/studio/tests/git/git.test.ts +++ b/packages/apps/studio/tests/git/git.test.ts @@ -704,6 +704,8 @@ describe('pull requests', () => { }); const issue = await h.projects.issues.create(alice(), { title: 'Fix login', + executor: null, + statusKey: 'in_progress', start: false, }); await mentionAgent(h, 'alice', issue.id, agentId); @@ -857,6 +859,8 @@ describe('pull requests', () => { }); const issue = await h.projects.issues.create(alice(), { title: 'Fix login', + executor: null, + statusKey: 'in_progress', start: false, }); await mentionAgent(h, 'alice', issue.id, agentId); diff --git a/packages/apps/studio/tests/git/pr-routes.test.ts b/packages/apps/studio/tests/git/pr-routes.test.ts index 9c9a4d5490d..dda029db90c 100644 --- a/packages/apps/studio/tests/git/pr-routes.test.ts +++ b/packages/apps/studio/tests/git/pr-routes.test.ts @@ -27,6 +27,8 @@ async function runOn(actions: readonly string[]) { const agentId = await h.createAgent({ actions: [...actions] }); const issue = await h.projects.issues.create(h.viewer('alice'), { title: 'Fix login', + executor: null, + statusKey: 'in_progress', start: false, }); await h.projects.comments.create(h.viewer('alice'), issue.id, { diff --git a/packages/apps/studio/tests/logic/run-wording.test.ts b/packages/apps/studio/tests/logic/run-wording.test.ts new file mode 100644 index 00000000000..c1a3080e2c1 --- /dev/null +++ b/packages/apps/studio/tests/logic/run-wording.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment node +import fs from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import en from '../../client/locales/en-US.js'; +import zh from '../../client/locales/zh-CN.js'; +import { progressSentence } from '../../client/issues/detail/progress-text.js'; +import type { IssueProgress } from '../../shared/project-manager.js'; + +describe('run wording after agent configuration removal', () => { + it('keeps model, effort and historical activity wording in both Studio locales', () => { + for (const locale of [en, zh]) { + expect(locale.runTranscript.usedModel).toContain('{{model}}'); + expect(locale.runTranscript.defaultModel).toBeTruthy(); + expect(locale.runTranscript.afterSubtasks).toBeTruthy(); + for (const effort of [ + 'off', + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', + 'max', + 'ultra', + ] as const) + expect(locale.runTranscript.efforts[effort]).toBeTruthy(); + } + expect(zh.runTranscript.usedModel).toBe('使用模型:{{model}}'); + const sources = [ + 'agents/issue-runs.tsx', + 'issues/detail/activity-row.tsx', + 'issues/detail/labels.ts', + 'projects/detail/labels.ts', + ]; + const deleted = [ + 'modelEntries.used', + 'agents.defaultModel', + 'agentForm.efforts.', + 'activity.afterSubtasks', + 'projects.workflow', + 'start.title', + 'start.later', + 'start.start', + ]; + for (const file of sources) { + const source = fs.readFileSync( + new URL(`../../client/${file}`, import.meta.url), + 'utf8', + ); + for (const key of deleted) expect(source).not.toContain(key); + } + }); + + it('renders each waiting reason in both languages', () => { + for (const locale of [en, zh]) { + for (const reason of [ + 'noOwner', + 'notConfigured', + 'noRunner', + 'ownerHasNoRunner', + 'notAllowed', + ] as const) { + const progress = { + state: 'queued', + agent: { id: 'manager', name: 'Manager' }, + waiting: { reason, owner: { id: 'a', name: 'Alice' } }, + } as IssueProgress; + const t = (key: string, options?: Record) => { + const text = key + .split('.') + .reduce( + (value, part) => (value as Record)[part], + locale, + ); + expect(typeof text).toBe('string'); + return String(text).replace( + /\{\{(\w+)\}\}/gu, + (_match, name: string) => String(options?.[name] ?? ''), + ); + }; + const sentence = progressSentence(t, progress); + expect(sentence).not.toContain('issueProgress.'); + if (reason === 'ownerHasNoRunner' || reason === 'notAllowed') + expect(sentence).toContain('Alice'); + } + } + }); +}); diff --git a/packages/apps/studio/tests/previews/routes.test.ts b/packages/apps/studio/tests/previews/routes.test.ts index a23a61cec84..94b0628cbec 100644 --- a/packages/apps/studio/tests/previews/routes.test.ts +++ b/packages/apps/studio/tests/previews/routes.test.ts @@ -24,6 +24,8 @@ async function runOn(actions: readonly string[]) { const issue = await h.projects.issues.create(h.viewer('alice'), { title: 'Fix login', executor: null, + statusKey: 'in_progress', + start: false, }); await h.projects.comments.post({ type: 'user', id: 'alice' }, issue.id, { content: `[@Coder](mention://agent/${agentId}) look at it`, diff --git a/packages/apps/studio/tests/reports/dashboard.test.ts b/packages/apps/studio/tests/reports/dashboard.test.ts index 154231b7356..1d98d548a58 100644 --- a/packages/apps/studio/tests/reports/dashboard.test.ts +++ b/packages/apps/studio/tests/reports/dashboard.test.ts @@ -88,6 +88,12 @@ async function seed() { title: `Work ${issues.length + 1}`, projectId, executor: null, + start: false, + }); + await h.projects.issues.update(alice, issue.id, { + revision: issue.revision, + statusKey: 'in_progress', + start: false, }); await h.projects.comments.post({ type: 'user', id: 'alice' }, issue.id, { content: `[@Coder](mention://agent/${coder}) do it`, diff --git a/packages/apps/studio/tests/reports/metrics.test.ts b/packages/apps/studio/tests/reports/metrics.test.ts index 583f0c660e2..b0b0d628c1e 100644 --- a/packages/apps/studio/tests/reports/metrics.test.ts +++ b/packages/apps/studio/tests/reports/metrics.test.ts @@ -71,6 +71,8 @@ async function seed() { title: `Work ${index + 1}`, projectId, executor: null, + statusKey: 'in_progress', + start: false, }); // Alice hands the first two their step, Bob the last: its run acts for him. await h.projects.comments.post( diff --git a/packages/plugins/app-plugin-agents/server/core/runs/claim.ts b/packages/plugins/app-plugin-agents/server/core/runs/claim.ts index 6c250a5fa80..3f1aa007a7d 100644 --- a/packages/plugins/app-plugin-agents/server/core/runs/claim.ts +++ b/packages/plugins/app-plugin-agents/server/core/runs/claim.ts @@ -108,6 +108,7 @@ import { } from './run.store.js'; import { dropInputs } from './input-gate.js'; import { finishRun, type TransitionDeps } from './transitions.js'; +import { refreshQueuedIdentity } from './identity.js'; import { consumeReset, saveBrief } from './workspace.store.js'; /** Queued runs looked at per claim, oldest first by priority. */ @@ -775,6 +776,18 @@ export function createClaimService(deps: ClaimDeps): ClaimService { candidate: RunRecord, reported: ToolRoom, ): Promise { + // Repair pre-upgrade queued work before even checking personal runner ownership. + if (deps.subjects.get(candidate.subjectKind)?.actsAs) { + const current = await tx.run(async (unit) => { + await lockAgentForClaim(unit.conn, agent.id, clock.now().toISOString()); + const run = await findRunRecord(unit.conn, candidate.id); + return run?.status === 'queued' + ? refreshQueuedIdentity(unit, deps, run) + : null; + }); + if (!current) return { kind: 'skipped' }; + candidate = current; + } // Avoid assembling or asking authorization when a cheap claim rule already rules this work out. const conn = tx.read(); if ( @@ -801,6 +814,12 @@ export function createClaimService(deps: ClaimDeps): ClaimService { const now = clock.now(); const nowText = now.toISOString(); await lockAgentForClaim(conn, agent.id, nowText); + const current = await findRunRecord(conn, candidate.id); + if (!current || current.status !== 'queued') return null; + const identity = await refreshQueuedIdentity(unit, deps, current); + // Providers were prepared for the candidate's identity outside this transaction. + if (!identity || identity.actorUserId !== candidate.actorUserId) + return null; if ( (await countActive(conn, 'agentId', agent.id)) >= agent.maxConcurrentRuns @@ -827,6 +846,7 @@ export function createClaimService(deps: ClaimDeps): ClaimService { f.and([ f.string('id').eq(candidate.id), f.string('status').eq('queued'), + f.string('actorUserId').eq(candidate.actorUserId), ]), values: { status: 'dispatched', @@ -1010,7 +1030,12 @@ export function createClaimService(deps: ClaimDeps): ClaimService { if (!agents.has(candidate.agentId)) agents.set(candidate.agentId, await findAgent(conn, candidate.agentId)); const agent = agents.get(candidate.agentId); - if (!agent || !fits(runner, agent, candidate)) continue; + if ( + !agent || + (!deps.subjects.get(candidate.subjectKind)?.actsAs && + !fits(runner, agent, candidate)) + ) + continue; // None of its tools has room on the runner's side: the runs behind it may still fit. if (!pickOpenEntry(runner, agent, (tool) => reported.has(tool))) continue; try { diff --git a/packages/plugins/app-plugin-agents/server/core/runs/identity.ts b/packages/plugins/app-plugin-agents/server/core/runs/identity.ts new file mode 100644 index 00000000000..5b42077a8ae --- /dev/null +++ b/packages/plugins/app-plugin-agents/server/core/runs/identity.ts @@ -0,0 +1,64 @@ +/** Subject-selected identities, refreshed before a queued run can borrow a runner or credentials. */ +import type { Tx } from '../../kernel/tx.js'; +import type { TransitionDeps } from './transitions.js'; +import { finishRun } from './transitions.js'; +import { + inputsRepo, + runsRepo, + toInput, + toExecutions, + type RunRecord, +} from './run.store.js'; + +export async function refreshQueuedIdentity( + unit: Tx, + deps: TransitionDeps, + run: RunRecord, +): Promise { + const resolve = deps.subjects.get(run.subjectKind)?.actsAs; + // A run returned to the queue after a lease loss has already fixed its identity. + if ( + !resolve || + run.status !== 'queued' || + run.dispatchedAt || + Number(run.attempt) > 1 || + toExecutions(run).length > 0 + ) + return run; + const inputs = await inputsRepo(unit.conn).findMany({ + filter: { runId: run.id }, + }); + const identity = await resolve(unit.conn, { + subjectId: run.subjectId, + agentId: run.agentId, + causedByRunId: run.retryOfRunId ?? undefined, + inputs: inputs.map(toInput), + }); + if (!identity) return run; + if (!identity.actorUserId) { + await finishRun(unit, deps, run, { + status: 'cancelled', + reason: null, + detail: identity.reason ?? 'noOwner', + }); + return null; + } + if ( + run.actorUserId === identity.actorUserId && + run.ownerUserId === identity.actorUserId + ) + return run; + const values = { + actorUserId: identity.actorUserId, + ownerUserId: identity.actorUserId, + requestedByUserId: run.requestedByUserId ?? run.actorUserId, + updatedAt: deps.clock.now().toISOString(), + }; + const updated = await runsRepo(unit.conn).updateMany({ + filter: { id: run.id, status: 'queued', actorUserId: run.actorUserId }, + values, + }); + if (updated.updatedCount !== 1) return null; + unit.emit({ type: 'run.changed', runId: run.id, status: 'queued' }); + return { ...run, ...values }; +} diff --git a/packages/plugins/app-plugin-agents/server/core/runs/ports.ts b/packages/plugins/app-plugin-agents/server/core/runs/ports.ts index b33a0d064cb..1d42ed5b5bb 100644 --- a/packages/plugins/app-plugin-agents/server/core/runs/ports.ts +++ b/packages/plugins/app-plugin-agents/server/core/runs/ports.ts @@ -231,6 +231,21 @@ export interface SubjectReports { export interface SubjectBinding { readonly kind: string; + /** Overrides execution identity for selected work. Undefined preserves the caller's identity; a null actorUserId refuses it. + * Read in the enqueue/claim transaction. Claimed runs keep their identity until they finish. + */ + readonly actsAs?: ( + conn: DatabaseConnection, + work: { + readonly subjectId: string; + readonly agentId: string; + readonly causedByRunId?: string; + readonly inputs: readonly Pick[]; + }, + ) => Promise< + | { readonly actorUserId: string | null; readonly reason?: string } + | undefined + >; /** * Reads the current responsible inside the request's transaction, before confirming or rejecting. Null means * nobody answers for it (including a deleted subject). Applications with mutable responsibility must bind this; diff --git a/packages/plugins/app-plugin-agents/server/core/runs/run.service.ts b/packages/plugins/app-plugin-agents/server/core/runs/run.service.ts index 9bf8b51f169..d03939a7eff 100644 --- a/packages/plugins/app-plugin-agents/server/core/runs/run.service.ts +++ b/packages/plugins/app-plugin-agents/server/core/runs/run.service.ts @@ -198,6 +198,14 @@ export interface RunService { * events). */ enqueue(request: EnqueueRequest, outer?: Tx): Promise; + /** Reads causal work inside the caller's transaction, without opening a second database connection. */ + identityInputs( + conn: DatabaseConnection, + runId: string, + ): Promise<{ + readonly run: Run; + readonly inputs: readonly RunInput[]; + } | null>; /** Work waiting for the subject's responsible to confirm it: confirming, rejecting, withdrawing, handing it on. */ readonly requests: RunRequestService; /** Adds input to a run that has not ended. */ @@ -666,6 +674,24 @@ export function createRunService(deps: RunServiceDeps): RunService { return source; } + async function selectedIdentity(unit: Tx, request: EnqueueRequest) { + const identity = await deps.subjects + .get(request.subject.kind) + ?.actsAs?.(unit.conn, { + subjectId: request.subject.id, + agentId: request.agentId, + causedByRunId: request.causedByRunId, + inputs: [request.input], + }); + if (identity && !identity.actorUserId) + throw new ProtocolError( + 'FORBIDDEN', + 'The subject has no valid execution identity.', + { identityReason: identity.reason ?? 'noOwner' }, + ); + return identity; + } + /** Refuses work run as `userId` when no runner they may use can run the agent now: it would only wait. */ async function requireRunnerFor( unit: Tx, @@ -699,29 +725,40 @@ export function createRunService(deps: RunServiceDeps): RunService { mayInvoke: (agent, userId) => deps.agents.mayInvoke(agent, userId), requireInvocable, requireRunnerFor, - direct: (unit, run) => - queue(unit, run.agent, { + direct: async (unit, run) => { + const identity = await selectedIdentity(unit, { + agentId: run.agent.id, + subject: run.subject, + input: run.input, + }); + const actorUserId = identity?.actorUserId ?? run.actorUserId; + const agent = await requireInvocable(unit, run.agent.id, actorUserId); + return queue(unit, agent, { freshSession: run.freshSession, subject: run.subject, threadScope: run.threadScope, - actorUserId: run.actorUserId, + actorUserId, requestedByUserId: run.requestedByUserId, confirmedByUserId: run.confirmedByUserId, - ownerUserId: run.ownerUserId, + ownerUserId: identity ? actorUserId : run.ownerUserId, priority: run.priority, // As it was asked: a moment already past when it is confirmed means now. fireAt: fireAtOf(run.fireAt ?? undefined, clock.now()), requires: run.requires, ...(run.maxAttempts === null ? {} : { maxAttempts: run.maxAttempts }), input: run.input, - }), + }); + }, }); return { enqueue: (request, outer) => tx.run(async (unit): Promise => { const source = await sourceOf(unit, request); - const responsible = request.responsibleUserId ?? null; + const identity = await selectedIdentity(unit, request); + const responsible = identity + ? null + : (request.responsibleUserId ?? null); const threadScope = request.threadScope ?? DEFAULT_THREAD; const foreign = responsible !== null && source !== responsible; if (foreign && (request.execution ?? 'auto') === 'auto') { @@ -764,9 +801,9 @@ export function createRunService(deps: RunServiceDeps): RunService { } // As the responsible when they caused it; as the source when they run it as themselves (`mine`); as whoever // woke the agent when nobody answers for the subject. - const actorUserId = foreign - ? source - : (responsible ?? request.actorUserId ?? source); + const actorUserId = + identity?.actorUserId ?? + (foreign ? source : (responsible ?? request.actorUserId ?? source)); const agent = await requireInvocable( unit, request.agentId, @@ -786,7 +823,7 @@ export function createRunService(deps: RunServiceDeps): RunService { actorUserId, requestedByUserId: source, confirmedByUserId: null, - ownerUserId: request.ownerUserId ?? null, + ownerUserId: identity ? actorUserId : (request.ownerUserId ?? null), priority: request.priority ?? 0, fireAt: fireAtOf(request.fireAt, clock.now()), requires: request.requires ?? [], @@ -798,6 +835,13 @@ export function createRunService(deps: RunServiceDeps): RunService { requests, + identityInputs: async (conn, runId) => { + const run = await findRunRecord(conn, runId); + if (!run) return null; + const inputs = await inputsRepo(conn).findMany({ filter: { runId } }); + return { run: toRun(run), inputs: inputs.map(toInput) }; + }, + addInput: (runId, input, outer) => tx.run(async (unit) => { const run = await require(unit.conn, runId); @@ -917,12 +961,26 @@ export function createRunService(deps: RunServiceDeps): RunService { runId: retried.id, }, ); - const agent = await requireInvocable(unit, run.agentId, byUserId); + const previousInputs = await inputsRepo(unit.conn).findMany({ + filter: { runId: run.id }, + }); + const identity = await deps.subjects + .get(run.subjectKind) + ?.actsAs?.(unit.conn, { + subjectId: run.subjectId, + agentId: run.agentId, + inputs: previousInputs.map(toInput), + causedByRunId: run.retryOfRunId ?? undefined, + }); + if (identity && !identity.actorUserId) + throw forbidden(identity.reason ?? 'noOwner'); + const actorUserId = identity?.actorUserId ?? byUserId; + const agent = await requireInvocable(unit, run.agentId, actorUserId); const open = await runsOfKey( unit.conn, run, ['queued', ...ACTIVE], - byUserId, + actorUserId, ); if (open.length > 0) throw precondition( @@ -935,10 +993,10 @@ export function createRunService(deps: RunServiceDeps): RunService { const newId = await createRun(unit, agent, { subject: { kind: run.subjectKind, id: run.subjectId }, threadScope: run.threadScope, - actorUserId: byUserId, + actorUserId, requestedByUserId: byUserId, confirmedByUserId: null, - ownerUserId: run.ownerUserId, + ownerUserId: identity ? actorUserId : run.ownerUserId, priority: Number(run.priority), requires: toRun(run).requires, retryOfRunId: run.id, @@ -1028,6 +1086,18 @@ export function createRunService(deps: RunServiceDeps): RunService { ); return { ...(await readRuns([run]))[0], + actorName: + (await deps.people.names(conn, [run.actorUserId])).get( + run.actorUserId, + ) ?? run.actorUserId, + requestedByName: + ( + await deps.people.names(conn, [ + run.requestedByUserId ?? run.actorUserId, + ]) + ).get(run.requestedByUserId ?? run.actorUserId) ?? + run.requestedByUserId ?? + run.actorUserId, retriedRunId: retried?.id ?? null, inputs: inputs.map(toInputView), repos: repos.map(toRepo), diff --git a/packages/plugins/app-plugin-agents/server/routes/schemas.ts b/packages/plugins/app-plugin-agents/server/routes/schemas.ts index 887534b0561..00bb4dd0c06 100644 --- a/packages/plugins/app-plugin-agents/server/routes/schemas.ts +++ b/packages/plugins/app-plugin-agents/server/routes/schemas.ts @@ -926,6 +926,14 @@ const runObject = z.object({ export const RunSchema: z.ZodType = runObject.meta({ ref: 'AgentsRun' }); export const RunDetailSchema: z.ZodType = runObject.extend({ + actorName: z + .string() + .optional() + .meta({ description: 'The name of the execution identity.' }), + requestedByName: z.string().optional().meta({ + description: + 'The name of the user associated with the chain origin; input actors identify individual triggers.', + }), retriedRunId: z.string().nullable().optional(), inputs: z.array( z.object({ diff --git a/packages/plugins/app-plugin-agents/shared/runs.ts b/packages/plugins/app-plugin-agents/shared/runs.ts index 50a5d23ca75..9428808e826 100644 --- a/packages/plugins/app-plugin-agents/shared/runs.ts +++ b/packages/plugins/app-plugin-agents/shared/runs.ts @@ -184,6 +184,8 @@ export interface RunChild { } export interface RunDetail extends Run { + readonly actorName?: string; + readonly requestedByName?: string; /** The first run that retried this one; absent on older servers. */ readonly retriedRunId?: string | null; readonly inputs: readonly RunInputRecord[]; diff --git a/packages/plugins/app-plugin-agents/tests/subject-identity.test.ts b/packages/plugins/app-plugin-agents/tests/subject-identity.test.ts new file mode 100644 index 00000000000..aa931f2f650 --- /dev/null +++ b/packages/plugins/app-plugin-agents/tests/subject-identity.test.ts @@ -0,0 +1,182 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { claim, createHarness, type Harness } from './harness.js'; + +let h: Harness; +afterEach(async () => h?.close()); + +describe('subject-selected execution identity', () => { + async function setup() { + h = await createHarness(); + const binding = h.services.subjects.get('sample')!; + let owner: string | null = 'owner'; + h.services.subjects.register({ + ...binding, + kind: 'owned', + actsAs: async () => ({ actorUserId: owner }), + }); + const agentId = await h.createAgent(); + const enqueue = (text = 'Work') => + h.services.runs.enqueue({ + agentId, + subject: { kind: 'owned', id: '1' }, + actorUserId: 'bob', + input: { + type: 'comment', + actor: { kind: 'user', id: 'bob', name: 'Bob' }, + text, + }, + }); + return { + agentId, + enqueue, + setOwner: (next: string | null) => { + owner = next; + }, + }; + } + + it('uses the owner runner and credentials while preserving the trigger and inputs', async () => { + const { enqueue, setOwner } = await setup(); + const result = await enqueue(); + await enqueue('More'); + expect(await h.services.runs.get(result.runId!)).toMatchObject({ + actorUserId: 'owner', + requestedByUserId: 'bob', + ownerUserId: 'owner', + }); + const bob = await h.registerRunner({ + trust: 'ownerOnly', + ownerUserId: 'bob', + }); + const owner = await h.registerRunner({ + trust: 'ownerOnly', + ownerUserId: 'owner', + }); + expect(await claim(h, bob)).toEqual([]); + setOwner('bob'); + expect(await claim(h, owner)).toEqual([]); + const [payload] = await claim(h, bob); + expect(payload.inputs.map((input: { text: string }) => input.text)).toEqual( + ['Work', 'More'], + ); + expect(await h.services.runs.get(result.runId!)).toMatchObject({ + actorUserId: 'bob', + requestedByUserId: 'bob', + }); + const credential = JSON.parse( + payload.cli.credential.content + ? JSON.stringify(payload.cli.credential.content) + : '{}', + ); + expect( + (await h.services.runs.authenticateToken(credential.token)).run + .actorUserId, + ).toBe('bob'); + }); + + it('repairs legacy queued identity before runner ownership checks and leaves claimed work fixed', async () => { + const { enqueue, setOwner } = await setup(); + const result = await enqueue(); + await h.database + .connection() + .repository('agRuns') + .updateMany({ + filter: { id: result.runId }, + values: { + actorUserId: 'bob', + ownerUserId: 'bob', + requestedByUserId: null, + }, + }); + const bob = await h.registerRunner({ + trust: 'ownerOnly', + ownerUserId: 'bob', + }); + const owner = await h.registerRunner({ + trust: 'ownerOnly', + ownerUserId: 'owner', + }); + expect((await claim(h, owner))[0].run.id).toBe(result.runId); + expect((await h.services.runs.get(result.runId!)).requestedByUserId).toBe( + 'bob', + ); + expect(await claim(h, bob)).toEqual([]); + setOwner('bob'); + expect(await h.services.runs.get(result.runId!)).toMatchObject({ + actorUserId: 'owner', + status: 'dispatched', + }); + expect( + (await h.services.runs.detail(result.runId!)).inputs.at(-1)?.text, + ).toBe('Work'); + expect(await enqueue('Next')).toMatchObject({ outcome: 'created' }); + const failed = await h.request( + 'POST', + `/agents/runners/runs/${result.runId}/fail`, + { + runnerKey: owner.key, + body: { reason: 'workspaceBusy' }, + }, + ); + expect(failed.status).toBe(200); + h.clock.advance(30_000); + expect((await claim(h, owner))[0].run.id).toBe(result.runId); + expect((await h.services.runs.get(result.runId!)).actorUserId).toBe( + 'owner', + ); + }); + + it('abandons a stale prepared candidate when identity changes at the claim boundary', async () => { + const { enqueue, setOwner } = await setup(); + const result = await enqueue(); + let reached!: () => void; + let resume!: () => void; + const prepared = new Promise((resolve) => { + reached = resolve; + }); + const released = new Promise((resolve) => { + resume = resolve; + }); + h.services.briefs.sections.register({ + key: 'pause-before-claim', + prepare: async () => { + reached(); + await released; + }, + section: async () => null, + }); + const owner = await h.registerRunner({ + trust: 'ownerOnly', + ownerUserId: 'owner', + }); + const claiming = claim(h, owner); + await prepared; + try { + setOwner('bob'); + } finally { + resume(); + } + expect(await claiming).toEqual([]); + expect(await h.services.runs.get(result.runId!)).toMatchObject({ + actorUserId: 'bob', + status: 'queued', + }); + const bob = await h.registerRunner({ + trust: 'ownerOnly', + ownerUserId: 'bob', + }); + expect((await claim(h, bob))[0].run.id).toBe(result.runId); + }); + + it('refuses an absent owner and cancels queued work rather than falling back', async () => { + const { enqueue, setOwner } = await setup(); + const result = await enqueue(); + setOwner(null); + await expect(enqueue()).rejects.toMatchObject({ code: 'FORBIDDEN' }); + expect(await claim(h, await h.registerRunner())).toEqual([]); + expect(await h.services.runs.get(result.runId!)).toMatchObject({ + status: 'cancelled', + failureDetail: 'noOwner', + }); + }); +}); diff --git a/packages/plugins/app-plugin-projects/server/composition.ts b/packages/plugins/app-plugin-projects/server/composition.ts index d289abda752..83f7bbe5429 100644 --- a/packages/plugins/app-plugin-projects/server/composition.ts +++ b/packages/plugins/app-plugin-projects/server/composition.ts @@ -10,7 +10,10 @@ import type { DatabaseManager } from '@nocobase/db'; import type { ProjectsAccess } from './tokens.js'; -import { createActivityRecorder } from './kernel/activity.js'; +import { + createActivityRecorder, + type ActivityRecorder, +} from './kernel/activity.js'; import { createKindRegistry, type KindRegistry } from './kernel/kinds.js'; import { createDomainEventBus, type DomainEventBus } from './kernel/events.js'; import { createIdSource } from './kernel/ids.js'; @@ -132,6 +135,8 @@ export interface ProjectsDeps { } export interface Projects { + /** Application-owned issue events, recorded in the transaction that caused them. */ + readonly activity: ActivityRecorder; readonly events: DomainEventBus; readonly tx: TxRunner; readonly members: MemberService; @@ -340,6 +345,7 @@ export function createProjects(deps: ProjectsDeps): Projects { kinds, }); return { + activity, events, tx, settings, diff --git a/packages/plugins/app-plugin-projects/server/kernel/work.ts b/packages/plugins/app-plugin-projects/server/kernel/work.ts index 6a84c5ebfe2..649658d9138 100644 --- a/packages/plugins/app-plugin-projects/server/kernel/work.ts +++ b/packages/plugins/app-plugin-projects/server/kernel/work.ts @@ -107,7 +107,7 @@ export interface IssueWorkHandler { tx: Tx, change: CommentChange, ): Promise; - /** The issue's owner changed while the kind executes it: queued work woken by the previous owner is withdrawn. */ + /** The issue's owner changed. Every work kind hears this before ordinary triggers, including unassigned issues. */ onOwnerChanged?( tx: Tx, input: { @@ -245,24 +245,27 @@ export function kindTriggers( return { async onIssueChanged(tx, change) { const { before, after } = change; + // Transfers reach every work kind before ordinary triggers, even when the executor is cleared. + if (before && before.ownerUserId !== after.ownerUserId) + for (const [, work] of handlersOf( + kinds, + kinds.list().map((kind) => kind.key), + )) + await report( + tx, + after, + work.onOwnerChanged?.(tx, { + issue: after, + from: before.ownerUserId, + to: after.ownerUserId, + actor: change.actor, + }), + ); for (const [, work] of handlersOf(kinds, [ before?.executor?.type, after.executor?.type, ])) await report(tx, after, work.onIssueChanged(tx, change)); - if (before && before.ownerUserId !== after.ownerUserId) { - const work = handlerOf(kinds, after.executor?.type); - await report( - tx, - after, - work?.onOwnerChanged?.(tx, { - issue: after, - from: before.ownerUserId, - to: after.ownerUserId, - actor: change.actor, - }), - ); - } }, async onCommentCreated(tx, change) { diff --git a/packages/plugins/app-plugin-projects/tests/domains/work.test.ts b/packages/plugins/app-plugin-projects/tests/domains/work.test.ts index 8baf4fbff4a..d5270eb3325 100644 --- a/packages/plugins/app-plugin-projects/tests/domains/work.test.ts +++ b/packages/plugins/app-plugin-projects/tests/domains/work.test.ts @@ -133,13 +133,30 @@ describe('work handlers of registered kinds', () => { revision: issue.revision, ownerUserId: 'bob', }); - expect(seen.at(-1)).toEqual({ + expect(seen.slice(-2).map((event) => event.hook)).toEqual([ + 'owner', + 'changed', + ]); + expect(seen.at(-2)).toEqual({ hook: 'owner', issueId: issue.id, detail: ['alice', 'bob'], }); }); + it('notifies all work kinds on transfer even when there is no executor', async () => { + const seen = addBots(); + const issue = await h.services.issues.create(alice(), { title: 'A' }); + await h.services.issues.update(alice(), issue.id, { + revision: issue.revision, + ownerUserId: 'bob', + executor: null, + }); + expect(seen).toEqual([ + { hook: 'owner', issueId: issue.id, detail: ['alice', 'bob'] }, + ]); + }); + it('starts work for a mentioned principal and returns it as triggered', async () => { const seen = addBots(); const issue = await h.services.issues.create(alice(), { title: 'A' });