Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -4322,6 +4322,10 @@
"format": "date-time",
"type": "string"
},
"creatorUserId": {
"format": "uuid",
"type": "string"
},
"disabledReason": {
"nullable": true,
"type": "string"
Expand All @@ -4337,6 +4341,10 @@
"format": "uuid",
"type": "string"
},
"model": {
"nullable": true,
"type": "string"
},
"name": {
"type": "string"
},
Expand Down Expand Up @@ -4374,6 +4382,7 @@
"required": [
"id",
"issueId",
"creatorUserId",
"name",
"prompt",
"agentId",
Expand Down Expand Up @@ -5005,7 +5014,8 @@
"issue_run.thread_appended",
"issue_run.thread_changed",
"project.repository_updated",
"issue.conversation_changed"
"issue.conversation_changed",
"space.scheduled_tasks_updated"
],
"type": "string"
},
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/api/generated.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1995,11 +1995,14 @@ export interface ScheduledTaskSchedule {
export interface ScheduledTask {
agentId: string;
createdAt: string;
creatorUserId: string;
/** @nullable */
disabledReason?: string | null;
enabled: boolean;
id: string;
issueId: string;
/** @nullable */
model?: string | null;
name: string;
prompt: string;
revision: number;
Expand Down Expand Up @@ -2231,6 +2234,7 @@ export const SpaceEventType = {
issue_runthread_changed: 'issue_run.thread_changed',
projectrepository_updated: 'project.repository_updated',
issueconversation_changed: 'issue.conversation_changed',
spacescheduled_tasks_updated: 'space.scheduled_tasks_updated',
} as const;

export interface SpaceEvent {
Expand Down
125 changes: 96 additions & 29 deletions frontend/src/features/scheduled-tasks/scheduled-task-form.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
Expand Down Expand Up @@ -30,6 +31,10 @@ import {
const selectClass =
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs'

/** D5 无人值守授权的风险说明:必须显式同意后才能启用。 */
const CONSENT_RISK_STATEMENT =
'我已知悉并同意:定时运行在一次性隔离 Workspace 中全自动执行(非用户可见的数据边界承诺),Agent 可执行任意工具调用,消耗创建者个人模型额度,并以创建者 Git 身份提交。'

const KIND_OPTIONS: readonly { value: ScheduledTaskScheduleKind; label: string }[] = [
{ value: 'daily', label: '每天' },
{ value: 'weekly', label: '每周' },
Expand Down Expand Up @@ -185,6 +190,8 @@ function Fields({
setSchedule,
enabled,
setEnabled,
consented,
setConsented,
}: {
isEdit: boolean
issues: Issue[]
Expand All @@ -203,13 +210,77 @@ function Fields({
setSchedule: (value: ScheduledTaskSchedule) => void
enabled: boolean
setEnabled: (value: boolean) => void
consented: boolean
setConsented: (value: boolean) => void
}) {
return (
<div className="space-y-4">
<Field label="名称" htmlFor="st-name">
<Input id="st-name" value={name} onChange={(e) => setName(e.target.value)} />
</Field>

<TargetSelectors
isEdit={isEdit}
issues={issues}
agents={agents}
issueId={issueId}
setIssueId={setIssueId}
agentId={agentId}
setAgentId={setAgentId}
/>

<Field label="提示词" htmlFor="st-prompt">
<Textarea
id="st-prompt"
rows={4}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
/>
</Field>

<Field label="时区(IANA)" htmlFor="st-timezone">
<Input
id="st-timezone"
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
placeholder="Asia/Shanghai"
/>
</Field>

<ScheduleEditor schedule={schedule} onChange={setSchedule} />

{!isEdit && (
<label className="flex items-center gap-2 text-sm">
<Switch checked={enabled} onCheckedChange={setEnabled} />
创建后立即启用
</label>
)}

{enabled && <ConsentAgreement consented={consented} setConsented={setConsented} />}
</div>
)
}

/** The issue and agent target selectors; the issue selector is only shown on creation. */
function TargetSelectors({
isEdit,
issues,
agents,
issueId,
setIssueId,
agentId,
setAgentId,
}: {
isEdit: boolean
issues: Issue[]
agents: CollaborationTarget[]
issueId: string
setIssueId: (value: string) => void
agentId: string
setAgentId: (value: string) => void
}) {
return (
<>
{!isEdit && (
<Field label="绑定 issue" htmlFor="st-issue">
<select
Expand All @@ -227,7 +298,6 @@ function Fields({
</select>
</Field>
)}

<Field label="执行智能体" htmlFor="st-agent">
<select
id="st-agent"
Expand All @@ -243,34 +313,27 @@ function Fields({
))}
</select>
</Field>
</>
)
}

<Field label="提示词" htmlFor="st-prompt">
<Textarea
id="st-prompt"
rows={4}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
/>
</Field>

<Field label="时区(IANA)" htmlFor="st-timezone">
<Input
id="st-timezone"
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
placeholder="Asia/Shanghai"
/>
</Field>

<ScheduleEditor schedule={schedule} onChange={setSchedule} />

{!isEdit && (
<label className="flex items-center gap-2 text-sm">
<Switch checked={enabled} onCheckedChange={setEnabled} />
创建后立即启用
</label>
)}
</div>
/** The D5 unattended-consent risk statement and its required agreement checkbox. */
function ConsentAgreement({
consented,
setConsented,
}: {
consented: boolean
setConsented: (value: boolean) => void
}) {
return (
<label className="flex items-start gap-2 rounded-md border border-muted p-3 text-xs text-muted-foreground">
<Checkbox
checked={consented}
onCheckedChange={(checked) => setConsented(checked)}
aria-label="同意无人值守授权"
/>
<span>{CONSENT_RISK_STATEMENT}</span>
</label>
)
}

Expand Down Expand Up @@ -322,13 +385,15 @@ export function ScheduledTaskForm({
const [timezone, setTimezone] = useState(draft.timezone)
const [schedule, setSchedule] = useState<ScheduledTaskSchedule>(draft.schedule)
const [enabled, setEnabled] = useState(false)
const [consented, setConsented] = useState(false)

const submit = () =>
save.mutate(
{ issueId, agentId, name, prompt, schedule, timezone, enabled },
{ onSuccess: onClose },
)
const invalid = isDraftInvalid(isEdit, { name, prompt, agentId, issueId })
const invalid =
isDraftInvalid(isEdit, { name, prompt, agentId, issueId }) || (enabled && !consented)
let submitLabel = '创建'
if (save.isPending) submitLabel = '保存中…'
else if (isEdit) submitLabel = '保存'
Expand Down Expand Up @@ -361,6 +426,8 @@ export function ScheduledTaskForm({
setSchedule={setSchedule}
enabled={enabled}
setEnabled={setEnabled}
consented={consented}
setConsented={setConsented}
/>

{save.isError && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ function task(overrides: Partial<ScheduledTask> = {}): ScheduledTask {
return {
id: 'st1',
issueId: 'i1',
creatorUserId: 'u1',
name: '每日同步',
prompt: '同步仓库',
agentId: 'a1',
Expand Down Expand Up @@ -89,6 +90,16 @@ describe('ScheduledTasksPage', () => {
expect(screen.getByRole('dialog', { name: '新建定时任务' })).toBeInTheDocument()
})

it('shows the risk disclosure and requires consent once the task is enabled', async () => {
serve([], true)
const user = userEvent.setup()
renderPage()
await user.click(await screen.findByRole('button', { name: '新建定时任务' }))
await user.click(screen.getByRole('switch'))
expect(screen.getByLabelText('同意无人值守授权')).toBeInTheDocument()
expect(screen.getByText(/我已知悉并同意/)).toBeInTheDocument()
})

it('deletes a task only after confirmation', async () => {
serve([task()])
let deleted = null
Expand Down
110 changes: 110 additions & 0 deletions integration/scheduled_task_contract_obligations_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package integration

import (
"context"
"testing"

"github.com/wanglongan587/cloud/internal/core"
)

// TestScheduledTaskModelSelectionPersistsAndBumpsRevision pins D1 模型选择持久化:创建时携带的 model
// 原样落库并在读取时返回;修改 model 与修改 prompt/agent/schedule 一样递增 revision。
func TestScheduledTaskModelSelectionPersistsAndBumpsRevision(t *testing.T) {
store, s, agentID := scheduledTaskStore(t)
caller := scheduledTaskCaller(t, store, s.tenantID)
path := "/api/v1/tenants/" + s.tenantID + "/scheduled-tasks"

out, status, err := store.Public(context.Background(), &core.PublicRequest{
Method: "POST", Key: "create-with-model", Path: path,
TenantID: s.tenantID, Identity: caller,
Body: core.Object{
"issueId": s.issueID, "agentId": agentID, "name": "Model task", "prompt": "Sync",
"model": "vendor/model-v1",
"schedule": core.Object{"kind": "every_hours", "intervalHours": 1},
"timezone": "Asia/Shanghai",
"approval": core.Object{"policy": "full_auto_in_cloud_workspace"},
"enabled": false,
},
})
must(t, err)
if status != 200 {
t.Fatalf("create with model must succeed, got %d", status)
}
task := out.O("resource")
if task.S("model") != "vendor/model-v1" {
t.Fatalf("model must round-trip through create, got %q", task.S("model"))
}

_, status, err = store.Public(context.Background(), &core.PublicRequest{
Method: "PATCH", Key: "patch-model", Path: path + "/" + task.S("id"),
TenantID: s.tenantID, ScheduledTaskID: task.S("id"), Identity: caller,
Body: core.Object{"model": "vendor/model-v2", "version": task.N("version")},
})
must(t, err)
if status != 200 {
t.Fatalf("model update must succeed, got %d", status)
}
var model string
var revision int64
must(t, store.Pool.QueryRow(`SELECT COALESCE(model,''), revision FROM scheduled_tasks WHERE id=$1`, task.S("id")).Scan(&model, &revision))
if model != "vendor/model-v2" {
t.Fatalf("model update must persist, got %q", model)
}
if revision != 2 {
t.Fatalf("model change must bump revision, got %d", revision)
}
}

// TestScheduledTaskReconsentAfterRevoke pins D5 撤销同意后重新同意:撤销清空 consent 并停用;不带新
// 同意的启用被拒,携带新同意的启用恢复 consent 与启用状态。
func TestScheduledTaskReconsentAfterRevoke(t *testing.T) {
store, s, agentID := scheduledTaskStore(t)
caller := scheduledTaskCaller(t, store, s.tenantID)
taskID := seedEnabledScheduledTask(t, store, s, agentID, "Reconsent", 65)
path := "/api/v1/tenants/" + s.tenantID + "/scheduled-tasks/" + taskID

_, _, err := store.Public(context.Background(), &core.PublicRequest{
Method: "POST", Key: "revoke", Path: path + "/revoke-consent",
TenantID: s.tenantID, ScheduledTaskID: taskID, Identity: caller, Body: core.Object{},
})
must(t, err)

_, _, err = store.Public(context.Background(), &core.PublicRequest{
Method: "POST", Key: "enable-no-consent", Path: path + "/enable",
TenantID: s.tenantID, ScheduledTaskID: taskID, Identity: caller, Body: core.Object{},
})
if fault := core.ErrorCode(err); fault.Code != "approval_policy_invalid" {
t.Fatalf("enable without fresh consent must be approval_policy_invalid, got %v", err)
}

_, _, err = store.Public(context.Background(), &core.PublicRequest{
Method: "POST", Key: "enable-reconsent", Path: path + "/enable",
TenantID: s.tenantID, ScheduledTaskID: taskID, Identity: caller,
Body: core.Object{"approval": core.Object{"policy": "full_auto_in_cloud_workspace"}},
})
must(t, err)
var enabled bool
var policy string
must(t, store.Pool.QueryRow(`SELECT enabled, COALESCE(unattended_policy,'') FROM scheduled_tasks WHERE id=$1`, taskID).Scan(&enabled, &policy))
if !enabled || policy != "full_auto_in_cloud_workspace" {
t.Fatalf("re-consent enable must restore consent, got enabled=%v policy=%q", enabled, policy)
}
}

// TestScheduledTaskReadRequiresBoundIssue pins D1 读取跟随绑定 issue 可见性:绑定 issue 已删除后,
// 任务的读取与历史读取都回答 404 not_found。
func TestScheduledTaskReadRequiresBoundIssue(t *testing.T) {
store, s, agentID := scheduledTaskStore(t)
caller := scheduledTaskCaller(t, store, s.tenantID)
taskID := seedEnabledScheduledTask(t, store, s, agentID, "Orphan read", 65)
_, err := store.Pool.Exec(`UPDATE issues SET deleted_at=now() WHERE id=$1`, s.issueID)
must(t, err)

_, _, err = store.Public(context.Background(), &core.PublicRequest{
Method: "GET", Key: "orphan-get", Path: "/api/v1/tenants/" + s.tenantID + "/scheduled-tasks/" + taskID,
TenantID: s.tenantID, ScheduledTaskID: taskID, Identity: caller,
})
if fault := core.ErrorCode(err); fault.Status != 404 || fault.Code != "not_found" {
t.Fatalf("read of a task whose issue is deleted must be 404 not_found, got %v", err)
}
}
Loading
Loading