@hasna/loops 0.4.2 → 0.4.4

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