@dotdrelle/wiki-manager 0.9.3 → 0.11.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.
@@ -1,7 +1,51 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
+ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
3
4
  import { startRuntimeServer } from './server.js';
4
5
 
6
+ test('runtime server checks bearer and x-runtime-token credentials', async (t) => {
7
+ let handle;
8
+ try {
9
+ handle = await startRuntimeServer({
10
+ host: '127.0.0.1',
11
+ port: 0,
12
+ token: 'runtime-secret',
13
+ store: {
14
+ dbPath: ':memory:',
15
+ getState: () => ({ status: 'idle' }),
16
+ listEvents: () => [],
17
+ },
18
+ session: {},
19
+ run: async () => {},
20
+ });
21
+ } catch (err) {
22
+ if (err?.code === 'EPERM') {
23
+ t.skip('network listen is not permitted in this sandbox');
24
+ return;
25
+ }
26
+ throw err;
27
+ }
28
+
29
+ try {
30
+ const url = `http://127.0.0.1:${handle.port}/health`;
31
+
32
+ const missing = await fetch(url);
33
+ assert.equal(missing.status, 401);
34
+
35
+ const bearer = await fetch(url, {
36
+ headers: { authorization: 'Bearer runtime-secret' },
37
+ });
38
+ assert.equal(bearer.status, 200);
39
+
40
+ const legacy = await fetch(url, {
41
+ headers: { 'x-runtime-token': 'runtime-secret' },
42
+ });
43
+ assert.equal(legacy.status, 200);
44
+ } finally {
45
+ await handle.close();
46
+ }
47
+ });
48
+
5
49
  test('runtime server accepts only one active run', async (t) => {
6
50
  let releaseRun;
7
51
  let runCount = 0;
@@ -102,6 +146,58 @@ test('runtime server returns the accepted run id and passes it to the runner', a
102
146
  }
103
147
  });
104
148
 
