@hasna/loops 0.4.26 → 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/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.26",
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",
@@ -5655,6 +5735,9 @@ CREATE INDEX IF NOT EXISTS idx_run_receipts_knowledge_ids ON run_receipts USING
5655
5735
  migration("0006_work_item_machine_id", `
5656
5736
  ALTER TABLE workflow_work_items ADD COLUMN IF NOT EXISTS machine_id TEXT;
5657
5737
  CREATE INDEX IF NOT EXISTS idx_workflow_work_items_machine ON workflow_work_items(machine_id, status);
5738
+ `),
5739
+ migration("0007_work_item_gate_deaths", `
5740
+ ALTER TABLE workflow_work_items ADD COLUMN IF NOT EXISTS gate_deaths INTEGER NOT NULL DEFAULT 0;
5658
5741
  `)
5659
5742
  ]);
5660
5743
 
@@ -6689,11 +6772,30 @@ function remoteWorktreePrepareLines(worktree) {
6689
6772
  ' 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; }',
6690
6773
  ' mkdir -p "$(dirname "$path")" || return 1',
6691
6774
  " # Preparation chatter goes to stderr so run stdout stays the agent's.",
6692
- ' if git -C "$repo" show-ref --verify --quiet "refs/heads/$branch"; then',
6693
- ' git -C "$repo" worktree add "$path" "$branch" 1>&2 || return 1',
6694
- " else",
6695
- ' git -C "$repo" worktree add -b "$branch" "$path" HEAD 1>&2 || return 1',
6775
+ " __openloops_worktree_add() {",
6776
+ ' if git -C "$repo" show-ref --verify --quiet "refs/heads/$branch"; then',
6777
+ ' git -C "$repo" worktree add "$path" "$branch"',
6778
+ " else",
6779
+ ' git -C "$repo" worktree add -b "$branch" "$path" HEAD',
6780
+ " fi",
6781
+ " }",
6782
+ " local __ol_add_out",
6783
+ ' if __ol_add_out="$(__openloops_worktree_add 2>&1)"; then',
6784
+ ' if [ -n "$__ol_add_out" ]; then printf "%s\\n" "$__ol_add_out" >&2; fi',
6785
+ " return 0",
6696
6786
  " fi",
6787
+ ' printf "%s\\n" "$__ol_add_out" >&2',
6788
+ " # Self-heal git's own remedy for a stale 'missing but already registered",
6789
+ " # worktree': prune the dead registration (metadata-only; the directory is",
6790
+ " # already gone) and retry the add exactly once, then fail honestly.",
6791
+ ' case "$__ol_add_out" in',
6792
+ ' *"missing but already registered worktree"*)',
6793
+ ' git -C "$repo" worktree prune 1>&2 || true',
6794
+ " __openloops_worktree_add 1>&2 || return 1",
6795
+ " return 0",
6796
+ " ;;",
6797
+ " esac",
6798
+ " return 1",
6697
6799
  "}"
6698
6800
  ];
6699
6801
  }
