unfazed 0.6.1 → 0.8.1
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 +124 -0
- package/README.md +201 -52
- package/REFERENCE.md +298 -0
- package/dist/cli/login.d.ts +1 -1
- package/dist/client.d.ts +1 -1
- package/dist/client.js +1 -1
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +64 -0
- package/dist/errors.js +95 -0
- package/dist/errors.js.map +1 -1
- package/dist/generated/gql.d.ts +90 -2
- package/dist/generated/gql.js +24 -2
- package/dist/generated/gql.js.map +1 -1
- package/dist/generated/graphql.d.ts +586 -6
- package/dist/generated/graphql.js +526 -3
- package/dist/generated/graphql.js.map +1 -1
- package/dist/index.d.ts +13 -4
- package/dist/index.js +12 -3
- package/dist/index.js.map +1 -1
- package/dist/operations.d.ts +144 -5
- package/dist/operations.js +448 -7
- package/dist/operations.js.map +1 -1
- package/dist/reference.d.ts +18 -0
- package/dist/reference.generated.d.ts +2 -0
- package/dist/reference.generated.js +5 -0
- package/dist/reference.generated.js.map +1 -0
- package/dist/reference.js +38 -0
- package/dist/reference.js.map +1 -0
- package/dist/unfazed.d.ts +599 -68
- package/dist/unfazed.js +842 -64
- package/dist/unfazed.js.map +1 -1
- package/dist/wait.d.ts +7 -0
- package/dist/wait.js.map +1 -1
- package/dist/workflow/nitro.js +5 -3
- package/dist/workflow/nitro.js.map +1 -1
- package/package.json +8 -4
- package/schema/SOURCE.json +4 -4
- package/schema/schema.graphqls +1704 -30
package/dist/unfazed.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { UnfazedClient } from "./client.js";
|
|
2
|
-
import { DEPLOYMENT_NOT_FOUND, UnfazedConflictError, UnfazedDeployError, UnfazedGraphQLError, UnfazedNotFoundError, UnfazedRunError, WORKSPACE_NOT_FOUND, isDuplicateWorkspaceName, isNotFoundSentinel as isNotFound, looksLikeCASConflict, } from "./errors.js";
|
|
2
|
+
import { DEPLOYMENT_NOT_FOUND, UnfazedConflictError, UnfazedDeployError, UnfazedError, UnfazedGraphQLError, UnfazedNotFoundError, UnfazedRunError, UnfazedTimeoutError, UnfazedUnconfirmedAppsError, UnfazedWriteError, WORKSPACE_NOT_FOUND, isAppNotFound, isDuplicateWorkspaceName, isNotFoundSentinel as isNotFound, isUnconfirmedAppsRefusal, isTooManyWaits, isUnsupportedField, looksLikeCASConflict, } from "./errors.js";
|
|
3
3
|
import * as ops from "./operations.js";
|
|
4
4
|
import { pollWithBackoff, resolveWaitOptions } from "./wait.js";
|
|
5
5
|
/**
|
|
@@ -44,41 +44,103 @@ export class Unfazed {
|
|
|
44
44
|
return data.workspaces;
|
|
45
45
|
}
|
|
46
46
|
/**
|
|
47
|
-
* Deletes a workspace
|
|
48
|
-
*
|
|
47
|
+
* Deletes a workspace — its files, env and secrets, and a BOX's persistent
|
|
48
|
+
* data disk — and, with `withDeployments`, every app it serves.
|
|
49
49
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* than cascading and is the safer choice when you are deleting a workspace
|
|
60
|
-
* you did not create:
|
|
50
|
+
* A workspace can serve several apps (each `unfazed.json` `apps[]` entry, or
|
|
51
|
+
* several aliases deployed into it), so deleting one with its deployments is
|
|
52
|
+
* never implicit: **name exactly the apps it takes offline** in
|
|
53
|
+
* `confirmAliases`. The server compares them with the workspace's live apps
|
|
54
|
+
* and refuses, deleting nothing, on a missing or extra alias; the refusal
|
|
55
|
+
* ({@link UnfazedUnconfirmedAppsError}) lists every live app with its
|
|
56
|
+
* domains. The SDK never fills the list in: look it up deliberately with
|
|
57
|
+
* {@link liveApps} and decide. To delete one app and keep the rest, use
|
|
58
|
+
* {@link deleteApp}.
|
|
61
59
|
*
|
|
62
60
|
* ```ts
|
|
63
|
-
* await unfazed.deleteWorkspace(ws.id);
|
|
64
|
-
* await unfazed.deleteWorkspace(ws.id, { withDeployments:
|
|
65
|
-
* await unfazed.deleteWorkspace(id, {
|
|
61
|
+
* await unfazed.deleteWorkspace(ws.id); // only when no deployment is left
|
|
62
|
+
* await unfazed.deleteWorkspace(ws.id, { withDeployments: true, confirmAliases: ["web", "api"] });
|
|
63
|
+
* await unfazed.deleteWorkspace(ws.id, { withDeployments: true, confirmAliases: [] }); // serves no app
|
|
64
|
+
* await unfazed.deleteWorkspace(id, { ignoreNotFound: true }); // false, not a throw
|
|
66
65
|
* ```
|
|
67
66
|
*
|
|
67
|
+
* Without `withDeployments` the server refuses while ANY deployment record
|
|
68
|
+
* is left — FAILED ones included — with `still has deployments; delete them
|
|
69
|
+
* first`. A workspace whose deploys all failed serves no app: delete it with
|
|
70
|
+
* `withDeployments: true, confirmAliases: []`.
|
|
71
|
+
*
|
|
72
|
+
* What is deleted disappears at once — gone from your views, its names and
|
|
73
|
+
* aliases free, its VMs stopped — but the platform keeps it for 30 days:
|
|
74
|
+
* within that time support (the platform operator) can restore it exactly
|
|
75
|
+
* as it was. You cannot restore it yourself; ask support. After 30 days it
|
|
76
|
+
* is erased for good.
|
|
77
|
+
*
|
|
68
78
|
* With `ignoreNotFound` a workspace that is absent (or belongs to someone
|
|
69
79
|
* else — the server returns one indistinguishable sentinel for both, on
|
|
70
80
|
* purpose) resolves to `false` instead of throwing, which makes teardown in
|
|
71
81
|
* a `finally` block idempotent.
|
|
72
82
|
*/
|
|
73
83
|
async deleteWorkspace(workspaceId, options = {}) {
|
|
74
|
-
const withDeployments = options.withDeployments
|
|
84
|
+
const withDeployments = options.withDeployments === true;
|
|
85
|
+
const confirmAliases = options.confirmAliases;
|
|
86
|
+
// The types already require both together; these guard plain JavaScript
|
|
87
|
+
// callers, before anything is sent.
|
|
88
|
+
if (withDeployments && !Array.isArray(confirmAliases)) {
|
|
89
|
+
throw new UnfazedError(`unfazed: nothing was deleted: deleteWorkspace("${workspaceId}", { withDeployments: true }) ` +
|
|
90
|
+
"takes every app the workspace serves offline, so it needs confirmAliases naming exactly " +
|
|
91
|
+
`those apps ([] when it serves none). Look them up with unfazed.liveApps("${workspaceId}"); ` +
|
|
92
|
+
"to delete one app, use unfazed.deleteApp.");
|
|
93
|
+
}
|
|
94
|
+
if (!withDeployments && confirmAliases !== undefined) {
|
|
95
|
+
throw new UnfazedError("unfazed: nothing was deleted: confirmAliases applies only with withDeployments: true.");
|
|
96
|
+
}
|
|
75
97
|
try {
|
|
76
|
-
const data = await this.client.mutate(ops.DeleteWorkspaceDocument,
|
|
98
|
+
const data = await this.client.mutate(ops.DeleteWorkspaceDocument, withDeployments
|
|
99
|
+
? { workspaceID: workspaceId, withDeployments, confirmAliases: [...confirmAliases] }
|
|
100
|
+
: { workspaceID: workspaceId, withDeployments }, options);
|
|
77
101
|
return data.deleteWorkspace.ok;
|
|
78
102
|
}
|
|
79
103
|
catch (e) {
|
|
80
104
|
if (options.ignoreNotFound && isNotFound(e, WORKSPACE_NOT_FOUND))
|
|
81
105
|
return false;
|
|
106
|
+
if (isUnconfirmedAppsRefusal(e)) {
|
|
107
|
+
throw new UnfazedUnconfirmedAppsError(workspaceId, [...(confirmAliases ?? [])], e.errors, e.data);
|
|
108
|
+
}
|
|
109
|
+
throw e;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The apps a workspace serves right now, by alias, with the deployment
|
|
114
|
+
* serving each and its custom domains: everything
|
|
115
|
+
* `deleteWorkspace(id, { withDeployments: true, … })` takes offline, and the
|
|
116
|
+
* exact list its `confirmAliases` must name.
|
|
117
|
+
*
|
|
118
|
+
* It is a lookup for you to read, not something to pipe into a delete
|
|
119
|
+
* unseen: check that the apps are the ones you mean to take down.
|
|
120
|
+
*
|
|
121
|
+
* ```ts
|
|
122
|
+
* const live = await unfazed.liveApps(ws.id); // [{ alias: "web", deploymentID, domains: [] }, …]
|
|
123
|
+
* ```
|
|
124
|
+
*
|
|
125
|
+
* Throws {@link UnfazedNotFoundError} when the workspace is absent or not
|
|
126
|
+
* visible to this token.
|
|
127
|
+
*/
|
|
128
|
+
async liveApps(workspaceId, options) {
|
|
129
|
+
const data = await this.client.query(ops.WorkspaceLiveAppsDocument, { id: workspaceId }, options);
|
|
130
|
+
if (!data.workspace) {
|
|
131
|
+
throw new UnfazedNotFoundError(`unfazed: workspace ${workspaceId} not found`);
|
|
132
|
+
}
|
|
133
|
+
return data.workspace.liveApps;
|
|
134
|
+
}
|
|
135
|
+
async deleteApp(app, options = {}) {
|
|
136
|
+
try {
|
|
137
|
+
const data = await this.client.mutate(ops.DeleteAppDocument, { workspaceID: app.workspaceId, alias: app.alias }, options);
|
|
138
|
+
return data.deleteApp;
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
if (options.ignoreNotFound && (isNotFound(e, WORKSPACE_NOT_FOUND) || isAppNotFound(e, app.alias))) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
82
144
|
throw e;
|
|
83
145
|
}
|
|
84
146
|
}
|
|
@@ -120,48 +182,143 @@ export class Unfazed {
|
|
|
120
182
|
}
|
|
121
183
|
/* ------------------------------------------------------------------- files */
|
|
122
184
|
/**
|
|
123
|
-
* Writes many files in
|
|
124
|
-
*
|
|
125
|
-
* The API is one-file-per-mutation, so writing a 12-file app naively costs 12
|
|
126
|
-
* round trips. This batches them into a single GraphQL document with one
|
|
127
|
-
* ALIASED root field per file (`f0: writeWorkspaceTextFile(...)`,
|
|
128
|
-
* `f1: ...`), which the server resolves in one request.
|
|
185
|
+
* Writes many files in ONE request — and, on an app with platform git, ONE
|
|
186
|
+
* commit — exactly as MCP `write_workspace_files` does.
|
|
129
187
|
*
|
|
130
188
|
* ```ts
|
|
131
|
-
* await unfazed.writeFiles(ws.id, {
|
|
189
|
+
* const out = await unfazed.writeFiles(ws.id, {
|
|
132
190
|
* "server.ts": serverCode,
|
|
133
|
-
* "
|
|
191
|
+
* "package.json": JSON.stringify(pkg),
|
|
134
192
|
* "public/logo.png": { bytes: pngBytes },
|
|
135
|
-
* });
|
|
193
|
+
* }, { commitMessage: "Add the server" });
|
|
194
|
+
* out.commit; // { sha, committed, waiting, conflicts, writeSHA, … } or null
|
|
136
195
|
* ```
|
|
137
196
|
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
* {@link
|
|
141
|
-
*
|
|
197
|
+
* A call is not transactional: files are written in order, each atomically,
|
|
198
|
+
* and a file's failure does not undo the others. When any file fails this
|
|
199
|
+
* throws {@link UnfazedWriteError} (its `result` lists what was written and
|
|
200
|
+
* `failed` what was not; retry only those), unless `allowPartial`. A
|
|
201
|
+
* repeated path keeps its last content.
|
|
202
|
+
*
|
|
203
|
+
* On an app with platform git the commit is made before any file is
|
|
204
|
+
* written: if it fails, the request fails and nothing is written. `commit`
|
|
205
|
+
* may come back `waiting` — the files are saved and the commit lands in
|
|
206
|
+
* order; pass `waitForCommit: true` (or call {@link waitForCommit}) to
|
|
207
|
+
* resolve only once it has. See {@link FileCommit} for saved, committed and
|
|
208
|
+
* deployed.
|
|
209
|
+
*
|
|
210
|
+
* One request carries up to 100 files and 32 MiB; a bigger map is split
|
|
211
|
+
* into several requests, each its own commit (`commits`). On a platform
|
|
212
|
+
* older than the batch write the SDK falls back to one mutation per file
|
|
213
|
+
* (one commit each) and reports `commit: null`.
|
|
142
214
|
*/
|
|
143
215
|
async writeFiles(workspaceId, files, options = {}) {
|
|
144
216
|
const entries = Object.entries(files).map(([path, content]) => normalizeFile(path, content));
|
|
217
|
+
const result = {
|
|
218
|
+
workspaceId,
|
|
219
|
+
files: [],
|
|
220
|
+
written: 0,
|
|
221
|
+
failed: 0,
|
|
222
|
+
totalBytes: 0,
|
|
223
|
+
commit: null,
|
|
224
|
+
commits: [],
|
|
225
|
+
};
|
|
145
226
|
if (entries.length === 0)
|
|
146
|
-
return
|
|
147
|
-
const batchSize = options.batchSize ??
|
|
148
|
-
const maxBatchBytes = options.maxBatchBytes ??
|
|
149
|
-
const
|
|
227
|
+
return result;
|
|
228
|
+
const batchSize = Math.min(options.batchSize ?? MAX_BATCH_FILES, MAX_BATCH_FILES);
|
|
229
|
+
const maxBatchBytes = options.maxBatchBytes ?? MAX_BATCH_BYTES;
|
|
230
|
+
const requestOptions = options.signal ? { signal: options.signal } : {};
|
|
150
231
|
for (const batch of chunkFiles(entries, batchSize, maxBatchBytes)) {
|
|
151
|
-
written
|
|
232
|
+
const written = this.#legacyWrites
|
|
233
|
+
? await this.#writeBatchLegacy(workspaceId, batch, requestOptions)
|
|
234
|
+
: await this.#writeBatch(workspaceId, batch, options.commitMessage, requestOptions);
|
|
235
|
+
result.files.push(...written.files);
|
|
236
|
+
result.written += written.written;
|
|
237
|
+
result.failed += written.failed;
|
|
238
|
+
result.totalBytes += written.totalBytes;
|
|
239
|
+
if (written.commit) {
|
|
240
|
+
result.commits.push(written.commit);
|
|
241
|
+
result.commit = written.commit;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// Only a waiting commit lands by itself; a paused one waits for the
|
|
245
|
+
// operator's recovery, which no client wait should block on.
|
|
246
|
+
if (options.waitForCommit && result.commits.some((c) => c.waiting)) {
|
|
247
|
+
const waitOptions = options.waitForCommit === true ? {} : options.waitForCommit;
|
|
248
|
+
result.landed = await this.waitForCommit(workspaceId, {
|
|
249
|
+
...waitOptions,
|
|
250
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
if (result.failed > 0 && !options.allowPartial) {
|
|
254
|
+
throw new UnfazedWriteError(result, result.files.filter((f) => !f.ok).map((f) => ({ path: f.path, error: f.error ?? "not written" })));
|
|
152
255
|
}
|
|
153
|
-
return
|
|
256
|
+
return result;
|
|
154
257
|
}
|
|
155
|
-
/**
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
258
|
+
/**
|
|
259
|
+
* Writes one text file: {@link writeFiles} with one entry, so it is one
|
|
260
|
+
* commit with `commitMessage` on an app with platform git, and the answer
|
|
261
|
+
* carries that commit. Throws {@link UnfazedWriteError} when it is refused.
|
|
262
|
+
*/
|
|
263
|
+
async writeFile(workspaceId, path, text, options = {}) {
|
|
264
|
+
return this.#writeOne(workspaceId, path, text, options);
|
|
265
|
+
}
|
|
266
|
+
/** Writes one binary file (raw bytes or base64), like {@link writeFile}. */
|
|
267
|
+
async writeBinaryFile(workspaceId, path, content, options = {}) {
|
|
268
|
+
const file = content instanceof Uint8Array || content instanceof ArrayBuffer ? { bytes: content } : content;
|
|
269
|
+
return this.#writeOne(workspaceId, path, file, options);
|
|
159
270
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
271
|
+
async #writeOne(workspaceId, path, content, options) {
|
|
272
|
+
const out = await this.writeFiles(workspaceId, { [path]: content }, options);
|
|
273
|
+
const file = out.files[0]?.file;
|
|
274
|
+
if (!file)
|
|
275
|
+
throw new UnfazedError(`unfazed: ${path} was not written`);
|
|
276
|
+
return { ...file, commit: out.commit };
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Waits until no file write of the app waits for its commit: platform git
|
|
280
|
+
* commits a write at once, but when the git server is slow the write is
|
|
281
|
+
* saved and its commit `waiting`, landing in order. Resolves with the
|
|
282
|
+
* branch head then — every write made before the call is in its history.
|
|
283
|
+
* An app without platform git resolves at once (`head: null`).
|
|
284
|
+
*
|
|
285
|
+
* Default timeout 2 minutes; throws {@link UnfazedTimeoutError} past it
|
|
286
|
+
* (the writes stay saved, and still land).
|
|
287
|
+
*/
|
|
288
|
+
async waitForCommit(workspaceId, waitOptions = {}) {
|
|
289
|
+
const resolved = resolveWaitOptions({
|
|
290
|
+
timeoutMs: 120_000,
|
|
291
|
+
initialIntervalMs: 250,
|
|
292
|
+
maxIntervalMs: 2_000,
|
|
293
|
+
...waitOptions,
|
|
294
|
+
});
|
|
295
|
+
const requestOptions = resolved.signal ? { signal: resolved.signal } : {};
|
|
296
|
+
const started = resolved.now();
|
|
297
|
+
let waiting = 0;
|
|
298
|
+
return pollWithBackoff(async () => {
|
|
299
|
+
let page;
|
|
300
|
+
try {
|
|
301
|
+
page = (await this.client.query(ops.AppCommitsWaitingDocument, { workspaceID: workspaceId }, requestOptions))
|
|
302
|
+
.appCommits;
|
|
303
|
+
}
|
|
304
|
+
catch (e) {
|
|
305
|
+
if (!(e instanceof UnfazedGraphQLError))
|
|
306
|
+
throw e;
|
|
307
|
+
// The history can be unreadable while writes still wait: keep waiting.
|
|
308
|
+
if (e.errors.some((x) => /waiting to be committed/i.test(x.message)))
|
|
309
|
+
return { done: false };
|
|
310
|
+
// An app without platform git has no commits to wait for.
|
|
311
|
+
if (e.errors.some((x) => NO_PLATFORM_GIT.test(x.message))) {
|
|
312
|
+
return { done: true, value: { branch: "", head: null, waitedMs: resolved.now() - started } };
|
|
313
|
+
}
|
|
314
|
+
throw e;
|
|
315
|
+
}
|
|
316
|
+
waiting = page.waitingCommits;
|
|
317
|
+
if (page.waitingCommits > 0)
|
|
318
|
+
return { done: false };
|
|
319
|
+
return { done: true, value: { branch: page.branch, head: page.head ?? null, waitedMs: resolved.now() - started } };
|
|
320
|
+
}, resolved, () => new UnfazedTimeoutError(`unfazed: ${waiting} write(s) of workspace ${workspaceId} still wait for their commit after ${resolved.timeoutMs}ms; ` +
|
|
321
|
+
"they are saved and land in order"));
|
|
165
322
|
}
|
|
166
323
|
/** Lists workspace files (paginated). */
|
|
167
324
|
async listFiles(workspaceId, input = {}, options) {
|
|
@@ -266,29 +423,193 @@ export class Unfazed {
|
|
|
266
423
|
}
|
|
267
424
|
async #deploy(input, options) {
|
|
268
425
|
const requestOptions = options.signal ? { signal: options.signal } : {};
|
|
426
|
+
const now = options.waitOptions?.now ?? (() => Date.now());
|
|
427
|
+
const sent = now();
|
|
269
428
|
const data = await this.client.mutate(ops.CreateDeploymentDocument, { input }, requestOptions);
|
|
270
429
|
const deployment = data.createDeployment.deployment;
|
|
271
430
|
if (!options.wait)
|
|
272
431
|
return deployment;
|
|
273
|
-
|
|
432
|
+
const created = now();
|
|
433
|
+
const waited = await this.#waitTerminal(deployment.id, {
|
|
274
434
|
...options.waitOptions,
|
|
275
435
|
...(options.signal ? { signal: options.signal } : {}),
|
|
276
436
|
});
|
|
437
|
+
const seen = now();
|
|
438
|
+
const timings = {
|
|
439
|
+
client: {
|
|
440
|
+
createMs: created - sent,
|
|
441
|
+
waitMs: seen - created,
|
|
442
|
+
totalMs: seen - sent,
|
|
443
|
+
waitRequests: waited.requests,
|
|
444
|
+
waitMode: waited.mode,
|
|
445
|
+
},
|
|
446
|
+
platform: null,
|
|
447
|
+
};
|
|
448
|
+
if (options.timings !== false) {
|
|
449
|
+
timings.platform = await this.#readyTimings(deployment.id, options.waitOptions, requestOptions);
|
|
450
|
+
}
|
|
451
|
+
return { ...waited.deployment, timings };
|
|
277
452
|
}
|
|
278
453
|
/**
|
|
279
|
-
*
|
|
454
|
+
* Waits until a deployment is READY, throwing {@link UnfazedDeployError} on
|
|
455
|
+
* FAILED or timeout (default 10 minutes).
|
|
456
|
+
*
|
|
457
|
+
* It waits SERVER-SIDE (MCP `wait_for_deployment`): each request blocks on
|
|
458
|
+
* the control plane until the deployment leaves BUILDING — woken by the
|
|
459
|
+
* deploy pipeline itself — or 45 s pass, and comes back with the full
|
|
460
|
+
* deployment; so READY reaches you one round trip after it happens, not at
|
|
461
|
+
* the next poll. A NEW app that starts directly on its server is READY
|
|
462
|
+
* before it serves there; the wait covers that landing too. On a platform
|
|
463
|
+
* without the server-side wait, or when the platform refuses one because
|
|
464
|
+
* the account already holds its cap of waits in progress (16), it polls
|
|
465
|
+
* instead (`waitOptions.mode: "poll"` forces that), every 0.5 s backing
|
|
466
|
+
* off to 3 s by default.
|
|
280
467
|
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
468
|
+
* Pass a {@link CommitDeploymentTarget} instead of an id to wait for the
|
|
469
|
+
* deploy a git import makes of a commit — a restore, a push or a sync
|
|
470
|
+
* deploys before any id exists. The wait covers the import too, and
|
|
471
|
+
* {@link UnfazedDeployError} carries the import's error when it failed on
|
|
472
|
+
* the commit.
|
|
473
|
+
*
|
|
474
|
+
* ```ts
|
|
475
|
+
* const back = await unfazed.restoreVersion({ workspaceId: ws.id, sha });
|
|
476
|
+
* await unfazed.waitForDeployment({
|
|
477
|
+
* workspaceId: back.workspaceID, gitCommit: back.sha, alias: back.deployAlias, after: back.waitAfter,
|
|
478
|
+
* });
|
|
479
|
+
* ```
|
|
286
480
|
*/
|
|
287
|
-
async waitForDeployment(
|
|
481
|
+
async waitForDeployment(target, waitOptions = {}) {
|
|
482
|
+
if (typeof target !== "string")
|
|
483
|
+
return (await this.#commitWait(target, waitOptions)).deployment;
|
|
484
|
+
return (await this.#waitTerminal(target, waitOptions)).deployment;
|
|
485
|
+
}
|
|
486
|
+
async #waitTerminal(deploymentId, waitOptions) {
|
|
487
|
+
const now = waitOptions.now ?? (() => Date.now());
|
|
488
|
+
const started = now();
|
|
489
|
+
if ((waitOptions.mode ?? "server") === "server" && !this.#noServerWait) {
|
|
490
|
+
try {
|
|
491
|
+
return await this.#serverWait(deploymentId, waitOptions);
|
|
492
|
+
}
|
|
493
|
+
catch (e) {
|
|
494
|
+
if (isUnsupportedField(e, "waitForDeployment")) {
|
|
495
|
+
// An older platform: poll, and do not ask again on this instance.
|
|
496
|
+
this.#noServerWait = true;
|
|
497
|
+
}
|
|
498
|
+
else if (!isTooManyWaits(e)) {
|
|
499
|
+
throw e;
|
|
500
|
+
}
|
|
501
|
+
// Past the platform's cap on waits in progress (many parallel waits
|
|
502
|
+
// of one account): this wait polls instead, for what is left of its
|
|
503
|
+
// budget.
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
const timeoutMs = Math.max(0, (waitOptions.timeoutMs ?? 600_000) - (now() - started));
|
|
507
|
+
return this.#pollWait(deploymentId, { ...waitOptions, timeoutMs });
|
|
508
|
+
}
|
|
509
|
+
async #serverWait(deploymentId, waitOptions) {
|
|
288
510
|
const resolved = resolveWaitOptions(waitOptions);
|
|
511
|
+
const deadline = resolved.now() + resolved.timeoutMs;
|
|
512
|
+
let last = null;
|
|
513
|
+
let landing = false;
|
|
514
|
+
for (let requests = 1;; requests++) {
|
|
515
|
+
if (resolved.signal?.aborted)
|
|
516
|
+
throw new UnfazedTimeoutError("unfazed: wait aborted");
|
|
517
|
+
const remaining = deadline - resolved.now();
|
|
518
|
+
if (requests > 1 && remaining <= 0) {
|
|
519
|
+
throw new UnfazedDeployError("TIMEOUT", deploymentId, last, `unfazed: deployment ${deploymentId} still ${last?.status ?? "BUILDING"}` +
|
|
520
|
+
`${landing ? " (READY, still landing on its server)" : ""} after ${resolved.timeoutMs}ms`);
|
|
521
|
+
}
|
|
522
|
+
const seconds = Math.max(1, Math.min(SERVER_WAIT_SECONDS, Math.ceil(remaining / 1000)));
|
|
523
|
+
let answer;
|
|
524
|
+
try {
|
|
525
|
+
answer = (await this.client.query(ops.WaitForDeploymentDocument, { id: deploymentId, timeoutSeconds: seconds },
|
|
526
|
+
// The request is a long poll: give it the wait plus a round trip,
|
|
527
|
+
// whatever the client's default per-request timeout is.
|
|
528
|
+
{ ...(resolved.signal ? { signal: resolved.signal } : {}), timeoutMs: (seconds + 30) * 1000 })).waitForDeployment;
|
|
529
|
+
}
|
|
530
|
+
catch (e) {
|
|
531
|
+
if (isNotFound(e, DEPLOYMENT_NOT_FOUND)) {
|
|
532
|
+
throw new UnfazedNotFoundError(`unfazed: deployment ${deploymentId} not found`);
|
|
533
|
+
}
|
|
534
|
+
throw e;
|
|
535
|
+
}
|
|
536
|
+
// By id the platform always answers the deployment; null is only for a
|
|
537
|
+
// wait by commit before the import made it.
|
|
538
|
+
const d = answer.deployment;
|
|
539
|
+
if (!d)
|
|
540
|
+
throw new UnfazedNotFoundError(`unfazed: deployment ${deploymentId} not found`);
|
|
541
|
+
last = d;
|
|
542
|
+
if (answer.timedOut) {
|
|
543
|
+
landing = d.status === "READY";
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
if (d.status === "READY")
|
|
547
|
+
return { deployment: d, requests, mode: "server" };
|
|
548
|
+
throw new UnfazedDeployError("FAILED", deploymentId, d, `unfazed: deployment ${deploymentId} ${d.status}${d.error ? `: ${d.error}` : ""}`);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* The server-side wait by commit: for the import to make the commit's
|
|
553
|
+
* deployment, then for it, one budget for both. There is nothing cheaper to
|
|
554
|
+
* poll, so a refusal past the platform's cap on waits in progress backs off
|
|
555
|
+
* (0.5 s up to 3 s) and asks again.
|
|
556
|
+
*/
|
|
557
|
+
async #commitWait(target, waitOptions) {
|
|
558
|
+
const resolved = resolveWaitOptions(waitOptions);
|
|
559
|
+
const deadline = resolved.now() + resolved.timeoutMs;
|
|
560
|
+
const commit = target.gitCommit.slice(0, 7);
|
|
561
|
+
const what = `the deploy of ${commit}${target.alias ? ` to ${target.alias}` : ""}`;
|
|
562
|
+
let last = null;
|
|
563
|
+
let status = "IMPORTING";
|
|
564
|
+
let backoff = 500;
|
|
565
|
+
for (let requests = 1;; requests++) {
|
|
566
|
+
if (resolved.signal?.aborted)
|
|
567
|
+
throw new UnfazedTimeoutError("unfazed: wait aborted");
|
|
568
|
+
const remaining = deadline - resolved.now();
|
|
569
|
+
if (requests > 1 && remaining <= 0) {
|
|
570
|
+
throw new UnfazedDeployError("TIMEOUT", last?.id ?? "", last, `unfazed: ${what} still ${status} after ${resolved.timeoutMs}ms`);
|
|
571
|
+
}
|
|
572
|
+
const seconds = Math.max(1, Math.min(SERVER_WAIT_SECONDS, Math.ceil(remaining / 1000)));
|
|
573
|
+
let answer;
|
|
574
|
+
try {
|
|
575
|
+
answer = (await this.client.query(ops.WaitForCommitDeploymentDocument, {
|
|
576
|
+
workspaceID: target.workspaceId,
|
|
577
|
+
gitCommit: target.gitCommit,
|
|
578
|
+
alias: target.alias ?? null,
|
|
579
|
+
after: target.after ?? null,
|
|
580
|
+
timeoutSeconds: seconds,
|
|
581
|
+
}, { ...(resolved.signal ? { signal: resolved.signal } : {}), timeoutMs: (seconds + 30) * 1000 })).waitForDeployment;
|
|
582
|
+
}
|
|
583
|
+
catch (e) {
|
|
584
|
+
if (!isTooManyWaits(e))
|
|
585
|
+
throw e;
|
|
586
|
+
await resolved.sleep(Math.max(0, Math.min(backoff, deadline - resolved.now())), resolved.signal);
|
|
587
|
+
backoff = Math.min(backoff * 1.5, 3_000);
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
const d = answer.deployment;
|
|
591
|
+
status = answer.status;
|
|
592
|
+
if (d)
|
|
593
|
+
last = d;
|
|
594
|
+
if (answer.timedOut)
|
|
595
|
+
continue;
|
|
596
|
+
if (!d) {
|
|
597
|
+
// Not timed out and no deployment: the import failed on the commit
|
|
598
|
+
// and nothing imports any more, so no deployment of it is coming.
|
|
599
|
+
throw new UnfazedDeployError("FAILED", "", null, `unfazed: the import of ${commit} failed${answer.importError ? `: ${answer.importError}` : ""}`);
|
|
600
|
+
}
|
|
601
|
+
if (d.status === "READY")
|
|
602
|
+
return { deployment: d, requests };
|
|
603
|
+
throw new UnfazedDeployError("FAILED", d.id, d, `unfazed: deployment ${d.id} (${what}) ${d.status}${d.error ? `: ${d.error}` : ""}`);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
async #pollWait(deploymentId, waitOptions) {
|
|
607
|
+
const resolved = resolveWaitOptions({ initialIntervalMs: 500, maxIntervalMs: 3_000, ...waitOptions });
|
|
289
608
|
const requestOptions = resolved.signal ? { signal: resolved.signal } : {};
|
|
290
609
|
let last = null;
|
|
610
|
+
let requests = 0;
|
|
291
611
|
await pollWithBackoff(async () => {
|
|
612
|
+
requests++;
|
|
292
613
|
const data = await this.client.query(ops.PollDeploymentDocument, { id: deploymentId }, requestOptions);
|
|
293
614
|
const d = data.deployment;
|
|
294
615
|
if (!d) {
|
|
@@ -302,10 +623,43 @@ export class Unfazed {
|
|
|
302
623
|
}
|
|
303
624
|
return { done: false };
|
|
304
625
|
}, resolved, () => new UnfazedDeployError("TIMEOUT", deploymentId, last, `unfazed: deployment ${deploymentId} still ${last?.status ?? "BUILDING"} after ${resolved.timeoutMs}ms`));
|
|
626
|
+
// The poll query is narrow; re-read the READY deployment in full so the
|
|
627
|
+
// answer has the shape deployService returns without `wait`.
|
|
305
628
|
const full = await this.getDeployment(deploymentId, requestOptions);
|
|
306
629
|
if (!full)
|
|
307
630
|
throw new UnfazedNotFoundError(`unfazed: deployment ${deploymentId} not found`);
|
|
308
|
-
return full;
|
|
631
|
+
return { deployment: full, requests: requests + 1, mode: "poll" };
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* The platform's measured phases of a deploy ({@link PlatformTimings}), as
|
|
635
|
+
* it parses them from all of the deployment's timing events
|
|
636
|
+
* (`Deployment.timings`). Null when it recorded none (a deploy that is
|
|
637
|
+
* still building, or one from before timings existed), or for a deployment
|
|
638
|
+
* you cannot see.
|
|
639
|
+
*/
|
|
640
|
+
async getDeploymentTimings(deploymentId, options) {
|
|
641
|
+
const data = await this.client.query(ops.DeploymentTimingsDocument, { id: deploymentId }, options);
|
|
642
|
+
const timings = data.deployment?.timings;
|
|
643
|
+
return timings ? toPlatformTimings(timings) : null;
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* The timings of a deploy that just went READY. `deployment.phases` is
|
|
647
|
+
* written right after the READY transition, so a read that races it is
|
|
648
|
+
* retried once.
|
|
649
|
+
*/
|
|
650
|
+
async #readyTimings(deploymentId, waitOptions, requestOptions) {
|
|
651
|
+
try {
|
|
652
|
+
const first = await this.getDeploymentTimings(deploymentId, requestOptions);
|
|
653
|
+
if (first?.totalMs != null)
|
|
654
|
+
return first;
|
|
655
|
+
const sleep = waitOptions?.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
656
|
+
await sleep(250, requestOptions.signal);
|
|
657
|
+
return (await this.getDeploymentTimings(deploymentId, requestOptions)) ?? first;
|
|
658
|
+
}
|
|
659
|
+
catch {
|
|
660
|
+
// Timings are a diagnostic: never fail a deploy that went READY.
|
|
661
|
+
return null;
|
|
662
|
+
}
|
|
309
663
|
}
|
|
310
664
|
/** Reads one deployment by id. Null when absent. */
|
|
311
665
|
async getDeployment(deploymentId, options) {
|
|
@@ -340,10 +694,25 @@ export class Unfazed {
|
|
|
340
694
|
const data = await this.client.query(ops.ServiceLogsDocument, { deploymentID: deploymentId, tailBytes: input.tailBytes ?? null }, options);
|
|
341
695
|
return data.serviceLogs;
|
|
342
696
|
}
|
|
343
|
-
/**
|
|
697
|
+
/**
|
|
698
|
+
* Deployment lifecycle events — build, boot, probe, cron, restarts — the
|
|
699
|
+
* most recent `limit` (default 20), oldest first.
|
|
700
|
+
*
|
|
701
|
+
* `type` is the event's own name, as MCP reports it:
|
|
702
|
+
* `"deployment.build_finished"`, `"deployment.phases"`,
|
|
703
|
+
* `"service.vm_started"`, `"service.crashed"`. (`typeEnum` keeps the GraphQL
|
|
704
|
+
* enum, which reads `UNKNOWN` for names it does not list.) Timing events
|
|
705
|
+
* carry their phases in `metadata`; {@link getDeploymentTimings} turns them
|
|
706
|
+
* into numbers.
|
|
707
|
+
*
|
|
708
|
+
* ```ts
|
|
709
|
+
* const events = await unfazed.getEvents(app.id, 50);
|
|
710
|
+
* events.filter((e) => e.type === "deployment.build_finished");
|
|
711
|
+
* ```
|
|
712
|
+
*/
|
|
344
713
|
async getEvents(deploymentId, limit, options) {
|
|
345
714
|
const data = await this.client.query(ops.DeploymentEventsDocument, { deploymentID: deploymentId, limit: limit ?? null }, options);
|
|
346
|
-
return data.deploymentEvents;
|
|
715
|
+
return data.deploymentEvents.map(toDeploymentEvent);
|
|
347
716
|
}
|
|
348
717
|
/** Restarts a service's VM. */
|
|
349
718
|
async restartService(deploymentId, options) {
|
|
@@ -357,6 +726,12 @@ export class Unfazed {
|
|
|
357
726
|
}
|
|
358
727
|
/**
|
|
359
728
|
* Deletes a deployment: stops its VM, releases its alias, drops its release.
|
|
729
|
+
* To delete a whole app — every release of the alias, its env, App State and
|
|
730
|
+
* bindings — use {@link deleteApp}.
|
|
731
|
+
*
|
|
732
|
+
* The platform keeps a deleted release for 30 days: within that time
|
|
733
|
+
* support (the platform operator) can restore it; then it is erased for
|
|
734
|
+
* good.
|
|
360
735
|
*
|
|
361
736
|
* With `ignoreNotFound` a deployment that is absent — or belongs to another
|
|
362
737
|
* tenant, which the server reports identically so ids cannot be probed —
|
|
@@ -634,6 +1009,197 @@ export class Unfazed {
|
|
|
634
1009
|
}
|
|
635
1010
|
}
|
|
636
1011
|
}
|
|
1012
|
+
/* ------------------------------------------------------- platform git history
|
|
1013
|
+
*
|
|
1014
|
+
* An app with platform git (MCP `enable_platform_git`; apps get it by
|
|
1015
|
+
* themselves at their next change) keeps every file write, push and restore
|
|
1016
|
+
* as a commit. These are MCP's history tools.
|
|
1017
|
+
*/
|
|
1018
|
+
/**
|
|
1019
|
+
* The app's commits, newest first (MCP `list_commits`): `limit` per page
|
|
1020
|
+
* (default 20, max 100), `before: page.nextBefore` for the next. `deployed`
|
|
1021
|
+
* says what every live alias runs; `waitingCommits` counts writes whose
|
|
1022
|
+
* commit has not landed yet.
|
|
1023
|
+
*/
|
|
1024
|
+
async listCommits(workspaceId, input = {}, options) {
|
|
1025
|
+
const data = await this.client.query(ops.AppCommitsDocument, { workspaceID: workspaceId, limit: input.limit ?? null, before: input.before ?? null }, options);
|
|
1026
|
+
return data.appCommits;
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* One commit (full 40-character sha) with the paths it changed and a
|
|
1030
|
+
* unified diff against its first parent, capped at 64 KiB (MCP
|
|
1031
|
+
* `get_commit`). Commit messages and diffs are untrusted text.
|
|
1032
|
+
*/
|
|
1033
|
+
async getCommit(workspaceId, sha, options) {
|
|
1034
|
+
const data = await this.client.query(ops.AppCommitDocument, { workspaceID: workspaceId, sha }, options);
|
|
1035
|
+
return data.appCommit;
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* Gives the app back the files of an earlier commit as ONE new commit on
|
|
1039
|
+
* its branch (MCP `restore_version`); nothing is rewritten, and a restore is
|
|
1040
|
+
* undone by restoring the commit before it.
|
|
1041
|
+
*
|
|
1042
|
+
* When the app deploys every push (`deploys: true`), the new commit is
|
|
1043
|
+
* imported and deployed like a push. That deploy is queued behind the
|
|
1044
|
+
* restore, so the platform answers before it exists; with `wait: true` the
|
|
1045
|
+
* SDK waits for it by commit on the platform ({@link Unfazed.waitForDeployment}
|
|
1046
|
+
* with `gitCommit: sha`, `alias: deployAlias`, `after: waitAfter`) and
|
|
1047
|
+
* resolves with it READY, or throws {@link UnfazedDeployError} with its
|
|
1048
|
+
* build error or the import's. A link with unfazed.json `apps[]` names no
|
|
1049
|
+
* `deployAlias` and deploys once per app: then it follows the import until
|
|
1050
|
+
* every deployment it made is done.
|
|
1051
|
+
*
|
|
1052
|
+
* ```ts
|
|
1053
|
+
* const page = await unfazed.listCommits(ws.id);
|
|
1054
|
+
* const back = await unfazed.restoreVersion({ workspaceId: ws.id, sha: page.commits[1]!.sha, wait: true });
|
|
1055
|
+
* back.deployments?.[0]?.url;
|
|
1056
|
+
* ```
|
|
1057
|
+
*/
|
|
1058
|
+
async restoreVersion(options) {
|
|
1059
|
+
const requestOptions = options.signal ? { signal: options.signal } : {};
|
|
1060
|
+
const data = await this.client.mutate(ops.RestoreVersionDocument, {
|
|
1061
|
+
workspaceID: options.workspaceId,
|
|
1062
|
+
sha: options.sha,
|
|
1063
|
+
expectedHead: options.expectedHead ?? null,
|
|
1064
|
+
message: options.commitMessage ?? null,
|
|
1065
|
+
}, requestOptions);
|
|
1066
|
+
const restored = data.restoreAppVersion;
|
|
1067
|
+
if (!options.wait || !restored.deploys)
|
|
1068
|
+
return restored;
|
|
1069
|
+
const waitOptions = { ...options.waitOptions, ...(options.signal ? { signal: options.signal } : {}) };
|
|
1070
|
+
if (restored.deployAlias) {
|
|
1071
|
+
const { deployment } = await this.#commitWait({ workspaceId: restored.workspaceID, gitCommit: restored.sha, alias: restored.deployAlias, after: restored.waitAfter }, waitOptions);
|
|
1072
|
+
return { ...restored, deployments: [deployment] };
|
|
1073
|
+
}
|
|
1074
|
+
return { ...restored, deployments: await this.#waitForImport(restored.workspaceID, restored.sha, waitOptions) };
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* Waits until the app's git link has imported `sha` (READY: its deploys
|
|
1078
|
+
* are done) and returns the deployments of that commit; throws on a FAILED
|
|
1079
|
+
* import of it. Only for a restore whose link names no alias (unfazed.json
|
|
1080
|
+
* `apps[]`): the wait by commit waits for one app's deploy, and which apps
|
|
1081
|
+
* a commit deploys is known only once its import is done.
|
|
1082
|
+
*/
|
|
1083
|
+
async #waitForImport(workspaceId, sha, waitOptions) {
|
|
1084
|
+
const resolved = resolveWaitOptions({ initialIntervalMs: 500, maxIntervalMs: 3_000, ...waitOptions });
|
|
1085
|
+
const requestOptions = resolved.signal ? { signal: resolved.signal } : {};
|
|
1086
|
+
let last = null;
|
|
1087
|
+
await pollWithBackoff(async () => {
|
|
1088
|
+
const data = await this.client.query(ops.WorkspaceGitSourceDocument, { id: workspaceId }, requestOptions);
|
|
1089
|
+
const src = data.workspace?.gitSource ?? null;
|
|
1090
|
+
if (!src)
|
|
1091
|
+
throw new UnfazedNotFoundError(`unfazed: workspace ${workspaceId} has no git source`);
|
|
1092
|
+
last = src;
|
|
1093
|
+
if (src.lastImportedCommit !== sha)
|
|
1094
|
+
return { done: false };
|
|
1095
|
+
if (src.lastImportStatus === "READY")
|
|
1096
|
+
return { done: true, value: true };
|
|
1097
|
+
if (src.lastImportStatus === "FAILED") {
|
|
1098
|
+
throw new UnfazedDeployError("FAILED", "", null, `unfazed: the import of ${sha.slice(0, 7)} failed${src.lastImportError ? `: ${src.lastImportError}` : ""}`);
|
|
1099
|
+
}
|
|
1100
|
+
return { done: false };
|
|
1101
|
+
}, resolved, () => new UnfazedDeployError("TIMEOUT", "", null, `unfazed: ${sha.slice(0, 7)} was not imported after ${resolved.timeoutMs}ms` +
|
|
1102
|
+
(last ? ` (last import: ${last.lastImportStatus} ${last.lastImportedCommit?.slice(0, 7) ?? ""})` : "")));
|
|
1103
|
+
const page = await this.listDeployments({ workspaceID: workspaceId, limit: 50 }, requestOptions);
|
|
1104
|
+
return page.items.filter((d) => d.gitCommit === sha);
|
|
1105
|
+
}
|
|
1106
|
+
/* ------------------------------------------------------------- app errors
|
|
1107
|
+
*
|
|
1108
|
+
* Native error tracking: a service app's uncaught exceptions, unhandled
|
|
1109
|
+
* rejections, panics and Sentry SDK reports, grouped, kept on its server
|
|
1110
|
+
* for 30 days. These are MCP's error tools. Titles, stacks and requests are
|
|
1111
|
+
* what the app reported: untrusted text.
|
|
1112
|
+
*/
|
|
1113
|
+
/** A service app's error groups, most recently seen first (MCP `list_errors`). */
|
|
1114
|
+
async listErrors(input, options) {
|
|
1115
|
+
const data = await this.client.query(ops.AppErrorsDocument, {
|
|
1116
|
+
workspaceID: input.workspaceId,
|
|
1117
|
+
alias: input.alias,
|
|
1118
|
+
status: input.status ?? null,
|
|
1119
|
+
limit: input.limit ?? null,
|
|
1120
|
+
cursor: input.cursor ?? null,
|
|
1121
|
+
}, options);
|
|
1122
|
+
return data.appErrors;
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* One error group with its latest occurrence whole (a Sentry event: stack,
|
|
1126
|
+
* request), its recent occurrences and the deployments it was seen in (MCP
|
|
1127
|
+
* `get_error_group`).
|
|
1128
|
+
*/
|
|
1129
|
+
async getErrorGroup(ref, options) {
|
|
1130
|
+
const data = await this.client.query(ops.AppErrorGroupDocument, { workspaceID: ref.workspaceId, alias: ref.alias, groupID: ref.groupId }, options);
|
|
1131
|
+
return data.appErrorGroup;
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* Marks a group resolved in the app's current deployment, once its fix is
|
|
1135
|
+
* deployed (MCP `resolve_error_group`); another deployment reporting it
|
|
1136
|
+
* opens it again. Only on your human's say-so or a fix you verified.
|
|
1137
|
+
*/
|
|
1138
|
+
async resolveErrorGroup(ref, options) {
|
|
1139
|
+
const data = await this.client.mutate(ops.ResolveErrorGroupDocument, { input: errorGroupInput(ref) }, options);
|
|
1140
|
+
return data.resolveAppErrorGroup;
|
|
1141
|
+
}
|
|
1142
|
+
/** Mutes an expected group: still counted, never regresses or notifies (MCP `mute_error_group`). */
|
|
1143
|
+
async muteErrorGroup(ref, options) {
|
|
1144
|
+
const data = await this.client.mutate(ops.MuteErrorGroupDocument, { input: errorGroupInput(ref) }, options);
|
|
1145
|
+
return data.muteAppErrorGroup;
|
|
1146
|
+
}
|
|
1147
|
+
/** Opens a resolved or muted group again (MCP `reopen_error_group`). */
|
|
1148
|
+
async reopenErrorGroup(ref, options) {
|
|
1149
|
+
const data = await this.client.mutate(ops.ReopenErrorGroupDocument, { input: errorGroupInput(ref) }, options);
|
|
1150
|
+
return data.reopenAppErrorGroup;
|
|
1151
|
+
}
|
|
1152
|
+
/* ------------------------------------------------------------------ events
|
|
1153
|
+
*
|
|
1154
|
+
* What happened to the apps, runs and domains you can see —
|
|
1155
|
+
* deployment.finished, service.crashed, error.new, error.regressed,
|
|
1156
|
+
* git.conflict, run.finished, … — instead of re-polling status. The same
|
|
1157
|
+
* feed and cursors as MCP `poll_events` / `wait_for_events`.
|
|
1158
|
+
*/
|
|
1159
|
+
/** The event types, with each one's filters and payload schema (MCP `list_event_types`). */
|
|
1160
|
+
async listEventTypes(options) {
|
|
1161
|
+
const data = await this.client.query(ops.EventTypesDocument, {}, options);
|
|
1162
|
+
return data.platformEventTypes;
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* Events since `cursor` (MCP `poll_events`). Omit `cursor` to start from now
|
|
1166
|
+
* — nothing is replayed — and pass the returned `cursor` back: at once while
|
|
1167
|
+
* `hasMore`, otherwise after `nextPollMs`, or to {@link waitForEvents}.
|
|
1168
|
+
* `truncated` means events may be missing: re-read state.
|
|
1169
|
+
*/
|
|
1170
|
+
async pollEvents(input = {}, options) {
|
|
1171
|
+
const data = await this.client.query(ops.PollEventsDocument, { input: eventsInput(input) }, options);
|
|
1172
|
+
return data.pollEvents;
|
|
1173
|
+
}
|
|
1174
|
+
/**
|
|
1175
|
+
* {@link pollEvents} that waits server-side until an event arrives (MCP
|
|
1176
|
+
* `wait_for_events`), for up to `timeoutMs` (default 45 s; longer waits are
|
|
1177
|
+
* several server waits of up to 45 s, continuing from each cursor). A
|
|
1178
|
+
* timeout is not an error: the page has `timedOut: true`, no events and a
|
|
1179
|
+
* cursor to wait again with. The platform caps an account's waits in
|
|
1180
|
+
* progress (16, deploy waits included); past it this throws an error that
|
|
1181
|
+
* `isTooManyWaits` recognizes: poll then.
|
|
1182
|
+
*
|
|
1183
|
+
* ```ts
|
|
1184
|
+
* let page = await unfazed.pollEvents({ names: ["deployment.finished"] }); // from now
|
|
1185
|
+
* page = await unfazed.waitForEvents({ names: ["deployment.finished"], cursor: page.cursor, timeoutMs: 120_000 });
|
|
1186
|
+
* ```
|
|
1187
|
+
*/
|
|
1188
|
+
async waitForEvents(input = {}, options = {}) {
|
|
1189
|
+
const now = options.now ?? (() => Date.now());
|
|
1190
|
+
const deadline = now() + (input.timeoutMs ?? SERVER_WAIT_SECONDS * 1000);
|
|
1191
|
+
let cursor = input.cursor;
|
|
1192
|
+
for (;;) {
|
|
1193
|
+
const remaining = deadline - now();
|
|
1194
|
+
const seconds = Math.max(1, Math.min(SERVER_WAIT_SECONDS, Math.ceil(remaining / 1000)));
|
|
1195
|
+
const { now: _now, ...requestOptions } = options;
|
|
1196
|
+
const data = await this.client.query(ops.WaitForEventsDocument, { input: eventsInput({ ...input, ...(cursor === undefined ? {} : { cursor }) }), timeoutSeconds: seconds }, { ...requestOptions, timeoutMs: (seconds + 30) * 1000 });
|
|
1197
|
+
const page = data.waitForEvents;
|
|
1198
|
+
if (!page.timedOut || deadline - now() <= 0)
|
|
1199
|
+
return page;
|
|
1200
|
+
cursor = page.cursor;
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
637
1203
|
/* --------------------------------------------------------------- agent runs
|
|
638
1204
|
*
|
|
639
1205
|
* A run is the past-tense counterpart to a deployment: boot a fresh VM off
|
|
@@ -854,7 +1420,47 @@ export class Unfazed {
|
|
|
854
1420
|
return (workspaces.find((w) => w.name === name) ??
|
|
855
1421
|
workspaces.find((w) => w.name.toLowerCase() === folded));
|
|
856
1422
|
}
|
|
857
|
-
|
|
1423
|
+
/** Set once the platform turned out to predate writeWorkspaceFiles. */
|
|
1424
|
+
#legacyWrites = false;
|
|
1425
|
+
/** Set once the platform turned out to predate the server-side deployment wait. */
|
|
1426
|
+
#noServerWait = false;
|
|
1427
|
+
/** One request of {@link writeFiles}: one call of the batch mutation, one commit. */
|
|
1428
|
+
async #writeBatch(workspaceId, batch, commitMessage, requestOptions) {
|
|
1429
|
+
try {
|
|
1430
|
+
const data = await this.client.mutate(ops.WriteFilesDocument, {
|
|
1431
|
+
workspaceID: workspaceId,
|
|
1432
|
+
files: batch.map((f) => ({
|
|
1433
|
+
path: f.path,
|
|
1434
|
+
content: f.content,
|
|
1435
|
+
...(f.kind === "base64" ? { encoding: "base64" } : {}),
|
|
1436
|
+
})),
|
|
1437
|
+
commitMessage: commitMessage ?? null,
|
|
1438
|
+
}, requestOptions);
|
|
1439
|
+
const out = data.writeWorkspaceFiles;
|
|
1440
|
+
return {
|
|
1441
|
+
files: out.files.map((f) => ({ path: f.path, ok: f.ok, error: f.error ?? null, file: f.file ?? null })),
|
|
1442
|
+
written: out.written,
|
|
1443
|
+
failed: out.failed,
|
|
1444
|
+
totalBytes: out.totalBytes,
|
|
1445
|
+
commit: out.commit ?? null,
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1448
|
+
catch (e) {
|
|
1449
|
+
if (!isUnsupportedField(e, "writeWorkspaceFiles") && !isUnsupportedField(e, "WorkspaceFileWriteInput"))
|
|
1450
|
+
throw e;
|
|
1451
|
+
// A platform older than the batch mutation: one mutation per file, as
|
|
1452
|
+
// before 0.8.0. It reports no commit.
|
|
1453
|
+
this.#legacyWrites = true;
|
|
1454
|
+
return this.#writeBatchLegacy(workspaceId, batch, requestOptions);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
/**
|
|
1458
|
+
* The pre-0.8.0 batch: one document with one ALIASED single-file mutation
|
|
1459
|
+
* per file. One request, but one commit per file on an app with platform
|
|
1460
|
+
* git, and no commit in the answer. Used only against platforms without
|
|
1461
|
+
* writeWorkspaceFiles.
|
|
1462
|
+
*/
|
|
1463
|
+
async #writeBatchLegacy(workspaceId, batch, requestOptions) {
|
|
858
1464
|
const varDefs = ["$workspaceID: ID!"];
|
|
859
1465
|
const fields = [];
|
|
860
1466
|
const variables = { workspaceID: workspaceId };
|
|
@@ -867,13 +1473,185 @@ export class Unfazed {
|
|
|
867
1473
|
: ` f${i}: writeWorkspaceBase64File(input: { workspaceID: $workspaceID, path: $p${i}, contentBase64: $c${i} }) { file { ${FILE_SELECTION} } }`);
|
|
868
1474
|
});
|
|
869
1475
|
// Built at runtime rather than codegen'd: the field COUNT varies per call,
|
|
870
|
-
// so no static document can express it. The field shapes are still fixed
|
|
871
|
-
// and
|
|
872
|
-
//
|
|
1476
|
+
// so no static document can express it. The field shapes are still fixed,
|
|
1477
|
+
// and each individual mutation is codegen-validated by its single-file
|
|
1478
|
+
// twin in operations.ts.
|
|
873
1479
|
const document = `mutation SdkWriteFilesBatch(${varDefs.join(", ")}) {\n${fields.join("\n")}\n}`;
|
|
874
|
-
|
|
875
|
-
|
|
1480
|
+
let data;
|
|
1481
|
+
let errors = [];
|
|
1482
|
+
try {
|
|
1483
|
+
data = await this.client.mutate(document, variables, requestOptions);
|
|
1484
|
+
}
|
|
1485
|
+
catch (e) {
|
|
1486
|
+
// Fields answered independently: keep what landed, name what did not.
|
|
1487
|
+
if (!(e instanceof UnfazedGraphQLError) || !e.data)
|
|
1488
|
+
throw e;
|
|
1489
|
+
data = e.data;
|
|
1490
|
+
errors = e.errors;
|
|
1491
|
+
}
|
|
1492
|
+
const files = batch.map((f, i) => {
|
|
1493
|
+
const file = data[`f${i}`]?.file ?? null;
|
|
1494
|
+
if (file)
|
|
1495
|
+
return { path: f.path, ok: true, error: null, file };
|
|
1496
|
+
const error = errors.find((x) => x.path?.[0] === `f${i}`)?.message ?? "not written";
|
|
1497
|
+
return { path: f.path, ok: false, error, file: null };
|
|
1498
|
+
});
|
|
1499
|
+
const written = files.filter((f) => f.ok);
|
|
1500
|
+
return {
|
|
1501
|
+
files,
|
|
1502
|
+
written: written.length,
|
|
1503
|
+
failed: files.length - written.length,
|
|
1504
|
+
totalBytes: written.reduce((sum, f) => sum + (f.file?.sizeBytes ?? 0), 0),
|
|
1505
|
+
commit: null,
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
/** The platform's per-call limits of writeWorkspaceFiles (MCP's too). */
|
|
1510
|
+
const MAX_BATCH_FILES = 100;
|
|
1511
|
+
const MAX_BATCH_BYTES = 32 * 1024 * 1024;
|
|
1512
|
+
/** One server-side wait's budget, under the ~60 s a proxy or client allows a request. */
|
|
1513
|
+
const SERVER_WAIT_SECONDS = 45;
|
|
1514
|
+
/** How the platform says an app keeps no history in platform git. */
|
|
1515
|
+
const NO_PLATFORM_GIT = /platform git is not enabled|its history is there|has no git repository/i;
|
|
1516
|
+
/** The enum lists only the events it was written for; anything newer is UNKNOWN there. */
|
|
1517
|
+
function toDeploymentEvent(event) {
|
|
1518
|
+
return {
|
|
1519
|
+
id: event.id,
|
|
1520
|
+
deploymentID: event.deploymentID,
|
|
1521
|
+
type: event.rawType,
|
|
1522
|
+
typeEnum: event.type,
|
|
1523
|
+
rawType: event.rawType,
|
|
1524
|
+
severity: event.severity,
|
|
1525
|
+
message: event.message,
|
|
1526
|
+
vmID: event.vmID ?? null,
|
|
1527
|
+
metadata: event.metadata ?? null,
|
|
1528
|
+
timings: event.timings ? toPlatformTimings(event.timings) : null,
|
|
1529
|
+
createdAt: event.createdAt,
|
|
1530
|
+
};
|
|
1531
|
+
}
|
|
1532
|
+
const BUILD_FINISHED_EVENT = "deployment.build_finished";
|
|
1533
|
+
const DEPLOY_PHASES_EVENT = "deployment.phases";
|
|
1534
|
+
/**
|
|
1535
|
+
* A duration as the platform accepts one: a whole, non-negative number of
|
|
1536
|
+
* milliseconds (a number, or a string of digits as metadata carries it). A
|
|
1537
|
+
* fraction or anything else is not a duration; a negative one is a clock gone
|
|
1538
|
+
* backwards. Both are dropped, as the platform drops them.
|
|
1539
|
+
*/
|
|
1540
|
+
function durationMs(value) {
|
|
1541
|
+
let n;
|
|
1542
|
+
if (typeof value === "number")
|
|
1543
|
+
n = value;
|
|
1544
|
+
else if (typeof value === "string" && /^[+-]?\d+$/.test(value.trim()))
|
|
1545
|
+
n = Number(value.trim());
|
|
1546
|
+
else
|
|
1547
|
+
return null;
|
|
1548
|
+
return Number.isSafeInteger(n) && n >= 0 ? n + 0 : null; // + 0: -0 is 0
|
|
1549
|
+
}
|
|
1550
|
+
/** The platform's structured timings, with every duration checked like the metadata's. */
|
|
1551
|
+
function toPlatformTimings(t) {
|
|
1552
|
+
const phases = {};
|
|
1553
|
+
if (t.phases && typeof t.phases === "object") {
|
|
1554
|
+
for (const [name, value] of Object.entries(t.phases)) {
|
|
1555
|
+
const ms = durationMs(value);
|
|
1556
|
+
if (ms !== null)
|
|
1557
|
+
phases[name] = ms;
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
const memory = durationMs(t.buildMemoryMiB);
|
|
1561
|
+
return {
|
|
1562
|
+
totalMs: durationMs(t.totalMs),
|
|
1563
|
+
buildMs: durationMs(t.buildMs),
|
|
1564
|
+
promoteMs: durationMs(t.promoteMs),
|
|
1565
|
+
phases,
|
|
1566
|
+
buildMode: t.buildMode || null,
|
|
1567
|
+
artifactTransport: t.artifactTransport || null,
|
|
1568
|
+
buildMemoryMiB: memory ? memory : null,
|
|
1569
|
+
};
|
|
1570
|
+
}
|
|
1571
|
+
/**
|
|
1572
|
+
* Timing-event metadata read exactly as the platform reads it
|
|
1573
|
+
* (internal/usecases/deploy_timings.go): every `<name>_ms` that is a whole,
|
|
1574
|
+
* non-negative number; `total` only from the READY summary.
|
|
1575
|
+
*/
|
|
1576
|
+
function timingsFromMetadata(metadata, ready) {
|
|
1577
|
+
const out = {
|
|
1578
|
+
totalMs: null,
|
|
1579
|
+
buildMs: null,
|
|
1580
|
+
promoteMs: null,
|
|
1581
|
+
phases: {},
|
|
1582
|
+
buildMode: metadata["build_mode"] || null,
|
|
1583
|
+
artifactTransport: metadata["artifact_transport"] || null,
|
|
1584
|
+
buildMemoryMiB: null,
|
|
1585
|
+
};
|
|
1586
|
+
for (const [key, raw] of Object.entries(metadata)) {
|
|
1587
|
+
if (!key.endsWith("_ms") || key === "_ms")
|
|
1588
|
+
continue;
|
|
1589
|
+
const ms = durationMs(raw);
|
|
1590
|
+
if (ms === null)
|
|
1591
|
+
continue;
|
|
1592
|
+
const name = key.slice(0, -3);
|
|
1593
|
+
if (name === "total") {
|
|
1594
|
+
if (ready)
|
|
1595
|
+
out.totalMs = ms;
|
|
1596
|
+
}
|
|
1597
|
+
else if (name === "build")
|
|
1598
|
+
out.buildMs = ms;
|
|
1599
|
+
else if (name === "promote")
|
|
1600
|
+
out.promoteMs = ms;
|
|
1601
|
+
else
|
|
1602
|
+
out.phases[name] = ms;
|
|
876
1603
|
}
|
|
1604
|
+
const memory = durationMs(metadata["build_memory_mib"]);
|
|
1605
|
+
if (memory)
|
|
1606
|
+
out.buildMemoryMiB = memory;
|
|
1607
|
+
return out;
|
|
1608
|
+
}
|
|
1609
|
+
/**
|
|
1610
|
+
* A deploy's phases from its events, as the platform reads them: the latest
|
|
1611
|
+
* `deployment.phases` (written when it went READY: build, promotion and its
|
|
1612
|
+
* split, total) over the latest `deployment.build_finished` (the build VM's
|
|
1613
|
+
* own phases). It prefers the `timings` the platform parsed into each event
|
|
1614
|
+
* ({@link Unfazed.getEvents} reads them); events without them (an older
|
|
1615
|
+
* platform, events built by hand) are parsed from their `*_ms` metadata the
|
|
1616
|
+
* same way. Durations are whole, non-negative milliseconds: anything else is
|
|
1617
|
+
* dropped, as the platform drops it. Null when neither event is there.
|
|
1618
|
+
*/
|
|
1619
|
+
export function parseDeploymentTimings(events) {
|
|
1620
|
+
const latest = (type) => {
|
|
1621
|
+
const event = [...events].reverse().find((e) => e.type === type);
|
|
1622
|
+
return event && (event.timings || event.metadata) ? event : null;
|
|
1623
|
+
};
|
|
1624
|
+
const build = latest(BUILD_FINISHED_EVENT);
|
|
1625
|
+
const ready = latest(DEPLOY_PHASES_EVENT);
|
|
1626
|
+
if (!build && !ready)
|
|
1627
|
+
return null;
|
|
1628
|
+
if ((!build || build.timings) && (!ready || ready.timings)) {
|
|
1629
|
+
const b = build?.timings ? toPlatformTimings(build.timings) : null;
|
|
1630
|
+
const r = ready?.timings ? toPlatformTimings(ready.timings) : null;
|
|
1631
|
+
return {
|
|
1632
|
+
totalMs: r?.totalMs ?? null,
|
|
1633
|
+
buildMs: r?.buildMs ?? b?.buildMs ?? null,
|
|
1634
|
+
promoteMs: r?.promoteMs ?? b?.promoteMs ?? null,
|
|
1635
|
+
phases: { ...b?.phases, ...r?.phases },
|
|
1636
|
+
buildMode: r?.buildMode ?? b?.buildMode ?? null,
|
|
1637
|
+
artifactTransport: r?.artifactTransport ?? b?.artifactTransport ?? null,
|
|
1638
|
+
buildMemoryMiB: r?.buildMemoryMiB ?? b?.buildMemoryMiB ?? null,
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
// The platform merges the two events' metadata, the READY summary's values
|
|
1642
|
+
// over the build's, and parses the result.
|
|
1643
|
+
return timingsFromMetadata({ ...build?.metadata, ...ready?.metadata }, ready !== null);
|
|
1644
|
+
}
|
|
1645
|
+
function errorGroupInput(ref) {
|
|
1646
|
+
return { workspaceID: ref.workspaceId, alias: ref.alias, groupID: ref.groupId };
|
|
1647
|
+
}
|
|
1648
|
+
function eventsInput(input) {
|
|
1649
|
+
return {
|
|
1650
|
+
...(input.names === undefined ? {} : { names: input.names }),
|
|
1651
|
+
...(input.arguments === undefined ? {} : { arguments: input.arguments }),
|
|
1652
|
+
...(input.cursor === undefined ? {} : { cursor: input.cursor }),
|
|
1653
|
+
...(input.max === undefined ? {} : { max: input.max }),
|
|
1654
|
+
};
|
|
877
1655
|
}
|
|
878
1656
|
const FILE_SELECTION = "path sizeBytes size sha256 updatedAt";
|
|
879
1657
|
const ENV_SELECTION = "name value secret alias inherited masked updatedAt";
|