@valescoagency/runway 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/linear.js CHANGED
@@ -1,29 +1,177 @@
1
1
  import { LinearClient } from "@linear/sdk";
2
+ import { Data, Effect, ParseResult, Redacted, Schedule, Schema, } from "effect";
3
+ // VA-360: schemas covering only the fields runway actually reads
4
+ // off the Linear SDK. Validation runs at the gateway boundary so an
5
+ // upstream API drift (renamed field, removed field, type change)
6
+ // surfaces as a typed `LinearSchemaError` rather than a `Cannot read
7
+ // property 'nodes' of undefined` deep in the call stack.
8
+ const IssueNodeSchema = Schema.Struct({
9
+ id: Schema.String,
10
+ identifier: Schema.String,
11
+ title: Schema.String,
12
+ description: Schema.NullOr(Schema.String),
13
+ });
14
+ const WorkflowStateNodeSchema = Schema.Struct({
15
+ id: Schema.String,
16
+ name: Schema.String,
17
+ });
18
+ const TeamNodeSchema = Schema.Struct({
19
+ id: Schema.String,
20
+ key: Schema.String,
21
+ });
22
+ const IssueLabelNodeSchema = Schema.Struct({
23
+ id: Schema.String,
24
+ name: Schema.String,
25
+ });
26
+ const ProjectNodeSchema = Schema.Struct({
27
+ id: Schema.String,
28
+ });
29
+ const decodeIssueNode = Schema.decodeUnknownSync(IssueNodeSchema);
30
+ const decodeWorkflowStateNode = Schema.decodeUnknownSync(WorkflowStateNodeSchema);
31
+ const decodeTeamNode = Schema.decodeUnknownSync(TeamNodeSchema);
32
+ const decodeIssueLabelNode = Schema.decodeUnknownSync(IssueLabelNodeSchema);
33
+ const decodeProjectNode = Schema.decodeUnknownSync(ProjectNodeSchema);
34
+ export class LinearNotFound extends Data.TaggedError("LinearNotFound") {
35
+ }
36
+ export class LinearUnauthorized extends Data.TaggedError("LinearUnauthorized") {
37
+ }
38
+ export class LinearRateLimited extends Data.TaggedError("LinearRateLimited") {
39
+ }
40
+ export class LinearNetworkError extends Data.TaggedError("LinearNetworkError") {
41
+ }
42
+ // VA-357: a stalled HTTP request becomes a typed timeout (rather than
43
+ // a stuck process). Retryable just like NetworkError — the next attempt
44
+ // may succeed.
45
+ export class LinearTimeout extends Data.TaggedError("LinearTimeout") {
46
+ }
47
+ // VA-360: SDK response failed schema validation — Linear's GraphQL
48
+ // schema drifted, an expected field went missing, or a type changed.
49
+ // NOT retryable: re-running the same call will produce the same
50
+ // drift. The operator needs to update runway's schemas (or pin the
51
+ // SDK version) before this gets better.
52
+ export class LinearSchemaError extends Data.TaggedError("LinearSchemaError") {
53
+ }
54
+ // VA-357: which typed errors a retry policy will re-attempt. Hard
55
+ // failures (NotFound, Unauthorized) bypass the retry loop and surface
56
+ // immediately — they are operator-fix problems, not transient blips.
57
+ const RETRYABLE_LINEAR_TAGS = new Set([
58
+ "LinearNetworkError",
59
+ "LinearRateLimited",
60
+ "LinearTimeout",
61
+ ]);
62
+ const isRetryableLinearError = (err) => RETRYABLE_LINEAR_TAGS.has(err._tag);
63
+ /**
64
+ * VA-357: timeout + jittered-exponential retry policy for one Linear
65
+ * call. 5 retries from a 1s base ⇒ delays jittered around 1/2/4/8/16s
66
+ * ⇒ ~31s worst-case wall time before bubbling out. `while` gates the
67
+ * retry on the typed error tag so an `Unauthorized` or `NotFound` is
68
+ * not retried (no point: it's an operator/config problem).
69
+ *
70
+ * Exported so unit tests can apply it to a hand-built Effect and
71
+ * verify retry behavior under `TestClock`.
72
+ */
73
+ export const linearRetrySchedule = Schedule.exponential("1 second").pipe(Schedule.compose(Schedule.recurs(5)), Schedule.jittered);
74
+ export const applyLinearPolicy = (effect, opts) => {
75
+ const timeoutMs = opts.timeoutMs ?? 30_000;
76
+ return effect.pipe(Effect.timeoutFail({
77
+ duration: `${timeoutMs} millis`,
78
+ onTimeout: () => new LinearTimeout({
79
+ call: opts.call,
80
+ afterMs: timeoutMs,
81
+ message: `Linear ${opts.call} timed out after ${timeoutMs}ms`,
82
+ }),
83
+ }), Effect.retry({
84
+ schedule: linearRetrySchedule,
85
+ while: isRetryableLinearError,
86
+ }));
87
+ };
88
+ /**
89
+ * Best-effort classification of an unknown thrown value into the
90
+ * gateway's typed error ADT. `LinearNotFound`s we threw ourselves
91
+ * (from the find* helpers) pass through unchanged; SDK / network
92
+ * exceptions are bucketed by message-shape. Step 2 (VA-357) is where
93
+ * the retry policy actually leans on these tags — for now, the value
94
+ * is mostly that the orchestrator's `catch (err: unknown)` becomes
95
+ * `catch (err: LinearError)`.
96
+ */
97
+ function classifyLinearError(err, context) {
98
+ if (err instanceof LinearNotFound ||
99
+ err instanceof LinearUnauthorized ||
100
+ err instanceof LinearRateLimited ||
101
+ err instanceof LinearNetworkError ||
102
+ err instanceof LinearTimeout ||
103
+ err instanceof LinearSchemaError) {
104
+ return err;
105
+ }
106
+ // VA-360: a schema validation throw (Linear API drift) should
107
+ // surface as `LinearSchemaError`, not get bucketed as a transient
108
+ // NetworkError — retrying won't fix a drifted field.
109
+ if (ParseResult.isParseError(err)) {
110
+ return new LinearSchemaError({
111
+ call: context,
112
+ message: `${context}: SDK response failed schema validation — ${err.message}`,
113
+ });
114
+ }
115
+ const msg = err instanceof Error ? err.message : String(err);
116
+ if (/\b(401|403)\b|unauthorized|invalid api key|forbidden/i.test(msg)) {
117
+ return new LinearUnauthorized({ message: msg });
118
+ }
119
+ if (/\b429\b|rate.?limit/i.test(msg)) {
120
+ return new LinearRateLimited({ message: msg });
121
+ }
122
+ return new LinearNetworkError({
123
+ cause: err,
124
+ message: `${context}: ${msg}`,
125
+ });
126
+ }
2
127
  /**
3
128
  * Concrete @linear/sdk-backed implementation. Tests inject their own
4
129
  * gateway; never reach for the SDK directly outside this file.
130
+ *
131
+ * The internal `find*` helpers stay plain `async` and throw typed
132
+ * `LinearNotFound` directly. Only the public `LinearGateway` methods
133
+ * wrap into `Effect.tryPromise` — that's the boundary the orchestrator
134
+ * sees.
135
+ *
136
+ * VA-358: optional `limiter` wraps every method's Effect so the queue
137
+ * drain respects Linear's API rate limit (~1500 req/hr). When `null`
138
+ * (the default — tests don't need it), calls go through unrestricted.
139
+ * Production: `commands/run.ts` builds a limiter inside `Effect.scoped`
140
+ * and passes it here.
5
141
  */
