@sweny-ai/core 0.1.90 → 0.1.92

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;
package/dist/schema.d.ts CHANGED
@@ -1375,10 +1375,25 @@ export declare const edgeZ: z.ZodObject<{
1375
1375
  when?: string | undefined;
1376
1376
  max_iterations?: number | undefined;
1377
1377
  }>;
1378
+ /**
1379
+ * Workflow type discriminator. Cloud uses this to route runs to a
1380
+ * type-specific renderer (Caught/Calibration/Missed for `pr_review`, flake
1381
+ * heatmap for `e2e_test`, source coverage delta for `monitor`, etc.).
1382
+ *
1383
+ * The field is optional on the workflow YAML; absence defaults to `generic`,
1384
+ * which gets a baseline renderer. Marketplace templates published in v1+
1385
+ * are required to declare it.
1386
+ *
1387
+ * Adding a new value here is a deliberate spec change: it requires a new
1388
+ * cloud renderer + metric schema. Don't extend casually.
1389
+ */
1390
+ export declare const workflowTypeZ: z.ZodEnum<["pr_review", "e2e_test", "content_generation", "monitor", "data_sync", "generic"]>;
1391
+ export type WorkflowType = z.infer<typeof workflowTypeZ>;
1378
1392
  export declare const workflowZ: z.ZodObject<{
1379
1393
  id: z.ZodString;
1380
1394
  name: z.ZodString;
1381
1395
  description: z.ZodDefault<z.ZodString>;
1396
+ workflow_type: z.ZodOptional<z.ZodEnum<["pr_review", "e2e_test", "content_generation", "monitor", "data_sync", "generic"]>>;
1382
1397
  nodes: z.ZodRecord<z.ZodString, z.ZodObject<{
1383
1398
  name: z.ZodString;
1384
1399
  instruction: z.ZodUnion<[z.ZodString, z.ZodObject<{
@@ -2250,6 +2265,7 @@ export declare const workflowZ: z.ZodObject<{
2250
2265
  type?: string | undefined;
2251
2266
  })[] | undefined;
2252
2267
  judge_model?: string | undefined;
2268
+ workflow_type?: "pr_review" | "e2e_test" | "content_generation" | "monitor" | "data_sync" | "generic" | undefined;
2253
2269
  judge_budget?: number | undefined;
2254
2270
  }, {
2255
2271
  id: string;
@@ -2381,6 +2397,7 @@ export declare const workflowZ: z.ZodObject<{
2381
2397
  type?: string | undefined;
2382
2398
  })[] | undefined;
2383
2399
  judge_model?: string | undefined;
2400
+ workflow_type?: "pr_review" | "e2e_test" | "content_generation" | "monitor" | "data_sync" | "generic" | undefined;
2384
2401
  judge_budget?: number | undefined;
2385
2402
  }>;
2386
2403
  /** Parse + validate a raw object as a Workflow. Throws on invalid input. */
@@ -2514,6 +2531,7 @@ export declare function parseWorkflow(raw: unknown): {
2514
2531
  type?: string | undefined;
2515
2532
  })[] | undefined;
2516
2533
  judge_model?: string | undefined;
2534
+ workflow_type?: "pr_review" | "e2e_test" | "content_generation" | "monitor" | "data_sync" | "generic" | undefined;
2517
2535
  judge_budget?: number | undefined;
2518
2536
  };
2519
2537
  export interface WorkflowError {
@@ -2767,6 +2785,11 @@ export declare const workflowJsonSchema: {
2767
2785
  };
2768
2786
  readonly description: "Background knowledge prepended to every node's instruction.";
2769
2787
  };
2788
+ readonly workflow_type: {
2789
+ readonly type: "string";
2790
+ readonly enum: readonly ["pr_review", "e2e_test", "content_generation", "monitor", "data_sync", "generic"];
2791
+ readonly description: "Workflow type discriminator. Cloud uses this to route runs to a type-specific renderer. Optional; absence defaults to 'generic'.";
2792
+ };
2770
2793
  readonly judge_model: {
2771
2794
  readonly type: "string";
2772
2795
  readonly minLength: 1;
package/dist/schema.js CHANGED
@@ -209,6 +209,19 @@ export const edgeZ = z
209
209
  max_iterations: z.number().int().min(1).optional(),
210
210
  })
211
211
  .strict();
212
+ /**
213
+ * Workflow type discriminator. Cloud uses this to route runs to a
214
+ * type-specific renderer (Caught/Calibration/Missed for `pr_review`, flake
215
+ * heatmap for `e2e_test`, source coverage delta for `monitor`, etc.).
216
+ *
217
+ * The field is optional on the workflow YAML; absence defaults to `generic`,
218
+ * which gets a baseline renderer. Marketplace templates published in v1+
219
+ * are required to declare it.
220
+ *
221
+ * Adding a new value here is a deliberate spec change: it requires a new
222
+ * cloud renderer + metric schema. Don't extend casually.
223
+ */
224
+ export const workflowTypeZ = z.enum(["pr_review", "e2e_test", "content_generation", "monitor", "data_sync", "generic"]);
212
225
  // Fix #4 gap — workflowZ is intentionally NOT .strict(). The published
213
226
  // JSON Schema's `additionalProperties: false` only scopes the properties
214
227
  // block, but marketplace workflows routinely carry top-level metadata
