@yancyyu/openhermit 1.6.28 → 1.6.29

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 (96) hide show
  1. package/dist-renderer/assets/ProjectEditorOverlay-CQm6jUR1.js +52 -0
  2. package/dist-renderer/assets/{TeamGraphOverlay-Ba5njic5.js → TeamGraphOverlay-h0WDfifv.js} +1 -1
  3. package/dist-renderer/assets/{_basePickBy-BvnK-OC1.js → _basePickBy-CgG_tjgX.js} +1 -1
  4. package/dist-renderer/assets/{_baseUniq-DmFYXx9G.js → _baseUniq-DwPTU9lP.js} +1 -1
  5. package/dist-renderer/assets/{arc-DX4ZQFY4.js → arc-7nIrGRzY.js} +1 -1
  6. package/dist-renderer/assets/{architectureDiagram-VXUJARFQ-DfYr3vEN.js → architectureDiagram-VXUJARFQ-BYhA6Ev2.js} +1 -1
  7. package/dist-renderer/assets/{blockDiagram-VD42YOAC-DuXdVeWn.js → blockDiagram-VD42YOAC-BVpZUGDg.js} +1 -1
  8. package/dist-renderer/assets/{c4Diagram-YG6GDRKO-Bw2nixXe.js → c4Diagram-YG6GDRKO-DsdreMQ9.js} +1 -1
  9. package/dist-renderer/assets/channel-C0SqeFU7.js +1 -0
  10. package/dist-renderer/assets/{chunk-4BX2VUAB-DLiNGQoE.js → chunk-4BX2VUAB-CcoAs7Jd.js} +1 -1
  11. package/dist-renderer/assets/{chunk-55IACEB6-B1L_8VIF.js → chunk-55IACEB6-CGGAOoXd.js} +1 -1
  12. package/dist-renderer/assets/{chunk-B4BG7PRW-DaZMWKGk.js → chunk-B4BG7PRW-FhpTEPvD.js} +1 -1
  13. package/dist-renderer/assets/{chunk-DI55MBZ5-ku-dflJG.js → chunk-DI55MBZ5-DoYySbm1.js} +1 -1
  14. package/dist-renderer/assets/{chunk-FMBD7UC4-DV-mF1dP.js → chunk-FMBD7UC4-e9l2tGHG.js} +1 -1
  15. package/dist-renderer/assets/{chunk-QN33PNHL-ByGcDFQ0.js → chunk-QN33PNHL-DeiXVTCy.js} +1 -1
  16. package/dist-renderer/assets/{chunk-QZHKN3VN-7dv-Min8.js → chunk-QZHKN3VN-DC2UJLJM.js} +1 -1
  17. package/dist-renderer/assets/{chunk-TZMSLE5B-WdXL5fTu.js → chunk-TZMSLE5B-BHFD9eZI.js} +1 -1
  18. package/dist-renderer/assets/classDiagram-2ON5EDUG-DWew1HpM.js +1 -0
  19. package/dist-renderer/assets/classDiagram-v2-WZHVMYZB-DWew1HpM.js +1 -0
  20. package/dist-renderer/assets/clone-Dm-k63Yr.js +1 -0
  21. package/dist-renderer/assets/{cose-bilkent-S5V4N54A-CNcsvqPl.js → cose-bilkent-S5V4N54A-BdybQraU.js} +1 -1
  22. package/dist-renderer/assets/{dagre-6UL2VRFP-DBNx4qqx.js → dagre-6UL2VRFP-DdF3pwM3.js} +1 -1
  23. package/dist-renderer/assets/{diagram-PSM6KHXK-BfVlT6sT.js → diagram-PSM6KHXK-B9Ldd3nh.js} +1 -1
  24. package/dist-renderer/assets/{diagram-QEK2KX5R-HvVjs0K6.js → diagram-QEK2KX5R-XEqkrbpu.js} +1 -1
  25. package/dist-renderer/assets/{diagram-S2PKOQOG-DYb_KnWS.js → diagram-S2PKOQOG-CipwtY59.js} +1 -1
  26. package/dist-renderer/assets/{erDiagram-Q2GNP2WA-Ba-IgI5G.js → erDiagram-Q2GNP2WA-BB-2ISGo.js} +1 -1
  27. package/dist-renderer/assets/{flowDiagram-NV44I4VS-2iDN8Kpj.js → flowDiagram-NV44I4VS-B8XmJ0u2.js} +1 -1
  28. package/dist-renderer/assets/{ganttDiagram-JELNMOA3-Byjf8Fa3.js → ganttDiagram-JELNMOA3-D-8XglBb.js} +1 -1
  29. package/dist-renderer/assets/{gitGraphDiagram-V2S2FVAM-DbKvfZ_j.js → gitGraphDiagram-V2S2FVAM-DL4ChakD.js} +1 -1
  30. package/dist-renderer/assets/{graph-Enirf-f8.js → graph-BiFNoBjP.js} +1 -1
  31. package/dist-renderer/assets/{index-AjxP_rE_.js → index-6m1ZAymG.js} +1 -1
  32. package/dist-renderer/assets/index-BhellmRb.css +1 -0
  33. package/dist-renderer/assets/{index-DY1zqsb6.js → index-BowUl0Jb.js} +540 -536
  34. package/dist-renderer/assets/{index-CtlzGepK.js → index-Dp3kJTEe.js} +1 -1
  35. package/dist-renderer/assets/{index-COZPUWJW.js → index-TOpt_T7A.js} +1 -1
  36. package/dist-renderer/assets/{index-DdhqolqE.js → index-qNBNjW4K.js} +1 -1
  37. package/dist-renderer/assets/{index-ChR1D6ZF.js → index-vAykq1H1.js} +1 -1
  38. package/dist-renderer/assets/{infoDiagram-HS3SLOUP-D6uicwz1.js → infoDiagram-HS3SLOUP-DRIBfHDi.js} +1 -1
  39. package/dist-renderer/assets/{journeyDiagram-XKPGCS4Q-DqwZsXlQ.js → journeyDiagram-XKPGCS4Q-BOMiigU4.js} +1 -1
  40. package/dist-renderer/assets/{kanban-definition-3W4ZIXB7-fCDVhVUm.js → kanban-definition-3W4ZIXB7-DDxeyjod.js} +1 -1
  41. package/dist-renderer/assets/{layout-CPFgj98r.js → layout-DNANbrI4.js} +1 -1
  42. package/dist-renderer/assets/{linear-CYiQ7Y3M.js → linear-DxEJi1yT.js} +1 -1
  43. package/dist-renderer/assets/{mindmap-definition-VGOIOE7T-D31dS2KE.js → mindmap-definition-VGOIOE7T-nBfGriW8.js} +1 -1
  44. package/dist-renderer/assets/{pieDiagram-ADFJNKIX-BOsCJfds.js → pieDiagram-ADFJNKIX-Din5j6sV.js} +1 -1
  45. package/dist-renderer/assets/{quadrantDiagram-AYHSOK5B-CYTVQCfr.js → quadrantDiagram-AYHSOK5B-DMVK2BEQ.js} +1 -1
  46. package/dist-renderer/assets/{requirementDiagram-UZGBJVZJ-CODCFpkt.js → requirementDiagram-UZGBJVZJ-6SC94Gg_.js} +1 -1
  47. package/dist-renderer/assets/{sankeyDiagram-TZEHDZUN-Z4ce9ZtZ.js → sankeyDiagram-TZEHDZUN-CD2gghhu.js} +1 -1
  48. package/dist-renderer/assets/{sequenceDiagram-WL72ISMW-CmS9TxhW.js → sequenceDiagram-WL72ISMW-BnhkN7nZ.js} +1 -1
  49. package/dist-renderer/assets/{stateDiagram-FKZM4ZOC-o9k-ns3q.js → stateDiagram-FKZM4ZOC-Bn8XdYX-.js} +1 -1
  50. package/dist-renderer/assets/{stateDiagram-v2-4FDKWEC3-CxHMyEt1.js → stateDiagram-v2-4FDKWEC3-1b6sI1_g.js} +1 -1
  51. package/dist-renderer/assets/{timeline-definition-IT6M3QCI-B6T3zrde.js → timeline-definition-IT6M3QCI-CNs3RPoa.js} +1 -1
  52. package/dist-renderer/assets/treemap-GDKQZRPO-DU_yr827.js +162 -0
  53. package/dist-renderer/assets/{xychartDiagram-PRI3JC2R-CleBrdqc.js → xychartDiagram-PRI3JC2R-B8o5J2f3.js} +1 -1
  54. package/dist-renderer/index.html +2 -2
  55. package/package.json +1 -1
  56. package/src/main/server.ts +699 -179
  57. package/src/main/services/session-intelligence/UsageTelemetryService.ts +33 -18
  58. package/src/main/services/teams-mvp/CollaborationBoardService.ts +310 -0
  59. package/src/main/services/teams-mvp/TaskDispatchService.ts +880 -95
  60. package/src/main/services/teams-mvp/TeamProvisioningService.ts +58 -19
  61. package/src/main/services/teams-mvp/TeamWorkspaceService.ts +25 -2
  62. package/src/main/services/teams-mvp/index.ts +3 -0
  63. package/src/renderer/App.tsx +5 -0
  64. package/src/renderer/api/httpClient.ts +67 -0
  65. package/src/renderer/components/layout/PaneContent.tsx +2 -0
  66. package/src/renderer/components/layout/SortableTab.tsx +1 -0
  67. package/src/renderer/components/layout/TabBarActions.tsx +12 -12
  68. package/src/renderer/components/schedules/SchedulesView.tsx +54 -22
  69. package/src/renderer/components/settings/sections/AdvancedSection.tsx +1 -1
  70. package/src/renderer/components/settings/sections/TaskBusSection.tsx +129 -79
  71. package/src/renderer/components/tasks/TasksView.tsx +343 -0
  72. package/src/renderer/components/team/TeamDetailView.tsx +20 -98
  73. package/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +1 -1
  74. package/src/renderer/components/team/editor/EditorContextMenu.tsx +8 -23
  75. package/src/renderer/components/team/editor/EditorFileTree.tsx +0 -4
  76. package/src/renderer/components/team/editor/EditorSelectionMenu.tsx +1 -8
  77. package/src/renderer/components/team/editor/ProjectEditorOverlay.tsx +0 -10
  78. package/src/renderer/components/team/kanban/KanbanBoard.tsx +5 -1
  79. package/src/renderer/components/team/members/MemberDetailDialog.tsx +8 -33
  80. package/src/renderer/components/team/messages/MessageComposer.tsx +39 -3
  81. package/src/renderer/components/team/messages/MessagesPanel.tsx +72 -2
  82. package/src/renderer/components/team/messages/StatusBlock.tsx +2 -24
  83. package/src/renderer/components/team/schedule/ScheduleEmptyState.tsx +1 -1
  84. package/src/renderer/components/ui/MentionableTextarea.tsx +0 -1
  85. package/src/renderer/store/slices/scheduleSlice.ts +21 -0
  86. package/src/renderer/store/slices/teamSlice.ts +59 -23
  87. package/src/renderer/types/tabs.ts +1 -0
  88. package/src/shared/types/api.ts +29 -0
  89. package/src/shared/types/team.ts +104 -1
  90. package/dist-renderer/assets/ProjectEditorOverlay-A4DZTvSy.js +0 -57
  91. package/dist-renderer/assets/channel-Pre42N5O.js +0 -1
  92. package/dist-renderer/assets/classDiagram-2ON5EDUG-CdJsTJsj.js +0 -1
  93. package/dist-renderer/assets/classDiagram-v2-WZHVMYZB-CdJsTJsj.js +0 -1
  94. package/dist-renderer/assets/clone-BjQBiNfj.js +0 -1
  95. package/dist-renderer/assets/index-BIOJremZ.css +0 -1
  96. package/dist-renderer/assets/treemap-GDKQZRPO-CVd5GNDw.js +0 -162
