@hasna/loops 0.4.27 → 0.4.28

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/sdk/index.js CHANGED
@@ -1569,6 +1569,7 @@ var DEFAULT_RECOVERY_BATCH_LIMIT = 100;
1569
1569
  var DEFAULT_RECOVERY_SCAN_MULTIPLIER = 5;
1570
1570
  var LIVE_EXPIRED_RUN_GRACE_MS = 60000;
1571
1571
  var SCHEMA_USER_VERSION = 8;
1572
+ var BREAKING_SCHEMA_FLOOR = 7;
1572
1573
  var TERMINAL_RUN_STATUSES = ["succeeded", "failed", "timed_out", "abandoned", "skipped"];
1573
1574
  var PRUNE_BATCH_SIZE = 400;
1574
1575
  var GENERATED_ROUTE_TEMPLATE_IDS = new Set(["todos-task-worker-verifier", "task-lifecycle", "event-worker-verifier"]);
@@ -1711,6 +1712,7 @@ function rowToWorkflowWorkItem(row) {
1711
1712
  priority: row.priority,
1712
1713
  status: row.status,
1713
1714
  attempts: row.attempts,
1715
+ gateDeaths: row.gate_deaths ?? 0,
1714
1716
  nextAttemptAt: row.next_attempt_at ?? undefined,
1715
1717
  leaseExpiresAt: row.lease_expires_at ?? undefined,
1716
1718
  workflowId: row.workflow_id ?? undefined,
@@ -1860,6 +1862,23 @@ function workItemStatusForLoopRun(status, attempt, maxAttempts) {
1860
1862
  }
1861
1863
  return;
1862
1864
  }
1865
+ var WORK_ITEM_TEMPFAIL_EXIT_CODE = 75;
1866
+ var GATE_STEP_IDS = new Set(["triage", "planner", "plan"]);
1867
+ var GATE_DEATH_MAX_DURATION_MS = 60000;
1868
+ var GATE_DEATH_CEILING = 20;
1869
+ function classifyNonProductiveStepFailure(steps) {
1870
+ const failing = [...steps].reverse().find((step) => step.status === "failed" || step.status === "timed_out");
1871
+ if (!failing)
1872
+ return;
1873
+ if (failing.exitCode === WORK_ITEM_TEMPFAIL_EXIT_CODE)
1874
+ return "tempfail";
1875
+ if (typeof failing.error === "string" && failing.error.includes("worktree preparation failed"))
1876
+ return "gate-death";
1877
+ const fast = failing.durationMs === undefined || failing.durationMs < GATE_DEATH_MAX_DURATION_MS;
1878
+ if (GATE_STEP_IDS.has(failing.stepId) && fast)
1879
+ return "gate-death";
1880
+ return;
1881
+ }
1863
1882
  function scrubbedOrNull(value) {
1864
1883
  return value == null ? null : scrubSecrets(value);
1865
1884
  }
@@ -1960,11 +1979,21 @@ class Store {
1960
1979
  id TEXT PRIMARY KEY,
1961
1980
  applied_at TEXT NOT NULL
1962
1981
  );
