@hasna/loops 0.4.11 → 0.4.13
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 +80 -0
- package/README.md +35 -7
- package/dist/api/index.js +56 -1
- package/dist/cli/index.js +324 -31
- package/dist/daemon/index.js +37 -6
- package/dist/index.d.ts +1 -1
- package/dist/index.js +238 -25
- package/dist/lib/mode.d.ts +27 -0
- package/dist/lib/mode.js +56 -1
- package/dist/lib/route/profile-pool.d.ts +60 -0
- package/dist/lib/route/throttle.d.ts +1 -0
- package/dist/lib/route/types.d.ts +2 -0
- package/dist/lib/storage/index.js +40 -5
- package/dist/lib/storage/postgres-schema.js +4 -0
- package/dist/lib/storage/postgres.js +4 -0
- package/dist/lib/storage/sqlite.js +36 -5
- package/dist/lib/store.d.ts +11 -0
- package/dist/lib/store.js +36 -5
- package/dist/mcp/index.js +373 -135
- package/dist/runner/index.js +56 -1
- package/dist/sdk/index.js +107 -6
- package/dist/types.d.ts +10 -0
- package/docs/DEPLOYMENT_MODES.md +23 -1
- package/docs/USAGE.md +16 -5
- package/package.json +1 -1
package/dist/mcp/index.js
CHANGED
|
@@ -1323,6 +1323,7 @@ function rowToWorkflowWorkItem(row) {
|
|
|
1323
1323
|
subjectRef: row.subject_ref,
|
|
1324
1324
|
projectKey: row.project_key ?? undefined,
|
|
1325
1325
|
projectGroup: row.project_group ?? undefined,
|
|
1326
|
+
routeScope: row.route_scope ?? undefined,
|
|
1326
1327
|
priority: row.priority,
|
|
1327
1328
|
status: row.status,
|
|
1328
1329
|
attempts: row.attempts,
|
|
@@ -1603,6 +1604,13 @@ class Store {
|
|
|
1603
1604
|
this.addColumnIfMissing("loop_runs", "claim_token", "TEXT");
|
|
1604
1605
|
this.db.exec("CREATE INDEX IF NOT EXISTS idx_runs_claim_token ON loop_runs(claim_token) WHERE claim_token IS NOT NULL");
|
|
1605
1606
|
}
|
|
1607
|
+
},
|
|
1608
|
+
{
|
|
1609
|
+
id: "0008_work_item_route_scope",
|
|
1610
|
+
apply: () => {
|
|
1611
|
+
this.addColumnIfMissing("workflow_work_items", "route_scope", "TEXT");
|
|
1612
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_workflow_work_items_scope ON workflow_work_items(route_scope, status)");
|
|
1613
|
+
}
|
|
1606
1614
|
}
|
|
1607
1615
|
];
|
|
1608
1616
|
}
|
|
@@ -1750,6 +1758,7 @@ class Store {
|
|
|
1750
1758
|
subject_ref TEXT NOT NULL,
|
|
1751
1759
|
project_key TEXT,
|
|
1752
1760
|
project_group TEXT,
|
|
1761
|
+
route_scope TEXT,
|
|
1753
1762
|
priority INTEGER NOT NULL,
|
|
1754
1763
|
status TEXT NOT NULL,
|
|
1755
1764
|
attempts INTEGER NOT NULL,
|
|
@@ -1767,6 +1776,13 @@ class Store {
|
|
|
1767
1776
|
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_project ON workflow_work_items(project_key, status);
|
|
1768
1777
|
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_group ON workflow_work_items(project_group, status);
|
|
1769
1778
|
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_invocation ON workflow_work_items(invocation_id);
|
|
1779
|
+
-- idx_workflow_work_items_scope (route_scope, status) is created ONLY by
|
|
1780
|
+
-- migration 0008_work_item_route_scope, never here: this baseline DDL
|
|
1781
|
+
-- re-runs on EVERY open (0001 is not skip-guarded), and on a pre-0008
|
|
1782
|
+
-- database the CREATE TABLE above is a no-op, so an index on route_scope
|
|
1783
|
+
-- here would execute before the column exists and crash the open
|
|
1784
|
+
-- ("no such column: route_scope"). New columns may be folded into the
|
|
1785
|
+
-- CREATE TABLE (fresh-db only); their indexes must live in the migration.
|
|
1770
1786
|
|
|
1771
1787
|
CREATE TABLE IF NOT EXISTS workflow_step_runs (
|
|
1772
1788
|
id TEXT PRIMARY KEY,
|
|
@@ -2558,10 +2574,10 @@ class Store {
|
|
|
2558
2574
|
const id = genId();
|
|
2559
2575
|
const status = input.status ?? "queued";
|
|
2560
2576
|
this.db.query(`INSERT INTO workflow_work_items (id, route_key, idempotency_key, invocation_id, source_type, source_ref,
|
|
2561
|
-
subject_ref, project_key, project_group, priority, status, attempts, next_attempt_at, lease_expires_at,
|
|
2577
|
+
subject_ref, project_key, project_group, route_scope, priority, status, attempts, next_attempt_at, lease_expires_at,
|
|
2562
2578
|
workflow_id, loop_id, workflow_run_id, last_reason, created_at, updated_at)
|
|
2563
2579
|
VALUES ($id, $routeKey, $idempotencyKey, $invocationId, $sourceType, $sourceRef, $subjectRef,
|
|
2564
|
-
$projectKey, $projectGroup, $priority, $status, 0, $nextAttemptAt, NULL, NULL, NULL, NULL,
|
|
2580
|
+
$projectKey, $projectGroup, $routeScope, $priority, $status, 0, $nextAttemptAt, NULL, NULL, NULL, NULL,
|
|
2565
2581
|
$lastReason, $created, $updated)
|
|
2566
2582
|
ON CONFLICT(route_key, idempotency_key) DO UPDATE SET
|
|
2567
2583
|
invocation_id=excluded.invocation_id,
|
|
@@ -2570,6 +2586,7 @@ class Store {
|
|
|
2570
2586
|
subject_ref=excluded.subject_ref,
|
|
2571
2587
|
project_key=excluded.project_key,
|
|
2572
2588
|
project_group=excluded.project_group,
|
|
2589
|
+
route_scope=excluded.route_scope,
|
|
2573
2590
|
priority=excluded.priority,
|
|
2574
2591
|
status=CASE
|
|
2575
2592
|
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled')
|
|
@@ -2611,6 +2628,7 @@ class Store {
|
|
|
2611
2628
|
$subjectRef: input.subjectRef,
|
|
2612
2629
|
$projectKey: input.projectKey ?? null,
|
|
2613
2630
|
$projectGroup: input.projectGroup ?? null,
|
|
2631
|
+
$routeScope: input.routeScope ?? null,
|
|
2614
2632
|
$priority: input.priority ?? 0,
|
|
2615
2633
|
$status: status,
|
|
2616
2634
|
$nextAttemptAt: input.nextAttemptAt ?? null,
|
|
@@ -2648,11 +2666,21 @@ class Store {
|
|
|
2648
2666
|
countActiveWorkflowWorkItems(args = {}) {
|
|
2649
2667
|
const active = ["admitted", "running"];
|
|
2650
2668
|
const placeholders = active.map(() => "?").join(",");
|
|
2651
|
-
const
|
|
2669
|
+
const routeScope = args.routeScope?.trim() || undefined;
|
|
2670
|
+
const global = routeScope ? this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders}) AND route_scope = ?`).get(...active, routeScope)?.count ?? 0 : this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders})`).get(...active)?.count ?? 0;
|
|
2652
2671
|
const project = args.projectKey ? this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders}) AND project_key = ?`).get(...active, args.projectKey)?.count ?? 0 : 0;
|
|
2653
2672
|
const projectGroup = args.projectGroup ? this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders}) AND project_group = ?`).get(...active, args.projectGroup)?.count ?? 0 : undefined;
|
|
2654
2673
|
return { global, project, ...projectGroup !== undefined ? { projectGroup } : {} };
|
|
2655
2674
|
}
|
|
2675
|
+
countRunningWorkflowStepsByAuthProfile() {
|
|
2676
|
+
const rows = this.db.query("SELECT account_profile, COUNT(*) AS count FROM workflow_step_runs WHERE status = 'running' AND account_profile IS NOT NULL GROUP BY account_profile").all();
|
|
2677
|
+
const counts = {};
|
|
2678
|
+
for (const row of rows) {
|
|
2679
|
+
if (row.account_profile)
|
|
2680
|
+
counts[row.account_profile] = row.count;
|
|
2681
|
+
}
|
|
2682
|
+
return counts;
|
|
2683
|
+
}
|
|
2656
2684
|
requeueWorkflowWorkItem(id, patch = {}) {
|
|
2657
2685
|
const current = this.getWorkflowWorkItem(id);
|
|
2658
2686
|
if (!current)
|
|
@@ -3088,6 +3116,9 @@ class Store {
|
|
|
3088
3116
|
}
|
|
3089
3117
|
input.workflow.steps.forEach((step, sequence) => {
|
|
3090
3118
|
const account = step.account ?? step.target.account;
|
|
3119
|
+
const agentTarget = step.target.type === "agent" ? step.target : undefined;
|
|
3120
|
+
const resolvedProfile = account?.profile ?? agentTarget?.authProfile ?? null;
|
|
3121
|
+
const resolvedTool = account?.tool ?? (agentTarget?.authProfile ? agentTarget.provider : null);
|
|
3091
3122
|
this.db.query(`INSERT INTO workflow_step_runs (id, workflow_run_id, step_id, sequence, status, started_at, finished_at,
|
|
3092
3123
|
exit_code, pid, duration_ms, stdout, stderr, error, account_profile, account_tool, created_at, updated_at)
|
|
3093
3124
|
VALUES ($id, $workflowRunId, $stepId, $sequence, 'pending', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
|
|
@@ -3096,8 +3127,8 @@ class Store {
|
|
|
3096
3127
|
$workflowRunId: runId,
|
|
3097
3128
|
$stepId: step.id,
|
|
3098
3129
|
$sequence: sequence,
|
|
3099
|
-
$accountProfile:
|
|
3100
|
-
$accountTool:
|
|
3130
|
+
$accountProfile: resolvedProfile,
|
|
3131
|
+
$accountTool: resolvedTool,
|
|
3101
3132
|
$created: now,
|
|
3102
3133
|
$updated: now
|
|
3103
3134
|
});
|
|
@@ -4234,6 +4265,328 @@ class Store {
|
|
|
4234
4265
|
}
|
|
4235
4266
|
}
|
|
4236
4267
|
}
|
|
4268
|
+
// package.json
|
|
4269
|
+
var package_default = {
|
|
4270
|
+
name: "@hasna/loops",
|
|
4271
|
+
version: "0.4.13",
|
|
4272
|
+
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
4273
|
+
type: "module",
|
|
4274
|
+
main: "dist/index.js",
|
|
4275
|
+
types: "dist/index.d.ts",
|
|
4276
|
+
bin: {
|
|
4277
|
+
loops: "dist/cli/index.js",
|
|
4278
|
+
"loops-daemon": "dist/daemon/index.js",
|
|
4279
|
+
"loops-api": "dist/api/index.js",
|
|
4280
|
+
"loops-runner": "dist/runner/index.js",
|
|
4281
|
+
"loops-mcp": "dist/mcp/index.js"
|
|
4282
|
+
},
|
|
4283
|
+
exports: {
|
|
4284
|
+
".": {
|
|
4285
|
+
types: "./dist/index.d.ts",
|
|
4286
|
+
import: "./dist/index.js"
|
|
4287
|
+
},
|
|
4288
|
+
"./sdk": {
|
|
4289
|
+
types: "./dist/sdk/index.d.ts",
|
|
4290
|
+
import: "./dist/sdk/index.js"
|
|
4291
|
+
},
|
|
4292
|
+
"./mcp": {
|
|
4293
|
+
types: "./dist/mcp/index.d.ts",
|
|
4294
|
+
import: "./dist/mcp/index.js"
|
|
4295
|
+
},
|
|
4296
|
+
"./api": {
|
|
4297
|
+
types: "./dist/api/index.d.ts",
|
|
4298
|
+
import: "./dist/api/index.js"
|
|
4299
|
+
},
|
|
4300
|
+
"./runner": {
|
|
4301
|
+
types: "./dist/runner/index.d.ts",
|
|
4302
|
+
import: "./dist/runner/index.js"
|
|
4303
|
+
},
|
|
4304
|
+
"./mode": {
|
|
4305
|
+
types: "./dist/lib/mode.d.ts",
|
|
4306
|
+
import: "./dist/lib/mode.js"
|
|
4307
|
+
},
|
|
4308
|
+
"./storage": {
|
|
4309
|
+
types: "./dist/lib/store.d.ts",
|
|
4310
|
+
import: "./dist/lib/store.js"
|
|
4311
|
+
},
|
|
4312
|
+
"./storage/contract": {
|
|
4313
|
+
types: "./dist/lib/storage/contract.d.ts",
|
|
4314
|
+
import: "./dist/lib/storage/contract.js"
|
|
4315
|
+
},
|
|
4316
|
+
"./storage/sqlite": {
|
|
4317
|
+
types: "./dist/lib/storage/sqlite.d.ts",
|
|
4318
|
+
import: "./dist/lib/storage/sqlite.js"
|
|
4319
|
+
},
|
|
4320
|
+
"./storage/postgres": {
|
|
4321
|
+
types: "./dist/lib/storage/postgres.d.ts",
|
|
4322
|
+
import: "./dist/lib/storage/postgres.js"
|
|
4323
|
+
},
|
|
4324
|
+
"./storage/postgres-schema": {
|
|
4325
|
+
types: "./dist/lib/storage/postgres-schema.d.ts",
|
|
4326
|
+
import: "./dist/lib/storage/postgres-schema.js"
|
|
4327
|
+
}
|
|
4328
|
+
},
|
|
4329
|
+
files: [
|
|
4330
|
+
"dist",
|
|
4331
|
+
"README.md",
|
|
4332
|
+
"CHANGELOG.md",
|
|
4333
|
+
"docs",
|
|
4334
|
+
"LICENSE"
|
|
4335
|
+
],
|
|
4336
|
+
scripts: {
|
|
4337
|
+
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",
|
|
4338
|
+
"build:bin": "bun build src/cli/index.ts --compile --outfile dist/loops",
|
|
4339
|
+
typecheck: "tsc --noEmit",
|
|
4340
|
+
test: "bun test",
|
|
4341
|
+
"test:boundary": "bun run scripts/no-private-cloud-boundary.mjs",
|
|
4342
|
+
prepare: "test -d dist || bun run build",
|
|
4343
|
+
"dev:cli": "bun run src/cli/index.ts",
|
|
4344
|
+
"dev:daemon": "bun run src/daemon/index.ts",
|
|
4345
|
+
prepublishOnly: "bun run build && bun run typecheck && bun test && bun run test:boundary"
|
|
4346
|
+
},
|
|
4347
|
+
keywords: [
|
|
4348
|
+
"loops",
|
|
4349
|
+
"scheduler",
|
|
4350
|
+
"daemon",
|
|
4351
|
+
"agents",
|
|
4352
|
+
"claude",
|
|
4353
|
+
"cursor",
|
|
4354
|
+
"codewith",
|
|
4355
|
+
"opencode",
|
|
4356
|
+
"cli"
|
|
4357
|
+
],
|
|
4358
|
+
repository: {
|
|
4359
|
+
type: "git",
|
|
4360
|
+
url: "git+https://github.com/hasna/loops.git"
|
|
4361
|
+
},
|
|
4362
|
+
homepage: "https://github.com/hasna/loops#readme",
|
|
4363
|
+
bugs: {
|
|
4364
|
+
url: "https://github.com/hasna/loops/issues"
|
|
4365
|
+
},
|
|
4366
|
+
author: "Hasna <andrei@hasna.com>",
|
|
4367
|
+
license: "Apache-2.0",
|
|
4368
|
+
engines: {
|
|
4369
|
+
bun: ">=1.0.0"
|
|
4370
|
+
},
|
|
4371
|
+
dependencies: {
|
|
4372
|
+
"@hasna/events": "^0.1.9",
|
|
4373
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
4374
|
+
"@openrouter/ai-sdk-provider": "2.9.1",
|
|
4375
|
+
ai: "6.0.204",
|
|
4376
|
+
commander: "^13.1.0",
|
|
4377
|
+
zod: "4.4.3"
|
|
4378
|
+
},
|
|
4379
|
+
optionalDependencies: {
|
|
4380
|
+
"@hasna/machines": "0.0.49"
|
|
4381
|
+
},
|
|
4382
|
+
devDependencies: {
|
|
4383
|
+
"@types/bun": "latest",
|
|
4384
|
+
typescript: "^5.7.3"
|
|
4385
|
+
},
|
|
4386
|
+
publishConfig: {
|
|
4387
|
+
registry: "https://registry.npmjs.org",
|
|
4388
|
+
access: "public"
|
|
4389
|
+
}
|
|
4390
|
+
};
|
|
4391
|
+
|
|
4392
|
+
// src/lib/version.ts
|
|
4393
|
+
function packageVersion() {
|
|
4394
|
+
if (typeof package_default.version !== "string" || package_default.version.trim() === "")
|
|
4395
|
+
throw new Error("package.json version is missing");
|
|
4396
|
+
return package_default.version;
|
|
4397
|
+
}
|
|
4398
|
+
|
|
4399
|
+
// src/lib/mode.ts
|
|
4400
|
+
var LOOP_DEPLOYMENT_MODES = ["local", "self_hosted", "cloud"];
|
|
4401
|
+
var MODE_ENV_KEYS = ["LOOPS_MODE", "HASNA_LOOPS_MODE"];
|
|
4402
|
+
var API_URL_ENV_KEYS = ["LOOPS_API_URL", "HASNA_LOOPS_API_URL"];
|
|
4403
|
+
var CLOUD_API_URL_ENV_KEYS = ["LOOPS_CLOUD_API_URL", "HASNA_LOOPS_CLOUD_API_URL"];
|
|
4404
|
+
var DATABASE_URL_ENV_KEYS = ["LOOPS_DATABASE_URL", "HASNA_LOOPS_DATABASE_URL"];
|
|
4405
|
+
var API_TOKEN_ENV_KEYS = ["LOOPS_API_TOKEN", "HASNA_LOOPS_API_TOKEN"];
|
|
4406
|
+
var CLOUD_TOKEN_ENV_KEYS = ["LOOPS_CLOUD_TOKEN", "HASNA_LOOPS_CLOUD_TOKEN"];
|
|
4407
|
+
var TOKEN_ENV_KEYS = [...API_TOKEN_ENV_KEYS, ...CLOUD_TOKEN_ENV_KEYS];
|
|
4408
|
+
function envValue(env, keys) {
|
|
4409
|
+
for (const key of keys) {
|
|
4410
|
+
const value = env[key]?.trim();
|
|
4411
|
+
if (value)
|
|
4412
|
+
return { key, value };
|
|
4413
|
+
}
|
|
4414
|
+
return;
|
|
4415
|
+
}
|
|
4416
|
+
function normalizeLoopDeploymentMode(value) {
|
|
4417
|
+
const normalized = value.trim().toLowerCase().replace(/-/g, "_");
|
|
4418
|
+
if (normalized === "local")
|
|
4419
|
+
return "local";
|
|
4420
|
+
if (normalized === "self_hosted" || normalized === "selfhosted")
|
|
4421
|
+
return "self_hosted";
|
|
4422
|
+
if (normalized === "cloud" || normalized === "saas")
|
|
4423
|
+
return "cloud";
|
|
4424
|
+
throw new Error(`unsupported OpenLoops deployment mode "${value}"; expected local, self_hosted, or cloud`);
|
|
4425
|
+
}
|
|
4426
|
+
function resolveLoopDeploymentMode(env = process.env) {
|
|
4427
|
+
const explicitMode = envValue(env, MODE_ENV_KEYS);
|
|
4428
|
+
if (explicitMode) {
|
|
4429
|
+
return {
|
|
4430
|
+
deploymentMode: normalizeLoopDeploymentMode(explicitMode.value),
|
|
4431
|
+
source: explicitMode.key
|
|
4432
|
+
};
|
|
4433
|
+
}
|
|
4434
|
+
const cloudApiUrl = envValue(env, CLOUD_API_URL_ENV_KEYS);
|
|
4435
|
+
if (cloudApiUrl)
|
|
4436
|
+
return { deploymentMode: "cloud", source: cloudApiUrl.key };
|
|
4437
|
+
const apiUrl = envValue(env, API_URL_ENV_KEYS);
|
|
4438
|
+
if (apiUrl)
|
|
4439
|
+
return { deploymentMode: "self_hosted", source: apiUrl.key };
|
|
4440
|
+
const databaseUrl = envValue(env, DATABASE_URL_ENV_KEYS);
|
|
4441
|
+
if (databaseUrl)
|
|
4442
|
+
return { deploymentMode: "self_hosted", source: databaseUrl.key };
|
|
4443
|
+
return { deploymentMode: "local", source: "default" };
|
|
4444
|
+
}
|
|
4445
|
+
function loopControlPlaneConfig(env = process.env) {
|
|
4446
|
+
return {
|
|
4447
|
+
apiUrl: envValue(env, API_URL_ENV_KEYS)?.value,
|
|
4448
|
+
cloudApiUrl: envValue(env, CLOUD_API_URL_ENV_KEYS)?.value,
|
|
4449
|
+
databaseUrlPresent: Boolean(envValue(env, DATABASE_URL_ENV_KEYS)),
|
|
4450
|
+
apiAuthTokenPresent: Boolean(envValue(env, API_TOKEN_ENV_KEYS)),
|
|
4451
|
+
cloudAuthTokenPresent: Boolean(envValue(env, CLOUD_TOKEN_ENV_KEYS)),
|
|
4452
|
+
authTokenPresent: Boolean(envValue(env, TOKEN_ENV_KEYS))
|
|
4453
|
+
};
|
|
4454
|
+
}
|
|
4455
|
+
function sourceOfTruthForMode(mode) {
|
|
4456
|
+
if (mode === "local")
|
|
4457
|
+
return "local_sqlite";
|
|
4458
|
+
if (mode === "self_hosted")
|
|
4459
|
+
return "self_hosted_control_plane";
|
|
4460
|
+
return "cloud_control_plane";
|
|
4461
|
+
}
|
|
4462
|
+
var ROUTE_ADMISSION_GATES = [
|
|
4463
|
+
"max_dispatch",
|
|
4464
|
+
"max_active",
|
|
4465
|
+
"max_active_per_project",
|
|
4466
|
+
"max_active_per_project_group",
|
|
4467
|
+
"max_active_scope",
|
|
4468
|
+
"max_per_profile"
|
|
4469
|
+
];
|
|
4470
|
+
function remoteSchedulerBackendForMode(mode, config) {
|
|
4471
|
+
if (mode === "local")
|
|
4472
|
+
return "none";
|
|
4473
|
+
if (mode === "cloud")
|
|
4474
|
+
return config.cloudApiUrl ? "hosted_control_plane_contract" : "unconfigured";
|
|
4475
|
+
if (config.databaseUrlPresent)
|
|
4476
|
+
return "postgres_contract";
|
|
4477
|
+
if (config.apiUrl)
|
|
4478
|
+
return "api_control_plane_contract";
|
|
4479
|
+
return "unconfigured";
|
|
4480
|
+
}
|
|
4481
|
+
function schedulerStateForMode(args) {
|
|
4482
|
+
const nonLocal = args.deploymentMode !== "local";
|
|
4483
|
+
return {
|
|
4484
|
+
authority: args.sourceOfTruth,
|
|
4485
|
+
localStore: {
|
|
4486
|
+
backend: "sqlite",
|
|
4487
|
+
role: args.localRole,
|
|
4488
|
+
runArtifacts: "local_files",
|
|
4489
|
+
routeAdmissionState: "workflow_work_items"
|
|
4490
|
+
},
|
|
4491
|
+
remoteStore: {
|
|
4492
|
+
backend: remoteSchedulerBackendForMode(args.deploymentMode, args.config),
|
|
4493
|
+
configured: nonLocal && args.controlPlaneConfigured,
|
|
4494
|
+
applySupported: false,
|
|
4495
|
+
objectArtifacts: nonLocal ? "object_store_contract" : "none",
|
|
4496
|
+
mutatesAws: false
|
|
4497
|
+
},
|
|
4498
|
+
routeAdmission: {
|
|
4499
|
+
stateStore: nonLocal ? "control_plane_contract" : "local_sqlite",
|
|
4500
|
+
activeStatuses: ["admitted", "running"],
|
|
4501
|
+
gates: ROUTE_ADMISSION_GATES,
|
|
4502
|
+
dryRunEvaluatesLiveCounts: false
|
|
4503
|
+
}
|
|
4504
|
+
};
|
|
4505
|
+
}
|
|
4506
|
+
function displayControlPlaneUrl(value) {
|
|
4507
|
+
if (!value)
|
|
4508
|
+
return;
|
|
4509
|
+
try {
|
|
4510
|
+
const url = new URL(value);
|
|
4511
|
+
const path = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/g, "");
|
|
4512
|
+
return `${url.origin}${path}`;
|
|
4513
|
+
} catch {
|
|
4514
|
+
return "[invalid-url]";
|
|
4515
|
+
}
|
|
4516
|
+
}
|
|
4517
|
+
function buildDeploymentStatus(opts = {}) {
|
|
4518
|
+
const env = opts.env ?? process.env;
|
|
4519
|
+
const active = resolveLoopDeploymentMode(env);
|
|
4520
|
+
const deploymentMode = opts.perspective ?? active.deploymentMode;
|
|
4521
|
+
const config = loopControlPlaneConfig(env);
|
|
4522
|
+
const rawApiUrl = deploymentMode === "cloud" ? config.cloudApiUrl : config.apiUrl;
|
|
4523
|
+
const apiUrl = displayControlPlaneUrl(rawApiUrl);
|
|
4524
|
+
const controlPlaneKind = deploymentMode === "local" ? "none" : deploymentMode;
|
|
4525
|
+
const deploymentAuthTokenPresent = deploymentMode === "cloud" ? config.cloudAuthTokenPresent : deploymentMode === "self_hosted" ? config.apiAuthTokenPresent : false;
|
|
4526
|
+
const controlPlaneConfigured = deploymentMode === "local" ? false : deploymentMode === "self_hosted" ? Boolean(config.apiUrl || config.databaseUrlPresent) : Boolean(config.cloudApiUrl && deploymentAuthTokenPresent);
|
|
4527
|
+
const warnings = [];
|
|
4528
|
+
if (deploymentMode === "self_hosted" && !controlPlaneConfigured) {
|
|
4529
|
+
warnings.push("self_hosted mode needs LOOPS_API_URL or LOOPS_DATABASE_URL before it can become authoritative");
|
|
4530
|
+
}
|
|
4531
|
+
if (deploymentMode === "self_hosted" && config.databaseUrlPresent && !config.apiUrl) {
|
|
4532
|
+
warnings.push("LOOPS_DATABASE_URL selects the self_hosted storage contract; loops-runner still needs LOOPS_API_URL to claim remote work");
|
|
4533
|
+
}
|
|
4534
|
+
if (deploymentMode === "cloud" && config.apiUrl && !config.cloudApiUrl) {
|
|
4535
|
+
warnings.push("LOOPS_API_URL selects self_hosted; cloud mode uses LOOPS_CLOUD_API_URL");
|
|
4536
|
+
}
|
|
4537
|
+
if (deploymentMode === "cloud" && !config.cloudApiUrl) {
|
|
4538
|
+
warnings.push("cloud mode is a public contract until LOOPS_CLOUD_API_URL is configured");
|
|
4539
|
+
}
|
|
4540
|
+
if (deploymentMode === "cloud" && config.cloudApiUrl && !deploymentAuthTokenPresent) {
|
|
4541
|
+
warnings.push("cloud mode needs LOOPS_CLOUD_TOKEN or HASNA_LOOPS_CLOUD_TOKEN before it can become ready");
|
|
4542
|
+
}
|
|
4543
|
+
if (opts.perspective && opts.perspective !== active.deploymentMode) {
|
|
4544
|
+
warnings.push(`active deployment mode is ${active.deploymentMode}; this is the ${opts.perspective} perspective`);
|
|
4545
|
+
}
|
|
4546
|
+
return {
|
|
4547
|
+
packageVersion: packageVersion(),
|
|
4548
|
+
deploymentMode,
|
|
4549
|
+
activeDeploymentMode: active.deploymentMode,
|
|
4550
|
+
active: deploymentMode === active.deploymentMode,
|
|
4551
|
+
deploymentModeSource: active.source,
|
|
4552
|
+
sourceOfTruth: sourceOfTruthForMode(deploymentMode),
|
|
4553
|
+
localStore: {
|
|
4554
|
+
role: deploymentMode === "local" ? "authoritative" : "cache_and_spool"
|
|
4555
|
+
},
|
|
4556
|
+
controlPlane: {
|
|
4557
|
+
kind: controlPlaneKind,
|
|
4558
|
+
configured: controlPlaneConfigured,
|
|
4559
|
+
apiUrl,
|
|
4560
|
+
databaseUrlPresent: config.databaseUrlPresent,
|
|
4561
|
+
authTokenPresent: deploymentAuthTokenPresent
|
|
4562
|
+
},
|
|
4563
|
+
runner: {
|
|
4564
|
+
required: deploymentMode !== "local",
|
|
4565
|
+
role: deploymentMode === "local" ? "daemon" : "control_plane_worker"
|
|
4566
|
+
},
|
|
4567
|
+
schedulerState: schedulerStateForMode({
|
|
4568
|
+
deploymentMode,
|
|
4569
|
+
sourceOfTruth: sourceOfTruthForMode(deploymentMode),
|
|
4570
|
+
localRole: deploymentMode === "local" ? "authoritative" : "cache_and_spool",
|
|
4571
|
+
controlPlaneConfigured,
|
|
4572
|
+
config
|
|
4573
|
+
}),
|
|
4574
|
+
warnings
|
|
4575
|
+
};
|
|
4576
|
+
}
|
|
4577
|
+
function deploymentStatusLine(status) {
|
|
4578
|
+
const configured = status.controlPlane.kind === "none" ? "none" : String(status.controlPlane.configured);
|
|
4579
|
+
const active = status.active ? "active" : `active=${status.activeDeploymentMode}`;
|
|
4580
|
+
return [
|
|
4581
|
+
`deploymentMode=${status.deploymentMode}`,
|
|
4582
|
+
active,
|
|
4583
|
+
`source=${status.deploymentModeSource}`,
|
|
4584
|
+
`truth=${status.sourceOfTruth}`,
|
|
4585
|
+
`local=${status.localStore.role}`,
|
|
4586
|
+
`scheduler=${status.schedulerState.routeAdmission.stateStore}`,
|
|
4587
|
+
`control_plane=${configured}`
|
|
4588
|
+
].join(" ");
|
|
4589
|
+
}
|
|
4237
4590
|
|
|
4238
4591
|
// src/mcp/index.ts
|
|
4239
4592
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
@@ -5536,6 +5889,21 @@ function runDoctor(store) {
|
|
|
5536
5889
|
checks.push(status.running ? { id: "daemon", status: "ok", message: `daemon is running pid=${status.pid}` } : { id: "daemon", status: status.stale ? "warn" : "ok", message: status.stale ? "daemon pid file is stale" : "daemon is not running" });
|
|
5537
5890
|
const failedRuns = store.countRuns("failed");
|
|
5538
5891
|
checks.push(failedRuns === 0 ? { id: "loop-runs", status: "ok", message: "no failed loop runs recorded" } : { id: "loop-runs", status: "warn", message: `${failedRuns} failed loop run(s) recorded` });
|
|
5892
|
+
const deployment = buildDeploymentStatus();
|
|
5893
|
+
const schedulerState = deployment.schedulerState;
|
|
5894
|
+
checks.push({
|
|
5895
|
+
id: "scheduler-state",
|
|
5896
|
+
status: deployment.deploymentMode === "local" || deployment.controlPlane.configured ? "ok" : "warn",
|
|
5897
|
+
message: `scheduler state authority=${schedulerState.authority} local=${schedulerState.localStore.role} remote=${schedulerState.remoteStore.backend}`,
|
|
5898
|
+
detail: [
|
|
5899
|
+
`route_state=${schedulerState.routeAdmission.stateStore}`,
|
|
5900
|
+
`active_statuses=${schedulerState.routeAdmission.activeStatuses.join(",")}`,
|
|
5901
|
+
`gates=${schedulerState.routeAdmission.gates.join(",")}`,
|
|
5902
|
+
`artifacts=${schedulerState.localStore.runArtifacts}`,
|
|
5903
|
+
`remote_artifacts=${schedulerState.remoteStore.objectArtifacts}`,
|
|
5904
|
+
`remote_apply=${String(schedulerState.remoteStore.applySupported)}`
|
|
5905
|
+
].join(" ")
|
|
5906
|
+
});
|
|
5539
5907
|
for (const loop of store.listLoops({ status: "active" })) {
|
|
5540
5908
|
try {
|
|
5541
5909
|
if (loop.target.type === "workflow") {
|
|
@@ -7478,136 +7846,6 @@ async function tick(deps) {
|
|
|
7478
7846
|
}
|
|
7479
7847
|
return { claimed, completed, skipped, recovered, expired };
|
|
7480
7848
|
}
|
|
7481
|
-
// package.json
|
|
7482
|
-
var package_default = {
|
|
7483
|
-
name: "@hasna/loops",
|
|
7484
|
-
version: "0.4.11",
|
|
7485
|
-
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
7486
|
-
type: "module",
|
|
7487
|
-
main: "dist/index.js",
|
|
7488
|
-
types: "dist/index.d.ts",
|
|
7489
|
-
bin: {
|
|
7490
|
-
loops: "dist/cli/index.js",
|
|
7491
|
-
"loops-daemon": "dist/daemon/index.js",
|
|
7492
|
-
"loops-api": "dist/api/index.js",
|
|
7493
|
-
"loops-runner": "dist/runner/index.js",
|
|
7494
|
-
"loops-mcp": "dist/mcp/index.js"
|
|
7495
|
-
},
|
|
7496
|
-
exports: {
|
|
7497
|
-
".": {
|
|
7498
|
-
types: "./dist/index.d.ts",
|
|
7499
|
-
import: "./dist/index.js"
|
|
7500
|
-
},
|
|
7501
|
-
"./sdk": {
|
|
7502
|
-
types: "./dist/sdk/index.d.ts",
|
|
7503
|
-
import: "./dist/sdk/index.js"
|
|
7504
|
-
},
|
|
7505
|
-
"./mcp": {
|
|
7506
|
-
types: "./dist/mcp/index.d.ts",
|
|
7507
|
-
import: "./dist/mcp/index.js"
|
|
7508
|
-
},
|
|
7509
|
-
"./api": {
|
|
7510
|
-
types: "./dist/api/index.d.ts",
|
|
7511
|
-
import: "./dist/api/index.js"
|
|
7512
|
-
},
|
|
7513
|
-
"./runner": {
|
|
7514
|
-
types: "./dist/runner/index.d.ts",
|
|
7515
|
-
import: "./dist/runner/index.js"
|
|
7516
|
-
},
|
|
7517
|
-
"./mode": {
|
|
7518
|
-
types: "./dist/lib/mode.d.ts",
|
|
7519
|
-
import: "./dist/lib/mode.js"
|
|
7520
|
-
},
|
|
7521
|
-
"./storage": {
|
|
7522
|
-
types: "./dist/lib/store.d.ts",
|
|
7523
|
-
import: "./dist/lib/store.js"
|
|
7524
|
-
},
|
|
7525
|
-
"./storage/contract": {
|
|
7526
|
-
types: "./dist/lib/storage/contract.d.ts",
|
|
7527
|
-
import: "./dist/lib/storage/contract.js"
|
|
7528
|
-
},
|
|
7529
|
-
"./storage/sqlite": {
|
|
7530
|
-
types: "./dist/lib/storage/sqlite.d.ts",
|
|
7531
|
-
import: "./dist/lib/storage/sqlite.js"
|
|
7532
|
-
},
|
|
7533
|
-
"./storage/postgres": {
|
|
7534
|
-
types: "./dist/lib/storage/postgres.d.ts",
|
|
7535
|
-
import: "./dist/lib/storage/postgres.js"
|
|
7536
|
-
},
|
|
7537
|
-
"./storage/postgres-schema": {
|
|
7538
|
-
types: "./dist/lib/storage/postgres-schema.d.ts",
|
|
7539
|
-
import: "./dist/lib/storage/postgres-schema.js"
|
|
7540
|
-
}
|
|
7541
|
-
},
|
|
7542
|
-
files: [
|
|
7543
|
-
"dist",
|
|
7544
|
-
"README.md",
|
|
7545
|
-
"CHANGELOG.md",
|
|
7546
|
-
"docs",
|
|
7547
|
-
"LICENSE"
|
|
7548
|
-
],
|
|
7549
|
-
scripts: {
|
|
7550
|
-
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",
|
|
7551
|
-
"build:bin": "bun build src/cli/index.ts --compile --outfile dist/loops",
|
|
7552
|
-
typecheck: "tsc --noEmit",
|
|
7553
|
-
test: "bun test",
|
|
7554
|
-
"test:boundary": "bun run scripts/no-private-cloud-boundary.mjs",
|
|
7555
|
-
prepare: "test -d dist || bun run build",
|
|
7556
|
-
"dev:cli": "bun run src/cli/index.ts",
|
|
7557
|
-
"dev:daemon": "bun run src/daemon/index.ts",
|
|
7558
|
-
prepublishOnly: "bun run build && bun run typecheck && bun test && bun run test:boundary"
|
|
7559
|
-
},
|
|
7560
|
-
keywords: [
|
|
7561
|
-
"loops",
|
|
7562
|
-
"scheduler",
|
|
7563
|
-
"daemon",
|
|
7564
|
-
"agents",
|
|
7565
|
-
"claude",
|
|
7566
|
-
"cursor",
|
|
7567
|
-
"codewith",
|
|
7568
|
-
"opencode",
|
|
7569
|
-
"cli"
|
|
7570
|
-
],
|
|
7571
|
-
repository: {
|
|
7572
|
-
type: "git",
|
|
7573
|
-
url: "git+https://github.com/hasna/loops.git"
|
|
7574
|
-
},
|
|
7575
|
-
homepage: "https://github.com/hasna/loops#readme",
|
|
7576
|
-
bugs: {
|
|
7577
|
-
url: "https://github.com/hasna/loops/issues"
|
|
7578
|
-
},
|
|
7579
|
-
author: "Hasna <andrei@hasna.com>",
|
|
7580
|
-
license: "Apache-2.0",
|
|
7581
|
-
engines: {
|
|
7582
|
-
bun: ">=1.0.0"
|
|
7583
|
-
},
|
|
7584
|
-
dependencies: {
|
|
7585
|
-
"@hasna/events": "^0.1.9",
|
|
7586
|
-
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
7587
|
-
"@openrouter/ai-sdk-provider": "2.9.1",
|
|
7588
|
-
ai: "6.0.204",
|
|
7589
|
-
commander: "^13.1.0",
|
|
7590
|
-
zod: "4.4.3"
|
|
7591
|
-
},
|
|
7592
|
-
optionalDependencies: {
|
|
7593
|
-
"@hasna/machines": "0.0.49"
|
|
7594
|
-
},
|
|
7595
|
-
devDependencies: {
|
|
7596
|
-
"@types/bun": "latest",
|
|
7597
|
-
typescript: "^5.7.3"
|
|
7598
|
-
},
|
|
7599
|
-
publishConfig: {
|
|
7600
|
-
registry: "https://registry.npmjs.org",
|
|
7601
|
-
access: "public"
|
|
7602
|
-
}
|
|
7603
|
-
};
|
|
7604
|
-
|
|
7605
|
-
// src/lib/version.ts
|
|
7606
|
-
function packageVersion() {
|
|
7607
|
-
if (typeof package_default.version !== "string" || package_default.version.trim() === "")
|
|
7608
|
-
throw new Error("package.json version is missing");
|
|
7609
|
-
return package_default.version;
|
|
7610
|
-
}
|
|
7611
7849
|
|
|
7612
7850
|
// src/mcp/index.ts
|
|
7613
7851
|
var LOOP_STATUSES = ["active", "paused", "stopped", "expired"];
|