@hasna/loops 0.4.2 → 0.4.4

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