unfazed 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +88 -0
- package/README.md +299 -78
- package/REFERENCE.md +284 -0
- package/dist/cli/login.d.ts +129 -0
- package/dist/cli/login.js +596 -0
- package/dist/cli/login.js.map +1 -0
- package/dist/cli/workflow.d.ts +31 -0
- package/dist/cli/workflow.js +157 -0
- package/dist/cli/workflow.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +173 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +26 -5
- package/dist/client.js +93 -48
- package/dist/client.js.map +1 -1
- package/dist/credentials.d.ts +123 -0
- package/dist/credentials.js +317 -0
- package/dist/credentials.js.map +1 -0
- 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 +78 -2
- package/dist/generated/gql.js +21 -2
- package/dist/generated/gql.js.map +1 -1
- package/dist/generated/graphql.d.ts +505 -6
- package/dist/generated/graphql.js +439 -2
- package/dist/generated/graphql.js.map +1 -1
- package/dist/index.d.ts +25 -6
- package/dist/index.js +23 -5
- package/dist/index.js.map +1 -1
- package/dist/operations.d.ts +116 -5
- package/dist/operations.js +379 -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/remote/app.js +2 -1
- package/dist/remote/app.js.map +1 -1
- package/dist/remote/world.d.ts +2 -2
- package/dist/remote/world.js +32 -16
- package/dist/remote/world.js.map +1 -1
- package/dist/unfazed.d.ts +545 -68
- package/dist/unfazed.js +688 -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 +22 -6
- package/schema/SOURCE.json +4 -4
- package/schema/schema.graphqls +1628 -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
|
/**
|
|
@@ -12,7 +12,7 @@ import { pollWithBackoff, resolveWaitOptions } from "./wait.js";
|
|
|
12
12
|
* wrapped.
|
|
13
13
|
*
|
|
14
14
|
* ```ts
|
|
15
|
-
* const unfazed = new Unfazed();
|
|
15
|
+
* const unfazed = new Unfazed(); // UNFZ_API_TOKEN, else your `npx unfazed login`
|
|
16
16
|
* const ws = await unfazed.createWorkspace("hello");
|
|
17
17
|
* await unfazed.writeFiles(ws.id, { "server.ts": code });
|
|
18
18
|
* const app = await unfazed.deployService({ workspaceId: ws.id, alias: "hello", wait: true });
|
|
@@ -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,119 @@ 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).
|
|
280
456
|
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
* deployment
|
|
285
|
-
*
|
|
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.
|
|
286
467
|
*/
|
|
287
468
|
async waitForDeployment(deploymentId, waitOptions = {}) {
|
|
469
|
+
return (await this.#waitTerminal(deploymentId, waitOptions)).deployment;
|
|
470
|
+
}
|
|
471
|
+
async #waitTerminal(deploymentId, waitOptions) {
|
|
472
|
+
const now = waitOptions.now ?? (() => Date.now());
|
|
473
|
+
const started = now();
|
|
474
|
+
if ((waitOptions.mode ?? "server") === "server" && !this.#noServerWait) {
|
|
475
|
+
try {
|
|
476
|
+
return await this.#serverWait(deploymentId, waitOptions);
|
|
477
|
+
}
|
|
478
|
+
catch (e) {
|
|
479
|
+
if (isUnsupportedField(e, "waitForDeployment")) {
|
|
480
|
+
// An older platform: poll, and do not ask again on this instance.
|
|
481
|
+
this.#noServerWait = true;
|
|
482
|
+
}
|
|
483
|
+
else if (!isTooManyWaits(e)) {
|
|
484
|
+
throw e;
|
|
485
|
+
}
|
|
486
|
+
// Past the platform's cap on waits in progress (many parallel waits
|
|
487
|
+
// of one account): this wait polls instead, for what is left of its
|
|
488
|
+
// budget.
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
const timeoutMs = Math.max(0, (waitOptions.timeoutMs ?? 600_000) - (now() - started));
|
|
492
|
+
return this.#pollWait(deploymentId, { ...waitOptions, timeoutMs });
|
|
493
|
+
}
|
|
494
|
+
async #serverWait(deploymentId, waitOptions) {
|
|
288
495
|
const resolved = resolveWaitOptions(waitOptions);
|
|
496
|
+
const deadline = resolved.now() + resolved.timeoutMs;
|
|
497
|
+
let last = null;
|
|
498
|
+
let landing = false;
|
|
499
|
+
for (let requests = 1;; requests++) {
|
|
500
|
+
if (resolved.signal?.aborted)
|
|
501
|
+
throw new UnfazedTimeoutError("unfazed: wait aborted");
|
|
502
|
+
const remaining = deadline - resolved.now();
|
|
503
|
+
if (requests > 1 && remaining <= 0) {
|
|
504
|
+
throw new UnfazedDeployError("TIMEOUT", deploymentId, last, `unfazed: deployment ${deploymentId} still ${last?.status ?? "BUILDING"}` +
|
|
505
|
+
`${landing ? " (READY, still landing on its server)" : ""} after ${resolved.timeoutMs}ms`);
|
|
506
|
+
}
|
|
507
|
+
const seconds = Math.max(1, Math.min(SERVER_WAIT_SECONDS, Math.ceil(remaining / 1000)));
|
|
508
|
+
let answer;
|
|
509
|
+
try {
|
|
510
|
+
answer = (await this.client.query(ops.WaitForDeploymentDocument, { id: deploymentId, timeoutSeconds: seconds },
|
|
511
|
+
// The request is a long poll: give it the wait plus a round trip,
|
|
512
|
+
// whatever the client's default per-request timeout is.
|
|
513
|
+
{ ...(resolved.signal ? { signal: resolved.signal } : {}), timeoutMs: (seconds + 30) * 1000 })).waitForDeployment;
|
|
514
|
+
}
|
|
515
|
+
catch (e) {
|
|
516
|
+
if (isNotFound(e, DEPLOYMENT_NOT_FOUND)) {
|
|
517
|
+
throw new UnfazedNotFoundError(`unfazed: deployment ${deploymentId} not found`);
|
|
518
|
+
}
|
|
519
|
+
throw e;
|
|
520
|
+
}
|
|
521
|
+
const d = answer.deployment;
|
|
522
|
+
last = d;
|
|
523
|
+
if (answer.timedOut) {
|
|
524
|
+
landing = d.status === "READY";
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
if (d.status === "READY")
|
|
528
|
+
return { deployment: d, requests, mode: "server" };
|
|
529
|
+
throw new UnfazedDeployError("FAILED", deploymentId, d, `unfazed: deployment ${deploymentId} ${d.status}${d.error ? `: ${d.error}` : ""}`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
async #pollWait(deploymentId, waitOptions) {
|
|
533
|
+
const resolved = resolveWaitOptions({ initialIntervalMs: 500, maxIntervalMs: 3_000, ...waitOptions });
|
|
289
534
|
const requestOptions = resolved.signal ? { signal: resolved.signal } : {};
|
|
290
535
|
let last = null;
|
|
536
|
+
let requests = 0;
|
|
291
537
|
await pollWithBackoff(async () => {
|
|
538
|
+
requests++;
|
|
292
539
|
const data = await this.client.query(ops.PollDeploymentDocument, { id: deploymentId }, requestOptions);
|
|
293
540
|
const d = data.deployment;
|
|
294
541
|
if (!d) {
|
|
@@ -302,10 +549,39 @@ export class Unfazed {
|
|
|
302
549
|
}
|
|
303
550
|
return { done: false };
|
|
304
551
|
}, resolved, () => new UnfazedDeployError("TIMEOUT", deploymentId, last, `unfazed: deployment ${deploymentId} still ${last?.status ?? "BUILDING"} after ${resolved.timeoutMs}ms`));
|
|
552
|
+
// The poll query is narrow; re-read the READY deployment in full so the
|
|
553
|
+
// answer has the shape deployService returns without `wait`.
|
|
305
554
|
const full = await this.getDeployment(deploymentId, requestOptions);
|
|
306
555
|
if (!full)
|
|
307
556
|
throw new UnfazedNotFoundError(`unfazed: deployment ${deploymentId} not found`);
|
|
308
|
-
return full;
|
|
557
|
+
return { deployment: full, requests: requests + 1, mode: "poll" };
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* The platform's measured phases of a deploy ({@link PlatformTimings}),
|
|
561
|
+
* from its timing events. Null when it recorded none (a deploy that is
|
|
562
|
+
* still building, or one from before timings existed).
|
|
563
|
+
*/
|
|
564
|
+
async getDeploymentTimings(deploymentId, options) {
|
|
565
|
+
return parseDeploymentTimings(await this.getEvents(deploymentId, 100, options));
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* The timings of a deploy that just went READY. `deployment.phases` is
|
|
569
|
+
* written right after the READY transition, so a read that races it is
|
|
570
|
+
* retried once.
|
|
571
|
+
*/
|
|
572
|
+
async #readyTimings(deploymentId, waitOptions, requestOptions) {
|
|
573
|
+
try {
|
|
574
|
+
const first = await this.getDeploymentTimings(deploymentId, requestOptions);
|
|
575
|
+
if (first?.totalMs != null)
|
|
576
|
+
return first;
|
|
577
|
+
const sleep = waitOptions?.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
578
|
+
await sleep(250, requestOptions.signal);
|
|
579
|
+
return (await this.getDeploymentTimings(deploymentId, requestOptions)) ?? first;
|
|
580
|
+
}
|
|
581
|
+
catch {
|
|
582
|
+
// Timings are a diagnostic: never fail a deploy that went READY.
|
|
583
|
+
return null;
|
|
584
|
+
}
|
|
309
585
|
}
|
|
310
586
|
/** Reads one deployment by id. Null when absent. */
|
|
311
587
|
async getDeployment(deploymentId, options) {
|
|
@@ -340,10 +616,25 @@ export class Unfazed {
|
|
|
340
616
|
const data = await this.client.query(ops.ServiceLogsDocument, { deploymentID: deploymentId, tailBytes: input.tailBytes ?? null }, options);
|
|
341
617
|
return data.serviceLogs;
|
|
342
618
|
}
|
|
343
|
-
/**
|
|
619
|
+
/**
|
|
620
|
+
* Deployment lifecycle events — build, boot, probe, cron, restarts — the
|
|
621
|
+
* most recent `limit` (default 20), oldest first.
|
|
622
|
+
*
|
|
623
|
+
* `type` is the event's own name, as MCP reports it:
|
|
624
|
+
* `"deployment.build_finished"`, `"deployment.phases"`,
|
|
625
|
+
* `"service.vm_started"`, `"service.crashed"`. (`typeEnum` keeps the GraphQL
|
|
626
|
+
* enum, which reads `UNKNOWN` for names it does not list.) Timing events
|
|
627
|
+
* carry their phases in `metadata`; {@link getDeploymentTimings} turns them
|
|
628
|
+
* into numbers.
|
|
629
|
+
*
|
|
630
|
+
* ```ts
|
|
631
|
+
* const events = await unfazed.getEvents(app.id, 50);
|
|
632
|
+
* events.filter((e) => e.type === "deployment.build_finished");
|
|
633
|
+
* ```
|
|
634
|
+
*/
|
|
344
635
|
async getEvents(deploymentId, limit, options) {
|
|
345
636
|
const data = await this.client.query(ops.DeploymentEventsDocument, { deploymentID: deploymentId, limit: limit ?? null }, options);
|
|
346
|
-
return data.deploymentEvents;
|
|
637
|
+
return data.deploymentEvents.map(toDeploymentEvent);
|
|
347
638
|
}
|
|
348
639
|
/** Restarts a service's VM. */
|
|
349
640
|
async restartService(deploymentId, options) {
|
|
@@ -357,6 +648,12 @@ export class Unfazed {
|
|
|
357
648
|
}
|
|
358
649
|
/**
|
|
359
650
|
* Deletes a deployment: stops its VM, releases its alias, drops its release.
|
|
651
|
+
* To delete a whole app — every release of the alias, its env, App State and
|
|
652
|
+
* bindings — use {@link deleteApp}.
|
|
653
|
+
*
|
|
654
|
+
* The platform keeps a deleted release for 30 days: within that time
|
|
655
|
+
* support (the platform operator) can restore it; then it is erased for
|
|
656
|
+
* good.
|
|
360
657
|
*
|
|
361
658
|
* With `ignoreNotFound` a deployment that is absent — or belongs to another
|
|
362
659
|
* tenant, which the server reports identically so ids cannot be probed —
|
|
@@ -634,6 +931,193 @@ export class Unfazed {
|
|
|
634
931
|
}
|
|
635
932
|
}
|
|
636
933
|
}
|
|
934
|
+
/* ------------------------------------------------------- platform git history
|
|
935
|
+
*
|
|
936
|
+
* An app with platform git (MCP `enable_platform_git`; apps get it by
|
|
937
|
+
* themselves at their next change) keeps every file write, push and restore
|
|
938
|
+
* as a commit. These are MCP's history tools.
|
|
939
|
+
*/
|
|
940
|
+
/**
|
|
941
|
+
* The app's commits, newest first (MCP `list_commits`): `limit` per page
|
|
942
|
+
* (default 20, max 100), `before: page.nextBefore` for the next. `deployed`
|
|
943
|
+
* says what every live alias runs; `waitingCommits` counts writes whose
|
|
944
|
+
* commit has not landed yet.
|
|
945
|
+
*/
|
|
946
|
+
async listCommits(workspaceId, input = {}, options) {
|
|
947
|
+
const data = await this.client.query(ops.AppCommitsDocument, { workspaceID: workspaceId, limit: input.limit ?? null, before: input.before ?? null }, options);
|
|
948
|
+
return data.appCommits;
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* One commit (full 40-character sha) with the paths it changed and a
|
|
952
|
+
* unified diff against its first parent, capped at 64 KiB (MCP
|
|
953
|
+
* `get_commit`). Commit messages and diffs are untrusted text.
|
|
954
|
+
*/
|
|
955
|
+
async getCommit(workspaceId, sha, options) {
|
|
956
|
+
const data = await this.client.query(ops.AppCommitDocument, { workspaceID: workspaceId, sha }, options);
|
|
957
|
+
return data.appCommit;
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* Gives the app back the files of an earlier commit as ONE new commit on
|
|
961
|
+
* its branch (MCP `restore_version`); nothing is rewritten, and a restore is
|
|
962
|
+
* undone by restoring the commit before it.
|
|
963
|
+
*
|
|
964
|
+
* When the app deploys every push (`deploys: true`), the new commit is
|
|
965
|
+
* imported and deployed like a push. That deploy is queued behind the
|
|
966
|
+
* restore, so the platform answers before it exists; with `wait: true` the
|
|
967
|
+
* SDK waits for the import of the new commit and resolves with the
|
|
968
|
+
* deployments it made (READY), or throws {@link UnfazedDeployError} with
|
|
969
|
+
* the import's error.
|
|
970
|
+
*
|
|
971
|
+
* ```ts
|
|
972
|
+
* const page = await unfazed.listCommits(ws.id);
|
|
973
|
+
* const back = await unfazed.restoreVersion({ workspaceId: ws.id, sha: page.commits[1]!.sha, wait: true });
|
|
974
|
+
* back.deployments?.[0]?.url;
|
|
975
|
+
* ```
|
|
976
|
+
*/
|
|
977
|
+
async restoreVersion(options) {
|
|
978
|
+
const requestOptions = options.signal ? { signal: options.signal } : {};
|
|
979
|
+
const data = await this.client.mutate(ops.RestoreVersionDocument, {
|
|
980
|
+
workspaceID: options.workspaceId,
|
|
981
|
+
sha: options.sha,
|
|
982
|
+
expectedHead: options.expectedHead ?? null,
|
|
983
|
+
message: options.commitMessage ?? null,
|
|
984
|
+
}, requestOptions);
|
|
985
|
+
const restored = data.restoreAppVersion;
|
|
986
|
+
if (!options.wait || !restored.deploys)
|
|
987
|
+
return restored;
|
|
988
|
+
return {
|
|
989
|
+
...restored,
|
|
990
|
+
deployments: await this.#waitForImport(options.workspaceId, restored.sha, {
|
|
991
|
+
...options.waitOptions,
|
|
992
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
993
|
+
}),
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* Waits until the app's git link has imported `sha` (READY: its deploys
|
|
998
|
+
* are done) and returns the deployments of that commit; throws on a FAILED
|
|
999
|
+
* import of it.
|
|
1000
|
+
*/
|
|
1001
|
+
async #waitForImport(workspaceId, sha, waitOptions) {
|
|
1002
|
+
const resolved = resolveWaitOptions({ initialIntervalMs: 500, maxIntervalMs: 3_000, ...waitOptions });
|
|
1003
|
+
const requestOptions = resolved.signal ? { signal: resolved.signal } : {};
|
|
1004
|
+
let last = null;
|
|
1005
|
+
await pollWithBackoff(async () => {
|
|
1006
|
+
const data = await this.client.query(ops.WorkspaceGitSourceDocument, { id: workspaceId }, requestOptions);
|
|
1007
|
+
const src = data.workspace?.gitSource ?? null;
|
|
1008
|
+
if (!src)
|
|
1009
|
+
throw new UnfazedNotFoundError(`unfazed: workspace ${workspaceId} has no git source`);
|
|
1010
|
+
last = src;
|
|
1011
|
+
if (src.lastImportedCommit !== sha)
|
|
1012
|
+
return { done: false };
|
|
1013
|
+
if (src.lastImportStatus === "READY")
|
|
1014
|
+
return { done: true, value: true };
|
|
1015
|
+
if (src.lastImportStatus === "FAILED") {
|
|
1016
|
+
throw new UnfazedDeployError("FAILED", "", null, `unfazed: the import of ${sha.slice(0, 7)} failed${src.lastImportError ? `: ${src.lastImportError}` : ""}`);
|
|
1017
|
+
}
|
|
1018
|
+
return { done: false };
|
|
1019
|
+
}, resolved, () => new UnfazedDeployError("TIMEOUT", "", null, `unfazed: ${sha.slice(0, 7)} was not imported after ${resolved.timeoutMs}ms` +
|
|
1020
|
+
(last ? ` (last import: ${last.lastImportStatus} ${last.lastImportedCommit?.slice(0, 7) ?? ""})` : "")));
|
|
1021
|
+
const page = await this.listDeployments({ workspaceID: workspaceId, limit: 50 }, requestOptions);
|
|
1022
|
+
return page.items.filter((d) => d.gitCommit === sha);
|
|
1023
|
+
}
|
|
1024
|
+
/* ------------------------------------------------------------- app errors
|
|
1025
|
+
*
|
|
1026
|
+
* Native error tracking: a service app's uncaught exceptions, unhandled
|
|
1027
|
+
* rejections, panics and Sentry SDK reports, grouped, kept on its server
|
|
1028
|
+
* for 30 days. These are MCP's error tools. Titles, stacks and requests are
|
|
1029
|
+
* what the app reported: untrusted text.
|
|
1030
|
+
*/
|
|
1031
|
+
/** A service app's error groups, most recently seen first (MCP `list_errors`). */
|
|
1032
|
+
async listErrors(input, options) {
|
|
1033
|
+
const data = await this.client.query(ops.AppErrorsDocument, {
|
|
1034
|
+
workspaceID: input.workspaceId,
|
|
1035
|
+
alias: input.alias,
|
|
1036
|
+
status: input.status ?? null,
|
|
1037
|
+
limit: input.limit ?? null,
|
|
1038
|
+
cursor: input.cursor ?? null,
|
|
1039
|
+
}, options);
|
|
1040
|
+
return data.appErrors;
|
|
1041
|
+
}
|
|
1042
|
+
/**
|
|
1043
|
+
* One error group with its latest occurrence whole (a Sentry event: stack,
|
|
1044
|
+
* request), its recent occurrences and the deployments it was seen in (MCP
|
|
1045
|
+
* `get_error_group`).
|
|
1046
|
+
*/
|
|
1047
|
+
async getErrorGroup(ref, options) {
|
|
1048
|
+
const data = await this.client.query(ops.AppErrorGroupDocument, { workspaceID: ref.workspaceId, alias: ref.alias, groupID: ref.groupId }, options);
|
|
1049
|
+
return data.appErrorGroup;
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Marks a group resolved in the app's current deployment, once its fix is
|
|
1053
|
+
* deployed (MCP `resolve_error_group`); another deployment reporting it
|
|
1054
|
+
* opens it again. Only on your human's say-so or a fix you verified.
|
|
1055
|
+
*/
|
|
1056
|
+
async resolveErrorGroup(ref, options) {
|
|
1057
|
+
const data = await this.client.mutate(ops.ResolveErrorGroupDocument, { input: errorGroupInput(ref) }, options);
|
|
1058
|
+
return data.resolveAppErrorGroup;
|
|
1059
|
+
}
|
|
1060
|
+
/** Mutes an expected group: still counted, never regresses or notifies (MCP `mute_error_group`). */
|
|
1061
|
+
async muteErrorGroup(ref, options) {
|
|
1062
|
+
const data = await this.client.mutate(ops.MuteErrorGroupDocument, { input: errorGroupInput(ref) }, options);
|
|
1063
|
+
return data.muteAppErrorGroup;
|
|
1064
|
+
}
|
|
1065
|
+
/** Opens a resolved or muted group again (MCP `reopen_error_group`). */
|
|
1066
|
+
async reopenErrorGroup(ref, options) {
|
|
1067
|
+
const data = await this.client.mutate(ops.ReopenErrorGroupDocument, { input: errorGroupInput(ref) }, options);
|
|
1068
|
+
return data.reopenAppErrorGroup;
|
|
1069
|
+
}
|
|
1070
|
+
/* ------------------------------------------------------------------ events
|
|
1071
|
+
*
|
|
1072
|
+
* What happened to the apps, runs and domains you can see —
|
|
1073
|
+
* deployment.finished, service.crashed, error.new, error.regressed,
|
|
1074
|
+
* git.conflict, run.finished, … — instead of re-polling status. The same
|
|
1075
|
+
* feed and cursors as MCP `poll_events` / `wait_for_events`.
|
|
1076
|
+
*/
|
|
1077
|
+
/** The event types, with each one's filters and payload schema (MCP `list_event_types`). */
|
|
1078
|
+
async listEventTypes(options) {
|
|
1079
|
+
const data = await this.client.query(ops.EventTypesDocument, {}, options);
|
|
1080
|
+
return data.platformEventTypes;
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Events since `cursor` (MCP `poll_events`). Omit `cursor` to start from now
|
|
1084
|
+
* — nothing is replayed — and pass the returned `cursor` back: at once while
|
|
1085
|
+
* `hasMore`, otherwise after `nextPollMs`, or to {@link waitForEvents}.
|
|
1086
|
+
* `truncated` means events may be missing: re-read state.
|
|
1087
|
+
*/
|
|
1088
|
+
async pollEvents(input = {}, options) {
|
|
1089
|
+
const data = await this.client.query(ops.PollEventsDocument, { input: eventsInput(input) }, options);
|
|
1090
|
+
return data.pollEvents;
|
|
1091
|
+
}
|
|
1092
|
+
/**
|
|
1093
|
+
* {@link pollEvents} that waits server-side until an event arrives (MCP
|
|
1094
|
+
* `wait_for_events`), for up to `timeoutMs` (default 45 s; longer waits are
|
|
1095
|
+
* several server waits of up to 45 s, continuing from each cursor). A
|
|
1096
|
+
* timeout is not an error: the page has `timedOut: true`, no events and a
|
|
1097
|
+
* cursor to wait again with. The platform caps an account's waits in
|
|
1098
|
+
* progress (16, deploy waits included); past it this throws an error that
|
|
1099
|
+
* `isTooManyWaits` recognizes: poll then.
|
|
1100
|
+
*
|
|
1101
|
+
* ```ts
|
|
1102
|
+
* let page = await unfazed.pollEvents({ names: ["deployment.finished"] }); // from now
|
|
1103
|
+
* page = await unfazed.waitForEvents({ names: ["deployment.finished"], cursor: page.cursor, timeoutMs: 120_000 });
|
|
1104
|
+
* ```
|
|
1105
|
+
*/
|
|
1106
|
+
async waitForEvents(input = {}, options = {}) {
|
|
1107
|
+
const now = options.now ?? (() => Date.now());
|
|
1108
|
+
const deadline = now() + (input.timeoutMs ?? SERVER_WAIT_SECONDS * 1000);
|
|
1109
|
+
let cursor = input.cursor;
|
|
1110
|
+
for (;;) {
|
|
1111
|
+
const remaining = deadline - now();
|
|
1112
|
+
const seconds = Math.max(1, Math.min(SERVER_WAIT_SECONDS, Math.ceil(remaining / 1000)));
|
|
1113
|
+
const { now: _now, ...requestOptions } = options;
|
|
1114
|
+
const data = await this.client.query(ops.WaitForEventsDocument, { input: eventsInput({ ...input, ...(cursor === undefined ? {} : { cursor }) }), timeoutSeconds: seconds }, { ...requestOptions, timeoutMs: (seconds + 30) * 1000 });
|
|
1115
|
+
const page = data.waitForEvents;
|
|
1116
|
+
if (!page.timedOut || deadline - now() <= 0)
|
|
1117
|
+
return page;
|
|
1118
|
+
cursor = page.cursor;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
637
1121
|
/* --------------------------------------------------------------- agent runs
|
|
638
1122
|
*
|
|
639
1123
|
* A run is the past-tense counterpart to a deployment: boot a fresh VM off
|
|
@@ -854,7 +1338,47 @@ export class Unfazed {
|
|
|
854
1338
|
return (workspaces.find((w) => w.name === name) ??
|
|
855
1339
|
workspaces.find((w) => w.name.toLowerCase() === folded));
|
|
856
1340
|
}
|
|
857
|
-
|
|
1341
|
+
/** Set once the platform turned out to predate writeWorkspaceFiles. */
|
|
1342
|
+
#legacyWrites = false;
|
|
1343
|
+
/** Set once the platform turned out to predate the server-side deployment wait. */
|
|
1344
|
+
#noServerWait = false;
|
|
1345
|
+
/** One request of {@link writeFiles}: one call of the batch mutation, one commit. */
|
|
1346
|
+
async #writeBatch(workspaceId, batch, commitMessage, requestOptions) {
|
|
1347
|
+
try {
|
|
1348
|
+
const data = await this.client.mutate(ops.WriteFilesDocument, {
|
|
1349
|
+
workspaceID: workspaceId,
|
|
1350
|
+
files: batch.map((f) => ({
|
|
1351
|
+
path: f.path,
|
|
1352
|
+
content: f.content,
|
|
1353
|
+
...(f.kind === "base64" ? { encoding: "base64" } : {}),
|
|
1354
|
+
})),
|
|
1355
|
+
commitMessage: commitMessage ?? null,
|
|
1356
|
+
}, requestOptions);
|
|
1357
|
+
const out = data.writeWorkspaceFiles;
|
|
1358
|
+
return {
|
|
1359
|
+
files: out.files.map((f) => ({ path: f.path, ok: f.ok, error: f.error ?? null, file: f.file ?? null })),
|
|
1360
|
+
written: out.written,
|
|
1361
|
+
failed: out.failed,
|
|
1362
|
+
totalBytes: out.totalBytes,
|
|
1363
|
+
commit: out.commit ?? null,
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
catch (e) {
|
|
1367
|
+
if (!isUnsupportedField(e, "writeWorkspaceFiles") && !isUnsupportedField(e, "WorkspaceFileWriteInput"))
|
|
1368
|
+
throw e;
|
|
1369
|
+
// A platform older than the batch mutation: one mutation per file, as
|
|
1370
|
+
// before 0.8.0. It reports no commit.
|
|
1371
|
+
this.#legacyWrites = true;
|
|
1372
|
+
return this.#writeBatchLegacy(workspaceId, batch, requestOptions);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
/**
|
|
1376
|
+
* The pre-0.8.0 batch: one document with one ALIASED single-file mutation
|
|
1377
|
+
* per file. One request, but one commit per file on an app with platform
|
|
1378
|
+
* git, and no commit in the answer. Used only against platforms without
|
|
1379
|
+
* writeWorkspaceFiles.
|
|
1380
|
+
*/
|
|
1381
|
+
async #writeBatchLegacy(workspaceId, batch, requestOptions) {
|
|
858
1382
|
const varDefs = ["$workspaceID: ID!"];
|
|
859
1383
|
const fields = [];
|
|
860
1384
|
const variables = { workspaceID: workspaceId };
|
|
@@ -867,13 +1391,113 @@ export class Unfazed {
|
|
|
867
1391
|
: ` f${i}: writeWorkspaceBase64File(input: { workspaceID: $workspaceID, path: $p${i}, contentBase64: $c${i} }) { file { ${FILE_SELECTION} } }`);
|
|
868
1392
|
});
|
|
869
1393
|
// 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
|
-
//
|
|
1394
|
+
// so no static document can express it. The field shapes are still fixed,
|
|
1395
|
+
// and each individual mutation is codegen-validated by its single-file
|
|
1396
|
+
// twin in operations.ts.
|
|
873
1397
|
const document = `mutation SdkWriteFilesBatch(${varDefs.join(", ")}) {\n${fields.join("\n")}\n}`;
|
|
874
|
-
|
|
875
|
-
|
|
1398
|
+
let data;
|
|
1399
|
+
let errors = [];
|
|
1400
|
+
try {
|
|
1401
|
+
data = await this.client.mutate(document, variables, requestOptions);
|
|
1402
|
+
}
|
|
1403
|
+
catch (e) {
|
|
1404
|
+
// Fields answered independently: keep what landed, name what did not.
|
|
1405
|
+
if (!(e instanceof UnfazedGraphQLError) || !e.data)
|
|
1406
|
+
throw e;
|
|
1407
|
+
data = e.data;
|
|
1408
|
+
errors = e.errors;
|
|
1409
|
+
}
|
|
1410
|
+
const files = batch.map((f, i) => {
|
|
1411
|
+
const file = data[`f${i}`]?.file ?? null;
|
|
1412
|
+
if (file)
|
|
1413
|
+
return { path: f.path, ok: true, error: null, file };
|
|
1414
|
+
const error = errors.find((x) => x.path?.[0] === `f${i}`)?.message ?? "not written";
|
|
1415
|
+
return { path: f.path, ok: false, error, file: null };
|
|
1416
|
+
});
|
|
1417
|
+
const written = files.filter((f) => f.ok);
|
|
1418
|
+
return {
|
|
1419
|
+
files,
|
|
1420
|
+
written: written.length,
|
|
1421
|
+
failed: files.length - written.length,
|
|
1422
|
+
totalBytes: written.reduce((sum, f) => sum + (f.file?.sizeBytes ?? 0), 0),
|
|
1423
|
+
commit: null,
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
/** The platform's per-call limits of writeWorkspaceFiles (MCP's too). */
|
|
1428
|
+
const MAX_BATCH_FILES = 100;
|
|
1429
|
+
const MAX_BATCH_BYTES = 32 * 1024 * 1024;
|
|
1430
|
+
/** One server-side wait's budget, under the ~60 s a proxy or client allows a request. */
|
|
1431
|
+
const SERVER_WAIT_SECONDS = 45;
|
|
1432
|
+
/** How the platform says an app keeps no history in platform git. */
|
|
1433
|
+
const NO_PLATFORM_GIT = /platform git is not enabled|its history is there|has no git repository/i;
|
|
1434
|
+
/** The enum lists only the events it was written for; anything newer is UNKNOWN there. */
|
|
1435
|
+
function toDeploymentEvent(event) {
|
|
1436
|
+
return {
|
|
1437
|
+
id: event.id,
|
|
1438
|
+
deploymentID: event.deploymentID,
|
|
1439
|
+
type: event.rawType,
|
|
1440
|
+
typeEnum: event.type,
|
|
1441
|
+
rawType: event.rawType,
|
|
1442
|
+
severity: event.severity,
|
|
1443
|
+
message: event.message,
|
|
1444
|
+
vmID: event.vmID ?? null,
|
|
1445
|
+
metadata: event.metadata ?? null,
|
|
1446
|
+
createdAt: event.createdAt,
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
/**
|
|
1450
|
+
* A deploy's phases from its events: `deployment.phases` (written when it went
|
|
1451
|
+
* READY: build, promotion and its split, total) over `deployment.build_finished`
|
|
1452
|
+
* (the build VM's own phases). Every `*_ms` field becomes a number. Null when
|
|
1453
|
+
* neither event is there.
|
|
1454
|
+
*/
|
|
1455
|
+
export function parseDeploymentTimings(events) {
|
|
1456
|
+
const latest = (type) => [...events].reverse().find((e) => e.type === type)?.metadata ?? null;
|
|
1457
|
+
const build = latest("deployment.build_finished");
|
|
1458
|
+
const ready = latest("deployment.phases");
|
|
1459
|
+
if (!build && !ready)
|
|
1460
|
+
return null;
|
|
1461
|
+
const merged = { ...build, ...ready };
|
|
1462
|
+
const ms = (name) => {
|
|
1463
|
+
const value = merged[`${name}_ms`];
|
|
1464
|
+
if (value === undefined)
|
|
1465
|
+
return null;
|
|
1466
|
+
const n = Number(value);
|
|
1467
|
+
return Number.isFinite(n) ? n : null;
|
|
1468
|
+
};
|
|
1469
|
+
const phases = {};
|
|
1470
|
+
for (const [key, value] of Object.entries(merged)) {
|
|
1471
|
+
if (!key.endsWith("_ms"))
|
|
1472
|
+
continue;
|
|
1473
|
+
const name = key.slice(0, -3);
|
|
1474
|
+
if (name === "total" || name === "build" || name === "promote")
|
|
1475
|
+
continue;
|
|
1476
|
+
const n = Number(value);
|
|
1477
|
+
if (Number.isFinite(n))
|
|
1478
|
+
phases[name] = n;
|
|
876
1479
|
}
|
|
1480
|
+
const memory = Number(merged["build_memory_mib"]);
|
|
1481
|
+
return {
|
|
1482
|
+
totalMs: ready ? ms("total") : null,
|
|
1483
|
+
buildMs: ms("build"),
|
|
1484
|
+
promoteMs: ms("promote"),
|
|
1485
|
+
phases,
|
|
1486
|
+
buildMode: merged["build_mode"] ?? null,
|
|
1487
|
+
artifactTransport: merged["artifact_transport"] ?? null,
|
|
1488
|
+
buildMemoryMiB: Number.isFinite(memory) && merged["build_memory_mib"] !== undefined ? memory : null,
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
function errorGroupInput(ref) {
|
|
1492
|
+
return { workspaceID: ref.workspaceId, alias: ref.alias, groupID: ref.groupId };
|
|
1493
|
+
}
|
|
1494
|
+
function eventsInput(input) {
|
|
1495
|
+
return {
|
|
1496
|
+
...(input.names === undefined ? {} : { names: input.names }),
|
|
1497
|
+
...(input.arguments === undefined ? {} : { arguments: input.arguments }),
|
|
1498
|
+
...(input.cursor === undefined ? {} : { cursor: input.cursor }),
|
|
1499
|
+
...(input.max === undefined ? {} : { max: input.max }),
|
|
1500
|
+
};
|
|
877
1501
|
}
|
|
878
1502
|
const FILE_SELECTION = "path sizeBytes size sha256 updatedAt";
|
|
879
1503
|
const ENV_SELECTION = "name value secret alias inherited masked updatedAt";
|