@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/daemon/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
|
});
|
|
@@ -7785,7 +7816,7 @@ function enableStartup(result) {
|
|
|
7785
7816
|
// package.json
|
|
7786
7817
|
var package_default = {
|
|
7787
7818
|
name: "@hasna/loops",
|
|
7788
|
-
version: "0.4.
|
|
7819
|
+
version: "0.4.13",
|
|
7789
7820
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
7790
7821
|
type: "module",
|
|
7791
7822
|
main: "dist/index.js",
|
package/dist/index.d.ts
CHANGED
|
@@ -23,7 +23,7 @@ export { Store } from "./lib/store.js";
|
|
|
23
23
|
export { POSTGRES_MIGRATION_LEDGER_TABLE, POSTGRES_STORAGE_MIGRATIONS, PostgresStorage, SqliteLoopStorage, checksumStorageSql, createPostgresStorage, createSqliteLoopStorage, } from "./lib/storage/index.js";
|
|
24
24
|
export type { AppliedStorageMigration, AuditEventRecord, LoopStorageBackend, LoopStorageContract, LoopStorageMethodName, PostgresQueryExecutor, RunnerLeaseRecord, RunnerLeaseStatus, RunnerMachineRecord, RunnerMachineStatus, SchemaMigrationStorage, StorageMigration, StorageMigrationPlanItem, StorageMigrationResult, } from "./lib/storage/index.js";
|
|
25
25
|
export { LOOP_DEPLOYMENT_MODES, buildDeploymentStatus, deploymentStatusLine, loopControlPlaneConfig, normalizeLoopDeploymentMode, resolveLoopDeploymentMode, } from "./lib/mode.js";
|
|
26
|
-
export type { LoopControlPlaneConfig, LoopDeploymentMode, LoopDeploymentStatus, LoopModeResolution, LoopSourceOfTruth } from "./lib/mode.js";
|
|
26
|
+
export type { LoopControlPlaneConfig, LoopDeploymentMode, LoopDeploymentStatus, LoopModeResolution, LoopRemoteArtifactStore, LoopRemoteSchedulerBackend, LoopRouteAdmissionGate, LoopRouteAdmissionStateStore, LoopSchedulerStateStatus, LoopSourceOfTruth, } from "./lib/mode.js";
|
|
27
27
|
export { executeLoop, executeTarget, preflightTarget } from "./lib/executor.js";
|
|
28
28
|
export { executeLoopTarget, executeWorkflow, preflightWorkflow } from "./lib/workflow-runner.js";
|
|
29
29
|
export { workflowBodyFromJson, workflowExecutionOrder } from "./lib/workflow-spec.js";
|
package/dist/index.js
CHANGED
|
@@ -1321,6 +1321,7 @@ function rowToWorkflowWorkItem(row) {
|
|
|
1321
1321
|
subjectRef: row.subject_ref,
|
|
1322
1322
|
projectKey: row.project_key ?? undefined,
|
|
1323
1323
|
projectGroup: row.project_group ?? undefined,
|
|
1324
|
+
routeScope: row.route_scope ?? undefined,
|
|
1324
1325
|
priority: row.priority,
|
|
1325
1326
|
status: row.status,
|
|
1326
1327
|
attempts: row.attempts,
|
|
@@ -1601,6 +1602,13 @@ class Store {
|
|
|
1601
1602
|
this.addColumnIfMissing("loop_runs", "claim_token", "TEXT");
|
|
1602
1603
|
this.db.exec("CREATE INDEX IF NOT EXISTS idx_runs_claim_token ON loop_runs(claim_token) WHERE claim_token IS NOT NULL");
|
|
1603
1604
|
}
|
|
1605
|
+
},
|
|
1606
|
+
{
|
|
1607
|
+
id: "0008_work_item_route_scope",
|
|
1608
|
+
apply: () => {
|
|
1609
|
+
this.addColumnIfMissing("workflow_work_items", "route_scope", "TEXT");
|
|
1610
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_workflow_work_items_scope ON workflow_work_items(route_scope, status)");
|
|
1611
|
+
}
|
|
1604
1612
|
}
|
|
1605
1613
|
];
|
|
1606
1614
|
}
|
|
@@ -1748,6 +1756,7 @@ class Store {
|
|
|
1748
1756
|
subject_ref TEXT NOT NULL,
|
|
1749
1757
|
project_key TEXT,
|
|
1750
1758
|
project_group TEXT,
|
|
1759
|
+
route_scope TEXT,
|
|
1751
1760
|
priority INTEGER NOT NULL,
|
|
1752
1761
|
status TEXT NOT NULL,
|
|
1753
1762
|
attempts INTEGER NOT NULL,
|
|
@@ -1765,6 +1774,13 @@ class Store {
|
|
|
1765
1774
|
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_project ON workflow_work_items(project_key, status);
|
|
1766
1775
|
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_group ON workflow_work_items(project_group, status);
|
|
1767
1776
|
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_invocation ON workflow_work_items(invocation_id);
|
|
1777
|
+
-- idx_workflow_work_items_scope (route_scope, status) is created ONLY by
|
|
1778
|
+
-- migration 0008_work_item_route_scope, never here: this baseline DDL
|
|
1779
|
+
-- re-runs on EVERY open (0001 is not skip-guarded), and on a pre-0008
|
|
1780
|
+
-- database the CREATE TABLE above is a no-op, so an index on route_scope
|
|
1781
|
+
-- here would execute before the column exists and crash the open
|
|
1782
|
+
-- ("no such column: route_scope"). New columns may be folded into the
|
|
1783
|
+
-- CREATE TABLE (fresh-db only); their indexes must live in the migration.
|
|
1768
1784
|
|
|
1769
1785
|
CREATE TABLE IF NOT EXISTS workflow_step_runs (
|
|
1770
1786
|
id TEXT PRIMARY KEY,
|
|
@@ -2556,10 +2572,10 @@ class Store {
|
|
|
2556
2572
|
const id = genId();
|
|
2557
2573
|
const status = input.status ?? "queued";
|
|
2558
2574
|
this.db.query(`INSERT INTO workflow_work_items (id, route_key, idempotency_key, invocation_id, source_type, source_ref,
|
|
2559
|
-
subject_ref, project_key, project_group, priority, status, attempts, next_attempt_at, lease_expires_at,
|
|
2575
|
+
subject_ref, project_key, project_group, route_scope, priority, status, attempts, next_attempt_at, lease_expires_at,
|
|
2560
2576
|
workflow_id, loop_id, workflow_run_id, last_reason, created_at, updated_at)
|
|
2561
2577
|
VALUES ($id, $routeKey, $idempotencyKey, $invocationId, $sourceType, $sourceRef, $subjectRef,
|
|
2562
|
-
$projectKey, $projectGroup, $priority, $status, 0, $nextAttemptAt, NULL, NULL, NULL, NULL,
|
|
2578
|
+
$projectKey, $projectGroup, $routeScope, $priority, $status, 0, $nextAttemptAt, NULL, NULL, NULL, NULL,
|
|
2563
2579
|
$lastReason, $created, $updated)
|
|
2564
2580
|
ON CONFLICT(route_key, idempotency_key) DO UPDATE SET
|
|
2565
2581
|
invocation_id=excluded.invocation_id,
|
|
@@ -2568,6 +2584,7 @@ class Store {
|
|
|
2568
2584
|
subject_ref=excluded.subject_ref,
|
|
2569
2585
|
project_key=excluded.project_key,
|
|
2570
2586
|
project_group=excluded.project_group,
|
|
2587
|
+
route_scope=excluded.route_scope,
|
|
2571
2588
|
priority=excluded.priority,
|
|
2572
2589
|
status=CASE
|
|
2573
2590
|
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled')
|
|
@@ -2609,6 +2626,7 @@ class Store {
|
|
|
2609
2626
|
$subjectRef: input.subjectRef,
|
|
2610
2627
|
$projectKey: input.projectKey ?? null,
|
|
2611
2628
|
$projectGroup: input.projectGroup ?? null,
|
|
2629
|
+
$routeScope: input.routeScope ?? null,
|
|
2612
2630
|
$priority: input.priority ?? 0,
|
|
2613
2631
|
$status: status,
|
|
2614
2632
|
$nextAttemptAt: input.nextAttemptAt ?? null,
|
|
@@ -2646,11 +2664,21 @@ class Store {
|
|
|
2646
2664
|
countActiveWorkflowWorkItems(args = {}) {
|
|
2647
2665
|
const active = ["admitted", "running"];
|
|
2648
2666
|
const placeholders = active.map(() => "?").join(",");
|
|
2649
|
-
const
|
|
2667
|
+
const routeScope = args.routeScope?.trim() || undefined;
|
|
2668
|
+
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;
|
|
2650
2669
|
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;
|
|
2651
2670
|
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;
|
|
2652
2671
|
return { global, project, ...projectGroup !== undefined ? { projectGroup } : {} };
|
|
2653
2672
|
}
|
|
2673
|
+
countRunningWorkflowStepsByAuthProfile() {
|
|
2674
|
+
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();
|
|
2675
|
+
const counts = {};
|
|
2676
|
+
for (const row of rows) {
|
|
2677
|
+
if (row.account_profile)
|
|
2678
|
+
counts[row.account_profile] = row.count;
|
|
2679
|
+
}
|
|
2680
|
+
return counts;
|
|
2681
|
+
}
|
|
2654
2682
|
requeueWorkflowWorkItem(id, patch = {}) {
|
|
2655
2683
|
const current = this.getWorkflowWorkItem(id);
|
|
2656
2684
|
if (!current)
|
|
@@ -3086,6 +3114,9 @@ class Store {
|
|
|
3086
3114
|
}
|
|
3087
3115
|
input.workflow.steps.forEach((step, sequence) => {
|
|
3088
3116
|
const account = step.account ?? step.target.account;
|
|
3117
|
+
const agentTarget = step.target.type === "agent" ? step.target : undefined;
|
|
3118
|
+
const resolvedProfile = account?.profile ?? agentTarget?.authProfile ?? null;
|
|
3119
|
+
const resolvedTool = account?.tool ?? (agentTarget?.authProfile ? agentTarget.provider : null);
|
|
3089
3120
|
this.db.query(`INSERT INTO workflow_step_runs (id, workflow_run_id, step_id, sequence, status, started_at, finished_at,
|
|
3090
3121
|
exit_code, pid, duration_ms, stdout, stderr, error, account_profile, account_tool, created_at, updated_at)
|
|
3091
3122
|
VALUES ($id, $workflowRunId, $stepId, $sequence, 'pending', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
|
|
@@ -3094,8 +3125,8 @@ class Store {
|
|
|
3094
3125
|
$workflowRunId: runId,
|
|
3095
3126
|
$stepId: step.id,
|
|
3096
3127
|
$sequence: sequence,
|
|
3097
|
-
$accountProfile:
|
|
3098
|
-
$accountTool:
|
|
3128
|
+
$accountProfile: resolvedProfile,
|
|
3129
|
+
$accountTool: resolvedTool,
|
|
3099
3130
|
$created: now,
|
|
3100
3131
|
$updated: now
|
|
3101
3132
|
});
|
|
@@ -4235,7 +4266,7 @@ class Store {
|
|
|
4235
4266
|
// package.json
|
|
4236
4267
|
var package_default = {
|
|
4237
4268
|
name: "@hasna/loops",
|
|
4238
|
-
version: "0.4.
|
|
4269
|
+
version: "0.4.13",
|
|
4239
4270
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
4240
4271
|
type: "module",
|
|
4241
4272
|
main: "dist/index.js",
|
|
@@ -4426,6 +4457,50 @@ function sourceOfTruthForMode(mode) {
|
|
|
4426
4457
|
return "self_hosted_control_plane";
|
|
4427
4458
|
return "cloud_control_plane";
|
|
4428
4459
|
}
|
|
4460
|
+
var ROUTE_ADMISSION_GATES = [
|
|
4461
|
+
"max_dispatch",
|
|
4462
|
+
"max_active",
|
|
4463
|
+
"max_active_per_project",
|
|
4464
|
+
"max_active_per_project_group",
|
|
4465
|
+
"max_active_scope",
|
|
4466
|
+
"max_per_profile"
|
|
4467
|
+
];
|
|
4468
|
+
function remoteSchedulerBackendForMode(mode, config) {
|
|
4469
|
+
if (mode === "local")
|
|
4470
|
+
return "none";
|
|
4471
|
+
if (mode === "cloud")
|
|
4472
|
+
return config.cloudApiUrl ? "hosted_control_plane_contract" : "unconfigured";
|
|
4473
|
+
if (config.databaseUrlPresent)
|
|
4474
|
+
return "postgres_contract";
|
|
4475
|
+
if (config.apiUrl)
|
|
4476
|
+
return "api_control_plane_contract";
|
|
4477
|
+
return "unconfigured";
|
|
4478
|
+
}
|
|
4479
|
+
function schedulerStateForMode(args) {
|
|
4480
|
+
const nonLocal = args.deploymentMode !== "local";
|
|
4481
|
+
return {
|
|
4482
|
+
authority: args.sourceOfTruth,
|
|
4483
|
+
localStore: {
|
|
4484
|
+
backend: "sqlite",
|
|
4485
|
+
role: args.localRole,
|
|
4486
|
+
runArtifacts: "local_files",
|
|
4487
|
+
routeAdmissionState: "workflow_work_items"
|
|
4488
|
+
},
|
|
4489
|
+
remoteStore: {
|
|
4490
|
+
backend: remoteSchedulerBackendForMode(args.deploymentMode, args.config),
|
|
4491
|
+
configured: nonLocal && args.controlPlaneConfigured,
|
|
4492
|
+
applySupported: false,
|
|
4493
|
+
objectArtifacts: nonLocal ? "object_store_contract" : "none",
|
|
4494
|
+
mutatesAws: false
|
|
4495
|
+
},
|
|
4496
|
+
routeAdmission: {
|
|
4497
|
+
stateStore: nonLocal ? "control_plane_contract" : "local_sqlite",
|
|
4498
|
+
activeStatuses: ["admitted", "running"],
|
|
4499
|
+
gates: ROUTE_ADMISSION_GATES,
|
|
4500
|
+
dryRunEvaluatesLiveCounts: false
|
|
4501
|
+
}
|
|
4502
|
+
};
|
|
4503
|
+
}
|
|
4429
4504
|
function displayControlPlaneUrl(value) {
|
|
4430
4505
|
if (!value)
|
|
4431
4506
|
return;
|
|
@@ -4451,6 +4526,9 @@ function buildDeploymentStatus(opts = {}) {
|
|
|
4451
4526
|
if (deploymentMode === "self_hosted" && !controlPlaneConfigured) {
|
|
4452
4527
|
warnings.push("self_hosted mode needs LOOPS_API_URL or LOOPS_DATABASE_URL before it can become authoritative");
|
|
4453
4528
|
}
|
|
4529
|
+
if (deploymentMode === "self_hosted" && config.databaseUrlPresent && !config.apiUrl) {
|
|
4530
|
+
warnings.push("LOOPS_DATABASE_URL selects the self_hosted storage contract; loops-runner still needs LOOPS_API_URL to claim remote work");
|
|
4531
|
+
}
|
|
4454
4532
|
if (deploymentMode === "cloud" && config.apiUrl && !config.cloudApiUrl) {
|
|
4455
4533
|
warnings.push("LOOPS_API_URL selects self_hosted; cloud mode uses LOOPS_CLOUD_API_URL");
|
|
4456
4534
|
}
|
|
@@ -4484,6 +4562,13 @@ function buildDeploymentStatus(opts = {}) {
|
|
|
4484
4562
|
required: deploymentMode !== "local",
|
|
4485
4563
|
role: deploymentMode === "local" ? "daemon" : "control_plane_worker"
|
|
4486
4564
|
},
|
|
4565
|
+
schedulerState: schedulerStateForMode({
|
|
4566
|
+
deploymentMode,
|
|
4567
|
+
sourceOfTruth: sourceOfTruthForMode(deploymentMode),
|
|
4568
|
+
localRole: deploymentMode === "local" ? "authoritative" : "cache_and_spool",
|
|
4569
|
+
controlPlaneConfigured,
|
|
4570
|
+
config
|
|
4571
|
+
}),
|
|
4487
4572
|
warnings
|
|
4488
4573
|
};
|
|
4489
4574
|
}
|
|
@@ -4496,6 +4581,7 @@ function deploymentStatusLine(status) {
|
|
|
4496
4581
|
`source=${status.deploymentModeSource}`,
|
|
4497
4582
|
`truth=${status.sourceOfTruth}`,
|
|
4498
4583
|
`local=${status.localStore.role}`,
|
|
4584
|
+
`scheduler=${status.schedulerState.routeAdmission.stateStore}`,
|
|
4499
4585
|
`control_plane=${configured}`
|
|
4500
4586
|
].join(" ");
|
|
4501
4587
|
}
|
|
@@ -5801,6 +5887,21 @@ function runDoctor(store) {
|
|
|
5801
5887
|
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" });
|
|
5802
5888
|
const failedRuns = store.countRuns("failed");
|
|
5803
5889
|
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` });
|
|
5890
|
+
const deployment = buildDeploymentStatus();
|
|
5891
|
+
const schedulerState = deployment.schedulerState;
|
|
5892
|
+
checks.push({
|
|
5893
|
+
id: "scheduler-state",
|
|
5894
|
+
status: deployment.deploymentMode === "local" || deployment.controlPlane.configured ? "ok" : "warn",
|
|
5895
|
+
message: `scheduler state authority=${schedulerState.authority} local=${schedulerState.localStore.role} remote=${schedulerState.remoteStore.backend}`,
|
|
5896
|
+
detail: [
|
|
5897
|
+
`route_state=${schedulerState.routeAdmission.stateStore}`,
|
|
5898
|
+
`active_statuses=${schedulerState.routeAdmission.activeStatuses.join(",")}`,
|
|
5899
|
+
`gates=${schedulerState.routeAdmission.gates.join(",")}`,
|
|
5900
|
+
`artifacts=${schedulerState.localStore.runArtifacts}`,
|
|
5901
|
+
`remote_artifacts=${schedulerState.remoteStore.objectArtifacts}`,
|
|
5902
|
+
`remote_apply=${String(schedulerState.remoteStore.applySupported)}`
|
|
5903
|
+
].join(" ")
|
|
5904
|
+
});
|
|
5804
5905
|
for (const loop of store.listLoops({ status: "active" })) {
|
|
5805
5906
|
try {
|
|
5806
5907
|
if (loop.target.type === "workflow") {
|
|
@@ -9546,6 +9647,10 @@ CREATE TABLE IF NOT EXISTS audit_events (
|
|
|
9546
9647
|
);
|
|
9547
9648
|
CREATE INDEX IF NOT EXISTS idx_audit_events_subject ON audit_events(subject_type, subject_id, created_at DESC);
|
|
9548
9649
|
CREATE INDEX IF NOT EXISTS idx_audit_events_action ON audit_events(action, created_at DESC);
|
|
9650
|
+
`),
|
|
9651
|
+
migration("0004_work_item_route_scope", `
|
|
9652
|
+
ALTER TABLE workflow_work_items ADD COLUMN IF NOT EXISTS route_scope TEXT;
|
|
9653
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_scope ON workflow_work_items(route_scope, status);
|
|
9549
9654
|
`)
|
|
9550
9655
|
]);
|
|
9551
9656
|
|
|
@@ -10181,6 +10286,50 @@ var PR_HANDOFF_SCRIPT = [
|
|
|
10181
10286
|
"console.log(`PR handoff complete: ${finalPrUrl}`);"
|
|
10182
10287
|
].join(`
|
|
10183
10288
|
`);
|
|
10289
|
+
var PR_HANDOFF_NO_ARTIFACT_SCRIPT = [
|
|
10290
|
+
"const { spawnSync } = await import('node:child_process');",
|
|
10291
|
+
"const artifactPath = process.env.OPENLOOPS_PR_HANDOFF_ARTIFACT || '';",
|
|
10292
|
+
"const taskId = process.env.OPENLOOPS_PR_HANDOFF_TASK_ID || '';",
|
|
10293
|
+
"const todosProject = process.env.OPENLOOPS_PR_HANDOFF_TODOS_PROJECT || '';",
|
|
10294
|
+
"const worktree = process.env.OPENLOOPS_PR_HANDOFF_WORKTREE || process.cwd();",
|
|
10295
|
+
"const expectedBranch = process.env.OPENLOOPS_PR_HANDOFF_EXPECTED_BRANCH || '';",
|
|
10296
|
+
"const todosBin = process.env.OPENLOOPS_PR_HANDOFF_TODOS_BIN || 'todos';",
|
|
10297
|
+
"const gitBin = process.env.OPENLOOPS_PR_HANDOFF_GIT_BIN || 'git';",
|
|
10298
|
+
"const ghBin = process.env.OPENLOOPS_PR_HANDOFF_GH_BIN || 'gh';",
|
|
10299
|
+
"process.stdout.write(`no PR handoff artifact at ${artifactPath}\\n`);",
|
|
10300
|
+
"const run = (command, args, options = {}) => {",
|
|
10301
|
+
" try { return spawnSync(command, args, { encoding: 'utf8', ...options }); }",
|
|
10302
|
+
" catch (error) { return { status: 1, stdout: '', stderr: String((error && error.message) || error) }; }",
|
|
10303
|
+
"};",
|
|
10304
|
+
"const todosArgs = (...args) => todosProject ? ['--project', todosProject, ...args] : args;",
|
|
10305
|
+
"const comment = (text) => {",
|
|
10306
|
+
" const result = run(todosBin, todosArgs('comment', taskId, text));",
|
|
10307
|
+
" if (result.status !== 0) console.error(`failed to comment original task: ${result.stderr || result.stdout || result.status}`);",
|
|
10308
|
+
"};",
|
|
10309
|
+
"const main = () => {",
|
|
10310
|
+
" let branch = expectedBranch;",
|
|
10311
|
+
" if (!branch) {",
|
|
10312
|
+
" const shown = run(gitBin, ['-C', worktree, 'branch', '--show-current']);",
|
|
10313
|
+
" branch = String((shown.status === 0 ? shown.stdout : '') || '').trim();",
|
|
10314
|
+
" }",
|
|
10315
|
+
" if (!branch) { console.log('pr-handoff: no artifact and no resolvable branch; nothing to hand off'); return; }",
|
|
10316
|
+
" const listed = run(ghBin, ['pr', 'list', '--head', branch, '--state', 'open', '--json', 'url,number,headRefName,headRefOid'], { cwd: worktree });",
|
|
10317
|
+
" if (listed.status !== 0) { console.log(`pr-handoff: no artifact; PR lookup failed for branch ${branch}: ${String(listed.stderr || listed.stdout || listed.status).slice(0, 300)}`); return; }",
|
|
10318
|
+
" let prs = [];",
|
|
10319
|
+
" try { prs = JSON.parse(String(listed.stdout || '[]')); } catch { prs = []; }",
|
|
10320
|
+
" const pr = Array.isArray(prs) ? prs.find((entry) => entry && entry.headRefName === branch && typeof entry.url === 'string' && entry.url) : undefined;",
|
|
10321
|
+
" if (!pr) { console.log(`pr-handoff: no artifact and no open PR for branch ${branch}; worker completed without opening a PR`); return; }",
|
|
10322
|
+
" let commit = String(pr.headRefOid || '').trim();",
|
|
10323
|
+
" if (!commit) {",
|
|
10324
|
+
" const head = run(gitBin, ['-C', worktree, 'rev-parse', 'HEAD']);",
|
|
10325
|
+
" commit = String((head.status === 0 ? head.stdout : '') || '').trim();",
|
|
10326
|
+
" }",
|
|
10327
|
+
" comment(`openloops:pr-handoff=done task=${taskId} pr=${pr.url} commit=${commit || 'unknown'} branch=${branch}`);",
|
|
10328
|
+
" console.log(`PR handoff complete (worker-opened PR): ${pr.url}`);",
|
|
10329
|
+
"};",
|
|
10330
|
+
"try { main(); } catch (error) { console.error(`pr-handoff no-artifact detection error (ignored): ${String((error && error.message) || error)}`); }"
|
|
10331
|
+
].join(`
|
|
10332
|
+
`);
|
|
10184
10333
|
function prHandoffCommand(opts) {
|
|
10185
10334
|
return [
|
|
10186
10335
|
"set -euo pipefail",
|
|
@@ -10191,15 +10340,90 @@ function prHandoffCommand(opts) {
|
|
|
10191
10340
|
`export OPENLOOPS_PR_HANDOFF_WORKTREE_ROOT=${shellQuote2(opts.worktreeRoot)}`,
|
|
10192
10341
|
`export OPENLOOPS_PR_HANDOFF_EXPECTED_BRANCH=${shellQuote2(opts.expectedBranch)}`,
|
|
10193
10342
|
'if [ ! -s "$OPENLOOPS_PR_HANDOFF_ARTIFACT" ]; then',
|
|
10194
|
-
|
|
10195
|
-
|
|
10196
|
-
"
|
|
10343
|
+
"bun - <<'OPENLOOPS_PR_HANDOFF_NOARTIFACT'",
|
|
10344
|
+
PR_HANDOFF_NO_ARTIFACT_SCRIPT,
|
|
10345
|
+
"OPENLOOPS_PR_HANDOFF_NOARTIFACT",
|
|
10346
|
+
"else",
|
|
10197
10347
|
"bun - <<'BUN'",
|
|
10198
10348
|
PR_HANDOFF_SCRIPT,
|
|
10199
|
-
"BUN"
|
|
10349
|
+
"BUN",
|
|
10350
|
+
"fi"
|
|
10200
10351
|
].join(`
|
|
10201
10352
|
`);
|
|
10202
10353
|
}
|
|
10354
|
+
|
|
10355
|
+
// src/lib/route/profile-pool.ts
|
|
10356
|
+
function stableIndex(seed, size) {
|
|
10357
|
+
let hash = 2166136261;
|
|
10358
|
+
for (let i = 0;i < seed.length; i += 1) {
|
|
10359
|
+
hash ^= seed.charCodeAt(i);
|
|
10360
|
+
hash = Math.imul(hash, 16777619);
|
|
10361
|
+
}
|
|
10362
|
+
return Math.abs(hash >>> 0) % size;
|
|
10363
|
+
}
|
|
10364
|
+
function poolRoleOffset(role) {
|
|
10365
|
+
if (role === "worker")
|
|
10366
|
+
return 0;
|
|
10367
|
+
if (role === "verifier")
|
|
10368
|
+
return 1;
|
|
10369
|
+
if (role === "planner")
|
|
10370
|
+
return 2;
|
|
10371
|
+
return 3;
|
|
10372
|
+
}
|
|
10373
|
+
function selectLeastLoadedProfile(pool, loadCounts, anchor, exclude) {
|
|
10374
|
+
const size = pool.length;
|
|
10375
|
+
let best;
|
|
10376
|
+
let bestExcluded = true;
|
|
10377
|
+
let bestLoad = Number.POSITIVE_INFINITY;
|
|
10378
|
+
for (let k = 0;k < size; k += 1) {
|
|
10379
|
+
const profile = pool[(anchor + k) % size];
|
|
10380
|
+
const excluded = exclude.has(profile);
|
|
10381
|
+
const load = loadCounts[profile] ?? 0;
|
|
10382
|
+
if (best === undefined) {
|
|
10383
|
+
best = profile;
|
|
10384
|
+
bestExcluded = excluded;
|
|
10385
|
+
bestLoad = load;
|
|
10386
|
+
continue;
|
|
10387
|
+
}
|
|
10388
|
+
const better = excluded === bestExcluded ? load < bestLoad : !excluded && bestExcluded;
|
|
10389
|
+
if (better) {
|
|
10390
|
+
best = profile;
|
|
10391
|
+
bestExcluded = excluded;
|
|
10392
|
+
bestLoad = load;
|
|
10393
|
+
}
|
|
10394
|
+
}
|
|
10395
|
+
return best;
|
|
10396
|
+
}
|
|
10397
|
+
var ROLE_PRIORITY = ["worker", "verifier", "planner", "triage"];
|
|
10398
|
+
function assignPoolAuthProfiles(input) {
|
|
10399
|
+
const pool = input.pool.filter((entry) => entry.trim().length > 0);
|
|
10400
|
+
if (pool.length === 0)
|
|
10401
|
+
return { profiles: {}, deferred: false, minLoad: 0 };
|
|
10402
|
+
const minLoad = pool.reduce((min, profile) => Math.min(min, input.loadCounts[profile] ?? 0), Number.POSITIVE_INFINITY);
|
|
10403
|
+
const guard = input.maxPerProfile !== undefined && input.maxPerProfile > 0;
|
|
10404
|
+
if (guard && minLoad >= input.maxPerProfile) {
|
|
10405
|
+
return {
|
|
10406
|
+
profiles: {},
|
|
10407
|
+
deferred: true,
|
|
10408
|
+
minLoad,
|
|
10409
|
+
reason: `per-profile active limit reached (all ${pool.length} pool members >= ${input.maxPerProfile} running; min ${minLoad})`
|
|
10410
|
+
};
|
|
10411
|
+
}
|
|
10412
|
+
const workerIndex = stableIndex(input.seed, pool.length);
|
|
10413
|
+
const rolesToAssign = ROLE_PRIORITY.filter((role) => input.roles.includes(role));
|
|
10414
|
+
for (const role of input.roles)
|
|
10415
|
+
if (!rolesToAssign.includes(role))
|
|
10416
|
+
rolesToAssign.push(role);
|
|
10417
|
+
const profiles = {};
|
|
10418
|
+
const taken = new Set;
|
|
10419
|
+
for (const role of rolesToAssign) {
|
|
10420
|
+
const anchor = (workerIndex + poolRoleOffset(role)) % pool.length;
|
|
10421
|
+
const profile = selectLeastLoadedProfile(pool, input.loadCounts, anchor, taken);
|
|
10422
|
+
profiles[role] = profile;
|
|
10423
|
+
taken.add(profile);
|
|
10424
|
+
}
|
|
10425
|
+
return { profiles, deferred: false, minLoad: Number.isFinite(minLoad) ? minLoad : 0 };
|
|
10426
|
+
}
|
|
10203
10427
|
// src/lib/templates-custom.ts
|
|
10204
10428
|
import { existsSync as existsSync5, lstatSync as lstatSync2, mkdirSync as mkdirSync6, readdirSync, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
|
|
10205
10429
|
import { join as join6, resolve as resolve3 } from "path";
|
|
@@ -10595,25 +10819,13 @@ function parseDeterministicTimeoutMs(raw, fallbackMs, label = "timeoutMs") {
|
|
|
10595
10819
|
throw new Error(`${label} must be a positive integer number of milliseconds`);
|
|
10596
10820
|
return value;
|
|
10597
10821
|
}
|
|
10598
|
-
function stableIndex(seed, size) {
|
|
10599
|
-
let hash = 2166136261;
|
|
10600
|
-
for (let i = 0;i < seed.length; i += 1) {
|
|
10601
|
-
hash ^= seed.charCodeAt(i);
|
|
10602
|
-
hash = Math.imul(hash, 16777619);
|
|
10603
|
-
}
|
|
10604
|
-
return Math.abs(hash >>> 0) % size;
|
|
10605
|
-
}
|
|
10606
10822
|
function rolePoolValue(pool, seed, role) {
|
|
10607
10823
|
if (!pool?.length)
|
|
10608
10824
|
return;
|
|
10609
10825
|
const workerIndex = stableIndex(seed, pool.length);
|
|
10610
|
-
if (
|
|
10826
|
+
if (pool.length === 1)
|
|
10611
10827
|
return pool[workerIndex];
|
|
10612
|
-
|
|
10613
|
-
return pool[(workerIndex + 1) % pool.length];
|
|
10614
|
-
if (role === "planner")
|
|
10615
|
-
return pool[(workerIndex + 2) % pool.length];
|
|
10616
|
-
return pool[(workerIndex + 3) % pool.length];
|
|
10828
|
+
return pool[(workerIndex + poolRoleOffset(role)) % pool.length];
|
|
10617
10829
|
}
|
|
10618
10830
|
function authProfileForRole(input, role, seed) {
|
|
10619
10831
|
if (role === "triage" && input.triageAuthProfile)
|
|
@@ -10783,7 +10995,8 @@ function agentTarget(input, prompt, role, seed, plan) {
|
|
|
10783
10995
|
allowlist: input.manualBreakGlass ? { enforcement: "metadata_only", commands: ["manual-break-glass"] } : undefined,
|
|
10784
10996
|
routing: {
|
|
10785
10997
|
projectPath: input.routeProjectPath ?? input.projectPath,
|
|
10786
|
-
...input.projectGroup ? { projectGroup: input.projectGroup } : {}
|
|
10998
|
+
...input.projectGroup ? { projectGroup: input.projectGroup } : {},
|
|
10999
|
+
role
|
|
10787
11000
|
},
|
|
10788
11001
|
account: accountForRole(input, role, seed),
|
|
10789
11002
|
timeoutMs: agentTimeoutMs(input),
|
package/dist/lib/mode.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
export declare const LOOP_DEPLOYMENT_MODES: readonly ["local", "self_hosted", "cloud"];
|
|
2
2
|
export type LoopDeploymentMode = (typeof LOOP_DEPLOYMENT_MODES)[number];
|
|
3
3
|
export type LoopSourceOfTruth = "local_sqlite" | "self_hosted_control_plane" | "cloud_control_plane";
|
|
4
|
+
export type LoopRemoteSchedulerBackend = "none" | "unconfigured" | "api_control_plane_contract" | "postgres_contract" | "hosted_control_plane_contract";
|
|
5
|
+
export type LoopRemoteArtifactStore = "none" | "object_store_contract";
|
|
6
|
+
export type LoopRouteAdmissionStateStore = "local_sqlite" | "control_plane_contract";
|
|
7
|
+
export type LoopRouteAdmissionGate = "max_dispatch" | "max_active" | "max_active_per_project" | "max_active_per_project_group" | "max_active_scope" | "max_per_profile";
|
|
4
8
|
export interface LoopModeResolution {
|
|
5
9
|
deploymentMode: LoopDeploymentMode;
|
|
6
10
|
source: string;
|
|
@@ -34,8 +38,31 @@ export interface LoopDeploymentStatus {
|
|
|
34
38
|
required: boolean;
|
|
35
39
|
role: "daemon" | "control_plane_worker";
|
|
36
40
|
};
|
|
41
|
+
schedulerState: LoopSchedulerStateStatus;
|
|
37
42
|
warnings: string[];
|
|
38
43
|
}
|
|
44
|
+
export interface LoopSchedulerStateStatus {
|
|
45
|
+
authority: LoopSourceOfTruth;
|
|
46
|
+
localStore: {
|
|
47
|
+
backend: "sqlite";
|
|
48
|
+
role: "authoritative" | "cache_and_spool";
|
|
49
|
+
runArtifacts: "local_files";
|
|
50
|
+
routeAdmissionState: "workflow_work_items";
|
|
51
|
+
};
|
|
52
|
+
remoteStore: {
|
|
53
|
+
backend: LoopRemoteSchedulerBackend;
|
|
54
|
+
configured: boolean;
|
|
55
|
+
applySupported: boolean;
|
|
56
|
+
objectArtifacts: LoopRemoteArtifactStore;
|
|
57
|
+
mutatesAws: false;
|
|
58
|
+
};
|
|
59
|
+
routeAdmission: {
|
|
60
|
+
stateStore: LoopRouteAdmissionStateStore;
|
|
61
|
+
activeStatuses: readonly ["admitted", "running"];
|
|
62
|
+
gates: readonly LoopRouteAdmissionGate[];
|
|
63
|
+
dryRunEvaluatesLiveCounts: false;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
39
66
|
type Env = Record<string, string | undefined>;
|
|
40
67
|
export declare function normalizeLoopDeploymentMode(value: string): LoopDeploymentMode;
|
|
41
68
|
export declare function resolveLoopDeploymentMode(env?: Env): LoopModeResolution;
|