@hasna/loops 0.3.57 → 0.3.59

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/cli/index.js CHANGED
@@ -1260,12 +1260,21 @@ class Store {
1260
1260
  if (!row)
1261
1261
  throw new Error("daemon lease lost");
1262
1262
  }
1263
+ assertNoNestedWorkflowGoal(target, goal) {
1264
+ if (!goal || target.type !== "workflow")
1265
+ return;
1266
+ const workflow = this.getWorkflow(target.workflowId);
1267
+ if (workflow?.goal) {
1268
+ throw new Error(`workflow loop cannot define a loop-level goal when workflow ${workflow.name} already has a top-level goal; remove one goal wrapper`);
1269
+ }
1270
+ }
1263
1271
  createLoop(input, from = new Date) {
1264
1272
  const now = nowIso();
1265
1273
  const target = input.target.type === "workflow" ? input.target : normalizeCreateWorkflowInput({
1266
1274
  name: "loop-target-validation",
1267
1275
  steps: [{ id: "target", target: input.target }]
1268
1276
  }).steps[0].target;
1277
+ this.assertNoNestedWorkflowGoal(target, input.goal);
1269
1278
  const loop = {
1270
1279
  id: genId(),
1271
1280
  name: input.name,
@@ -1437,6 +1446,9 @@ class Store {
1437
1446
  if (this.hasRunningRun(current.id))
1438
1447
  throw new Error(`refusing to retarget running loop: ${current.id}`);
1439
1448
  const workflow = this.requireWorkflow(workflowId);
1449
+ if (current.goal && workflow.goal) {
1450
+ throw new Error(`workflow loop cannot retarget ${current.name} to workflow ${workflow.name} because both define top-level goals`);
1451
+ }
1440
1452
  const target = { ...current.target, workflowId: workflow.id };
1441
1453
  if (opts.workflowTimeoutMs !== undefined)
1442
1454
  target.timeoutMs = opts.workflowTimeoutMs;
@@ -1475,6 +1487,9 @@ class Store {
1475
1487
  throw new Error(`loop is not a workflow loop: ${idOrName}`);
1476
1488
  if (this.hasRunningRun(current.id))
1477
1489
  throw new Error(`refusing to retarget running loop: ${current.id}`);
1490
+ if (current.goal && normalized.goal) {
1491
+ throw new Error(`workflow loop cannot retarget ${current.name} to a workflow that also defines a top-level goal`);
1492
+ }
1478
1493
  const previousWorkflow = this.requireWorkflow(current.target.workflowId);
1479
1494
  const workflow = {
1480
1495
  id: genId(),
@@ -1528,6 +1543,70 @@ class Store {
1528
1543
  throw error;
1529
1544
  }
1530
1545
  }
1546
+ cloneWorkflowWithoutGoalAndRetargetLoop(idOrName, opts) {
1547
+ const updated = (opts.now ?? new Date).toISOString();
1548
+ this.db.exec("BEGIN IMMEDIATE");
1549
+ try {
1550
+ const current = this.requireUniqueLoop(idOrName);
1551
+ if (current.target.type !== "workflow")
1552
+ throw new Error(`loop is not a workflow loop: ${idOrName}`);
1553
+ if (this.hasRunningRun(current.id))
1554
+ throw new Error(`refusing to retarget running loop: ${current.id}`);
1555
+ if (!current.goal)
1556
+ throw new Error(`workflow loop ${current.name} has no loop-level goal wrapper`);
1557
+ const previousWorkflow = this.requireWorkflow(current.target.workflowId);
1558
+ if (!previousWorkflow.goal)
1559
+ throw new Error(`workflow ${previousWorkflow.name} has no top-level goal wrapper`);
1560
+ const workflow = {
1561
+ id: genId(),
1562
+ name: opts.workflowName,
1563
+ description: previousWorkflow.description,
1564
+ version: previousWorkflow.version,
1565
+ status: "active",
1566
+ steps: previousWorkflow.steps,
1567
+ createdAt: updated,
1568
+ updatedAt: updated
1569
+ };
1570
+ this.db.query(`INSERT INTO workflow_specs (id, name, description, version, status, goal_json, steps_json, created_at, updated_at)
1571
+ VALUES ($id, $name, $description, $version, $status, NULL, $steps, $created, $updated)`).run({
1572
+ $id: workflow.id,
1573
+ $name: workflow.name,
1574
+ $description: workflow.description ?? null,
1575
+ $version: workflow.version,
1576
+ $status: workflow.status,
1577
+ $steps: JSON.stringify(workflow.steps),
1578
+ $created: workflow.createdAt,
1579
+ $updated: workflow.updatedAt
1580
+ });
1581
+ const target = { ...current.target, workflowId: workflow.id };
1582
+ if (opts.workflowTimeoutMs !== undefined)
1583
+ target.timeoutMs = opts.workflowTimeoutMs;
1584
+ const res = this.db.query(`UPDATE loops SET target_json=$target, updated_at=$updated
1585
+ WHERE id=$id
1586
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1587
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1588
+ ))`).run({
1589
+ $id: current.id,
1590
+ $target: JSON.stringify(target),
1591
+ $updated: updated,
1592
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1593
+ $now: updated
1594
+ });
1595
+ if (res.changes !== 1)
1596
+ throw new Error("daemon lease lost");
1597
+ const archivedOld = opts.archiveOld ? this.archiveWorkflowIfUnreferenced(previousWorkflow.id, updated) : undefined;
1598
+ this.db.exec("COMMIT");
1599
+ const loop = this.getLoop(current.id);
1600
+ if (!loop)
1601
+ throw new Error(`loop not found after retarget: ${current.id}`);
1602
+ return { loop, workflow, previousWorkflow, archivedOld };
1603
+ } catch (error) {
1604
+ try {
1605
+ this.db.exec("ROLLBACK");
1606
+ } catch {}
1607
+ throw error;
1608
+ }
1609
+ }
1531
1610
  renameLoop(id, name, opts = {}) {
1532
1611
  const current = this.getLoop(id);
1533
1612
  if (!current)
@@ -4733,6 +4812,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
4733
4812
  const workflowWithoutGoal = { ...workflow, goal: undefined };
4734
4813
  return runGoal(store, workflow.goal, {
4735
4814
  ...opts,
4815
+ model: opts.goalModel,
4736
4816
  context: {
4737
4817
  loopId: opts.loop?.id,
4738
4818
  loopName: opts.loop?.name,
@@ -4825,6 +4905,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
4825
4905
  if (step.goal) {
4826
4906
  result = await runGoal(store, step.goal, {
4827
4907
  ...opts,
4908
+ model: opts.goalModel,
4828
4909
  target: targetWithStepAccount(step),
4829
4910
  signal: controller.signal,
4830
4911
  context: {
@@ -4966,6 +5047,7 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
4966
5047
  if (loop.goal) {
4967
5048
  return runGoal(store, loop.goal, {
4968
5049
  ...opts,
5050
+ model: opts.goalModel,
4969
5051
  target: loop.target,
4970
5052
  context: {
4971
5053
  loopId: loop.id,
@@ -4987,8 +5069,10 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
4987
5069
  }
4988
5070
  }
4989
5071
  if (loop.goal) {
5072
+ const workflowForLoopGoal = workflow.goal ? { ...workflow, goal: undefined } : workflow;
4990
5073
  return runGoal(store, loop.goal, {
4991
5074
  ...opts,
5075
+ model: opts.goalModel,
4992
5076
  context: {
4993
5077
  loopId: loop.id,
4994
5078
  loopName: loop.name,
@@ -4997,7 +5081,7 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
4997
5081
  workflowId: workflow.id,
4998
5082
  workflowName: workflow.name
4999
5083
  },
5000
- executeNode: async (node) => executeWorkflow(store, workflow, {
5084
+ executeNode: async (node) => executeWorkflow(store, workflowForLoopGoal, {
5001
5085
  ...opts,
5002
5086
  loop,
5003
5087
  loopRun: run,
@@ -6477,7 +6561,7 @@ function buildScriptInventoryReport(store, opts = {}) {
6477
6561
  // package.json
6478
6562
  var package_default = {
6479
6563
  name: "@hasna/loops",
6480
- version: "0.3.57",
6564
+ version: "0.3.59",
6481
6565
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
6482
6566
  type: "module",
6483
6567
  main: "dist/index.js",
@@ -8617,6 +8701,47 @@ function workflowTimeoutMigrationName(workflow, timeoutMs) {
8617
8701
  const suffix = randomUUID().replace(/-/g, "").slice(0, 8);
8618
8702
  return `${workflow.name}-${policy}-${suffix}`;
8619
8703
  }
8704
+ function workflowGoalWrapperMigrationName(workflow) {
8705
+ const suffix = randomUUID().replace(/-/g, "").slice(0, 8);
8706
+ return `${workflow.name}-no-workflow-goal-${suffix}`;
8707
+ }
8708
+ function publicMigrationGoalSummary(goal) {
8709
+ if (!goal)
8710
+ return;
8711
+ return {
8712
+ objective: redact(goal.objective),
8713
+ model: goal.model,
8714
+ tokenBudget: goal.tokenBudget,
8715
+ maxTurns: goal.maxTurns
8716
+ };
8717
+ }
8718
+ function publicMigrationWorkflowSummary(workflow) {
8719
+ return {
8720
+ id: workflow.id,
8721
+ name: workflow.name,
8722
+ version: workflow.version,
8723
+ status: workflow.status,
8724
+ stepCount: workflow.steps.length,
8725
+ hasGoal: Boolean(workflow.goal),
8726
+ goal: publicMigrationGoalSummary(workflow.goal)
8727
+ };
8728
+ }
8729
+ function publicMigrationLoopSummary(loop) {
8730
+ return {
8731
+ id: loop.id,
8732
+ name: loop.name,
8733
+ status: loop.status,
8734
+ archivedAt: loop.archivedAt,
8735
+ nextRunAt: loop.nextRunAt,
8736
+ hasGoal: Boolean(loop.goal),
8737
+ goal: publicMigrationGoalSummary(loop.goal),
8738
+ target: loop.target.type === "workflow" ? { type: "workflow", workflowId: loop.target.workflowId, timeoutMs: loop.target.timeoutMs } : { type: loop.target.type }
8739
+ };
8740
+ }
8741
+ function migrationErrorReason(error) {
8742
+ const message = error instanceof Error ? error.message : String(error);
8743
+ return redact(message, 240);
8744
+ }
8620
8745
  function printTextOutput(value) {
8621
8746
  for (const line of textOutputBlocks(value, { indent: " " }))
8622
8747
  console.log(line);
@@ -11240,19 +11365,30 @@ workflows.command("migrate-agent-timeouts").description("append-only migrate act
11240
11365
  });
11241
11366
  continue;
11242
11367
  }
11243
- const migrated = store.createAndRetargetWorkflowLoop(loop.id, migration.body, {
11244
- workflowTimeoutMs: timeoutMs,
11245
- archiveOld: Boolean(opts.archiveOld)
11246
- });
11247
- rows.push({
11248
- loop: publicLoop(migrated.loop),
11249
- previousWorkflow: publicWorkflow(migrated.previousWorkflow),
11250
- workflow: publicWorkflow(migrated.workflow),
11251
- archivedOld: migrated.archivedOld ? publicWorkflow(migrated.archivedOld) : undefined,
11252
- status: "migrated",
11253
- agentStepIds: migration.agentStepIds,
11254
- timeoutMs
11255
- });
11368
+ try {
11369
+ const migrated = store.createAndRetargetWorkflowLoop(loop.id, migration.body, {
11370
+ workflowTimeoutMs: timeoutMs,
11371
+ archiveOld: Boolean(opts.archiveOld)
11372
+ });
11373
+ rows.push({
11374
+ loop: publicLoop(migrated.loop),
11375
+ previousWorkflow: publicWorkflow(migrated.previousWorkflow),
11376
+ workflow: publicWorkflow(migrated.workflow),
11377
+ archivedOld: migrated.archivedOld ? publicWorkflow(migrated.archivedOld) : undefined,
11378
+ status: "migrated",
11379
+ agentStepIds: migration.agentStepIds,
11380
+ timeoutMs
11381
+ });
11382
+ } catch (error) {
11383
+ rows.push({
11384
+ loop: publicLoop(loop),
11385
+ workflow: publicWorkflow(workflow),
11386
+ status: "blocked",
11387
+ reason: migrationErrorReason(error),
11388
+ agentStepIds: migration.agentStepIds,
11389
+ timeoutMs
11390
+ });
11391
+ }
11256
11392
  }
11257
11393
  const summary = {
11258
11394
  apply: Boolean(opts.apply),
@@ -11268,6 +11404,94 @@ workflows.command("migrate-agent-timeouts").description("append-only migrate act
11268
11404
  store.close();
11269
11405
  }
11270
11406
  });
11407
+ workflows.command("migrate-goal-wrappers").description("append-only migrate active workflow loops away from workflow-level goal wrappers").option("--loop <idOrName>", "migrate only one loop instead of all active workflow loops").option("--apply", "create new workflow specs and retarget eligible loops").option("--archive-old", "archive old workflow specs after retargeting when no active loops still reference them").action((opts) => {
11408
+ const store = new Store;
11409
+ try {
11410
+ const candidateLoops = opts.loop ? [store.requireUniqueLoop(opts.loop)] : store.listLoops({ status: "active", limit: 1e4 }).filter((loop) => loop.target.type === "workflow");
11411
+ const rows = [];
11412
+ for (const loop of candidateLoops) {
11413
+ if (loop.archivedAt) {
11414
+ rows.push({ loop: publicMigrationLoopSummary(loop), status: "skipped", reason: "loop is archived" });
11415
+ continue;
11416
+ }
11417
+ if (loop.target.type !== "workflow") {
11418
+ rows.push({ loop: publicMigrationLoopSummary(loop), status: "skipped", reason: "loop is not a workflow loop" });
11419
+ continue;
11420
+ }
11421
+ const workflow = store.requireWorkflow(loop.target.workflowId);
11422
+ if (!loop.goal) {
11423
+ rows.push({
11424
+ loop: publicMigrationLoopSummary(loop),
11425
+ workflow: publicMigrationWorkflowSummary(workflow),
11426
+ status: "skipped",
11427
+ reason: "loop has no loop-level goal wrapper"
11428
+ });
11429
+ continue;
11430
+ }
11431
+ if (!workflow.goal) {
11432
+ rows.push({
11433
+ loop: publicMigrationLoopSummary(loop),
11434
+ workflow: publicMigrationWorkflowSummary(workflow),
11435
+ status: "skipped",
11436
+ reason: "workflow has no top-level goal wrapper"
11437
+ });
11438
+ continue;
11439
+ }
11440
+ const nextWorkflowName = workflowGoalWrapperMigrationName(workflow);
11441
+ if (store.hasRunningRun(loop.id)) {
11442
+ rows.push({
11443
+ loop: publicMigrationLoopSummary(loop),
11444
+ workflow: publicMigrationWorkflowSummary(workflow),
11445
+ status: "blocked",
11446
+ reason: "loop has a running run; retry after it finishes"
11447
+ });
11448
+ continue;
11449
+ }
11450
+ if (!opts.apply) {
11451
+ rows.push({
11452
+ loop: publicMigrationLoopSummary(loop),
11453
+ workflow: publicMigrationWorkflowSummary(workflow),
11454
+ status: "would_migrate",
11455
+ nextWorkflowName,
11456
+ removedGoal: publicMigrationGoalSummary(workflow.goal)
11457
+ });
11458
+ continue;
11459
+ }
11460
+ try {
11461
+ const migrated = store.cloneWorkflowWithoutGoalAndRetargetLoop(loop.id, {
11462
+ workflowName: nextWorkflowName,
11463
+ workflowTimeoutMs: loop.target.timeoutMs,
11464
+ archiveOld: Boolean(opts.archiveOld)
11465
+ });
11466
+ rows.push({
11467
+ loop: publicMigrationLoopSummary(migrated.loop),
11468
+ previousWorkflow: publicMigrationWorkflowSummary(migrated.previousWorkflow),
11469
+ workflow: publicMigrationWorkflowSummary(migrated.workflow),
11470
+ archivedOld: migrated.archivedOld ? publicMigrationWorkflowSummary(migrated.archivedOld) : undefined,
11471
+ status: "migrated"
11472
+ });
11473
+ } catch (error) {
11474
+ rows.push({
11475
+ loop: publicMigrationLoopSummary(loop),
11476
+ workflow: publicMigrationWorkflowSummary(workflow),
11477
+ status: "blocked",
11478
+ reason: migrationErrorReason(error)
11479
+ });
11480
+ }
11481
+ }
11482
+ const summary = {
11483
+ apply: Boolean(opts.apply),
11484
+ total: rows.length,
11485
+ migrated: rows.filter((row) => row.status === "migrated").length,
11486
+ wouldMigrate: rows.filter((row) => row.status === "would_migrate").length,
11487
+ blocked: rows.filter((row) => row.status === "blocked").length,
11488
+ skipped: rows.filter((row) => row.status === "skipped").length
11489
+ };
11490
+ print({ summary, rows }, opts.apply ? `migrated=${summary.migrated} blocked=${summary.blocked} skipped=${summary.skipped}` : `would_migrate=${summary.wouldMigrate} blocked=${summary.blocked} skipped=${summary.skipped}`);
11491
+ } finally {
11492
+ store.close();
11493
+ }
11494
+ });
11271
11495
  workflows.command("archive <idOrName>").action((idOrName) => {
11272
11496
  const store = new Store;
11273
11497
  try {
@@ -1260,12 +1260,21 @@ class Store {
1260
1260
  if (!row)
1261
1261
  throw new Error("daemon lease lost");
1262
1262
  }
1263
+ assertNoNestedWorkflowGoal(target, goal) {
1264
+ if (!goal || target.type !== "workflow")
1265
+ return;
1266
+ const workflow = this.getWorkflow(target.workflowId);
1267
+ if (workflow?.goal) {
1268
+ throw new Error(`workflow loop cannot define a loop-level goal when workflow ${workflow.name} already has a top-level goal; remove one goal wrapper`);
1269
+ }
1270
+ }
1263
1271
  createLoop(input, from = new Date) {
1264
1272
  const now = nowIso();
1265
1273
  const target = input.target.type === "workflow" ? input.target : normalizeCreateWorkflowInput({
1266
1274
  name: "loop-target-validation",
1267
1275
  steps: [{ id: "target", target: input.target }]
1268
1276
  }).steps[0].target;
1277
+ this.assertNoNestedWorkflowGoal(target, input.goal);
1269
1278
  const loop = {
1270
1279
  id: genId(),
1271
1280
  name: input.name,
@@ -1437,6 +1446,9 @@ class Store {
1437
1446
  if (this.hasRunningRun(current.id))
1438
1447
  throw new Error(`refusing to retarget running loop: ${current.id}`);
1439
1448
  const workflow = this.requireWorkflow(workflowId);
1449
+ if (current.goal && workflow.goal) {
1450
+ throw new Error(`workflow loop cannot retarget ${current.name} to workflow ${workflow.name} because both define top-level goals`);
1451
+ }
1440
1452
  const target = { ...current.target, workflowId: workflow.id };
1441
1453
  if (opts.workflowTimeoutMs !== undefined)
1442
1454
  target.timeoutMs = opts.workflowTimeoutMs;
@@ -1475,6 +1487,9 @@ class Store {
1475
1487
  throw new Error(`loop is not a workflow loop: ${idOrName}`);
1476
1488
  if (this.hasRunningRun(current.id))
1477
1489
  throw new Error(`refusing to retarget running loop: ${current.id}`);
1490
+ if (current.goal && normalized.goal) {
1491
+ throw new Error(`workflow loop cannot retarget ${current.name} to a workflow that also defines a top-level goal`);
1492
+ }
1478
1493
  const previousWorkflow = this.requireWorkflow(current.target.workflowId);
1479
1494
  const workflow = {
1480
1495
  id: genId(),
@@ -1528,6 +1543,70 @@ class Store {
1528
1543
  throw error;
1529
1544
  }
1530
1545
  }
1546
+ cloneWorkflowWithoutGoalAndRetargetLoop(idOrName, opts) {
1547
+ const updated = (opts.now ?? new Date).toISOString();
1548
+ this.db.exec("BEGIN IMMEDIATE");
1549
+ try {
1550
+ const current = this.requireUniqueLoop(idOrName);
1551
+ if (current.target.type !== "workflow")
1552
+ throw new Error(`loop is not a workflow loop: ${idOrName}`);
1553
+ if (this.hasRunningRun(current.id))
1554
+ throw new Error(`refusing to retarget running loop: ${current.id}`);
1555
+ if (!current.goal)
1556
+ throw new Error(`workflow loop ${current.name} has no loop-level goal wrapper`);
1557
+ const previousWorkflow = this.requireWorkflow(current.target.workflowId);
1558
+ if (!previousWorkflow.goal)
1559
+ throw new Error(`workflow ${previousWorkflow.name} has no top-level goal wrapper`);
1560
+ const workflow = {
1561
+ id: genId(),
1562
+ name: opts.workflowName,
1563
+ description: previousWorkflow.description,
1564
+ version: previousWorkflow.version,
1565
+ status: "active",
1566
+ steps: previousWorkflow.steps,
1567
+ createdAt: updated,
1568
+ updatedAt: updated
1569
+ };
1570
+ this.db.query(`INSERT INTO workflow_specs (id, name, description, version, status, goal_json, steps_json, created_at, updated_at)
1571
+ VALUES ($id, $name, $description, $version, $status, NULL, $steps, $created, $updated)`).run({
1572
+ $id: workflow.id,
1573
+ $name: workflow.name,
1574
+ $description: workflow.description ?? null,
1575
+ $version: workflow.version,
1576
+ $status: workflow.status,
1577
+ $steps: JSON.stringify(workflow.steps),
1578
+ $created: workflow.createdAt,
1579
+ $updated: workflow.updatedAt
1580
+ });
1581
+ const target = { ...current.target, workflowId: workflow.id };
1582
+ if (opts.workflowTimeoutMs !== undefined)
1583
+ target.timeoutMs = opts.workflowTimeoutMs;
1584
+ const res = this.db.query(`UPDATE loops SET target_json=$target, updated_at=$updated
1585
+ WHERE id=$id
1586
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1587
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1588
+ ))`).run({
1589
+ $id: current.id,
1590
+ $target: JSON.stringify(target),
1591
+ $updated: updated,
1592
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1593
+ $now: updated
1594
+ });
1595
+ if (res.changes !== 1)
1596
+ throw new Error("daemon lease lost");
1597
+ const archivedOld = opts.archiveOld ? this.archiveWorkflowIfUnreferenced(previousWorkflow.id, updated) : undefined;
1598
+ this.db.exec("COMMIT");
1599
+ const loop = this.getLoop(current.id);
1600
+ if (!loop)
1601
+ throw new Error(`loop not found after retarget: ${current.id}`);
1602
+ return { loop, workflow, previousWorkflow, archivedOld };
1603
+ } catch (error) {
1604
+ try {
1605
+ this.db.exec("ROLLBACK");
1606
+ } catch {}
1607
+ throw error;
1608
+ }
1609
+ }
1531
1610
  renameLoop(id, name, opts = {}) {
1532
1611
  const current = this.getLoop(id);
1533
1612
  if (!current)
@@ -4617,6 +4696,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
4617
4696
  const workflowWithoutGoal = { ...workflow, goal: undefined };
4618
4697
  return runGoal(store, workflow.goal, {
4619
4698
  ...opts,
4699
+ model: opts.goalModel,
4620
4700
  context: {
4621
4701
  loopId: opts.loop?.id,
4622
4702
  loopName: opts.loop?.name,
@@ -4709,6 +4789,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
4709
4789
  if (step.goal) {
4710
4790
  result = await runGoal(store, step.goal, {
4711
4791
  ...opts,
4792
+ model: opts.goalModel,
4712
4793
  target: targetWithStepAccount(step),
4713
4794
  signal: controller.signal,
4714
4795
  context: {
@@ -4850,6 +4931,7 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
4850
4931
  if (loop.goal) {
4851
4932
  return runGoal(store, loop.goal, {
4852
4933
  ...opts,
4934
+ model: opts.goalModel,
4853
4935
  target: loop.target,
4854
4936
  context: {
4855
4937
  loopId: loop.id,
@@ -4871,8 +4953,10 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
4871
4953
  }
4872
4954
  }
4873
4955
  if (loop.goal) {
4956
+ const workflowForLoopGoal = workflow.goal ? { ...workflow, goal: undefined } : workflow;
4874
4957
  return runGoal(store, loop.goal, {
4875
4958
  ...opts,
4959
+ model: opts.goalModel,
4876
4960
  context: {
4877
4961
  loopId: loop.id,
4878
4962
  loopName: loop.name,
@@ -4881,7 +4965,7 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
4881
4965
  workflowId: workflow.id,
4882
4966
  workflowName: workflow.name
4883
4967
  },
4884
- executeNode: async (node) => executeWorkflow(store, workflow, {
4968
+ executeNode: async (node) => executeWorkflow(store, workflowForLoopGoal, {
4885
4969
  ...opts,
4886
4970
  loop,
4887
4971
  loopRun: run,
@@ -5611,7 +5695,7 @@ function enableStartup(result) {
5611
5695
  // package.json
5612
5696
  var package_default = {
5613
5697
  name: "@hasna/loops",
5614
- version: "0.3.57",
5698
+ version: "0.3.59",
5615
5699
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5616
5700
  type: "module",
5617
5701
  main: "dist/index.js",
package/dist/index.js CHANGED
@@ -1258,12 +1258,21 @@ class Store {
1258
1258
  if (!row)
1259
1259
  throw new Error("daemon lease lost");
1260
1260
  }
1261
+ assertNoNestedWorkflowGoal(target, goal) {
1262
+ if (!goal || target.type !== "workflow")
1263
+ return;
1264
+ const workflow = this.getWorkflow(target.workflowId);
1265
+ if (workflow?.goal) {
1266
+ throw new Error(`workflow loop cannot define a loop-level goal when workflow ${workflow.name} already has a top-level goal; remove one goal wrapper`);
1267
+ }
1268
+ }
1261
1269
  createLoop(input, from = new Date) {
1262
1270
  const now = nowIso();
1263
1271
  const target = input.target.type === "workflow" ? input.target : normalizeCreateWorkflowInput({
1264
1272
  name: "loop-target-validation",
1265
1273
  steps: [{ id: "target", target: input.target }]
1266
1274
  }).steps[0].target;
1275
+ this.assertNoNestedWorkflowGoal(target, input.goal);
1267
1276
  const loop = {
1268
1277
  id: genId(),
1269
1278
  name: input.name,
@@ -1435,6 +1444,9 @@ class Store {
1435
1444
  if (this.hasRunningRun(current.id))
1436
1445
  throw new Error(`refusing to retarget running loop: ${current.id}`);
1437
1446
  const workflow = this.requireWorkflow(workflowId);
1447
+ if (current.goal && workflow.goal) {
1448
+ throw new Error(`workflow loop cannot retarget ${current.name} to workflow ${workflow.name} because both define top-level goals`);
1449
+ }
1438
1450
  const target = { ...current.target, workflowId: workflow.id };
1439
1451
  if (opts.workflowTimeoutMs !== undefined)
1440
1452
  target.timeoutMs = opts.workflowTimeoutMs;
@@ -1473,6 +1485,9 @@ class Store {
1473
1485
  throw new Error(`loop is not a workflow loop: ${idOrName}`);
1474
1486
  if (this.hasRunningRun(current.id))
1475
1487
  throw new Error(`refusing to retarget running loop: ${current.id}`);
1488
+ if (current.goal && normalized.goal) {
1489
+ throw new Error(`workflow loop cannot retarget ${current.name} to a workflow that also defines a top-level goal`);
1490
+ }
1476
1491
  const previousWorkflow = this.requireWorkflow(current.target.workflowId);
1477
1492
  const workflow = {
1478
1493
  id: genId(),
@@ -1526,6 +1541,70 @@ class Store {
1526
1541
  throw error;
1527
1542
  }
1528
1543
  }
1544
+ cloneWorkflowWithoutGoalAndRetargetLoop(idOrName, opts) {
1545
+ const updated = (opts.now ?? new Date).toISOString();
1546
+ this.db.exec("BEGIN IMMEDIATE");
1547
+ try {
1548
+ const current = this.requireUniqueLoop(idOrName);
1549
+ if (current.target.type !== "workflow")
1550
+ throw new Error(`loop is not a workflow loop: ${idOrName}`);
1551
+ if (this.hasRunningRun(current.id))
1552
+ throw new Error(`refusing to retarget running loop: ${current.id}`);
1553
+ if (!current.goal)
1554
+ throw new Error(`workflow loop ${current.name} has no loop-level goal wrapper`);
1555
+ const previousWorkflow = this.requireWorkflow(current.target.workflowId);
1556
+ if (!previousWorkflow.goal)
1557
+ throw new Error(`workflow ${previousWorkflow.name} has no top-level goal wrapper`);
1558
+ const workflow = {
1559
+ id: genId(),
1560
+ name: opts.workflowName,
1561
+ description: previousWorkflow.description,
1562
+ version: previousWorkflow.version,
1563
+ status: "active",
1564
+ steps: previousWorkflow.steps,
1565
+ createdAt: updated,
1566
+ updatedAt: updated
1567
+ };
1568
+ this.db.query(`INSERT INTO workflow_specs (id, name, description, version, status, goal_json, steps_json, created_at, updated_at)
1569
+ VALUES ($id, $name, $description, $version, $status, NULL, $steps, $created, $updated)`).run({
1570
+ $id: workflow.id,
1571
+ $name: workflow.name,
1572
+ $description: workflow.description ?? null,
1573
+ $version: workflow.version,
1574
+ $status: workflow.status,
1575
+ $steps: JSON.stringify(workflow.steps),
1576
+ $created: workflow.createdAt,
1577
+ $updated: workflow.updatedAt
1578
+ });
1579
+ const target = { ...current.target, workflowId: workflow.id };
1580
+ if (opts.workflowTimeoutMs !== undefined)
1581
+ target.timeoutMs = opts.workflowTimeoutMs;
1582
+ const res = this.db.query(`UPDATE loops SET target_json=$target, updated_at=$updated
1583
+ WHERE id=$id
1584
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1585
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1586
+ ))`).run({
1587
+ $id: current.id,
1588
+ $target: JSON.stringify(target),
1589
+ $updated: updated,
1590
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1591
+ $now: updated
1592
+ });
1593
+ if (res.changes !== 1)
1594
+ throw new Error("daemon lease lost");
1595
+ const archivedOld = opts.archiveOld ? this.archiveWorkflowIfUnreferenced(previousWorkflow.id, updated) : undefined;
1596
+ this.db.exec("COMMIT");
1597
+ const loop = this.getLoop(current.id);
1598
+ if (!loop)
1599
+ throw new Error(`loop not found after retarget: ${current.id}`);
1600
+ return { loop, workflow, previousWorkflow, archivedOld };
1601
+ } catch (error) {
1602
+ try {
1603
+ this.db.exec("ROLLBACK");
1604
+ } catch {}
1605
+ throw error;
1606
+ }
1607
+ }
1529
1608
  renameLoop(id, name, opts = {}) {
1530
1609
  const current = this.getLoop(id);
1531
1610
  if (!current)
@@ -4506,7 +4585,7 @@ function publicGoalRun(run) {
4506
4585
  // package.json
4507
4586
  var package_default = {
4508
4587
  name: "@hasna/loops",
4509
- version: "0.3.57",
4588
+ version: "0.3.59",
4510
4589
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4511
4590
  type: "module",
4512
4591
  main: "dist/index.js",
@@ -5047,6 +5126,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
5047
5126
  const workflowWithoutGoal = { ...workflow, goal: undefined };
5048
5127
  return runGoal(store, workflow.goal, {
5049
5128
  ...opts,
5129
+ model: opts.goalModel,
5050
5130
  context: {
5051
5131
  loopId: opts.loop?.id,
5052
5132
  loopName: opts.loop?.name,
@@ -5139,6 +5219,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
5139
5219
  if (step.goal) {
5140
5220
  result = await runGoal(store, step.goal, {
5141
5221
  ...opts,
5222
+ model: opts.goalModel,
5142
5223
  target: targetWithStepAccount(step),
5143
5224
  signal: controller.signal,
5144
5225
  context: {
@@ -5280,6 +5361,7 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
5280
5361
  if (loop.goal) {
5281
5362
  return runGoal(store, loop.goal, {
5282
5363
  ...opts,
5364
+ model: opts.goalModel,
5283
5365
  target: loop.target,
5284
5366
  context: {
5285
5367
  loopId: loop.id,
@@ -5301,8 +5383,10 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
5301
5383
  }
5302
5384
  }
5303
5385
  if (loop.goal) {
5386
+ const workflowForLoopGoal = workflow.goal ? { ...workflow, goal: undefined } : workflow;
5304
5387
  return runGoal(store, loop.goal, {
5305
5388
  ...opts,
5389
+ model: opts.goalModel,
5306
5390
  context: {
5307
5391
  loopId: loop.id,
5308
5392
  loopName: loop.name,
@@ -5311,7 +5395,7 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
5311
5395
  workflowId: workflow.id,
5312
5396
  workflowName: workflow.name
5313
5397
  },
5314
- executeNode: async (node) => executeWorkflow(store, workflow, {
5398
+ executeNode: async (node) => executeWorkflow(store, workflowForLoopGoal, {
5315
5399
  ...opts,
5316
5400
  loop,
5317
5401
  loopRun: run,
@@ -1,7 +1,9 @@
1
+ import type { LanguageModel } from "ai";
1
2
  import type { ExecutableTarget, ExecutorResult, Loop, LoopMachineRef, LoopRun, PersistGuardOptions } from "../types.js";
2
3
  export interface ExecuteOptions extends PersistGuardOptions {
3
4
  maxOutputBytes?: number;
4
5
  env?: NodeJS.ProcessEnv;
6
+ goalModel?: LanguageModel;
5
7
  log?: (message: string) => void;
6
8
  signal?: AbortSignal;
7
9
  onSpawn?: (pid: number) => void;
@@ -71,6 +71,7 @@ export declare class Store {
71
71
  private addColumnIfMissing;
72
72
  private createWorkflowRunBackfillIndexes;
73
73
  private assertDaemonLeaseFence;
74
+ private assertNoNestedWorkflowGoal;
74
75
  createLoop(input: CreateLoopInput, from?: Date): Loop;
75
76
  getLoop(id: string): Loop | undefined;
76
77
  findLoopByName(name: string): Loop | undefined;
@@ -98,6 +99,16 @@ export declare class Store {
98
99
  previousWorkflow: WorkflowSpec;
99
100
  archivedOld?: WorkflowSpec;
100
101
  };
102
+ cloneWorkflowWithoutGoalAndRetargetLoop(idOrName: string, opts: DaemonLeaseFence & {
103
+ workflowName: string;
104
+ workflowTimeoutMs?: TimeoutMs;
105
+ archiveOld?: boolean;
106
+ }): {
107
+ loop: Loop;
108
+ workflow: WorkflowSpec;
109
+ previousWorkflow: WorkflowSpec;
110
+ archivedOld?: WorkflowSpec;
111
+ };
101
112
  renameLoop(id: string, name: string, opts?: DaemonLeaseFence): Loop;
102
113
  archiveLoop(idOrName: string): Loop;
103
114
  unarchiveLoop(idOrName: string): Loop;
package/dist/lib/store.js CHANGED
@@ -1258,12 +1258,21 @@ class Store {
1258
1258
  if (!row)
1259
1259
  throw new Error("daemon lease lost");
1260
1260
  }
1261
+ assertNoNestedWorkflowGoal(target, goal) {
1262
+ if (!goal || target.type !== "workflow")
1263
+ return;
1264
+ const workflow = this.getWorkflow(target.workflowId);
1265
+ if (workflow?.goal) {
1266
+ throw new Error(`workflow loop cannot define a loop-level goal when workflow ${workflow.name} already has a top-level goal; remove one goal wrapper`);
1267
+ }
1268
+ }
1261
1269
  createLoop(input, from = new Date) {
1262
1270
  const now = nowIso();
1263
1271
  const target = input.target.type === "workflow" ? input.target : normalizeCreateWorkflowInput({
1264
1272
  name: "loop-target-validation",
1265
1273
  steps: [{ id: "target", target: input.target }]
1266
1274
  }).steps[0].target;
1275
+ this.assertNoNestedWorkflowGoal(target, input.goal);
1267
1276
  const loop = {
1268
1277
  id: genId(),
1269
1278
  name: input.name,
@@ -1435,6 +1444,9 @@ class Store {
1435
1444
  if (this.hasRunningRun(current.id))
1436
1445
  throw new Error(`refusing to retarget running loop: ${current.id}`);
1437
1446
  const workflow = this.requireWorkflow(workflowId);
1447
+ if (current.goal && workflow.goal) {
1448
+ throw new Error(`workflow loop cannot retarget ${current.name} to workflow ${workflow.name} because both define top-level goals`);
1449
+ }
1438
1450
  const target = { ...current.target, workflowId: workflow.id };
1439
1451
  if (opts.workflowTimeoutMs !== undefined)
1440
1452
  target.timeoutMs = opts.workflowTimeoutMs;
@@ -1473,6 +1485,9 @@ class Store {
1473
1485
  throw new Error(`loop is not a workflow loop: ${idOrName}`);
1474
1486
  if (this.hasRunningRun(current.id))
1475
1487
  throw new Error(`refusing to retarget running loop: ${current.id}`);
1488
+ if (current.goal && normalized.goal) {
1489
+ throw new Error(`workflow loop cannot retarget ${current.name} to a workflow that also defines a top-level goal`);
1490
+ }
1476
1491
  const previousWorkflow = this.requireWorkflow(current.target.workflowId);
1477
1492
  const workflow = {
1478
1493
  id: genId(),
@@ -1526,6 +1541,70 @@ class Store {
1526
1541
  throw error;
1527
1542
  }
1528
1543
  }
1544
+ cloneWorkflowWithoutGoalAndRetargetLoop(idOrName, opts) {
1545
+ const updated = (opts.now ?? new Date).toISOString();
1546
+ this.db.exec("BEGIN IMMEDIATE");
1547
+ try {
1548
+ const current = this.requireUniqueLoop(idOrName);
1549
+ if (current.target.type !== "workflow")
1550
+ throw new Error(`loop is not a workflow loop: ${idOrName}`);
1551
+ if (this.hasRunningRun(current.id))
1552
+ throw new Error(`refusing to retarget running loop: ${current.id}`);
1553
+ if (!current.goal)
1554
+ throw new Error(`workflow loop ${current.name} has no loop-level goal wrapper`);
1555
+ const previousWorkflow = this.requireWorkflow(current.target.workflowId);
1556
+ if (!previousWorkflow.goal)
1557
+ throw new Error(`workflow ${previousWorkflow.name} has no top-level goal wrapper`);
1558
+ const workflow = {
1559
+ id: genId(),
1560
+ name: opts.workflowName,
1561
+ description: previousWorkflow.description,
1562
+ version: previousWorkflow.version,
1563
+ status: "active",
1564
+ steps: previousWorkflow.steps,
1565
+ createdAt: updated,
1566
+ updatedAt: updated
1567
+ };
1568
+ this.db.query(`INSERT INTO workflow_specs (id, name, description, version, status, goal_json, steps_json, created_at, updated_at)
1569
+ VALUES ($id, $name, $description, $version, $status, NULL, $steps, $created, $updated)`).run({
1570
+ $id: workflow.id,
1571
+ $name: workflow.name,
1572
+ $description: workflow.description ?? null,
1573
+ $version: workflow.version,
1574
+ $status: workflow.status,
1575
+ $steps: JSON.stringify(workflow.steps),
1576
+ $created: workflow.createdAt,
1577
+ $updated: workflow.updatedAt
1578
+ });
1579
+ const target = { ...current.target, workflowId: workflow.id };
1580
+ if (opts.workflowTimeoutMs !== undefined)
1581
+ target.timeoutMs = opts.workflowTimeoutMs;
1582
+ const res = this.db.query(`UPDATE loops SET target_json=$target, updated_at=$updated
1583
+ WHERE id=$id
1584
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1585
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1586
+ ))`).run({
1587
+ $id: current.id,
1588
+ $target: JSON.stringify(target),
1589
+ $updated: updated,
1590
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1591
+ $now: updated
1592
+ });
1593
+ if (res.changes !== 1)
1594
+ throw new Error("daemon lease lost");
1595
+ const archivedOld = opts.archiveOld ? this.archiveWorkflowIfUnreferenced(previousWorkflow.id, updated) : undefined;
1596
+ this.db.exec("COMMIT");
1597
+ const loop = this.getLoop(current.id);
1598
+ if (!loop)
1599
+ throw new Error(`loop not found after retarget: ${current.id}`);
1600
+ return { loop, workflow, previousWorkflow, archivedOld };
1601
+ } catch (error) {
1602
+ try {
1603
+ this.db.exec("ROLLBACK");
1604
+ } catch {}
1605
+ throw error;
1606
+ }
1607
+ }
1529
1608
  renameLoop(id, name, opts = {}) {
1530
1609
  const current = this.getLoop(id);
1531
1610
  if (!current)
package/dist/mcp/index.js CHANGED
@@ -1260,12 +1260,21 @@ class Store {
1260
1260
  if (!row)
1261
1261
  throw new Error("daemon lease lost");
1262
1262
  }
1263
+ assertNoNestedWorkflowGoal(target, goal) {
1264
+ if (!goal || target.type !== "workflow")
1265
+ return;
1266
+ const workflow = this.getWorkflow(target.workflowId);
1267
+ if (workflow?.goal) {
1268
+ throw new Error(`workflow loop cannot define a loop-level goal when workflow ${workflow.name} already has a top-level goal; remove one goal wrapper`);
1269
+ }
1270
+ }
1263
1271
  createLoop(input, from = new Date) {
1264
1272
  const now = nowIso();
1265
1273
  const target = input.target.type === "workflow" ? input.target : normalizeCreateWorkflowInput({
1266
1274
  name: "loop-target-validation",
1267
1275
  steps: [{ id: "target", target: input.target }]
1268
1276
  }).steps[0].target;
1277
+ this.assertNoNestedWorkflowGoal(target, input.goal);
1269
1278
  const loop = {
1270
1279
  id: genId(),
1271
1280
  name: input.name,
@@ -1437,6 +1446,9 @@ class Store {
1437
1446
  if (this.hasRunningRun(current.id))
1438
1447
  throw new Error(`refusing to retarget running loop: ${current.id}`);
1439
1448
  const workflow = this.requireWorkflow(workflowId);
1449
+ if (current.goal && workflow.goal) {
1450
+ throw new Error(`workflow loop cannot retarget ${current.name} to workflow ${workflow.name} because both define top-level goals`);
1451
+ }
1440
1452
  const target = { ...current.target, workflowId: workflow.id };
1441
1453
  if (opts.workflowTimeoutMs !== undefined)
1442
1454
  target.timeoutMs = opts.workflowTimeoutMs;
@@ -1475,6 +1487,9 @@ class Store {
1475
1487
  throw new Error(`loop is not a workflow loop: ${idOrName}`);
1476
1488
  if (this.hasRunningRun(current.id))
1477
1489
  throw new Error(`refusing to retarget running loop: ${current.id}`);
1490
+ if (current.goal && normalized.goal) {
1491
+ throw new Error(`workflow loop cannot retarget ${current.name} to a workflow that also defines a top-level goal`);
1492
+ }
1478
1493
  const previousWorkflow = this.requireWorkflow(current.target.workflowId);
1479
1494
  const workflow = {
1480
1495
  id: genId(),
@@ -1528,6 +1543,70 @@ class Store {
1528
1543
  throw error;
1529
1544
  }
1530
1545
  }
1546
+ cloneWorkflowWithoutGoalAndRetargetLoop(idOrName, opts) {
1547
+ const updated = (opts.now ?? new Date).toISOString();
1548
+ this.db.exec("BEGIN IMMEDIATE");
1549
+ try {
1550
+ const current = this.requireUniqueLoop(idOrName);
1551
+ if (current.target.type !== "workflow")
1552
+ throw new Error(`loop is not a workflow loop: ${idOrName}`);
1553
+ if (this.hasRunningRun(current.id))
1554
+ throw new Error(`refusing to retarget running loop: ${current.id}`);
1555
+ if (!current.goal)
1556
+ throw new Error(`workflow loop ${current.name} has no loop-level goal wrapper`);
1557
+ const previousWorkflow = this.requireWorkflow(current.target.workflowId);
1558
+ if (!previousWorkflow.goal)
1559
+ throw new Error(`workflow ${previousWorkflow.name} has no top-level goal wrapper`);
1560
+ const workflow = {
1561
+ id: genId(),
1562
+ name: opts.workflowName,
1563
+ description: previousWorkflow.description,
1564
+ version: previousWorkflow.version,
1565
+ status: "active",
1566
+ steps: previousWorkflow.steps,
1567
+ createdAt: updated,
1568
+ updatedAt: updated
1569
+ };
1570
+ this.db.query(`INSERT INTO workflow_specs (id, name, description, version, status, goal_json, steps_json, created_at, updated_at)
1571
+ VALUES ($id, $name, $description, $version, $status, NULL, $steps, $created, $updated)`).run({
1572
+ $id: workflow.id,
1573
+ $name: workflow.name,
1574
+ $description: workflow.description ?? null,
1575
+ $version: workflow.version,
1576
+ $status: workflow.status,
1577
+ $steps: JSON.stringify(workflow.steps),
1578
+ $created: workflow.createdAt,
1579
+ $updated: workflow.updatedAt
1580
+ });
1581
+ const target = { ...current.target, workflowId: workflow.id };
1582
+ if (opts.workflowTimeoutMs !== undefined)
1583
+ target.timeoutMs = opts.workflowTimeoutMs;
1584
+ const res = this.db.query(`UPDATE loops SET target_json=$target, updated_at=$updated
1585
+ WHERE id=$id
1586
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1587
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1588
+ ))`).run({
1589
+ $id: current.id,
1590
+ $target: JSON.stringify(target),
1591
+ $updated: updated,
1592
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1593
+ $now: updated
1594
+ });
1595
+ if (res.changes !== 1)
1596
+ throw new Error("daemon lease lost");
1597
+ const archivedOld = opts.archiveOld ? this.archiveWorkflowIfUnreferenced(previousWorkflow.id, updated) : undefined;
1598
+ this.db.exec("COMMIT");
1599
+ const loop = this.getLoop(current.id);
1600
+ if (!loop)
1601
+ throw new Error(`loop not found after retarget: ${current.id}`);
1602
+ return { loop, workflow, previousWorkflow, archivedOld };
1603
+ } catch (error) {
1604
+ try {
1605
+ this.db.exec("ROLLBACK");
1606
+ } catch {}
1607
+ throw error;
1608
+ }
1609
+ }
1531
1610
  renameLoop(id, name, opts = {}) {
1532
1611
  const current = this.getLoop(id);
1533
1612
  if (!current)
@@ -4508,7 +4587,7 @@ function publicGoalRun(run) {
4508
4587
  // package.json
4509
4588
  var package_default = {
4510
4589
  name: "@hasna/loops",
4511
- version: "0.3.57",
4590
+ version: "0.3.59",
4512
4591
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4513
4592
  type: "module",
4514
4593
  main: "dist/index.js",
@@ -5049,6 +5128,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
5049
5128
  const workflowWithoutGoal = { ...workflow, goal: undefined };
5050
5129
  return runGoal(store, workflow.goal, {
5051
5130
  ...opts,
5131
+ model: opts.goalModel,
5052
5132
  context: {
5053
5133
  loopId: opts.loop?.id,
5054
5134
  loopName: opts.loop?.name,
@@ -5141,6 +5221,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
5141
5221
  if (step.goal) {
5142
5222
  result = await runGoal(store, step.goal, {
5143
5223
  ...opts,
5224
+ model: opts.goalModel,
5144
5225
  target: targetWithStepAccount(step),
5145
5226
  signal: controller.signal,
5146
5227
  context: {
@@ -5282,6 +5363,7 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
5282
5363
  if (loop.goal) {
5283
5364
  return runGoal(store, loop.goal, {
5284
5365
  ...opts,
5366
+ model: opts.goalModel,
5285
5367
  target: loop.target,
5286
5368
  context: {
5287
5369
  loopId: loop.id,
@@ -5303,8 +5385,10 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
5303
5385
  }
5304
5386
  }
5305
5387
  if (loop.goal) {
5388
+ const workflowForLoopGoal = workflow.goal ? { ...workflow, goal: undefined } : workflow;
5306
5389
  return runGoal(store, loop.goal, {
5307
5390
  ...opts,
5391
+ model: opts.goalModel,
5308
5392
  context: {
5309
5393
  loopId: loop.id,
5310
5394
  loopName: loop.name,
@@ -5313,7 +5397,7 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
5313
5397
  workflowId: workflow.id,
5314
5398
  workflowName: workflow.name
5315
5399
  },
5316
- executeNode: async (node) => executeWorkflow(store, workflow, {
5400
+ executeNode: async (node) => executeWorkflow(store, workflowForLoopGoal, {
5317
5401
  ...opts,
5318
5402
  loop,
5319
5403
  loopRun: run,
package/dist/sdk/index.js CHANGED
@@ -1258,12 +1258,21 @@ class Store {
1258
1258
  if (!row)
1259
1259
  throw new Error("daemon lease lost");
1260
1260
  }
1261
+ assertNoNestedWorkflowGoal(target, goal) {
1262
+ if (!goal || target.type !== "workflow")
1263
+ return;
1264
+ const workflow = this.getWorkflow(target.workflowId);
1265
+ if (workflow?.goal) {
1266
+ throw new Error(`workflow loop cannot define a loop-level goal when workflow ${workflow.name} already has a top-level goal; remove one goal wrapper`);
1267
+ }
1268
+ }
1261
1269
  createLoop(input, from = new Date) {
1262
1270
  const now = nowIso();
1263
1271
  const target = input.target.type === "workflow" ? input.target : normalizeCreateWorkflowInput({
1264
1272
  name: "loop-target-validation",
1265
1273
  steps: [{ id: "target", target: input.target }]
1266
1274
  }).steps[0].target;
1275
+ this.assertNoNestedWorkflowGoal(target, input.goal);
1267
1276
  const loop = {
1268
1277
  id: genId(),
1269
1278
  name: input.name,
@@ -1435,6 +1444,9 @@ class Store {
1435
1444
  if (this.hasRunningRun(current.id))
1436
1445
  throw new Error(`refusing to retarget running loop: ${current.id}`);
1437
1446
  const workflow = this.requireWorkflow(workflowId);
1447
+ if (current.goal && workflow.goal) {
1448
+ throw new Error(`workflow loop cannot retarget ${current.name} to workflow ${workflow.name} because both define top-level goals`);
1449
+ }
1438
1450
  const target = { ...current.target, workflowId: workflow.id };
1439
1451
  if (opts.workflowTimeoutMs !== undefined)
1440
1452
  target.timeoutMs = opts.workflowTimeoutMs;
@@ -1473,6 +1485,9 @@ class Store {
1473
1485
  throw new Error(`loop is not a workflow loop: ${idOrName}`);
1474
1486
  if (this.hasRunningRun(current.id))
1475
1487
  throw new Error(`refusing to retarget running loop: ${current.id}`);
1488
+ if (current.goal && normalized.goal) {
1489
+ throw new Error(`workflow loop cannot retarget ${current.name} to a workflow that also defines a top-level goal`);
1490
+ }
1476
1491
  const previousWorkflow = this.requireWorkflow(current.target.workflowId);
1477
1492
  const workflow = {
1478
1493
  id: genId(),
@@ -1526,6 +1541,70 @@ class Store {
1526
1541
  throw error;
1527
1542
  }
1528
1543
  }
1544
+ cloneWorkflowWithoutGoalAndRetargetLoop(idOrName, opts) {
1545
+ const updated = (opts.now ?? new Date).toISOString();
1546
+ this.db.exec("BEGIN IMMEDIATE");
1547
+ try {
1548
+ const current = this.requireUniqueLoop(idOrName);
1549
+ if (current.target.type !== "workflow")
1550
+ throw new Error(`loop is not a workflow loop: ${idOrName}`);
1551
+ if (this.hasRunningRun(current.id))
1552
+ throw new Error(`refusing to retarget running loop: ${current.id}`);
1553
+ if (!current.goal)
1554
+ throw new Error(`workflow loop ${current.name} has no loop-level goal wrapper`);
1555
+ const previousWorkflow = this.requireWorkflow(current.target.workflowId);
1556
+ if (!previousWorkflow.goal)
1557
+ throw new Error(`workflow ${previousWorkflow.name} has no top-level goal wrapper`);
1558
+ const workflow = {
1559
+ id: genId(),
1560
+ name: opts.workflowName,
1561
+ description: previousWorkflow.description,
1562
+ version: previousWorkflow.version,
1563
+ status: "active",
1564
+ steps: previousWorkflow.steps,
1565
+ createdAt: updated,
1566
+ updatedAt: updated
1567
+ };
1568
+ this.db.query(`INSERT INTO workflow_specs (id, name, description, version, status, goal_json, steps_json, created_at, updated_at)
1569
+ VALUES ($id, $name, $description, $version, $status, NULL, $steps, $created, $updated)`).run({
1570
+ $id: workflow.id,
1571
+ $name: workflow.name,
1572
+ $description: workflow.description ?? null,
1573
+ $version: workflow.version,
1574
+ $status: workflow.status,
1575
+ $steps: JSON.stringify(workflow.steps),
1576
+ $created: workflow.createdAt,
1577
+ $updated: workflow.updatedAt
1578
+ });
1579
+ const target = { ...current.target, workflowId: workflow.id };
1580
+ if (opts.workflowTimeoutMs !== undefined)
1581
+ target.timeoutMs = opts.workflowTimeoutMs;
1582
+ const res = this.db.query(`UPDATE loops SET target_json=$target, updated_at=$updated
1583
+ WHERE id=$id
1584
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1585
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1586
+ ))`).run({
1587
+ $id: current.id,
1588
+ $target: JSON.stringify(target),
1589
+ $updated: updated,
1590
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1591
+ $now: updated
1592
+ });
1593
+ if (res.changes !== 1)
1594
+ throw new Error("daemon lease lost");
1595
+ const archivedOld = opts.archiveOld ? this.archiveWorkflowIfUnreferenced(previousWorkflow.id, updated) : undefined;
1596
+ this.db.exec("COMMIT");
1597
+ const loop = this.getLoop(current.id);
1598
+ if (!loop)
1599
+ throw new Error(`loop not found after retarget: ${current.id}`);
1600
+ return { loop, workflow, previousWorkflow, archivedOld };
1601
+ } catch (error) {
1602
+ try {
1603
+ this.db.exec("ROLLBACK");
1604
+ } catch {}
1605
+ throw error;
1606
+ }
1607
+ }
1529
1608
  renameLoop(id, name, opts = {}) {
1530
1609
  const current = this.getLoop(id);
1531
1610
  if (!current)
@@ -4607,6 +4686,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
4607
4686
  const workflowWithoutGoal = { ...workflow, goal: undefined };
4608
4687
  return runGoal(store, workflow.goal, {
4609
4688
  ...opts,
4689
+ model: opts.goalModel,
4610
4690
  context: {
4611
4691
  loopId: opts.loop?.id,
4612
4692
  loopName: opts.loop?.name,
@@ -4699,6 +4779,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
4699
4779
  if (step.goal) {
4700
4780
  result = await runGoal(store, step.goal, {
4701
4781
  ...opts,
4782
+ model: opts.goalModel,
4702
4783
  target: targetWithStepAccount(step),
4703
4784
  signal: controller.signal,
4704
4785
  context: {
@@ -4840,6 +4921,7 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
4840
4921
  if (loop.goal) {
4841
4922
  return runGoal(store, loop.goal, {
4842
4923
  ...opts,
4924
+ model: opts.goalModel,
4843
4925
  target: loop.target,
4844
4926
  context: {
4845
4927
  loopId: loop.id,
@@ -4861,8 +4943,10 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
4861
4943
  }
4862
4944
  }
4863
4945
  if (loop.goal) {
4946
+ const workflowForLoopGoal = workflow.goal ? { ...workflow, goal: undefined } : workflow;
4864
4947
  return runGoal(store, loop.goal, {
4865
4948
  ...opts,
4949
+ model: opts.goalModel,
4866
4950
  context: {
4867
4951
  loopId: loop.id,
4868
4952
  loopName: loop.name,
@@ -4871,7 +4955,7 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
4871
4955
  workflowId: workflow.id,
4872
4956
  workflowName: workflow.name
4873
4957
  },
4874
- executeNode: async (node) => executeWorkflow(store, workflow, {
4958
+ executeNode: async (node) => executeWorkflow(store, workflowForLoopGoal, {
4875
4959
  ...opts,
4876
4960
  loop,
4877
4961
  loopRun: run,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/loops",
3
- "version": "0.3.57",
3
+ "version": "0.3.59",
4
4
  "description": "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",