@co0ontty/wand 4.77.0 → 4.78.0-beta.ga1e4f59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +21 -46
  2. package/dist/agent-dispatch.js +3 -2
  3. package/dist/ai-team-live.d.ts +25 -0
  4. package/dist/ai-team-live.js +135 -0
  5. package/dist/ai-team-prompts.d.ts +10 -6
  6. package/dist/ai-team-prompts.js +74 -41
  7. package/dist/ai-team-runner.d.ts +85 -3
  8. package/dist/ai-team-runner.js +336 -33
  9. package/dist/ai-team-types.d.ts +57 -2
  10. package/dist/ai-team-types.js +4 -0
  11. package/dist/build-info.json +4 -4
  12. package/dist/commit-task-archive.js +14 -18
  13. package/dist/config.js +14 -6
  14. package/dist/git-quick-commit.js +1 -1
  15. package/dist/middleware/rate-limit.d.ts +18 -4
  16. package/dist/middleware/rate-limit.js +55 -23
  17. package/dist/missions.js +0 -3
  18. package/dist/models.d.ts +6 -1
  19. package/dist/models.js +35 -0
  20. package/dist/path-repair.d.ts +6 -5
  21. package/dist/path-repair.js +3 -3
  22. package/dist/render-protocol.d.ts +1 -1
  23. package/dist/render-protocol.js +1 -1
  24. package/dist/server-ai-team-routes.d.ts +1 -0
  25. package/dist/server-ai-team-routes.js +28 -14
  26. package/dist/server-local-preview-routes.js +1 -1
  27. package/dist/server-session-routes.js +0 -3
  28. package/dist/server-settings-routes.d.ts +1 -1
  29. package/dist/server-settings-routes.js +3 -2
  30. package/dist/server-task-routes.js +16 -32
  31. package/dist/server-workspace-routes.js +31 -24
  32. package/dist/server.js +60 -21
  33. package/dist/storage.d.ts +54 -21
  34. package/dist/storage.js +485 -122
  35. package/dist/types.d.ts +7 -0
  36. package/dist/upload-routes.d.ts +2 -2
  37. package/dist/upload-routes.js +10 -3
  38. package/dist/wand-task-sync.d.ts +2 -34
  39. package/dist/wand-task-sync.js +0 -206
  40. package/dist/web-ui/asset-version.d.ts +5 -0
  41. package/dist/web-ui/asset-version.js +5 -0
  42. package/dist/web-ui/content/ai-teams.js +17 -8
  43. package/dist/web-ui/content/scripts.js +67 -66
  44. package/dist/web-ui/content/styles.css +1 -1
  45. package/dist/web-ui/content/tailwind.css +0 -21
  46. package/dist/web-ui/embedded-assets.js +8 -7
  47. package/dist/web-ui/index.js +9 -17
  48. package/dist/web-ui/scripts.d.ts +3 -1
  49. package/dist/web-ui/scripts.js +52 -25
  50. package/dist/web-ui/styles.d.ts +2 -0
  51. package/dist/web-ui/styles.js +12 -0
  52. package/package.json +13 -12
  53. package/scripts/install-render-binary.js +2 -2
  54. package/scripts/stage-render-binaries.js +1 -1
package/dist/storage.js CHANGED
@@ -7,7 +7,8 @@ import { normalizeSessionDirectory } from "./session-directory-tree.js";
7
7
  import { inferProviderFromCommand, inferProviderFromRunner, isSessionProvider, SESSION_PROVIDERS } from "./session-provider.js";
8
8
  import { DEFAULT_ITERATION_NAME, DEFAULT_WAND_TASK_AGENT_KIND, DEFAULT_WAND_TASK_PRIORITY, isWandTaskAgentKind, normalizeWandTaskAgentMode } from "./task-types.js";
9
9
  import { firstLayoutTabId } from "./layout-tree.js";
10
- import { AI_TEAM_DEFAULT_MAX_STEPS, isTeamMemberRole, memberAgents } from "./ai-team-types.js";
10
+ import { isUnnamedWorkspaceTaskName } from "./wand-task-sync.js";
11
+ import { AI_TEAM_DEFAULT_MAX_STEPS, AI_TEAM_TERMINAL_RUN_STATUSES, isTeamMemberRole, memberAgents } from "./ai-team-types.js";
11
12
  import { isThinkingEffort } from "./structured-provider-common.js";
12
13
  import { DEFAULT_PASSWORD_VAULT_ID, DEFAULT_PASSWORD_VAULT_NAME, decryptVaultSecret, encryptVaultSecret, itemMatchesFilter, normalizePasswordItemInput, normalizeVaultName, nowIso, } from "./password-manager.js";
