@hasna/loops 0.3.27 → 0.3.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +336 -159
- package/dist/daemon/index.js +1 -1
- package/docs/USAGE.md +33 -0
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -5253,7 +5253,7 @@ function buildScriptInventoryReport(store, opts = {}) {
|
|
|
5253
5253
|
// package.json
|
|
5254
5254
|
var package_default = {
|
|
5255
5255
|
name: "@hasna/loops",
|
|
5256
|
-
version: "0.3.
|
|
5256
|
+
version: "0.3.28",
|
|
5257
5257
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
5258
5258
|
type: "module",
|
|
5259
5259
|
main: "dist/index.js",
|
|
@@ -6649,6 +6649,283 @@ async function readEventEnvelopeFromStdin() {
|
|
|
6649
6649
|
throw new Error("event.source is required");
|
|
6650
6650
|
return event;
|
|
6651
6651
|
}
|
|
6652
|
+
function routeTodosTaskEvent(event, opts) {
|
|
6653
|
+
const data = eventData(event);
|
|
6654
|
+
const metadata = eventMetadata(event);
|
|
6655
|
+
const taskId = taskEventField(data, ["id", "task_id", "taskId"]);
|
|
6656
|
+
if (!taskId)
|
|
6657
|
+
throw new Error("todos task event is missing task id in data.id, data.task_id, data.task.id, or data.payload.id");
|
|
6658
|
+
const eligibility = taskRouteEligibility(data, metadata);
|
|
6659
|
+
if (!eligibility.eligible) {
|
|
6660
|
+
return {
|
|
6661
|
+
kind: "skipped",
|
|
6662
|
+
value: { skipped: true, reason: eligibility.reason, event, taskId, eligibility },
|
|
6663
|
+
human: `skipped task ${taskId}: ${eligibility.reason}`
|
|
6664
|
+
};
|
|
6665
|
+
}
|
|
6666
|
+
const taskTitle = taskEventField(data, ["title", "task_title", "taskTitle"]);
|
|
6667
|
+
const taskDescription = taskEventField(data, ["description", "body"]);
|
|
6668
|
+
const dataProjectPath = taskEventField(data, ["working_dir", "workingDir", "project_path", "projectPath", "cwd"]);
|
|
6669
|
+
const metadataProjectPath = taskEventField(metadata, [
|
|
6670
|
+
"working_dir",
|
|
6671
|
+
"workingDir",
|
|
6672
|
+
"project_path",
|
|
6673
|
+
"projectPath",
|
|
6674
|
+
"project_canonical_path",
|
|
6675
|
+
"cwd"
|
|
6676
|
+
]);
|
|
6677
|
+
const projectPath = opts.projectPath ?? dataProjectPath ?? metadataProjectPath ?? process.cwd();
|
|
6678
|
+
const routeProjectPath = normalizeRoutePath(projectPath) ?? resolve2(projectPath);
|
|
6679
|
+
const projectGroup = routeProjectGroup(opts.projectGroup, data, metadata);
|
|
6680
|
+
const throttleLimits = routeThrottleLimitsFromOpts(opts);
|
|
6681
|
+
const idempotencyKey = `todos-task:${taskId}:${event.type}`;
|
|
6682
|
+
const idempotencySuffix = stableSuffix(idempotencyKey);
|
|
6683
|
+
const namePrefix = opts.namePrefix ?? "event:todos-task";
|
|
6684
|
+
const workflowName = `${namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:workflow`;
|
|
6685
|
+
const loopName = `${namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:run`;
|
|
6686
|
+
const legacyLoopName = `${namePrefix}:${taskId.slice(0, 8)}:${event.id.slice(0, 8)}:run`;
|
|
6687
|
+
if (!opts.dryRun) {
|
|
6688
|
+
const store2 = new Store;
|
|
6689
|
+
try {
|
|
6690
|
+
const existingLoop = store2.findLoopByName(loopName) ?? store2.findLoopByName(legacyLoopName);
|
|
6691
|
+
if (existingLoop) {
|
|
6692
|
+
const existingWorkflow = existingLoop.target.type === "workflow" ? store2.getWorkflow(existingLoop.target.workflowId) : undefined;
|
|
6693
|
+
return {
|
|
6694
|
+
kind: "deduped",
|
|
6695
|
+
value: {
|
|
6696
|
+
deduped: true,
|
|
6697
|
+
idempotencyKey,
|
|
6698
|
+
dedupedBy: existingLoop.name === loopName ? "idempotency" : "legacy-event-name",
|
|
6699
|
+
event,
|
|
6700
|
+
workflow: existingWorkflow ? publicWorkflow(existingWorkflow) : undefined,
|
|
6701
|
+
loop: publicLoop(existingLoop)
|
|
6702
|
+
},
|
|
6703
|
+
human: `deduped existing loop ${existingLoop.id} (${existingLoop.name}) for event=${event.id} idempotency=${idempotencyKey}`
|
|
6704
|
+
};
|
|
6705
|
+
}
|
|
6706
|
+
} finally {
|
|
6707
|
+
store2.close();
|
|
6708
|
+
}
|
|
6709
|
+
}
|
|
6710
|
+
const provider = opts.provider ?? "codewith";
|
|
6711
|
+
if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider))
|
|
6712
|
+
throw new Error("unsupported provider");
|
|
6713
|
+
const permissionMode = permissionModeFromOpts({ permissionMode: opts.permissionMode ?? "bypass" }, provider);
|
|
6714
|
+
const sandbox = sandboxFromOpts({ sandbox: opts.sandbox }, provider);
|
|
6715
|
+
const authProfile = providerAuthProfileFromOpts({ authProfile: opts.authProfile }, provider);
|
|
6716
|
+
const workflowBody = renderTodosTaskWorkerVerifierWorkflow({
|
|
6717
|
+
taskId,
|
|
6718
|
+
taskTitle,
|
|
6719
|
+
taskDescription,
|
|
6720
|
+
projectPath,
|
|
6721
|
+
routeProjectPath,
|
|
6722
|
+
projectGroup,
|
|
6723
|
+
provider,
|
|
6724
|
+
authProfile,
|
|
6725
|
+
authProfilePool: splitList(opts.authProfilePool),
|
|
6726
|
+
workerAuthProfile: opts.workerAuthProfile,
|
|
6727
|
+
verifierAuthProfile: opts.verifierAuthProfile,
|
|
6728
|
+
account: accountFromOpts(opts),
|
|
6729
|
+
accountPool: accountPoolFromOpts(opts),
|
|
6730
|
+
workerAccount: roleAccountFromOpts(opts, opts.workerAccount),
|
|
6731
|
+
verifierAccount: roleAccountFromOpts(opts, opts.verifierAccount),
|
|
6732
|
+
model: opts.model,
|
|
6733
|
+
variant: opts.variant,
|
|
6734
|
+
agent: opts.agent,
|
|
6735
|
+
permissionMode,
|
|
6736
|
+
sandbox,
|
|
6737
|
+
worktreeMode: opts.worktreeMode ?? "auto",
|
|
6738
|
+
worktreeRoot: opts.worktreeRoot,
|
|
6739
|
+
worktreeBranchPrefix: opts.worktreeBranchPrefix ?? "openloops",
|
|
6740
|
+
eventId: event.id,
|
|
6741
|
+
eventType: event.type
|
|
6742
|
+
});
|
|
6743
|
+
workflowBody.name = workflowName;
|
|
6744
|
+
workflowBody.description = `Task-triggered worker/verifier workflow for ${taskTitle ?? taskId} from ${event.source}/${event.type}; ` + `idempotency=${idempotencyKey}; event=${event.id}; project=${projectPath}; projectGroup=${projectGroup ?? "-"}`;
|
|
6745
|
+
const loopInput = {
|
|
6746
|
+
name: loopName,
|
|
6747
|
+
description: `Run ${workflowBody.name} once for task ${taskId}; idempotency=${idempotencyKey}; event=${event.id}`,
|
|
6748
|
+
schedule: { type: "once", at: new Date(Date.now() + 1000).toISOString() },
|
|
6749
|
+
target: { type: "workflow", workflowId: "<created-workflow-id>" },
|
|
6750
|
+
overlap: "skip",
|
|
6751
|
+
maxAttempts: 1,
|
|
6752
|
+
retryDelayMs: 60000,
|
|
6753
|
+
leaseMs: 90 * 60000
|
|
6754
|
+
};
|
|
6755
|
+
if (opts.dryRun) {
|
|
6756
|
+
const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDryRunPreview({ projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
|
|
6757
|
+
const preflight = opts.preflight ? preflightStoredWorkflow(workflowSpecForPreflight(workflowBody, "event-preflight"), {
|
|
6758
|
+
name: workflowBody.name,
|
|
6759
|
+
type: "todos-task-event-workflow",
|
|
6760
|
+
event: event.id
|
|
6761
|
+
}, {}) : undefined;
|
|
6762
|
+
return {
|
|
6763
|
+
kind: "created",
|
|
6764
|
+
value: { deduped: false, idempotencyKey, event, workflow: workflowBody, loop: loopInput, throttle, preflight },
|
|
6765
|
+
human: `dry-run ${loopName}`
|
|
6766
|
+
};
|
|
6767
|
+
}
|
|
6768
|
+
const store = new Store;
|
|
6769
|
+
try {
|
|
6770
|
+
const existingWorkflowForPreflight = store.findWorkflowByName(workflowBody.name);
|
|
6771
|
+
const workflowPreflightSpec = existingWorkflowForPreflight ?? workflowSpecForPreflight(workflowBody, "event-preflight");
|
|
6772
|
+
const preflight = opts.preflight ? preflightStoredWorkflow(workflowPreflightSpec, {
|
|
6773
|
+
name: workflowBody.name,
|
|
6774
|
+
type: "todos-task-event-workflow",
|
|
6775
|
+
event: event.id
|
|
6776
|
+
}, {}) : undefined;
|
|
6777
|
+
const outcome = store.writeTransaction(() => {
|
|
6778
|
+
const existingLoop = store.findLoopByName(loopName) ?? store.findLoopByName(legacyLoopName);
|
|
6779
|
+
if (existingLoop) {
|
|
6780
|
+
const existingWorkflow2 = existingLoop.target.type === "workflow" ? store.getWorkflow(existingLoop.target.workflowId) : undefined;
|
|
6781
|
+
return { kind: "deduped", existingLoop, existingWorkflow: existingWorkflow2 };
|
|
6782
|
+
}
|
|
6783
|
+
const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDecision(store, { projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
|
|
6784
|
+
if (throttle && !throttle.allowed)
|
|
6785
|
+
return { kind: "throttled", throttle };
|
|
6786
|
+
const existingWorkflow = store.findWorkflowByName(workflowBody.name);
|
|
6787
|
+
const workflow = existingWorkflow ?? store.createWorkflow(workflowBody);
|
|
6788
|
+
const loop = store.createLoop({
|
|
6789
|
+
...loopInput,
|
|
6790
|
+
target: { type: "workflow", workflowId: workflow.id }
|
|
6791
|
+
});
|
|
6792
|
+
return { kind: "created", workflow, loop, throttle };
|
|
6793
|
+
});
|
|
6794
|
+
if (outcome.kind === "deduped") {
|
|
6795
|
+
return {
|
|
6796
|
+
kind: "deduped",
|
|
6797
|
+
value: {
|
|
6798
|
+
deduped: true,
|
|
6799
|
+
idempotencyKey,
|
|
6800
|
+
dedupedBy: outcome.existingLoop.name === loopName ? "idempotency" : "legacy-event-name",
|
|
6801
|
+
event,
|
|
6802
|
+
workflow: outcome.existingWorkflow ? publicWorkflow(outcome.existingWorkflow) : undefined,
|
|
6803
|
+
loop: publicLoop(outcome.existingLoop)
|
|
6804
|
+
},
|
|
6805
|
+
human: `deduped existing loop ${outcome.existingLoop.id} (${outcome.existingLoop.name}) for event=${event.id} idempotency=${idempotencyKey}`
|
|
6806
|
+
};
|
|
6807
|
+
}
|
|
6808
|
+
if (outcome.kind === "throttled") {
|
|
6809
|
+
return {
|
|
6810
|
+
kind: "throttled",
|
|
6811
|
+
value: {
|
|
6812
|
+
skipped: true,
|
|
6813
|
+
queuedAtSource: true,
|
|
6814
|
+
reason: outcome.throttle.reason,
|
|
6815
|
+
idempotencyKey,
|
|
6816
|
+
event,
|
|
6817
|
+
throttle: outcome.throttle,
|
|
6818
|
+
workflow: workflowBody,
|
|
6819
|
+
loop: loopInput
|
|
6820
|
+
},
|
|
6821
|
+
human: `skipped task ${taskId}: ${outcome.throttle.reason}`
|
|
6822
|
+
};
|
|
6823
|
+
}
|
|
6824
|
+
return {
|
|
6825
|
+
kind: "created",
|
|
6826
|
+
value: {
|
|
6827
|
+
deduped: false,
|
|
6828
|
+
idempotencyKey,
|
|
6829
|
+
event,
|
|
6830
|
+
workflow: publicWorkflow(outcome.workflow),
|
|
6831
|
+
loop: publicLoop(outcome.loop),
|
|
6832
|
+
throttle: outcome.throttle,
|
|
6833
|
+
preflight
|
|
6834
|
+
},
|
|
6835
|
+
human: `created ${outcome.loop.id} (${outcome.loop.name}) workflow=${outcome.workflow.name} event=${event.id} idempotency=${idempotencyKey}`
|
|
6836
|
+
};
|
|
6837
|
+
} finally {
|
|
6838
|
+
store.close();
|
|
6839
|
+
}
|
|
6840
|
+
}
|
|
6841
|
+
function taskField(task, keys) {
|
|
6842
|
+
for (const key of keys) {
|
|
6843
|
+
const value = stringField(task[key]);
|
|
6844
|
+
if (value)
|
|
6845
|
+
return value;
|
|
6846
|
+
}
|
|
6847
|
+
return;
|
|
6848
|
+
}
|
|
6849
|
+
function taskListId(task) {
|
|
6850
|
+
return taskField(task, ["task_list_id", "taskListId"]) ?? stringField(task.task_list?.id);
|
|
6851
|
+
}
|
|
6852
|
+
function taskProjectId(task) {
|
|
6853
|
+
return taskField(task, ["project_id", "projectId"]);
|
|
6854
|
+
}
|
|
6855
|
+
function taskDrainEvent(task) {
|
|
6856
|
+
const taskId = taskField(task, ["id", "task_id", "taskId"]);
|
|
6857
|
+
if (!taskId)
|
|
6858
|
+
throw new Error("todos ready returned a task without an id");
|
|
6859
|
+
const metadata = objectField(task.metadata) ?? {};
|
|
6860
|
+
const workingDir = taskField(task, ["working_dir", "workingDir", "project_path", "projectPath", "cwd"]);
|
|
6861
|
+
const data = {
|
|
6862
|
+
...task,
|
|
6863
|
+
id: taskId,
|
|
6864
|
+
title: taskField(task, ["title"]),
|
|
6865
|
+
description: taskField(task, ["description", "body"]),
|
|
6866
|
+
status: taskField(task, ["status"]),
|
|
6867
|
+
tags: tagsFromValue(task.tags),
|
|
6868
|
+
metadata
|
|
6869
|
+
};
|
|
6870
|
+
if (workingDir) {
|
|
6871
|
+
data.working_dir = workingDir;
|
|
6872
|
+
data.project_path = taskField(task, ["project_path", "projectPath"]) ?? workingDir;
|
|
6873
|
+
data.cwd = taskField(task, ["cwd"]) ?? workingDir;
|
|
6874
|
+
}
|
|
6875
|
+
const time = new Date().toISOString();
|
|
6876
|
+
return {
|
|
6877
|
+
id: `drain-todos-task-${taskId}`,
|
|
6878
|
+
type: "task.created",
|
|
6879
|
+
source: "@hasna/todos",
|
|
6880
|
+
subject: taskId,
|
|
6881
|
+
severity: "info",
|
|
6882
|
+
data,
|
|
6883
|
+
time,
|
|
6884
|
+
schemaVersion: "1.0",
|
|
6885
|
+
metadata: {
|
|
6886
|
+
...metadata,
|
|
6887
|
+
...workingDir ? { working_dir: workingDir, project_path: data.project_path, cwd: data.cwd } : {},
|
|
6888
|
+
drained_by: "@hasna/loops",
|
|
6889
|
+
drained_from: "todos ready"
|
|
6890
|
+
}
|
|
6891
|
+
};
|
|
6892
|
+
}
|
|
6893
|
+
function loadReadyTodosTasks(opts, scanLimit) {
|
|
6894
|
+
const todosProject = opts.todosProject ?? defaultLoopsProject();
|
|
6895
|
+
const args = ["--project", todosProject, "--json", "ready", "--limit", String(scanLimit)];
|
|
6896
|
+
const result = runLocalCommand("todos", args, { timeoutMs: 60000 });
|
|
6897
|
+
if (!result.ok)
|
|
6898
|
+
throw new Error(result.stderr || result.error || "todos ready failed");
|
|
6899
|
+
const parsed = JSON.parse(result.stdout || "[]");
|
|
6900
|
+
if (!Array.isArray(parsed))
|
|
6901
|
+
throw new Error("todos ready --json returned a non-array value");
|
|
6902
|
+
return parsed;
|
|
6903
|
+
}
|
|
6904
|
+
function resolveTaskListFilter(todosProject, filter) {
|
|
6905
|
+
const wanted = filter?.trim();
|
|
6906
|
+
if (!wanted)
|
|
6907
|
+
return;
|
|
6908
|
+
const result = runLocalCommand("todos", ["--project", todosProject, "--json", "task-lists"], { timeoutMs: 30000 });
|
|
6909
|
+
if (!result.ok)
|
|
6910
|
+
throw new Error(result.stderr || result.error || "failed to list todos task lists");
|
|
6911
|
+
const values = JSON.parse(result.stdout || "[]");
|
|
6912
|
+
const match = values.find((entry) => entry.id === wanted || entry.slug === wanted || entry.name === wanted);
|
|
6913
|
+
return match?.id ?? wanted;
|
|
6914
|
+
}
|
|
6915
|
+
function taskMatchesDrainFilters(task, filters) {
|
|
6916
|
+
if (filters.projectId && taskProjectId(task) !== filters.projectId)
|
|
6917
|
+
return false;
|
|
6918
|
+
if (filters.taskListId && taskListId(task) !== filters.taskListId)
|
|
6919
|
+
return false;
|
|
6920
|
+
if (filters.tags.length) {
|
|
6921
|
+
const taskTags = new Set(tagsFromValue(task.tags));
|
|
6922
|
+
for (const tag of filters.tags) {
|
|
6923
|
+
if (!taskTags.has(tag))
|
|
6924
|
+
return false;
|
|
6925
|
+
}
|
|
6926
|
+
}
|
|
6927
|
+
return true;
|
|
6928
|
+
}
|
|
6652
6929
|
function providerAuthProfileFromOpts(opts, provider) {
|
|
6653
6930
|
if (!opts.authProfile)
|
|
6654
6931
|
return;
|
|
@@ -6801,165 +7078,65 @@ templates.command("create-workflow <id>").description("render and store a templa
|
|
|
6801
7078
|
var eventsHandle = events.command("handle").description("handle a Hasna event envelope");
|
|
6802
7079
|
eventsHandle.command("todos-task").description("create a one-shot worker/verifier workflow loop for a todos task event").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated task worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--preflight", "check generated workflow steps before storing the workflow loop").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
|
|
6803
7080
|
const event = await readEventEnvelopeFromStdin();
|
|
6804
|
-
const
|
|
6805
|
-
|
|
6806
|
-
|
|
6807
|
-
|
|
6808
|
-
|
|
6809
|
-
const
|
|
6810
|
-
|
|
6811
|
-
|
|
6812
|
-
|
|
6813
|
-
|
|
6814
|
-
const
|
|
6815
|
-
const
|
|
6816
|
-
const
|
|
6817
|
-
const
|
|
6818
|
-
|
|
6819
|
-
|
|
6820
|
-
|
|
6821
|
-
|
|
6822
|
-
|
|
6823
|
-
|
|
6824
|
-
]
|
|
6825
|
-
|
|
6826
|
-
const
|
|
6827
|
-
|
|
6828
|
-
|
|
6829
|
-
|
|
6830
|
-
|
|
6831
|
-
|
|
6832
|
-
|
|
6833
|
-
|
|
6834
|
-
|
|
6835
|
-
|
|
6836
|
-
|
|
6837
|
-
|
|
6838
|
-
|
|
6839
|
-
|
|
6840
|
-
|
|
6841
|
-
|
|
6842
|
-
|
|
6843
|
-
|
|
6844
|
-
|
|
6845
|
-
|
|
6846
|
-
|
|
6847
|
-
|
|
6848
|
-
|
|
6849
|
-
|
|
6850
|
-
|
|
6851
|
-
|
|
6852
|
-
|
|
6853
|
-
|
|
6854
|
-
|
|
6855
|
-
|
|
6856
|
-
|
|
6857
|
-
|
|
6858
|
-
|
|
6859
|
-
|
|
6860
|
-
const workflowBody = renderTodosTaskWorkerVerifierWorkflow({
|
|
6861
|
-
taskId,
|
|
6862
|
-
taskTitle,
|
|
6863
|
-
taskDescription,
|
|
6864
|
-
projectPath,
|
|
6865
|
-
routeProjectPath,
|
|
6866
|
-
projectGroup,
|
|
6867
|
-
provider,
|
|
6868
|
-
authProfile,
|
|
6869
|
-
authProfilePool: splitList(opts.authProfilePool),
|
|
6870
|
-
workerAuthProfile: opts.workerAuthProfile,
|
|
6871
|
-
verifierAuthProfile: opts.verifierAuthProfile,
|
|
6872
|
-
account: accountFromOpts(opts),
|
|
6873
|
-
accountPool: accountPoolFromOpts(opts),
|
|
6874
|
-
workerAccount: roleAccountFromOpts(opts, opts.workerAccount),
|
|
6875
|
-
verifierAccount: roleAccountFromOpts(opts, opts.verifierAccount),
|
|
6876
|
-
model: opts.model,
|
|
6877
|
-
variant: opts.variant,
|
|
6878
|
-
agent: opts.agent,
|
|
6879
|
-
permissionMode,
|
|
6880
|
-
sandbox,
|
|
6881
|
-
worktreeMode: opts.worktreeMode,
|
|
6882
|
-
worktreeRoot: opts.worktreeRoot,
|
|
6883
|
-
worktreeBranchPrefix: opts.worktreeBranchPrefix,
|
|
6884
|
-
eventId: event.id,
|
|
6885
|
-
eventType: event.type
|
|
6886
|
-
});
|
|
6887
|
-
workflowBody.name = workflowName;
|
|
6888
|
-
workflowBody.description = `Task-triggered worker/verifier workflow for ${taskTitle ?? taskId} from ${event.source}/${event.type}; ` + `idempotency=${idempotencyKey}; event=${event.id}; project=${projectPath}; projectGroup=${projectGroup ?? "-"}`;
|
|
6889
|
-
const loopInput = {
|
|
6890
|
-
name: loopName,
|
|
6891
|
-
description: `Run ${workflowBody.name} once for task ${taskId}; idempotency=${idempotencyKey}; event=${event.id}`,
|
|
6892
|
-
schedule: { type: "once", at: new Date(Date.now() + 1000).toISOString() },
|
|
6893
|
-
target: { type: "workflow", workflowId: "<created-workflow-id>" },
|
|
6894
|
-
overlap: "skip",
|
|
6895
|
-
maxAttempts: 1,
|
|
6896
|
-
retryDelayMs: 60000,
|
|
6897
|
-
leaseMs: 90 * 60000
|
|
7081
|
+
const result = routeTodosTaskEvent(event, opts);
|
|
7082
|
+
print(result.value, result.human);
|
|
7083
|
+
});
|
|
7084
|
+
var eventsDrain = events.command("drain").description("drain durable source queues into bounded OpenLoops workflows");
|
|
7085
|
+
eventsDrain.command("todos-task").description("drain ready todos tasks into bounded worker/verifier workflow loops").option("--todos-project <path>", "todos storage project path", defaultLoopsProject()).option("--todos-project-id <id>", "filter todos ready output to one todos project id").option("--task-list <id-or-slug>", "filter ready tasks to one task-list id, slug, or name").option("--tags <tags>", "require all comma-separated tags before routing").option("--tag <tags>", "alias for --tags").option("--limit <n>", "maximum filtered ready-task candidates to consider", "50").option("--scan-limit <n>", "maximum raw todos ready rows to fetch before filters; defaults to 500 when filters are used").option("--max-dispatch <n>", "maximum new workflow loops to create in this drain run", "1").option("--evidence-dir <path>", "write a JSON drain report to this directory").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated task worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--preflight", "check generated workflow steps before storing workflow loops").option("--dry-run", "preview selected tasks and generated workflow loops without storing anything").action((opts) => {
|
|
7086
|
+
const maxDispatch = positiveInteger(opts.maxDispatch ?? "1", "--max-dispatch") ?? 1;
|
|
7087
|
+
const todosProject = opts.todosProject ?? defaultLoopsProject();
|
|
7088
|
+
const requiredTags = splitList(opts.tags ?? opts.tag) ?? [];
|
|
7089
|
+
const taskListFilter = resolveTaskListFilter(todosProject, opts.taskList);
|
|
7090
|
+
const candidateLimit = positiveInteger(opts.limit ?? "50", "--limit") ?? 50;
|
|
7091
|
+
const hasPostFilters = Boolean(opts.todosProjectId || taskListFilter || requiredTags.length);
|
|
7092
|
+
const defaultScanLimit = hasPostFilters ? Math.max(candidateLimit, 500) : candidateLimit;
|
|
7093
|
+
const scanLimit = positiveInteger(opts.scanLimit ?? String(defaultScanLimit), "--scan-limit") ?? defaultScanLimit;
|
|
7094
|
+
const ready = loadReadyTodosTasks(opts, scanLimit);
|
|
7095
|
+
const filteredCandidates = ready.filter((task) => taskMatchesDrainFilters(task, {
|
|
7096
|
+
projectId: opts.todosProjectId,
|
|
7097
|
+
taskListId: taskListFilter,
|
|
7098
|
+
tags: requiredTags
|
|
7099
|
+
}));
|
|
7100
|
+
const candidates = filteredCandidates.slice(0, candidateLimit);
|
|
7101
|
+
const results = [];
|
|
7102
|
+
let created = 0;
|
|
7103
|
+
for (const task of candidates) {
|
|
7104
|
+
if (created >= maxDispatch)
|
|
7105
|
+
break;
|
|
7106
|
+
const event = taskDrainEvent(task);
|
|
7107
|
+
const result = routeTodosTaskEvent(event, opts);
|
|
7108
|
+
results.push(result);
|
|
7109
|
+
if (result.kind === "created" && !opts.dryRun)
|
|
7110
|
+
created += 1;
|
|
7111
|
+
if (result.kind === "created" && opts.dryRun)
|
|
7112
|
+
created += 1;
|
|
7113
|
+
}
|
|
7114
|
+
const report = {
|
|
7115
|
+
drainedAt: new Date().toISOString(),
|
|
7116
|
+
todosProject,
|
|
7117
|
+
todosProjectId: opts.todosProjectId,
|
|
7118
|
+
taskList: opts.taskList,
|
|
7119
|
+
taskListId: taskListFilter,
|
|
7120
|
+
tags: requiredTags,
|
|
7121
|
+
limit: candidateLimit,
|
|
7122
|
+
scanLimit,
|
|
7123
|
+
filtersApplied: hasPostFilters,
|
|
7124
|
+
scanned: ready.length,
|
|
7125
|
+
candidates: candidates.length,
|
|
7126
|
+
filteredCandidates: filteredCandidates.length,
|
|
7127
|
+
scanExhausted: ready.length >= scanLimit && filteredCandidates.length < candidateLimit,
|
|
7128
|
+
considered: results.length,
|
|
7129
|
+
created: results.filter((result) => result.kind === "created" && !result.value.deduped).length,
|
|
7130
|
+
deduped: results.filter((result) => result.kind === "deduped").length,
|
|
7131
|
+
throttled: results.filter((result) => result.kind === "throttled").length,
|
|
7132
|
+
skipped: results.filter((result) => result.kind === "skipped").length,
|
|
7133
|
+
maxDispatch,
|
|
7134
|
+
source: "todos ready",
|
|
7135
|
+
dryRun: Boolean(opts.dryRun),
|
|
7136
|
+
results: results.map((result) => ({ kind: result.kind, ...result.value }))
|
|
6898
7137
|
};
|
|
6899
|
-
|
|
6900
|
-
|
|
6901
|
-
const preflight = opts.preflight ? preflightStoredWorkflow(workflowSpecForPreflight(workflowBody, "event-preflight"), {
|
|
6902
|
-
name: workflowBody.name,
|
|
6903
|
-
type: "todos-task-event-workflow",
|
|
6904
|
-
event: event.id
|
|
6905
|
-
}, {}) : undefined;
|
|
6906
|
-
print({ deduped: false, idempotencyKey, event, workflow: workflowBody, loop: loopInput, throttle, preflight }, `dry-run ${loopName}`);
|
|
6907
|
-
return;
|
|
6908
|
-
}
|
|
6909
|
-
const store = new Store;
|
|
6910
|
-
try {
|
|
6911
|
-
const existingWorkflowForPreflight = store.findWorkflowByName(workflowBody.name);
|
|
6912
|
-
const workflowPreflightSpec = existingWorkflowForPreflight ?? workflowSpecForPreflight(workflowBody, "event-preflight");
|
|
6913
|
-
const preflight = opts.preflight ? preflightStoredWorkflow(workflowPreflightSpec, {
|
|
6914
|
-
name: workflowBody.name,
|
|
6915
|
-
type: "todos-task-event-workflow",
|
|
6916
|
-
event: event.id
|
|
6917
|
-
}, {}) : undefined;
|
|
6918
|
-
const outcome = store.writeTransaction(() => {
|
|
6919
|
-
const existingLoop = store.findLoopByName(loopName) ?? store.findLoopByName(legacyLoopName);
|
|
6920
|
-
if (existingLoop) {
|
|
6921
|
-
const existingWorkflow2 = existingLoop.target.type === "workflow" ? store.getWorkflow(existingLoop.target.workflowId) : undefined;
|
|
6922
|
-
return { kind: "deduped", existingLoop, existingWorkflow: existingWorkflow2 };
|
|
6923
|
-
}
|
|
6924
|
-
const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDecision(store, { projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
|
|
6925
|
-
if (throttle && !throttle.allowed)
|
|
6926
|
-
return { kind: "throttled", throttle };
|
|
6927
|
-
const existingWorkflow = store.findWorkflowByName(workflowBody.name);
|
|
6928
|
-
const workflow = existingWorkflow ?? store.createWorkflow(workflowBody);
|
|
6929
|
-
const loop = store.createLoop({
|
|
6930
|
-
...loopInput,
|
|
6931
|
-
target: { type: "workflow", workflowId: workflow.id }
|
|
6932
|
-
});
|
|
6933
|
-
return { kind: "created", workflow, loop, throttle };
|
|
6934
|
-
});
|
|
6935
|
-
if (outcome.kind === "deduped") {
|
|
6936
|
-
print({
|
|
6937
|
-
deduped: true,
|
|
6938
|
-
idempotencyKey,
|
|
6939
|
-
dedupedBy: outcome.existingLoop.name === loopName ? "idempotency" : "legacy-event-name",
|
|
6940
|
-
event,
|
|
6941
|
-
workflow: outcome.existingWorkflow ? publicWorkflow(outcome.existingWorkflow) : undefined,
|
|
6942
|
-
loop: publicLoop(outcome.existingLoop)
|
|
6943
|
-
}, `deduped existing loop ${outcome.existingLoop.id} (${outcome.existingLoop.name}) for event=${event.id} idempotency=${idempotencyKey}`);
|
|
6944
|
-
return;
|
|
6945
|
-
}
|
|
6946
|
-
if (outcome.kind === "throttled") {
|
|
6947
|
-
print({
|
|
6948
|
-
skipped: true,
|
|
6949
|
-
queuedAtSource: true,
|
|
6950
|
-
reason: outcome.throttle.reason,
|
|
6951
|
-
idempotencyKey,
|
|
6952
|
-
event,
|
|
6953
|
-
throttle: outcome.throttle,
|
|
6954
|
-
workflow: workflowBody,
|
|
6955
|
-
loop: loopInput
|
|
6956
|
-
}, `skipped task ${taskId}: ${outcome.throttle.reason}`);
|
|
6957
|
-
return;
|
|
6958
|
-
}
|
|
6959
|
-
print({ deduped: false, idempotencyKey, event, workflow: publicWorkflow(outcome.workflow), loop: publicLoop(outcome.loop), throttle: outcome.throttle, preflight }, `created ${outcome.loop.id} (${outcome.loop.name}) workflow=${outcome.workflow.name} event=${event.id} idempotency=${idempotencyKey}`);
|
|
6960
|
-
} finally {
|
|
6961
|
-
store.close();
|
|
6962
|
-
}
|
|
7138
|
+
const evidencePath = writeRouteEvidence("todos-task-drain", report, opts.evidenceDir);
|
|
7139
|
+
print({ ...report, evidencePath }, `drained todos ready queue: considered=${report.considered} created=${report.created} deduped=${report.deduped} throttled=${report.throttled} skipped=${report.skipped}`);
|
|
6963
7140
|
});
|
|
6964
7141
|
eventsHandle.command("generic").description("create a one-shot worker/verifier workflow loop for any Hasna event").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated event worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:generic").option("--preflight", "check generated workflow steps before storing the workflow loop").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
|
|
6965
7142
|
const event = await readEventEnvelopeFromStdin();
|
package/dist/daemon/index.js
CHANGED
|
@@ -4574,7 +4574,7 @@ function enableStartup(result) {
|
|
|
4574
4574
|
// package.json
|
|
4575
4575
|
var package_default = {
|
|
4576
4576
|
name: "@hasna/loops",
|
|
4577
|
-
version: "0.3.
|
|
4577
|
+
version: "0.3.28",
|
|
4578
4578
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
4579
4579
|
type: "module",
|
|
4580
4580
|
main: "dist/index.js",
|
package/docs/USAGE.md
CHANGED
|
@@ -310,6 +310,39 @@ by task/event id before rendering worktree plans or checking route limits. In
|
|
|
310
310
|
dry-run mode, throttle counts are not evaluated because opening the live loop
|
|
311
311
|
store can create or migrate the local database.
|
|
312
312
|
|
|
313
|
+
When tasks were created while capacity was full, or when bulk producers created
|
|
314
|
+
many tasks at once, use the drain command instead of replaying every webhook by
|
|
315
|
+
hand. It scans `todos ready --json`, so tasks with incomplete dependencies,
|
|
316
|
+
locks, or non-pending states stay queued in todos and are not routed:
|
|
317
|
+
|
|
318
|
+
```bash
|
|
319
|
+
loops events drain todos-task \
|
|
320
|
+
--todos-project /home/hasna/.hasna/loops \
|
|
321
|
+
--task-list repoops-pr-queue \
|
|
322
|
+
--tags auto:route \
|
|
323
|
+
--provider codewith \
|
|
324
|
+
--auth-profile-pool account004,account005,account006 \
|
|
325
|
+
--project-group oss \
|
|
326
|
+
--max-dispatch 2 \
|
|
327
|
+
--scan-limit 500 \
|
|
328
|
+
--max-active-per-project 1 \
|
|
329
|
+
--max-active-per-project-group 4 \
|
|
330
|
+
--max-active 12 \
|
|
331
|
+
--worktree-mode auto \
|
|
332
|
+
--evidence-dir /home/hasna/.hasna/loops/reports/task-drain
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
`--max-dispatch` caps new workflow-loop creation per drain run. `--limit` caps
|
|
336
|
+
filtered ready-task candidates, while `--scan-limit` controls how many raw
|
|
337
|
+
`todos ready` rows are fetched before filters. When `--task-list`, `--tags`, or
|
|
338
|
+
`--todos-project-id` are set, the default scan limit is raised to 500 so a busy
|
|
339
|
+
shared queue is less likely to starve project-specific drains. The route
|
|
340
|
+
throttle flags are still checked for every candidate, so a drain can safely run
|
|
341
|
+
every few minutes as a deterministic command loop: it fills only available
|
|
342
|
+
capacity, writes compact JSON evidence when requested, and leaves excess ready
|
|
343
|
+
tasks in todos for a later drain pass. Use `--dry-run` to preview candidates and
|
|
344
|
+
rendered workflows without mutating OpenLoops state.
|
|
345
|
+
|
|
313
346
|
For other Hasna apps that expose `@hasna/events` webhooks, use the generic
|
|
314
347
|
handler:
|
|
315
348
|
|
package/package.json
CHANGED