@hasna/loops 0.3.5 → 0.3.6

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/dist/lib/store.js CHANGED
@@ -234,6 +234,82 @@ function parseDuration(input) {
234
234
  return Math.round(value * multiplier);
235
235
  }
236
236
 
237
+ // src/lib/goal/types.ts
238
+ var GOAL_TERMINAL = ["budgetLimited", "complete", "cancelled"];
239
+ var GOAL_OBJECTIVE_MAX_CHARS = 4000;
240
+
241
+ // src/lib/goal/status.ts
242
+ function isTerminal(status) {
243
+ return GOAL_TERMINAL.includes(status);
244
+ }
245
+ function nodeBudgetExhausted(node) {
246
+ return node.tokenBudget !== undefined && node.tokensUsed >= node.tokenBudget;
247
+ }
248
+ function readyNodeKeys(plan) {
249
+ if (plan.status !== "active")
250
+ return [];
251
+ const byKey = new Map(plan.nodes.map((node) => [node.key, node]));
252
+ return plan.nodes.filter((node) => {
253
+ if (node.status !== "pending")
254
+ return false;
255
+ if (nodeBudgetExhausted(node))
256
+ return false;
257
+ return node.dependsOn.every((dependency) => byKey.get(dependency)?.status === "complete");
258
+ }).sort((a, b) => b.priority - a.priority || a.sequence - b.sequence || a.key.localeCompare(b.key)).map((node) => node.key);
259
+ }
260
+ function rollupSummary(nodes) {
261
+ const rollup = {
262
+ total: nodes.length,
263
+ pending: 0,
264
+ active: 0,
265
+ paused: 0,
266
+ blocked: 0,
267
+ usageLimited: 0,
268
+ budgetLimited: 0,
269
+ complete: 0,
270
+ cancelled: 0
271
+ };
272
+ for (const node of nodes) {
273
+ if (node.status in rollup) {
274
+ rollup[node.status] += 1;
275
+ }
276
+ }
277
+ return rollup;
278
+ }
279
+ function assertGoalTransition(from, to) {
280
+ if (isTerminal(from) && from !== to) {
281
+ throw new Error(`cannot transition terminal goal status ${from} to ${to}`);
282
+ }
283
+ }
284
+ function assertAcyclicNodes(nodes) {
285
+ const byKey = new Map(nodes.map((node) => [node.key, node]));
286
+ const visiting = new Set;
287
+ const visited = new Set;
288
+ function visit(key) {
289
+ if (visited.has(key))
290
+ return;
291
+ if (visiting.has(key))
292
+ throw new Error(`goal dependency cycle includes node ${key}`);
293
+ const node = byKey.get(key);
294
+ if (!node)
295
+ throw new Error(`goal plan references missing node ${key}`);
296
+ visiting.add(key);
297
+ for (const dependency of node.dependsOn) {
298
+ if (!byKey.has(dependency))
299
+ throw new Error(`goal node ${key} depends on missing node ${dependency}`);
300
+ visit(dependency);
301
+ }
302
+ visiting.delete(key);
303
+ visited.add(key);
304
+ }
305
+ for (const node of nodes)
306
+ visit(node.key);
307
+ }
308
+ function updateReadyFlags(nodes, planStatus) {
309
+ const ready = new Set(readyNodeKeys({ status: planStatus, nodes }));
310
+ return nodes.map((node) => ({ ...node, ready: ready.has(node.key) }));
311
+ }
312
+
237
313
  // src/lib/workflow-spec.ts
