@synergenius/flow-weaver-pack-weaver 0.9.138 → 0.9.142

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 (79) hide show
  1. package/dist/bot/assistant-tools.d.ts.map +1 -1
  2. package/dist/bot/assistant-tools.js +5 -11
  3. package/dist/bot/assistant-tools.js.map +1 -1
  4. package/dist/bot/capability-registry.d.ts.map +1 -1
  5. package/dist/bot/capability-registry.js +185 -15
  6. package/dist/bot/capability-registry.js.map +1 -1
  7. package/dist/bot/dashboard.js +3 -3
  8. package/dist/bot/dashboard.js.map +1 -1
  9. package/dist/bot/hierarchy-event-log.d.ts +37 -0
  10. package/dist/bot/hierarchy-event-log.d.ts.map +1 -0
  11. package/dist/bot/hierarchy-event-log.js +58 -0
  12. package/dist/bot/hierarchy-event-log.js.map +1 -0
  13. package/dist/bot/operations.d.ts +2 -0
  14. package/dist/bot/operations.d.ts.map +1 -1
  15. package/dist/bot/operations.js +5 -0
  16. package/dist/bot/operations.js.map +1 -1
  17. package/dist/bot/profile-store.d.ts.map +1 -1
  18. package/dist/bot/profile-store.js +39 -0
  19. package/dist/bot/profile-store.js.map +1 -1
  20. package/dist/bot/runner.d.ts.map +1 -1
  21. package/dist/bot/runner.js +7 -2
  22. package/dist/bot/runner.js.map +1 -1
  23. package/dist/bot/step-executor.d.ts.map +1 -1
  24. package/dist/bot/step-executor.js +33 -1
  25. package/dist/bot/step-executor.js.map +1 -1
  26. package/dist/bot/swarm-controller.d.ts +1 -0
  27. package/dist/bot/swarm-controller.d.ts.map +1 -1
  28. package/dist/bot/swarm-controller.js +59 -5
  29. package/dist/bot/swarm-controller.js.map +1 -1
  30. package/dist/bot/task-store.d.ts +1 -1
  31. package/dist/bot/task-store.d.ts.map +1 -1
  32. package/dist/bot/task-store.js +25 -37
  33. package/dist/bot/task-store.js.map +1 -1
  34. package/dist/bot/task-types.d.ts +5 -1
  35. package/dist/bot/task-types.d.ts.map +1 -1
  36. package/dist/node-types/bot-report.d.ts +2 -1
  37. package/dist/node-types/bot-report.d.ts.map +1 -1
  38. package/dist/node-types/bot-report.js +9 -4
  39. package/dist/node-types/bot-report.js.map +1 -1
  40. package/dist/node-types/build-context.d.ts.map +1 -1
  41. package/dist/node-types/build-context.js +32 -0
  42. package/dist/node-types/build-context.js.map +1 -1
  43. package/dist/node-types/plan-task.d.ts.map +1 -1
  44. package/dist/node-types/plan-task.js +5 -1
  45. package/dist/node-types/plan-task.js.map +1 -1
  46. package/dist/node-types/report.d.ts +1 -1
  47. package/dist/node-types/report.d.ts.map +1 -1
  48. package/dist/node-types/report.js +58 -8
  49. package/dist/node-types/report.js.map +1 -1
  50. package/dist/ui/capability-editor.js +184 -15
  51. package/dist/ui/profile-editor.js +184 -15
  52. package/dist/ui/swarm-dashboard.js +244 -44
  53. package/dist/ui/task-detail-view.js +60 -29
  54. package/dist/ui/use-stream-timeline.d.ts.map +1 -1
  55. package/dist/ui/use-stream-timeline.js +69 -29
  56. package/dist/ui/use-stream-timeline.js.map +1 -1
  57. package/dist/workflows/weaver-bot.d.ts +1 -0
  58. package/dist/workflows/weaver-bot.d.ts.map +1 -1
  59. package/dist/workflows/weaver-bot.js +239 -4
  60. package/dist/workflows/weaver-bot.js.map +1 -1
  61. package/flowweaver.manifest.json +1 -1
  62. package/package.json +1 -1
  63. package/src/bot/assistant-tools.ts +5 -11
  64. package/src/bot/capability-registry.ts +196 -18
  65. package/src/bot/dashboard.ts +3 -3
  66. package/src/bot/hierarchy-event-log.ts +64 -0
  67. package/src/bot/operations.ts +7 -0
  68. package/src/bot/profile-store.ts +39 -0
  69. package/src/bot/runner.ts +8 -4
  70. package/src/bot/step-executor.ts +29 -1
  71. package/src/bot/swarm-controller.ts +62 -5
  72. package/src/bot/task-store.ts +26 -39
  73. package/src/bot/task-types.ts +7 -1
  74. package/src/node-types/bot-report.ts +8 -3
  75. package/src/node-types/build-context.ts +32 -0
  76. package/src/node-types/plan-task.ts +5 -1
  77. package/src/node-types/report.ts +56 -8
  78. package/src/ui/use-stream-timeline.ts +73 -33
  79. package/src/workflows/weaver-bot.ts +398 -3