@@ -17,6 +17,7 @@ const KEY_WORK_SECONDS = (slug: string) => `hermit:usage:${slug}:workSeconds`;
17
17
  const KEY_PROJECTS = (slug: string) => `hermit:usage:${slug}:projects`;
18
18
 
19
19
  let scanInterval: ReturnType<typeof setInterval> | null = null;
20
+ let lastLocalScan: TelemetryStatusResult | null = null;
20
21
 
21
22
  function redisConfig(cfg: TaskBusConfig) {
22
23
  return {
@@ -103,12 +104,19 @@ async function uploadMetrics(client: Redis, slug: string, result: ParseResult):
103
104
  async function doScan(cfg: TaskBusConfig): Promise<ParseResult | null> {
104
105
  if (!cfg.telemetry?.enabled) return null;
105
106
 
107
+ const result = await scanSessions();
108
+ lastLocalScan = statusFromParseResult(result, false);
109
+
110
+ if (!cfg.telemetry.uploadEnabled) {
111
+ return result;
112
+ }
113
+
106
114
  const client = await getRedis(cfg);
107
- if (!client) return null;
115
+ if (!client) return result;
108
116
 
109
117
  try {
110
- const result = await scanSessions();
111
118
  await uploadMetrics(client, 'global', result);
119
+ lastLocalScan = statusFromParseResult(result, true);
112
120
  return result;
113
121
  } finally {
114
122
  try {
@@ -171,15 +179,35 @@ interface TelemetryStatusResult {
171
179
  workSecondsByDay: Record<string, number>;
172
180
  }
173
181
 
182
+ function statusFromParseResult(result: ParseResult, connected: boolean): TelemetryStatusResult {
183
+ const { aggregate } = result;
184
+ return {
185
+ connected,
186
+ lastScan: new Date().toISOString(),
187
+ sessions: aggregate.sessions,
188
+ messages: aggregate.messages,
189
+ tokensIn: aggregate.tokens.input,
190
+ tokensOut: aggregate.tokens.output,
191
+ cacheRead: aggregate.tokens.cacheRead,
192
+ cacheCreation: aggregate.tokens.cacheCreation,
193
+ activeDays: aggregate.activeDays,
194
+ hourly: aggregate.hourly,
195
+ projects: aggregate.projects,
196
+ workSecondsByDay: aggregate.workSecondsByDay,
197
+ };
198
+ }
199
+
174
200
  export async function getTelemetryStatus(
175
- redisCfg: TaskBusConfig['redis']
201
+ redisCfg?: TaskBusConfig['redis']
176
202
  ): Promise<TelemetryStatusResult | null> {
203
+ if (!redisCfg) return lastLocalScan;
204
+
177
205
  let Redis: typeof import('ioredis').default;
178
206
  try {
179
207
  const mod = await import('ioredis');
180
208
  Redis = mod.default;
181
209
  } catch {
182
- return null;
210
+ return lastLocalScan;
183
211
  }
184
212
 
185
213
  const cfg = { redis: redisCfg };
@@ -188,20 +216,7 @@ export async function getTelemetryStatus(
188
216
  await client.connect();
189
217
  await client.ping();
190
218
  } catch {
191
- return {
192
- connected: false,
193
- lastScan: null,
194
- sessions: 0,
195
- messages: 0,
196
- tokensIn: 0,
197
- tokensOut: 0,
198
- cacheRead: 0,
199
- cacheCreation: 0,
200
- activeDays: 0,
201
- hourly: [],
202
- projects: [],
203
- workSecondsByDay: {},
204
- };
219
+ return lastLocalScan;
205
220
  }
206
221
 
207
222
  try {
@@ -0,0 +1,310 @@
1
+ /**
2
+ * CollaborationBoardService — canonical state store for cross-team tasks.
3
+ *
4
+ * The collaboration board is a projection of CollabTask state. All meaningful
5
+ * changes must go through transition(), which validates the previous status,
6
+ * bumps version, appends an event, persists locally, and syncs to Redis.
7
+ */
8
+
9
+ import * as fs from 'fs';
10
+ import * as os from 'os';
11
+ import * as path from 'path';
12
+
13
+ import type {
14
+ CollabTask,
15
+ CollabTaskEvent,
16
+ CollabTaskEventType,
17
+ CollabTaskStatus,
18
+ } from '@shared/types/team';
19
+ import type Redis from 'ioredis';
20
+
21
+ const HERMIT_HOME = process.env.HERMIT_HOME || path.join(os.homedir(), '.hermit');
22
+ const COLLAB_BOARD_FILE = path.join(HERMIT_HOME, 'collab-board.json');
23
+ const COLLAB_EVENTS_FILE = path.join(HERMIT_HOME, 'collab-events.jsonl');
24
+
25
+ interface TransitionInput {
26
+ dispatchId: string;
27
+ expected: CollabTaskStatus | CollabTaskStatus[];
28
+ next: CollabTaskStatus;
29
+ actor: CollabTaskEvent['actor'];
30
+ eventType: CollabTaskEventType;
31
+ payload?: Record<string, unknown>;
32
+ extra?: Partial<CollabTask>;
33
+ }
34
+
35
+ function nowIso(): string {
36
+ return new Date().toISOString();
37
+ }
38
+
39
+ function normalizeTask(task: CollabTask): CollabTask {
40
+ const now = nowIso();
41
+ return {
42
+ ...task,
43
+ version: task.version ?? 1,
44
+ revisionCount: task.revisionCount ?? 0,
45
+ createdAt: task.createdAt || now,
46
+ updatedAt: task.updatedAt || now,
47
+ };
48
+ }
49
+
50
+ function eventTypeForStatus(status: CollabTaskStatus): CollabTaskEventType {
51
+ switch (status) {
52
+ case 'pending_accept':
53
+ return 'task_sent';
54
+ case 'accepted':
55
+ return 'task_accepted';
56
+ case 'delivered':
57
+ return 'task_delivered';
58
+ case 'approved':
59
+ return 'task_approved';
60
+ case 'revision':
61
+ return 'revision_requested';
62
+ case 'rejected':
63
+ return 'task_rejected';
64
+ case 'failed':
65
+ return 'task_failed';
66
+ default:
67
+ return 'task_failed';
68
+ }
69
+ }
70
+
71
+ export class CollaborationBoardService {
72
+ private tasks: Map<string, CollabTask> = new Map();
73
+ private redis: Redis | null = null;
74
+ private loaded = false;
75
+
76
+ constructor() {
77
+ this.loadFromDisk();
78
+ }
79
+
80
+ setRedis(redis: Redis | null): void {
81
+ this.redis = redis;
82
+ if (redis) {
83
+ this.syncFromRedis().catch(() => {});
84
+ }
85
+ }
86
+
87
+ getBoard(): CollabTask[] {
88
+ return Array.from(this.tasks.values()).sort(
89
+ (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
90
+ );
91
+ }
92
+
93
+ getTask(dispatchId: string): CollabTask | undefined {
94
+ return this.tasks.get(dispatchId);
95
+ }
96
+
97
+ getEvents(dispatchId: string): CollabTaskEvent[] {
98
+ try {
99
+ if (!fs.existsSync(COLLAB_EVENTS_FILE)) return [];
100
+ return fs
101
+ .readFileSync(COLLAB_EVENTS_FILE, 'utf-8')
102
+ .split(/\r?\n/)
103
+ .filter(Boolean)
104
+ .map((line) => JSON.parse(line) as CollabTaskEvent)
105
+ .filter((event) => event.dispatchId === dispatchId);
106
+ } catch {
107
+ return [];
108
+ }
109
+ }
110
+
111
+ addTask(task: CollabTask): CollabTask {
112
+ const existing = this.tasks.get(task.dispatchId);
113
+ if (existing) return existing;
114
+
115
+ const normalized = normalizeTask(task);
116
+ this.tasks.set(normalized.dispatchId, normalized);
117
+ this.appendEvent({
118
+ eventId: crypto.randomUUID(),
119
+ dispatchId: normalized.dispatchId,
120
+ version: normalized.version ?? 1,
121
+ type: 'task_sent',
122
+ actor: { type: 'team', id: normalized.fromTeam },
123
+ payload: {
124
+ fromTeam: normalized.fromTeam,
125
+ toTeam: normalized.toTeam,
126
+ subject: normalized.subject,
127
+ },
128
+ createdAt: nowIso(),
129
+ });
130
+ this.persistToDisk();
131
+ this.syncTaskToRedis(normalized).catch(() => {});
132
+ return normalized;
133
+ }
134
+
135
+ transition(input: TransitionInput): CollabTask {
136
+ const task = this.tasks.get(input.dispatchId);
137
+ if (!task) {
138
+ throw new Error(`Collab task not found: ${input.dispatchId}`);
139
+ }
140
+
141
+ const expected = Array.isArray(input.expected) ? input.expected : [input.expected];
142
+ if (!expected.includes(task.status)) {
143
+ throw new Error(
144
+ `Invalid collab task transition: ${task.status} -> ${input.next}; expected ${expected.join(', ')}`
145
+ );
146
+ }
147
+
148
+ const nextVersion = (task.version ?? 1) + 1;
149
+ const nextTask: CollabTask = {
150
+ ...task,
151
+ ...input.extra,
152
+ status: input.next,
153
+ version: nextVersion,
154
+ updatedAt: nowIso(),
155
+ };
156
+
157
+ this.tasks.set(input.dispatchId, nextTask);
158
+ this.appendEvent({
159
+ eventId: crypto.randomUUID(),
160
+ dispatchId: input.dispatchId,
161
+ version: nextVersion,
162
+ type: input.eventType,
163
+ actor: input.actor,
164
+ payload: input.payload,
165
+ createdAt: nowIso(),
166
+ });
167
+ this.persistToDisk();
168
+ this.syncTaskToRedis(nextTask).catch(() => {});
169
+ return nextTask;
170
+ }
171
+
172
+ /**
173
+ * Compatibility method for older call sites. New code should prefer transition().
174
+ */
175
+ updateStatus(
176
+ dispatchId: string,
177
+ status: CollabTaskStatus,
178
+ extra?: Partial<CollabTask>
179
+ ): CollabTask | undefined {
180
+ const current = this.tasks.get(dispatchId);
181
+ if (!current) return undefined;
182
+ return this.transition({
183
+ dispatchId,
184
+ expected: current.status,
185
+ next: status,
186
+ actor: { type: 'system', id: 'legacy-updateStatus' },
187
+ eventType: eventTypeForStatus(status),
188
+ payload: extra as Record<string, unknown> | undefined,
189
+ extra,
190
+ });
191
+ }
192
+
193
+ private loadFromDisk(): void {
194
+ if (this.loaded) return;
195
+ this.loaded = true;
196
+ try {
197
+ if (!fs.existsSync(COLLAB_BOARD_FILE)) return;
198
+ const raw = fs.readFileSync(COLLAB_BOARD_FILE, 'utf-8');
199
+ const arr = JSON.parse(raw) as CollabTask[];
200
+ for (const task of arr) {
201
+ const normalized = normalizeTask(task);
202
+ this.tasks.set(normalized.dispatchId, normalized);
203
+ }
204
+ } catch {
205
+ // corrupted or missing — start empty
206
+ }
207
+ }
208
+
209
+ private persistToDisk(): void {
210
+ try {
211
+ const dir = path.dirname(COLLAB_BOARD_FILE);
212
+ if (!fs.existsSync(dir)) {
213
+ fs.mkdirSync(dir, { recursive: true });
214
+ }
215
+ fs.writeFileSync(COLLAB_BOARD_FILE, JSON.stringify(this.getBoard(), null, 2), 'utf-8');
216
+ } catch {
217
+ // best-effort
218
+ }
219
+ }
220
+
221
+ private appendEvent(event: CollabTaskEvent): void {
222
+ try {
223
+ const dir = path.dirname(COLLAB_EVENTS_FILE);
224
+ if (!fs.existsSync(dir)) {
225
+ fs.mkdirSync(dir, { recursive: true });
226
+ }
227
+ fs.appendFileSync(COLLAB_EVENTS_FILE, `${JSON.stringify(event)}\n`, 'utf-8');
228
+ } catch {
229
+ // best-effort
230
+ }
231
+ }
232
+
233
+ private async syncTaskToRedis(task: CollabTask): Promise<void> {
234
+ if (!this.redis) return;
235
+ try {
236
+ const score = new Date(task.updatedAt).getTime();
237
+ await this.redis.zadd('collab:board', score, task.dispatchId);
238
+ await this.redis.hset(`collab:task:${task.dispatchId}`, {
239
+ id: task.id,
240
+ dispatchId: task.dispatchId,
241
+ subject: task.subject,
242
+ description: task.description ?? '',
243
+ fromTeam: task.fromTeam,
244
+ fromTeamDisplay: task.fromTeamDisplay,
245
+ toTeam: task.toTeam,
246
+ toTeamDisplay: task.toTeamDisplay,
247
+ status: task.status,
248
+ version: String(task.version ?? 1),
249
+ result: task.result ?? '',
250
+ feedback: task.feedback ?? '',
251
+ deadline: task.deadline ?? '',
252
+ needsHumanReview: String(task.needsHumanReview),
253
+ revisionCount: String(task.revisionCount),
254
+ createdAt: task.createdAt,
255
+ updatedAt: task.updatedAt,
256
+ acceptedAt: task.acceptedAt ?? '',
257
+ deliveredAt: task.deliveredAt ?? '',
258
+ approvedAt: task.approvedAt ?? '',
259
+ });
260
+ } catch {
261
+ // degraded
262
+ }
263
+ }
264
+
265
+ async syncToRedis(): Promise<void> {
266
+ if (!this.redis) return;
267
+ for (const task of this.tasks.values()) {
268
+ await this.syncTaskToRedis(task);
269
+ }
270
+ }
271
+
272
+ async syncFromRedis(): Promise<void> {
273
+ if (!this.redis) return;
274
+ try {
275
+ const ids = await this.redis.zrange('collab:board', 0, -1);
276
+ for (const id of ids) {
277
+ if (this.tasks.has(id)) continue;
278
+ const hash = await this.redis.hgetall(`collab:task:${id}`);
279
+ if (!hash || !hash.dispatchId) continue;
280
+
281
+ const task: CollabTask = normalizeTask({
282
+ id: hash.id,
283
+ dispatchId: hash.dispatchId,
284
+ subject: hash.subject,
285
+ description: hash.description || undefined,
286
+ fromTeam: hash.fromTeam,
287
+ fromTeamDisplay: hash.fromTeamDisplay,
288
+ toTeam: hash.toTeam,
289
+ toTeamDisplay: hash.toTeamDisplay,
290
+ status: hash.status as CollabTaskStatus,
291
+ version: Number(hash.version) || 1,
292
+ result: hash.result || undefined,
293
+ feedback: hash.feedback || undefined,
294
+ deadline: hash.deadline || undefined,
295
+ needsHumanReview: hash.needsHumanReview === 'true',
296
+ revisionCount: Number(hash.revisionCount) || 0,
297
+ createdAt: hash.createdAt,
298
+ updatedAt: hash.updatedAt,
299
+ acceptedAt: hash.acceptedAt || undefined,
300
+ deliveredAt: hash.deliveredAt || undefined,
301
+ approvedAt: hash.approvedAt || undefined,
302
+ });
303
+ this.tasks.set(id, task);
304
+ }
305
+ this.persistToDisk();
306
+ } catch {
307
+ // degraded
308
+ }
309
+ }
310
+ }