@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/README.md +87 -6
- package/dist/commands/run.js +39 -12
- package/dist/config.js +53 -67
- package/dist/git.js +43 -29
- package/dist/github.js +136 -21
- package/dist/linear.js +255 -64
- package/dist/orchestrator.js +260 -165
- package/dist/subprocess.js +40 -0
- package/dist/telemetry.js +31 -0
- package/package.json +9 -1
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
|
|
13
|
-
if (!
|
|
14
|
-
throw new
|
|
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
|
-
|
|
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
|
|
23
|
-
if (!
|
|
24
|
-
throw new
|
|
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
|
|
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
|
|
44
|
-
if (!
|
|
45
|
-
throw new
|
|
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
|
|
199
|
+
return decodeProjectNode(rawProject).id;
|
|
48
200
|
}
|
|
49
201
|
return {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
});
|