@hasna/loops 0.3.53 → 0.3.55

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.
@@ -1,4 +1,4 @@
1
- import type { CreateLoopInput, CreateWorkflowInvocationInput, CreateWorkflowInput, Goal, GoalAutoExecute, GoalPlanNode, GoalRun, GoalStatus, Loop, LoopRun, LoopStatus, RunStatus, WorkflowEvent, WorkflowInvocation, WorkflowRun, WorkflowRunStatus, WorkflowSpec, WorkflowStepRun, WorkflowWorkItem, WorkflowWorkItemStatus, UpsertWorkflowWorkItemInput } from "../types.js";
1
+ import type { CreateLoopInput, CreateWorkflowInvocationInput, CreateWorkflowInput, Goal, GoalAutoExecute, GoalPlanNode, GoalRun, GoalStatus, Loop, LoopRun, LoopStatus, RunStatus, TimeoutMs, WorkflowEvent, WorkflowInvocation, WorkflowRun, WorkflowRunStatus, WorkflowSpec, WorkflowStepRun, WorkflowWorkItem, WorkflowWorkItemStatus, UpsertWorkflowWorkItemInput } from "../types.js";
2
2
  interface DaemonLeaseFence {
3
3
  daemonLeaseId?: string;
4
4
  now?: Date;
@@ -74,6 +74,7 @@ export declare class Store {
74
74
  createLoop(input: CreateLoopInput, from?: Date): Loop;
75
75
  getLoop(id: string): Loop | undefined;
76
76
  findLoopByName(name: string): Loop | undefined;
77
+ requireUniqueLoop(idOrName: string): Loop;
77
78
  requireLoop(idOrName: string): Loop;
78
79
  listLoops(opts?: {
79
80
  status?: LoopStatus;
@@ -83,6 +84,20 @@ export declare class Store {
83
84
  }): Loop[];
84
85
  dueLoops(now: Date, limit?: number): Loop[];
85
86
  updateLoop(id: string, patch: Partial<Pick<Loop, "status" | "nextRunAt" | "retryScheduledFor" | "expiresAt">>, opts?: DaemonLeaseFence): Loop;
87
+ private activeLoopReferenceCount;
88
+ private archiveWorkflowIfUnreferenced;
89
+ retargetWorkflowLoop(idOrName: string, workflowId: string, opts?: DaemonLeaseFence & {
90
+ workflowTimeoutMs?: TimeoutMs;
91
+ }): Loop;
92
+ createAndRetargetWorkflowLoop(idOrName: string, workflowInput: CreateWorkflowInput, opts?: DaemonLeaseFence & {
93
+ workflowTimeoutMs?: TimeoutMs;
94
+ archiveOld?: boolean;
95
+ }): {
96
+ loop: Loop;
97
+ workflow: WorkflowSpec;
98
+ previousWorkflow: WorkflowSpec;
99
+ archivedOld?: WorkflowSpec;
100
+ };
86
101
  renameLoop(id: string, name: string, opts?: DaemonLeaseFence): Loop;
87
102
  archiveLoop(idOrName: string): Loop;
88
103
  unarchiveLoop(idOrName: string): Loop;
package/dist/lib/store.js CHANGED
@@ -312,6 +312,8 @@ function updateReadyFlags(nodes, planStatus) {
312
312
  }
313
313
 
314
314
  // src/lib/workflow-spec.ts
315
+ import { readFileSync } from "fs";
316
+ import { isAbsolute, resolve } from "path";
315
317
  function assertObject(value, label) {
316
318
  if (!value || typeof value !== "object" || Array.isArray(value))
317
319
  throw new Error(`${label} must be an object`);
@@ -327,6 +329,15 @@ function optionalPositiveInteger(value, label) {
327
329
  throw new Error(`${label} must be a positive integer`);
328
330
  return value;
329
331
  }
332
+ function optionalTimeoutMs(value, label) {
333
+ if (value === undefined)
334
+ return;
335
+ if (value === null)
336
+ return null;
337
+ if (!Number.isInteger(value) || value <= 0)
338
+ throw new Error(`${label} must be a positive integer or null for unlimited`);
339
+ return value;
340
+ }
330
341
  function optionalBoolean(value, label) {
331
342
  if (value === undefined)
332
343
  return;
@@ -357,6 +368,38 @@ function optionalAccountRef(value, label) {
357
368
  tool: typeof value.tool === "string" ? value.tool.trim() : undefined
358
369
  };
359
370
  }
371
+ function promptFilePath(rawPath, opts) {
372
+ return isAbsolute(rawPath) ? resolve(rawPath) : resolve(opts.baseDir ?? process.cwd(), rawPath);
373
+ }
374
+ function readPromptFile(rawPath, label, opts) {
375
+ assertString(rawPath, `${label}.promptFile`);
376
+ const path = promptFilePath(rawPath.trim(), opts);
377
+ let prompt;
378
+ try {
379
+ prompt = readFileSync(path, "utf8");
380
+ } catch (error) {
381
+ const code = error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
382
+ throw new Error(`${label}.promptFile could not be read${code ? `: ${code}` : ""}`);
383
+ }
384
+ if (prompt.trim() === "")
385
+ throw new Error(`${label}.promptFile must contain a non-empty prompt`);
386
+ return { prompt, promptSource: { type: "file", path } };
387
+ }
388
+ function matchingPromptSource(value, prompt, opts) {
389
+ if (!value || typeof value !== "object" || Array.isArray(value))
390
+ return;
391
+ if (value.type !== "file")
392
+ return;
393
+ const rawPath = value.path;
394
+ if (typeof rawPath !== "string" || rawPath.trim() === "")
395
+ return;
396
+ const path = promptFilePath(rawPath.trim(), opts);
397
+ try {
398
+ return readFileSync(path, "utf8") === prompt ? { type: "file", path } : undefined;
399
+ } catch {
400
+ return;
401
+ }
402
+ }
360
403
  function normalizeGoalSpec(value, label = "goal") {
361
404
  if (value === undefined)
362
405
  return;
@@ -379,11 +422,11 @@ function normalizeGoalSpec(value, label = "goal") {
379
422
  autoExecute
380
423
  };
381
424
  }
382
- function validateTarget(value, label) {
425
+ function validateTarget(value, label, opts) {
383
426
  assertObject(value, label);
384
427
  if (value.type === "command") {
385
428
  assertString(value.command, `${label}.command`);
386
- optionalPositiveInteger(value.timeoutMs, `${label}.timeoutMs`);
429
+ optionalTimeoutMs(value.timeoutMs, `${label}.timeoutMs`);
387
430
  optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
388
431
  if (value.shell !== true && /\s/.test(value.command.trim())) {
389
432
  throw new Error(`${label}.command must be an executable without spaces when shell is false; put flags in args or set shell true`);
@@ -392,11 +435,17 @@ function validateTarget(value, label) {
392
435
  }
393
436
  if (value.type === "agent") {
394
437
  assertString(value.provider, `${label}.provider`);
395
- assertString(value.prompt, `${label}.prompt`);
438
+ const hasPrompt = typeof value.prompt === "string" && value.prompt.trim() !== "";
439
+ const hasPromptFile = typeof value.promptFile === "string" && value.promptFile.trim() !== "";
440
+ if (hasPrompt && hasPromptFile)
441
+ throw new Error(`${label} must use either prompt or promptFile, not both`);
442
+ if (!hasPrompt && !hasPromptFile)
443
+ throw new Error(`${label}.prompt must be a non-empty string or ${label}.promptFile must be set`);
444
+ const promptFields = hasPrompt ? { prompt: value.prompt, promptSource: matchingPromptSource(value.promptSource, value.prompt, opts) } : readPromptFile(value.promptFile, label, opts);
396
445
  const providers = ["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"];
397
446
  if (!providers.includes(value.provider))
398
447
  throw new Error(`${label}.provider must be one of ${providers.join(", ")}`);
399
- optionalPositiveInteger(value.timeoutMs, `${label}.timeoutMs`);
448
+ optionalTimeoutMs(value.timeoutMs, `${label}.timeoutMs`);
400
449
  optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
401
450
  if (value.authProfile !== undefined) {
402
451
  assertString(value.authProfile, `${label}.authProfile`);
@@ -493,11 +542,14 @@ function validateTarget(value, label) {
493
542
  if (value.routing.eventSource !== undefined)
494
543
  assertString(value.routing.eventSource, `${label}.routing.eventSource`);
495
544
  }
496
- return value;
545
+ const target = { ...value };
546
+ delete target.promptFile;
547
+ delete target.promptSource;
548
+ return { ...target, ...promptFields };
497
549
  }
498
550
  throw new Error(`${label}.type must be command or agent`);
499
551
  }
500
- function normalizeCreateWorkflowInput(input) {
552
+ function normalizeCreateWorkflowInput(input, opts = {}) {
501
553
  assertString(input.name, "workflow.name");
502
554
  const goal = normalizeGoalSpec(input.goal, "goal");
503
555
  if (!Array.isArray(input.steps) || input.steps.length === 0)
@@ -513,10 +565,10 @@ function normalizeCreateWorkflowInput(input) {
513
565
  ...step,
514
566
  id: step.id,
515
567
  goal: normalizeGoalSpec(step.goal, `workflow.steps[${index}].goal`),
516
- target: validateTarget(step.target, `workflow.steps[${index}].target`),
568
+ target: validateTarget(step.target, `workflow.steps[${index}].target`, opts),
517
569
  dependsOn: optionalStringArray(step.dependsOn, `workflow.steps[${index}].dependsOn`) ?? [],
518
570
  continueOnFailure: optionalBoolean(step.continueOnFailure, `workflow.steps[${index}].continueOnFailure`) ?? false,
519
- timeoutMs: optionalPositiveInteger(step.timeoutMs, `workflow.steps[${index}].timeoutMs`),
571
+ timeoutMs: optionalTimeoutMs(step.timeoutMs, `workflow.steps[${index}].timeoutMs`),
520
572
  account: optionalAccountRef(step.account, `workflow.steps[${index}].account`)
521
573
  };
522
574
  });
@@ -556,7 +608,7 @@ function workflowExecutionOrder(workflow) {
556
608
  visit(step);
557
609
  return order;
558
610
  }
559
- function workflowBodyFromJson(value, fallbackName) {
611
+ function workflowBodyFromJson(value, fallbackName, opts = {}) {
560
612
  assertObject(value, "workflow file");
561
613
  const rawName = fallbackName ?? value.name;
562
614
  assertString(rawName, "workflow.name");
@@ -568,7 +620,7 @@ function workflowBodyFromJson(value, fallbackName) {
568
620
  goal: normalizeGoalSpec(value.goal, "goal"),
569
621
  version: typeof value.version === "number" ? value.version : undefined,
570
622
  steps: value.steps
571
- });
623
+ }, opts);
572
624
  }
573
625
 
574
626
  // src/lib/run-artifacts.ts
@@ -1205,13 +1257,17 @@ class Store {
1205
1257
  }
1206
1258
  createLoop(input, from = new Date) {
1207
1259
  const now = nowIso();
1260
+ const target = input.target.type === "workflow" ? input.target : normalizeCreateWorkflowInput({
1261
+ name: "loop-target-validation",
1262
+ steps: [{ id: "target", target: input.target }]
1263
+ }).steps[0].target;
1208
1264
  const loop = {
1209
1265
  id: genId(),
1210
1266
  name: input.name,
1211
1267
  description: input.description,
1212
1268
  status: "active",
1213
1269
  schedule: input.schedule,
1214
- target: input.target,
1270
+ target,
1215
1271
  goal: input.goal,
1216
1272
  machine: input.machine,
1217
1273
  nextRunAt: initialNextRun(input.schedule, from),
@@ -1258,6 +1314,17 @@ class Store {
1258
1314
  const row = this.db.query("SELECT * FROM loops WHERE name = ? ORDER BY created_at DESC LIMIT 1").get(name);
1259
1315
  return row ? rowToLoop(row) : undefined;
1260
1316
  }
1317
+ requireUniqueLoop(idOrName) {
1318
+ const byId = this.getLoop(idOrName);
1319
+ if (byId)
1320
+ return byId;
1321
+ const rows = this.db.query("SELECT * FROM loops WHERE name = ? ORDER BY created_at DESC LIMIT 2").all(idOrName);
1322
+ if (rows.length === 0)
1323
+ throw new Error(`loop not found: ${idOrName}`);
1324
+ if (rows.length > 1)
1325
+ throw new Error(`ambiguous loop name: ${idOrName}; use a loop id`);
1326
+ return rowToLoop(rows[0]);
1327
+ }
1261
1328
  requireLoop(idOrName) {
1262
1329
  return this.getLoop(idOrName) ?? this.findLoopByName(idOrName) ?? (() => {
1263
1330
  throw new Error(`loop not found: ${idOrName}`);
@@ -1332,6 +1399,130 @@ class Store {
1332
1399
  throw new Error(`loop not found after update: ${id}`);
1333
1400
  return after;
1334
1401
  }
1402
+ activeLoopReferenceCount(workflowId) {
1403
+ const rows = this.db.query("SELECT target_json FROM loops WHERE archived_at IS NULL").all();
1404
+ let count = 0;
1405
+ for (const row of rows) {
1406
+ try {
1407
+ const target = JSON.parse(row.target_json);
1408
+ if (target.type === "workflow" && target.workflowId === workflowId)
1409
+ count += 1;
1410
+ } catch {}
1411
+ }
1412
+ return count;
1413
+ }
1414
+ archiveWorkflowIfUnreferenced(workflowId, updated) {
1415
+ if (this.activeLoopReferenceCount(workflowId) > 0)
1416
+ return;
1417
+ const workflow = this.getWorkflow(workflowId);
1418
+ if (!workflow || workflow.status !== "active")
1419
+ return;
1420
+ const res = this.db.query("UPDATE workflow_specs SET status='archived', updated_at=? WHERE id=? AND status='active'").run(updated, workflowId);
1421
+ if (res.changes !== 1)
1422
+ return;
1423
+ return this.getWorkflow(workflowId);
1424
+ }
1425
+ retargetWorkflowLoop(idOrName, workflowId, opts = {}) {
1426
+ const updated = (opts.now ?? new Date).toISOString();
1427
+ this.db.exec("BEGIN IMMEDIATE");
1428
+ try {
1429
+ const current = this.requireLoop(idOrName);
1430
+ if (current.target.type !== "workflow")
1431
+ throw new Error(`loop is not a workflow loop: ${idOrName}`);
1432
+ if (this.hasRunningRun(current.id))
1433
+ throw new Error(`refusing to retarget running loop: ${current.id}`);
1434
+ const workflow = this.requireWorkflow(workflowId);
1435
+ const target = { ...current.target, workflowId: workflow.id };
1436
+ if (opts.workflowTimeoutMs !== undefined)
1437
+ target.timeoutMs = opts.workflowTimeoutMs;
1438
+ const res = this.db.query(`UPDATE loops SET target_json=$target, updated_at=$updated
1439
+ WHERE id=$id
1440
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1441
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1442
+ ))`).run({
1443
+ $id: current.id,
1444
+ $target: JSON.stringify(target),
1445
+ $updated: updated,
1446
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1447
+ $now: updated
1448
+ });
1449
+ if (res.changes !== 1)
1450
+ throw new Error("daemon lease lost");
1451
+ this.db.exec("COMMIT");
1452
+ const after = this.getLoop(current.id);
1453
+ if (!after)
1454
+ throw new Error(`loop not found after retarget: ${current.id}`);
1455
+ return after;
1456
+ } catch (error) {
1457
+ try {
1458
+ this.db.exec("ROLLBACK");
1459
+ } catch {}
1460
+ throw error;
1461
+ }
1462
+ }
1463
+ createAndRetargetWorkflowLoop(idOrName, workflowInput, opts = {}) {
1464
+ const normalized = normalizeCreateWorkflowInput(workflowInput);
1465
+ const updated = (opts.now ?? new Date).toISOString();
1466
+ this.db.exec("BEGIN IMMEDIATE");
1467
+ try {
1468
+ const current = this.requireUniqueLoop(idOrName);
1469
+ if (current.target.type !== "workflow")
1470
+ throw new Error(`loop is not a workflow loop: ${idOrName}`);
1471
+ if (this.hasRunningRun(current.id))
1472
+ throw new Error(`refusing to retarget running loop: ${current.id}`);
1473
+ const previousWorkflow = this.requireWorkflow(current.target.workflowId);
1474
+ const workflow = {
1475
+ id: genId(),
1476
+ name: normalized.name,
1477
+ description: normalized.description,
1478
+ version: normalized.version ?? 1,
1479
+ status: "active",
1480
+ goal: normalized.goal,
1481
+ steps: normalized.steps,
1482
+ createdAt: updated,
1483
+ updatedAt: updated
1484
+ };
1485
+ this.db.query(`INSERT INTO workflow_specs (id, name, description, version, status, goal_json, steps_json, created_at, updated_at)
1486
+ VALUES ($id, $name, $description, $version, $status, $goal, $steps, $created, $updated)`).run({
1487
+ $id: workflow.id,
1488
+ $name: workflow.name,
1489
+ $description: workflow.description ?? null,
1490
+ $version: workflow.version,
1491
+ $status: workflow.status,
1492
+ $goal: workflow.goal ? JSON.stringify(workflow.goal) : null,
1493
+ $steps: JSON.stringify(workflow.steps),
1494
+ $created: workflow.createdAt,
1495
+ $updated: workflow.updatedAt
1496
+ });
1497
+ const target = { ...current.target, workflowId: workflow.id };
1498
+ if (opts.workflowTimeoutMs !== undefined)
1499
+ target.timeoutMs = opts.workflowTimeoutMs;
1500
+ const res = this.db.query(`UPDATE loops SET target_json=$target, updated_at=$updated
1501
+ WHERE id=$id
1502
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1503
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1504
+ ))`).run({
1505
+ $id: current.id,
1506
+ $target: JSON.stringify(target),
1507
+ $updated: updated,
1508
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1509
+ $now: updated
1510
+ });
1511
+ if (res.changes !== 1)
1512
+ throw new Error("daemon lease lost");
1513
+ const archivedOld = opts.archiveOld ? this.archiveWorkflowIfUnreferenced(previousWorkflow.id, updated) : undefined;
1514
+ this.db.exec("COMMIT");
1515
+ const loop = this.getLoop(current.id);
1516
+ if (!loop)
1517
+ throw new Error(`loop not found after retarget: ${current.id}`);
1518
+ return { loop, workflow, previousWorkflow, archivedOld };
1519
+ } catch (error) {
1520
+ try {
1521
+ this.db.exec("ROLLBACK");
1522
+ } catch {}
1523
+ throw error;
1524
+ }
1525
+ }
1335
1526
  renameLoop(id, name, opts = {}) {
1336
1527
  const current = this.getLoop(id);
1337
1528
  if (!current)
@@ -1,4 +1,4 @@
1
- import type { AccountRef, AgentPermissionMode, AgentProvider, AgentSandbox, AgentWorktreeMode, CreateWorkflowInput, LoopTemplateSource, LoopTemplateSummary } from "../types.js";
1
+ import type { AccountRef, AgentPermissionMode, AgentProvider, AgentSandbox, AgentWorktreeMode, CreateWorkflowInput, LoopTemplateSource, LoopTemplateSummary, TimeoutMs } from "../types.js";
2
2
  export declare const TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
3
3
  export declare const EVENT_WORKER_VERIFIER_TEMPLATE_ID = "event-worker-verifier";
4
4
  export declare const BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID = "bounded-agent-worker-verifier";
@@ -40,6 +40,7 @@ export interface TodosTaskWorkflowTemplateInput {
40
40
  worktreeMode?: AgentWorktreeMode;
41
41
  worktreeRoot?: string;
42
42
  worktreeBranchPrefix?: string;
43
+ timeoutMs?: TimeoutMs;
43
44
  eventId?: string;
44
45
  eventType?: string;
45
46
  }
@@ -76,6 +77,7 @@ export interface EventWorkflowTemplateInput {
76
77
  worktreeMode?: AgentWorktreeMode;
77
78
  worktreeRoot?: string;
78
79
  worktreeBranchPrefix?: string;
80
+ timeoutMs?: TimeoutMs;
79
81
  }
80
82
  export interface BoundedAgentWorkflowTemplateInput {
81
83
  name?: string;
@@ -107,7 +109,7 @@ export interface BoundedAgentWorkflowTemplateInput {
107
109
  worktreeMode?: AgentWorktreeMode;
108
110
  worktreeRoot?: string;
109
111
  worktreeBranchPrefix?: string;
110
- timeoutMs?: number;
112
+ timeoutMs?: TimeoutMs;
111
113
  }
112
114
  export type LoopTemplateSourceFilter = LoopTemplateSource | "all";
113
115
  export interface ListLoopTemplatesOptions {
@@ -1,6 +1,12 @@
1
1
  import type { CreateWorkflowInput, GoalSpec, WorkflowSpec, WorkflowStep } from "../types.js";
2
2
  export type WorkflowSpecBody = Pick<WorkflowSpec, "name" | "description" | "version" | "steps">;
3
+ export type NormalizedCreateWorkflowInput = Omit<CreateWorkflowInput, "steps"> & {
4
+ steps: WorkflowStep[];
5
+ };
6
+ export interface WorkflowNormalizeOptions {
7
+ baseDir?: string;
8
+ }
3
9
  export declare function normalizeGoalSpec(value: unknown, label?: string): GoalSpec | undefined;
4
- export declare function normalizeCreateWorkflowInput(input: CreateWorkflowInput): CreateWorkflowInput;
10
+ export declare function normalizeCreateWorkflowInput(input: CreateWorkflowInput, opts?: WorkflowNormalizeOptions): NormalizedCreateWorkflowInput;
5
11
  export declare function workflowExecutionOrder(workflow: Pick<WorkflowSpec, "steps">): WorkflowStep[];
6
- export declare function workflowBodyFromJson(value: unknown, fallbackName?: string): CreateWorkflowInput;
12
+ export declare function workflowBodyFromJson(value: unknown, fallbackName?: string, opts?: WorkflowNormalizeOptions): NormalizedCreateWorkflowInput;