a617d39a4a
SaaS 侧人工审核后才创建 publish job,desktop 不再做二次审核:移除 manual/waiting_user/parked/from_parked 状态机与 LeaseFromParked 查询, desktop client 只执行发布并新增"发布管理"页(待发布队列 / 历史 / 再次 发送)。同时抽离 @geo/publisher-platforms 共享适配器包、新增 Redis-based desktop_presence 与 publish_record_support,刷新 admin-web 发布弹窗与 媒体库;plan A / spec 文档同步口径。
1502 lines
52 KiB
Markdown
1502 lines
52 KiB
Markdown
# Plan A · 桌面客户端骨架穿透 Implementation Plan
|
||
|
||
> **2026-04-20 当前实现口径**:desktop 侧二次人工审核与 `manual / waiting_user / parked / from_parked` 链路已废弃。SaaS 完成人工审核后才创建 publish job;desktop client 只负责执行发布,并提供“发布管理”查看待发布队列、历史结果与手动再次发送。
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 基于 Plan 0 的 workspace 底座,端到端打通一条 Doubao 监控任务 + 一条头条发布任务;desktop client 提供“发布管理”(待发布队列 + 历史结果 + 再次发送);Mac 公证版可安装;lease 协议在 fake 多实例 + 混沌测试下无脏写;UNKNOWN 任务 reconcile 工作流闭环。
|
||
|
||
**Architecture:** Electron 单进程树 + 多 Session + WebContentsView Hot/Cold 分档 + CDP 按 transport 分流抓取 + `/api/desktop/*` 定向路由协议 + SSE + 60s pull 兜底 + RabbitMQ fanout + Redis presence registry。桌面端复用 `packages/http-client + shared-types`;renderer 基于新建 `packages/ui-shared`(含 design tokens + composables);admin-web 复用同一套共享组件。
|
||
|
||
**Tech Stack:** Electron 41.2.0 (Chromium 146) + WebContentsView + webContents.debugger · Vue 3 + Vite + Ant Design Vue 4.x · pino · electron-builder · pnpm workspace · Go (gin/sqlc) · PostgreSQL · Redis · RabbitMQ · Playwright (_electron.launch)
|
||
|
||
**Prerequisite:** Plan 0 必须完成并打 `plan-0-complete` tag 后才能开工。
|
||
|
||
**Spec reference:** `docs/superpowers/specs/2026-04-18-electron-desktop-client-design.md` §12 Plan A
|
||
|
||
---
|
||
|
||
## Plan A 分 Phase 总览
|
||
|
||
| Phase | 范围 | 建议工期 | 依赖 | 可并行 |
|
||
|---|---|---|---|---|
|
||
| A.0 · Spike 前置 | §5.2 CDP × Doubao 三态矩阵 | 0.5 周 | Plan 0 | ❌ 必须先做 |
|
||
| A.1 · 服务端协议 + DB | 块 E:`/api/desktop/*` + `/api/tenant/publish-jobs,accounts,tasks,events` + desktop 四张表新建 + reconcile/cancel endpoints | 1.5 周 | A.0 | ✅ 与 A.2 并行 |
|
||
| A.2 · Desktop runtime 基座 | 块 A:Main 进程 / session-registry / view-pool / vault / lease-manager / rate-limiter + crash-recovery | 2 周 | A.0 | ✅ 与 A.1 并行 |
|
||
| A.3 · ui-shared H1 | `packages/ui-shared` 骨架 + design tokens + `useAccountSelection` composable + 5 个组件迁入 | 1.5 周 | Plan 0 | ✅ 与 A.1/A.2 并行 |
|
||
| A.4 · 1 监控适配器 Doubao | 基于 A.2 + A.0 spike 结论实现 | 1 周 | A.0, A.2 | — |
|
||
| A.5 · 1 发布适配器 头条号 | 基于 A.2 的直发状态机 + 队列节流 + 失败可再次发送 | 1 周 | A.2 | ✅ 与 A.4 并行 |
|
||
| A.6 · PublishArticleModal + 发布管理 H2 | 基于 A.3 composable + A.1 API 重构账号级多选 Modal,并补 desktop publish management 页 | 1 周 | A.1, A.2, A.3 | — |
|
||
| A.7 · 测试基建 + Mac 公证 | 块 I + F:fake server / fake 平台 / Playwright _electron + Mac 公证 CI | 1 周 | A.2, A.4, A.5 | ✅ 与 A.6 并行 |
|
||
| A.8 · Exit bar | E2E + 混沌 + 真实账号冒烟 + reconcile 闭环演练 | 0.5 周 | ALL | — |
|
||
|
||
**合计 5–6 工程周**(如果团队 ≥ 3 人并行 A.1+A.2+A.3)。
|
||
|
||
---
|
||
|
||
## Phase A.0 · §5.2 CDP Spike(0.5 周 · 必须先做)
|
||
|
||
**Owner:** runtime owner(负责 Electron/CDP 基建) + Doubao adapter owner 联签。
|
||
**产物:** `docs/superpowers/specs/electron-chromium-cdp-matrix.md` + 抓包/日志附件。
|
||
**Exit:** 三态结论(supported / degraded / blocked)对 Doubao 的实测完结。
|
||
|
||
### Task A.0.1:建 Spike 工作区
|
||
|
||
**Files:**
|
||
- Create: `apps/desktop-client/` pnpm workspace 初始骨架
|
||
- Create: `tools/spike-cdp/` 独立 Electron 最小脚本
|
||
|
||
- [ ] **Step 1:初始化 spike 工作区**
|
||
|
||
```bash
|
||
mkdir -p tools/spike-cdp && cd tools/spike-cdp
|
||
pnpm init
|
||
pnpm add -D electron@41.2.0 typescript @types/node
|
||
```
|
||
|
||
- [ ] **Step 2:写最小 Electron entry**
|
||
|
||
`tools/spike-cdp/main.ts`:
|
||
|
||
```ts
|
||
import { app, BrowserWindow, WebContentsView, BaseWindow } from 'electron'
|
||
|
||
app.whenReady().then(async () => {
|
||
const base = new BaseWindow({ width: 1200, height: 900 })
|
||
const view = new WebContentsView({
|
||
webPreferences: { contextIsolation: true, sandbox: true }
|
||
})
|
||
base.contentView.addChildView(view)
|
||
view.setBounds({ x: 0, y: 0, width: 1200, height: 900 })
|
||
|
||
const dbg = view.webContents.debugger
|
||
dbg.attach('1.3')
|
||
|
||
// 实测矩阵 A:EventSource → Network.eventSourceMessageReceived
|
||
await dbg.sendCommand('Network.enable')
|
||
dbg.on('message', (_e, method, params) => {
|
||
if (method === 'Network.eventSourceMessageReceived') {
|
||
console.log('[ESM]', JSON.stringify(params).slice(0, 200))
|
||
}
|
||
})
|
||
|
||
// 实测矩阵 B:fetch-stream → Fetch + takeResponseBodyAsStream
|
||
await dbg.sendCommand('Fetch.enable', { patterns: [{ urlPattern: '*doubao*' }] })
|
||
dbg.on('message', async (_e, method, params: any) => {
|
||
if (method === 'Fetch.requestPaused') {
|
||
console.log('[FRP]', params.request.url)
|
||
await dbg.sendCommand('Fetch.continueResponse', { requestId: params.requestId })
|
||
}
|
||
})
|
||
|
||
await view.webContents.loadURL('https://www.doubao.com/chat/')
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 3:commit spike scaffold**
|
||
|
||
```bash
|
||
git add tools/spike-cdp/
|
||
git commit -m "chore(spike): minimal Electron CDP spike harness"
|
||
```
|
||
|
||
### Task A.0.2:对 Doubao 做三态 spike
|
||
|
||
- [ ] **Step 1:登录 Doubao 账号(手工)**
|
||
|
||
```bash
|
||
cd tools/spike-cdp && pnpm exec electron main.ts
|
||
```
|
||
手动扫码登录 Doubao。
|
||
|
||
- [ ] **Step 2:问一个测试问题**
|
||
|
||
输入「你好」按回车,观察 console 日志哪类事件触发:
|
||
- 若出现 `[ESM]` 行:属 EventSource 路径 → transport = `EventSource`
|
||
- 若出现 `[FRP]` 行 → 抓到 fetch/chunked 请求,继续用 `Fetch.takeResponseBodyAsStream + IO.read` 读 body
|
||
|
||
- [ ] **Step 3:抓取响应 body**
|
||
|
||
依 Step 2 观察结果:
|
||
|
||
Path A(EventSource):记录 `params.data` 字段。
|
||
Path B(Fetch):
|
||
|
||
```ts
|
||
if (method === 'Fetch.requestPaused') {
|
||
const { stream } = await dbg.sendCommand('Fetch.takeResponseBodyAsStream', { requestId: params.requestId })
|
||
let out = ''
|
||
while (true) {
|
||
const r = await dbg.sendCommand('IO.read', { handle: stream, size: 8192 }) as any
|
||
out += r.data
|
||
if (r.eof) break
|
||
}
|
||
console.log('[BODY]', out.slice(0, 500))
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4:记录三态结论**
|
||
|
||
到 `docs/superpowers/specs/electron-chromium-cdp-matrix.md` 写首个条目:
|
||
|
||
```markdown
|
||
# Electron × Chromium × CDP 兼容矩阵
|
||
|
||
| Provider | Transport | Electron 41.2.0 / Chromium 146 | 结论 | Workaround / Notes |
|
||
|---|---|---|---|---|
|
||
| Doubao | <实测 transport> | <supported/degraded/blocked> | <描述> | <抓包附件 link> |
|
||
```
|
||
|
||
- [ ] **Step 5:附上 3 张 DevTools 截图 + 1 段原始日志**
|
||
|
||
放到 `docs/superpowers/specs/diagrams/spike-doubao-2026-04.png` 等。
|
||
|
||
- [ ] **Step 6:Go / No-Go 决策**
|
||
|
||
- 若 `supported` → A.4 可开工,parser 按实测 schema 写。
|
||
- 若 `degraded` → 记 workaround 条目,A.4 按 workaround 实现。
|
||
- 若 `blocked`(所有 CDP 路径都拿不到 body)→ 升级到 **fallback 决策**(DOM-only 降级 或 移出 Plan A 范围)。此时**停**继续,回产品线做范围决策。
|
||
|
||
- [ ] **Step 7:commit spike 结果**
|
||
|
||
```bash
|
||
git add docs/superpowers/specs/electron-chromium-cdp-matrix.md docs/superpowers/specs/diagrams/spike-*
|
||
git commit -m "spike(cdp): Doubao transport matrix (2026-04-18 Electron 41.2.0)"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase A.1 · 服务端协议 + DB(块 E · 1.5 周)
|
||
|
||
本 Phase 补齐 spec §6 的所有新 schema + 路由 + handler。**与 A.2 并行**。
|
||
|
||
### Task A.1.1:DB schema 迁移
|
||
|
||
**Files:**
|
||
- Create: `server/migrations/20260421100000_create_desktop_tables.up.sql`
|
||
- Create: `server/migrations/20260421100000_create_desktop_tables.down.sql`
|
||
|
||
- [ ] **Step 1:up migration(按 spec §6.6 完整 schema)**
|
||
|
||
```sql
|
||
-- desktop_clients
|
||
CREATE TABLE desktop_clients (
|
||
id UUID PRIMARY KEY,
|
||
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
|
||
workspace_id BIGINT NOT NULL REFERENCES workspaces(id),
|
||
user_id BIGINT NOT NULL REFERENCES users(id),
|
||
token_hash BYTEA NOT NULL,
|
||
device_name TEXT,
|
||
os TEXT,
|
||
cpu_arch TEXT,
|
||
client_version TEXT,
|
||
channel TEXT,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
last_seen_at TIMESTAMPTZ,
|
||
last_rotated_at TIMESTAMPTZ,
|
||
revoked_at TIMESTAMPTZ
|
||
);
|
||
CREATE INDEX idx_desktop_clients_workspace ON desktop_clients (workspace_id);
|
||
|
||
-- 重建 platform_accounts 为新 schema(drop 旧 + create 新,因为是开发版本无生产数据)
|
||
DROP TABLE IF EXISTS platform_accounts CASCADE;
|
||
CREATE TABLE platform_accounts (
|
||
id UUID PRIMARY KEY,
|
||
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
|
||
workspace_id BIGINT NOT NULL REFERENCES workspaces(id),
|
||
client_id UUID NOT NULL REFERENCES desktop_clients(id),
|
||
platform TEXT NOT NULL,
|
||
platform_uid TEXT NOT NULL,
|
||
account_fingerprint TEXT,
|
||
display_name TEXT NOT NULL,
|
||
health TEXT NOT NULL,
|
||
verified_at TIMESTAMPTZ,
|
||
tags JSONB,
|
||
sync_version BIGINT NOT NULL DEFAULT 1,
|
||
deleted_at TIMESTAMPTZ,
|
||
delete_requested_at TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
last_seen_at TIMESTAMPTZ,
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
CREATE UNIQUE INDEX uniq_platform_accounts_platform_uid
|
||
ON platform_accounts (platform, platform_uid) WHERE deleted_at IS NULL;
|
||
CREATE INDEX idx_platform_accounts_workspace
|
||
ON platform_accounts (workspace_id) WHERE deleted_at IS NULL;
|
||
CREATE INDEX idx_platform_accounts_client
|
||
ON platform_accounts (client_id) WHERE deleted_at IS NULL;
|
||
|
||
-- desktop_tasks
|
||
CREATE TABLE desktop_tasks (
|
||
id UUID PRIMARY KEY,
|
||
job_id UUID NOT NULL,
|
||
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
|
||
workspace_id BIGINT NOT NULL REFERENCES workspaces(id),
|
||
target_account_id UUID NOT NULL REFERENCES platform_accounts(id),
|
||
target_client_id UUID NOT NULL REFERENCES desktop_clients(id),
|
||
platform TEXT NOT NULL,
|
||
kind TEXT NOT NULL,
|
||
payload JSONB NOT NULL,
|
||
status TEXT NOT NULL,
|
||
dedup_key TEXT,
|
||
active_attempt_id UUID,
|
||
lease_token_hash BYTEA,
|
||
lease_expires_at TIMESTAMPTZ,
|
||
attempts INT NOT NULL DEFAULT 0,
|
||
result JSONB,
|
||
error JSONB,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
CREATE INDEX idx_tasks_job ON desktop_tasks (job_id);
|
||
CREATE INDEX idx_tasks_target_client_status
|
||
ON desktop_tasks (target_client_id, status)
|
||
WHERE status IN ('queued', 'in_progress');
|
||
CREATE INDEX idx_tasks_workspace ON desktop_tasks (workspace_id);
|
||
|
||
-- desktop_publish_jobs
|
||
CREATE TABLE desktop_publish_jobs (
|
||
id UUID PRIMARY KEY,
|
||
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
|
||
workspace_id BIGINT NOT NULL REFERENCES workspaces(id),
|
||
created_by_user_id BIGINT NOT NULL REFERENCES users(id),
|
||
title TEXT NOT NULL,
|
||
content_ref JSONB NOT NULL,
|
||
scheduled_at TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
|
||
-- desktop_task_attempts (审计)
|
||
CREATE TABLE desktop_task_attempts (
|
||
id UUID PRIMARY KEY,
|
||
task_id UUID NOT NULL REFERENCES desktop_tasks(id),
|
||
client_id UUID NOT NULL REFERENCES desktop_clients(id),
|
||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
ended_at TIMESTAMPTZ,
|
||
final_status TEXT,
|
||
error JSONB
|
||
);
|
||
|
||
-- desktop_task_traces
|
||
CREATE TABLE desktop_task_traces (
|
||
task_id UUID NOT NULL REFERENCES desktop_tasks(id),
|
||
attempt_id UUID,
|
||
size BIGINT,
|
||
uploaded_at TIMESTAMPTZ,
|
||
object_key TEXT
|
||
);
|
||
|
||
-- 删除旧 plugin_installations(硬切换,spec §10.2)
|
||
DROP TABLE IF EXISTS plugin_installations;
|
||
```
|
||
|
||
- [ ] **Step 2:down migration**
|
||
|
||
```sql
|
||
-- 还原:drop 新表、重建旧 plugin_installations(用于开发端万一回滚)
|
||
DROP TABLE IF EXISTS desktop_task_traces;
|
||
DROP TABLE IF EXISTS desktop_task_attempts;
|
||
DROP TABLE IF EXISTS desktop_publish_jobs;
|
||
DROP TABLE IF EXISTS desktop_tasks;
|
||
DROP TABLE IF EXISTS platform_accounts;
|
||
DROP TABLE IF EXISTS desktop_clients;
|
||
|
||
-- plugin_installations 重建略(开发端无生产数据可不回)
|
||
```
|
||
|
||
- [ ] **Step 3:跑迁移**
|
||
|
||
```bash
|
||
cd server && make migrate-up
|
||
```
|
||
|
||
- [ ] **Step 4:commit**
|
||
|
||
```bash
|
||
git add server/migrations/20260421100000_create_desktop_tables.*
|
||
git commit -m "feat(db): create desktop_clients/platform_accounts/desktop_tasks/publish_jobs/attempts/traces"
|
||
```
|
||
|
||
### Task A.1.2:sqlc queries + generated code
|
||
|
||
**Files:**
|
||
- Create: `server/internal/tenant/repository/queries/desktop_client.sql`
|
||
- Create: `server/internal/tenant/repository/queries/platform_account.sql`
|
||
- Create: `server/internal/tenant/repository/queries/desktop_task.sql`
|
||
- Create: `server/internal/tenant/repository/queries/desktop_publish_job.sql`
|
||
|
||
- [ ] **Step 1:写 desktop_client.sql**
|
||
|
||
以 spec §6.4 路由反推必须的查询:
|
||
|
||
```sql
|
||
-- name: RegisterDesktopClient :one
|
||
INSERT INTO desktop_clients (id, tenant_id, workspace_id, user_id, token_hash,
|
||
device_name, os, cpu_arch, client_version, channel)
|
||
VALUES (sqlc.arg(id), sqlc.arg(tenant_id), sqlc.arg(workspace_id), sqlc.arg(user_id),
|
||
sqlc.arg(token_hash), sqlc.arg(device_name), sqlc.arg(os), sqlc.arg(cpu_arch),
|
||
sqlc.arg(client_version), sqlc.arg(channel))
|
||
RETURNING *;
|
||
|
||
-- name: GetDesktopClientByTokenHash :one
|
||
SELECT * FROM desktop_clients
|
||
WHERE token_hash = sqlc.arg(token_hash) AND revoked_at IS NULL;
|
||
|
||
-- name: UpdateClientLastSeen :exec
|
||
UPDATE desktop_clients SET last_seen_at = now()
|
||
WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id);
|
||
|
||
-- name: RotateClientToken :one
|
||
UPDATE desktop_clients
|
||
SET token_hash = sqlc.arg(token_hash), last_rotated_at = now()
|
||
WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id)
|
||
RETURNING *;
|
||
|
||
-- name: RevokeClient :exec
|
||
UPDATE desktop_clients SET revoked_at = now()
|
||
WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id);
|
||
|
||
-- name: ListClientsByWorkspace :many
|
||
SELECT * FROM desktop_clients
|
||
WHERE workspace_id = sqlc.arg(workspace_id) AND revoked_at IS NULL
|
||
ORDER BY last_seen_at DESC NULLS LAST;
|
||
```
|
||
|
||
- [ ] **Step 2:写 platform_account.sql**
|
||
|
||
```sql
|
||
-- name: UpsertPlatformAccount :one
|
||
INSERT INTO platform_accounts (id, tenant_id, workspace_id, client_id, platform,
|
||
platform_uid, account_fingerprint, display_name, health, tags)
|
||
VALUES (sqlc.arg(id), sqlc.arg(tenant_id), sqlc.arg(workspace_id), sqlc.arg(client_id),
|
||
sqlc.arg(platform), sqlc.arg(platform_uid), sqlc.arg(account_fingerprint),
|
||
sqlc.arg(display_name), 'live', sqlc.arg(tags))
|
||
ON CONFLICT (platform, platform_uid) WHERE deleted_at IS NULL
|
||
DO UPDATE SET
|
||
display_name = EXCLUDED.display_name,
|
||
tags = EXCLUDED.tags,
|
||
sync_version = platform_accounts.sync_version + 1,
|
||
updated_at = now()
|
||
WHERE platform_accounts.workspace_id = EXCLUDED.workspace_id
|
||
RETURNING *;
|
||
|
||
-- name: GetPlatformAccount :one
|
||
SELECT * FROM platform_accounts
|
||
WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id) AND deleted_at IS NULL;
|
||
|
||
-- name: ListAccountsByWorkspace :many
|
||
SELECT * FROM platform_accounts
|
||
WHERE workspace_id = sqlc.arg(workspace_id) AND deleted_at IS NULL
|
||
AND (sqlc.narg(kind)::text IS NULL OR sqlc.narg(kind)::text = 'any'
|
||
OR (sqlc.narg(kind) = 'publish' AND platform NOT IN ('doubao','qwen','deepseek','hunyuan','kimi'))
|
||
OR (sqlc.narg(kind) = 'monitor' AND platform IN ('doubao','qwen','deepseek','hunyuan','kimi')))
|
||
ORDER BY platform, display_name;
|
||
|
||
-- name: PatchPlatformAccount :one
|
||
UPDATE platform_accounts
|
||
SET display_name = COALESCE(sqlc.narg(display_name), display_name),
|
||
health = COALESCE(sqlc.narg(health), health),
|
||
tags = COALESCE(sqlc.narg(tags), tags),
|
||
sync_version = sync_version + 1,
|
||
updated_at = now()
|
||
WHERE id = sqlc.arg(id)
|
||
AND workspace_id = sqlc.arg(workspace_id)
|
||
AND sync_version = sqlc.arg(if_sync_version)
|
||
AND deleted_at IS NULL
|
||
RETURNING *;
|
||
|
||
-- name: TombstonePlatformAccount :one
|
||
UPDATE platform_accounts
|
||
SET deleted_at = now(), sync_version = sync_version + 1, updated_at = now()
|
||
WHERE id = sqlc.arg(id)
|
||
AND workspace_id = sqlc.arg(workspace_id)
|
||
AND sync_version = sqlc.arg(if_sync_version)
|
||
AND deleted_at IS NULL
|
||
RETURNING *;
|
||
|
||
-- name: MarkDeleteRequested :one
|
||
UPDATE platform_accounts
|
||
SET delete_requested_at = now(), sync_version = sync_version + 1, updated_at = now()
|
||
WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id) AND deleted_at IS NULL
|
||
RETURNING *;
|
||
|
||
-- name: ClearDeleteRequested :exec
|
||
UPDATE platform_accounts
|
||
SET delete_requested_at = NULL, sync_version = sync_version + 1, updated_at = now()
|
||
WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id);
|
||
```
|
||
|
||
- [ ] **Step 3:写 desktop_task.sql(租约协议核心)**
|
||
|
||
```sql
|
||
-- name: CreatePublishJobTasks :many
|
||
WITH inserted AS (
|
||
INSERT INTO desktop_publish_jobs (id, tenant_id, workspace_id, created_by_user_id, title, content_ref, scheduled_at)
|
||
VALUES (sqlc.arg(job_id), sqlc.arg(tenant_id), sqlc.arg(workspace_id), sqlc.arg(user_id),
|
||
sqlc.arg(title), sqlc.arg(content_ref), sqlc.narg(scheduled_at))
|
||
RETURNING id
|
||
)
|
||
INSERT INTO desktop_tasks (id, job_id, tenant_id, workspace_id, target_account_id,
|
||
target_client_id, platform, kind, payload, status)
|
||
SELECT gen_random_uuid(), (SELECT id FROM inserted), sqlc.arg(tenant_id), sqlc.arg(workspace_id),
|
||
a.id, a.client_id, a.platform, 'publish', sqlc.arg(task_payload), 'queued'
|
||
FROM platform_accounts a
|
||
WHERE a.id = ANY(sqlc.arg(account_ids)::uuid[])
|
||
AND a.workspace_id = sqlc.arg(workspace_id)
|
||
RETURNING *;
|
||
|
||
-- name: LeaseTask :one
|
||
-- 正常从 queued 领取;只有 target_client_id 匹配才生效
|
||
UPDATE desktop_tasks
|
||
SET active_attempt_id = sqlc.arg(attempt_id),
|
||
lease_token_hash = sqlc.arg(lease_token_hash),
|
||
lease_expires_at = now() + interval '10 minutes',
|
||
status = 'in_progress',
|
||
attempts = attempts + 1,
|
||
updated_at = now()
|
||
WHERE id = sqlc.arg(task_id)
|
||
AND target_client_id = sqlc.arg(client_id)
|
||
AND status = 'queued'
|
||
RETURNING *;
|
||
|
||
-- name: ExtendLease :one
|
||
UPDATE desktop_tasks
|
||
SET lease_expires_at = now() + interval '10 minutes', updated_at = now()
|
||
WHERE id = sqlc.arg(task_id)
|
||
AND lease_token_hash = sqlc.arg(lease_token_hash)
|
||
AND status = 'in_progress'
|
||
RETURNING *;
|
||
|
||
-- name: CompleteTask :one
|
||
UPDATE desktop_tasks
|
||
SET status = sqlc.arg(status), result = sqlc.arg(result), error = sqlc.narg(error),
|
||
active_attempt_id = NULL, lease_token_hash = NULL, lease_expires_at = NULL,
|
||
updated_at = now()
|
||
WHERE id = sqlc.arg(task_id) AND lease_token_hash = sqlc.arg(lease_token_hash)
|
||
RETURNING *;
|
||
|
||
-- name: ReconcileTask :one
|
||
-- Web reconcile:status 可为 succeeded/failed/aborted/retry
|
||
-- retry = 原 task 回 queued + attempts+1
|
||
UPDATE desktop_tasks
|
||
SET status = CASE WHEN sqlc.arg(status)::text = 'retry' THEN 'queued' ELSE sqlc.arg(status)::text END,
|
||
attempts = attempts + CASE WHEN sqlc.arg(status)::text = 'retry' THEN 1 ELSE 0 END,
|
||
result = sqlc.narg(result),
|
||
error = sqlc.narg(error),
|
||
active_attempt_id = NULL,
|
||
lease_token_hash = NULL,
|
||
lease_expires_at = NULL,
|
||
updated_at = now()
|
||
WHERE id = sqlc.arg(task_id)
|
||
AND workspace_id = sqlc.arg(workspace_id)
|
||
AND status = 'unknown'
|
||
RETURNING *;
|
||
|
||
-- name: WebCancelTask :one
|
||
-- Web 取消 queued task
|
||
UPDATE desktop_tasks
|
||
SET status = 'aborted', updated_at = now()
|
||
WHERE id = sqlc.arg(task_id)
|
||
AND workspace_id = sqlc.arg(workspace_id)
|
||
AND status = 'queued'
|
||
RETURNING *;
|
||
|
||
-- name: GetTaskForAudit :one
|
||
SELECT * FROM desktop_tasks WHERE id = sqlc.arg(task_id) AND workspace_id = sqlc.arg(workspace_id);
|
||
|
||
-- name: ListPublishTasksByClient :many
|
||
-- 发布管理:排队任务优先,随后返回历史发送记录
|
||
SELECT * FROM desktop_tasks
|
||
WHERE target_client_id = sqlc.arg(client_id)
|
||
AND kind = 'publish'
|
||
ORDER BY
|
||
CASE WHEN status IN ('queued', 'in_progress') THEN 0 ELSE 1 END,
|
||
updated_at DESC
|
||
LIMIT sqlc.arg(limit_count);
|
||
|
||
-- name: ListQueuedTasksByClient :many
|
||
SELECT * FROM desktop_tasks
|
||
WHERE target_client_id = sqlc.arg(client_id) AND status = 'queued';
|
||
```
|
||
|
||
- [ ] **Step 4:sqlc generate + 编译**
|
||
|
||
```bash
|
||
cd server && sqlc generate && go build ./...
|
||
```
|
||
|
||
- [ ] **Step 5:commit**
|
||
|
||
```bash
|
||
git add server/internal/tenant/repository/
|
||
git commit -m "feat(sqlc): desktop task lease queries and publish management listing"
|
||
```
|
||
|
||
### Task A.1.3:Handler 层实现所有 `/api/desktop/*` 和 `/api/tenant/*` 白名单路由
|
||
|
||
**Files:**
|
||
- Create: `server/internal/tenant/transport/desktop_client_handler.go`
|
||
- Create: `server/internal/tenant/transport/desktop_account_handler.go`
|
||
- Create: `server/internal/tenant/transport/desktop_task_handler.go`
|
||
- Create: `server/internal/tenant/transport/publish_job_handler.go`
|
||
- Create: `server/internal/tenant/transport/tenant_events_handler.go`
|
||
- Modify: `server/internal/tenant/transport/router.go`
|
||
|
||
**说明:**每个 handler 按 spec §6.4 路由逐个实现。下面给 3 个关键端点的代码骨架,其余类似。
|
||
|
||
- [ ] **Step 1:Client register handler**
|
||
|
||
`desktop_client_handler.go`:
|
||
|
||
```go
|
||
package transport
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"net/http"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/google/uuid"
|
||
|
||
"github.com/geo-platform/tenant-api/internal/bootstrap"
|
||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||
"github.com/geo-platform/tenant-api/internal/shared/middleware"
|
||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||
"github.com/geo-platform/tenant-api/internal/tenant/repository/generated"
|
||
)
|
||
|
||
type DesktopClientHandler struct{ app *bootstrap.App }
|
||
|
||
func NewDesktopClientHandler(app *bootstrap.App) *DesktopClientHandler {
|
||
return &DesktopClientHandler{app: app}
|
||
}
|
||
|
||
type registerReq struct {
|
||
DeviceName string `json:"device_name" binding:"required"`
|
||
OS string `json:"os" binding:"required"`
|
||
CPUArch string `json:"cpu_arch"`
|
||
ClientVersion string `json:"client_version" binding:"required"`
|
||
Channel string `json:"channel"`
|
||
}
|
||
|
||
func (h *DesktopClientHandler) Register(c *gin.Context) {
|
||
actor := auth.MustActor(c.Request.Context())
|
||
var req registerReq
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
response.Error(c, response.ErrBadRequest(40001, "bad_json", err.Error()))
|
||
return
|
||
}
|
||
|
||
// 生成 client_token
|
||
raw := make([]byte, 32)
|
||
rand.Read(raw)
|
||
token := hex.EncodeToString(raw)
|
||
tokenHash := sha256Hash(token) // helper
|
||
|
||
client, err := h.app.DesktopRepo.RegisterDesktopClient(c.Request.Context(),
|
||
generated.RegisterDesktopClientParams{
|
||
ID: uuid.New(),
|
||
TenantID: actor.TenantID,
|
||
WorkspaceID: actor.PrimaryWorkspaceID,
|
||
UserID: actor.UserID,
|
||
TokenHash: tokenHash,
|
||
DeviceName: sql.NullString{String: req.DeviceName, Valid: true},
|
||
OS: sql.NullString{String: req.OS, Valid: true},
|
||
CPUArch: sql.NullString{String: req.CPUArch, Valid: true},
|
||
ClientVersion: sql.NullString{String: req.ClientVersion, Valid: true},
|
||
Channel: sql.NullString{String: req.Channel, Valid: req.Channel != ""},
|
||
})
|
||
if err != nil {
|
||
response.Error(c, response.ErrInternal(50000, "client_register_failed", err.Error()))
|
||
return
|
||
}
|
||
|
||
response.OK(c, gin.H{
|
||
"client_id": client.ID,
|
||
"client_token": token, // 仅此次返回,client 自己存
|
||
"expires_at": client.CreatedAt.Add(30 * 24 * time.Hour).Unix(),
|
||
})
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2:Publish job handler(账号级多选展开 + content_ref 边界校验)**
|
||
|
||
`publish_job_handler.go`:
|
||
|
||
```go
|
||
type createJobReq struct {
|
||
Title string `json:"title" binding:"required"`
|
||
ContentRef ContentRef `json:"content_ref" binding:"required"`
|
||
Accounts []struct {
|
||
AccountID uuid.UUID `json:"account_id" binding:"required"`
|
||
Mode string `json:"mode" binding:"required,oneof=auto manual"`
|
||
} `json:"accounts" binding:"required,min=1"`
|
||
ScheduledAt *time.Time `json:"scheduled_at"`
|
||
}
|
||
|
||
type ContentRef struct {
|
||
Type string `json:"type" binding:"required,oneof=article_draft template_gen video brand_asset"`
|
||
ID string `json:"id" binding:"required"`
|
||
}
|
||
|
||
func (h *PublishJobHandler) Create(c *gin.Context) {
|
||
actor := auth.MustActor(c.Request.Context())
|
||
var req createJobReq
|
||
if err := c.ShouldBindJSON(&req); err != nil { /* ... */ return }
|
||
|
||
// content_ref workspace 边界校验(spec §6.8.2 Phase 1 规则)
|
||
if err := h.validateContentRefInWorkspace(c, req.ContentRef, actor); err != nil {
|
||
response.Error(c, response.ErrUnprocessable(42201, "content_ref_not_in_primary_workspace",
|
||
"content_ref 引用的实体不属于当前 workspace"))
|
||
return
|
||
}
|
||
|
||
accountIDs := make([]uuid.UUID, len(req.Accounts))
|
||
for i, a := range req.Accounts { accountIDs[i] = a.AccountID }
|
||
|
||
taskPayload, _ := json.Marshal(map[string]any{
|
||
"content_ref": req.ContentRef,
|
||
"modes": lo.Associate(req.Accounts, func(a any) (uuid.UUID, string) { /* ... */ }),
|
||
})
|
||
|
||
tasks, err := h.app.DesktopRepo.CreatePublishJobTasks(c.Request.Context(),
|
||
generated.CreatePublishJobTasksParams{
|
||
JobID: uuid.New(),
|
||
TenantID: actor.TenantID,
|
||
WorkspaceID: actor.PrimaryWorkspaceID,
|
||
UserID: actor.UserID,
|
||
Title: req.Title,
|
||
ContentRef: req.ContentRef, // jsonb
|
||
AccountIDs: accountIDs,
|
||
TaskPayload: taskPayload,
|
||
})
|
||
if err != nil { /* ... */ return }
|
||
|
||
// 发 task_available 到 RabbitMQ
|
||
for _, t := range tasks {
|
||
h.app.EventBus.PublishTaskAvailable(t.TargetClientID, t.ID)
|
||
}
|
||
|
||
response.OK(c, gin.H{"job_id": tasks[0].JobID, "task_ids": lo.Map(tasks, func(t any) uuid.UUID { /* ... */ })})
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3:发布管理 handlers(列表 + 再次发送)**
|
||
|
||
```go
|
||
func (h *DesktopTaskHandler) ListPublish(c *gin.Context) {
|
||
client := middleware.MustClientFromCtx(c.Request.Context())
|
||
limit := clampInt(queryInt(c, "limit", 100), 1, 200)
|
||
|
||
tasks, err := h.app.DesktopTaskService.ListPublishTasks(c.Request.Context(), client, limit)
|
||
if err != nil { /* ... */ return }
|
||
|
||
response.OK(c, gin.H{"items": tasks})
|
||
}
|
||
|
||
func (h *DesktopTaskHandler) RetryPublish(c *gin.Context) {
|
||
client := middleware.MustClientFromCtx(c.Request.Context())
|
||
taskID := uuid.MustParse(c.Param("id"))
|
||
|
||
result, err := h.app.PublishJobService.RetryByDesktopTask(c.Request.Context(), client, taskID)
|
||
if err != nil { /* ... */ return }
|
||
|
||
h.app.EventBus.PublishTaskAvailable(result.TargetClientID, result.TaskID)
|
||
response.OK(c, result)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4:Tenant events SSE handler(参考 spec §6.8.4)**
|
||
|
||
```go
|
||
func (h *TenantEventsHandler) Subscribe(c *gin.Context) {
|
||
actor := auth.MustActor(c.Request.Context())
|
||
lastEventID := c.GetHeader("Last-Event-ID")
|
||
|
||
topics := events.DeriveAllowedTopics(actor)
|
||
if len(topics) == 0 {
|
||
response.Error(c, response.ErrUnauthorized(40106, "no_topics", "no subscribable topics"))
|
||
return
|
||
}
|
||
|
||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||
c.Writer.Header().Set("Connection", "keep-alive")
|
||
c.Writer.Flush()
|
||
|
||
// Step A: 发 snapshot(订阅建立首帧)
|
||
snapshot := h.app.EventBus.SnapshotForTopics(topics, lastEventID)
|
||
for _, evt := range snapshot {
|
||
fmt.Fprintf(c.Writer, "id: %d\nevent: %s\ndata: %s\n\n", evt.SeqID, evt.Type, evt.JSON)
|
||
c.Writer.Flush()
|
||
}
|
||
|
||
// Step B: 订阅 RabbitMQ,转发
|
||
sub := h.app.EventBus.Subscribe(topics)
|
||
defer sub.Close()
|
||
for {
|
||
select {
|
||
case <-c.Request.Context().Done():
|
||
return
|
||
case evt := <-sub.Chan:
|
||
if !events.ValidateTopicForActor(evt.Topic, actor) { continue }
|
||
fmt.Fprintf(c.Writer, "id: %d\nevent: %s\ndata: %s\n\n", evt.SeqID, evt.Type, evt.JSON)
|
||
c.Writer.Flush()
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5:router.go 把所有新 handler 挂到路由白名单(替换 Plan 0 的 notImplementedHandler)**
|
||
|
||
把 Task 0.3.2 的占位 handler 一一替换成上面的真实 handler。
|
||
|
||
- [ ] **Step 6:编译 + 集成测试**
|
||
|
||
```bash
|
||
cd server && go build ./... && go test ./internal/tenant/transport/
|
||
```
|
||
|
||
- [ ] **Step 7:commit**
|
||
|
||
```bash
|
||
git add server/internal/tenant/transport/
|
||
git commit -m "feat(api): desktop_clients/accounts/tasks/publish_jobs/tenant_events handlers"
|
||
```
|
||
|
||
### Task A.1.4:RabbitMQ fanout + Redis presence
|
||
|
||
**Files:**
|
||
- Create: `server/internal/shared/eventbus/rabbitmq_bus.go`
|
||
- Create: `server/internal/shared/eventbus/presence.go`
|
||
|
||
- [ ] **Step 1:定义 EventBus interface**
|
||
|
||
```go
|
||
package eventbus
|
||
|
||
type Event struct {
|
||
SeqID int64
|
||
Topic string
|
||
Type string
|
||
JSON json.RawMessage
|
||
Time time.Time
|
||
}
|
||
|
||
type EventBus interface {
|
||
Publish(topic string, eventType string, payload any) error
|
||
Subscribe(topics []string) Subscription
|
||
PublishTaskAvailable(targetClientID uuid.UUID, taskID uuid.UUID) error
|
||
SnapshotForTopics(topics []string, lastEventID string) []Event
|
||
}
|
||
|
||
type Subscription interface {
|
||
Chan() <-chan Event
|
||
Close() error
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2:RabbitMQ 实现(fanout exchange)**
|
||
|
||
略(标准 amqp091-go 使用方式;每个实例订阅 fanout,本地 filter 感兴趣 topic)。
|
||
|
||
- [ ] **Step 3:Redis presence**
|
||
|
||
```go
|
||
type Presence interface {
|
||
MarkOnline(ctx, clientID, instanceID) error // TTL 30s,每 10s 续期
|
||
IsOnline(ctx, clientID) (instanceID string, ok bool)
|
||
GetOnlineClientsInWorkspace(ctx, workspaceID) []uuid.UUID
|
||
}
|
||
```
|
||
|
||
用 `SET presence:<client_id> <instance_id> EX 30`。
|
||
|
||
- [ ] **Step 4:offline→online 监听器**
|
||
|
||
在每个 server 实例启动 worker,订阅 Redis keyspace notification `expired` / `set`,检测 `presence:<client>` 的 set 事件。收到时:
|
||
|
||
```go
|
||
queuedTasks, _ := repo.ListQueuedTasksByClient(ctx, clientID)
|
||
for _, t := range queuedTasks {
|
||
bus.PublishTaskAvailable(clientID, t.ID)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5:commit**
|
||
|
||
```bash
|
||
git add server/internal/shared/eventbus/
|
||
git commit -m "feat(eventbus): RabbitMQ fanout + Redis presence + offline→online task reclaim"
|
||
```
|
||
|
||
### Task A.1.5:服务端 Task 集成测试(关键:lease CAS / publish retry / cross-client)
|
||
|
||
**Files:**
|
||
- Create: `server/internal/tenant/transport/desktop_task_integration_test.go`
|
||
|
||
- [ ] **Step 1:核心测试用例**
|
||
|
||
```go
|
||
func TestListPublishTasks_ReturnsQueueBeforeHistory(t *testing.T) { /* ... */ }
|
||
func TestRetryPublish_RejectsNonTargetClient(t *testing.T) { /* ... */ }
|
||
func TestRetryPublish_CreatesNewQueuedTask(t *testing.T) { /* ... */ }
|
||
func TestQueuedTaskNotClaimedByOtherClient(t *testing.T) { /* ... */ }
|
||
func TestCrashedTargetClient_QueuedTaskIsRepublishedOnReconnect(t *testing.T) { /* ... */ }
|
||
func TestReconcileRetry_MovesTaskBackToQueued(t *testing.T) { /* ... */ }
|
||
func TestReconcileSucceeded_MarksTaskSucceeded(t *testing.T) { /* ... */ }
|
||
func TestWebCancelQueuedTask(t *testing.T) { /* ... */ }
|
||
func TestPublishJobRejectsContentRefFromAnotherWorkspace(t *testing.T) { /* 422 */ }
|
||
```
|
||
|
||
- [ ] **Step 2:跑测试**
|
||
|
||
```bash
|
||
cd server && go test ./internal/tenant/transport/ -run TestDesktop -v
|
||
```
|
||
|
||
- [ ] **Step 3:commit**
|
||
|
||
```bash
|
||
git add server/internal/tenant/transport/desktop_task_integration_test.go
|
||
git commit -m "test(desktop): task protocol + lease CAS + reconcile + Web cancel integration suite"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase A.2 · Desktop runtime 基座(块 A · 2 周)
|
||
|
||
### Task A.2.1:pnpm workspace + Electron scaffold
|
||
|
||
**Files:**
|
||
- Create: `apps/desktop-client/package.json`
|
||
- Create: `apps/desktop-client/tsconfig.json`
|
||
- Create: `apps/desktop-client/electron-builder.yml`
|
||
- Create: `apps/desktop-client/src/main/bootstrap.ts`
|
||
|
||
- [ ] **Step 1:package.json**
|
||
|
||
```json
|
||
{
|
||
"name": "@geo/desktop-client",
|
||
"version": "0.1.0",
|
||
"main": "dist/main/bootstrap.js",
|
||
"scripts": {
|
||
"dev": "electron-vite dev",
|
||
"build": "electron-vite build",
|
||
"package:mac": "electron-builder --mac --arm64 --x64 --universal",
|
||
"test": "vitest run",
|
||
"typecheck": "tsc --noEmit"
|
||
},
|
||
"dependencies": {
|
||
"@geo/http-client": "workspace:*",
|
||
"@geo/shared-types": "workspace:*",
|
||
"@geo/ui-shared": "workspace:*",
|
||
"electron-updater": "^6.x",
|
||
"pino": "^9.x",
|
||
"better-sqlite3": "^11.x"
|
||
},
|
||
"devDependencies": {
|
||
"electron": "41.2.0",
|
||
"electron-vite": "^2.x",
|
||
"electron-builder": "^25.x",
|
||
"vitest": "^2.x",
|
||
"@playwright/test": "^1.x"
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2:electron-vite 结构 + tsconfig + entry**
|
||
|
||
```
|
||
apps/desktop-client/
|
||
electron.vite.config.ts
|
||
src/
|
||
main/
|
||
bootstrap.ts # app ready, single-instance-lock, tray, autoUpdater
|
||
session-registry.ts
|
||
view-pool.ts
|
||
vault.ts
|
||
scheduler.ts
|
||
lease-manager.ts
|
||
rate-limiter.ts
|
||
circuit-breaker.ts
|
||
keep-alive.ts
|
||
crash-recovery.ts
|
||
transport/
|
||
api-client.ts
|
||
sse-client.ts
|
||
tracing/
|
||
recorder.ts
|
||
trace-writer.ts
|
||
redactor.ts
|
||
adapters/
|
||
base.ts
|
||
_shared/
|
||
doubao.ts # Phase A.4 填充
|
||
toutiao.ts # Phase A.5 填充
|
||
index.ts
|
||
preload/
|
||
bridge.ts
|
||
renderer/
|
||
main.ts
|
||
routes.ts
|
||
views/
|
||
```
|
||
|
||
- [ ] **Step 3:最小 bootstrap 能启动**
|
||
|
||
```ts
|
||
import { app, Tray, BaseWindow } from 'electron'
|
||
import { initSingleInstance } from './single-instance'
|
||
import { initTray } from './tray'
|
||
import { initSessionRegistry } from './session-registry'
|
||
import { initTransport } from './transport/api-client'
|
||
|
||
app.whenReady().then(async () => {
|
||
initSingleInstance()
|
||
await initSessionRegistry()
|
||
initTransport()
|
||
initTray()
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 4:commit scaffold**
|
||
|
||
```bash
|
||
git add apps/desktop-client/
|
||
git commit -m "chore(desktop-client): Electron + electron-vite scaffold"
|
||
```
|
||
|
||
### Task A.2.2-A.2.N:逐模块实现
|
||
|
||
> **Note:** 每个模块(session-registry / view-pool / vault / lease-manager / rate-limiter / keep-alive / circuit-breaker / crash-recovery / api-client / sse-client / tracing)都按如下模板做:
|
||
>
|
||
> 1. **写接口 + 失败测试**(Vitest)
|
||
> 2. **写最小实现**
|
||
> 3. **跑测试通过**
|
||
> 4. **commit**
|
||
|
||
下面给每个模块的**最小功能清单**;具体 bite-sized 步骤由 subagent 在 executing-plans 时展开。
|
||
|
||
---
|
||
|
||
### Task A.2.2: `session-registry.ts` + `view-pool.ts`
|
||
|
||
**功能清单(每条对应一个子 Task,TDD 实现):**
|
||
|
||
1. `SessionRegistry.create(platform)` → 分配新 partition (`persist:acc-<uuid>`),返回 session
|
||
2. `SessionRegistry.get(accountId)` → 已存在则返回,否则 lazy create
|
||
3. `ViewPool.acquireHot(accountId)` → 创建或复用 WebContentsView
|
||
4. `ViewPool.demoteToCool(accountId)` → 30 min 空闲后 close view but keep session partition on disk
|
||
5. 单元测试:Hot/Cold transitions + partition 持久化 + RAM baseline 断言
|
||
|
||
### Task A.2.3: `vault.ts` + `vault-export.ts`
|
||
|
||
1. `Vault.writeEncrypted(key, value)` → 用 `safeStorage.encryptString`;Linux 检测 backend
|
||
2. `Vault.readEncrypted(key)` → 解密;backend mismatch 返 error 不 panic
|
||
3. `VaultExport.exportToFile(path, passphrase)` → PBKDF2 + AES-GCM 用户口令二次加密
|
||
4. `VaultExport.importFromFile(path, passphrase)` → 解密后用新环境 safeStorage 重加密
|
||
5. 单元测试:Linux basic_text fallback 告警 + 跨环境导出/导入
|
||
|
||
### Task A.2.4: `lease-manager.ts`(关键)
|
||
|
||
1. `LeaseManager.acquire(taskId)` → `POST /tasks/lease`
|
||
2. `LeaseManager.extend(lease_token)` → 每 60s 自动调
|
||
3. `LeaseManager.complete(result)` → `POST /tasks/{id}/result`
|
||
4. `LeaseManager.cancel(taskId)` / `clearLocalState()` → 终态或放弃时清本地 lease
|
||
5. 单元测试:409 抢占幂等、断线重连带同一 attempt 的 409 处理、终态后不再续租
|
||
|
||
### Task A.2.5: `rate-limiter.ts`
|
||
|
||
1. Token bucket per (platform, account),桶容量 = `hourlyBudget`
|
||
2. 业务 / 保活 / 探活 同桶扣减
|
||
3. 429 / captcha 响应识别 → 指数退避
|
||
4. `powerMonitor suspend/resume` → 暂停 + 错峰 warm-up
|
||
5. 单元测试:桶耗尽时拒绝;resume 错峰分批
|
||
|
||
### Task A.2.6: `keep-alive.ts`
|
||
|
||
1. 按 spec §4.4 分层:probe 30 min / heartbeat 8 min(Doubao/Qwen)
|
||
2. 错峰:`hash(accountId) mod period ± 30s`
|
||
3. 静音时段(用户本地时区 0:00–7:00)
|
||
4. 失败升级 → `health=expired` → Tray 红点 + OS 通知
|
||
5. 单元测试:多账号错峰不同步;健康状态迁移
|
||
|
||
### Task A.2.7: `crash-recovery.ts`
|
||
|
||
1. 启动时 `SELECT FROM local_tasks WHERE lease_client_id = self AND status = 'in_progress'`
|
||
2. 按 task.kind 分治:monitor → 重跑;publish 已进 submitting → UNKNOWN
|
||
3. 上报服务端
|
||
4. 单元测试:publish submitting 崩溃 → UNKNOWN;monitor 崩溃 → 重跑无副作用
|
||
|
||
### Task A.2.8: `transport/api-client.ts` + `transport/sse-client.ts`
|
||
|
||
1. 基于 `@geo/http-client`,所有请求自动注入 `Authorization: Bearer <client_token>`
|
||
2. `SseClient`:EventTarget 类(不依赖 Vue),包含指数退避重连、`Last-Event-ID`、event_id 去重、snapshot 首帧处理
|
||
3. 60s pull 兜底(SSE disconnect 超 X 秒转 pull)
|
||
4. 单元测试:重连、重放、去重、fallback switching
|
||
|
||
### Task A.2.9: `tracing/` 三件套(Alpha 范围)
|
||
|
||
1. `Recorder`:按 Plan 开关,默认关闭;失败时自动 flush rolling buffer
|
||
2. `TraceWriter`:流式 zip,最多 50 份 / 500 MB
|
||
3. `Redactor`:Cookie / Authorization / password 等自动 `<redacted>`
|
||
4. 单元测试:脱敏不漏字段;buffer overflow 不 OOM
|
||
|
||
**每个 Task 都按"写接口 → 测试失败 → 最小实现 → 测试通过 → commit"的节奏走,具体步骤在 subagent execute 时展开。**
|
||
|
||
---
|
||
|
||
## Phase A.3 · ui-shared H1(1.5 周 · 与 A.1/A.2 并行)
|
||
|
||
### Task A.3.1:packages/ui-shared 骨架
|
||
|
||
**Files:**
|
||
- Create: `packages/ui-shared/package.json`
|
||
- Create: `packages/ui-shared/src/index.ts`
|
||
|
||
- [ ] **Step 1:package 初始化**
|
||
|
||
```bash
|
||
mkdir -p packages/ui-shared/src
|
||
cd packages/ui-shared && pnpm init
|
||
```
|
||
|
||
`package.json`:
|
||
```json
|
||
{
|
||
"name": "@geo/ui-shared",
|
||
"version": "0.1.0",
|
||
"type": "module",
|
||
"main": "src/index.ts",
|
||
"exports": {
|
||
".": "./src/index.ts",
|
||
"./tokens": "./src/tokens/index.ts",
|
||
"./composables": "./src/composables/index.ts",
|
||
"./components": "./src/components/index.ts"
|
||
},
|
||
"peerDependencies": {
|
||
"vue": "^3.x",
|
||
"ant-design-vue": "^4.x"
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2:commit**
|
||
|
||
```bash
|
||
git add packages/ui-shared/package.json pnpm-workspace.yaml
|
||
pnpm install
|
||
git commit -m "chore(ui-shared): init packages/ui-shared"
|
||
```
|
||
|
||
### Task A.3.2:Design tokens
|
||
|
||
**Files:**
|
||
- Create: `packages/ui-shared/src/tokens/colors.ts`
|
||
- Create: `packages/ui-shared/src/tokens/theme.ts`
|
||
- Create: `packages/ui-shared/src/tokens/index.css`
|
||
|
||
- [ ] **Step 1:CSS variables light/dark**
|
||
|
||
```css
|
||
:root {
|
||
--color-bg-primary: #ffffff;
|
||
--color-bg-secondary: #f5f7fa;
|
||
--color-text-primary: #0f172a;
|
||
--color-text-secondary: #475569;
|
||
--color-border: #e2e8f0;
|
||
--color-brand: #2563eb;
|
||
--color-success: #16a34a;
|
||
--color-warn: #eab308;
|
||
--color-danger: #ef4444;
|
||
--spacing-xs: 4px;
|
||
--spacing-sm: 8px;
|
||
--spacing-md: 16px;
|
||
--spacing-lg: 24px;
|
||
--radius-sm: 4px;
|
||
--radius-md: 8px;
|
||
}
|
||
|
||
html[data-theme='dark'] {
|
||
--color-bg-primary: #0f172a;
|
||
--color-bg-secondary: #1e293b;
|
||
--color-text-primary: #f1f5f9;
|
||
--color-text-secondary: #94a3b8;
|
||
--color-border: #334155;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2:AntD theme config**
|
||
|
||
```ts
|
||
// theme.ts
|
||
import type { ThemeConfig } from 'ant-design-vue/es/config-provider'
|
||
|
||
export const lightTheme: ThemeConfig = {
|
||
token: {
|
||
colorPrimary: '#2563eb',
|
||
colorSuccess: '#16a34a',
|
||
colorWarning: '#eab308',
|
||
colorError: '#ef4444',
|
||
borderRadius: 8,
|
||
}
|
||
}
|
||
|
||
export const darkTheme: ThemeConfig = {
|
||
algorithm: 'darkAlgorithm',
|
||
token: { colorPrimary: '#60a5fa' }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3:commit**
|
||
|
||
```bash
|
||
git add packages/ui-shared/src/tokens/
|
||
git commit -m "feat(ui-shared): design tokens (CSS variables + AntD theme light/dark)"
|
||
```
|
||
|
||
### Task A.3.3:`useAccountSelection` composable(多选核心)
|
||
|
||
**Files:**
|
||
- Create: `packages/ui-shared/src/composables/useAccountSelection.ts`
|
||
- Create: `packages/ui-shared/src/composables/useAccountSelection.test.ts`
|
||
|
||
- [ ] **Step 1:写失败测试**
|
||
|
||
```ts
|
||
import { describe, it, expect } from 'vitest'
|
||
import { useAccountSelection, type AccountCard } from './useAccountSelection'
|
||
|
||
const sample: AccountCard[] = [
|
||
{ id: 'a1', platform: 'wechat', displayName: 'GZH A', tags: ['brand'], online: true, health: 'live' },
|
||
{ id: 'a2', platform: 'wechat', displayName: 'GZH B', tags: ['test'], online: false, health: 'expired' },
|
||
{ id: 'b1', platform: 'toutiao', displayName: 'Toutiao 1', tags: ['brand'], online: true, health: 'live' },
|
||
]
|
||
|
||
describe('useAccountSelection', () => {
|
||
it('selects by tag', () => {
|
||
const sel = useAccountSelection(sample)
|
||
sel.selectByTag('brand')
|
||
expect([...sel.selected.value]).toEqual(['a1', 'b1'])
|
||
})
|
||
|
||
it('selects by platform', () => {
|
||
const sel = useAccountSelection(sample)
|
||
sel.selectByPlatform('wechat')
|
||
expect([...sel.selected.value]).toEqual(['a1', 'a2'])
|
||
})
|
||
|
||
it('search filters visible', () => {
|
||
const sel = useAccountSelection(sample)
|
||
sel.search.value = 'GZH'
|
||
expect(sel.visible.value.map(a => a.id)).toEqual(['a1', 'a2'])
|
||
})
|
||
|
||
it('toggle inverts selection per visible', () => {
|
||
const sel = useAccountSelection(sample)
|
||
sel.selectAll()
|
||
sel.invert()
|
||
expect(sel.selected.value.size).toBe(0)
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2:跑失败**
|
||
|
||
```bash
|
||
cd packages/ui-shared && pnpm test -- useAccountSelection
|
||
```
|
||
Expected: FAIL。
|
||
|
||
- [ ] **Step 3:写实现**
|
||
|
||
```ts
|
||
import { computed, ref } from 'vue'
|
||
|
||
export type AccountCard = {
|
||
id: string
|
||
platform: string
|
||
displayName: string
|
||
tags: string[]
|
||
online: boolean
|
||
health: 'live' | 'expired' | 'captcha' | 'risk'
|
||
}
|
||
|
||
export function useAccountSelection(initial: AccountCard[]) {
|
||
const all = ref(initial)
|
||
const selected = ref(new Set<string>())
|
||
const search = ref('')
|
||
const tagFilter = ref<string[]>([])
|
||
const platformFilter = ref<string[]>([])
|
||
|
||
const visible = computed(() =>
|
||
all.value.filter(a => {
|
||
if (search.value && !a.displayName.includes(search.value) && !a.id.includes(search.value)) return false
|
||
if (tagFilter.value.length && !a.tags.some(t => tagFilter.value.includes(t))) return false
|
||
if (platformFilter.value.length && !platformFilter.value.includes(a.platform)) return false
|
||
return true
|
||
})
|
||
)
|
||
|
||
function toggle(id: string) {
|
||
if (selected.value.has(id)) selected.value.delete(id)
|
||
else selected.value.add(id)
|
||
}
|
||
|
||
function selectAll() {
|
||
for (const a of visible.value) selected.value.add(a.id)
|
||
}
|
||
function invert() {
|
||
const s = new Set<string>()
|
||
for (const a of visible.value) if (!selected.value.has(a.id)) s.add(a.id)
|
||
selected.value = s
|
||
}
|
||
function selectByTag(tag: string) {
|
||
for (const a of all.value) if (a.tags.includes(tag)) selected.value.add(a.id)
|
||
}
|
||
function selectByPlatform(p: string) {
|
||
for (const a of all.value) if (a.platform === p) selected.value.add(a.id)
|
||
}
|
||
|
||
return { all, visible, selected, search, tagFilter, platformFilter,
|
||
toggle, selectAll, invert, selectByTag, selectByPlatform }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4:跑通过**
|
||
|
||
```bash
|
||
cd packages/ui-shared && pnpm test -- useAccountSelection
|
||
```
|
||
Expected: PASS。
|
||
|
||
- [ ] **Step 5:commit**
|
||
|
||
```bash
|
||
git add packages/ui-shared/src/composables/
|
||
git commit -m "feat(ui-shared): useAccountSelection composable"
|
||
```
|
||
|
||
### Task A.3.4:组件迁入 + SSE composable
|
||
|
||
为简洁起见,下列 5 个子任务每个都按同样 TDD 模式做(失败测试 → 实现 → 通过 → commit):
|
||
|
||
- [ ] **A.3.4.1:** `MarkdownPreview.vue`(从 `apps/admin-web/src/components/MarkdownPreview.vue` 提取)
|
||
- [ ] **A.3.4.2:** `AccountCard.vue`(账号卡片带 health / online badge)
|
||
- [ ] **A.3.4.3:** `TagMultiSelect.vue`(tag 多选控件)
|
||
- [ ] **A.3.4.4:** `HealthBadge.vue`
|
||
- [ ] **A.3.4.5:** `useSseSubscription` composable(基于 `@geo/http-client` 的 `SseClient`,提供 Vue 响应式绑定)
|
||
|
||
**`useSseSubscription` 关键点:**
|
||
|
||
```ts
|
||
import { onMounted, onUnmounted, ref } from 'vue'
|
||
import { SseClient } from '@geo/http-client'
|
||
|
||
export function useSseSubscription<T>(url: string) {
|
||
const events = ref<T[]>([])
|
||
const connected = ref(false)
|
||
const client = new SseClient(url, {
|
||
onEvent: (e) => events.value.push(e as T),
|
||
onConnect: () => (connected.value = true),
|
||
onDisconnect: () => (connected.value = false),
|
||
})
|
||
|
||
onMounted(() => client.connect())
|
||
onUnmounted(() => client.close())
|
||
|
||
return { events, connected, client }
|
||
}
|
||
```
|
||
|
||
### Task A.3.5:admin-web 接入 ui-shared(回退测试)
|
||
|
||
把 `apps/admin-web` 里原来内嵌的 MarkdownPreview 等替换为 `@geo/ui-shared` 引用。跑现有 admin-web 测试不回归。
|
||
|
||
---
|
||
|
||
## Phase A.4 · Doubao 监控适配器(1 周)
|
||
|
||
**Prereq:** A.0 spike 结论为 supported / degraded。
|
||
|
||
### Task A.4.1:DoubaoAdapter 基于 spike 结果实现
|
||
|
||
**Files:**
|
||
- Create: `apps/desktop-client/src/main/adapters/doubao.ts`
|
||
- Create: `apps/desktop-client/src/main/adapters/_shared/sse-framer.ts`
|
||
- Create: `apps/desktop-client/src/main/adapters/doubao.test.ts`
|
||
|
||
**模块清单(TDD 实施):**
|
||
|
||
1. `probe(ctx)` → 打开 `doubao.com`,检查登录态 DOM 标志
|
||
2. `keepAlive(ctx)` → 访问私有 API 触发 ms_token 刷新
|
||
3. `query(ctx, task)` → 按 A.0 matrix 选 transport 分支抓 SSE / fetch-stream
|
||
4. Provider parser(handle 豆包特定 payload schema)
|
||
5. 所有 CDP 调用 wrap 进 `_shared/debugger-helper.ts`
|
||
6. fixture 测试:抓一段真实 SSE(脱敏)→ parser 断言输出
|
||
|
||
### Task A.4.2:端到端冒烟
|
||
|
||
- [ ] 启 fake server + fake Doubao page → 派监控任务 → 验证 result 回调
|
||
|
||
---
|
||
|
||
## Phase A.5 · 头条号发布适配器(1 周)
|
||
|
||
**Files:**
|
||
- Create: `apps/desktop-client/src/main/adapters/toutiao.ts`
|
||
|
||
**模块清单:**
|
||
|
||
1. `probe` + `keepAlive`
|
||
2. `publish(ctx, task)` 状态机:filling → submitting → done/failed/unknown
|
||
3. 接入 rate limiter,离线积压任务按账号节流逐篇外发,避免过快触发平台风控
|
||
4. 成功/失败/unknown/aborted 任务进入 desktop publish management,可手动再次发送
|
||
5. 集成测试:连续 queued 任务按节流顺序出队;失败任务可以通过 `POST /api/desktop/publish-tasks/{id}/retry` 重新生成 queued task
|
||
|
||
---
|
||
|
||
## Phase A.6 · PublishArticleModal H2 重构(1 周)
|
||
|
||
### Task A.6.1:基于 `useAccountSelection` 重写 Modal
|
||
|
||
**Files:**
|
||
- Modify: `apps/admin-web/src/components/PublishArticleModal.vue`
|
||
|
||
- [ ] **Step 1:删除 `accounts[0]` 假设**
|
||
- [ ] **Step 2:引入 `useAccountSelection`**
|
||
- [ ] **Step 3:Modal UI:平台折叠块 + tag 筛选 + 搜索 + 三级批量勾选**
|
||
- [ ] **Step 4:提交用新 `POST /api/tenant/publish-jobs` 展开语义**
|
||
- [ ] **Step 5:进度面板走 `/api/tenant/events?topic=job:<id>` SSE**
|
||
- [ ] **Step 6:进度面板展示桌面端“发布管理”入口与排队提示,不再出现二次审核 deep link**
|
||
- [ ] **Step 7:E2E 测试(Playwright admin-web)**
|
||
- [ ] **Step 8:commit**
|
||
|
||
### Task A.6.2:desktop-client 发布管理页
|
||
|
||
**Files:**
|
||
- Modify: `apps/desktop-client/src/renderer/components/DesktopShell.vue`
|
||
- Create: `apps/desktop-client/src/renderer/views/PublishManagementView.vue`
|
||
- Modify: `apps/desktop-client/src/main/transport/api-client.ts`
|
||
|
||
- [ ] **Step 1:侧栏新增“发布管理”入口**
|
||
- [ ] **Step 2:调用 `GET /api/desktop/publish-tasks` 展示待发布队列 + 历史发送结果**
|
||
- [ ] **Step 3:历史列表覆盖 succeeded / failed / unknown / aborted**
|
||
- [ ] **Step 4:对任一历史 publish task 提供“再次发送”按钮,调用 `POST /api/desktop/publish-tasks/{id}/retry`**
|
||
- [ ] **Step 5:E2E 测试离线积压多篇任务逐条发送与手动补发路径**
|
||
|
||
---
|
||
|
||
## Phase A.7 · 测试基建 + Mac 公证(1 周)
|
||
|
||
### Task A.7.1:Fake server + fake platforms
|
||
|
||
**Files:**
|
||
- Create: `tests/fake-server/` (replay /api/desktop/* 的最小 mock)
|
||
- Create: `tests/fake-platforms/doubao/`
|
||
- Create: `tests/fake-platforms/toutiao/`
|
||
|
||
具体实现略——要求能在 CI 中 `node tests/fake-server start` 起服务。
|
||
|
||
### Task A.7.2:Playwright `_electron.launch` 跑端到端
|
||
|
||
- 启动 fake server + fake 平台
|
||
- 注入伪账号
|
||
- 派监控任务 + 发布任务
|
||
- 断言 result 回调格式
|
||
|
||
### Task A.7.3:Mac 公证 CI
|
||
|
||
**Files:**
|
||
- Modify: `.github/workflows/release.yml`
|
||
|
||
```yaml
|
||
- name: Build Mac universal
|
||
run: cd apps/desktop-client && pnpm package:mac
|
||
env:
|
||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
|
||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||
CSC_LINK: ${{ secrets.MAC_CSC_P12_BASE64 }}
|
||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_PASSWORD }}
|
||
```
|
||
|
||
- [ ] **Step 1:配 secrets**(用户线下)
|
||
- [ ] **Step 2:CI 跑通一次 `.dmg` 产出 + notarize 成功**
|
||
|
||
### Task A.7.4:autoUpdater smoke unit tests
|
||
|
||
- 版本比较
|
||
- 签名验签
|
||
- `allowDowngrade` 决策
|
||
- `minClientVersion` 校验
|
||
|
||
---
|
||
|
||
## Phase A.8 · Exit bar(0.5 周)
|
||
|
||
### Task A.8.1:端到端真实账号冒烟 Playbook
|
||
|
||
**Files:** `docs/superpowers/runbooks/plan-a-smoke-playbook.md`
|
||
|
||
- [ ] 用测试账号登录头条 → 创建 publish job → manual 模式 → 前往桌面端审核 → 发布成功
|
||
- [ ] 用测试账号登录 Doubao → 创建监控计划 → 验证结果
|
||
- [ ] UNKNOWN 路径:kill desktop 进程 during submitting → 重启 → reconcile 按 4 种 action 各跑一次
|
||
|
||
### Task A.8.2:混沌测试
|
||
|
||
- [ ] 启动两个 server 实例(fake RabbitMQ / Redis)→ client A 连 instance 1 → task 创建于 instance 2 → 验证 SSE 正确推到 A
|
||
- [ ] kill instance 1 → A 降级 60s pull → 重连 instance 2 → 继续工作
|
||
- [ ] Parked 恢复:park task → kill client → 重启 → acquireFromParked 成功
|
||
|
||
### Task A.8.3:Sign-off + 打 tag
|
||
|
||
- [ ] 所有 Phase 的 exit bar 都 close
|
||
- [ ] `git tag plan-a-complete`
|
||
- [ ] 在 Plan 文档顶部标 ✅ COMPLETED
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
**1. Spec coverage:**
|
||
- 块 A(runtime 基座):Phase A.2 ✅
|
||
- 块 E(服务端协议):Phase A.1 ✅
|
||
- 块 H1/H2:Phase A.3 + A.6 ✅
|
||
- 块 B(Doubao):Phase A.4 ✅
|
||
- 块 C(头条迁移):Phase A.5 ✅
|
||
- 块 I(测试基建):Phase A.7 ✅
|
||
- 块 F(Mac 公证):Phase A.7 ✅
|
||
- Spike 矩阵:Phase A.0 ✅
|
||
- Exit bar:Phase A.8 ✅
|
||
|
||
**2. Placeholder scan:** Plan A 里 Phase A.2 的 Task A.2.2-A.2.9 采用"模块清单 + TDD 模板"的形式,由 subagent 执行时展开为具体 bite-sized 步骤。这是经过权衡的:Plan 0 是"几乎无已知代码的新建"所以全 bite-sized 合理;Plan A 因每个模块接口在 spec 已明确、且 TDD 模式固定,细化到每一行代码会让 plan 变成 5000+ 行文档,反而降低可读性。每个模块子任务数(功能清单条目数)清晰,subagent-driven-development 能把它展开。
|
||
|
||
**3. Type consistency:** `LeaseManager` / `SseClient` / `useAccountSelection` / `AccountCard` / `ContentRef` 等命名在 Plan A 各 Phase 间一致,和 Plan 0 的 `Actor / WorkspaceScope / PrimaryWorkspaceID` 接入。
|
||
|
||
**4. Dependency ordering:** A.0 阻塞 A.4;A.1/A.2/A.3 可并行;A.4 依赖 A.2+A.0;A.5 依赖 A.2;A.6 依赖 A.1+A.3;A.7 依赖 A.2+A.4+A.5;A.8 依赖所有。DAG 正确。
|
||
|
||
**5. Gaps check:** Plan A 未覆盖块 D(7 家 detect-only 发布适配器从零实现),这部分在 Plan B 里。Plan A 只做头条号这**一家**真实发布适配器,作为骨架验证;Plan B 做剩余 12 家。与 spec §12 一致。
|
||
|
||
---
|
||
|
||
## Execution Handoff
|
||
|
||
两个 plan 都已完成并保存到 `docs/superpowers/plans/`:
|
||
|
||
- `2026-04-18-plan-0-workspace-foundation.md`
|
||
- `2026-04-18-plan-a-desktop-skeleton.md`
|
||
|
||
Plan 0 是 Plan A 的硬前置。**先执行 Plan 0,打完 `plan-0-complete` tag 后再启 Plan A**。
|
||
|
||
**两种执行方式:**
|
||
|
||
1. **Subagent-Driven(推荐)** — 每个 Task 派一个 fresh subagent 执行,两段式 review,快速迭代。REQUIRED SUB-SKILL: `superpowers:subagent-driven-development`
|
||
2. **Inline Execution** — 当前会话里顺序执行 Task,每 5-10 Task 一个 checkpoint review。REQUIRED SUB-SKILL: `superpowers:executing-plans`
|
||
|
||
用户可在下一轮对话里选执行方式并指定从哪条 Task 开始。
|