feat(studio): require owner acceptance for agent work (#10797)

* feat(studio): require owner acceptance before resuming agent work

* test(studio): start check-reader fixture before claiming work
This commit is contained in:
YANG QIA
2026-10-13 08:03:38 +08:00
committed by GitHub
parent de18bb5442
commit cb009d72bb
41 changed files with 2024 additions and 25 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@nocobase/studio': patch
'@nocobase/app-plugin-projects': patch
---
Pause agent work after an owner transfer until the new owner accepts. Add owner acceptance and rejection in issue pages and inbox decisions, with transfer activities and localized waiting reasons.
@@ -40,7 +40,10 @@ import {
} from '../../shared/project-manager.js';
import { useIssuePullRequests, usePullRequestMark } from '../git/api.js';
import { PR_ACTIVITY_LABELS } from '../git/lib.js';
import { actionLabel } from '../issues/detail/activity-labels.js';
import {
actionLabel,
isOwnerHandoffActivity,
} from '../issues/detail/activity-labels.js';
import {
issueProgressKey,
progressSentence,
@@ -526,7 +529,9 @@ function describe(
const activity = event.activity;
if (!activity) return { what: '', detail: null };
return {
what: projects(actionLabel(activity.action, activity.details), {
what: (isOwnerHandoffActivity(activity.action, activity.details)
? t
: projects)(actionLabel(activity.action, activity.details), {
name: text(activity.details.name) ?? projects('common.unknown'),
}),
detail: null,
@@ -8,6 +8,7 @@ import { Link } from 'react-router';
import { Skeleton } from '@/components/ui/skeleton';
import { RecentActivity } from './recent-activity.js';
import { OwnerHandoffActions } from '../../issues/detail/owner-handoff.js';
import type { InboxPartProps } from '@/extensions/nocobase-inbox/registry';
import type { ProjectsModel } from './projects.js';
import { useParamsOf } from './projects-wording.js';
@@ -30,6 +31,12 @@ export function ProjectsBody({
{params.excerpt}
</blockquote>
) : null}
{entry.notice?.type === 'owner_handoff' && issueId ? (
<OwnerHandoffActions
issueId={issueId}
notificationKey={params.notificationKey}
/>
) : null}
{entry.notice?.type === 'comment_runs_limited' && issueId ? (
<Link
to={`/issues/${encodeURIComponent(detail.data?.identifier ?? issueId)}`}
@@ -179,6 +179,10 @@ export function useEntryText(): (
let sentence: string | null = null;
switch (notice.type) {
case 'owner_handoff':
case 'owner_handoff_rejected':
sentence = t(`inbox.body.${notice.type}`);
break;
case 'status_changed': {
const from = status(params.from);
if (from && to)
@@ -43,6 +43,8 @@ import {
/** One icon per type, so a card is recognisable before it is read. */
const TYPE_ICON: Readonly<Record<string, LucideIcon>> = {
owner_handoff: UserCheckIcon,
owner_handoff_rejected: CircleXIcon,
approval_requested: ShieldCheckIcon,
approval_decided: ShieldCheckIcon,
approval_stale: ShieldAlertIcon,
@@ -6,6 +6,9 @@ import type { Activity } from '@nocobase/app-plugin-projects/shared/issues';
/** The server's activity actions and the keys of their wording. */
const ACTION_LABELS: Readonly<Record<string, string>> = {
studio_owner_transferred: 'ownerTransferred',
studio_owner_accepted: 'ownerAccepted',
studio_owner_rejected: 'ownerRejected',
issue_created: 'created',
issue_deleted: 'deleted',
issue_restored: 'restored',
@@ -61,10 +64,23 @@ export function actionLabel(
key = 'stageActionBlocked';
else if (action === 'work_skipped')
key =
reason === 'dormant'
? 'workDormant'
: reason === 'limited'
? 'workLimited'
: 'workBlocked';
reason === 'waitingOwner'
? 'waitingOwner'
: reason === 'dormant'
? 'workDormant'
: reason === 'limited'
? 'workLimited'
: 'workBlocked';
return `activity.actions.${key}`;
}
/** Handoff activities use Studio's wording rather than the projects plugin's. */
export function isOwnerHandoffActivity(
action: string,
details: Activity['details'],
): boolean {
return (
action.startsWith('studio_owner_') ||
(action === 'work_skipped' && details.reason === 'waitingOwner')
);
}
@@ -25,7 +25,7 @@ import {
import { PR_ACTIVITY_LABELS } from '../../git/lib.js';
import type { Translate } from './labels.js';
import { actionLabel } from './activity-labels.js';
import { actionLabel, isOwnerHandoffActivity } from './activity-labels.js';
import { usePlanLinkTo } from './plan-links.js';
import { StatusBadge } from './status-badge.js';
@@ -254,7 +254,9 @@ export function ActivityRow({
text(activity.details.name) ??
t('common.unknown'),
})
: t(actionLabel(activity.action, activity.details), {
: (isOwnerHandoffActivity(activity.action, activity.details)
? studioT
: t)(actionLabel(activity.action, activity.details), {
name: text(activity.details.name) ?? t('common.unknown'),
})}
</span>
@@ -21,6 +21,7 @@ import { Spinner } from '@/components/ui/spinner';
import type { IssueProgress } from '../../../shared/project-manager.js';
import { useNotify } from '../../access/notify.js';
import { issueProgressKey, progressSentence } from './progress-text.js';
import { OwnerHandoffActions } from './owner-handoff.js';
/** Refetches on its own too: a pull request's checks change with no issue change. */
const PROGRESS_POLL_MS = 15_000;
@@ -74,7 +75,10 @@ export function IssueProgressLine({
});
const data = progress.data;
const canStart = canEdit && issue.statusKey === BACKLOG_STATUS;
const canStart =
canEdit &&
issue.statusKey === BACKLOG_STATUS &&
data?.waiting?.reason !== 'waitingOwner';
const canHandOver =
canEdit && data?.handOver === true && issue.statusKey !== BACKLOG_STATUS;
return (
@@ -117,6 +121,7 @@ export function IssueProgressLine({
</p>
) : null}
</div>
<OwnerHandoffActions issueId={issue.id} />
{canStart || canHandOver ? (
<div className='flex shrink-0 flex-wrap gap-2'>
{canStart ? (
@@ -0,0 +1,172 @@
import { ApiClientError, useApiClient } from '@nocobase/app-client';
import { useTranslation } from '@nocobase/i18n/client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { Skeleton } from '@/components/ui/skeleton';
import { inboxKeys } from '@nocobase/app-plugin-notification-in-app/client/inbox';
import { useNotify } from '../../access/notify.js';
const ownerHandoffKey = (issueId: string) =>
['studio', 'ownerHandoff', issueId] as const;
interface HandoffView {
readonly handoff: {
readonly waiting: boolean;
readonly notificationKey: string;
readonly rejectedAt: string | null;
readonly ownerUserId: string;
} | null;
readonly canDecide: boolean;
readonly canClaim: boolean;
}
/** Shared by the issue page and its inbox decision. An old inbox item never borrows the current decision. */
export function OwnerHandoffActions({
issueId,
notificationKey,
}: {
readonly issueId: string;
readonly notificationKey?: string;
}) {
const { t } = useTranslation();
const api = useApiClient();
const queries = useQueryClient();
const notify = useNotify();
const handoff = useQuery({
queryKey: ownerHandoffKey(issueId),
queryFn: async () =>
(
await api.request<{ data: HandoffView }>({
path: `issueOwnerHandoffs/${encodeURIComponent(issueId)}`,
})
).data,
refetchInterval: 15_000,
});
const decide = useMutation({
mutationFn: (decision: 'accept' | 'reject') =>
api.request({
method: 'POST',
path: `issueOwnerHandoffs/${encodeURIComponent(issueId)}/${decision}`,
json: {
notificationKey:
notificationKey ?? handoff.data?.handoff?.notificationKey,
},
}),
onSuccess: (_data, decision) =>
notify.success(
t(`ownerHandoff.${decision === 'accept' ? 'accepted' : 'rejected'}`),
),
onError: (error) =>
notify.error(
null,
t(
error instanceof ApiClientError && error.status === 409
? 'ownerHandoff.stale'
: 'ownerHandoff.failed',
),
),
onSettled: async () => {
await Promise.all(
[
ownerHandoffKey(issueId),
['studio', 'issueProgress', issueId],
['pm', 'issue'],
inboxKeys.all,
].map((queryKey) => queries.invalidateQueries({ queryKey })),
);
},
});
const claim = useMutation({
mutationFn: async () => {
const issue = await api.request<{ data: { revision: number } }>({
path: `projects/issues/${encodeURIComponent(issueId)}`,
});
return api.request({
method: 'PATCH',
path: `projects/issues/${encodeURIComponent(issueId)}`,
json: {
revision: issue.data.revision,
ownerUserId: handoff.data?.handoff?.ownerUserId,
},
});
},
onSuccess: () => notify.success(t('ownerHandoff.accepted')),
onError: () => notify.error(null, t('ownerHandoff.failed')),
onSettled: async () => {
await Promise.all(
[
ownerHandoffKey(issueId),
['studio', 'issueProgress', issueId],
['pm', 'issue'],
inboxKeys.all,
].map((queryKey) => queries.invalidateQueries({ queryKey })),
);
},
});
if (handoff.isPending)
return notificationKey ? <Skeleton className='h-8 w-40' /> : null;
if (handoff.isError)
return (
<p className='text-sm text-muted-foreground'>
{t('ownerHandoff.loadFailed')}
</p>
);
const current = handoff.data?.handoff;
if (
notificationKey &&
(!current?.waiting ||
current.notificationKey !== notificationKey ||
current.rejectedAt)
)
return (
<p className='text-sm text-muted-foreground'>{t('ownerHandoff.stale')}</p>
);
if (!current?.waiting) return null;
if (current.rejectedAt)
return (
<div className='space-y-2'>
<p className='text-sm text-muted-foreground'>
{t('ownerHandoff.rejectedWaiting')}
</p>
{handoff.data?.canClaim ? (
<Button
size='sm'
disabled={claim.isPending}
onClick={() => claim.mutate()}
>
{claim.isPending ? <Spinner data-icon='inline-start' /> : null}
{t('ownerHandoff.claim')}
</Button>
) : null}
</div>
);
if (!handoff.data?.canDecide) return null;
return (
<div
className='flex flex-wrap items-center gap-2'
aria-label={t('ownerHandoff.title')}
>
<Button
size='sm'
disabled={decide.isPending}
onClick={() => decide.mutate('accept')}
>
{decide.isPending && decide.variables === 'accept' ? (
<Spinner data-icon='inline-start' />
) : null}
{t('ownerHandoff.accept')}
</Button>
<Button
size='sm'
variant='outline'
disabled={decide.isPending}
onClick={() => decide.mutate('reject')}
>
{decide.isPending && decide.variables === 'reject' ? (
<Spinner data-icon='inline-start' />
) : null}
{t('ownerHandoff.reject')}
</Button>
</div>
);
}
@@ -13,6 +13,32 @@ import inboxEnUS from '@/extensions/nocobase-inbox/locales/en-US';
import planCardEnUS from '@/extensions/nocobase-plan-card/locales/en-US';
const enUS = {
'ownerHandoff.title': 'Owner handoff',
'ownerHandoff.accept': 'Take over',
'ownerHandoff.claim': 'Claim',
'ownerHandoff.reject': 'Reject',
'ownerHandoff.accepted': 'Handoff accepted',
'ownerHandoff.rejected': 'Handoff rejected; agent work remains paused',
'ownerHandoff.rejectedWaiting':
'Handoff rejected. Waiting for a new assignment or a claim by the owner.',
'ownerHandoff.stale':
'This handoff is no longer awaiting a decision. Refresh the issue.',
'ownerHandoff.failed':
'Could not decide this handoff. Check your permission and refresh the issue.',
'ownerHandoff.loadFailed': 'Could not load the owner handoff.',
'activity.actions.ownerTransferred': 'transferred responsibility',
'activity.actions.ownerAccepted': 'accepted responsibility',
'activity.actions.ownerRejected': 'rejected the handoff',
'activity.actions.waitingOwner':
'skipped agent work: waiting for the new owner to take over',
'inbox.types.owner_handoff': 'Owner handoff',
'inbox.types.owner_handoff_rejected': 'Owner handoff rejected',
'inbox.text.owner_handoff': 'Take over {{identifier}}',
'inbox.text.owner_handoff_rejected': 'Owner handoff rejected: {{identifier}}',
'inbox.body.owner_handoff':
'Accept or reject this handoff. Agent work is paused until the new owner accepts.',
'inbox.body.owner_handoff_rejected':
'The new owner declined. The owner stays assigned and agent work remains paused.',
'issueRuns.model': 'Model: {{model}}',
'issueRuns.configuredModel': 'Configured model: {{model}}',
'issueRuns.modelUnknown': 'Model not reported',
@@ -1083,6 +1109,7 @@ const enUS = {
comment: 'Comment',
statusChange: 'Status change',
ownerChanged: 'Owner change',
ownerAccepted: 'Owner accepted',
projectChanged: 'Moved to another project',
unblocked: 'Unblocked',
subtasksFinished: 'Sub-issues done',
@@ -1391,6 +1418,7 @@ const enUS = {
issueStart: { title: 'Start now?', later: "Don't start now", start: 'Start' },
issueProgress: {
waiting: {
waitingOwner: 'Waiting for {{owner}} to take over',
noOwner: 'Waiting for a valid issue owner',
notConfigured:
'{{agent}} waits: no preferred or fallback model is configured',
@@ -13,6 +13,28 @@ import inboxZhCN from '@/extensions/nocobase-inbox/locales/zh-CN';
import planCardZhCN from '@/extensions/nocobase-plan-card/locales/zh-CN';
const zhCN: AppResource = {
'ownerHandoff.title': '负责人接手',
'ownerHandoff.accept': '接手',
'ownerHandoff.claim': '认领',
'ownerHandoff.reject': '拒绝',
'ownerHandoff.accepted': '已接手',
'ownerHandoff.rejected': '已拒绝接手,agent 运行保持暂停',
'ownerHandoff.rejectedWaiting': '已拒绝接手,等待重新转交或负责人自行认领。',
'ownerHandoff.stale': '此接手请求已失效,请刷新任务。',
'ownerHandoff.failed': '未能处理接手请求,请检查权限并刷新任务。',
'ownerHandoff.loadFailed': '未能加载接手请求。',
'activity.actions.ownerTransferred': '转交了负责人',
'activity.actions.ownerAccepted': '接手了任务',
'activity.actions.ownerRejected': '拒绝了接手',
'activity.actions.waitingOwner': '跳过 agent 运行:等待新负责人接手',
'inbox.types.owner_handoff': '等待接手',
'inbox.types.owner_handoff_rejected': '接手已拒绝',
'inbox.text.owner_handoff': '接手 {{identifier}}',
'inbox.text.owner_handoff_rejected': '{{identifier}} 的接手已拒绝',
'inbox.body.owner_handoff':
'请选择接手或拒绝,新负责人接手前 agent 运行保持暂停。',
'inbox.body.owner_handoff_rejected':
'新负责人拒绝了接手,负责人保持不变,agent 运行保持暂停。',
'issueRuns.model': '模型:{{model}}',
'issueRuns.configuredModel': '配置模型:{{model}}',
'issueRuns.modelUnknown': '模型尚未报告',
@@ -998,6 +1020,7 @@ const zhCN: AppResource = {
comment: '评论',
statusChange: '状态变更',
ownerChanged: '负责人变更',
ownerAccepted: '负责人接手',
projectChanged: '转项目',
unblocked: '阻塞解除',
subtasksFinished: '子任务完成',
@@ -1278,6 +1301,7 @@ const zhCN: AppResource = {
issueStart: { title: '现在开始?', later: '暂不开始', start: '开始' },
issueProgress: {
waiting: {
waitingOwner: '等待 {{owner}} 接手',
noOwner: '正在等待有效的任务负责人',
notConfigured: '{{agent}} 正在等待:尚未配置首选或备选模型',
noRunner: '{{agent}} 正在等待:没有能运行首选或备选模型的在线执行机',
@@ -0,0 +1,22 @@
import { defineMigration, type MigrationDefinition } from '@nocobase/db';
const migration: MigrationDefinition = defineMigration({
name: '202610240040_studio_issue_owner_handoffs',
async up({ builder }) {
await builder.createCollection('studioIssueOwnerHandoffs', (collection) => {
collection.string('issueId', { length: 64 }).primary().notNull();
collection.boolean('waiting').notNull().defaultTo(false);
collection.string('ownerUserId', { length: 64 }).notNull();
collection.string('previousOwnerUserId', { length: 64 }).notNull();
collection.string('transferredByUserId', { length: 64 }).notNull();
collection.string('notificationKey', { length: 64 }).notNull();
collection.datetimeTz('transferredAt').notNull();
collection.datetimeTz('rejectedAt').nullable();
});
},
async down({ builder }) {
await builder.dropCollection('studioIssueOwnerHandoffs');
},
});
export default migration;
@@ -0,0 +1,110 @@
import {
apiAs,
expect,
open,
signIn,
test,
unique,
horizontalOverflow,
} from './support/fixtures.ts';
import { FakeRunner, type Issue } from './support/runner.ts';
test('owner handoff controls in the issue and inbox, in Chinese and English', async ({
page,
api,
browser,
context,
}) => {
test.setTimeout(120_000);
const admin = await apiAs(browser, 'admin');
const lisa = await apiAs(browser, 'lisa');
const me = await api.get<{ userId: string }>('projects/me');
const next = await lisa.api.get<{ userId: string }>('projects/me');
const settings = await admin.api.get<{
firstChoiceAgentId: string | null;
fallbackAgentId: string | null;
}>('projectManager');
try {
const runner = new FakeRunner(admin.api, `handoff-${unique()}`);
const agent = await runner.agent();
await admin.api.patch('projectManager', {
firstChoiceAgentId: agent,
fallbackAgentId: null,
});
const issue = await api.post<Issue>('projects/issues', {
title: `负责人接手 ${unique()}`,
ownerUserId: me.userId,
statusKey: 'in_progress',
start: false,
});
await api.post(`projectManager/issues/${issue.id}/start`);
const before = await api.get<Issue>(`projects/issues/${issue.id}`);
await api.patch(`projects/issues/${issue.id}`, {
revision: before.revision,
ownerUserId: next.userId,
});
await open(page, `/issues/${issue.identifier}`);
await expect(
page.getByText('等待 Lisa Nguyen 接手', { exact: true }),
).toBeVisible();
await expect(
page.getByRole('button', { name: '接手', exact: true }),
).toHaveCount(0);
await signIn(context, 'lisa');
await open(page, `/issues/${issue.identifier}`);
await expect(
page.getByRole('button', { name: '接手', exact: true }),
).toBeVisible();
await page.screenshot({
path: test.info().outputPath('owner-handoff-zh.png'),
});
await open(page, '/inbox');
await page
.getByRole('button', { name: new RegExp(issue.identifier) })
.first()
.click();
const card = page
.getByRole('article')
.filter({ hasText: issue.title })
.first();
await expect(
card.getByRole('button', { name: '接手', exact: true }),
).toBeVisible();
await expect(
card.getByRole('button', { name: '拒绝', exact: true }),
).toBeVisible();
await page.screenshot({
path: test.info().outputPath('owner-handoff-inbox-zh.png'),
});
await lisa.api.patch('users/me/preferences', { locale: 'en-US' });
await page.evaluate(() => localStorage.setItem('nocobase.locale', 'en-US'));
await page.setViewportSize({ width: 375, height: 812 });
await open(page, `/issues/${issue.identifier}`);
await expect(
page.getByText('Waiting for Lisa Nguyen to take over', { exact: true }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Take over', exact: true }),
).toBeVisible();
expect(await horizontalOverflow(page)).toBeLessThanOrEqual(1);
await page.screenshot({
path: test.info().outputPath('owner-handoff-en-mobile.png'),
});
await page.getByRole('button', { name: 'Take over', exact: true }).click();
await expect(
page.getByText('Waiting for Lisa Nguyen to take over', { exact: true }),
).toHaveCount(0);
const current = await lisa.api.get<{ handoff: { waiting: boolean } }>(
`issueOwnerHandoffs/${issue.id}`,
);
expect(current.handoff.waiting).toBe(false);
} finally {
await lisa.api.patch('users/me/preferences', { locale: 'zh-CN' });
await admin.api.patch('projectManager', {
firstChoiceAgentId: settings.firstChoiceAgentId,
fallbackAgentId: settings.fallbackAgentId,
});
await lisa.close();
await admin.close();
}
});
+16 -1
View File
@@ -96,6 +96,12 @@ import { createAgentWork } from './work.js';
import { commentRunNoticeRule } from './comment-run-limit.js';
import { reportCommentRuns } from './run-report.js';
import { PROJECT_MANAGER_ID } from '../../shared/project-manager.js';
import {
OWNER_HANDOFF_NOTICE,
ownerHandoffNoticeRule,
settleOwnerHandoffNotice,
type HandoffAnnouncement,
} from '../issues/owner-handoff-notices.js';
export interface StudioAgentsDeps {
readonly agents: Agents;
@@ -155,11 +161,11 @@ export function bindStudioAgents(deps: StudioAgentsDeps): () => void {
const { agents } = deps;
const onError =
deps.onError ?? ((message, error) => console.error(message, error));
const work = createAgentWork({ agents, projects: deps.projects });
const askerOf = createAskerLookup(agents);
const permissions = createPermissionSource(deps.access, askerOf);
const viewerOf = (userId: string) =>
permissions.viewerOf({ kind: 'user', userId, displayName: userId });
const work = createAgentWork({ agents, projects: deps.projects, viewerOf });
const direct = createDirectWrites({ agents, projects: deps.projects });
const principal = createRunPrincipal({
askerOf,
@@ -175,6 +181,14 @@ export function bindStudioAgents(deps: StudioAgentsDeps): () => void {
inbox,
});
const releases: (() => void)[] = [
deps.projects().events.on('work.announced', async (event) => {
const port = deps.inboxPort?.();
if (port && event.kind === OWNER_HANDOFF_NOTICE)
await settleOwnerHandoffNotice(
port,
event.payload as HandoffAnnouncement,
);
}),
deps.kinds.add(createAgentKind({ agents, work })),
agents.subjects.register({
...issueBinding(
@@ -287,6 +301,7 @@ export function bindStudioAgents(deps: StudioAgentsDeps): () => void {
deps.noticeRules.add(runFailedRule(agents, deps.projects)),
deps.noticeRules.add(agentBlockedRule(deps.projects, agents)),
deps.noticeRules.add(commentRunNoticeRule()),
deps.noticeRules.add(ownerHandoffNoticeRule()),
);
// The project manager runs with the tool and model of the agents it names: one of them changing changes it.
const manager = deps.manager;
@@ -20,6 +20,7 @@ export const ISSUE_TRIGGERS = [
'comment',
'statusChange',
'ownerChanged',
'ownerAccepted',
// Moved to another project: the work starts again in its working directories (`work.ts`).
'projectChanged',
'unblocked',
@@ -21,6 +21,7 @@ import {
type HeldCommentWake,
} from './comment-guard.js';
import { createAgentWork } from './work.js';
import { ownerHandoffWaiting } from '../issues/owner-handoff.js';
/** The notice type of the owner's card. */
export const COMMENT_RUNS_LIMITED = 'comment_runs_limited';
@@ -91,6 +92,7 @@ export async function pendingCommentRun(
} | null> {
const issue = await projects.issueQueries.detail(viewer, issueId);
if (!mayContinue(viewer) || viewer.userId !== issue.ownerUserId) return null;
if (await ownerHandoffWaiting(projects.tx.read(), issue)) return null;
const guard = await commentGuards(projects.tx.read()).findOne({
filter: { id: commentGuardId(issue.id) },
});
@@ -126,6 +128,12 @@ export async function continueCommentRun(
'The current issue owner must continue this work.',
);
const id = commentGuardId(issue.id);
if (await ownerHandoffWaiting(tx.conn, issue))
throw studioError(
'FAILED_PRECONDITION',
'ISSUE_OWNER_HANDOFF_PENDING',
'Accept the owner handoff before continuing this work.',
);
const stale = () =>
studioError(
'FAILED_PRECONDITION',
@@ -7,12 +7,15 @@ 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';
import { readOwnerHandoff } from '../issues/owner-handoff.js';
export function issueExecutionIdentity(
agents: Pick<Agents, 'runs' | 'agents'>,
projects: () => Pick<Projects, 'issueContext' | 'commentQueries'>,
): NonNullable<SubjectBinding['actsAs']> {
return async (conn, work) => {
if ((await readOwnerHandoff(conn, work.subjectId))?.waiting)
return { actorUserId: null, reason: 'waitingOwner' };
const seen = new Set<string>();
const comments = await projects().commentQueries.list(conn, work.subjectId);
const byId = new Map(comments.map((comment) => [comment.id, comment]));
@@ -50,6 +50,7 @@ import {
} from './catalog/triggers.js';
import { AGENT_KIND } from './tx.js';
import { issueExecutionIdentity } from './execution-identity.js';
import { readOwnerHandoff } from '../issues/owner-handoff.js';
const SUBJECT_NOUN = 'issue';
@@ -418,6 +419,11 @@ export function createIssueContextProvider(
): ContextProvider {
return {
async admit(conn, claim) {
if ((await readOwnerHandoff(conn, claim.run.subject.id))?.waiting)
return {
reason: 'waitingOwner',
detail: 'Waiting for the new issue owner to take over.',
};
const context = await projects().issueContext.contextFor(
conn,
claim.run.subject.id,
@@ -25,6 +25,7 @@ import {
validIssueOwner,
} from '../execution-identity.js';
import { userName } from '../work.js';
import { ownerHandoffWaiting } from '../../issues/owner-handoff.js';
/** How much of a step or a question the line carries. */
const LINE_LENGTH = 160;
@@ -129,6 +130,18 @@ export async function issueProgress(
handOver: managed && open && managerRuns.length === 0,
};
if (await ownerHandoffWaiting(conn, issue))
return {
...base,
handOver: false,
waiting: {
reason: 'waitingOwner',
owner: {
id: issue.ownerUserId,
name: await userName(conn, issue.ownerUserId),
},
},
};
const runs = await deps.agents.runs.openOn(conn, subject);
const held = runs.filter((run) => HELD.has(run.status));
const queued = runs.filter((run) => run.status === 'queued');
@@ -42,6 +42,7 @@ export const IssueProgressSchema: z.ZodType<IssueProgress> = z
waiting: z
.object({
reason: z.enum([
'waitingOwner',
'noOwner',
'notConfigured',
'noRunner',
+195 -3
View File
@@ -50,6 +50,7 @@ import type {
IssueWorkHandler,
Projects,
ProjectsTx,
Viewer,
RunAttempt,
} from '@nocobase/app-plugin-projects/server/tokens';
import type { Priority } from '@nocobase/app-plugin-projects/shared/common';
@@ -86,6 +87,12 @@ 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 {
ownerHandoffWaiting,
readOwnerHandoff,
recordOwnerHandoff,
} from '../issues/owner-handoff.js';
import { announceOwnerHandoff } from '../issues/owner-handoff-notices.js';
import { issueExecutionIdentity } from './execution-identity.js';
interface Wake {
@@ -144,6 +151,12 @@ const agentOf = (issue: Issue | null): string | null =>
/** The work handler, and "Start" on an issue its agent executes. */
export interface AgentWork extends IssueWorkHandler {
takeOver(
tx: ProjectsTx,
issue: Issue,
actor: Actor,
viewer?: Viewer,
): Promise<RunAttempt>;
/**
* Starts the agent executing `issue` for `actor` (trigger `started`): unless the issue is dormant, waits for
* unfinished issues, or the actor may not wake the agent (`skipped`).
@@ -170,8 +183,14 @@ export function createAgentWork(deps: {
readonly agents: Pick<Agents, 'runs' | 'agents'>;
readonly projects: () => Pick<
Projects,
'subtasks' | 'issueContext' | 'commentQueries' | 'activity'
| 'subtasks'
| 'issueContext'
| 'commentQueries'
| 'activity'
| 'issues'
| 'tx'
>;
readonly viewerOf?: (userId: string) => Promise<Viewer>;
}): AgentWork {
const { runs } = deps.agents;
const subjectOf = (issue: Issue) => ({ kind: ISSUE_SUBJECT, id: issue.id });
@@ -183,6 +202,8 @@ export function createAgentWork(deps: {
subjectId: request.issue.id,
triggerType: request.triggerType,
};
if (await ownerHandoffWaiting(tx.conn, request.issue))
return { ...attempt, started: false, skipped: 'waitingOwner' };
const agent = await deps.agents.agents.findWorkable(
tx.conn,
request.agentId,
@@ -566,9 +587,54 @@ export function createAgentWork(deps: {
});
}
async function takeOver(
tx: ProjectsTx,
issue: Issue,
actor: Actor,
viewer?: Viewer,
): Promise<RunAttempt> {
requireOwnerStart(issue, actor);
if (
agentOf(issue) !== PROJECT_MANAGER_ID &&
(await deps.agents.agents.findWorkable(tx.conn, PROJECT_MANAGER_ID))
) {
const claimant =
viewer ?? (actor.id ? await deps.viewerOf?.(actor.id) : undefined);
if (claimant)
issue = {
...issue,
...(await deps.projects().tx.savepoint(tx, () =>
deps.projects().issues.update(claimant, issue.id, {
revision: issue.revision,
executor: { type: AGENT_KIND, id: PROJECT_MANAGER_ID },
start: false,
}),
)),
};
}
if (dormant(issue))
return skipped(PROJECT_MANAGER_ID, issue, 'ownerChanged', 'dormant');
const who = await actorOf(tx, actor, issue);
return wake(tx, {
agentId: PROJECT_MANAGER_ID,
issue,
actorUserId: issue.ownerUserId,
triggerType: 'ownerAccepted',
input: {
type: 'signal',
actor: who.ref,
text: `${who.ref.name} accepted responsibility for ${issue.identifier}. Continue as the current owner and read all comments since your last run.`,
},
});
}
return {
observesComments: true,
observesOwnerClaims: true,
takeOver,
async onRunFinished(tx, run) {
if (run.subject.kind !== ISSUE_SUBJECT) return;
if ((await readOwnerHandoff(tx.conn, run.subject.id))?.waiting) 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 (
@@ -667,6 +733,12 @@ export function createAgentWork(deps: {
async startNow(tx, issue, actor) {
requireOwnerStart(issue, actor);
if (await ownerHandoffWaiting(tx.conn, issue))
throw studioError(
'FAILED_PRECONDITION',
'ISSUE_OWNER_HANDOFF_PENDING',
'The issue is waiting for its new owner to take over.',
);
const agentId = agentOf(issue);
if (!agentId) throw new Error('No agent executes the issue.');
return assigned(
@@ -680,8 +752,43 @@ 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 [];
if (change.ownerClaimed && !tx.rehearsal) {
const pending = await readOwnerHandoff(tx.conn, after.id);
if (pending?.waiting) {
const claimed = await recordOwnerHandoff(tx.conn, {
...pending,
authenticatedPersonUserId: change.actor.id,
transferredByUserId: after.ownerUserId,
});
await deps.projects().activity.record(tx.conn, {
issueId: after.id,
actor: change.actor,
action: 'studio_owner_accepted',
details: { ownerUserId: after.ownerUserId },
});
announceOwnerHandoff(tx, after, claimed, pending.notificationKey);
if (change.start) return [await takeOver(tx, after, change.actor)];
}
}
// The owner-change hook already withdrew/stopped work. The handoff controls when the new owner resumes it.
if (before && before.ownerUserId !== after.ownerUserId) {
if (
change.start &&
change.actor.type === 'user' &&
change.actor.via !== 'agent' &&
change.actor.id === after.ownerUserId
)
return [
await assigned(
tx,
after,
PROJECT_MANAGER_ID,
await actorOf(tx, change.actor, after),
'started',
),
];
return [];
}
const from = agentOf(before);
const to = agentOf(after);
if (
@@ -691,6 +798,28 @@ export function createAgentWork(deps: {
(!before || dormant(before) || to !== from)
)
requireOwnerStart(after, change.actor);
if (await ownerHandoffWaiting(tx.conn, after)) {
if (
change.start &&
change.actor.type === 'user' &&
change.actor.via !== 'agent' &&
after.statusKey === IN_PROGRESS_STATUS &&
before?.statusKey !== IN_PROGRESS_STATUS
)
throw studioError(
'FAILED_PRECONDITION',
'ISSUE_OWNER_HANDOFF_PENDING',
'Accept the owner handoff before starting this issue.',
);
return [
skipped(
to ?? PROJECT_MANAGER_ID,
after,
'statusChange',
'waitingOwner',
),
];
}
const who = await actorOf(tx, change.actor, after);
if (from && from !== to)
await stop(
@@ -762,6 +891,21 @@ export function createAgentWork(deps: {
async onCommentCreated(tx, change) {
const { comment, issue, parent } = change;
if (await ownerHandoffWaiting(tx.conn, issue)) {
const targets = new Set(
change.mentions
.filter((ref) => ref.kind === AGENT_KIND)
.map((ref) => ref.id),
);
const executor = agentOf(issue);
if (executor) targets.add(executor);
if (parent?.authorType === AGENT_KIND && parent.authorId)
targets.add(parent.authorId);
if (targets.size === 0) targets.add(PROJECT_MANAGER_ID);
return [...targets].map((id) =>
skipped(id, issue, 'comment', 'waitingOwner'),
);
}
if (statusCategory(issue.statusKey) === 'unstarted') return [];
const authorId = change.actor.id;
if (!authorId) return [];
@@ -832,6 +976,28 @@ export function createAgentWork(deps: {
async onOwnerChanged(tx, { issue, from, to, actor }) {
if (tx.rehearsal) return [];
const previous = await readOwnerHandoff(tx.conn, issue.id);
const source = actor?.trace?.runId
? await runs.identityInputs(tx.conn, actor.trace.runId)
: null;
const handoff = await recordOwnerHandoff(tx.conn, {
issueId: issue.id,
ownerUserId: to,
previousOwnerUserId: from,
transferredByUserId:
actor?.type === 'user'
? (actor.id ?? from)
: (source?.run.actorUserId ?? from),
authenticatedPersonUserId:
actor?.type === 'user' && actor.via !== 'agent' ? actor.id : null,
});
await deps.projects().activity.record(tx.conn, {
issueId: issue.id,
actor: actor ?? { type: 'system', id: null },
action: 'studio_owner_transferred',
details: { from, to, waiting: handoff.waiting },
});
announceOwnerHandoff(tx, issue, handoff, previous?.notificationKey);
const subject = subjectOf(issue);
const who = await actorOf(
tx,
@@ -861,6 +1027,14 @@ export function createAgentWork(deps: {
for (const run of await runs.openOn(tx.conn, subject))
if (run.status !== 'queued')
await runs.cancel(run.id, who.userId, agentsTx(tx));
if (!handoff.waiting) {
await deps.projects().activity.record(tx.conn, {
issueId: issue.id,
actor: actor ?? { type: 'system', id: null },
action: 'studio_owner_accepted',
details: { ownerUserId: to },
});
}
return [];
},
@@ -884,6 +1058,15 @@ export function createAgentWork(deps: {
},
async onUnblocked(tx, { issue, releasedBy }) {
if (await ownerHandoffWaiting(tx.conn, issue))
return [
skipped(
agentOf(issue) ?? PROJECT_MANAGER_ID,
issue,
'unblocked',
'waitingOwner',
),
];
const agentId = agentOf(issue);
if (!agentId) return [];
if (dormant(issue))
@@ -905,6 +1088,15 @@ export function createAgentWork(deps: {
},
async onSubtasksFinished(tx, { parent, stage, childIssueIds }) {
if (await ownerHandoffWaiting(tx.conn, parent))
return [
skipped(
agentOf(parent) ?? PROJECT_MANAGER_ID,
parent,
'subtasksFinished',
'waitingOwner',
),
];
const agentId = agentOf(parent);
if (!agentId) return [];
if (dormant(parent))
@@ -36,8 +36,11 @@ export interface FollowUpPreferences {
}
export interface FollowUpsDeps {
readonly projects: Pick<Projects, 'issueQueries' | 'commentQueries' | 'tx'>;
readonly agents: Pick<Agents, 'runs' | 'agents' | 'runners'>;
readonly projects: Pick<
Projects,
'issueQueries' | 'commentQueries' | 'issueContext' | 'tx'
>;
readonly agents: Pick<Agents, 'runs' | 'agents' | 'runners' | 'availability'>;
readonly preferences: FollowUpPreferences;
readonly now?: () => Date;
}
+13 -2
View File
@@ -44,6 +44,7 @@ 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 { readOwnerHandoff } from '../issues/owner-handoff.js';
import type { StudioInboxPort } from '../inbox/port.js';
import {
IN_REVIEW,
@@ -368,10 +369,20 @@ export function createGitFlow(deps: GitFlowDeps): GitFlow {
if (
!issue ||
issue.status.category === 'done' ||
issue.status.category === 'closed' ||
issue.executor?.type !== AGENT_KIND
issue.status.category === 'closed'
)
continue;
if ((await readOwnerHandoff(tx.conn, issue.id))?.waiting) {
await deps.projects().activity.record(tx.conn, {
issueId: issue.id,
actor: { type: 'system', id: null },
action: 'work_skipped',
details: { reason: 'waitingOwner', trigger: signal },
});
tx.emit({ type: 'issue.changed', issueId: issue.id });
continue;
}
if (issue.executor?.type !== AGENT_KIND) continue;
if (signal !== 'passed' && count > SIGNAL_WAKE_LIMIT) {
const port = deps.inbox();
const card = await cardIssue(tx.conn, issue.id);
@@ -0,0 +1,79 @@
import type { Agents } from '@nocobase/app-plugin-agents/server/tokens';
import type {
Projects,
Viewer,
} from '@nocobase/app-plugin-projects/server/tokens';
import { createAgentWork } from '../agents/work.js';
import { studioError } from '../http/errors.js';
import { decideOwnerHandoff, readOwnerHandoff } from './owner-handoff.js';
import { announceOwnerHandoff } from './owner-handoff-notices.js';
export function createOwnerHandoffApi(projects: Projects, agents: Agents) {
const work = createAgentWork({ agents, projects: () => projects });
return {
async read(viewer: Viewer, key: string, personUserId: string | null) {
const issue = await projects.issueQueries.detail(viewer, key);
const handoff = await readOwnerHandoff(projects.tx.read(), issue.id);
const canClaim = Boolean(
handoff?.waiting &&
handoff.ownerUserId === issue.ownerUserId &&
personUserId === issue.ownerUserId &&
viewer.permissions.scopes['pm.issues/edit'] !== 'none',
);
return {
handoff,
canClaim,
canDecide: Boolean(
handoff?.waiting &&
!handoff.rejectedAt &&
handoff.ownerUserId === issue.ownerUserId &&
personUserId === issue.ownerUserId &&
viewer.permissions.scopes['pm.issues/edit'] !== 'none',
),
};
},
async decide(
viewer: Viewer,
key: string,
personUserId: string | null,
notificationKey: string,
decision: 'accept' | 'reject',
) {
if (
!personUserId ||
viewer.actor.type !== 'user' ||
viewer.permissions.scopes['pm.issues/edit'] === 'none'
)
throw studioError(
'PERMISSION_DENIED',
'ISSUE_OWNER_HANDOFF_REQUIRED',
'Only a person with issue editing permission may decide this handoff.',
);
return projects.tx.unit(async (tx) => {
const issue = await projects.issueQueries.detail(viewer, key);
const handoff = await decideOwnerHandoff(tx.conn, {
issue,
authenticatedPersonUserId: personUserId,
notificationKey,
decision,
});
await projects.activity.record(tx.conn, {
issueId: issue.id,
actor: viewer.actor,
action:
decision === 'accept'
? 'studio_owner_accepted'
: 'studio_owner_rejected',
details: { ownerUserId: issue.ownerUserId },
});
announceOwnerHandoff(tx, issue, handoff, undefined, decision);
const run =
decision === 'accept'
? await work.takeOver(tx, issue, viewer.actor, viewer)
: null;
tx.emit({ type: 'issue.changed', issueId: issue.id });
return { handoff, runId: run?.runId ?? null };
});
},
};
}
@@ -0,0 +1,91 @@
import type {
NoticeRule,
ProjectsTx,
} from '@nocobase/app-plugin-projects/server/tokens';
import type { Issue } from '@nocobase/app-plugin-projects/shared/issues';
import type { StudioInboxPort } from '../inbox/port.js';
import {
ownerHandoffRejectionRecipients,
type OwnerHandoff,
} from './owner-handoff.js';
export const OWNER_HANDOFF_NOTICE = 'studio.ownerHandoff';
export interface HandoffAnnouncement {
readonly issue: Pick<Issue, 'id' | 'identifier' | 'title'>;
readonly handoff: OwnerHandoff;
readonly previousNotificationKey?: string;
readonly decision?: 'accept' | 'reject';
}
export const ownerHandoffNoticeKey = (key: string) => `owner-handoff:${key}`;
export function announceOwnerHandoff(
tx: ProjectsTx,
issue: HandoffAnnouncement['issue'],
handoff: OwnerHandoff,
previousNotificationKey?: string,
decision?: HandoffAnnouncement['decision'],
): void {
tx.emit({
type: 'work.announced',
kind: OWNER_HANDOFF_NOTICE,
payload: {
issue: { id: issue.id, identifier: issue.identifier, title: issue.title },
handoff,
previousNotificationKey,
decision,
} satisfies HandoffAnnouncement,
});
}
export function ownerHandoffNoticeRule(): NoticeRule {
return async ({ events }) =>
events.flatMap((event) => {
if (
event.type !== 'work.announced' ||
event.kind !== OWNER_HANDOFF_NOTICE
)
return [];
const { issue, handoff, decision } = event.payload as HandoffAnnouncement;
if (decision === 'accept' || (!decision && !handoff.waiting)) return [];
const rejected = decision === 'reject';
const title = rejected
? `Owner handoff rejected: ${issue.identifier}`
: `Take over ${issue.identifier}`;
const body = rejected
? 'The owner declined the handoff. The issue remains paused.'
: 'Accept or reject the handoff on the issue. Agent work is paused until you accept.';
return [
{
key: `${ownerHandoffNoticeKey(handoff.notificationKey)}${rejected ? ':rejected' : ''}`,
kind: rejected ? ('info' as const) : ('decision' as const),
type: rejected ? 'owner_handoff_rejected' : 'owner_handoff',
issue,
userIds: rejected
? ownerHandoffRejectionRecipients(handoff)
: [handoff.ownerUserId],
actor: { type: 'system' as const, id: null, name: 'Studio' },
slot: 'change' as const,
params: {
title,
body,
identifier: issue.identifier,
notificationKey: handoff.notificationKey,
},
},
];
});
}
export async function settleOwnerHandoffNotice(
port: StudioInboxPort,
notice: HandoffAnnouncement,
): Promise<void> {
if (notice.previousNotificationKey)
await port.withdraw({
source: 'projects',
decisionKey: ownerHandoffNoticeKey(notice.previousNotificationKey),
});
if (notice.decision)
await port.resolve({
source: 'projects',
decisionKey: ownerHandoffNoticeKey(notice.handoff.notificationKey),
outcome: notice.decision === 'accept' ? 'accepted' : 'rejected',
});
}
@@ -0,0 +1,141 @@
import { agentsToken } from '@nocobase/app-plugin-agents/server/tokens';
import { authenticationToken } from '@nocobase/app-plugin-authentication';
import { authorizationToken } from '@nocobase/app-plugin-authorization';
import {
projectsAccessToken,
projectsToken,
} from '@nocobase/app-plugin-projects/server/tokens';
import type { Application } from '@nocobase/app-server/application';
import {
apiErrorResponse,
apiErrorResponses,
apiValidator,
cliRoute,
dataResponse,
defineApiRoutes,
describeRoute,
type AppApiRouteContribution,
} from '@nocobase/app-server/router';
import { Hono, type Context } from 'hono';
import { z } from 'zod';
import { createPermissionSource } from '../agents/commands/permissions.js';
import { createAskerLookup } from '../agents/conversation/acting.js';
import { callerOfRequest } from '../agents/run-principal.js';
import { studioError, studioErrorHandler } from '../http/errors.js';
import { createOwnerHandoffApi } from './owner-handoff-api.js';
const Params = z.object({ issueId: z.string().min(1) });
const Input = z.strictObject({ notificationKey: z.string().uuid() });
const Handoff = z.object({
issueId: z.string(),
waiting: z.boolean(),
ownerUserId: z.string(),
previousOwnerUserId: z.string(),
transferredByUserId: z.string(),
notificationKey: z.string(),
transferredAt: z.string(),
rejectedAt: z.string().nullable(),
});
export const ownerHandoffRoutes: AppApiRouteContribution<Application> =
defineApiRoutes(({ container }) => {
const agents = container.resolve(agentsToken);
const projects = container.resolve(projectsToken);
const permissions = createPermissionSource(
() => container.resolve(projectsAccessToken),
createAskerLookup(agents),
);
const api = createOwnerHandoffApi(projects, agents);
const router = new Hono();
router.onError(studioErrorHandler);
router.use(
'*',
container.resolve(authenticationToken).required({ scopedKeys: true }),
);
router.use('*', container.resolve(authorizationToken).middleware());
router.use('*', async (context, next) => {
const identity = callerOfRequest(context as unknown as Context);
const action =
context.req.method === 'GET' ? 'pm.issues/view' : 'pm.issues/edit';
if (!identity || !(await agents.gate.allowed(identity)).has(action))
throw studioError(
'PERMISSION_DENIED',
'ACTION_FORBIDDEN',
'This action is not allowed.',
);
if (context.req.method !== 'GET' && identity.kind !== 'user')
throw studioError(
'PERMISSION_DENIED',
'ISSUE_OWNER_HANDOFF_REQUIRED',
'An agent cannot consent to an owner handoff.',
);
await next();
});
router.get(
'/:issueId',
describeRoute({
tags: ['Studio'],
operationId: 'issueOwnerHandoffGet',
summary: 'Get an issue owner handoff',
...cliRoute(false),
security: [{ cookieAuth: [] }, { apiKeyAuth: [] }],
responses: {
200: dataResponse(
z.object({
handoff: Handoff.nullable(),
canDecide: z.boolean(),
canClaim: z.boolean(),
}),
),
404: apiErrorResponse(404, 'Issue not found.'),
...apiErrorResponses,
},
}),
apiValidator('param', Params),
async (context) => {
const identity = callerOfRequest(context as unknown as Context)!;
return context.json({
data: await api.read(
await permissions.viewerOf(identity),
context.req.valid('param').issueId,
identity.kind === 'user' ? identity.userId : null,
),
});
},
);
for (const decision of ['accept', 'reject'] as const)
router.post(
`/:issueId/${decision}`,
describeRoute({
tags: ['Studio'],
operationId: `issueOwnerHandoff${decision === 'accept' ? 'Accept' : 'Reject'}`,
summary: `${decision === 'accept' ? 'Accept' : 'Reject'} an issue owner handoff`,
...cliRoute(false),
security: [{ cookieAuth: [] }, { apiKeyAuth: [] }],
description:
'Only the current owner in person, with issue editing permission, may decide the current handoff. Rejection keeps the issue paused. A stale notification returns a conflict.',
responses: {
200: dataResponse(
z.object({ handoff: Handoff, runId: z.string().nullable() }),
),
409: apiErrorResponse(409, 'The handoff is no longer current.'),
404: apiErrorResponse(404, 'Issue not found.'),
...apiErrorResponses,
},
}),
apiValidator('param', Params),
apiValidator('json', Input),
async (context) => {
const identity = callerOfRequest(context as unknown as Context)!;
return context.json({
data: await api.decide(
await permissions.viewerOf(identity),
context.req.valid('param').issueId,
identity.kind === 'user' ? identity.userId : null,
context.req.valid('json').notificationKey,
decision,
),
});
},
);
return new Hono().route('/issueOwnerHandoffs', router);
});
@@ -0,0 +1,142 @@
/** One current handoff per issue; this is not an execution authorization or a request history. */
import { randomUUID } from 'node:crypto';
import type { DatabaseConnection } from '@nocobase/db';
import { studioError } from '../http/errors.js';
export const OWNER_HANDOFFS = 'studioIssueOwnerHandoffs';
export interface OwnerHandoff {
readonly issueId: string;
readonly waiting: boolean;
readonly ownerUserId: string;
readonly previousOwnerUserId: string;
readonly transferredByUserId: string;
/** Correlates an inbox item with the current transfer, including a transfer back to the same owner. */
readonly notificationKey: string;
readonly transferredAt: string;
readonly rejectedAt: string | null;
}
export async function readOwnerHandoff(
conn: DatabaseConnection,
issueId: string,
): Promise<OwnerHandoff | null> {
return (
(await conn.repository<OwnerHandoff>(OWNER_HANDOFFS).findOne({
filter: { issueId },
})) ?? null
);
}
/** Write in the owner change's transaction, before any wake or synchronous cancellation callback. */
export async function recordOwnerHandoff(
conn: DatabaseConnection,
change: {
readonly issueId: string;
readonly ownerUserId: string;
readonly previousOwnerUserId: string;
readonly transferredByUserId: string;
/** Null for a run or an automatic change, even when it acts for the target owner. */
readonly authenticatedPersonUserId: string | null;
},
): Promise<OwnerHandoff> {
const handoff: OwnerHandoff = {
issueId: change.issueId,
ownerUserId: change.ownerUserId,
previousOwnerUserId: change.previousOwnerUserId,
transferredByUserId: change.transferredByUserId,
waiting: change.authenticatedPersonUserId !== change.ownerUserId,
notificationKey: randomUUID(),
transferredAt: new Date().toISOString(),
rejectedAt: null,
};
await conn.repository(OWNER_HANDOFFS).upsertOne({
filter: { issueId: change.issueId },
create: { ...handoff },
update: { ...handoff },
});
return handoff;
}
/** A stale row never grants consent for a different current owner. */
export async function ownerHandoffWaiting(
conn: DatabaseConnection,
issue: { readonly id: string; readonly ownerUserId: string },
): Promise<boolean> {
const handoff = await readOwnerHandoff(conn, issue.id);
return (
handoff !== null &&
(handoff.waiting || handoff.ownerUserId !== issue.ownerUserId)
);
}
/**
* The caller has already checked issue access in this transaction. A route must pass the actual human identity,
* never a run's mapped userId. Decisions, activities and manager wakes belong to the same caller transaction.
*/
export async function decideOwnerHandoff(
conn: DatabaseConnection,
input: {
readonly issue: { readonly id: string; readonly ownerUserId: string };
readonly authenticatedPersonUserId: string | null;
readonly notificationKey: string;
readonly decision: 'accept' | 'reject';
},
): Promise<OwnerHandoff> {
const { issue } = input;
if (
!input.authenticatedPersonUserId ||
input.authenticatedPersonUserId !== issue.ownerUserId
)
throw studioError(
'PERMISSION_DENIED',
'ISSUE_OWNER_HANDOFF_REQUIRED',
'Only the current issue owner may accept or reject this handoff in person.',
);
const current = await readOwnerHandoff(conn, issue.id);
if (
!current?.waiting ||
current.rejectedAt !== null ||
current.ownerUserId !== issue.ownerUserId ||
current.notificationKey !== input.notificationKey
)
throw studioError(
'ABORTED',
'ISSUE_OWNER_HANDOFF_STALE',
'This handoff is no longer awaiting a decision. Refresh the issue.',
);
const next: OwnerHandoff = {
...current,
waiting: input.decision === 'reject',
rejectedAt: input.decision === 'reject' ? new Date().toISOString() : null,
};
const updated = await conn.repository(OWNER_HANDOFFS).updateMany({
filter: (f) =>
f.and([
f.string('issueId').eq(issue.id),
f.string('ownerUserId').eq(issue.ownerUserId),
f.string('notificationKey').eq(input.notificationKey),
f.boolean('waiting').isTrue(),
f.date('rejectedAt').empty(),
]),
values: { waiting: next.waiting, rejectedAt: next.rejectedAt },
});
if (updated.updatedCount !== 1)
throw studioError(
'ABORTED',
'ISSUE_OWNER_HANDOFF_STALE',
'This handoff is no longer awaiting a decision. Refresh the issue.',
);
return next;
}
/** Rejection informs both people, once each, without resuming any work. */
export function ownerHandoffRejectionRecipients(
handoff: OwnerHandoff,
): string[] {
return [
...new Set([handoff.previousOwnerUserId, handoff.transferredByUserId]),
];
}
@@ -2,6 +2,7 @@ import type { Application } from '@nocobase/app-server/application';
import type { AppRouteContribution } from '@nocobase/app-server/router';
import { accessRoutes } from '../access/routes.js';
import { ownerHandoffRoutes } from '../issues/owner-handoff-routes.js';
import { ciRunRoutes } from '../builds/ci-run-routes.js';
import { buildsRoutes } from '../builds/routes.js';
import { studioAgentsRoutes } from '../agents/routes.js';
@@ -19,6 +20,7 @@ import { reportsRoutes } from '../reports/routes.js';
const routes: readonly AppRouteContribution<Application>[] = [
accessRoutes,
ownerHandoffRoutes,
inboxRoutes,
studioAgentsRoutes,
issueRunsRoutes,
@@ -82,6 +82,7 @@ export type IssueProgressState =
export interface IssueProgress {
readonly waiting?: {
readonly reason:
| 'waitingOwner'
| 'noOwner'
| 'notConfigured'
| 'noRunner'
@@ -117,6 +117,7 @@ import {
} from '../../server/agents/conversation/delegation.js';
import { issueRunsRoutes } from '../../server/agents/run-views.js';
import { projectManagerRoutes } from '../../server/agents/manager/routes.js';
import { ownerHandoffRoutes } from '../../server/issues/owner-handoff-routes.js';
import { followUpsRoutes } from '../../server/follow-ups/routes.js';
import { createProjectManager } from '../../server/agents/manager/service.js';
import { studioProjectManagerToken } from '../../server/agents/manager/token.js';
@@ -1215,6 +1216,10 @@ export async function createBridgeHarness(
await studioAgentsRoutes.createRouter(appLike as unknown as never),
);
api.route('/', await inboxRoutes.createRouter(appLike as unknown as never));
api.route(
'/',
await ownerHandoffRoutes.createRouter(appLike as unknown as never),
);
api.route(
'/',
await projectManagerRoutes.createRouter(appLike as unknown as never),
@@ -348,6 +348,7 @@ describe('manager and delegated identities', () => {
await h.projects.issues.update(h.viewer('bob'), task.id, {
revision: current.revision,
ownerUserId: 'bob',
start: false,
});
expect((await h.agents.runs.get(run.id)).actorUserId).toBe('alice');
expect((await h.agents.runs.get(run.id)).cancelRequestedAt).not.toBeNull();
@@ -383,6 +384,7 @@ describe('manager and delegated identities', () => {
await h.projects.issues.update(h.viewer('bob'), task.id, {
revision: current.revision,
ownerUserId: 'bob',
start: false,
executor: null,
});
const after = await runs(task.id);
@@ -586,6 +588,7 @@ describe('manager and delegated identities', () => {
await h.projects.issues.update(h.viewer('bob'), task.id, {
revision: current.revision,
ownerUserId: 'bob',
start: false,
});
const retry = await h.agents.runs.retry(previous.id, 'bob');
expect(retry.actorUserId).toBe('bob');
@@ -0,0 +1,456 @@
// @vitest-environment node
import { RUNNER_ROUTES } from '@nocobase/agent-protocol';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createBridgeHarness, type BridgeHarness } from './bridge-harness.js';
import { readOwnerHandoff } from '../../server/issues/owner-handoff.js';
import { createAgentWork } from '../../server/agents/work.js';
import {
ownerHandoffNoticeRule,
OWNER_HANDOFF_NOTICE,
} from '../../server/issues/owner-handoff-notices.js';
import { connectedRepo } from '../git/helpers.js';
import { PROJECT_MANAGER_ID } from '../../shared/project-manager.js';
let h: BridgeHarness;
beforeEach(async () => {
h = await createBridgeHarness();
for (const id of ['alice', 'bob', 'carol']) {
await h.addUser(id);
h.roles.set(id, 'admin');
}
await h.createAgent();
});
afterEach(async () => h.close());
const runs = (id: string) =>
h.agents.runs.list({ subjectKind: 'issue', subjectId: id });
const task = (statusKey = 'in_progress') =>
h.projects.issues.create(h.viewer('alice'), {
title: 'Handoff',
statusKey,
start: false,
});
async function transfer(id: string, to = 'bob', by = 'alice') {
const issue = await h.projects.issueQueries.detail(h.viewer(by), id);
return h.projects.issues.update(h.viewer(by), id, {
revision: issue.revision,
ownerUserId: to,
});
}
async function decide(
id: string,
decision: 'accept' | 'reject',
user = 'bob',
key?: string,
) {
const handoff = await readOwnerHandoff(h.projects.tx.read(), id);
return h.request('POST', `/issueOwnerHandoffs/${id}/${decision}`, {
user,
body: { notificationKey: key ?? handoff!.notificationKey },
});
}
describe('owner acceptance', () => {
it('withdraws all queued work, stops held work, skips comments and release events, and resumes only the manager with comment delta', async () => {
const issue = await task();
await h.request('POST', `/projectManager/issues/${issue.id}/start`, {
user: 'alice',
});
const original = await h.claimOne();
const worker = await h.createAgent({ name: 'Worker' });
await h.projects.comments.create(h.viewer('alice'), issue.id, {
content: `[@Worker](mention://agent/${worker}) queued step`,
});
const before = await runs(issue.id);
await transfer(issue.id);
expect(
(await h.agents.runs.get(original.run.id)).cancelRequestedAt,
).not.toBeNull();
expect(
(await runs(issue.id)).filter((run) => run.status === 'queued'),
).toEqual([]);
await h.projects.comments.create(h.viewer('carol'), issue.id, {
content: `[@Worker](mention://agent/${worker}) waiting-period-comment`,
});
const current = await h.projects.issueQueries.detail(
h.viewer('bob'),
issue.id,
);
const work = createAgentWork({
agents: h.agents,
projects: () => h.projects,
});
await h.projects.tx.run(async (tx) => {
expect(
await work.onUnblocked!(tx, { issue: current, releasedBy: current }),
).toMatchObject([{ skipped: 'waitingOwner' }]);
expect(
await work.onSubtasksFinished!(tx, {
parent: current,
stage: null,
childIssueIds: [],
}),
).toMatchObject([{ skipped: 'waitingOwner' }]);
});
expect(await runs(issue.id)).toHaveLength(before.length);
expect(
(
await h.request('POST', `/projectManager/issues/${issue.id}/start`, {
user: 'bob',
})
).body.error.reason,
).toBe('ISSUE_OWNER_HANDOFF_PENDING');
expect(
(await h.request('GET', `/issueProgress/${issue.id}`, { user: 'alice' }))
.body.data.waiting,
).toMatchObject({ reason: 'waitingOwner', owner: { id: 'bob' } });
expect(
current.activities.some(
(activity) =>
activity.action === 'work_skipped' &&
activity.details.reason === 'waitingOwner',
),
).toBe(true);
await expect(
h.agents.runs.retry(
before.find((run) => run.status === 'queued')!.id,
'bob',
),
).rejects.toBeDefined();
expect((await decide(issue.id, 'accept')).status).toBe(200);
expect((await decide(issue.id, 'accept')).status).toBe(409);
const newRuns = (await runs(issue.id)).filter(
(run) => run.status === 'queued',
);
expect(newRuns).toHaveLength(1);
expect(newRuns[0]).toMatchObject({
agentId: PROJECT_MANAGER_ID,
actorUserId: 'bob',
});
await h.runner(RUNNER_ROUTES.cancelAck, original.run.id, {});
const claimed = await h.claimOne();
expect(claimed.run.id).toBe(newRuns[0].id);
expect(JSON.stringify(claimed)).toContain('waiting-period-comment');
});
it('keeps rejection paused and notifies the original owner and actual transfer user', async () => {
const issue = await task();
const events: Parameters<
ReturnType<typeof ownerHandoffNoticeRule>
>[0]['events'][number][] = [];
h.projects.events.on('work.announced', (event) => {
if (event.kind === OWNER_HANDOFF_NOTICE) events.push(event);
});
await transfer(issue.id, 'bob', 'carol');
expect((await decide(issue.id, 'reject')).status).toBe(200);
expect(
await readOwnerHandoff(h.projects.tx.read(), issue.id),
).toMatchObject({
waiting: true,
ownerUserId: 'bob',
rejectedAt: expect.any(String),
});
await h.projects.comments.create(h.viewer('alice'), issue.id, {
content: 'Do not resume yet',
});
expect(await runs(issue.id)).toEqual([]);
const notices = await h.projects.tx.run((tx) =>
ownerHandoffNoticeRule()({
tx,
events,
subscribers: async () => [],
nameOf: async () => null,
}),
);
expect(
notices.find((notice) => notice.type === 'owner_handoff')?.userIds,
).toEqual(['bob']);
expect(
notices.find((notice) => notice.type === 'owner_handoff_rejected')
?.userIds,
).toEqual(['alice', 'carol']);
expect(h.port.settled).toContainEqual({
decisionKey: `owner-handoff:${(await readOwnerHandoff(h.projects.tx.read(), issue.id))!.notificationKey}`,
outcome: 'rejected',
});
});
it('checks authentication, issue permission, actual human identity and stale notices including ABA', async () => {
const issue = await task();
await transfer(issue.id);
const first = (await readOwnerHandoff(h.projects.tx.read(), issue.id))!;
expect(
(
await h.request('POST', `/issueOwnerHandoffs/${issue.id}/accept`, {
body: { notificationKey: first.notificationKey },
})
).status,
).toBe(401);
expect((await decide(issue.id, 'accept', 'alice')).status).toBe(403);
h.roles.set('bob', 'none');
expect((await decide(issue.id, 'accept')).status).toBe(403);
h.roles.set('bob', 'admin');
const other = await h.projects.issues.create(h.viewer('bob'), {
title: 'Actual Bob run',
statusKey: 'in_progress',
start: false,
});
await h.request('POST', `/projectManager/issues/${other.id}/start`, {
user: 'bob',
});
const claimed = await h.claimOne();
expect(
(
await h.request('POST', `/issueOwnerHandoffs/${issue.id}/accept`, {
runToken: claimed.cli.credential.content.token,
body: { notificationKey: first.notificationKey },
})
).status,
).toBe(403);
await transfer(issue.id, 'carol');
await transfer(issue.id, 'bob');
expect(
(await decide(issue.id, 'accept', 'bob', first.notificationKey)).status,
).toBe(409);
expect(
(await readOwnerHandoff(h.projects.tx.read(), issue.id))!.waiting,
).toBe(true);
});
it('does not replace the first start when accepting or claiming a backlog task', async () => {
const issue = await task('backlog');
await transfer(issue.id);
const pending = await h.projects.issueQueries.detail(
h.viewer('bob'),
issue.id,
);
await expect(
h.projects.issues.update(h.viewer('bob'), issue.id, {
revision: pending.revision,
statusKey: 'in_progress',
}),
).rejects.toMatchObject({ reason: 'ISSUE_OWNER_HANDOFF_PENDING' });
expect(
(await h.projects.issueQueries.detail(h.viewer('bob'), issue.id))
.statusKey,
).toBe('backlog');
expect((await decide(issue.id, 'accept')).status).toBe(200);
await h.projects.comments.create(h.viewer('bob'), issue.id, {
content: 'Still requires start',
});
expect(await runs(issue.id)).toEqual([]);
await transfer(issue.id, 'alice', 'alice');
expect(
(await readOwnerHandoff(h.projects.tx.read(), issue.id))!.waiting,
).toBe(false);
expect(await runs(issue.id)).toEqual([]);
});
it('allows a human owner to claim their already assigned, rejected handoff', async () => {
const issue = await task();
await transfer(issue.id);
await decide(issue.id, 'reject');
await transfer(issue.id, 'bob', 'bob');
expect(await runs(issue.id)).toHaveLength(1);
expect(
(await readOwnerHandoff(h.projects.tx.read(), issue.id))!.waiting,
).toBe(false);
await h.projects.comments.create(h.viewer('alice'), issue.id, {
content: 'Continue after the claim',
});
expect((await runs(issue.id))[0]).toMatchObject({
agentId: PROJECT_MANAGER_ID,
actorUserId: 'bob',
});
});
it.each(['pending', 'rejected'] as const)(
'clears an unassigned %s handoff on a human claim and resumes only the owner manager',
async (state) => {
const issue = await task();
await h.projects.issues.update(h.viewer('alice'), issue.id, {
revision: issue.revision,
ownerUserId: 'bob',
executor: null,
});
if (state === 'rejected') await decide(issue.id, 'reject');
const before = await h.projects.issueQueries.detail(
h.viewer('bob'),
issue.id,
);
const response = await h.request(
'PATCH',
`/projects/issues/${issue.id}`,
{
user: 'bob',
body: { revision: before.revision, ownerUserId: 'bob' },
},
);
expect(response.status).toBe(200);
const returned = response.body.data.issue;
const stored = await h.projects.issueQueries.detail(
h.viewer('bob'),
issue.id,
);
expect(returned.revision).toBe(stored.revision);
expect(returned.executor).toEqual(stored.executor);
const edited = await h.request('PATCH', `/projects/issues/${issue.id}`, {
user: 'bob',
body: { revision: returned.revision, title: 'Immediate edit' },
});
expect(edited.status).toBe(200);
expect(
await readOwnerHandoff(h.projects.tx.read(), issue.id),
).toMatchObject({ waiting: false, rejectedAt: null });
const after = await h.projects.issueQueries.detail(
h.viewer('bob'),
issue.id,
);
expect(after.executor).toEqual({ type: 'agent', id: PROJECT_MANAGER_ID });
expect(await runs(issue.id)).toMatchObject([
{ agentId: PROJECT_MANAGER_ID, actorUserId: 'bob', status: 'queued' },
]);
expect(await runs(issue.id)).toHaveLength(1);
expect((await decide(issue.id, 'accept')).status).toBe(409);
await transfer(issue.id, 'bob', 'bob');
expect(await runs(issue.id)).toHaveLength(1);
},
);
it('does not let another person or an agent claim an unassigned handoff for its owner', async () => {
const issue = await task();
await h.projects.issues.update(h.viewer('alice'), issue.id, {
revision: issue.revision,
ownerUserId: 'bob',
executor: null,
});
for (const viewer of [
h.viewer('alice'),
{
...h.viewer('bob'),
actor: { type: 'user', id: 'bob', via: 'agent' } as const,
},
]) {
const current = await h.projects.issueQueries.detail(
h.viewer('bob'),
issue.id,
);
await h.projects.issues.update(viewer, issue.id, {
revision: current.revision,
ownerUserId: 'bob',
});
expect(
await readOwnerHandoff(h.projects.tx.read(), issue.id),
).toMatchObject({ waiting: true });
expect(await runs(issue.id)).toEqual([]);
}
});
it('clears an unassigned backlog handoff without replacing the first start', async () => {
const issue = await task('backlog');
await h.projects.issues.update(h.viewer('alice'), issue.id, {
revision: issue.revision,
ownerUserId: 'bob',
executor: null,
});
await transfer(issue.id, 'bob', 'bob');
expect(
await readOwnerHandoff(h.projects.tx.read(), issue.id),
).toMatchObject({ waiting: false, rejectedAt: null });
expect(await runs(issue.id)).toEqual([]);
await h.projects.comments.create(h.viewer('bob'), issue.id, {
content: 'Still not started',
});
expect(await runs(issue.id)).toEqual([]);
});
it('keeps the pause when the executor is cleared and restores the manager on acceptance', async () => {
const issue = await task();
await h.projects.issues.update(h.viewer('alice'), issue.id, {
revision: issue.revision,
ownerUserId: 'bob',
executor: null,
});
await h.projects.comments.create(h.viewer('alice'), issue.id, {
content: 'Comment with no executor',
});
const before = await h.projects.issueQueries.detail(
h.viewer('bob'),
issue.id,
);
expect(
before.activities.some(
(activity) =>
activity.action === 'work_skipped' &&
activity.details.reason === 'waitingOwner',
),
).toBe(true);
expect((await decide(issue.id, 'accept')).status).toBe(200);
const after = await h.projects.issueQueries.detail(
h.viewer('bob'),
issue.id,
);
expect(after.executor).toEqual({ type: 'agent', id: PROJECT_MANAGER_ID });
expect((await runs(issue.id))[0]).toMatchObject({
actorUserId: 'bob',
agentId: PROJECT_MANAGER_ID,
});
});
it('refuses a legacy queued attempt returned after transfer at claim time', async () => {
const issue = await task();
await h.request('POST', `/projectManager/issues/${issue.id}/start`, {
user: 'alice',
});
const [run] = await runs(issue.id);
await transfer(issue.id);
await h.database
.connection()
.repository('agRuns')
.updateOne({
filter: { id: run.id },
values: {
status: 'queued',
attempt: 2,
dispatchedAt: new Date().toISOString(),
},
});
expect(await h.claimOne()).toBeUndefined();
expect((await h.agents.runs.get(run.id)).status).toBe('cancelled');
});
it('keeps CI and PR signals in activity while skipping agent work', async () => {
const issue = await task();
const repo = await connectedRepo(h, 'acme/handoff');
h.github.addPull('acme/handoff', {
number: 1,
head: { ref: `agent/${issue.identifier}`, sha: 'head1' },
});
await h.git.link(
h.viewer('alice'),
issue.id,
'https://github.com/acme/handoff/pull/1',
{ type: 'user', id: 'alice' },
);
await transfer(issue.id);
h.github.setStatus('acme/handoff', 'head1', 'failure');
await h.git.pollRepo(repo);
h.github.pull('acme/handoff', 1).mergeable_state = 'dirty';
await h.git.pollRepo(repo);
h.github.setStatus('acme/handoff', 'head1', 'success');
await h.git.pollRepo(repo);
expect(
(await runs(issue.id)).filter((run) => run.status === 'queued'),
).toEqual([]);
const detail = await h.projects.issueQueries.detail(
h.viewer('bob'),
issue.id,
);
expect(
detail.activities.filter(
(activity) =>
activity.action === 'work_skipped' &&
activity.details.reason === 'waitingOwner',
).length,
).toBeGreaterThanOrEqual(3);
});
});
@@ -237,6 +237,34 @@ const card = (rail: HTMLElement, identifier: string) =>
rail.querySelector<HTMLElement>(`[data-issue="${identifier}"]`)!;
describe('My follow-ups', () => {
it('keeps handoff activities readable after sharing the issue activity wording', async () => {
const original = mocks.issue.getMockImplementation()!;
mocks.issue.mockImplementation(async (id: string) => ({
...(await original(id)),
activities: [
'studio_owner_transferred',
'studio_owner_accepted',
'studio_owner_rejected',
].map((action, index) => ({
id: `handoff-${index}`,
actorType: 'user',
actorId: 'alice',
actorName: 'Alice',
action,
details: action === 'work_skipped' ? { reason: 'waitingOwner' } : {},
via: null,
createdAt: at(index + 4),
})),
}));
await mount();
const panel = await screen.findByTestId('follow-ups-activity');
await waitFor(() =>
expect(panel.textContent).toContain('transferred responsibility'),
);
expect(panel.textContent).toContain('accepted responsibility');
expect(panel.textContent).toContain('rejected the handoff');
});
it('shows the followed issues, what needs the viewer and what is new, with the first one open', async () => {
const rail = await mount();
const first = card(rail, 'WEB-5');
@@ -0,0 +1,129 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
createTestI18nRuntime,
TestI18nProvider,
} from '@nocobase/i18n/testing';
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { afterEach, expect, it, vi } from 'vitest';
import enUS from '../../client/locales/en-US.js';
import zhCN from '../../client/locales/zh-CN.js';
import { OwnerHandoffActions } from '../../client/issues/detail/owner-handoff.js';
const mocks = vi.hoisted(() => ({ request: vi.fn(), show: vi.fn() }));
vi.mock('@nocobase/app-client', async (original) => ({
...(await original<typeof import('@nocobase/app-client')>()),
useApiClient: () => ({ request: mocks.request }),
useToaster: () => ({ show: mocks.show }),
}));
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
async function mount(
canDecide = true,
expected?: string,
language: 'en-US' | 'zh-CN' = 'zh-CN',
rejected = false,
) {
mocks.request.mockResolvedValue({
data: {
canDecide,
canClaim: canDecide || rejected,
handoff: {
waiting: true,
notificationKey: 'current',
rejectedAt: rejected ? '2026-10-12T00:00:00Z' : null,
ownerUserId: 'bob',
},
},
});
const runtime = await createTestI18nRuntime({
application: {
namespace: 'studio',
resources: language === 'en-US' ? enUS : zhCN,
},
locale: language,
});
const query = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<TestI18nProvider runtime={runtime}>
<QueryClientProvider client={query}>
<OwnerHandoffActions issueId='i1' notificationKey={expected} />
</QueryClientProvider>
</TestI18nProvider>,
);
}
it.each(['en-US', 'zh-CN'] as const)(
'submits the current decision and renders both actions in %s',
async (language) => {
await mount(true, undefined, language);
const accept = await screen.findByRole('button', {
name: language === 'en-US' ? 'Take over' : '接手',
});
expect(
screen.getByRole('button', {
name: language === 'en-US' ? 'Reject' : '拒绝',
}),
).toBeDefined();
fireEvent.click(accept);
await waitFor(() =>
expect(mocks.request).toHaveBeenCalledWith({
method: 'POST',
path: 'issueOwnerHandoffs/i1/accept',
json: { notificationKey: 'current' },
}),
);
},
);
it('hides decisions for an unauthorized viewer and explains a stale inbox item', async () => {
await mount(false);
await waitFor(() => expect(mocks.request).toHaveBeenCalled());
expect(screen.queryByRole('button')).toBeNull();
cleanup();
await mount(true, 'old');
expect(
await screen.findByText('此接手请求已失效,请刷新任务。'),
).toBeDefined();
expect(screen.queryByRole('button')).toBeNull();
});
it('submits rejection without removing the waiting marker locally', async () => {
await mount();
fireEvent.click(await screen.findByRole('button', { name: '拒绝' }));
await waitFor(() =>
expect(mocks.request).toHaveBeenCalledWith({
method: 'POST',
path: 'issueOwnerHandoffs/i1/reject',
json: { notificationKey: 'current' },
}),
);
});
it('lets the owner explicitly claim a rejected handoff using the issue revision', async () => {
await mount(false, undefined, 'zh-CN', true);
const claim = await screen.findByRole('button', { name: '认领' });
mocks.request.mockImplementation(
(request: { method?: string; path: string }) =>
Promise.resolve({
data:
request.path === 'projects/issues/i1'
? { revision: 4 }
: { canDecide: false, handoff: null },
}),
);
fireEvent.click(claim);
await waitFor(() =>
expect(mocks.request).toHaveBeenCalledWith({
method: 'PATCH',
path: 'projects/issues/i1',
json: { revision: 4, ownerUserId: 'bob' },
}),
);
});
@@ -1223,7 +1223,12 @@ describe('push credentials on demand', () => {
async function claimed(features: readonly string[] = ON_DEMAND) {
const app = await appConnection();
const agentId = await h.createAgent({ actions: ['pm.issues/view'] });
const linked = await projectWithRepo(app);
const linked = await projectWithRepo(app, 'in_progress');
linked.issue = await h.projects.issues.update(alice(), linked.issue.id, {
revision: linked.issue.revision,
executor: null,
start: false,
});
await mentionAgent(h, 'alice', linked.issue.id, agentId);
const payload = await h.claimOne(features);
return { app, payload, ...linked };
@@ -1263,7 +1268,12 @@ describe('push credentials on demand', () => {
it('lists nothing on demand for a token connection', async () => {
const connection = await tokenConnection(h);
const { issue } = await projectWithRepo(connection);
const { issue: created } = await projectWithRepo(connection, 'in_progress');
const issue = await h.projects.issues.update(alice(), created.id, {
revision: created.revision,
executor: null,
start: false,
});
const agentId = await h.createAgent({ actions: ['pm.issues/view'] });
await mentionAgent(h, 'alice', issue.id, agentId);
const payload = await h.claimOne(ON_DEMAND);
@@ -228,6 +228,12 @@ describe('required and reference checks', () => {
const agentId = await h.createAgent({ actions: ['pm.issues/view'] });
const issue = await h.projects.issues.create(alice(), {
title: 'Read checks',
statusKey: 'in_progress',
start: false,
});
await h.projects.issues.update(alice(), issue.id, {
revision: issue.revision,
executor: null,
start: false,
});
await repoWithReference();
@@ -0,0 +1,142 @@
// @vitest-environment node
import path from 'node:path';
import { describeMigration } from '@nocobase/app-testing/server';
import { expect } from 'vitest';
import {
decideOwnerHandoff,
ownerHandoffRejectionRecipients,
ownerHandoffWaiting,
readOwnerHandoff,
recordOwnerHandoff,
} from '../../server/issues/owner-handoff.js';
describeMigration('202610240040_studio_issue_owner_handoffs', {
sources: [
{
packageName: 'projects-issues-fixture',
directory: path.resolve(
import.meta.dirname,
'../fixtures/projects-issues',
),
},
{
packageName: '@nocobase/studio',
directory: path.resolve(
import.meta.dirname,
'../../database/main/migrations',
),
},
],
up: async ({ connection, expectCollection }) => {
await expectCollection('studioIssueOwnerHandoffs').toHaveField('waiting');
await expectCollection('studioIssueOwnerHandoffs').toHaveField(
'notificationKey',
);
const issue = { id: 'handoff', ownerUserId: 'bob' };
expect(await readOwnerHandoff(connection, issue.id)).toBeNull();
expect(await ownerHandoffWaiting(connection, issue)).toBe(false);
const transfer = {
issueId: issue.id,
ownerUserId: 'bob',
previousOwnerUserId: 'alice',
transferredByUserId: 'carol',
authenticatedPersonUserId: 'carol',
};
const first = await recordOwnerHandoff(connection, transfer);
expect(await ownerHandoffWaiting(connection, issue)).toBe(true);
expect(await readOwnerHandoff(connection, issue.id)).toMatchObject(first);
// A run acting for Bob cannot make Bob's decision, and Alice cannot decide for Bob.
for (const authenticatedPersonUserId of [null, 'alice']) {
await expect(
decideOwnerHandoff(connection, {
issue,
authenticatedPersonUserId,
notificationKey: first.notificationKey,
decision: 'accept',
}),
).rejects.toMatchObject({ reason: 'ISSUE_OWNER_HANDOFF_REQUIRED' });
}
const rejected = await decideOwnerHandoff(connection, {
issue,
authenticatedPersonUserId: 'bob',
notificationKey: first.notificationKey,
decision: 'reject',
});
expect(rejected.waiting).toBe(true);
expect(rejected.ownerUserId).toBe('bob');
expect(rejected.rejectedAt).not.toBeNull();
expect(ownerHandoffRejectionRecipients(rejected)).toEqual([
'alice',
'carol',
]);
expect(
ownerHandoffRejectionRecipients({
...rejected,
transferredByUserId: 'alice',
}),
).toEqual(['alice']);
await expect(
decideOwnerHandoff(connection, {
issue,
authenticatedPersonUserId: 'bob',
notificationKey: first.notificationKey,
decision: 'accept',
}),
).rejects.toMatchObject({ reason: 'ISSUE_OWNER_HANDOFF_STALE' });
// The current owner and current notice are checked independently: even an ABA transfer cannot reuse the old notice.
await recordOwnerHandoff(connection, { ...transfer, ownerUserId: 'dave' });
const latest = await recordOwnerHandoff(connection, transfer);
expect(latest.notificationKey).not.toBe(first.notificationKey);
await expect(
decideOwnerHandoff(connection, {
issue,
authenticatedPersonUserId: 'bob',
notificationKey: first.notificationKey,
decision: 'accept',
}),
).rejects.toMatchObject({ reason: 'ISSUE_OWNER_HANDOFF_STALE' });
await expect(
decideOwnerHandoff(connection, {
issue: { ...issue, ownerUserId: 'dave' },
authenticatedPersonUserId: 'dave',
notificationKey: latest.notificationKey,
decision: 'accept',
}),
).rejects.toMatchObject({ reason: 'ISSUE_OWNER_HANDOFF_STALE' });
const accepted = await decideOwnerHandoff(connection, {
issue,
authenticatedPersonUserId: 'bob',
notificationKey: latest.notificationKey,
decision: 'accept',
});
expect(accepted.waiting).toBe(false);
expect(await ownerHandoffWaiting(connection, issue)).toBe(false);
expect(
await ownerHandoffWaiting(connection, { ...issue, ownerUserId: 'dave' }),
).toBe(true);
const claimed = await recordOwnerHandoff(connection, {
...transfer,
authenticatedPersonUserId: 'bob',
transferredByUserId: 'bob',
});
expect(claimed.waiting).toBe(false);
// Agent and automatic assignment still need confirmation, even when the mapped user is the new owner.
expect(
(
await recordOwnerHandoff(connection, {
...transfer,
transferredByUserId: 'bob',
authenticatedPersonUserId: null,
})
).waiting,
).toBe(true);
},
down: async ({ expectCollection }) => {
await expectCollection('studioIssueOwnerHandoffs').not.toExist();
},
});
@@ -207,8 +207,11 @@ export function createIssueService(deps: IssueDeps): IssueService {
after: Issue,
actor: Actor,
start: boolean,
ownerClaimed = false,
): Promise<void> {
await deps.triggers().onIssueChanged(tx, { before, after, actor, start });
await deps
.triggers()
.onIssueChanged(tx, { before, after, actor, start, ownerClaimed });
await deps.relations().changed(tx, { before, after, actor });
tx.emit({ type: 'issue.changed', issueId: after.id });
for (const parentId of new Set([before.parentIssueId, after.parentIssueId]))
@@ -313,6 +316,11 @@ export function createIssueService(deps: IssueDeps): IssueService {
return deps.tx.run(async (tx) => {
const before = await requireVisible(tx.conn, viewer, idOrKey);
requireEditor(viewer);
const ownerClaimed =
patch.ownerUserId === before.ownerUserId &&
viewer.actor.type === 'user' &&
viewer.actor.via !== 'agent' &&
viewer.actor.id === before.ownerUserId;
// Status and project changes can release waits. Take the graph guard
// before this issue's first write, in the same order as relationship edits and plan execution.
if (
@@ -343,7 +351,7 @@ export function createIssueService(deps: IssueDeps): IssueService {
: { added: [], removed: [] };
if (labels.added.length > 0 || labels.removed.length > 0)
activities.push({ action: 'labels_changed', details: labels });
if (activities.length === 0) return;
if (activities.length === 0 && !ownerClaimed) return;
const now = new Date().toISOString();
await updateIssue(
tx.conn,
@@ -367,8 +375,16 @@ export function createIssueService(deps: IssueDeps): IssueService {
if (!written) return before;
const after = await reload(tx, before.id);
if (placed) await deps.relations().placed(tx, after);
await announce(tx, before, after, viewer.actor, patch.start !== false);
return after;
await announce(
tx,
before,
after,
viewer.actor,
patch.start !== false,
ownerClaimed,
);
// Work hooks may update the issue again within this transaction.
return reload(tx, before.id);
});
},
@@ -55,6 +55,8 @@ export interface StatusService {
}
export interface IssueChange {
/** A person explicitly assigned the issue to themselves, including an unchanged owner field. */
readonly ownerClaimed?: boolean;
/** Null for a new issue. */
readonly before: Issue | null;
readonly after: Issue;
@@ -56,6 +56,7 @@ declare module './events.js' {
* principals' comments started as many runs on the issue as the kind allows.
*/
export type RunAttemptSkip =
| 'waitingOwner'
| 'deferred'
| 'limited'
| 'denied'
@@ -96,6 +97,10 @@ export interface WorkWithdrawal {
* that transaction is a rehearsal (`tx.rehearsal`), it only answers what it would start.
*/
export interface IssueWorkHandler {
/** Receives comments even when the executor was cleared, for issue-wide pause policies. */
readonly observesComments?: boolean;
/** Receives trusted owner claims even when the executor is cleared. */
readonly observesOwnerClaims?: boolean;
/** A new issue, or an update (including a status moved by a status event). */
onIssueChanged(tx: Tx, change: IssueChange): Promise<readonly RunAttempt[]>;
/**
@@ -199,6 +204,7 @@ const blockerRefs = (blockers: readonly Blocker[]) =>
/** The skips recorded on the issue's activity. */
const RECORDED_SKIPS: ReadonlySet<string> = new Set([
'waitingOwner',
'blocked',
'dormant',
'limited',
@@ -264,12 +270,22 @@ export function kindTriggers(
for (const [, work] of handlersOf(kinds, [
before?.executor?.type,
after.executor?.type,
...(change.ownerClaimed
? kinds
.list()
.filter((kind) => kind.work?.observesOwnerClaims)
.map((kind) => kind.key)
: []),
]))
await report(tx, after, work.onIssueChanged(tx, change));
},
async onCommentCreated(tx, change) {
const concerned = handlersOf(kinds, [
...kinds
.list()
.filter((kind) => kind.work?.observesComments)
.map((kind) => kind.key),
change.issue.executor?.type,
change.parent?.authorType,
...change.mentions.map((ref) => ref.kind),
@@ -21,7 +21,13 @@ interface Seen {
}
/** A kind whose work handler records what it hears and starts work for every call, unless told to refuse. */
function addBots(options: { refuse?: boolean } = {}): Seen[] {
function addBots(
options: {
refuse?: boolean;
observesOwnerClaims?: boolean;
changed?: IssueWorkHandler['onIssueChanged'];
} = {},
): Seen[] {
const seen: Seen[] = [];
const attempt = (
subjectId: string,
@@ -38,7 +44,9 @@ function addBots(options: { refuse?: boolean } = {}): Seen[] {
},
];
const work: IssueWorkHandler = {
observesOwnerClaims: options.observesOwnerClaims,
onIssueChanged(_tx, change) {
if (options.changed) return options.changed(_tx, change);
if (options.refuse) return Promise.reject(new Error('No runner.'));
seen.push({
hook: 'changed',
@@ -86,6 +94,72 @@ function addBots(options: { refuse?: boolean } = {}): Seen[] {
return seen;
}
it('delivers only trusted owner claims to an opt-in work kind without an executor', async () => {
const seen = addBots({ observesOwnerClaims: true });
const issue = await h.services.issues.create(alice(), {
title: 'Claim',
executor: null,
});
seen.length = 0;
await h.services.issues.update(alice(), issue.id, {
revision: issue.revision,
title: 'Ordinary change',
});
expect(seen).toEqual([]);
const current = await h.services.issueQueries.detail(alice(), issue.id);
await h.services.issues.update(alice(), issue.id, {
revision: current.revision,
ownerUserId: 'alice',
});
expect(seen.filter((event) => event.hook === 'changed')).toHaveLength(1);
});
it('returns the final issue after ordinary updates and nested work-hook updates', async () => {
const calls: string[] = [];
addBots({
changed: async (tx, change) => {
calls.push(change.after.title);
if (change.after.title === 'Outer update')
await h.services.tx.savepoint(tx, () =>
h.services.issues.update(alice(), change.after.id, {
revision: change.after.revision,
title: 'Hook update',
start: false,
}),
);
return [];
},
});
const issue = await h.services.issues.create(alice(), {
title: 'Initial',
executor: { type: 'bot', id: 'b1' },
});
calls.length = 0;
const ordinary = await h.services.issues.update(alice(), issue.id, {
revision: issue.revision,
title: 'Ordinary update',
});
expect(ordinary).toMatchObject({
title: 'Ordinary update',
revision: issue.revision + 1,
});
const nested = await h.services.issues.update(alice(), issue.id, {
revision: ordinary.revision,
title: 'Outer update',
});
const stored = await h.services.issueQueries.detail(alice(), issue.id);
expect(nested).toMatchObject({
title: 'Hook update',
revision: ordinary.revision + 2,
});
expect(nested.revision).toBe(stored.revision);
expect(nested.title).toBe(stored.title);
expect(calls).toEqual(['Ordinary update', 'Outer update', 'Hook update']);
expect(
stored.activities.filter((entry) => entry.action === 'title_changed'),
).toHaveLength(3);
});
describe('work handlers of registered kinds', () => {
it('hears an issue given to its kind, in the same transaction, and reports the attempt', async () => {
const seen = addBots();