@@ -219,6 +232,7 @@ export const workflowZ = z.object({
219
232
  id: z.string().min(1),
220
233
  name: z.string().min(1),
221
234
  description: z.string().default(""),
235
+ workflow_type: workflowTypeZ.optional(),
222
236
  nodes: z.record(nodeZ),
223
237
  edges: z.array(edgeZ),
224
238
  entry: z.string().min(1),
@@ -541,6 +555,11 @@ export const workflowJsonSchema = {
541
555
  items: { $ref: "#/$defs/Source" },
542
556
  description: "Background knowledge prepended to every node's instruction.",
543
557
  },
558
+ workflow_type: {
559
+ type: "string",
560
+ enum: ["pr_review", "e2e_test", "content_generation", "monitor", "data_sync", "generic"],
561
+ description: "Workflow type discriminator. Cloud uses this to route runs to a type-specific renderer. Optional; absence defaults to 'generic'.",
562
+ },
544
563
  judge_model: {
545
564
  type: "string",
546
565
  minLength: 1,
package/dist/types.d.ts CHANGED
@@ -287,11 +287,18 @@ export interface Edge {
287
287
  /** Max times this edge can be followed (enables retry loops). Default: unlimited. */
288
288
  max_iterations?: number;
289
289
  }
290
+ /**
291
+ * Workflow type discriminator. Cloud uses this to render runs in a
292
+ * type-specific way. Adding a value requires a corresponding renderer.
293
+ */
294
+ export type WorkflowType = "pr_review" | "e2e_test" | "content_generation" | "monitor" | "data_sync" | "generic";
290
295
  /** A complete workflow definition. Pure data, fully serializable. */
291
296
  export interface Workflow {
292
297
  id: string;
293
298
  name: string;
294
299
  description: string;
300
+ /** Optional. Defaults to "generic" when absent. Required on marketplace templates. */
301
+ workflow_type?: WorkflowType;
295
302
  nodes: Record<string, Node>;
296
303
  edges: Edge[];
297
304
  entry: string;
@@ -1,3 +1,3 @@
1
- export const triageWorkflow = {"id":"triage","name":"Alert Triage","description":"Investigate a production alert, determine root cause, create an issue, implement a fix, and notify the team","entry":"gather","nodes":{"gather":{"name":"Gather Context","instruction":"You are investigating a production alert. Gather all relevant context using the available tools:\n\n1. **Observability**: Pull error details, stack traces, recent logs, metrics, and active incidents around the time of the alert. Use whichever observability tools are available to you.\n2. **Source control**: Check recent commits, pull requests, and deploys that might be related.\n3. **Issue tracker**: Search for similar past issues or known problems.\n\nBe thorough — the investigation step depends on complete context. Use every tool available to you.","skills":["github","sentry","datadog","betterstack","linear"]},"investigate":{"name":"Root Cause Analysis","instruction":"Based on the gathered context, classify every distinct issue you found into one of two buckets: **novel** or **duplicate**.\n\nFor EACH issue found:\n1. Identify the root cause and affected code/service.\n2. Assess severity: critical (service down), high (major feature broken), medium (degraded), low (cosmetic/minor).\n3. Assess fix complexity: \"simple\" (a few lines, clear change), \"moderate\" (multiple files but well-understood), or \"complex\" (architectural, risky, or unclear).\n4. **Novelty check (REQUIRED — you MUST call tools for this step):** Filing a duplicate ticket is worse than under-reporting — the team inherits sprawl that humans have to clean up later. Complete EVERY step below before classifying anything as novel. Do NOT skip these searches even if the gather context already mentions existing issues or branches — you must verify independently.\n4a. **Search broadly — THIS REQUIRES TOOL CALLS.** You MUST call `linear_search_issues` (or `github_search_issues`) with multiple queries against the issue tracker (BOTH open AND closed): the affected file or module path, the error message or log string verbatim, the feature or service name, and the symptom family (e.g. \"startup crash\", \"data dropped silently\"). Do not rely on a single keyword — a narrow search is how duplicates slip through. If you skip this search, the eval check will fail.\n4b. **Read the full body of every promising candidate — do NOT match on title alone.** For each top match, open the issue and read its entire description, including any \"Recommended Fix\", \"Proposed Fix\", \"Action Items\", or \"Acceptance Criteria\" section. An existing open issue whose recommended fix already prescribes the change you were about to propose IS a duplicate, even if the symptom you just observed is a new instance of the same underlying bug.\n4c. **Check for meta-issues and bug families.** If an existing ticket names a pattern that your finding is another instance of (same root cause, same function/module, different input or caller), treat your finding as a sibling of that family and comment on the existing ticket. Do not file a parallel ticket per instance — that is the exact pattern that produces backlog sprawl.\n4d. **When uncertain, comment don't file.** If you are less than ~80% confident a finding is truly novel after steps 4a–4c, classify it as a duplicate and add a \"+1 — another instance\" comment to the closest existing issue with the new context. Over-reporting duplicates is cheap; ticket sprawl is expensive.\n\n - If matched → **duplicate**. Record the existing issue ID in `duplicate_of`.\n - If no match after 4a–4d → **novel**.\n\n**Output rules:**\n- `findings`: array of ALL issues found (both novel and duplicate).\n- `novel_count`: how many findings are novel (not duplicates).\n- `highest_severity`: the highest severity across ALL findings.\n- `recommendation`: what should happen next.\n\nDownstream nodes will act ONLY on novel findings. Duplicates will be +1'd automatically.\n\n**IMPORTANT — scope boundaries for this node:**\n- DO NOT create issues (Linear, GitHub, or any tracker). A downstream `create_issue` node handles that.\n- DO NOT create branches, commits, or pull requests. A downstream `implement` node handles that.\n- DO NOT call any write tools (`linear_create_issue`, `github_create_issue`, `github_create_pr`, `linear_add_comment`, `github_add_comment`, etc.). Downstream nodes handle all writes — issue creation, +1 comments, and PRs.\n- Your ONLY job is read, search, classify, and output. If you create an issue or add a comment here, the downstream eval check will fail or duplicate work.","skills":["github","linear"],"eval":[{"name":"novelty_search_was_performed","kind":"function","rule":{"any_tool_called":["linear_search_issues","github_search_issues"]}}],"output":{"type":"object","properties":{"findings":{"type":"array","items":{"type":"object","properties":{"title":{"type":"string","description":"Short description of the issue"},"root_cause":{"type":"string"},"severity":{"type":"string","enum":["critical","high","medium","low"]},"affected_services":{"type":"array","items":{"type":"string"}},"is_duplicate":{"type":"boolean"},"duplicate_of":{"type":"string","description":"Existing issue ID/URL if duplicate"},"fix_approach":{"type":"string"},"fix_complexity":{"type":"string","enum":["simple","moderate","complex"]}},"required":["title","root_cause","severity","is_duplicate"]}},"novel_count":{"type":"number","description":"Count of novel (non-duplicate) findings"},"highest_severity":{"type":"string","enum":["critical","high","medium","low"]},"recommendation":{"type":"string"}},"required":["findings","novel_count","highest_severity","recommendation"]}},"create_issue":{"name":"Create Issues & Triage Duplicates","instruction":"Process ALL findings from the investigation. The findings array contains both novel and duplicate issues.\n\n**For each NOVEL finding** (is_duplicate = false):\n1. **First, check if a prior triage run already created an issue for this exact bug.** Call `linear_search_issues` (or `github_search_issues`) with the error message or root cause. If a matching issue already exists with the same root cause:\n - Treat it as a duplicate: DO NOT create a new issue.\n - Populate `issueIdentifier`, `issueTitle`, and `issueUrl` from the existing issue.\n - Add a \"+1\" comment if appropriate (using `linear_add_comment` or `github_add_comment`).\n - Set the `action` to `updated` in the `issues` array.\n - This prevents duplicate tickets when the same alert fires multiple times.\n\n2. If no matching issue exists, create a new one with a clear, actionable title by invoking `linear_create_issue` or `github_create_issue`. You MUST actually call the tool — do NOT synthesize an identifier from an alert ID, commit SHA, or your own imagination. If the tool errors, stop and report the failure; do not invent a fallback identifier. **Use the exact tool names above** — do NOT use generic alternatives like `create_issue` or `create_pull_request` from ToolSearch. The eval check requires the specific tool names listed here.\n3. Include: root cause, severity, affected services, reproduction steps, and recommended fix.\n4. Add appropriate labels (bug, severity level, affected service).\n5. Link to relevant commits, PRs, or existing issues.\n6. Copy the identifier returned by the tool verbatim into the `issueIdentifier` output field. The identifier must match `^[A-Z][A-Z0-9]*-\\d+$` (e.g. `OFF-1234`) — do not prefix it with anything else.\n\n**For each DUPLICATE finding** (is_duplicate = true):\n1. Find the existing issue (check duplicate_of field).\n2. Check the issue's comments — if the most recent comment is already from SWEny (contains \"+1\") within the last 24 hours, skip adding another comment.\n3. Otherwise add a SHORT comment: \"+1 — seen again {UTC timestamp}. {one sentence of new context}.\" (Keep it under 2 lines. No markdown headers, no emoji, no formatting.)\n4. If the existing issue is closed/done, reopen it.\n\nIf context.issueTemplate is provided, use it as the format for new issue bodies. Otherwise use a clear structure with: Summary, Root Cause, Impact, Steps to Reproduce, and Recommended Fix.\n\nUse the issue tracker specified in context (e.g. Linear, Jira, or GitHub Issues) — do NOT fall back to a different tracker. Output the created/updated issue identifiers.\n\n**IMPORTANT — scope boundaries for this node:**\n- DO NOT create branches, commits, or pull requests. The `implement` and `create_pr` nodes handle that.\n- DO NOT call `create_pull_request`, `github_create_pr`, or any PR/branch tools.\n- Your job is issue creation and duplicate management ONLY.","skills":["linear","github"],"eval":[{"name":"issue_tracker_tool_was_called","kind":"function","rule":{"any_tool_called":["linear_create_issue","github_create_issue","linear_search_issues","github_search_issues","linear_add_comment","github_add_comment"]}}],"output":{"type":"object","properties":{"issueIdentifier":{"type":"string","pattern":"^[A-Z][A-Z0-9]*-\\d+$","description":"Primary issue identifier (e.g. OFF-1234). MUST be the identifier returned by `linear_create_issue` / `github_create_issue` — do NOT invent a value from a Sentry short-ID or commit SHA. Used by downstream nodes for branch naming and PR linking."},"issueTitle":{"type":"string","description":"Title of the primary issue created"},"issueUrl":{"type":"string","pattern":"^https?://","description":"URL of the primary issue created, as returned by the create-issue tool"},"issues":{"type":"array","minItems":1,"description":"All issues created or updated during this step","items":{"type":"object","properties":{"identifier":{"type":"string"},"title":{"type":"string"},"url":{"type":"string"},"action":{"type":"string","enum":["created","updated","reopened"]}},"required":["identifier","action"]}}},"required":["issueIdentifier","issueTitle","issueUrl","issues"]},"retry":{"max":1,"instruction":{"auto":true}}},"skip":{"name":"Skip — All Duplicates or Low Priority","instruction":"Every finding from the investigation was either a duplicate or low-priority. No new issues need to be created.\n\nFor each **duplicate** finding (check the findings array for items where is_duplicate = true):\n1. Find the existing issue (check duplicate_of field).\n2. Check the issue's comments — if the most recent comment is already from SWEny (contains \"+1\") within the last 24 hours, skip adding another comment.\n3. Otherwise add a SHORT comment: \"+1 — seen again {UTC timestamp}. {one sentence of new context}.\" (Keep it under 2 lines. No markdown headers, no emoji, no formatting.)\n4. If the issue is closed/done, reopen it.\n\nFor **low priority** findings, log a brief note about why they were skipped.","skills":["linear","github"]},"implement":{"name":"Implement Fix","instruction":"Implement the fix identified during investigation, plus the tests that pin\nit as a regression guard. The issue created in the previous step is in your\ncontext — use its identifier for branch naming and commit messages.\n\n## Quality bar (non-negotiable)\n\nEvery change you make must clear all of these. If you cannot, STOP and\nreturn a failed status — do not ship a half-measure.\n\n- **Correctness**: the fix actually closes the failure mode in the issue.\n- **Completeness**: handles the obvious edge cases (nulls, empties, off-by-one,\n concurrent paths). If the issue lists multiple symptoms, address them all\n or explicitly state which are out of scope and why.\n- **Idiomatic for THIS repo, not in general.** Read at least one\n neighboring file in the same module before you write any code, and\n match the patterns you see: error-handling primitives the codebase\n already uses, type discipline, naming, file layout. Examples to make\n the shape concrete:\n - In a NestJS service, throwing `NotFoundException` /\n `BadRequestException` is idiomatic; `throw new Error(...)` is not.\n - In a Go service, returning a wrapped `fmt.Errorf(\"...: %w\", err)`\n beats panicking.\n - In a TypeScript repo, narrow types properly — do not `as any` past\n the type checker.\n These are illustrations. Generalize: whatever the repo already does,\n keep doing.\n- **No hacky shortcuts**: no `try/catch` to swallow errors, no commented-out\n guards, no `// TODO: revisit`, no global flags to bypass validation, no\n copy-pasted bodies. If the right fix needs a small refactor (e.g. extract\n a helper), do it; if it needs a large refactor, file a follow-up issue\n and ship the minimal correct fix without the refactor.\n- **Well-tested**: see Test Requirements below.\n\n## Steps\n\n0. **Precondition — verify context.create_issue.issueIdentifier is well-formed.**\n The identifier MUST match `^[A-Z][A-Z0-9]*-\\d+$` (e.g. `OFF-1234`). If it\n is missing, empty, or matches a Sentry short-ID / slug / any other shape,\n STOP immediately — do NOT invent a branch name from the Sentry event,\n commit SHA, or alert payload. Return a failed status with a clear message\n instead; the workflow will route to notify and a human will pick it up.\n\n1. Create a feature branch from the base branch (check context for\n baseBranch, default `main`). **Branch name MUST include the issue\n identifier** exactly as provided in `context.create_issue.issueIdentifier`.\n Format: lowercase the identifier and use it as a prefix, e.g.\n `off-1234-fix-null-check`. If the repository uses a different branch\n convention (check context.branchPattern or the rules/CLAUDE.md for a\n regex), follow that — but the identifier piece MUST still come from\n `context.create_issue.issueIdentifier`, never from a Sentry short-ID.\n\n2. Read the relevant source files to understand the current code AND read\n the nearest existing test file (e.g. `foo.service.spec.ts` next to\n `foo.service.ts`, or `foo_test.py` next to `foo.py`) so your new tests\n match the existing test conventions: framework, mock style, factory use,\n describe/it shape, naming.\n\n3. Make the necessary code change. Keep it minimal and focused — do not\n refactor surrounding code or add unrelated improvements.\n\n4. **Write tests for the fix.** See Test Requirements below.\n\n5. **Run the test suite for the affected package** and confirm green\n (the package's `test` command from package.json, `pytest` for Python,\n etc.). If the test runner is not obvious, read the project's\n CLAUDE.md / README / package.json scripts. If you cannot run the\n tests at all in this environment, say so explicitly in the output —\n do not claim \"tests pass\" when you didn't run them.\n\n6. Stage and commit with a clear commit message. Start the message with the\n issue identifier in brackets, e.g.\n `[OFF-1234] fix: guard null projectId before database lookup`.\n\n## Test Requirements\n\nA code change without a test is unfinished work. Every fix this node ships\nMUST come with at least one new test case that:\n\n- **Would fail on the unfixed code.** A test that passes both before and\n after the fix is decoration, not a regression guard. Mentally run it\n against the previous version; if it would have passed, it is not the\n right test.\n- **Asserts the user-facing contract**, not just an implementation\n detail. For an API/handler bug: assert the response shape, status code,\n or error type the client sees. For a data corruption bug: assert the\n persisted row's final state, not just an internal call count.\n- **Covers the obvious adjacent edge cases.** If the bug was \"throws plain\n Error on missing record\", add the missing-record case AND at least one\n success-path case to confirm the fix didn't break the happy path.\n- **Lives in the right file** (next to the source under test, in the\n project's existing test layout).\n\nThe eval gate on this node REQUIRES `test_status` to be either\n`pass` (tests ran and all passed) OR `no-framework` (the project\ngenuinely has no test runner — not \"I couldn't be bothered\").\n\nIf the project HAS a test framework, you must write tests and they\nmust pass. Empty `test_files_changed` with `test_status: pass` is a\nlie and eval will reject it via the schema's required-fields\ncontract; `not-run` and `fail` will retry once with reflection and\nthen halt for human attention.\n\nIf the fix turns out to be more complex than expected, stop and\nexplain why — do not force a bad fix.","skills":["github"],"output":{"type":"object","properties":{"branch":{"type":"string","description":"Feature branch name (must include the issue identifier)."},"commit_sha":{"type":"string","description":"SHA of the commit shipping the fix and tests."},"files_changed":{"type":"array","description":"Source files modified by the fix (NOT including test files).","items":{"type":"string"}},"test_files_changed":{"type":"array","description":"Test files added or modified to cover the fix. Should be\nnon-empty whenever `test_status: pass`. May be empty when\n`test_status: no-framework` (genuinely no test runner in\nthe repo).\n","items":{"type":"string"}},"test_command":{"type":"string","description":"Exact command run to validate (e.g. `npx nx test server`)."},"test_status":{"type":"string","enum":["pass","fail","no-framework","not-run"],"description":"Outcome of running the test suite.\n- `pass` — tests ran end-to-end and every assertion passed.\n `test_files_changed` MUST be non-empty in this case (you\n changed code; tests must cover it).\n- `no-framework` — the repo genuinely has no test runner\n (e.g. a docs-only repo, a YAML-config repo, a research\n notebook collection). Reserved for cases where there is\n no test infrastructure to extend — not a permission slip\n to skip tests in a normal codebase.\n- `fail` — tests ran and at least one failed. Do not push\n a broken fix. Return this status, retry will fire once.\n- `not-run` — tests exist but the environment couldn't\n execute them. Explain in `test_notes`. Retry will fire.\n"},"test_notes":{"type":"string","description":"One-paragraph explanation of which tests were added and what they assert."}},"required":["branch","commit_sha","files_changed","test_files_changed","test_status"]},"eval":[{"name":"test_status_is_pass_or_no_framework","kind":"value","rule":{"output_matches":[{"path":"test_status","in":["pass","no-framework"]}]}},{"name":"tests_present_when_pass_claimed","kind":"judge","rubric":"You are checking the contract between test_status and test_files_changed.\n\nRead result.data.test_status and result.data.test_files_changed.\n\nRules:\n- If test_status is \"pass\": test_files_changed MUST contain at least\n one real test file path (paths with extensions like .spec.ts,\n .test.ts, .test.tsx, .test.py, _test.go, _spec.rb, .test.rs,\n etc., or paths in a __tests__/, tests/, or spec/ directory).\n An empty array, or paths that are clearly not test files\n (README.md, package.json, source files), is a contract violation.\n- If test_status is \"no-framework\": test_files_changed MAY be empty.\n Pass vacuously.\n- If test_status is \"fail\" or \"not-run\": this evaluator should not\n run (the prior value evaluator catches that). Pass vacuously.\n\nRespond yes if the contract is satisfied, no if it is violated.\n","pass_when":"yes"}],"retry":{"max":1,"instruction":{"auto":true}}},"create_pr":{"name":"Open Pull Request","instruction":"Open a pull request for the fix. The issue identifier and URL are in your context from the create_issue step.\n\n1. Push the branch to the remote.\n2. Create the PR using the `github_create_pr` tool. **Use this exact tool name** — do NOT use `create_pull_request` or any other variant from ToolSearch. The eval check requires `github_create_pr` specifically.\n3. PR title MUST start with the issue identifier in brackets. Format: `[{context.create_issue.issueIdentifier}] fix: {short description}`.\n4. In the PR body, include: summary of the bug, what the fix does, and a link to the issue (context.create_issue.issueUrl). If the investigation found a Sentry issue, include \"Fixes {sentryShortId}\" in the PR body so Sentry auto-resolves on merge.\n5. Add appropriate labels if available.\n\nIf context.prTemplate is provided, use it as the format for the PR body. Otherwise use a clear structure with: Summary, Changes, Testing, and Related Issues.\n\nIf context.cloudRunId is provided, append this HTML comment to the end of the PR body: <!-- sweny:run_id:{context.cloudRunId} -->\n\nReturn the PR URL and number.","skills":["github"],"eval":[{"name":"pr_was_created","kind":"function","rule":{"any_tool_called":["github_create_pr"]}}],"retry":{"max":1,"instruction":{"auto":true}}},"notify":{"name":"Notify Team","instruction":"Send a notification summarizing the triage result:\n\n1. Include: alert summary, severity, root cause (1-2 sentences), and links to any created issues or PRs.\n2. For critical/high severity, make the notification urgent.\n3. For medium/low, a standard notification is fine.\n\nUse whichever notification channel is available to you.","skills":["slack","notification"]}},"edges":[{"from":"gather","to":"investigate"},{"from":"investigate","to":"create_issue","when":"novel_count is greater than 0 AND highest_severity is medium or higher"},{"from":"investigate","to":"skip","when":"novel_count is 0, OR highest_severity is low"},{"from":"create_issue","to":"implement","when":"at least one novel finding has fix_complexity simple or moderate AND fix_approach is provided"},{"from":"create_issue","to":"notify","when":"all novel findings have fix_complexity complex, OR no clear fix_approach"},{"from":"skip","to":"notify"},{"from":"implement","to":"create_pr"},{"from":"create_pr","to":"notify"}],"skills":{}};
2
- export const implementWorkflow = {"id":"implement","name":"Implement Fix","description":"Implement a code fix for an issue and open a pull request","entry":"analyze","nodes":{"analyze":{"name":"Analyze Issue","instruction":"Read the issue details and understand what needs to be fixed:\n\n1. Fetch the issue from the tracker (GitHub or Linear).\n2. Read the relevant source files to understand the current code.\n3. Identify the exact files and lines that need to change.\n4. Plan the fix approach.\n\nOutput a clear analysis of what needs to change and why.","skills":["github","linear"],"output":{"type":"object","properties":{"issue_summary":{"type":"string"},"files_to_change":{"type":"array","items":{"type":"string"}},"fix_plan":{"type":"string"},"risk_level":{"type":"string","enum":["low","medium","high"]}},"required":["issue_summary","fix_plan"]}},"implement":{"name":"Implement Fix","instruction":"Implement the planned fix:\n\n1. Create a feature branch.\n2. Make the necessary code changes.\n3. Ensure changes are minimal and focused — fix the bug, nothing more.\n4. Stage and commit with a clear commit message referencing the issue.\n\nIf the fix is too risky or complex, explain why and skip.","skills":["github"]},"create_pr":{"name":"Open Pull Request","instruction":"Open a pull request for the fix:\n\n1. Push the branch to the remote.\n2. Create a PR with a clear title and description.\n3. Reference the original issue in the PR body.\n4. Add appropriate reviewers or labels if possible.\n\nIf context.prTemplate is provided, use it as the format for the PR body. Otherwise use a clear structure with: Summary, Changes, Testing, and Related Issues.\n\nIf context.cloudRunId is provided, append this HTML comment to the end of the PR body: <!-- sweny:run_id:{context.cloudRunId} -->\n\nReturn the PR URL.","skills":["github"]},"notify":{"name":"Notify","instruction":"Send a notification about the implementation result:\n\n1. Include: issue reference, PR link, brief summary of changes.\n2. Keep it concise — one message, not a wall of text.","skills":["slack","notification"]},"skip":{"name":"Skip — Too Complex","instruction":"The fix was determined to be too complex or risky for automated implementation.\nAdd a comment to the issue explaining what was found and why it needs manual attention.","skills":["github","linear"]}},"edges":[{"from":"analyze","to":"implement","when":"Fix risk level is low or medium and a clear plan exists"},{"from":"analyze","to":"skip","when":"Fix is too complex, risky, or unclear"},{"from":"implement","to":"create_pr"},{"from":"skip","to":"notify"},{"from":"create_pr","to":"notify"}],"skills":{}};
3
- export const seedContentWorkflow = {"id":"seed-content","name":"Content Generation Pipeline","description":"Audit content gaps, generate educational content, validate quality, and publish to Supabase","entry":"audit","nodes":{"audit":{"name":"Audit Content Gaps","instruction":"You are auditing an educational platform's content library.\n\nUse supabase_count to check how many items exist for each combination of:\n- Content types: worksheets, games, lessons\n- Subjects: math, science, english, history, geography, economics, music, art, life-skills, foreign-language\n- Difficulties: beginner, easy, medium, hard\n\nAlso check the input parameters — if the user specified a subject, topic, difficulty, and count,\nskip the full audit and just report what they want generated.\n\nReturn a structured content plan: what to generate, how many, at what difficulty and grade level.\nPrioritize gaps where a subject has ZERO content of a given type.","skills":["supabase"],"output":{"type":"object","properties":{"plan":{"type":"array","items":{"type":"object","properties":{"contentType":{"type":"string"},"subject":{"type":"string"},"topic":{"type":"string"},"difficulty":{"type":"string"},"gradeLevel":{"type":"string"},"count":{"type":"number"},"reason":{"type":"string"}},"required":["contentType","subject","topic","difficulty","count"]}},"summary":{"type":"string"}},"required":["plan","summary"]}},"generate":{"name":"Generate Content","instruction":"You are an expert educator creating content for children ages 5-14.\n\nFor each item in the content plan from the previous step, generate the content directly.\nThe content must be high-quality, engaging, age-appropriate, and educationally sound.\n\n**For worksheets:**\nGenerate a JSON array of problems. Each problem needs: \"problem\", \"answer\", \"format\" (answer-line | multiple-choice | write-on).\nRequirements:\n- Every answer MUST be correct. Double-check math, facts, spelling.\n- Problems must progress in difficulty within the set.\n- Use at least 2 different problem structures (e.g., equations + word problems for math).\n- Vary phrasing — no two problems should feel copy-pasted.\n- For the target grade level, use appropriate vocabulary and complexity.\n\n**For lessons:**\nGenerate complete HTML + CSS + JS for an interactive lesson.\nRequirements:\n- Must have substantial educational content (not just a shell with navigation).\n- Include interactive elements: quizzes, click-to-reveal, drag-and-drop.\n- Must be visually engaging with colors, icons, and clear typography.\n- Include practice exercises with immediate feedback.\n- Minimum 5 teaching sections plus 2 practice activities.\n\n**For games:**\nGenerate a description and educational requirements only (actual game code is generated\nby the platform's Phaser-based generator).\n\nUse supabase_insert to write each piece of content to the appropriate table.\n\nTable schemas:\n- worksheets: id (auto), title, subject, topic, difficulty, grade_level, num_questions, problems (jsonb), status='published', source='ai-seed'\n- lessons: id (auto), title, subject, difficulty, grade_level, lesson_type, html_code, css_code, js_code, status='published', source='ai-seed'\n- games: id (auto), title, subject, difficulty, grade_level, game_type, html_code, css_code, js_code, status='published', source='ai-seed'\n\nReturn a summary of what was generated and inserted.","skills":["supabase"],"output":{"type":"object","properties":{"generated":{"type":"array","items":{"type":"object","properties":{"contentType":{"type":"string"},"title":{"type":"string"},"subject":{"type":"string"},"id":{"type":"string"},"status":{"type":"string"}}}},"totalGenerated":{"type":"number"},"errors":{"type":"array","items":{"type":"string"}}},"required":["generated","totalGenerated"]}},"validate":{"name":"Validate Content Quality","instruction":"Review the content that was just generated and inserted.\n\nFor each item, use supabase_query to fetch it back and verify:\n\n**Worksheets:**\n- Parse the problems JSON — are all answers correct?\n- Are problems diverse (not repetitive)?\n- Is difficulty appropriate for the stated grade level?\n- Are there the right number of questions?\n\n**Lessons:**\n- Is the HTML substantial (> 500 characters)?\n- Does it contain actual educational content (not just navigation shells)?\n- Are there interactive elements?\n\n**Games:**\n- Does the HTML contain a working game structure?\n\nFlag any issues. If critical issues are found (wrong answers, empty content),\nuse supabase_update to set status='draft' and add a note.\n\nReturn a quality report.","skills":["supabase"],"output":{"type":"object","properties":{"totalReviewed":{"type":"number"},"passed":{"type":"number"},"flagged":{"type":"number"},"issues":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"title":{"type":"string"},"issue":{"type":"string"},"severity":{"type":"string"},"action":{"type":"string"}}}},"summary":{"type":"string"}},"required":["totalReviewed","passed","flagged","summary"]}},"report":{"name":"Generate Report","instruction":"Compile a final report of the content generation run.\n\nInclude:\n- Total content generated by type and subject\n- Quality validation results\n- Any issues found and actions taken\n- Recommendations for next content generation run (what gaps remain)\n\nFormat as a clean markdown summary that a solo operator can scan in 30 seconds.","skills":[],"output":{"type":"object","properties":{"markdown":{"type":"string"},"nextPriorities":{"type":"array","items":{"type":"string"}}},"required":["markdown"]}}},"edges":[{"from":"audit","to":"generate"},{"from":"generate","to":"validate"},{"from":"validate","to":"report"}],"skills":{}};
1
+ export const triageWorkflow = {"id":"triage","name":"Alert Triage","workflow_type":"pr_review","description":"Investigate a production alert, determine root cause, create an issue, implement a fix, and notify the team","entry":"gather","nodes":{"gather":{"name":"Gather Context","instruction":"You are investigating a production alert. Gather all relevant context using the available tools:\n\n1. **Observability**: Pull error details, stack traces, recent logs, metrics, and active incidents around the time of the alert. Use whichever observability tools are available to you.\n2. **Source control**: Check recent commits, pull requests, and deploys that might be related.\n3. **Issue tracker**: Search for similar past issues or known problems.\n\nBe thorough — the investigation step depends on complete context. Use every tool available to you.","skills":["github","sentry","datadog","betterstack","linear"]},"investigate":{"name":"Root Cause Analysis","instruction":"Based on the gathered context, classify every distinct issue you found into one of two buckets: **novel** or **duplicate**.\n\nFor EACH issue found:\n1. Identify the root cause and affected code/service.\n2. Assess severity: critical (service down), high (major feature broken), medium (degraded), low (cosmetic/minor).\n3. Assess fix complexity: \"simple\" (a few lines, clear change), \"moderate\" (multiple files but well-understood), or \"complex\" (architectural, risky, or unclear).\n4. **Novelty check (REQUIRED — you MUST call tools for this step):** Filing a duplicate ticket is worse than under-reporting — the team inherits sprawl that humans have to clean up later. Complete EVERY step below before classifying anything as novel. Do NOT skip these searches even if the gather context already mentions existing issues or branches — you must verify independently.\n4a. **Search broadly — THIS REQUIRES TOOL CALLS.** You MUST call `linear_search_issues` (or `github_search_issues`) with multiple queries against the issue tracker (BOTH open AND closed): the affected file or module path, the error message or log string verbatim, the feature or service name, and the symptom family (e.g. \"startup crash\", \"data dropped silently\"). Do not rely on a single keyword — a narrow search is how duplicates slip through. If you skip this search, the eval check will fail.\n4b. **Read the full body of every promising candidate — do NOT match on title alone.** For each top match, open the issue and read its entire description, including any \"Recommended Fix\", \"Proposed Fix\", \"Action Items\", or \"Acceptance Criteria\" section. An existing open issue whose recommended fix already prescribes the change you were about to propose IS a duplicate, even if the symptom you just observed is a new instance of the same underlying bug.\n4c. **Check for meta-issues and bug families.** If an existing ticket names a pattern that your finding is another instance of (same root cause, same function/module, different input or caller), treat your finding as a sibling of that family and comment on the existing ticket. Do not file a parallel ticket per instance — that is the exact pattern that produces backlog sprawl.\n4d. **When uncertain, comment don't file.** If you are less than ~80% confident a finding is truly novel after steps 4a–4c, classify it as a duplicate and add a \"+1 — another instance\" comment to the closest existing issue with the new context. Over-reporting duplicates is cheap; ticket sprawl is expensive.\n\n - If matched → **duplicate**. Record the existing issue ID in `duplicate_of`.\n - If no match after 4a–4d → **novel**.\n\n**Output rules:**\n- `findings`: array of ALL issues found (both novel and duplicate).\n- `novel_count`: how many findings are novel (not duplicates).\n- `highest_severity`: the highest severity across ALL findings.\n- `recommendation`: what should happen next.\n\nDownstream nodes will act ONLY on novel findings. Duplicates will be +1'd automatically.\n\n**IMPORTANT — scope boundaries for this node:**\n- DO NOT create issues (Linear, GitHub, or any tracker). A downstream `create_issue` node handles that.\n- DO NOT create branches, commits, or pull requests. A downstream `implement` node handles that.\n- DO NOT call any write tools (`linear_create_issue`, `github_create_issue`, `github_create_pr`, `linear_add_comment`, `github_add_comment`, etc.). Downstream nodes handle all writes — issue creation, +1 comments, and PRs.\n- Your ONLY job is read, search, classify, and output. If you create an issue or add a comment here, the downstream eval check will fail or duplicate work.","skills":["github","linear"],"eval":[{"name":"novelty_search_was_performed","kind":"function","rule":{"any_tool_called":["linear_search_issues","github_search_issues"]}}],"output":{"type":"object","properties":{"findings":{"type":"array","items":{"type":"object","properties":{"title":{"type":"string","description":"Short description of the issue"},"root_cause":{"type":"string"},"severity":{"type":"string","enum":["critical","high","medium","low"]},"affected_services":{"type":"array","items":{"type":"string"}},"is_duplicate":{"type":"boolean"},"duplicate_of":{"type":"string","description":"Existing issue ID/URL if duplicate"},"fix_approach":{"type":"string"},"fix_complexity":{"type":"string","enum":["simple","moderate","complex"]}},"required":["title","root_cause","severity","is_duplicate"]}},"novel_count":{"type":"number","description":"Count of novel (non-duplicate) findings"},"highest_severity":{"type":"string","enum":["critical","high","medium","low"]},"recommendation":{"type":"string"}},"required":["findings","novel_count","highest_severity","recommendation"]}},"create_issue":{"name":"Create Issues & Triage Duplicates","instruction":"Process ALL findings from the investigation. The findings array contains both novel and duplicate issues.\n\n**For each NOVEL finding** (is_duplicate = false):\n1. **First, check if a prior triage run already created an issue for this exact bug.** Call `linear_search_issues` (or `github_search_issues`) with the error message or root cause. If a matching issue already exists with the same root cause:\n - Treat it as a duplicate: DO NOT create a new issue.\n - Populate `issueIdentifier`, `issueTitle`, and `issueUrl` from the existing issue.\n - Add a \"+1\" comment if appropriate (using `linear_add_comment` or `github_add_comment`).\n - Set the `action` to `updated` in the `issues` array.\n - This prevents duplicate tickets when the same alert fires multiple times.\n\n2. If no matching issue exists, create a new one with a clear, actionable title by invoking `linear_create_issue` or `github_create_issue`. You MUST actually call the tool — do NOT synthesize an identifier from an alert ID, commit SHA, or your own imagination. If the tool errors, stop and report the failure; do not invent a fallback identifier. **Use the exact tool names above** — do NOT use generic alternatives like `create_issue` or `create_pull_request` from ToolSearch. The eval check requires the specific tool names listed here.\n3. Include: root cause, severity, affected services, reproduction steps, and recommended fix.\n4. Add appropriate labels (bug, severity level, affected service).\n5. Link to relevant commits, PRs, or existing issues.\n6. Copy the identifier returned by the tool verbatim into the `issueIdentifier` output field. The identifier must match `^[A-Z][A-Z0-9]*-\\d+$` (e.g. `OFF-1234`) — do not prefix it with anything else.\n\n**For each DUPLICATE finding** (is_duplicate = true):\n1. Find the existing issue (check duplicate_of field).\n2. Check the issue's comments — if the most recent comment is already from SWEny (contains \"+1\") within the last 24 hours, skip adding another comment.\n3. Otherwise add a SHORT comment: \"+1 — seen again {UTC timestamp}. {one sentence of new context}.\" (Keep it under 2 lines. No markdown headers, no emoji, no formatting.)\n4. If the existing issue is closed/done, reopen it.\n\nIf context.issueTemplate is provided, use it as the format for new issue bodies. Otherwise use a clear structure with: Summary, Root Cause, Impact, Steps to Reproduce, and Recommended Fix.\n\nUse the issue tracker specified in context (e.g. Linear, Jira, or GitHub Issues) — do NOT fall back to a different tracker. Output the created/updated issue identifiers.\n\n**IMPORTANT — scope boundaries for this node:**\n- DO NOT create branches, commits, or pull requests. The `implement` and `create_pr` nodes handle that.\n- DO NOT call `create_pull_request`, `github_create_pr`, or any PR/branch tools.\n- Your job is issue creation and duplicate management ONLY.","skills":["linear","github"],"eval":[{"name":"issue_tracker_tool_was_called","kind":"function","rule":{"any_tool_called":["linear_create_issue","github_create_issue","linear_search_issues","github_search_issues","linear_add_comment","github_add_comment"]}}],"output":{"type":"object","properties":{"issueIdentifier":{"type":"string","pattern":"^[A-Z][A-Z0-9]*-\\d+$","description":"Primary issue identifier (e.g. OFF-1234). MUST be the identifier returned by `linear_create_issue` / `github_create_issue` — do NOT invent a value from a Sentry short-ID or commit SHA. Used by downstream nodes for branch naming and PR linking."},"issueTitle":{"type":"string","description":"Title of the primary issue created"},"issueUrl":{"type":"string","pattern":"^https?://","description":"URL of the primary issue created, as returned by the create-issue tool"},"issues":{"type":"array","minItems":1,"description":"All issues created or updated during this step","items":{"type":"object","properties":{"identifier":{"type":"string"},"title":{"type":"string"},"url":{"type":"string"},"action":{"type":"string","enum":["created","updated","reopened"]}},"required":["identifier","action"]}}},"required":["issueIdentifier","issueTitle","issueUrl","issues"]},"retry":{"max":1,"instruction":{"auto":true}}},"skip":{"name":"Skip — All Duplicates or Low Priority","instruction":"Every finding from the investigation was either a duplicate or low-priority. No new issues need to be created.\n\nFor each **duplicate** finding (check the findings array for items where is_duplicate = true):\n1. Find the existing issue (check duplicate_of field).\n2. Check the issue's comments — if the most recent comment is already from SWEny (contains \"+1\") within the last 24 hours, skip adding another comment.\n3. Otherwise add a SHORT comment: \"+1 — seen again {UTC timestamp}. {one sentence of new context}.\" (Keep it under 2 lines. No markdown headers, no emoji, no formatting.)\n4. If the issue is closed/done, reopen it.\n\nFor **low priority** findings, log a brief note about why they were skipped.","skills":["linear","github"]},"implement":{"name":"Implement Fix","instruction":"Implement the fix identified during investigation, plus the tests that pin\nit as a regression guard. The issue created in the previous step is in your\ncontext — use its identifier for branch naming and commit messages.\n\n## Quality bar (non-negotiable)\n\nEvery change you make must clear all of these. If you cannot, STOP and\nreturn a failed status — do not ship a half-measure.\n\n- **Correctness**: the fix actually closes the failure mode in the issue.\n- **Completeness**: handles the obvious edge cases (nulls, empties, off-by-one,\n concurrent paths). If the issue lists multiple symptoms, address them all\n or explicitly state which are out of scope and why.\n- **Idiomatic for THIS repo, not in general.** Read at least one\n neighboring file in the same module before you write any code, and\n match the patterns you see: error-handling primitives the codebase\n already uses, type discipline, naming, file layout. Examples to make\n the shape concrete:\n - In a NestJS service, throwing `NotFoundException` /\n `BadRequestException` is idiomatic; `throw new Error(...)` is not.\n - In a Go service, returning a wrapped `fmt.Errorf(\"...: %w\", err)`\n beats panicking.\n - In a TypeScript repo, narrow types properly — do not `as any` past\n the type checker.\n These are illustrations. Generalize: whatever the repo already does,\n keep doing.\n- **No hacky shortcuts**: no `try/catch` to swallow errors, no commented-out\n guards, no `// TODO: revisit`, no global flags to bypass validation, no\n copy-pasted bodies. If the right fix needs a small refactor (e.g. extract\n a helper), do it; if it needs a large refactor, file a follow-up issue\n and ship the minimal correct fix without the refactor.\n- **Well-tested**: see Test Requirements below.\n\n## Steps\n\n0a. **Precondition — verify context.create_issue.issueIdentifier is well-formed.**\n The identifier MUST match `^[A-Z][A-Z0-9]*-\\d+$` (e.g. `OFF-1234`). If it\n is missing, empty, or matches a Sentry short-ID / slug / any other shape,\n STOP immediately — do NOT invent a branch name from the Sentry event,\n commit SHA, or alert payload. Return a failed status with a clear message\n instead; the workflow will route to notify and a human will pick it up.\n\n0b. **Precondition — check for an existing open PR for this issue.** Call\n `github_search_issues` (which hits GitHub's `/search/issues` endpoint\n and returns both issues and PRs) with `type:pr state:open` and the\n issue identifier wrapped in brackets — e.g. query\n `[OFF-1234] in:title type:pr state:open` and pass `repo` for scoping.\n If the response contains one or more PRs whose title starts with\n `[<issueIdentifier>]`, STOP. Do NOT create a branch, commit, or any\n new PR. Output:\n\n ```\n test_status: \"skipped\"\n skipped_reason: \"open-pr-exists\"\n existing_pr_url: \"<url of the existing PR>\"\n test_files_changed: []\n files_changed: []\n branch: \"\"\n commit_sha: \"\"\n ```\n\n The downstream `create_pr` node will be bypassed and the workflow will\n route to `notify` so the team is told the prior PR is still pending.\n\n Rationale: re-implementing a fix while an open PR already exists\n produces a duplicate (or worse, the agent closes its own prior PR\n mid-run and opens a new one with a different approach — exactly the\n thrash observed in `letsoffload/permit-service` PR #81 → #82 and\n `letsoffload/offload` PR #572 → #573, where two PRs for the same\n identifier were opened-and-closed 12 to 16 seconds apart inside a\n single workflow run).\n\n Only proceed to Step 1 if no open PR exists for the identifier.\n\n1. Create a feature branch from the base branch (check context for\n baseBranch, default `main`). **Branch name MUST include the issue\n identifier** exactly as provided in `context.create_issue.issueIdentifier`.\n Format: lowercase the identifier and use it as a prefix, e.g.\n `off-1234-fix-null-check`. If the repository uses a different branch\n convention (check context.branchPattern or the rules/CLAUDE.md for a\n regex), follow that — but the identifier piece MUST still come from\n `context.create_issue.issueIdentifier`, never from a Sentry short-ID.\n\n2. Read the relevant source files to understand the current code AND read\n the nearest existing test file (e.g. `foo.service.spec.ts` next to\n `foo.service.ts`, or `foo_test.py` next to `foo.py`) so your new tests\n match the existing test conventions: framework, mock style, factory use,\n describe/it shape, naming.\n\n3. Make the necessary code change. Keep it minimal and focused — do not\n refactor surrounding code or add unrelated improvements.\n\n4. **Write tests for the fix.** See Test Requirements below.\n\n5. **Run the test suite for the affected package** and confirm green\n (the package's `test` command from package.json, `pytest` for Python,\n etc.). If the test runner is not obvious, read the project's\n CLAUDE.md / README / package.json scripts. If you cannot run the\n tests at all in this environment, say so explicitly in the output —\n do not claim \"tests pass\" when you didn't run them.\n\n6. Stage and commit with a clear commit message. Start the message with the\n issue identifier in brackets, e.g.\n `[OFF-1234] fix: guard null projectId before database lookup`.\n\n## Test Requirements\n\nA code change without a test is unfinished work. Every fix this node ships\nMUST come with at least one new test case that:\n\n- **Would fail on the unfixed code.** A test that passes both before and\n after the fix is decoration, not a regression guard. Mentally run it\n against the previous version; if it would have passed, it is not the\n right test.\n- **Asserts the user-facing contract**, not just an implementation\n detail. For an API/handler bug: assert the response shape, status code,\n or error type the client sees. For a data corruption bug: assert the\n persisted row's final state, not just an internal call count.\n- **Covers the obvious adjacent edge cases.** If the bug was \"throws plain\n Error on missing record\", add the missing-record case AND at least one\n success-path case to confirm the fix didn't break the happy path.\n- **Lives in the right file** (next to the source under test, in the\n project's existing test layout).\n\nThe eval gate on this node REQUIRES `test_status` to be either\n`pass` (tests ran and all passed) OR `no-framework` (the project\ngenuinely has no test runner — not \"I couldn't be bothered\").\n\nIf the project HAS a test framework, you must write tests and they\nmust pass. Empty `test_files_changed` with `test_status: pass` is a\nlie and eval will reject it via the schema's required-fields\ncontract; `not-run` and `fail` will retry once with reflection and\nthen halt for human attention.\n\nIf the fix turns out to be more complex than expected, stop and\nexplain why — do not force a bad fix.","skills":["github"],"output":{"type":"object","properties":{"branch":{"type":"string","description":"Feature branch name (must include the issue identifier)."},"commit_sha":{"type":"string","description":"SHA of the commit shipping the fix and tests."},"files_changed":{"type":"array","description":"Source files modified by the fix (NOT including test files).","items":{"type":"string"}},"test_files_changed":{"type":"array","description":"Test files added or modified to cover the fix. Should be\nnon-empty whenever `test_status: pass`. May be empty when\n`test_status: no-framework` (genuinely no test runner in\nthe repo).\n","items":{"type":"string"}},"test_command":{"type":"string","description":"Exact command run to validate (e.g. `npx nx test server`)."},"test_status":{"type":"string","enum":["pass","fail","no-framework","not-run","skipped"],"description":"Outcome of running the test suite.\n- `pass` — tests ran end-to-end and every assertion passed.\n `test_files_changed` MUST be non-empty in this case (you\n changed code; tests must cover it).\n- `no-framework` — the repo genuinely has no test runner\n (e.g. a docs-only repo, a YAML-config repo, a research\n notebook collection). Reserved for cases where there is\n no test infrastructure to extend — not a permission slip\n to skip tests in a normal codebase.\n- `fail` — tests ran and at least one failed. Do not push\n a broken fix. Return this status, retry will fire once.\n- `not-run` — tests exist but the environment couldn't\n execute them. Explain in `test_notes`. Retry will fire.\n- `skipped` — the node short-circuited before running tests\n because a precondition was not met. Today the only\n accepted reason is `open-pr-exists` (Step 0b found an\n open PR with the issue identifier in its title).\n `skipped_reason` and `existing_pr_url` MUST be populated.\n Downstream `create_pr` is bypassed and the workflow\n routes to `notify`.\n"},"skipped_reason":{"type":"string","description":"Why the node short-circuited. Only set when `test_status` is\n`skipped`. Today the only valid value is `open-pr-exists`.\n","enum":["open-pr-exists"]},"existing_pr_url":{"type":"string","description":"URL of the open PR that triggered the skip. Set when\n`skipped_reason` is `open-pr-exists` so the notify node\ncan link to the prior PR.\n","pattern":"^https?://"},"test_notes":{"type":"string","description":"One-paragraph explanation of which tests were added and what they assert."}},"required":["branch","commit_sha","files_changed","test_files_changed","test_status"]},"eval":[{"name":"test_status_is_pass_or_no_framework","kind":"value","rule":{"output_matches":[{"path":"test_status","in":["pass","no-framework","skipped"]}]}},{"name":"tests_present_when_pass_claimed","kind":"judge","rubric":"You are checking the contract between test_status and test_files_changed.\n\nRead result.data.test_status and result.data.test_files_changed.\n\nRules:\n- If test_status is \"pass\": test_files_changed MUST contain at least\n one real test file path (paths with extensions like .spec.ts,\n .test.ts, .test.tsx, .test.py, _test.go, _spec.rb, .test.rs,\n etc., or paths in a __tests__/, tests/, or spec/ directory).\n An empty array, or paths that are clearly not test files\n (README.md, package.json, source files), is a contract violation.\n- If test_status is \"no-framework\": test_files_changed MAY be empty.\n Pass vacuously.\n- If test_status is \"skipped\": test_files_changed MAY be empty.\n The node short-circuited before writing any code (Step 0b\n found an open PR for the issue). `skipped_reason` and\n `existing_pr_url` MUST be populated; if they are missing,\n return no.\n- If test_status is \"fail\" or \"not-run\": this evaluator should not\n run (the prior value evaluator catches that). Pass vacuously.\n\nRespond yes if the contract is satisfied, no if it is violated.\n","pass_when":"yes"}],"retry":{"max":1,"instruction":{"auto":true}}},"create_pr":{"name":"Open Pull Request","instruction":"Open a pull request for the fix. The issue identifier and URL are in your context from the create_issue step.\n\n1. Push the branch to the remote.\n2. Create the PR using the `github_create_pr` tool. **Use this exact tool name** — do NOT use `create_pull_request` or any other variant from ToolSearch. The eval check requires `github_create_pr` specifically.\n3. PR title MUST start with the issue identifier in brackets. Format: `[{context.create_issue.issueIdentifier}] fix: {short description}`.\n4. In the PR body, include: summary of the bug, what the fix does, and a link to the issue (context.create_issue.issueUrl). If the investigation found a Sentry issue, include \"Fixes {sentryShortId}\" in the PR body so Sentry auto-resolves on merge.\n5. Add appropriate labels if available.\n\nIf context.prTemplate is provided, use it as the format for the PR body. Otherwise use a clear structure with: Summary, Changes, Testing, and Related Issues.\n\nIf context.cloudRunId is provided, append this HTML comment to the end of the PR body: <!-- sweny:run_id:{context.cloudRunId} -->\n\nReturn the PR URL and number.","skills":["github"],"eval":[{"name":"pr_was_created","kind":"function","rule":{"any_tool_called":["github_create_pr"]}}],"retry":{"max":1,"instruction":{"auto":true}}},"notify":{"name":"Notify Team","instruction":"Send a notification summarizing the triage result:\n\n1. Include: alert summary, severity, root cause (1-2 sentences), and links to any created issues or PRs.\n2. For critical/high severity, make the notification urgent.\n3. For medium/low, a standard notification is fine.\n\nUse whichever notification channel is available to you.","skills":["slack","notification"]}},"edges":[{"from":"gather","to":"investigate"},{"from":"investigate","to":"create_issue","when":"novel_count is greater than 0 AND highest_severity is medium or higher"},{"from":"investigate","to":"skip","when":"novel_count is 0, OR highest_severity is low"},{"from":"create_issue","to":"implement","when":"at least one novel finding has fix_complexity simple or moderate AND fix_approach is provided"},{"from":"create_issue","to":"notify","when":"all novel findings have fix_complexity complex, OR no clear fix_approach"},{"from":"skip","to":"notify"},{"from":"implement","to":"notify","when":"implement.test_status is \"skipped\" (Step 0b found an open PR already exists for the issue identifier)"},{"from":"implement","to":"create_pr","when":"implement.test_status is \"pass\" or \"no-framework\" (no existing PR; fix was implemented or there is no test framework)"},{"from":"create_pr","to":"notify"}],"skills":{}};
2
+ export const implementWorkflow = {"id":"implement","name":"Implement Fix","workflow_type":"pr_review","description":"Implement a code fix for an issue and open a pull request","entry":"analyze","nodes":{"analyze":{"name":"Analyze Issue","instruction":"Read the issue details and understand what needs to be fixed:\n\n1. Fetch the issue from the tracker (GitHub or Linear).\n2. **Check for an existing open PR for this issue.** Call\n `github_search_issues` (GitHub's `/search/issues` endpoint returns\n both issues and PRs) with `type:pr state:open` and the issue\n identifier wrapped in brackets — e.g. query\n `[OFF-1234] in:title type:pr state:open`. If the response contains\n one or more PRs whose title starts with `[<issueIdentifier>]`,\n STOP analysis. Output `existing_pr_url`\n (the URL of the open PR) and a short `issue_summary` noting the\n skip; leave `fix_plan` set to a one-line \"Skip: open PR\n <url> already exists\". The workflow will route to `notify` and\n the team will be told the prior PR is still pending.\n Rationale: re-implementing while an open PR exists produces a\n duplicate (or worse, the agent closes its own prior PR mid-run\n and opens a new one — exactly the thrash seen in\n `letsoffload/permit-service` PR #81 → #82 and\n `letsoffload/offload` PR #572 → #573).\n3. Read the relevant source files to understand the current code.\n4. Identify the exact files and lines that need to change.\n5. Plan the fix approach.\n\nOutput a clear analysis of what needs to change and why.","skills":["github","linear"],"output":{"type":"object","properties":{"issue_summary":{"type":"string"},"files_to_change":{"type":"array","items":{"type":"string"}},"fix_plan":{"type":"string"},"risk_level":{"type":"string","enum":["low","medium","high"]},"existing_pr_url":{"type":"string","description":"URL of an open PR found in step 2 whose title starts with the\nissue identifier in brackets. Set ONLY when a prior PR exists\nand the analyze node is short-circuiting. Triggers the\n`analyze → notify` edge.\n","pattern":"^https?://"}},"required":["issue_summary","fix_plan"]}},"implement":{"name":"Implement Fix","instruction":"Implement the planned fix:\n\n1. Create a feature branch.\n2. Make the necessary code changes.\n3. Ensure changes are minimal and focused — fix the bug, nothing more.\n4. Stage and commit with a clear commit message referencing the issue.\n\nIf the fix is too risky or complex, explain why and skip.","skills":["github"]},"create_pr":{"name":"Open Pull Request","instruction":"Open a pull request for the fix:\n\n1. Push the branch to the remote.\n2. Create a PR with a clear title and description.\n3. Reference the original issue in the PR body.\n4. Add appropriate reviewers or labels if possible.\n\nIf context.prTemplate is provided, use it as the format for the PR body. Otherwise use a clear structure with: Summary, Changes, Testing, and Related Issues.\n\nIf context.cloudRunId is provided, append this HTML comment to the end of the PR body: <!-- sweny:run_id:{context.cloudRunId} -->\n\nReturn the PR URL.","skills":["github"]},"notify":{"name":"Notify","instruction":"Send a notification about the implementation result:\n\n1. Include: issue reference, PR link, brief summary of changes.\n2. Keep it concise — one message, not a wall of text.","skills":["slack","notification"]},"skip":{"name":"Skip — Too Complex","instruction":"The fix was determined to be too complex or risky for automated implementation.\nAdd a comment to the issue explaining what was found and why it needs manual attention.","skills":["github","linear"]}},"edges":[{"from":"analyze","to":"notify","when":"analyze.existing_pr_url is set (an open PR for this issue already exists; skip the implement and create_pr nodes)"},{"from":"analyze","to":"implement","when":"Fix risk level is low or medium and a clear plan exists"},{"from":"analyze","to":"skip","when":"Fix is too complex, risky, or unclear"},{"from":"implement","to":"create_pr"},{"from":"skip","to":"notify"},{"from":"create_pr","to":"notify"}],"skills":{}};
3
+ export const seedContentWorkflow = {"id":"seed-content","name":"Content Generation Pipeline","workflow_type":"content_generation","description":"Audit content gaps, generate educational content, validate quality, and publish to Supabase","entry":"audit","nodes":{"audit":{"name":"Audit Content Gaps","instruction":"You are auditing an educational platform's content library.\n\nUse supabase_count to check how many items exist for each combination of:\n- Content types: worksheets, games, lessons\n- Subjects: math, science, english, history, geography, economics, music, art, life-skills, foreign-language\n- Difficulties: beginner, easy, medium, hard\n\nAlso check the input parameters — if the user specified a subject, topic, difficulty, and count,\nskip the full audit and just report what they want generated.\n\nReturn a structured content plan: what to generate, how many, at what difficulty and grade level.\nPrioritize gaps where a subject has ZERO content of a given type.","skills":["supabase"],"output":{"type":"object","properties":{"plan":{"type":"array","items":{"type":"object","properties":{"contentType":{"type":"string"},"subject":{"type":"string"},"topic":{"type":"string"},"difficulty":{"type":"string"},"gradeLevel":{"type":"string"},"count":{"type":"number"},"reason":{"type":"string"}},"required":["contentType","subject","topic","difficulty","count"]}},"summary":{"type":"string"}},"required":["plan","summary"]}},"generate":{"name":"Generate Content","instruction":"You are an expert educator creating content for children ages 5-14.\n\nFor each item in the content plan from the previous step, generate the content directly.\nThe content must be high-quality, engaging, age-appropriate, and educationally sound.\n\n**For worksheets:**\nGenerate a JSON array of problems. Each problem needs: \"problem\", \"answer\", \"format\" (answer-line | multiple-choice | write-on).\nRequirements:\n- Every answer MUST be correct. Double-check math, facts, spelling.\n- Problems must progress in difficulty within the set.\n- Use at least 2 different problem structures (e.g., equations + word problems for math).\n- Vary phrasing — no two problems should feel copy-pasted.\n- For the target grade level, use appropriate vocabulary and complexity.\n\n**For lessons:**\nGenerate complete HTML + CSS + JS for an interactive lesson.\nRequirements:\n- Must have substantial educational content (not just a shell with navigation).\n- Include interactive elements: quizzes, click-to-reveal, drag-and-drop.\n- Must be visually engaging with colors, icons, and clear typography.\n- Include practice exercises with immediate feedback.\n- Minimum 5 teaching sections plus 2 practice activities.\n\n**For games:**\nGenerate a description and educational requirements only (actual game code is generated\nby the platform's Phaser-based generator).\n\nUse supabase_insert to write each piece of content to the appropriate table.\n\nTable schemas:\n- worksheets: id (auto), title, subject, topic, difficulty, grade_level, num_questions, problems (jsonb), status='published', source='ai-seed'\n- lessons: id (auto), title, subject, difficulty, grade_level, lesson_type, html_code, css_code, js_code, status='published', source='ai-seed'\n- games: id (auto), title, subject, difficulty, grade_level, game_type, html_code, css_code, js_code, status='published', source='ai-seed'\n\nReturn a summary of what was generated and inserted.","skills":["supabase"],"output":{"type":"object","properties":{"generated":{"type":"array","items":{"type":"object","properties":{"contentType":{"type":"string"},"title":{"type":"string"},"subject":{"type":"string"},"id":{"type":"string"},"status":{"type":"string"}}}},"totalGenerated":{"type":"number"},"errors":{"type":"array","items":{"type":"string"}}},"required":["generated","totalGenerated"]}},"validate":{"name":"Validate Content Quality","instruction":"Review the content that was just generated and inserted.\n\nFor each item, use supabase_query to fetch it back and verify:\n\n**Worksheets:**\n- Parse the problems JSON — are all answers correct?\n- Are problems diverse (not repetitive)?\n- Is difficulty appropriate for the stated grade level?\n- Are there the right number of questions?\n\n**Lessons:**\n- Is the HTML substantial (> 500 characters)?\n- Does it contain actual educational content (not just navigation shells)?\n- Are there interactive elements?\n\n**Games:**\n- Does the HTML contain a working game structure?\n\nFlag any issues. If critical issues are found (wrong answers, empty content),\nuse supabase_update to set status='draft' and add a note.\n\nReturn a quality report.","skills":["supabase"],"output":{"type":"object","properties":{"totalReviewed":{"type":"number"},"passed":{"type":"number"},"flagged":{"type":"number"},"issues":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"title":{"type":"string"},"issue":{"type":"string"},"severity":{"type":"string"},"action":{"type":"string"}}}},"summary":{"type":"string"}},"required":["totalReviewed","passed","flagged","summary"]}},"report":{"name":"Generate Report","instruction":"Compile a final report of the content generation run.\n\nInclude:\n- Total content generated by type and subject\n- Quality validation results\n- Any issues found and actions taken\n- Recommendations for next content generation run (what gaps remain)\n\nFormat as a clean markdown summary that a solo operator can scan in 30 seconds.","skills":[],"output":{"type":"object","properties":{"markdown":{"type":"string"},"nextPriorities":{"type":"array","items":{"type":"string"}}},"required":["markdown"]}}},"edges":[{"from":"audit","to":"generate"},{"from":"generate","to":"validate"},{"from":"validate","to":"report"}],"skills":{}};
@@ -1,5 +1,6 @@
1
1
  id: implement