@@ -38,7 +38,7 @@ export class TaskStore {
38
38
  id: parentId,
39
39
  title: input.title,
40
40
  description: input.description,
41
- status: 'pending',
41
+ status: 'open',
42
42
  priority: input.priority ?? 0,
43
43
  isParent: true,
44
44
  parentId: input.parentId,
@@ -77,7 +77,7 @@ export class TaskStore {
77
77
  id: subId,
78
78
  title: sub.title,
79
79
  description: sub.description ?? '',
80
- status: 'pending',
80
+ status: 'open',
81
81
  priority: sub.priority ?? input.priority ?? 0,
82
82
  isParent: false,
83
83
  parentId: parentId,
@@ -115,7 +115,7 @@ export class TaskStore {
115
115
  id: crypto.randomUUID().slice(0, 8),
116
116
  title: input.title,
117
117
  description: input.description,
118
- status: 'pending',
118
+ status: 'open',
119
119
  priority: input.priority ?? 0,
120
120
  isParent: false,
121
121
  parentId: input.parentId,
@@ -233,8 +233,8 @@ export class TaskStore {
233
233
 
234
234
  const task = tasks[idx];
235
235
  const assignable =
236
- task.status === 'pending' ||
237
- (task.status === 'failed' && task.attempt < task.maxAttempts);
236
+ (task.status === 'open' || task.status === 'pending') &&
237
+ task.attempt < task.maxAttempts;
238
238
  if (!assignable) {
239
239
  throw new Error(`Task ${taskId} is not assignable (status: ${task.status}, attempt: ${task.attempt}/${task.maxAttempts})`);
240
240
  }
@@ -254,7 +254,7 @@ export class TaskStore {
254
254
  // Release
255
255
  // ---------------------------------------------------------------------------
256
256
 
257
- async release(taskId: string, status: 'done' | 'failed', runSummary: RunSummary): Promise<Task> {
257
+ async release(taskId: string, status: 'done' | 'failed' | 'open', runSummary: RunSummary): Promise<Task> {
258
258
  return this.mutex.runExclusive(async () => {
259
259
  const tasks = this._readAll();
260
260
  const idx = tasks.findIndex(t => t.id === taskId);
@@ -262,8 +262,11 @@ export class TaskStore {
262
262
 
263
263
  const task = tasks[idx];
264
264
 
265
- // Append run summary
265
+ // Append run summary and track runId
266
266
  task.context.runSummaries.push(runSummary);
267
+ if (runSummary.runId && !task.runs.includes(runSummary.runId)) {
268
+ task.runs.push(runSummary.runId);
269
+ }
267
270
  if (runSummary.outcome === 'failed' || runSummary.outcome === 'error') {
268
271
  task.context.lastError = runSummary.error;
269
272
  } else {
@@ -276,7 +279,8 @@ export class TaskStore {
276
279
 
277
280
  // Update execution tracking
278
281
  task.attempt += 1;
279
- task.status = status;
282
+ // Tasks don't fail — runs fail. Map 'failed' to 'open' for backward compat.
283
+ task.status = status === 'failed' ? 'open' : status;
280
284
  task.currentBotId = undefined;
281
285
  task.currentRunId = undefined;
282
286
  task.updatedAt = new Date().toISOString();
@@ -284,9 +288,7 @@ export class TaskStore {
284
288
  if (status === 'done') {
285
289
  task.completedAt = new Date().toISOString();
286
290
  }
287
- if (status === 'failed') {
288
- task.completedAt = new Date().toISOString();
289
- }
291
+ // 'open' and 'failed' → task stays open, no completedAt
290
292
 
291
293
  tasks[idx] = task;
292
294
 
@@ -305,28 +307,19 @@ export class TaskStore {
305
307
 
306
308
  private _handleDependencyEffects(tasks: Task[], changedTask: Task): void {
307
309
  if (changedTask.status === 'done') {
308
- // Unblock dependents: set blocked tasks to pending if all deps are now done
310
+ // Unblock dependents: set blocked tasks to open if all deps are now done
309
311
  const doneIds = new Set(tasks.filter(t => t.status === 'done').map(t => t.id));
310
312
  for (const t of tasks) {
311
313
  if (t.status === 'blocked' && t.dependsOn.includes(changedTask.id)) {
312
314
  if (t.dependsOn.every(depId => doneIds.has(depId))) {
313
- t.status = 'pending';
314
- t.updatedAt = new Date().toISOString();
315
- }
316
- }
317
- }
318
- } else if (changedTask.status === 'failed') {
319
- // Check if failure is terminal (no retries left)
320
- if (changedTask.attempt >= changedTask.maxAttempts) {
321
- // Block dependents
322
- for (const t of tasks) {
323
- if (t.status === 'pending' && t.dependsOn.includes(changedTask.id)) {
324
- t.status = 'blocked';
315
+ t.status = 'open';
325
316
  t.updatedAt = new Date().toISOString();
326
317
  }
327
318
  }
328
319
  }
329
320
  }
321
+ // Tasks don't fail — runs fail. Dependents stay blocked until dep
322
+ // succeeds (status=done). No terminal 'failed' state to handle.
330
323
  }
331
324
 
332
325
  private _handleParentEffects(tasks: Task[], changedTask: Task): void {
@@ -348,14 +341,8 @@ export class TaskStore {
348
341
  return;
349
342
  }
350
343
 
351
- // Any terminal failure => parent failed
352
- const hasTerminalFailure = subtasks.some(
353
- s => s.status === 'failed' && s.attempt >= s.maxAttempts,
354
- );
355
- if (hasTerminalFailure) {
356
- parent.status = 'failed';
357
- parent.updatedAt = new Date().toISOString();
358
- }
344
+ // Tasks don't fail. Parent stays open until all subtasks are done
345
+ // or user cancels. No terminal 'failed' state for parent.
359
346
  }
360
347
 
361
348
  // ---------------------------------------------------------------------------
@@ -366,7 +353,7 @@ export class TaskStore {
366
353
  return this.mutex.runExclusive(async () => {
367
354
  const tasks = this._readAll();
368
355
  const before = tasks.length;
369
- const kept = tasks.filter(t => t.status !== 'done' && t.status !== 'failed' && t.status !== 'cancelled');
356
+ const kept = tasks.filter(t => t.status !== 'done' && t.status !== 'cancelled');
370
357
  this._writeAll(kept);
371
358
  return before - kept.length;
372
359
  });
@@ -386,17 +373,17 @@ export class TaskStore {
386
373
  // ---------------------------------------------------------------------------
387
374
 
388
375
  private _findDuplicate(tasks: Task[], title: string, description: string): Task | null {
389
- // Check pending duplicates
390
- const pendingDup = tasks.find(
391
- t => t.status === 'pending' && t.title === title && t.description === description,
376
+ // Check open/pending duplicates
377
+ const openDup = tasks.find(
378
+ t => (t.status === 'open' || t.status === 'pending') && t.title === title && t.description === description,
392
379
  );
393
- if (pendingDup) return pendingDup;
380
+ if (openDup) return openDup;
394
381
 
395
- // Check recently completed/failed (within dedup window)
382
+ // Check recently completed (within dedup window)
396
383
  const now = Date.now();
397
384
  const recentDup = tasks.find(
398
385
  t =>
399
- (t.status === 'done' || t.status === 'failed') &&
386
+ t.status === 'done' &&
400
387
  t.title === title &&
401
388
  t.description === description &&
402
389
  t.completedAt !== undefined &&
@@ -22,7 +22,13 @@ export interface TaskContext {
22
22
  lastError?: string;
23
23
  }
24
24
 
25
- export type TaskStatus = 'pending' | 'in-progress' | 'blocked' | 'done' | 'failed' | 'cancelled';
25
+ /**
26
+ * Task status. Tasks NEVER go to 'failed' — runs fail, tasks stay open.
27
+ * 'open' replaces 'pending'. A task is open until done or cancelled.
28
+ */
29
+ export type TaskStatus = 'open' | 'pending' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
30
+ // NOTE: 'pending' kept for backward compatibility with existing data.
31
+ // New code should use 'open'. Both are treated as assignable.
26
32
 
27
33
  export interface Task {
28
34
  id: string;
@@ -14,6 +14,7 @@ import { TaskStore } from '../bot/task-store.js';
14
14
  * @input [mainCtx] [order:0] - Context from main path (JSON, optional)
15
15
  * @input [readCtx] [order:1] - Context from read-only path (JSON, optional)
16
16
  * @input [abortCtx] [order:2] - Context from abort path (JSON, optional)
17
+ * @input [failCtx] [order:3] - Context from failure paths (exec/plan fail, JSON, optional)
17
18
  * @output summary [order:0] - Summary text
18
19
  * @output reportJson [order:1] [hidden] - Full report (JSON)
19
20
  * @output onFailure [hidden]
@@ -23,8 +24,9 @@ export async function weaverBotReport(
23
24
  mainCtx?: string,
24
25
  readCtx?: string,
25
26
  abortCtx?: string,
27
+ failCtx?: string,
26
28
  ): Promise<{ onSuccess: boolean; onFailure: boolean; summary: string; reportJson: string }> {
27
- const ctxStr = mainCtx ?? readCtx ?? abortCtx;
29
+ const ctxStr = mainCtx ?? readCtx ?? abortCtx ?? failCtx;
28
30
 
29
31
  if (!execute) {
30
32
  const report = { task: {}, path: 'unknown', result: null, filesModified: [], gitResult: null, timestamp: Date.now() };
@@ -66,6 +68,9 @@ export async function weaverBotReport(
66
68
  } else if (abortCtx) {
67
69
  result = safeJsonParse<ResultShape>(context.resultJson, null);
68
70
  pathName = 'abort';
71
+ } else if (failCtx) {
72
+ result = safeJsonParse<ResultShape>(context.resultJson, null);
73
+ pathName = 'failed';
69
74
  }
70
75
 
71
76
  const parts: string[] = [];
@@ -115,8 +120,8 @@ export async function weaverBotReport(
115
120
  if (task.queueId && context.env?.projectDir) {
116
121
  try {
117
122
  const store = new TaskStore(context.env.projectDir);
118
- await store.update(task.queueId, { status: success ? 'done' : 'failed' });
119
- console.log(`\x1b[36m→ Queue task ${task.queueId}: ${success ? 'done' : 'failed'}\x1b[0m`);
123
+ await store.update(task.queueId, { status: success ? 'done' : 'open' });
124
+ console.log(`\x1b[36m→ Queue task ${task.queueId}: ${success ? 'done' : 'open'}\x1b[0m`);
120
125
  } catch (err) { if (process.env.WEAVER_VERBOSE) console.error('[bot-report] queue update failed:', err); }
121
126
  }
122
127
 
@@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process';
2
2
  import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
4
  import type { WeaverContext } from '../bot/types.js';
5
+ import { HierarchyEventLog } from '../bot/hierarchy-event-log.js';
5
6
 
6
7
  /**
7
8
  * Builds the knowledge bundle the AI needs for planning.
@@ -31,6 +32,37 @@ export function weaverBuildContext(ctx: string): { ctx: string } {
31
32
  sections.push(...buildFullContext(projectDir, task.mode));
32
33
  }
33
34
 
35
+ // Auto-recall project memory (conventions persisted by previous bots)
36
+ try {
37
+ const memPath = path.join(projectDir, '.weaver', 'project-memory.json');
38
+ if (fs.existsSync(memPath)) {
39
+ const memory = JSON.parse(fs.readFileSync(memPath, 'utf-8')) as Record<string, string>;
40
+ const entries = Object.entries(memory);
41
+ if (entries.length > 0) {
42
+ const memLines = entries.map(([key, value]) => `- **${key}**: ${value}`);
43
+ sections.push(`## Project Conventions (from memory)\n\nFollow these established patterns:\n${memLines.join('\n')}`);
44
+ }
45
+ }
46
+ } catch { /* non-fatal — memory is best-effort */ }
47
+
48
+ // Include sibling context from hierarchy events (previous task completions in same hierarchy)
49
+ try {
50
+ const parsedTask = JSON.parse(context.taskJson!) as { parentId?: string };
51
+ const parentId = parsedTask.parentId;
52
+ if (parentId) {
53
+ const hierarchyLog = new HierarchyEventLog(projectDir);
54
+ const siblingEvents = hierarchyLog.tailByParent(parentId);
55
+ if (siblingEvents.length > 0) {
56
+ const siblingLines = siblingEvents.map(e => {
57
+ const d = e.data ?? {};
58
+ const files = (d.filesModified as string[])?.join(', ') || 'none';
59
+ return `- ${e.type}: ${d.summary ?? e.taskId} (files: ${files})`;
60
+ });
61
+ sections.push(`## Previous Task Completions\n\nYour sibling tasks in this hierarchy have completed:\n${siblingLines.join('\n')}`);
62
+ }
63
+ }
64
+ } catch { /* non-fatal — sibling context is best-effort */ }
65
+
34
66
  const bundle = sections.join('\n\n---\n\n');
35
67
  // Output handled by session renderer; keep a dim line for debugging
36
68
  if (process.env.WEAVER_VERBOSE) process.stderr.write(`\x1b[2m Context: ${bundle.length} chars\x1b[0m\n`);
@@ -102,7 +102,11 @@ export async function weaverPlanTask(
102
102
  try {
103
103
  const mod = await import('../bot/system-prompt.js');
104
104
  const basePrompt = mod.buildPromptFromCapabilities(selectedCaps);
105
- const botPrompt = mod.buildBotSystemPrompt(context.contextBundle!, undefined, context.env?.projectDir);
105
+ // Only include the context bundle if the 'context' capability was selected.
106
+ // This prevents sending 10k+ tokens of FW authoring docs for simple file creation tasks.
107
+ const selectedCapNames = new Set(selectedCaps.map(c => c.name));
108
+ const contextBundle = selectedCapNames.has('context') ? context.contextBundle : undefined;
109
+ const botPrompt = mod.buildBotSystemPrompt(contextBundle, undefined, context.env?.projectDir);
106
110
  systemPrompt = basePrompt + '\n\n' + botPrompt;
107
111
  } catch (err) {
108
112
  if (process.env.WEAVER_VERBOSE) console.error('[plan-task] system prompt build failed:', err);
@@ -10,18 +10,66 @@ import type { WeaverContext } from '../bot/types.js';
10
10
  * @icon summarize
11
11
  * @color cyan
12
12
  * @input ctx [order:0] - Weaver context (JSON)
13
- * @output summary [order:0] - Summary string
13
+ * @output summary [order:0, format:md] - Summary
14
14
  * @output onFailure [hidden]
15
15
  */
16
16
  export function weaverReport(ctx: string): { summary: string } {
17
17
  const context = JSON.parse(ctx) as WeaverContext;
18
18
  const result = JSON.parse(context.resultJson!);
19
19
  const relPath = path.relative(context.env.projectDir, context.targetPath!);
20
- const lines = [
21
- `Weaver: ${result.outcome} (${relPath})`,
22
- result.summary,
23
- ];
24
- if (result.executionTime) lines.push(`Time: ${result.executionTime}s`);
25
- console.log(`\x1b[32m✓ ${lines[0]}\x1b[0m`);
26
- return { summary: lines.join('\n') };
20
+
21
+ console.log(`\x1b[32m✓ Weaver: ${result.outcome} (${relPath})\x1b[0m`);
22
+
23
+ const md: string[] = [];
24
+ md.push(`## ${result.outcome === 'success' ? 'Task Completed' : 'Task Failed'}`);
25
+ md.push('');
26
+ if (result.summary) md.push(result.summary);
27
+ md.push('');
28
+
29
+ // Steps
30
+ try {
31
+ const steps = JSON.parse(context.stepLogJson ?? '[]') as Array<{ step: string; status: string; detail?: string }>;
32
+ if (steps.length > 0) {
33
+ md.push('### Steps');
34
+ md.push('');
35
+ for (const s of steps) {
36
+ const icon = s.status === 'ok' ? '**ok**' : s.status === 'error' ? '**error**' : '**blocked**';
37
+ md.push(`- ${s.step} (${icon})${s.detail ? `: ${s.detail}` : ''}`);
38
+ }
39
+ md.push('');
40
+ }
41
+ } catch { /* skip */ }
42
+
43
+ // Files
44
+ try {
45
+ const files = JSON.parse(context.filesModified ?? '[]') as string[];
46
+ if (files.length > 0) {
47
+ md.push('### Files Modified');
48
+ md.push('');
49
+ for (const f of files) md.push(`- \`${f}\``);
50
+ md.push('');
51
+ }
52
+ } catch { /* skip */ }
53
+
54
+ // Review
55
+ try {
56
+ const review = JSON.parse(context.reviewJson ?? '{}') as Record<string, string>;
57
+ if (review.intent || review.execution || review.result || review.completeness) {
58
+ md.push('### Review');
59
+ md.push('');
60
+ for (const key of ['intent', 'execution', 'result', 'completeness']) {
61
+ if (review[key]) md.push(`- **${key}:** ${review[key]}`);
62
+ }
63
+ if (review.reason) md.push(`\n${review.reason}`);
64
+ md.push('');
65
+ }
66
+ } catch { /* skip */ }
67
+
68
+ // Meta
69
+ const meta: string[] = [];
70
+ if (result.executionTime) meta.push(`**Duration:** ${result.executionTime}s`);
71
+ if (context.env.providerInfo?.model) meta.push(`**Model:** ${context.env.providerInfo.model}`);
72
+ if (meta.length > 0) md.push(meta.join(' | '));
73
+
74
+ return { summary: md.join('\n') };
27
75
  }
@@ -76,6 +76,9 @@ export function useStreamTimeline(events: StreamEvent[], isDone: boolean): Strea
76
76
  // Map nodeId → index in entries[] so we can replace start with complete
77
77
  const nodeEntryIndex = new Map<string, number>();
78
78
  const nodeStarts = new Map<string, number>();
79
+ // Track completed/failed nodes to skip duplicate completion events
80
+ // (FW runtime emits 2x STATUS_CHANGED per node transition)
81
+ const completedNodes = new Set<string>();
79
82
  let idCounter = 0;
80
83
 
81
84
  for (const event of events) {
@@ -95,6 +98,17 @@ export function useStreamTimeline(events: StreamEvent[], isDone: boolean): Strea
95
98
  case 'node-start': {
96
99
  const nodeId = d.nodeId as string;
97
100
  if (nodeId) nodeStarts.set(nodeId, event.timestamp);
101
+
102
+ // If we already have an in-flight entry for this node (duplicate
103
+ // STATUS_CHANGED from the FW runtime), skip the duplicate.
104
+ const existingStartIdx = nodeEntryIndex.get(nodeId);
105
+ if (existingStartIdx != null && entries[existingStartIdx]?.type === 'node-started') {
106
+ break;
107
+ }
108
+
109
+ // Clear completed tracking so retry loops work (node can re-enter)
110
+ completedNodes.delete(nodeId);
111
+
98
112
  const idx = entries.length;
99
113
  nodeEntryIndex.set(nodeId, idx);
100
114
  entries.push({
@@ -111,65 +125,91 @@ export function useStreamTimeline(events: StreamEvent[], isDone: boolean): Strea
111
125
 
112
126
  case 'node-complete': {
113
127
  const nodeId = d.nodeId as string;
128
+ // Skip duplicate completion events for the same node
129
+ if (completedNodes.has(nodeId)) break;
130
+
114
131
  const startTs = nodeStarts.get(nodeId);
115
132
  const duration =
116
133
  (d.durationMs as number) ?? (startTs ? event.timestamp - startTs : undefined);
117
134
  if (nodeId) nodeStarts.delete(nodeId);
118
-
119
- const rawOutputs = d.outputs as Array<{ portLabel: string; value: unknown }> | undefined;
120
-
121
- const completed: TimelineEntry = {
122
- id: `s-${idCounter++}`,
123
- timestamp: new Date(startTs ?? event.timestamp),
124
- type: 'node-completed',
125
- nodeId,
126
- label: (d.label as string) ?? (d.nodeType as string) ?? nodeId ?? 'Node',
127
- duration,
128
- color: d.color as string | undefined,
129
- icon: d.icon as string | undefined,
130
- outputs: rawOutputs && rawOutputs.length > 0 ? rawOutputs : undefined,
131
- };
135
+ completedNodes.add(nodeId);
132
136
 
133
137
  // Replace the node-started entry in-place
134
138
  const existingIdx = nodeEntryIndex.get(nodeId);
135
139
  if (existingIdx != null && entries[existingIdx]) {
136
- entries[existingIdx] = completed;
140
+ const rawOutputs = d.outputs as Array<{ portLabel: string; value: unknown }> | undefined;
141
+ entries[existingIdx] = {
142
+ id: entries[existingIdx]!.id,
143
+ timestamp: new Date(startTs ?? event.timestamp),
144
+ type: 'node-completed',
145
+ nodeId,
146
+ label: (d.label as string) ?? (d.nodeType as string) ?? nodeId ?? 'Node',
147
+ duration,
148
+ color: d.color as string | undefined,
149
+ icon: d.icon as string | undefined,
150
+ outputs: rawOutputs && rawOutputs.length > 0 ? rawOutputs : undefined,
151
+ };
137
152
  nodeEntryIndex.delete(nodeId);
138
153
  } else {
139
- entries.push(completed);
154
+ // No matching start entry — standalone complete (e.g. Start node)
155
+ const rawOutputs = d.outputs as Array<{ portLabel: string; value: unknown }> | undefined;
156
+ entries.push({
157
+ id: `s-${idCounter++}`,
158
+ timestamp: new Date(startTs ?? event.timestamp),
159
+ type: 'node-completed',
160
+ nodeId,
161
+ label: (d.label as string) ?? (d.nodeType as string) ?? nodeId ?? 'Node',
162
+ duration,
163
+ color: d.color as string | undefined,
164
+ icon: d.icon as string | undefined,
165
+ outputs: rawOutputs && rawOutputs.length > 0 ? rawOutputs : undefined,
166
+ });
140
167
  }
141
168
  break;
142
169
  }
143
170
 
144
171
  case 'node-error': {
145
172
  const nodeId = d.nodeId as string;
173
+ if (completedNodes.has(nodeId)) break; // skip duplicate
174
+
146
175
  const startTs = nodeStarts.get(nodeId);
147
176
  const duration =
148
177
  (d.durationMs as number) ?? (startTs ? event.timestamp - startTs : undefined);
149
178
  if (nodeId) nodeStarts.delete(nodeId);
150
-
151
- const rawOutputs = d.outputs as Array<{ portLabel: string; value: unknown }> | undefined;
152
-
153
- const failed: TimelineEntry = {
154
- id: `s-${idCounter++}`,
155
- timestamp: new Date(startTs ?? event.timestamp),
156
- type: 'node-failed',
157
- nodeId,
158
- label: (d.label as string) ?? (d.nodeType as string) ?? nodeId ?? 'Node',
159
- detail: d.error as string | undefined,
160
- duration,
161
- color: d.color as string | undefined,
162
- icon: d.icon as string | undefined,
163
- outputs: rawOutputs && rawOutputs.length > 0 ? rawOutputs : undefined,
164
- };
179
+ completedNodes.add(nodeId);
165
180
 
166
181
  // Replace the node-started entry in-place
167
182
  const existingIdx = nodeEntryIndex.get(nodeId);
168
183
  if (existingIdx != null && entries[existingIdx]) {
169
- entries[existingIdx] = failed;
184
+
185
+ const rawOutputs = d.outputs as Array<{ portLabel: string; value: unknown }> | undefined;
186
+ entries[existingIdx] = {
187
+ id: entries[existingIdx]!.id,
188
+ timestamp: new Date(startTs ?? event.timestamp),
189
+ type: 'node-failed',
190
+ nodeId,
191
+ label: (d.label as string) ?? (d.nodeType as string) ?? nodeId ?? 'Node',
192
+ detail: d.error as string | undefined,
193
+ duration,
194
+ color: d.color as string | undefined,
195
+ icon: d.icon as string | undefined,
196
+ outputs: rawOutputs && rawOutputs.length > 0 ? rawOutputs : undefined,
197
+ };
170
198
  nodeEntryIndex.delete(nodeId);
171
199
  } else {
172
- entries.push(failed);
200
+ const rawOutputs = d.outputs as Array<{ portLabel: string; value: unknown }> | undefined;
201
+ entries.push({
202
+ id: `s-${idCounter++}`,
203
+ timestamp: new Date(startTs ?? event.timestamp),
204
+ type: 'node-failed',
205
+ nodeId,
206
+ label: (d.label as string) ?? (d.nodeType as string) ?? nodeId ?? 'Node',
207
+ detail: d.error as string | undefined,
208
+ duration,
209
+ color: d.color as string | undefined,
210
+ icon: d.icon as string | undefined,
211
+ outputs: rawOutputs && rawOutputs.length > 0 ? rawOutputs : undefined,
212
+ });
173
213
  }
174
214
  break;
175
215
  }