@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/cli/index.js CHANGED
@@ -1525,7 +1525,7 @@ class Store {
1525
1525
  }
1526
1526
  const applied = new Set(this.db.query("SELECT id FROM schema_migrations").all().map((row) => row.id));
1527
1527
  for (const migration of this.migrations()) {
1528
- if (!migration.baseline && !migration.reapply && applied.has(migration.id))
1528
+ if (!migration.baseline && applied.has(migration.id))
1529
1529
  continue;
1530
1530
  migration.apply();
1531
1531
  if (!applied.has(migration.id)) {
@@ -1578,7 +1578,6 @@ class Store {
1578
1578
  },
1579
1579
  {
1580
1580
  id: "0007_run_claim_tokens",
1581
- reapply: true,
1582
1581
  apply: () => {
1583
1582
  this.addColumnIfMissing("loop_runs", "claim_token", "TEXT");
1584
1583
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_runs_claim_token ON loop_runs(claim_token) WHERE claim_token IS NOT NULL");
@@ -3843,6 +3842,190 @@ class Store {
3843
3842
  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();
3844
3843
  return row?.count ?? 0;
3845
3844
  }
3845
+ exportMigrationRows(opts = {}) {
3846
+ const includeRuns = opts.includeRuns ?? true;
3847
+ const workflows = this.db.query("SELECT * FROM workflow_specs ORDER BY created_at ASC, id ASC").all().map(rowToWorkflow);
3848
+ const loops = this.db.query("SELECT * FROM loops ORDER BY created_at ASC, id ASC").all().map(rowToLoop);
3849
+ const runs = includeRuns ? this.db.query("SELECT * FROM loop_runs ORDER BY created_at ASC, id ASC").all().map(rowToRun) : [];
3850
+ return { schemaVersion: SCHEMA_USER_VERSION, workflows, loops, runs, checks: this.migrationChecks() };
3851
+ }
3852
+ countTable(table) {
3853
+ const row = this.db.query(`SELECT COUNT(*) AS count FROM ${table}`).get();
3854
+ return row?.count ?? 0;
3855
+ }
3856
+ migrationChecks() {
3857
+ const now = nowIso();
3858
+ return {
3859
+ unsupportedCounts: {
3860
+ workflowInvocations: this.countTable("workflow_invocations"),
3861
+ workflowWorkItems: this.countTable("workflow_work_items"),
3862
+ workflowRuns: this.countTable("workflow_runs"),
3863
+ workflowStepRuns: this.countTable("workflow_step_runs"),
3864
+ workflowEvents: this.countTable("workflow_events"),
3865
+ goals: this.countTable("goals"),
3866
+ goalPlanNodes: this.countTable("goal_plan_nodes"),
3867
+ goalRuns: this.countTable("goal_runs")
3868
+ },
3869
+ volatileCounts: {
3870
+ daemonLeases: this.countTable("daemon_lease"),
3871
+ activeDaemonLeases: this.db.query("SELECT COUNT(*) AS count FROM daemon_lease WHERE expires_at > ?").get(now)?.count ?? 0,
3872
+ runningLoopRuns: this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE status = 'running'").get()?.count ?? 0,
3873
+ runningWorkflowRuns: this.db.query("SELECT COUNT(*) AS count FROM workflow_runs WHERE status = 'running'").get()?.count ?? 0,
3874
+ runningWorkflowStepRuns: this.db.query("SELECT COUNT(*) AS count FROM workflow_step_runs WHERE status = 'running'").get()?.count ?? 0,
3875
+ 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
3876
+ }
3877
+ };
3878
+ }
3879
+ upsertMigrationWorkflow(workflow, opts = {}) {
3880
+ const existing = this.getWorkflow(workflow.id);
3881
+ if (existing && !opts.replace)
3882
+ return existing;
3883
+ this.db.query(`INSERT INTO workflow_specs (id, name, description, version, status, goal_json, steps_json, created_at, updated_at)
3884
+ VALUES ($id, $name, $description, $version, $status, $goal, $steps, $created, $updated)
3885
+ ON CONFLICT(id) DO UPDATE SET
3886
+ name=$name,
3887
+ description=$description,
3888
+ version=$version,
3889
+ status=$status,
3890
+ goal_json=$goal,
3891
+ steps_json=$steps,
3892
+ created_at=$created,
3893
+ updated_at=$updated`).run({
3894
+ $id: workflow.id,
3895
+ $name: workflow.name,
3896
+ $description: workflow.description ?? null,
3897
+ $version: workflow.version,
3898
+ $status: workflow.status,
3899
+ $goal: workflow.goal ? JSON.stringify(workflow.goal) : null,
3900
+ $steps: JSON.stringify(workflow.steps),
3901
+ $created: workflow.createdAt,
3902
+ $updated: workflow.updatedAt
3903
+ });
3904
+ const imported = this.getWorkflow(workflow.id);
3905
+ if (!imported)
3906
+ throw new Error(`workflow not found after migration import: ${workflow.id}`);
3907
+ return imported;
3908
+ }
3909
+ upsertMigrationLoop(loop, opts = {}) {
3910
+ const existing = this.getLoop(loop.id);
3911
+ if (existing && !opts.replace)
3912
+ return existing;
3913
+ this.assertNoNestedWorkflowGoal(loop.target, loop.goal);
3914
+ this.db.query(`INSERT INTO loops (id, name, description, status, archived_at, archived_from_status, schedule_json, target_json,
3915
+ goal_json, machine_json, next_run_at, retry_scheduled_for, catch_up, catch_up_limit, overlap, max_attempts,
3916
+ retry_delay_ms, lease_ms, expires_at, created_at, updated_at)
3917
+ VALUES ($id, $name, $description, $status, $archivedAt, $archivedFromStatus, $schedule, $target,
3918
+ $goal, $machine, $nextRun, $retrySlot, $catchUp, $catchUpLimit, $overlap, $maxAttempts,
3919
+ $retryDelay, $leaseMs, $expiresAt, $created, $updated)
3920
+ ON CONFLICT(id) DO UPDATE SET
3921
+ name=$name,
3922
+ description=$description,
3923
+ status=$status,
3924
+ archived_at=$archivedAt,
3925
+ archived_from_status=$archivedFromStatus,
3926
+ schedule_json=$schedule,
3927
+ target_json=$target,
3928
+ goal_json=$goal,
3929
+ machine_json=$machine,
3930
+ next_run_at=$nextRun,
3931
+ retry_scheduled_for=$retrySlot,
3932
+ catch_up=$catchUp,
3933
+ catch_up_limit=$catchUpLimit,
3934
+ overlap=$overlap,
3935
+ max_attempts=$maxAttempts,
3936
+ retry_delay_ms=$retryDelay,
3937
+ lease_ms=$leaseMs,
3938
+ expires_at=$expiresAt,
3939
+ created_at=$created,
3940
+ updated_at=$updated`).run({
3941
+ $id: loop.id,
3942
+ $name: loop.name,
3943
+ $description: loop.description ?? null,
3944
+ $status: loop.status,
3945
+ $archivedAt: loop.archivedAt ?? null,
3946
+ $archivedFromStatus: loop.archivedFromStatus ?? null,
3947
+ $schedule: JSON.stringify(loop.schedule),
3948
+ $target: JSON.stringify(loop.target),
3949
+ $goal: loop.goal ? JSON.stringify(loop.goal) : null,
3950
+ $machine: loop.machine ? JSON.stringify(loop.machine) : null,
3951
+ $nextRun: loop.nextRunAt ?? null,
3952
+ $retrySlot: loop.retryScheduledFor ?? null,
3953
+ $catchUp: loop.catchUp,
3954
+ $catchUpLimit: loop.catchUpLimit,
3955
+ $overlap: loop.overlap,
3956
+ $maxAttempts: loop.maxAttempts,
3957
+ $retryDelay: loop.retryDelayMs,
3958
+ $leaseMs: loop.leaseMs,
3959
+ $expiresAt: loop.expiresAt ?? null,
3960
+ $created: loop.createdAt,
3961
+ $updated: loop.updatedAt
3962
+ });
3963
+ const imported = this.getLoop(loop.id);
3964
+ if (!imported)
3965
+ throw new Error(`loop not found after migration import: ${loop.id}`);
3966
+ return imported;
3967
+ }
3968
+ upsertMigrationRun(run, opts = {}) {
3969
+ if (run.status === "running")
3970
+ throw new ValidationError(`cannot import running run ${run.id}`);
3971
+ const existing = this.getRun(run.id);
3972
+ if (existing && !opts.replace)
3973
+ return existing;
3974
+ this.db.query(`INSERT INTO loop_runs (id, loop_id, loop_name, scheduled_for, attempt, status, started_at, finished_at,
3975
+ claimed_by, claim_token, lease_expires_at, pid, pgid, process_started_at, exit_code, duration_ms,
3976
+ stdout, stderr, error, goal_run_id, created_at, updated_at)
3977
+ VALUES ($id, $loopId, $loopName, $scheduledFor, $attempt, $status, $startedAt, $finishedAt,
3978
+ $claimedBy, NULL, $leaseExpiresAt, $pid, $pgid, $processStartedAt, $exitCode, $durationMs,
3979
+ $stdout, $stderr, $error, $goalRunId, $created, $updated)
3980
+ ON CONFLICT(id) DO UPDATE SET
3981
+ loop_id=$loopId,
3982
+ loop_name=$loopName,
3983
+ scheduled_for=$scheduledFor,
3984
+ attempt=$attempt,
3985
+ status=$status,
3986
+ started_at=$startedAt,
3987
+ finished_at=$finishedAt,
3988
+ claimed_by=$claimedBy,
3989
+ claim_token=NULL,
3990
+ lease_expires_at=$leaseExpiresAt,
3991
+ pid=$pid,
3992
+ pgid=$pgid,
3993
+ process_started_at=$processStartedAt,
3994
+ exit_code=$exitCode,
3995
+ duration_ms=$durationMs,
3996
+ stdout=$stdout,
3997
+ stderr=$stderr,
3998
+ error=$error,
3999
+ goal_run_id=$goalRunId,
4000
+ created_at=$created,
4001
+ updated_at=$updated`).run({
4002
+ $id: run.id,
4003
+ $loopId: run.loopId,
4004
+ $loopName: run.loopName,
4005
+ $scheduledFor: run.scheduledFor,
4006
+ $attempt: run.attempt,
4007
+ $status: run.status,
4008
+ $startedAt: run.startedAt ?? null,
4009
+ $finishedAt: run.finishedAt ?? null,
4010
+ $claimedBy: run.claimedBy ?? null,
4011
+ $leaseExpiresAt: run.leaseExpiresAt ?? null,
4012
+ $pid: run.pid ?? null,
4013
+ $pgid: run.pgid ?? null,
4014
+ $processStartedAt: run.processStartedAt ?? null,
4015
+ $exitCode: run.exitCode ?? null,
4016
+ $durationMs: run.durationMs ?? null,
4017
+ $stdout: scrubbedOrNull(run.stdout),
4018
+ $stderr: scrubbedOrNull(run.stderr),
4019
+ $error: scrubbedOrNull(run.error),
4020
+ $goalRunId: run.goalRunId ?? null,
4021
+ $created: run.createdAt,
4022
+ $updated: run.updatedAt
4023
+ });
4024
+ const imported = this.getRun(run.id);
4025
+ if (!imported)
4026
+ throw new Error(`run not found after migration import: ${run.id}`);
4027
+ return imported;
4028
+ }
3846
4029
  pruneHistory(opts) {
3847
4030
  const { maxAgeDays, keepPerLoop } = opts;
3848
4031
  if (maxAgeDays === undefined && keepPerLoop === undefined) {
@@ -3984,7 +4167,7 @@ class Store {
3984
4167
  // package.json
3985
4168
  var package_default = {
3986
4169
  name: "@hasna/loops",
3987
- version: "0.4.3",
4170
+ version: "0.4.5",
3988
4171
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
3989
4172
  type: "module",
3990
4173
  main: "dist/index.js",
@@ -4058,7 +4241,7 @@ var package_default = {
4058
4241
  prepare: "test -d dist || bun run build",
4059
4242
  "dev:cli": "bun run src/cli/index.ts",
4060
4243
  "dev:daemon": "bun run src/daemon/index.ts",
4061
- prepublishOnly: "bun run build && bun run test:boundary"
4244
+ prepublishOnly: "bun run build && bun run typecheck && bun test && bun run test:boundary"
4062
4245
  },
4063
4246
  keywords: [
4064
4247
  "loops",
@@ -4251,7 +4434,7 @@ function deploymentStatusLine(status) {
4251
4434
 
4252
4435
  // src/cli/index.ts
4253
4436
  import { randomUUID as randomUUID2 } from "crypto";
4254
- import { existsSync as existsSync10, readdirSync as readdirSync3, readFileSync as readFileSync10, rmSync as rmSync7, statSync as statSync2 } from "fs";
4437
+ import { existsSync as existsSync10, readdirSync as readdirSync3, readFileSync as readFileSync10, rmSync as rmSync7, statSync as statSync2, writeFileSync as writeFileSync6 } from "fs";
4255
4438
  import { join as join11 } from "path";
4256
4439
  import { Database as Database3 } from "bun:sqlite";
4257
4440
  import { Command } from "commander";
@@ -7984,6 +8167,515 @@ function runDoctor(store) {
7984
8167
  };
7985
8168
  }
7986
8169
 
8170
+ // src/lib/migration.ts
8171
+ import { createHash as createHash3 } from "crypto";
8172
+ import { hostname as hostname3 } from "os";
8173
+ var LOOPS_MIGRATION_SCHEMA = "open-loops.migration/v1";
8174
+ function canonicalize(value) {
8175
+ if (Array.isArray(value))
8176
+ return value.map((entry) => canonicalize(entry));
8177
+ if (value && typeof value === "object") {
8178
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => [key, canonicalize(entry)]));
8179
+ }
8180
+ return value;
8181
+ }
8182
+ function migrationHash(value) {
8183
+ return createHash3("sha256").update(JSON.stringify(canonicalize(value))).digest("hex");
8184
+ }
8185
+ function pushBlocker(rows, resource, id, reason, name) {
8186
+ rows.push({ resource, id, name, action: "blocked", reason });
8187
+ }
8188
+ function checksToBlockers(checks, prefix, warnings = []) {
8189
+ const rows = [];
8190
+ for (const [name, count] of Object.entries(checks.unsupportedCounts)) {
8191
+ if (count > 0) {
8192
+ pushBlocker(rows, "remote", `${prefix}:unsupported:${name}`, `${prefix} ${name} has ${count} rows; this migration bundle does not preserve that table yet`);
8193
+ }
8194
+ }
8195
+ for (const [name, count] of Object.entries(checks.volatileCounts)) {
8196
+ if (name === "daemonLeases") {
8197
+ if (count > 0)
8198
+ warnings.push(`${prefix} daemon_lease has ${count} volatile rows; they are intentionally not exported as authority`);
8199
+ continue;
8200
+ }
8201
+ if (count > 0) {
8202
+ pushBlocker(rows, "remote", `${prefix}:volatile:${name}`, `${prefix} ${name} has ${count} rows; stop or finish active work before no-loss migration`);
8203
+ }
8204
+ }
8205
+ return rows;
8206
+ }
8207
+ function sanitizeCommandEnv(value, blockers, resource, id, name) {
8208
+ const copy = structuredClone(value);
8209
+ const target = copy.target;
8210
+ if (target && typeof target === "object" && !Array.isArray(target) && target.type === "command") {
8211
+ const commandTarget = target;
8212
+ if (commandTarget.env && Object.keys(commandTarget.env).length > 0) {
8213
+ commandTarget.env = Object.fromEntries(Object.keys(commandTarget.env).map((key) => [key, "[redacted]"]));
8214
+ pushBlocker(blockers, resource, id, "command target env values are redacted and cannot be imported as a no-loss row", name);
8215
+ }
8216
+ }
8217
+ return copy;
8218
+ }
8219
+ function sanitizeWorkflow(workflow, blockers) {
8220
+ const copy = structuredClone(workflow);
8221
+ copy.steps = copy.steps.map((step) => {
8222
+ if (step.target.type !== "command" || !step.target.env || Object.keys(step.target.env).length === 0)
8223
+ return step;
8224
+ 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);
8225
+ return {
8226
+ ...step,
8227
+ target: {
8228
+ ...step.target,
8229
+ env: Object.fromEntries(Object.keys(step.target.env).map((key) => [key, "[redacted]"]))
8230
+ }
8231
+ };
8232
+ });
8233
+ return copy;
8234
+ }
8235
+ function sanitizeExportRows(rows) {
8236
+ const blockers = [];
8237
+ const warnings = [];
8238
+ blockers.push(...checksToBlockers(rows.checks, "source", warnings));
8239
+ const workflows = rows.workflows.map((workflow) => sanitizeWorkflow(workflow, blockers));
8240
+ const loops = rows.loops.map((loop) => sanitizeCommandEnv(loop, blockers, "loop", loop.id, loop.name));
8241
+ const beforeScrub = JSON.stringify({ workflows, loops, runs: rows.runs });
8242
+ const data = scrubSecretsDeep({ workflows, loops, runs: rows.runs });
8243
+ if (JSON.stringify(data) !== beforeScrub) {
8244
+ warnings.push("secret-looking strings were scrubbed from the export bundle; treat this as non-importable unless every scrub is expected");
8245
+ pushBlocker(blockers, "remote", "secret-scrub", "secret-looking strings were scrubbed from the export bundle");
8246
+ }
8247
+ return { data, blockers, warnings };
8248
+ }
8249
+ function exportLoopsMigrationBundle(store, opts = {}) {
8250
+ const rows = store.exportMigrationRows({ includeRuns: opts.includeRuns ?? true });
8251
+ const sanitized = sanitizeExportRows(rows);
8252
+ const bundleBody = {
8253
+ schema: LOOPS_MIGRATION_SCHEMA,
8254
+ packageVersion: packageVersion(),
8255
+ exportedAt: new Date().toISOString(),
8256
+ source: {
8257
+ backend: "sqlite",
8258
+ schemaVersion: rows.schemaVersion,
8259
+ hostname: hostname3()
8260
+ },
8261
+ checks: rows.checks,
8262
+ importable: sanitized.blockers.length === 0,
8263
+ counts: {
8264
+ workflows: sanitized.data.workflows.length,
8265
+ loops: sanitized.data.loops.length,
8266
+ runs: sanitized.data.runs.length
8267
+ },
8268
+ data: sanitized.data,
8269
+ blockers: sanitized.blockers,
8270
+ warnings: sanitized.warnings
8271
+ };
8272
+ return {
8273
+ ...bundleBody,
8274
+ hash: migrationHash(bundleBody)
8275
+ };
8276
+ }
8277
+ function validateLoopsMigrationBundle(value) {
8278
+ if (!value || typeof value !== "object")
8279
+ throw new ValidationError("migration bundle must be a JSON object");
8280
+ const bundle = value;
8281
+ if (bundle.schema !== LOOPS_MIGRATION_SCHEMA)
8282
+ throw new ValidationError(`unsupported migration bundle schema: ${String(bundle.schema)}`);
8283
+ if (!bundle.data || !Array.isArray(bundle.data.workflows) || !Array.isArray(bundle.data.loops) || !Array.isArray(bundle.data.runs)) {
8284
+ throw new ValidationError("migration bundle data must include workflows, loops, and runs arrays");
8285
+ }
8286
+ if (!bundle.checks || !bundle.counts || !bundle.source || !bundle.hash)
8287
+ throw new ValidationError("migration bundle is missing required metadata");
8288
+ const typed = bundle;
8289
+ assertMigrationBundleIntegrity(typed);
8290
+ return typed;
8291
+ }
8292
+ function assertMigrationBundleIntegrity(bundle) {
8293
+ const { hash: _hash, ...body } = bundle;
8294
+ const expectedHash = migrationHash(body);
8295
+ if (bundle.hash !== expectedHash)
8296
+ throw new ValidationError("migration bundle hash mismatch");
8297
+ }
8298
+ function rowPlanSummary(plan) {
8299
+ const count = (action) => plan.rows.filter((row) => row.action === action).length;
8300
+ return {
8301
+ dryRun: plan.dryRun,
8302
+ replace: plan.replace,
8303
+ importable: plan.importable,
8304
+ workflows: plan.rows.filter((row) => row.resource === "workflow").length,
8305
+ loops: plan.rows.filter((row) => row.resource === "loop").length,
8306
+ runs: plan.rows.filter((row) => row.resource === "run").length,
8307
+ insert: count("insert"),
8308
+ update: count("update"),
8309
+ skip: count("skip"),
8310
+ conflict: count("conflict"),
8311
+ blocked: count("blocked")
8312
+ };
8313
+ }
8314
+ function finalizePlan(plan) {
8315
+ return { ...plan, summary: rowPlanSummary(plan) };
8316
+ }
8317
+ function compareResource(current, incoming, opts) {
8318
+ const incomingHash = migrationHash(incoming);
8319
+ if (!current)
8320
+ return { resource: opts.resource, id: opts.id, name: opts.name, action: "insert", incomingHash };
8321
+ const currentHash = migrationHash(current);
8322
+ if (currentHash === incomingHash)
8323
+ return { resource: opts.resource, id: opts.id, name: opts.name, action: "skip", incomingHash, currentHash };
8324
+ return {
8325
+ resource: opts.resource,
8326
+ id: opts.id,
8327
+ name: opts.name,
8328
+ action: opts.replace ? "update" : "conflict",
8329
+ reason: opts.replace ? "existing row differs and --replace was requested" : "existing row differs; rerun with --replace to update",
8330
+ incomingHash,
8331
+ currentHash
8332
+ };
8333
+ }
8334
+ function buildImportMigrationPlan(store, bundle, opts = {}) {
8335
+ assertMigrationBundleIntegrity(bundle);
8336
+ const includeRuns = opts.includeRuns ?? true;
8337
+ const replace = opts.replace ?? false;
8338
+ const rows = [];
8339
+ const warnings = [...bundle.warnings ?? []];
8340
+ rows.push(...checksToBlockers(store.exportMigrationRows({ includeRuns: false }).checks, "destination", warnings));
8341
+ if (!bundle.importable || bundle.blockers.length > 0) {
8342
+ rows.push(...bundle.blockers.map((row) => ({ ...row, action: "blocked" })));
8343
+ }
8344
+ const workflowIds = new Set(bundle.data.workflows.map((workflow) => workflow.id));
8345
+ const loopIds = new Set(bundle.data.loops.map((loop) => loop.id));
8346
+ for (const workflow of bundle.data.workflows) {
8347
+ const redactedStep = workflow.steps.find((step) => step.target.type === "command" && step.target.env && Object.values(step.target.env).includes("[redacted]"));
8348
+ if (redactedStep) {
8349
+ rows.push({
8350
+ resource: "workflow",
8351
+ id: workflow.id,
8352
+ name: workflow.name,
8353
+ action: "blocked",
8354
+ reason: `workflow step ${redactedStep.id} has redacted command env values`,
8355
+ incomingHash: migrationHash(workflow)
8356
+ });
8357
+ continue;
8358
+ }
8359
+ const activeNameCollision = store.listWorkflows({ status: "active" }).find((current) => current.name === workflow.name && current.id !== workflow.id);
8360
+ if (workflow.status === "active" && activeNameCollision) {
8361
+ rows.push({
8362
+ resource: "workflow",
8363
+ id: workflow.id,
8364
+ name: workflow.name,
8365
+ action: "conflict",
8366
+ reason: `active workflow name collides with existing workflow ${activeNameCollision.id}`,
8367
+ incomingHash: migrationHash(workflow),
8368
+ currentHash: migrationHash(activeNameCollision)
8369
+ });
8370
+ continue;
8371
+ }
8372
+ rows.push(compareResource(store.getWorkflow(workflow.id), workflow, { replace, resource: "workflow", id: workflow.id, name: workflow.name }));
8373
+ }
8374
+ for (const loop of bundle.data.loops) {
8375
+ if (loop.target.type === "command" && loop.target.env && Object.values(loop.target.env).includes("[redacted]")) {
8376
+ rows.push({
8377
+ resource: "loop",
8378
+ id: loop.id,
8379
+ name: loop.name,
8380
+ action: "blocked",
8381
+ reason: "loop has redacted command env values",
8382
+ incomingHash: migrationHash(loop)
8383
+ });
8384
+ continue;
8385
+ }
8386
+ if (loop.target.type === "workflow" && !workflowIds.has(loop.target.workflowId) && !store.getWorkflow(loop.target.workflowId)) {
8387
+ rows.push({
8388
+ resource: "loop",
8389
+ id: loop.id,
8390
+ name: loop.name,
8391
+ action: "blocked",
8392
+ reason: `workflow target ${loop.target.workflowId} is not present in bundle or destination store`,
8393
+ incomingHash: migrationHash(loop)
8394
+ });
8395
+ continue;
8396
+ }
8397
+ rows.push(compareResource(store.getLoop(loop.id), loop, { replace, resource: "loop", id: loop.id, name: loop.name }));
8398
+ }
8399
+ if (includeRuns) {
8400
+ for (const run of bundle.data.runs) {
8401
+ if (run.status === "running") {
8402
+ rows.push({
8403
+ resource: "run",
8404
+ id: run.id,
8405
+ name: run.loopName,
8406
+ action: "blocked",
8407
+ reason: "running rows carry volatile lease/process ownership and must finish before import",
8408
+ incomingHash: migrationHash(run)
8409
+ });
8410
+ continue;
8411
+ }
8412
+ if (!loopIds.has(run.loopId) && !store.getLoop(run.loopId)) {
8413
+ rows.push({
8414
+ resource: "run",
8415
+ id: run.id,
8416
+ name: run.loopName,
8417
+ action: "blocked",
8418
+ reason: `loop ${run.loopId} is not present in bundle or destination store`,
8419
+ incomingHash: migrationHash(run)
8420
+ });
8421
+ continue;
8422
+ }
8423
+ const slot = store.getRunBySlot(run.loopId, run.scheduledFor);
8424
+ if (slot && slot.id !== run.id) {
8425
+ rows.push({
8426
+ resource: "run",
8427
+ id: run.id,
8428
+ name: run.loopName,
8429
+ action: "conflict",
8430
+ reason: `scheduled slot already belongs to run ${slot.id}`,
8431
+ incomingHash: migrationHash(run),
8432
+ currentHash: migrationHash(slot)
8433
+ });
8434
+ continue;
8435
+ }
8436
+ rows.push(compareResource(store.getRun(run.id), run, { replace, resource: "run", id: run.id, name: run.loopName }));
8437
+ }
8438
+ } else if (bundle.data.runs.length > 0) {
8439
+ warnings.push("run history is present in the bundle but --no-runs was requested");
8440
+ }
8441
+ const plan = finalizePlan({
8442
+ schema: LOOPS_MIGRATION_SCHEMA,
8443
+ operation: "import",
8444
+ dryRun: opts.dryRun ?? true,
8445
+ replace,
8446
+ importable: bundle.importable && rows.every((row) => row.action !== "blocked" && row.action !== "conflict"),
8447
+ rows,
8448
+ warnings
8449
+ });
8450
+ return { ...plan, importable: plan.summary.blocked === 0 && plan.summary.conflict === 0 };
8451
+ }
8452
+ function applyImportMigrationBundle(store, bundle, opts = {}) {
8453
+ const plan = buildImportMigrationPlan(store, bundle, { ...opts, dryRun: false });
8454
+ if (plan.summary.blocked > 0 || plan.summary.conflict > 0 || !plan.importable) {
8455
+ throw new ValidationError(`migration import is not safe to apply: blocked=${plan.summary.blocked} conflict=${plan.summary.conflict}`);
8456
+ }
8457
+ const applied = { workflows: 0, loops: 0, runs: 0 };
8458
+ let appliedPlan = plan;
8459
+ store.writeTransaction(() => {
8460
+ appliedPlan = buildImportMigrationPlan(store, bundle, { ...opts, dryRun: false });
8461
+ if (appliedPlan.summary.blocked > 0 || appliedPlan.summary.conflict > 0 || !appliedPlan.importable) {
8462
+ throw new ValidationError(`destination store changed before import apply: blocked=${appliedPlan.summary.blocked} conflict=${appliedPlan.summary.conflict}`);
8463
+ }
8464
+ for (const workflow of bundle.data.workflows) {
8465
+ const row = appliedPlan.rows.find((entry) => entry.resource === "workflow" && entry.id === workflow.id);
8466
+ if (row?.action === "insert" || row?.action === "update") {
8467
+ store.upsertMigrationWorkflow(workflow, { replace: opts.replace });
8468
+ applied.workflows += 1;
8469
+ }
8470
+ }
8471
+ for (const loop of bundle.data.loops) {
8472
+ const row = appliedPlan.rows.find((entry) => entry.resource === "loop" && entry.id === loop.id);
8473
+ if (row?.action === "insert" || row?.action === "update") {
8474
+ store.upsertMigrationLoop(loop, { replace: opts.replace });
8475
+ applied.loops += 1;
8476
+ }
8477
+ }
8478
+ if (opts.includeRuns ?? true) {
8479
+ for (const run of bundle.data.runs) {
8480
+ const row = appliedPlan.rows.find((entry) => entry.resource === "run" && entry.id === run.id);
8481
+ if (row?.action === "insert" || row?.action === "update") {
8482
+ store.upsertMigrationRun(run, { replace: opts.replace });
8483
+ applied.runs += 1;
8484
+ }
8485
+ }
8486
+ }
8487
+ });
8488
+ return { plan: appliedPlan, applied };
8489
+ }
8490
+ function envValue2(env, keys) {
8491
+ for (const key of keys) {
8492
+ const value = env[key]?.trim();
8493
+ if (value)
8494
+ return value;
8495
+ }
8496
+ return;
8497
+ }
8498
+ function resolveApiConfig(opts) {
8499
+ const env = opts.env ?? process.env;
8500
+ return {
8501
+ apiUrl: opts.apiUrl ?? envValue2(env, ["LOOPS_API_URL", "HASNA_LOOPS_API_URL", "LOOPS_CLOUD_API_URL", "HASNA_LOOPS_CLOUD_API_URL"]),
8502
+ token: opts.apiToken ?? envValue2(env, ["LOOPS_API_TOKEN", "HASNA_LOOPS_API_TOKEN", "LOOPS_CLOUD_TOKEN", "HASNA_LOOPS_CLOUD_TOKEN"])
8503
+ };
8504
+ }
8505
+ function isLocalApiUrl(value) {
8506
+ try {
8507
+ return ["127.0.0.1", "localhost", "::1"].includes(new URL(value).hostname);
8508
+ } catch {
8509
+ return false;
8510
+ }
8511
+ }
8512
+ function endpoint(base, path) {
8513
+ return new URL(path.replace(/^\//, ""), base.endsWith("/") ? base : `${base}/`).toString();
8514
+ }
8515
+ async function requestJson(fetchImpl, config, path, init = {}) {
8516
+ const response = await fetchImpl(endpoint(config.apiUrl, path), {
8517
+ ...init,
8518
+ headers: {
8519
+ ...init.body ? { "content-type": "application/json" } : {},
8520
+ ...config.token ? { authorization: `Bearer ${config.token}` } : {},
8521
+ ...init.headers
8522
+ }
8523
+ });
8524
+ const payload = await response.json().catch(() => ({}));
8525
+ if (!response.ok)
8526
+ throw new Error(typeof payload.error === "string" ? payload.error : `loops-api request failed: ${response.status}`);
8527
+ return payload;
8528
+ }
8529
+ async function fetchRemotePreview(opts) {
8530
+ const config = resolveApiConfig(opts);
8531
+ const warnings = [];
8532
+ if (!config.apiUrl) {
8533
+ warnings.push("LOOPS_API_URL or HASNA_LOOPS_API_URL is required to inspect a self-hosted control plane");
8534
+ return { loops: [], runs: [], warnings };
8535
+ }
8536
+ if (!isLocalApiUrl(config.apiUrl) && !config.token) {
8537
+ warnings.push("non-local self-hosted APIs require LOOPS_API_TOKEN or HASNA_LOOPS_API_TOKEN");
8538
+ return { loops: [], runs: [], warnings };
8539
+ }
8540
+ const fetchImpl = opts.fetchImpl ?? fetch;
8541
+ const loopsPayload = await requestJson(fetchImpl, { apiUrl: config.apiUrl, token: config.token }, "/v1/loops?includeArchived=true&limit=1000");
8542
+ const runsPayload = opts.includeRuns === false ? { runs: [] } : await requestJson(fetchImpl, { apiUrl: config.apiUrl, token: config.token }, "/v1/runs?limit=1000");
8543
+ return {
8544
+ loops: Array.isArray(loopsPayload.loops) ? loopsPayload.loops : [],
8545
+ runs: Array.isArray(runsPayload.runs) ? runsPayload.runs : [],
8546
+ warnings
8547
+ };
8548
+ }
8549
+ async function buildSelfHostedMigrationPlan(store, opts) {
8550
+ const bundle = exportLoopsMigrationBundle(store, { includeRuns: opts.includeRuns ?? true });
8551
+ const remote = await fetchRemotePreview(opts);
8552
+ const rows = [...bundle.blockers.map((row) => ({ ...row, action: "blocked" }))];
8553
+ const warnings = [
8554
+ ...bundle.warnings,
8555
+ ...remote.warnings,
8556
+ "self-hosted remote apply is preview-only until the control plane exposes id-preserving workflow/loop/run import endpoints"
8557
+ ];
8558
+ if (opts.operation === "self-hosted-pull") {
8559
+ for (const entry of remote.loops) {
8560
+ const value = entry && typeof entry === "object" ? entry : {};
8561
+ const id = typeof value.id === "string" ? value.id : `remote-loop:${rows.length}`;
8562
+ rows.push({
8563
+ resource: "loop",
8564
+ id,
8565
+ name: typeof value.name === "string" ? value.name : undefined,
8566
+ action: "blocked",
8567
+ reason: "remote loop pull needs a full id-preserving export/import endpoint; /v1/loops returns public redacted rows only",
8568
+ currentHash: migrationHash(entry)
8569
+ });
8570
+ }
8571
+ for (const entry of remote.runs) {
8572
+ const value = entry && typeof entry === "object" ? entry : {};
8573
+ const id = typeof value.id === "string" ? value.id : `remote-run:${rows.length}`;
8574
+ rows.push({
8575
+ resource: "run",
8576
+ id,
8577
+ name: typeof value.loopName === "string" ? value.loopName : undefined,
8578
+ action: "blocked",
8579
+ reason: "remote run-history pull needs an id-preserving export/import endpoint; /v1/runs returns public redacted rows only",
8580
+ currentHash: migrationHash(entry)
8581
+ });
8582
+ }
8583
+ const plan2 = finalizePlan({
8584
+ schema: LOOPS_MIGRATION_SCHEMA,
8585
+ operation: opts.operation,
8586
+ dryRun: true,
8587
+ replace: false,
8588
+ importable: false,
8589
+ rows,
8590
+ warnings
8591
+ });
8592
+ return { ...plan2, importable: false };
8593
+ }
8594
+ 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]));
8595
+ for (const workflow of bundle.data.workflows) {
8596
+ rows.push({
8597
+ resource: "workflow",
8598
+ id: workflow.id,
8599
+ name: workflow.name,
8600
+ action: "blocked",
8601
+ reason: "self-hosted API does not expose id-preserving workflow import/list endpoints yet",
8602
+ incomingHash: migrationHash(workflow)
8603
+ });
8604
+ }
8605
+ for (const loop of bundle.data.loops) {
8606
+ const remoteLoop = remoteLoopsByName.get(loop.name);
8607
+ rows.push({
8608
+ resource: "loop",
8609
+ id: loop.id,
8610
+ name: loop.name,
8611
+ action: "blocked",
8612
+ 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",
8613
+ incomingHash: migrationHash(loop),
8614
+ currentHash: remoteLoop ? migrationHash(remoteLoop) : undefined
8615
+ });
8616
+ }
8617
+ if (opts.includeRuns ?? true) {
8618
+ for (const run of bundle.data.runs) {
8619
+ rows.push({
8620
+ resource: "run",
8621
+ id: run.id,
8622
+ name: run.loopName,
8623
+ action: "blocked",
8624
+ reason: "self-hosted API does not expose run-history import endpoints; remote runners should create new run rows",
8625
+ incomingHash: migrationHash(run)
8626
+ });
8627
+ }
8628
+ }
8629
+ const plan = finalizePlan({
8630
+ schema: LOOPS_MIGRATION_SCHEMA,
8631
+ operation: opts.operation,
8632
+ dryRun: true,
8633
+ replace: false,
8634
+ importable: false,
8635
+ rows,
8636
+ warnings
8637
+ });
8638
+ return { ...plan, importable: false };
8639
+ }
8640
+ async function registerSelfHostedRunner(opts) {
8641
+ const config = resolveApiConfig(opts);
8642
+ if (!config.apiUrl)
8643
+ throw new ValidationError("LOOPS_API_URL or --api-url is required");
8644
+ if (!isLocalApiUrl(config.apiUrl) && !config.token)
8645
+ throw new ValidationError("non-local self-hosted APIs require LOOPS_API_TOKEN or HASNA_LOOPS_API_TOKEN");
8646
+ const payload = await requestJson(opts.fetchImpl ?? fetch, { apiUrl: config.apiUrl, token: config.token }, "/v1/runners/register", {
8647
+ method: "POST",
8648
+ body: JSON.stringify({
8649
+ runnerId: opts.runnerId,
8650
+ machineId: opts.machineId,
8651
+ labels: opts.labels ?? {},
8652
+ capabilities: opts.capabilities ?? {}
8653
+ })
8654
+ });
8655
+ return {
8656
+ ok: payload.ok === true,
8657
+ runner: payload.runner && typeof payload.runner === "object" ? payload.runner : {}
8658
+ };
8659
+ }
8660
+ function publicMigrationBundle(bundle) {
8661
+ return {
8662
+ ...bundle,
8663
+ data: {
8664
+ workflows: bundle.data.workflows.map(publicWorkflow),
8665
+ loops: bundle.data.loops.map(publicLoop),
8666
+ runs: bundle.data.runs.map((run) => publicRun(run, false, { redactError: true }))
8667
+ }
8668
+ };
8669
+ }
8670
+ function selfHostedControlPlaneSummary(env = process.env) {
8671
+ const config = loopControlPlaneConfig(env);
8672
+ return {
8673
+ apiUrl: config.apiUrl,
8674
+ databaseUrlPresent: config.databaseUrlPresent,
8675
+ authTokenPresent: config.apiAuthTokenPresent
8676
+ };
8677
+ }
8678
+
7987
8679
  // src/lib/hygiene.ts
7988
8680
  import { basename as basename3 } from "path";
7989
8681
  import { homedir as homedir3 } from "os";
@@ -8321,7 +9013,6 @@ var BUILTIN_TEMPLATE_SUMMARIES = [
8321
9013
  { name: "taskTitle", description: "Human-readable task title." },
8322
9014
  projectPathVariable(),
8323
9015
  { name: "todosProjectPath", description: "Todos storage project path used in worker/verifier commands." },
8324
- { name: "todosDbPath", description: "Optional exact Todos DB path used via TODOS_DB_PATH/HASNA_TODOS_DB_PATH." },
8325
9016
  ...routeScopeVariables(),
8326
9017
  ...workerVerifierAgentVariables({ addDirs: true, branchNoun: "task/event" })
8327
9018
  ]
@@ -8362,8 +9053,6 @@ var BUILTIN_TEMPLATE_SUMMARIES = [
8362
9053
  variables: [
8363
9054
  { name: "taskId", required: true, description: "Todos task id." },
8364
9055
  projectPathVariable(),
8365
- { name: "todosProjectPath", description: "Todos storage project path used in lifecycle commands." },
8366
- { name: "todosDbPath", description: "Optional exact Todos DB path used via TODOS_DB_PATH/HASNA_TODOS_DB_PATH." },
8367
9056
  { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
8368
9057
  roleAuthProfileVariable("triage"),
8369
9058
  roleAuthProfileVariable("planner"),
@@ -8494,29 +9183,28 @@ function verifierRuntimeGuidance(input) {
8494
9183
  ].join(`
8495
9184
  `);
8496
9185
  }
8497
- function todosInspectLine(todosProjectPath, taskId, todosDbPath) {
8498
- return `- Inspect first: ${todosCommand(todosProjectPath, todosDbPath)} inspect ${taskId}`;
9186
+ function todosInspectLine(todosProjectPath, taskId) {
9187
+ return `- Inspect first: todos --project ${todosProjectPath} inspect ${taskId}`;
8499
9188
  }
8500
- function todosStartLine(todosProjectPath, taskId, todosDbPath) {
8501
- return `- Claim/start if appropriate: ${todosCommand(todosProjectPath, todosDbPath)} start ${taskId}`;
9189
+ function todosStartLine(todosProjectPath, taskId) {
9190
+ return `- Claim/start if appropriate: todos --project ${todosProjectPath} start ${taskId}`;
8502
9191
  }
8503
- function todosEvidenceLine(todosProjectPath, taskId, placeholder, todosDbPath) {
8504
- return `- Record evidence: ${todosCommand(todosProjectPath, todosDbPath)} comment ${taskId} "<${placeholder}>"`;
9192
+ function todosEvidenceLine(todosProjectPath, taskId, placeholder) {
9193
+ return `- Record evidence: todos --project ${todosProjectPath} comment ${taskId} "<${placeholder}>"`;
8505
9194
  }
8506
- function todosVerificationLine(todosProjectPath, taskId, todosDbPath) {
8507
- return `- Record verification: ${todosCommand(todosProjectPath, todosDbPath)} comment ${taskId} "<verification evidence or blocker>"`;
9195
+ function todosVerificationLine(todosProjectPath, taskId) {
9196
+ return `- Record verification: todos --project ${todosProjectPath} comment ${taskId} "<verification evidence or blocker>"`;
8508
9197
  }
8509
- function todosDoneLine(todosProjectPath, taskId, todosDbPath) {
8510
- return `- If valid and complete: ${todosCommand(todosProjectPath, todosDbPath)} done ${taskId}`;
9198
+ function todosDoneLine(todosProjectPath, taskId) {
9199
+ return `- If valid and complete: todos --project ${todosProjectPath} done ${taskId}`;
8511
9200
  }
8512
- function todosExactCommandsFragment(todosProjectPath, taskId, commandLines, todosDbPath) {
9201
+ function todosExactCommandsFragment(todosProjectPath, taskId, commandLines) {
8513
9202
  return [
8514
9203
  `Todos project path: ${todosProjectPath}`,
8515
- todosDbPath ? `Todos DB path: ${todosDbPath}` : undefined,
8516
9204
  "Use these exact todos commands so worktree cwd inference cannot attach to the wrong project:",
8517
- todosInspectLine(todosProjectPath, taskId, todosDbPath),
9205
+ todosInspectLine(todosProjectPath, taskId),
8518
9206
  ...commandLines
8519
- ].filter((line) => Boolean(line));
9207
+ ];
8520
9208
  }
8521
9209
  function worktreePrompt(plan) {
8522
9210
  if (plan.enabled) {
@@ -8553,19 +9241,10 @@ function worktreeContextFragment(plan) {
8553
9241
  function shellQuote3(value) {
8554
9242
  return `'${value.replace(/'/g, `'\\''`)}'`;
8555
9243
  }
8556
- function todosEnvPrefix(todosDbPath) {
8557
- return todosDbPath ? `TODOS_DB_PATH=${shellQuote3(todosDbPath)} HASNA_TODOS_DB_PATH=${shellQuote3(todosDbPath)} ` : "";
8558
- }
8559
- function todosCommand(todosProjectPath, todosDbPath) {
8560
- return `${todosEnvPrefix(todosDbPath)}todos --project ${todosProjectPath}`;
8561
- }
8562
- function shellTodosCommand(todosProjectPath, todosDbPath) {
8563
- return `${todosEnvPrefix(todosDbPath)}todos --project ${shellQuote3(todosProjectPath)}`;
8564
- }
8565
- function sourceTaskGateCommand(todosProjectPath, taskId, todosDbPath) {
9244
+ function sourceTaskGateCommand(todosProjectPath, taskId) {
8566
9245
  return [
8567
9246
  "set -euo pipefail",
8568
- `${shellTodosCommand(todosProjectPath, todosDbPath)} --json inspect ${shellQuote3(taskId)} >/dev/null`,
9247
+ `todos --project ${shellQuote3(todosProjectPath)} --json inspect ${shellQuote3(taskId)} >/dev/null`,
8569
9248
  `printf "source task %s resolved in todos project %s\\n" ${shellQuote3(taskId)} ${shellQuote3(todosProjectPath)}`
8570
9249
  ].join(`
8571
9250
  `);
@@ -8625,10 +9304,10 @@ var LIFECYCLE_GATE_SCRIPT_TAIL = [
8625
9304
  "console.log(`task lifecycle ${stage} gate passed for ${task.id || task.taskId || 'task'} status=${status || 'unknown'}`);"
8626
9305
  ].join(`
8627
9306
  `);
8628
- function lifecycleGateCommand(todosProjectPath, taskId, stage, goMarker, blockedMarker, todosDbPath) {
9307
+ function lifecycleGateCommand(todosProjectPath, taskId, stage, goMarker, blockedMarker) {
8629
9308
  return [
8630
9309
  "set -euo pipefail",
8631
- `task_json="$(${shellTodosCommand(todosProjectPath, todosDbPath)} --json inspect ${shellQuote3(taskId)})"`,
9310
+ `task_json="$(todos --project ${shellQuote3(todosProjectPath)} --json inspect ${shellQuote3(taskId)})"`,
8632
9311
  `TASK_JSON="$task_json" STAGE=${shellQuote3(stage)} bun - <<'BUN'`,
8633
9312
  LIFECYCLE_GATE_SCRIPT_HEAD,
8634
9313
  `const goMarker = ${JSON.stringify(goMarker)};`,
@@ -8644,7 +9323,6 @@ var PR_HANDOFF_SCRIPT = [
8644
9323
  "const artifactPath = process.env.OPENLOOPS_PR_HANDOFF_ARTIFACT || '';",
8645
9324
  "const taskId = process.env.OPENLOOPS_PR_HANDOFF_TASK_ID || '';",
8646
9325
  "const todosProject = process.env.OPENLOOPS_PR_HANDOFF_TODOS_PROJECT || '';",
8647
- "const todosDbPath = process.env.OPENLOOPS_PR_HANDOFF_TODOS_DB_PATH || '';",
8648
9326
  "const fallbackWorktree = process.env.OPENLOOPS_PR_HANDOFF_WORKTREE || process.cwd();",
8649
9327
  "const expectedRoot = process.env.OPENLOOPS_PR_HANDOFF_WORKTREE_ROOT || fallbackWorktree;",
8650
9328
  "const expectedBranch = process.env.OPENLOOPS_PR_HANDOFF_EXPECTED_BRANCH || '';",
@@ -8662,8 +9340,7 @@ var PR_HANDOFF_SCRIPT = [
8662
9340
  "};",
8663
9341
  "const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', ...options });",
8664
9342
  "const todosArgs = (...args) => todosProject ? ['--project', todosProject, ...args] : args;",
8665
- "const todosEnv = todosDbPath ? { ...process.env, TODOS_DB_PATH: todosDbPath, HASNA_TODOS_DB_PATH: todosDbPath } : process.env;",
8666
- "const todos = (...args) => run(todosBin, todosArgs(...args), { env: todosEnv });",
9343
+ "const todos = (...args) => run(todosBin, todosArgs(...args));",
8667
9344
  "const comment = (text) => {",
8668
9345
  " const result = todos('comment', taskId, text);",
8669
9346
  " if (result.status !== 0) console.error(`failed to comment original task: ${result.stderr || result.stdout || result.status}`);",
@@ -8792,7 +9469,6 @@ function prHandoffCommand(opts) {
8792
9469
  `export OPENLOOPS_PR_HANDOFF_ARTIFACT=${shellQuote3(opts.artifactPath)}`,
8793
9470
  `export OPENLOOPS_PR_HANDOFF_TASK_ID=${shellQuote3(opts.taskId)}`,
8794
9471
  `export OPENLOOPS_PR_HANDOFF_TODOS_PROJECT=${shellQuote3(opts.todosProjectPath)}`,
8795
- opts.todosDbPath ? `export OPENLOOPS_PR_HANDOFF_TODOS_DB_PATH=${shellQuote3(opts.todosDbPath)}` : undefined,
8796
9472
  `export OPENLOOPS_PR_HANDOFF_WORKTREE=${shellQuote3(opts.worktreeCwd)}`,
8797
9473
  `export OPENLOOPS_PR_HANDOFF_WORKTREE_ROOT=${shellQuote3(opts.worktreeRoot)}`,
8798
9474
  `export OPENLOOPS_PR_HANDOFF_EXPECTED_BRANCH=${shellQuote3(opts.expectedBranch)}`,
@@ -8803,7 +9479,7 @@ function prHandoffCommand(opts) {
8803
9479
  "bun - <<'BUN'",
8804
9480
  PR_HANDOFF_SCRIPT,
8805
9481
  "BUN"
8806
- ].filter((line) => Boolean(line)).join(`
9482
+ ].join(`
8807
9483
  `);
8808
9484
  }
8809
9485
  // src/lib/templates-custom.ts
@@ -9393,12 +10069,12 @@ function commandStep(opts) {
9393
10069
  ...opts.blockedExitCodes ? { blockedExitCodes: opts.blockedExitCodes } : {}
9394
10070
  };
9395
10071
  }
9396
- function sourceTaskGateStep(todosProjectPath, taskId, plan, description, todosDbPath) {
10072
+ function sourceTaskGateStep(todosProjectPath, taskId, plan, description) {
9397
10073
  return commandStep({
9398
10074
  id: "source-task-gate",
9399
10075
  name: "Source Task Gate",
9400
10076
  description,
9401
- command: sourceTaskGateCommand(todosProjectPath, taskId, todosDbPath),
10077
+ command: sourceTaskGateCommand(todosProjectPath, taskId),
9402
10078
  cwd: plan.originalCwd,
9403
10079
  timeoutMs: 60000,
9404
10080
  blockedExitCodes: GATE_BLOCKED_EXIT_CODES
@@ -9410,7 +10086,7 @@ function lifecycleGateStep(opts) {
9410
10086
  name: opts.stage === "triage" ? "Triage Gate" : "Planner Gate",
9411
10087
  description: opts.description,
9412
10088
  dependsOn: opts.dependsOn,
9413
- command: lifecycleGateCommand(opts.todosProjectPath, opts.taskId, opts.stage, opts.goMarker, opts.blockedMarker, opts.todosDbPath),
10089
+ command: lifecycleGateCommand(opts.todosProjectPath, opts.taskId, opts.stage, opts.goMarker, opts.blockedMarker),
9414
10090
  cwd: opts.plan.originalCwd,
9415
10091
  timeoutMs: 2 * 60000,
9416
10092
  blockedExitCodes: GATE_BLOCKED_EXIT_CODES
@@ -9419,7 +10095,7 @@ function lifecycleGateStep(opts) {
9419
10095
  function prHandoffArtifactPath(plan, taskId) {
9420
10096
  return join7(plan.cwd, ".openloops", "pr-handoff", `${slugSegment(taskId, "task")}.json`);
9421
10097
  }
9422
- function prHandoffStep(input, plan, todosProjectPath, todosDbPath) {
10098
+ function prHandoffStep(input, plan, todosProjectPath) {
9423
10099
  return commandStep({
9424
10100
  id: "pr-handoff",
9425
10101
  name: "PR Handoff",
@@ -9429,7 +10105,6 @@ function prHandoffStep(input, plan, todosProjectPath, todosDbPath) {
9429
10105
  artifactPath: prHandoffArtifactPath(plan, input.taskId),
9430
10106
  taskId: input.taskId,
9431
10107
  todosProjectPath,
9432
- todosDbPath,
9433
10108
  worktreeCwd: plan.cwd,
9434
10109
  worktreeRoot: plan.path ?? plan.cwd,
9435
10110
  expectedBranch: plan.branch ?? ""
@@ -9516,7 +10191,6 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
9516
10191
  if (!input.projectPath?.trim())
9517
10192
  throw new Error("projectPath is required");
9518
10193
  const todosProjectPath = input.todosProjectPath ?? input.routeProjectPath ?? input.projectPath;
9519
- const todosDbPath = input.todosDbPath;
9520
10194
  const plan = worktreePlan(input, input.taskId);
9521
10195
  const taskContext = {
9522
10196
  taskId: input.taskId,
@@ -9527,17 +10201,15 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
9527
10201
  projectPath: input.projectPath,
9528
10202
  routeProjectPath: input.routeProjectPath,
9529
10203
  projectGroup: input.projectGroup,
9530
- todosProjectPath: input.todosProjectPath ?? (input.todosDbPath ? todosProjectPath : undefined),
9531
- todosDbPath,
9532
10204
  worktree: worktreeContextFragment(plan)
9533
10205
  };
9534
10206
  const workerPrompt = [
9535
10207
  ...goalHeaderFragment(`Complete todos task ${input.taskId} in ${input.projectPath}.`, "worker", "task"),
9536
10208
  worktreePrompt(plan),
9537
10209
  ...todosExactCommandsFragment(todosProjectPath, input.taskId, [
9538
- todosStartLine(todosProjectPath, input.taskId, todosDbPath),
9539
- todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence and blockers", todosDbPath)
9540
- ], todosDbPath),
10210
+ todosStartLine(todosProjectPath, input.taskId),
10211
+ todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence and blockers")
10212
+ ]),
9541
10213
  "Investigate first before changing files. Use the todos CLI as the source of truth for the task.",
9542
10214
  "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.",
9543
10215
  NO_TMUX_DISPATCH_FRAGMENT,
@@ -9550,9 +10222,9 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
9550
10222
  ...goalHeaderFragment(`Verify todos task ${input.taskId} after the worker step.`, "verifier", "task"),
9551
10223
  worktreePrompt(plan),
9552
10224
  ...todosExactCommandsFragment(todosProjectPath, input.taskId, [
9553
- todosVerificationLine(todosProjectPath, input.taskId, todosDbPath),
9554
- todosDoneLine(todosProjectPath, input.taskId, todosDbPath)
9555
- ], todosDbPath),
10225
+ todosVerificationLine(todosProjectPath, input.taskId),
10226
+ todosDoneLine(todosProjectPath, input.taskId)
10227
+ ]),
9556
10228
  adversarialReviewFragment("the task, repository state, commits, tests, and worker evidence", TASK_REVIEW_FOCUS),
9557
10229
  verifierRuntimeGuidance(input),
9558
10230
  TASK_VERIFIER_DECISION_FRAGMENT,
@@ -9567,7 +10239,7 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
9567
10239
  description: `Task-triggered worker/verifier workflow for ${taskLabel(input)}`,
9568
10240
  version: 1,
9569
10241
  steps: [
9570
- sourceTaskGateStep(todosProjectPath, input.taskId, plan, "Fail before worker execution when the source todos task is not resolvable.", todosDbPath),
10242
+ sourceTaskGateStep(todosProjectPath, input.taskId, plan, "Fail before worker execution when the source todos task is not resolvable."),
9571
10243
  ...workerVerifierSteps({
9572
10244
  input,
9573
10245
  seed: input.taskId,
@@ -9587,7 +10259,6 @@ function renderTaskLifecycleWorkflow(input) {
9587
10259
  if (!input.projectPath?.trim())
9588
10260
  throw new Error("projectPath is required");
9589
10261
  const todosProjectPath = input.todosProjectPath ?? input.routeProjectPath ?? input.projectPath;
9590
- const todosDbPath = input.todosDbPath;
9591
10262
  const plan = worktreePlan(input, input.taskId);
9592
10263
  const taskContext = {
9593
10264
  taskId: input.taskId,
@@ -9599,7 +10270,6 @@ function renderTaskLifecycleWorkflow(input) {
9599
10270
  routeProjectPath: input.routeProjectPath,
9600
10271
  projectGroup: input.projectGroup,
9601
10272
  todosProjectPath,
9602
- todosDbPath,
9603
10273
  worktree: worktreeContextFragment(plan)
9604
10274
  };
9605
10275
  const handoffArtifactPath = prHandoffArtifactPath(plan, input.taskId);
@@ -9613,8 +10283,8 @@ function renderTaskLifecycleWorkflow(input) {
9613
10283
  const shared = [
9614
10284
  worktreePrompt(plan),
9615
10285
  ...todosExactCommandsFragment(todosProjectPath, input.taskId, [
9616
- todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence, decision, or blocker", todosDbPath)
9617
- ], todosDbPath),
10286
+ todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence, decision, or blocker")
10287
+ ]),
9618
10288
  NO_TMUX_DISPATCH_FRAGMENT,
9619
10289
  "Preserve unrelated user changes and keep scope tied to the task acceptance criteria.",
9620
10290
  "",
@@ -9623,7 +10293,7 @@ function renderTaskLifecycleWorkflow(input) {
9623
10293
  ].join(`
9624
10294
  `);
9625
10295
  const gateMarker = (stage, state) => `openloops:${stage}=${state} task=${input.taskId}${input.eventId ? ` event=${input.eventId}` : ""}`;
9626
- const blockTaskCommand = `${todosEnvPrefix(todosDbPath)}todos --project ${todosProjectPath} update ${input.taskId} --status blocked`;
10296
+ const blockTaskCommand = `todos --project ${todosProjectPath} update ${input.taskId} --status blocked`;
9627
10297
  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.`;
9628
10298
  const triagePrompt = [
9629
10299
  ...goalHeaderFragment(`Triage todos task ${input.taskId} for safe automated execution.`, "triage", "lifecycle"),
@@ -9651,7 +10321,7 @@ function renderTaskLifecycleWorkflow(input) {
9651
10321
  const workerPrompt = [
9652
10322
  ...goalHeaderFragment(`Complete todos task ${input.taskId} according to the planner evidence.`, "worker", "lifecycle"),
9653
10323
  shared,
9654
- todosStartLine(todosProjectPath, input.taskId, todosDbPath),
10324
+ todosStartLine(todosProjectPath, input.taskId),
9655
10325
  "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.",
9656
10326
  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,
9657
10327
  WORKER_LEAVES_COMPLETION_FRAGMENT
@@ -9660,8 +10330,8 @@ function renderTaskLifecycleWorkflow(input) {
9660
10330
  const verifierPrompt = [
9661
10331
  ...goalHeaderFragment(`Verify todos task ${input.taskId} after the full lifecycle worker step.`, "verifier", "lifecycle"),
9662
10332
  shared,
9663
- todosVerificationLine(todosProjectPath, input.taskId, todosDbPath),
9664
- todosDoneLine(todosProjectPath, input.taskId, todosDbPath),
10333
+ todosVerificationLine(todosProjectPath, input.taskId),
10334
+ todosDoneLine(todosProjectPath, input.taskId),
9665
10335
  adversarialReviewFragment("triage, plan, worker evidence, repo state, commits, tests, and acceptance criteria", TASK_REVIEW_FOCUS),
9666
10336
  verifierRuntimeGuidance(input),
9667
10337
  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,
@@ -9670,7 +10340,7 @@ function renderTaskLifecycleWorkflow(input) {
9670
10340
  ].filter(Boolean).join(`
9671
10341
  `);
9672
10342
  const steps = [
9673
- sourceTaskGateStep(todosProjectPath, input.taskId, plan, "Fail before lifecycle agents execute when the source todos task is not resolvable.", todosDbPath),
10343
+ sourceTaskGateStep(todosProjectPath, input.taskId, plan, "Fail before lifecycle agents execute when the source todos task is not resolvable."),
9674
10344
  {
9675
10345
  id: "triage",
9676
10346
  name: "Triage",
@@ -9684,7 +10354,6 @@ function renderTaskLifecycleWorkflow(input) {
9684
10354
  description: "Stop the lifecycle before planning when triage blocked or disallowed automation.",
9685
10355
  dependsOn: ["triage"],
9686
10356
  todosProjectPath,
9687
- todosDbPath,
9688
10357
  taskId: input.taskId,
9689
10358
  goMarker: gateMarker("triage", "go"),
9690
10359
  blockedMarker: gateMarker("triage", "blocked"),
@@ -9703,7 +10372,6 @@ function renderTaskLifecycleWorkflow(input) {
9703
10372
  description: "Stop the lifecycle before implementation when planning blocked or disallowed automation.",
9704
10373
  dependsOn: ["planner"],
9705
10374
  todosProjectPath,
9706
- todosDbPath,
9707
10375
  taskId: input.taskId,
9708
10376
  goMarker: gateMarker("planner", "go"),
9709
10377
  blockedMarker: gateMarker("planner", "blocked"),
@@ -9719,7 +10387,7 @@ function renderTaskLifecycleWorkflow(input) {
9719
10387
  }
9720
10388
  ];
9721
10389
  if (input.prHandoff) {
9722
- steps.push(prHandoffStep(input, plan, todosProjectPath, todosDbPath));
10390
+ steps.push(prHandoffStep(input, plan, todosProjectPath));
9723
10391
  }
9724
10392
  steps.push({
9725
10393
  id: "verifier",
@@ -9944,7 +10612,6 @@ function renderLifecycleBoundedTemplate(id, values) {
9944
10612
  taskTitle: values.taskTitle,
9945
10613
  taskDescription: values.taskDescription,
9946
10614
  todosProjectPath: values.todosProjectPath ?? values.todosProject,
9947
- todosDbPath: values.todosDbPath,
9948
10615
  triageAuthProfile: values.triageAuthProfile,
9949
10616
  plannerAuthProfile: values.plannerAuthProfile,
9950
10617
  triageAccount: accountVar(values.triageAccount, values.accountTool),
@@ -10009,7 +10676,6 @@ function renderBuiltinLoopTemplate(id, values) {
10009
10676
  taskTitle: values.taskTitle,
10010
10677
  taskDescription: values.taskDescription,
10011
10678
  todosProjectPath: values.todosProjectPath ?? values.todosProject,
10012
- todosDbPath: values.todosDbPath,
10013
10679
  eventId: values.eventId,
10014
10680
  eventType: values.eventType
10015
10681
  });
@@ -10169,7 +10835,7 @@ function collectValues(value, previous = []) {
10169
10835
  return previous;
10170
10836
  }
10171
10837
  // src/lib/route/fields.ts
10172
- import { createHash as createHash3 } from "crypto";
10838
+ import { createHash as createHash4 } from "crypto";
10173
10839
  function eventData(event) {
10174
10840
  const data = event.data;
10175
10841
  if (data && typeof data === "object" && !Array.isArray(data))
@@ -10189,10 +10855,10 @@ function slugSegment2(value, fallback = "event") {
10189
10855
  return value.toLowerCase().replace(/[^a-z0-9._:-]+/g, "-").replace(/^-|-$/g, "").slice(0, 80) || fallback;
10190
10856
  }
10191
10857
  function stableSuffix(value) {
10192
- return createHash3("sha256").update(value).digest("hex").slice(0, 12);
10858
+ return createHash4("sha256").update(value).digest("hex").slice(0, 12);
10193
10859
  }
10194
10860
  function stableHash(parts) {
10195
- return createHash3("sha256").update(parts.map((part) => JSON.stringify(part)).join(`
10861
+ return createHash4("sha256").update(parts.map((part) => JSON.stringify(part)).join(`
10196
10862
  `)).digest("hex").slice(0, 16);
10197
10863
  }
10198
10864
  function taskEventField(data, keys) {
@@ -10457,8 +11123,7 @@ function writeRouteEvidence(kind, value, evidenceDir) {
10457
11123
  mkdirSync9(evidenceDir, { recursive: true, mode: 448 });
10458
11124
  const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\./g, "");
10459
11125
  const evidencePath = join9(evidenceDir, `${kind}-${stamp}-${randomUUID().slice(0, 8)}.json`);
10460
- const encoded = scrubSecrets(JSON.stringify(scrubSecretsDeep(value), null, 2));
10461
- writeFileSync5(evidencePath, encoded, { mode: 384, flag: "wx" });
11126
+ writeFileSync5(evidencePath, JSON.stringify(value, null, 2), { mode: 384, flag: "wx" });
10462
11127
  return evidencePath;
10463
11128
  }
10464
11129
  function selectRouteItems(items, maxActions, cursorKey, fingerprintOf) {
@@ -11054,70 +11719,6 @@ function todosTaskRouteTemplateId(opts) {
11054
11719
  }
11055
11720
  return id;
11056
11721
  }
11057
- function sourceCandidateRecords(data, metadata) {
11058
- const records = [data, metadata];
11059
- for (const record of [data, metadata]) {
11060
- const sourceTask = objectField2(record.source_task) ?? objectField2(record.sourceTask);
11061
- if (sourceTask)
11062
- records.push(sourceTask);
11063
- }
11064
- return records;
11065
- }
11066
- function sourceStringField(records, keys) {
11067
- for (const record of records) {
11068
- for (const key of keys) {
11069
- const value = stringField2(record[key]);
11070
- if (value)
11071
- return value;
11072
- }
11073
- }
11074
- return;
11075
- }
11076
- function sourceBooleanField(records, keys) {
11077
- for (const record of records) {
11078
- for (const key of keys) {
11079
- const value = record[key];
11080
- if (typeof value === "boolean")
11081
- return value;
11082
- if (value === "true" || value === "1" || value === 1)
11083
- return true;
11084
- if (value === "false" || value === "0" || value === 0)
11085
- return false;
11086
- }
11087
- }
11088
- return;
11089
- }
11090
- function todosTaskSourceRef(data, metadata, taskId) {
11091
- const records = sourceCandidateRecords(data, metadata);
11092
- const sourceTaskKey = sourceStringField(records, ["source_task_key", "sourceTaskKey"]);
11093
- const sourceStoreId = sourceStringField(records, ["source_store_id", "sourceStoreId"]);
11094
- const sourceRepoPath = sourceStringField(records, ["source_repo_path", "sourceRepoPath"]);
11095
- const sourceDbPath = sourceStringField(records, ["source_db_path", "sourceDbPath"]);
11096
- const sourceSelectedByInput = sourceBooleanField(records, ["source_selected_by_input", "sourceSelectedByInput"]);
11097
- const idempotencySource = sourceTaskKey ? "source_task_key" : sourceStoreId ? "source_store_id" : "legacy_task_id";
11098
- return {
11099
- sourceTaskKey,
11100
- sourceStoreId,
11101
- sourceRepoPath,
11102
- sourceDbPath,
11103
- sourceSelectedByInput,
11104
- idempotencySource,
11105
- idempotencyKey: sourceTaskKey ? `todos-task:${sourceTaskKey}` : sourceStoreId ? `todos-task:${sourceStoreId}:${taskId}` : `todos-task:${taskId}`
11106
- };
11107
- }
11108
- function publicSourceTask(sourceTask) {
11109
- if (!sourceTask.sourceTaskKey && !sourceTask.sourceStoreId && !sourceTask.sourceRepoPath && !sourceTask.sourceDbPath && sourceTask.sourceSelectedByInput === undefined) {
11110
- return;
11111
- }
11112
- return {
11113
- source_task_key: sourceTask.sourceTaskKey,
11114
- source_store_id: sourceTask.sourceStoreId,
11115
- source_repo_path: sourceTask.sourceRepoPath,
11116
- source_db_path: sourceTask.sourceDbPath,
11117
- source_selected_by_input: sourceTask.sourceSelectedByInput,
11118
- idempotency_source: sourceTask.idempotencySource
11119
- };
11120
- }
11121
11722
  async function readEventEnvelopeInput(opts = {}) {
11122
11723
  const raw = opts.eventJson ?? (opts.eventFile ? readFileSync8(opts.eventFile, "utf8") : process.env.HASNA_EVENT_JSON || await Bun.stdin.text());
11123
11724
  const event = JSON.parse(raw);
@@ -11294,13 +11895,11 @@ function routeTodosTaskEvent(event, opts) {
11294
11895
  const taskId = taskEventField(data, ["id", "task_id", "taskId"]);
11295
11896
  if (!taskId)
11296
11897
  throw new ValidationError("todos task event is missing task id in data.id, data.task_id, data.task.id, or data.payload.id");
11297
- const sourceTask = todosTaskSourceRef(data, metadata, taskId);
11298
- const sourceTaskPublic = publicSourceTask(sourceTask);
11299
11898
  const eligibility = taskRouteEligibility(data, metadata);
11300
11899
  if (!eligibility.eligible) {
11301
11900
  return {
11302
11901
  kind: "skipped",
11303
- value: { skipped: true, reason: eligibility.reason, event, taskId, eligibility, sourceTask: sourceTaskPublic },
11902
+ value: { skipped: true, reason: eligibility.reason, event, taskId, eligibility },
11304
11903
  human: `skipped task ${taskId}: ${eligibility.reason}`
11305
11904
  };
11306
11905
  }
@@ -11315,11 +11914,11 @@ function routeTodosTaskEvent(event, opts) {
11315
11914
  "project_canonical_path",
11316
11915
  "cwd"
11317
11916
  ]);
11318
- const projectPath = dataProjectPath ?? metadataProjectPath ?? sourceTask.sourceRepoPath ?? opts.projectPath ?? process.cwd();
11917
+ const projectPath = dataProjectPath ?? metadataProjectPath ?? opts.projectPath ?? process.cwd();
11319
11918
  const routeProjectPath = normalizeRoutePath(projectPath) ?? resolve7(projectPath);
11320
11919
  const projectGroup = routeProjectGroup(opts.projectGroup, data, metadata);
11321
11920
  const throttleLimits = routeThrottleLimitsFromOpts(opts);
11322
- const idempotencyKey = sourceTask.idempotencyKey;
11921
+ const idempotencyKey = `todos-task:${taskId}`;
11323
11922
  const idempotencySuffix = stableSuffix(idempotencyKey);
11324
11923
  const namePrefix = opts.namePrefix ?? "event:todos-task";
11325
11924
  const workflowName = `${namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:workflow`;
@@ -11332,7 +11931,7 @@ function routeTodosTaskEvent(event, opts) {
11332
11931
  const existingLoop = existingItem.loopId ? store.getLoop(existingItem.loopId) : undefined;
11333
11932
  const existingWorkflow = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
11334
11933
  const existingInvocation = store.getWorkflowInvocation(existingItem.invocationId);
11335
- return dedupedRoutePrint({ event, idempotencyKey, dedupeValueExtras: sourceTaskPublic ? { sourceTask: sourceTaskPublic } : {} }, { existingItem, existingLoop, existingWorkflow, invocation: existingInvocation });
11934
+ return dedupedRoutePrint({ event, idempotencyKey, dedupeValueExtras: {} }, { existingItem, existingLoop, existingWorkflow, invocation: existingInvocation });
11336
11935
  }
11337
11936
  } finally {
11338
11937
  store.close();
@@ -11395,14 +11994,12 @@ function routeTodosTaskEvent(event, opts) {
11395
11994
  prHandoff: templateId === TASK_LIFECYCLE_TEMPLATE_ID ? Boolean(opts.prHandoff) : false,
11396
11995
  eventId: event.id,
11397
11996
  eventType: event.type,
11398
- todosProjectPath: sourceTask.sourceRepoPath ?? opts.todosProject,
11399
- todosDbPath: sourceTask.sourceDbPath
11997
+ todosProjectPath: opts.todosProject
11400
11998
  };
11401
11999
  const workflowContext = {
11402
12000
  name: workflowName,
11403
12001
  type: "todos-task-event-workflow",
11404
- event: event.id,
11405
- sourceTask: sourceTaskPublic
12002
+ event: event.id
11406
12003
  };
11407
12004
  let workflowBody = templateId === TASK_LIFECYCLE_TEMPLATE_ID ? renderTaskLifecycleWorkflow(workflowInput) : renderTodosTaskWorkerVerifierWorkflow(workflowInput);
11408
12005
  workflowBody.name = workflowName;
@@ -11415,13 +12012,13 @@ function routeTodosTaskEvent(event, opts) {
11415
12012
  kind: "event",
11416
12013
  id: event.id,
11417
12014
  dedupeKey: idempotencyKey,
11418
- raw: { type: event.type, source: event.source, subject: event.subject, sourceTask: sourceTaskPublic }
12015
+ raw: { type: event.type, source: event.source, subject: event.subject }
11419
12016
  },
11420
12017
  subjectRef: {
11421
12018
  kind: "task",
11422
12019
  id: taskId,
11423
12020
  path: routeProjectPath,
11424
- raw: { title: taskTitle, description: taskDescription, sourceTask: sourceTaskPublic }
12021
+ raw: { title: taskTitle, description: taskDescription }
11425
12022
  },
11426
12023
  intent: "route",
11427
12024
  scope: {
@@ -11434,8 +12031,7 @@ function routeTodosTaskEvent(event, opts) {
11434
12031
  accountPolicy: providerRouting.authProfilePool?.length || providerRouting.accountPool?.length ? "pool" : hasExplicitRoleAccount ? "role-explicit" : "single",
11435
12032
  providerRouting: providerRoutingPublic(providerRouting),
11436
12033
  prReviewRouting: prReviewRouting.required ? prReviewRouting : undefined,
11437
- concurrencyGroup: projectGroup ?? routeProjectPath,
11438
- sourceTask: sourceTaskPublic
12034
+ concurrencyGroup: projectGroup ?? routeProjectPath
11439
12035
  },
11440
12036
  outputPolicy: {
11441
12037
  report: "always",
@@ -11460,10 +12056,9 @@ function routeTodosTaskEvent(event, opts) {
11460
12056
  humanSubject: `task ${taskId}`,
11461
12057
  valueExtras: {
11462
12058
  providerRouting: providerRoutingPublic(providerRouting),
11463
- prReviewRouting: prReviewRouting.required ? prReviewRouting : undefined,
11464
- sourceTask: sourceTaskPublic
12059
+ prReviewRouting: prReviewRouting.required ? prReviewRouting : undefined
11465
12060
  },
11466
- dedupeValueExtras: sourceTaskPublic ? { sourceTask: sourceTaskPublic } : {}
12061
+ dedupeValueExtras: {}
11467
12062
  });
11468
12063
  }
11469
12064
  function routeGenericEvent(event, opts) {
@@ -11600,7 +12195,7 @@ function runLocalCommand(command, args, opts = {}) {
11600
12195
  encoding: "utf8",
11601
12196
  timeout: opts.timeoutMs ?? 30000,
11602
12197
  maxBuffer: opts.maxBuffer ?? 8 * 1024 * 1024,
11603
- env: { ...process.env, ...opts.env }
12198
+ env: process.env
11604
12199
  });
11605
12200
  return {
11606
12201
  ok: result.status === 0,
@@ -11667,66 +12262,8 @@ function taskField(task, keys) {
11667
12262
  }
11668
12263
  return;
11669
12264
  }
11670
- function booleanTaskField(task, keys) {
11671
- for (const key of keys) {
11672
- const value = task[key];
11673
- if (typeof value === "boolean")
11674
- return value;
11675
- if (value === "true" || value === "1" || value === 1)
11676
- return true;
11677
- if (value === "false" || value === "0" || value === 0)
11678
- return false;
11679
- }
11680
- return;
11681
- }
11682
- function normalizedSourceRoots(opts) {
11683
- return listFromRepeatedOpts(opts.todosSourceRoot) ?? [];
11684
- }
11685
- function normalizedSourceStores(opts) {
11686
- return listFromRepeatedOpts(opts.todosSourceStore) ?? [];
11687
- }
11688
- function normalizedSourceIncludes(opts) {
11689
- return listFromRepeatedOpts(opts.todosSourceInclude) ?? [];
11690
- }
11691
- function normalizedSourceExcludes(opts) {
11692
- return listFromRepeatedOpts(opts.todosSourceExclude) ?? [];
11693
- }
11694
- function hasTodosSourceOptions(opts) {
11695
- return Boolean(normalizedSourceRoots(opts).length || normalizedSourceStores(opts).length || normalizedSourceIncludes(opts).length || normalizedSourceExcludes(opts).length);
11696
- }
11697
- function taskSourceIdentity(task, taskId) {
11698
- const sourceTaskKey = taskField(task, ["source_task_key", "sourceTaskKey"]);
11699
- const sourceStoreId = taskField(task, ["source_store_id", "sourceStoreId"]);
11700
- const identity = sourceTaskKey ?? (sourceStoreId && taskId ? `${sourceStoreId}:${taskId}` : undefined);
11701
- return {
11702
- sourceTaskKey,
11703
- sourceStoreId,
11704
- sourceRepoPath: taskField(task, ["source_repo_path", "sourceRepoPath"]),
11705
- sourceDbPath: taskField(task, ["source_db_path", "sourceDbPath"]),
11706
- sourceSelectedByInput: booleanTaskField(task, ["source_selected_by_input", "sourceSelectedByInput"]),
11707
- idempotencyIdentity: identity
11708
- };
11709
- }
11710
- function publicSourceTaskIdentity(source) {
11711
- if (!source.sourceTaskKey && !source.sourceStoreId && !source.sourceRepoPath && !source.sourceDbPath && source.sourceSelectedByInput === undefined) {
11712
- return;
11713
- }
11714
- return {
11715
- source_task_key: source.sourceTaskKey,
11716
- source_store_id: source.sourceStoreId,
11717
- source_repo_path: source.sourceRepoPath,
11718
- source_db_path: source.sourceDbPath,
11719
- source_selected_by_input: source.sourceSelectedByInput
11720
- };
11721
- }
11722
- function taskListValues(task) {
11723
- const values = [
11724
- taskField(task, ["task_list_id", "taskListId", "task_list_slug", "taskListSlug", "task_list_name", "taskListName"]),
11725
- stringField2(task.task_list?.id),
11726
- stringField2(task.task_list?.slug),
11727
- stringField2(task.task_list?.name)
11728
- ].filter((value) => Boolean(value));
11729
- return [...new Set(values)];
12265
+ function taskListId(task) {
12266
+ return taskField(task, ["task_list_id", "taskListId"]) ?? stringField2(task.task_list?.id);
11730
12267
  }
11731
12268
  function taskProjectId(task) {
11732
12269
  return taskField(task, ["project_id", "projectId"]);
@@ -11740,14 +12277,12 @@ function taskProjectPath(task) {
11740
12277
  const metadata = objectField2(task.metadata) ?? {};
11741
12278
  return taskField(task, ["project_path", "projectPath"]) ?? taskEventField(metadata, ["project_path", "projectPath", "project_canonical_path"]) ?? taskDescriptionProjectPath(task) ?? taskField(task, ["working_dir", "workingDir", "cwd"]) ?? taskEventField(metadata, ["working_dir", "workingDir", "cwd"]);
11742
12279
  }
11743
- function taskDrainEvent(task, opts = {}) {
12280
+ function taskDrainEvent(task) {
11744
12281
  const taskId = taskField(task, ["id", "task_id", "taskId"]);
11745
12282
  if (!taskId)
11746
12283
  throw new Error("todos ready returned a task without an id");
11747
12284
  const metadata = objectField2(task.metadata) ?? {};
11748
12285
  const workingDir = taskProjectPath(task);
11749
- const sourceIdentity = taskSourceIdentity(task, taskId);
11750
- const sourceTask = publicSourceTaskIdentity(sourceIdentity);
11751
12286
  const data = {
11752
12287
  ...task,
11753
12288
  id: taskId,
@@ -11757,21 +12292,14 @@ function taskDrainEvent(task, opts = {}) {
11757
12292
  tags: tagsFromValue2(task.tags),
11758
12293
  metadata
11759
12294
  };
11760
- if (opts.sourceRouteEligible) {
11761
- data.route_enabled = true;
11762
- data.route_enabled_by = "source_route_state";
11763
- }
11764
12295
  if (workingDir) {
11765
12296
  data.working_dir = workingDir;
11766
12297
  data.project_path = taskField(task, ["project_path", "projectPath"]) ?? workingDir;
11767
12298
  data.cwd = taskField(task, ["cwd"]) ?? workingDir;
11768
12299
  }
11769
- if (sourceTask)
11770
- data.source_task = sourceTask;
11771
- const eventId = sourceIdentity.idempotencyIdentity ? `drain-todos-task-${slugSegment2(taskId, "task")}-${stableSuffix(sourceIdentity.idempotencyIdentity)}` : `drain-todos-task-${taskId}`;
11772
12300
  const time = new Date().toISOString();
11773
12301
  return {
11774
- id: eventId,
12302
+ id: `drain-todos-task-${taskId}`,
11775
12303
  type: "task.created",
11776
12304
  source: "@hasna/todos",
11777
12305
  subject: taskId,
@@ -11782,8 +12310,6 @@ function taskDrainEvent(task, opts = {}) {
11782
12310
  metadata: {
11783
12311
  ...metadata,
11784
12312
  ...workingDir ? { working_dir: workingDir, project_path: data.project_path, cwd: data.cwd } : {},
11785
- ...sourceTask,
11786
- ...sourceTask ? { source_task: sourceTask } : {},
11787
12313
  drained_by: "@hasna/loops",
11788
12314
  drained_from: "todos ready"
11789
12315
  }
@@ -11792,7 +12318,6 @@ function taskDrainEvent(task, opts = {}) {
11792
12318
  function compactDrainResult(result) {
11793
12319
  const value = result.value;
11794
12320
  const event = objectField2(value.event);
11795
- const sourceTask = objectField2(value.sourceTask) ?? objectField2(objectField2(event?.metadata)?.source_task) ?? objectField2(objectField2(event?.data)?.source_task);
11796
12321
  const loop = objectField2(value.loop);
11797
12322
  const workflow = objectField2(value.workflow);
11798
12323
  const throttle = objectField2(value.throttle);
@@ -11809,75 +12334,26 @@ function compactDrainResult(result) {
11809
12334
  workflowId: stringField2(workflow?.id),
11810
12335
  workflowName: stringField2(workflow?.name),
11811
12336
  providerRouting,
11812
- sourceTask,
11813
12337
  requeue,
11814
12338
  queuedAtSource: value.queuedAtSource
11815
12339
  };
11816
12340
  }
11817
- function numberField2(value) {
11818
- return typeof value === "number" && Number.isFinite(value) ? value : undefined;
11819
- }
11820
- function parseTodosReadyJson(stdout) {
11821
- try {
11822
- return JSON.parse(stdout || "[]");
11823
- } catch (error) {
11824
- const message = error instanceof Error ? error.message : String(error);
11825
- throw new Error(`failed to parse todos ready --json output (${stdout.length} bytes): ${message}`);
11826
- }
11827
- }
11828
- function loadReadyTodosTasks(opts, scanLimit, sourceMode) {
11829
- const sourceRoots = normalizedSourceRoots(opts);
11830
- const sourceStores = normalizedSourceStores(opts);
11831
- const sourceIncludes = normalizedSourceIncludes(opts);
11832
- const sourceExcludes = normalizedSourceExcludes(opts);
11833
- if (sourceMode && !sourceRoots.length && !sourceStores.length) {
11834
- throw new ValidationError("source discovery requires at least one --todos-source-root or --todos-source-store");
11835
- }
11836
- const args = sourceMode ? ["--json", "ready"] : ["--project", opts.todosProject ?? defaultLoopsProject(), "--json", "ready"];
11837
- if (sourceMode) {
11838
- for (const root of sourceRoots)
11839
- args.push("--source-root", root);
11840
- for (const store of sourceStores)
11841
- args.push("--source-store", store);
11842
- for (const pattern of sourceIncludes)
11843
- args.push("--include", pattern);
11844
- for (const pattern of sourceExcludes)
11845
- args.push("--exclude", pattern);
11846
- }
11847
- args.push("--limit", String(scanLimit));
12341
+ function loadReadyTodosTasks(opts, scanLimit) {
12342
+ const todosProject = opts.todosProject ?? defaultLoopsProject();
12343
+ const args = ["--project", todosProject, "--json", "ready", "--limit", String(scanLimit)];
11848
12344
  const result = runLocalCommandWithStdoutFile("todos", args, { timeoutMs: 60000, maxBuffer: 64 * 1024 * 1024 });
11849
12345
  if (!result.ok)
11850
12346
  throw new Error(result.stderr || result.error || "todos ready failed");
11851
- const parsed = parseTodosReadyJson(result.stdout);
11852
- if (!sourceMode) {
11853
- if (!Array.isArray(parsed))
11854
- throw new Error("todos ready --json returned a non-array value");
11855
- return { tasks: parsed, sourceMode, sourceRoots, sourceStores, sourceIncludes, sourceExcludes };
11856
- }
11857
- const response = objectField2(parsed);
11858
- if (!response)
11859
- throw new Error("todos ready --json source discovery returned a non-object value");
11860
- if (response.schema_version !== "todos.task_route_sources.v1") {
11861
- throw new Error(`todos ready source discovery returned unsupported schema_version: ${String(response.schema_version)}`);
11862
- }
11863
- if (!Array.isArray(response.candidates))
11864
- throw new Error("todos ready source discovery returned missing candidates array");
11865
- return {
11866
- tasks: response.candidates,
11867
- sourceMode,
11868
- sourceRoots,
11869
- sourceStores,
11870
- sourceIncludes,
11871
- sourceExcludes,
11872
- sourceDiscovery: {
11873
- schemaVersion: "todos.task_route_sources.v1",
11874
- stores: Array.isArray(response.stores) ? response.stores : undefined,
11875
- errors: Array.isArray(response.errors) ? response.errors : undefined,
11876
- totalCandidateCount: numberField2(response.total_candidate_count),
11877
- returnedCandidateCount: numberField2(response.returned_candidate_count),
11878
- truncated: typeof response.truncated === "boolean" ? response.truncated : undefined
11879
- }
11880
- };
12347
+ let parsed;
12348
+ try {
12349
+ parsed = JSON.parse(result.stdout || "[]");
12350
+ } catch (error) {
12351
+ const message = error instanceof Error ? error.message : String(error);
12352
+ throw new Error(`failed to parse todos ready --json output (${result.stdout.length} bytes): ${message}`);
12353
+ }
12354
+ if (!Array.isArray(parsed))
12355
+ throw new Error("todos ready --json returned a non-array value");
12356
+ return parsed;
11881
12357
  }
11882
12358
  function resolveTaskListFilter(todosProject, filter) {
11883
12359
  const wanted = filter?.trim();
@@ -11893,7 +12369,7 @@ function resolveTaskListFilter(todosProject, filter) {
11893
12369
  function taskMatchesDrainFilters(task, filters) {
11894
12370
  if (filters.projectId && taskProjectId(task) !== filters.projectId)
11895
12371
  return false;
11896
- if (filters.taskList && !taskListValues(task).includes(filters.taskList))
12372
+ if (filters.taskListId && taskListId(task) !== filters.taskListId)
11897
12373
  return false;
11898
12374
  if (filters.projectPathPrefix) {
11899
12375
  const path = taskProjectPath(task);
@@ -11931,77 +12407,19 @@ function skippedDrainTask(task, event, reason, extra = {}) {
11931
12407
  function isSkippableDrainRouteError(message) {
11932
12408
  return message.startsWith("worktreeMode=required but projectPath is not an existing git repository:");
11933
12409
  }
11934
- function sourceRouteState(task) {
11935
- return objectField2(task.route_state) ?? objectField2(task.routeState);
11936
- }
11937
- function sourceRouteReason(routeState) {
11938
- const reasons = routeState?.reasons;
11939
- const firstReason = Array.isArray(reasons) ? stringField2(reasons[0]) : undefined;
11940
- return stringField2(routeState?.reason) ?? stringField2(routeState?.eligibility_reason) ?? stringField2(routeState?.eligibilityReason) ?? firstReason;
11941
- }
11942
- function sourceRouteEligibility(task) {
11943
- const routeState = sourceRouteState(task);
11944
- if (routeState?.eligible === true)
11945
- return { eligible: true, routeState };
11946
- const reason = sourceRouteReason(routeState) ?? "source route_state.eligible is not true";
11947
- return { eligible: false, reason, routeState };
11948
- }
11949
- function sourceTaskMutationTarget(todosProject, task, opts = {}) {
11950
- const source = taskSourceIdentity(task, taskField(task, ["id", "task_id", "taskId"]));
11951
- if (source.sourceDbPath) {
11952
- return {
11953
- argsPrefix: source.sourceRepoPath ? ["--project", source.sourceRepoPath] : [],
11954
- env: {
11955
- TODOS_DB_PATH: source.sourceDbPath,
11956
- HASNA_TODOS_DB_PATH: source.sourceDbPath
11957
- },
11958
- project: source.sourceRepoPath,
11959
- sourceDbPath: source.sourceDbPath,
11960
- sourceRepoPath: source.sourceRepoPath,
11961
- sourceStoreId: source.sourceStoreId
11962
- };
11963
- }
11964
- if (source.sourceRepoPath) {
11965
- return {
11966
- argsPrefix: ["--project", source.sourceRepoPath],
11967
- project: source.sourceRepoPath,
11968
- sourceRepoPath: source.sourceRepoPath,
11969
- sourceStoreId: source.sourceStoreId
11970
- };
11971
- }
11972
- if (opts.sourceMode)
11973
- return;
11974
- const project = todosProject ?? defaultLoopsProject();
11975
- return { argsPrefix: ["--project", project], project };
11976
- }
11977
- function markInvalidDrainTaskNonRouteable(todosProject, task, reason, opts = {}) {
12410
+ function markInvalidDrainTaskNonRouteable(todosProject, task, reason) {
11978
12411
  const taskId = taskField(task, ["id", "task_id", "taskId"]);
11979
12412
  if (!taskId)
11980
12413
  return { attempted: false, reason: "task id missing" };
11981
12414
  const comment = `OpenLoops route blocked for task ${taskId}: ${reason}. Added no-auto and removed auto:route so route drains do not repeatedly route this task until its project path is fixed.`;
11982
- const target = sourceTaskMutationTarget(todosProject, task, { sourceMode: opts.sourceMode });
11983
- if (!target) {
11984
- return {
11985
- attempted: false,
11986
- taskId,
11987
- reason: "source task missing source_db_path or source_repo_path; refusing to update router/default Todos store"
11988
- };
11989
- }
11990
- const commentResult = runLocalCommand("todos", [...target.argsPrefix, "comment", taskId, comment], { timeoutMs: 30000, env: target.env });
11991
- const tagResult = runLocalCommand("todos", [...target.argsPrefix, "tag", taskId, "no-auto"], { timeoutMs: 30000, env: target.env });
11992
- const untagResult = runLocalCommand("todos", [...target.argsPrefix, "untag", taskId, "auto:route"], { timeoutMs: 30000, env: target.env });
12415
+ const commentResult = runLocalCommand("todos", ["--project", todosProject, "comment", taskId, comment], { timeoutMs: 30000 });
12416
+ const tagResult = runLocalCommand("todos", ["--project", todosProject, "tag", taskId, "no-auto"], { timeoutMs: 30000 });
12417
+ const untagResult = runLocalCommand("todos", ["--project", todosProject, "untag", taskId, "auto:route"], { timeoutMs: 30000 });
11993
12418
  const ok = commentResult.ok && tagResult.ok && untagResult.ok;
11994
12419
  return {
11995
12420
  ok,
11996
12421
  attempted: true,
11997
12422
  taskId,
11998
- target: {
11999
- project: target.project,
12000
- source_db_path: target.sourceDbPath,
12001
- source_repo_path: target.sourceRepoPath,
12002
- source_store_id: target.sourceStoreId,
12003
- used_db_env: Boolean(target.sourceDbPath)
12004
- },
12005
12423
  error: ok ? undefined : "one or more source task updates failed; inspect per-command results",
12006
12424
  comment: todosMutationSummary(commentResult),
12007
12425
  tagNoAuto: todosMutationSummary(tagResult),
@@ -12010,19 +12428,17 @@ function markInvalidDrainTaskNonRouteable(todosProject, task, reason, opts = {})
12010
12428
  }
12011
12429
  function drainTodosTaskRoutes(opts) {
12012
12430
  const maxDispatch = positiveInteger(opts.maxDispatch ?? "1", "--max-dispatch") ?? 1;
12013
- const sourceMode = hasTodosSourceOptions(opts);
12014
- const todosProject = sourceMode ? opts.todosProject : opts.todosProject ?? defaultLoopsProject();
12431
+ const todosProject = opts.todosProject ?? defaultLoopsProject();
12015
12432
  const requiredTags = splitList(opts.tags ?? opts.tag) ?? [];
12016
- const taskListFilter = sourceMode ? opts.taskList?.trim() : resolveTaskListFilter(todosProject ?? defaultLoopsProject(), opts.taskList);
12433
+ const taskListFilter = resolveTaskListFilter(todosProject, opts.taskList);
12017
12434
  const candidateLimit = positiveInteger(opts.limit ?? "50", "--limit") ?? 50;
12018
12435
  const hasPostFilters = Boolean(opts.todosProjectId || taskListFilter || opts.projectPathPrefix || requiredTags.length);
12019
12436
  const defaultScanLimit = hasPostFilters ? Math.max(candidateLimit, 500) : candidateLimit;
12020
12437
  const scanLimit = positiveInteger(opts.scanLimit ?? String(defaultScanLimit), "--scan-limit") ?? defaultScanLimit;
12021
- const loaded = loadReadyTodosTasks(opts, scanLimit, sourceMode);
12022
- const ready = loaded.tasks;
12438
+ const ready = loadReadyTodosTasks(opts, scanLimit);
12023
12439
  const filteredCandidates = ready.filter((task) => taskMatchesDrainFilters(task, {
12024
12440
  projectId: opts.todosProjectId,
12025
- taskList: taskListFilter,
12441
+ taskListId: taskListFilter,
12026
12442
  projectPathPrefix: opts.projectPathPrefix,
12027
12443
  tags: requiredTags
12028
12444
  }));
@@ -12035,25 +12451,13 @@ function drainTodosTaskRoutes(opts) {
12035
12451
  let event;
12036
12452
  let result;
12037
12453
  try {
12038
- let sourceEligibility;
12039
- if (sourceMode) {
12040
- sourceEligibility = sourceRouteEligibility(task);
12041
- if (!sourceEligibility.eligible) {
12042
- result = skippedDrainTask(task, undefined, sourceEligibility.reason ?? "source route_state.eligible is not true", {
12043
- sourceRouteState: sourceEligibility.routeState,
12044
- sourceTask: publicSourceTaskIdentity(taskSourceIdentity(task, taskField(task, ["id", "task_id", "taskId"])))
12045
- });
12046
- results.push(result);
12047
- continue;
12048
- }
12049
- }
12050
- event = taskDrainEvent(task, { sourceRouteEligible: sourceEligibility?.eligible === true });
12454
+ event = taskDrainEvent(task);
12051
12455
  result = routeTodosTaskEvent(event, opts);
12052
12456
  } catch (error) {
12053
12457
  const message = error instanceof Error ? error.message : String(error);
12054
12458
  if (!isSkippableDrainRouteError(message))
12055
12459
  throw error;
12056
- const sourceTaskUpdate = opts.dryRun ? { attempted: false, reason: "dry-run" } : markInvalidDrainTaskNonRouteable(todosProject, task, message, { sourceMode });
12460
+ const sourceTaskUpdate = opts.dryRun ? { attempted: false, reason: "dry-run" } : markInvalidDrainTaskNonRouteable(todosProject, task, message);
12057
12461
  result = skippedDrainTask(task, event, redact(message, 640) ?? "route task failed", { sourceTaskUpdate });
12058
12462
  }
12059
12463
  results.push(result);
@@ -12063,12 +12467,6 @@ function drainTodosTaskRoutes(opts) {
12063
12467
  const report = {
12064
12468
  drainedAt: new Date().toISOString(),
12065
12469
  todosProject,
12066
- sourceMode,
12067
- sourceRoots: loaded.sourceRoots,
12068
- sourceStores: loaded.sourceStores,
12069
- sourceIncludes: loaded.sourceIncludes,
12070
- sourceExcludes: loaded.sourceExcludes,
12071
- sourceDiscovery: loaded.sourceDiscovery,
12072
12470
  templateId: todosTaskRouteTemplateId(opts),
12073
12471
  todosProjectId: opts.todosProjectId,
12074
12472
  taskList: opts.taskList,
@@ -12081,14 +12479,14 @@ function drainTodosTaskRoutes(opts) {
12081
12479
  scanned: ready.length,
12082
12480
  candidates: candidates.length,
12083
12481
  filteredCandidates: filteredCandidates.length,
12084
- scanExhausted: sourceMode ? Boolean(loaded.sourceDiscovery?.truncated) || ready.length >= scanLimit && filteredCandidates.length < candidateLimit : ready.length >= scanLimit && filteredCandidates.length < candidateLimit,
12482
+ scanExhausted: ready.length >= scanLimit && filteredCandidates.length < candidateLimit,
12085
12483
  considered: results.length,
12086
12484
  created: results.filter((result) => result.kind === "created" && !result.value.deduped).length,
12087
12485
  deduped: results.filter((result) => result.kind === "deduped").length,
12088
12486
  throttled: results.filter((result) => result.kind === "throttled").length,
12089
12487
  skipped: results.filter((result) => result.kind === "skipped").length,
12090
12488
  maxDispatch,
12091
- source: sourceMode ? "todos ready source discovery" : "todos ready",
12489
+ source: "todos ready",
12092
12490
  dryRun: Boolean(opts.dryRun),
12093
12491
  results: results.map((result) => ({ kind: result.kind, ...result.value }))
12094
12492
  };
@@ -12096,12 +12494,6 @@ function drainTodosTaskRoutes(opts) {
12096
12494
  const value = opts.compact ? {
12097
12495
  drainedAt: report.drainedAt,
12098
12496
  todosProject: report.todosProject,
12099
- sourceMode: report.sourceMode,
12100
- sourceRoots: report.sourceRoots,
12101
- sourceStores: report.sourceStores,
12102
- sourceIncludes: report.sourceIncludes,
12103
- sourceExcludes: report.sourceExcludes,
12104
- sourceDiscovery: report.sourceDiscovery,
12105
12497
  templateId: report.templateId,
12106
12498
  todosProjectId: report.todosProjectId,
12107
12499
  taskList: report.taskList,
@@ -12127,7 +12519,7 @@ function drainTodosTaskRoutes(opts) {
12127
12519
  results: results.map(compactDrainResult)
12128
12520
  } : { ...report, evidencePath };
12129
12521
  return {
12130
- value: scrubSecretsDeep(value),
12522
+ value,
12131
12523
  human: `drained todos ready queue: considered=${report.considered} created=${report.created} deduped=${report.deduped} throttled=${report.throttled} skipped=${report.skipped}`
12132
12524
  };
12133
12525
  }
@@ -12405,34 +12797,6 @@ var EVENT_INPUT_OPTION_SPECS = [
12405
12797
  { flags: "--event-json <json>", key: "eventJson", kind: "value", description: "read event envelope JSON from this string instead of stdin/HASNA_EVENT_JSON" }
12406
12798
  ];
12407
12799
  var DRAIN_FILTER_OPTION_SPECS = [
12408
- {
12409
- flags: "--todos-source-root <path>",
12410
- key: "todosSourceRoot",
12411
- kind: "repeat",
12412
- description: "scan todos route candidates discovered under this source root; may be repeated",
12413
- serializeValue: (opts) => listFromRepeatedOpts(opts.todosSourceRoot)
12414
- },
12415
- {
12416
- flags: "--todos-source-store <path>",
12417
- key: "todosSourceStore",
12418
- kind: "repeat",
12419
- description: "scan this explicit todos source store path; may be repeated",
12420
- serializeValue: (opts) => listFromRepeatedOpts(opts.todosSourceStore)
12421
- },
12422
- {
12423
- flags: "--todos-source-include <pattern>",
12424
- key: "todosSourceInclude",
12425
- kind: "repeat",
12426
- description: "include source discovery entries matching this pattern; may be repeated",
12427
- serializeValue: (opts) => listFromRepeatedOpts(opts.todosSourceInclude)
12428
- },
12429
- {
12430
- flags: "--todos-source-exclude <pattern>",
12431
- key: "todosSourceExclude",
12432
- kind: "repeat",
12433
- description: "exclude source discovery entries matching this pattern; may be repeated",
12434
- serializeValue: (opts) => listFromRepeatedOpts(opts.todosSourceExclude)
12435
- },
12436
12800
  { flags: "--todos-project-id <id>", key: "todosProjectId", kind: "value", description: "filter todos ready output to one todos project id" },
12437
12801
  { flags: "--task-list <id-or-slug>", key: "taskList", kind: "value", description: "filter ready tasks to one task-list id, slug, or name" },
12438
12802
  { flags: "--project-path-prefix <path>", key: "projectPathPrefix", kind: "value", description: "filter ready tasks to a project/repo path prefix" },
@@ -12931,6 +13295,53 @@ function parseVars(values) {
12931
13295
  }
12932
13296
  return vars;
12933
13297
  }
13298
+ function parseJsonFile(file) {
13299
+ try {
13300
+ return JSON.parse(readFileSync10(file, "utf8"));
13301
+ } catch (error) {
13302
+ const reason = error instanceof Error ? error.message : String(error);
13303
+ throw new ValidationError(`failed to read JSON file ${file}: ${reason}`);
13304
+ }
13305
+ }
13306
+ function parseStringMap(values, flag) {
13307
+ const result = {};
13308
+ for (const value of values ?? []) {
13309
+ const index = value.indexOf("=");
13310
+ if (index <= 0)
13311
+ throw new ValidationError(`invalid ${flag} value, expected key=value: ${value}`);
13312
+ result[value.slice(0, index)] = value.slice(index + 1);
13313
+ }
13314
+ return result;
13315
+ }
13316
+ function parseJsonMap(values, flag) {
13317
+ const result = {};
13318
+ for (const value of values ?? []) {
13319
+ const index = value.indexOf("=");
13320
+ if (index <= 0)
13321
+ throw new ValidationError(`invalid ${flag} value, expected key=json-or-string: ${value}`);
13322
+ const raw = value.slice(index + 1);
13323
+ try {
13324
+ result[value.slice(0, index)] = JSON.parse(raw);
13325
+ } catch {
13326
+ result[value.slice(0, index)] = raw;
13327
+ }
13328
+ }
13329
+ return result;
13330
+ }
13331
+ function printMigrationPlan(plan, opts = {}) {
13332
+ if (isJson() || opts.json) {
13333
+ console.log(JSON.stringify(plan, null, 2));
13334
+ return;
13335
+ }
13336
+ console.log(`${plan.operation} dryRun=${plan.dryRun} workflows=${plan.summary.workflows} loops=${plan.summary.loops} runs=${plan.summary.runs} ` + `insert=${plan.summary.insert} update=${plan.summary.update} skip=${plan.summary.skip} conflict=${plan.summary.conflict} blocked=${plan.summary.blocked}`);
13337
+ for (const warning of plan.warnings)
13338
+ console.log(`warn ${warning}`);
13339
+ for (const row of plan.rows) {
13340
+ if (row.action !== "blocked" && row.action !== "conflict")
13341
+ continue;
13342
+ console.log(`${row.action} ${row.resource}:${row.name ?? row.id} ${row.reason ?? ""}`.trim());
13343
+ }
13344
+ }
12934
13345
  function backupLoopsDatabase(reason) {
12935
13346
  return backupDatabase({ reason, keep: 3 }).path;
12936
13347
  }
@@ -13019,6 +13430,104 @@ var goal = program.command("goal").description("inspect goal runs");
13019
13430
  program.command("mode").description("show the active OpenLoops deployment mode").option("--json", "print JSON").action(runAction(deploymentStatusCommand()));
13020
13431
  var selfHosted = program.command("self-hosted").alias("selfhosted").description("inspect the self-hosted OpenLoops contract");
13021
13432
  selfHosted.command("status").option("--json", "print JSON").action(runAction(deploymentStatusCommand("self_hosted")));
13433
+ program.command("export").description("export a local OpenLoops migration bundle").requiredOption("--file <path>", "write bundle JSON to this path").option("--dry-run", "preview the bundle without writing the file").option("--no-runs", "omit loop run history from the bundle").option("--allow-redacted", "write a redacted non-importable bundle when env/secrets must be removed").option("--json", "print JSON").action(runAction((opts) => {
13434
+ const store = new Store;
13435
+ try {
13436
+ const bundle = exportLoopsMigrationBundle(store, { includeRuns: opts.runs });
13437
+ if (!opts.dryRun && !bundle.importable && !opts.allowRedacted) {
13438
+ throw new ValidationError("export is not no-loss because redactions/blockers are present; rerun with --allow-redacted to write a redacted bundle");
13439
+ }
13440
+ if (!opts.dryRun)
13441
+ writeFileSync6(opts.file, `${JSON.stringify(bundle, null, 2)}
13442
+ `, { mode: 384 });
13443
+ const output = {
13444
+ ok: true,
13445
+ dryRun: Boolean(opts.dryRun),
13446
+ file: opts.file,
13447
+ bundle: publicMigrationBundle(bundle)
13448
+ };
13449
+ if (isJson() || opts.json)
13450
+ console.log(JSON.stringify(output, null, 2));
13451
+ else {
13452
+ console.log(`${opts.dryRun ? "would export" : "exported"} ${opts.file} workflows=${bundle.counts.workflows} loops=${bundle.counts.loops} runs=${bundle.counts.runs}`);
13453
+ for (const warning of bundle.warnings)
13454
+ console.log(`warn ${warning}`);
13455
+ }
13456
+ } finally {
13457
+ store.close();
13458
+ }
13459
+ }));
13460
+ program.command("import <file>").description("preview or apply a local OpenLoops migration bundle").option("--apply", "apply the import; default is a dry-run preview").option("--replace", "update existing rows whose ids match but hashes differ").option("--no-runs", "ignore loop run history in the bundle").option("--json", "print JSON").action(runAction((file, opts) => {
13461
+ const bundle = validateLoopsMigrationBundle(parseJsonFile(file));
13462
+ const store = new Store;
13463
+ try {
13464
+ if (!opts.apply) {
13465
+ printMigrationPlan(buildImportMigrationPlan(store, bundle, {
13466
+ includeRuns: opts.runs,
13467
+ replace: opts.replace,
13468
+ dryRun: true
13469
+ }), opts);
13470
+ return;
13471
+ }
13472
+ const plan = buildImportMigrationPlan(store, bundle, {
13473
+ includeRuns: opts.runs,
13474
+ replace: opts.replace,
13475
+ dryRun: false
13476
+ });
13477
+ if (plan.summary.blocked > 0 || plan.summary.conflict > 0 || !plan.importable) {
13478
+ printMigrationPlan(plan, opts);
13479
+ throw new ValidationError(`refusing to import unsafe bundle: blocked=${plan.summary.blocked} conflict=${plan.summary.conflict}`);
13480
+ }
13481
+ const backupPath = backupLoopsDatabase("migration-import");
13482
+ const result = applyImportMigrationBundle(store, bundle, {
13483
+ includeRuns: opts.runs,
13484
+ replace: opts.replace,
13485
+ dryRun: false
13486
+ });
13487
+ const output = { ok: true, backupPath, ...result };
13488
+ if (isJson() || opts.json)
13489
+ console.log(JSON.stringify(output, null, 2));
13490
+ else {
13491
+ console.log(`imported workflows=${result.applied.workflows} loops=${result.applied.loops} runs=${result.applied.runs}${backupPath ? ` backup=${backupPath}` : ""}`);
13492
+ }
13493
+ } finally {
13494
+ store.close();
13495
+ }
13496
+ }));
13497
+ function selfHostedMigrationCommand(operation) {
13498
+ return runAction(async (opts) => {
13499
+ const store = new Store;
13500
+ try {
13501
+ const plan = await buildSelfHostedMigrationPlan(store, {
13502
+ operation,
13503
+ apiUrl: opts.apiUrl,
13504
+ includeRuns: opts.runs
13505
+ });
13506
+ printMigrationPlan(plan, opts);
13507
+ } finally {
13508
+ store.close();
13509
+ }
13510
+ });
13511
+ }
13512
+ selfHosted.command("migrate").description("preview local-to-self-hosted migration actions").option("--api-url <url>", "self-hosted control-plane API URL").option("--dry-run", "preview only; self-hosted migrate does not apply remote changes yet").option("--no-runs", "omit loop run history from the preview").option("--json", "print JSON").action(selfHostedMigrationCommand("self-hosted-migrate"));
13513
+ selfHosted.command("push").description("preview local rows that would be pushed to self-hosted").option("--api-url <url>", "self-hosted control-plane API URL").option("--dry-run", "preview only; self-hosted push does not apply remote changes yet").option("--no-runs", "omit loop run history from the preview").option("--json", "print JSON").action(selfHostedMigrationCommand("self-hosted-push"));
13514
+ selfHosted.command("pull").description("preview self-hosted rows that would be pulled locally").option("--api-url <url>", "self-hosted control-plane API URL").option("--dry-run", "preview only; self-hosted pull does not apply local changes yet").option("--no-runs", "omit loop run history from the preview").option("--json", "print JSON").action(selfHostedMigrationCommand("self-hosted-pull"));
13515
+ selfHosted.command("runner-register").description("register this machine as a self-hosted runner").requiredOption("--runner-id <id>", "stable runner id").option("--api-url <url>", "self-hosted control-plane API URL").option("--machine-id <id>", "OpenMachines machine id").option("--label <key=value>", "runner label; may be repeated or comma-separated", collectValues, []).option("--capability <key=json>", "runner capability; may be repeated or comma-separated", collectValues, []).option("--dry-run", "preview registration without posting").option("--apply", "post the registration to the control plane").option("--json", "print JSON").action(runAction(async (opts) => {
13516
+ if (opts.apply && opts.dryRun)
13517
+ throw new ValidationError("use either --apply or --dry-run, not both");
13518
+ const request = {
13519
+ apiUrl: opts.apiUrl,
13520
+ runnerId: opts.runnerId,
13521
+ machineId: opts.machineId,
13522
+ labels: parseStringMap(listFromRepeatedOpts(opts.label), "--label"),
13523
+ capabilities: parseJsonMap(listFromRepeatedOpts(opts.capability), "--capability")
13524
+ };
13525
+ const result = opts.apply ? await registerSelfHostedRunner(request) : { ok: true, dryRun: true, runner: request };
13526
+ if (isJson() || opts.json)
13527
+ console.log(JSON.stringify(result, null, 2));
13528
+ else
13529
+ console.log(`${opts.apply ? "registered" : "would register"} runner ${String(opts.runnerId)}`);
13530
+ }));
13022
13531
  var cloud = program.command("cloud").description("inspect the hosted OpenLoops contract");
13023
13532
  cloud.command("status").option("--json", "print JSON").action(runAction(deploymentStatusCommand("cloud")));
13024
13533
  function formatTemplateVariable(template, name) {