mirror of
https://github.com/nocobase/nocobase.git
synced 2026-10-16 06:56:22 +08:00
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:
@@ -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}} 正在等待:没有能运行首选或备选模型的在线执行机',
|
||||
|
||||
+22
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user