@elmundi/ship-cli 0.12.0 → 0.12.2

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.
@@ -49,6 +49,18 @@ export const LANE_FANOUT_MODES = Object.freeze([
49
49
  ]);
50
50
  export const LANE_FANOUT_DEFAULT = "matrix";
51
51
 
52
+ export const PROCESS_AGENT_PROFILES = Object.freeze([
53
+ "auto",
54
+ "main",
55
+ "cheaper",
56
+ "cursor_agent",
57
+ "codex_cli",
58
+ "ship_cloud_agent",
59
+ "local_cli",
60
+ ]);
61
+
62
+ export const PROCESS_TRIGGER_TYPES = Object.freeze(["manual", "event", "schedule"]);
63
+
52
64
  /* Lane ids travel into file paths (`.ship/state/<key>.json`), workflow
53
65
  * file names (`.github/workflows/ship-<lane>.yml`), and env vars, so
54
66
  * restrict them conservatively: ASCII lowercase, digits, dash, underscore. */
@@ -198,14 +210,14 @@ export function DEFAULT_PROCESS_CONFIG() {
198
210
  { from: "dev_implementation", to: "qa_manual" },
199
211
  { from: "qa_manual", to: "pr_review" },
200
212
  ],
201
- routines: [],
213
+ routines: {},
202
214
  };
203
215
  }
204
216
 
205
217
  /**
206
218
  * Back-compat alias. Pre-existing callers expect DEFAULT_CONFIG() to
207
219
  * return the current schema's shape; default to v2 so new installs
208
- * benefit from lanes.
220
+ * benefit from process routines.
209
221
  */
