From d1f9cd2686c497caea8faa730e84cb3ad80c5499 Mon Sep 17 00:00:00 2001 From: sunSUNQ <33311698+sunSUNQ@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:54:38 +0800 Subject: [PATCH 01/12] feat(scheduled-task): expose creatorUserId in the task resource --- api/openapi.json | 5 +++++ internal/contract/openapi.go | 4 ++-- internal/core/scheduled_task_api.go | 1 + 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/api/openapi.json b/api/openapi.json index 84bedaeb..a0093794 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -4322,6 +4322,10 @@ "format": "date-time", "type": "string" }, + "creatorUserId": { + "format": "uuid", + "type": "string" + }, "disabledReason": { "nullable": true, "type": "string" @@ -4374,6 +4378,7 @@ "required": [ "id", "issueId", + "creatorUserId", "name", "prompt", "agentId", diff --git a/internal/contract/openapi.go b/internal/contract/openapi.go index 0156d3f0..cc8c6ea0 100644 --- a/internal/contract/openapi.go +++ b/internal/contract/openapi.go @@ -244,7 +244,7 @@ func Document() map[string]any { }, "kind") s["ScheduledTaskApproval"] = object(obj{"policy": enumeration("full_auto_in_cloud_workspace")}, "policy") s["ScheduledTask"] = object(obj{ - "id": uuid(), "issueId": uuid(), "name": str(), "prompt": str(), "agentId": uuid(), + "id": uuid(), "issueId": uuid(), "creatorUserId": uuid(), "name": str(), "prompt": str(), "agentId": uuid(), "schedule": ref("ScheduledTaskSchedule"), "timezone": str(), "revision": number(), "version": number(), @@ -253,7 +253,7 @@ func Document() map[string]any { "unattendedConsentedAt": optional(timestamp()), "disabledReason": optional(str()), "createdAt": timestamp(), "updatedAt": timestamp(), - }, "id", "issueId", "name", "prompt", "agentId", "schedule", "timezone", "revision", "version", "enabled", "createdAt", "updatedAt") + }, "id", "issueId", "creatorUserId", "name", "prompt", "agentId", "schedule", "timezone", "revision", "version", "enabled", "createdAt", "updatedAt") s["ScheduledTaskList"] = object(obj{"tasks": array(ref("ScheduledTask")), "acceptanceEnabled": boolean()}, "tasks", "acceptanceEnabled") s["ScheduledTaskOccurrence"] = object(obj{ "scheduledAt": timestamp(), diff --git a/internal/core/scheduled_task_api.go b/internal/core/scheduled_task_api.go index 469513a5..a52b40f4 100644 --- a/internal/core/scheduled_task_api.go +++ b/internal/core/scheduled_task_api.go @@ -45,6 +45,7 @@ func scheduledTaskOut(task Object) Object { return Object{ "id": task.S("id"), "issueId": task.S("issueId"), + "creatorUserId": task.S("creatorUserId"), "name": task.S("name"), "prompt": task.S("prompt"), "agentId": task.S("agentId"), From 38ea5e5e0216f15f9e8659a43de452890bc7a775 Mon Sep 17 00:00:00 2001 From: sunSUNQ <33311698+sunSUNQ@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:05:25 +0800 Subject: [PATCH 02/12] feat(scheduled-task): project history into D6 vocabulary and emit Space events --- internal/core/public.go | 2 ++ internal/core/scheduled_task_api.go | 12 +++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/internal/core/public.go b/internal/core/public.go index fa5a991d..ab8fa0dc 100644 --- a/internal/core/public.go +++ b/internal/core/public.go @@ -241,6 +241,8 @@ func (s *Store) Public(ctx context.Context, r *PublicRequest) (Object, int, erro } else { out = Object{"resource": resolved} } + // D7:任务定义或历史随写变更,发 Space 失效事件让任务列表与历史刷新。 + events = append(events, SpaceEvent{Type: "space.scheduled_tasks_updated", SpaceID: soleSpace(t, r.TenantID).S("id")}) if r.Method == "POST" && !strings.HasSuffix(r.Path, "/history") { status = 200 } diff --git a/internal/core/scheduled_task_api.go b/internal/core/scheduled_task_api.go index a52b40f4..047ac7ba 100644 --- a/internal/core/scheduled_task_api.go +++ b/internal/core/scheduled_task_api.go @@ -312,7 +312,17 @@ func scheduledTaskHistory(t *transaction, r *PublicRequest) Object { item["failureReason"] = "timeout" break } - item["outcome"] = status + // D6: project the run's own terminal state into the history vocabulary — + // completed→succeeded, failed/cancelled stay as they are, any non-terminal + // (queued/dispatched/running/deferred) shows as running. + switch status { + case "completed": + item["outcome"] = "succeeded" + case "failed", "cancelled": + item["outcome"] = status + default: + item["outcome"] = "running" + } if entry["runFailureReason"] != nil { item["failureReason"] = entry["runFailureReason"] } From abea3fcb12144a36f24052c8c49f66589697c74b Mon Sep 17 00:00:00 2001 From: sunSUNQ <33311698+sunSUNQ@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:08:18 +0800 Subject: [PATCH 03/12] feat(scheduled-task): re-consent on enable and gate reads on the bound issue --- internal/core/scheduled_task_api.go | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/internal/core/scheduled_task_api.go b/internal/core/scheduled_task_api.go index 047ac7ba..f570df5b 100644 --- a/internal/core/scheduled_task_api.go +++ b/internal/core/scheduled_task_api.go @@ -23,11 +23,13 @@ func managedScheduledTask(t *transaction, r *PublicRequest, uid string) Object { } // readableScheduledTask loads the task for a READ operation: every effective tenant member who can -// read the bound issue may read the task and its history (D1 visibility follows the issue). +// read the bound issue may read the task and its history (D1 visibility follows the issue). A task +// whose bound issue has been deleted is not readable. func readableScheduledTask(t *transaction, r *PublicRequest) Object { require(validID(r.ScheduledTaskID), 404, "not_found") task := t.one("SELECT * FROM scheduled_tasks WHERE id=$1 AND tenant_id=$2 AND deleted_at IS NULL", r.ScheduledTaskID, r.TenantID) require(task != nil, 404, "not_found") + require(scheduledTaskIssue(t, r.TenantID, task.S("issueId")) != nil, 404, "not_found") return task } @@ -218,10 +220,20 @@ func jsonChanged(left, right Object) bool { // scheduledTaskEnable turns the task on (POST .../enable). The ADR's enable-time checks run here — // the same table an admission would run, minus the parts that need a due point — and a failure -// answers with its mapped reason so the form can explain what is missing (D5). +// answers with its mapped reason so the form can explain what is missing (D5). After a consent +// revocation the consent fact is cleared, so enabling again must carry a fresh explicit consent. func scheduledTaskEnable(t *transaction, r *PublicRequest, uid string) Object { task := managedScheduledTask(t, r, uid) - require(task.S("unattendedPolicy") == unattendedPolicyCloud, 409, "approval_policy_invalid") + policy := task.S("unattendedPolicy") + consentedAt := task["unattendedConsentedAt"] + if policy == "" { + // D5: 撤销同意后重新同意——consent 已清空,启用必须携带新的显式同意。 + approval := r.Body.O("approval") + require(approval.S("policy") == unattendedPolicyCloud, 409, "approval_policy_invalid") + policy = unattendedPolicyCloud + consentedAt = dbNow(t) + } + require(policy == unattendedPolicyCloud, 409, "approval_policy_invalid") creator := task.S("creatorUserId") require(creatorMembershipActive(t, task.S("id"), r.TenantID, creator), 409, "target_unavailable") issue := scheduledTaskIssue(t, r.TenantID, task.S("issueId")) @@ -232,8 +244,9 @@ func scheduledTaskEnable(t *transaction, r *PublicRequest, uid string) Object { require(creatorModelReady(t, creator, agent.S("pluginId")), 409, "model_unavailable") t.exec(`UPDATE scheduled_tasks - SET enabled=true, enabled_at=now(), disabled_reason=NULL, updated_at=now() - WHERE id=$1 AND enabled=false`, task.S("id")) + SET enabled=true, enabled_at=now(), disabled_reason=NULL, + unattended_policy=$2, unattended_consented_at=$3, updated_at=now() + WHERE id=$1 AND enabled=false`, task.S("id"), policy, consentedAt) return scheduledTaskOut(t.one("SELECT * FROM scheduled_tasks WHERE id=$1", task.S("id"))) } From 189ba4ebbec9230fd2ba9d596c082d998ec23413 Mon Sep 17 00:00:00 2001 From: sunSUNQ <33311698+sunSUNQ@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:22:41 +0800 Subject: [PATCH 04/12] feat(scheduled-task): persist the creator's model selection --- api/openapi.json | 7 +++++- internal/contract/openapi.go | 3 ++- .../migrations/0063_scheduled_task_model.sql | 3 +++ internal/core/scheduled_task_api.go | 23 ++++++++++++------- 4 files changed, 26 insertions(+), 10 deletions(-) create mode 100644 internal/core/migrations/0063_scheduled_task_model.sql diff --git a/api/openapi.json b/api/openapi.json index a0093794..c94f907c 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -4341,6 +4341,10 @@ "format": "uuid", "type": "string" }, + "model": { + "nullable": true, + "type": "string" + }, "name": { "type": "string" }, @@ -5010,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" }, diff --git a/internal/contract/openapi.go b/internal/contract/openapi.go index cc8c6ea0..fbfcf342 100644 --- a/internal/contract/openapi.go +++ b/internal/contract/openapi.go @@ -93,7 +93,7 @@ func Document() map[string]any { // issue_run.thread_appended / issue_run.thread_changed address one agent run's Thread (Thread // D5): issueId and runId name it and lastSeq is only a hint — the Thread GET alone moves a // client's cursor. Every other event omits the three fields. - s["SpaceEvent"] = object(obj{"type": enumeration("space.updated", "space.member_updated", "project.created", "project.updated", "project.archived", "space.plugins_updated", "plugins.catalog_updated", "issue_run.thread_appended", "issue_run.thread_changed", "project.repository_updated", "issue.conversation_changed"), "spaceId": uuid(), "projectId": optional(uuid()), "version": number(), "issueId": uuid(), "runId": uuid(), "lastSeq": number()}, "type", "spaceId") + s["SpaceEvent"] = object(obj{"type": enumeration("space.updated", "space.member_updated", "project.created", "project.updated", "project.archived", "space.plugins_updated", "plugins.catalog_updated", "issue_run.thread_appended", "issue_run.thread_changed", "project.repository_updated", "issue.conversation_changed", "space.scheduled_tasks_updated"), "spaceId": uuid(), "projectId": optional(uuid()), "version": number(), "issueId": uuid(), "runId": uuid(), "lastSeq": number()}, "type", "spaceId") // GitIdentity is the identity the caller's Agent runs commit as (identity-access git identity // D1). version is 0 while isDefault is true: no identity is stated and the default applies. s["GitIdentity"] = object(obj{"name": str(), "email": str(), "isDefault": boolean(), "version": number()}, "name", "email", "isDefault", "version") @@ -245,6 +245,7 @@ func Document() map[string]any { s["ScheduledTaskApproval"] = object(obj{"policy": enumeration("full_auto_in_cloud_workspace")}, "policy") s["ScheduledTask"] = object(obj{ "id": uuid(), "issueId": uuid(), "creatorUserId": uuid(), "name": str(), "prompt": str(), "agentId": uuid(), + "model": optional(str()), "schedule": ref("ScheduledTaskSchedule"), "timezone": str(), "revision": number(), "version": number(), diff --git a/internal/core/migrations/0063_scheduled_task_model.sql b/internal/core/migrations/0063_scheduled_task_model.sql new file mode 100644 index 00000000..66d0e869 --- /dev/null +++ b/internal/core/migrations/0063_scheduled_task_model.sql @@ -0,0 +1,3 @@ +-- D1: 创建者的模型选择随任务定义持久化(可空——缺省走创建者个人默认/agent 默认)。修改模型选择 +-- 增加 revision(见 scheduled_task_api.go)。Additive;不改写已应用的迁移。 +ALTER TABLE scheduled_tasks ADD COLUMN model text; diff --git a/internal/core/scheduled_task_api.go b/internal/core/scheduled_task_api.go index f570df5b..459a995d 100644 --- a/internal/core/scheduled_task_api.go +++ b/internal/core/scheduled_task_api.go @@ -51,6 +51,7 @@ func scheduledTaskOut(task Object) Object { "name": task.S("name"), "prompt": task.S("prompt"), "agentId": task.S("agentId"), + "model": nilIfEmpty(task.S("model")), "schedule": task["schedule"], "timezone": task.S("timezone"), "revision": task["revision"], @@ -73,6 +74,7 @@ func scheduledTaskCreate(t *transaction, r *PublicRequest, uid string) Object { agentID := r.Body.S("agentId") name := r.Body.S("name") prompt := r.Body.S("prompt") + model := r.Body.S("model") timezone := r.Body.S("timezone") enabled := r.Body.B("enabled") require(validID(issueID) && validID(agentID) && name != "" && prompt != "" && timezone != "", 400, "invalid_request") @@ -110,11 +112,11 @@ func scheduledTaskCreate(t *transaction, r *PublicRequest, uid string) Object { enabledAt = consentedAt } t.exec(`INSERT INTO scheduled_tasks - (id, tenant_id, issue_id, creator_user_id, agent_id, name, prompt, schedule, timezone, + (id, tenant_id, issue_id, creator_user_id, agent_id, name, prompt, model, schedule, timezone, revision, version, enabled, enabled_at, disabled_reason, unattended_policy, unattended_consented_at) - VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,1,1,$10,$11,NULL,$12,$13)`, - id, r.TenantID, issueID, uid, agentID, name, prompt, jsonText(scheduleDoc), timezone, + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,1,1,$11,$12,NULL,$13,$14)`, + id, r.TenantID, issueID, uid, agentID, name, prompt, nilIfEmpty(model), jsonText(scheduleDoc), timezone, enabled, enabledAt, unattendedPolicyCloud, consentedAt) task := t.one("SELECT * FROM scheduled_tasks WHERE id=$1", id) return scheduledTaskOut(task) @@ -140,6 +142,10 @@ func scheduledTaskUpdate(t *transaction, r *PublicRequest, uid string) Object { prompt = body.S("prompt") require(prompt != "", 400, "invalid_request") } + model := task.S("model") + if body["model"] != nil { + model = body.S("model") + } agentID := task.S("agentId") if body["agentId"] != nil { agentID = body.S("agentId") @@ -160,7 +166,8 @@ func scheduledTaskUpdate(t *transaction, r *PublicRequest, uid string) Object { scheduleDoc = doc } revision := task.N("revision") - contentChanged := prompt != task.S("prompt") || agentID != task.S("agentId") || + contentChanged := prompt != task.S("prompt") || model != task.S("model") || + agentID != task.S("agentId") || timezone != task.S("timezone") || scheduleDocJSONChanged(scheduleDoc, task["schedule"]) if contentChanged { revision = task.N("revision") + 1 @@ -178,10 +185,10 @@ func scheduledTaskUpdate(t *transaction, r *PublicRequest, uid string) Object { require(creatorModelReady(t, uid, agent.S("pluginId")), 409, "model_unavailable") updated := t.execRows(`UPDATE scheduled_tasks - SET name=$1, prompt=$2, agent_id=$3, schedule=$4, timezone=$5, - revision=$6, version=version+1, updated_at=now() - WHERE id=$7 AND tenant_id=$8 AND creator_user_id=$9 AND deleted_at IS NULL - AND version=$10`, name, prompt, agentID, jsonText(scheduleDoc), timezone, + SET name=$1, prompt=$2, model=$3, agent_id=$4, schedule=$5, timezone=$6, + revision=$7, version=version+1, updated_at=now() + WHERE id=$8 AND tenant_id=$9 AND creator_user_id=$10 AND deleted_at IS NULL + AND version=$11`, name, prompt, nilIfEmpty(model), agentID, jsonText(scheduleDoc), timezone, revision, task.S("id"), r.TenantID, uid, body.N("version")) require(updated == 1, 409, "version_conflict") return scheduledTaskOut(t.one("SELECT * FROM scheduled_tasks WHERE id=$1", task.S("id"))) From f71cf5ee4020c1246c0b53009b670b2ffa5f8323 Mon Sep 17 00:00:00 2001 From: sunSUNQ <33311698+sunSUNQ@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:27:15 +0800 Subject: [PATCH 05/12] feat(scheduled-task): show risk disclosure and require explicit consent --- .../scheduled-tasks/scheduled-task-form.tsx | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/frontend/src/features/scheduled-tasks/scheduled-task-form.tsx b/frontend/src/features/scheduled-tasks/scheduled-task-form.tsx index 61bb57ed..6032a7bd 100644 --- a/frontend/src/features/scheduled-tasks/scheduled-task-form.tsx +++ b/frontend/src/features/scheduled-tasks/scheduled-task-form.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' import { Dialog, DialogContent, @@ -185,6 +186,8 @@ function Fields({ setSchedule, enabled, setEnabled, + consented, + setConsented, }: { isEdit: boolean issues: Issue[] @@ -203,6 +206,8 @@ function Fields({ setSchedule: (value: ScheduledTaskSchedule) => void enabled: boolean setEnabled: (value: boolean) => void + consented: boolean + setConsented: (value: boolean) => void }) { return (
@@ -270,6 +275,20 @@ function Fields({ 创建后立即启用 )} + + {enabled && ( + + )}
) } @@ -322,13 +341,14 @@ export function ScheduledTaskForm({ const [timezone, setTimezone] = useState(draft.timezone) const [schedule, setSchedule] = useState(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 = '保存' @@ -361,6 +381,8 @@ export function ScheduledTaskForm({ setSchedule={setSchedule} enabled={enabled} setEnabled={setEnabled} + consented={consented} + setConsented={setConsented} /> {save.isError && ( From 9d19cbe701eebe738de038d5dee687afcbbec5c8 Mon Sep 17 00:00:00 2001 From: sunSUNQ <33311698+sunSUNQ@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:28:49 +0800 Subject: [PATCH 06/12] test(scheduled-task): cover model persistence, re-consent and bound-issue reads --- ...cheduled_task_contract_obligations_test.go | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 integration/scheduled_task_contract_obligations_test.go diff --git a/integration/scheduled_task_contract_obligations_test.go b/integration/scheduled_task_contract_obligations_test.go new file mode 100644 index 00000000..040a26f2 --- /dev/null +++ b/integration/scheduled_task_contract_obligations_test.go @@ -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) + } +} From 1c9f5a0931f3641c6e6518e8aa66e14695aacec5 Mon Sep 17 00:00:00 2001 From: sunSUNQ <33311698+sunSUNQ@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:35:47 +0800 Subject: [PATCH 07/12] chore(scheduled-task): regenerate typed client for contract additions --- frontend/src/api/generated.schemas.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/api/generated.schemas.ts b/frontend/src/api/generated.schemas.ts index d81d0f4e..03f9317e 100644 --- a/frontend/src/api/generated.schemas.ts +++ b/frontend/src/api/generated.schemas.ts @@ -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; @@ -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 { From 054ae80be7664c016306d4238e924380a262b283 Mon Sep 17 00:00:00 2001 From: sunSUNQ <33311698+sunSUNQ@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:37:11 +0800 Subject: [PATCH 08/12] test(scheduled-task): cover risk disclosure and consent gating --- .../scheduled-tasks/scheduled-tasks-page.test.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/frontend/src/features/scheduled-tasks/scheduled-tasks-page.test.tsx b/frontend/src/features/scheduled-tasks/scheduled-tasks-page.test.tsx index 0239d1ad..616bebda 100644 --- a/frontend/src/features/scheduled-tasks/scheduled-tasks-page.test.tsx +++ b/frontend/src/features/scheduled-tasks/scheduled-tasks-page.test.tsx @@ -16,6 +16,7 @@ function task(overrides: Partial = {}): ScheduledTask { return { id: 'st1', issueId: 'i1', + creatorUserId: 'u1', name: '每日同步', prompt: '同步仓库', agentId: 'a1', @@ -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 From a4ae75b7ebeb0f02278a52310b92a4939c41ae2b Mon Sep 17 00:00:00 2001 From: sunSUNQ <33311698+sunSUNQ@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:48:27 +0800 Subject: [PATCH 09/12] style(scheduled-task): format consent disclosure for prettier --- .../scheduled-tasks/scheduled-task-form.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/frontend/src/features/scheduled-tasks/scheduled-task-form.tsx b/frontend/src/features/scheduled-tasks/scheduled-task-form.tsx index 6032a7bd..be8f1646 100644 --- a/frontend/src/features/scheduled-tasks/scheduled-task-form.tsx +++ b/frontend/src/features/scheduled-tasks/scheduled-task-form.tsx @@ -31,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: '每周' }, @@ -277,16 +281,15 @@ function Fields({ )} {enabled && ( -