@hasna/loops 0.3.58 → 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
@@ -1543,6 +1543,70 @@ class Store {
1543
1543
  throw error;
1544
1544
  }
1545
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
+ }
1546
1610
  renameLoop(id, name, opts = {}) {
1547
1611
  const current = this.getLoop(id);
1548
1612
  if (!current)
@@ -6497,7 +6561,7 @@ function buildScriptInventoryReport(store, opts = {}) {
6497
6561
  // package.json
6498
6562
  var package_default = {
6499
6563
  name: "@hasna/loops",
6500
- version: "0.3.58",
6564
+ version: "0.3.59",
6501
6565
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
6502
6566
  type: "module",
6503
6567
  main: "dist/index.js",
@@ -8637,21 +8701,47 @@ function workflowTimeoutMigrationName(workflow, timeoutMs) {
8637
8701
  const suffix = randomUUID().replace(/-/g, "").slice(0, 8);
8638
8702
  return `${workflow.name}-${policy}-${suffix}`;
8639
8703
  }
8640
- function workflowWithoutGoalWrapper(workflow, opts = {}) {
8641
- return {
8642
- body: {
8643
- name: opts.name ?? workflow.name,
8644
- description: workflow.description,
8645
- version: workflow.version,
8646
- steps: workflow.steps
8647
- },
8648
- changed: workflow.goal !== undefined
8649
- };
8650
- }
8651
8704
  function workflowGoalWrapperMigrationName(workflow) {
8652
8705
  const suffix = randomUUID().replace(/-/g, "").slice(0, 8);
8653
8706
  return `${workflow.name}-no-workflow-goal-${suffix}`;
8654
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
+ }
8655
8745
  function printTextOutput(value) {
8656
8746
  for (const line of textOutputBlocks(value, { indent: " " }))
8657
8747
  console.log(line);
@@ -11275,19 +11365,30 @@ workflows.command("migrate-agent-timeouts").description("append-only migrate act
11275
11365
  });
11276
11366
  continue;
11277
11367
  }
11278
- const migrated = store.createAndRetargetWorkflowLoop(loop.id, migration.body, {
11279
- workflowTimeoutMs: timeoutMs,
11280
- archiveOld: Boolean(opts.archiveOld)
11281
- });
11282
- rows.push({
11283
- loop: publicLoop(migrated.loop),
11284
- previousWorkflow: publicWorkflow(migrated.previousWorkflow),
11285
- workflow: publicWorkflow(migrated.workflow),
11286
- archivedOld: migrated.archivedOld ? publicWorkflow(migrated.archivedOld) : undefined,
11287
- status: "migrated",
11288
- agentStepIds: migration.agentStepIds,
11289
- timeoutMs
11290
- });
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
+ }
11291
11392
  }
11292
11393
  const summary = {
11293
11394
  apply: Boolean(opts.apply),
@@ -11310,45 +11411,73 @@ workflows.command("migrate-goal-wrappers").description("append-only migrate acti
11310
11411
  const rows = [];
11311
11412
  for (const loop of candidateLoops) {
11312
11413
  if (loop.archivedAt) {
11313
- rows.push({ loop: publicLoop(loop), status: "skipped", reason: "loop is archived" });
11414
+ rows.push({ loop: publicMigrationLoopSummary(loop), status: "skipped", reason: "loop is archived" });
11314
11415
  continue;
11315
11416
  }
11316
11417
  if (loop.target.type !== "workflow") {
11317
- rows.push({ loop: publicLoop(loop), status: "skipped", reason: "loop is not a workflow loop" });
11418
+ rows.push({ loop: publicMigrationLoopSummary(loop), status: "skipped", reason: "loop is not a workflow loop" });
11318
11419
  continue;
11319
11420
  }
11320
11421
  const workflow = store.requireWorkflow(loop.target.workflowId);
11321
- const nextWorkflowName = workflowGoalWrapperMigrationName(workflow);
11322
- const migration = workflowWithoutGoalWrapper(workflow, { name: nextWorkflowName });
11323
- if (!migration.changed) {
11324
- rows.push({ loop: publicLoop(loop), workflow: publicWorkflow(workflow), status: "skipped", reason: "workflow has no top-level goal wrapper" });
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
+ });
11325
11429
  continue;
11326
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);
11327
11441
  if (store.hasRunningRun(loop.id)) {
11328
- rows.push({ loop: publicLoop(loop), workflow: publicWorkflow(workflow), status: "blocked", reason: "loop has a running run; retry after it finishes" });
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
+ });
11329
11448
  continue;