2
2
  name: Implement Fix
3
+ workflow_type: pr_review
3
4
  description: Implement a code fix for an issue and open a pull request
4
5
  entry: analyze
5
6
  nodes:
@@ -9,9 +10,25 @@ nodes:
9
10
  Read the issue details and understand what needs to be fixed:
10
11
 
11
12
  1. Fetch the issue from the tracker (GitHub or Linear).
12
- 2. Read the relevant source files to understand the current code.
13
- 3. Identify the exact files and lines that need to change.
14
- 4. Plan the fix approach.
13
+ 2. **Check for an existing open PR for this issue.** Call
14
+ `github_search_issues` (GitHub's `/search/issues` endpoint returns
15
+ both issues and PRs) with `type:pr state:open` and the issue
16
+ identifier wrapped in brackets — e.g. query
17
+ `[OFF-1234] in:title type:pr state:open`. If the response contains
18
+ one or more PRs whose title starts with `[<issueIdentifier>]`,
19
+ STOP analysis. Output `existing_pr_url`
20
+ (the URL of the open PR) and a short `issue_summary` noting the
21
+ skip; leave `fix_plan` set to a one-line "Skip: open PR
22
+ <url> already exists". The workflow will route to `notify` and
23
+ the team will be told the prior PR is still pending.
24
+ Rationale: re-implementing while an open PR exists produces a
25
+ duplicate (or worse, the agent closes its own prior PR mid-run
26
+ and opens a new one — exactly the thrash seen in
27
+ `letsoffload/permit-service` PR #81 → #82 and
28
+ `letsoffload/offload` PR #572 → #573).
29
+ 3. Read the relevant source files to understand the current code.
30
+ 4. Identify the exact files and lines that need to change.
31
+ 5. Plan the fix approach.
15
32
 
16
33
  Output a clear analysis of what needs to change and why.
17
34
  skills:
@@ -34,6 +51,14 @@ nodes:
34
51
  - low
35
52
  - medium
36
53
  - high
54
+ existing_pr_url:
55
+ type: string
56
+ description: |
57
+ URL of an open PR found in step 2 whose title starts with the
58
+ issue identifier in brackets. Set ONLY when a prior PR exists
59
+ and the analyze node is short-circuiting. Triggers the
60
+ `analyze → notify` edge.
61
+ pattern: "^https?://"
37
62
  required:
38
63
  - issue_summary
39
64
  - fix_plan
@@ -95,6 +120,9 @@ nodes:
95
120
  - github
96
121
  - linear
97
122
  edges:
123
+ - from: analyze
124
+ to: notify
125
+ when: analyze.existing_pr_url is set (an open PR for this issue already exists; skip the implement and create_pr nodes)
98
126
  - from: analyze
99
127
  to: implement
100
128
  when: Fix risk level is low or medium and a clear plan exists
@@ -1,5 +1,6 @@
1
1
  id: seed-content
2
2
  name: Content Generation Pipeline
3
+ workflow_type: content_generation
3
4
  description: Audit content gaps, generate educational content, validate quality, and publish to Supabase
4
5
  entry: audit
5
6
  nodes:
@@ -1,5 +1,6 @@
1
1
  id: triage
2
2
  name: Alert Triage
3
+ workflow_type: pr_review
3
4
  description: Investigate a production alert, determine root cause, create an issue, implement a fix, and notify the team
4
5
  entry: gather
5
6
  nodes:
@@ -360,13 +361,45 @@ nodes:
360
361
 
361
362
  ## Steps
362
363
 
363
- 0. **Precondition — verify context.create_issue.issueIdentifier is well-formed.**
364
+ 0a. **Precondition — verify context.create_issue.issueIdentifier is well-formed.**
364
365
  The identifier MUST match `^[A-Z][A-Z0-9]*-\d+$` (e.g. `OFF-1234`). If it
365
366
  is missing, empty, or matches a Sentry short-ID / slug / any other shape,
366
367
  STOP immediately — do NOT invent a branch name from the Sentry event,
367
368
  commit SHA, or alert payload. Return a failed status with a clear message
368
369
  instead; the workflow will route to notify and a human will pick it up.
369
370
 
371
+ 0b. **Precondition — check for an existing open PR for this issue.** Call
372
+ `github_search_issues` (which hits GitHub's `/search/issues` endpoint
373
+ and returns both issues and PRs) with `type:pr state:open` and the
374
+ issue identifier wrapped in brackets — e.g. query
375
+ `[OFF-1234] in:title type:pr state:open` and pass `repo` for scoping.
376
+ If the response contains one or more PRs whose title starts with
377
+ `[<issueIdentifier>]`, STOP. Do NOT create a branch, commit, or any
378
+ new PR. Output:
379
+
380
+ ```
381
+ test_status: "skipped"
382
+ skipped_reason: "open-pr-exists"
383
+ existing_pr_url: "<url of the existing PR>"
384
+ test_files_changed: []
385
+ files_changed: []
386
+ branch: ""
387
+ commit_sha: ""
388
+ ```
389
+
390
+ The downstream `create_pr` node will be bypassed and the workflow will
391
+ route to `notify` so the team is told the prior PR is still pending.
392
+
393
+ Rationale: re-implementing a fix while an open PR already exists
394
+ produces a duplicate (or worse, the agent closes its own prior PR
395
+ mid-run and opens a new one with a different approach — exactly the
396
+ thrash observed in `letsoffload/permit-service` PR #81 → #82 and
397
+ `letsoffload/offload` PR #572 → #573, where two PRs for the same
398
+ identifier were opened-and-closed 12 to 16 seconds apart inside a
399
+ single workflow run).
400
+
401
+ Only proceed to Step 1 if no open PR exists for the identifier.
402
+
370
403
  1. Create a feature branch from the base branch (check context for
371
404
  baseBranch, default `main`). **Branch name MUST include the issue
372
405
  identifier** exactly as provided in `context.create_issue.issueIdentifier`.
@@ -459,7 +492,7 @@ nodes:
459
492
  description: Exact command run to validate (e.g. `npx nx test server`).
460
493
  test_status:
461
494
  type: string
462
- enum: [pass, fail, no-framework, not-run]
495
+ enum: [pass, fail, no-framework, not-run, skipped]
463
496
  description: |
464
497
  Outcome of running the test suite.
465
498
  - `pass` — tests ran end-to-end and every assertion passed.
@@ -474,6 +507,26 @@ nodes:
474
507
  a broken fix. Return this status, retry will fire once.
475
508
  - `not-run` — tests exist but the environment couldn't
476
509
  execute them. Explain in `test_notes`. Retry will fire.
510
+ - `skipped` — the node short-circuited before running tests
511
+ because a precondition was not met. Today the only
512
+ accepted reason is `open-pr-exists` (Step 0b found an
513
+ open PR with the issue identifier in its title).
514
+ `skipped_reason` and `existing_pr_url` MUST be populated.
515
+ Downstream `create_pr` is bypassed and the workflow
516
+ routes to `notify`.
517
+ skipped_reason:
518
+ type: string
519
+ description: |
520
+ Why the node short-circuited. Only set when `test_status` is
521
+ `skipped`. Today the only valid value is `open-pr-exists`.
522
+ enum: [open-pr-exists]
523
+ existing_pr_url:
524
+ type: string
525
+ description: |
526
+ URL of the open PR that triggered the skip. Set when
527
+ `skipped_reason` is `open-pr-exists` so the notify node
528
+ can link to the prior PR.
529
+ pattern: "^https?://"
477
530
  test_notes:
478
531
  type: string
479
532
  description: One-paragraph explanation of which tests were added and what they assert.
@@ -484,14 +537,18 @@ nodes:
484
537
  - test_files_changed
485
538
  - test_status
486
539
  eval:
487
- # `pass` and `no-framework` both clear the gate. `fail` and `not-run`
488
- # trigger retry.
540
+ # `pass`, `no-framework`, and `skipped` clear the gate. `fail` and
541
+ # `not-run` trigger retry. `skipped` is the Step 0b short-circuit
542
+ # (open PR already exists for the issue identifier) — see the
543
+ # implement instruction for the driving incidents
544
+ # (letsoffload/permit-service #81 → #82,
545
+ # letsoffload/offload #572 → #573).
489
546
  - name: test_status_is_pass_or_no_framework
490
547
  kind: value
491
548
  rule:
492
549
  output_matches:
493
550
  - path: test_status
494
- in: [pass, no-framework]
551
+ in: [pass, no-framework, skipped]
495
552
  # Conditional: when test_status is pass, test_files_changed MUST contain
496
553
  # at least one real test file path. Empty array with status pass is a
497
554
  # contract violation (the agent claimed it ran tests without actually
@@ -517,6 +574,11 @@ nodes:
517
574
  (README.md, package.json, source files), is a contract violation.
518
575
  - If test_status is "no-framework": test_files_changed MAY be empty.
519
576
  Pass vacuously.
577
+ - If test_status is "skipped": test_files_changed MAY be empty.
578
+ The node short-circuited before writing any code (Step 0b
579
+ found an open PR for the issue). `skipped_reason` and
580
+ `existing_pr_url` MUST be populated; if they are missing,
581
+ return no.
520
582
  - If test_status is "fail" or "not-run": this evaluator should not
521
583
  run (the prior value evaluator catches that). Pass vacuously.
522
584
 
@@ -599,7 +661,11 @@ edges:
599
661
  when: all novel findings have fix_complexity complex, OR no clear fix_approach
600
662
  - from: skip
601
663
  to: notify
664
+ - from: implement
665
+ to: notify
666
+ when: implement.test_status is "skipped" (Step 0b found an open PR already exists for the issue identifier)
602
667
  - from: implement
603
668
  to: create_pr
669
+ when: implement.test_status is "pass" or "no-framework" (no existing PR; fix was implemented or there is no test framework)
604
670
  - from: create_pr
605
671
  to: notify
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.90",
3
+ "version": "0.1.92",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"