@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/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",
|
|
@@ -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
|
-
|
|
6693
|
-
' git -C "$repo"
|
|
6694
|
-
"
|
|
6695
|
-
|
|
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
|
|
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
|
|
11553
|
-
const
|
|
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
|
-
|
|
11556
|
-
|
|
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
|
|
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
|
|
11868
|
+
replace,
|
|
11599
11869
|
importable: false,
|
|
11600
11870
|
rows,
|
|
11601
11871
|
warnings
|
|
11602
11872
|
});
|
|
11603
|
-
return {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
11618
|
-
|
|
11619
|
-
|
|
11620
|
-
id
|
|
11621
|
-
|
|
11622
|
-
|
|
11623
|
-
|
|
11624
|
-
|
|
11625
|
-
|
|
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
|
-
|
|
11631
|
-
|
|
11632
|
-
|
|
11633
|
-
|
|
11634
|
-
|
|
11635
|
-
|
|
11636
|
-
|
|
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
|
-
|
|
11947
|
+
let plan = finalizePlan({
|
|
11641
11948
|
schema: LOOPS_MIGRATION_SCHEMA,
|
|
11642
11949
|
operation: opts.operation,
|
|
11643
11950
|
dryRun: true,
|
|
11644
|
-
replace
|
|
11645
|
-
importable:
|
|
11951
|
+
replace,
|
|
11952
|
+
importable: bundle.importable && rows.every((row) => row.action !== "blocked" && row.action !== "conflict"),
|
|
11646
11953
|
rows,
|
|
11647
11954
|
warnings
|
|
11648
11955
|
});
|
|
11649
|
-
|
|
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 <
|
|
11678
|
-
const batch =
|
|
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 <
|
|
11685
|
-
const batch =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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");
|