@delegance/claude-autopilot 5.5.2 → 6.2.2
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/CHANGELOG.md +935 -6
- package/README.md +55 -0
- package/dist/src/adapters/council/openai.js +12 -6
- package/dist/src/adapters/deploy/_http.d.ts +43 -0
- package/dist/src/adapters/deploy/_http.js +99 -0
- package/dist/src/adapters/deploy/fly.d.ts +206 -0
- package/dist/src/adapters/deploy/fly.js +696 -0
- package/dist/src/adapters/deploy/index.d.ts +2 -0
- package/dist/src/adapters/deploy/index.js +33 -0
- package/dist/src/adapters/deploy/render.d.ts +181 -0
- package/dist/src/adapters/deploy/render.js +550 -0
- package/dist/src/adapters/deploy/types.d.ts +67 -3
- package/dist/src/adapters/deploy/vercel.d.ts +17 -1
- package/dist/src/adapters/deploy/vercel.js +29 -49
- package/dist/src/adapters/pricing.d.ts +36 -0
- package/dist/src/adapters/pricing.js +40 -0
- package/dist/src/adapters/review-engine/codex.js +10 -7
- package/dist/src/cli/autopilot.d.ts +71 -0
- package/dist/src/cli/autopilot.js +735 -0
- package/dist/src/cli/brainstorm.d.ts +23 -0
- package/dist/src/cli/brainstorm.js +131 -0
- package/dist/src/cli/costs.d.ts +15 -1
- package/dist/src/cli/costs.js +99 -10
- package/dist/src/cli/deploy.d.ts +3 -3
- package/dist/src/cli/deploy.js +34 -9
- package/dist/src/cli/fix.d.ts +18 -0
- package/dist/src/cli/fix.js +105 -11
- package/dist/src/cli/help-text.d.ts +52 -0
- package/dist/src/cli/help-text.js +400 -0
- package/dist/src/cli/implement.d.ts +91 -0
- package/dist/src/cli/implement.js +196 -0
- package/dist/src/cli/index.js +719 -245
- package/dist/src/cli/json-envelope.d.ts +187 -0
- package/dist/src/cli/json-envelope.js +270 -0
- package/dist/src/cli/json-mode.d.ts +33 -0
- package/dist/src/cli/json-mode.js +201 -0
- package/dist/src/cli/migrate.d.ts +111 -0
- package/dist/src/cli/migrate.js +305 -0
- package/dist/src/cli/plan.d.ts +81 -0
- package/dist/src/cli/plan.js +149 -0
- package/dist/src/cli/pr.d.ts +106 -0
- package/dist/src/cli/pr.js +191 -19
- package/dist/src/cli/preflight.js +26 -0
- package/dist/src/cli/review.d.ts +27 -0
- package/dist/src/cli/review.js +126 -0
- package/dist/src/cli/runs-watch-renderer.d.ts +45 -0
- package/dist/src/cli/runs-watch-renderer.js +275 -0
- package/dist/src/cli/runs-watch.d.ts +41 -0
- package/dist/src/cli/runs-watch.js +395 -0
- package/dist/src/cli/runs.d.ts +122 -0
- package/dist/src/cli/runs.js +902 -0
- package/dist/src/cli/scan.d.ts +93 -0
- package/dist/src/cli/scan.js +166 -40
- package/dist/src/cli/spec.d.ts +66 -0
- package/dist/src/cli/spec.js +132 -0
- package/dist/src/cli/validate.d.ts +29 -0
- package/dist/src/cli/validate.js +131 -0
- package/dist/src/core/config/schema.d.ts +9 -0
- package/dist/src/core/config/schema.js +7 -0
- package/dist/src/core/config/types.d.ts +11 -0
- package/dist/src/core/council/runner.d.ts +10 -1
- package/dist/src/core/council/runner.js +25 -3
- package/dist/src/core/council/types.d.ts +7 -0
- package/dist/src/core/errors.d.ts +1 -1
- package/dist/src/core/errors.js +11 -0
- package/dist/src/core/logging/redaction.d.ts +13 -0
- package/dist/src/core/logging/redaction.js +20 -0
- package/dist/src/core/migrate/schema-validator.js +15 -1
- package/dist/src/core/phases/static-rules.d.ts +5 -1
- package/dist/src/core/phases/static-rules.js +2 -5
- package/dist/src/core/run-state/budget.d.ts +88 -0
- package/dist/src/core/run-state/budget.js +141 -0
- package/dist/src/core/run-state/cli-internal.d.ts +21 -0
- package/dist/src/core/run-state/cli-internal.js +174 -0
- package/dist/src/core/run-state/events.d.ts +59 -0
- package/dist/src/core/run-state/events.js +504 -0
- package/dist/src/core/run-state/lock.d.ts +61 -0
- package/dist/src/core/run-state/lock.js +206 -0
- package/dist/src/core/run-state/phase-context.d.ts +60 -0
- package/dist/src/core/run-state/phase-context.js +108 -0
- package/dist/src/core/run-state/phase-registry.d.ts +137 -0
- package/dist/src/core/run-state/phase-registry.js +162 -0
- package/dist/src/core/run-state/phase-runner.d.ts +80 -0
- package/dist/src/core/run-state/phase-runner.js +447 -0
- package/dist/src/core/run-state/provider-readback.d.ts +130 -0
- package/dist/src/core/run-state/provider-readback.js +426 -0
- package/dist/src/core/run-state/replay-decision.d.ts +69 -0
- package/dist/src/core/run-state/replay-decision.js +144 -0
- package/dist/src/core/run-state/resolve-engine.d.ts +100 -0
- package/dist/src/core/run-state/resolve-engine.js +190 -0
- package/dist/src/core/run-state/resume-preflight.d.ts +66 -0
- package/dist/src/core/run-state/resume-preflight.js +116 -0
- package/dist/src/core/run-state/run-phase-with-lifecycle.d.ts +73 -0
- package/dist/src/core/run-state/run-phase-with-lifecycle.js +186 -0
- package/dist/src/core/run-state/runs.d.ts +57 -0
- package/dist/src/core/run-state/runs.js +288 -0
- package/dist/src/core/run-state/snapshot.d.ts +14 -0
- package/dist/src/core/run-state/snapshot.js +114 -0
- package/dist/src/core/run-state/state.d.ts +40 -0
- package/dist/src/core/run-state/state.js +164 -0
- package/dist/src/core/run-state/types.d.ts +278 -0
- package/dist/src/core/run-state/types.js +13 -0
- package/dist/src/core/run-state/ulid.d.ts +11 -0
- package/dist/src/core/run-state/ulid.js +95 -0
- package/dist/src/core/schema-alignment/extractor/index.d.ts +1 -1
- package/dist/src/core/schema-alignment/extractor/index.js +2 -2
- package/dist/src/core/schema-alignment/extractor/prisma.d.ts +13 -1
- package/dist/src/core/schema-alignment/extractor/prisma.js +65 -10
- package/dist/src/core/schema-alignment/git-history.d.ts +19 -0
- package/dist/src/core/schema-alignment/git-history.js +53 -0
- package/dist/src/core/static-rules/rules/brand-tokens.js +2 -2
- package/dist/src/core/static-rules/rules/schema-alignment.js +14 -4
- package/package.json +2 -1
- package/scripts/autoregress.ts +1 -1
- package/skills/claude-autopilot.md +1 -1
- package/skills/make-interfaces-feel-better/SKILL.md +104 -0
- package/skills/simplify-ui/SKILL.md +103 -0
- package/skills/ui/SKILL.md +117 -0
- package/skills/ui-ux-pro-max/SKILL.md +90 -0
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
// src/adapters/deploy/fly.ts
|
|
2
|
+
//
|
|
3
|
+
// First-class Fly.io deploy adapter. Phase 1 of the v5.6 spec.
|
|
4
|
+
//
|
|
5
|
+
// Implements `deploy()` (POST a new release with a pre-pushed image, then
|
|
6
|
+
// poll until terminal) and `status()` (one-shot GET). Log streaming is
|
|
7
|
+
// Phase 3 (Fly uses WebSockets, not yet wired) and rollback is Phase 4.
|
|
8
|
+
//
|
|
9
|
+
// All HTTP calls go through an injectable `fetchImpl` so unit tests never
|
|
10
|
+
// hit the real Fly Machines API. The endpoint shapes below mirror the
|
|
11
|
+
// Codex-reviewed v5.6 spec at docs/specs/v5.6-fly-render-adapters.md and
|
|
12
|
+
// will be reconciled with captured fixtures during Phase 2 if the published
|
|
13
|
+
// API has drifted; the adapter's surface (auth, error mapping, redaction,
|
|
14
|
+
// capability metadata) is stable regardless of which exact body Fly accepts.
|
|
15
|
+
//
|
|
16
|
+
// Spec: docs/specs/v5.6-fly-render-adapters.md
|
|
17
|
+
import { GuardrailError } from "../../core/errors.js";
|
|
18
|
+
import { redactLogLines } from "../../core/logging/redaction.js";
|
|
19
|
+
import { fetchWithRetry, safeReadBody } from "./_http.js";
|
|
20
|
+
const FLY_API_BASE = 'https://api.machines.dev';
|
|
21
|
+
const FLY_DASHBOARD_BASE = 'https://fly.io/apps';
|
|
22
|
+
const FLY_TOKEN_DOC_URL = 'https://fly.io/dashboard/personal/tokens';
|
|
23
|
+
/**
|
|
24
|
+
* Fly.io deploy adapter.
|
|
25
|
+
*
|
|
26
|
+
* Construct once per pipeline run. The adapter is stateless across calls —
|
|
27
|
+
* all configuration (token, app, image, region) is captured at construction
|
|
28
|
+
* time. Per the v5.6 spec, only `deploy()` and `status()` are wired in
|
|
29
|
+
* Phase 1; `streamLogs` (WebSocket) and `rollback` (native + simulated)
|
|
30
|
+
* land in Phases 3 and 4 respectively.
|
|
31
|
+
*/
|
|
32
|
+
export class FlyDeployAdapter {
|
|
33
|
+
name = 'fly';
|
|
34
|
+
capabilities = {
|
|
35
|
+
streamMode: 'websocket',
|
|
36
|
+
nativeRollback: true,
|
|
37
|
+
};
|
|
38
|
+
token;
|
|
39
|
+
app;
|
|
40
|
+
image;
|
|
41
|
+
region;
|
|
42
|
+
pollIntervalMs;
|
|
43
|
+
maxPollMs;
|
|
44
|
+
fetchImpl;
|
|
45
|
+
sleep;
|
|
46
|
+
now;
|
|
47
|
+
redactionPatterns;
|
|
48
|
+
wsImpl;
|
|
49
|
+
buildLogsWsUrlFn;
|
|
50
|
+
constructor(opts) {
|
|
51
|
+
const token = opts.token ?? process.env.FLY_API_TOKEN;
|
|
52
|
+
if (!token) {
|
|
53
|
+
throw new GuardrailError(`Fly deploy adapter requires FLY_API_TOKEN. Create one at ${FLY_TOKEN_DOC_URL}`, { code: 'auth', provider: 'fly' });
|
|
54
|
+
}
|
|
55
|
+
if (!opts.app) {
|
|
56
|
+
throw new GuardrailError('Fly deploy adapter requires `app` (Fly app slug)', { code: 'invalid_config', provider: 'fly' });
|
|
57
|
+
}
|
|
58
|
+
if (!opts.image) {
|
|
59
|
+
throw new GuardrailError('Fly deploy adapter requires `image` (e.g. registry.fly.io/<app>:<tag>). Push first via `fly deploy --build-only --push`.', { code: 'invalid_config', provider: 'fly' });
|
|
60
|
+
}
|
|
61
|
+
this.token = token;
|
|
62
|
+
this.app = opts.app;
|
|
63
|
+
this.image = opts.image;
|
|
64
|
+
this.region = opts.region;
|
|
65
|
+
this.pollIntervalMs = opts.pollIntervalMs ?? 2000;
|
|
66
|
+
this.maxPollMs = opts.maxPollMs ?? 15 * 60 * 1000;
|
|
67
|
+
this.fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
68
|
+
this.sleep = opts.sleepImpl ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
69
|
+
this.now = opts.nowImpl ?? Date.now;
|
|
70
|
+
this.redactionPatterns = opts.redactionPatterns;
|
|
71
|
+
// Node 22 ships a global `WebSocket`. We don't fall back to a thrown
|
|
72
|
+
// error here — when a caller invokes `streamLogs` and `wsImpl` is
|
|
73
|
+
// undefined we'd surface that there. Most production runtimes have
|
|
74
|
+
// `globalThis.WebSocket` defined; tests inject `wsImpl` directly.
|
|
75
|
+
this.wsImpl = opts.wsImpl ?? globalThis.WebSocket;
|
|
76
|
+
this.buildLogsWsUrlFn = opts.buildLogsWsUrl ?? defaultFlyLogsWsUrl;
|
|
77
|
+
}
|
|
78
|
+
async deploy(input) {
|
|
79
|
+
const start = this.now();
|
|
80
|
+
const url = `${FLY_API_BASE}/v1/apps/${encodeURIComponent(this.app)}/releases`;
|
|
81
|
+
const body = {
|
|
82
|
+
image: this.image,
|
|
83
|
+
};
|
|
84
|
+
if (this.region)
|
|
85
|
+
body.region = this.region;
|
|
86
|
+
if (input.meta)
|
|
87
|
+
body.meta = input.meta;
|
|
88
|
+
if (input.commitSha)
|
|
89
|
+
body.commit_sha = input.commitSha;
|
|
90
|
+
if (input.ref)
|
|
91
|
+
body.ref = input.ref;
|
|
92
|
+
const res = await fetchWithRetry(this.fetchImpl, url, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: this.headers(),
|
|
95
|
+
body: JSON.stringify(body),
|
|
96
|
+
signal: input.signal,
|
|
97
|
+
}, { sleepImpl: this.sleep, provider: 'fly' });
|
|
98
|
+
await this.assertOkOrThrow(res, 'create release');
|
|
99
|
+
const created = (await res.json());
|
|
100
|
+
if (!created.id) {
|
|
101
|
+
throw new GuardrailError(`Fly returned no release id (got: ${JSON.stringify(created).slice(0, 200)})`, { code: 'adapter_bug', provider: 'fly' });
|
|
102
|
+
}
|
|
103
|
+
// Fire onDeployStart so callers can subscribe to side-channel work
|
|
104
|
+
// (log streaming once Phase 3 lands) in parallel with polling. Wrap in
|
|
105
|
+
// try/catch — a buggy callback must not crash the deploy.
|
|
106
|
+
try {
|
|
107
|
+
input.onDeployStart?.(created.id);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
/* swallow — observability concern only */
|
|
111
|
+
}
|
|
112
|
+
return this.pollUntilTerminal(created.id, start, input.signal);
|
|
113
|
+
}
|
|
114
|
+
async status(input) {
|
|
115
|
+
const start = this.now();
|
|
116
|
+
const url = `${FLY_API_BASE}/v1/apps/${encodeURIComponent(this.app)}/releases/${encodeURIComponent(input.deployId)}`;
|
|
117
|
+
const res = await fetchWithRetry(this.fetchImpl, url, {
|
|
118
|
+
method: 'GET',
|
|
119
|
+
headers: this.headers(),
|
|
120
|
+
signal: input.signal,
|
|
121
|
+
}, { sleepImpl: this.sleep, provider: 'fly' });
|
|
122
|
+
await this.assertOkOrThrow(res, 'get release');
|
|
123
|
+
const data = (await res.json());
|
|
124
|
+
const state = data.state ?? data.status;
|
|
125
|
+
const result = this.shapeResult(input.deployId, data, state, this.now() - start);
|
|
126
|
+
return { ...result, deployId: input.deployId };
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Phase 3 of v5.6 — subscribe to real-time build logs for a release via
|
|
130
|
+
* Fly's WebSocket log endpoint.
|
|
131
|
+
*
|
|
132
|
+
* Wire shape:
|
|
133
|
+
* - Connect to `wss://api.machines.dev/v1/apps/{app}/machines/{releaseId}/logs`
|
|
134
|
+
* (intent-level URL per the v5.6 spec's "Logs" bullet — exact path will
|
|
135
|
+
* be reconciled against captured fixtures in Phase 7; the `wsImpl` and
|
|
136
|
+
* `buildLogsWsUrl` injection points keep this overridable until then).
|
|
137
|
+
* - Each WS message is a single NDJSON line containing one log entry.
|
|
138
|
+
* Multiple lines per message are also tolerated (split on `\n`). Malformed
|
|
139
|
+
* JSON lines are skipped silently rather than crashing the iterator.
|
|
140
|
+
* - Auth via `Authorization: Bearer <FLY_API_TOKEN>` is passed through the
|
|
141
|
+
* `protocols` argument (Node's built-in WebSocket doesn't accept custom
|
|
142
|
+
* `headers` directly the way `ws` does); Fly accepts the token as the
|
|
143
|
+
* first protocol value. This is the documented pattern for browsers and
|
|
144
|
+
* matches Node 22's WS surface.
|
|
145
|
+
* - One reconnect with exponential backoff (1s, 2s) on disconnect, then
|
|
146
|
+
* yield a final `level: 'warn'` line referencing `buildLogsUrl` and
|
|
147
|
+
* finish the iterator.
|
|
148
|
+
* - `signal.aborted` is honored at every await boundary; the underlying
|
|
149
|
+
* socket is closed eagerly.
|
|
150
|
+
* - Every yielded line's `text` is run through `redactLogLines()` before
|
|
151
|
+
* leaving the adapter.
|
|
152
|
+
*/
|
|
153
|
+
async *streamLogs(input) {
|
|
154
|
+
if (!this.wsImpl) {
|
|
155
|
+
throw new GuardrailError('Fly streamLogs requires a WebSocket implementation (Node 22+ ships one as globalThis.WebSocket; tests can inject `wsImpl`)', { code: 'adapter_bug', provider: 'fly' });
|
|
156
|
+
}
|
|
157
|
+
const buildLogsUrl = this.buildLogsUrl(input.deployId);
|
|
158
|
+
let attempt = 0;
|
|
159
|
+
const maxAttempts = 2; // initial + one reconnect, per spec
|
|
160
|
+
while (attempt < maxAttempts) {
|
|
161
|
+
if (input.signal?.aborted)
|
|
162
|
+
return;
|
|
163
|
+
// Re-build the URL each connection attempt — ensures any caller-side
|
|
164
|
+
// state (counters, freshly-rotated tokens) is sampled per-attempt.
|
|
165
|
+
const url = this.buildLogsWsUrlFn(this.app, input.deployId);
|
|
166
|
+
const queue = new AsyncMessageQueue();
|
|
167
|
+
let socket;
|
|
168
|
+
try {
|
|
169
|
+
// Fly accepts the API token as the first protocol value — see method
|
|
170
|
+
// doc-comment for why we don't use the `headers` option here.
|
|
171
|
+
socket = new this.wsImpl(url, [this.token]);
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
// Constructor threw synchronously (rare — usually for invalid URL).
|
|
175
|
+
// Treat as a disconnect for retry purposes.
|
|
176
|
+
if (attempt === maxAttempts - 1) {
|
|
177
|
+
yield this.redactLine({
|
|
178
|
+
timestamp: this.now(),
|
|
179
|
+
level: 'warn',
|
|
180
|
+
text: `log stream lost — see ${buildLogsUrl} (constructor: ${err?.message ?? String(err)})`,
|
|
181
|
+
});
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
attempt += 1;
|
|
185
|
+
await this.sleep(1000 * 2 ** (attempt - 1));
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const onMessage = (ev) => {
|
|
189
|
+
const data = typeof ev.data === 'string' ? ev.data : safeBufferToString(ev.data);
|
|
190
|
+
if (!data)
|
|
191
|
+
return;
|
|
192
|
+
// NDJSON: one or more newline-separated JSON lines per message.
|
|
193
|
+
for (const raw of data.split('\n')) {
|
|
194
|
+
const line = parseFlyLogLine(raw, this.now());
|
|
195
|
+
if (line)
|
|
196
|
+
queue.push(line);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
const onError = (_ev) => {
|
|
200
|
+
// We let `onClose` drive the reconnect/teardown decision — `error`
|
|
201
|
+
// is purely informational on the standard WS surface.
|
|
202
|
+
};
|
|
203
|
+
const onClose = (_ev) => {
|
|
204
|
+
queue.push({ __end: true });
|
|
205
|
+
};
|
|
206
|
+
const abortHandler = () => {
|
|
207
|
+
try {
|
|
208
|
+
socket.close();
|
|
209
|
+
}
|
|
210
|
+
catch { /* ignore */ }
|
|
211
|
+
queue.push({ __end: true, reason: 'aborted' });
|
|
212
|
+
};
|
|
213
|
+
socket.addEventListener('message', onMessage);
|
|
214
|
+
socket.addEventListener('error', onError);
|
|
215
|
+
socket.addEventListener('close', onClose);
|
|
216
|
+
input.signal?.addEventListener('abort', abortHandler, { once: true });
|
|
217
|
+
try {
|
|
218
|
+
// Drain messages until the socket closes or signal aborts.
|
|
219
|
+
// eslint-disable-next-line no-constant-condition
|
|
220
|
+
while (true) {
|
|
221
|
+
if (input.signal?.aborted)
|
|
222
|
+
return;
|
|
223
|
+
const item = await queue.next();
|
|
224
|
+
if (input.signal?.aborted)
|
|
225
|
+
return;
|
|
226
|
+
if (item && '__end' in item) {
|
|
227
|
+
if (item.reason === 'aborted')
|
|
228
|
+
return;
|
|
229
|
+
break; // close → break inner loop, decide reconnect-or-give-up below
|
|
230
|
+
}
|
|
231
|
+
if (item)
|
|
232
|
+
yield this.redactLine(item);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
socket.removeEventListener('message', onMessage);
|
|
237
|
+
socket.removeEventListener('error', onError);
|
|
238
|
+
socket.removeEventListener('close', onClose);
|
|
239
|
+
input.signal?.removeEventListener('abort', abortHandler);
|
|
240
|
+
try {
|
|
241
|
+
socket.close();
|
|
242
|
+
}
|
|
243
|
+
catch { /* ignore */ }
|
|
244
|
+
}
|
|
245
|
+
// Closed — decide whether to retry.
|
|
246
|
+
attempt += 1;
|
|
247
|
+
if (attempt >= maxAttempts) {
|
|
248
|
+
yield this.redactLine({
|
|
249
|
+
timestamp: this.now(),
|
|
250
|
+
level: 'warn',
|
|
251
|
+
text: `log stream lost — see ${buildLogsUrl}`,
|
|
252
|
+
});
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
// Exponential backoff: 1s after first close, 2s after second (won't
|
|
256
|
+
// happen given maxAttempts = 2 today, but kept for future tuning).
|
|
257
|
+
const backoffMs = 1000 * 2 ** (attempt - 1);
|
|
258
|
+
await this.sleep(backoffMs);
|
|
259
|
+
if (input.signal?.aborted)
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Phase 4 of v5.6 — roll back to a previous Fly release.
|
|
265
|
+
*
|
|
266
|
+
* Two modes per spec § "Fly.io adapter → Rollback":
|
|
267
|
+
*
|
|
268
|
+
* 1. Native: try `POST /v1/apps/{app}/releases/{releaseId}/rollback`.
|
|
269
|
+
* This is the historical Fly API; the Machines-era replacement may
|
|
270
|
+
* differ — Phase 7 fixture-capture reconciles. If the endpoint returns
|
|
271
|
+
* 404 / 405 / 410 (removed across the Nomad → Machines transition),
|
|
272
|
+
* fall through to the simulated path. Any other non-OK status
|
|
273
|
+
* (auth, invalid_config, etc.) propagates via `assertOkOrThrow`.
|
|
274
|
+
*
|
|
275
|
+
* 2. Simulated: list prior releases via
|
|
276
|
+
* `GET /v1/apps/{app}/releases?limit=10`, find the most recent one
|
|
277
|
+
* with `status === 'succeeded'` whose `id` differs from the one we'd
|
|
278
|
+
* be rolling back from, and trigger a new deploy with that release's
|
|
279
|
+
* `image`. Re-uses the same POST + poll machinery as `deploy()` via
|
|
280
|
+
* `deployImage()`.
|
|
281
|
+
*
|
|
282
|
+
* When `input.to` is set we treat that as a specific release ID:
|
|
283
|
+
* - Native path uses it as the URL fragment.
|
|
284
|
+
* - Simulated path looks it up in the list to grab its `image`. If the
|
|
285
|
+
* release is not present in the recent-10 window, throw
|
|
286
|
+
* `not_found` — caller almost certainly typo'd the ID.
|
|
287
|
+
*
|
|
288
|
+
* Throws `GuardrailError({ code: 'no_previous_deploy', provider: 'fly' })`
|
|
289
|
+
* when the simulated path runs out of candidates (i.e. no prior release
|
|
290
|
+
* with `status === 'succeeded'` exists).
|
|
291
|
+
*/
|
|
292
|
+
async rollback(input) {
|
|
293
|
+
const start = this.now();
|
|
294
|
+
// ── Native path ──
|
|
295
|
+
// When `to` is set, we have a concrete release ID to target. When it's
|
|
296
|
+
// not, we still attempt the native verb on the *previous* release we
|
|
297
|
+
// discover via the list endpoint — same call shape, just one indirection.
|
|
298
|
+
let nativeTargetId = input.to;
|
|
299
|
+
let prevImage;
|
|
300
|
+
if (!nativeTargetId) {
|
|
301
|
+
const prev = await this.findPreviousSucceededRelease(undefined, input.signal);
|
|
302
|
+
if (!prev) {
|
|
303
|
+
throw new GuardrailError(`No previous successful Fly release found for app "${this.app}" to roll back to`, { code: 'no_previous_deploy', provider: 'fly' });
|
|
304
|
+
}
|
|
305
|
+
nativeTargetId = prev.id;
|
|
306
|
+
prevImage = prev.image;
|
|
307
|
+
}
|
|
308
|
+
const nativeUrl = `${FLY_API_BASE}/v1/apps/${encodeURIComponent(this.app)}/releases/${encodeURIComponent(nativeTargetId)}/rollback`;
|
|
309
|
+
let nativeRes;
|
|
310
|
+
try {
|
|
311
|
+
nativeRes = await fetchWithRetry(this.fetchImpl, nativeUrl, {
|
|
312
|
+
method: 'POST',
|
|
313
|
+
headers: this.headers(),
|
|
314
|
+
body: '{}',
|
|
315
|
+
signal: input.signal,
|
|
316
|
+
}, { sleepImpl: this.sleep, provider: 'fly' });
|
|
317
|
+
}
|
|
318
|
+
catch (err) {
|
|
319
|
+
// Network exhaustion is already mapped to GuardrailError(transient_network)
|
|
320
|
+
// by fetchWithRetry — rethrow.
|
|
321
|
+
throw err;
|
|
322
|
+
}
|
|
323
|
+
if (nativeRes.ok) {
|
|
324
|
+
let data;
|
|
325
|
+
try {
|
|
326
|
+
data = (await nativeRes.json());
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
data = undefined;
|
|
330
|
+
}
|
|
331
|
+
const rawOutput = `Fly release ${nativeTargetId} rolled back natively for app "${this.app}"`;
|
|
332
|
+
return {
|
|
333
|
+
status: 'pass',
|
|
334
|
+
deployId: nativeTargetId,
|
|
335
|
+
rolledBackTo: nativeTargetId,
|
|
336
|
+
deployUrl: data?.hostname ? `https://${data.hostname}` : undefined,
|
|
337
|
+
buildLogsUrl: this.buildLogsUrl(nativeTargetId),
|
|
338
|
+
durationMs: this.now() - start,
|
|
339
|
+
output: redactLogLines(rawOutput, this.redactionPatterns),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
// ── Simulated fallback ──
|
|
343
|
+
// The native rollback verb has been removed from the Machines API in
|
|
344
|
+
// some org/region pairs. 404 (endpoint removed), 405 (method now
|
|
345
|
+
// disallowed), and 410 (gone) all indicate "use the simulated path".
|
|
346
|
+
// Anything else — auth, validation, 5xx exhaustion — propagates.
|
|
347
|
+
if (nativeRes.status !== 404 && nativeRes.status !== 405 && nativeRes.status !== 410) {
|
|
348
|
+
await this.assertOkOrThrow(nativeRes, 'native rollback');
|
|
349
|
+
// assertOkOrThrow always throws for non-OK responses; this is unreachable
|
|
350
|
+
// but keeps the type checker happy.
|
|
351
|
+
throw new GuardrailError(`Fly native rollback returned non-OK ${nativeRes.status} (unreachable)`, { code: 'adapter_bug', provider: 'fly' });
|
|
352
|
+
}
|
|
353
|
+
// Simulated rollback: re-deploy a previous successful image.
|
|
354
|
+
let imageToDeploy;
|
|
355
|
+
let simulatedTargetId;
|
|
356
|
+
if (input.to) {
|
|
357
|
+
// Look up the user-specified release in the recent window to grab its
|
|
358
|
+
// image. We search by id rather than re-using `prevImage` (which is
|
|
359
|
+
// unset when `input.to` was provided).
|
|
360
|
+
const releases = await this.listReleases(10, input.signal);
|
|
361
|
+
const match = releases.find((r) => r.id === input.to);
|
|
362
|
+
if (!match) {
|
|
363
|
+
throw new GuardrailError(`Fly release "${input.to}" not found in the last 10 releases for app "${this.app}" — cannot simulate rollback`, { code: 'not_found', provider: 'fly', step: 'simulated rollback' });
|
|
364
|
+
}
|
|
365
|
+
if (!match.image) {
|
|
366
|
+
throw new GuardrailError(`Fly release "${input.to}" has no recorded image — cannot simulate rollback`, { code: 'invalid_config', provider: 'fly', step: 'simulated rollback' });
|
|
367
|
+
}
|
|
368
|
+
imageToDeploy = match.image;
|
|
369
|
+
simulatedTargetId = match.id;
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
// We already discovered the previous successful release before the
|
|
373
|
+
// native attempt; reuse its image when present, otherwise re-list.
|
|
374
|
+
if (prevImage) {
|
|
375
|
+
imageToDeploy = prevImage;
|
|
376
|
+
simulatedTargetId = nativeTargetId;
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
const prev = await this.findPreviousSucceededRelease(undefined, input.signal);
|
|
380
|
+
if (!prev) {
|
|
381
|
+
throw new GuardrailError(`No previous successful Fly release found for app "${this.app}" to roll back to`, { code: 'no_previous_deploy', provider: 'fly' });
|
|
382
|
+
}
|
|
383
|
+
if (!prev.image) {
|
|
384
|
+
throw new GuardrailError(`Previous Fly release "${prev.id}" has no recorded image — cannot simulate rollback`, { code: 'invalid_config', provider: 'fly', step: 'simulated rollback' });
|
|
385
|
+
}
|
|
386
|
+
imageToDeploy = prev.image;
|
|
387
|
+
simulatedTargetId = prev.id;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const redeployed = await this.deployImage(imageToDeploy, input.signal);
|
|
391
|
+
const rawOutput = `Fly rollback simulated by re-deploying image "${imageToDeploy}" (prior release ${simulatedTargetId}) → new release ${redeployed.deployId ?? '<unknown>'}`;
|
|
392
|
+
return {
|
|
393
|
+
...redeployed,
|
|
394
|
+
// Carry the new release id forward as `deployId` (we just deployed it),
|
|
395
|
+
// and flag the prior release as the rollback target so the CLI can
|
|
396
|
+
// surface "rolled back to X (new deploy Y)".
|
|
397
|
+
rolledBackTo: simulatedTargetId,
|
|
398
|
+
durationMs: this.now() - start,
|
|
399
|
+
output: redactLogLines(rawOutput, this.redactionPatterns),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Private helper — re-uses the deploy() POST + poll machinery to deploy a
|
|
404
|
+
* specific image without going through the constructor-stamped image. Used
|
|
405
|
+
* by `rollback()`'s simulated path to redeploy a previous successful image.
|
|
406
|
+
*/
|
|
407
|
+
async deployImage(image, signal) {
|
|
408
|
+
const start = this.now();
|
|
409
|
+
const url = `${FLY_API_BASE}/v1/apps/${encodeURIComponent(this.app)}/releases`;
|
|
410
|
+
const body = { image };
|
|
411
|
+
if (this.region)
|
|
412
|
+
body.region = this.region;
|
|
413
|
+
const res = await fetchWithRetry(this.fetchImpl, url, {
|
|
414
|
+
method: 'POST',
|
|
415
|
+
headers: this.headers(),
|
|
416
|
+
body: JSON.stringify(body),
|
|
417
|
+
signal,
|
|
418
|
+
}, { sleepImpl: this.sleep, provider: 'fly' });
|
|
419
|
+
await this.assertOkOrThrow(res, 'create release (rollback)');
|
|
420
|
+
const created = (await res.json());
|
|
421
|
+
if (!created.id) {
|
|
422
|
+
throw new GuardrailError(`Fly returned no release id during rollback (got: ${JSON.stringify(created).slice(0, 200)})`, { code: 'adapter_bug', provider: 'fly' });
|
|
423
|
+
}
|
|
424
|
+
return this.pollUntilTerminal(created.id, start, signal);
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* List the most recent releases for the configured app. Newest-first.
|
|
428
|
+
* `limit` caps the result set — defaults to 10 (the spec's recommended
|
|
429
|
+
* window for the rollback lookup). 4xx/5xx errors propagate via
|
|
430
|
+
* `assertOkOrThrow`.
|
|
431
|
+
*/
|
|
432
|
+
async listReleases(limit = 10, signal) {
|
|
433
|
+
const url = `${FLY_API_BASE}/v1/apps/${encodeURIComponent(this.app)}/releases?limit=${encodeURIComponent(String(limit))}`;
|
|
434
|
+
const res = await fetchWithRetry(this.fetchImpl, url, {
|
|
435
|
+
method: 'GET',
|
|
436
|
+
headers: this.headers(),
|
|
437
|
+
signal,
|
|
438
|
+
}, { sleepImpl: this.sleep, provider: 'fly' });
|
|
439
|
+
await this.assertOkOrThrow(res, 'list releases');
|
|
440
|
+
const data = (await res.json());
|
|
441
|
+
// Be defensive — Fly has shipped both list-envelope and bare-array
|
|
442
|
+
// shapes across API generations.
|
|
443
|
+
const arr = Array.isArray(data) ? data : Array.isArray(data?.releases) ? data.releases : [];
|
|
444
|
+
return arr;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Find the most recent prior release with `status === 'succeeded'`. When
|
|
448
|
+
* `excludeId` is supplied, that release is skipped (used to ensure
|
|
449
|
+
* `rollback()` never returns "rolled back to the deploy I'm rolling back
|
|
450
|
+
* from" when the caller didn't supply `input.to`).
|
|
451
|
+
*
|
|
452
|
+
* Returns `null` when no candidate exists.
|
|
453
|
+
*/
|
|
454
|
+
async findPreviousSucceededRelease(excludeId, signal) {
|
|
455
|
+
const releases = await this.listReleases(10, signal);
|
|
456
|
+
// Fly returns newest-first; the first `succeeded` entry is the current
|
|
457
|
+
// prod release. When `excludeId` is unset we still want the *previous*
|
|
458
|
+
// succeeded release — drop the first match and return the next.
|
|
459
|
+
const succeeded = releases.filter((r) => {
|
|
460
|
+
const state = r.state ?? r.status;
|
|
461
|
+
if (state !== 'succeeded')
|
|
462
|
+
return false;
|
|
463
|
+
if (excludeId && r.id === excludeId)
|
|
464
|
+
return false;
|
|
465
|
+
return true;
|
|
466
|
+
});
|
|
467
|
+
if (excludeId) {
|
|
468
|
+
// Caller already filtered out the rollback-from id; return the newest
|
|
469
|
+
// remaining succeeded release.
|
|
470
|
+
return succeeded[0] ?? null;
|
|
471
|
+
}
|
|
472
|
+
// No exclude — drop the head (current prod) and return the next.
|
|
473
|
+
if (succeeded.length < 2)
|
|
474
|
+
return null;
|
|
475
|
+
return succeeded[1] ?? null;
|
|
476
|
+
}
|
|
477
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
478
|
+
// private helpers
|
|
479
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
480
|
+
/**
|
|
481
|
+
* Apply the adapter's redaction patterns to a log line's `text` field.
|
|
482
|
+
* Pure helper — keeps the streamLogs loop readable.
|
|
483
|
+
*/
|
|
484
|
+
redactLine(line) {
|
|
485
|
+
return { ...line, text: redactLogLines(line.text, this.redactionPatterns) };
|
|
486
|
+
}
|
|
487
|
+
async pollUntilTerminal(releaseId, start, signal) {
|
|
488
|
+
const url = `${FLY_API_BASE}/v1/apps/${encodeURIComponent(this.app)}/releases/${encodeURIComponent(releaseId)}`;
|
|
489
|
+
while (true) {
|
|
490
|
+
if (signal?.aborted) {
|
|
491
|
+
return { status: 'in-progress', deployId: releaseId, durationMs: this.now() - start };
|
|
492
|
+
}
|
|
493
|
+
if (this.now() - start > this.maxPollMs) {
|
|
494
|
+
return {
|
|
495
|
+
status: 'in-progress',
|
|
496
|
+
deployId: releaseId,
|
|
497
|
+
durationMs: this.now() - start,
|
|
498
|
+
buildLogsUrl: this.buildLogsUrl(releaseId),
|
|
499
|
+
output: redactLogLines(`Fly release still in progress after ${this.maxPollMs}ms — check ${this.buildLogsUrl(releaseId)}`, this.redactionPatterns),
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
const res = await fetchWithRetry(this.fetchImpl, url, {
|
|
503
|
+
method: 'GET',
|
|
504
|
+
headers: this.headers(),
|
|
505
|
+
signal,
|
|
506
|
+
}, { sleepImpl: this.sleep, provider: 'fly' });
|
|
507
|
+
await this.assertOkOrThrow(res, 'poll release');
|
|
508
|
+
const data = (await res.json());
|
|
509
|
+
const state = data.state ?? data.status;
|
|
510
|
+
if (state === 'succeeded' || state === 'failed' || state === 'cancelled') {
|
|
511
|
+
return this.shapeResult(releaseId, data, state, this.now() - start);
|
|
512
|
+
}
|
|
513
|
+
await this.sleep(this.pollIntervalMs);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
shapeResult(releaseId, data, state, durationMs) {
|
|
517
|
+
const status = state === 'succeeded'
|
|
518
|
+
? 'pass'
|
|
519
|
+
: state === 'failed' || state === 'cancelled'
|
|
520
|
+
? 'fail'
|
|
521
|
+
: 'in-progress';
|
|
522
|
+
// Apply redaction to the human-readable output line. Real-world Fly
|
|
523
|
+
// logs often echo back env vars and tokens; we never want those landing
|
|
524
|
+
// in PR-comment bodies. (Spec § "Log redaction".)
|
|
525
|
+
const rawOutput = state ? `Fly release ${releaseId}: state=${state}` : undefined;
|
|
526
|
+
return {
|
|
527
|
+
status,
|
|
528
|
+
deployId: releaseId,
|
|
529
|
+
deployUrl: data.hostname ? `https://${data.hostname}` : undefined,
|
|
530
|
+
buildLogsUrl: this.buildLogsUrl(releaseId),
|
|
531
|
+
durationMs,
|
|
532
|
+
output: rawOutput !== undefined ? redactLogLines(rawOutput, this.redactionPatterns) : undefined,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
headers() {
|
|
536
|
+
return {
|
|
537
|
+
Authorization: `Bearer ${this.token}`,
|
|
538
|
+
'Content-Type': 'application/json',
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
buildLogsUrl(releaseId) {
|
|
542
|
+
return `${FLY_DASHBOARD_BASE}/${encodeURIComponent(this.app)}/releases/${encodeURIComponent(releaseId)}`;
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* HTTP-status-keyed error mapper. Per v5.6 spec:
|
|
546
|
+
*
|
|
547
|
+
* | Status | ErrorCode |
|
|
548
|
+
* |---|---|
|
|
549
|
+
* | 401 / 403 | `auth` |
|
|
550
|
+
* | 404 | `not_found` |
|
|
551
|
+
* | 422 / 400 | `invalid_config` |
|
|
552
|
+
* | 5xx | `transient_network` (retryable) |
|
|
553
|
+
* | other 4xx | `adapter_bug` |
|
|
554
|
+
*
|
|
555
|
+
* The `Fly-Request-Id` response header is captured into `details` whenever
|
|
556
|
+
* present so support tickets can quote it back to Fly.
|
|
557
|
+
*/
|
|
558
|
+
async assertOkOrThrow(res, step) {
|
|
559
|
+
if (res.ok)
|
|
560
|
+
return;
|
|
561
|
+
const bodyText = await safeReadBody(res);
|
|
562
|
+
const requestId = readFlyRequestId(res);
|
|
563
|
+
const details = { status: res.status };
|
|
564
|
+
if (requestId)
|
|
565
|
+
details.flyRequestId = requestId;
|
|
566
|
+
if (res.status === 401 || res.status === 403) {
|
|
567
|
+
throw new GuardrailError(`Fly auth failed (${res.status}) on ${step} — check FLY_API_TOKEN scope for app "${this.app}". Regenerate at ${FLY_TOKEN_DOC_URL}: ${bodyText}`, { code: 'auth', provider: 'fly', step, details });
|
|
568
|
+
}
|
|
569
|
+
if (res.status === 404) {
|
|
570
|
+
throw new GuardrailError(`Fly resource not found (${res.status}) on ${step} — app slug "${this.app}" may be wrong, or the release ID belongs to a different app${requestId ? ` (Fly-Request-Id: ${requestId})` : ''}: ${bodyText}`, { code: 'not_found', provider: 'fly', step, details });
|
|
571
|
+
}
|
|
572
|
+
if (res.status === 422 || res.status === 400) {
|
|
573
|
+
throw new GuardrailError(`Fly rejected the request (${res.status}) on ${step} — likely a bad image reference, missing region, or malformed body: ${bodyText}`, { code: 'invalid_config', provider: 'fly', step, details });
|
|
574
|
+
}
|
|
575
|
+
if (res.status >= 500 && res.status < 600) {
|
|
576
|
+
throw new GuardrailError(`Fly API server error (${res.status}) on ${step}: ${bodyText}`, { code: 'transient_network', provider: 'fly', step, details, retryable: true });
|
|
577
|
+
}
|
|
578
|
+
throw new GuardrailError(`Fly API error (${res.status}) on ${step}: ${bodyText}`, { code: 'adapter_bug', provider: 'fly', step, details });
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Pull `Fly-Request-Id` (case-insensitive) off the response. Fly echoes this
|
|
583
|
+
* header on every API response and support tickets quote it back, so we
|
|
584
|
+
* stash it in `GuardrailError.details.flyRequestId` for any non-OK status.
|
|
585
|
+
*
|
|
586
|
+
* Falls back to `null` when `headers.get` is unavailable (e.g. a stubbed
|
|
587
|
+
* Response in tests that doesn't implement Headers).
|
|
588
|
+
*/
|
|
589
|
+
function readFlyRequestId(res) {
|
|
590
|
+
const headers = res.headers;
|
|
591
|
+
if (!headers || typeof headers.get !== 'function')
|
|
592
|
+
return null;
|
|
593
|
+
return headers.get('Fly-Request-Id') ?? headers.get('fly-request-id') ?? null;
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Default WebSocket URL builder for Fly log streaming.
|
|
597
|
+
*
|
|
598
|
+
* The URL shape below is intent-level per the v5.6 spec § "Fly.io adapter →
|
|
599
|
+
* Logs":
|
|
600
|
+
*
|
|
601
|
+
* wss://api.machines.dev/v1/apps/{app}/machines/{machineId}/logs
|
|
602
|
+
*
|
|
603
|
+
* Phase 7 of v5.6 reconciles this against captured fixtures from a real
|
|
604
|
+
* Fly account. If the published path differs (e.g. `/releases/{id}/logs` or
|
|
605
|
+
* a different host), we'll update this builder there. Until then, callers
|
|
606
|
+
* who hit a divergent path can pass `buildLogsWsUrl` to override.
|
|
607
|
+
*
|
|
608
|
+
* Note: we treat the `deployId` (release id) as the machine id for now —
|
|
609
|
+
* Fly's deploy → release → machine mapping is not 1:1 in all cases, and
|
|
610
|
+
* Phase 7 will need to either look up the machine list before subscribing
|
|
611
|
+
* or use a different log endpoint that takes a release id directly.
|
|
612
|
+
*/
|
|
613
|
+
function defaultFlyLogsWsUrl(app, releaseId) {
|
|
614
|
+
// wss base mirrors FLY_API_BASE but with a `wss://` scheme.
|
|
615
|
+
return `wss://api.machines.dev/v1/apps/${encodeURIComponent(app)}/machines/${encodeURIComponent(releaseId)}/logs`;
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Best-effort decoder for the binary `data` field of a `MessageEvent`.
|
|
619
|
+
* Fly normally sends UTF-8 text; tests may pass a Buffer or Uint8Array.
|
|
620
|
+
*/
|
|
621
|
+
function safeBufferToString(data) {
|
|
622
|
+
if (data == null)
|
|
623
|
+
return '';
|
|
624
|
+
if (typeof data === 'string')
|
|
625
|
+
return data;
|
|
626
|
+
if (data instanceof ArrayBuffer)
|
|
627
|
+
return new TextDecoder('utf-8').decode(data);
|
|
628
|
+
if (ArrayBuffer.isView(data)) {
|
|
629
|
+
const view = data;
|
|
630
|
+
return new TextDecoder('utf-8').decode(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
|
|
631
|
+
}
|
|
632
|
+
if (typeof data.toString === 'function') {
|
|
633
|
+
return data.toString();
|
|
634
|
+
}
|
|
635
|
+
return '';
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Parse a single NDJSON log line from Fly's WS stream into a `DeployLogLine`.
|
|
639
|
+
*
|
|
640
|
+
* Fly wraps log entries in objects whose canonical shape is roughly
|
|
641
|
+
* `{ timestamp: <epoch_ms>, level: 'info' | 'warn' | 'error', message: '<text>' }`.
|
|
642
|
+
* We accept both `message` and `text` (older Fly clients use the latter).
|
|
643
|
+
* Lines that fail to JSON-parse OR that have no usable text return `null`,
|
|
644
|
+
* which the caller drops silently — never crash a long-running stream.
|
|
645
|
+
*/
|
|
646
|
+
function parseFlyLogLine(raw, fallbackTs) {
|
|
647
|
+
const trimmed = raw.trim();
|
|
648
|
+
if (!trimmed)
|
|
649
|
+
return null;
|
|
650
|
+
let parsed;
|
|
651
|
+
try {
|
|
652
|
+
parsed = JSON.parse(trimmed);
|
|
653
|
+
}
|
|
654
|
+
catch {
|
|
655
|
+
// Not all lines are JSON — Fly occasionally emits raw text (e.g. boot
|
|
656
|
+
// banners). Surface those as plain stdout entries.
|
|
657
|
+
return { timestamp: fallbackTs, level: 'info', text: trimmed };
|
|
658
|
+
}
|
|
659
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
660
|
+
return null;
|
|
661
|
+
const obj = parsed;
|
|
662
|
+
const text = typeof obj.message === 'string' ? obj.message : typeof obj.text === 'string' ? obj.text : '';
|
|
663
|
+
if (!text)
|
|
664
|
+
return null;
|
|
665
|
+
const ts = typeof obj.timestamp === 'number'
|
|
666
|
+
? obj.timestamp
|
|
667
|
+
: typeof obj.ts === 'number' ? obj.ts : fallbackTs;
|
|
668
|
+
return { timestamp: ts, level: obj.level, text };
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Tiny FIFO queue with an awaitable `next()`. Backs the WS event-pump → async
|
|
672
|
+
* generator bridge in `streamLogs`. Resolves promises in push order; if the
|
|
673
|
+
* queue is empty, `next()` returns a promise that resolves on the next push.
|
|
674
|
+
*/
|
|
675
|
+
class AsyncMessageQueue {
|
|
676
|
+
buffer = [];
|
|
677
|
+
waiter = null;
|
|
678
|
+
push(item) {
|
|
679
|
+
if (this.waiter) {
|
|
680
|
+
const w = this.waiter;
|
|
681
|
+
this.waiter = null;
|
|
682
|
+
w(item);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
this.buffer.push(item);
|
|
686
|
+
}
|
|
687
|
+
next() {
|
|
688
|
+
const head = this.buffer.shift();
|
|
689
|
+
if (head !== undefined)
|
|
690
|
+
return Promise.resolve(head);
|
|
691
|
+
return new Promise((resolve) => {
|
|
692
|
+
this.waiter = resolve;
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
//# sourceMappingURL=fly.js.map
|