@davvidbaker/flambe-cli 0.1.1 → 0.1.3

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.
@@ -5,8 +5,8 @@ import { DatabaseSync } from 'node:sqlite';
5
5
 
6
6
  import { pickName, usableName } from './agents.mjs';
7
7
 
8
- const LIFECYCLE_PHASES = ['B', 'E', 'S', 'R'];
9
- const OPEN_PHASES = ['B', 'R'];
8
+ const LIFECYCLE_PHASES = ['B', 'E', 'S', 'R', 'X', 'J', 'V'];
9
+ const OPEN_PHASES = ['B', 'R', 'X'];
10
10
 
11
11
  const SCHEMA = `
12
12
  PRAGMA foreign_keys = ON;
@@ -60,6 +60,10 @@ CREATE TABLE IF NOT EXISTS activities (
60
60
  weight INTEGER,
61
61
  agent_id TEXT,
62
62
  agent_name TEXT,
63
+ scheduled_start_ms INTEGER,
64
+ scheduled_end_ms INTEGER,
65
+ proposed_by_agent_id TEXT,
66
+ proposed_by_agent_name TEXT,
63
67
  export_id TEXT NOT NULL UNIQUE
64
68
  );
65
69
 
@@ -79,13 +83,6 @@ CREATE TABLE IF NOT EXISTS events (
79
83
  export_id TEXT NOT NULL UNIQUE
80
84
  );
81
85
 
82
- CREATE TABLE IF NOT EXISTS todos (
83
- id INTEGER PRIMARY KEY,
84
- user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
85
- name TEXT NOT NULL,
86
- description TEXT
87
- );
88
-
89
86
  CREATE TABLE IF NOT EXISTS mantras (
90
87
  id INTEGER PRIMARY KEY,
91
88
  user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -142,11 +139,25 @@ export const EXPORT_FORMAT = 'flambe-local-export';
142
139
  export const EXPORT_VERSION = 1;
143
140
 
144
141
  export const DEFAULT_CATEGORIES = [
145
- { name: 'coding', color_background: '#efc360', color_text: '#000000' },
146
- { name: 'investigation', color_background: '#60a5fa', color_text: '#000000' },
147
- { name: 'review', color_background: '#a78bfa', color_text: '#ffffff' },
148
- { name: 'operations', color_background: '#34d399', color_text: '#000000' },
149
- { name: 'failure', color_background: '#fb7185', color_text: '#000000' },
142
+ { name: 'bug fixing', color_background: '#ff4747', color_text: '#000000' },
143
+ { name: 'research', color_background: '#cd94f1', color_text: '#000000' },
144
+ { name: 'cleaning', color_background: '#ffbd69', color_text: '#000000' },
145
+ { name: 'bug hunting', color_background: '#ef60ab', color_text: '#000000' },
146
+ { name: 'design', color_background: '#dce7ea', color_text: '#000000' },
147
+ { name: 'writing tests', color_background: '#50f035', color_text: '#000000' },
148
+ { name: 'toil', color_background: '#8b6125', color_text: '#ffffff' },
149
+ { name: 'analytics', color_background: '#f9f9f9', color_text: '#4598d6' },
150
+ { name: 'oss', color_background: '#60ef6c', color_text: '#000000' },
151
+ { name: 'enhancements', color_background: '#55defb', color_text: '#ffffff' },
152
+ { name: 'refactoring components', color_background: '#ffd368', color_text: '#000000' },
153
+ { name: 'Fundamentals', color_background: '#acefdb', color_text: '#000000' },
154
+ { name: 'jarring ui', color_background: '#f22d6a', color_text: '#ffffff' },
155
+ { name: 'writing', color_background: '#ffefce', color_text: '#000000' },
156
+ { name: 'risky', color_background: '#ff2a22', color_text: '#ffffff' },
157
+ { name: 'dependency upgrades', color_background: '#b58e38', color_text: '#ffffff' },
158
+ { name: 'shiny', color_background: '#f5ff86', color_text: '#000000' },
159
+ { name: 'performance', color_background: '#60efc7', color_text: '#000000' },
160
+ { name: 'learning', color_background: '#d3f791', color_text: '#000000' },
150
161
  ];
151
162
 
152
163
  export function hashToken(rawToken) {
@@ -169,6 +180,17 @@ export class LocalStore {
169
180
  this.db = new DatabaseSync(dbPath);
170
181
  this.db.exec('PRAGMA foreign_keys = ON');
171
182
  this.db.exec(SCHEMA);
183
+ this.db.exec('DROP TABLE IF EXISTS todos');
184
+ for (const column of [
185
+ 'scheduled_start_ms INTEGER',
186
+ 'scheduled_end_ms INTEGER',
187
+ 'proposed_by_agent_id TEXT',
188
+ 'proposed_by_agent_name TEXT',
189
+ ]) {
190
+ const name = column.split(' ')[0];
191
+ const exists = this.db.prepare('PRAGMA table_info(activities)').all().some(row => row.name === name);
192
+ if (!exists) this.db.exec(`ALTER TABLE activities ADD COLUMN ${column}`);
193
+ }
172
194
  this.#seed();
173
195
  }
174
196
 
@@ -224,7 +246,9 @@ export class LocalStore {
224
246
  const events = this.db.prepare(`