11330
11449
  }
11331
11450
  if (!opts.apply) {
11332
11451
  rows.push({
11333
- loop: publicLoop(loop),
11334
- workflow: publicWorkflow(workflow),
11452
+ loop: publicMigrationLoopSummary(loop),
11453
+ workflow: publicMigrationWorkflowSummary(workflow),
11335
11454
  status: "would_migrate",
11336
11455
  nextWorkflowName,
11337
- removedGoal: workflow.goal
11456
+ removedGoal: publicMigrationGoalSummary(workflow.goal)
11338
11457
  });
11339
11458
  continue;
11340
11459
  }
11341
- const migrated = store.createAndRetargetWorkflowLoop(loop.id, migration.body, {
11342
- workflowTimeoutMs: loop.target.timeoutMs,
11343
- archiveOld: Boolean(opts.archiveOld)
11344
- });
11345
- rows.push({
11346
- loop: publicLoop(migrated.loop),
11347
- previousWorkflow: publicWorkflow(migrated.previousWorkflow),
11348
- workflow: publicWorkflow(migrated.workflow),
11349
- archivedOld: migrated.archivedOld ? publicWorkflow(migrated.archivedOld) : undefined,
11350
- status: "migrated"
11351
- });
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
+ }
11352
11481
  }
11353
11482
  const summary = {
11354
11483
  apply: Boolean(opts.apply),
@@ -1543,6 +1543,70 @@ class Store {
1543
1543
  throw error;
1544
1544
  }
1545
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
+ }
1546
1610
  renameLoop(id, name, opts = {}) {
1547
1611
  const current = this.getLoop(id);
1548
1612
  if (!current)
@@ -5631,7 +5695,7 @@ function enableStartup(result) {
5631
5695
  // package.json
5632
5696
  var package_default = {
5633
5697
  name: "@hasna/loops",
5634
- version: "0.3.58",
5698
+ version: "0.3.59",
5635
5699
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5636
5700
  type: "module",
5637
5701
  main: "dist/index.js",
package/dist/index.js CHANGED
@@ -1541,6 +1541,70 @@ class Store {
1541
1541
  throw error;
1542
1542
  }
1543
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
+ }
1544
1608
  renameLoop(id, name, opts = {}) {
1545
1609
  const current = this.getLoop(id);
1546
1610
  if (!current)
@@ -4521,7 +4585,7 @@ function publicGoalRun(run) {
4521
4585
  // package.json
4522
4586
  var package_default = {
4523
4587
  name: "@hasna/loops",
4524
- version: "0.3.58",
4588
+ version: "0.3.59",
4525
4589
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4526
4590
  type: "module",
4527
4591
  main: "dist/index.js",
@@ -99,6 +99,16 @@ export declare class Store {
99
99
  previousWorkflow: WorkflowSpec;
100
100
  archivedOld?: WorkflowSpec;
101
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
+ };
102
112
  renameLoop(id: string, name: string, opts?: DaemonLeaseFence): Loop;
103
113
  archiveLoop(idOrName: string): Loop;
104
114
  unarchiveLoop(idOrName: string): Loop;
package/dist/lib/store.js CHANGED
@@ -1541,6 +1541,70 @@ class Store {
1541
1541
  throw error;
1542
1542
  }
1543
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
+ }
1544
1608
  renameLoop(id, name, opts = {}) {
1545
1609
  const current = this.getLoop(id);
1546
1610
  if (!current)
package/dist/mcp/index.js CHANGED
@@ -1543,6 +1543,70 @@ class Store {
1543
1543
  throw error;
1544
1544
  }
1545
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
+ }
1546
1610
  renameLoop(id, name, opts = {}) {
1547
1611
  const current = this.getLoop(id);
1548
1612
  if (!current)
@@ -4523,7 +4587,7 @@ function publicGoalRun(run) {
4523
4587
  // package.json
4524
4588
  var package_default = {
4525
4589
  name: "@hasna/loops",
4526
- version: "0.3.58",
4590
+ version: "0.3.59",
4527
4591
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4528
4592
  type: "module",
4529
4593
  main: "dist/index.js",
package/dist/sdk/index.js CHANGED
@@ -1541,6 +1541,70 @@ class Store {
1541
1541
  throw error;
1542
1542
  }
1543
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
+ }
1544
1608
  renameLoop(id, name, opts = {}) {
1545
1609
  const current = this.getLoop(id);
1546
1610
  if (!current)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/loops",
3
- "version": "0.3.58",
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",