238
314
  function assertObject(value, label) {
239
315
  if (!value || typeof value !== "object" || Array.isArray(value))
@@ -243,6 +319,35 @@ function assertString(value, label) {
243
319
  if (typeof value !== "string" || value.trim() === "")
244
320
  throw new Error(`${label} must be a non-empty string`);
245
321
  }
322
+ function optionalPositiveInteger(value, label) {
323
+ if (value === undefined)
324
+ return;
325
+ if (!Number.isInteger(value) || value <= 0)
326
+ throw new Error(`${label} must be a positive integer`);
327
+ return value;
328
+ }
329
+ function normalizeGoalSpec(value, label = "goal") {
330
+ if (value === undefined)
331
+ return;
332
+ assertObject(value, label);
333
+ assertString(value.objective, `${label}.objective`);
334
+ const objective = value.objective.trim();
335
+ if (objective.length > GOAL_OBJECTIVE_MAX_CHARS) {
336
+ throw new Error(`${label}.objective must be ${GOAL_OBJECTIVE_MAX_CHARS} characters or fewer`);
337
+ }
338
+ const autoExecute = value.autoExecute === undefined ? undefined : String(value.autoExecute);
339
+ if (autoExecute !== undefined && !["off", "readyOnly", "aiDirected"].includes(autoExecute)) {
340
+ throw new Error(`${label}.autoExecute must be off, readyOnly, or aiDirected`);
341
+ }
342
+ return {
343
+ objective,
344
+ tokenBudget: optionalPositiveInteger(value.tokenBudget, `${label}.tokenBudget`),
345
+ maxTurns: optionalPositiveInteger(value.maxTurns, `${label}.maxTurns`),
346
+ maxTokens: optionalPositiveInteger(value.maxTokens, `${label}.maxTokens`),
347
+ model: typeof value.model === "string" && value.model.trim() ? value.model.trim() : undefined,
348
+ autoExecute
349
+ };
350
+ }
246
351
  function validateTarget(value, label) {
247
352
  assertObject(value, label);
248
353
  if (value.type === "command") {
@@ -269,6 +374,7 @@ function validateTarget(value, label) {
269
374
  }
270
375
  function normalizeCreateWorkflowInput(input) {
271
376
  assertString(input.name, "workflow.name");
377
+ const goal = normalizeGoalSpec(input.goal, "goal");
272
378
  if (!Array.isArray(input.steps) || input.steps.length === 0)
273
379
  throw new Error("workflow.steps must contain at least one step");
274
380
  const seen = new Set;
@@ -281,6 +387,7 @@ function normalizeCreateWorkflowInput(input) {
281
387
  return {
282
388
  ...step,
283
389
  id: step.id,
390
+ goal: normalizeGoalSpec(step.goal, `workflow.steps[${index}].goal`),
284
391
  target: validateTarget(step.target, `workflow.steps[${index}].target`),
285
392
  dependsOn: step.dependsOn ?? [],
286
393
  continueOnFailure: step.continueOnFailure ?? false
@@ -295,7 +402,7 @@ function normalizeCreateWorkflowInput(input) {
295
402
  }
296
403
  }
297
404
  workflowExecutionOrder({ steps });
298
- return { ...input, name: input.name.trim(), version: input.version ?? 1, steps };
405
+ return { ...input, name: input.name.trim(), goal, version: input.version ?? 1, steps };
299
406
  }
300
407
  function workflowExecutionOrder(workflow) {
301
408
  const byId = new Map(workflow.steps.map((step) => [step.id, step]));
@@ -331,6 +438,7 @@ function workflowBodyFromJson(value, fallbackName) {
331
438
  return normalizeCreateWorkflowInput({
332
439
  name: rawName,
333
440
  description: typeof value.description === "string" ? value.description : undefined,
441
+ goal: normalizeGoalSpec(value.goal, "goal"),
334
442
  version: typeof value.version === "number" ? value.version : undefined,
335
443
  steps: value.steps
336
444
  });
@@ -345,6 +453,7 @@ function rowToLoop(row) {
345
453
  status: row.status,
346
454
  schedule: JSON.parse(row.schedule_json),
347
455
  target: JSON.parse(row.target_json),
456
+ goal: row.goal_json ? JSON.parse(row.goal_json) : undefined,
348
457
  machine: row.machine_json ? JSON.parse(row.machine_json) : undefined,
349
458
  nextRunAt: row.next_run_at ?? undefined,
350
459
  retryScheduledFor: row.retry_scheduled_for ?? undefined,
@@ -377,6 +486,7 @@ function rowToRun(row) {
377
486
  stdout: row.stdout ?? undefined,
378
487
  stderr: row.stderr ?? undefined,
379
488
  error: row.error ?? undefined,
489
+ goalRunId: row.goal_run_id ?? undefined,
380
490
  createdAt: row.created_at,
381
491
  updatedAt: row.updated_at
382
492
  };
@@ -388,6 +498,7 @@ function rowToWorkflow(row) {
388
498
  description: row.description ?? undefined,
389
499
  version: row.version,
390
500
  status: row.status,
501
+ goal: row.goal_json ? JSON.parse(row.goal_json) : undefined,
391
502
  steps: JSON.parse(row.steps_json),
392
503
  createdAt: row.created_at,
393
504
  updatedAt: row.updated_at
@@ -407,6 +518,7 @@ function rowToWorkflowRun(row) {
407
518
  finishedAt: row.finished_at ?? undefined,
408
519
  durationMs: row.duration_ms ?? undefined,
409
520
  error: row.error ?? undefined,
521
+ goalRunId: row.goal_run_id ?? undefined,
410
522
  createdAt: row.created_at,
411
523
  updatedAt: row.updated_at
412
524
  };
@@ -428,6 +540,68 @@ function rowToWorkflowStepRun(row) {
428
540
  error: row.error ?? undefined,
429
541
  accountProfile: row.account_profile ?? undefined,
430
542
  accountTool: row.account_tool ?? undefined,
543
+ goalRunId: row.goal_run_id ?? undefined,
544
+ createdAt: row.created_at,
545
+ updatedAt: row.updated_at
546
+ };
547
+ }
548
+ function rowToGoal(row) {
549
+ return {
550
+ goalId: row.id,
551
+ planId: row.plan_id,
552
+ objective: row.objective,
553
+ status: row.status,
554
+ tokenBudget: row.token_budget ?? undefined,
555
+ tokensUsed: row.tokens_used,
556
+ timeUsedSeconds: row.time_used_seconds,
557
+ autoExecute: row.auto_execute,
558
+ maxTokens: row.max_tokens ?? undefined,
559
+ sourceType: row.source_type ?? undefined,
560
+ sourceId: row.source_id ?? undefined,
561
+ loopId: row.loop_id ?? undefined,
562
+ loopRunId: row.loop_run_id ?? undefined,
563
+ workflowId: row.workflow_id ?? undefined,
564
+ workflowRunId: row.workflow_run_id ?? undefined,
565
+ workflowStepId: row.workflow_step_id ?? undefined,
566
+ createdAt: row.created_at,
567
+ updatedAt: row.updated_at
568
+ };
569
+ }
570
+ function rowToGoalPlanNode(row) {
571
+ return {
572
+ nodeId: row.id,
573
+ planId: row.plan_id,
574
+ key: row.key,
575
+ sequence: row.sequence,
576
+ priority: row.priority,
577
+ objective: row.objective,
578
+ status: row.status,
579
+ ready: row.ready === 1,
580
+ tokenBudget: row.token_budget ?? undefined,
581
+ tokensUsed: row.tokens_used,
582
+ timeUsedSeconds: row.time_used_seconds,
583
+ dependsOn: JSON.parse(row.depends_on_json),
584
+ createdAt: row.created_at,
585
+ updatedAt: row.updated_at
586
+ };
587
+ }
588
+ function rowToGoalRun(row) {
589
+ return {
590
+ runId: row.id,
591
+ goalId: row.goal_id,
592
+ planId: row.plan_id,
593
+ loopId: row.loop_id ?? undefined,
594
+ loopRunId: row.loop_run_id ?? undefined,
595
+ workflowId: row.workflow_id ?? undefined,
596
+ workflowRunId: row.workflow_run_id ?? undefined,
597
+ workflowStepId: row.workflow_step_id ?? undefined,
598
+ turn: row.turn,
599
+ phase: row.phase,
600
+ status: row.status,
601
+ nodeKey: row.node_key ?? undefined,
602
+ tokensUsed: row.tokens_used,
603
+ evidence: row.evidence_json ? JSON.parse(row.evidence_json) : undefined,
604
+ rawResponse: row.raw_response_json ? JSON.parse(row.raw_response_json) : undefined,
431
605
  createdAt: row.created_at,
432
606
  updatedAt: row.updated_at
433
607
  };
@@ -488,6 +662,7 @@ class Store {
488
662
  status TEXT NOT NULL,
489
663
  schedule_json TEXT NOT NULL,
490
664
  target_json TEXT NOT NULL,
665
+ goal_json TEXT,
491
666
  machine_json TEXT,
492
667
  next_run_at TEXT,
493
668
  retry_scheduled_for TEXT,
@@ -521,6 +696,7 @@ class Store {
521
696
  stdout TEXT,
522
697
  stderr TEXT,
523
698
  error TEXT,
699
+ goal_run_id TEXT,
524
700
  created_at TEXT NOT NULL,
525
701
  updated_at TEXT NOT NULL,
526
702
  UNIQUE(loop_id, scheduled_for)
@@ -545,6 +721,7 @@ class Store {
545
721
  description TEXT,
546
722
  version INTEGER NOT NULL,
547
723
  status TEXT NOT NULL,
724
+ goal_json TEXT,
548
725
  steps_json TEXT NOT NULL,
549
726
  created_at TEXT NOT NULL,
550
727
  updated_at TEXT NOT NULL
@@ -565,6 +742,7 @@ class Store {
565
742
  finished_at TEXT,
566
743
  duration_ms INTEGER,
567
744
  error TEXT,
745
+ goal_run_id TEXT,
568
746
  created_at TEXT NOT NULL,
569
747
  updated_at TEXT NOT NULL
570
748
  );
@@ -591,6 +769,7 @@ class Store {
591
769
  error TEXT,
592
770
  account_profile TEXT,
593
771
  account_tool TEXT,
772
+ goal_run_id TEXT,
594
773
  created_at TEXT NOT NULL,
595
774
  updated_at TEXT NOT NULL,
596
775
  UNIQUE(workflow_run_id, step_id)
@@ -609,15 +788,100 @@ class Store {
609
788
  UNIQUE(workflow_run_id, sequence)
610
789
  );
611
790
  CREATE INDEX IF NOT EXISTS idx_workflow_events_run_sequence ON workflow_events(workflow_run_id, sequence);
791
+
792
+ CREATE TABLE IF NOT EXISTS goals (
793
+ id TEXT PRIMARY KEY,
794
+ plan_id TEXT NOT NULL,
795
+ objective TEXT NOT NULL,
796
+ status TEXT NOT NULL,
797
+ token_budget INTEGER,
798
+ tokens_used INTEGER NOT NULL,
799
+ time_used_seconds INTEGER NOT NULL,
800
+ auto_execute TEXT NOT NULL,
801
+ max_tokens INTEGER,
802
+ source_type TEXT,
803
+ source_id TEXT,
804
+ loop_id TEXT,
805
+ loop_run_id TEXT,
806
+ workflow_id TEXT,
807
+ workflow_run_id TEXT,
808
+ workflow_step_id TEXT,
809
+ created_at TEXT NOT NULL,
810
+ updated_at TEXT NOT NULL
811
+ );
812
+ CREATE INDEX IF NOT EXISTS idx_goals_status_updated ON goals(status, updated_at DESC);
813
+ CREATE INDEX IF NOT EXISTS idx_goals_loop_run ON goals(loop_run_id);
814
+ CREATE INDEX IF NOT EXISTS idx_goals_workflow_run ON goals(workflow_run_id);
815
+ CREATE INDEX IF NOT EXISTS idx_goals_source ON goals(source_type, source_id);
816
+
817
+ CREATE TABLE IF NOT EXISTS goal_plan_nodes (
818
+ id TEXT PRIMARY KEY,
819
+ goal_id TEXT NOT NULL REFERENCES goals(id) ON DELETE CASCADE,
820
+ plan_id TEXT NOT NULL,
821
+ key TEXT NOT NULL,
822
+ sequence INTEGER NOT NULL,
823
+ priority INTEGER NOT NULL,
824
+ objective TEXT NOT NULL,
825
+ status TEXT NOT NULL,
826
+ ready INTEGER NOT NULL,
827
+ token_budget INTEGER,
828
+ tokens_used INTEGER NOT NULL,
829
+ time_used_seconds INTEGER NOT NULL,
830
+ depends_on_json TEXT NOT NULL,
831
+ created_at TEXT NOT NULL,
832
+ updated_at TEXT NOT NULL,
833
+ UNIQUE(plan_id, key)
834
+ );
835
+ CREATE INDEX IF NOT EXISTS idx_goal_plan_nodes_goal_sequence ON goal_plan_nodes(goal_id, sequence);
836
+ CREATE INDEX IF NOT EXISTS idx_goal_plan_nodes_status ON goal_plan_nodes(status);
837
+
838
+ CREATE TABLE IF NOT EXISTS goal_runs (
839
+ id TEXT PRIMARY KEY,
840
+ goal_id TEXT NOT NULL REFERENCES goals(id) ON DELETE CASCADE,
841
+ plan_id TEXT NOT NULL,
842
+ loop_id TEXT,
843
+ loop_run_id TEXT,
844
+ workflow_id TEXT,
845
+ workflow_run_id TEXT,
846
+ workflow_step_id TEXT,
847
+ turn INTEGER NOT NULL,
848
+ phase TEXT NOT NULL,
849
+ status TEXT NOT NULL,
850
+ node_key TEXT,
851
+ tokens_used INTEGER NOT NULL,
852
+ evidence_json TEXT,
853
+ raw_response_json TEXT,
854
+ created_at TEXT NOT NULL,
855
+ updated_at TEXT NOT NULL
856
+ );
857
+ CREATE INDEX IF NOT EXISTS idx_goal_runs_goal_created ON goal_runs(goal_id, created_at);
858
+ CREATE INDEX IF NOT EXISTS idx_goal_runs_loop_run ON goal_runs(loop_run_id);
859
+ CREATE INDEX IF NOT EXISTS idx_goal_runs_workflow_run ON goal_runs(workflow_run_id);
612
860
  `);
613
861
  try {
614
862
  this.db.query("ALTER TABLE loops ADD COLUMN machine_json TEXT").run();
615
863
  } catch {}
864
+ try {
865
+ this.db.query("ALTER TABLE loops ADD COLUMN goal_json TEXT").run();
866
+ } catch {}
867
+ try {
868
+ this.db.query("ALTER TABLE loop_runs ADD COLUMN goal_run_id TEXT").run();
869
+ } catch {}
870
+ try {
871
+ this.db.query("ALTER TABLE workflow_specs ADD COLUMN goal_json TEXT").run();
872
+ } catch {}
873
+ try {
874
+ this.db.query("ALTER TABLE workflow_runs ADD COLUMN goal_run_id TEXT").run();
875
+ } catch {}
616
876
  try {
617
877
  this.db.query("ALTER TABLE workflow_step_runs ADD COLUMN pid INTEGER").run();
618
878
  } catch {}
879
+ try {
880
+ this.db.query("ALTER TABLE workflow_step_runs ADD COLUMN goal_run_id TEXT").run();
881
+ } catch {}
619
882
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
620
883
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0002_loop_machines", nowIso());
884
+ this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0003_goals", nowIso());
621
885
  }
622
886
  assertDaemonLeaseFence(opts = {}, now = nowIso()) {
623
887
  if (!opts.daemonLeaseId)
@@ -635,6 +899,7 @@ class Store {
635
899
  status: "active",
636
900
  schedule: input.schedule,
637
901
  target: input.target,
902
+ goal: input.goal,
638
903
  machine: input.machine,
639
904
  nextRunAt: initialNextRun(input.schedule, from),
640
905
  catchUp: input.catchUp ?? "latest",
@@ -648,8 +913,8 @@ class Store {
648
913
  updatedAt: now
649
914
  };
650
915
  this.db.query(`INSERT INTO loops (id, name, description, status, schedule_json, target_json, machine_json, next_run_at, retry_scheduled_for,
651
- catch_up, catch_up_limit, overlap, max_attempts, retry_delay_ms, lease_ms, expires_at, created_at, updated_at)
652
- VALUES ($id, $name, $description, $status, $schedule, $target, $machine, $nextRun, NULL, $catchUp, $catchUpLimit,
916
+ goal_json, catch_up, catch_up_limit, overlap, max_attempts, retry_delay_ms, lease_ms, expires_at, created_at, updated_at)
917
+ VALUES ($id, $name, $description, $status, $schedule, $target, $machine, $nextRun, NULL, $goal, $catchUp, $catchUpLimit,
653
918
  $overlap, $maxAttempts, $retryDelay, $leaseMs, $expiresAt, $created, $updated)`).run({
654
919
  $id: loop.id,
655
920
  $name: loop.name,
@@ -658,6 +923,7 @@ class Store {
658
923
  $schedule: JSON.stringify(loop.schedule),
659
924
  $target: JSON.stringify(loop.target),
660
925
  $machine: loop.machine ? JSON.stringify(loop.machine) : null,
926
+ $goal: loop.goal ? JSON.stringify(loop.goal) : null,
661
927
  $nextRun: loop.nextRunAt ?? null,
662
928
  $catchUp: loop.catchUp,
663
929
  $catchUpLimit: loop.catchUpLimit,
@@ -737,17 +1003,19 @@ class Store {
737
1003
  description: normalized.description,
738
1004
  version: normalized.version ?? 1,
739
1005
  status: "active",
1006
+ goal: normalized.goal,
740
1007
  steps: normalized.steps,
741
1008
  createdAt: now,
742
1009
  updatedAt: now
743
1010
  };
744
- this.db.query(`INSERT INTO workflow_specs (id, name, description, version, status, steps_json, created_at, updated_at)
745
- VALUES ($id, $name, $description, $version, $status, $steps, $created, $updated)`).run({
1011
+ this.db.query(`INSERT INTO workflow_specs (id, name, description, version, status, goal_json, steps_json, created_at, updated_at)
1012
+ VALUES ($id, $name, $description, $version, $status, $goal, $steps, $created, $updated)`).run({
746
1013
  $id: workflow.id,
747
1014
  $name: workflow.name,
748
1015
  $description: workflow.description ?? null,
749
1016
  $version: workflow.version,
750
1017
  $status: workflow.status,
1018
+ $goal: workflow.goal ? JSON.stringify(workflow.goal) : null,
751
1019
  $steps: JSON.stringify(workflow.steps),
752
1020
  $created: workflow.createdAt,
753
1021
  $updated: workflow.updatedAt
@@ -781,6 +1049,277 @@ class Store {
781
1049
  throw new Error(`workflow not found after archive: ${workflow.id}`);
782
1050
  return archived;
783
1051
  }
1052
+ createGoal(input, opts = {}) {
1053
+ const now = nowIso();
1054
+ this.db.exec("BEGIN IMMEDIATE");
1055
+ try {
1056
+ this.assertDaemonLeaseFence(opts, now);
1057
+ const id = genId();
1058
+ this.db.query(`INSERT INTO goals (id, plan_id, objective, status, token_budget, tokens_used, time_used_seconds, auto_execute,
1059
+ max_tokens, source_type, source_id, loop_id, loop_run_id, workflow_id, workflow_run_id, workflow_step_id,
1060
+ created_at, updated_at)
1061
+ VALUES ($id, $planId, $objective, 'active', $tokenBudget, 0, 0, $autoExecute, $maxTokens, $sourceType,
1062
+ $sourceId, $loopId, $loopRunId, $workflowId, $workflowRunId, $workflowStepId, $created, $updated)`).run({
1063
+ $id: id,
1064
+ $planId: id,
1065
+ $objective: input.objective,
1066
+ $tokenBudget: input.tokenBudget ?? null,
1067
+ $autoExecute: input.autoExecute ?? "readyOnly",
1068
+ $maxTokens: input.maxTokens ?? input.tokenBudget ?? null,
1069
+ $sourceType: input.sourceType ?? null,
1070
+ $sourceId: input.sourceId ?? null,
1071
+ $loopId: input.loopId ?? null,
1072
+ $loopRunId: input.loopRunId ?? null,
1073
+ $workflowId: input.workflowId ?? null,
1074
+ $workflowRunId: input.workflowRunId ?? null,
1075
+ $workflowStepId: input.workflowStepId ?? null,
1076
+ $created: now,
1077
+ $updated: now
1078
+ });
1079
+ this.db.exec("COMMIT");
1080
+ return this.requireGoal(id);
1081
+ } catch (error) {
1082
+ try {
1083
+ this.db.exec("ROLLBACK");
1084
+ } catch {}
1085
+ throw error;
1086
+ }
1087
+ }
1088
+ getGoal(id) {
1089
+ const row = this.db.query("SELECT * FROM goals WHERE id = ?").get(id);
1090
+ return row ? rowToGoal(row) : undefined;
1091
+ }
1092
+ requireGoal(id) {
1093
+ const goal = this.getGoal(id);
1094
+ if (!goal)
1095
+ throw new Error(`goal not found: ${id}`);
1096
+ return goal;
1097
+ }
1098
+ findGoalByLoop(idOrName) {
1099
+ const loop = this.getLoop(idOrName) ?? this.findLoopByName(idOrName);
1100
+ if (!loop)
1101
+ return;
1102
+ const row = this.db.query("SELECT * FROM goals WHERE loop_id = ? ORDER BY created_at DESC LIMIT 1").get(loop.id);
1103
+ return row ? rowToGoal(row) : undefined;
1104
+ }
1105
+ findGoalByRunId(id) {
1106
+ const direct = this.getGoal(id);
1107
+ if (direct)
1108
+ return direct;
1109
+ const event = this.db.query("SELECT * FROM goal_runs WHERE id = ?").get(id);
1110
+ if (event)
1111
+ return this.getGoal(event.goal_id);
1112
+ const row = this.db.query(`SELECT * FROM goals
1113
+ WHERE loop_run_id = ? OR workflow_run_id = ? OR workflow_step_id = ?
1114
+ ORDER BY created_at DESC LIMIT 1`).get(id, id, id);
1115
+ return row ? rowToGoal(row) : undefined;
1116
+ }
1117
+ findGoalByContext(context) {
1118
+ if (context.loopRunId) {
1119
+ const row = this.db.query(`SELECT * FROM goals
1120
+ WHERE loop_run_id = ? AND (? IS NULL OR workflow_step_id = ?)
1121
+ ORDER BY created_at DESC LIMIT 1`).get(context.loopRunId, context.workflowStepId ?? null, context.workflowStepId ?? null);
1122
+ if (row)
1123
+ return rowToGoal(row);
1124
+ }
1125
+ if (context.workflowRunId) {
1126
+ const row = this.db.query(`SELECT * FROM goals
1127
+ WHERE workflow_run_id = ? AND (? IS NULL OR workflow_step_id = ?)
1128
+ ORDER BY created_at DESC LIMIT 1`).get(context.workflowRunId, context.workflowStepId ?? null, context.workflowStepId ?? null);
1129
+ if (row)
1130
+ return rowToGoal(row);
1131
+ }
1132
+ if (context.sourceType && context.sourceId) {
1133
+ const row = this.db.query("SELECT * FROM goals WHERE source_type = ? AND source_id = ? ORDER BY created_at DESC LIMIT 1").get(context.sourceType, context.sourceId);
1134
+ if (row)
1135
+ return rowToGoal(row);
1136
+ }
1137
+ return;
1138
+ }
1139
+ listGoals(opts = {}) {
1140
+ const limit = opts.limit ?? 100;
1141
+ const rows = opts.status ? this.db.query("SELECT * FROM goals WHERE status = ? ORDER BY created_at DESC LIMIT ?").all(opts.status, limit) : this.db.query("SELECT * FROM goals ORDER BY created_at DESC LIMIT ?").all(limit);
1142
+ return rows.map(rowToGoal);
1143
+ }
1144
+ createGoalPlanNodes(goalId, nodes, opts = {}) {
1145
+ const goal = this.requireGoal(goalId);
1146
+ const now = nowIso();
1147
+ const materialized = nodes.map((node, sequence) => ({
1148
+ nodeId: genId(),
1149
+ planId: goal.planId,
1150
+ key: node.key,
1151
+ sequence,
1152
+ priority: node.priority ?? 0,
1153
+ objective: node.objective,
1154
+ status: "pending",
1155
+ ready: false,
1156
+ tokenBudget: node.tokenBudget,
1157
+ tokensUsed: 0,
1158
+ timeUsedSeconds: 0,
1159
+ dependsOn: node.dependsOn ?? [],
1160
+ createdAt: now,
1161
+ updatedAt: now
1162
+ }));
1163
+ const withReady = updateReadyFlags(materialized, "active");
1164
+ this.db.exec("BEGIN IMMEDIATE");
1165
+ try {
1166
+ this.assertDaemonLeaseFence(opts, now);
1167
+ for (const node of withReady) {
1168
+ this.db.query(`INSERT OR IGNORE INTO goal_plan_nodes (id, goal_id, plan_id, key, sequence, priority, objective, status, ready,
1169
+ token_budget, tokens_used, time_used_seconds, depends_on_json, created_at, updated_at)
1170
+ VALUES ($id, $goalId, $planId, $key, $sequence, $priority, $objective, $status, $ready, $tokenBudget,
1171
+ $tokensUsed, $timeUsedSeconds, $dependsOn, $created, $updated)`).run({
1172
+ $id: node.nodeId,
1173
+ $goalId: goal.goalId,
1174
+ $planId: goal.planId,
1175
+ $key: node.key,
1176
+ $sequence: node.sequence,
1177
+ $priority: node.priority,
1178
+ $objective: node.objective,
1179
+ $status: node.status,
1180
+ $ready: node.ready ? 1 : 0,
1181
+ $tokenBudget: node.tokenBudget ?? null,
1182
+ $tokensUsed: node.tokensUsed,
1183
+ $timeUsedSeconds: node.timeUsedSeconds,
1184
+ $dependsOn: JSON.stringify(node.dependsOn),
1185
+ $created: node.createdAt,
1186
+ $updated: node.updatedAt
1187
+ });
1188
+ }
1189
+ this.db.exec("COMMIT");
1190
+ } catch (error) {
1191
+ try {
1192
+ this.db.exec("ROLLBACK");
1193
+ } catch {}
1194
+ throw error;
1195
+ }
1196
+ return this.listGoalPlanNodes(goalId);
1197
+ }
1198
+ listGoalPlanNodes(goalIdOrPlanId) {
1199
+ const rows = this.db.query("SELECT * FROM goal_plan_nodes WHERE goal_id = ? OR plan_id = ? ORDER BY sequence ASC").all(goalIdOrPlanId, goalIdOrPlanId);
1200
+ return rows.map(rowToGoalPlanNode);
1201
+ }
1202
+ updateGoalStatus(goalId, status, opts = {}) {
1203
+ const current = this.requireGoal(goalId);
1204
+ assertGoalTransition(current.status, status);
1205
+ const now = (opts.now ?? new Date).toISOString();
1206
+ this.db.query(`UPDATE goals SET status=$status, updated_at=$updated
1207
+ WHERE id=$id
1208
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1209
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1210
+ ))`).run({
1211
+ $id: goalId,
1212
+ $status: status,
1213
+ $updated: now,
1214
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1215
+ $now: now
1216
+ });
1217
+ return this.requireGoal(goalId);
1218
+ }
1219
+ addGoalUsage(goalId, tokens, timeUsedSeconds = 0, opts = {}) {
1220
+ const now = (opts.now ?? new Date).toISOString();
1221
+ this.db.query(`UPDATE goals
1222
+ SET tokens_used=tokens_used + $tokens, time_used_seconds=time_used_seconds + $seconds, updated_at=$updated
1223
+ WHERE id=$id
1224
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1225
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1226
+ ))`).run({
1227
+ $id: goalId,
1228
+ $tokens: tokens,
1229
+ $seconds: timeUsedSeconds,
1230
+ $updated: now,
1231
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1232
+ $now: now
1233
+ });
1234
+ return this.requireGoal(goalId);
1235
+ }
1236
+ updateGoalPlanNode(goalId, key, patch, opts = {}) {
1237
+ const now = (opts.now ?? new Date).toISOString();
1238
+ this.db.query(`UPDATE goal_plan_nodes
1239
+ SET status=COALESCE($status, status),
1240
+ tokens_used=COALESCE($tokensUsed, tokens_used),
1241
+ time_used_seconds=COALESCE($timeUsedSeconds, time_used_seconds),
1242
+ ready=COALESCE($ready, ready),
1243
+ updated_at=$updated
1244
+ WHERE goal_id=$goalId AND key=$key
1245
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1246
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1247
+ ))`).run({
1248
+ $goalId: goalId,
1249
+ $key: key,
1250
+ $status: patch.status ?? null,
1251
+ $tokensUsed: patch.tokensUsed ?? null,
1252
+ $timeUsedSeconds: patch.timeUsedSeconds ?? null,
1253
+ $ready: patch.ready === undefined ? null : patch.ready ? 1 : 0,
1254
+ $updated: now,
1255
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1256
+ $now: now
1257
+ });
1258
+ const node = this.listGoalPlanNodes(goalId).find((entry) => entry.key === key);
1259
+ if (!node)
1260
+ throw new Error(`goal node not found: ${goalId}/${key}`);
1261
+ return node;
1262
+ }
1263
+ recordGoalEvent(input, opts = {}) {
1264
+ const goal = this.requireGoal(input.goalId);
1265
+ const now = nowIso();
1266
+ this.db.exec("BEGIN IMMEDIATE");
1267
+ try {
1268
+ this.assertDaemonLeaseFence(opts, now);
1269
+ const previous = this.db.query("SELECT MAX(turn) AS turn FROM goal_runs WHERE goal_id = ?").get(goal.goalId);
1270
+ const turn = input.turn ?? (previous?.turn ?? 0) + 1;
1271
+ const id = genId();
1272
+ this.db.query(`INSERT INTO goal_runs (id, goal_id, plan_id, loop_id, loop_run_id, workflow_id, workflow_run_id, workflow_step_id,
1273
+ turn, phase, status, node_key, tokens_used, evidence_json, raw_response_json, created_at, updated_at)
1274
+ VALUES ($id, $goalId, $planId, $loopId, $loopRunId, $workflowId, $workflowRunId, $workflowStepId,
1275
+ $turn, $phase, $status, $nodeKey, $tokensUsed, $evidence, $rawResponse, $created, $updated)`).run({
1276
+ $id: id,
1277
+ $goalId: goal.goalId,
1278
+ $planId: goal.planId,
1279
+ $loopId: goal.loopId ?? null,
1280
+ $loopRunId: goal.loopRunId ?? null,
1281
+ $workflowId: goal.workflowId ?? null,
1282
+ $workflowRunId: goal.workflowRunId ?? null,
1283
+ $workflowStepId: goal.workflowStepId ?? null,
1284
+ $turn: turn,
1285
+ $phase: input.phase,
1286
+ $status: input.status,
1287
+ $nodeKey: input.nodeKey ?? null,
1288
+ $tokensUsed: input.tokensUsed ?? 0,
1289
+ $evidence: input.evidence ? JSON.stringify(input.evidence) : null,
1290
+ $rawResponse: input.rawResponse === undefined ? null : JSON.stringify(input.rawResponse),
1291
+ $created: now,
1292
+ $updated: now
1293
+ });
1294
+ if (input.tokensUsed && input.tokensUsed > 0) {
1295
+ this.db.query("UPDATE goals SET tokens_used=tokens_used + ?, updated_at=? WHERE id=?").run(input.tokensUsed, now, goal.goalId);
1296
+ }
1297
+ this.db.exec("COMMIT");
1298
+ const event = this.db.query("SELECT * FROM goal_runs WHERE id = ?").get(id);
1299
+ if (!event)
1300
+ throw new Error(`goal run not found after record: ${id}`);
1301
+ return rowToGoalRun(event);
1302
+ } catch (error) {
1303
+ try {
1304
+ this.db.exec("ROLLBACK");
1305
+ } catch {}
1306
+ throw error;
1307
+ }
1308
+ }
1309
+ listGoalRuns(opts = {}) {
1310
+ const limit = opts.limit ?? 200;
1311
+ let rows;
1312
+ if (opts.goalId) {
1313
+ rows = this.db.query("SELECT * FROM goal_runs WHERE goal_id = ? ORDER BY created_at ASC LIMIT ?").all(opts.goalId, limit);
1314
+ } else if (opts.runId) {
1315
+ rows = this.db.query(`SELECT * FROM goal_runs
1316
+ WHERE id = ? OR loop_run_id = ? OR workflow_run_id = ?
1317
+ ORDER BY created_at ASC LIMIT ?`).all(opts.runId, opts.runId, opts.runId, limit);
1318
+ } else {
1319
+ rows = this.db.query("SELECT * FROM goal_runs ORDER BY created_at DESC LIMIT ?").all(limit);
1320
+ }
1321
+ return rows.map(rowToGoalRun);
1322
+ }
784
1323
  createWorkflowRun(input) {
785
1324
  const now = nowIso();
786
1325
  if (input.idempotencyKey) {