6
- export function createLinearGateway(config) {
7
- const client = new LinearClient({ apiKey: config.linearApiKey });
142
+ export function createLinearGateway(config, limiter = null) {
143
+ const client = new LinearClient({ apiKey: Redacted.value(config.linearApiKey) });
144
+ const gate = (eff) => (limiter ? limiter(eff) : eff);
8
145
  async function findStateId(teamId, name) {
9
146
  const states = await client.workflowStates({
10
147
  filter: { team: { id: { eq: teamId } }, name: { eq: name } },
11
148
  });
12
- const state = states.nodes[0];
13
- if (!state) {
14
- throw new Error(`Linear workflow state "${name}" not found on team ${teamId}`);
149
+ const rawState = states.nodes[0];
150
+ if (!rawState) {
151
+ throw new LinearNotFound({
152
+ resource: "state",
153
+ identifier: name,
154
+ message: `Linear workflow state "${name}" not found on team ${teamId}`,
155
+ });
15
156
  }
16
- return state.id;
157
+ // VA-360: validate the SDK shape. `decodeWorkflowStateNode` throws
158
+ // `ParseError` on drift, which `classifyLinearError` maps to
159
+ // `LinearSchemaError` (non-retryable).
160
+ return decodeWorkflowStateNode(rawState).id;
17
161
  }
18
162
  async function findTeamId() {
19
163
  const teams = await client.teams({
20
164
  filter: { key: { eq: config.linearTeam } },
21
165
  });
22
- const team = teams.nodes[0];
23
- if (!team) {
24
- throw new Error(`Linear team "${config.linearTeam}" not found`);
166
+ const rawTeam = teams.nodes[0];
167
+ if (!rawTeam) {
168
+ throw new LinearNotFound({
169
+ resource: "team",
170
+ identifier: config.linearTeam,
171
+ message: `Linear team "${config.linearTeam}" not found`,
172
+ });
25
173
  }
26
- return team.id;
174
+ return decodeTeamNode(rawTeam).id;
27
175
  }
28
176
  /**
29
177
  * Resolve a project identifier (UUID, slug, or name) to its Linear
@@ -40,72 +188,115 @@ export function createLinearGateway(config) {
40
188
  ],
41
189
  },
42
190
  });
43
- const project = projects.nodes[0];
44
- if (!project) {
45
- throw new Error(`Linear project "${identifier}" not found`);
191
+ const rawProject = projects.nodes[0];
192
+ if (!rawProject) {
193
+ throw new LinearNotFound({
194
+ resource: "project",
195
+ identifier,
196
+ message: `Linear project "${identifier}" not found`,
197
+ });
46
198
  }
47
- return project.id;
199
+ return decodeProjectNode(rawProject).id;
48
200
  }
49
201
  return {
50
- async fetchReady() {
51
- const teamId = await findTeamId();
52
- const readyStateId = await findStateId(teamId, config.readyStatus);
53
- const projectId = config.linearProject
54
- ? await findProjectId(config.linearProject)
55
- : null;
56
- const issues = await client.issues({
57
- filter: {
58
- team: { id: { eq: teamId } },
59
- state: { id: { eq: readyStateId } },
60
- ...(projectId ? { project: { id: { eq: projectId } } } : {}),
202
+ fetchReady() {
203
+ return gate(applyLinearPolicy(Effect.tryPromise({
204
+ try: async () => {
205
+ const teamId = await findTeamId();
206
+ const readyStateId = await findStateId(teamId, config.readyStatus);
207
+ const projectId = config.linearProject
208
+ ? await findProjectId(config.linearProject)
209
+ : null;
210
+ const issues = await client.issues({
211
+ filter: {
212
+ team: { id: { eq: teamId } },
213
+ state: { id: { eq: readyStateId } },
214
+ ...(projectId ? { project: { id: { eq: projectId } } } : {}),
215
+ },
216
+ // Stable order: oldest first so the queue drains FIFO.
217
+ orderBy: "createdAt",
218
+ });
219
+ // VA-360: validate every issue node through the schema —
220
+ // a single drifted issue surfaces as `LinearSchemaError`
221
+ // instead of a downstream `cannot read property X`.
222
+ return issues.nodes.map((raw) => {
223
+ const i = decodeIssueNode(raw);
224
+ return {
225
+ id: i.id,
226
+ identifier: i.identifier,
227
+ title: i.title,
228
+ description: i.description ?? "",
229
+ };
230
+ });
61
231
  },
62
- // Stable order: oldest first so the queue drains FIFO.
63
- orderBy: "createdAt",
64
- });
65
- return issues.nodes.map((i) => ({
66
- id: i.id,
67
- identifier: i.identifier,
68
- title: i.title,
69
- description: i.description ?? "",
70
- }));
232
+ catch: (err) => classifyLinearError(err, "fetchReady"),
233
+ }), { call: "fetchReady" }));
71
234
  },
72
- async transition(issueId, statusName) {
73
- const issue = await client.issue(issueId);
74
- const team = await issue.team;
75
- if (!team)
76
- throw new Error(`Issue ${issueId} has no team`);
77
- const stateId = await findStateId(team.id, statusName);
78
- await client.updateIssue(issueId, { stateId });
235
+ transition(issueId, statusName) {
236
+ return gate(applyLinearPolicy(Effect.tryPromise({
237
+ try: async () => {
238
+ const issue = await client.issue(issueId);
239
+ const team = await issue.team;
240
+ if (!team) {
241
+ throw new LinearNotFound({
242
+ resource: "issue",
243
+ identifier: issueId,
244
+ message: `Issue ${issueId} has no team`,
245
+ });
246
+ }
247
+ const stateId = await findStateId(team.id, statusName);
248
+ await client.updateIssue(issueId, { stateId });
249
+ },
250
+ catch: (err) => classifyLinearError(err, `transition(${statusName})`),
251
+ }), { call: `transition(${statusName})` }));
79
252
  },
80
- async applyLabel(issueId, labelName) {
81
- const issue = await client.issue(issueId);
82
- const team = await issue.team;
83
- if (!team)
84
- throw new Error(`Issue ${issueId} has no team`);
85
- const labels = await client.issueLabels({
86
- filter: {
87
- team: { id: { eq: team.id } },
88
- name: { eq: labelName },
253
+ applyLabel(issueId, labelName) {
254
+ return gate(applyLinearPolicy(Effect.tryPromise({
255
+ try: async () => {
256
+ const issue = await client.issue(issueId);
257
+ const team = await issue.team;
258
+ if (!team) {
259
+ throw new LinearNotFound({
260
+ resource: "issue",
261
+ identifier: issueId,
262
+ message: `Issue ${issueId} has no team`,
263
+ });
264
+ }
265
+ const labels = await client.issueLabels({
266
+ filter: {
267
+ team: { id: { eq: team.id } },
268
+ name: { eq: labelName },
269
+ },
270
+ });
271
+ const rawLabel = labels.nodes[0];
272
+ if (!rawLabel) {
273
+ throw new LinearNotFound({
274
+ resource: "label",
275
+ identifier: labelName,
276
+ message: `Linear label "${labelName}" not found on team ${team.id}`,
277
+ });
278
+ }
279
+ const label = decodeIssueLabelNode(rawLabel);
280
+ const existing = await issue.labels();
281
+ const existingIds = existing.nodes.map((l) => decodeIssueLabelNode(l).id);
282
+ const labelIds = [...existingIds, label.id];
283
+ await client.updateIssue(issueId, { labelIds });
89
284
  },
90
- });
91
- const label = labels.nodes[0];
92
- if (!label) {
93
- throw new Error(`Linear label "${labelName}" not found on team ${team.id}`);
94
- }
95
- const existing = await issue.labels();
96
- const labelIds = [
97
- ...existing.nodes.map((l) => l.id),
98
- label.id,
99
- ];
100
- await client.updateIssue(issueId, { labelIds });
285
+ catch: (err) => classifyLinearError(err, `applyLabel(${labelName})`),
286
+ }), { call: `applyLabel(${labelName})` }));
101
287
  },
102
- async comment(issueId, body) {
103
- await client.createComment({ issueId, body });
288
+ comment(issueId, body) {
289
+ return gate(applyLinearPolicy(Effect.tryPromise({
290
+ try: async () => {
291
+ await client.createComment({ issueId, body });
292
+ },
293
+ catch: (err) => classifyLinearError(err, "comment"),
294
+ }), { call: "comment" }));
104
295
  },
105
296
  };
106
297
  }
107
298
  export async function validateLinearConfig(config) {
108
- const client = new LinearClient({ apiKey: config.linearApiKey });
299
+ const client = new LinearClient({ apiKey: Redacted.value(config.linearApiKey) });
109
300
  const teams = await client.teams({
110
301
  filter: { key: { eq: config.linearTeam } },
111
302
  });