opencode-dashboard 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +329 -0
- package/agents/orchestrator.md +99 -0
- package/agents/pipeline-builder.md +53 -0
- package/agents/pipeline-committer.md +78 -0
- package/agents/pipeline-refactor.md +58 -0
- package/agents/pipeline-reviewer.md +68 -0
- package/bin/cli.ts +332 -0
- package/commands/dashboard-start.md +5 -0
- package/commands/dashboard-status.md +5 -0
- package/commands/dashboard-stop.md +5 -0
- package/dist/assets/index-W-qyIr7d.js +134 -0
- package/dist/assets/index-mMdK5PVd.css +1 -0
- package/dist/index.html +13 -0
- package/package.json +82 -0
- package/plugin/index.ts +1441 -0
- package/server/PLUGIN_EVENTS.md +410 -0
- package/server/index.ts +55 -0
- package/server/pid.ts +140 -0
- package/server/routes.ts +520 -0
- package/server/sse.ts +196 -0
- package/server/state.ts +936 -0
- package/shared/types.ts +402 -0
package/server/state.ts
ADDED
|
@@ -0,0 +1,936 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard Server - State Management
|
|
3
|
+
*
|
|
4
|
+
* Manages the canonical aggregated state across all connected projects.
|
|
5
|
+
* Processes plugin events into state mutations and persists to disk.
|
|
6
|
+
*
|
|
7
|
+
* State hierarchy:
|
|
8
|
+
* DashboardState
|
|
9
|
+
* └─ projects: Map<projectPath, ProjectState>
|
|
10
|
+
* └─ pipelines: Map<pipelineId, Pipeline>
|
|
11
|
+
* └─ beads: Map<beadId, BeadState>
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
15
|
+
import { dirname } from "path";
|
|
16
|
+
import type {
|
|
17
|
+
BeadRecord,
|
|
18
|
+
BeadState,
|
|
19
|
+
Pipeline,
|
|
20
|
+
ProjectState,
|
|
21
|
+
DashboardState,
|
|
22
|
+
PipelineStatus,
|
|
23
|
+
Stage,
|
|
24
|
+
ColumnConfig,
|
|
25
|
+
} from "../shared/types";
|
|
26
|
+
|
|
27
|
+
// Re-export types so existing consumers (routes.ts, tests) continue to work
|
|
28
|
+
export type { BeadRecord, BeadState, Pipeline, ProjectState, DashboardState };
|
|
29
|
+
|
|
30
|
+
// --- Serialization types (for JSON persistence) ---
|
|
31
|
+
|
|
32
|
+
interface SerializedBeadState extends Omit<BeadState, never> {}
|
|
33
|
+
|
|
34
|
+
interface SerializedPipeline {
|
|
35
|
+
id: string;
|
|
36
|
+
title: string;
|
|
37
|
+
status: PipelineStatus;
|
|
38
|
+
currentBeadId: string | null;
|
|
39
|
+
beads: [string, SerializedBeadState][];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface SerializedProjectState {
|
|
43
|
+
projectPath: string;
|
|
44
|
+
projectName: string;
|
|
45
|
+
pluginId: string;
|
|
46
|
+
lastHeartbeat: number;
|
|
47
|
+
connected: boolean;
|
|
48
|
+
pipelines: [string, SerializedPipeline][];
|
|
49
|
+
lastBeadSnapshot: BeadRecord[];
|
|
50
|
+
columns: ColumnConfig[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface SerializedDashboardState {
|
|
54
|
+
version: 1;
|
|
55
|
+
savedAt: number;
|
|
56
|
+
projects: [string, SerializedProjectState][];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// --- StateManager ---
|
|
60
|
+
|
|
61
|
+
export class StateManager {
|
|
62
|
+
private state: DashboardState;
|
|
63
|
+
private persistPath: string;
|
|
64
|
+
private persistDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
65
|
+
private persistDebounceMs: number;
|
|
66
|
+
|
|
67
|
+
/** Listeners called after every state mutation (for SSE broadcasting) */
|
|
68
|
+
private listeners: Array<(event: string, data: unknown) => void> = [];
|
|
69
|
+
|
|
70
|
+
constructor(
|
|
71
|
+
persistPath: string = new URL(".dashboard-state.json", import.meta.url)
|
|
72
|
+
.pathname,
|
|
73
|
+
persistDebounceMs: number = 500
|
|
74
|
+
) {
|
|
75
|
+
this.persistPath = persistPath;
|
|
76
|
+
this.persistDebounceMs = persistDebounceMs;
|
|
77
|
+
this.state = { projects: new Map() };
|
|
78
|
+
this.loadFromDisk();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- Public API ---
|
|
82
|
+
|
|
83
|
+
/** Subscribe to state change events (for SSE broadcasting) */
|
|
84
|
+
onEvent(listener: (event: string, data: unknown) => void): () => void {
|
|
85
|
+
this.listeners.push(listener);
|
|
86
|
+
return () => {
|
|
87
|
+
this.listeners = this.listeners.filter((l) => l !== listener);
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Get full state snapshot (for GET /api/state and SSE state:full) */
|
|
92
|
+
getState(): DashboardState {
|
|
93
|
+
return this.state;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Serialize state for JSON response */
|
|
97
|
+
toJSON(): { projects: unknown[] } {
|
|
98
|
+
const projects: unknown[] = [];
|
|
99
|
+
for (const [, project] of this.state.projects) {
|
|
100
|
+
const pipelines: unknown[] = [];
|
|
101
|
+
for (const [, pipeline] of project.pipelines) {
|
|
102
|
+
const beads: unknown[] = [];
|
|
103
|
+
for (const [, bead] of pipeline.beads) {
|
|
104
|
+
beads.push(bead);
|
|
105
|
+
}
|
|
106
|
+
pipelines.push({
|
|
107
|
+
id: pipeline.id,
|
|
108
|
+
title: pipeline.title,
|
|
109
|
+
status: pipeline.status,
|
|
110
|
+
currentBeadId: pipeline.currentBeadId,
|
|
111
|
+
beads,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
projects.push({
|
|
115
|
+
projectPath: project.projectPath,
|
|
116
|
+
projectName: project.projectName,
|
|
117
|
+
pluginId: project.pluginId,
|
|
118
|
+
lastHeartbeat: project.lastHeartbeat,
|
|
119
|
+
connected: project.connected,
|
|
120
|
+
pipelines,
|
|
121
|
+
lastBeadSnapshot: project.lastBeadSnapshot,
|
|
122
|
+
columns: project.columns,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return { projects };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- Plugin Registration ---
|
|
129
|
+
|
|
130
|
+
/** Register a new plugin, creating or re-activating a project entry */
|
|
131
|
+
registerPlugin(
|
|
132
|
+
pluginId: string,
|
|
133
|
+
projectPath: string,
|
|
134
|
+
projectName: string
|
|
135
|
+
): void {
|
|
136
|
+
let project = this.state.projects.get(projectPath);
|
|
137
|
+
if (project) {
|
|
138
|
+
// Re-connecting: update plugin info, mark connected
|
|
139
|
+
project.pluginId = pluginId;
|
|
140
|
+
project.connected = true;
|
|
141
|
+
project.lastHeartbeat = Date.now();
|
|
142
|
+
} else {
|
|
143
|
+
// New project
|
|
144
|
+
project = {
|
|
145
|
+
projectPath,
|
|
146
|
+
projectName,
|
|
147
|
+
pluginId,
|
|
148
|
+
lastHeartbeat: Date.now(),
|
|
149
|
+
connected: true,
|
|
150
|
+
pipelines: new Map(),
|
|
151
|
+
lastBeadSnapshot: [],
|
|
152
|
+
columns: [],
|
|
153
|
+
};
|
|
154
|
+
this.state.projects.set(projectPath, project);
|
|
155
|
+
}
|
|
156
|
+
this.schedulePersist();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Deregister a plugin (mark project as disconnected, but retain state) */
|
|
160
|
+
deregisterPlugin(pluginId: string): ProjectState | null {
|
|
161
|
+
for (const [, project] of this.state.projects) {
|
|
162
|
+
if (project.pluginId === pluginId) {
|
|
163
|
+
project.connected = false;
|
|
164
|
+
// No active session — mark active pipelines as idle
|
|
165
|
+
for (const [, pipeline] of project.pipelines) {
|
|
166
|
+
if (pipeline.status === "active") {
|
|
167
|
+
pipeline.status = "idle";
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
this.schedulePersist();
|
|
171
|
+
return project;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Update heartbeat for a plugin */
|
|
178
|
+
updateHeartbeat(pluginId: string): boolean {
|
|
179
|
+
for (const [, project] of this.state.projects) {
|
|
180
|
+
if (project.pluginId === pluginId) {
|
|
181
|
+
project.lastHeartbeat = Date.now();
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Find project by pluginId */
|
|
189
|
+
findProjectByPluginId(pluginId: string): ProjectState | null {
|
|
190
|
+
for (const [, project] of this.state.projects) {
|
|
191
|
+
if (project.pluginId === pluginId) {
|
|
192
|
+
return project;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// --- Event Processing ---
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Process a plugin event and update state accordingly.
|
|
202
|
+
* Returns the enriched event data that should be broadcast to SSE clients.
|
|
203
|
+
*/
|
|
204
|
+
processEvent(
|
|
205
|
+
pluginId: string,
|
|
206
|
+
event: string,
|
|
207
|
+
data: Record<string, unknown>
|
|
208
|
+
): { event: string; data: Record<string, unknown> } | null {
|
|
209
|
+
const project = this.findProjectByPluginId(pluginId);
|
|
210
|
+
if (!project) return null;
|
|
211
|
+
|
|
212
|
+
// Update heartbeat on any event
|
|
213
|
+
project.lastHeartbeat = Date.now();
|
|
214
|
+
|
|
215
|
+
const projectPath = project.projectPath;
|
|
216
|
+
|
|
217
|
+
switch (event) {
|
|
218
|
+
case "bead:discovered":
|
|
219
|
+
return this.handleBeadDiscovered(project, data);
|
|
220
|
+
|
|
221
|
+
case "bead:claimed":
|
|
222
|
+
return this.handleBeadClaimed(project, data);
|
|
223
|
+
|
|
224
|
+
case "bead:stage":
|
|
225
|
+
return this.handleBeadStage(project, data);
|
|
226
|
+
|
|
227
|
+
case "bead:done":
|
|
228
|
+
return this.handleBeadDone(project, data);
|
|
229
|
+
|
|
230
|
+
case "bead:error":
|
|
231
|
+
return this.handleBeadError(project, data);
|
|
232
|
+
|
|
233
|
+
case "bead:changed":
|
|
234
|
+
return this.handleBeadChanged(project, data);
|
|
235
|
+
|
|
236
|
+
case "bead:removed":
|
|
237
|
+
return this.handleBeadRemoved(project, data);
|
|
238
|
+
|
|
239
|
+
case "agent:active":
|
|
240
|
+
return this.handleAgentActive(project, data);
|
|
241
|
+
|
|
242
|
+
case "agent:idle":
|
|
243
|
+
return this.handleAgentIdle(project, data);
|
|
244
|
+
|
|
245
|
+
case "beads:refreshed":
|
|
246
|
+
return this.handleBeadsRefreshed(project, data);
|
|
247
|
+
|
|
248
|
+
case "pipeline:started":
|
|
249
|
+
return this.handlePipelineStarted(project, data);
|
|
250
|
+
|
|
251
|
+
case "pipeline:done":
|
|
252
|
+
return this.handlePipelineDone(project, data);
|
|
253
|
+
|
|
254
|
+
case "columns:update":
|
|
255
|
+
return this.handleColumnsUpdate(project, data);
|
|
256
|
+
|
|
257
|
+
default:
|
|
258
|
+
// Unknown event — pass through with projectPath enrichment
|
|
259
|
+
return {
|
|
260
|
+
event,
|
|
261
|
+
data: { ...data, projectPath },
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// --- Event Handlers ---
|
|
267
|
+
|
|
268
|
+
private handleBeadDiscovered(
|
|
269
|
+
project: ProjectState,
|
|
270
|
+
data: Record<string, unknown>
|
|
271
|
+
): { event: string; data: Record<string, unknown> } {
|
|
272
|
+
const beadRecord = data.bead as BeadRecord | undefined;
|
|
273
|
+
if (!beadRecord?.id) {
|
|
274
|
+
return {
|
|
275
|
+
event: "bead:discovered",
|
|
276
|
+
data: { ...data, projectPath: project.projectPath },
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Ensure there's a default pipeline to hold beads
|
|
281
|
+
const pipeline = this.getOrCreateDefaultPipeline(project);
|
|
282
|
+
|
|
283
|
+
// Only add if not already tracked
|
|
284
|
+
if (!pipeline.beads.has(beadRecord.id)) {
|
|
285
|
+
const stage = this.bdStatusToStage(beadRecord.status);
|
|
286
|
+
const beadState: BeadState = {
|
|
287
|
+
id: beadRecord.id,
|
|
288
|
+
title: beadRecord.title,
|
|
289
|
+
description: beadRecord.description || "",
|
|
290
|
+
priority: beadRecord.priority ?? 1,
|
|
291
|
+
issueType: beadRecord.issue_type || "task",
|
|
292
|
+
bdStatus: beadRecord.status,
|
|
293
|
+
stage,
|
|
294
|
+
stageStartedAt: Date.now(),
|
|
295
|
+
};
|
|
296
|
+
pipeline.beads.set(beadRecord.id, beadState);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Update snapshot
|
|
300
|
+
this.updateBeadInSnapshot(project, beadRecord);
|
|
301
|
+
this.schedulePersist();
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
event: "bead:discovered",
|
|
305
|
+
data: {
|
|
306
|
+
...data,
|
|
307
|
+
projectPath: project.projectPath,
|
|
308
|
+
pipelineId: pipeline.id,
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private handleBeadClaimed(
|
|
314
|
+
project: ProjectState,
|
|
315
|
+
data: Record<string, unknown>
|
|
316
|
+
): { event: string; data: Record<string, unknown> } {
|
|
317
|
+
const beadId = (data.beadId as string) || (data.bead as BeadRecord)?.id;
|
|
318
|
+
const beadRecord = data.bead as BeadRecord | undefined;
|
|
319
|
+
const stage = (data.stage as string) || "ready";
|
|
320
|
+
|
|
321
|
+
const pipeline = this.getOrCreateDefaultPipeline(project);
|
|
322
|
+
|
|
323
|
+
if (beadId) {
|
|
324
|
+
let beadState = pipeline.beads.get(beadId);
|
|
325
|
+
if (beadState) {
|
|
326
|
+
beadState.bdStatus = "in_progress";
|
|
327
|
+
beadState.stage = stage;
|
|
328
|
+
beadState.stageStartedAt = Date.now();
|
|
329
|
+
beadState.claimedAt = Date.now();
|
|
330
|
+
} else if (beadRecord) {
|
|
331
|
+
// Discovered + claimed in same cycle — create it
|
|
332
|
+
beadState = {
|
|
333
|
+
id: beadRecord.id,
|
|
334
|
+
title: beadRecord.title,
|
|
335
|
+
description: beadRecord.description || "",
|
|
336
|
+
priority: beadRecord.priority ?? 1,
|
|
337
|
+
issueType: beadRecord.issue_type || "task",
|
|
338
|
+
bdStatus: "in_progress",
|
|
339
|
+
stage,
|
|
340
|
+
stageStartedAt: Date.now(),
|
|
341
|
+
claimedAt: Date.now(),
|
|
342
|
+
};
|
|
343
|
+
pipeline.beads.set(beadId, beadState);
|
|
344
|
+
}
|
|
345
|
+
pipeline.currentBeadId = beadId;
|
|
346
|
+
pipeline.status = "active";
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (beadRecord) {
|
|
350
|
+
this.updateBeadInSnapshot(project, beadRecord);
|
|
351
|
+
}
|
|
352
|
+
this.schedulePersist();
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
event: "bead:claimed",
|
|
356
|
+
data: {
|
|
357
|
+
...data,
|
|
358
|
+
projectPath: project.projectPath,
|
|
359
|
+
pipelineId: pipeline.id,
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private handleBeadStage(
|
|
365
|
+
project: ProjectState,
|
|
366
|
+
data: Record<string, unknown>
|
|
367
|
+
): { event: string; data: Record<string, unknown> } {
|
|
368
|
+
const beadId = data.beadId as string;
|
|
369
|
+
const stage = data.stage as BeadState["stage"];
|
|
370
|
+
const agentSessionId = data.agentSessionId as string | undefined;
|
|
371
|
+
|
|
372
|
+
const pipeline = this.getOrCreateDefaultPipeline(project);
|
|
373
|
+
|
|
374
|
+
if (beadId && stage) {
|
|
375
|
+
const beadState = pipeline.beads.get(beadId);
|
|
376
|
+
if (beadState) {
|
|
377
|
+
beadState.stage = stage;
|
|
378
|
+
beadState.stageStartedAt = Date.now();
|
|
379
|
+
if (agentSessionId) {
|
|
380
|
+
beadState.agentSessionId = agentSessionId;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
this.schedulePersist();
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
event: "bead:stage",
|
|
388
|
+
data: {
|
|
389
|
+
...data,
|
|
390
|
+
projectPath: project.projectPath,
|
|
391
|
+
pipelineId: pipeline.id,
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private handleBeadDone(
|
|
397
|
+
project: ProjectState,
|
|
398
|
+
data: Record<string, unknown>
|
|
399
|
+
): { event: string; data: Record<string, unknown> } {
|
|
400
|
+
const beadId = (data.beadId as string) || (data.bead as BeadRecord)?.id;
|
|
401
|
+
const beadRecord = data.bead as BeadRecord | undefined;
|
|
402
|
+
|
|
403
|
+
const pipeline = this.getOrCreateDefaultPipeline(project);
|
|
404
|
+
|
|
405
|
+
if (beadId) {
|
|
406
|
+
const beadState = pipeline.beads.get(beadId);
|
|
407
|
+
if (beadState) {
|
|
408
|
+
beadState.bdStatus = "closed";
|
|
409
|
+
beadState.stage = "done";
|
|
410
|
+
beadState.stageStartedAt = Date.now();
|
|
411
|
+
beadState.completedAt = Date.now();
|
|
412
|
+
beadState.agentSessionId = undefined;
|
|
413
|
+
beadState.error = undefined;
|
|
414
|
+
}
|
|
415
|
+
if (pipeline.currentBeadId === beadId) {
|
|
416
|
+
pipeline.currentBeadId = null;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (beadRecord) {
|
|
421
|
+
this.updateBeadInSnapshot(project, beadRecord);
|
|
422
|
+
}
|
|
423
|
+
this.schedulePersist();
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
event: "bead:done",
|
|
427
|
+
data: {
|
|
428
|
+
...data,
|
|
429
|
+
projectPath: project.projectPath,
|
|
430
|
+
pipelineId: pipeline.id,
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private handleBeadError(
|
|
436
|
+
project: ProjectState,
|
|
437
|
+
data: Record<string, unknown>
|
|
438
|
+
): { event: string; data: Record<string, unknown> } {
|
|
439
|
+
const beadId = (data.beadId as string) || (data.bead as BeadRecord)?.id;
|
|
440
|
+
const beadRecord = data.bead as BeadRecord | undefined;
|
|
441
|
+
const error = data.error as string | undefined;
|
|
442
|
+
|
|
443
|
+
const pipeline = this.getOrCreateDefaultPipeline(project);
|
|
444
|
+
|
|
445
|
+
if (beadId) {
|
|
446
|
+
const beadState = pipeline.beads.get(beadId);
|
|
447
|
+
if (beadState) {
|
|
448
|
+
beadState.stage = "error";
|
|
449
|
+
beadState.stageStartedAt = Date.now();
|
|
450
|
+
beadState.error = error || "Unknown error";
|
|
451
|
+
if (beadRecord?.status) {
|
|
452
|
+
beadState.bdStatus = beadRecord.status;
|
|
453
|
+
}
|
|
454
|
+
} else if (beadRecord) {
|
|
455
|
+
// Create the bead in error state
|
|
456
|
+
const newBead: BeadState = {
|
|
457
|
+
id: beadRecord.id,
|
|
458
|
+
title: beadRecord.title,
|
|
459
|
+
description: beadRecord.description || "",
|
|
460
|
+
priority: beadRecord.priority ?? 1,
|
|
461
|
+
issueType: beadRecord.issue_type || "task",
|
|
462
|
+
bdStatus: beadRecord.status,
|
|
463
|
+
stage: "error",
|
|
464
|
+
stageStartedAt: Date.now(),
|
|
465
|
+
error: error || "Unknown error",
|
|
466
|
+
};
|
|
467
|
+
pipeline.beads.set(beadId, newBead);
|
|
468
|
+
}
|
|
469
|
+
if (pipeline.currentBeadId === beadId) {
|
|
470
|
+
pipeline.currentBeadId = null;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (beadRecord) {
|
|
475
|
+
this.updateBeadInSnapshot(project, beadRecord);
|
|
476
|
+
}
|
|
477
|
+
this.schedulePersist();
|
|
478
|
+
|
|
479
|
+
return {
|
|
480
|
+
event: "bead:error",
|
|
481
|
+
data: {
|
|
482
|
+
...data,
|
|
483
|
+
projectPath: project.projectPath,
|
|
484
|
+
pipelineId: pipeline.id,
|
|
485
|
+
},
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
private handleBeadChanged(
|
|
490
|
+
project: ProjectState,
|
|
491
|
+
data: Record<string, unknown>
|
|
492
|
+
): { event: string; data: Record<string, unknown> } {
|
|
493
|
+
const beadRecord = data.bead as BeadRecord | undefined;
|
|
494
|
+
const pipeline = this.getOrCreateDefaultPipeline(project);
|
|
495
|
+
|
|
496
|
+
if (beadRecord?.id) {
|
|
497
|
+
const beadState = pipeline.beads.get(beadRecord.id);
|
|
498
|
+
if (beadState) {
|
|
499
|
+
beadState.bdStatus = beadRecord.status;
|
|
500
|
+
// Update title/description if they changed
|
|
501
|
+
beadState.title = beadRecord.title;
|
|
502
|
+
beadState.description = beadRecord.description || "";
|
|
503
|
+
beadState.priority = beadRecord.priority ?? beadState.priority;
|
|
504
|
+
beadState.issueType = beadRecord.issue_type || beadState.issueType;
|
|
505
|
+
}
|
|
506
|
+
this.updateBeadInSnapshot(project, beadRecord);
|
|
507
|
+
}
|
|
508
|
+
this.schedulePersist();
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
event: "bead:changed",
|
|
512
|
+
data: {
|
|
513
|
+
...data,
|
|
514
|
+
projectPath: project.projectPath,
|
|
515
|
+
pipelineId: pipeline.id,
|
|
516
|
+
},
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Handle beads:refreshed — reconcile stale beads.
|
|
522
|
+
*
|
|
523
|
+
* When the payload includes a `beadIds` array, this is treated as the
|
|
524
|
+
* authoritative set of bead IDs that currently exist in bd. Any beads
|
|
525
|
+
* in the server's persisted state that are NOT in this set are removed.
|
|
526
|
+
* This fixes stale beads that linger after issues are closed/deleted in bd.
|
|
527
|
+
*
|
|
528
|
+
* Returns `removedBeadIds` in the event data so the routes layer can
|
|
529
|
+
* broadcast individual `bead:removed` events to connected frontends.
|
|
530
|
+
*/
|
|
531
|
+
private handleBeadsRefreshed(
|
|
532
|
+
project: ProjectState,
|
|
533
|
+
data: Record<string, unknown>
|
|
534
|
+
): { event: string; data: Record<string, unknown> } {
|
|
535
|
+
const removedBeadIds: string[] = [];
|
|
536
|
+
|
|
537
|
+
// If beadIds is provided, reconcile: remove any beads not in the set.
|
|
538
|
+
if (Array.isArray(data.beadIds)) {
|
|
539
|
+
const currentIds = new Set(data.beadIds as string[]);
|
|
540
|
+
|
|
541
|
+
for (const [, pipeline] of project.pipelines) {
|
|
542
|
+
for (const [beadId] of pipeline.beads) {
|
|
543
|
+
if (!currentIds.has(beadId)) {
|
|
544
|
+
pipeline.beads.delete(beadId);
|
|
545
|
+
if (pipeline.currentBeadId === beadId) {
|
|
546
|
+
pipeline.currentBeadId = null;
|
|
547
|
+
}
|
|
548
|
+
removedBeadIds.push(beadId);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (removedBeadIds.length > 0) {
|
|
554
|
+
const removedSet = new Set(removedBeadIds);
|
|
555
|
+
project.lastBeadSnapshot = project.lastBeadSnapshot.filter(
|
|
556
|
+
(b) => !removedSet.has(b.id)
|
|
557
|
+
);
|
|
558
|
+
this.schedulePersist();
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return {
|
|
563
|
+
event: "beads:refreshed",
|
|
564
|
+
data: {
|
|
565
|
+
...data,
|
|
566
|
+
projectPath: project.projectPath,
|
|
567
|
+
removedBeadIds,
|
|
568
|
+
},
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
private handleBeadRemoved(
|
|
573
|
+
project: ProjectState,
|
|
574
|
+
data: Record<string, unknown>
|
|
575
|
+
): { event: string; data: Record<string, unknown> } {
|
|
576
|
+
const beadId = data.beadId as string;
|
|
577
|
+
const pipeline = this.getOrCreateDefaultPipeline(project);
|
|
578
|
+
|
|
579
|
+
if (beadId) {
|
|
580
|
+
pipeline.beads.delete(beadId);
|
|
581
|
+
if (pipeline.currentBeadId === beadId) {
|
|
582
|
+
pipeline.currentBeadId = null;
|
|
583
|
+
}
|
|
584
|
+
// Remove from snapshot
|
|
585
|
+
project.lastBeadSnapshot = project.lastBeadSnapshot.filter(
|
|
586
|
+
(b) => b.id !== beadId
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
this.schedulePersist();
|
|
590
|
+
|
|
591
|
+
return {
|
|
592
|
+
event: "bead:removed",
|
|
593
|
+
data: {
|
|
594
|
+
...data,
|
|
595
|
+
projectPath: project.projectPath,
|
|
596
|
+
pipelineId: pipeline.id,
|
|
597
|
+
},
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
private handleAgentActive(
|
|
602
|
+
project: ProjectState,
|
|
603
|
+
data: Record<string, unknown>
|
|
604
|
+
): { event: string; data: Record<string, unknown> } {
|
|
605
|
+
const beadId = data.beadId as string;
|
|
606
|
+
const sessionId = data.sessionId as string;
|
|
607
|
+
const pipeline = this.getOrCreateDefaultPipeline(project);
|
|
608
|
+
|
|
609
|
+
if (beadId && sessionId) {
|
|
610
|
+
const beadState = pipeline.beads.get(beadId);
|
|
611
|
+
if (beadState) {
|
|
612
|
+
beadState.agentSessionId = sessionId;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
return {
|
|
617
|
+
event: "agent:active",
|
|
618
|
+
data: {
|
|
619
|
+
...data,
|
|
620
|
+
projectPath: project.projectPath,
|
|
621
|
+
pipelineId: pipeline.id,
|
|
622
|
+
},
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
private handleAgentIdle(
|
|
627
|
+
project: ProjectState,
|
|
628
|
+
data: Record<string, unknown>
|
|
629
|
+
): { event: string; data: Record<string, unknown> } {
|
|
630
|
+
const beadId = data.beadId as string;
|
|
631
|
+
const sessionId = data.sessionId as string;
|
|
632
|
+
const pipeline = this.getOrCreateDefaultPipeline(project);
|
|
633
|
+
|
|
634
|
+
if (beadId) {
|
|
635
|
+
const beadState = pipeline.beads.get(beadId);
|
|
636
|
+
if (beadState && beadState.agentSessionId === sessionId) {
|
|
637
|
+
beadState.agentSessionId = undefined;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return {
|
|
642
|
+
event: "agent:idle",
|
|
643
|
+
data: {
|
|
644
|
+
...data,
|
|
645
|
+
projectPath: project.projectPath,
|
|
646
|
+
pipelineId: pipeline.id,
|
|
647
|
+
},
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
private handlePipelineStarted(
|
|
652
|
+
project: ProjectState,
|
|
653
|
+
data: Record<string, unknown>
|
|
654
|
+
): { event: string; data: Record<string, unknown> } {
|
|
655
|
+
const pipelineId = data.pipelineId as string;
|
|
656
|
+
const title = (data.title as string) || "Pipeline";
|
|
657
|
+
|
|
658
|
+
if (pipelineId) {
|
|
659
|
+
let pipeline = project.pipelines.get(pipelineId);
|
|
660
|
+
if (!pipeline) {
|
|
661
|
+
pipeline = {
|
|
662
|
+
id: pipelineId,
|
|
663
|
+
title,
|
|
664
|
+
status: "active",
|
|
665
|
+
currentBeadId: null,
|
|
666
|
+
beads: new Map(),
|
|
667
|
+
};
|
|
668
|
+
project.pipelines.set(pipelineId, pipeline);
|
|
669
|
+
} else {
|
|
670
|
+
pipeline.title = title;
|
|
671
|
+
pipeline.status = "active";
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
this.schedulePersist();
|
|
675
|
+
|
|
676
|
+
return {
|
|
677
|
+
event: "pipeline:started",
|
|
678
|
+
data: {
|
|
679
|
+
...data,
|
|
680
|
+
projectPath: project.projectPath,
|
|
681
|
+
},
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
private handlePipelineDone(
|
|
686
|
+
project: ProjectState,
|
|
687
|
+
data: Record<string, unknown>
|
|
688
|
+
): { event: string; data: Record<string, unknown> } {
|
|
689
|
+
const pipelineId = data.pipelineId as string;
|
|
690
|
+
|
|
691
|
+
if (pipelineId) {
|
|
692
|
+
const pipeline = project.pipelines.get(pipelineId);
|
|
693
|
+
if (pipeline) {
|
|
694
|
+
pipeline.status = "done";
|
|
695
|
+
pipeline.currentBeadId = null;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
this.schedulePersist();
|
|
699
|
+
|
|
700
|
+
return {
|
|
701
|
+
event: "pipeline:done",
|
|
702
|
+
data: {
|
|
703
|
+
...data,
|
|
704
|
+
projectPath: project.projectPath,
|
|
705
|
+
},
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
private handleColumnsUpdate(
|
|
710
|
+
project: ProjectState,
|
|
711
|
+
data: Record<string, unknown>
|
|
712
|
+
): { event: string; data: Record<string, unknown> } {
|
|
713
|
+
const columns = data.columns as ColumnConfig[] | undefined;
|
|
714
|
+
|
|
715
|
+
if (Array.isArray(columns)) {
|
|
716
|
+
project.columns = columns;
|
|
717
|
+
this.schedulePersist();
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
return {
|
|
721
|
+
event: "columns:update",
|
|
722
|
+
data: {
|
|
723
|
+
...data,
|
|
724
|
+
projectPath: project.projectPath,
|
|
725
|
+
columns: project.columns,
|
|
726
|
+
},
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// --- Helpers ---
|
|
731
|
+
|
|
732
|
+
/** Get or create the default pipeline for a project */
|
|
733
|
+
private getOrCreateDefaultPipeline(project: ProjectState): Pipeline {
|
|
734
|
+
// Use the first pipeline if one exists, otherwise create "default"
|
|
735
|
+
if (project.pipelines.size > 0) {
|
|
736
|
+
return project.pipelines.values().next().value!;
|
|
737
|
+
}
|
|
738
|
+
const pipeline: Pipeline = {
|
|
739
|
+
id: "default",
|
|
740
|
+
title: "Pipeline",
|
|
741
|
+
status: "active",
|
|
742
|
+
currentBeadId: null,
|
|
743
|
+
beads: new Map(),
|
|
744
|
+
};
|
|
745
|
+
project.pipelines.set("default", pipeline);
|
|
746
|
+
return pipeline;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** Map bd status to a default stage */
|
|
750
|
+
private bdStatusToStage(bdStatus: string): BeadState["stage"] {
|
|
751
|
+
switch (bdStatus) {
|
|
752
|
+
case "in_progress":
|
|
753
|
+
return "ready"; // will be moved to agent column via bead:claimed/bead:stage
|
|
754
|
+
case "closed":
|
|
755
|
+
return "done";
|
|
756
|
+
case "blocked":
|
|
757
|
+
return "error";
|
|
758
|
+
default:
|
|
759
|
+
return "ready";
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** Update or insert a bead record in the project's snapshot */
|
|
764
|
+
private updateBeadInSnapshot(
|
|
765
|
+
project: ProjectState,
|
|
766
|
+
beadRecord: BeadRecord
|
|
767
|
+
): void {
|
|
768
|
+
const idx = project.lastBeadSnapshot.findIndex(
|
|
769
|
+
(b) => b.id === beadRecord.id
|
|
770
|
+
);
|
|
771
|
+
if (idx >= 0) {
|
|
772
|
+
project.lastBeadSnapshot[idx] = beadRecord;
|
|
773
|
+
} else {
|
|
774
|
+
project.lastBeadSnapshot.push(beadRecord);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// --- Disk Persistence ---
|
|
779
|
+
|
|
780
|
+
/** Schedule a debounced persist to disk */
|
|
781
|
+
private schedulePersist(): void {
|
|
782
|
+
if (this.persistDebounceTimer) {
|
|
783
|
+
clearTimeout(this.persistDebounceTimer);
|
|
784
|
+
}
|
|
785
|
+
this.persistDebounceTimer = setTimeout(() => {
|
|
786
|
+
this.persistToDisk();
|
|
787
|
+
this.persistDebounceTimer = null;
|
|
788
|
+
}, this.persistDebounceMs);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** Force an immediate persist (useful for shutdown) */
|
|
792
|
+
persistNow(): void {
|
|
793
|
+
if (this.persistDebounceTimer) {
|
|
794
|
+
clearTimeout(this.persistDebounceTimer);
|
|
795
|
+
this.persistDebounceTimer = null;
|
|
796
|
+
}
|
|
797
|
+
this.persistToDisk();
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** Serialize and write state to disk */
|
|
801
|
+
private persistToDisk(): void {
|
|
802
|
+
try {
|
|
803
|
+
const serialized = this.serialize();
|
|
804
|
+
const dir = dirname(this.persistPath);
|
|
805
|
+
if (!existsSync(dir)) {
|
|
806
|
+
mkdirSync(dir, { recursive: true });
|
|
807
|
+
}
|
|
808
|
+
writeFileSync(this.persistPath, JSON.stringify(serialized, null, 2));
|
|
809
|
+
} catch (err) {
|
|
810
|
+
console.error("[state] Failed to persist state:", err);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** Load state from disk (called once at construction) */
|
|
815
|
+
private loadFromDisk(): void {
|
|
816
|
+
try {
|
|
817
|
+
if (!existsSync(this.persistPath)) return;
|
|
818
|
+
const raw = readFileSync(this.persistPath, "utf-8");
|
|
819
|
+
const parsed = JSON.parse(raw) as SerializedDashboardState;
|
|
820
|
+
if (parsed.version !== 1) {
|
|
821
|
+
console.warn(
|
|
822
|
+
`[state] Unknown state version ${parsed.version}, starting fresh`
|
|
823
|
+
);
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
this.state = this.deserialize(parsed);
|
|
827
|
+
// Mark all projects as disconnected on load (plugins will re-register)
|
|
828
|
+
for (const [, project] of this.state.projects) {
|
|
829
|
+
project.connected = false;
|
|
830
|
+
// No session running after restart — mark active pipelines as idle
|
|
831
|
+
for (const [, pipeline] of project.pipelines) {
|
|
832
|
+
if (pipeline.status === "active") {
|
|
833
|
+
pipeline.status = "idle";
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
console.log(
|
|
838
|
+
`[state] Loaded state from disk: ${this.state.projects.size} project(s)`
|
|
839
|
+
);
|
|
840
|
+
} catch (err) {
|
|
841
|
+
console.error("[state] Failed to load state from disk:", err);
|
|
842
|
+
this.state = { projects: new Map() };
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** Serialize state for JSON persistence */
|
|
847
|
+
private serialize(): SerializedDashboardState {
|
|
848
|
+
const projects: [string, SerializedProjectState][] = [];
|
|
849
|
+
for (const [key, project] of this.state.projects) {
|
|
850
|
+
const pipelines: [string, SerializedPipeline][] = [];
|
|
851
|
+
for (const [pKey, pipeline] of project.pipelines) {
|
|
852
|
+
const beads: [string, SerializedBeadState][] = [];
|
|
853
|
+
for (const [bKey, bead] of pipeline.beads) {
|
|
854
|
+
beads.push([bKey, { ...bead }]);
|
|
855
|
+
}
|
|
856
|
+
pipelines.push([
|
|
857
|
+
pKey,
|
|
858
|
+
{
|
|
859
|
+
id: pipeline.id,
|
|
860
|
+
title: pipeline.title,
|
|
861
|
+
status: pipeline.status,
|
|
862
|
+
currentBeadId: pipeline.currentBeadId,
|
|
863
|
+
beads,
|
|
864
|
+
},
|
|
865
|
+
]);
|
|
866
|
+
}
|
|
867
|
+
projects.push([
|
|
868
|
+
key,
|
|
869
|
+
{
|
|
870
|
+
projectPath: project.projectPath,
|
|
871
|
+
projectName: project.projectName,
|
|
872
|
+
pluginId: project.pluginId,
|
|
873
|
+
lastHeartbeat: project.lastHeartbeat,
|
|
874
|
+
connected: project.connected,
|
|
875
|
+
pipelines,
|
|
876
|
+
lastBeadSnapshot: project.lastBeadSnapshot,
|
|
877
|
+
columns: project.columns,
|
|
878
|
+
},
|
|
879
|
+
]);
|
|
880
|
+
}
|
|
881
|
+
return {
|
|
882
|
+
version: 1,
|
|
883
|
+
savedAt: Date.now(),
|
|
884
|
+
projects,
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/** Deserialize JSON back into Map-based state */
|
|
889
|
+
private deserialize(data: SerializedDashboardState): DashboardState {
|
|
890
|
+
const projects = new Map<string, ProjectState>();
|
|
891
|
+
for (const [key, sp] of data.projects) {
|
|
892
|
+
const pipelines = new Map<string, Pipeline>();
|
|
893
|
+
for (const [pKey, sPipeline] of sp.pipelines) {
|
|
894
|
+
const beads = new Map<string, BeadState>();
|
|
895
|
+
for (const [bKey, sBead] of sPipeline.beads) {
|
|
896
|
+
beads.set(bKey, { ...sBead });
|
|
897
|
+
}
|
|
898
|
+
pipelines.set(pKey, {
|
|
899
|
+
id: sPipeline.id,
|
|
900
|
+
title: sPipeline.title,
|
|
901
|
+
status: sPipeline.status,
|
|
902
|
+
currentBeadId: sPipeline.currentBeadId,
|
|
903
|
+
beads,
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
projects.set(key, {
|
|
907
|
+
projectPath: sp.projectPath,
|
|
908
|
+
projectName: sp.projectName,
|
|
909
|
+
pluginId: sp.pluginId,
|
|
910
|
+
lastHeartbeat: sp.lastHeartbeat,
|
|
911
|
+
connected: sp.connected,
|
|
912
|
+
pipelines,
|
|
913
|
+
lastBeadSnapshot: sp.lastBeadSnapshot || [],
|
|
914
|
+
columns: sp.columns || [],
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
return { projects };
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// --- Cleanup ---
|
|
921
|
+
|
|
922
|
+
/** Destroy the state manager, flushing pending writes */
|
|
923
|
+
destroy(): void {
|
|
924
|
+
this.persistNow();
|
|
925
|
+
this.listeners = [];
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** Reset all state (for testing) */
|
|
929
|
+
clear(): void {
|
|
930
|
+
this.state = { projects: new Map() };
|
|
931
|
+
if (this.persistDebounceTimer) {
|
|
932
|
+
clearTimeout(this.persistDebounceTimer);
|
|
933
|
+
this.persistDebounceTimer = null;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|