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 && (
-