@hasna/loops 0.4.3 → 0.4.5

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/index.js CHANGED
@@ -1523,7 +1523,7 @@ class Store {
1523
1523
  }
1524
1524
  const applied = new Set(this.db.query("SELECT id FROM schema_migrations").all().map((row) => row.id));
1525
1525
  for (const migration of this.migrations()) {
1526
- if (!migration.baseline && !migration.reapply && applied.has(migration.id))
1526
+ if (!migration.baseline && applied.has(migration.id))
1527
1527
  continue;
1528
1528
  migration.apply();
1529
1529
  if (!applied.has(migration.id)) {
@@ -1576,7 +1576,6 @@ class Store {
1576
1576
  },
1577
1577
  {
1578
1578
  id: "0007_run_claim_tokens",
1579
- reapply: true,
1580
1579
  apply: () => {
1581
1580
  this.addColumnIfMissing("loop_runs", "claim_token", "TEXT");
1582
1581
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_runs_claim_token ON loop_runs(claim_token) WHERE claim_token IS NOT NULL");
@@ -3841,6 +3840,190 @@ class Store {
3841
3840
  const row = status ? this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE status = ?").get(status) : this.db.query("SELECT COUNT(*) AS count FROM loop_runs").get();
3842
3841
  return row?.count ?? 0;
3843
3842
  }
3843
+ exportMigrationRows(opts = {}) {
3844
+ const includeRuns = opts.includeRuns ?? true;
3845
+ const workflows = this.db.query("SELECT * FROM workflow_specs ORDER BY created_at ASC, id ASC").all().map(rowToWorkflow);
3846
+ const loops = this.db.query("SELECT * FROM loops ORDER BY created_at ASC, id ASC").all().map(rowToLoop);
3847
+ const runs = includeRuns ? this.db.query("SELECT * FROM loop_runs ORDER BY created_at ASC, id ASC").all().map(rowToRun) : [];
3848
+ return { schemaVersion: SCHEMA_USER_VERSION, workflows, loops, runs, checks: this.migrationChecks() };
3849
+ }
3850
+ countTable(table) {
3851
+ const row = this.db.query(`SELECT COUNT(*) AS count FROM ${table}`).get();
3852
+ return row?.count ?? 0;
3853
+ }
3854
+ migrationChecks() {
3855
+ const now = nowIso();
3856
+ return {
3857
+ unsupportedCounts: {
3858
+ workflowInvocations: this.countTable("workflow_invocations"),
3859
+ workflowWorkItems: this.countTable("workflow_work_items"),
3860
+ workflowRuns: this.countTable("workflow_runs"),
3861
+ workflowStepRuns: this.countTable("workflow_step_runs"),
3862
+ workflowEvents: this.countTable("workflow_events"),
3863
+ goals: this.countTable("goals"),
3864
+ goalPlanNodes: this.countTable("goal_plan_nodes"),
3865
+ goalRuns: this.countTable("goal_runs")
3866
+ },
3867
+ volatileCounts: {
3868
+ daemonLeases: this.countTable("daemon_lease"),
3869
+ activeDaemonLeases: this.db.query("SELECT COUNT(*) AS count FROM daemon_lease WHERE expires_at > ?").get(now)?.count ?? 0,
3870
+ runningLoopRuns: this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE status = 'running'").get()?.count ?? 0,
3871
+ runningWorkflowRuns: this.db.query("SELECT COUNT(*) AS count FROM workflow_runs WHERE status = 'running'").get()?.count ?? 0,
3872
+ runningWorkflowStepRuns: this.db.query("SELECT COUNT(*) AS count FROM workflow_step_runs WHERE status = 'running'").get()?.count ?? 0,
3873
+ leasedWorkflowWorkItems: this.db.query("SELECT COUNT(*) AS count FROM workflow_work_items WHERE lease_expires_at IS NOT NULL OR status IN ('admitted', 'running')").get()?.count ?? 0
3874
+ }
3875
+ };
3876
+ }
3877
+ upsertMigrationWorkflow(workflow, opts = {}) {
3878
+ const existing = this.getWorkflow(workflow.id);
3879
+ if (existing && !opts.replace)
3880
+ return existing;
3881
+ this.db.query(`INSERT INTO workflow_specs (id, name, description, version, status, goal_json, steps_json, created_at, updated_at)
3882
+ VALUES ($id, $name, $description, $version, $status, $goal, $steps, $created, $updated)
3883
+ ON CONFLICT(id) DO UPDATE SET
3884
+ name=$name,
3885
+ description=$description,
3886
+ version=$version,
3887
+ status=$status,
3888
+ goal_json=$goal,
3889
+ steps_json=$steps,
3890
+ created_at=$created,
3891
+ updated_at=$updated`).run({
3892
+ $id: workflow.id,
3893
+ $name: workflow.name,
3894
+ $description: workflow.description ?? null,
3895
+ $version: workflow.version,
3896
+ $status: workflow.status,
3897
+ $goal: workflow.goal ? JSON.stringify(workflow.goal) : null,
3898
+ $steps: JSON.stringify(workflow.steps),
3899
+ $created: workflow.createdAt,
3900
+ $updated: workflow.updatedAt
3901
+ });
3902
+ const imported = this.getWorkflow(workflow.id);
3903
+ if (!imported)
3904
+ throw new Error(`workflow not found after migration import: ${workflow.id}`);
3905
+ return imported;
3906
+ }
3907
+ upsertMigrationLoop(loop, opts = {}) {
3908
+ const existing = this.getLoop(loop.id);
3909
+ if (existing && !opts.replace)
3910
+ return existing;
3911
+ this.assertNoNestedWorkflowGoal(loop.target, loop.goal);
3912
+ this.db.query(`INSERT INTO loops (id, name, description, status, archived_at, archived_from_status, schedule_json, target_json,
3913
+ goal_json, machine_json, next_run_at, retry_scheduled_for, catch_up, catch_up_limit, overlap, max_attempts,
3914
+ retry_delay_ms, lease_ms, expires_at, created_at, updated_at)
3915
+ VALUES ($id, $name, $description, $status, $archivedAt, $archivedFromStatus, $schedule, $target,
3916
+ $goal, $machine, $nextRun, $retrySlot, $catchUp, $catchUpLimit, $overlap, $maxAttempts,
3917
+ $retryDelay, $leaseMs, $expiresAt, $created, $updated)
3918
+ ON CONFLICT(id) DO UPDATE SET
3919
+ name=$name,
3920
+ description=$description,
3921
+ status=$status,
3922
+ archived_at=$archivedAt,
3923
+ archived_from_status=$archivedFromStatus,
3924
+ schedule_json=$schedule,
3925
+ target_json=$target,
3926
+ goal_json=$goal,
3927
+ machine_json=$machine,
3928
+ next_run_at=$nextRun,
3929
+ retry_scheduled_for=$retrySlot,
3930
+ catch_up=$catchUp,
3931
+ catch_up_limit=$catchUpLimit,
3932
+ overlap=$overlap,
3933
+ max_attempts=$maxAttempts,
3934
+ retry_delay_ms=$retryDelay,
3935
+ lease_ms=$leaseMs,
3936
+ expires_at=$expiresAt,
3937
+ created_at=$created,
3938
+ updated_at=$updated`).run({
3939
+ $id: loop.id,
3940
+ $name: loop.name,
3941
+ $description: loop.description ?? null,
3942
+ $status: loop.status,
3943
+ $archivedAt: loop.archivedAt ?? null,
3944
+ $archivedFromStatus: loop.archivedFromStatus ?? null,
3945
+ $schedule: JSON.stringify(loop.schedule),
3946
+ $target: JSON.stringify(loop.target),
3947
+ $goal: loop.goal ? JSON.stringify(loop.goal) : null,
3948
+ $machine: loop.machine ? JSON.stringify(loop.machine) : null,
3949
+ $nextRun: loop.nextRunAt ?? null,
3950
+ $retrySlot: loop.retryScheduledFor ?? null,
3951
+ $catchUp: loop.catchUp,
3952
+ $catchUpLimit: loop.catchUpLimit,
3953
+ $overlap: loop.overlap,
3954
+ $maxAttempts: loop.maxAttempts,
3955
+ $retryDelay: loop.retryDelayMs,
3956
+ $leaseMs: loop.leaseMs,
3957
+ $expiresAt: loop.expiresAt ?? null,
3958
+ $created: loop.createdAt,
3959
+ $updated: loop.updatedAt
3960
+ });
3961
+ const imported = this.getLoop(loop.id);
3962
+ if (!imported)
3963
+ throw new Error(`loop not found after migration import: ${loop.id}`);
3964
+ return imported;
3965
+ }
3966
+ upsertMigrationRun(run, opts = {}) {
3967
+ if (run.status === "running")
3968
+ throw new ValidationError(`cannot import running run ${run.id}`);
3969
+ const existing = this.getRun(run.id);
3970
+ if (existing && !opts.replace)
3971
+ return existing;
3972
+ this.db.query(`INSERT INTO loop_runs (id, loop_id, loop_name, scheduled_for, attempt, status, started_at, finished_at,
3973
+ claimed_by, claim_token, lease_expires_at, pid, pgid, process_started_at, exit_code, duration_ms,
3974
+ stdout, stderr, error, goal_run_id, created_at, updated_at)
3975
+ VALUES ($id, $loopId, $loopName, $scheduledFor, $attempt, $status, $startedAt, $finishedAt,
3976
+ $claimedBy, NULL, $leaseExpiresAt, $pid, $pgid, $processStartedAt, $exitCode, $durationMs,
3977
+ $stdout, $stderr, $error, $goalRunId, $created, $updated)
3978
+ ON CONFLICT(id) DO UPDATE SET
3979
+ loop_id=$loopId,
3980
+ loop_name=$loopName,
3981
+ scheduled_for=$scheduledFor,
3982
+ attempt=$attempt,
3983
+ status=$status,
3984
+ started_at=$startedAt,
3985
+ finished_at=$finishedAt,
3986
+ claimed_by=$claimedBy,
3987
+ claim_token=NULL,
3988
+ lease_expires_at=$leaseExpiresAt,
3989
+ pid=$pid,
3990
+ pgid=$pgid,
3991
+ process_started_at=$processStartedAt,
3992
+ exit_code=$exitCode,
3993
+ duration_ms=$durationMs,
3994
+ stdout=$stdout,
3995
+ stderr=$stderr,
3996
+ error=$error,
3997
+ goal_run_id=$goalRunId,
3998
+ created_at=$created,
3999
+ updated_at=$updated`).run({
4000
+ $id: run.id,
4001
+ $loopId: run.loopId,
4002
+ $loopName: run.loopName,
4003
+ $scheduledFor: run.scheduledFor,
4004
+ $attempt: run.attempt,
4005
+ $status: run.status,
4006
+ $startedAt: run.startedAt ?? null,
4007
+ $finishedAt: run.finishedAt ?? null,
4008
+ $claimedBy: run.claimedBy ?? null,
4009
+ $leaseExpiresAt: run.leaseExpiresAt ?? null,
4010
+ $pid: run.pid ?? null,
4011
+ $pgid: run.pgid ?? null,
4012
+ $processStartedAt: run.processStartedAt ?? null,
4013
+ $exitCode: run.exitCode ?? null,
4014
+ $durationMs: run.durationMs ?? null,
4015
+ $stdout: scrubbedOrNull(run.stdout),
4016
+ $stderr: scrubbedOrNull(run.stderr),
4017
+ $error: scrubbedOrNull(run.error),
4018
+ $goalRunId: run.goalRunId ?? null,
4019
+ $created: run.createdAt,
4020
+ $updated: run.updatedAt
4021
+ });
4022
+ const imported = this.getRun(run.id);
4023
+ if (!imported)
4024
+ throw new Error(`run not found after migration import: ${run.id}`);
4025
+ return imported;
4026
+ }
3844
4027
  pruneHistory(opts) {
3845
4028
  const { maxAgeDays, keepPerLoop } = opts;
3846
4029
  if (maxAgeDays === undefined && keepPerLoop === undefined) {
@@ -3982,7 +4165,7 @@ class Store {
3982
4165
  // package.json
3983
4166
  var package_default = {
3984
4167
  name: "@hasna/loops",
3985
- version: "0.4.3",
4168
+ version: "0.4.5",
3986
4169
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
3987
4170
  type: "module",
3988
4171
  main: "dist/index.js",
@@ -4056,7 +4239,7 @@ var package_default = {
4056
4239
  prepare: "test -d dist || bun run build",
4057
4240
  "dev:cli": "bun run src/cli/index.ts",
4058
4241
  "dev:daemon": "bun run src/daemon/index.ts",
4059
- prepublishOnly: "bun run build && bun run test:boundary"
4242
+ prepublishOnly: "bun run build && bun run typecheck && bun test && bun run test:boundary"
4060
4243
  },
4061
4244
  keywords: [
4062
4245
  "loops",
@@ -8231,6 +8414,515 @@ if (import.meta.main) {
8231
8414
  });
8232
8415
  }
8233
8416
 
8417
+ // src/lib/migration.ts
8418
+ import { createHash as createHash3 } from "crypto";
8419
+ import { hostname as hostname2 } from "os";
8420
+ var LOOPS_MIGRATION_SCHEMA = "open-loops.migration/v1";
8421
+ function canonicalize(value) {
8422
+ if (Array.isArray(value))
8423
+ return value.map((entry) => canonicalize(entry));
8424
+ if (value && typeof value === "object") {
8425
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => [key, canonicalize(entry)]));
8426
+ }
8427
+ return value;
8428
+ }
8429
+ function migrationHash(value) {
8430
+ return createHash3("sha256").update(JSON.stringify(canonicalize(value))).digest("hex");
8431
+ }
8432
+ function pushBlocker(rows, resource, id, reason, name) {
8433
+ rows.push({ resource, id, name, action: "blocked", reason });
8434
+ }
8435
+ function checksToBlockers(checks, prefix, warnings = []) {
8436
+ const rows = [];
8437
+ for (const [name, count] of Object.entries(checks.unsupportedCounts)) {
8438
+ if (count > 0) {
8439
+ pushBlocker(rows, "remote", `${prefix}:unsupported:${name}`, `${prefix} ${name} has ${count} rows; this migration bundle does not preserve that table yet`);
8440
+ }
8441
+ }
8442
+ for (const [name, count] of Object.entries(checks.volatileCounts)) {
8443
+ if (name === "daemonLeases") {
8444
+ if (count > 0)
8445
+ warnings.push(`${prefix} daemon_lease has ${count} volatile rows; they are intentionally not exported as authority`);
8446
+ continue;
8447
+ }
8448
+ if (count > 0) {
8449
+ pushBlocker(rows, "remote", `${prefix}:volatile:${name}`, `${prefix} ${name} has ${count} rows; stop or finish active work before no-loss migration`);
8450
+ }
8451
+ }
8452
+ return rows;
8453
+ }
8454
+ function sanitizeCommandEnv(value, blockers, resource, id, name) {
8455
+ const copy = structuredClone(value);
8456
+ const target = copy.target;
8457
+ if (target && typeof target === "object" && !Array.isArray(target) && target.type === "command") {
8458
+ const commandTarget = target;
8459
+ if (commandTarget.env && Object.keys(commandTarget.env).length > 0) {
8460
+ commandTarget.env = Object.fromEntries(Object.keys(commandTarget.env).map((key) => [key, "[redacted]"]));
8461
+ pushBlocker(blockers, resource, id, "command target env values are redacted and cannot be imported as a no-loss row", name);
8462
+ }
8463
+ }
8464
+ return copy;
8465
+ }
8466
+ function sanitizeWorkflow(workflow, blockers) {
8467
+ const copy = structuredClone(workflow);
8468
+ copy.steps = copy.steps.map((step) => {
8469
+ if (step.target.type !== "command" || !step.target.env || Object.keys(step.target.env).length === 0)
8470
+ return step;
8471
+ pushBlocker(blockers, "workflow", workflow.id, `workflow step ${step.id} command env values are redacted and cannot be imported as a no-loss row`, workflow.name);
8472
+ return {
8473
+ ...step,
8474
+ target: {
8475
+ ...step.target,
8476
+ env: Object.fromEntries(Object.keys(step.target.env).map((key) => [key, "[redacted]"]))
8477
+ }
8478
+ };
8479
+ });
8480
+ return copy;
8481
+ }
8482
+ function sanitizeExportRows(rows) {
8483
+ const blockers = [];
8484
+ const warnings = [];
8485
+ blockers.push(...checksToBlockers(rows.checks, "source", warnings));
8486
+ const workflows = rows.workflows.map((workflow) => sanitizeWorkflow(workflow, blockers));
8487
+ const loops = rows.loops.map((loop) => sanitizeCommandEnv(loop, blockers, "loop", loop.id, loop.name));
8488
+ const beforeScrub = JSON.stringify({ workflows, loops, runs: rows.runs });
8489
+ const data = scrubSecretsDeep({ workflows, loops, runs: rows.runs });
8490
+ if (JSON.stringify(data) !== beforeScrub) {
8491
+ warnings.push("secret-looking strings were scrubbed from the export bundle; treat this as non-importable unless every scrub is expected");
8492
+ pushBlocker(blockers, "remote", "secret-scrub", "secret-looking strings were scrubbed from the export bundle");
8493
+ }
8494
+ return { data, blockers, warnings };
8495
+ }
8496
+ function exportLoopsMigrationBundle(store, opts = {}) {
8497
+ const rows = store.exportMigrationRows({ includeRuns: opts.includeRuns ?? true });
8498
+ const sanitized = sanitizeExportRows(rows);
8499
+ const bundleBody = {
8500
+ schema: LOOPS_MIGRATION_SCHEMA,
8501
+ packageVersion: packageVersion(),
8502
+ exportedAt: new Date().toISOString(),
8503
+ source: {
8504
+ backend: "sqlite",
8505
+ schemaVersion: rows.schemaVersion,
8506
+ hostname: hostname2()
8507
+ },
8508
+ checks: rows.checks,
8509
+ importable: sanitized.blockers.length === 0,
8510
+ counts: {
8511
+ workflows: sanitized.data.workflows.length,
8512
+ loops: sanitized.data.loops.length,
8513
+ runs: sanitized.data.runs.length
8514
+ },
8515
+ data: sanitized.data,
8516
+ blockers: sanitized.blockers,
8517
+ warnings: sanitized.warnings
8518
+ };
8519
+ return {
8520
+ ...bundleBody,
8521
+ hash: migrationHash(bundleBody)
8522
+ };
8523
+ }
8524
+ function validateLoopsMigrationBundle(value) {
8525
+ if (!value || typeof value !== "object")
8526
+ throw new ValidationError("migration bundle must be a JSON object");
8527
+ const bundle = value;
8528
+ if (bundle.schema !== LOOPS_MIGRATION_SCHEMA)
8529
+ throw new ValidationError(`unsupported migration bundle schema: ${String(bundle.schema)}`);
8530
+ if (!bundle.data || !Array.isArray(bundle.data.workflows) || !Array.isArray(bundle.data.loops) || !Array.isArray(bundle.data.runs)) {
8531
+ throw new ValidationError("migration bundle data must include workflows, loops, and runs arrays");
8532
+ }
8533
+ if (!bundle.checks || !bundle.counts || !bundle.source || !bundle.hash)
8534
+ throw new ValidationError("migration bundle is missing required metadata");
8535
+ const typed = bundle;
8536
+ assertMigrationBundleIntegrity(typed);
8537
+ return typed;
8538
+ }
8539
+ function assertMigrationBundleIntegrity(bundle) {
8540
+ const { hash: _hash, ...body } = bundle;
8541
+ const expectedHash = migrationHash(body);
8542
+ if (bundle.hash !== expectedHash)
8543
+ throw new ValidationError("migration bundle hash mismatch");
8544
+ }
8545
+ function rowPlanSummary(plan) {
8546
+ const count = (action) => plan.rows.filter((row) => row.action === action).length;
8547
+ return {
8548
+ dryRun: plan.dryRun,
8549
+ replace: plan.replace,
8550
+ importable: plan.importable,
8551
+ workflows: plan.rows.filter((row) => row.resource === "workflow").length,
8552
+ loops: plan.rows.filter((row) => row.resource === "loop").length,
8553
+ runs: plan.rows.filter((row) => row.resource === "run").length,
8554
+ insert: count("insert"),
8555
+ update: count("update"),
8556
+ skip: count("skip"),
8557
+ conflict: count("conflict"),
8558
+ blocked: count("blocked")
8559
+ };
8560
+ }
8561
+ function finalizePlan(plan) {
8562
+ return { ...plan, summary: rowPlanSummary(plan) };
8563
+ }
8564
+ function compareResource(current, incoming, opts) {
8565
+ const incomingHash = migrationHash(incoming);
8566
+ if (!current)
8567
+ return { resource: opts.resource, id: opts.id, name: opts.name, action: "insert", incomingHash };
8568
+ const currentHash = migrationHash(current);
8569
+ if (currentHash === incomingHash)
8570
+ return { resource: opts.resource, id: opts.id, name: opts.name, action: "skip", incomingHash, currentHash };
8571
+ return {
8572
+ resource: opts.resource,
8573
+ id: opts.id,
8574
+ name: opts.name,
8575
+ action: opts.replace ? "update" : "conflict",
8576
+ reason: opts.replace ? "existing row differs and --replace was requested" : "existing row differs; rerun with --replace to update",
8577
+ incomingHash,
8578
+ currentHash
8579
+ };
8580
+ }
8581
+ function buildImportMigrationPlan(store, bundle, opts = {}) {
8582
+ assertMigrationBundleIntegrity(bundle);
8583
+ const includeRuns = opts.includeRuns ?? true;
8584
+ const replace = opts.replace ?? false;
8585
+ const rows = [];
8586
+ const warnings = [...bundle.warnings ?? []];
8587
+ rows.push(...checksToBlockers(store.exportMigrationRows({ includeRuns: false }).checks, "destination", warnings));
8588
+ if (!bundle.importable || bundle.blockers.length > 0) {
8589
+ rows.push(...bundle.blockers.map((row) => ({ ...row, action: "blocked" })));
8590
+ }
8591
+ const workflowIds = new Set(bundle.data.workflows.map((workflow) => workflow.id));
8592
+ const loopIds = new Set(bundle.data.loops.map((loop) => loop.id));
8593
+ for (const workflow of bundle.data.workflows) {
8594
+ const redactedStep = workflow.steps.find((step) => step.target.type === "command" && step.target.env && Object.values(step.target.env).includes("[redacted]"));
8595
+ if (redactedStep) {
8596
+ rows.push({
8597
+ resource: "workflow",
8598
+ id: workflow.id,
8599
+ name: workflow.name,
8600
+ action: "blocked",
8601
+ reason: `workflow step ${redactedStep.id} has redacted command env values`,
8602
+ incomingHash: migrationHash(workflow)
8603
+ });
8604
+ continue;
8605
+ }
8606
+ const activeNameCollision = store.listWorkflows({ status: "active" }).find((current) => current.name === workflow.name && current.id !== workflow.id);
8607
+ if (workflow.status === "active" && activeNameCollision) {
8608
+ rows.push({
8609
+ resource: "workflow",
8610
+ id: workflow.id,
8611
+ name: workflow.name,
8612
+ action: "conflict",
8613
+ reason: `active workflow name collides with existing workflow ${activeNameCollision.id}`,
8614
+ incomingHash: migrationHash(workflow),
8615
+ currentHash: migrationHash(activeNameCollision)
8616
+ });
8617
+ continue;
8618
+ }
8619
+ rows.push(compareResource(store.getWorkflow(workflow.id), workflow, { replace, resource: "workflow", id: workflow.id, name: workflow.name }));
8620
+ }
8621
+ for (const loop of bundle.data.loops) {
8622
+ if (loop.target.type === "command" && loop.target.env && Object.values(loop.target.env).includes("[redacted]")) {
8623
+ rows.push({
8624
+ resource: "loop",
8625
+ id: loop.id,
8626
+ name: loop.name,
8627
+ action: "blocked",
8628
+ reason: "loop has redacted command env values",
8629
+ incomingHash: migrationHash(loop)
8630
+ });
8631
+ continue;
8632
+ }
8633
+ if (loop.target.type === "workflow" && !workflowIds.has(loop.target.workflowId) && !store.getWorkflow(loop.target.workflowId)) {
8634
+ rows.push({
8635
+ resource: "loop",
8636
+ id: loop.id,
8637
+ name: loop.name,
8638
+ action: "blocked",
8639
+ reason: `workflow target ${loop.target.workflowId} is not present in bundle or destination store`,
8640
+ incomingHash: migrationHash(loop)
8641
+ });
8642
+ continue;
8643
+ }
8644
+ rows.push(compareResource(store.getLoop(loop.id), loop, { replace, resource: "loop", id: loop.id, name: loop.name }));
8645
+ }
8646
+ if (includeRuns) {
8647
+ for (const run of bundle.data.runs) {
8648
+ if (run.status === "running") {
8649
+ rows.push({
8650
+ resource: "run",
8651
+ id: run.id,
8652
+ name: run.loopName,
8653
+ action: "blocked",
8654
+ reason: "running rows carry volatile lease/process ownership and must finish before import",
8655
+ incomingHash: migrationHash(run)
8656
+ });
8657
+ continue;
8658
+ }
8659
+ if (!loopIds.has(run.loopId) && !store.getLoop(run.loopId)) {
8660
+ rows.push({
8661
+ resource: "run",
8662
+ id: run.id,
8663
+ name: run.loopName,
8664
+ action: "blocked",
8665
+ reason: `loop ${run.loopId} is not present in bundle or destination store`,
8666
+ incomingHash: migrationHash(run)
8667
+ });
8668
+ continue;
8669
+ }
8670
+ const slot = store.getRunBySlot(run.loopId, run.scheduledFor);
8671
+ if (slot && slot.id !== run.id) {
8672
+ rows.push({
8673
+ resource: "run",
8674
+ id: run.id,
8675
+ name: run.loopName,
8676
+ action: "conflict",
8677
+ reason: `scheduled slot already belongs to run ${slot.id}`,
8678
+ incomingHash: migrationHash(run),
8679
+ currentHash: migrationHash(slot)
8680
+ });
8681
+ continue;
8682
+ }
8683
+ rows.push(compareResource(store.getRun(run.id), run, { replace, resource: "run", id: run.id, name: run.loopName }));
8684
+ }
8685
+ } else if (bundle.data.runs.length > 0) {
8686
+ warnings.push("run history is present in the bundle but --no-runs was requested");
8687
+ }
8688
+ const plan = finalizePlan({
8689
+ schema: LOOPS_MIGRATION_SCHEMA,
8690
+ operation: "import",
8691
+ dryRun: opts.dryRun ?? true,
8692
+ replace,
8693
+ importable: bundle.importable && rows.every((row) => row.action !== "blocked" && row.action !== "conflict"),
8694
+ rows,
8695
+ warnings
8696
+ });
8697
+ return { ...plan, importable: plan.summary.blocked === 0 && plan.summary.conflict === 0 };
8698
+ }
8699
+ function applyImportMigrationBundle(store, bundle, opts = {}) {
8700
+ const plan = buildImportMigrationPlan(store, bundle, { ...opts, dryRun: false });
8701
+ if (plan.summary.blocked > 0 || plan.summary.conflict > 0 || !plan.importable) {
8702
+ throw new ValidationError(`migration import is not safe to apply: blocked=${plan.summary.blocked} conflict=${plan.summary.conflict}`);
8703
+ }
8704
+ const applied = { workflows: 0, loops: 0, runs: 0 };
8705
+ let appliedPlan = plan;
8706
+ store.writeTransaction(() => {
8707
+ appliedPlan = buildImportMigrationPlan(store, bundle, { ...opts, dryRun: false });
8708
+ if (appliedPlan.summary.blocked > 0 || appliedPlan.summary.conflict > 0 || !appliedPlan.importable) {
8709
+ throw new ValidationError(`destination store changed before import apply: blocked=${appliedPlan.summary.blocked} conflict=${appliedPlan.summary.conflict}`);
8710
+ }
8711
+ for (const workflow of bundle.data.workflows) {
8712
+ const row = appliedPlan.rows.find((entry) => entry.resource === "workflow" && entry.id === workflow.id);
8713
+ if (row?.action === "insert" || row?.action === "update") {
8714
+ store.upsertMigrationWorkflow(workflow, { replace: opts.replace });
8715
+ applied.workflows += 1;
8716
+ }
8717
+ }
8718
+ for (const loop of bundle.data.loops) {
8719
+ const row = appliedPlan.rows.find((entry) => entry.resource === "loop" && entry.id === loop.id);
8720
+ if (row?.action === "insert" || row?.action === "update") {
8721
+ store.upsertMigrationLoop(loop, { replace: opts.replace });
8722
+ applied.loops += 1;
8723
+ }
8724
+ }
8725
+ if (opts.includeRuns ?? true) {
8726
+ for (const run of bundle.data.runs) {
8727
+ const row = appliedPlan.rows.find((entry) => entry.resource === "run" && entry.id === run.id);
8728
+ if (row?.action === "insert" || row?.action === "update") {
8729
+ store.upsertMigrationRun(run, { replace: opts.replace });
8730
+ applied.runs += 1;
8731
+ }
8732
+ }
8733
+ }
8734
+ });
8735
+ return { plan: appliedPlan, applied };
8736
+ }
8737
+ function envValue2(env, keys) {
8738
+ for (const key of keys) {
8739
+ const value = env[key]?.trim();
8740
+ if (value)
8741
+ return value;
8742
+ }
8743
+ return;
8744
+ }
8745
+ function resolveApiConfig(opts) {
8746
+ const env = opts.env ?? process.env;
8747
+ return {
8748
+ apiUrl: opts.apiUrl ?? envValue2(env, ["LOOPS_API_URL", "HASNA_LOOPS_API_URL", "LOOPS_CLOUD_API_URL", "HASNA_LOOPS_CLOUD_API_URL"]),
8749
+ token: opts.apiToken ?? envValue2(env, ["LOOPS_API_TOKEN", "HASNA_LOOPS_API_TOKEN", "LOOPS_CLOUD_TOKEN", "HASNA_LOOPS_CLOUD_TOKEN"])
8750
+ };
8751
+ }
8752
+ function isLocalApiUrl(value) {
8753
+ try {
8754
+ return ["127.0.0.1", "localhost", "::1"].includes(new URL(value).hostname);
8755
+ } catch {
8756
+ return false;
8757
+ }
8758
+ }
8759
+ function endpoint(base, path) {
8760
+ return new URL(path.replace(/^\//, ""), base.endsWith("/") ? base : `${base}/`).toString();
8761
+ }
8762
+ async function requestJson(fetchImpl, config, path, init = {}) {
8763
+ const response = await fetchImpl(endpoint(config.apiUrl, path), {
8764
+ ...init,
8765
+ headers: {
8766
+ ...init.body ? { "content-type": "application/json" } : {},
8767
+ ...config.token ? { authorization: `Bearer ${config.token}` } : {},
8768
+ ...init.headers
8769
+ }
8770
+ });
8771
+ const payload = await response.json().catch(() => ({}));
8772
+ if (!response.ok)
8773
+ throw new Error(typeof payload.error === "string" ? payload.error : `loops-api request failed: ${response.status}`);
8774
+ return payload;
8775
+ }
8776
+ async function fetchRemotePreview(opts) {
8777
+ const config = resolveApiConfig(opts);
8778
+ const warnings = [];
8779
+ if (!config.apiUrl) {
8780
+ warnings.push("LOOPS_API_URL or HASNA_LOOPS_API_URL is required to inspect a self-hosted control plane");
8781
+ return { loops: [], runs: [], warnings };
8782
+ }
8783
+ if (!isLocalApiUrl(config.apiUrl) && !config.token) {
8784
+ warnings.push("non-local self-hosted APIs require LOOPS_API_TOKEN or HASNA_LOOPS_API_TOKEN");
8785
+ return { loops: [], runs: [], warnings };
8786
+ }
8787
+ const fetchImpl = opts.fetchImpl ?? fetch;
8788
+ const loopsPayload = await requestJson(fetchImpl, { apiUrl: config.apiUrl, token: config.token }, "/v1/loops?includeArchived=true&limit=1000");
8789
+ const runsPayload = opts.includeRuns === false ? { runs: [] } : await requestJson(fetchImpl, { apiUrl: config.apiUrl, token: config.token }, "/v1/runs?limit=1000");
8790
+ return {
8791
+ loops: Array.isArray(loopsPayload.loops) ? loopsPayload.loops : [],
8792
+ runs: Array.isArray(runsPayload.runs) ? runsPayload.runs : [],
8793
+ warnings
8794
+ };
8795
+ }
8796
+ async function buildSelfHostedMigrationPlan(store, opts) {
8797
+ const bundle = exportLoopsMigrationBundle(store, { includeRuns: opts.includeRuns ?? true });
8798
+ const remote = await fetchRemotePreview(opts);
8799
+ const rows = [...bundle.blockers.map((row) => ({ ...row, action: "blocked" }))];
8800
+ const warnings = [
8801
+ ...bundle.warnings,
8802
+ ...remote.warnings,
8803
+ "self-hosted remote apply is preview-only until the control plane exposes id-preserving workflow/loop/run import endpoints"
8804
+ ];
8805
+ if (opts.operation === "self-hosted-pull") {
8806
+ for (const entry of remote.loops) {
8807
+ const value = entry && typeof entry === "object" ? entry : {};
8808
+ const id = typeof value.id === "string" ? value.id : `remote-loop:${rows.length}`;
8809
+ rows.push({
8810
+ resource: "loop",
8811
+ id,
8812
+ name: typeof value.name === "string" ? value.name : undefined,
8813
+ action: "blocked",
8814
+ reason: "remote loop pull needs a full id-preserving export/import endpoint; /v1/loops returns public redacted rows only",
8815
+ currentHash: migrationHash(entry)
8816
+ });
8817
+ }
8818
+ for (const entry of remote.runs) {
8819
+ const value = entry && typeof entry === "object" ? entry : {};
8820
+ const id = typeof value.id === "string" ? value.id : `remote-run:${rows.length}`;
8821
+ rows.push({
8822
+ resource: "run",
8823
+ id,
8824
+ name: typeof value.loopName === "string" ? value.loopName : undefined,
8825
+ action: "blocked",
8826
+ reason: "remote run-history pull needs an id-preserving export/import endpoint; /v1/runs returns public redacted rows only",
8827
+ currentHash: migrationHash(entry)
8828
+ });
8829
+ }
8830
+ const plan2 = finalizePlan({
8831
+ schema: LOOPS_MIGRATION_SCHEMA,
8832
+ operation: opts.operation,
8833
+ dryRun: true,
8834
+ replace: false,
8835
+ importable: false,
8836
+ rows,
8837
+ warnings
8838
+ });
8839
+ return { ...plan2, importable: false };
8840
+ }
8841
+ const remoteLoopsByName = new Map(remote.loops.map((entry) => entry && typeof entry === "object" ? entry : undefined).filter((entry) => typeof entry?.name === "string").map((entry) => [String(entry.name), entry]));
8842
+ for (const workflow of bundle.data.workflows) {
8843
+ rows.push({
8844
+ resource: "workflow",
8845
+ id: workflow.id,
8846
+ name: workflow.name,
8847
+ action: "blocked",
8848
+ reason: "self-hosted API does not expose id-preserving workflow import/list endpoints yet",
8849
+ incomingHash: migrationHash(workflow)
8850
+ });
8851
+ }
8852
+ for (const loop of bundle.data.loops) {
8853
+ const remoteLoop = remoteLoopsByName.get(loop.name);
8854
+ rows.push({
8855
+ resource: "loop",
8856
+ id: loop.id,
8857
+ name: loop.name,
8858
+ action: "blocked",
8859
+ reason: remoteLoop ? `remote loop name exists (${String(remoteLoop.id ?? "unknown id")}); exact id-preserving comparison is not available` : "normal self-hosted loop create would generate a new id; waiting for id-preserving import endpoint",
8860
+ incomingHash: migrationHash(loop),
8861
+ currentHash: remoteLoop ? migrationHash(remoteLoop) : undefined
8862
+ });
8863
+ }
8864
+ if (opts.includeRuns ?? true) {
8865
+ for (const run of bundle.data.runs) {
8866
+ rows.push({
8867
+ resource: "run",
8868
+ id: run.id,
8869
+ name: run.loopName,
8870
+ action: "blocked",
8871
+ reason: "self-hosted API does not expose run-history import endpoints; remote runners should create new run rows",
8872
+ incomingHash: migrationHash(run)
8873
+ });
8874
+ }
8875
+ }
8876
+ const plan = finalizePlan({
8877
+ schema: LOOPS_MIGRATION_SCHEMA,
8878
+ operation: opts.operation,
8879
+ dryRun: true,
8880
+ replace: false,
8881
+ importable: false,
8882
+ rows,
8883
+ warnings
8884
+ });
8885
+ return { ...plan, importable: false };
8886
+ }
8887
+ async function registerSelfHostedRunner(opts) {
8888
+ const config = resolveApiConfig(opts);
8889
+ if (!config.apiUrl)
8890
+ throw new ValidationError("LOOPS_API_URL or --api-url is required");
8891
+ if (!isLocalApiUrl(config.apiUrl) && !config.token)
8892
+ throw new ValidationError("non-local self-hosted APIs require LOOPS_API_TOKEN or HASNA_LOOPS_API_TOKEN");
8893
+ const payload = await requestJson(opts.fetchImpl ?? fetch, { apiUrl: config.apiUrl, token: config.token }, "/v1/runners/register", {
8894
+ method: "POST",
8895
+ body: JSON.stringify({
8896
+ runnerId: opts.runnerId,
8897
+ machineId: opts.machineId,
8898
+ labels: opts.labels ?? {},
8899
+ capabilities: opts.capabilities ?? {}
8900
+ })
8901
+ });
8902
+ return {
8903
+ ok: payload.ok === true,
8904
+ runner: payload.runner && typeof payload.runner === "object" ? payload.runner : {}
8905
+ };
8906
+ }
8907
+ function publicMigrationBundle(bundle) {
8908
+ return {
8909
+ ...bundle,
8910
+ data: {
8911
+ workflows: bundle.data.workflows.map(publicWorkflow),
8912
+ loops: bundle.data.loops.map(publicLoop),
8913
+ runs: bundle.data.runs.map((run) => publicRun(run, false, { redactError: true }))
8914
+ }
8915
+ };
8916
+ }
8917
+ function selfHostedControlPlaneSummary(env = process.env) {
8918
+ const config = loopControlPlaneConfig(env);
8919
+ return {
8920
+ apiUrl: config.apiUrl,
8921
+ databaseUrlPresent: config.databaseUrlPresent,
8922
+ authTokenPresent: config.apiAuthTokenPresent
8923
+ };
8924
+ }
8925
+
8234
8926
  // src/sdk/index.ts
8235
8927
  class LoopsClient {
8236
8928
  store;
@@ -8306,6 +8998,18 @@ class LoopsClient {
8306
8998
  const result = await runLoopNow({ store: this.store, idOrName, runnerId: this.runnerId });
8307
8999
  return result.run;
8308
9000
  }
9001
+ exportBundle(opts = {}) {
9002
+ return exportLoopsMigrationBundle(this.store, opts);
9003
+ }
9004
+ planImport(bundle, opts = {}) {
9005
+ return buildImportMigrationPlan(this.store, bundle, opts);
9006
+ }
9007
+ importBundle(bundle, opts = {}) {
9008
+ return applyImportMigrationBundle(this.store, bundle, opts);
9009
+ }
9010
+ planSelfHostedMigration(opts = {}) {
9011
+ return buildSelfHostedMigrationPlan(this.store, { ...opts, operation: opts.operation ?? "self-hosted-migrate" });
9012
+ }
8309
9013
  close() {
8310
9014
  if (this.ownStore)
8311
9015
  this.store.close();
@@ -8557,12 +9261,12 @@ function createSqliteLoopStorage(path) {
8557
9261
  }
8558
9262
 
8559
9263
  // src/lib/storage/postgres-schema.ts
8560
- import { createHash as createHash3 } from "crypto";
9264
+ import { createHash as createHash4 } from "crypto";
8561
9265
  var POSTGRES_MIGRATION_LEDGER_TABLE = "open_loops_schema_migrations";
8562
9266
  function checksumStorageSql(sql) {
8563
9267
  const normalized = sql.trim().replace(/\r\n/g, `
8564
9268
  `);
8565
- return `sha256:${createHash3("sha256").update(normalized).digest("hex")}`;
9269
+ return `sha256:${createHash4("sha256").update(normalized).digest("hex")}`;
8566
9270
  }
8567
9271
  function migration(id, sql) {
8568
9272
  return Object.freeze({ id, sql: sql.trim(), checksum: checksumStorageSql(sql) });
@@ -9058,7 +9762,6 @@ var BUILTIN_TEMPLATE_SUMMARIES = [
9058
9762
  { name: "taskTitle", description: "Human-readable task title." },
9059
9763
  projectPathVariable(),
9060
9764
  { name: "todosProjectPath", description: "Todos storage project path used in worker/verifier commands." },
9061
- { name: "todosDbPath", description: "Optional exact Todos DB path used via TODOS_DB_PATH/HASNA_TODOS_DB_PATH." },
9062
9765
  ...routeScopeVariables(),
9063
9766
  ...workerVerifierAgentVariables({ addDirs: true, branchNoun: "task/event" })
9064
9767
  ]
@@ -9099,8 +9802,6 @@ var BUILTIN_TEMPLATE_SUMMARIES = [
9099
9802
  variables: [
9100
9803
  { name: "taskId", required: true, description: "Todos task id." },
9101
9804
  projectPathVariable(),
9102
- { name: "todosProjectPath", description: "Todos storage project path used in lifecycle commands." },
9103
- { name: "todosDbPath", description: "Optional exact Todos DB path used via TODOS_DB_PATH/HASNA_TODOS_DB_PATH." },
9104
9805
  { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
9105
9806
  roleAuthProfileVariable("triage"),
9106
9807
  roleAuthProfileVariable("planner"),
@@ -9231,29 +9932,28 @@ function verifierRuntimeGuidance(input) {
9231
9932
  ].join(`
9232
9933
  `);
9233
9934
  }
9234
- function todosInspectLine(todosProjectPath, taskId, todosDbPath) {
9235
- return `- Inspect first: ${todosCommand(todosProjectPath, todosDbPath)} inspect ${taskId}`;
9935
+ function todosInspectLine(todosProjectPath, taskId) {
9936
+ return `- Inspect first: todos --project ${todosProjectPath} inspect ${taskId}`;
9236
9937
  }
9237
- function todosStartLine(todosProjectPath, taskId, todosDbPath) {
9238
- return `- Claim/start if appropriate: ${todosCommand(todosProjectPath, todosDbPath)} start ${taskId}`;
9938
+ function todosStartLine(todosProjectPath, taskId) {
9939
+ return `- Claim/start if appropriate: todos --project ${todosProjectPath} start ${taskId}`;
9239
9940
  }
9240
- function todosEvidenceLine(todosProjectPath, taskId, placeholder, todosDbPath) {
9241
- return `- Record evidence: ${todosCommand(todosProjectPath, todosDbPath)} comment ${taskId} "<${placeholder}>"`;
9941
+ function todosEvidenceLine(todosProjectPath, taskId, placeholder) {
9942
+ return `- Record evidence: todos --project ${todosProjectPath} comment ${taskId} "<${placeholder}>"`;
9242
9943
  }
9243
- function todosVerificationLine(todosProjectPath, taskId, todosDbPath) {
9244
- return `- Record verification: ${todosCommand(todosProjectPath, todosDbPath)} comment ${taskId} "<verification evidence or blocker>"`;
9944
+ function todosVerificationLine(todosProjectPath, taskId) {
9945
+ return `- Record verification: todos --project ${todosProjectPath} comment ${taskId} "<verification evidence or blocker>"`;
9245
9946
  }
9246
- function todosDoneLine(todosProjectPath, taskId, todosDbPath) {
9247
- return `- If valid and complete: ${todosCommand(todosProjectPath, todosDbPath)} done ${taskId}`;
9947
+ function todosDoneLine(todosProjectPath, taskId) {
9948
+ return `- If valid and complete: todos --project ${todosProjectPath} done ${taskId}`;
9248
9949
  }
9249
- function todosExactCommandsFragment(todosProjectPath, taskId, commandLines, todosDbPath) {
9950
+ function todosExactCommandsFragment(todosProjectPath, taskId, commandLines) {
9250
9951
  return [
9251
9952
  `Todos project path: ${todosProjectPath}`,
9252
- todosDbPath ? `Todos DB path: ${todosDbPath}` : undefined,
9253
9953
  "Use these exact todos commands so worktree cwd inference cannot attach to the wrong project:",
9254
- todosInspectLine(todosProjectPath, taskId, todosDbPath),
9954
+ todosInspectLine(todosProjectPath, taskId),
9255
9955
  ...commandLines
9256
- ].filter((line) => Boolean(line));
9956
+ ];
9257
9957
  }
9258
9958
  function worktreePrompt(plan) {
9259
9959
  if (plan.enabled) {
@@ -9290,19 +9990,10 @@ function worktreeContextFragment(plan) {
9290
9990
  function shellQuote2(value) {
9291
9991
  return `'${value.replace(/'/g, `'\\''`)}'`;
9292
9992
  }
9293
- function todosEnvPrefix(todosDbPath) {
9294
- return todosDbPath ? `TODOS_DB_PATH=${shellQuote2(todosDbPath)} HASNA_TODOS_DB_PATH=${shellQuote2(todosDbPath)} ` : "";
9295
- }
9296
- function todosCommand(todosProjectPath, todosDbPath) {
9297
- return `${todosEnvPrefix(todosDbPath)}todos --project ${todosProjectPath}`;
9298
- }
9299
- function shellTodosCommand(todosProjectPath, todosDbPath) {
9300
- return `${todosEnvPrefix(todosDbPath)}todos --project ${shellQuote2(todosProjectPath)}`;
9301
- }
9302
- function sourceTaskGateCommand(todosProjectPath, taskId, todosDbPath) {
9993
+ function sourceTaskGateCommand(todosProjectPath, taskId) {
9303
9994
  return [
9304
9995
  "set -euo pipefail",
9305
- `${shellTodosCommand(todosProjectPath, todosDbPath)} --json inspect ${shellQuote2(taskId)} >/dev/null`,
9996
+ `todos --project ${shellQuote2(todosProjectPath)} --json inspect ${shellQuote2(taskId)} >/dev/null`,
9306
9997
  `printf "source task %s resolved in todos project %s\\n" ${shellQuote2(taskId)} ${shellQuote2(todosProjectPath)}`
9307
9998
  ].join(`
9308
9999
  `);
@@ -9362,10 +10053,10 @@ var LIFECYCLE_GATE_SCRIPT_TAIL = [
9362
10053
  "console.log(`task lifecycle ${stage} gate passed for ${task.id || task.taskId || 'task'} status=${status || 'unknown'}`);"
9363
10054
  ].join(`
9364
10055
  `);
9365
- function lifecycleGateCommand(todosProjectPath, taskId, stage, goMarker, blockedMarker, todosDbPath) {
10056
+ function lifecycleGateCommand(todosProjectPath, taskId, stage, goMarker, blockedMarker) {
9366
10057
  return [
9367
10058
  "set -euo pipefail",
9368
- `task_json="$(${shellTodosCommand(todosProjectPath, todosDbPath)} --json inspect ${shellQuote2(taskId)})"`,
10059
+ `task_json="$(todos --project ${shellQuote2(todosProjectPath)} --json inspect ${shellQuote2(taskId)})"`,
9369
10060
  `TASK_JSON="$task_json" STAGE=${shellQuote2(stage)} bun - <<'BUN'`,
9370
10061
  LIFECYCLE_GATE_SCRIPT_HEAD,
9371
10062
  `const goMarker = ${JSON.stringify(goMarker)};`,
@@ -9381,7 +10072,6 @@ var PR_HANDOFF_SCRIPT = [
9381
10072
  "const artifactPath = process.env.OPENLOOPS_PR_HANDOFF_ARTIFACT || '';",
9382
10073
  "const taskId = process.env.OPENLOOPS_PR_HANDOFF_TASK_ID || '';",
9383
10074
  "const todosProject = process.env.OPENLOOPS_PR_HANDOFF_TODOS_PROJECT || '';",
9384
- "const todosDbPath = process.env.OPENLOOPS_PR_HANDOFF_TODOS_DB_PATH || '';",
9385
10075
  "const fallbackWorktree = process.env.OPENLOOPS_PR_HANDOFF_WORKTREE || process.cwd();",
9386
10076
  "const expectedRoot = process.env.OPENLOOPS_PR_HANDOFF_WORKTREE_ROOT || fallbackWorktree;",
9387
10077
  "const expectedBranch = process.env.OPENLOOPS_PR_HANDOFF_EXPECTED_BRANCH || '';",
@@ -9399,8 +10089,7 @@ var PR_HANDOFF_SCRIPT = [
9399
10089
  "};",
9400
10090
  "const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', ...options });",
9401
10091
  "const todosArgs = (...args) => todosProject ? ['--project', todosProject, ...args] : args;",
9402
- "const todosEnv = todosDbPath ? { ...process.env, TODOS_DB_PATH: todosDbPath, HASNA_TODOS_DB_PATH: todosDbPath } : process.env;",
9403
- "const todos = (...args) => run(todosBin, todosArgs(...args), { env: todosEnv });",
10092
+ "const todos = (...args) => run(todosBin, todosArgs(...args));",
9404
10093
  "const comment = (text) => {",
9405
10094
  " const result = todos('comment', taskId, text);",
9406
10095
  " if (result.status !== 0) console.error(`failed to comment original task: ${result.stderr || result.stdout || result.status}`);",
@@ -9529,7 +10218,6 @@ function prHandoffCommand(opts) {
9529
10218
  `export OPENLOOPS_PR_HANDOFF_ARTIFACT=${shellQuote2(opts.artifactPath)}`,
9530
10219
  `export OPENLOOPS_PR_HANDOFF_TASK_ID=${shellQuote2(opts.taskId)}`,
9531
10220
  `export OPENLOOPS_PR_HANDOFF_TODOS_PROJECT=${shellQuote2(opts.todosProjectPath)}`,
9532
- opts.todosDbPath ? `export OPENLOOPS_PR_HANDOFF_TODOS_DB_PATH=${shellQuote2(opts.todosDbPath)}` : undefined,
9533
10221
  `export OPENLOOPS_PR_HANDOFF_WORKTREE=${shellQuote2(opts.worktreeCwd)}`,
9534
10222
  `export OPENLOOPS_PR_HANDOFF_WORKTREE_ROOT=${shellQuote2(opts.worktreeRoot)}`,
9535
10223
  `export OPENLOOPS_PR_HANDOFF_EXPECTED_BRANCH=${shellQuote2(opts.expectedBranch)}`,
@@ -9540,7 +10228,7 @@ function prHandoffCommand(opts) {
9540
10228
  "bun - <<'BUN'",
9541
10229
  PR_HANDOFF_SCRIPT,
9542
10230
  "BUN"
9543
- ].filter((line) => Boolean(line)).join(`
10231
+ ].join(`
9544
10232
  `);
9545
10233
  }
9546
10234
  // src/lib/templates-custom.ts
@@ -10130,12 +10818,12 @@ function commandStep(opts) {
10130
10818
  ...opts.blockedExitCodes ? { blockedExitCodes: opts.blockedExitCodes } : {}
10131
10819
  };
10132
10820
  }
10133
- function sourceTaskGateStep(todosProjectPath, taskId, plan, description, todosDbPath) {
10821
+ function sourceTaskGateStep(todosProjectPath, taskId, plan, description) {
10134
10822
  return commandStep({
10135
10823
  id: "source-task-gate",
10136
10824
  name: "Source Task Gate",
10137
10825
  description,
10138
- command: sourceTaskGateCommand(todosProjectPath, taskId, todosDbPath),
10826
+ command: sourceTaskGateCommand(todosProjectPath, taskId),
10139
10827
  cwd: plan.originalCwd,
10140
10828
  timeoutMs: 60000,
10141
10829
  blockedExitCodes: GATE_BLOCKED_EXIT_CODES
@@ -10147,7 +10835,7 @@ function lifecycleGateStep(opts) {
10147
10835
  name: opts.stage === "triage" ? "Triage Gate" : "Planner Gate",
10148
10836
  description: opts.description,
10149
10837
  dependsOn: opts.dependsOn,
10150
- command: lifecycleGateCommand(opts.todosProjectPath, opts.taskId, opts.stage, opts.goMarker, opts.blockedMarker, opts.todosDbPath),
10838
+ command: lifecycleGateCommand(opts.todosProjectPath, opts.taskId, opts.stage, opts.goMarker, opts.blockedMarker),
10151
10839
  cwd: opts.plan.originalCwd,
10152
10840
  timeoutMs: 2 * 60000,
10153
10841
  blockedExitCodes: GATE_BLOCKED_EXIT_CODES
@@ -10156,7 +10844,7 @@ function lifecycleGateStep(opts) {
10156
10844
  function prHandoffArtifactPath(plan, taskId) {
10157
10845
  return join7(plan.cwd, ".openloops", "pr-handoff", `${slugSegment(taskId, "task")}.json`);
10158
10846
  }
10159
- function prHandoffStep(input, plan, todosProjectPath, todosDbPath) {
10847
+ function prHandoffStep(input, plan, todosProjectPath) {
10160
10848
  return commandStep({
10161
10849
  id: "pr-handoff",
10162
10850
  name: "PR Handoff",
@@ -10166,7 +10854,6 @@ function prHandoffStep(input, plan, todosProjectPath, todosDbPath) {
10166
10854
  artifactPath: prHandoffArtifactPath(plan, input.taskId),
10167
10855
  taskId: input.taskId,
10168
10856
  todosProjectPath,
10169
- todosDbPath,
10170
10857
  worktreeCwd: plan.cwd,
10171
10858
  worktreeRoot: plan.path ?? plan.cwd,
10172
10859
  expectedBranch: plan.branch ?? ""
@@ -10253,7 +10940,6 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
10253
10940
  if (!input.projectPath?.trim())
10254
10941
  throw new Error("projectPath is required");
10255
10942
  const todosProjectPath = input.todosProjectPath ?? input.routeProjectPath ?? input.projectPath;
10256
- const todosDbPath = input.todosDbPath;
10257
10943
  const plan = worktreePlan(input, input.taskId);
10258
10944
  const taskContext = {
10259
10945
  taskId: input.taskId,
@@ -10264,17 +10950,15 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
10264
10950
  projectPath: input.projectPath,
10265
10951
  routeProjectPath: input.routeProjectPath,
10266
10952
  projectGroup: input.projectGroup,
10267
- todosProjectPath: input.todosProjectPath ?? (input.todosDbPath ? todosProjectPath : undefined),
10268
- todosDbPath,
10269
10953
  worktree: worktreeContextFragment(plan)
10270
10954
  };
10271
10955
  const workerPrompt = [
10272
10956
  ...goalHeaderFragment(`Complete todos task ${input.taskId} in ${input.projectPath}.`, "worker", "task"),
10273
10957
  worktreePrompt(plan),
10274
10958
  ...todosExactCommandsFragment(todosProjectPath, input.taskId, [
10275
- todosStartLine(todosProjectPath, input.taskId, todosDbPath),
10276
- todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence and blockers", todosDbPath)
10277
- ], todosDbPath),
10959
+ todosStartLine(todosProjectPath, input.taskId),
10960
+ todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence and blockers")
10961
+ ]),
10278
10962
  "Investigate first before changing files. Use the todos CLI as the source of truth for the task.",
10279
10963
  "Inspect the repository/project state, implement only the task scope, run focused validation, preserve unrelated user changes, and update the task with comments, evidence, changed files, commits, and blockers.",
10280
10964
  NO_TMUX_DISPATCH_FRAGMENT,
@@ -10287,9 +10971,9 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
10287
10971
  ...goalHeaderFragment(`Verify todos task ${input.taskId} after the worker step.`, "verifier", "task"),
10288
10972
  worktreePrompt(plan),
10289
10973
  ...todosExactCommandsFragment(todosProjectPath, input.taskId, [
10290
- todosVerificationLine(todosProjectPath, input.taskId, todosDbPath),
10291
- todosDoneLine(todosProjectPath, input.taskId, todosDbPath)
10292
- ], todosDbPath),
10974
+ todosVerificationLine(todosProjectPath, input.taskId),
10975
+ todosDoneLine(todosProjectPath, input.taskId)
10976
+ ]),
10293
10977
  adversarialReviewFragment("the task, repository state, commits, tests, and worker evidence", TASK_REVIEW_FOCUS),
10294
10978
  verifierRuntimeGuidance(input),
10295
10979
  TASK_VERIFIER_DECISION_FRAGMENT,
@@ -10304,7 +10988,7 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
10304
10988
  description: `Task-triggered worker/verifier workflow for ${taskLabel(input)}`,
10305
10989
  version: 1,
10306
10990
  steps: [
10307
- sourceTaskGateStep(todosProjectPath, input.taskId, plan, "Fail before worker execution when the source todos task is not resolvable.", todosDbPath),
10991
+ sourceTaskGateStep(todosProjectPath, input.taskId, plan, "Fail before worker execution when the source todos task is not resolvable."),
10308
10992
  ...workerVerifierSteps({
10309
10993
  input,
10310
10994
  seed: input.taskId,
@@ -10324,7 +11008,6 @@ function renderTaskLifecycleWorkflow(input) {
10324
11008
  if (!input.projectPath?.trim())
10325
11009
  throw new Error("projectPath is required");
10326
11010
  const todosProjectPath = input.todosProjectPath ?? input.routeProjectPath ?? input.projectPath;
10327
- const todosDbPath = input.todosDbPath;
10328
11011
  const plan = worktreePlan(input, input.taskId);
10329
11012
  const taskContext = {
10330
11013
  taskId: input.taskId,
@@ -10336,7 +11019,6 @@ function renderTaskLifecycleWorkflow(input) {
10336
11019
  routeProjectPath: input.routeProjectPath,
10337
11020
  projectGroup: input.projectGroup,
10338
11021
  todosProjectPath,
10339
- todosDbPath,
10340
11022
  worktree: worktreeContextFragment(plan)
10341
11023
  };
10342
11024
  const handoffArtifactPath = prHandoffArtifactPath(plan, input.taskId);
@@ -10350,8 +11032,8 @@ function renderTaskLifecycleWorkflow(input) {
10350
11032
  const shared = [
10351
11033
  worktreePrompt(plan),
10352
11034
  ...todosExactCommandsFragment(todosProjectPath, input.taskId, [
10353
- todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence, decision, or blocker", todosDbPath)
10354
- ], todosDbPath),
11035
+ todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence, decision, or blocker")
11036
+ ]),
10355
11037
  NO_TMUX_DISPATCH_FRAGMENT,
10356
11038
  "Preserve unrelated user changes and keep scope tied to the task acceptance criteria.",
10357
11039
  "",
@@ -10360,7 +11042,7 @@ function renderTaskLifecycleWorkflow(input) {
10360
11042
  ].join(`
10361
11043
  `);
10362
11044
  const gateMarker = (stage, state) => `openloops:${stage}=${state} task=${input.taskId}${input.eventId ? ` event=${input.eventId}` : ""}`;
10363
- const blockTaskCommand = `${todosEnvPrefix(todosDbPath)}todos --project ${todosProjectPath} update ${input.taskId} --status blocked`;
11045
+ const blockTaskCommand = `todos --project ${todosProjectPath} update ${input.taskId} --status blocked`;
10364
11046
  const gateStopFragment = (stage, stops) => `The deterministic ${stage} gate will stop ${stops} unless the latest ${stage} marker is the exact go marker and the task has no blocked/completed/done/cancelled/failed/archived/no-auto/manual/approval-required state.`;
10365
11047
  const triagePrompt = [
10366
11048
  ...goalHeaderFragment(`Triage todos task ${input.taskId} for safe automated execution.`, "triage", "lifecycle"),
@@ -10388,7 +11070,7 @@ function renderTaskLifecycleWorkflow(input) {
10388
11070
  const workerPrompt = [
10389
11071
  ...goalHeaderFragment(`Complete todos task ${input.taskId} according to the planner evidence.`, "worker", "lifecycle"),
10390
11072
  shared,
10391
- todosStartLine(todosProjectPath, input.taskId, todosDbPath),
11073
+ todosStartLine(todosProjectPath, input.taskId),
10392
11074
  "Read the triage and planner comments first. Implement only the scoped task, run focused validation, and record changed files, commits, evidence, blockers, and residual risks.",
10393
11075
  input.prHandoff ? `When only GitHub network access is blocked after a successful commit/validation, record the handoff artifact at ${handoffArtifactPath} instead of repeatedly retrying push/PR creation.` : undefined,
10394
11076
  WORKER_LEAVES_COMPLETION_FRAGMENT
@@ -10397,8 +11079,8 @@ function renderTaskLifecycleWorkflow(input) {
10397
11079
  const verifierPrompt = [
10398
11080
  ...goalHeaderFragment(`Verify todos task ${input.taskId} after the full lifecycle worker step.`, "verifier", "lifecycle"),
10399
11081
  shared,
10400
- todosVerificationLine(todosProjectPath, input.taskId, todosDbPath),
10401
- todosDoneLine(todosProjectPath, input.taskId, todosDbPath),
11082
+ todosVerificationLine(todosProjectPath, input.taskId),
11083
+ todosDoneLine(todosProjectPath, input.taskId),
10402
11084
  adversarialReviewFragment("triage, plan, worker evidence, repo state, commits, tests, and acceptance criteria", TASK_REVIEW_FOCUS),
10403
11085
  verifierRuntimeGuidance(input),
10404
11086
  input.prHandoff ? `If ${handoffArtifactPath} exists and there is no PR URL evidence, verify that the PR handoff step queued or completed a bounded handoff; leave the original task open or blocked until PR evidence is recorded.` : undefined,
@@ -10407,7 +11089,7 @@ function renderTaskLifecycleWorkflow(input) {
10407
11089
  ].filter(Boolean).join(`
10408
11090
  `);
10409
11091
  const steps = [
10410
- sourceTaskGateStep(todosProjectPath, input.taskId, plan, "Fail before lifecycle agents execute when the source todos task is not resolvable.", todosDbPath),
11092
+ sourceTaskGateStep(todosProjectPath, input.taskId, plan, "Fail before lifecycle agents execute when the source todos task is not resolvable."),
10411
11093
  {
10412
11094
  id: "triage",
10413
11095
  name: "Triage",
@@ -10421,7 +11103,6 @@ function renderTaskLifecycleWorkflow(input) {
10421
11103
  description: "Stop the lifecycle before planning when triage blocked or disallowed automation.",
10422
11104
  dependsOn: ["triage"],
10423
11105
  todosProjectPath,
10424
- todosDbPath,
10425
11106
  taskId: input.taskId,
10426
11107
  goMarker: gateMarker("triage", "go"),
10427
11108
  blockedMarker: gateMarker("triage", "blocked"),
@@ -10440,7 +11121,6 @@ function renderTaskLifecycleWorkflow(input) {
10440
11121
  description: "Stop the lifecycle before implementation when planning blocked or disallowed automation.",
10441
11122
  dependsOn: ["planner"],
10442
11123
  todosProjectPath,
10443
- todosDbPath,
10444
11124
  taskId: input.taskId,
10445
11125
  goMarker: gateMarker("planner", "go"),
10446
11126
  blockedMarker: gateMarker("planner", "blocked"),
@@ -10456,7 +11136,7 @@ function renderTaskLifecycleWorkflow(input) {
10456
11136
  }
10457
11137
  ];
10458
11138
  if (input.prHandoff) {
10459
- steps.push(prHandoffStep(input, plan, todosProjectPath, todosDbPath));
11139
+ steps.push(prHandoffStep(input, plan, todosProjectPath));
10460
11140
  }
10461
11141
  steps.push({
10462
11142
  id: "verifier",
@@ -10681,7 +11361,6 @@ function renderLifecycleBoundedTemplate(id, values) {
10681
11361
  taskTitle: values.taskTitle,
10682
11362
  taskDescription: values.taskDescription,
10683
11363
  todosProjectPath: values.todosProjectPath ?? values.todosProject,
10684
- todosDbPath: values.todosDbPath,
10685
11364
  triageAuthProfile: values.triageAuthProfile,
10686
11365
  plannerAuthProfile: values.plannerAuthProfile,
10687
11366
  triageAccount: accountVar(values.triageAccount, values.accountTool),
@@ -10746,7 +11425,6 @@ function renderBuiltinLoopTemplate(id, values) {
10746
11425
  taskTitle: values.taskTitle,
10747
11426
  taskDescription: values.taskDescription,
10748
11427
  todosProjectPath: values.todosProjectPath ?? values.todosProject,
10749
- todosDbPath: values.todosDbPath,
10750
11428
  eventId: values.eventId,
10751
11429
  eventType: values.eventType
10752
11430
  });
@@ -11041,7 +11719,9 @@ function buildScriptInventoryReport(store, opts = {}) {
11041
11719
  export {
11042
11720
  workflowExecutionOrder,
11043
11721
  workflowBodyFromJson,
11722
+ validateLoopsMigrationBundle,
11044
11723
  tick,
11724
+ selfHostedControlPlaneSummary,
11045
11725
  runLoopNow,
11046
11726
  runGoal,
11047
11727
  runDoctor,
@@ -11053,8 +11733,10 @@ export {
11053
11733
  renderLoopTemplate,
11054
11734
  renderEventWorkerVerifierWorkflow,
11055
11735
  renderBoundedAgentWorkerVerifierWorkflow,
11736
+ registerSelfHostedRunner,
11056
11737
  refreshLoopMachine,
11057
11738
  readyNodeKeys,
11739
+ publicMigrationBundle,
11058
11740
  preflightWorkflow,
11059
11741
  preflightTarget,
11060
11742
  parseDuration,
@@ -11062,6 +11744,7 @@ export {
11062
11744
  openAutomationsRuntimeBinding,
11063
11745
  normalizeLoopDeploymentMode,
11064
11746
  nextCronRun,
11747
+ migrationHash,
11065
11748
  loops,
11066
11749
  loopControlPlaneConfig,
11067
11750
  listToolsForCli,
@@ -11070,6 +11753,7 @@ export {
11070
11753
  isTerminal as isGoalTerminal,
11071
11754
  initialNextRun,
11072
11755
  getLoopTemplate,
11756
+ exportLoopsMigrationBundle,
11073
11757
  expectationForLoop,
11074
11758
  executeWorkflow,
11075
11759
  executeTarget,
@@ -11082,11 +11766,14 @@ export {
11082
11766
  computeNextAfter,
11083
11767
  classifyRunFailure,
11084
11768
  checksumStorageSql,
11769
+ buildSelfHostedMigrationPlan,
11085
11770
  buildScriptInventoryReport,
11086
11771
  buildNameHygieneReport,
11772
+ buildImportMigrationPlan,
11087
11773
  buildHealthReport,
11088
11774
  buildDuplicateOverlapReport,
11089
11775
  buildDeploymentStatus,
11776
+ applyImportMigrationBundle,
11090
11777
  ValidationError,
11091
11778
  TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID,
11092
11779
  Store,
@@ -11098,6 +11785,7 @@ export {
11098
11785
  LoopNotFoundError,
11099
11786
  LoopArchivedError,
11100
11787
  LOOP_DEPLOYMENT_MODES,
11788
+ LOOPS_MIGRATION_SCHEMA,
11101
11789
  LOOPS_MCP_TOOLS,
11102
11790
  EVENT_WORKER_VERIFIER_TEMPLATE_ID,
11103
11791
  CodedError,