1982
+ CREATE TABLE IF NOT EXISTS schema_compat (
1983
+ id INTEGER PRIMARY KEY CHECK (id = 1),
1984
+ min_compatible_user_version INTEGER NOT NULL
1985
+ );
1963
1986
  `);
1964
1987
  const versionRow = this.db.query("PRAGMA user_version").get();
1965
1988
  const userVersion = versionRow?.user_version ?? 0;
1966
1989
  if (userVersion > SCHEMA_USER_VERSION) {
1967
- throw new Error(`loops database schema version ${userVersion} is newer than this binary supports (${SCHEMA_USER_VERSION}); upgrade open-loops before opening this database`);
1990
+ const floorRow = this.db.query("SELECT min_compatible_user_version FROM schema_compat WHERE id = 1").get();
1991
+ if (!floorRow) {
1992
+ throw new Error(`loops database schema version ${userVersion} is newer than this binary supports (${SCHEMA_USER_VERSION}) and carries no compatibility floor; upgrade open-loops before opening this database`);
1993
+ }
1994
+ if (SCHEMA_USER_VERSION < floorRow.min_compatible_user_version) {
1995
+ throw new Error(`loops database schema version ${userVersion} requires a binary with schema support >= ${floorRow.min_compatible_user_version} (this binary supports ${SCHEMA_USER_VERSION}); upgrade open-loops before opening this database`);
1996
+ }
1968
1997
  }
1969
1998
  const applied = new Set(this.db.query("SELECT id FROM schema_migrations").all().map((row) => row.id));
1970
1999
  for (const migration of this.migrations()) {
@@ -1975,8 +2004,10 @@ class Store {
1975
2004
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run(migration.id, nowIso());
1976
2005
  }
1977
2006
  }
1978
- if (userVersion !== SCHEMA_USER_VERSION)
2007
+ if (userVersion < SCHEMA_USER_VERSION)
1979
2008
  this.db.exec(`PRAGMA user_version = ${SCHEMA_USER_VERSION}`);
2009
+ this.db.query(`INSERT INTO schema_compat (id, min_compatible_user_version) VALUES (1, ?)
2010
+ ON CONFLICT(id) DO UPDATE SET min_compatible_user_version = MAX(min_compatible_user_version, excluded.min_compatible_user_version)`).run(BREAKING_SCHEMA_FLOOR);
1980
2011
  }
1981
2012
  migrations() {
1982
2013
  return [
@@ -2043,6 +2074,12 @@ class Store {
2043
2074
  this.addColumnIfMissing("workflow_work_items", "machine_id", "TEXT");
2044
2075
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_workflow_work_items_machine ON workflow_work_items(machine_id, status)");
2045
2076
  }
2077
+ },
2078
+ {
2079
+ id: "0011_work_item_gate_deaths",
2080
+ apply: () => {
2081
+ this.addColumnIfMissing("workflow_work_items", "gate_deaths", "INTEGER NOT NULL DEFAULT 0");
2082
+ }
2046
2083
  }
2047
2084
  ];
2048
2085
  }
@@ -3173,7 +3210,7 @@ class Store {
3173
3210
  }
3174
3211
  upsertWorkflowWorkItem(input) {
3175
3212
  const now = nowIso();
3176
- const id = genId();
3213
+ const id = input.id ?? genId();
3177
3214
  const status = input.status ?? "queued";
3178
3215
  this.db.query(`INSERT INTO workflow_work_items (id, route_key, idempotency_key, invocation_id, source_type, source_ref,
3179
3216
  subject_ref, project_key, project_group, machine_id, route_scope, priority, status, attempts, next_attempt_at, lease_expires_at,
@@ -3301,6 +3338,7 @@ class Store {
3301
3338
  const placeholders = requeueableStatuses.map(() => "?").join(",");
3302
3339
  const res = this.db.query(`UPDATE workflow_work_items
3303
3340
  SET status='queued', workflow_id=NULL, loop_id=NULL, workflow_run_id=NULL,
3341
+ ${patch.resetAttempts ? "attempts=0, gate_deaths=0," : ""}
3304
3342
  next_attempt_at=NULL, lease_expires_at=NULL, last_reason=?, updated_at=?
3305
3343
  WHERE id=? AND status IN (${placeholders})`).run(reason, now, id, ...requeueableStatuses);
3306
3344
  const item = this.getWorkflowWorkItem(id);
@@ -3310,6 +3348,46 @@ class Store {
3310
3348
  throw new Error(`workflow work item was not requeued: ${id} status=${item.status}`);
3311
3349
  return item;
3312
3350
  }
3351
+ deadLetterWorkflowWorkItem(id, patch = {}) {
3352
+ const now = nowIso();
3353
+ const reason = patch.reason?.trim() || "redispatch cap reached; dead-lettered";
3354
+ this.db.query(`UPDATE workflow_work_items
3355
+ SET status='dead_letter', next_attempt_at=NULL, lease_expires_at=NULL, last_reason=?, updated_at=?
3356
+ WHERE id=? AND status IN ('succeeded','failed','cancelled')`).run(reason, now, id);
3357
+ const item = this.getWorkflowWorkItem(id);
3358
+ if (!item)
3359
+ throw new Error(`workflow work item not found after dead-letter: ${id}`);
3360
+ return item;
3361
+ }
3362
+ demoteNonProductiveWorkItems(workflowRunId, finishedAt) {
3363
+ const kind = classifyNonProductiveStepFailure(this.listWorkflowStepRuns(workflowRunId));
3364
+ if (!kind) {
3365
+ this.db.query("UPDATE workflow_work_items SET gate_deaths=0, updated_at=? WHERE workflow_run_id=? AND status='failed' AND gate_deaths > 0").run(finishedAt, workflowRunId);
3366
+ return;
3367
+ }
3368
+ if (kind === "tempfail") {
3369
+ this.db.query(`UPDATE workflow_work_items
3370
+ SET status='queued', attempts=CASE WHEN attempts > 0 THEN attempts - 1 ELSE 0 END,
3371
+ gate_deaths=0,
3372
+ workflow_id=NULL, loop_id=NULL, workflow_run_id=NULL,
3373
+ next_attempt_at=NULL, lease_expires_at=NULL,
3374
+ last_reason='worker exited 75 (tempfail): requeued for retry; attempt refunded (does not count toward redispatch cap)',
3375
+ updated_at=?
3376
+ WHERE workflow_run_id=? AND status='failed'`).run(finishedAt, workflowRunId);
3377
+ return;
3378
+ }
3379
+ this.db.query(`UPDATE workflow_work_items
3380
+ SET attempts=CASE WHEN attempts > 0 THEN attempts - 1 ELSE 0 END,
3381
+ gate_deaths=gate_deaths + 1,
3382
+ status=CASE WHEN gate_deaths + 1 >= ${GATE_DEATH_CEILING} THEN 'dead_letter' ELSE status END,
3383
+ last_reason=CASE
3384
+ WHEN gate_deaths + 1 >= ${GATE_DEATH_CEILING}
3385
+ THEN 'gate-death ceiling reached (' || (gate_deaths + 1) || '/${GATE_DEATH_CEILING} consecutive runs died at worktree prep / triage / planner without reaching the worker): dead-lettered \u2014 the infrastructure fault needs an operator; ''loops routes requeue'' resets and retries'
3386
+ ELSE 'gate death before real work (worktree prep / triage / planner): attempt refunded (does not count toward redispatch cap); consecutive gate deaths: ' || (gate_deaths + 1) || '/${GATE_DEATH_CEILING}'
3387
+ END,
3388
+ updated_at=?
3389
+ WHERE workflow_run_id=? AND status='failed'`).run(finishedAt, workflowRunId);
3390
+ }
3313
3391
  admitWorkflowWorkItem(id, patch) {
3314
3392
  const now = nowIso();
3315
3393
  const res = this.db.query(`UPDATE workflow_work_items
@@ -4021,6 +4099,8 @@ class Store {
4021
4099
  if (changed) {
4022
4100
  const itemStatus = status === "succeeded" ? "succeeded" : status === "cancelled" ? "cancelled" : "failed";
4023
4101
  this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, itemStatus, error, finishedAt);
4102
+ if (itemStatus === "failed")
4103
+ this.demoteNonProductiveWorkItems(workflowRunId, finishedAt);
4024
4104
  this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, finishedAt);
4025
4105
  }
4026
4106
  this.db.exec("COMMIT");
@@ -4970,7 +5050,7 @@ class Store {
4970
5050
  // package.json
4971
5051
  var package_default = {
4972
5052
  name: "@hasna/loops",
4973
- version: "0.4.27",
5053
+ version: "0.4.28",
4974
5054
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4975
5055
  type: "module",
4976
5056
  main: "dist/index.js",
@@ -5080,6 +5160,7 @@ var package_default = {
5080
5160
  bun: ">=1.0.0"
5081
5161
  },
5082
5162
  dependencies: {
5163
+ "@hasna/contracts": "^0.5.1",
5083
5164
  "@hasna/events": "^0.1.9",
5084
5165
  "@modelcontextprotocol/sdk": "^1.29.0",
5085
5166
  "@openrouter/ai-sdk-provider": "2.9.1",
@@ -5089,8 +5170,7 @@ var package_default = {
5089
5170
  zod: "4.4.3"
5090
5171
  },
5091
5172
  optionalDependencies: {
5092
- "@hasna/machines": "0.0.49",
5093
- "@hasna/contracts": "^0.4.2"
5173
+ "@hasna/machines": "0.0.49"
5094
5174
  },
5095
5175
  devDependencies: {
5096
5176
  "@types/bun": "latest",
@@ -6136,11 +6216,30 @@ function remoteWorktreePrepareLines(worktree) {
6136
6216
  ' git -C "$repo" rev-parse --is-inside-work-tree >/dev/null 2>&1 || { echo "worktree repoRoot is not a git repository: $repo" >&2; return 1; }',
6137
6217
  ' mkdir -p "$(dirname "$path")" || return 1',
6138
6218
  " # Preparation chatter goes to stderr so run stdout stays the agent's.",
6139
- ' if git -C "$repo" show-ref --verify --quiet "refs/heads/$branch"; then',
6140
- ' git -C "$repo" worktree add "$path" "$branch" 1>&2 || return 1',
6141
- " else",
6142
- ' git -C "$repo" worktree add -b "$branch" "$path" HEAD 1>&2 || return 1',
6219
+ " __openloops_worktree_add() {",
6220
+ ' if git -C "$repo" show-ref --verify --quiet "refs/heads/$branch"; then',
6221
+ ' git -C "$repo" worktree add "$path" "$branch"',
6222
+ " else",
6223
+ ' git -C "$repo" worktree add -b "$branch" "$path" HEAD',
6224
+ " fi",
6225
+ " }",
6226
+ " local __ol_add_out",
6227
+ ' if __ol_add_out="$(__openloops_worktree_add 2>&1)"; then',
6228
+ ' if [ -n "$__ol_add_out" ]; then printf "%s\\n" "$__ol_add_out" >&2; fi',
6229
+ " return 0",
6143
6230
  " fi",
6231
+ ' printf "%s\\n" "$__ol_add_out" >&2',
6232
+ " # Self-heal git's own remedy for a stale 'missing but already registered",
6233
+ " # worktree': prune the dead registration (metadata-only; the directory is",
6234
+ " # already gone) and retry the add exactly once, then fail honestly.",
6235
+ ' case "$__ol_add_out" in',
6236
+ ' *"missing but already registered worktree"*)',
6237
+ ' git -C "$repo" worktree prune 1>&2 || true',
6238
+ " __openloops_worktree_add 1>&2 || return 1",
6239
+ " return 0",
6240
+ " ;;",
6241
+ " esac",
6242
+ " return 1",
6144
6243
  "}"
6145
6244
  ];
6146
6245
  }
@@ -6280,6 +6379,9 @@ async function preflightNativeAuthProfile(spec, env) {
6280
6379
  function spawnDetail(result) {
6281
6380
  return (result.stderr || result.stdout || result.error || "").toString().trim();
6282
6381
  }
6382
+ function isStaleWorktreeRegistration(detail) {
6383
+ return typeof detail === "string" && /missing but already registered worktree/i.test(detail);
6384
+ }
6283
6385
  function resolvedDirEquals(left, right) {
6284
6386
  try {
6285
6387
  return realpathSync(left) === realpathSync(right);
@@ -6381,7 +6483,12 @@ async function ensureLocalWorktree(worktree, env) {
6381
6483
  return { error: `could not create worktree parent directory: ${error instanceof Error ? error.message : String(error)}` };
6382
6484
  }
6383
6485
  const hasBranch = await git(["-C", repoRoot, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
6384
- const add = hasBranch.status === 0 ? await git(["-C", repoRoot, "worktree", "add", path, branch]) : await git(["-C", repoRoot, "worktree", "add", "-b", branch, path, "HEAD"]);
6486
+ const runAdd = () => hasBranch.status === 0 ? git(["-C", repoRoot, "worktree", "add", path, branch]) : git(["-C", repoRoot, "worktree", "add", "-b", branch, path, "HEAD"]);
6487
+ let add = await runAdd();
6488
+ if ((add.error || (add.status ?? 1) !== 0) && isStaleWorktreeRegistration(spawnDetail(add))) {
6489
+ await git(["-C", repoRoot, "worktree", "prune"]);
6490
+ add = await runAdd();
6491
+ }
6385
6492
  if (add.error || (add.status ?? 1) !== 0) {
6386
6493
  const detail = spawnDetail(add);
6387
6494
  return { error: `git worktree add failed for ${path}${detail ? `: ${detail}` : ""}` };
@@ -7649,6 +7756,7 @@ function runDoctor(store) {
7649
7756
  import { createHash as createHash4 } from "crypto";
7650
7757
  import { hostname as hostname3 } from "os";
7651
7758
  var LOOPS_MIGRATION_SCHEMA = "open-loops.migration/v1";
7759
+ var LOOPS_SELF_HOSTED_PUSH_MANIFEST_SCHEMA = "open-loops.self-hosted-push-manifest/v1";
7652
7760
  function canonicalize(value) {
7653
7761
  if (Array.isArray(value))
7654
7762
  return value.map((entry) => canonicalize(entry));
@@ -7809,6 +7917,20 @@ function compareResource(current, incoming, opts) {
7809
7917
  currentHash
7810
7918
  };
7811
7919
  }
7920
+ function remoteRepresentationRow(current, incoming, opts) {
7921
+ const incomingHash = migrationHash(incoming);
7922
+ if (!current)
7923
+ return { resource: opts.resource, id: opts.id, name: opts.name, action: "insert", incomingHash };
7924
+ return {
7925
+ resource: opts.resource,
7926
+ id: opts.id,
7927
+ name: opts.name,
7928
+ action: "skip",
7929
+ reason: "remote id is represented; self-hosted list payload is public/redacted, so exact byte comparison is reserved for import apply",
7930
+ incomingHash,
7931
+ currentHash: migrationHash(current)
7932
+ };
7933
+ }
7812
7934
  function buildImportMigrationPlan(store, bundle, opts = {}) {
7813
7935
  assertMigrationBundleIntegrity(bundle);
7814
7936
  const includeRuns = opts.includeRuns ?? true;
@@ -8000,39 +8122,184 @@ async function requestJson(fetchImpl, config, path, init = {}) {
8000
8122
  }
8001
8123
  });
8002
8124
  const payload = await response.json().catch(() => ({}));
8003
- if (!response.ok)
8004
- throw new Error(typeof payload.error === "string" ? payload.error : `loops-api request failed: ${response.status}`);
8125
+ if (!response.ok) {
8126
+ throw Object.assign(new Error(typeof payload.error === "string" ? payload.error : `loops-api request failed: ${response.status}`), { status: response.status, payload });
8127
+ }
8005
8128
  return payload;
8006
8129
  }
8130
+ async function fetchPagedRows(fetchImpl, config, path, key, opts) {
8131
+ const rows = [];
8132
+ const limit = 1000;
8133
+ for (let offset = 0;; offset += limit) {
8134
+ try {
8135
+ const separator = path.includes("?") ? "&" : "?";
8136
+ const payload = await requestJson(fetchImpl, config, `${path}${separator}limit=${limit}&offset=${offset}`);
8137
+ const page = Array.isArray(payload[key]) ? payload[key] : [];
8138
+ rows.push(...page);
8139
+ if (page.length < limit)
8140
+ break;
8141
+ } catch (error) {
8142
+ if (error.status === 404) {
8143
+ opts.unsupported.push(path);
8144
+ opts.warnings.push(`self-hosted control plane does not expose ${path}; exact ${key} comparison is unavailable`);
8145
+ break;
8146
+ }
8147
+ throw error;
8148
+ }
8149
+ }
8150
+ return rows;
8151
+ }
8152
+ async function fetchOptionalCount(fetchImpl, config, path, opts) {
8153
+ try {
8154
+ const payload = await requestJson(fetchImpl, config, path);
8155
+ return typeof payload.count === "number" ? payload.count : undefined;
8156
+ } catch (error) {
8157
+ if (error.status === 404) {
8158
+ opts.unsupported.push(path);
8159
+ opts.warnings.push(`self-hosted control plane does not expose ${path}; count comparison is unavailable`);
8160
+ return;
8161
+ }
8162
+ throw error;
8163
+ }
8164
+ }
8007
8165
  async function fetchRemotePreview(opts) {
8008
8166
  const config = resolveApiConfig(opts);
8009
8167
  const warnings = [];
8168
+ const unsupported = [];
8010
8169
  if (!config.apiUrl) {
8011
8170
  warnings.push("LOOPS_API_URL or HASNA_LOOPS_API_URL is required to inspect a self-hosted control plane");
8012
- return { loops: [], runs: [], warnings };
8171
+ return { workflows: [], loops: [], runs: [], counts: {}, unsupported, warnings };
8013
8172
  }
8014
8173
  if (!isLocalApiUrl(config.apiUrl) && !config.token) {
8015
8174
  warnings.push("non-local self-hosted APIs require LOOPS_API_TOKEN or HASNA_LOOPS_API_TOKEN");
8016
- return { loops: [], runs: [], warnings };
8175
+ return { workflows: [], loops: [], runs: [], counts: {}, unsupported, warnings };
8017
8176
  }
8018
8177
  const fetchImpl = opts.fetchImpl ?? fetch;
8019
- const loopsPayload = await requestJson(fetchImpl, { apiUrl: config.apiUrl, token: config.token }, "/v1/loops?includeArchived=true&limit=1000");
8020
- const runsPayload = opts.includeRuns === false ? { runs: [] } : await requestJson(fetchImpl, { apiUrl: config.apiUrl, token: config.token }, "/v1/runs?limit=1000");
8178
+ const api = { apiUrl: config.apiUrl, token: config.token };
8179
+ const requestOpts = { unsupported, warnings };
8180
+ const workflows = await fetchPagedRows(fetchImpl, api, "/v1/workflows", "workflows", requestOpts);
8181
+ const loops = await fetchPagedRows(fetchImpl, api, "/v1/loops?includeArchived=true", "loops", requestOpts);
8182
+ const runs = opts.includeRuns === false ? [] : await fetchPagedRows(fetchImpl, api, "/v1/runs?showOutput=true", "runs", requestOpts);
8021
8183
  return {
8022
- loops: Array.isArray(loopsPayload.loops) ? loopsPayload.loops : [],
8023
- runs: Array.isArray(runsPayload.runs) ? runsPayload.runs : [],
8184
+ workflows,
8185
+ loops,
8186
+ runs,
8187
+ counts: {
8188
+ workflows: await fetchOptionalCount(fetchImpl, api, "/v1/workflows/count", requestOpts),
8189
+ loops: await fetchOptionalCount(fetchImpl, api, "/v1/loops/count?includeArchived=true", requestOpts),
8190
+ runs: opts.includeRuns === false ? undefined : await fetchOptionalCount(fetchImpl, api, "/v1/runs/count", requestOpts)
8191
+ },
8192
+ unsupported,
8024
8193
  warnings
8025
8194
  };
8026
8195
  }
8196
+ function disabledWorkflowForSelfHostedImport(workflow) {
8197
+ return { ...workflow, status: "archived" };
8198
+ }
8199
+ function pausedLoopForSelfHostedImport(loop) {
8200
+ return {
8201
+ ...loop,
8202
+ status: "paused",
8203
+ nextRunAt: undefined,
8204
+ retryScheduledFor: undefined
8205
+ };
8206
+ }
8207
+ function selfHostedDefinitionBundle(bundle) {
8208
+ const body = {
8209
+ ...bundle,
8210
+ data: {
8211
+ workflows: bundle.data.workflows.map(disabledWorkflowForSelfHostedImport),
8212
+ loops: bundle.data.loops.map(pausedLoopForSelfHostedImport),
8213
+ runs: bundle.data.runs
8214
+ }
8215
+ };
8216
+ const { hash: _hash, ...hashBody } = body;
8217
+ return { ...body, hash: migrationHash(hashBody) };
8218
+ }
8219
+ function typedRows(rows) {
8220
+ return rows.filter((row) => Boolean(row && typeof row === "object" && typeof row.id === "string"));
8221
+ }
8222
+ function rowsById(rows) {
8223
+ return new Map(rows.map((row) => [row.id, row]));
8224
+ }
8225
+ function rowsByName(rows) {
8226
+ return new Map(rows.filter((row) => typeof row.name === "string").map((row) => [String(row.name), row]));
8227
+ }
8228
+ function rowIds(rows, resource, action) {
8229
+ return rows.filter((row) => row.resource === resource && row.action === action).map((row) => row.id);
8230
+ }
8231
+ function existingRowIds(rows, resource) {
8232
+ return rows.filter((row) => row.resource === resource && (row.action === "skip" || row.action === "update")).map((row) => row.id);
8233
+ }
8234
+ function buildSelfHostedManifest(args) {
8235
+ return {
8236
+ schema: LOOPS_SELF_HOSTED_PUSH_MANIFEST_SCHEMA,
8237
+ generatedAt: new Date().toISOString(),
8238
+ apiUrl: args.apiUrl,
8239
+ dryRun: args.dryRun,
8240
+ replace: args.replace,
8241
+ includeRuns: args.includeRuns,
8242
+ safety: {
8243
+ forcedLoopStatus: "paused",
8244
+ clearedLoopRunPointers: true,
8245
+ forcedWorkflowStatus: "archived",
8246
+ resumesLoops: false
8247
+ },
8248
+ counts: {
8249
+ source: args.bundle.counts,
8250
+ remoteBefore: args.remote.counts,
8251
+ remoteAfter: args.remoteAfter,
8252
+ plan: args.plan.summary,
8253
+ applied: args.applied,
8254
+ skipped: args.skipped,
8255
+ requests: args.requests
8256
+ },
8257
+ missingIds: {
8258
+ workflows: rowIds(args.plan.rows, "workflow", "insert"),
8259
+ loops: rowIds(args.plan.rows, "loop", "insert"),
8260
+ runs: rowIds(args.plan.rows, "run", "insert")
8261
+ },
8262
+ existingIds: {
8263
+ workflows: existingRowIds(args.plan.rows, "workflow"),
8264
+ loops: existingRowIds(args.plan.rows, "loop"),
8265
+ runs: existingRowIds(args.plan.rows, "run")
8266
+ },
8267
+ unsafe: {
8268
+ blocked: args.plan.rows.filter((row) => row.action === "blocked"),
8269
+ conflicts: args.plan.rows.filter((row) => row.action === "conflict"),
8270
+ unsupported: args.remote.unsupported,
8271
+ warnings: args.plan.warnings
8272
+ },
8273
+ rollback: {
8274
+ notes: [
8275
+ "self-hosted push imports through id-preserving upserts; no local loops are resumed or mutated",
8276
+ "imported loops are forced to paused with nextRunAt/retryScheduledFor cleared before upload",
8277
+ "imported workflows are forced to archived before upload",
8278
+ "rollback is manual: archive or delete imported ids from the self-hosted control plane after reviewing this manifest"
8279
+ ],
8280
+ commands: [
8281
+ "loops self-hosted push --dry-run --no-runs --manifest-file <post-rollback-check.json>"
8282
+ ]
8283
+ }
8284
+ };
8285
+ }
8027
8286
  async function buildSelfHostedMigrationPlan(store, opts) {
8028
- const bundle = exportLoopsMigrationBundle(store, { includeRuns: opts.includeRuns ?? true });
8287
+ const bundle = selfHostedDefinitionBundle(exportLoopsMigrationBundle(store, { includeRuns: opts.includeRuns ?? true }));
8029
8288
  const remote = await fetchRemotePreview(opts);
8030
8289
  const rows = [...bundle.blockers.map((row) => ({ ...row, action: "blocked" }))];
8031
8290
  const warnings = [
8032
8291
  ...bundle.warnings,
8033
8292
  ...remote.warnings,
8034
- "self-hosted remote apply is preview-only until the control plane exposes id-preserving workflow/loop/run import endpoints"
8293
+ "self-hosted push forces imported loops to paused and imported workflows to archived until activation is explicitly approved"
8035
8294
  ];
8295
+ const config = resolveApiConfig(opts);
8296
+ const apiUrl = config.apiUrl;
8297
+ if (!apiUrl) {
8298
+ pushBlocker(rows, "remote", "self-hosted-api-url", "LOOPS_API_URL or HASNA_LOOPS_API_URL is required to compare a self-hosted control plane");
8299
+ } else if (!isLocalApiUrl(apiUrl) && !config.token) {
8300
+ pushBlocker(rows, "remote", "self-hosted-api-token", "non-local self-hosted APIs require LOOPS_API_TOKEN or HASNA_LOOPS_API_TOKEN");
8301
+ }
8302
+ const replace = opts.replace ?? false;
8036
8303
  if (opts.operation === "self-hosted-pull") {
8037
8304
  for (const entry of remote.loops) {
8038
8305
  const value = entry && typeof entry === "object" ? entry : {};
@@ -8062,58 +8329,107 @@ async function buildSelfHostedMigrationPlan(store, opts) {
8062
8329
  schema: LOOPS_MIGRATION_SCHEMA,
8063
8330
  operation: opts.operation,
8064
8331
  dryRun: true,
8065
- replace: false,
8332
+ replace,
8066
8333
  importable: false,
8067
8334
  rows,
8068
8335
  warnings
8069
8336
  });
8070
- return { ...plan2, importable: false };
8337
+ return {
8338
+ ...plan2,
8339
+ importable: false,
8340
+ manifest: buildSelfHostedManifest({
8341
+ apiUrl,
8342
+ dryRun: true,
8343
+ replace,
8344
+ includeRuns: opts.includeRuns ?? true,
8345
+ bundle,
8346
+ remote,
8347
+ plan: plan2
8348
+ })
8349
+ };
8071
8350
  }
8072
- 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]));
8351
+ const remoteWorkflows = typedRows(remote.workflows);
8352
+ const remoteLoops = typedRows(remote.loops);
8353
+ const remoteRuns = typedRows(remote.runs);
8354
+ const remoteWorkflowsById = rowsById(remoteWorkflows);
8355
+ const remoteLoopsById = rowsById(remoteLoops);
8356
+ const remoteRunsById = rowsById(remoteRuns);
8357
+ const remoteLoopsByName = rowsByName(remoteLoops);
8073
8358
  for (const workflow of bundle.data.workflows) {
8074
- rows.push({
8359
+ if (remote.unsupported.some((entry) => entry.startsWith("/v1/workflows"))) {
8360
+ rows.push({
8361
+ resource: "workflow",
8362
+ id: workflow.id,
8363
+ name: workflow.name,
8364
+ action: "blocked",
8365
+ reason: "self-hosted API does not expose workflow list/count endpoints for safe comparison",
8366
+ incomingHash: migrationHash(workflow)
8367
+ });
8368
+ continue;
8369
+ }
8370
+ rows.push(remoteRepresentationRow(remoteWorkflowsById.get(workflow.id), workflow, {
8075
8371
  resource: "workflow",
8076
8372
  id: workflow.id,
8077
- name: workflow.name,
8078
- action: "blocked",
8079
- reason: "self-hosted API does not expose id-preserving workflow import/list endpoints yet",
8080
- incomingHash: migrationHash(workflow)
8081
- });
8373
+ name: workflow.name
8374
+ }));
8082
8375
  }
8083
8376
  for (const loop of bundle.data.loops) {
8084
- const remoteLoop = remoteLoopsByName.get(loop.name);
8085
- rows.push({
8086
- resource: "loop",
8087
- id: loop.id,
8088
- name: loop.name,
8089
- action: "blocked",
8090
- 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",
8091
- incomingHash: migrationHash(loop),
8092
- currentHash: remoteLoop ? migrationHash(remoteLoop) : undefined
8093
- });
8377
+ const remoteLoop = remoteLoopsById.get(loop.id);
8378
+ if (!remoteLoop) {
8379
+ const nameCollision = remoteLoopsByName.get(loop.name);
8380
+ if (nameCollision && nameCollision.id !== loop.id) {
8381
+ rows.push({
8382
+ resource: "loop",
8383
+ id: loop.id,
8384
+ name: loop.name,
8385
+ action: "insert",
8386
+ reason: `remote loop name exists under different id ${String(nameCollision.id ?? "unknown id")}; id-preserving import will add the missing source id paused`,
8387
+ incomingHash: migrationHash(loop),
8388
+ currentHash: migrationHash(nameCollision)
8389
+ });
8390
+ continue;
8391
+ }
8392
+ }
8393
+ rows.push(remoteRepresentationRow(remoteLoop, loop, { resource: "loop", id: loop.id, name: loop.name }));
8094
8394
  }
8095
8395
  if (opts.includeRuns ?? true) {
8096
8396
  for (const run of bundle.data.runs) {
8097
- rows.push({
8098
- resource: "run",
8099
- id: run.id,
8100
- name: run.loopName,
8101
- action: "blocked",
8102
- reason: "self-hosted API does not expose run-history import endpoints; remote runners should create new run rows",
8103
- incomingHash: migrationHash(run)
8104
- });
8397
+ if (run.status === "running") {
8398
+ rows.push({
8399
+ resource: "run",
8400
+ id: run.id,
8401
+ name: run.loopName,
8402
+ action: "blocked",
8403
+ reason: "running rows carry volatile lease/process ownership and are skipped by self-hosted import",
8404
+ incomingHash: migrationHash(run)
8405
+ });
8406
+ continue;
8407
+ }
8408
+ rows.push(remoteRepresentationRow(remoteRunsById.get(run.id), run, { resource: "run", id: run.id, name: run.loopName }));
8105
8409
  }
8106
8410
  }
8107
- const plan = finalizePlan({
8411
+ let plan = finalizePlan({
8108
8412
  schema: LOOPS_MIGRATION_SCHEMA,
8109
8413
  operation: opts.operation,
8110
8414
  dryRun: true,
8111
- replace: false,
8112
- importable: false,
8415
+ replace,
8416
+ importable: bundle.importable && rows.every((row) => row.action !== "blocked" && row.action !== "conflict"),
8113
8417
  rows,
8114
8418
  warnings
8115
8419
  });
8116
- return { ...plan, importable: false };
8420
+ plan = { ...plan, importable: plan.summary.blocked === 0 && plan.summary.conflict === 0 };
8421
+ return {
8422
+ ...plan,
8423
+ manifest: buildSelfHostedManifest({
8424
+ apiUrl,
8425
+ dryRun: true,
8426
+ replace,
8427
+ includeRuns: opts.includeRuns ?? true,
8428
+ bundle,
8429
+ remote,
8430
+ plan
8431
+ })
8432
+ };
8117
8433
  }
8118
8434
  async function postImportBatch(fetchImpl, config, payload) {
8119
8435
  const body = await requestJson(fetchImpl, config, "/v1/import", { method: "POST", body: JSON.stringify(payload) });
@@ -8134,23 +8450,29 @@ async function applySelfHostedPush(store, opts) {
8134
8450
  const fetchImpl = opts.fetchImpl ?? fetch;
8135
8451
  const includeRuns = opts.includeRuns ?? true;
8136
8452
  const replace = opts.replace ?? false;
8453
+ const plan = await buildSelfHostedMigrationPlan(store, { ...opts, operation: "self-hosted-push", includeRuns, replace });
8454
+ if (plan.summary.blocked > 0 || plan.summary.conflict > 0 || !plan.importable) {
8455
+ throw new ValidationError(`self-hosted push is not safe to apply: blocked=${plan.summary.blocked} conflict=${plan.summary.conflict}`);
8456
+ }
8137
8457
  const batchRows = Math.max(1, opts.batchRows ?? 200);
8138
8458
  const runBatchBytes = Math.max(64 * 1024, opts.runBatchBytes ?? 4 * 1024 * 1024);
8139
8459
  const applied = { workflows: 0, loops: 0, runs: 0 };
8140
8460
  const skipped = { runningRuns: 0, orphanRuns: 0 };
8141
8461
  let requests = 0;
8142
8462
  const base = store.exportMigrationRows({ includeRuns: false });
8463
+ const workflows = base.workflows.map(disabledWorkflowForSelfHostedImport);
8464
+ const loops = base.loops.map(pausedLoopForSelfHostedImport);
8143
8465
  const loopIds = new Set(base.loops.map((loop) => loop.id));
8144
- for (let i = 0;i < base.workflows.length; i += batchRows) {
8145
- const batch = base.workflows.slice(i, i + batchRows);
8146
- const result = await postImportBatch(fetchImpl, config, { workflows: batch, replace });
8466
+ for (let i = 0;i < workflows.length; i += batchRows) {
8467
+ const batch = workflows.slice(i, i + batchRows);
8468
+ const result = await postImportBatch(fetchImpl, config, { workflows: batch, replace, preserveWorkflowActivation: false, preserveLoopScheduling: false });
8147
8469
  applied.workflows += result.imported.workflows;
8148
8470
  requests += 1;
8149
8471
  opts.onProgress?.({ phase: "workflows", sent: applied.workflows, requests });
8150
8472
  }
8151
- for (let i = 0;i < base.loops.length; i += batchRows) {
8152
- const batch = base.loops.slice(i, i + batchRows);
8153
- const result = await postImportBatch(fetchImpl, config, { loops: batch, replace });
8473
+ for (let i = 0;i < loops.length; i += batchRows) {
8474
+ const batch = loops.slice(i, i + batchRows);
8475
+ const result = await postImportBatch(fetchImpl, config, { loops: batch, replace, preserveLoopScheduling: false });
8154
8476
  applied.loops += result.imported.loops;
8155
8477
  requests += 1;
8156
8478
  opts.onProgress?.({ phase: "loops", sent: applied.loops, requests });
@@ -8162,7 +8484,7 @@ async function applySelfHostedPush(store, opts) {
8162
8484
  const flush = async () => {
8163
8485
  if (pending.length === 0)
8164
8486
  return;
8165
- const result = await postImportBatch(fetchImpl, config, { runs: pending, replace });
8487
+ const result = await postImportBatch(fetchImpl, config, { runs: pending, replace, preserveLoopScheduling: false });
8166
8488
  applied.runs += result.imported.runs;
8167
8489
  skipped.runningRuns += result.skippedRunning;
8168
8490
  requests += 1;
@@ -8192,7 +8514,36 @@ async function applySelfHostedPush(store, opts) {
8192
8514
  }
8193
8515
  await flush();
8194
8516
  }
8195
- return { ok: true, apiUrl: config.apiUrl, applied, skipped, requests };
8517
+ const remoteAfter = await fetchRemotePreview({ ...opts, operation: "self-hosted-push", includeRuns: false });
8518
+ const bundle = selfHostedDefinitionBundle(exportLoopsMigrationBundle(store, { includeRuns }));
8519
+ const remoteBefore = plan.manifest ? {
8520
+ workflows: [],
8521
+ loops: [],
8522
+ runs: [],
8523
+ counts: plan.manifest.counts.remoteBefore,
8524
+ unsupported: plan.manifest.unsafe.unsupported,
8525
+ warnings: []
8526
+ } : await fetchRemotePreview({ ...opts, operation: "self-hosted-push", includeRuns });
8527
+ return {
8528
+ ok: true,
8529
+ apiUrl: config.apiUrl,
8530
+ applied,
8531
+ skipped,
8532
+ requests,
8533
+ manifest: buildSelfHostedManifest({
8534
+ apiUrl: config.apiUrl,
8535
+ dryRun: false,
8536
+ replace,
8537
+ includeRuns,
8538
+ bundle,
8539
+ remote: remoteBefore,
8540
+ plan,
8541
+ applied,
8542
+ skipped,
8543
+ requests,
8544
+ remoteAfter: remoteAfter.counts
8545
+ })
8546
+ };
8196
8547
  }
8197
8548
  async function registerSelfHostedRunner(opts) {
8198
8549
  const config = resolveApiConfig(opts);