@@ -6833,6 +6935,9 @@ async function preflightNativeAuthProfile(spec, env) {
6833
6935
  function spawnDetail(result) {
6834
6936
  return (result.stderr || result.stdout || result.error || "").toString().trim();
6835
6937
  }
6938
+ function isStaleWorktreeRegistration(detail) {
6939
+ return typeof detail === "string" && /missing but already registered worktree/i.test(detail);
6940
+ }
6836
6941
  function resolvedDirEquals(left, right) {
6837
6942
  try {
6838
6943
  return realpathSync(left) === realpathSync(right);
@@ -6934,7 +7039,12 @@ async function ensureLocalWorktree(worktree, env) {
6934
7039
  return { error: `could not create worktree parent directory: ${error instanceof Error ? error.message : String(error)}` };
6935
7040
  }
6936
7041
  const hasBranch = await git(["-C", repoRoot, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
6937
- const add = hasBranch.status === 0 ? await git(["-C", repoRoot, "worktree", "add", path, branch]) : await git(["-C", repoRoot, "worktree", "add", "-b", branch, path, "HEAD"]);
7042
+ const runAdd = () => hasBranch.status === 0 ? git(["-C", repoRoot, "worktree", "add", path, branch]) : git(["-C", repoRoot, "worktree", "add", "-b", branch, path, "HEAD"]);
7043
+ let add = await runAdd();
7044
+ if ((add.error || (add.status ?? 1) !== 0) && isStaleWorktreeRegistration(spawnDetail(add))) {
7045
+ await git(["-C", repoRoot, "worktree", "prune"]);
7046
+ add = await runAdd();
7047
+ }
6938
7048
  if (add.error || (add.status ?? 1) !== 0) {
6939
7049
  const detail = spawnDetail(add);
6940
7050
  return { error: `git worktree add failed for ${path}${detail ? `: ${detail}` : ""}` };
@@ -11182,6 +11292,7 @@ if (import.meta.main) {
11182
11292
  import { createHash as createHash5 } from "crypto";
11183
11293
  import { hostname as hostname3 } from "os";
11184
11294
  var LOOPS_MIGRATION_SCHEMA = "open-loops.migration/v1";
11295
+ var LOOPS_SELF_HOSTED_PUSH_MANIFEST_SCHEMA = "open-loops.self-hosted-push-manifest/v1";
11185
11296
  function canonicalize(value) {
11186
11297
  if (Array.isArray(value))
11187
11298
  return value.map((entry) => canonicalize(entry));
@@ -11342,6 +11453,20 @@ function compareResource(current, incoming, opts) {
11342
11453
  currentHash
11343
11454
  };
11344
11455
  }
11456
+ function remoteRepresentationRow(current, incoming, opts) {
11457
+ const incomingHash = migrationHash(incoming);
11458
+ if (!current)
11459
+ return { resource: opts.resource, id: opts.id, name: opts.name, action: "insert", incomingHash };
11460
+ return {
11461
+ resource: opts.resource,
11462
+ id: opts.id,
11463
+ name: opts.name,
11464
+ action: "skip",
11465
+ reason: "remote id is represented; self-hosted list payload is public/redacted, so exact byte comparison is reserved for import apply",
11466
+ incomingHash,
11467
+ currentHash: migrationHash(current)
11468
+ };
11469
+ }
11345
11470
  function buildImportMigrationPlan(store, bundle, opts = {}) {
11346
11471
  assertMigrationBundleIntegrity(bundle);
11347
11472
  const includeRuns = opts.includeRuns ?? true;
@@ -11533,39 +11658,184 @@ async function requestJson(fetchImpl, config, path, init = {}) {
11533
11658
  }
11534
11659
  });
11535
11660
  const payload = await response.json().catch(() => ({}));
11536
- if (!response.ok)
11537
- throw new Error(typeof payload.error === "string" ? payload.error : `loops-api request failed: ${response.status}`);
11661
+ if (!response.ok) {
11662
+ throw Object.assign(new Error(typeof payload.error === "string" ? payload.error : `loops-api request failed: ${response.status}`), { status: response.status, payload });
11663
+ }
11538
11664
  return payload;
11539
11665
  }
11666
+ async function fetchPagedRows(fetchImpl, config, path, key, opts) {
11667
+ const rows = [];
11668
+ const limit = 1000;
11669
+ for (let offset = 0;; offset += limit) {
11670
+ try {
11671
+ const separator = path.includes("?") ? "&" : "?";
11672
+ const payload = await requestJson(fetchImpl, config, `${path}${separator}limit=${limit}&offset=${offset}`);
11673
+ const page = Array.isArray(payload[key]) ? payload[key] : [];
11674
+ rows.push(...page);
11675
+ if (page.length < limit)
11676
+ break;
11677
+ } catch (error) {
11678
+ if (error.status === 404) {
11679
+ opts.unsupported.push(path);
11680
+ opts.warnings.push(`self-hosted control plane does not expose ${path}; exact ${key} comparison is unavailable`);
11681
+ break;
11682
+ }
11683
+ throw error;
11684
+ }
11685
+ }
11686
+ return rows;
11687
+ }
11688
+ async function fetchOptionalCount(fetchImpl, config, path, opts) {
11689
+ try {
11690
+ const payload = await requestJson(fetchImpl, config, path);
11691
+ return typeof payload.count === "number" ? payload.count : undefined;
11692
+ } catch (error) {
11693
+ if (error.status === 404) {
11694
+ opts.unsupported.push(path);
11695
+ opts.warnings.push(`self-hosted control plane does not expose ${path}; count comparison is unavailable`);
11696
+ return;
11697
+ }
11698
+ throw error;
11699
+ }
11700
+ }
11540
11701
  async function fetchRemotePreview(opts) {
11541
11702
  const config = resolveApiConfig(opts);
11542
11703
  const warnings = [];
11704
+ const unsupported = [];
11543
11705
  if (!config.apiUrl) {
11544
11706
  warnings.push("LOOPS_API_URL or HASNA_LOOPS_API_URL is required to inspect a self-hosted control plane");
11545
- return { loops: [], runs: [], warnings };
11707
+ return { workflows: [], loops: [], runs: [], counts: {}, unsupported, warnings };
11546
11708
  }
11547
11709
  if (!isLocalApiUrl(config.apiUrl) && !config.token) {
11548
11710
  warnings.push("non-local self-hosted APIs require LOOPS_API_TOKEN or HASNA_LOOPS_API_TOKEN");
11549
- return { loops: [], runs: [], warnings };
11711
+ return { workflows: [], loops: [], runs: [], counts: {}, unsupported, warnings };
11550
11712
  }
11551
11713
  const fetchImpl = opts.fetchImpl ?? fetch;
11552
- const loopsPayload = await requestJson(fetchImpl, { apiUrl: config.apiUrl, token: config.token }, "/v1/loops?includeArchived=true&limit=1000");
11553
- const runsPayload = opts.includeRuns === false ? { runs: [] } : await requestJson(fetchImpl, { apiUrl: config.apiUrl, token: config.token }, "/v1/runs?limit=1000");
11714
+ const api = { apiUrl: config.apiUrl, token: config.token };
11715
+ const requestOpts = { unsupported, warnings };
11716
+ const workflows = await fetchPagedRows(fetchImpl, api, "/v1/workflows", "workflows", requestOpts);
11717
+ const loops = await fetchPagedRows(fetchImpl, api, "/v1/loops?includeArchived=true", "loops", requestOpts);
11718
+ const runs = opts.includeRuns === false ? [] : await fetchPagedRows(fetchImpl, api, "/v1/runs?showOutput=true", "runs", requestOpts);
11554
11719
  return {
11555
- loops: Array.isArray(loopsPayload.loops) ? loopsPayload.loops : [],
11556
- runs: Array.isArray(runsPayload.runs) ? runsPayload.runs : [],
11720
+ workflows,
11721
+ loops,
11722
+ runs,
11723
+ counts: {
11724
+ workflows: await fetchOptionalCount(fetchImpl, api, "/v1/workflows/count", requestOpts),
11725
+ loops: await fetchOptionalCount(fetchImpl, api, "/v1/loops/count?includeArchived=true", requestOpts),
11726
+ runs: opts.includeRuns === false ? undefined : await fetchOptionalCount(fetchImpl, api, "/v1/runs/count", requestOpts)
11727
+ },
11728
+ unsupported,
11557
11729
  warnings
11558
11730
  };
11559
11731
  }
11732
+ function disabledWorkflowForSelfHostedImport(workflow) {
11733
+ return { ...workflow, status: "archived" };
11734
+ }
11735
+ function pausedLoopForSelfHostedImport(loop) {
11736
+ return {
11737
+ ...loop,
11738
+ status: "paused",
11739
+ nextRunAt: undefined,
11740
+ retryScheduledFor: undefined
11741
+ };
11742
+ }
11743
+ function selfHostedDefinitionBundle(bundle) {
11744
+ const body = {
11745
+ ...bundle,
11746
+ data: {
11747
+ workflows: bundle.data.workflows.map(disabledWorkflowForSelfHostedImport),
11748
+ loops: bundle.data.loops.map(pausedLoopForSelfHostedImport),
11749
+ runs: bundle.data.runs
11750
+ }
11751
+ };
11752
+ const { hash: _hash, ...hashBody } = body;
11753
+ return { ...body, hash: migrationHash(hashBody) };
11754
+ }
11755
+ function typedRows(rows) {
11756
+ return rows.filter((row) => Boolean(row && typeof row === "object" && typeof row.id === "string"));
11757
+ }
11758
+ function rowsById(rows) {
11759
+ return new Map(rows.map((row) => [row.id, row]));
11760
+ }
11761
+ function rowsByName(rows) {
11762
+ return new Map(rows.filter((row) => typeof row.name === "string").map((row) => [String(row.name), row]));
11763
+ }
11764
+ function rowIds(rows, resource, action) {
11765
+ return rows.filter((row) => row.resource === resource && row.action === action).map((row) => row.id);
11766
+ }
11767
+ function existingRowIds(rows, resource) {
11768
+ return rows.filter((row) => row.resource === resource && (row.action === "skip" || row.action === "update")).map((row) => row.id);
11769
+ }
11770
+ function buildSelfHostedManifest(args) {
11771
+ return {
11772
+ schema: LOOPS_SELF_HOSTED_PUSH_MANIFEST_SCHEMA,
11773
+ generatedAt: new Date().toISOString(),
11774
+ apiUrl: args.apiUrl,
11775
+ dryRun: args.dryRun,
11776
+ replace: args.replace,
11777
+ includeRuns: args.includeRuns,
11778
+ safety: {
11779
+ forcedLoopStatus: "paused",
11780
+ clearedLoopRunPointers: true,
11781
+ forcedWorkflowStatus: "archived",
11782
+ resumesLoops: false
11783
+ },
11784
+ counts: {
11785
+ source: args.bundle.counts,
11786
+ remoteBefore: args.remote.counts,
11787
+ remoteAfter: args.remoteAfter,
11788
+ plan: args.plan.summary,
11789
+ applied: args.applied,
11790
+ skipped: args.skipped,
11791
+ requests: args.requests
11792
+ },
11793
+ missingIds: {
11794
+ workflows: rowIds(args.plan.rows, "workflow", "insert"),
11795
+ loops: rowIds(args.plan.rows, "loop", "insert"),
11796
+ runs: rowIds(args.plan.rows, "run", "insert")
11797
+ },
11798
+ existingIds: {
11799
+ workflows: existingRowIds(args.plan.rows, "workflow"),
11800
+ loops: existingRowIds(args.plan.rows, "loop"),
11801
+ runs: existingRowIds(args.plan.rows, "run")
11802
+ },
11803
+ unsafe: {
11804
+ blocked: args.plan.rows.filter((row) => row.action === "blocked"),
11805
+ conflicts: args.plan.rows.filter((row) => row.action === "conflict"),
11806
+ unsupported: args.remote.unsupported,
11807
+ warnings: args.plan.warnings
11808
+ },
11809
+ rollback: {
11810
+ notes: [
11811
+ "self-hosted push imports through id-preserving upserts; no local loops are resumed or mutated",
11812
+ "imported loops are forced to paused with nextRunAt/retryScheduledFor cleared before upload",
11813
+ "imported workflows are forced to archived before upload",
11814
+ "rollback is manual: archive or delete imported ids from the self-hosted control plane after reviewing this manifest"
11815
+ ],
11816
+ commands: [
11817
+ "loops self-hosted push --dry-run --no-runs --manifest-file <post-rollback-check.json>"
11818
+ ]
11819
+ }
11820
+ };
11821
+ }
11560
11822
  async function buildSelfHostedMigrationPlan(store, opts) {
11561
- const bundle = exportLoopsMigrationBundle(store, { includeRuns: opts.includeRuns ?? true });
11823
+ const bundle = selfHostedDefinitionBundle(exportLoopsMigrationBundle(store, { includeRuns: opts.includeRuns ?? true }));
11562
11824
  const remote = await fetchRemotePreview(opts);
11563
11825
  const rows = [...bundle.blockers.map((row) => ({ ...row, action: "blocked" }))];
11564
11826
  const warnings = [
11565
11827
  ...bundle.warnings,
11566
11828
  ...remote.warnings,
11567
- "self-hosted remote apply is preview-only until the control plane exposes id-preserving workflow/loop/run import endpoints"
11829
+ "self-hosted push forces imported loops to paused and imported workflows to archived until activation is explicitly approved"
11568
11830
  ];
11831
+ const config = resolveApiConfig(opts);
11832
+ const apiUrl = config.apiUrl;
11833
+ if (!apiUrl) {
11834
+ pushBlocker(rows, "remote", "self-hosted-api-url", "LOOPS_API_URL or HASNA_LOOPS_API_URL is required to compare a self-hosted control plane");
11835
+ } else if (!isLocalApiUrl(apiUrl) && !config.token) {
11836
+ pushBlocker(rows, "remote", "self-hosted-api-token", "non-local self-hosted APIs require LOOPS_API_TOKEN or HASNA_LOOPS_API_TOKEN");
11837
+ }
11838
+ const replace = opts.replace ?? false;
11569
11839
  if (opts.operation === "self-hosted-pull") {
11570
11840
  for (const entry of remote.loops) {
11571
11841
  const value = entry && typeof entry === "object" ? entry : {};
@@ -11595,58 +11865,107 @@ async function buildSelfHostedMigrationPlan(store, opts) {
11595
11865
  schema: LOOPS_MIGRATION_SCHEMA,
11596
11866
  operation: opts.operation,
11597
11867
  dryRun: true,
11598
- replace: false,
11868
+ replace,
11599
11869
  importable: false,
11600
11870
  rows,
11601
11871
  warnings
11602
11872
  });
11603
- return { ...plan2, importable: false };
11873
+ return {
11874
+ ...plan2,
11875
+ importable: false,
11876
+ manifest: buildSelfHostedManifest({
11877
+ apiUrl,
11878
+ dryRun: true,
11879
+ replace,
11880
+ includeRuns: opts.includeRuns ?? true,
11881
+ bundle,
11882
+ remote,
11883
+ plan: plan2
11884
+ })
11885
+ };
11604
11886
  }
11605
- 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]));
11887
+ const remoteWorkflows = typedRows(remote.workflows);
11888
+ const remoteLoops = typedRows(remote.loops);
11889
+ const remoteRuns = typedRows(remote.runs);
11890
+ const remoteWorkflowsById = rowsById(remoteWorkflows);
11891
+ const remoteLoopsById = rowsById(remoteLoops);
11892
+ const remoteRunsById = rowsById(remoteRuns);
11893
+ const remoteLoopsByName = rowsByName(remoteLoops);
11606
11894
  for (const workflow of bundle.data.workflows) {
11607
- rows.push({
11895
+ if (remote.unsupported.some((entry) => entry.startsWith("/v1/workflows"))) {
11896
+ rows.push({
11897
+ resource: "workflow",
11898
+ id: workflow.id,
11899
+ name: workflow.name,
11900
+ action: "blocked",
11901
+ reason: "self-hosted API does not expose workflow list/count endpoints for safe comparison",
11902
+ incomingHash: migrationHash(workflow)
11903
+ });
11904
+ continue;
11905
+ }
11906
+ rows.push(remoteRepresentationRow(remoteWorkflowsById.get(workflow.id), workflow, {
11608
11907
  resource: "workflow",
11609
11908
  id: workflow.id,
11610
- name: workflow.name,
11611
- action: "blocked",
11612
- reason: "self-hosted API does not expose id-preserving workflow import/list endpoints yet",
11613
- incomingHash: migrationHash(workflow)
11614
- });
11909
+ name: workflow.name
11910
+ }));
11615
11911
  }
11616
11912
  for (const loop of bundle.data.loops) {
11617
- const remoteLoop = remoteLoopsByName.get(loop.name);
11618
- rows.push({
11619
- resource: "loop",
11620
- id: loop.id,
11621
- name: loop.name,
11622
- action: "blocked",
11623
- 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",
11624
- incomingHash: migrationHash(loop),
11625
- currentHash: remoteLoop ? migrationHash(remoteLoop) : undefined
11626
- });
11913
+ const remoteLoop = remoteLoopsById.get(loop.id);
11914
+ if (!remoteLoop) {
11915
+ const nameCollision = remoteLoopsByName.get(loop.name);
11916
+ if (nameCollision && nameCollision.id !== loop.id) {
11917
+ rows.push({
11918
+ resource: "loop",
11919
+ id: loop.id,
11920
+ name: loop.name,
11921
+ action: "insert",
11922
+ reason: `remote loop name exists under different id ${String(nameCollision.id ?? "unknown id")}; id-preserving import will add the missing source id paused`,
11923
+ incomingHash: migrationHash(loop),
11924
+ currentHash: migrationHash(nameCollision)
11925
+ });
11926
+ continue;
11927
+ }
11928
+ }
11929
+ rows.push(remoteRepresentationRow(remoteLoop, loop, { resource: "loop", id: loop.id, name: loop.name }));
11627
11930
  }
11628
11931
  if (opts.includeRuns ?? true) {
11629
11932
  for (const run of bundle.data.runs) {
11630
- rows.push({
11631
- resource: "run",
11632
- id: run.id,
11633
- name: run.loopName,
11634
- action: "blocked",
11635
- reason: "self-hosted API does not expose run-history import endpoints; remote runners should create new run rows",
11636
- incomingHash: migrationHash(run)
11637
- });
11933
+ if (run.status === "running") {
11934
+ rows.push({
11935
+ resource: "run",
11936
+ id: run.id,
11937
+ name: run.loopName,
11938
+ action: "blocked",
11939
+ reason: "running rows carry volatile lease/process ownership and are skipped by self-hosted import",
11940
+ incomingHash: migrationHash(run)
11941
+ });
11942
+ continue;
11943
+ }
11944
+ rows.push(remoteRepresentationRow(remoteRunsById.get(run.id), run, { resource: "run", id: run.id, name: run.loopName }));
11638
11945
  }
11639
11946
  }
11640
- const plan = finalizePlan({
11947
+ let plan = finalizePlan({
11641
11948
  schema: LOOPS_MIGRATION_SCHEMA,
11642
11949
  operation: opts.operation,
11643
11950
  dryRun: true,
11644
- replace: false,
11645
- importable: false,
11951
+ replace,
11952
+ importable: bundle.importable && rows.every((row) => row.action !== "blocked" && row.action !== "conflict"),
11646
11953
  rows,
11647
11954
  warnings
11648
11955
  });
11649
- return { ...plan, importable: false };
11956
+ plan = { ...plan, importable: plan.summary.blocked === 0 && plan.summary.conflict === 0 };
11957
+ return {
11958
+ ...plan,
11959
+ manifest: buildSelfHostedManifest({
11960
+ apiUrl,
11961
+ dryRun: true,
11962
+ replace,
11963
+ includeRuns: opts.includeRuns ?? true,
11964
+ bundle,
11965
+ remote,
11966
+ plan
11967
+ })
11968
+ };
11650
11969
  }
11651
11970
  async function postImportBatch(fetchImpl, config, payload) {
11652
11971
  const body = await requestJson(fetchImpl, config, "/v1/import", { method: "POST", body: JSON.stringify(payload) });
@@ -11667,23 +11986,29 @@ async function applySelfHostedPush(store, opts) {
11667
11986
  const fetchImpl = opts.fetchImpl ?? fetch;
11668
11987
  const includeRuns = opts.includeRuns ?? true;
11669
11988
  const replace = opts.replace ?? false;
11989
+ const plan = await buildSelfHostedMigrationPlan(store, { ...opts, operation: "self-hosted-push", includeRuns, replace });
11990
+ if (plan.summary.blocked > 0 || plan.summary.conflict > 0 || !plan.importable) {
11991
+ throw new ValidationError(`self-hosted push is not safe to apply: blocked=${plan.summary.blocked} conflict=${plan.summary.conflict}`);
11992
+ }
11670
11993
  const batchRows = Math.max(1, opts.batchRows ?? 200);
11671
11994
  const runBatchBytes = Math.max(64 * 1024, opts.runBatchBytes ?? 4 * 1024 * 1024);
11672
11995
  const applied = { workflows: 0, loops: 0, runs: 0 };
11673
11996
  const skipped = { runningRuns: 0, orphanRuns: 0 };
11674
11997
  let requests = 0;
11675
11998
  const base = store.exportMigrationRows({ includeRuns: false });
11999
+ const workflows = base.workflows.map(disabledWorkflowForSelfHostedImport);
12000
+ const loops = base.loops.map(pausedLoopForSelfHostedImport);
11676
12001
  const loopIds = new Set(base.loops.map((loop) => loop.id));
11677
- for (let i = 0;i < base.workflows.length; i += batchRows) {
11678
- const batch = base.workflows.slice(i, i + batchRows);
11679
- const result = await postImportBatch(fetchImpl, config, { workflows: batch, replace });
12002
+ for (let i = 0;i < workflows.length; i += batchRows) {
12003
+ const batch = workflows.slice(i, i + batchRows);
12004
+ const result = await postImportBatch(fetchImpl, config, { workflows: batch, replace, preserveWorkflowActivation: false, preserveLoopScheduling: false });
11680
12005
  applied.workflows += result.imported.workflows;
11681
12006
  requests += 1;
11682
12007
  opts.onProgress?.({ phase: "workflows", sent: applied.workflows, requests });
11683
12008
  }
11684
- for (let i = 0;i < base.loops.length; i += batchRows) {
11685
- const batch = base.loops.slice(i, i + batchRows);
11686
- const result = await postImportBatch(fetchImpl, config, { loops: batch, replace });
12009
+ for (let i = 0;i < loops.length; i += batchRows) {
12010
+ const batch = loops.slice(i, i + batchRows);
12011
+ const result = await postImportBatch(fetchImpl, config, { loops: batch, replace, preserveLoopScheduling: false });
11687
12012
  applied.loops += result.imported.loops;
11688
12013
  requests += 1;
11689
12014
  opts.onProgress?.({ phase: "loops", sent: applied.loops, requests });
@@ -11695,7 +12020,7 @@ async function applySelfHostedPush(store, opts) {
11695
12020
  const flush = async () => {
11696
12021
  if (pending.length === 0)
11697
12022
  return;
11698
- const result = await postImportBatch(fetchImpl, config, { runs: pending, replace });
12023
+ const result = await postImportBatch(fetchImpl, config, { runs: pending, replace, preserveLoopScheduling: false });
11699
12024
  applied.runs += result.imported.runs;
11700
12025
  skipped.runningRuns += result.skippedRunning;
11701
12026
  requests += 1;
@@ -11725,7 +12050,36 @@ async function applySelfHostedPush(store, opts) {
11725
12050
  }
11726
12051
  await flush();
11727
12052
  }
11728
- return { ok: true, apiUrl: config.apiUrl, applied, skipped, requests };
12053
+ const remoteAfter = await fetchRemotePreview({ ...opts, operation: "self-hosted-push", includeRuns: false });
12054
+ const bundle = selfHostedDefinitionBundle(exportLoopsMigrationBundle(store, { includeRuns }));
12055
+ const remoteBefore = plan.manifest ? {
12056
+ workflows: [],
12057
+ loops: [],
12058
+ runs: [],
12059
+ counts: plan.manifest.counts.remoteBefore,
12060
+ unsupported: plan.manifest.unsafe.unsupported,
12061
+ warnings: []
12062
+ } : await fetchRemotePreview({ ...opts, operation: "self-hosted-push", includeRuns });
12063
+ return {
12064
+ ok: true,
12065
+ apiUrl: config.apiUrl,
12066
+ applied,
12067
+ skipped,
12068
+ requests,
12069
+ manifest: buildSelfHostedManifest({
12070
+ apiUrl: config.apiUrl,
12071
+ dryRun: false,
12072
+ replace,
12073
+ includeRuns,
12074
+ bundle,
12075
+ remote: remoteBefore,
12076
+ plan,
12077
+ applied,
12078
+ skipped,
12079
+ requests,
12080
+ remoteAfter: remoteAfter.counts
12081
+ })
12082
+ };
11729
12083
  }
11730
12084
  async function registerSelfHostedRunner(opts) {
11731
12085
  const config = resolveApiConfig(opts);
@@ -13185,11 +13539,118 @@ class PostgresLoopStorage {
13185
13539
  throw new Error(`workflow not found after archive: ${existing.id}`);
13186
13540
  return archived;
13187
13541
  }
13188
- createWorkflowInvocation() {
13189
- throw new NotImplementedError("createWorkflowInvocation");
13542
+ async createWorkflowInvocation(...args) {
13543
+ const [input] = args;
13544
+ const now = nowIso();
13545
+ const sourceDedupeKey = input.sourceRef.dedupeKey ?? undefined;
13546
+ if (sourceDedupeKey) {
13547
+ const existing = await this.client.get("SELECT * FROM workflow_invocations WHERE source_kind = $1 AND source_dedupe_key = $2 LIMIT 1", [input.sourceRef.kind, sourceDedupeKey]);
13548
+ if (existing)
13549
+ return rowToWorkflowInvocation(existing);
13550
+ }
13551
+ const id = input.id ?? genId();
13552
+ await this.client.execute(`INSERT INTO workflow_invocations (id, workflow_id, template_id, source_kind, source_id, source_dedupe_key,
13553
+ source_json, subject_kind, subject_id, subject_path, subject_url, subject_json, intent, scope_json,
13554
+ output_policy_json, created_at, updated_at)
13555
+ VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8,$9,$10,$11,$12::jsonb,$13,$14::jsonb,$15::jsonb,$16,$17)`, [
13556
+ id,
13557
+ input.workflowId ?? null,
13558
+ input.templateId ?? null,
13559
+ input.sourceRef.kind,
13560
+ input.sourceRef.id ?? null,
13561
+ sourceDedupeKey ?? null,
13562
+ JSON.stringify(input.sourceRef),
13563
+ input.subjectRef.kind,
13564
+ input.subjectRef.id ?? null,
13565
+ input.subjectRef.path ?? null,
13566
+ input.subjectRef.url ?? null,
13567
+ JSON.stringify(input.subjectRef),
13568
+ input.intent,
13569
+ input.scope ? JSON.stringify(input.scope) : null,
13570
+ input.outputPolicy ? JSON.stringify(input.outputPolicy) : null,
13571
+ now,
13572
+ now
13573
+ ]);
13574
+ const row = await this.client.get("SELECT * FROM workflow_invocations WHERE id = $1", [id]);
13575
+ if (!row)
13576
+ throw new Error(`workflow invocation not found after create: ${id}`);
13577
+ return rowToWorkflowInvocation(row);
13190
13578
  }
13191
- upsertWorkflowWorkItem() {
13192
- throw new NotImplementedError("upsertWorkflowWorkItem");
13579
+ async upsertWorkflowWorkItem(...args) {
13580
+ const [input] = args;
13581
+ const now = nowIso();
13582
+ const id = input.id ?? genId();
13583
+ const status = input.status ?? "queued";
13584
+ await this.client.execute(`INSERT INTO workflow_work_items (id, route_key, idempotency_key, invocation_id, source_type, source_ref,
13585
+ subject_ref, project_key, project_group, machine_id, route_scope, priority, status, attempts, next_attempt_at,
13586
+ lease_expires_at, workflow_id, loop_id, workflow_run_id, last_reason, created_at, updated_at)
13587
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,0,$14,NULL,NULL,NULL,NULL,$15,$16,$17)
13588
+ ON CONFLICT(route_key, idempotency_key) DO UPDATE SET
13589
+ invocation_id=excluded.invocation_id,
13590
+ source_type=excluded.source_type,
13591
+ source_ref=excluded.source_ref,
13592
+ subject_ref=excluded.subject_ref,
13593
+ project_key=excluded.project_key,
13594
+ project_group=excluded.project_group,
13595
+ machine_id=CASE
13596
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.machine_id
13597
+ ELSE excluded.machine_id
13598
+ END,
13599
+ route_scope=excluded.route_scope,
13600
+ priority=excluded.priority,
13601
+ status=CASE
13602
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled')
13603
+ THEN workflow_work_items.status
13604
+ ELSE excluded.status
13605
+ END,
13606
+ workflow_id=CASE
13607
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.workflow_id
13608
+ ELSE NULL
13609
+ END,
13610
+ loop_id=CASE
13611
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.loop_id
13612
+ ELSE NULL
13613
+ END,
13614
+ workflow_run_id=CASE
13615
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.workflow_run_id
13616
+ ELSE NULL
13617
+ END,
13618
+ lease_expires_at=CASE
13619
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.lease_expires_at
13620
+ ELSE NULL
13621
+ END,
13622
+ next_attempt_at=excluded.next_attempt_at,
13623
+ last_reason=CASE
13624
+ WHEN workflow_work_items.attempts > 0
13625
+ AND workflow_work_items.status IN ('queued', 'deferred')
13626
+ AND workflow_work_items.last_reason IS NOT NULL
13627
+ AND excluded.last_reason IS NOT NULL
13628
+ THEN workflow_work_items.last_reason || '; ' || excluded.last_reason
13629
+ ELSE COALESCE(excluded.last_reason, workflow_work_items.last_reason)
13630
+ END,
13631
+ updated_at=excluded.updated_at`, [
13632
+ id,
13633
+ input.routeKey,
13634
+ input.idempotencyKey,
13635
+ input.invocationId,
13636
+ input.sourceType,
13637
+ input.sourceRef,
13638
+ input.subjectRef,
13639
+ input.projectKey ?? null,
13640
+ input.projectGroup ?? null,
13641
+ input.machineId ?? null,
13642
+ input.routeScope ?? null,
13643
+ input.priority ?? 0,
13644
+ status,
13645
+ input.nextAttemptAt ?? null,
13646
+ input.lastReason ?? null,
13647
+ now,
13648
+ now
13649
+ ]);
13650
+ const row = await this.client.get("SELECT * FROM workflow_work_items WHERE route_key = $1 AND idempotency_key = $2 LIMIT 1", [input.routeKey, input.idempotencyKey]);
13651
+ if (!row)
13652
+ throw new Error(`workflow work item not found after upsert: ${input.routeKey}/${input.idempotencyKey}`);
13653
+ return rowToWorkflowWorkItem(row);
13193
13654
  }
13194
13655
  admitWorkflowWorkItem() {
13195
13656
  throw new NotImplementedError("admitWorkflowWorkItem");