210
222
  export function DEFAULT_CONFIG() {
211
223
  return DEFAULT_CONFIG_V2();
@@ -346,7 +358,7 @@ function validateV2Process(obj, errors, warnings) {
346
358
  }
347
359
  pushUnknownKeyWarnings(
348
360
  state,
349
- new Set(["id", "name", "specialist", "layout", "instructions", "triggers", "exit_conditions", "block_conditions"]),
361
+ new Set(["id", "name", "specialist", "agent_profile", "layout", "instructions", "triggers", "exit_conditions", "block_conditions"]),
350
362
  prefix,
351
363
  warnings,
352
364
  );
@@ -363,9 +375,33 @@ function validateV2Process(obj, errors, warnings) {
363
375
  if (state.instructions !== undefined && typeof state.instructions !== "string") {
364
376
  errors.push(`${prefix}.instructions: must be a string when set`);
365
377
  }
366
- if (state.specialist !== undefined && !isPlainObject(state.specialist) && typeof state.specialist !== "string") {
367
- errors.push(`${prefix}.specialist: must be an object or string when set`);
378
+ if (state.specialist !== undefined) {
379
+ if (typeof state.specialist === "string") {
380
+ if (!state.specialist.trim()) {
381
+ errors.push(`${prefix}.specialist: must be a non-empty string when set`);
382
+ }
383
+ } else if (!isPlainObject(state.specialist)) {
384
+ errors.push(`${prefix}.specialist: must be an object or string when set`);
385
+ } else {
386
+ pushUnknownKeyWarnings(
387
+ state.specialist,
388
+ new Set(["id", "name", "agent_profile"]),
389
+ `${prefix}.specialist`,
390
+ warnings,
391
+ );
392
+ requireOptionalString(state.specialist.id, `${prefix}.specialist.id`, errors, { required: false });
393
+ requireOptionalString(state.specialist.name, `${prefix}.specialist.name`, errors, { required: false });
394
+ validateProcessAgentProfile(
395
+ state.specialist.agent_profile,
396
+ `${prefix}.specialist.agent_profile`,
397
+ errors,
398
+ );
399
+ }
368
400
  }
401
+ validateProcessAgentProfile(state.agent_profile, `${prefix}.agent_profile`, errors);
402
+ validateProcessTriggers(state.triggers, `${prefix}.triggers`, errors);
403
+ validateProcessConditions(state.exit_conditions, `${prefix}.exit_conditions`, errors);
404
+ validateProcessConditions(state.block_conditions, `${prefix}.block_conditions`, errors);
369
405
  if (state.layout !== undefined) {
370
406
  if (!isPlainObject(state.layout)) {
371
407
  errors.push(`${prefix}.layout: must be an object when set`);
@@ -399,12 +435,162 @@ function validateV2Process(obj, errors, warnings) {
399
435
  if (!stateIds.has(transition.to)) {
400
436
  errors.push(`${prefix}.to: must reference an existing state id`);
401
437
  }
438
+ requireOptionalString(transition.condition, `${prefix}.condition`, errors, { required: false });
402
439
  }
403
440
  }
404
441
  }
405
442
 
406
- if (process.routines !== undefined && !Array.isArray(process.routines)) {
407
- errors.push("process.routines: must be a list when set");
443
+ validateProcessRoutines(process.routines, errors, warnings);
444
+ }
445
+
446
+ function validateProcessAgentProfile(value, prefix, errors) {
447
+ if (value === undefined || value === null) return;
448
+ if (typeof value !== "string" || !PROCESS_AGENT_PROFILES.includes(value)) {
449
+ errors.push(`${prefix}: must be one of ${PROCESS_AGENT_PROFILES.join("|")}`);
450
+ }
451
+ }
452
+
453
+ function validateProcessTriggers(value, prefix, errors) {
454
+ if (value === undefined) return;
455
+ if (!Array.isArray(value)) {
456
+ errors.push(`${prefix}: must be a list when set`);
457
+ return;
458
+ }
459
+ for (let i = 0; i < value.length; i += 1) {
460
+ const trigger = value[i];
461
+ const itemPrefix = `${prefix}[${i}]`;
462
+ if (!isPlainObject(trigger)) {
463
+ errors.push(`${itemPrefix}: must be an object`);
464
+ continue;
465
+ }
466
+ if (!PROCESS_TRIGGER_TYPES.includes(trigger.type)) {
467
+ errors.push(`${itemPrefix}.type: must be one of ${PROCESS_TRIGGER_TYPES.join("|")}`);
468
+ }
469
+ if (trigger.type === "schedule") {
470
+ requireOptionalString(trigger.interval, `${itemPrefix}.interval`, errors, { required: true });
471
+ } else if (trigger.type === "event") {
472
+ requireOptionalString(trigger.event, `${itemPrefix}.event`, errors, { required: true });
473
+ }
474
+ }
475
+ }
476
+
477
+ function validateProcessConditions(value, prefix, errors) {
478
+ if (value === undefined) return;
479
+ if (!Array.isArray(value)) {
480
+ errors.push(`${prefix}: must be a list when set`);
481
+ return;
482
+ }
483
+ for (let i = 0; i < value.length; i += 1) {
484
+ const condition = value[i];
485
+ const itemPrefix = `${prefix}[${i}]`;
486
+ if (!isPlainObject(condition)) {
487
+ errors.push(`${itemPrefix}: must be an object`);
488
+ continue;
489
+ }
490
+ requireOptionalString(condition.expression, `${itemPrefix}.expression`, errors, { required: true });
491
+ }
492
+ }
493
+
494
+ function validateProcessRoutines(value, errors, warnings) {
495
+ if (value === undefined) return;
496
+ const entries = Array.isArray(value)
497
+ ? value.map((routine, i) => [String(i), routine, `process.routines[${i}]`])
498
+ : isPlainObject(value)
499
+ ? Object.entries(value).map(([id, routine]) => [id, routine, `process.routines.${id}`])
500
+ : null;
501
+ if (!entries) {
502
+ errors.push("process.routines: must be a map or list when set");
503
+ return;
504
+ }
505
+ for (const [routineId, routine, prefix] of entries) {
506
+ if (!isPlainObject(routine)) {
507
+ errors.push(`${prefix}: must be an object`);
508
+ continue;
509
+ }
510
+ pushUnknownKeyWarnings(
511
+ routine,
512
+ new Set([
513
+ "id",
514
+ "name",
515
+ "cadence",
516
+ "enabled",
517
+ "trigger",
518
+ "description",
519
+ "prompt",
520
+ "instructions",
521
+ "pattern",
522
+ "patterns",
523
+ "pattern_version",
524
+ "fanout",
525
+ "idempotency",
526
+ "agent_profile",
527
+ "specialist_id",
528
+ "specialist_name",
529
+ "specialist",
530
+ "schedule",
531
+ "window",
532
+ "event",
533
+ ]),
534
+ prefix,
535
+ warnings,
536
+ );
537
+ if (Array.isArray(value)) {
538
+ requireOptionalString(routine.id, `${prefix}.id`, errors, { required: true });
539
+ } else if (!LANE_ID_REGEX.test(routineId)) {
540
+ errors.push(`${prefix}: invalid routine id`);
541
+ }
542
+ requireOptionalString(routine.name, `${prefix}.name`, errors, { required: true });
543
+ requireOptionalString(routine.cadence, `${prefix}.cadence`, errors, { required: false });
544
+ if (routine.enabled !== undefined && routine.enabled !== null && typeof routine.enabled !== "boolean") {
545
+ errors.push(`${prefix}.enabled: must be a boolean when set`);
546
+ }
547
+ requireOptionalString(routine.description, `${prefix}.description`, errors, { required: false });
548
+ requireOptionalString(routine.instructions, `${prefix}.instructions`, errors, { required: false });
549
+ requireOptionalString(routine.prompt, `${prefix}.prompt`, errors, { required: false });
550
+ requireOptionalString(routine.pattern, `${prefix}.pattern`, errors, { required: false });
551
+ if (routine.patterns !== undefined) {
552
+ if (!Array.isArray(routine.patterns) || routine.patterns.some((p) => typeof p !== "string" || !p.trim())) {
553
+ errors.push(`${prefix}.patterns: must be a list of non-empty strings`);
554
+ }
555
+ }
556
+ if (routine.fanout !== undefined && !LANE_FANOUT_MODES.includes(routine.fanout)) {
557
+ errors.push(`${prefix}.fanout: must be one of ${LANE_FANOUT_MODES.join("|")}`);
558
+ }
559
+ requireOptionalString(routine.specialist_id, `${prefix}.specialist_id`, errors, { required: false });
560
+ requireOptionalString(routine.specialist_name, `${prefix}.specialist_name`, errors, { required: false });
561
+ validateProcessAgentProfile(routine.agent_profile, `${prefix}.agent_profile`, errors);
562
+ validateRoutineTrigger(routine.trigger, `${prefix}.trigger`, errors);
563
+ if (routine.schedule !== undefined && routine.schedule !== null) {
564
+ if (typeof routine.schedule !== "string" && !isPlainObject(routine.schedule)) {
565
+ errors.push(`${prefix}.schedule: must be an object or cron string when set`);
566
+ }
567
+ }
568
+ }
569
+ }
570
+
571
+ function validateRoutineTrigger(value, prefix, errors) {
572
+ if (value === undefined || value === null) return;
573
+ if (!isPlainObject(value)) {
574
+ errors.push(`${prefix}: must be an object when set`);
575
+ return;
576
+ }
577
+ if (!PROCESS_TRIGGER_TYPES.includes(value.type)) {
578
+ errors.push(`${prefix}.type: must be one of ${PROCESS_TRIGGER_TYPES.join("|")}`);
579
+ }
580
+ if (value.type === "schedule") {
581
+ requireOptionalString(value.cron ?? value.interval, `${prefix}.cron`, errors, { required: true });
582
+ }
583
+ if (value.type === "event") {
584
+ requireOptionalString(value.event, `${prefix}.event`, errors, { required: true });
585
+ }
586
+ }
587
+
588
+ function requireOptionalString(value, prefix, errors, { required }) {
589
+ if ((value === undefined || value === null) && !required) return;
590
+ if (typeof value !== "string" || !value.trim()) {
591
+ errors.push(
592
+ `${prefix}: must be ${required ? "a non-empty string" : "null or a non-empty string"}`,
593
+ );
408
594
  }
409
595
  }
410
596
 
package/lib/config.mjs CHANGED
@@ -8,6 +8,7 @@ export function extractGlobalArgv(argv) {
8
8
  baseUrl: (
9
9
  process.env.SHIP_API_BASE || "https://ship.elmundi.com/api/methodology"
10
10
  ).replace(/\/$/, ""),
11
+ baseUrlSource: process.env.SHIP_API_BASE ? "env" : "default",
11
12
  json: false,
12
13
  yes: false,
13
14
  force: false,
@@ -39,10 +40,12 @@ export function extractGlobalArgv(argv) {
39
40
  if (a === "--base-url" && copy[1]) {
40
41
  copy.shift();
41
42
  out.baseUrl = String(copy.shift()).replace(/\/$/, "");
43
+ out.baseUrlSource = "flag";
42
44
  continue;
43
45
  }
44
46
  if (a.startsWith("--base-url=")) {
45
47
  out.baseUrl = a.slice("--base-url=".length).replace(/\/$/, "");
48
+ out.baseUrlSource = "flag";
46
49
  copy.shift();
47
50
  continue;
48
51
  }
@@ -0,0 +1,171 @@
1
+ export const SPECIALIST_PROMPT_GUARDRAILS = `# Ship specialist execution guardrails
2
+
3
+ These rules apply to specialist prompts assembled from process configuration,
4
+ ticket context, workspace policies, and knowledge buckets.
5
+
6
+ ## Knowledge First
7
+
8
+ Before inventing a solution or procedure, search Ship knowledge for relevant
9
+ recipes, patterns, policies, and technical context. Use repository-specific
10
+ knowledge first when a repo is known, then workspace knowledge. If no relevant
11
+ article is found, say that explicitly before proposing a new approach.
12
+
13
+ Knowledge articles can also answer clarifying technical questions when the
14
+ ticket lacks implementation detail.
15
+
16
+ ## Allowed Exits
17
+
18
+ A specialist cycle may end only through a Ship-controlled outcome:
19
+
20
+ - Ask for clarification: return a clarification intent for Ship to post as a
21
+ tracker comment and mirror into Inbox.
22
+ - Handoff: request one of the transitions explicitly configured in the process
23
+ FSM. Ship validates the transition before any side effect.
24
+ - Complete with result: produce the final result or PR reference for Ship to
25
+ record. Repository changes must be delivered through pull requests only.
26
+
27
+ ## Boundaries
28
+
29
+ Do not perform direct ticket-system mutations. Do not execute transitions that
30
+ are not declared in the process configuration. Do not push directly to protected
31
+ branches or bypass review. All material actions must be represented in Ship
32
+ audit data. Workspace policies are mandatory and override recipe guidance.`;
33
+
34
+ export function renderSpecialistPromptGuardrails() {
35
+ return `${SPECIALIST_PROMPT_GUARDRAILS.trim()}\n`;
36
+ }
37
+
38
+ export function buildSpecialistPromptBundle({
39
+ process,
40
+ state,
41
+ allowedTransitions = [],
42
+ ticket = null,
43
+ policies = null,
44
+ }) {
45
+ const specialist = normalizeSpecialist(state.specialist);
46
+ const agentProfile =
47
+ state.agent_profile || specialist.agent_profile || "auto";
48
+ return {
49
+ process: {
50
+ id: process.id,
51
+ name: process.name || process.id,
52
+ primary: process.primary === true,
53
+ },
54
+ state: {
55
+ id: state.id,
56
+ name: state.name || state.id,
57
+ instructions: typeof state.instructions === "string" ? state.instructions : "",
58
+ triggers: Array.isArray(state.triggers) ? state.triggers : [],
59
+ exit_conditions: Array.isArray(state.exit_conditions) ? state.exit_conditions : [],
60
+ block_conditions: Array.isArray(state.block_conditions) ? state.block_conditions : [],
61
+ },
62
+ specialist,
63
+ agent_profile: agentProfile,
64
+ ticket,
65
+ policies,
66
+ allowed_transitions: allowedTransitions.map((transition) => ({
67
+ from: transition.from,
68
+ to: transition.to,
69
+ condition: transition.condition || null,
70
+ })),
71
+ guardrails: renderSpecialistPromptGuardrails(),
72
+ };
73
+ }
74
+
75
+ export function renderSpecialistPromptBundleMarkdown(bundle) {
76
+ const lines = [
77
+ "# Ship Specialist Prompt Bundle",
78
+ "",
79
+ "## Process",
80
+ "",
81
+ `- Process: ${bundle.process.name} (\`${bundle.process.id}\`)`,
82
+ `- Primary: ${bundle.process.primary ? "yes" : "no"}`,
83
+ `- State: ${bundle.state.name} (\`${bundle.state.id}\`)`,
84
+ "",
85
+ "## Specialist",
86
+ "",
87
+ `- Specialist: ${bundle.specialist.name} (\`${bundle.specialist.id}\`)`,
88
+ `- Agent profile: \`${bundle.agent_profile}\``,
89
+ "",
90
+ bundle.specialist.role || "No specialist role description configured.",
91
+ "",
92
+ ];
93
+
94
+ if (bundle.state.instructions) {
95
+ lines.push("## State Instructions", "", bundle.state.instructions, "");
96
+ }
97
+
98
+ lines.push("## Ticket Context", "");
99
+ if (bundle.ticket) {
100
+ lines.push(...renderTicketLines(bundle.ticket), "");
101
+ } else {
102
+ lines.push("No ticket context was supplied. Use the Ship tracker picker before starting this specialist cycle.", "");
103
+ }
104
+
105
+ lines.push("## Workspace Policies", "");
106
+ if (bundle.policies && bundle.policies.trim()) {
107
+ lines.push(bundle.policies.trim(), "");
108
+ } else {
109
+ lines.push("Workspace policies were not supplied locally. In managed runs, Ship must inject enabled workspace policies before the agent starts.", "");
110
+ }
111
+
112
+ lines.push("## Allowed Transitions", "");
113
+ if (bundle.allowed_transitions.length) {
114
+ for (const transition of bundle.allowed_transitions) {
115
+ const condition = transition.condition ? ` when ${transition.condition}` : "";
116
+ lines.push(`- \`${transition.from}\` -> \`${transition.to}\`${condition}`);
117
+ }
118
+ } else {
119
+ lines.push("- No outgoing transitions are configured for this state. The agent may only ask for clarification or complete with a result.");
120
+ }
121
+
122
+ lines.push(
123
+ "",
124
+ "## Required Guardrails",
125
+ "",
126
+ bundle.guardrails.trim(),
127
+ "",
128
+ );
129
+ return `${lines.join("\n").trim()}\n`;
130
+ }
131
+
132
+ function normalizeSpecialist(value) {
133
+ if (typeof value === "string") {
134
+ return { id: value, name: value, role: "", agent_profile: null };
135
+ }
136
+ if (value && typeof value === "object" && !Array.isArray(value)) {
137
+ return {
138
+ id: String(value.id || value.name || "specialist"),
139
+ name: String(value.name || value.id || "Specialist"),
140
+ role: typeof value.role === "string" ? value.role : "",
141
+ agent_profile: typeof value.agent_profile === "string" ? value.agent_profile : null,
142
+ };
143
+ }
144
+ return { id: "specialist", name: "Specialist", role: "", agent_profile: null };
145
+ }
146
+
147
+ function renderTicketLines(ticket) {
148
+ const lines = [];
149
+ for (const key of ["id", "key", "title", "url", "status", "description"]) {
150
+ const value = ticket[key];
151
+ if (typeof value === "string" && value.trim()) {
152
+ lines.push(`- ${titleCase(key)}: ${value.trim()}`);
153
+ }
154
+ }
155
+ const extra = Object.entries(ticket).filter(
156
+ ([key, value]) =>
157
+ !["id", "key", "title", "url", "status", "description"].includes(key) &&
158
+ value != null &&
159
+ typeof value !== "object",
160
+ );
161
+ for (const [key, value] of extra) {
162
+ lines.push(`- ${titleCase(key)}: ${String(value)}`);
163
+ }
164
+ return lines.length ? lines : ["- Ticket context object was empty."];
165
+ }
166
+
167
+ function titleCase(value) {
168
+ return value
169
+ .replace(/[_-]+/g, " ")
170
+ .replace(/\b\w/g, (char) => char.toUpperCase());
171
+ }
@@ -0,0 +1,264 @@
1
+ import { laneFanout, lanePatterns, LANE_FANOUT_DEFAULT } from "../config/schema.mjs";
2
+
3
+ const DEFAULT_SCHEDULE_WINDOW_MINUTES = 30;
4
+
5
+ export function routineEntries(config) {
6
+ const process = config?.process;
7
+ const raw = process && typeof process === "object" ? process.routines : null;
8
+ const out = [];
9
+ if (Array.isArray(raw)) {
10
+ for (const item of raw) {
11
+ if (!item || typeof item !== "object") continue;
12
+ const id = typeof item.id === "string" ? item.id.trim() : "";
13
+ if (!id) continue;
14
+ out.push([id, item]);
15
+ }
16
+ } else if (raw && typeof raw === "object") {
17
+ for (const [id, item] of Object.entries(raw)) {
18
+ if (!item || typeof item !== "object") continue;
19
+ out.push([id, { id, ...item }]);
20
+ }
21
+ }
22
+ return out;
23
+ }
24
+
25
+ export function routineMap(config) {
26
+ return Object.fromEntries(routineEntries(config));
27
+ }
28
+
29
+ export function resolveExecutable(config, id) {
30
+ const routines = routineMap(config);
31
+ if (routines[id]) {
32
+ return { kind: "routine", id, source: routines[id], executable: routineToExecutable(id, routines[id]) };
33
+ }
34
+ const lane = config?.lanes?.[id];
35
+ if (lane) {
36
+ return { kind: "lane", id, source: lane, executable: laneToExecutable(id, lane) };
37
+ }
38
+ return null;
39
+ }
40
+
41
+ export function executableIds(config) {
42
+ return {
43
+ routines: Object.keys(routineMap(config)).sort(),
44
+ lanes: Object.keys(config?.lanes || {}).sort(),
45
+ };
46
+ }
47
+
48
+ export function routineToExecutable(id, routine) {
49
+ const trigger = normalizeRoutineTrigger(routine);
50
+ return {
51
+ id,
52
+ type: "routine",
53
+ kind: trigger.kind,
54
+ trigger,
55
+ pattern: stringOrNull(routine.pattern),
56
+ patterns: Array.isArray(routine.patterns) ? routine.patterns : undefined,
57
+ pattern_version: stringOrNull(routine.pattern_version),
58
+ fanout: routine.fanout || LANE_FANOUT_DEFAULT,
59
+ idempotency: routine.idempotency || null,
60
+ prompt: stringOrNull(routine.prompt) || stringOrNull(routine.instructions),
61
+ agent_profile: stringOrNull(routine.agent_profile) || stringOrNull(routine.specialist?.agent_profile),
62
+ };
63
+ }
64
+
65
+ export function laneToExecutable(id, lane) {
66
+ return {
67
+ id,
68
+ type: "lane",
69
+ kind: lane.kind,
70
+ trigger: laneTrigger(lane),
71
+ pattern: lane.pattern,
72
+ patterns: lane.patterns,
73
+ pattern_version: lane.pattern_version,
74
+ fanout: laneFanout(lane),
75
+ idempotency: lane.idempotency || null,
76
+ prompt: null,
77
+ agent_profile: null,
78
+ };
79
+ }
80
+
81
+ export function executablePatterns(executable) {
82
+ return lanePatterns(executable);
83
+ }
84
+
85
+ export function executableFanout(executable) {
86
+ return executable?.fanout || LANE_FANOUT_DEFAULT;
87
+ }
88
+
89
+ export function dueRoutines(config, { event, now = new Date() }) {
90
+ const due = [];
91
+ const skipped = [];
92
+ for (const [routineId, routine] of routineEntries(config)) {
93
+ if (routine.enabled === false) {
94
+ skipped.push({ routine_id: routineId, reason: "disabled" });
95
+ continue;
96
+ }
97
+ const executable = routineToExecutable(routineId, routine);
98
+ if (!routineAcceptsEvent(executable, event)) {
99
+ skipped.push({ routine_id: routineId, reason: "trigger_mismatch", trigger: executable.kind });
100
+ continue;
101
+ }
102
+ if (event === "schedule") {
103
+ const slot = latestCronMatch(executable.trigger.cron, {
104
+ now,
105
+ windowMinutes: executable.trigger.window_minutes,
106
+ });
107
+ if (!slot) {
108
+ skipped.push({ routine_id: routineId, reason: "not_due", trigger: "schedule" });
109
+ continue;
110
+ }
111
+ const windowEnd = new Date(slot.getTime() + executable.trigger.window_minutes * 60_000);
112
+ due.push({
113
+ routine_id: routineId,
114
+ trigger: "schedule",
115
+ cron: executable.trigger.cron,
116
+ scheduled_for: slot.toISOString(),
117
+ window_start: slot.toISOString(),
118
+ window_end: windowEnd.toISOString(),
119
+ window_key: `schedule:${routineId}:${formatWindowKey(slot)}`,
120
+ });
121
+ continue;
122
+ }
123
+ const started = new Date(now);
124
+ due.push({
125
+ routine_id: routineId,
126
+ trigger: event,
127
+ scheduled_for: started.toISOString(),
128
+ window_start: started.toISOString(),
129
+ window_end: started.toISOString(),
130
+ window_key: `${event}:${routineId}:${formatWindowKey(started)}`,
131
+ });
132
+ }
133
+ return { due, skipped };
134
+ }
135
+
136
+ export function dueLanesFromRoutines(due) {
137
+ return due.map((routine) => ({
138
+ lane_id: routine.routine_id,
139
+ routine_id: routine.routine_id,
140
+ kind: routine.trigger,
141
+ reason: "due",
142
+ window_key: routine.window_key,
143
+ scheduled_for: routine.scheduled_for,
144
+ }));
145
+ }
146
+
147
+ function normalizeRoutineTrigger(routine) {
148
+ const trigger = routine.trigger && typeof routine.trigger === "object" ? routine.trigger : {};
149
+ if (routine.schedule || trigger.type === "schedule") {
150
+ const schedule = routine.schedule && typeof routine.schedule === "object" ? routine.schedule : {};
151
+ const cron =
152
+ stringOrNull(trigger.cron) ||
153
+ stringOrNull(trigger.interval) ||
154
+ stringOrNull(schedule.cron) ||
155
+ stringOrNull(schedule.interval) ||
156
+ (typeof routine.schedule === "string" ? routine.schedule : null);
157
+ return {
158
+ kind: "schedule",
159
+ cron,
160
+ window_minutes: parseWindowMinutes(trigger.window || schedule.window || routine.window),
161
+ catchup: stringOrNull(trigger.catchup) || stringOrNull(schedule.catchup) || "latest",
162
+ };
163
+ }
164
+ if (trigger.type === "event" || routine.event) {
165
+ return {
166
+ kind: "event",
167
+ event: stringOrNull(trigger.event) || stringOrNull(routine.event),
168
+ };
169
+ }
170
+ return { kind: "once" };
171
+ }
172
+
173
+ function laneTrigger(lane) {
174
+ if (lane.kind === "schedule") {
175
+ return {
176
+ kind: "schedule",
177
+ cron: stringOrNull(lane.cron),
178
+ window_minutes: DEFAULT_SCHEDULE_WINDOW_MINUTES,
179
+ catchup: "latest",
180
+ };
181
+ }
182
+ if (lane.kind === "event") return { kind: "event", event: stringOrNull(lane.on) };
183
+ return { kind: lane.kind || "once" };
184
+ }
185
+
186
+ function routineAcceptsEvent(executable, event) {
187
+ if (event === "schedule") return executable.kind === "schedule";
188
+ if (event === "manual") return ["schedule", "event", "once"].includes(executable.kind);
189
+ if (executable.kind !== "event") return false;
190
+ const configured = executable.trigger.event || "";
191
+ return configured.split(",").map((part) => part.trim()).filter(Boolean).includes(event);
192
+ }
193
+
194
+ function latestCronMatch(cron, { now, windowMinutes }) {
195
+ if (typeof cron !== "string" || !cron.trim()) return null;
196
+ const fields = cron.trim().split(/\s+/);
197
+ if (fields.length !== 5) return null;
198
+ const [minute, hour, dom, month, dow] = fields;
199
+ const cursor = new Date(now);
200
+ cursor.setUTCSeconds(0, 0);
201
+ for (let offset = 0; offset <= windowMinutes; offset += 1) {
202
+ const candidate = new Date(cursor.getTime() - offset * 60_000);
203
+ if (
204
+ cronFieldMatches(minute, candidate.getUTCMinutes(), 0, 59) &&
205
+ cronFieldMatches(hour, candidate.getUTCHours(), 0, 23) &&
206
+ cronFieldMatches(dom, candidate.getUTCDate(), 1, 31) &&
207
+ cronFieldMatches(month, candidate.getUTCMonth() + 1, 1, 12) &&
208
+ cronFieldMatches(dow, candidate.getUTCDay(), 0, 6)
209
+ ) {
210
+ return candidate;
211
+ }
212
+ }
213
+ return null;
214
+ }
215
+
216
+ function cronFieldMatches(expr, value, min, max) {
217
+ for (const rawPart of String(expr).split(",")) {
218
+ const part = rawPart.trim();
219
+ if (!part) continue;
220
+ let step = 1;
221
+ let base = part;
222
+ if (part.includes("/")) {
223
+ const pieces = part.split("/");
224
+ base = pieces[0];
225
+ step = Number.parseInt(pieces[1], 10);
226
+ if (!Number.isInteger(step) || step <= 0) continue;
227
+ }
228
+ let start;
229
+ let end;
230
+ if (base === "*") {
231
+ start = min;
232
+ end = max;
233
+ } else if (base.includes("-")) {
234
+ const [a, b] = base.split("-");
235
+ start = Number.parseInt(a, 10);
236
+ end = Number.parseInt(b, 10);
237
+ } else {
238
+ start = Number.parseInt(base, 10);
239
+ end = start;
240
+ }
241
+ if (!Number.isInteger(start) || !Number.isInteger(end)) continue;
242
+ if (start <= value && value <= end && (value - start) % step === 0) return true;
243
+ }
244
+ return false;
245
+ }
246
+
247
+ function parseWindowMinutes(value) {
248
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) return Math.floor(value);
249
+ if (typeof value !== "string" || !value.trim()) return DEFAULT_SCHEDULE_WINDOW_MINUTES;
250
+ const raw = value.trim();
251
+ const match = raw.match(/^(\d+)\s*(m|min|minute|minutes)?$/i);
252
+ if (match) return Math.max(1, Number.parseInt(match[1], 10));
253
+ const hours = raw.match(/^(\d+)\s*(h|hr|hour|hours)$/i);
254
+ if (hours) return Math.max(1, Number.parseInt(hours[1], 10) * 60);
255
+ return DEFAULT_SCHEDULE_WINDOW_MINUTES;
256
+ }
257
+
258
+ function formatWindowKey(date) {
259
+ return date.toISOString().slice(0, 16).replace(/[-:]/g, "").replace("T", "T");
260
+ }
261
+
262
+ function stringOrNull(value) {
263
+ return typeof value === "string" && value.trim() ? value.trim() : null;
264
+ }