149
+ test('runtime server state exposes active run identity while running', async (t) => {
150
+ let releaseRun;
151
+ const context = {
152
+ workspace: 'juno',
153
+ session: { workspace: 'juno' },
154
+ running: false,
155
+ currentAbortController: null,
156
+ currentRunId: null,
157
+ };
158
+ let acceptedRun;
159
+ let handle;
160
+ try {
161
+ handle = await startRuntimeServer({
162
+ host: '127.0.0.1',
163
+ port: 0,
164
+ store: {
165
+ dbPath: ':memory:',
166
+ getState: () => ({ status: 'idle', plan: [] }),
167
+ listEvents: () => [],
168
+ },
169
+ getContext: async () => context,
170
+ run: async () => {
171
+ await new Promise((resolve) => { releaseRun = resolve; });
172
+ },
173
+ });
174
+ } catch (err) {
175
+ if (err?.code === 'EPERM') {
176
+ t.skip('network listen is not permitted in this sandbox');
177
+ return;
178
+ }
179
+ throw err;
180
+ }
181
+
182
+ try {
183
+ const runResponse = await fetch(`http://127.0.0.1:${handle.port}/run?workspace=juno`, {
184
+ method: 'POST',
185
+ headers: { 'Content-Type': 'application/json' },
186
+ body: JSON.stringify({ input: 'build' }),
187
+ });
188
+ acceptedRun = await runResponse.json();
189
+ const stateResponse = await fetch(`http://127.0.0.1:${handle.port}/state?workspace=juno`);
190
+ const state = await stateResponse.json();
191
+ assert.equal(state.status, 'running');
192
+ assert.equal(state.running, true);
193
+ assert.equal(state.runId, acceptedRun.runId);
194
+ assert.equal(state.workspace, 'juno');
195
+ } finally {
196
+ releaseRun?.();
197
+ await handle.close();
198
+ }
199
+ });
200
+
105
201
  test('runtime server isolates active runs by workspace', async (t) => {
106
202
  const releases = new Map();
107
203
  const runWorkspaces = [];
@@ -222,6 +318,52 @@ test('runtime server filters state and events by workspace', async (t) => {
222
318
  }
223
319
  });
224
320
 
321
+ test('runtime server exposes a correlated audit trail endpoint', async (t) => {
322
+ let auditArgs = null;
323
+ let handle;
324
+ try {
325
+ handle = await startRuntimeServer({
326
+ host: '127.0.0.1',
327
+ port: 0,
328
+ store: {
329
+ dbPath: ':memory:',
330
+ getState: () => ({ status: 'idle' }),
331
+ listEvents: () => [],
332
+ listAuditTrail: (options) => {
333
+ auditArgs = options;
334
+ return [{ sequence: 1, type: 'tool_call_started', runId: options.runId, taskId: 'task-a' }];
335
+ },
336
+ },
337
+ getContext: async (workspace) => ({
338
+ workspace,
339
+ session: { workspace },
340
+ running: false,
341
+ currentAbortController: null,
342
+ }),
343
+ run: async () => {},
344
+ });
345
+ } catch (err) {
346
+ if (err?.code === 'EPERM') {
347
+ t.skip('network listen is not permitted in this sandbox');
348
+ return;
349
+ }
350
+ throw err;
351
+ }
352
+
353
+ try {
354
+ const response = await fetch(`http://127.0.0.1:${handle.port}/audit?workspace=docs&runId=run-1`);
355
+ assert.equal(response.status, 200);
356
+ const body = await response.json();
357
+ assert.equal(body.ok, true);
358
+ assert.equal(body.workspace, 'docs');
359
+ assert.equal(body.runId, 'run-1');
360
+ assert.deepEqual(body.audit, [{ sequence: 1, type: 'tool_call_started', runId: 'run-1', taskId: 'task-a' }]);
361
+ assert.deepEqual(auditArgs, { workspace: 'docs', runId: 'run-1' });
362
+ } finally {
363
+ await handle.close();
364
+ }
365
+ });
366
+
225
367
  test('runtime server exposes manual resume endpoint', async (t) => {
226
368
  let resumedWorkspace = null;
227
369
  let handle;
@@ -426,6 +568,203 @@ test('runtime server control enqueue emits events but does not patch an active p
426
568
  }
427
569
  });
428
570
 
571
+ test('runtime server control message observes an active run without enqueueing', async (t) => {
572
+ const session = {
573
+ workspace: 'juno',
574
+ controlQueue: [],
575
+ };
576
+ let runCount = 0;
577
+ let handle;
578
+ try {
579
+ handle = await startRuntimeServer({
580
+ host: '127.0.0.1',
581
+ port: 0,
582
+ store: {
583
+ dbPath: ':memory:',
584
+ getState: () => ({
585
+ status: 'running',
586
+ plan: [{ step: 1, description: 'Build documents', status: 'running' }],
587
+ queue: [],
588
+ approvals: [],
589
+ summary: null,
590
+ }),
591
+ listEvents: () => [],
592
+ },
593
+ getContext: async () => ({
594
+ workspace: 'juno',
595
+ session,
596
+ running: true,
597
+ currentAbortController: new AbortController(),
598
+ }),
599
+ run: async () => { runCount += 1; },
600
+ });
601
+ } catch (err) {
602
+ if (err?.code === 'EPERM') {
603
+ t.skip('network listen is not permitted in this sandbox');
604
+ return;
605
+ }
606
+ throw err;
607
+ }
608
+
609
+ try {
610
+ const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
611
+ method: 'POST',
612
+ headers: { 'Content-Type': 'application/json' },
613
+ body: JSON.stringify({ action: 'message', input: 'Où en est le build ?' }),
614
+ });
615
+ assert.equal(response.status, 200);
616
+ const body = await response.json();
617
+ assert.equal(body.kind, 'observe');
618
+ assert.match(body.explanation, /Build documents/);
619
+ assert.equal(session.controlQueue.length, 0);
620
+ assert.equal(runCount, 0);
621
+ } finally {
622
+ await handle.close();
623
+ }
624
+ });
625
+
626
+ test('runtime server control message records active plan mutation as a proposal', async (t) => {
627
+ const session = {
628
+ workspace: 'juno',
629
+ controlQueue: [],
630
+ };
631
+ dispatchAgentEvent(session, createAgentEvent('run_started', {
632
+ origin: 'runtime',
633
+ runId: 'run-mutate',
634
+ workspace: 'juno',
635
+ }));
636
+ dispatchAgentEvent(session, createAgentEvent('plan_set', {
637
+ origin: 'tool',
638
+ runId: 'run-mutate',
639
+ workspace: 'juno',
640
+ payload: {
641
+ steps: [{ step: 1, id: 'generate', description: 'Generate', status: 'running' }],
642
+ },
643
+ }));
644
+ let runCount = 0;
645
+ let handle;
646
+ try {
647
+ handle = await startRuntimeServer({
648
+ host: '127.0.0.1',
649
+ port: 0,
650
+ store: {
651
+ dbPath: ':memory:',
652
+ getState: () => ({
653
+ ...session.agentProjection,
654
+ status: session.agentProjection?.status ?? 'running',
655
+ queue: [],
656
+ runs: [{ id: 'run-mutate', workspace: 'juno', status: 'running' }],
657
+ runId: 'run-mutate',
658
+ }),
659
+ listEvents: () => [],
660
+ },
661
+ getContext: async () => ({
662
+ workspace: 'juno',
663
+ session,
664
+ running: true,
665
+ currentAbortController: new AbortController(),
666
+ currentRunId: 'run-mutate',
667
+ }),
668
+ run: async () => { runCount += 1; },
669
+ });
670
+ } catch (err) {
671
+ if (err?.code === 'EPERM') {
672
+ t.skip('network listen is not permitted in this sandbox');
673
+ return;
674
+ }
675
+ throw err;
676
+ }
677
+
678
+ try {
679
+ const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
680
+ method: 'POST',
681
+ headers: { 'Content-Type': 'application/json' },
682
+ body: JSON.stringify({ action: 'message', input: 'Ajoute un envoi après chaque génération' }),
683
+ });
684
+ assert.equal(response.status, 202);
685
+ const body = await response.json();
686
+ assert.equal(body.kind, 'mutate');
687
+ assert.equal(body.proposal.status, 'proposed');
688
+ assert.equal(body.proposal.input, 'Ajoute un envoi après chaque génération');
689
+ assert.equal(session.agentProjection.planPatches[0].status, 'proposed');
690
+ assert.ok(session.agentEvents.some((event) => event.type === 'control_message_received'));
691
+ assert.ok(session.agentEvents.some((event) => event.type === 'plan_patch_proposed'));
692
+ const approveResponse = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
693
+ method: 'POST',
694
+ headers: { 'Content-Type': 'application/json' },
695
+ body: JSON.stringify({ action: 'approve_patch', patchId: body.proposal.id }),
696
+ });
697
+ assert.equal(approveResponse.status, 202);
698
+ const approved = await approveResponse.json();
699
+ assert.equal(approved.kind, 'approve_patch');
700
+ // plan_set (revision 0->1) then the approved patch application (1->2).
701
+ assert.equal(session.agentProjection.planRevision, 2);
702
+ assert.deepEqual(session.agentProjection.plan.map((step) => step.id), ['generate', session.agentProjection.plan[1].id]);
703
+ assert.deepEqual(session.agentProjection.plan[1].dependsOn, ['generate']);
704
+ assert.ok(session.agentEvents.some((event) => event.type === 'plan_patch_approved'));
705
+ assert.ok(session.agentEvents.some((event) => event.type === 'plan_patch_applied'));
706
+ assert.equal(session.controlQueue.length, 0);
707
+ assert.equal(runCount, 0);
708
+ } finally {
709
+ await handle.close();
710
+ }
711
+ });
712
+
713
+ test('runtime server control message reports ambiguity without starting a run', async (t) => {
714
+ const session = {
715
+ workspace: 'juno',
716
+ controlQueue: [],
717
+ };
718
+ let runCount = 0;
719
+ let handle;
720
+ try {
721
+ handle = await startRuntimeServer({
722
+ host: '127.0.0.1',
723
+ port: 0,
724
+ store: {
725
+ dbPath: ':memory:',
726
+ getState: () => ({
727
+ status: 'running',
728
+ plan: [{ step: 1, description: 'Generate', status: 'running' }],
729
+ queue: [],
730
+ approvals: [],
731
+ summary: null,
732
+ }),
733
+ listEvents: () => [],
734
+ },
735
+ getContext: async () => ({
736
+ workspace: 'juno',
737
+ session,
738
+ running: true,
739
+ currentAbortController: new AbortController(),
740
+ }),
741
+ run: async () => { runCount += 1; },
742
+ });
743
+ } catch (err) {
744
+ if (err?.code === 'EPERM') {
745
+ t.skip('network listen is not permitted in this sandbox');
746
+ return;
747
+ }
748
+ throw err;
749
+ }
750
+
751
+ try {
752
+ const response = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=juno`, {
753
+ method: 'POST',
754
+ headers: { 'Content-Type': 'application/json' },
755
+ body: JSON.stringify({ action: 'message', input: 'Lance aussi la publication' }),
756
+ });
757
+ assert.equal(response.status, 200);
758
+ const body = await response.json();
759
+ assert.equal(body.kind, 'ambiguous');
760
+ assert.equal(body.choices.length, 3);
761
+ assert.equal(session.controlQueue.length, 0);
762
+ assert.equal(runCount, 0);
763
+ } finally {
764
+ await handle.close();
765
+ }
766
+ });
767
+
429
768
  test('runtime server drains queued control requests when idle', async (t) => {
430
769
  const session = {
431
770
  workspace: 'juno',
@@ -4,6 +4,7 @@ import { DatabaseSync } from 'node:sqlite';
4
4
  import { applyAgentProjectionToSession, dispatchAgentEvent, reduceAgentEvents } from '../core/agentEvents.js';
5
5
  import { defaultRuntimeStateDir } from '../core/env.js';
6
6
  import { projectQueue } from '../core/jobQueue.js';
7
+ import { projectWorkflow } from '../core/workflow.js';
7
8
 
8
9
  export { defaultRuntimeStateDir };
9
10
 
@@ -35,6 +36,7 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
35
36
  type TEXT NOT NULL,
36
37
  run_id TEXT,
37
38
  turn_id TEXT,
39
+ task_id TEXT,
38
40
  workspace TEXT,
39
41
  origin TEXT,
40
42
  payload TEXT NOT NULL
@@ -69,6 +71,7 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
69
71
  `);
70
72
  ensureColumn(db, 'events', 'sequence', 'INTEGER');
71
73
  ensureColumn(db, 'events', 'workspace', 'TEXT');
74
+ ensureColumn(db, 'events', 'task_id', 'TEXT');
72
75
  ensureColumn(db, 'runs', 'workspace', 'TEXT');
73
76
  backfillEventSequence(db);
74
77
  db.exec('CREATE INDEX IF NOT EXISTS idx_events_workspace ON events(workspace)');
@@ -78,17 +81,17 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
78
81
  let lastEventSequence = null;
79
82
 
80
83
  const insertEvent = db.prepare(`
81
- INSERT OR IGNORE INTO events (sequence, id, ts, type, run_id, turn_id, workspace, origin, payload)
82
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
84
+ INSERT OR IGNORE INTO events (sequence, id, ts, type, run_id, turn_id, task_id, workspace, origin, payload)
85
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
83
86
  `);
84
87
  const nextEventSequenceStatement = db.prepare('SELECT COALESCE(MAX(sequence), 0) + 1 AS next_sequence FROM events');
85
88
  const listEventsStatement = db.prepare(`
86
- SELECT sequence, id, ts, type, run_id, turn_id, workspace, origin, payload
89
+ SELECT sequence, id, ts, type, run_id, turn_id, task_id, workspace, origin, payload
87
90
  FROM events
88
91
  ORDER BY sequence ASC
89
92
  `);
90
93
  const listEventsByWorkspaceStatement = db.prepare(`
91
- SELECT sequence, id, ts, type, run_id, turn_id, workspace, origin, payload
94
+ SELECT sequence, id, ts, type, run_id, turn_id, task_id, workspace, origin, payload
92
95
  FROM events
93
96
  WHERE workspace = ?
94
97
  ORDER BY sequence ASC
@@ -195,6 +198,7 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
195
198
  event.type,
196
199
  event.runId ?? null,
197
200
  event.turnId ?? null,
201
+ event.taskId ?? event.payload?.taskId ?? null,
198
202
  ws,
199
203
  event.origin ?? null,
200
204
  JSON.stringify(event.payload ?? {}),
@@ -240,6 +244,12 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
240
244
  return rows.map(rowToEvent);
241
245
  }
242
246
 
247
+ function listAuditTrail({ workspace = null, runId = null } = {}) {
248
+ return listEvents({ workspace })
249
+ .filter((event) => !runId || event.runId === runId || event.payload?.runId === runId)
250
+ .map(eventToAuditEntry);
251
+ }
252
+
243
253
  function listRuns({ workspace = null } = {}) {
244
254
  const rows = workspace
245
255
  ? listRunsByWorkspaceStatement.all(workspace)
@@ -362,15 +372,22 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
362
372
  const events = session?.agentProjection ? null : listEvents({ workspace });
363
373
  const projection = session?.agentProjection ?? reduceAgentEvents(events);
364
374
  const rawQueue = session?.queueStore?.list() ?? listQueue({ workspace });
365
- return {
375
+ const queue = projectQueue(projection.plan, rawQueue, { workspace });
376
+ const controlQueue = Array.isArray(projection.controlQueue)
377
+ ? projection.controlQueue.filter((item) => !workspace || item.workspace === workspace || !item.workspace).map((item) => ({ ...item }))
378
+ : [];
379
+ const baseState = {
366
380
  ...projection,
367
- runs: listRuns({ workspace }),
368
- queue: projectQueue(projection.plan, rawQueue, { workspace }),
369
- controlQueue: Array.isArray(projection.controlQueue)
370
- ? projection.controlQueue.filter((item) => !workspace || item.workspace === workspace || !item.workspace).map((item) => ({ ...item }))
371
- : [],
381
+ queue,
382
+ controlQueue,
372
383
  eventsCursor: session?.agentEvents?.at(-1)?.sequence ?? events?.at(-1)?.sequence ?? lastEventSequence,
373
384
  };
385
+ const runs = listRuns({ workspace });
386
+ return {
387
+ ...baseState,
388
+ runs,
389
+ workflow: projectWorkflow({ ...baseState, runs, workspace }, events ?? session?.agentEvents ?? []),
390
+ };
374
391
  }
375
392
 
376
393
  function hydrateSession(session, { workspace = null } = {}) {
@@ -391,6 +408,7 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
391
408
  persistEvent,
392
409
  persistRun,
393
410
  listEvents,
411
+ listAuditTrail,
394
412
  listRuns,
395
413
  listRecoverableRuns,
396
414
  listRecoverableWorkspaces,
@@ -414,11 +432,42 @@ function rowToEvent(row) {
414
432
  origin: row.origin ?? 'system',
415
433
  runId: row.run_id ?? null,
416
434
  turnId: row.turn_id ?? null,
435
+ taskId: row.task_id ?? null,
417
436
  workspace: row.workspace ?? null,
418
437
  payload: row.payload ? JSON.parse(row.payload) : {},
419
438
  };
420
439
  }
421
440
 
441
+ function eventToAuditEntry(event) {
442
+ const payload = event.payload ?? {};
443
+ const activity = payload.activity ?? {};
444
+ return {
445
+ sequence: event.sequence ?? null,
446
+ ts: event.ts,
447
+ type: event.type,
448
+ runId: event.runId ?? payload.runId ?? null,
449
+ turnId: event.turnId ?? payload.turnId ?? null,
450
+ taskId: event.taskId ?? payload.taskId ?? activity.taskId ?? null,
451
+ activityId: payload.activityId ?? activity.id ?? activity.key ?? null,
452
+ toolCallId: payload.toolCallId ?? payload.callId ?? null,
453
+ workspace: event.workspace ?? payload.workspace ?? null,
454
+ caller: event.origin ?? payload.caller ?? 'system',
455
+ status: payload.status ?? activity.status ?? null,
456
+ tool: payload.tool ?? payload.name ?? activity.tool ?? null,
457
+ summary: auditSummary(event),
458
+ };
459
+ }
460
+
461
+ function auditSummary(event) {
462
+ const payload = event.payload ?? {};
463
+ if (typeof payload.message === 'string') return payload.message;
464
+ if (typeof payload.summary === 'string') return payload.summary;
465
+ if (typeof payload.input === 'string') return payload.input;
466
+ if (typeof payload.content === 'string') return payload.content.slice(0, 240);
467
+ if (payload.activity?.label) return payload.activity.label;
468
+ return event.type;
469
+ }
470
+
422
471
  function ensureColumn(db, table, column, definition) {
423
472
  const existing = db.prepare(`PRAGMA table_info(${table})`).all();
424
473
  if (existing.some((row) => row.name === column)) return;
@@ -59,6 +59,7 @@ test('runtime store persists run identity fields on events', () => {
59
59
  origin: 'agent',
60
60
  runId: 'run-2',
61
61
  turnId: 'run-2:turn-1',
62
+ taskId: 'task-1',
62
63
  workspace: 'docs',
63
64
  payload: { content: 'done' },
64
65
  }));
@@ -66,10 +67,51 @@ test('runtime store persists run identity fields on events', () => {
66
67
  const [event] = store.listEvents();
67
68
  assert.equal(event.runId, 'run-2');
68
69
  assert.equal(event.turnId, 'run-2:turn-1');
70
+ assert.equal(event.taskId, 'task-1');
69
71
  assert.equal(event.workspace, 'docs');
70
72
  store.close();
71
73
  });
72
74
 
75
+ test('runtime store exposes a correlated audit trail', () => {
76
+ const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
77
+ const store = openRuntimeStore({ stateDir });
78
+ store.persistEvent(createAgentEvent('tool_call_started', {
79
+ origin: 'agent',
80
+ runId: 'run-audit',
81
+ turnId: 'run-audit:turn-1',
82
+ taskId: 'task-a',
83
+ workspace: 'docs',
84
+ payload: { callId: 'call-1', name: 'production_start_job' },
85
+ }));
86
+ store.persistEvent(createAgentEvent('activity_upserted', {
87
+ origin: 'tool',
88
+ runId: 'run-audit',
89
+ turnId: 'run-audit:turn-1',
90
+ taskId: 'task-a',
91
+ workspace: 'docs',
92
+ payload: {
93
+ activity: {
94
+ id: 'job-1',
95
+ source: 'production',
96
+ kind: 'job',
97
+ label: 'Production job',
98
+ status: 'running',
99
+ progress: {},
100
+ poll: null,
101
+ outputRefs: [],
102
+ },
103
+ },
104
+ }));
105
+
106
+ const audit = store.listAuditTrail({ workspace: 'docs', runId: 'run-audit' });
107
+ assert.equal(audit.length, 2);
108
+ assert.equal(audit[0].toolCallId, 'call-1');
109
+ assert.equal(audit[0].taskId, 'task-a');
110
+ assert.equal(audit[1].activityId, 'job-1');
111
+ assert.equal(audit[1].workspace, 'docs');
112
+ store.close();
113
+ });
114
+
73
115
  test('runtime store replays evaluator verdict into state', () => {
74
116
  const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
75
117
  const store = openRuntimeStore({ stateDir });
@@ -404,11 +446,15 @@ test('runtime state exposes queue as blocked jobs and pending plan steps', () =>
404
446
  { id: 'q-done', workspace: 'docs', status: 'done', args: { type: 'done' } },
405
447
  ], { workspace: 'docs' });
406
448
 
407
- const queue = store.getState(session, { workspace: 'docs' }).queue;
449
+ const state = store.getState(session, { workspace: 'docs' });
450
+ const queue = state.queue;
408
451
  assert.deepEqual(queue.map((item) => item.id), ['q-wait', 'plan-2', 'plan-3']);
409
452
  assert.equal(queue[0].queueType, 'blocked_job');
410
453
  assert.equal(queue[1].queueType, 'pending_step');
411
454
  assert.equal(queue[1].args.type, 'Build');
455
+ assert.ok(state.workflow.nodes.some((node) => node.id === 'task:2'));
456
+ assert.ok(state.workflow.nodes.some((node) => node.id === 'queue:q-wait'));
457
+ assert.ok(state.workflow.waitingReasons.includes('queue:q-wait'));
412
458
  store.close();
413
459
  });
414
460
 
@@ -1,5 +1,6 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
  import { createMemo, Index, Show } from 'solid-js';
3
+ import { fit } from './textFit';
3
4
 
4
5
  type PlanStep = { step: number; description: string; status: string };
5
6
  type QueueItem = {
@@ -45,13 +46,6 @@ function wrapLine(value: string, width: number) {
45
46
  return lines;
46
47
  }
47
48
 
48
- function fit(value: string, width: number) {
49
- const max = Math.max(1, width);
50
- if (value.length <= max) return value;
51
- if (max <= 1) return '…';
52
- return value.slice(0, max - 1) + '…';
53
- }
54
-
55
49
  function logLineParts(line: string): LogLineParts {
56
50
  const match = line.match(/^((?:\d{1,2}:\d{2}(?::\d{2})?(?:\s?[AP]M)?|\d{4}-\d{2}-\d{2}[T ][0-9:.]+Z?))\s+(.+)$/i);
57
51
  if (!match) return { time: null, message: line };