@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/lib/mode.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// package.json
|
|
3
3
|
var package_default = {
|
|
4
4
|
name: "@hasna/loops",
|
|
5
|
-
version: "0.4.
|
|
5
|
+
version: "0.4.13",
|
|
6
6
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
7
7
|
type: "module",
|
|
8
8
|
main: "dist/index.js",
|
|
@@ -193,6 +193,50 @@ function sourceOfTruthForMode(mode) {
|
|
|
193
193
|
return "self_hosted_control_plane";
|
|
194
194
|
return "cloud_control_plane";
|
|
195
195
|
}
|
|
196
|
+
var ROUTE_ADMISSION_GATES = [
|
|
197
|
+
"max_dispatch",
|
|
198
|
+
"max_active",
|
|
199
|
+
"max_active_per_project",
|
|
200
|
+
"max_active_per_project_group",
|
|
201
|
+
"max_active_scope",
|
|
202
|
+
"max_per_profile"
|
|
203
|
+
];
|
|
204
|
+
function remoteSchedulerBackendForMode(mode, config) {
|
|
205
|
+
if (mode === "local")
|
|
206
|
+
return "none";
|
|
207
|
+
if (mode === "cloud")
|
|
208
|
+
return config.cloudApiUrl ? "hosted_control_plane_contract" : "unconfigured";
|
|
209
|
+
if (config.databaseUrlPresent)
|
|
210
|
+
return "postgres_contract";
|
|
211
|
+
if (config.apiUrl)
|
|
212
|
+
return "api_control_plane_contract";
|
|
213
|
+
return "unconfigured";
|
|
214
|
+
}
|
|
215
|
+
function schedulerStateForMode(args) {
|
|
216
|
+
const nonLocal = args.deploymentMode !== "local";
|
|
217
|
+
return {
|
|
218
|
+
authority: args.sourceOfTruth,
|
|
219
|
+
localStore: {
|
|
220
|
+
backend: "sqlite",
|
|
221
|
+
role: args.localRole,
|
|
222
|
+
runArtifacts: "local_files",
|
|
223
|
+
routeAdmissionState: "workflow_work_items"
|
|
224
|
+
},
|
|
225
|
+
remoteStore: {
|
|
226
|
+
backend: remoteSchedulerBackendForMode(args.deploymentMode, args.config),
|
|
227
|
+
configured: nonLocal && args.controlPlaneConfigured,
|
|
228
|
+
applySupported: false,
|
|
229
|
+
objectArtifacts: nonLocal ? "object_store_contract" : "none",
|
|
230
|
+
mutatesAws: false
|
|
231
|
+
},
|
|
232
|
+
routeAdmission: {
|
|
233
|
+
stateStore: nonLocal ? "control_plane_contract" : "local_sqlite",
|
|
234
|
+
activeStatuses: ["admitted", "running"],
|
|
235
|
+
gates: ROUTE_ADMISSION_GATES,
|
|
236
|
+
dryRunEvaluatesLiveCounts: false
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
}
|
|
196
240
|
function displayControlPlaneUrl(value) {
|
|
197
241
|
if (!value)
|
|
198
242
|
return;
|
|
@@ -218,6 +262,9 @@ function buildDeploymentStatus(opts = {}) {
|
|
|
218
262
|
if (deploymentMode === "self_hosted" && !controlPlaneConfigured) {
|
|
219
263
|
warnings.push("self_hosted mode needs LOOPS_API_URL or LOOPS_DATABASE_URL before it can become authoritative");
|
|
220
264
|
}
|
|
265
|
+
if (deploymentMode === "self_hosted" && config.databaseUrlPresent && !config.apiUrl) {
|
|
266
|
+
warnings.push("LOOPS_DATABASE_URL selects the self_hosted storage contract; loops-runner still needs LOOPS_API_URL to claim remote work");
|
|
267
|
+
}
|
|
221
268
|
if (deploymentMode === "cloud" && config.apiUrl && !config.cloudApiUrl) {
|
|
222
269
|
warnings.push("LOOPS_API_URL selects self_hosted; cloud mode uses LOOPS_CLOUD_API_URL");
|
|
223
270
|
}
|
|
@@ -251,6 +298,13 @@ function buildDeploymentStatus(opts = {}) {
|
|
|
251
298
|
required: deploymentMode !== "local",
|
|
252
299
|
role: deploymentMode === "local" ? "daemon" : "control_plane_worker"
|
|
253
300
|
},
|
|
301
|
+
schedulerState: schedulerStateForMode({
|
|
302
|
+
deploymentMode,
|
|
303
|
+
sourceOfTruth: sourceOfTruthForMode(deploymentMode),
|
|
304
|
+
localRole: deploymentMode === "local" ? "authoritative" : "cache_and_spool",
|
|
305
|
+
controlPlaneConfigured,
|
|
306
|
+
config
|
|
307
|
+
}),
|
|
254
308
|
warnings
|
|
255
309
|
};
|
|
256
310
|
}
|
|
@@ -263,6 +317,7 @@ function deploymentStatusLine(status) {
|
|
|
263
317
|
`source=${status.deploymentModeSource}`,
|
|
264
318
|
`truth=${status.sourceOfTruth}`,
|
|
265
319
|
`local=${status.localStore.role}`,
|
|
320
|
+
`scheduler=${status.schedulerState.routeAdmission.stateStore}`,
|
|
266
321
|
`control_plane=${configured}`
|
|
267
322
|
].join(" ");
|
|
268
323
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { AgentWorkflowRole } from "../template-kit.js";
|
|
2
|
+
/**
|
|
3
|
+
* Least-loaded auth-profile pool selection + a `--max-per-profile` admission
|
|
4
|
+
* guard. Drain dispatch used to pick a pool member by a pure FNV-1a hash of the
|
|
5
|
+
* work-item seed (worker=pool[i], verifier=i+1, planner=i+2), with no awareness
|
|
6
|
+
* of how many runs each subscription account was already carrying. At high
|
|
7
|
+
* concurrency that stacked several concurrent workers on one ChatGPT account and
|
|
8
|
+
* tripped the provider-side 429/stream-drop wall. This module spreads work to
|
|
9
|
+
* the least-loaded account instead, and defers a route when every pool member is
|
|
10
|
+
* already saturated.
|
|
11
|
+
*
|
|
12
|
+
* The hash order is preserved as the deterministic tie-break: with equal load
|
|
13
|
+
* counts (e.g. a cold store) the selection is byte-for-byte identical to the old
|
|
14
|
+
* behaviour, so it is fully reproducible and does not perturb rendered-template
|
|
15
|
+
* snapshots.
|
|
16
|
+
*/
|
|
17
|
+
/** FNV-1a index into a pool of `size`. Shared by the deterministic default
|
|
18
|
+
* (templates) and the least-loaded tie-break so the two never drift. */
|
|
19
|
+
export declare function stableIndex(seed: string, size: number): number;
|
|
20
|
+
/** Historical per-role offset from the worker index (worker+0, verifier+1,
|
|
21
|
+
* planner+2, everything else +3). Kept identical to the original rolePoolValue
|
|
22
|
+
* so equal-load selection reproduces the legacy assignment. */
|
|
23
|
+
export declare function poolRoleOffset(role: AgentWorkflowRole): number;
|
|
24
|
+
/**
|
|
25
|
+
* Pick the least-loaded pool member. Selection key, minimised lexicographically:
|
|
26
|
+
* 1. not in `exclude` before excluded (so verifier/planner land on a DIFFERENT
|
|
27
|
+
* profile than the worker whenever the pool is large enough),
|
|
28
|
+
* 2. lower running-step load,
|
|
29
|
+
* 3. deterministic order starting at `anchor` (first encountered wins ties).
|
|
30
|
+
* When every member is excluded (pool smaller than the number of roles) the
|
|
31
|
+
* exclusion is ignored and pure least-loaded/tie-break applies.
|
|
32
|
+
*/
|
|
33
|
+
export declare function selectLeastLoadedProfile(pool: string[], loadCounts: Record<string, number>, anchor: number, exclude: ReadonlySet<string>): string;
|
|
34
|
+
export interface PoolAuthProfileAssignmentInput {
|
|
35
|
+
pool: string[];
|
|
36
|
+
seed: string;
|
|
37
|
+
loadCounts: Record<string, number>;
|
|
38
|
+
/** Defer the whole route when EVERY pool member already has >= K running
|
|
39
|
+
* steps. Undefined disables the guard (spread only, never defer). */
|
|
40
|
+
maxPerProfile?: number;
|
|
41
|
+
/** Roles present in the workflow that draw from the pool. */
|
|
42
|
+
roles: AgentWorkflowRole[];
|
|
43
|
+
}
|
|
44
|
+
export interface PoolAuthProfileAssignment {
|
|
45
|
+
/** Chosen auth profile per role. Empty when deferred. */
|
|
46
|
+
profiles: Partial<Record<AgentWorkflowRole, string>>;
|
|
47
|
+
/** True when the max-per-profile guard fired: every pool member is saturated. */
|
|
48
|
+
deferred: boolean;
|
|
49
|
+
reason?: string;
|
|
50
|
+
/** Minimum running-step load across the pool at decision time (attribution). */
|
|
51
|
+
minLoad: number;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Assign one pool member per role: the worker takes the globally least-loaded
|
|
55
|
+
* account; verifier/planner/triage each take the least-loaded account not
|
|
56
|
+
* already claimed by an earlier role (so reviews run on a different subscription
|
|
57
|
+
* than the work they review). Returns `deferred` when the max-per-profile guard
|
|
58
|
+
* fires so the caller can hold the route until an account frees up.
|
|
59
|
+
*/
|
|
60
|
+
export declare function assignPoolAuthProfiles(input: PoolAuthProfileAssignmentInput): PoolAuthProfileAssignment;
|
|
@@ -28,6 +28,7 @@ export declare function routeProjectGroup(optsGroup: string | undefined, data: R
|
|
|
28
28
|
export declare function routeThrottleDecision(store: Store, args: {
|
|
29
29
|
projectPath: string;
|
|
30
30
|
projectGroup?: string;
|
|
31
|
+
routeScope?: string;
|
|
31
32
|
limits: RouteThrottleLimits;
|
|
32
33
|
}): RouteThrottleDecision;
|
|
33
34
|
export declare function routeThrottleDryRunPreview(args: {
|
|
@@ -32,6 +32,8 @@ export interface TodosTaskRouteOptions {
|
|
|
32
32
|
maxActive?: string;
|
|
33
33
|
maxActivePerProject?: string;
|
|
34
34
|
maxActivePerProjectGroup?: string;
|
|
35
|
+
maxActiveScope?: string;
|
|
36
|
+
maxPerProfile?: string;
|
|
35
37
|
worktreeMode?: string;
|
|
36
38
|
worktreeRoot?: string;
|
|
37
39
|
worktreeBranchPrefix?: string;
|
|
@@ -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
|
});
|
|
@@ -4755,6 +4786,10 @@ CREATE TABLE IF NOT EXISTS audit_events (
|
|
|
4755
4786
|
);
|
|
4756
4787
|
CREATE INDEX IF NOT EXISTS idx_audit_events_subject ON audit_events(subject_type, subject_id, created_at DESC);
|
|
4757
4788
|
CREATE INDEX IF NOT EXISTS idx_audit_events_action ON audit_events(action, created_at DESC);
|
|
4789
|
+
`),
|
|
4790
|
+
migration("0004_work_item_route_scope", `
|
|
4791
|
+
ALTER TABLE workflow_work_items ADD COLUMN IF NOT EXISTS route_scope TEXT;
|
|
4792
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_scope ON workflow_work_items(route_scope, status);
|
|
4758
4793
|
`)
|
|
4759
4794
|
]);
|
|
4760
4795
|
|
|
@@ -319,6 +319,10 @@ CREATE TABLE IF NOT EXISTS audit_events (
|
|
|
319
319
|
);
|
|
320
320
|
CREATE INDEX IF NOT EXISTS idx_audit_events_subject ON audit_events(subject_type, subject_id, created_at DESC);
|
|
321
321
|
CREATE INDEX IF NOT EXISTS idx_audit_events_action ON audit_events(action, created_at DESC);
|
|
322
|
+
`),
|
|
323
|
+
migration("0004_work_item_route_scope", `
|
|
324
|
+
ALTER TABLE workflow_work_items ADD COLUMN IF NOT EXISTS route_scope TEXT;
|
|
325
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_scope ON workflow_work_items(route_scope, status);
|
|
322
326
|
`)
|
|
323
327
|
]);
|
|
324
328
|
export {
|
|
@@ -319,6 +319,10 @@ CREATE TABLE IF NOT EXISTS audit_events (
|
|
|
319
319
|
);
|
|
320
320
|
CREATE INDEX IF NOT EXISTS idx_audit_events_subject ON audit_events(subject_type, subject_id, created_at DESC);
|
|
321
321
|
CREATE INDEX IF NOT EXISTS idx_audit_events_action ON audit_events(action, created_at DESC);
|
|
322
|
+
`),
|
|
323
|
+
migration("0004_work_item_route_scope", `
|
|
324
|
+
ALTER TABLE workflow_work_items ADD COLUMN IF NOT EXISTS route_scope TEXT;
|
|
325
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_scope ON workflow_work_items(route_scope, status);
|
|
322
326
|
`)
|
|
323
327
|
]);
|
|
324
328
|
|
|
@@ -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
|
});
|
package/dist/lib/store.d.ts
CHANGED
|
@@ -218,11 +218,22 @@ export declare class Store {
|
|
|
218
218
|
countActiveWorkflowWorkItems(args?: {
|
|
219
219
|
projectKey?: string;
|
|
220
220
|
projectGroup?: string;
|
|
221
|
+
routeScope?: string;
|
|
221
222
|
}): {
|
|
222
223
|
global: number;
|
|
223
224
|
project: number;
|
|
224
225
|
projectGroup?: number;
|
|
225
226
|
};
|
|
227
|
+
/**
|
|
228
|
+
* Number of currently-running workflow steps per resolved auth profile
|
|
229
|
+
* (account_profile). Drives least-loaded pool selection and the
|
|
230
|
+
* `--max-per-profile` guard so concurrency spreads across subscription
|
|
231
|
+
* accounts instead of stacking on one (the provider-side 429 wall). Only
|
|
232
|
+
* `running` steps are counted: within a workflow steps run sequentially, so a
|
|
233
|
+
* profile's running count is the number of concurrent workflows executing a
|
|
234
|
+
* step on that account right now — exactly the concurrency to bound.
|
|
235
|
+
*/
|
|
236
|
+
countRunningWorkflowStepsByAuthProfile(): Record<string, number>;
|
|
226
237
|
requeueWorkflowWorkItem(id: string, patch?: {
|
|
227
238
|
reason?: string;
|
|
228
239
|
}): WorkflowWorkItem;
|
package/dist/lib/store.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
|
});
|