@sweny-ai/core 0.1.89 → 0.1.91

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,161 @@
1
+ /**
2
+ * Lifecycle-endpoint reporter for SWEny Cloud.
3
+ *
4
+ * Posts run lifecycle events (start, finish, optional per-node updates) to
5
+ * cloud's universal ingest API at `${cloudUrl}/api/runs[/:id/...]`. Auth via
6
+ * the same `SWENY_CLOUD_TOKEN` that the legacy /api/report flow uses.
7
+ *
8
+ * This reporter is CI-agnostic: it works from GitHub Actions, GitLab CI,
9
+ * CircleCI, a developer's laptop — anywhere a workflow runs and a token is
10
+ * configured. The legacy `reportToCloud` (in cloud-report.ts) keeps working
11
+ * for back-compat; new code paths should prefer this lifecycle flow.
12
+ *
13
+ * Source proposal: cloud/docs/proposals/02-cloud-architecture-for-any-run.md
14
+ * (task 05).
15
+ *
16
+ * All errors are swallowed: cloud reporting MUST NEVER block the workflow.
17
+ */
18
+ import type { NodeResult, Observer, Workflow } from "../types.js";
19
+ export type TriggerSource = "github_action" | "gitlab_ci" | "circleci" | "buildkite" | "other_ci" | "manual" | "cron" | "mcp" | "unknown";
20
+ export interface CloudReportConfig {
21
+ cloudToken: string;
22
+ cloudUrl?: string;
23
+ repository?: string;
24
+ }
25
+ export interface StartRunInput {
26
+ workflow_id: string;
27
+ workflow_type?: string;
28
+ trigger_source?: TriggerSource;
29
+ trigger_metadata?: Record<string, unknown>;
30
+ metadata?: Record<string, unknown>;
31
+ run_uuid?: string;
32
+ }
33
+ export interface StartRunResult {
34
+ run_id: string;
35
+ dashboard_url?: string;
36
+ stream_url?: string;
37
+ idempotent_replay?: boolean;
38
+ }
39
+ export interface FinishRunInput {
40
+ status: "success" | "failed" | "skipped" | "completed" | "partial";
41
+ metrics?: Record<string, unknown>;
42
+ duration_ms?: number;
43
+ error?: string;
44
+ }
45
+ /**
46
+ * Detect the CI trigger source from environment variables. Best-effort;
47
+ * returns "unknown" if no signal is present.
48
+ */
49
+ export declare function detectTriggerSource(env?: NodeJS.ProcessEnv): TriggerSource;
50
+ /**
51
+ * Build the trigger_metadata blob from env. Each CI provider exposes a
52
+ * different set of variables; pick the ones that are useful for debugging
53
+ * a specific run later (run id, actor, ref).
54
+ */
55
+ export declare function buildTriggerMetadata(source: TriggerSource, env?: NodeJS.ProcessEnv): Record<string, unknown>;
56
+ /**
57
+ * Generate a run UUID. Each call to startRun() should generate exactly one
58
+ * and reuse it for the matching finishRun() — this is the idempotency key.
59
+ */
60
+ export declare function newRunUuid(): string;
61
+ /**
62
+ * Build a run-start payload from a Workflow + env. Pure function; the caller
63
+ * is responsible for passing it to `startRun()`.
64
+ */
65
+ export declare function buildStartRunPayload(workflow: Pick<Workflow, "id" | "workflow_type">, options: {
66
+ runUuid: string;
67
+ env?: NodeJS.ProcessEnv;
68
+ }): StartRunInput;
69
+ /**
70
+ * POST /api/runs — start a run, returning the run id (or null on failure).
71
+ * Failure is silent: cloud reporting must not block workflow execution.
72
+ */
73
+ export declare function startRun(config: CloudReportConfig, payload: StartRunInput): Promise<StartRunResult | null>;
74
+ /**
75
+ * POST /api/runs/:id/finish — record the final outcome + metrics.
76
+ */
77
+ export declare function finishRun(config: CloudReportConfig, runId: string, payload: FinishRunInput): Promise<boolean>;
78
+ /**
79
+ * POST /api/runs/:id/node — record a node lifecycle event. Optional helper
80
+ * for callers that want streaming updates; safe to skip if you only need
81
+ * start + finish. Fire-and-forget.
82
+ */
83
+ export declare function postNodeEvent(config: CloudReportConfig, runId: string, event: {
84
+ event: "enter" | "exit" | "progress";
85
+ node: string;
86
+ timestamp?: string;
87
+ status?: "success" | "failed" | "skipped";
88
+ duration_ms?: number;
89
+ data?: Record<string, unknown>;
90
+ }): Promise<void>;
91
+ /**
92
+ * Derive a `metrics` blob from the executor's NodeResult map.
93
+ *
94
+ * For now this is intentionally minimal: total node count, failed-node
95
+ * count, success rate. Per-workflow-type metric extraction (Caught /
96
+ * Calibration / Missed for pr_review, flake rate for e2e_test, etc.) is
97
+ * the renderer's job; the engine just ships what it has.
98
+ */
99
+ export declare function deriveGenericMetrics(results: Map<string, NodeResult>, durationMs: number): {
100
+ duration_ms: number;
101
+ node_count: number;
102
+ failed_nodes: number;
103
+ success_rate: number;
104
+ };
105
+ export interface CloudLifecycleHandle {
106
+ runUuid: string;
107
+ runId: string;
108
+ dashboardUrl?: string;
109
+ }
110
+ /**
111
+ * Open a cloud lifecycle session for an upcoming workflow run.
112
+ *
113
+ * Returns a handle if the cloud token is set AND `startRun()` succeeds; null
114
+ * otherwise. Callers should ALWAYS handle the null case — cloud reporting
115
+ * is opt-in and best-effort, the workflow must run regardless.
116
+ *
117
+ * Pair every successful call with `finishCloudLifecycle(handle, …)` once the
118
+ * workflow completes (success or failure).
119
+ */
120
+ export declare function beginCloudLifecycle(config: {
121
+ cloudToken?: string;
122
+ repository?: string;
123
+ }, workflow: Pick<Workflow, "id" | "workflow_type">): Promise<CloudLifecycleHandle | null>;
124
+ /**
125
+ * Close a cloud lifecycle session opened by `beginCloudLifecycle`.
126
+ *
127
+ * No-op when handle is null (cloud reporting was disabled or startRun
128
+ * failed). Status maps the engine's `success | failed | partial` outcome
129
+ * onto the cloud's accepted enum. Failure is silent — the workflow's
130
+ * exit code already reflects the truth.
131
+ */
132
+ export declare function finishCloudLifecycle(config: {
133
+ cloudToken?: string;
134
+ }, handle: CloudLifecycleHandle | null, results: Map<string, NodeResult>, durationMs: number, status?: "success" | "failed" | "partial"): Promise<void>;
135
+ /**
136
+ * Build an Observer that ships per-node lifecycle events to cloud.
137
+ *
138
+ * Maps the engine's ExecutionEvent stream onto `POST /api/runs/:id/node`:
139
+ * node:enter → { event: "enter", node }
140
+ * node:exit → { event: "exit", node, status, duration_ms }
141
+ * node:progress → { event: "progress", node, data: { message } }
142
+ * node:retry → { event: "progress", node, data: { retry: true, attempt, reason } }
143
+ * Other events (workflow:start, tool:*, route, workflow:end) are dropped.
144
+ *
145
+ * Returns undefined when the handle is null (no cloud session) or the
146
+ * token is missing. The observer captures `node:enter` timestamps in a
147
+ * closure-scoped Map so it can compute duration_ms on the matching exit
148
+ * without depending on any external state.
149
+ *
150
+ * Hot-path safety contract:
151
+ * 1. The observer never throws synchronously. The whole body is
152
+ * wrapped in try/catch — any future ExecutionEvent shape change
153
+ * that breaks a field access cannot crash the engine.
154
+ * 2. The observer never awaits. postNodeEvent is fired with .catch
155
+ * to swallow any rejection so node never sees an unhandledRejection.
156
+ * 3. The Map is keyed by node id; collisions only happen if the same
157
+ * node id runs concurrently, which the engine does not do today.
158
+ */
159
+ export declare function createCloudStreamObserver(config: {
160
+ cloudToken?: string;
161
+ }, handle: CloudLifecycleHandle | null): Observer | undefined;
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Lifecycle-endpoint reporter for SWEny Cloud.
3
+ *
4
+ * Posts run lifecycle events (start, finish, optional per-node updates) to
5
+ * cloud's universal ingest API at `${cloudUrl}/api/runs[/:id/...]`. Auth via
6
+ * the same `SWENY_CLOUD_TOKEN` that the legacy /api/report flow uses.
7
+ *
8
+ * This reporter is CI-agnostic: it works from GitHub Actions, GitLab CI,
9
+ * CircleCI, a developer's laptop — anywhere a workflow runs and a token is
10
+ * configured. The legacy `reportToCloud` (in cloud-report.ts) keeps working
11
+ * for back-compat; new code paths should prefer this lifecycle flow.
12
+ *
13
+ * Source proposal: cloud/docs/proposals/02-cloud-architecture-for-any-run.md
14
+ * (task 05).
15
+ *
16
+ * All errors are swallowed: cloud reporting MUST NEVER block the workflow.
17
+ */
18
+ import { randomUUID } from "node:crypto";
19
+ const CLOUD_URL_DEFAULT = "https://cloud.sweny.ai";
20
+ const TIMEOUT_MS = 10_000;
21
+ // startRun runs on the critical path BEFORE execute(), so a slow cloud
22
+ // would delay every workflow start. Cap it tighter than the general
23
+ // timeout — if cloud can't accept the run-start in 3s, skip and run
24
+ // uninstrumented rather than make the user wait.
25
+ const START_TIMEOUT_MS = 3_000;
26
+ /**
27
+ * Detect the CI trigger source from environment variables. Best-effort;
28
+ * returns "unknown" if no signal is present.
29
+ */
30
+ export function detectTriggerSource(env = process.env) {
31
+ if (env.GITHUB_ACTIONS === "true")
32
+ return "github_action";
33
+ if (env.GITLAB_CI === "true")
34
+ return "gitlab_ci";
35
+ if (env.CIRCLECI === "true")
36
+ return "circleci";
37
+ if (env.BUILDKITE === "true")
38
+ return "buildkite";
39
+ if (env.CI === "true" || env.CI === "1")
40
+ return "other_ci";
41
+ return "manual";
42
+ }
43
+ /**
44
+ * Build the trigger_metadata blob from env. Each CI provider exposes a
45
+ * different set of variables; pick the ones that are useful for debugging
46
+ * a specific run later (run id, actor, ref).
47
+ */
48
+ export function buildTriggerMetadata(source, env = process.env) {
49
+ switch (source) {
50
+ case "github_action":
51
+ return {
52
+ runner_id: env.RUNNER_NAME,
53
+ workflow_run_id: env.GITHUB_RUN_ID,
54
+ workflow_run_attempt: env.GITHUB_RUN_ATTEMPT,
55
+ actor: env.GITHUB_ACTOR,
56
+ event_name: env.GITHUB_EVENT_NAME,
57
+ ref: env.GITHUB_REF,
58
+ sha: env.GITHUB_SHA,
59
+ };
60
+ case "gitlab_ci":
61
+ return {
62
+ pipeline_id: env.CI_PIPELINE_ID,
63
+ job_id: env.CI_JOB_ID,
64
+ actor: env.GITLAB_USER_LOGIN,
65
+ ref: env.CI_COMMIT_REF_NAME,
66
+ sha: env.CI_COMMIT_SHA,
67
+ };
68
+ case "circleci":
69
+ return {
70
+ build_num: env.CIRCLE_BUILD_NUM,
71
+ actor: env.CIRCLE_USERNAME,
72
+ sha: env.CIRCLE_SHA1,
73
+ };
74
+ default:
75
+ return {};
76
+ }
77
+ }
78
+ /**
79
+ * Generate a run UUID. Each call to startRun() should generate exactly one
80
+ * and reuse it for the matching finishRun() — this is the idempotency key.
81
+ */
82
+ export function newRunUuid() {
83
+ return randomUUID();
84
+ }
85
+ /**
86
+ * Build a run-start payload from a Workflow + env. Pure function; the caller
87
+ * is responsible for passing it to `startRun()`.
88
+ */
89
+ export function buildStartRunPayload(workflow, options) {
90
+ const env = options.env ?? process.env;
91
+ const trigger_source = detectTriggerSource(env);
92
+ const trigger_metadata = buildTriggerMetadata(trigger_source, env);
93
+ const metadata = {};
94
+ if (env.GITHUB_REF)
95
+ metadata.branch = env.GITHUB_REF.replace(/^refs\/heads\//, "");
96
+ if (env.GITHUB_SHA)
97
+ metadata.commit_sha = env.GITHUB_SHA;
98
+ if (env.GITHUB_ACTOR)
99
+ metadata.actor = env.GITHUB_ACTOR;
100
+ return {
101
+ workflow_id: workflow.id,
102
+ workflow_type: workflow.workflow_type ?? "generic",
103
+ trigger_source,
104
+ trigger_metadata,
105
+ metadata,
106
+ run_uuid: options.runUuid,
107
+ };
108
+ }
109
+ /**
110
+ * POST /api/runs — start a run, returning the run id (or null on failure).
111
+ * Failure is silent: cloud reporting must not block workflow execution.
112
+ */
113
+ export async function startRun(config, payload) {
114
+ const cloudUrl = config.cloudUrl ?? process.env.SWENY_CLOUD_URL ?? CLOUD_URL_DEFAULT;
115
+ try {
116
+ const res = await fetch(`${cloudUrl}/api/runs`, {
117
+ method: "POST",
118
+ headers: {
119
+ "Content-Type": "application/json",
120
+ Authorization: `Bearer ${config.cloudToken}`,
121
+ },
122
+ body: JSON.stringify(payload),
123
+ signal: AbortSignal.timeout(START_TIMEOUT_MS),
124
+ });
125
+ if (!res.ok)
126
+ return null;
127
+ const data = (await res.json().catch(() => ({})));
128
+ if (!data.run_id)
129
+ return null;
130
+ return data;
131
+ }
132
+ catch {
133
+ return null;
134
+ }
135
+ }
136
+ /**
137
+ * POST /api/runs/:id/finish — record the final outcome + metrics.
138
+ */
139
+ export async function finishRun(config, runId, payload) {
140
+ const cloudUrl = config.cloudUrl ?? process.env.SWENY_CLOUD_URL ?? CLOUD_URL_DEFAULT;
141
+ try {
142
+ const res = await fetch(`${cloudUrl}/api/runs/${encodeURIComponent(runId)}/finish`, {
143
+ method: "POST",
144
+ headers: {
145
+ "Content-Type": "application/json",
146
+ Authorization: `Bearer ${config.cloudToken}`,
147
+ },
148
+ body: JSON.stringify(payload),
149
+ signal: AbortSignal.timeout(TIMEOUT_MS),
150
+ });
151
+ return res.ok;
152
+ }
153
+ catch {
154
+ return false;
155
+ }
156
+ }
157
+ /**
158
+ * POST /api/runs/:id/node — record a node lifecycle event. Optional helper
159
+ * for callers that want streaming updates; safe to skip if you only need
160
+ * start + finish. Fire-and-forget.
161
+ */
162
+ export async function postNodeEvent(config, runId, event) {
163
+ const cloudUrl = config.cloudUrl ?? process.env.SWENY_CLOUD_URL ?? CLOUD_URL_DEFAULT;
164
+ try {
165
+ await fetch(`${cloudUrl}/api/runs/${encodeURIComponent(runId)}/node`, {
166
+ method: "POST",
167
+ headers: {
168
+ "Content-Type": "application/json",
169
+ Authorization: `Bearer ${config.cloudToken}`,
170
+ },
171
+ body: JSON.stringify({ ...event, timestamp: event.timestamp ?? new Date().toISOString() }),
172
+ signal: AbortSignal.timeout(TIMEOUT_MS),
173
+ });
174
+ }
175
+ catch {
176
+ // Silent — never block the workflow.
177
+ }
178
+ }
179
+ /**
180
+ * Derive a `metrics` blob from the executor's NodeResult map.
181
+ *
182
+ * For now this is intentionally minimal: total node count, failed-node
183
+ * count, success rate. Per-workflow-type metric extraction (Caught /
184
+ * Calibration / Missed for pr_review, flake rate for e2e_test, etc.) is
185
+ * the renderer's job; the engine just ships what it has.
186
+ */
187
+ export function deriveGenericMetrics(results, durationMs) {
188
+ const nodeCount = results.size;
189
+ const failedNodes = [...results.values()].filter((r) => r.status === "failed").length;
190
+ return {
191
+ duration_ms: durationMs,
192
+ node_count: nodeCount,
193
+ failed_nodes: failedNodes,
194
+ success_rate: nodeCount > 0 ? (nodeCount - failedNodes) / nodeCount : 0,
195
+ };
196
+ }
197
+ /**
198
+ * Open a cloud lifecycle session for an upcoming workflow run.
199
+ *
200
+ * Returns a handle if the cloud token is set AND `startRun()` succeeds; null
201
+ * otherwise. Callers should ALWAYS handle the null case — cloud reporting
202
+ * is opt-in and best-effort, the workflow must run regardless.
203
+ *
204
+ * Pair every successful call with `finishCloudLifecycle(handle, …)` once the
205
+ * workflow completes (success or failure).
206
+ */
207
+ export async function beginCloudLifecycle(config, workflow) {
208
+ if (!config.cloudToken)
209
+ return null;
210
+ const runUuid = newRunUuid();
211
+ const payload = buildStartRunPayload(workflow, { runUuid });
212
+ const reportConfig = {
213
+ cloudToken: config.cloudToken,
214
+ repository: config.repository,
215
+ };
216
+ const result = await startRun(reportConfig, payload);
217
+ if (!result)
218
+ return null;
219
+ return {
220
+ runUuid,
221
+ runId: result.run_id,
222
+ dashboardUrl: result.dashboard_url,
223
+ };
224
+ }
225
+ /**
226
+ * Close a cloud lifecycle session opened by `beginCloudLifecycle`.
227
+ *
228
+ * No-op when handle is null (cloud reporting was disabled or startRun
229
+ * failed). Status maps the engine's `success | failed | partial` outcome
230
+ * onto the cloud's accepted enum. Failure is silent — the workflow's
231
+ * exit code already reflects the truth.
232
+ */
233
+ export async function finishCloudLifecycle(config, handle, results, durationMs, status = "success") {
234
+ if (!handle || !config.cloudToken)
235
+ return;
236
+ const reportConfig = { cloudToken: config.cloudToken };
237
+ await finishRun(reportConfig, handle.runId, {
238
+ status,
239
+ duration_ms: durationMs,
240
+ metrics: deriveGenericMetrics(results, durationMs),
241
+ });
242
+ }
243
+ /**
244
+ * Build an Observer that ships per-node lifecycle events to cloud.
245
+ *
246
+ * Maps the engine's ExecutionEvent stream onto `POST /api/runs/:id/node`:
247
+ * node:enter → { event: "enter", node }
248
+ * node:exit → { event: "exit", node, status, duration_ms }
249
+ * node:progress → { event: "progress", node, data: { message } }
250
+ * node:retry → { event: "progress", node, data: { retry: true, attempt, reason } }
251
+ * Other events (workflow:start, tool:*, route, workflow:end) are dropped.
252
+ *
253
+ * Returns undefined when the handle is null (no cloud session) or the
254
+ * token is missing. The observer captures `node:enter` timestamps in a
255
+ * closure-scoped Map so it can compute duration_ms on the matching exit
256
+ * without depending on any external state.
257
+ *
258
+ * Hot-path safety contract:
259
+ * 1. The observer never throws synchronously. The whole body is
260
+ * wrapped in try/catch — any future ExecutionEvent shape change
261
+ * that breaks a field access cannot crash the engine.
262
+ * 2. The observer never awaits. postNodeEvent is fired with .catch
263
+ * to swallow any rejection so node never sees an unhandledRejection.
264
+ * 3. The Map is keyed by node id; collisions only happen if the same
265
+ * node id runs concurrently, which the engine does not do today.
266
+ */
267
+ export function createCloudStreamObserver(config, handle) {
268
+ if (!handle || !config.cloudToken)
269
+ return undefined;
270
+ const reportConfig = { cloudToken: config.cloudToken };
271
+ // Single-threaded-per-node assumption: the engine doesn't run the
272
+ // same node id twice concurrently, so node id alone is enough as a
273
+ // key. A future parallelizing executor change would need to revisit.
274
+ const enterTimes = new Map();
275
+ const fire = (event) => {
276
+ postNodeEvent(reportConfig, handle.runId, event).catch(() => {
277
+ // Cloud reporting must never bubble — postNodeEvent already
278
+ // swallows internally, but this is belt-and-suspenders.
279
+ });
280
+ };
281
+ return (event) => {
282
+ try {
283
+ switch (event.type) {
284
+ case "node:enter": {
285
+ enterTimes.set(event.node, Date.now());
286
+ fire({ event: "enter", node: event.node });
287
+ break;
288
+ }
289
+ case "node:exit": {
290
+ const enter = enterTimes.get(event.node);
291
+ const duration_ms = typeof enter === "number" ? Date.now() - enter : undefined;
292
+ enterTimes.delete(event.node);
293
+ fire({
294
+ event: "exit",
295
+ node: event.node,
296
+ status: event.result.status,
297
+ duration_ms,
298
+ });
299
+ break;
300
+ }
301
+ case "node:progress": {
302
+ fire({
303
+ event: "progress",
304
+ node: event.node,
305
+ data: { message: event.message },
306
+ });
307
+ break;
308
+ }
309
+ case "node:retry": {
310
+ fire({
311
+ event: "progress",
312
+ node: event.node,
313
+ data: { retry: true, attempt: event.attempt, reason: event.reason },
314
+ });
315
+ break;
316
+ }
317
+ default:
318
+ // Drop workflow:start, sources:resolved, tool:*, route,
319
+ // workflow:end. Cloud only models the per-node lifecycle today.
320
+ break;
321
+ }
322
+ }
323
+ catch {
324
+ // Hot-path defense: an unexpected event shape must not crash
325
+ // the engine. Silent swallow is the correct behavior here.
326
+ }
327
+ };
328
+ }
package/dist/cli/main.js CHANGED
@@ -32,6 +32,7 @@ import { registerSetupCommand } from "./setup.js";
32
32
  import { registerPublishCommand } from "./publish.js";
33
33
  import { registerSkillCommand } from "./skill.js";
34
34
  import { reportToCloud } from "./cloud-report.js";
35
+ import { beginCloudLifecycle, finishCloudLifecycle, createCloudStreamObserver } from "./cloud-lifecycle.js";
35
36
  import { runUpgrade, fetchLatestFromNpm } from "./upgrade.js";
36
37
  import { maybeNudge, defaultCachePath } from "./version-check.js";
37
38
  import { spawnSync } from "node:child_process";
@@ -313,7 +314,6 @@ triageCmd.action(async (options) => {
313
314
  break;
314
315
  }
315
316
  };
316
- const observer = composeObservers(progressObserver, config.stream ? createStreamObserver() : undefined);
317
317
  // ── Build workflow input from config ──────────────────────
318
318
  const providerCtx = buildProviderCtx(config, mcpServers);
319
319
  const { rules, context } = await resolveRulesAndContext(config);
@@ -348,6 +348,15 @@ triageCmd.action(async (options) => {
348
348
  rules,
349
349
  context: fullContext,
350
350
  };
351
+ // Open the cloud lifecycle session BEFORE composing observers so the
352
+ // node-streaming observer can attach events to the correct runId.
353
+ // Null when the token is unset or startRun fails — cloud reporting
354
+ // must never block the workflow.
355
+ const cloudHandle = await beginCloudLifecycle(config, triageWorkflow);
356
+ if (cloudHandle?.dashboardUrl) {
357
+ console.log(c.subtle(` cloud: ${cloudHandle.dashboardUrl}`));
358
+ }
359
+ const observer = composeObservers(progressObserver, config.stream ? createStreamObserver() : undefined, createCloudStreamObserver(config, cloudHandle));
351
360
  try {
352
361
  const { results, trace } = await execute(triageWorkflow, workflowInput, {
353
362
  skills,
@@ -381,7 +390,19 @@ triageCmd.action(async (options) => {
381
390
  console.error(c.subtle(` ⚠ could not write GITHUB_STEP_SUMMARY: ${err instanceof Error ? err.message : err}`));
382
391
  }
383
392
  }
384
- // Report to SWEny Cloud (best-effort, never block the run)
393
+ // Close the cloud lifecycle session opened above. Status reflects
394
+ // whether any node failed — cloud's runs.status enum accepts the
395
+ // same vocabulary the engine uses.
396
+ const triageHasFailed = [...results.values()].some((r) => r.status === "failed");
397
+ try {
398
+ await finishCloudLifecycle(config, cloudHandle, results, durationMs, triageHasFailed ? "failed" : "success");
399
+ }
400
+ catch {
401
+ // silent
402
+ }
403
+ // Report to SWEny Cloud (best-effort, legacy /api/report path for
404
+ // back-compat with cloud builds that haven't deployed lifecycle
405
+ // endpoints yet — runs through both paths so neither side breaks).
385
406
  try {
386
407
  await reportToCloud(results, durationMs, config, "triage");
387
408
  }
@@ -481,7 +502,6 @@ implementCmd.action(async (issueId, options) => {
481
502
  break;
482
503
  }
483
504
  };
484
- const observer = composeObservers(implProgressObserver, Boolean(options.stream) ? createStreamObserver() : undefined);
485
505
  // Resolve rules/context from .sweny.yml (same as triage path)
486
506
  const providerCtx = buildProviderCtx(config, mcpServers);
487
507
  const { rules, context } = await resolveRulesAndContext(config);
@@ -502,6 +522,14 @@ implementCmd.action(async (issueId, options) => {
502
522
  rules,
503
523
  context: fullImplContext,
504
524
  };
525
+ // Open cloud lifecycle session BEFORE composing observers so the
526
+ // node-streaming observer can attach to the correct runId. See
527
+ // triage path for rationale.
528
+ const implCloudHandle = await beginCloudLifecycle(config, implementWorkflow);
529
+ if (implCloudHandle?.dashboardUrl) {
530
+ console.log(c.subtle(` cloud: ${implCloudHandle.dashboardUrl}`));
531
+ }
532
+ const observer = composeObservers(implProgressObserver, Boolean(options.stream) ? createStreamObserver() : undefined, createCloudStreamObserver(config, implCloudHandle));
505
533
  try {
506
534
  const { results } = await execute(implementWorkflow, workflowInput, {
507
535
  skills,
@@ -514,6 +542,16 @@ implementCmd.action(async (issueId, options) => {
514
542
  offline: config.offline,
515
543
  });
516
544
  const hasFailed = [...results.values()].some((r) => r.status === "failed");
545
+ const implDurationMs = Date.now() - implRunStart;
546
+ // Close the cloud lifecycle session. We do this BEFORE the early
547
+ // process.exit(1) on failure so cloud sees the final status — a
548
+ // crashed-without-finish run would stay stuck at "started" forever.
549
+ try {
550
+ await finishCloudLifecycle(config, implCloudHandle, results, implDurationMs, hasFailed ? "failed" : "success");
551
+ }
552
+ catch {
553
+ // silent
554
+ }
517
555
  if (hasFailed) {
518
556
  console.error(chalk.red(`\n Implement workflow failed\n`));
519
557
  process.exit(1);
@@ -526,9 +564,9 @@ implementCmd.action(async (issueId, options) => {
526
564
  else {
527
565
  console.log(chalk.green(`\n Implement workflow completed\n`));
528
566
  }
529
- // Report to SWEny Cloud (best-effort)
567
+ // Legacy /api/report back-compat — see triage path.
530
568
  try {
531
- await reportToCloud(results, Date.now() - implRunStart, config, "implement");
569
+ await reportToCloud(results, implDurationMs, config, "implement");
532
570
  }
533
571
  catch {
534
572
  // silent
@@ -676,7 +714,6 @@ export async function workflowRunAction(file, options) {
676
714
  break;
677
715
  }
678
716
  };
679
- const observer = composeObservers(wfProgressObserver, options.stream ? createStreamObserver() : undefined);
680
717
  // Build workflow input — prefer --input JSON if provided, else fall back to config-derived input
681
718
  let workflowInput;
682
719
  if (options.input && typeof options.input === "string") {
@@ -709,6 +746,14 @@ export async function workflowRunAction(file, options) {
709
746
  context: buildProviderCtx(config, mcpServers),
710
747
  };
711
748
  }
749
+ // Open cloud lifecycle session BEFORE composing observers so the
750
+ // node-streaming observer can attach to the correct runId. See
751
+ // triage path for rationale.
752
+ const wfCloudHandle = await beginCloudLifecycle(config, workflow);
753
+ if (wfCloudHandle?.dashboardUrl && !isJson) {
754
+ console.log(c.subtle(` cloud: ${wfCloudHandle.dashboardUrl}`));
755
+ }
756
+ const observer = composeObservers(wfProgressObserver, options.stream ? createStreamObserver() : undefined, createCloudStreamObserver(config, wfCloudHandle));
712
757
  try {
713
758
  const { results, trace } = await execute(workflow, workflowInput, {
714
759
  skills,
@@ -720,10 +765,20 @@ export async function workflowRunAction(file, options) {
720
765
  fetchAuth: config.fetchAuth,
721
766
  offline: config.offline,
722
767
  });
768
+ const wfDurationMs = Date.now() - runStart;
769
+ const wfHasFailed = [...results.values()].some((r) => r.status === "failed");
770
+ // Close the cloud lifecycle session BEFORE the JSON early-exit so
771
+ // every workflow run reports a terminal status, regardless of how
772
+ // the CLI returns (json, mermaid, or normal stdout).
773
+ try {
774
+ await finishCloudLifecycle(config, wfCloudHandle, results, wfDurationMs, wfHasFailed ? "failed" : "success");
775
+ }
776
+ catch {
777
+ // silent
778
+ }
723
779
  if (isJson) {
724
780
  process.stdout.write(JSON.stringify(Object.fromEntries(results), null, 2) + "\n");
725
- const hasFailed = [...results.values()].some((r) => r.status === "failed");
726
- process.exit(hasFailed ? 1 : 0);
781
+ process.exit(wfHasFailed ? 1 : 0);
727
782
  return;
728
783
  }
729
784
  // Mermaid diagram with execution state
@@ -734,15 +789,14 @@ export async function workflowRunAction(file, options) {
734
789
  }
735
790
  process.stdout.write(toMermaidBlock(workflow, { state, trace, title: workflow.name }) + "\n");
736
791
  }
737
- // Report to SWEny Cloud (best-effort)
792
+ // Legacy /api/report back-compat — see triage path.
738
793
  try {
739
- await reportToCloud(results, Date.now() - runStart, config, workflow.id);
794
+ await reportToCloud(results, wfDurationMs, config, workflow.id);
740
795
  }
741
796
  catch {
742
797
  // silent
743
798
  }
744
- const hasFailed = [...results.values()].some((r) => r.status === "failed");
745
- if (hasFailed) {
799
+ if (wfHasFailed) {
746
800
  console.error(chalk.red(` Workflow failed\n`));
747
801
  process.exit(1);
748
802
  return;
@@ -15,9 +15,9 @@ import { parse as parseYaml } from "yaml";
15
15
  import { parseWorkflow, validateWorkflow } from "../schema.js";
16
16
  import { builtinSkills } from "../skills/index.js";
17
17
  import { discoverSkillsWithDiagnostics } from "../skills/custom-loader.js";
18
+ import { isValidSkillId } from "../types.js";
18
19
  // ── Types ──────────────────────────────────────────────────────────────
19
20
  const VALID_CATEGORIES = ["triage", "security", "devops", "code-review", "testing", "content", "ops"];
20
- const VALID_SKILL_ID = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
21
21
  // ── Validation helpers ─────────────────────────────────────────────────
22
22
  export function validateWorkflowFile(filePath) {
23
23
  const errors = [];
@@ -119,8 +119,8 @@ export function validateSkillDir(dirPath) {
119
119
  if (!id) {
120
120
  errors.push("Missing 'name' field in frontmatter");
121
121
  }
122
- else if (!VALID_SKILL_ID.test(id) || id.includes("--") || id.length > 64) {
123
- errors.push(`Invalid skill ID: "${id}" — must be lowercase, hyphens, no consecutive hyphens, max 64 chars`);
122
+ else if (!isValidSkillId(id)) {
123
+ errors.push(`Invalid skill ID: "${id}" (must be lowercase alphanumeric + hyphens, no consecutive hyphens, max 64 chars)`);
124
124
  }
125
125
  // Check directory name matches skill name
126
126
  const dirName = path.basename(dirPath);