@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.
- package/dist/cli/cloud-lifecycle.d.ts +161 -0
- package/dist/cli/cloud-lifecycle.js +328 -0
- package/dist/cli/main.js +66 -12
- package/dist/cli/publish.js +3 -3
- package/dist/cli/skill.d.ts +2 -9
- package/dist/cli/skill.js +8 -12
- package/dist/schema.d.ts +186 -6
- package/dist/schema.js +170 -9
- package/dist/skills/custom-loader.js +9 -6
- package/dist/types.d.ts +57 -2
- package/dist/types.js +42 -0
- package/dist/workflows/browser.js +3 -3
- package/dist/workflows/implement.yml +1 -0
- package/dist/workflows/seed-content.yml +1 -0
- package/dist/workflows/triage.yml +1 -0
- package/package.json +2 -2
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
567
|
+
// Legacy /api/report back-compat — see triage path.
|
|
530
568
|
try {
|
|
531
|
-
await reportToCloud(results,
|
|
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
|
-
|
|
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
|
-
//
|
|
792
|
+
// Legacy /api/report back-compat — see triage path.
|
|
738
793
|
try {
|
|
739
|
-
await reportToCloud(results,
|
|
794
|
+
await reportToCloud(results, wfDurationMs, config, workflow.id);
|
|
740
795
|
}
|
|
741
796
|
catch {
|
|
742
797
|
// silent
|
|
743
798
|
}
|
|
744
|
-
|
|
745
|
-
if (hasFailed) {
|
|
799
|
+
if (wfHasFailed) {
|
|
746
800
|
console.error(chalk.red(` Workflow failed\n`));
|
|
747
801
|
process.exit(1);
|
|
748
802
|
return;
|
package/dist/cli/publish.js
CHANGED
|
@@ -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 (!
|
|
123
|
-
errors.push(`Invalid skill ID: "${id}"
|
|
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);
|