13
14
  function safeJsonParse(raw) {
@@ -996,6 +997,35 @@ export function ensureDatabaseFile(dbPath) {
996
997
  chmodSync(dbPath, 0o600);
997
998
  return created;
998
999
  }
1000
+ const WAND_TASK_FIELDS = `id, identifier, workspace_id, workspace_task_id, parent_task_id,
1001
+ title, title_source, auto_title_signature, description, status, priority, labels_json,
1002
+ due_date, milestone_id, sort_order, agent_json, created_at, updated_at`;
1003
+ // Shared metadata comes from the latest explicitly linked card, including meaningful NULLs.
1004
+ // The old container columns remain readable for legacy rows until the startup migration.
1005
+ const WORKSPACE_TASK_PROJECTION = `SELECT wt.id,
1006
+ CASE WHEN card.id IS NULL THEN wt.workspace_id
1007
+ ELSE COALESCE(card.workspace_id, '${GLOBAL_WORKSPACE_ID}') END AS workspace_id,
1008
+ CASE WHEN card.id IS NULL THEN wt.name ELSE card.title END AS name,
1009
+ CASE WHEN card.id IS NULL THEN wt.status
1010
+ WHEN card.status IN ('done', 'archived') THEN 'done' ELSE 'active' END AS status,
1011
+ CASE WHEN card.id IS NULL THEN wt.milestone_id ELSE card.milestone_id END AS milestone_id,
1012
+ wt.worktree_json, wt.layout_json, wt.cwd, wt.created_at, wt.last_opened_at, wt.layout_revision,
1013
+ wt.rowid AS _created_order
1014
+ FROM workspace_tasks wt LEFT JOIN wand_tasks card ON card.id = (
1015
+ SELECT id FROM wand_tasks WHERE workspace_task_id = wt.id
1016
+ ORDER BY updated_at DESC, rowid DESC LIMIT 1
1017
+ )`;
1018
+ function withoutSessionTab(node, sessionId) {
1019
+ if (node.type === "split")
1020
+ return {
1021
+ ...node,
1022
+ children: [withoutSessionTab(node.children[0], sessionId), withoutSessionTab(node.children[1], sessionId)],
1023
+ };
1024
+ const activeId = node.tabs[node.active]?.id;
1025
+ const tabs = node.tabs.filter((tab) => tab.kind !== "session" || tab.sessionId !== sessionId);
1026
+ const active = tabs.findIndex((tab) => tab.id === activeId);
1027
+ return { ...node, tabs, active: active >= 0 ? active : Math.min(node.active, Math.max(0, tabs.length - 1)) };
1028
+ }
999
1029
  export class WandStorage {
1000
1030
  db;
1001
1031
  dbPath;
@@ -1016,6 +1046,7 @@ export class WandStorage {
1016
1046
  ensureAiTeamSchema(this.db);
1017
1047
  ensureConnectorSchema(this.db);
1018
1048
  this.ensureDefaultPasswordVault();
1049
+ this.migrateTaskRecords();
1019
1050
  }
1020
1051
  directory() {
1021
1052
  return path.dirname(this.dbPath);
@@ -1233,20 +1264,40 @@ export class WandStorage {
1233
1264
  this.db.prepare("UPDATE workspaces SET last_opened_at = ? WHERE id = ?").run(nowIso(), id);
1234
1265
  }
1235
1266
  deleteWorkspace(id, options = {}) {
1236
- if (options.cascade) {
1237
- this.db.prepare(`DELETE FROM command_sessions
1238
- WHERE workspace_id = ?
1239
- OR workspace_task_id IN (SELECT id FROM workspace_tasks WHERE workspace_id = ?)`).run(id, id);
1240
- }
1241
- else {
1242
- // 解绑:保留会话,同时清空 workspace 与即将级联删除的 task 归属。
1243
- this.db.prepare(`UPDATE command_sessions
1244
- SET workspace_id = NULL, workspace_task_id = NULL
1245
- WHERE workspace_id = ?
1246
- OR workspace_task_id IN (SELECT id FROM workspace_tasks WHERE workspace_id = ?)`).run(id, id);
1247
- }
1248
- this.db.prepare("DELETE FROM workspaces WHERE id = ?").run(id);
1249
- this.forgetWorkspaceGroupOrder(id);
1267
+ this.transaction(() => {
1268
+ const cards = this.db.prepare(`SELECT ${WAND_TASK_FIELDS} FROM wand_tasks
1269
+ WHERE workspace_task_id IN (SELECT id FROM workspace_tasks WHERE workspace_id = ?)`)
1270
+ .all(id);
1271
+ if (options.cascade) {
1272
+ this.db.prepare(`DELETE FROM command_sessions WHERE workspace_id = ?
1273
+ OR workspace_task_id IN (SELECT id FROM workspace_tasks WHERE workspace_id = ?)`)
1274
+ .run(id, id);
1275
+ }
1276
+ else {
1277
+ this.db.prepare(`UPDATE command_sessions SET workspace_id = NULL, workspace_task_id = NULL
1278
+ WHERE workspace_id = ? OR workspace_task_id IN
1279
+ (SELECT id FROM workspace_tasks WHERE workspace_id = ?)`)
1280
+ .run(id, id);
1281
+ }
1282
+ this.db.prepare(`UPDATE wand_tasks SET workspace_task_id = NULL
1283
+ WHERE workspace_task_id IN (SELECT id FROM workspace_tasks WHERE workspace_id = ?)`)
1284
+ .run(id);
1285
+ this.db.prepare("DELETE FROM workspaces WHERE id = ?").run(id);
1286
+ // Direct project removal detaches active cards; their next dispatch still uses the default cwd.
1287
+ // Route-level destructive removal archives cards first, so those cards never gain a container.
1288
+ for (const row of cards) {
1289
+ const card = this.mapWandTaskRow(row);
1290
+ if (card.status === "archived")
1291
+ continue;
1292
+ const global = this.ensureGlobalWorkspace();
1293
+ const containerId = this.insertWorkspaceTask({
1294
+ workspaceId: global.id, name: card.title, milestoneId: card.milestoneId,
1295
+ status: card.status === "done" ? "done" : "active", createdAt: card.createdAt,
1296
+ });
1297
+ this.writeWandTask(this.getWandTask(card.id), { workspaceTaskId: containerId });
1298
+ }
1299
+ this.forgetWorkspaceGroupOrder(id);
1300
+ });
1250
1301
  }
1251
1302
  listSessionsByWorkspace(workspaceId) {
1252
1303
  const rows = this.db
@@ -1264,7 +1315,10 @@ export class WandStorage {
1264
1315
  return row ? { workspaceId: row.workspace_id ?? undefined, workspaceTaskId: row.workspace_task_id ?? undefined } : null;
1265
1316
  }
1266
1317
  setSessionWorkspaceId(sessionId, workspaceId) {
1267
- this.db.prepare("UPDATE command_sessions SET workspace_id = ? WHERE id = ?").run(workspaceId, sessionId);
1318
+ const binding = this.getSessionWorkspace(sessionId);
1319
+ const task = binding?.workspaceTaskId ? this.getWorkspaceTask(binding.workspaceTaskId) : null;
1320
+ this.db.prepare("UPDATE command_sessions SET workspace_id = ? WHERE id = ?")
1321
+ .run(task?.workspaceId ?? workspaceId, sessionId);
1268
1322
  }
1269
1323
  /** Lightweight count of persisted sessions grouped by workspace. */
1270
1324
  countSessionsByWorkspace() {
@@ -1306,84 +1360,78 @@ export class WandStorage {
1306
1360
  }));
1307
1361
  }
1308
1362
  // ── Workspace tasks(任务 = 命名 + 独立 worktree + 一组标签)──
1309
- /** 侧栏任务顺序:新创建在前。GET /api/tasks 按此顺序填充目录组。 */
1363
+ /** Both task DTOs read one set of task metadata; containers own only local layout/worktree. */
1310
1364
  listWorkspaceTasks(workspaceId) {
1311
- const rows = this.db
1312
- .prepare(`SELECT id, workspace_id, name, worktree_json, layout_json, status, cwd, milestone_id, created_at, last_opened_at, layout_revision
1313
- FROM workspace_tasks WHERE workspace_id = ?
1314
- ORDER BY created_at DESC, rowid DESC`)
1315
- .all(workspaceId);
1365
+ const rows = this.db.prepare(`SELECT * FROM (${WORKSPACE_TASK_PROJECTION}) WHERE workspace_id = ?
1366
+ ORDER BY created_at DESC, _created_order DESC`).all(workspaceId);
1316
1367
  return rows.map(mapWorkspaceTaskRow);
1317
1368
  }
1318
1369
  getWorkspaceTask(id) {
1319
- const row = this.db
1320
- .prepare(`SELECT id, workspace_id, name, worktree_json, layout_json, status, cwd, milestone_id, created_at, last_opened_at, layout_revision
1321
- FROM workspace_tasks WHERE id = ?`)
1370
+ const row = this.db.prepare(`${WORKSPACE_TASK_PROJECTION} WHERE wt.id = ?`)
1322
1371
  .get(id);
1323
1372
  return row ? mapWorkspaceTaskRow(row) : null;
1324
1373
  }
1325
- createWorkspaceTask(input) {
1374
+ insertWorkspaceTask(input) {
1326
1375
  const id = crypto.randomUUID();
1327
- const createdAt = nowIso();
1328
- const status = input.status ?? "active";
1329
- const cwd = typeof input.cwd === "string" && input.cwd.trim() ? input.cwd.trim() : null;
1330
- const milestoneId = typeof input.milestoneId === "string" && input.milestoneId ? input.milestoneId : null;
1331
- this.db
1332
- .prepare(`INSERT INTO workspace_tasks (id, workspace_id, name, worktree_json, layout_json, status, cwd, milestone_id, created_at, last_opened_at)
1333
- VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, NULL)`)
1334
- .run(id, input.workspaceId, input.name, input.worktree ? JSON.stringify(input.worktree) : null, status, cwd, milestoneId, createdAt);
1335
- return {
1336
- id,
1337
- workspaceId: input.workspaceId,
1338
- name: input.name,
1339
- worktree: input.worktree ?? null,
1340
- ...(cwd ? { cwd } : {}),
1341
- milestoneId,
1342
- layout: null,
1343
- status,
1344
- createdAt,
1345
- lastOpenedAt: null,
1346
- };
1376
+ this.db.prepare(`INSERT INTO workspace_tasks
1377
+ (id, workspace_id, name, worktree_json, layout_json, status, cwd, milestone_id, created_at, last_opened_at)
1378
+ VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, NULL)`)
1379
+ .run(id, input.workspaceId, input.name, input.worktree ? JSON.stringify(input.worktree) : null, input.status ?? "active", input.cwd?.trim() || null, input.milestoneId ?? null, input.createdAt ?? nowIso());
1380
+ return id;
1381
+ }
1382
+ taskMilestoneForWrite(id, workspaceId) {
1383
+ if (!id)
1384
+ return this.ensureDefaultWandMilestone().id;
1385
+ const milestone = this.getWandMilestone(id);
1386
+ if (!milestone)
1387
+ throw new Error("未找到该里程碑。");
1388
+ return workspaceId && milestone.workspaceId && milestone.workspaceId !== workspaceId
1389
+ ? this.ensureDefaultWandMilestone().id : id;
1390
+ }
1391
+ createWorkspaceTask(input) {
1392
+ return this.transaction(() => {
1393
+ const workspace = this.getWorkspace(input.workspaceId);
1394
+ if (!workspace)
1395
+ throw new Error("任务所属工作区不存在。");
1396
+ const projectId = workspace.kind === "global" || workspace.id === GLOBAL_WORKSPACE_ID ? null : workspace.id;
1397
+ const milestoneId = this.taskMilestoneForWrite(input.milestoneId, projectId);
1398
+ const id = this.insertWorkspaceTask({ ...input, milestoneId });
1399
+ this.insertWandTask({
1400
+ ...input.board, workspaceTaskId: id,
1401
+ workspaceId: projectId,
1402
+ title: input.name,
1403
+ titleSource: input.board?.titleSource ?? (isUnnamedWorkspaceTaskName(input.name) ? "auto" : "user"),
1404
+ status: input.status === "done" ? "done" : "todo", milestoneId,
1405
+ });
1406
+ return this.getWorkspaceTask(id);
1407
+ });
1347
1408
  }
1348
1409
  updateWorkspaceTask(id, patch) {
1349
- const assignments = [];
1350
- const values = [];
1351
- if (patch.workspaceId !== undefined) {
1352
- assignments.push("workspace_id = ?");
1353
- values.push(patch.workspaceId);
1354
- }
1355
- if (patch.name !== undefined) {
1356
- assignments.push("name = ?");
1357
- values.push(patch.name);
1358
- }
1359
- if (patch.status !== undefined) {
1360
- assignments.push("status = ?");
1361
- values.push(patch.status);
1362
- }
1363
- if (patch.worktree !== undefined) {
1364
- assignments.push("worktree_json = ?");
1365
- values.push(patch.worktree ? JSON.stringify(patch.worktree) : null);
1366
- }
1367
- if (patch.milestoneId !== undefined) {
1368
- assignments.push("milestone_id = ?");
1369
- values.push(patch.milestoneId || null);
1370
- }
1371
- if (assignments.length === 0)
1372
- return;
1373
- this.db.prepare(`UPDATE workspace_tasks SET ${assignments.join(", ")} WHERE id = ?`).run(...values, id);
1374
- const card = this.getWandTaskByWorkspaceTaskId(id);
1375
- if (card) {
1376
- // Reverse projection uses direct SQL, so these writers cannot recurse.
1377
- this.updateWandTask(card.id, {
1410
+ this.transaction(() => {
1411
+ const card = this.getWandTaskByWorkspaceTaskId(id);
1412
+ if (!card)
1413
+ throw new Error("未找到该任务。");
1414
+ if (patch.worktree !== undefined) {
1415
+ this.db.prepare("UPDATE workspace_tasks SET worktree_json = ? WHERE id = ?")
1416
+ .run(patch.worktree ? JSON.stringify(patch.worktree) : null, id);
1417
+ }
1418
+ this.writeWandTask(card, {
1378
1419
  ...(patch.name !== undefined ? { title: patch.name, titleSource: "user" } : {}),
1379
1420
  ...(patch.milestoneId !== undefined ? { milestoneId: patch.milestoneId } : {}),
1421
+ ...(patch.workspaceId !== undefined ? {
1422
+ workspaceId: patch.workspaceId === GLOBAL_WORKSPACE_ID ? null : patch.workspaceId,
1423
+ } : {}),
1380
1424
  ...(patch.status !== undefined ? {
1381
1425
  status: patch.status === "done"
1382
1426
  ? card.status === "archived" ? "archived" : "done"
1383
1427
  : card.status === "todo" ? "todo" : "doing",
1384
1428
  } : {}),
1385
1429
  });
1386
- }
1430
+ });
1431
+ }
1432
+ archiveWorkspaceTask(id) {
1433
+ const card = this.getWandTaskByWorkspaceTaskId(id);
1434
+ return card ? this.updateWandTask(card.id, { status: "archived" }) : null;
1387
1435
  }
1388
1436
  saveWorkspaceTaskLayout(id, layout, expectedRevision) {
1389
1437
  const current = this.getWorkspaceTask(id);
@@ -1403,13 +1451,17 @@ export class WandStorage {
1403
1451
  this.db.prepare("UPDATE workspace_tasks SET last_opened_at = ? WHERE id = ?").run(nowIso(), id);
1404
1452
  }
1405
1453
  deleteWorkspaceTask(id, options = {}) {
1406
- if (options.cascade) {
1407
- this.db.prepare("DELETE FROM command_sessions WHERE workspace_task_id = ?").run(id);
1408
- }
1409
- else {
1410
- this.db.prepare("UPDATE command_sessions SET workspace_task_id = NULL WHERE workspace_task_id = ?").run(id);
1411
- }
1412
- this.db.prepare("DELETE FROM workspace_tasks WHERE id = ?").run(id);
1454
+ this.transaction(() => {
1455
+ this.db.prepare(`UPDATE wand_tasks SET workspace_task_id = NULL, status = 'archived', updated_at = ?
1456
+ WHERE workspace_task_id = ?`).run(nowIso(), id);
1457
+ if (options.cascade) {
1458
+ this.db.prepare("DELETE FROM command_sessions WHERE workspace_task_id = ?").run(id);
1459
+ }
1460
+ else {
1461
+ this.db.prepare("UPDATE command_sessions SET workspace_task_id = NULL WHERE workspace_task_id = ?").run(id);
1462
+ }
1463
+ this.db.prepare("DELETE FROM workspace_tasks WHERE id = ?").run(id);
1464
+ });
1413
1465
  }
1414
1466
  tasksAggregateFingerprint() {
1415
1467
  const sessions = this.db.prepare(`SELECT COUNT(*) AS count,
@@ -1421,7 +1473,7 @@ export class WandStorage {
1421
1473
  // timestamp/revision. Layout payloads are represented by their own revision.
1422
1474
  const tasks = this.db.prepare(`SELECT id, workspace_id, name, status, cwd, worktree_json, milestone_id,
1423
1475
  created_at, last_opened_at, layout_revision
1424
- FROM workspace_tasks ORDER BY id`).all();
1476
+ FROM (${WORKSPACE_TASK_PROJECTION}) ORDER BY id`).all();
1425
1477
  const workspaces = this.db.prepare(`SELECT COUNT(*) AS count, COALESCE(MAX(last_opened_at), '') AS opened FROM workspaces`).get();
1426
1478
  // 名称也进指纹:改名(含工作区/项目名与目录自定义名)后客户端才肯重拉任务列表。
1427
1479
  const names = this.db.prepare(`SELECT
@@ -1438,9 +1490,24 @@ export class WandStorage {
1438
1490
  names,
1439
1491
  });
1440
1492
  }
1493
+ /**
1494
+ * 会话列表团队标记的廉价指纹:运行/步骤状态一变,`/api/tasks` 里的 `teamChat`/`teamStep`
1495
+ * 就跟着变,客户端必须肯重拉。只看聚合值和最近若干条状态,不逐行拼整张表。
1496
+ */
1497
+ teamSessionFingerprint() {
1498
+ const runs = this.db.prepare(`SELECT COUNT(*) AS count,
1499
+ COALESCE((SELECT GROUP_CONCAT(status || ':' || COALESCE(chat_session_id, ''), '|')
1500
+ FROM (SELECT status, chat_session_id FROM ai_team_runs ORDER BY rowid DESC LIMIT 500)), '') AS statuses
1501
+ FROM ai_team_runs`).get();
1502
+ const steps = this.db.prepare(`SELECT COUNT(*) AS count,
1503
+ COALESCE((SELECT GROUP_CONCAT(status || ':' || COALESCE(session_id, ''), '|')
1504
+ FROM (SELECT status, session_id FROM ai_team_steps ORDER BY rowid DESC LIMIT 500)), '') AS statuses
1505
+ FROM ai_team_steps`).get();
1506
+ return JSON.stringify({ runs, steps });
1507
+ }
1441
1508
  /** GET /api/wand-tasks 的顺序:新创建在前。客户端按此顺序渲染,不再本地排序。 */
1442
1509
  listWandTasks(workspaceId) {
1443
- const rows = this.db.prepare(`SELECT id, identifier, workspace_id, workspace_task_id, parent_task_id, title, title_source, auto_title_signature, description, status, priority, labels_json, due_date, milestone_id, sort_order, agent_json, created_at, updated_at
1510
+ const rows = this.db.prepare(`SELECT ${WAND_TASK_FIELDS}
1444
1511
  FROM wand_tasks ${workspaceId === undefined ? "" : "WHERE workspace_id IS ?"}
1445
1512
  ORDER BY created_at DESC, rowid DESC`).all(...(workspaceId === undefined ? [] : [workspaceId]));
1446
1513
  return rows.map((row) => this.mapWandTaskRow(row));
@@ -1468,42 +1535,181 @@ export class WandStorage {
1468
1535
  };
1469
1536
  }
1470
1537
  getWandTask(id) {
1471
- return this.listWandTasks().find((task) => task.id === id) ?? null;
1538
+ const row = this.db.prepare(`SELECT ${WAND_TASK_FIELDS} FROM wand_tasks WHERE id = ?`)
1539
+ .get(id);
1540
+ return row ? this.mapWandTaskRow(row) : null;
1472
1541
  }
1473
1542
  getWandTaskByWorkspaceTaskId(workspaceTaskId) {
1474
- const row = this.db.prepare(`SELECT id, identifier, workspace_id, workspace_task_id, parent_task_id, title, title_source, auto_title_signature, description, status, priority, labels_json, due_date, milestone_id, sort_order, agent_json, created_at, updated_at
1475
- FROM wand_tasks WHERE workspace_task_id = ? ORDER BY updated_at DESC LIMIT 1`).get(workspaceTaskId);
1543
+ const row = this.db.prepare(`SELECT ${WAND_TASK_FIELDS} FROM wand_tasks
1544
+ WHERE workspace_task_id = ? ORDER BY updated_at DESC, rowid DESC LIMIT 1`)
1545
+ .get(workspaceTaskId);
1476
1546
  return row ? this.mapWandTaskRow(row) : null;
1477
1547
  }
1478
- createWandTask(input) {
1548
+ insertWandTask(input, createdAt = nowIso()) {
1479
1549
  const id = crypto.randomUUID();
1480
- const now = nowIso();
1481
1550
  const status = input.status ?? "todo";
1482
- const priority = input.priority ?? DEFAULT_WAND_TASK_PRIORITY;
1483
- const max = this.db.prepare("SELECT COALESCE(MAX(sort_order), -1) AS value FROM wand_tasks WHERE workspace_id IS ? AND status = ?").get(input.workspaceId ?? null, status);
1484
- const numberRow = this.db.prepare("SELECT COALESCE(MAX(CAST(substr(identifier, 6) AS INTEGER)), 0) AS value FROM wand_tasks WHERE identifier GLOB 'TASK-[0-9]*'").get();
1485
- const identifier = `TASK-${(numberRow?.value ?? 0) + 1}`;
1486
- this.db.prepare(`INSERT INTO wand_tasks (id, identifier, workspace_id, workspace_task_id, parent_task_id, title, title_source, auto_title_signature, description, status, priority, labels_json, due_date, milestone_id, sort_order, agent_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, identifier, input.workspaceId ?? null, input.workspaceTaskId ?? null, input.parentTaskId ?? null, input.title, input.titleSource ?? "user", null, input.description ?? "", status, priority, JSON.stringify(input.labels ?? []), input.dueDate ?? null, input.milestoneId ?? null, (max?.value ?? -1) + 1, input.agent ? JSON.stringify(input.agent) : null, now, now);
1551
+ const max = this.db.prepare(`SELECT COALESCE(MAX(sort_order), -1) AS value FROM wand_tasks
1552
+ WHERE workspace_id IS ? AND status = ?`).get(input.workspaceId ?? null, status);
1553
+ const number = this.db.prepare(`SELECT COALESCE(MAX(CAST(substr(identifier, 6) AS INTEGER)), 0) AS value
1554
+ FROM wand_tasks WHERE identifier GLOB 'TASK-[0-9]*'`).get();
1555
+ this.db.prepare(`INSERT INTO wand_tasks (${WAND_TASK_FIELDS})
1556
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1557
+ .run(id, `TASK-${number.value + 1}`, input.workspaceId ?? null, input.workspaceTaskId ?? null, input.parentTaskId ?? null, input.title, input.titleSource ?? "user", null, input.description ?? "", status, input.priority ?? DEFAULT_WAND_TASK_PRIORITY, JSON.stringify(input.labels ?? []), input.dueDate ?? null, input.milestoneId ?? null, max.value + 1, input.agent ? JSON.stringify(input.agent) : null, createdAt, createdAt);
1487
1558
  return this.getWandTask(id);
1488
1559
  }
1489
- updateWandTask(id, patch) {
1490
- const current = this.getWandTask(id);
1491
- if (!current)
1492
- return null;
1560
+ createWandTask(input) {
1561
+ return this.transaction(() => {
1562
+ const linked = input.workspaceTaskId ? this.getWorkspaceTask(input.workspaceTaskId) : null;
1563
+ const workspaceId = input.workspaceId === undefined && linked
1564
+ ? linked.workspaceId === GLOBAL_WORKSPACE_ID ? null : linked.workspaceId
1565
+ : input.workspaceId ?? null;
1566
+ const workspace = workspaceId ? this.getWorkspace(workspaceId) : this.ensureGlobalWorkspace();
1567
+ if (!workspace)
1568
+ throw new Error("任务所属工作区不存在。");
1569
+ const milestoneId = this.taskMilestoneForWrite(input.milestoneId, workspaceId);
1570
+ if (input.workspaceTaskId) {
1571
+ const container = this.getWorkspaceTask(input.workspaceTaskId);
1572
+ if (!container)
1573
+ throw new Error("未找到该任务。");
1574
+ const existing = this.getWandTaskByWorkspaceTaskId(container.id);
1575
+ // Compatibility for callers which supplied a previously created container.
1576
+ if (existing)
1577
+ return this.writeWandTask(existing, { ...input, workspaceId, milestoneId });
1578
+ }
1579
+ const workspaceTaskId = input.workspaceTaskId ?? this.insertWorkspaceTask({
1580
+ workspaceId: workspace.id, name: input.title,
1581
+ status: input.status === "done" || input.status === "archived" ? "done" : "active",
1582
+ milestoneId,
1583
+ });
1584
+ return this.insertWandTask({ ...input, workspaceId, workspaceTaskId, milestoneId });
1585
+ });
1586
+ }
1587
+ /** Private canonical writer; callers own the transaction, never a reverse writer. */
1588
+ writeWandTask(current, patch) {
1493
1589
  const next = { ...current, ...patch, updatedAt: nowIso() };
1494
- this.db.prepare(`UPDATE wand_tasks SET workspace_id = ?, workspace_task_id = ?, parent_task_id = ?, title = ?, title_source = ?, auto_title_signature = ?, description = ?, status = ?, priority = ?, labels_json = ?, due_date = ?, milestone_id = ?, sort_order = ?, agent_json = ?, updated_at = ? WHERE id = ?`).run(next.workspaceId, next.workspaceTaskId, next.parentTaskId, next.title, next.titleSource, next.autoTitleSignature ?? null, next.description, next.status, next.priority, JSON.stringify(next.labels), next.dueDate, next.milestoneId ?? null, next.sortOrder, next.agent ? JSON.stringify(next.agent) : null, next.updatedAt, id);
1495
- if (next.workspaceTaskId) {
1590
+ if (patch.milestoneId !== undefined || patch.workspaceId !== undefined) {
1591
+ next.milestoneId = this.taskMilestoneForWrite(next.milestoneId, next.workspaceId);
1592
+ }
1593
+ if (!next.workspaceTaskId && next.status !== "archived") {
1594
+ const workspace = next.workspaceId ? this.getWorkspace(next.workspaceId) : this.ensureGlobalWorkspace();
1595
+ if (!workspace)
1596
+ throw new Error("任务所属工作区不存在。");
1597
+ next.workspaceTaskId = this.insertWorkspaceTask({
1598
+ workspaceId: workspace.id, name: next.title, milestoneId: next.milestoneId,
1599
+ status: next.status === "done" ? "done" : "active", createdAt: next.createdAt,
1600
+ });
1601
+ }
1602
+ this.db.prepare(`UPDATE wand_tasks SET workspace_id = ?, workspace_task_id = ?, parent_task_id = ?,
1603
+ title = ?, title_source = ?, auto_title_signature = ?, description = ?, status = ?, priority = ?,
1604
+ labels_json = ?, due_date = ?, milestone_id = ?, sort_order = ?, agent_json = ?, updated_at = ?
1605
+ WHERE id = ?`)
1606
+ .run(next.workspaceId, next.workspaceTaskId, next.parentTaskId, next.title, next.titleSource, next.autoTitleSignature ?? null, next.description, next.status, next.priority, JSON.stringify(next.labels), next.dueDate, next.milestoneId ?? null, next.sortOrder, next.agent ? JSON.stringify(next.agent) : null, next.updatedAt, next.id);
1607
+ if (patch.workspaceId !== undefined && next.workspaceTaskId) {
1496
1608
  const workspaceId = next.workspaceId ?? this.ensureGlobalWorkspace().id;
1497
- this.db.prepare(`UPDATE workspace_tasks SET name = ?, status = ?, milestone_id = ?, workspace_id = ? WHERE id = ?`)
1498
- .run(next.title, next.status === "done" || next.status === "archived" ? "done" : "active", next.milestoneId ?? null, workspaceId, next.workspaceTaskId);
1609
+ // The container FK/index and standalone session workspace bucket follow the canonical project.
1610
+ this.db.prepare("UPDATE workspace_tasks SET workspace_id = ? WHERE id = ?")
1611
+ .run(workspaceId, next.workspaceTaskId);
1499
1612
  this.db.prepare("UPDATE command_sessions SET workspace_id = ? WHERE workspace_task_id = ?")
1500
1613
  .run(workspaceId, next.workspaceTaskId);
1501
1614
  }
1502
1615
  return next;
1503
1616
  }
1617
+ updateWandTask(id, patch) {
1618
+ return this.transaction(() => {
1619
+ const current = this.getWandTask(id);
1620
+ if (!current)
1621
+ return null;
1622
+ return this.writeWandTask(current, patch);
1623
+ });
1624
+ }
1625
+ /** Commit completion archives only the selected completed cards, atomically. */
1626
+ archiveCompletedWandTasks(ids) {
1627
+ return this.transaction(() => {
1628
+ const archived = [];
1629
+ for (const id of new Set(ids)) {
1630
+ const task = this.getWandTask(id);
1631
+ if (task?.status !== "done")
1632
+ continue;
1633
+ this.writeWandTask(task, { status: "archived" });
1634
+ archived.push(id);
1635
+ }
1636
+ return archived;
1637
+ });
1638
+ }
1504
1639
  deleteWandTask(id) {
1505
- this.db.prepare("UPDATE wand_tasks SET parent_task_id = NULL WHERE parent_task_id = ?").run(id);
1506
- this.db.prepare("DELETE FROM wand_tasks WHERE id = ?").run(id);
1640
+ this.transaction(() => {
1641
+ const card = this.getWandTask(id);
1642
+ if (!card)
1643
+ return;
1644
+ this.db.prepare("UPDATE wand_tasks SET parent_task_id = NULL WHERE parent_task_id = ?").run(id);
1645
+ if (card.workspaceTaskId) {
1646
+ this.db.prepare("UPDATE command_sessions SET workspace_task_id = NULL WHERE workspace_task_id = ?")
1647
+ .run(card.workspaceTaskId);
1648
+ this.db.prepare("DELETE FROM workspace_tasks WHERE id = ?").run(card.workspaceTaskId);
1649
+ }
1650
+ this.db.prepare("DELETE FROM wand_tasks WHERE id = ?").run(id);
1651
+ });
1652
+ }
1653
+ /** One-time id-based reconciliation; normal reads never create tasks or repair membership. */
1654
+ migrateTaskRecords() {
1655
+ if (this.getPreference("pref:taskRecordsVersion", 0) === 1)
1656
+ return;
1657
+ this.transaction(() => {
1658
+ for (const workspace of this.listWorkspaces()) {
1659
+ for (const task of this.listWorkspaceTasks(workspace.id)) {
1660
+ if (this.getWandTaskByWorkspaceTaskId(task.id))
1661
+ continue;
1662
+ this.insertWandTask({
1663
+ workspaceId: workspace.kind === "global" || workspace.id === GLOBAL_WORKSPACE_ID ? null : workspace.id,
1664
+ workspaceTaskId: task.id, title: task.name,
1665
+ titleSource: isUnnamedWorkspaceTaskName(task.name) ? "auto" : "user",
1666
+ status: task.status === "done" ? "done" : "todo", milestoneId: task.milestoneId,
1667
+ }, task.createdAt);
1668
+ }
1669
+ }
1670
+ for (const card of this.listWandTasks()) {
1671
+ let container = card.workspaceTaskId ? this.getWorkspaceTask(card.workspaceTaskId) : null;
1672
+ if (!container && card.status !== "archived") {
1673
+ const workspace = card.workspaceId ? this.getWorkspace(card.workspaceId) : this.ensureGlobalWorkspace();
1674
+ if (!workspace)
1675
+ continue;
1676
+ const id = this.insertWorkspaceTask({
1677
+ workspaceId: workspace.id, name: card.title, milestoneId: card.milestoneId,
1678
+ status: card.status === "done" ? "done" : "active", createdAt: card.createdAt,
1679
+ });
1680
+ this.writeWandTask(card, { workspaceTaskId: id });
1681
+ container = this.getWorkspaceTask(id);
1682
+ }
1683
+ if (!container || this.getWandTaskByWorkspaceTaskId(container.id)?.id !== card.id)
1684
+ continue;
1685
+ this.db.prepare("UPDATE workspace_tasks SET workspace_id = ? WHERE id = ?")
1686
+ .run(container.workspaceId, container.id);
1687
+ const previouslyBound = new Set(this.legacyWandTaskSessionIds(card.id));
1688
+ for (const sessionId of previouslyBound) {
1689
+ const session = this.getSessionWorkspace(sessionId);
1690
+ if (!session || (session.workspaceTaskId && session.workspaceTaskId !== container.id))
1691
+ continue;
1692
+ this.db.prepare("UPDATE command_sessions SET workspace_task_id = ?, workspace_id = ? WHERE id = ?")
1693
+ .run(container.id, container.workspaceId, sessionId);
1694
+ }
1695
+ this.db.prepare("UPDATE command_sessions SET workspace_id = ? WHERE workspace_task_id = ?")
1696
+ .run(container.workspaceId, container.id);
1697
+ const sessions = this.listSessionsByWorkspaceTaskSlim(container.id);
1698
+ for (const session of sessions) {
1699
+ this.db.prepare("INSERT OR IGNORE INTO wand_task_sessions (task_id, session_id, bound_at) VALUES (?, ?, ?)")
1700
+ .run(card.id, session.id, nowIso());
1701
+ }
1702
+ const agentSession = sessions.find((session) => isSessionProvider(session.provider));
1703
+ const newlyAssigned = sessions.some((session) => !previouslyBound.has(session.id));
1704
+ if (agentSession && (!card.agent || newlyAssigned)) {
1705
+ this.recordTaskSession(agentSession, newlyAssigned);
1706
+ }
1707
+ else if (newlyAssigned && sessions[0]) {
1708
+ this.recordTaskSession(sessions[0]);
1709
+ }
1710
+ }
1711
+ this.setPreference("pref:taskRecordsVersion", 1);
1712
+ });
1507
1713
  }
1508
1714
  // ── 里程碑(迭代,归属工作区;任务只存 milestoneId)──
1509
1715
  /**
@@ -1664,28 +1870,109 @@ export class WandStorage {
1664
1870
  return Number(result.changes) || 0;
1665
1871
  }
1666
1872
  // ============ 会话 → 任务 / 迭代 ============
1667
- /** 会话绑定的看板任务 id;没绑或已删除时返回 null。 */
1873
+ /** Live membership has a single owner: command_sessions.workspace_task_id. */
1668
1874
  getWandTaskIdForSession(sessionId) {
1669
- const row = this.db.prepare("SELECT task_id FROM wand_task_sessions WHERE session_id = ? ORDER BY rowid DESC LIMIT 1").get(sessionId);
1670
- return row?.task_id || null;
1875
+ const binding = this.getSessionWorkspace(sessionId);
1876
+ if (binding?.workspaceTaskId)
1877
+ return this.getWandTaskByWorkspaceTaskId(binding.workspaceTaskId)?.id ?? null;
1878
+ const row = this.db.prepare(`SELECT links.task_id FROM wand_task_sessions links
1879
+ JOIN wand_tasks card ON card.id = links.task_id
1880
+ WHERE links.session_id = ? AND card.workspace_task_id IS NULL AND card.status = 'archived'
1881
+ ORDER BY links.rowid DESC LIMIT 1`).get(sessionId);
1882
+ return row?.task_id ?? null;
1883
+ }
1884
+ legacyWandTaskSessionIds(taskId) {
1885
+ const rows = this.db.prepare(`SELECT session_id FROM wand_task_sessions
1886
+ WHERE task_id = ? ORDER BY rowid ASC, session_id ASC`).all(taskId);
1887
+ return rows.map((row) => row.session_id);
1671
1888
  }
1672
1889
  listWandTaskSessionIds(taskId) {
1673
- const rows = this.db.prepare("SELECT session_id FROM wand_task_sessions WHERE task_id = ? ORDER BY rowid ASC, session_id ASC").all(taskId);
1674
- return rows.map((row) => row.session_id);
1890
+ const task = this.getWandTask(taskId);
1891
+ if (!task)
1892
+ return [];
1893
+ if (!task.workspaceTaskId)
1894
+ return task.status === "archived" ? this.legacyWandTaskSessionIds(taskId) : [];
1895
+ // Binding timestamps are ordering metadata only; stale membership cannot leak into this projection.
1896
+ const rows = this.db.prepare(`SELECT s.id FROM command_sessions s
1897
+ LEFT JOIN wand_task_sessions links ON links.session_id = s.id AND links.task_id = ?
1898
+ WHERE s.workspace_task_id = ? ORDER BY COALESCE(links.bound_at, s.started_at), s.rowid`)
1899
+ .all(task.id, task.workspaceTaskId);
1900
+ return rows.map((row) => row.id);
1675
1901
  }
1676
1902
  listBoundWandTaskSessionIds() {
1677
- const rows = this.db.prepare("SELECT session_id FROM wand_task_sessions").all();
1678
- return rows.map((row) => row.session_id);
1903
+ const rows = this.db.prepare(`SELECT s.id FROM command_sessions s
1904
+ WHERE EXISTS (SELECT 1 FROM wand_tasks card WHERE card.workspace_task_id = s.workspace_task_id)
1905
+ ORDER BY s.rowid`).all();
1906
+ return rows.map((row) => row.id);
1679
1907
  }
1680
1908
  bindWandTaskSession(taskId, sessionId) {
1681
- if (!this.getWandTask(taskId))
1909
+ const task = this.getWandTask(taskId);
1910
+ if (!task?.workspaceTaskId)
1682
1911
  throw new Error("未找到该任务。");
1683
- if (!this.getSession(sessionId))
1684
- throw new Error("未找到该会话。");
1685
- this.db.prepare("DELETE FROM wand_task_sessions WHERE session_id = ? AND task_id != ?").run(sessionId, taskId);
1686
- this.db.prepare("INSERT OR IGNORE INTO wand_task_sessions (task_id, session_id, bound_at) VALUES (?, ?, ?)").run(taskId, sessionId, nowIso());
1912
+ this.moveSessionToWorkspaceTask(sessionId, task.workspaceTaskId);
1913
+ }
1914
+ unbindWandTaskSession(taskId, sessionId) {
1915
+ if (this.getWandTaskIdForSession(sessionId) === taskId)
1916
+ this.moveSessionToWorkspaceTask(sessionId, null);
1917
+ else
1918
+ this.db.prepare("DELETE FROM wand_task_sessions WHERE task_id = ? AND session_id = ?").run(taskId, sessionId);
1919
+ }
1920
+ /** Atomic exclusive ownership change; cwd, output and execution are untouched. */
1921
+ moveSessionToWorkspaceTask(sessionId, taskId) {
1922
+ this.transaction(() => {
1923
+ const session = this.getSessionWorkspace(sessionId);
1924
+ if (!session)
1925
+ throw new Error("未找到该会话。");
1926
+ const target = taskId ? this.getWorkspaceTask(taskId) : null;
1927
+ if (taskId && !target)
1928
+ throw new Error("未找到目标任务。");
1929
+ if (target && !this.getWorkspace(target.workspaceId))
1930
+ throw new Error("目标工作区不存在。");
1931
+ const source = session.workspaceTaskId ? this.getWorkspaceTask(session.workspaceTaskId) : null;
1932
+ if (source && source.id !== target?.id && source.layout) {
1933
+ const windows = source.layout.windows.map((window) => {
1934
+ const layout = withoutSessionTab(window.layout, sessionId);
1935
+ return { ...window, layout, activeTabId: firstLayoutTabId(layout) };
1936
+ });
1937
+ this.saveWorkspaceTaskLayout(source.id, { ...source.layout, windows });
1938
+ }
1939
+ const targetCard = target ? this.getWandTaskByWorkspaceTaskId(target.id) : null;
1940
+ if (target && !targetCard)
1941
+ throw new Error("未找到目标任务。");
1942
+ this.db.prepare("DELETE FROM wand_task_sessions WHERE session_id = ? AND (? IS NULL OR task_id != ?)")
1943
+ .run(sessionId, targetCard?.id ?? null, targetCard?.id ?? null);
1944
+ this.db.prepare("UPDATE command_sessions SET workspace_task_id = ?, workspace_id = ? WHERE id = ?")
1945
+ .run(target?.id ?? null, target?.workspaceId ?? session.workspaceId ?? null, sessionId);
1946
+ if (target) {
1947
+ this.db.prepare("INSERT OR IGNORE INTO wand_task_sessions (task_id, session_id, bound_at) VALUES (?, ?, ?)")
1948
+ .run(targetCard.id, sessionId, nowIso());
1949
+ if (source?.id !== target.id)
1950
+ this.recordTaskSession(this.getSessionSlim(sessionId));
1951
+ }
1952
+ });
1953
+ }
1954
+ /** Metadata promotion happens on session creation/move, never on reads or checkpoints. */
1955
+ recordTaskSession(session, newlyAssigned = true) {
1956
+ if (!session.workspaceTaskId)
1957
+ return;
1958
+ const card = this.getWandTaskByWorkspaceTaskId(session.workspaceTaskId);
1959
+ if (!card)
1960
+ return;
1961
+ this.db.prepare("INSERT OR IGNORE INTO wand_task_sessions (task_id, session_id, bound_at) VALUES (?, ?, ?)")
1962
+ .run(card.id, session.id, nowIso());
1963
+ const provider = session.provider;
1964
+ const agent = isSessionProvider(provider) ? {
1965
+ provider,
1966
+ model: session.selectedModel || session.structuredState?.model || "default",
1967
+ thinkingEffort: isThinkingEffort(session.thinkingEffort) ? session.thinkingEffort : "off",
1968
+ mode: normalizeWandTaskAgentMode(provider, session.mode),
1969
+ kind: session.sessionKind === "structured" ? "structured" : "pty",
1970
+ } : null;
1971
+ this.db.prepare(`UPDATE wand_tasks SET agent_json = COALESCE(agent_json, ?),
1972
+ status = CASE WHEN status = 'todo' AND ? THEN 'doing' ELSE status END,
1973
+ updated_at = ? WHERE id = ?`)
1974
+ .run(agent ? JSON.stringify(agent) : null, newlyAssigned ? 1 : 0, nowIso(), card.id);
1687
1975
  }
1688
- unbindWandTaskSession(taskId, sessionId) { this.db.prepare("DELETE FROM wand_task_sessions WHERE task_id = ? AND session_id = ?").run(taskId, sessionId); }
1689
1976
  listGithubIssueBindings(owner, repo, issueNumber) {
1690
1977
  const rows = this.db.prepare(`SELECT session_id, bound_at FROM github_issue_bindings
1691
1978
  WHERE owner = ? AND repo = ? AND issue_number = ? ORDER BY bound_at ASC`).all(owner, repo, issueNumber);
@@ -1725,7 +2012,7 @@ export class WandStorage {
1725
2012
  return rows.map((row) => this.mapSessionRow(row));
1726
2013
  }
1727
2014
  setSessionWorkspaceTaskId(sessionId, taskId) {
1728
- this.db.prepare("UPDATE command_sessions SET workspace_task_id = ? WHERE id = ?").run(taskId, sessionId);
2015
+ this.moveSessionToWorkspaceTask(sessionId, taskId);
1729
2016
  }
1730
2017
  /** Get password from database */
1731
2018
  getPassword() {
@@ -2014,8 +2301,9 @@ export class WandStorage {
2014
2301
  * 一次查询建整张表,供 /api/tasks 每请求一次构建;不逐会话查询。
2015
2302
  */
2016
2303
  listAiTeamRunChatMarkers() {
2017
- const rows = this.db.prepare(`SELECT id, team_json, chat_session_id FROM ai_team_runs
2018
- WHERE chat_session_id IS NOT NULL AND chat_session_id <> '' ORDER BY created_at DESC`).all();
2304
+ const rows = this.db.prepare(`SELECT r.id, r.team_json, r.chat_session_id, t.name AS current_team_name
2305
+ FROM ai_team_runs r LEFT JOIN ai_teams t ON t.id = r.team_id
2306
+ WHERE r.chat_session_id IS NOT NULL AND r.chat_session_id <> '' ORDER BY r.created_at DESC`).all();
2019
2307
  const markers = new Map();
2020
2308
  for (const row of rows) {
2021
2309
  const chatSessionId = typeof row.chat_session_id === "string" ? row.chat_session_id : "";
@@ -2024,12 +2312,59 @@ export class WandStorage {
2024
2312
  const rawTeam = safeJsonParse(typeof row.team_json === "string" ? row.team_json : null);
2025
2313
  markers.set(chatSessionId, {
2026
2314
  runId: String(row.id),
2027
- teamName: String(rawTeam?.name ?? ""),
2315
+ teamName: typeof row.current_team_name === "string" ? row.current_team_name : String(rawTeam?.name ?? ""),
2028
2316
  memberCount: Array.isArray(rawTeam?.members) ? rawTeam.members.length : 0,
2029
2317
  });
2030
2318
  }
2031
2319
  return markers;
2032
2320
  }
2321
+ /**
2322
+ * 会话列表用的成员步骤标记:session_id → 这一步的身份。
2323
+ * 和 `listAiTeamRunChatMarkers()` 一样一次查询建整张表,供 /api/tasks 每请求一次构建。
2324
+ * 同一个会话被多个步骤复用时取 seq 最大的那一步(最近一次派发)。
2325
+ */
2326
+ listAiTeamStepSessionMarkers() {
2327
+ const rows = this.db.prepare(`SELECT s.id, s.run_id, s.kind, s.seq, s.member_id, s.title, s.status, s.session_id,
2328
+ r.status AS run_status, r.team_json, t.name AS current_team_name, t.members_json AS current_members_json
2329
+ FROM ai_team_steps s
2330
+ JOIN ai_team_runs r ON r.id = s.run_id
2331
+ LEFT JOIN ai_teams t ON t.id = r.team_id
2332
+ WHERE s.session_id IS NOT NULL AND s.session_id <> ''
2333
+ ORDER BY s.seq DESC`).all();
2334
+ const markers = new Map();
2335
+ const currentTeams = new Map();
2336
+ for (const row of rows) {
2337
+ const sessionId = typeof row.session_id === "string" ? row.session_id : "";
2338
+ if (!sessionId || markers.has(sessionId))
2339
+ continue;
2340
+ const runTeam = safeJsonParse(typeof row.team_json === "string" ? row.team_json : null);
2341
+ const runId = String(row.run_id);
2342
+ const memberId = String(row.member_id ?? "");
2343
+ if (!currentTeams.has(runId)) {
2344
+ // ai_teams.members_json 存的是裸成员数组;team_json 存的是整份团队对象。
2345
+ currentTeams.set(runId, safeJsonParse(typeof row.current_members_json === "string" ? row.current_members_json : null));
2346
+ }
2347
+ const memberName = teamMemberName(currentTeams.get(runId), memberId)
2348
+ ?? teamMemberName(runTeam?.members, memberId)
2349
+ ?? memberId;
2350
+ const runStatus = String(row.run_status ?? "");
2351
+ markers.set(sessionId, {
2352
+ runId,
2353
+ stepId: String(row.id),
2354
+ kind: String(row.kind ?? "work") === "leader" ? "leader" : "work",
2355
+ title: String(row.title ?? ""),
2356
+ memberId,
2357
+ memberName,
2358
+ teamName: typeof row.current_team_name === "string" && row.current_team_name
2359
+ ? row.current_team_name
2360
+ : String(runTeam?.name ?? ""),
2361
+ stepStatus: String(row.status ?? "queued"),
2362
+ runStatus,
2363
+ runFinished: AI_TEAM_TERMINAL_RUN_STATUSES.includes(runStatus),
2364
+ });
2365
+ }
2366
+ return markers;
2367
+ }
2033
2368
  getAiTeamRun(id) {
2034
2369
  const row = this.db.prepare("SELECT * FROM ai_team_runs WHERE id = ?").get(id);
2035
2370
  return row ? mapAiTeamRunRow(row) : null;
@@ -2178,6 +2513,7 @@ export class WandStorage {
2178
2513
  this.db.prepare("UPDATE agent_activity SET read_at = ? WHERE read_at IS NULL").run(at);
2179
2514
  }
2180
2515
  saveSession(snapshot) {
2516
+ const isNew = !this.getSessionWorkspace(snapshot.id);
2181
2517
  // A single SQLite statement is already atomic. Avoid BEGIN IMMEDIATE in
2182
2518
  // this hot path so streaming checkpoints do not take an unnecessary write
2183
2519
  // lock and saveSession can also participate in a caller-owned transaction.
@@ -2188,6 +2524,14 @@ export class WandStorage {
2188
2524
  ON CONFLICT(id) DO UPDATE SET
2189
2525
  ${sessionPersistAssignments()}`)
2190
2526
  .run(...sessionPersistValues(snapshot));
2527
+ if (isNew) {
2528
+ const task = snapshot.workspaceTaskId ? this.getWorkspaceTask(snapshot.workspaceTaskId) : null;
2529
+ if (task && task.workspaceId !== snapshot.workspaceId) {
2530
+ this.db.prepare("UPDATE command_sessions SET workspace_id = ? WHERE id = ?")
2531
+ .run(task.workspaceId, snapshot.id);
2532
+ }
2533
+ this.recordTaskSession(snapshot);
2534
+ }
2191
2535
  }
2192
2536
  /** Update runtime/scalar fields without serializing or rewriting messages/output. */
2193
2537
  updateSessionRuntimeMetadata(snapshot) {
@@ -2336,6 +2680,25 @@ function normalizeAiTeamMembers(raw) {
2336
2680
  }
2337
2681
  return members;
2338
2682
  }
2683
+ /**
2684
+ * 从裸成员 JSON(`ai_teams.members_json` 是数组、`team_json.members` 是数组)里按 id 取显示名。
2685
+ * 列表只要名字投影,不做读端归一,损坏行也不该把名字整个丢掉。
2686
+ */
2687
+ function teamMemberName(members, memberId) {
2688
+ if (!Array.isArray(members))
2689
+ return undefined;
2690
+ for (const member of members) {
2691
+ if (!member || typeof member !== "object")
2692
+ continue;
2693
+ const record = member;
2694
+ if (String(record.id ?? "") !== memberId)
2695
+ continue;
2696
+ const name = typeof record.name === "string" ? record.name.trim() : "";
2697
+ if (name)
2698
+ return name;
2699
+ }
2700
+ return undefined;
2701
+ }
2339
2702
  function mapAiTeamRunRow(row) {
2340
2703
  const rawTeam = safeJsonParse(typeof row.team_json === "string" ? row.team_json : null);
2341
2704
  const team = rawTeam ? {