@hasna/loops 0.4.2 → 0.4.4

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