225
247
  SELECT events.*, activities.name AS activity_name, activities.parent_id,
226
248
  activities.thread_id, activities.weight, activities.agent_id, activities.agent_name,
227
- activities.description AS activity_description
249
+ activities.description AS activity_description,
250
+ activities.scheduled_start_ms, activities.scheduled_end_ms,
251
+ activities.proposed_by_agent_id, activities.proposed_by_agent_name
228
252
  FROM events
229
253
  LEFT JOIN activities ON activities.id = events.activity_id
230
254
  WHERE events.trace_id = ?
@@ -236,6 +260,7 @@ export class LocalStore {
236
260
  name: trace.name,
237
261
  threads: threads.map(thread => ({ id: thread.id, name: thread.name, rank: thread.rank })),
238
262
  events: events.map(event => this.#eventPayload(event)),
263
+ unstarted: this.listUnstarted(trace.id).map(activity => this.#traceActivityPayload(activity)),
239
264
  };
240
265
  }
241
266
 
@@ -317,6 +342,7 @@ export class LocalStore {
317
342
  }
318
343
 
319
344
  listCategories(userId) {
345
+ this.#ensureDefaultCategories(userId);
320
346
  return this.db.prepare(
321
347
  'SELECT id, name, color_background, color_text FROM categories WHERE user_id = ? ORDER BY id',
322
348
  ).all(userId);
@@ -383,7 +409,7 @@ export class LocalStore {
383
409
  .all(userId);
384
410
  }
385
411
 
386
- /** Newest activity in the trace whose latest lifecycle event is B/R, optionally for one agent or thread. */
412
+ /** Newest running activity in the trace, optionally for one agent or thread. */
387
413
  newestActiveActivity(traceId, { agentId, threadId } = {}) {
388
414
  const placeholders = LIFECYCLE_PHASES.map(() => '?').join(',');
389
415
  const open = OPEN_PHASES.map(() => '?').join(',');
@@ -414,7 +440,19 @@ export class LocalStore {
414
440
  * rules apply: an absent parent_id infers this agent's newest active activity, a child
415
441
  * lives in its parent's thread and inherits categories, and thread_id may be omitted.
416
442
  */
417
- createActivity(userId, { traceId, threadId, activity, event, agent, reduce = false }) {
443
+ createActivity(userId, options) {
444
+ this.db.exec('SAVEPOINT create_activity');
445
+ try {
446
+ const result = this.#createActivity(userId, options);
447
+ this.db.exec('RELEASE create_activity');
448
+ return result;
449
+ } catch (error) {
450
+ this.db.exec('ROLLBACK TO create_activity; RELEASE create_activity');
451
+ throw error;
452
+ }
453
+ }
454
+
455
+ #createActivity(userId, { traceId, threadId, activity, event, agent, reduce = false }) {
418
456
  const trace = this.db.prepare('SELECT id FROM traces WHERE id = ? AND user_id = ?').get(Number(traceId), userId);
419
457
  if (!trace) return { error: 'not_found' };
420
458
 
@@ -471,8 +509,11 @@ export class LocalStore {
471
509
  : { agent_id: null, agent_name: null };
472
510
 
473
511
  const inserted = this.db.prepare(`
474
- INSERT INTO activities (thread_id, parent_id, name, description, weight, agent_id, agent_name, export_id)
475
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
512
+ INSERT INTO activities (
513
+ thread_id, parent_id, name, description, weight, agent_id, agent_name,
514
+ scheduled_start_ms, scheduled_end_ms, proposed_by_agent_id, proposed_by_agent_name, export_id
515
+ )
516
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
476
517
  `).run(
477
518
  thread.id,
478
519
  parentId,
@@ -481,6 +522,10 @@ export class LocalStore {
481
522
  activity.weight ?? null,
482
523
  agentColumns.agent_id,
483
524
  agentColumns.agent_name,
525
+ activity.scheduled_start_integer ?? activity.scheduled_start_ms ?? null,
526
+ activity.scheduled_end_integer ?? activity.scheduled_end_ms ?? null,
527
+ activity.proposed_by_agent_id ?? agentColumns.agent_id,
528
+ activity.proposed_by_agent_name ?? agentColumns.agent_name,
484
529
  randomUUID(),
485
530
  );
486
531
  const activityId = Number(inserted.lastInsertRowid);
@@ -490,11 +535,15 @@ export class LocalStore {
490
535
  ).run(activityId, categoryId);
491
536
  }
492
537
 
493
- const timestamp = Number(event.timestamp_integer);
494
- const eventRow = this.db.prepare(`
495
- INSERT INTO events (trace_id, activity_id, timestamp_ms, phase, message, export_id)
496
- VALUES (?, ?, ?, ?, ?, ?)
497
- `).run(thread.trace_id, activityId, timestamp, event.phase, event.message ?? null, randomUUID());
538
+ let createdEvent = null;
539
+ if (event) {
540
+ const timestamp = Number(event.timestamp_integer);
541
+ const eventRow = this.db.prepare(`
542
+ INSERT INTO events (trace_id, activity_id, timestamp_ms, phase, message, export_id)
543
+ VALUES (?, ?, ?, ?, ?, ?)
544
+ `).run(thread.trace_id, activityId, timestamp, event.phase, event.message ?? null, randomUUID());
545
+ createdEvent = { id: Number(eventRow.lastInsertRowid), phase: event.phase };
546
+ }
498
547
 
499
548
  return {
500
549
  activity: {
@@ -505,11 +554,43 @@ export class LocalStore {
505
554
  parent_id: parentId,
506
555
  thread_id: thread.id,
507
556
  },
508
- event: { id: Number(eventRow.lastInsertRowid), phase: event.phase },
557
+ event: createdEvent,
509
558
  ...(reduce ? { reducer: notes } : {}),
510
559
  };
511
560
  }
512
561
 
562
+ createUnstarted(userId, { traceId, name, description, weight, agent, scheduledStart, scheduledEnd }) {
563
+ const thread = this.#defaultThread(Number(traceId));
564
+ if (!thread) return { error: 'not_found' };
565
+ const trace = this.db.prepare('SELECT id FROM traces WHERE id = ? AND user_id = ?').get(thread.trace_id, userId);
566
+ if (!trace) return { error: 'not_found' };
567
+ const inserted = this.db.prepare(`
568
+ INSERT INTO activities (
569
+ thread_id, parent_id, name, description, weight, agent_id, agent_name,
570
+ scheduled_start_ms, scheduled_end_ms, proposed_by_agent_id, proposed_by_agent_name, export_id
571
+ ) VALUES (?, NULL, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?)
572
+ `).run(
573
+ thread.id, name, description ?? null, weight ?? null,
574
+ scheduledStart ?? null, scheduledEnd ?? null,
575
+ agent?.agent_id ?? null, agent?.name ?? null, randomUUID(),
576
+ );
577
+ return { activity: { id: Number(inserted.lastInsertRowid), name, thread_id: thread.id, parent_id: null } };
578
+ }
579
+
580
+ listUnstarted(traceId) {
581
+ return this.db.prepare(`
582
+ SELECT activities.* FROM activities
583
+ JOIN threads ON threads.id = activities.thread_id
584
+ WHERE threads.trace_id = ?
585
+ AND NOT EXISTS (
586
+ SELECT 1 FROM events
587
+ WHERE events.activity_id = activities.id
588
+ AND events.phase IN ('B','Q','R','X','S','E','J','V')
589
+ )
590
+ ORDER BY activities.id
591
+ `).all(Number(traceId));
592
+ }
593
+
513
594
  #defaultThread(traceId) {
514
595
  return this.db.prepare('SELECT id, trace_id FROM threads WHERE trace_id = ? ORDER BY rank, id LIMIT 1').get(traceId) ?? null;
515
596
  }
@@ -530,6 +611,8 @@ export class LocalStore {
530
611
  weight: row.weight,
531
612
  parent_id: row.parent_id,
532
613
  thread_id: row.thread_id,
614
+ agent_id: row.agent_id ?? null,
615
+ agent_name: row.agent_name ?? null,
533
616
  };
534
617
  }
535
618
 
@@ -545,6 +628,13 @@ export class LocalStore {
545
628
  WHERE id = ?
546
629
  `).run(attrs.name ?? null, attrs.description ?? null, attrs.weight ?? null, attrs.parent_id ?? null, current.id);
547
630
 
631
+ if (Object.prototype.hasOwnProperty.call(attrs, 'agent_id')) {
632
+ const agentId = attrs.agent_id == null || attrs.agent_id === '' ? null : String(attrs.agent_id);
633
+ const agentName = agentId == null ? null : this.#activityAgentName(userId, agentId, attrs);
634
+ this.db.prepare('UPDATE activities SET agent_id = ?, agent_name = ? WHERE id = ?')
635
+ .run(agentId, agentName, current.id);
636
+ }
637
+
548
638
  if (Array.isArray(attrs.category_ids)) {
549
639
  this.db.prepare('DELETE FROM activities_categories WHERE activity_id = ?').run(current.id);
550
640
  for (const categoryId of attrs.category_ids) {
@@ -628,7 +718,6 @@ export class LocalStore {
628
718
  username: user.username,
629
719
  traces: this.listTraces(userId),
630
720
  categories: this.listCategories(userId),
631
- todos: this.db.prepare('SELECT id, name, description FROM todos WHERE user_id = ?').all(userId),
632
721
  mantras: this.db.prepare('SELECT id, name, timestamp_ms FROM mantras WHERE user_id = ? ORDER BY id').all(userId)
633
722
  .map(row => ({ id: row.id, name: row.name, timestamp: isoFromMs(row.timestamp_ms) })),
634
723
  attentionShifts: this.db.prepare(
@@ -654,17 +743,14 @@ export class LocalStore {
654
743
  timestamp: isoFromMs(row.timestamp_ms),
655
744
  })),
656
745
  observations: this.listObservations(userId),
746
+ agents: this.listAgents(userId).map(agent => ({
747
+ agent_id: agent.agent_id,
748
+ name: agent.name,
749
+ platform: agent.platform ?? null,
750
+ })),
657
751
  };
658
752
  }
659
753
 
660
- createTodo(userId, attrs) {
661
- const result = this.db.prepare(
662
- 'INSERT INTO todos (user_id, name, description) VALUES (?, ?, ?)',
663
- ).run(userId, attrs.name, attrs.description ?? null);
664
- return this.db.prepare('SELECT id, name, description FROM todos WHERE id = ?')
665
- .get(Number(result.lastInsertRowid));
666
- }
667
-
668
754
  createMantra(userId, attrs) {
669
755
  const timestamp = Number(attrs.timestamp_integer ?? Date.now());
670
756
  const result = this.db.prepare(
@@ -792,28 +878,55 @@ export class LocalStore {
792
878
  };
793
879
  }
794
880
 
795
- #eventPayload(event) {
796
- const categoryIds = event.activity_id == null
797
- ? []
798
- : this.db.prepare(
799
- 'SELECT category_id FROM activities_categories WHERE activity_id = ?',
800
- ).all(event.activity_id).map(row => row.category_id);
881
+ #activityAgentName(userId, agentId, attrs) {
882
+ const known = this.db.prepare(
883
+ 'SELECT name FROM agents WHERE user_id = ? AND agent_id = ?',
884
+ ).get(userId, agentId);
885
+ if (known?.name) return known.name;
886
+ const snapshot = this.db.prepare(`
887
+ SELECT activities.agent_name FROM activities
888
+ JOIN threads ON threads.id = activities.thread_id
889
+ JOIN traces ON traces.id = threads.trace_id
890
+ WHERE traces.user_id = ? AND activities.agent_id = ?
891
+ AND activities.agent_name IS NOT NULL AND activities.agent_name != ''
892
+ LIMIT 1
893
+ `).get(userId, agentId);
894
+ if (snapshot?.agent_name) return snapshot.agent_name;
895
+ if (typeof attrs.agent_name === 'string' && attrs.agent_name !== '') return attrs.agent_name;
896
+ return agentId;
897
+ }
801
898
 
899
+ #eventPayload(event) {
802
900
  return {
803
901
  id: event.id,
804
902
  timestamp: isoFromMs(event.timestamp_ms),
805
903
  phase: event.phase,
806
904
  message: event.message ?? null,
807
- activity: event.activity_id == null ? null : {
808
- id: event.activity_id,
809
- name: event.activity_name,
810
- agent_id: event.agent_id ?? null,
811
- agent_name: event.agent_name ?? null,
812
- parent_id: event.parent_id ?? null,
813
- thread: { id: event.thread_id },
814
- categories: categoryIds,
815
- weight: event.weight ?? null,
816
- },
905
+ activity: event.activity_id == null ? null : this.#traceActivityPayload({
906
+ ...event, id: event.activity_id, name: event.activity_name,
907
+ description: event.activity_description,
908
+ }),
909
+ };
910
+ }
911
+
912
+ #traceActivityPayload(activity) {
913
+ const categoryIds = this.db.prepare(
914
+ 'SELECT category_id FROM activities_categories WHERE activity_id = ?',
915
+ ).all(activity.id).map(row => row.category_id);
916
+ return {
917
+ id: activity.id,
918
+ name: activity.name,
919
+ agent_id: activity.agent_id ?? null,
920
+ agent_name: activity.agent_name ?? null,
921
+ parent_id: activity.parent_id ?? null,
922
+ thread: { id: activity.thread_id },
923
+ categories: categoryIds,
924
+ weight: activity.weight ?? null,
925
+ description: activity.description ?? null,
926
+ scheduled_start: activity.scheduled_start_ms ?? null,
927
+ scheduled_end: activity.scheduled_end_ms ?? null,
928
+ proposed_by_agent_id: activity.proposed_by_agent_id ?? null,
929
+ proposed_by_agent_name: activity.proposed_by_agent_name ?? null,
817
930
  };
818
931
  }
819
932
 
@@ -830,15 +943,16 @@ export class LocalStore {
830
943
  }
831
944
 
832
945
  #ensureDefaultCategories(userId) {
833
- const existing = this.db.prepare(
834
- 'SELECT COUNT(*) AS count FROM categories WHERE user_id = ?',
835
- ).get(userId);
836
- if (existing.count > 0) return;
837
-
946
+ const have = new Set(
947
+ this.db.prepare('SELECT lower(name) AS name FROM categories WHERE user_id = ?')
948
+ .all(userId)
949
+ .map(row => row.name),
950
+ );
838
951
  const insert = this.db.prepare(
839
952
  'INSERT INTO categories (user_id, name, color_background, color_text, export_id) VALUES (?, ?, ?, ?, ?)',
840
953
  );
841
954
  for (const category of DEFAULT_CATEGORIES) {
955
+ if (have.has(category.name.toLowerCase())) continue;
842
956
  insert.run(
843
957
  userId,
844
958
  category.name,
@@ -0,0 +1,48 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+
6
+ // A question the reducer asked and the worker has not answered yet (ADR-014).
7
+ // One open question per trace. The CLI reminds; it never refuses the next command.
8
+
9
+ export function defaultQuestionsPath() {
10
+ return join(homedir(), '.flambe', 'open-questions.json');
11
+ }
12
+
13
+ export function readOpenQuestion(traceId, path = defaultQuestionsPath()) {
14
+ if (!traceId) return undefined;
15
+ const stored = readAll(path)[String(traceId)];
16
+ if (!stored || typeof stored.reply !== 'string' || !stored.reply.trim()) return undefined;
17
+ return { activityId: stored.activityId, reply: stored.reply };
18
+ }
19
+
20
+ export function rememberQuestion(traceId, { activityId, reply }, path = defaultQuestionsPath()) {
21
+ if (!traceId || !reply) return;
22
+ const all = { ...readAll(path), [String(traceId)]: { activityId, reply } };
23
+ writeAll(path, all);
24
+ }
25
+
26
+ export function clearQuestion(traceId, path = defaultQuestionsPath()) {
27
+ if (!traceId) return;
28
+ const all = readAll(path);
29
+ if (!(String(traceId) in all)) return;
30
+ delete all[String(traceId)];
31
+ writeAll(path, all);
32
+ }
33
+
34
+ function readAll(path) {
35
+ try {
36
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
37
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
38
+ } catch {
39
+ return {};
40
+ }
41
+ }
42
+
43
+ function writeAll(path, questions) {
44
+ mkdirSync(dirname(path), { recursive: true });
45
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
46
+ writeFileSync(tmp, `${JSON.stringify(questions, null, 2)}\n`);
47
+ renameSync(tmp, path);
48
+ }
package/src/queue.mjs CHANGED
@@ -28,14 +28,19 @@ function emptyState() {
28
28
  }
29
29
 
30
30
  export class FlambeQueue {
31
- constructor({ baseUrl, traceId, path = defaultQueuePath() }) {
31
+ constructor({ baseUrl, traceId, agentId, path = defaultQueuePath() }) {
32
32
  this.baseUrl = baseUrl;
33
33
  this.traceId = Number(traceId);
34
+ this.agentId = agentId;
34
35
  this.path = path;
35
36
  }
36
37
 
37
38
  matches(entry) {
38
- return entry.baseUrl === this.baseUrl && entry.traceId === this.traceId;
39
+ return entry.baseUrl === this.baseUrl
40
+ && entry.traceId === this.traceId
41
+ // Version-1 entries created before identity was recorded remain replayable.
42
+ // New entries may only be replayed by the worker that queued them.
43
+ && (entry.agentId === undefined || entry.agentId === this.agentId);
39
44
  }
40
45
 
41
46
  async read() {
@@ -70,6 +75,7 @@ export class FlambeQueue {
70
75
  type,
71
76
  baseUrl: this.baseUrl,
72
77
  traceId: this.traceId,
78
+ ...(this.agentId ? { agentId: this.agentId } : {}),
73
79
  ...(localId ? { localId } : {}),
74
80
  ...payload,
75
81
  });
@@ -107,7 +113,13 @@ export class FlambeQueue {
107
113
  } else if (entry.type === 'end' || entry.type === 'suspend' || entry.type === 'resume') {
108
114
  const activityId = state.aliases[entry.activityId] ?? entry.activityId;
109
115
  const post = { end, suspend, resume }[entry.type];
110
- await post({ activityId, message: entry.message, timestamp: entry.timestamp });
116
+ await post({
117
+ activityId,
118
+ message: entry.message,
119
+ timestamp: entry.timestamp,
120
+ force: entry.force ?? false,
121
+ phase: entry.phase,
122
+ });
111
123
  if (entry.type === 'end') delete state.aliases[entry.activityId];
112
124
  } else if (entry.type === 'observe') {
113
125
  await observe(entry.input);
@@ -0,0 +1,23 @@
1
+ /** Explicit transport boundary; never infer local-only intent from a URL. */
2
+ export function runtimeConfig(env) {
3
+ const mode = env.FLAMBE_RUNTIME_MODE?.trim();
4
+ if (!mode) return {};
5
+ if (!['cloud', 'local_self_contained'].includes(mode)) {
6
+ throw new Error('FLAMBE_RUNTIME_MODE must be cloud or local_self_contained');
7
+ }
8
+ return { runtimeMode: mode };
9
+ }
10
+
11
+ export function assertRuntimeUrl(value, mode) {
12
+ if (mode !== 'local_self_contained') return;
13
+ let url;
14
+ try { url = new URL(value); } catch {
15
+ throw new Error('Self-contained mode requires a loopback HTTP URL');
16
+ }
17
+ // Numeric addresses avoid DNS resolution changing the trust boundary.
18
+ if (!['http:', 'https:'].includes(url.protocol)
19
+ || !['127.0.0.1', '[::1]'].includes(url.hostname)
20
+ || url.username || url.password) {
21
+ throw new Error('Self-contained mode requires 127.0.0.1 or [::1]; remote destinations are disabled');
22
+ }
23
+ }