@hasna/loops 0.3.28 → 0.3.30
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 +24 -5
- package/dist/daemon/index.js +1 -1
- package/docs/USAGE.md +11 -8
- 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.30",
|
|
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",
|
|
@@ -6636,6 +6636,10 @@ function routeThrottleDryRunPreview(args) {
|
|
|
6636
6636
|
limits: args.limits
|
|
6637
6637
|
};
|
|
6638
6638
|
}
|
|
6639
|
+
function findLoopByTaskIdempotency(store, idempotencyKey) {
|
|
6640
|
+
const marker = `idempotency=${idempotencyKey}`;
|
|
6641
|
+
return store.listLoops({ includeArchived: true, limit: 1e5 }).find((loop) => loop.description?.includes(marker));
|
|
6642
|
+
}
|
|
6639
6643
|
async function readEventEnvelopeFromStdin() {
|
|
6640
6644
|
const raw = process.env.HASNA_EVENT_JSON || await Bun.stdin.text();
|
|
6641
6645
|
const event = JSON.parse(raw);
|
|
@@ -6687,7 +6691,7 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
6687
6691
|
if (!opts.dryRun) {
|
|
6688
6692
|
const store2 = new Store;
|
|
6689
6693
|
try {
|
|
6690
|
-
const existingLoop = store2.findLoopByName(loopName) ?? store2.findLoopByName(legacyLoopName);
|
|
6694
|
+
const existingLoop = store2.findLoopByName(loopName) ?? store2.findLoopByName(legacyLoopName) ?? findLoopByTaskIdempotency(store2, idempotencyKey);
|
|
6691
6695
|
if (existingLoop) {
|
|
6692
6696
|
const existingWorkflow = existingLoop.target.type === "workflow" ? store2.getWorkflow(existingLoop.target.workflowId) : undefined;
|
|
6693
6697
|
return {
|
|
@@ -6775,7 +6779,7 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
6775
6779
|
event: event.id
|
|
6776
6780
|
}, {}) : undefined;
|
|
6777
6781
|
const outcome = store.writeTransaction(() => {
|
|
6778
|
-
const existingLoop = store.findLoopByName(loopName) ?? store.findLoopByName(legacyLoopName);
|
|
6782
|
+
const existingLoop = store.findLoopByName(loopName) ?? store.findLoopByName(legacyLoopName) ?? findLoopByTaskIdempotency(store, idempotencyKey);
|
|
6779
6783
|
if (existingLoop) {
|
|
6780
6784
|
const existingWorkflow2 = existingLoop.target.type === "workflow" ? store.getWorkflow(existingLoop.target.workflowId) : undefined;
|
|
6781
6785
|
return { kind: "deduped", existingLoop, existingWorkflow: existingWorkflow2 };
|
|
@@ -6852,6 +6856,10 @@ function taskListId(task) {
|
|
|
6852
6856
|
function taskProjectId(task) {
|
|
6853
6857
|
return taskField(task, ["project_id", "projectId"]);
|
|
6854
6858
|
}
|
|
6859
|
+
function taskProjectPath(task) {
|
|
6860
|
+
const metadata = objectField(task.metadata) ?? {};
|
|
6861
|
+
return taskField(task, ["working_dir", "workingDir", "project_path", "projectPath", "cwd"]) ?? taskEventField(metadata, ["working_dir", "workingDir", "project_path", "projectPath", "project_canonical_path", "cwd"]);
|
|
6862
|
+
}
|
|
6855
6863
|
function taskDrainEvent(task) {
|
|
6856
6864
|
const taskId = taskField(task, ["id", "task_id", "taskId"]);
|
|
6857
6865
|
if (!taskId)
|
|
@@ -6917,6 +6925,15 @@ function taskMatchesDrainFilters(task, filters) {
|
|
|
6917
6925
|
return false;
|
|
6918
6926
|
if (filters.taskListId && taskListId(task) !== filters.taskListId)
|
|
6919
6927
|
return false;
|
|
6928
|
+
if (filters.projectPathPrefix) {
|
|
6929
|
+
const path = taskProjectPath(task);
|
|
6930
|
+
if (!path)
|
|
6931
|
+
return false;
|
|
6932
|
+
const normalizedPath = normalizeRoutePath(path) ?? resolve2(path);
|
|
6933
|
+
const normalizedPrefix = normalizeRoutePath(filters.projectPathPrefix) ?? resolve2(filters.projectPathPrefix);
|
|
6934
|
+
if (normalizedPath !== normalizedPrefix && !normalizedPath.startsWith(`${normalizedPrefix}/`))
|
|
6935
|
+
return false;
|
|
6936
|
+
}
|
|
6920
6937
|
if (filters.tags.length) {
|
|
6921
6938
|
const taskTags = new Set(tagsFromValue(task.tags));
|
|
6922
6939
|
for (const tag of filters.tags) {
|
|
@@ -7082,19 +7099,20 @@ eventsHandle.command("todos-task").description("create a one-shot worker/verifie
|
|
|
7082
7099
|
print(result.value, result.human);
|
|
7083
7100
|
});
|
|
7084
7101
|
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) => {
|
|
7102
|
+
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("--project-path-prefix <path>", "filter ready tasks to a project/repo path prefix").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
7103
|
const maxDispatch = positiveInteger(opts.maxDispatch ?? "1", "--max-dispatch") ?? 1;
|
|
7087
7104
|
const todosProject = opts.todosProject ?? defaultLoopsProject();
|
|
7088
7105
|
const requiredTags = splitList(opts.tags ?? opts.tag) ?? [];
|
|
7089
7106
|
const taskListFilter = resolveTaskListFilter(todosProject, opts.taskList);
|
|
7090
7107
|
const candidateLimit = positiveInteger(opts.limit ?? "50", "--limit") ?? 50;
|
|
7091
|
-
const hasPostFilters = Boolean(opts.todosProjectId || taskListFilter || requiredTags.length);
|
|
7108
|
+
const hasPostFilters = Boolean(opts.todosProjectId || taskListFilter || opts.projectPathPrefix || requiredTags.length);
|
|
7092
7109
|
const defaultScanLimit = hasPostFilters ? Math.max(candidateLimit, 500) : candidateLimit;
|
|
7093
7110
|
const scanLimit = positiveInteger(opts.scanLimit ?? String(defaultScanLimit), "--scan-limit") ?? defaultScanLimit;
|
|
7094
7111
|
const ready = loadReadyTodosTasks(opts, scanLimit);
|
|
7095
7112
|
const filteredCandidates = ready.filter((task) => taskMatchesDrainFilters(task, {
|
|
7096
7113
|
projectId: opts.todosProjectId,
|
|
7097
7114
|
taskListId: taskListFilter,
|
|
7115
|
+
projectPathPrefix: opts.projectPathPrefix,
|
|
7098
7116
|
tags: requiredTags
|
|
7099
7117
|
}));
|
|
7100
7118
|
const candidates = filteredCandidates.slice(0, candidateLimit);
|
|
@@ -7117,6 +7135,7 @@ eventsDrain.command("todos-task").description("drain ready todos tasks into boun
|
|
|
7117
7135
|
todosProjectId: opts.todosProjectId,
|
|
7118
7136
|
taskList: opts.taskList,
|
|
7119
7137
|
taskListId: taskListFilter,
|
|
7138
|
+
projectPathPrefix: opts.projectPathPrefix,
|
|
7120
7139
|
tags: requiredTags,
|
|
7121
7140
|
limit: candidateLimit,
|
|
7122
7141
|
scanLimit,
|
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.30",
|
|
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
|
@@ -320,6 +320,7 @@ loops events drain todos-task \
|
|
|
320
320
|
--todos-project /home/hasna/.hasna/loops \
|
|
321
321
|
--task-list repoops-pr-queue \
|
|
322
322
|
--tags auto:route \
|
|
323
|
+
--project-path-prefix /home/hasna/workspace/hasna/opensource \
|
|
323
324
|
--provider codewith \
|
|
324
325
|
--auth-profile-pool account004,account005,account006 \
|
|
325
326
|
--project-group oss \
|
|
@@ -334,14 +335,16 @@ loops events drain todos-task \
|
|
|
334
335
|
|
|
335
336
|
`--max-dispatch` caps new workflow-loop creation per drain run. `--limit` caps
|
|
336
337
|
filtered ready-task candidates, while `--scan-limit` controls how many raw
|
|
337
|
-
`todos ready` rows are fetched before filters.
|
|
338
|
-
`--todos-project-id
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
338
|
+
`todos ready` rows are fetched before filters. Use `--task-list`, `--tags`,
|
|
339
|
+
`--todos-project-id`, and `--project-path-prefix` to keep each drain aligned
|
|
340
|
+
with the route/name-prefix it services. When any of those filters are set, the
|
|
341
|
+
default scan limit is raised to 500 so a busy shared queue is less likely to
|
|
342
|
+
starve project-specific drains. The route throttle flags are still checked for
|
|
343
|
+
every candidate, so a drain can safely run every few minutes as a deterministic
|
|
344
|
+
command loop: it fills only available capacity, writes compact JSON evidence
|
|
345
|
+
when requested, and leaves excess ready tasks in todos for a later drain pass.
|
|
346
|
+
Use `--dry-run` to preview candidates and rendered workflows without mutating
|
|
347
|
+
OpenLoops state.
|
|
345
348
|
|
|
346
349
|
For other Hasna apps that expose `@hasna/events` webhooks, use the generic
|
|
347
350
|
handler:
|
package/package.json
CHANGED