@elmundi/ship-cli 0.12.1 → 0.14.0

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.
@@ -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
+ }
@@ -0,0 +1,254 @@
1
+ # ship-cli: run-agent v1 — generated by `shipctl lanes install`.
2
+ # Regenerate via: shipctl lanes install
3
+ # Template ships with @elmundi/ship-cli; hand edits outside this banner may be overwritten.
4
+ name: Ship · run-agent (reusable)
5
+ # RFC-0007 Phase 3 + RFC-0008 C3.2 — reusable workflow for lane wrappers.
6
+ # Caller repos use: uses: ./.github/workflows/run-agent.yml (same repository).
7
+
8
+ on:
9
+ workflow_call:
10
+ inputs:
11
+ lane:
12
+ description: "Lane id as declared in .ship/config.yml"
13
+ type: string
14
+ required: true
15
+ trigger:
16
+ description: "Override trigger (once|event|schedule|manual). Defaults to github.event_name."
17
+ type: string
18
+ required: false
19
+ default: ""
20
+ shipctl_version:
21
+ description: "npm dist-tag or semver for @elmundi/ship-cli. Default: latest."
22
+ type: string
23
+ required: false
24
+ default: "latest"
25
+ node_version:
26
+ description: "Node.js major version for the runner"
27
+ type: string
28
+ required: false
29
+ default: "20"
30
+ dry_run:
31
+ description: "Resolve the lane but neither call the callback nor write the idempotency marker."
32
+ type: boolean
33
+ required: false
34
+ default: false
35
+ offline:
36
+ description: "Do not talk to the methodology API; read patterns from the local .ship/cache."
37
+ type: boolean
38
+ required: false
39
+ default: false
40
+ ship_run_id:
41
+ description: "Pipeline-run id the callback should report against (set by Ship dispatch)."
42
+ type: string
43
+ required: false
44
+ default: ""
45
+ ship_callback_url:
46
+ description: "Ship callback URL (set by Ship dispatch)."
47
+ type: string
48
+ required: false
49
+ default: ""
50
+ upload_prompt:
51
+ description: "Upload the rendered prompt as a workflow artifact named `ship-prompt`."
52
+ type: boolean
53
+ required: false
54
+ default: true
55
+ secrets:
56
+ SHIP_API_TOKEN:
57
+ description: "Methodology API token (optional; required for private patterns / callbacks)."
58
+ required: false
59
+ SHIP_API_BASE:
60
+ description: "Ship API origin for shipctl (e.g. https://api.example.com). Wizard seeds this from SHIP_PUBLIC_URL."
61
+ required: false
62
+ SHIP_RUN_TOKEN:
63
+ description: "Short-lived bearer token Ship issued when dispatching this run."
64
+ required: false
65
+
66
+ permissions:
67
+ contents: read
68
+
69
+ jobs:
70
+ plan:
71
+ name: "ship plan --lane ${{ inputs.lane }}"
72
+ runs-on: ubuntu-latest
73
+ timeout-minutes: 5
74
+ outputs:
75
+ matrix: ${{ steps.plan.outputs.matrix }}
76
+ patterns: ${{ steps.plan.outputs.patterns }}
77
+ fanout: ${{ steps.plan.outputs.fanout }}
78
+ steps:
79
+ - name: Checkout caller repo
80
+ uses: actions/checkout@v4
81
+
82
+ - name: Setup Node.js
83
+ uses: actions/setup-node@v4
84
+ with:
85
+ node-version: ${{ inputs.node_version }}
86
+
87
+ - name: Install shipctl
88
+ run: npm install -g @elmundi/ship-cli@${{ inputs.shipctl_version }}
89
+
90
+ - name: Resolve patterns + fanout
91
+ id: plan
92
+ env:
93
+ LANE: ${{ inputs.lane }}
94
+ run: |
95
+ set -euo pipefail
96
+ JSON=$(shipctl lanes list --json)
97
+ ROW=$(echo "$JSON" | jq -c --arg lane "$LANE" '.lanes[] | select(.lane == $lane)')
98
+ if [ -z "$ROW" ] || [ "$ROW" = "null" ]; then
99
+ echo "::error::lane '$LANE' not declared in .ship/config.yml"
100
+ exit 1
101
+ fi
102
+ PATTERNS=$(echo "$ROW" | jq -c '.patterns // []')
103
+ FANOUT=$(echo "$ROW" | jq -r '.fanout // "matrix"')
104
+ N=$(echo "$PATTERNS" | jq 'length')
105
+ if [ "$N" -lt 1 ]; then
106
+ echo "::error::lane '$LANE' declares no patterns"
107
+ exit 1
108
+ fi
109
+
110
+ if [ "$FANOUT" = "matrix" ] && [ "$N" -gt 1 ]; then
111
+ MATRIX=$(echo "$PATTERNS" | jq -c '[.[] | {pattern: ., fanout: "matrix"}]')
112
+ elif [ "$N" -gt 1 ]; then
113
+ MATRIX=$(jq -cn --arg fanout "$FANOUT" '[{pattern: "", fanout: $fanout}]')
114
+ else
115
+ MATRIX='[{"pattern": "", "fanout": "matrix"}]'
116
+ fi
117
+
118
+ echo "patterns=$PATTERNS" >> "$GITHUB_OUTPUT"
119
+ echo "fanout=$FANOUT" >> "$GITHUB_OUTPUT"
120
+ echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
121
+ {
122
+ echo "### Plan"
123
+ echo "- lane: \`$LANE\`"
124
+ echo "- patterns: $(echo "$PATTERNS" | jq -r '. | join(", ")')"
125
+ echo "- fanout: $FANOUT"
126
+ } >> "$GITHUB_STEP_SUMMARY"
127
+
128
+ run:
129
+ name: "ship run --lane ${{ inputs.lane }} (pattern=${{ matrix.entry.pattern }} fanout=${{ matrix.entry.fanout }})"
130
+ needs: plan
131
+ runs-on: ubuntu-latest
132
+ timeout-minutes: 20
133
+ strategy:
134
+ fail-fast: false
135
+ matrix:
136
+ entry: ${{ fromJSON(needs.plan.outputs.matrix) }}
137
+ steps:
138
+ - name: Checkout caller repo
139
+ uses: actions/checkout@v4
140
+
141
+ - name: Setup Node.js
142
+ uses: actions/setup-node@v4
143
+ with:
144
+ node-version: ${{ inputs.node_version }}
145
+
146
+ - name: Install shipctl
147
+ run: npm install -g @elmundi/ship-cli@${{ inputs.shipctl_version }}
148
+
149
+ - name: Resolve trigger
150
+ id: trigger
151
+ env:
152
+ OVERRIDE: ${{ inputs.trigger }}
153
+ EVENT: ${{ github.event_name }}
154
+ run: |
155
+ set -euo pipefail
156
+ if [ -n "${OVERRIDE:-}" ]; then
157
+ echo "value=$OVERRIDE" >> "$GITHUB_OUTPUT"
158
+ else
159
+ case "$EVENT" in
160
+ workflow_dispatch) echo "value=manual" >> "$GITHUB_OUTPUT" ;;
161
+ schedule) echo "value=schedule" >> "$GITHUB_OUTPUT" ;;
162
+ *) echo "value=event" >> "$GITHUB_OUTPUT" ;;
163
+ esac
164
+ fi
165
+
166
+ - name: shipctl run
167
+ id: run
168
+ env:
169
+ SHIP_API_TOKEN: ${{ secrets.SHIP_API_TOKEN }}
170
+ SHIP_API_BASE: ${{ secrets.SHIP_API_BASE }}
171
+ SHIP_RUN_TOKEN: ${{ secrets.SHIP_RUN_TOKEN }}
172
+ SHIP_RUN_ID: ${{ inputs.ship_run_id }}
173
+ MATRIX_PATTERN: ${{ matrix.entry.pattern }}
174
+ MATRIX_FANOUT: ${{ matrix.entry.fanout }}
175
+ run: |
176
+ set -euo pipefail
177
+ mkdir -p .ship/run-output
178
+ ARGS=(run --lane "${{ inputs.lane }}" --trigger "${{ steps.trigger.outputs.value }}")
179
+ [ "${{ inputs.dry_run }}" = "true" ] && ARGS+=(--dry-run)
180
+ [ "${{ inputs.offline }}" = "true" ] && ARGS+=(--offline)
181
+ [ -n "${MATRIX_PATTERN:-}" ] && ARGS+=(--pattern "$MATRIX_PATTERN")
182
+ [ -n "${MATRIX_FANOUT:-}" ] && ARGS+=(--fanout "$MATRIX_FANOUT")
183
+ [ -n "${SHIP_RUN_ID:-}" ] && ARGS+=(--ship-run-id "$SHIP_RUN_ID")
184
+
185
+ set +e
186
+ shipctl "${ARGS[@]}" \
187
+ 1> .ship/run-output/prompt.md \
188
+ 2> .ship/run-output/shipctl.log
189
+ STATUS=$?
190
+ set -e
191
+
192
+ cat .ship/run-output/shipctl.log >&2 || true
193
+ echo "status=$STATUS" >> "$GITHUB_OUTPUT"
194
+ if [ "$STATUS" -ne 0 ]; then
195
+ echo "::error::shipctl run exited with $STATUS"
196
+ exit "$STATUS"
197
+ fi
198
+
199
+ - name: Upload prompt artifact
200
+ if: ${{ inputs.upload_prompt && (success() || failure()) }}
201
+ uses: actions/upload-artifact@v4
202
+ with:
203
+ name: ship-prompt-${{ inputs.lane }}${{ matrix.entry.pattern != '' && format('-{0}', matrix.entry.pattern) || '' }}
204
+ path: .ship/run-output/
205
+ if-no-files-found: warn
206
+ retention-days: 14
207
+
208
+ aggregate:
209
+ name: "ship aggregate --lane ${{ inputs.lane }}"
210
+ if: ${{ always() && inputs.ship_callback_url != '' }}
211
+ needs: [plan, run]
212
+ runs-on: ubuntu-latest
213
+ timeout-minutes: 5
214
+ steps:
215
+ - name: Checkout caller repo
216
+ uses: actions/checkout@v4
217
+
218
+ - name: Setup Node.js
219
+ uses: actions/setup-node@v4
220
+ with:
221
+ node-version: ${{ inputs.node_version }}
222
+
223
+ - name: Install shipctl
224
+ run: npm install -g @elmundi/ship-cli@${{ inputs.shipctl_version }}
225
+
226
+ - name: Report rollup to Ship
227
+ env:
228
+ SHIP_RUN_ID: ${{ inputs.ship_run_id }}
229
+ SHIP_CALLBACK_URL: ${{ inputs.ship_callback_url }}
230
+ SHIP_RUN_TOKEN: ${{ secrets.SHIP_RUN_TOKEN }}
231
+ GH_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
232
+ RUN_RESULT: ${{ needs.run.result }}
233
+ PLAN_PATTERNS: ${{ needs.plan.outputs.patterns }}
234
+ PLAN_FANOUT: ${{ needs.plan.outputs.fanout }}
235
+ run: |
236
+ set -euo pipefail
237
+ case "$RUN_RESULT" in
238
+ success)
239
+ CB=ok
240
+ SUMMARY="Ship lane ${{ inputs.lane }} completed ($PLAN_FANOUT over $PLAN_PATTERNS)."
241
+ ;;
242
+ failure|cancelled|skipped|*)
243
+ CB=fail
244
+ SUMMARY="Ship lane ${{ inputs.lane }} $RUN_RESULT ($PLAN_FANOUT over $PLAN_PATTERNS)."
245
+ ;;
246
+ esac
247
+ shipctl callback --status "$CB" \
248
+ --summary "$SUMMARY" \
249
+ --metric "lane_id=${{ inputs.lane }}" \
250
+ --metric "fanout=$PLAN_FANOUT" \
251
+ --metric "patterns=$(echo "$PLAN_PATTERNS" | jq -r '. | join(",")')" \
252
+ --metric "gh_workflow_run_id=${{ github.run_id }}" \
253
+ --metric "gh_html_url=$GH_RUN_URL" \
254
+ --metric "gh_event=${{ github.event_name }}" || true
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@elmundi/ship-cli",
3
- "version": "0.12.1",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
- "description": "Ship CLI: bootstrap a repo, sync the Plays catalog, run Automations, report Runs.",
5
+ "description": "Ship CLI: bootstrap a repo, sync the catalog, run process routines, report outcomes.",
6
6
  "license": "Apache-2.0",
7
7
  "author": "Denys Kuzin",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "https://github.com/ElMundiUA/ship.git",
10
+ "url": "git+https://github.com/ElMundiUA/ship.git",
11
11
  "directory": "cli"
12
12
  },
13
13
  "bugs": {
@@ -32,7 +32,7 @@
32
32
  "access": "public"
33
33
  },
34
34
  "bin": {
35
- "shipctl": "./bin/shipctl.mjs"
35
+ "shipctl": "bin/shipctl.mjs"
36
36
  },
37
37
  "scripts": {
38
38
  "test": "node --test tests/*.test.mjs"