@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/CHANGELOG.md +121 -0
- package/README.md +6 -0
- package/dist/api/index.js +689 -20
- package/dist/cli/index.js +509 -85
- package/dist/daemon/index.js +118 -11
- package/dist/index.js +525 -64
- package/dist/lib/executor.d.ts +9 -0
- package/dist/lib/migration.d.ts +66 -0
- package/dist/lib/mode.js +3 -3
- package/dist/lib/route/fields.d.ts +8 -0
- package/dist/lib/storage/index.js +197 -7
- package/dist/lib/storage/postgres-loop-storage.d.ts +2 -2
- package/dist/lib/storage/postgres-schema.js +3 -0
- package/dist/lib/storage/postgres.js +3 -0
- package/dist/lib/storage/sqlite.js +83 -3
- package/dist/lib/store/index.d.ts +3 -0
- package/dist/lib/store.d.ts +77 -0
- package/dist/lib/store.js +89 -4
- package/dist/mcp/index.js +118 -11
- package/dist/runner/index.js +35 -8
- package/dist/sdk/http.d.ts +153 -1
- package/dist/sdk/http.js +78 -1
- package/dist/sdk/index.js +411 -60
- package/dist/serve/index.js +919 -60
- package/dist/types.d.ts +11 -0
- package/docs/DEPLOYMENT_MODES.md +6 -0
- package/docs/USAGE.md +11 -8
- package/package.json +3 -3
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
6140
|
-
' git -C "$repo"
|
|
6141
|
-
"
|
|
6142
|
-
|
|
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
|
|
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
|
|
8020
|
-
const
|
|
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
|
-
|
|
8023
|
-
|
|
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
|
|
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
|
|
8332
|
+
replace,
|
|
8066
8333
|
importable: false,
|
|
8067
8334
|
rows,
|
|
8068
8335
|
warnings
|
|
8069
8336
|
});
|
|
8070
|
-
return {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
8085
|
-
|
|
8086
|
-
|
|
8087
|
-
id
|
|
8088
|
-
|
|
8089
|
-
|
|
8090
|
-
|
|
8091
|
-
|
|
8092
|
-
|
|
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
|
-
|
|
8098
|
-
|
|
8099
|
-
|
|
8100
|
-
|
|
8101
|
-
|
|
8102
|
-
|
|
8103
|
-
|
|
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
|
-
|
|
8411
|
+
let plan = finalizePlan({
|
|
8108
8412
|
schema: LOOPS_MIGRATION_SCHEMA,
|
|
8109
8413
|
operation: opts.operation,
|
|
8110
8414
|
dryRun: true,
|
|
8111
|
-
replace
|
|
8112
|
-
importable:
|
|
8415
|
+
replace,
|
|
8416
|
+
importable: bundle.importable && rows.every((row) => row.action !== "blocked" && row.action !== "conflict"),
|
|
8113
8417
|
rows,
|
|
8114
8418
|
warnings
|
|
8115
8419
|
});
|
|
8116
|
-
|
|
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 <
|
|
8145
|
-
const batch =
|
|
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 <
|
|
8152
|
-
const batch =
|
|
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
|
-
|
|
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);
|