@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/runner/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// package.json
|
|
4
4
|
var package_default = {
|
|
5
5
|
name: "@hasna/loops",
|
|
6
|
-
version: "0.4.
|
|
6
|
+
version: "0.4.13",
|
|
7
7
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
8
8
|
type: "module",
|
|
9
9
|
main: "dist/index.js",
|
|
@@ -194,6 +194,50 @@ function sourceOfTruthForMode(mode) {
|
|
|
194
194
|
return "self_hosted_control_plane";
|
|
195
195
|
return "cloud_control_plane";
|
|
196
196
|
}
|
|
197
|
+
var ROUTE_ADMISSION_GATES = [
|
|
198
|
+
"max_dispatch",
|
|
199
|
+
"max_active",
|
|
200
|
+
"max_active_per_project",
|
|
201
|
+
"max_active_per_project_group",
|
|
202
|
+
"max_active_scope",
|
|
203
|
+
"max_per_profile"
|
|
204
|
+
];
|
|
205
|
+
function remoteSchedulerBackendForMode(mode, config) {
|
|
206
|
+
if (mode === "local")
|
|
207
|
+
return "none";
|
|
208
|
+
if (mode === "cloud")
|
|
209
|
+
return config.cloudApiUrl ? "hosted_control_plane_contract" : "unconfigured";
|
|
210
|
+
if (config.databaseUrlPresent)
|
|
211
|
+
return "postgres_contract";
|
|
212
|
+
if (config.apiUrl)
|
|
213
|
+
return "api_control_plane_contract";
|
|
214
|
+
return "unconfigured";
|
|
215
|
+
}
|
|
216
|
+
function schedulerStateForMode(args) {
|
|
217
|
+
const nonLocal = args.deploymentMode !== "local";
|
|
218
|
+
return {
|
|
219
|
+
authority: args.sourceOfTruth,
|
|
220
|
+
localStore: {
|
|
221
|
+
backend: "sqlite",
|
|
222
|
+
role: args.localRole,
|
|
223
|
+
runArtifacts: "local_files",
|
|
224
|
+
routeAdmissionState: "workflow_work_items"
|
|
225
|
+
},
|
|
226
|
+
remoteStore: {
|
|
227
|
+
backend: remoteSchedulerBackendForMode(args.deploymentMode, args.config),
|
|
228
|
+
configured: nonLocal && args.controlPlaneConfigured,
|
|
229
|
+
applySupported: false,
|
|
230
|
+
objectArtifacts: nonLocal ? "object_store_contract" : "none",
|
|
231
|
+
mutatesAws: false
|
|
232
|
+
},
|
|
233
|
+
routeAdmission: {
|
|
234
|
+
stateStore: nonLocal ? "control_plane_contract" : "local_sqlite",
|
|
235
|
+
activeStatuses: ["admitted", "running"],
|
|
236
|
+
gates: ROUTE_ADMISSION_GATES,
|
|
237
|
+
dryRunEvaluatesLiveCounts: false
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
}
|
|
197
241
|
function displayControlPlaneUrl(value) {
|
|
198
242
|
if (!value)
|
|
199
243
|
return;
|
|
@@ -219,6 +263,9 @@ function buildDeploymentStatus(opts = {}) {
|
|
|
219
263
|
if (deploymentMode === "self_hosted" && !controlPlaneConfigured) {
|
|
220
264
|
warnings.push("self_hosted mode needs LOOPS_API_URL or LOOPS_DATABASE_URL before it can become authoritative");
|
|
221
265
|
}
|
|
266
|
+
if (deploymentMode === "self_hosted" && config.databaseUrlPresent && !config.apiUrl) {
|
|
267
|
+
warnings.push("LOOPS_DATABASE_URL selects the self_hosted storage contract; loops-runner still needs LOOPS_API_URL to claim remote work");
|
|
268
|
+
}
|
|
222
269
|
if (deploymentMode === "cloud" && config.apiUrl && !config.cloudApiUrl) {
|
|
223
270
|
warnings.push("LOOPS_API_URL selects self_hosted; cloud mode uses LOOPS_CLOUD_API_URL");
|
|
224
271
|
}
|
|
@@ -252,6 +299,13 @@ function buildDeploymentStatus(opts = {}) {
|
|
|
252
299
|
required: deploymentMode !== "local",
|
|
253
300
|
role: deploymentMode === "local" ? "daemon" : "control_plane_worker"
|
|
254
301
|
},
|
|
302
|
+
schedulerState: schedulerStateForMode({
|
|
303
|
+
deploymentMode,
|
|
304
|
+
sourceOfTruth: sourceOfTruthForMode(deploymentMode),
|
|
305
|
+
localRole: deploymentMode === "local" ? "authoritative" : "cache_and_spool",
|
|
306
|
+
controlPlaneConfigured,
|
|
307
|
+
config
|
|
308
|
+
}),
|
|
255
309
|
warnings
|
|
256
310
|
};
|
|
257
311
|
}
|
|
@@ -264,6 +318,7 @@ function deploymentStatusLine(status) {
|
|
|
264
318
|
`source=${status.deploymentModeSource}`,
|
|
265
319
|
`truth=${status.sourceOfTruth}`,
|
|
266
320
|
`local=${status.localStore.role}`,
|
|
321
|
+
`scheduler=${status.schedulerState.routeAdmission.stateStore}`,
|
|
267
322
|
`control_plane=${configured}`
|
|
268
323
|
].join(" ");
|
|
269
324
|
}
|
package/dist/sdk/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
|
}
|
|
@@ -5796,6 +5882,21 @@ function runDoctor(store) {
|
|
|
5796
5882
|
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" });
|
|
5797
5883
|
const failedRuns = store.countRuns("failed");
|
|
5798
5884
|
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` });
|
|
5885
|
+
const deployment = buildDeploymentStatus();
|
|
5886
|
+
const schedulerState = deployment.schedulerState;
|
|
5887
|
+
checks.push({
|
|
5888
|
+
id: "scheduler-state",
|
|
5889
|
+
status: deployment.deploymentMode === "local" || deployment.controlPlane.configured ? "ok" : "warn",
|
|
5890
|
+
message: `scheduler state authority=${schedulerState.authority} local=${schedulerState.localStore.role} remote=${schedulerState.remoteStore.backend}`,
|
|
5891
|
+
detail: [
|
|
5892
|
+
`route_state=${schedulerState.routeAdmission.stateStore}`,
|
|
5893
|
+
`active_statuses=${schedulerState.routeAdmission.activeStatuses.join(",")}`,
|
|
5894
|
+
`gates=${schedulerState.routeAdmission.gates.join(",")}`,
|
|
5895
|
+
`artifacts=${schedulerState.localStore.runArtifacts}`,
|
|
5896
|
+
`remote_artifacts=${schedulerState.remoteStore.objectArtifacts}`,
|
|
5897
|
+
`remote_apply=${String(schedulerState.remoteStore.applySupported)}`
|
|
5898
|
+
].join(" ")
|
|
5899
|
+
});
|
|
5799
5900
|
for (const loop of store.listLoops({ status: "active" })) {
|
|
5800
5901
|
try {
|
|
5801
5902
|
if (loop.target.type === "workflow") {
|
package/dist/types.d.ts
CHANGED
|
@@ -104,6 +104,9 @@ export interface AgentRoutingSpec {
|
|
|
104
104
|
eventId?: string;
|
|
105
105
|
eventType?: string;
|
|
106
106
|
eventSource?: string;
|
|
107
|
+
/** Lifecycle role of this agent step (triage/planner/worker/verifier). Used
|
|
108
|
+
* for per-account attribution and least-loaded auth-profile pool selection. */
|
|
109
|
+
role?: "triage" | "planner" | "worker" | "verifier";
|
|
107
110
|
}
|
|
108
111
|
export interface AgentPromptSource {
|
|
109
112
|
type: "file";
|
|
@@ -209,6 +212,12 @@ export interface WorkflowWorkItem {
|
|
|
209
212
|
subjectRef: string;
|
|
210
213
|
projectKey?: string;
|
|
211
214
|
projectGroup?: string;
|
|
215
|
+
/**
|
|
216
|
+
* The drain/route identity (loop name) that admitted this item. Used to scope
|
|
217
|
+
* the `--max-active` global admission count to a single route instead of the
|
|
218
|
+
* whole store. Undefined for items created before route-scope support.
|
|
219
|
+
*/
|
|
220
|
+
routeScope?: string;
|
|
212
221
|
priority: number;
|
|
213
222
|
status: WorkflowWorkItemStatus;
|
|
214
223
|
attempts: number;
|
|
@@ -230,6 +239,7 @@ export interface UpsertWorkflowWorkItemInput {
|
|
|
230
239
|
subjectRef: string;
|
|
231
240
|
projectKey?: string;
|
|
232
241
|
projectGroup?: string;
|
|
242
|
+
routeScope?: string;
|
|
233
243
|
priority?: number;
|
|
234
244
|
status?: Extract<WorkflowWorkItemStatus, "queued" | "deferred">;
|
|
235
245
|
nextAttemptAt?: string;
|
package/docs/DEPLOYMENT_MODES.md
CHANGED
|
@@ -68,7 +68,7 @@ loops import ./loops-export.json --apply
|
|
|
68
68
|
Human status output is intentionally compact:
|
|
69
69
|
|
|
70
70
|
```text
|
|
71
|
-
deploymentMode=local active source=default truth=local_sqlite local=authoritative control_plane=none
|
|
71
|
+
deploymentMode=local active source=default truth=local_sqlite local=authoritative scheduler=local_sqlite control_plane=none
|
|
72
72
|
```
|
|
73
73
|
|
|
74
74
|
JSON uses these field names:
|
|
@@ -85,6 +85,22 @@ JSON uses these field names:
|
|
|
85
85
|
presence signal.
|
|
86
86
|
- `controlPlane.apiUrl`: a display-safe URL without credentials, query string,
|
|
87
87
|
or fragment.
|
|
88
|
+
- `schedulerState.localStore`: always names the local SQLite store and local
|
|
89
|
+
run artifact files. In `local` mode this store is authoritative; in
|
|
90
|
+
non-local modes it is a cache, offline spool, and audit copy.
|
|
91
|
+
- `schedulerState.remoteStore`: names the remote scheduler contract:
|
|
92
|
+
`api_control_plane_contract`, `postgres_contract`,
|
|
93
|
+
`hosted_control_plane_contract`, `unconfigured`, or `none`. Remote apply is
|
|
94
|
+
`false` in this public package until a control-plane host wires a full
|
|
95
|
+
storage adapter.
|
|
96
|
+
- `schedulerState.remoteStore.objectArtifacts`: `object_store_contract` means
|
|
97
|
+
remote artifact/object storage is a control-plane contract. The public package
|
|
98
|
+
does not create or mutate S3 buckets, AWS resources, or hosted credentials.
|
|
99
|
+
- `schedulerState.routeAdmission`: names the active route-state store and the
|
|
100
|
+
bounded gates (`max_dispatch`, `max_active`, `max_active_per_project`,
|
|
101
|
+
`max_active_per_project_group`, `max_active_scope`, and `max_per_profile`).
|
|
102
|
+
Live active counts use admitted/running work items; dry-runs do not open or
|
|
103
|
+
migrate the live store to compute counts.
|
|
88
104
|
|
|
89
105
|
`loops-api` is a separate process in the same public package. It is not a
|
|
90
106
|
separate package at this stage because self-hosted users and the hosted service
|
|
@@ -150,6 +166,12 @@ would generate new ids, so it is not a no-loss migration. Local SQLite remains
|
|
|
150
166
|
authoritative until a safe import is applied; in non-local modes it may remain
|
|
151
167
|
a cache, offline spool, and audit copy.
|
|
152
168
|
|
|
169
|
+
`LOOPS_DATABASE_URL` selects the self-hosted Postgres scheduler-state contract,
|
|
170
|
+
but it does not make the standalone CLI mutate a remote database by itself.
|
|
171
|
+
Remote execution still flows through a configured control-plane API and runner
|
|
172
|
+
protocol. `loops-runner` needs `LOOPS_API_URL` or `HASNA_LOOPS_API_URL` to claim
|
|
173
|
+
work; a database URL alone is migration/readiness configuration.
|
|
174
|
+
|
|
153
175
|
`loops self-hosted runner-register` is also preview-only unless `--apply` is
|
|
154
176
|
present. The dry run prints the runner id, machine id, labels, and
|
|
155
177
|
capabilities that would be posted, without exposing tokens.
|
package/docs/USAGE.md
CHANGED
|
@@ -25,6 +25,16 @@ planes:
|
|
|
25
25
|
status only, and requires `LOOPS_CLOUD_API_URL` plus `LOOPS_CLOUD_TOKEN` or
|
|
26
26
|
`HASNA_LOOPS_CLOUD_TOKEN` before status can report ready.
|
|
27
27
|
|
|
28
|
+
Scheduler state is explicit in status JSON. `schedulerState.localStore` is
|
|
29
|
+
SQLite plus local run artifact files: authoritative in `local`, cache/spool in
|
|
30
|
+
non-local modes. `schedulerState.remoteStore` names the non-local contract
|
|
31
|
+
(`api_control_plane_contract`, `postgres_contract`, or
|
|
32
|
+
`hosted_control_plane_contract`) and reports `applySupported=false` because this
|
|
33
|
+
public package does not directly mutate remote Postgres, S3/object storage, AWS
|
|
34
|
+
resources, or hosted credentials. Route admission remains bounded by
|
|
35
|
+
`max_dispatch`, `max_active`, `max_active_per_project`,
|
|
36
|
+
`max_active_per_project_group`, `max_active_scope`, and `max_per_profile`.
|
|
37
|
+
|
|
28
38
|
Useful status commands:
|
|
29
39
|
|
|
30
40
|
```bash
|
|
@@ -674,11 +684,12 @@ idempotency key before rendering worktree plans or checking route limits. In
|
|
|
674
684
|
dry-run mode, throttle counts are not evaluated because opening the live loop
|
|
675
685
|
store can create or migrate the local database.
|
|
676
686
|
Terminal routed work items such as failed, dead-letter, cancelled, or succeeded
|
|
677
|
-
history
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
687
|
+
history are re-admitted only when the todos task is still actionable, the
|
|
688
|
+
per-attempt backoff has elapsed, and the redispatch cap has not been reached.
|
|
689
|
+
Operators can still force a retry with `loops routes requeue <work-item-id>
|
|
690
|
+
--reason "<cause fixed>"`. The next route-created output records `requeue`
|
|
691
|
+
evidence with the previous work item id, previous attempts, reason, new attempt,
|
|
692
|
+
workflow id, and loop id.
|
|
682
693
|
|
|
683
694
|
When a sandboxed Codewith/Codex worker must update app stores outside the repo
|
|
684
695
|
worktree, pass those stores explicitly with `--add-dir` or template `addDirs`.
|
package/package.json
CHANGED