From 7973c441e41bdf32eb9b0a8dc3e856ead2502797 Mon Sep 17 00:00:00 2001 From: AI Bot Date: Sat, 6 Jun 2026 17:41:11 +0800 Subject: [PATCH] docs: plan enterprise safety recovery phase one --- ...nterprise-safety-backup-recovery-phase1.md | 1427 +++++++++++++++++ 1 file changed, 1427 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-06-enterprise-safety-backup-recovery-phase1.md diff --git a/docs/superpowers/plans/2026-06-06-enterprise-safety-backup-recovery-phase1.md b/docs/superpowers/plans/2026-06-06-enterprise-safety-backup-recovery-phase1.md new file mode 100644 index 0000000..aafc4f5 --- /dev/null +++ b/docs/superpowers/plans/2026-06-06-enterprise-safety-backup-recovery-phase1.md @@ -0,0 +1,1427 @@ +# Enterprise Safety Backup Recovery Phase 1 Implementation Plan + +> **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:** Build the first enterprise-grade safety loop: visible backups, restore preview, restore audit, task risk visibility, and readable APP progress state. + +**Architecture:** Keep the current file-backed Boss state as the source of truth, then add a product-facing projection layer for backup and recovery status. The first phase does not migrate storage to PostgreSQL; it turns existing snapshots, task phase data, and audit logs into enterprise-visible safety controls. + +**Tech Stack:** Next.js App Router API routes, TypeScript state helpers, Node test runner, Android Java/Robolectric UI tests, existing `boss-state.json` file store. + +--- + +## Scope + +This plan implements the first batch from `docs/superpowers/specs/2026-06-06-enterprise-safety-backup-recovery-design.md`. + +Included: + +- Backup projection with business fields. +- Snapshot verification. +- Restore preview and dry-run restore. +- Restore audit and recovery report payload. +- Backoffice safety summary and stale task risk summary. +- Read-only task recovery endpoint and safe retry marker for pre-turn recoverable failures. +- Android progress card phase explanation. +- Runtime documentation update. + +Excluded from Phase 1: + +- PostgreSQL WAL / point-in-time recovery. +- Object storage replication. +- Full project/message/goal/version object-level restore. +- Enterprise SSO / IdP. +- Automatic Computer Use rollback. + +## File Map + +- Modify `src/lib/boss-state-backups.ts`: Owns file snapshot listing, projection, checksum verification, restore preview, and dry-run behavior. +- Modify `src/app/api/v1/admin/backups/route.ts`: Exposes backup actions to highest admin and writes audit logs. +- Modify `src/app/api/v1/admin/backoffice/route.ts`: Adds data-safety and task-risk summaries for platform and enterprise backoffice. +- Modify `src/lib/boss-data.ts`: Adds small task recovery helpers and audit helper reuse where needed; avoid broad state refactors. +- Create `src/app/api/v1/master-agent/tasks/[taskId]/recovery/route.ts`: Read-only task recovery diagnosis plus safe retry action entry point. +- Modify `android/app/src/main/java/com/hyzq/boss/BossUi.java`: Adds human-readable phase/status explanation in execution progress cards. +- Modify `tests/admin-backups-route.test.ts`: API coverage for verify, preview, dry-run, restore audit. +- Modify `tests/admin-backoffice-bff-route.test.ts`: BFF coverage for safety and task-risk summaries. +- Create `tests/master-agent-task-recovery-route.test.ts`: Task recovery endpoint coverage. +- Modify `android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java`: Android card phase explanation coverage. +- Modify `docs/architecture/current_runtime_and_deploy_status_cn.md`: Records what Phase 1 actually supports after implementation. + +--- + +### Task 1: Baseline Verification + +**Files:** +- Read: `docs/superpowers/specs/2026-06-06-enterprise-safety-backup-recovery-design.md` +- Read: `src/lib/boss-state-backups.ts` +- Read: `src/app/api/v1/admin/backups/route.ts` +- Read: `src/app/api/v1/admin/backoffice/route.ts` + +- [ ] **Step 1: Verify current repo state before editing** + +Run: + +```bash +git status --short +npm run build +npm run lint +``` + +Expected: + +```text +npm run build exits 0 +npm run lint exits 0 +``` + +If unrelated modified files exist, leave them untouched. Only stage files listed in this plan. + +- [ ] **Step 2: Verify current backup and backoffice tests before editing** + +Run: + +```bash +npx tsx --test tests/admin-backups-route.test.ts +npx tsx --test tests/admin-backoffice-bff-route.test.ts +``` + +Expected: + +```text +tests pass before Phase 1 edits +``` + +- [ ] **Step 3: Commit only if a baseline documentation note is added** + +No commit is required if no files changed. + +--- + +### Task 2: Backup Projection, Verify, and Restore Preview Tests + +**Files:** +- Modify: `tests/admin-backups-route.test.ts` +- Later implementation target: `src/lib/boss-state-backups.ts` +- Later implementation target: `src/app/api/v1/admin/backups/route.ts` + +- [ ] **Step 1: Add assertions for product-facing snapshot fields** + +In `tests/admin-backups-route.test.ts`, extend the existing `highest admin can create, list, and restore a state snapshot` test after `createPayload` is read: + +```ts + assert.equal(createPayload.snapshot.scope, "global"); + assert.equal(createPayload.snapshot.storageKind, "file"); + assert.equal(createPayload.snapshot.status, "ready"); + assert.equal(createPayload.snapshot.verification.ok, true); + assert.equal(typeof createPayload.snapshot.businessSummary.projectCount, "number"); + assert.equal(typeof createPayload.snapshot.businessSummary.accountCount, "number"); +``` + +Extend the list assertion: + +```ts + const listedSnapshot = listPayload.snapshots.find( + (snapshot: { snapshotId: string }) => snapshot.snapshotId === createPayload.snapshot.snapshotId, + ); + assert.ok(listedSnapshot); + assert.equal(listedSnapshot.scope, "global"); + assert.equal(listedSnapshot.storageKind, "file"); + assert.equal(listedSnapshot.status, "ready"); + assert.equal(listedSnapshot.verification.ok, true); +``` + +- [ ] **Step 2: Add verify snapshot test** + +Append this test to `tests/admin-backups-route.test.ts`: + +```ts +test("highest admin can verify a backup snapshot checksum", async () => { + const createResponse = await postBackups( + await requestFor("owner@boss.com", "highest_admin", { + method: "POST", + body: JSON.stringify({ action: "create_snapshot", reason: "verify me" }), + }), + ); + const createPayload = await createResponse.json(); + + const verifyResponse = await postBackups( + await requestFor("owner@boss.com", "highest_admin", { + method: "POST", + body: JSON.stringify({ action: "verify_snapshot", snapshotId: createPayload.snapshot.snapshotId }), + }), + ); + + assert.equal(verifyResponse.status, 200); + const verifyPayload = await verifyResponse.json(); + assert.equal(verifyPayload.ok, true); + assert.equal(verifyPayload.action, "verify_snapshot"); + assert.equal(verifyPayload.verification.snapshotId, createPayload.snapshot.snapshotId); + assert.equal(verifyPayload.verification.ok, true); + assert.equal(verifyPayload.verification.sha256, createPayload.snapshot.sha256); +}); +``` + +- [ ] **Step 3: Add restore preview and dry-run test** + +Append this test: + +```ts +test("restore preview and dry run report impact without writing state", async () => { + const createResponse = await postBackups( + await requestFor("owner@boss.com", "highest_admin", { + method: "POST", + body: JSON.stringify({ action: "create_snapshot", reason: "preview before change" }), + }), + ); + const createPayload = await createResponse.json(); + + const mutated = await data.readState(); + mutated.projects.push({ + ...mutated.projects[0]!, + id: "project-after", + name: "恢复预览后新增项目", + preview: "after", + updatedAt: "2026-05-16T11:00:00+08:00", + lastMessageAt: "2026-05-16T11:00:00+08:00", + threadMeta: { + ...mutated.projects[0]!.threadMeta, + projectId: "project-after", + threadId: "thread-after", + threadDisplayName: "恢复预览后线程", + }, + }); + await data.writeState(mutated); + + const previewResponse = await postBackups( + await requestFor("owner@boss.com", "highest_admin", { + method: "POST", + body: JSON.stringify({ action: "preview_restore", snapshotId: createPayload.snapshot.snapshotId }), + }), + ); + assert.equal(previewResponse.status, 200); + const previewPayload = await previewResponse.json(); + assert.equal(previewPayload.ok, true); + assert.equal(previewPayload.preview.willWriteState, false); + assert.equal(previewPayload.preview.impact.projects.after, 1); + assert.equal(previewPayload.preview.impact.projects.current, 2); + assert.match(previewPayload.preview.summary, /项目/); + + const dryRunResponse = await postBackups( + await requestFor("owner@boss.com", "highest_admin", { + method: "POST", + body: JSON.stringify({ action: "dry_run_restore", snapshotId: createPayload.snapshot.snapshotId }), + }), + ); + assert.equal(dryRunResponse.status, 200); + const dryRunPayload = await dryRunResponse.json(); + assert.equal(dryRunPayload.ok, true); + assert.equal(dryRunPayload.preview.willWriteState, false); + + const unchanged = await data.readState(); + assert.equal(unchanged.projects.some((project) => project.id === "project-after"), true); +}); +``` + +- [ ] **Step 4: Run tests to verify failure** + +Run: + +```bash +npx tsx --test tests/admin-backups-route.test.ts +``` + +Expected: + +```text +FAIL because snapshot projection fields and verify/preview actions are not implemented yet +``` + +--- + +### Task 3: Implement Backup Projection, Verification, and Preview + +**Files:** +- Modify: `src/lib/boss-state-backups.ts` + +- [ ] **Step 1: Add projection and preview types** + +Add after `BossStateBackupStatus`: + +```ts +export type BossBackupScope = "global" | "company" | "project" | "conversation" | "config" | "skill" | "task"; + +export interface BossStateBackupBusinessSummary { + companyCount: number; + accountCount: number; + deviceCount: number; + projectCount: number; + messageCount: number; + taskCount: number; + skillCount: number; + auditLogCount: number; +} + +export interface BossStateBackupVerification { + snapshotId: string; + checkedAt: string; + ok: boolean; + sha256: string; + expectedSha256?: string; + message: string; +} + +export interface BossStateBackupProjection extends BossStateBackupSnapshot { + scope: BossBackupScope; + storageKind: "file"; + status: "ready" | "invalid"; + verification: BossStateBackupVerification; + businessSummary: BossStateBackupBusinessSummary; +} + +export interface BossStateRestoreImpactCounter { + current: number; + after: number; + delta: number; +} + +export interface BossStateRestorePreview { + snapshotId: string; + willWriteState: boolean; + generatedAt: string; + summary: string; + impact: { + companies: BossStateRestoreImpactCounter; + accounts: BossStateRestoreImpactCounter; + devices: BossStateRestoreImpactCounter; + projects: BossStateRestoreImpactCounter; + messages: BossStateRestoreImpactCounter; + tasks: BossStateRestoreImpactCounter; + skills: BossStateRestoreImpactCounter; + auditLogs: BossStateRestoreImpactCounter; + }; +} +``` + +- [ ] **Step 2: Add summary helpers** + +Add below `parseStateText`: + +```ts +function countProjectMessages(state: BossState) { + return state.projects.reduce((total, project) => total + project.messages.length, 0); +} + +function summarizeBusinessState(state: BossState): BossStateBackupBusinessSummary { + return { + companyCount: state.adminCompanies.length, + accountCount: state.authAccounts.length, + deviceCount: state.devices.length, + projectCount: state.projects.length, + messageCount: countProjectMessages(state), + taskCount: state.masterAgentTasks.length, + skillCount: state.deviceSkills.length + state.skillCatalog.length, + auditLogCount: state.permissionAuditLogs.length, + }; +} + +function counter(current: number, after: number): BossStateRestoreImpactCounter { + return { current, after, delta: after - current }; +} + +function buildRestoreImpact(current: BossState, after: BossState): BossStateRestorePreview["impact"] { + const currentSummary = summarizeBusinessState(current); + const afterSummary = summarizeBusinessState(after); + return { + companies: counter(currentSummary.companyCount, afterSummary.companyCount), + accounts: counter(currentSummary.accountCount, afterSummary.accountCount), + devices: counter(currentSummary.deviceCount, afterSummary.deviceCount), + projects: counter(currentSummary.projectCount, afterSummary.projectCount), + messages: counter(currentSummary.messageCount, afterSummary.messageCount), + tasks: counter(currentSummary.taskCount, afterSummary.taskCount), + skills: counter(currentSummary.skillCount, afterSummary.skillCount), + auditLogs: counter(currentSummary.auditLogCount, afterSummary.auditLogCount), + }; +} + +function restorePreviewSummary(impact: BossStateRestorePreview["impact"]) { + const changed = Object.entries(impact) + .filter(([, value]) => value.delta !== 0) + .map(([key, value]) => `${key}:${value.current}->${value.after}`); + return changed.length > 0 ? `恢复会改变 ${changed.join(",")}` : "恢复后业务对象数量不变"; +} +``` + +- [ ] **Step 3: Add verification and projection functions** + +Add below `listBossStateBackups`: + +```ts +export async function verifyBossStateBackup(snapshotId: string): Promise { + const absolutePath = snapshotPath(snapshotId); + const text = await fs.readFile(absolutePath, "utf8"); + const meta = await readMeta(snapshotId); + const sha256 = createHash("sha256").update(text).digest("hex"); + let ok = true; + let message = "快照校验通过"; + try { + parseStateText(text, absolutePath); + if (typeof meta.sha256 === "string" && meta.sha256 !== sha256) { + ok = false; + message = "快照 sha256 与元数据不一致"; + } + } catch (error) { + ok = false; + message = error instanceof Error ? error.message : "快照解析失败"; + } + return { + snapshotId, + checkedAt: new Date().toISOString(), + ok, + sha256, + expectedSha256: typeof meta.sha256 === "string" ? meta.sha256 : undefined, + message, + }; +} + +export async function projectBossStateBackupSnapshot( + snapshot: BossStateBackupSnapshot, +): Promise { + const text = await fs.readFile(snapshot.absolutePath, "utf8"); + let businessSummary: BossStateBackupBusinessSummary = { + companyCount: 0, + accountCount: 0, + deviceCount: 0, + projectCount: 0, + messageCount: 0, + taskCount: 0, + skillCount: 0, + auditLogCount: 0, + }; + let verification = await verifyBossStateBackup(snapshot.snapshotId); + if (verification.ok) { + businessSummary = summarizeBusinessState(parseStateText(text, snapshot.absolutePath)); + } + return { + ...snapshot, + scope: "global", + storageKind: "file", + status: verification.ok ? "ready" : "invalid", + verification, + businessSummary, + }; +} + +export async function listBossStateBackupProjections(limit = 20): Promise { + const snapshots = await listBossStateBackups(limit); + return Promise.all(snapshots.map((snapshot) => projectBossStateBackupSnapshot(snapshot))); +} +``` + +- [ ] **Step 4: Add restore preview function** + +Add before `restoreBossStateBackup`: + +```ts +export async function previewBossStateRestore(input: { + snapshotId: string; + willWriteState?: boolean; +}): Promise { + const absolutePath = snapshotPath(input.snapshotId); + const text = await fs.readFile(absolutePath, "utf8"); + const after = parseStateText(text, absolutePath); + const current = await readState(); + const impact = buildRestoreImpact(current, after); + return { + snapshotId: input.snapshotId, + willWriteState: input.willWriteState === true, + generatedAt: new Date().toISOString(), + summary: restorePreviewSummary(impact), + impact, + }; +} +``` + +- [ ] **Step 5: Update create/list/restore callers to return projections** + +Change `createBossStateBackup` return type to remain `BossStateBackupSnapshot`, but API route will project the result. Do not make `createBossStateBackup` itself return a projection; keeping raw snapshot creation focused avoids checksum re-reading during automatic writes. + +- [ ] **Step 6: Run focused tests** + +Run: + +```bash +npx tsx --test tests/admin-backups-route.test.ts +``` + +Expected: + +```text +FAIL only because route actions still call old list and do not expose verify/preview +``` + +--- + +### Task 4: Expose Backup Actions and Write Audit + +**Files:** +- Modify: `src/app/api/v1/admin/backups/route.ts` +- Modify: `tests/admin-backups-route.test.ts` + +- [ ] **Step 1: Import new backup helpers and audit helper** + +Update imports in `src/app/api/v1/admin/backups/route.ts`: + +```ts +import { + createBossStateBackup, + getBossStateBackupStatus, + listBossStateBackupProjections, + previewBossStateRestore, + projectBossStateBackupSnapshot, + restoreBossStateBackup, + verifyBossStateBackup, +} from "@/lib/boss-state-backups"; +import { appendPermissionAuditLog } from "@/lib/boss-data"; +``` + +- [ ] **Step 2: Add local audit helper** + +Add below `stringValue`: + +```ts +async function auditBackupAction(input: { + actorAccount: string; + action: string; + detail: string; + snapshotId?: string; + afterJson?: Record; +}) { + await appendPermissionAuditLog({ + actorAccount: input.actorAccount, + action: input.action, + detail: input.detail, + requestId: input.snapshotId, + afterJson: input.afterJson, + }); +} +``` + +- [ ] **Step 3: Switch GET to projection list** + +Replace the GET body: + +```ts + const status = await getBossStateBackupStatus(); + const snapshots = await listBossStateBackupProjections(50); + return jsonNoStore({ ok: true, status, snapshots }); +``` + +- [ ] **Step 4: Project create response and audit it** + +Inside `create_snapshot`, replace the response preparation with: + +```ts + const rawSnapshot = await createBossStateBackup({ + actorAccount: auth.session.account, + reason: stringValue(body.reason) || "manual", + }); + const snapshot = await projectBossStateBackupSnapshot(rawSnapshot); + await auditBackupAction({ + actorAccount: auth.session.account, + action: "backup.snapshot_created", + detail: `创建备份快照:${snapshot.snapshotId}`, + snapshotId: snapshot.snapshotId, + afterJson: { + reason: snapshot.reason, + sha256: snapshot.sha256, + bytes: snapshot.bytes, + businessSummary: snapshot.businessSummary, + }, + }); + const status = await getBossStateBackupStatus(); + return jsonNoStore({ ok: true, action, snapshot, status }); +``` + +- [ ] **Step 5: Add verify, preview, and dry-run actions** + +Add before `restore_snapshot`: + +```ts + if (action === "verify_snapshot") { + const snapshotId = stringValue(body.snapshotId); + if (!snapshotId) { + return jsonNoStore({ ok: false, message: "BACKUP_SNAPSHOT_ID_REQUIRED" }, { status: 400 }); + } + const verification = await verifyBossStateBackup(snapshotId); + await auditBackupAction({ + actorAccount: auth.session.account, + action: "backup.snapshot_verified", + detail: `${verification.ok ? "校验通过" : "校验失败"}:${snapshotId}`, + snapshotId, + afterJson: verification, + }); + return jsonNoStore({ ok: true, action, verification }); + } + + if (action === "preview_restore" || action === "dry_run_restore") { + const snapshotId = stringValue(body.snapshotId); + if (!snapshotId) { + return jsonNoStore({ ok: false, message: "BACKUP_SNAPSHOT_ID_REQUIRED" }, { status: 400 }); + } + const preview = await previewBossStateRestore({ snapshotId, willWriteState: false }); + await auditBackupAction({ + actorAccount: auth.session.account, + action: action === "dry_run_restore" ? "backup.restore_dry_run" : "backup.restore_previewed", + detail: preview.summary, + snapshotId, + afterJson: preview, + }); + return jsonNoStore({ ok: true, action, preview }); + } +``` + +- [ ] **Step 6: Audit actual restore** + +After `restoreBossStateBackup` returns, project `restored` and audit: + +```ts + await auditBackupAction({ + actorAccount: auth.session.account, + action: "backup.snapshot_restored", + detail: `恢复快照:${result.restored.snapshotId}`, + snapshotId: result.restored.snapshotId, + afterJson: { + restoredSnapshotId: result.restored.snapshotId, + preRestoreSnapshotId: result.preRestoreSnapshot.snapshotId, + }, + }); +``` + +Keep response shape compatible: + +```ts + return jsonNoStore({ ok: true, action, ...result, status }); +``` + +- [ ] **Step 7: Add audit assertion to restore test** + +In `tests/admin-backups-route.test.ts`, after restore and state read: + +```ts + const auditState = await data.readState(); + assert.equal( + auditState.permissionAuditLogs.some((log) => log.action === "backup.snapshot_restored"), + true, + ); +``` + +- [ ] **Step 8: Run focused tests** + +Run: + +```bash +npx tsx --test tests/admin-backups-route.test.ts +``` + +Expected: + +```text +PASS +``` + +- [ ] **Step 9: Commit** + +Run: + +```bash +git add src/lib/boss-state-backups.ts src/app/api/v1/admin/backups/route.ts tests/admin-backups-route.test.ts +git commit -m "feat: add backup verification and restore preview" +``` + +--- + +### Task 5: Backoffice Data Safety and Task Risk Summary + +**Files:** +- Modify: `src/app/api/v1/admin/backoffice/route.ts` +- Modify: `tests/admin-backoffice-bff-route.test.ts` + +- [ ] **Step 1: Add failing BFF assertions** + +In `tests/admin-backoffice-bff-route.test.ts`, add assertions to the existing platform GET test: + +```ts + assert.equal(payload.insights.dataSafetySummary.restorePointCount >= 0, true); + assert.match(payload.insights.dataSafetySummary.rpoLabel, /文件 MVP|企业标准/); + assert.equal(Array.isArray(payload.insights.taskRiskSummary.rows), true); + assert.equal(typeof payload.insights.taskRiskSummary.counts.stale, "number"); +``` + +Add a task-risk fixture in `beforeEach`: + +```ts + state.masterAgentTasks = [ + { + taskId: "task-stale", + projectId: "project-acme", + taskType: "conversation_reply", + requestMessageId: "msg-stale", + requestText: "卡住任务", + executionPrompt: "卡住任务", + requestedBy: "测试", + requestedByAccount: "owner@acme.com", + deviceId: "win-1", + status: "running", + phase: "awaiting_reply", + requestedAt: "2026-04-30T08:00:00+08:00", + claimedAt: "2026-04-30T08:01:00+08:00", + lastProgressAt: "2026-04-30T08:01:00+08:00", + leaseExpiresAt: "2026-04-30T08:02:00+08:00", + attemptCount: 1, + maxAttempts: 2, + }, + ]; +``` + +- [ ] **Step 2: Run BFF test to verify failure** + +Run: + +```bash +npx tsx --test tests/admin-backoffice-bff-route.test.ts +``` + +Expected: + +```text +FAIL because dataSafetySummary and taskRiskSummary do not exist yet +``` + +- [ ] **Step 3: Add helper functions** + +In `src/app/api/v1/admin/backoffice/route.ts`, add below `riskAggregateValue`: + +```ts +function minutesSince(value?: string) { + const timestamp = Date.parse(value ?? ""); + if (!Number.isFinite(timestamp)) return null; + return Math.max(0, Math.round((Date.now() - timestamp) / 60_000)); +} + +function buildDataSafetySummary(backupStatus: BossStateBackupStatus) { + const ageMinutes = minutesSince(backupStatus.lastBackupAt); + const stale = ageMinutes === null || ageMinutes > 24 * 60; + return { + mode: backupStatus.mode, + status: backupStatus.status, + restorePointCount: backupStatus.restorePointCount, + lastBackupAt: backupStatus.lastBackupAt ?? "", + ageMinutes, + healthLabel: + backupStatus.status === "ready" && !stale + ? "备份正常" + : backupStatus.status === "ready" + ? "备份过期" + : backupStatus.status === "empty" + ? "暂无备份" + : "备份异常", + rpoLabel: "文件 MVP:以最近成功快照为准", + rtoLabel: "文件 MVP:人工恢复目标 30-60 分钟", + nextAction: + backupStatus.status === "ready" && !stale + ? "保持自动快照和定期演练" + : "请创建手动快照并执行 dry-run 恢复校验", + }; +} + +function buildTaskRiskSummary(state: BossState) { + const nowMs = Date.now(); + const active = state.masterAgentTasks.filter((task) => + task.status === "queued" || task.status === "running" || task.status === "needs_user_action", + ); + const rows = active.slice(0, 20).map((task) => { + const lastProgressMs = Date.parse(task.lastProgressAt ?? task.claimedAt ?? task.requestedAt); + const stale = Number.isFinite(lastProgressMs) && nowMs - lastProgressMs > 10 * 60_000; + return { + taskId: task.taskId, + projectId: task.projectId, + deviceId: task.deviceId, + status: task.status, + phase: task.phase ?? task.status, + stale, + recoverable: task.recoverable === true, + lastProgressAt: task.lastProgressAt ?? task.claimedAt ?? task.requestedAt, + summary: task.errorMessage || task.requestText.slice(0, 80), + }; + }); + return { + counts: { + active: active.length, + stale: rows.filter((row) => row.stale).length, + recoverable: rows.filter((row) => row.recoverable).length, + needsUserAction: rows.filter((row) => row.status === "needs_user_action").length, + }, + rows, + }; +} +``` + +- [ ] **Step 4: Wire summaries into `buildBackofficeInsights`** + +Add to the returned object in `buildBackofficeInsights`: + +```ts + dataSafetySummary: buildDataSafetySummary(options.backupStatus), + taskRiskSummary: buildTaskRiskSummary(state), +``` + +- [ ] **Step 5: Run BFF tests** + +Run: + +```bash +npx tsx --test tests/admin-backoffice-bff-route.test.ts +``` + +Expected: + +```text +PASS +``` + +- [ ] **Step 6: Commit** + +Run: + +```bash +git add src/app/api/v1/admin/backoffice/route.ts tests/admin-backoffice-bff-route.test.ts +git commit -m "feat: expose enterprise data safety summary" +``` + +--- + +### Task 6: Task Recovery Endpoint + +**Files:** +- Create: `src/app/api/v1/master-agent/tasks/[taskId]/recovery/route.ts` +- Create: `tests/master-agent-task-recovery-route.test.ts` +- Modify: `src/lib/boss-data.ts` + +- [ ] **Step 1: Add failing route test** + +Create `tests/master-agent-task-recovery-route.test.ts`: + +```ts +import test from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; +import { NextRequest } from "next/server"; +import type { MasterAgentTask } from "../src/lib/boss-data"; + +let runtimeRoot = ""; +let data: typeof import("../src/lib/boss-data.ts"); +let authCookie = ""; +let getRecovery: (typeof import("../src/app/api/v1/master-agent/tasks/[taskId]/recovery/route.ts"))["GET"]; +let postRecovery: (typeof import("../src/app/api/v1/master-agent/tasks/[taskId]/recovery/route.ts"))["POST"]; +let baseState: Awaited>; + +async function setup() { + if (runtimeRoot) return; + runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-task-recovery-")); + process.env.BOSS_RUNTIME_ROOT = runtimeRoot; + process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json"); + const [dataModule, authModule, routeModule] = await Promise.all([ + import("../src/lib/boss-data.ts"), + import("../src/lib/boss-auth.ts"), + import("../src/app/api/v1/master-agent/tasks/[taskId]/recovery/route.ts"), + ]); + data = dataModule; + authCookie = authModule.AUTH_SESSION_COOKIE; + getRecovery = routeModule.GET; + postRecovery = routeModule.POST; + baseState = structuredClone(await data.readState()); +} + +test.after(async () => { + if (runtimeRoot) await rm(runtimeRoot, { recursive: true, force: true }); +}); + +function task(overrides: Partial): MasterAgentTask { + return { + taskId: "task-recoverable", + projectId: "project-1", + taskType: "conversation_reply", + requestMessageId: "msg-1", + requestText: "继续执行", + executionPrompt: "继续执行", + requestedBy: "Boss", + requestedByAccount: "owner@boss.com", + deviceId: "mac-1", + status: "running", + phase: "executor_starting", + requestedAt: "2026-06-06T08:00:00.000Z", + lastProgressAt: "2026-06-06T08:01:00.000Z", + attemptCount: 1, + maxAttempts: 2, + recoverable: true, + lastErrorCode: "EXECUTOR_START_FAILED", + ...overrides, + }; +} + +async function authedRequest(method = "GET", body?: unknown) { + const session = await data.createAuthSession({ + account: "owner@boss.com", + role: "highest_admin", + displayName: "Owner", + loginMethod: "password", + }); + return new NextRequest("http://127.0.0.1:3000/api/v1/master-agent/tasks/task-recoverable/recovery", { + method, + headers: { + "content-type": "application/json", + cookie: `${authCookie}=${session.sessionToken}`, + }, + body: body ? JSON.stringify(body) : undefined, + }); +} + +test.beforeEach(async () => { + await setup(); + const state = structuredClone(baseState); + state.authAccounts = [ + { + id: "account-owner", + account: "owner@boss.com", + passwordHash: "secret", + displayName: "Owner", + role: "highest_admin", + createdAt: "2026-06-06T08:00:00.000Z", + updatedAt: "2026-06-06T08:00:00.000Z", + }, + ]; + state.masterAgentTasks = [task({})]; + await data.writeState(state); +}); + +test("task recovery GET returns safe diagnosis", async () => { + const response = await getRecovery(await authedRequest(), { params: Promise.resolve({ taskId: "task-recoverable" }) }); + assert.equal(response.status, 200); + const payload = await response.json(); + assert.equal(payload.ok, true); + assert.equal(payload.recovery.taskId, "task-recoverable"); + assert.equal(payload.recovery.canRetry, true); + assert.equal(payload.recovery.safeNextAction, "retry"); + assert.equal(payload.recovery.diagnosis.includes("executor_starting"), true); +}); + +test("task recovery POST retry requeues only recoverable pre-turn task", async () => { + const response = await postRecovery( + await authedRequest("POST", { action: "retry", reason: "executor recovered" }), + { params: Promise.resolve({ taskId: "task-recoverable" }) }, + ); + assert.equal(response.status, 200); + const payload = await response.json(); + assert.equal(payload.ok, true); + assert.equal(payload.task.status, "queued"); + assert.equal(payload.task.phase, "queued"); + + const state = await data.readState(); + assert.equal(state.permissionAuditLogs.some((log) => log.action === "master_agent.task_retried"), true); +}); +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: + +```bash +npx tsx --test tests/master-agent-task-recovery-route.test.ts +``` + +Expected: + +```text +FAIL because the route does not exist +``` + +- [ ] **Step 3: Add helper in `src/lib/boss-data.ts`** + +Add near `cancelMasterAgentTask`: + +```ts +export function canRetryMasterAgentTaskSafely(task: MasterAgentTask) { + return ( + task.recoverable === true && + (task.phase === "queued" || task.phase === "claimed" || task.phase === "executor_starting" || task.phase === "recoverable_failed") && + task.status !== "completed" && + task.status !== "canceled" && + task.status !== "timed_out" + ); +} + +export async function retryRecoverableMasterAgentTask(input: { + taskId: string; + actorAccount: string; + reason?: string; +}) { + return mutateState((state) => { + const task = state.masterAgentTasks.find((item) => item.taskId === input.taskId); + if (!task) throw new Error("MASTER_AGENT_TASK_NOT_FOUND"); + if (!canRetryMasterAgentTaskSafely(task)) throw new Error("MASTER_AGENT_TASK_RETRY_UNSAFE"); + const now = nowIso(); + task.status = "queued"; + task.phase = "queued"; + task.claimedAt = undefined; + task.lastClaimedAt = undefined; + task.leaseExpiresAt = undefined; + task.lastProgressAt = now; + task.completedAt = undefined; + task.lastErrorCode = undefined; + task.errorMessage = undefined; + task.recoverable = false; + task.nextRetryAt = undefined; + upsertTaskExecutionProgressMessageInState(state, task, "queued", { phase: "queued" }); + state.permissionAuditLogs.unshift( + normalizePermissionAuditLog({ + auditId: randomToken("audit"), + actorAccount: input.actorAccount, + action: "master_agent.task_retried", + projectId: task.projectId, + deviceId: task.deviceId, + detail: input.reason?.trim() || `重试任务:${task.taskId}`, + requestId: task.taskId, + createdAt: now, + afterJson: { + taskId: task.taskId, + phase: task.phase, + status: task.status, + }, + }), + ); + state.permissionAuditLogs = state.permissionAuditLogs.slice(0, 500); + return { ...task }; + }); +} +``` + +- [ ] **Step 4: Create recovery route** + +Create `src/app/api/v1/master-agent/tasks/[taskId]/recovery/route.ts`: + +```ts +import { NextRequest } from "next/server"; +import { jsonNoStore } from "@/lib/api-response"; +import { requireRequestSession } from "@/lib/boss-auth"; +import { requireCsrfSafeMutation } from "@/lib/boss-csrf"; +import { + canRetryMasterAgentTaskSafely, + getMasterAgentTask, + retryRecoverableMasterAgentTask, +} from "@/lib/boss-data"; + +function stringValue(value: unknown) { + return typeof value === "string" ? value.trim() : ""; +} + +async function paramsTaskId(context: { params: Promise<{ taskId: string }> }) { + const params = await context.params; + return params.taskId; +} + +function forbidden() { + return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 }); +} + +export async function GET(request: NextRequest, context: { params: Promise<{ taskId: string }> }) { + const session = await requireRequestSession(request); + if (!session) return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + const taskId = await paramsTaskId(context); + const task = await getMasterAgentTask(taskId); + if (!task) return jsonNoStore({ ok: false, message: "MASTER_AGENT_TASK_NOT_FOUND" }, { status: 404 }); + if (session.role !== "highest_admin" && task.requestedByAccount !== session.account) return forbidden(); + const canRetry = canRetryMasterAgentTaskSafely(task); + return jsonNoStore({ + ok: true, + recovery: { + taskId: task.taskId, + status: task.status, + phase: task.phase ?? task.status, + canRetry, + safeNextAction: canRetry ? "retry" : task.status === "needs_user_action" ? "user_action" : "inspect", + diagnosis: `任务处于 ${task.phase ?? task.status},最后进度时间 ${task.lastProgressAt ?? task.claimedAt ?? task.requestedAt}`, + lastErrorCode: task.lastErrorCode, + lastProgressAt: task.lastProgressAt ?? task.claimedAt ?? task.requestedAt, + }, + }); +} + +export async function POST(request: NextRequest, context: { params: Promise<{ taskId: string }> }) { + const csrf = requireCsrfSafeMutation(request); + if (csrf) return csrf; + const session = await requireRequestSession(request); + if (!session) return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + if (session.role !== "highest_admin") return forbidden(); + const body = (await request.json().catch(() => ({}))) as Record; + const action = stringValue(body.action); + if (action !== "retry") return jsonNoStore({ ok: false, message: "TASK_RECOVERY_ACTION_INVALID" }, { status: 400 }); + const taskId = await paramsTaskId(context); + try { + const task = await retryRecoverableMasterAgentTask({ + taskId, + actorAccount: session.account, + reason: stringValue(body.reason) || "管理员从恢复面板重试任务", + }); + return jsonNoStore({ ok: true, action, task }); + } catch (error) { + const message = error instanceof Error ? error.message : "TASK_RECOVERY_FAILED"; + const status = message === "MASTER_AGENT_TASK_NOT_FOUND" ? 404 : 400; + return jsonNoStore({ ok: false, message }, { status }); + } +} +``` + +- [ ] **Step 5: Run task recovery tests** + +Run: + +```bash +npx tsx --test tests/master-agent-task-recovery-route.test.ts +``` + +Expected: + +```text +PASS +``` + +- [ ] **Step 6: Run task reliability tests** + +Run: + +```bash +npx tsx --test tests/master-agent-task-reliability.test.ts +``` + +Expected: + +```text +PASS +``` + +- [ ] **Step 7: Commit** + +Run: + +```bash +git add src/lib/boss-data.ts src/app/api/v1/master-agent/tasks/[taskId]/recovery/route.ts tests/master-agent-task-recovery-route.test.ts +git commit -m "feat: add master agent task recovery endpoint" +``` + +--- + +### Task 7: Android Execution Progress Phase Explanation + +**Files:** +- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java` +- Modify: `android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java` + +- [ ] **Step 1: Add failing Android UI assertion** + +Add a test in `ProjectDetailActivityUiTest.java`: + +```java + @Test + public void executionProgressMessageRendersPhaseExplanation() throws Exception { + Intent intent = new Intent() + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "thread-phase") + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "Boss开发主线程"); + TestProjectDetailActivity activity = Robolectric + .buildActivity(TestProjectDetailActivity.class, intent) + .setup() + .get(); + + JSONObject message = new JSONObject() + .put("id", "progress-phase-1") + .put("sender", "master") + .put("senderLabel", "主 Agent") + .put("body", "执行进度") + .put("kind", "execution_progress") + .put("sentAt", "2026-06-06T10:16:00+08:00") + .put("executionProgress", new JSONObject() + .put("status", "running") + .put("phase", "awaiting_reply") + .put("lastProgressAt", "2026-06-06T10:15:30+08:00") + .put("steps", new JSONArray() + .put(new JSONObject().put("text", "等待目标线程回复").put("status", "running")))); + + View messageView = ReflectionHelpers.callInstanceMethod( + activity, + "buildMessageView", + ReflectionHelpers.ClassParameter.from(JSONObject.class, message) + ); + + assertTrue(viewTreeContainsText(messageView, "当前状态:等待线程回复")); + assertTrue(viewTreeContainsText(messageView, "最后更新:2026-06-06T10:15:30+08:00")); + } +``` + +- [ ] **Step 2: Run Android test to verify failure** + +Run: + +```bash +cd android && ./gradlew testDebugUnitTest --tests com.hyzq.boss.ProjectDetailActivityUiTest.executionProgressMessageRendersPhaseExplanation +``` + +Expected: + +```text +FAIL because phase explanation is not rendered +``` + +- [ ] **Step 3: Add phase label helper** + +In `BossUi.java`, add near other helper methods: + +```java + private static String executionPhaseLabel(String phase) { + if (TextUtils.isEmpty(phase)) return ""; + switch (phase) { + case "queued": + return "等待设备接收"; + case "claimed": + return "设备已接收"; + case "executor_starting": + return "正在启动执行器"; + case "turn_started": + return "已写入目标线程"; + case "awaiting_reply": + return "等待线程回复"; + case "completing": + return "正在回写结果"; + case "completed": + return "已完成"; + case "recoverable_failed": + return "可恢复失败"; + case "terminal_failed": + return "执行失败"; + case "timed_out": + return "执行超时"; + case "canceled": + return "已取消"; + case "needs_user_action": + return "需要用户处理"; + default: + return phase; + } + } +``` + +- [ ] **Step 4: Render phase explanation in progress card** + +In `buildExecutionProgressCard`, after `card.addView(titleRow);`, add: + +```java + String phase = progress == null ? "" : progress.optString("phase", "").trim(); + String phaseLabel = executionPhaseLabel(phase); + String lastProgressAt = progress == null ? "" : progress.optString("lastProgressAt", "").trim(); + if (!TextUtils.isEmpty(phaseLabel)) { + TextView phaseView = secondaryText(context, "当前状态:" + phaseLabel); + phaseView.setPadding(0, dp(context, 8), 0, 0); + card.addView(phaseView); + } + if (!TextUtils.isEmpty(lastProgressAt)) { + TextView updatedView = secondaryText(context, "最后更新:" + lastProgressAt); + updatedView.setPadding(0, dp(context, 4), 0, dp(context, 4)); + card.addView(updatedView); + } +``` + +- [ ] **Step 5: Run Android focused test** + +Run: + +```bash +cd android && ./gradlew testDebugUnitTest --tests com.hyzq.boss.ProjectDetailActivityUiTest.executionProgressMessageRendersPhaseExplanation +``` + +Expected: + +```text +PASS +``` + +- [ ] **Step 6: Commit** + +Run: + +```bash +git add android/app/src/main/java/com/hyzq/boss/BossUi.java android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java +git commit -m "feat: explain task phase in android progress cards" +``` + +--- + +### Task 8: Runtime Documentation Update + +**Files:** +- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md` + +- [ ] **Step 1: Add Phase 1 runtime note** + +Append to the reliability section: + +```markdown +- 2026-06-06 起,Boss 企业安全第一批已开始产品化:`GET/POST /api/v1/admin/backups` 在原有文件快照基础上新增业务投影、校验、恢复预览和 dry-run;恢复前仍自动创建 pre-restore 快照,恢复和校验动作写入审计日志。`GET /api/v1/admin/backoffice` 新增 `dataSafetySummary / taskRiskSummary`,平台后台和企业后台可展示备份健康、RPO/RTO 当前阶段说明、卡住任务和可恢复任务。APP 进度卡新增 `phase` 人话解释,用于避免用户只看到“卡第一步”。 +``` + +- [ ] **Step 2: Run markdown grep self-check** + +Run: + +```bash +rg -n "dataSafetySummary|taskRiskSummary|dry-run|phase" docs/architecture/current_runtime_and_deploy_status_cn.md +``` + +Expected: + +```text +At least one matching line for each key phrase +``` + +- [ ] **Step 3: Commit** + +Run: + +```bash +git add docs/architecture/current_runtime_and_deploy_status_cn.md +git commit -m "docs: document enterprise safety phase one runtime" +``` + +--- + +### Task 9: Full Verification and Packaging Decision + +**Files:** +- No source edits expected. + +- [ ] **Step 1: Run focused server tests** + +Run: + +```bash +npx tsx --test tests/admin-backups-route.test.ts +npx tsx --test tests/admin-backoffice-bff-route.test.ts +npx tsx --test tests/master-agent-task-recovery-route.test.ts +npx tsx --test tests/master-agent-task-reliability.test.ts +``` + +Expected: + +```text +all tests pass +``` + +- [ ] **Step 2: Run Android focused test** + +Run: + +```bash +cd android && ./gradlew testDebugUnitTest --tests com.hyzq.boss.ProjectDetailActivityUiTest.executionProgressMessageRendersPhaseExplanation +``` + +Expected: + +```text +BUILD SUCCESSFUL +``` + +- [ ] **Step 3: Run repository verification** + +Run: + +```bash +npm run build +npm run lint +git diff --check +``` + +Expected: + +```text +all commands exit 0 +``` + +- [ ] **Step 4: Review final changed files** + +Run: + +```bash +git status --short +git log --oneline -5 +``` + +Expected: + +```text +Only intended Phase 1 files are modified or committed by this implementation sequence. +``` + +- [ ] **Step 5: Decide deployment** + +If the user wants cloud deployment after verification, deploy with the existing Boss server process: + +```bash +export BOSS_SERVER_PASS="$(security find-generic-password -a ubuntu -s boss-server-debug-ssh -w)" +./scripts/deploy-server.sh +curl -fsS https://boss.hyzq.net/api/health +``` + +Expected: + +```text +{"ok":true,...} +``` + +If the user wants Android packaging after verification, build the existing Android debug package: + +```bash +cd android +./gradlew assembleDebug +``` + +Expected: + +```text +BUILD SUCCESSFUL +``` + +--- + +## Self-Review + +Spec coverage: + +- Backup visibility is covered by Tasks 2, 3, 4, and 5. +- Restore preview and dry-run are covered by Tasks 2, 3, and 4. +- Restore audit is covered by Task 4. +- Task risk visibility is covered by Tasks 5 and 6. +- APP progress clarity is covered by Task 7. +- Runtime documentation is covered by Task 8. + +Safety checks: + +- Actual restore still creates `pre-restore` through existing `restoreBossStateBackup`. +- Dry-run and preview explicitly set `willWriteState: false` and do not call `writeState`. +- Task retry is allowed only for recoverable pre-turn phases. +- Android APP displays human-readable state but does not execute restore actions. + +Execution order: + +- Tasks 2-4 must run in sequence. +- Tasks 5, 6, and 7 can run in parallel after Task 4 if subagents avoid touching the same files. +- Task 8 should run after implementation behavior is final. +- Task 9 is the completion gate.