@hasna/loops 0.4.3 → 0.4.5
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 +88 -0
- package/README.md +6 -1
- package/dist/api/index.js +2 -2
- package/dist/cli/index.js +927 -418
- package/dist/daemon/index.js +187 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +758 -70
- package/dist/lib/migration.d.ts +109 -0
- package/dist/lib/mode.js +2 -2
- package/dist/lib/route/todos-cli.d.ts +0 -1
- package/dist/lib/route/types.d.ts +0 -10
- package/dist/lib/storage/index.js +185 -2
- package/dist/lib/storage/sqlite.js +185 -2
- package/dist/lib/store.d.ts +41 -0
- package/dist/lib/store.js +185 -2
- package/dist/lib/template-kit.d.ts +8 -10
- package/dist/lib/templates.d.ts +0 -1
- package/dist/mcp/index.js +187 -4
- package/dist/runner/index.js +2 -2
- package/dist/sdk/index.d.ts +9 -0
- package/dist/sdk/index.js +982 -3
- package/docs/DEPLOYMENT_MODES.md +73 -5
- package/docs/USAGE.md +52 -1
- package/package.json +2 -2
package/dist/sdk/index.js
CHANGED
|
@@ -1523,7 +1523,7 @@ class Store {
|
|
|
1523
1523
|
}
|
|
1524
1524
|
const applied = new Set(this.db.query("SELECT id FROM schema_migrations").all().map((row) => row.id));
|
|
1525
1525
|
for (const migration of this.migrations()) {
|
|
1526
|
-
if (!migration.baseline &&
|
|
1526
|
+
if (!migration.baseline && applied.has(migration.id))
|
|
1527
1527
|
continue;
|
|
1528
1528
|
migration.apply();
|
|
1529
1529
|
if (!applied.has(migration.id)) {
|
|
@@ -1576,7 +1576,6 @@ class Store {
|
|
|
1576
1576
|
},
|
|
1577
1577
|
{
|
|
1578
1578
|
id: "0007_run_claim_tokens",
|
|
1579
|
-
reapply: true,
|
|
1580
1579
|
apply: () => {
|
|
1581
1580
|
this.addColumnIfMissing("loop_runs", "claim_token", "TEXT");
|
|
1582
1581
|
this.db.exec("CREATE INDEX IF NOT EXISTS idx_runs_claim_token ON loop_runs(claim_token) WHERE claim_token IS NOT NULL");
|
|
@@ -3841,6 +3840,190 @@ class Store {
|
|
|
3841
3840
|
const row = status ? this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE status = ?").get(status) : this.db.query("SELECT COUNT(*) AS count FROM loop_runs").get();
|
|
3842
3841
|
return row?.count ?? 0;
|
|
3843
3842
|
}
|
|
3843
|
+
exportMigrationRows(opts = {}) {
|
|
3844
|
+
const includeRuns = opts.includeRuns ?? true;
|
|
3845
|
+
const workflows = this.db.query("SELECT * FROM workflow_specs ORDER BY created_at ASC, id ASC").all().map(rowToWorkflow);
|
|
3846
|
+
const loops = this.db.query("SELECT * FROM loops ORDER BY created_at ASC, id ASC").all().map(rowToLoop);
|
|
3847
|
+
const runs = includeRuns ? this.db.query("SELECT * FROM loop_runs ORDER BY created_at ASC, id ASC").all().map(rowToRun) : [];
|
|
3848
|
+
return { schemaVersion: SCHEMA_USER_VERSION, workflows, loops, runs, checks: this.migrationChecks() };
|
|
3849
|
+
}
|
|
3850
|
+
countTable(table) {
|
|
3851
|
+
const row = this.db.query(`SELECT COUNT(*) AS count FROM ${table}`).get();
|
|
3852
|
+
return row?.count ?? 0;
|
|
3853
|
+
}
|
|
3854
|
+
migrationChecks() {
|
|
3855
|
+
const now = nowIso();
|
|
3856
|
+
return {
|
|
3857
|
+
unsupportedCounts: {
|
|
3858
|
+
workflowInvocations: this.countTable("workflow_invocations"),
|
|
3859
|
+
workflowWorkItems: this.countTable("workflow_work_items"),
|
|
3860
|
+
workflowRuns: this.countTable("workflow_runs"),
|
|
3861
|
+
workflowStepRuns: this.countTable("workflow_step_runs"),
|
|
3862
|
+
workflowEvents: this.countTable("workflow_events"),
|
|
3863
|
+
goals: this.countTable("goals"),
|
|
3864
|
+
goalPlanNodes: this.countTable("goal_plan_nodes"),
|
|
3865
|
+
goalRuns: this.countTable("goal_runs")
|
|
3866
|
+
},
|
|
3867
|
+
volatileCounts: {
|
|
3868
|
+
daemonLeases: this.countTable("daemon_lease"),
|
|
3869
|
+
activeDaemonLeases: this.db.query("SELECT COUNT(*) AS count FROM daemon_lease WHERE expires_at > ?").get(now)?.count ?? 0,
|
|
3870
|
+
runningLoopRuns: this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE status = 'running'").get()?.count ?? 0,
|
|
3871
|
+
runningWorkflowRuns: this.db.query("SELECT COUNT(*) AS count FROM workflow_runs WHERE status = 'running'").get()?.count ?? 0,
|
|
3872
|
+
runningWorkflowStepRuns: this.db.query("SELECT COUNT(*) AS count FROM workflow_step_runs WHERE status = 'running'").get()?.count ?? 0,
|
|
3873
|
+
leasedWorkflowWorkItems: this.db.query("SELECT COUNT(*) AS count FROM workflow_work_items WHERE lease_expires_at IS NOT NULL OR status IN ('admitted', 'running')").get()?.count ?? 0
|
|
3874
|
+
}
|
|
3875
|
+
};
|
|
3876
|
+
}
|
|
3877
|
+
upsertMigrationWorkflow(workflow, opts = {}) {
|
|
3878
|
+
const existing = this.getWorkflow(workflow.id);
|
|
3879
|
+
if (existing && !opts.replace)
|
|
3880
|
+
return existing;
|
|
3881
|
+
this.db.query(`INSERT INTO workflow_specs (id, name, description, version, status, goal_json, steps_json, created_at, updated_at)
|
|
3882
|
+
VALUES ($id, $name, $description, $version, $status, $goal, $steps, $created, $updated)
|
|
3883
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
3884
|
+
name=$name,
|
|
3885
|
+
description=$description,
|
|
3886
|
+
version=$version,
|
|
3887
|
+
status=$status,
|
|
3888
|
+
goal_json=$goal,
|
|
3889
|
+
steps_json=$steps,
|
|
3890
|
+
created_at=$created,
|
|
3891
|
+
updated_at=$updated`).run({
|
|
3892
|
+
$id: workflow.id,
|
|
3893
|
+
$name: workflow.name,
|
|
3894
|
+
$description: workflow.description ?? null,
|
|
3895
|
+
$version: workflow.version,
|
|
3896
|
+
$status: workflow.status,
|
|
3897
|
+
$goal: workflow.goal ? JSON.stringify(workflow.goal) : null,
|
|
3898
|
+
$steps: JSON.stringify(workflow.steps),
|
|
3899
|
+
$created: workflow.createdAt,
|
|
3900
|
+
$updated: workflow.updatedAt
|
|
3901
|
+
});
|
|
3902
|
+
const imported = this.getWorkflow(workflow.id);
|
|
3903
|
+
if (!imported)
|
|
3904
|
+
throw new Error(`workflow not found after migration import: ${workflow.id}`);
|
|
3905
|
+
return imported;
|
|
3906
|
+
}
|
|
3907
|
+
upsertMigrationLoop(loop, opts = {}) {
|
|
3908
|
+
const existing = this.getLoop(loop.id);
|
|
3909
|
+
if (existing && !opts.replace)
|
|
3910
|
+
return existing;
|
|
3911
|
+
this.assertNoNestedWorkflowGoal(loop.target, loop.goal);
|
|
3912
|
+
this.db.query(`INSERT INTO loops (id, name, description, status, archived_at, archived_from_status, schedule_json, target_json,
|
|
3913
|
+
goal_json, machine_json, next_run_at, retry_scheduled_for, catch_up, catch_up_limit, overlap, max_attempts,
|
|
3914
|
+
retry_delay_ms, lease_ms, expires_at, created_at, updated_at)
|
|
3915
|
+
VALUES ($id, $name, $description, $status, $archivedAt, $archivedFromStatus, $schedule, $target,
|
|
3916
|
+
$goal, $machine, $nextRun, $retrySlot, $catchUp, $catchUpLimit, $overlap, $maxAttempts,
|
|
3917
|
+
$retryDelay, $leaseMs, $expiresAt, $created, $updated)
|
|
3918
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
3919
|
+
name=$name,
|
|
3920
|
+
description=$description,
|
|
3921
|
+
status=$status,
|
|
3922
|
+
archived_at=$archivedAt,
|
|
3923
|
+
archived_from_status=$archivedFromStatus,
|
|
3924
|
+
schedule_json=$schedule,
|
|
3925
|
+
target_json=$target,
|
|
3926
|
+
goal_json=$goal,
|
|
3927
|
+
machine_json=$machine,
|
|
3928
|
+
next_run_at=$nextRun,
|
|
3929
|
+
retry_scheduled_for=$retrySlot,
|
|
3930
|
+
catch_up=$catchUp,
|
|
3931
|
+
catch_up_limit=$catchUpLimit,
|
|
3932
|
+
overlap=$overlap,
|
|
3933
|
+
max_attempts=$maxAttempts,
|
|
3934
|
+
retry_delay_ms=$retryDelay,
|
|
3935
|
+
lease_ms=$leaseMs,
|
|
3936
|
+
expires_at=$expiresAt,
|
|
3937
|
+
created_at=$created,
|
|
3938
|
+
updated_at=$updated`).run({
|
|
3939
|
+
$id: loop.id,
|
|
3940
|
+
$name: loop.name,
|
|
3941
|
+
$description: loop.description ?? null,
|
|
3942
|
+
$status: loop.status,
|
|
3943
|
+
$archivedAt: loop.archivedAt ?? null,
|
|
3944
|
+
$archivedFromStatus: loop.archivedFromStatus ?? null,
|
|
3945
|
+
$schedule: JSON.stringify(loop.schedule),
|
|
3946
|
+
$target: JSON.stringify(loop.target),
|
|
3947
|
+
$goal: loop.goal ? JSON.stringify(loop.goal) : null,
|
|
3948
|
+
$machine: loop.machine ? JSON.stringify(loop.machine) : null,
|
|
3949
|
+
$nextRun: loop.nextRunAt ?? null,
|
|
3950
|
+
$retrySlot: loop.retryScheduledFor ?? null,
|
|
3951
|
+
$catchUp: loop.catchUp,
|
|
3952
|
+
$catchUpLimit: loop.catchUpLimit,
|
|
3953
|
+
$overlap: loop.overlap,
|
|
3954
|
+
$maxAttempts: loop.maxAttempts,
|
|
3955
|
+
$retryDelay: loop.retryDelayMs,
|
|
3956
|
+
$leaseMs: loop.leaseMs,
|
|
3957
|
+
$expiresAt: loop.expiresAt ?? null,
|
|
3958
|
+
$created: loop.createdAt,
|
|
3959
|
+
$updated: loop.updatedAt
|
|
3960
|
+
});
|
|
3961
|
+
const imported = this.getLoop(loop.id);
|
|
3962
|
+
if (!imported)
|
|
3963
|
+
throw new Error(`loop not found after migration import: ${loop.id}`);
|
|
3964
|
+
return imported;
|
|
3965
|
+
}
|
|
3966
|
+
upsertMigrationRun(run, opts = {}) {
|
|
3967
|
+
if (run.status === "running")
|
|
3968
|
+
throw new ValidationError(`cannot import running run ${run.id}`);
|
|
3969
|
+
const existing = this.getRun(run.id);
|
|
3970
|
+
if (existing && !opts.replace)
|
|
3971
|
+
return existing;
|
|
3972
|
+
this.db.query(`INSERT INTO loop_runs (id, loop_id, loop_name, scheduled_for, attempt, status, started_at, finished_at,
|
|
3973
|
+
claimed_by, claim_token, lease_expires_at, pid, pgid, process_started_at, exit_code, duration_ms,
|
|
3974
|
+
stdout, stderr, error, goal_run_id, created_at, updated_at)
|
|
3975
|
+
VALUES ($id, $loopId, $loopName, $scheduledFor, $attempt, $status, $startedAt, $finishedAt,
|
|
3976
|
+
$claimedBy, NULL, $leaseExpiresAt, $pid, $pgid, $processStartedAt, $exitCode, $durationMs,
|
|
3977
|
+
$stdout, $stderr, $error, $goalRunId, $created, $updated)
|
|
3978
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
3979
|
+
loop_id=$loopId,
|
|
3980
|
+
loop_name=$loopName,
|
|
3981
|
+
scheduled_for=$scheduledFor,
|
|
3982
|
+
attempt=$attempt,
|
|
3983
|
+
status=$status,
|
|
3984
|
+
started_at=$startedAt,
|
|
3985
|
+
finished_at=$finishedAt,
|
|
3986
|
+
claimed_by=$claimedBy,
|
|
3987
|
+
claim_token=NULL,
|
|
3988
|
+
lease_expires_at=$leaseExpiresAt,
|
|
3989
|
+
pid=$pid,
|
|
3990
|
+
pgid=$pgid,
|
|
3991
|
+
process_started_at=$processStartedAt,
|
|
3992
|
+
exit_code=$exitCode,
|
|
3993
|
+
duration_ms=$durationMs,
|
|
3994
|
+
stdout=$stdout,
|
|
3995
|
+
stderr=$stderr,
|
|
3996
|
+
error=$error,
|
|
3997
|
+
goal_run_id=$goalRunId,
|
|
3998
|
+
created_at=$created,
|
|
3999
|
+
updated_at=$updated`).run({
|
|
4000
|
+
$id: run.id,
|
|
4001
|
+
$loopId: run.loopId,
|
|
4002
|
+
$loopName: run.loopName,
|
|
4003
|
+
$scheduledFor: run.scheduledFor,
|
|
4004
|
+
$attempt: run.attempt,
|
|
4005
|
+
$status: run.status,
|
|
4006
|
+
$startedAt: run.startedAt ?? null,
|
|
4007
|
+
$finishedAt: run.finishedAt ?? null,
|
|
4008
|
+
$claimedBy: run.claimedBy ?? null,
|
|
4009
|
+
$leaseExpiresAt: run.leaseExpiresAt ?? null,
|
|
4010
|
+
$pid: run.pid ?? null,
|
|
4011
|
+
$pgid: run.pgid ?? null,
|
|
4012
|
+
$processStartedAt: run.processStartedAt ?? null,
|
|
4013
|
+
$exitCode: run.exitCode ?? null,
|
|
4014
|
+
$durationMs: run.durationMs ?? null,
|
|
4015
|
+
$stdout: scrubbedOrNull(run.stdout),
|
|
4016
|
+
$stderr: scrubbedOrNull(run.stderr),
|
|
4017
|
+
$error: scrubbedOrNull(run.error),
|
|
4018
|
+
$goalRunId: run.goalRunId ?? null,
|
|
4019
|
+
$created: run.createdAt,
|
|
4020
|
+
$updated: run.updatedAt
|
|
4021
|
+
});
|
|
4022
|
+
const imported = this.getRun(run.id);
|
|
4023
|
+
if (!imported)
|
|
4024
|
+
throw new Error(`run not found after migration import: ${run.id}`);
|
|
4025
|
+
return imported;
|
|
4026
|
+
}
|
|
3844
4027
|
pruneHistory(opts) {
|
|
3845
4028
|
const { maxAgeDays, keepPerLoop } = opts;
|
|
3846
4029
|
if (maxAgeDays === undefined && keepPerLoop === undefined) {
|
|
@@ -3979,6 +4162,273 @@ class Store {
|
|
|
3979
4162
|
this.db.close();
|
|
3980
4163
|
}
|
|
3981
4164
|
}
|
|
4165
|
+
// package.json
|
|
4166
|
+
var package_default = {
|
|
4167
|
+
name: "@hasna/loops",
|
|
4168
|
+
version: "0.4.5",
|
|
4169
|
+
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
4170
|
+
type: "module",
|
|
4171
|
+
main: "dist/index.js",
|
|
4172
|
+
types: "dist/index.d.ts",
|
|
4173
|
+
bin: {
|
|
4174
|
+
loops: "dist/cli/index.js",
|
|
4175
|
+
"loops-daemon": "dist/daemon/index.js",
|
|
4176
|
+
"loops-api": "dist/api/index.js",
|
|
4177
|
+
"loops-runner": "dist/runner/index.js",
|
|
4178
|
+
"loops-mcp": "dist/mcp/index.js"
|
|
4179
|
+
},
|
|
4180
|
+
exports: {
|
|
4181
|
+
".": {
|
|
4182
|
+
types: "./dist/index.d.ts",
|
|
4183
|
+
import: "./dist/index.js"
|
|
4184
|
+
},
|
|
4185
|
+
"./sdk": {
|
|
4186
|
+
types: "./dist/sdk/index.d.ts",
|
|
4187
|
+
import: "./dist/sdk/index.js"
|
|
4188
|
+
},
|
|
4189
|
+
"./mcp": {
|
|
4190
|
+
types: "./dist/mcp/index.d.ts",
|
|
4191
|
+
import: "./dist/mcp/index.js"
|
|
4192
|
+
},
|
|
4193
|
+
"./api": {
|
|
4194
|
+
types: "./dist/api/index.d.ts",
|
|
4195
|
+
import: "./dist/api/index.js"
|
|
4196
|
+
},
|
|
4197
|
+
"./runner": {
|
|
4198
|
+
types: "./dist/runner/index.d.ts",
|
|
4199
|
+
import: "./dist/runner/index.js"
|
|
4200
|
+
},
|
|
4201
|
+
"./mode": {
|
|
4202
|
+
types: "./dist/lib/mode.d.ts",
|
|
4203
|
+
import: "./dist/lib/mode.js"
|
|
4204
|
+
},
|
|
4205
|
+
"./storage": {
|
|
4206
|
+
types: "./dist/lib/store.d.ts",
|
|
4207
|
+
import: "./dist/lib/store.js"
|
|
4208
|
+
},
|
|
4209
|
+
"./storage/contract": {
|
|
4210
|
+
types: "./dist/lib/storage/contract.d.ts",
|
|
4211
|
+
import: "./dist/lib/storage/contract.js"
|
|
4212
|
+
},
|
|
4213
|
+
"./storage/sqlite": {
|
|
4214
|
+
types: "./dist/lib/storage/sqlite.d.ts",
|
|
4215
|
+
import: "./dist/lib/storage/sqlite.js"
|
|
4216
|
+
},
|
|
4217
|
+
"./storage/postgres": {
|
|
4218
|
+
types: "./dist/lib/storage/postgres.d.ts",
|
|
4219
|
+
import: "./dist/lib/storage/postgres.js"
|
|
4220
|
+
},
|
|
4221
|
+
"./storage/postgres-schema": {
|
|
4222
|
+
types: "./dist/lib/storage/postgres-schema.d.ts",
|
|
4223
|
+
import: "./dist/lib/storage/postgres-schema.js"
|
|
4224
|
+
}
|
|
4225
|
+
},
|
|
4226
|
+
files: [
|
|
4227
|
+
"dist",
|
|
4228
|
+
"README.md",
|
|
4229
|
+
"CHANGELOG.md",
|
|
4230
|
+
"docs",
|
|
4231
|
+
"LICENSE"
|
|
4232
|
+
],
|
|
4233
|
+
scripts: {
|
|
4234
|
+
build: "rm -rf dist && bun build src/cli/index.ts src/daemon/index.ts src/api/index.ts src/runner/index.ts src/mcp/index.ts src/index.ts src/sdk/index.ts src/lib/store.ts src/lib/mode.ts src/lib/storage/index.ts src/lib/storage/contract.ts src/lib/storage/sqlite.ts src/lib/storage/postgres.ts src/lib/storage/postgres-schema.ts --root src --outdir dist --target bun --packages external && chmod +x dist/cli/index.js dist/daemon/index.js dist/api/index.js dist/runner/index.js dist/mcp/index.js && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
|
|
4235
|
+
"build:bin": "bun build src/cli/index.ts --compile --outfile dist/loops",
|
|
4236
|
+
typecheck: "tsc --noEmit",
|
|
4237
|
+
test: "bun test",
|
|
4238
|
+
"test:boundary": "bun run scripts/no-private-cloud-boundary.mjs",
|
|
4239
|
+
prepare: "test -d dist || bun run build",
|
|
4240
|
+
"dev:cli": "bun run src/cli/index.ts",
|
|
4241
|
+
"dev:daemon": "bun run src/daemon/index.ts",
|
|
4242
|
+
prepublishOnly: "bun run build && bun run typecheck && bun test && bun run test:boundary"
|
|
4243
|
+
},
|
|
4244
|
+
keywords: [
|
|
4245
|
+
"loops",
|
|
4246
|
+
"scheduler",
|
|
4247
|
+
"daemon",
|
|
4248
|
+
"agents",
|
|
4249
|
+
"claude",
|
|
4250
|
+
"cursor",
|
|
4251
|
+
"codewith",
|
|
4252
|
+
"opencode",
|
|
4253
|
+
"cli"
|
|
4254
|
+
],
|
|
4255
|
+
repository: {
|
|
4256
|
+
type: "git",
|
|
4257
|
+
url: "git+https://github.com/hasna/loops.git"
|
|
4258
|
+
},
|
|
4259
|
+
homepage: "https://github.com/hasna/loops#readme",
|
|
4260
|
+
bugs: {
|
|
4261
|
+
url: "https://github.com/hasna/loops/issues"
|
|
4262
|
+
},
|
|
4263
|
+
author: "Hasna <andrei@hasna.com>",
|
|
4264
|
+
license: "Apache-2.0",
|
|
4265
|
+
engines: {
|
|
4266
|
+
bun: ">=1.0.0"
|
|
4267
|
+
},
|
|
4268
|
+
dependencies: {
|
|
4269
|
+
"@hasna/events": "^0.1.9",
|
|
4270
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
4271
|
+
"@openrouter/ai-sdk-provider": "2.9.1",
|
|
4272
|
+
ai: "6.0.204",
|
|
4273
|
+
commander: "^13.1.0",
|
|
4274
|
+
zod: "4.4.3"
|
|
4275
|
+
},
|
|
4276
|
+
optionalDependencies: {
|
|
4277
|
+
"@hasna/machines": "0.0.49"
|
|
4278
|
+
},
|
|
4279
|
+
devDependencies: {
|
|
4280
|
+
"@types/bun": "latest",
|
|
4281
|
+
typescript: "^5.7.3"
|
|
4282
|
+
},
|
|
4283
|
+
publishConfig: {
|
|
4284
|
+
registry: "https://registry.npmjs.org",
|
|
4285
|
+
access: "public"
|
|
4286
|
+
}
|
|
4287
|
+
};
|
|
4288
|
+
|
|
4289
|
+
// src/lib/version.ts
|
|
4290
|
+
function packageVersion() {
|
|
4291
|
+
if (typeof package_default.version !== "string" || package_default.version.trim() === "")
|
|
4292
|
+
throw new Error("package.json version is missing");
|
|
4293
|
+
return package_default.version;
|
|
4294
|
+
}
|
|
4295
|
+
|
|
4296
|
+
// src/lib/mode.ts
|
|
4297
|
+
var LOOP_DEPLOYMENT_MODES = ["local", "self_hosted", "cloud"];
|
|
4298
|
+
var MODE_ENV_KEYS = ["LOOPS_MODE", "HASNA_LOOPS_MODE"];
|
|
4299
|
+
var API_URL_ENV_KEYS = ["LOOPS_API_URL", "HASNA_LOOPS_API_URL"];
|
|
4300
|
+
var CLOUD_API_URL_ENV_KEYS = ["LOOPS_CLOUD_API_URL", "HASNA_LOOPS_CLOUD_API_URL"];
|
|
4301
|
+
var DATABASE_URL_ENV_KEYS = ["LOOPS_DATABASE_URL", "HASNA_LOOPS_DATABASE_URL"];
|
|
4302
|
+
var API_TOKEN_ENV_KEYS = ["LOOPS_API_TOKEN", "HASNA_LOOPS_API_TOKEN"];
|
|
4303
|
+
var CLOUD_TOKEN_ENV_KEYS = ["LOOPS_CLOUD_TOKEN", "HASNA_LOOPS_CLOUD_TOKEN"];
|
|
4304
|
+
var TOKEN_ENV_KEYS = [...API_TOKEN_ENV_KEYS, ...CLOUD_TOKEN_ENV_KEYS];
|
|
4305
|
+
function envValue(env, keys) {
|
|
4306
|
+
for (const key of keys) {
|
|
4307
|
+
const value = env[key]?.trim();
|
|
4308
|
+
if (value)
|
|
4309
|
+
return { key, value };
|
|
4310
|
+
}
|
|
4311
|
+
return;
|
|
4312
|
+
}
|
|
4313
|
+
function normalizeLoopDeploymentMode(value) {
|
|
4314
|
+
const normalized = value.trim().toLowerCase().replace(/-/g, "_");
|
|
4315
|
+
if (normalized === "local")
|
|
4316
|
+
return "local";
|
|
4317
|
+
if (normalized === "self_hosted" || normalized === "selfhosted")
|
|
4318
|
+
return "self_hosted";
|
|
4319
|
+
if (normalized === "cloud" || normalized === "saas")
|
|
4320
|
+
return "cloud";
|
|
4321
|
+
throw new Error(`unsupported OpenLoops deployment mode "${value}"; expected local, self_hosted, or cloud`);
|
|
4322
|
+
}
|
|
4323
|
+
function resolveLoopDeploymentMode(env = process.env) {
|
|
4324
|
+
const explicitMode = envValue(env, MODE_ENV_KEYS);
|
|
4325
|
+
if (explicitMode) {
|
|
4326
|
+
return {
|
|
4327
|
+
deploymentMode: normalizeLoopDeploymentMode(explicitMode.value),
|
|
4328
|
+
source: explicitMode.key
|
|
4329
|
+
};
|
|
4330
|
+
}
|
|
4331
|
+
const cloudApiUrl = envValue(env, CLOUD_API_URL_ENV_KEYS);
|
|
4332
|
+
if (cloudApiUrl)
|
|
4333
|
+
return { deploymentMode: "cloud", source: cloudApiUrl.key };
|
|
4334
|
+
const apiUrl = envValue(env, API_URL_ENV_KEYS);
|
|
4335
|
+
if (apiUrl)
|
|
4336
|
+
return { deploymentMode: "self_hosted", source: apiUrl.key };
|
|
4337
|
+
const databaseUrl = envValue(env, DATABASE_URL_ENV_KEYS);
|
|
4338
|
+
if (databaseUrl)
|
|
4339
|
+
return { deploymentMode: "self_hosted", source: databaseUrl.key };
|
|
4340
|
+
return { deploymentMode: "local", source: "default" };
|
|
4341
|
+
}
|
|
4342
|
+
function loopControlPlaneConfig(env = process.env) {
|
|
4343
|
+
return {
|
|
4344
|
+
apiUrl: envValue(env, API_URL_ENV_KEYS)?.value,
|
|
4345
|
+
cloudApiUrl: envValue(env, CLOUD_API_URL_ENV_KEYS)?.value,
|
|
4346
|
+
databaseUrlPresent: Boolean(envValue(env, DATABASE_URL_ENV_KEYS)),
|
|
4347
|
+
apiAuthTokenPresent: Boolean(envValue(env, API_TOKEN_ENV_KEYS)),
|
|
4348
|
+
cloudAuthTokenPresent: Boolean(envValue(env, CLOUD_TOKEN_ENV_KEYS)),
|
|
4349
|
+
authTokenPresent: Boolean(envValue(env, TOKEN_ENV_KEYS))
|
|
4350
|
+
};
|
|
4351
|
+
}
|
|
4352
|
+
function sourceOfTruthForMode(mode) {
|
|
4353
|
+
if (mode === "local")
|
|
4354
|
+
return "local_sqlite";
|
|
4355
|
+
if (mode === "self_hosted")
|
|
4356
|
+
return "self_hosted_control_plane";
|
|
4357
|
+
return "cloud_control_plane";
|
|
4358
|
+
}
|
|
4359
|
+
function displayControlPlaneUrl(value) {
|
|
4360
|
+
if (!value)
|
|
4361
|
+
return;
|
|
4362
|
+
try {
|
|
4363
|
+
const url = new URL(value);
|
|
4364
|
+
const path = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/g, "");
|
|
4365
|
+
return `${url.origin}${path}`;
|
|
4366
|
+
} catch {
|
|
4367
|
+
return "[invalid-url]";
|
|
4368
|
+
}
|
|
4369
|
+
}
|
|
4370
|
+
function buildDeploymentStatus(opts = {}) {
|
|
4371
|
+
const env = opts.env ?? process.env;
|
|
4372
|
+
const active = resolveLoopDeploymentMode(env);
|
|
4373
|
+
const deploymentMode = opts.perspective ?? active.deploymentMode;
|
|
4374
|
+
const config = loopControlPlaneConfig(env);
|
|
4375
|
+
const rawApiUrl = deploymentMode === "cloud" ? config.cloudApiUrl : config.apiUrl;
|
|
4376
|
+
const apiUrl = displayControlPlaneUrl(rawApiUrl);
|
|
4377
|
+
const controlPlaneKind = deploymentMode === "local" ? "none" : deploymentMode;
|
|
4378
|
+
const deploymentAuthTokenPresent = deploymentMode === "cloud" ? config.cloudAuthTokenPresent : deploymentMode === "self_hosted" ? config.apiAuthTokenPresent : false;
|
|
4379
|
+
const controlPlaneConfigured = deploymentMode === "local" ? false : deploymentMode === "self_hosted" ? Boolean(config.apiUrl || config.databaseUrlPresent) : Boolean(config.cloudApiUrl && deploymentAuthTokenPresent);
|
|
4380
|
+
const warnings = [];
|
|
4381
|
+
if (deploymentMode === "self_hosted" && !controlPlaneConfigured) {
|
|
4382
|
+
warnings.push("self_hosted mode needs LOOPS_API_URL or LOOPS_DATABASE_URL before it can become authoritative");
|
|
4383
|
+
}
|
|
4384
|
+
if (deploymentMode === "cloud" && config.apiUrl && !config.cloudApiUrl) {
|
|
4385
|
+
warnings.push("LOOPS_API_URL selects self_hosted; cloud mode uses LOOPS_CLOUD_API_URL");
|
|
4386
|
+
}
|
|
4387
|
+
if (deploymentMode === "cloud" && !config.cloudApiUrl) {
|
|
4388
|
+
warnings.push("cloud mode is a public contract until LOOPS_CLOUD_API_URL is configured");
|
|
4389
|
+
}
|
|
4390
|
+
if (deploymentMode === "cloud" && config.cloudApiUrl && !deploymentAuthTokenPresent) {
|
|
4391
|
+
warnings.push("cloud mode needs LOOPS_CLOUD_TOKEN or HASNA_LOOPS_CLOUD_TOKEN before it can become ready");
|
|
4392
|
+
}
|
|
4393
|
+
if (opts.perspective && opts.perspective !== active.deploymentMode) {
|
|
4394
|
+
warnings.push(`active deployment mode is ${active.deploymentMode}; this is the ${opts.perspective} perspective`);
|
|
4395
|
+
}
|
|
4396
|
+
return {
|
|
4397
|
+
packageVersion: packageVersion(),
|
|
4398
|
+
deploymentMode,
|
|
4399
|
+
activeDeploymentMode: active.deploymentMode,
|
|
4400
|
+
active: deploymentMode === active.deploymentMode,
|
|
4401
|
+
deploymentModeSource: active.source,
|
|
4402
|
+
sourceOfTruth: sourceOfTruthForMode(deploymentMode),
|
|
4403
|
+
localStore: {
|
|
4404
|
+
role: deploymentMode === "local" ? "authoritative" : "cache_and_spool"
|
|
4405
|
+
},
|
|
4406
|
+
controlPlane: {
|
|
4407
|
+
kind: controlPlaneKind,
|
|
4408
|
+
configured: controlPlaneConfigured,
|
|
4409
|
+
apiUrl,
|
|
4410
|
+
databaseUrlPresent: config.databaseUrlPresent,
|
|
4411
|
+
authTokenPresent: deploymentAuthTokenPresent
|
|
4412
|
+
},
|
|
4413
|
+
runner: {
|
|
4414
|
+
required: deploymentMode !== "local",
|
|
4415
|
+
role: deploymentMode === "local" ? "daemon" : "control_plane_worker"
|
|
4416
|
+
},
|
|
4417
|
+
warnings
|
|
4418
|
+
};
|
|
4419
|
+
}
|
|
4420
|
+
function deploymentStatusLine(status) {
|
|
4421
|
+
const configured = status.controlPlane.kind === "none" ? "none" : String(status.controlPlane.configured);
|
|
4422
|
+
const active = status.active ? "active" : `active=${status.activeDeploymentMode}`;
|
|
4423
|
+
return [
|
|
4424
|
+
`deploymentMode=${status.deploymentMode}`,
|
|
4425
|
+
active,
|
|
4426
|
+
`source=${status.deploymentModeSource}`,
|
|
4427
|
+
`truth=${status.sourceOfTruth}`,
|
|
4428
|
+
`local=${status.localStore.role}`,
|
|
4429
|
+
`control_plane=${configured}`
|
|
4430
|
+
].join(" ");
|
|
4431
|
+
}
|
|
3982
4432
|
|
|
3983
4433
|
// src/lib/doctor.ts
|
|
3984
4434
|
import { spawnSync as spawnSync5 } from "child_process";
|
|
@@ -6043,6 +6493,515 @@ function buildHealthReport(store, opts = {}) {
|
|
|
6043
6493
|
};
|
|
6044
6494
|
}
|
|
6045
6495
|
|
|
6496
|
+
// src/lib/migration.ts
|
|
6497
|
+
import { createHash as createHash3 } from "crypto";
|
|
6498
|
+
import { hostname as hostname2 } from "os";
|
|
6499
|
+
var LOOPS_MIGRATION_SCHEMA = "open-loops.migration/v1";
|
|
6500
|
+
function canonicalize(value) {
|
|
6501
|
+
if (Array.isArray(value))
|
|
6502
|
+
return value.map((entry) => canonicalize(entry));
|
|
6503
|
+
if (value && typeof value === "object") {
|
|
6504
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => [key, canonicalize(entry)]));
|
|
6505
|
+
}
|
|
6506
|
+
return value;
|
|
6507
|
+
}
|
|
6508
|
+
function migrationHash(value) {
|
|
6509
|
+
return createHash3("sha256").update(JSON.stringify(canonicalize(value))).digest("hex");
|
|
6510
|
+
}
|
|
6511
|
+
function pushBlocker(rows, resource, id, reason, name) {
|
|
6512
|
+
rows.push({ resource, id, name, action: "blocked", reason });
|
|
6513
|
+
}
|
|
6514
|
+
function checksToBlockers(checks, prefix, warnings = []) {
|
|
6515
|
+
const rows = [];
|
|
6516
|
+
for (const [name, count] of Object.entries(checks.unsupportedCounts)) {
|
|
6517
|
+
if (count > 0) {
|
|
6518
|
+
pushBlocker(rows, "remote", `${prefix}:unsupported:${name}`, `${prefix} ${name} has ${count} rows; this migration bundle does not preserve that table yet`);
|
|
6519
|
+
}
|
|
6520
|
+
}
|
|
6521
|
+
for (const [name, count] of Object.entries(checks.volatileCounts)) {
|
|
6522
|
+
if (name === "daemonLeases") {
|
|
6523
|
+
if (count > 0)
|
|
6524
|
+
warnings.push(`${prefix} daemon_lease has ${count} volatile rows; they are intentionally not exported as authority`);
|
|
6525
|
+
continue;
|
|
6526
|
+
}
|
|
6527
|
+
if (count > 0) {
|
|
6528
|
+
pushBlocker(rows, "remote", `${prefix}:volatile:${name}`, `${prefix} ${name} has ${count} rows; stop or finish active work before no-loss migration`);
|
|
6529
|
+
}
|
|
6530
|
+
}
|
|
6531
|
+
return rows;
|
|
6532
|
+
}
|
|
6533
|
+
function sanitizeCommandEnv(value, blockers, resource, id, name) {
|
|
6534
|
+
const copy = structuredClone(value);
|
|
6535
|
+
const target = copy.target;
|
|
6536
|
+
if (target && typeof target === "object" && !Array.isArray(target) && target.type === "command") {
|
|
6537
|
+
const commandTarget = target;
|
|
6538
|
+
if (commandTarget.env && Object.keys(commandTarget.env).length > 0) {
|
|
6539
|
+
commandTarget.env = Object.fromEntries(Object.keys(commandTarget.env).map((key) => [key, "[redacted]"]));
|
|
6540
|
+
pushBlocker(blockers, resource, id, "command target env values are redacted and cannot be imported as a no-loss row", name);
|
|
6541
|
+
}
|
|
6542
|
+
}
|
|
6543
|
+
return copy;
|
|
6544
|
+
}
|
|
6545
|
+
function sanitizeWorkflow(workflow, blockers) {
|
|
6546
|
+
const copy = structuredClone(workflow);
|
|
6547
|
+
copy.steps = copy.steps.map((step) => {
|
|
6548
|
+
if (step.target.type !== "command" || !step.target.env || Object.keys(step.target.env).length === 0)
|
|
6549
|
+
return step;
|
|
6550
|
+
pushBlocker(blockers, "workflow", workflow.id, `workflow step ${step.id} command env values are redacted and cannot be imported as a no-loss row`, workflow.name);
|
|
6551
|
+
return {
|
|
6552
|
+
...step,
|
|
6553
|
+
target: {
|
|
6554
|
+
...step.target,
|
|
6555
|
+
env: Object.fromEntries(Object.keys(step.target.env).map((key) => [key, "[redacted]"]))
|
|
6556
|
+
}
|
|
6557
|
+
};
|
|
6558
|
+
});
|
|
6559
|
+
return copy;
|
|
6560
|
+
}
|
|
6561
|
+
function sanitizeExportRows(rows) {
|
|
6562
|
+
const blockers = [];
|
|
6563
|
+
const warnings = [];
|
|
6564
|
+
blockers.push(...checksToBlockers(rows.checks, "source", warnings));
|
|
6565
|
+
const workflows = rows.workflows.map((workflow) => sanitizeWorkflow(workflow, blockers));
|
|
6566
|
+
const loops = rows.loops.map((loop) => sanitizeCommandEnv(loop, blockers, "loop", loop.id, loop.name));
|
|
6567
|
+
const beforeScrub = JSON.stringify({ workflows, loops, runs: rows.runs });
|
|
6568
|
+
const data = scrubSecretsDeep({ workflows, loops, runs: rows.runs });
|
|
6569
|
+
if (JSON.stringify(data) !== beforeScrub) {
|
|
6570
|
+
warnings.push("secret-looking strings were scrubbed from the export bundle; treat this as non-importable unless every scrub is expected");
|
|
6571
|
+
pushBlocker(blockers, "remote", "secret-scrub", "secret-looking strings were scrubbed from the export bundle");
|
|
6572
|
+
}
|
|
6573
|
+
return { data, blockers, warnings };
|
|
6574
|
+
}
|
|
6575
|
+
function exportLoopsMigrationBundle(store, opts = {}) {
|
|
6576
|
+
const rows = store.exportMigrationRows({ includeRuns: opts.includeRuns ?? true });
|
|
6577
|
+
const sanitized = sanitizeExportRows(rows);
|
|
6578
|
+
const bundleBody = {
|
|
6579
|
+
schema: LOOPS_MIGRATION_SCHEMA,
|
|
6580
|
+
packageVersion: packageVersion(),
|
|
6581
|
+
exportedAt: new Date().toISOString(),
|
|
6582
|
+
source: {
|
|
6583
|
+
backend: "sqlite",
|
|
6584
|
+
schemaVersion: rows.schemaVersion,
|
|
6585
|
+
hostname: hostname2()
|
|
6586
|
+
},
|
|
6587
|
+
checks: rows.checks,
|
|
6588
|
+
importable: sanitized.blockers.length === 0,
|
|
6589
|
+
counts: {
|
|
6590
|
+
workflows: sanitized.data.workflows.length,
|
|
6591
|
+
loops: sanitized.data.loops.length,
|
|
6592
|
+
runs: sanitized.data.runs.length
|
|
6593
|
+
},
|
|
6594
|
+
data: sanitized.data,
|
|
6595
|
+
blockers: sanitized.blockers,
|
|
6596
|
+
warnings: sanitized.warnings
|
|
6597
|
+
};
|
|
6598
|
+
return {
|
|
6599
|
+
...bundleBody,
|
|
6600
|
+
hash: migrationHash(bundleBody)
|
|
6601
|
+
};
|
|
6602
|
+
}
|
|
6603
|
+
function validateLoopsMigrationBundle(value) {
|
|
6604
|
+
if (!value || typeof value !== "object")
|
|
6605
|
+
throw new ValidationError("migration bundle must be a JSON object");
|
|
6606
|
+
const bundle = value;
|
|
6607
|
+
if (bundle.schema !== LOOPS_MIGRATION_SCHEMA)
|
|
6608
|
+
throw new ValidationError(`unsupported migration bundle schema: ${String(bundle.schema)}`);
|
|
6609
|
+
if (!bundle.data || !Array.isArray(bundle.data.workflows) || !Array.isArray(bundle.data.loops) || !Array.isArray(bundle.data.runs)) {
|
|
6610
|
+
throw new ValidationError("migration bundle data must include workflows, loops, and runs arrays");
|
|
6611
|
+
}
|
|
6612
|
+
if (!bundle.checks || !bundle.counts || !bundle.source || !bundle.hash)
|
|
6613
|
+
throw new ValidationError("migration bundle is missing required metadata");
|
|
6614
|
+
const typed = bundle;
|
|
6615
|
+
assertMigrationBundleIntegrity(typed);
|
|
6616
|
+
return typed;
|
|
6617
|
+
}
|
|
6618
|
+
function assertMigrationBundleIntegrity(bundle) {
|
|
6619
|
+
const { hash: _hash, ...body } = bundle;
|
|
6620
|
+
const expectedHash = migrationHash(body);
|
|
6621
|
+
if (bundle.hash !== expectedHash)
|
|
6622
|
+
throw new ValidationError("migration bundle hash mismatch");
|
|
6623
|
+
}
|
|
6624
|
+
function rowPlanSummary(plan) {
|
|
6625
|
+
const count = (action) => plan.rows.filter((row) => row.action === action).length;
|
|
6626
|
+
return {
|
|
6627
|
+
dryRun: plan.dryRun,
|
|
6628
|
+
replace: plan.replace,
|
|
6629
|
+
importable: plan.importable,
|
|
6630
|
+
workflows: plan.rows.filter((row) => row.resource === "workflow").length,
|
|
6631
|
+
loops: plan.rows.filter((row) => row.resource === "loop").length,
|
|
6632
|
+
runs: plan.rows.filter((row) => row.resource === "run").length,
|
|
6633
|
+
insert: count("insert"),
|
|
6634
|
+
update: count("update"),
|
|
6635
|
+
skip: count("skip"),
|
|
6636
|
+
conflict: count("conflict"),
|
|
6637
|
+
blocked: count("blocked")
|
|
6638
|
+
};
|
|
6639
|
+
}
|
|
6640
|
+
function finalizePlan(plan) {
|
|
6641
|
+
return { ...plan, summary: rowPlanSummary(plan) };
|
|
6642
|
+
}
|
|
6643
|
+
function compareResource(current, incoming, opts) {
|
|
6644
|
+
const incomingHash = migrationHash(incoming);
|
|
6645
|
+
if (!current)
|
|
6646
|
+
return { resource: opts.resource, id: opts.id, name: opts.name, action: "insert", incomingHash };
|
|
6647
|
+
const currentHash = migrationHash(current);
|
|
6648
|
+
if (currentHash === incomingHash)
|
|
6649
|
+
return { resource: opts.resource, id: opts.id, name: opts.name, action: "skip", incomingHash, currentHash };
|
|
6650
|
+
return {
|
|
6651
|
+
resource: opts.resource,
|
|
6652
|
+
id: opts.id,
|
|
6653
|
+
name: opts.name,
|
|
6654
|
+
action: opts.replace ? "update" : "conflict",
|
|
6655
|
+
reason: opts.replace ? "existing row differs and --replace was requested" : "existing row differs; rerun with --replace to update",
|
|
6656
|
+
incomingHash,
|
|
6657
|
+
currentHash
|
|
6658
|
+
};
|
|
6659
|
+
}
|
|
6660
|
+
function buildImportMigrationPlan(store, bundle, opts = {}) {
|
|
6661
|
+
assertMigrationBundleIntegrity(bundle);
|
|
6662
|
+
const includeRuns = opts.includeRuns ?? true;
|
|
6663
|
+
const replace = opts.replace ?? false;
|
|
6664
|
+
const rows = [];
|
|
6665
|
+
const warnings = [...bundle.warnings ?? []];
|
|
6666
|
+
rows.push(...checksToBlockers(store.exportMigrationRows({ includeRuns: false }).checks, "destination", warnings));
|
|
6667
|
+
if (!bundle.importable || bundle.blockers.length > 0) {
|
|
6668
|
+
rows.push(...bundle.blockers.map((row) => ({ ...row, action: "blocked" })));
|
|
6669
|
+
}
|
|
6670
|
+
const workflowIds = new Set(bundle.data.workflows.map((workflow) => workflow.id));
|
|
6671
|
+
const loopIds = new Set(bundle.data.loops.map((loop) => loop.id));
|
|
6672
|
+
for (const workflow of bundle.data.workflows) {
|
|
6673
|
+
const redactedStep = workflow.steps.find((step) => step.target.type === "command" && step.target.env && Object.values(step.target.env).includes("[redacted]"));
|
|
6674
|
+
if (redactedStep) {
|
|
6675
|
+
rows.push({
|
|
6676
|
+
resource: "workflow",
|
|
6677
|
+
id: workflow.id,
|
|
6678
|
+
name: workflow.name,
|
|
6679
|
+
action: "blocked",
|
|
6680
|
+
reason: `workflow step ${redactedStep.id} has redacted command env values`,
|
|
6681
|
+
incomingHash: migrationHash(workflow)
|
|
6682
|
+
});
|
|
6683
|
+
continue;
|
|
6684
|
+
}
|
|
6685
|
+
const activeNameCollision = store.listWorkflows({ status: "active" }).find((current) => current.name === workflow.name && current.id !== workflow.id);
|
|
6686
|
+
if (workflow.status === "active" && activeNameCollision) {
|
|
6687
|
+
rows.push({
|
|
6688
|
+
resource: "workflow",
|
|
6689
|
+
id: workflow.id,
|
|
6690
|
+
name: workflow.name,
|
|
6691
|
+
action: "conflict",
|
|
6692
|
+
reason: `active workflow name collides with existing workflow ${activeNameCollision.id}`,
|
|
6693
|
+
incomingHash: migrationHash(workflow),
|
|
6694
|
+
currentHash: migrationHash(activeNameCollision)
|
|
6695
|
+
});
|
|
6696
|
+
continue;
|
|
6697
|
+
}
|
|
6698
|
+
rows.push(compareResource(store.getWorkflow(workflow.id), workflow, { replace, resource: "workflow", id: workflow.id, name: workflow.name }));
|
|
6699
|
+
}
|
|
6700
|
+
for (const loop of bundle.data.loops) {
|
|
6701
|
+
if (loop.target.type === "command" && loop.target.env && Object.values(loop.target.env).includes("[redacted]")) {
|
|
6702
|
+
rows.push({
|
|
6703
|
+
resource: "loop",
|
|
6704
|
+
id: loop.id,
|
|
6705
|
+
name: loop.name,
|
|
6706
|
+
action: "blocked",
|
|
6707
|
+
reason: "loop has redacted command env values",
|
|
6708
|
+
incomingHash: migrationHash(loop)
|
|
6709
|
+
});
|
|
6710
|
+
continue;
|
|
6711
|
+
}
|
|
6712
|
+
if (loop.target.type === "workflow" && !workflowIds.has(loop.target.workflowId) && !store.getWorkflow(loop.target.workflowId)) {
|
|
6713
|
+
rows.push({
|
|
6714
|
+
resource: "loop",
|
|
6715
|
+
id: loop.id,
|
|
6716
|
+
name: loop.name,
|
|
6717
|
+
action: "blocked",
|
|
6718
|
+
reason: `workflow target ${loop.target.workflowId} is not present in bundle or destination store`,
|
|
6719
|
+
incomingHash: migrationHash(loop)
|
|
6720
|
+
});
|
|
6721
|
+
continue;
|
|
6722
|
+
}
|
|
6723
|
+
rows.push(compareResource(store.getLoop(loop.id), loop, { replace, resource: "loop", id: loop.id, name: loop.name }));
|
|
6724
|
+
}
|
|
6725
|
+
if (includeRuns) {
|
|
6726
|
+
for (const run of bundle.data.runs) {
|
|
6727
|
+
if (run.status === "running") {
|
|
6728
|
+
rows.push({
|
|
6729
|
+
resource: "run",
|
|
6730
|
+
id: run.id,
|
|
6731
|
+
name: run.loopName,
|
|
6732
|
+
action: "blocked",
|
|
6733
|
+
reason: "running rows carry volatile lease/process ownership and must finish before import",
|
|
6734
|
+
incomingHash: migrationHash(run)
|
|
6735
|
+
});
|
|
6736
|
+
continue;
|
|
6737
|
+
}
|
|
6738
|
+
if (!loopIds.has(run.loopId) && !store.getLoop(run.loopId)) {
|
|
6739
|
+
rows.push({
|
|
6740
|
+
resource: "run",
|
|
6741
|
+
id: run.id,
|
|
6742
|
+
name: run.loopName,
|
|
6743
|
+
action: "blocked",
|
|
6744
|
+
reason: `loop ${run.loopId} is not present in bundle or destination store`,
|
|
6745
|
+
incomingHash: migrationHash(run)
|
|
6746
|
+
});
|
|
6747
|
+
continue;
|
|
6748
|
+
}
|
|
6749
|
+
const slot = store.getRunBySlot(run.loopId, run.scheduledFor);
|
|
6750
|
+
if (slot && slot.id !== run.id) {
|
|
6751
|
+
rows.push({
|
|
6752
|
+
resource: "run",
|
|
6753
|
+
id: run.id,
|
|
6754
|
+
name: run.loopName,
|
|
6755
|
+
action: "conflict",
|
|
6756
|
+
reason: `scheduled slot already belongs to run ${slot.id}`,
|
|
6757
|
+
incomingHash: migrationHash(run),
|
|
6758
|
+
currentHash: migrationHash(slot)
|
|
6759
|
+
});
|
|
6760
|
+
continue;
|
|
6761
|
+
}
|
|
6762
|
+
rows.push(compareResource(store.getRun(run.id), run, { replace, resource: "run", id: run.id, name: run.loopName }));
|
|
6763
|
+
}
|
|
6764
|
+
} else if (bundle.data.runs.length > 0) {
|
|
6765
|
+
warnings.push("run history is present in the bundle but --no-runs was requested");
|
|
6766
|
+
}
|
|
6767
|
+
const plan = finalizePlan({
|
|
6768
|
+
schema: LOOPS_MIGRATION_SCHEMA,
|
|
6769
|
+
operation: "import",
|
|
6770
|
+
dryRun: opts.dryRun ?? true,
|
|
6771
|
+
replace,
|
|
6772
|
+
importable: bundle.importable && rows.every((row) => row.action !== "blocked" && row.action !== "conflict"),
|
|
6773
|
+
rows,
|
|
6774
|
+
warnings
|
|
6775
|
+
});
|
|
6776
|
+
return { ...plan, importable: plan.summary.blocked === 0 && plan.summary.conflict === 0 };
|
|
6777
|
+
}
|
|
6778
|
+
function applyImportMigrationBundle(store, bundle, opts = {}) {
|
|
6779
|
+
const plan = buildImportMigrationPlan(store, bundle, { ...opts, dryRun: false });
|
|
6780
|
+
if (plan.summary.blocked > 0 || plan.summary.conflict > 0 || !plan.importable) {
|
|
6781
|
+
throw new ValidationError(`migration import is not safe to apply: blocked=${plan.summary.blocked} conflict=${plan.summary.conflict}`);
|
|
6782
|
+
}
|
|
6783
|
+
const applied = { workflows: 0, loops: 0, runs: 0 };
|
|
6784
|
+
let appliedPlan = plan;
|
|
6785
|
+
store.writeTransaction(() => {
|
|
6786
|
+
appliedPlan = buildImportMigrationPlan(store, bundle, { ...opts, dryRun: false });
|
|
6787
|
+
if (appliedPlan.summary.blocked > 0 || appliedPlan.summary.conflict > 0 || !appliedPlan.importable) {
|
|
6788
|
+
throw new ValidationError(`destination store changed before import apply: blocked=${appliedPlan.summary.blocked} conflict=${appliedPlan.summary.conflict}`);
|
|
6789
|
+
}
|
|
6790
|
+
for (const workflow of bundle.data.workflows) {
|
|
6791
|
+
const row = appliedPlan.rows.find((entry) => entry.resource === "workflow" && entry.id === workflow.id);
|
|
6792
|
+
if (row?.action === "insert" || row?.action === "update") {
|
|
6793
|
+
store.upsertMigrationWorkflow(workflow, { replace: opts.replace });
|
|
6794
|
+
applied.workflows += 1;
|
|
6795
|
+
}
|
|
6796
|
+
}
|
|
6797
|
+
for (const loop of bundle.data.loops) {
|
|
6798
|
+
const row = appliedPlan.rows.find((entry) => entry.resource === "loop" && entry.id === loop.id);
|
|
6799
|
+
if (row?.action === "insert" || row?.action === "update") {
|
|
6800
|
+
store.upsertMigrationLoop(loop, { replace: opts.replace });
|
|
6801
|
+
applied.loops += 1;
|
|
6802
|
+
}
|
|
6803
|
+
}
|
|
6804
|
+
if (opts.includeRuns ?? true) {
|
|
6805
|
+
for (const run of bundle.data.runs) {
|
|
6806
|
+
const row = appliedPlan.rows.find((entry) => entry.resource === "run" && entry.id === run.id);
|
|
6807
|
+
if (row?.action === "insert" || row?.action === "update") {
|
|
6808
|
+
store.upsertMigrationRun(run, { replace: opts.replace });
|
|
6809
|
+
applied.runs += 1;
|
|
6810
|
+
}
|
|
6811
|
+
}
|
|
6812
|
+
}
|
|
6813
|
+
});
|
|
6814
|
+
return { plan: appliedPlan, applied };
|
|
6815
|
+
}
|
|
6816
|
+
function envValue2(env, keys) {
|
|
6817
|
+
for (const key of keys) {
|
|
6818
|
+
const value = env[key]?.trim();
|
|
6819
|
+
if (value)
|
|
6820
|
+
return value;
|
|
6821
|
+
}
|
|
6822
|
+
return;
|
|
6823
|
+
}
|
|
6824
|
+
function resolveApiConfig(opts) {
|
|
6825
|
+
const env = opts.env ?? process.env;
|
|
6826
|
+
return {
|
|
6827
|
+
apiUrl: opts.apiUrl ?? envValue2(env, ["LOOPS_API_URL", "HASNA_LOOPS_API_URL", "LOOPS_CLOUD_API_URL", "HASNA_LOOPS_CLOUD_API_URL"]),
|
|
6828
|
+
token: opts.apiToken ?? envValue2(env, ["LOOPS_API_TOKEN", "HASNA_LOOPS_API_TOKEN", "LOOPS_CLOUD_TOKEN", "HASNA_LOOPS_CLOUD_TOKEN"])
|
|
6829
|
+
};
|
|
6830
|
+
}
|
|
6831
|
+
function isLocalApiUrl(value) {
|
|
6832
|
+
try {
|
|
6833
|
+
return ["127.0.0.1", "localhost", "::1"].includes(new URL(value).hostname);
|
|
6834
|
+
} catch {
|
|
6835
|
+
return false;
|
|
6836
|
+
}
|
|
6837
|
+
}
|
|
6838
|
+
function endpoint(base, path) {
|
|
6839
|
+
return new URL(path.replace(/^\//, ""), base.endsWith("/") ? base : `${base}/`).toString();
|
|
6840
|
+
}
|
|
6841
|
+
async function requestJson(fetchImpl, config, path, init = {}) {
|
|
6842
|
+
const response = await fetchImpl(endpoint(config.apiUrl, path), {
|
|
6843
|
+
...init,
|
|
6844
|
+
headers: {
|
|
6845
|
+
...init.body ? { "content-type": "application/json" } : {},
|
|
6846
|
+
...config.token ? { authorization: `Bearer ${config.token}` } : {},
|
|
6847
|
+
...init.headers
|
|
6848
|
+
}
|
|
6849
|
+
});
|
|
6850
|
+
const payload = await response.json().catch(() => ({}));
|
|
6851
|
+
if (!response.ok)
|
|
6852
|
+
throw new Error(typeof payload.error === "string" ? payload.error : `loops-api request failed: ${response.status}`);
|
|
6853
|
+
return payload;
|
|
6854
|
+
}
|
|
6855
|
+
async function fetchRemotePreview(opts) {
|
|
6856
|
+
const config = resolveApiConfig(opts);
|
|
6857
|
+
const warnings = [];
|
|
6858
|
+
if (!config.apiUrl) {
|
|
6859
|
+
warnings.push("LOOPS_API_URL or HASNA_LOOPS_API_URL is required to inspect a self-hosted control plane");
|
|
6860
|
+
return { loops: [], runs: [], warnings };
|
|
6861
|
+
}
|
|
6862
|
+
if (!isLocalApiUrl(config.apiUrl) && !config.token) {
|
|
6863
|
+
warnings.push("non-local self-hosted APIs require LOOPS_API_TOKEN or HASNA_LOOPS_API_TOKEN");
|
|
6864
|
+
return { loops: [], runs: [], warnings };
|
|
6865
|
+
}
|
|
6866
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
6867
|
+
const loopsPayload = await requestJson(fetchImpl, { apiUrl: config.apiUrl, token: config.token }, "/v1/loops?includeArchived=true&limit=1000");
|
|
6868
|
+
const runsPayload = opts.includeRuns === false ? { runs: [] } : await requestJson(fetchImpl, { apiUrl: config.apiUrl, token: config.token }, "/v1/runs?limit=1000");
|
|
6869
|
+
return {
|
|
6870
|
+
loops: Array.isArray(loopsPayload.loops) ? loopsPayload.loops : [],
|
|
6871
|
+
runs: Array.isArray(runsPayload.runs) ? runsPayload.runs : [],
|
|
6872
|
+
warnings
|
|
6873
|
+
};
|
|
6874
|
+
}
|
|
6875
|
+
async function buildSelfHostedMigrationPlan(store, opts) {
|
|
6876
|
+
const bundle = exportLoopsMigrationBundle(store, { includeRuns: opts.includeRuns ?? true });
|
|
6877
|
+
const remote = await fetchRemotePreview(opts);
|
|
6878
|
+
const rows = [...bundle.blockers.map((row) => ({ ...row, action: "blocked" }))];
|
|
6879
|
+
const warnings = [
|
|
6880
|
+
...bundle.warnings,
|
|
6881
|
+
...remote.warnings,
|
|
6882
|
+
"self-hosted remote apply is preview-only until the control plane exposes id-preserving workflow/loop/run import endpoints"
|
|
6883
|
+
];
|
|
6884
|
+
if (opts.operation === "self-hosted-pull") {
|
|
6885
|
+
for (const entry of remote.loops) {
|
|
6886
|
+
const value = entry && typeof entry === "object" ? entry : {};
|
|
6887
|
+
const id = typeof value.id === "string" ? value.id : `remote-loop:${rows.length}`;
|
|
6888
|
+
rows.push({
|
|
6889
|
+
resource: "loop",
|
|
6890
|
+
id,
|
|
6891
|
+
name: typeof value.name === "string" ? value.name : undefined,
|
|
6892
|
+
action: "blocked",
|
|
6893
|
+
reason: "remote loop pull needs a full id-preserving export/import endpoint; /v1/loops returns public redacted rows only",
|
|
6894
|
+
currentHash: migrationHash(entry)
|
|
6895
|
+
});
|
|
6896
|
+
}
|
|
6897
|
+
for (const entry of remote.runs) {
|
|
6898
|
+
const value = entry && typeof entry === "object" ? entry : {};
|
|
6899
|
+
const id = typeof value.id === "string" ? value.id : `remote-run:${rows.length}`;
|
|
6900
|
+
rows.push({
|
|
6901
|
+
resource: "run",
|
|
6902
|
+
id,
|
|
6903
|
+
name: typeof value.loopName === "string" ? value.loopName : undefined,
|
|
6904
|
+
action: "blocked",
|
|
6905
|
+
reason: "remote run-history pull needs an id-preserving export/import endpoint; /v1/runs returns public redacted rows only",
|
|
6906
|
+
currentHash: migrationHash(entry)
|
|
6907
|
+
});
|
|
6908
|
+
}
|
|
6909
|
+
const plan2 = finalizePlan({
|
|
6910
|
+
schema: LOOPS_MIGRATION_SCHEMA,
|
|
6911
|
+
operation: opts.operation,
|
|
6912
|
+
dryRun: true,
|
|
6913
|
+
replace: false,
|
|
6914
|
+
importable: false,
|
|
6915
|
+
rows,
|
|
6916
|
+
warnings
|
|
6917
|
+
});
|
|
6918
|
+
return { ...plan2, importable: false };
|
|
6919
|
+
}
|
|
6920
|
+
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]));
|
|
6921
|
+
for (const workflow of bundle.data.workflows) {
|
|
6922
|
+
rows.push({
|
|
6923
|
+
resource: "workflow",
|
|
6924
|
+
id: workflow.id,
|
|
6925
|
+
name: workflow.name,
|
|
6926
|
+
action: "blocked",
|
|
6927
|
+
reason: "self-hosted API does not expose id-preserving workflow import/list endpoints yet",
|
|
6928
|
+
incomingHash: migrationHash(workflow)
|
|
6929
|
+
});
|
|
6930
|
+
}
|
|
6931
|
+
for (const loop of bundle.data.loops) {
|
|
6932
|
+
const remoteLoop = remoteLoopsByName.get(loop.name);
|
|
6933
|
+
rows.push({
|
|
6934
|
+
resource: "loop",
|
|
6935
|
+
id: loop.id,
|
|
6936
|
+
name: loop.name,
|
|
6937
|
+
action: "blocked",
|
|
6938
|
+
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",
|
|
6939
|
+
incomingHash: migrationHash(loop),
|
|
6940
|
+
currentHash: remoteLoop ? migrationHash(remoteLoop) : undefined
|
|
6941
|
+
});
|
|
6942
|
+
}
|
|
6943
|
+
if (opts.includeRuns ?? true) {
|
|
6944
|
+
for (const run of bundle.data.runs) {
|
|
6945
|
+
rows.push({
|
|
6946
|
+
resource: "run",
|
|
6947
|
+
id: run.id,
|
|
6948
|
+
name: run.loopName,
|
|
6949
|
+
action: "blocked",
|
|
6950
|
+
reason: "self-hosted API does not expose run-history import endpoints; remote runners should create new run rows",
|
|
6951
|
+
incomingHash: migrationHash(run)
|
|
6952
|
+
});
|
|
6953
|
+
}
|
|
6954
|
+
}
|
|
6955
|
+
const plan = finalizePlan({
|
|
6956
|
+
schema: LOOPS_MIGRATION_SCHEMA,
|
|
6957
|
+
operation: opts.operation,
|
|
6958
|
+
dryRun: true,
|
|
6959
|
+
replace: false,
|
|
6960
|
+
importable: false,
|
|
6961
|
+
rows,
|
|
6962
|
+
warnings
|
|
6963
|
+
});
|
|
6964
|
+
return { ...plan, importable: false };
|
|
6965
|
+
}
|
|
6966
|
+
async function registerSelfHostedRunner(opts) {
|
|
6967
|
+
const config = resolveApiConfig(opts);
|
|
6968
|
+
if (!config.apiUrl)
|
|
6969
|
+
throw new ValidationError("LOOPS_API_URL or --api-url is required");
|
|
6970
|
+
if (!isLocalApiUrl(config.apiUrl) && !config.token)
|
|
6971
|
+
throw new ValidationError("non-local self-hosted APIs require LOOPS_API_TOKEN or HASNA_LOOPS_API_TOKEN");
|
|
6972
|
+
const payload = await requestJson(opts.fetchImpl ?? fetch, { apiUrl: config.apiUrl, token: config.token }, "/v1/runners/register", {
|
|
6973
|
+
method: "POST",
|
|
6974
|
+
body: JSON.stringify({
|
|
6975
|
+
runnerId: opts.runnerId,
|
|
6976
|
+
machineId: opts.machineId,
|
|
6977
|
+
labels: opts.labels ?? {},
|
|
6978
|
+
capabilities: opts.capabilities ?? {}
|
|
6979
|
+
})
|
|
6980
|
+
});
|
|
6981
|
+
return {
|
|
6982
|
+
ok: payload.ok === true,
|
|
6983
|
+
runner: payload.runner && typeof payload.runner === "object" ? payload.runner : {}
|
|
6984
|
+
};
|
|
6985
|
+
}
|
|
6986
|
+
function publicMigrationBundle(bundle) {
|
|
6987
|
+
return {
|
|
6988
|
+
...bundle,
|
|
6989
|
+
data: {
|
|
6990
|
+
workflows: bundle.data.workflows.map(publicWorkflow),
|
|
6991
|
+
loops: bundle.data.loops.map(publicLoop),
|
|
6992
|
+
runs: bundle.data.runs.map((run) => publicRun(run, false, { redactError: true }))
|
|
6993
|
+
}
|
|
6994
|
+
};
|
|
6995
|
+
}
|
|
6996
|
+
function selfHostedControlPlaneSummary(env = process.env) {
|
|
6997
|
+
const config = loopControlPlaneConfig(env);
|
|
6998
|
+
return {
|
|
6999
|
+
apiUrl: config.apiUrl,
|
|
7000
|
+
databaseUrlPresent: config.databaseUrlPresent,
|
|
7001
|
+
authTokenPresent: config.apiAuthTokenPresent
|
|
7002
|
+
};
|
|
7003
|
+
}
|
|
7004
|
+
|
|
6046
7005
|
// src/lib/goal/metadata.ts
|
|
6047
7006
|
function goalExecutionContext(parts) {
|
|
6048
7007
|
return {
|
|
@@ -7414,6 +8373,18 @@ class LoopsClient {
|
|
|
7414
8373
|
const result = await runLoopNow({ store: this.store, idOrName, runnerId: this.runnerId });
|
|
7415
8374
|
return result.run;
|
|
7416
8375
|
}
|
|
8376
|
+
exportBundle(opts = {}) {
|
|
8377
|
+
return exportLoopsMigrationBundle(this.store, opts);
|
|
8378
|
+
}
|
|
8379
|
+
planImport(bundle, opts = {}) {
|
|
8380
|
+
return buildImportMigrationPlan(this.store, bundle, opts);
|
|
8381
|
+
}
|
|
8382
|
+
importBundle(bundle, opts = {}) {
|
|
8383
|
+
return applyImportMigrationBundle(this.store, bundle, opts);
|
|
8384
|
+
}
|
|
8385
|
+
planSelfHostedMigration(opts = {}) {
|
|
8386
|
+
return buildSelfHostedMigrationPlan(this.store, { ...opts, operation: opts.operation ?? "self-hosted-migrate" });
|
|
8387
|
+
}
|
|
7417
8388
|
close() {
|
|
7418
8389
|
if (this.ownStore)
|
|
7419
8390
|
this.store.close();
|
|
@@ -7462,8 +8433,16 @@ function openAutomationsRuntimeBinding(overrides = {}) {
|
|
|
7462
8433
|
};
|
|
7463
8434
|
}
|
|
7464
8435
|
export {
|
|
8436
|
+
validateLoopsMigrationBundle,
|
|
7465
8437
|
runGoal,
|
|
8438
|
+
registerSelfHostedRunner,
|
|
7466
8439
|
openAutomationsRuntimeBinding,
|
|
8440
|
+
migrationHash,
|
|
7467
8441
|
loops,
|
|
7468
|
-
|
|
8442
|
+
exportLoopsMigrationBundle,
|
|
8443
|
+
buildSelfHostedMigrationPlan,
|
|
8444
|
+
buildImportMigrationPlan,
|
|
8445
|
+
applyImportMigrationBundle,
|
|
8446
|
+
LoopsClient,
|
|
8447
|
+
LOOPS_MIGRATION_SCHEMA
|
|
7469
8448
|
};
|