gencow 0.1.223 → 0.1.225

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.
@@ -17,7 +17,6 @@ import {
17
17
  import { pollAppDeleteOperation } from "./app-delete-operation.mjs";
18
18
  import { appResponseError } from "./app-response-error.mjs";
19
19
  import { readJsonObjectResponse, readJsonValueResponse } from "./http-response-json.mjs";
20
- import { runWithAbortDeadline } from "./request-deadline.mjs";
21
20
  import { BOLD, CYAN, DIM, GREEN, RED, RESET, error, info, log, success, warn } from "./output.mjs";
22
21
  import { loadCreds, rpcMutation, rpcQuery, saveCreds, requireCreds } from "./platform-client.mjs";
23
22
  import { resolveProjectMetadataPath, resolveProjectSelection } from "./project-context.mjs";
@@ -28,7 +27,6 @@ import { updateEnvLocalUrl } from "./cli-project-runtime.mjs";
28
27
  const DEFAULT_DELETE_POLL_ATTEMPTS = 120;
29
28
  const DELETE_POLL_INTERVAL_MS = 1_000;
30
29
  const DEFAULT_APP_CREATE_TIMEOUT_MS = 60_000;
31
- const DEFAULT_APP_DELETE_REQUEST_TIMEOUT_MS = 20_000;
32
30
 
33
31
  function resolveAppCreateTimeoutMs(value) {
34
32
  const parsed = Number(value);
@@ -37,13 +35,6 @@ function resolveAppCreateTimeoutMs(value) {
37
35
  : DEFAULT_APP_CREATE_TIMEOUT_MS;
38
36
  }
39
37
 
40
- function resolveAppDeleteRequestTimeoutMs(value) {
41
- const parsed = Number(value);
42
- return Number.isSafeInteger(parsed) && parsed >= 100 && parsed <= 600_000
43
- ? parsed
44
- : DEFAULT_APP_DELETE_REQUEST_TIMEOUT_MS;
45
- }
46
-
47
38
  function appResponseStatus(response) {
48
39
  return Number.isSafeInteger(response?.status) ? `HTTP ${response.status}` : "unknown HTTP status";
49
40
  }
@@ -146,9 +137,6 @@ async function confirmDelete(name) {
146
137
 
147
138
  export function createAppCommand({
148
139
  appCreateTimeoutMs = resolveAppCreateTimeoutMs(process.env.GENCOW_APP_CREATE_TIMEOUT_MS),
149
- appDeleteRequestTimeoutMs = resolveAppDeleteRequestTimeoutMs(
150
- process.env.GENCOW_APP_DELETE_REQUEST_TIMEOUT_MS,
151
- ),
152
140
  clearTimeoutImpl = clearTimeout,
153
141
  confirmDeleteImpl = confirmDelete,
154
142
  createAbortControllerImpl = () => new AbortController(),
@@ -171,38 +159,28 @@ export function createAppCommand({
171
159
  warnImpl = warn,
172
160
  }) {
173
161
  const boundedAppCreateTimeoutMs = resolveAppCreateTimeoutMs(appCreateTimeoutMs);
174
- const boundedAppDeleteRequestTimeoutMs = resolveAppDeleteRequestTimeoutMs(appDeleteRequestTimeoutMs);
175
162
 
176
163
  async function requestAppCreate(creds, name) {
164
+ const controller = createAbortControllerImpl();
177
165
  const timeoutError = new Error(
178
166
  `App creation request timed out after ${boundedAppCreateTimeoutMs}ms. Please try again.`,
179
167
  );
180
- return runWithAbortDeadline({
181
- clearTimeoutImpl,
182
- createAbortControllerImpl,
183
- request: async (signal) => {
184
- const response = await rpcMutationImpl(creds, "apps.create", { name }, { signal });
185
- return { response, data: await readJsonObjectResponse(response) };
186
- },
187
- setTimeoutImpl,
188
- timeoutError,
189
- timeoutMs: boundedAppCreateTimeoutMs,
168
+ let timeoutHandle;
169
+ const timeoutPromise = new Promise((_resolve, reject) => {
170
+ timeoutHandle = setTimeoutImpl(() => {
171
+ controller.abort(timeoutError);
172
+ reject(timeoutError);
173
+ }, boundedAppCreateTimeoutMs);
190
174
  });
191
- }
192
-
193
- async function requestAppDelete(creds, name, requestId) {
194
- const timeoutError = new Error("App deletion request timed out");
195
- return runWithAbortDeadline({
196
- clearTimeoutImpl,
197
- createAbortControllerImpl,
198
- request: async (signal) => {
199
- const response = await rpcMutationImpl(creds, "apps.delete", { name, requestId }, { signal });
175
+ try {
176
+ const request = (async () => {
177
+ const response = await rpcMutationImpl(creds, "apps.create", { name }, { signal: controller.signal });
200
178
  return { response, data: await readJsonObjectResponse(response) };
201
- },
202
- setTimeoutImpl,
203
- timeoutError,
204
- timeoutMs: boundedAppDeleteRequestTimeoutMs,
205
- });
179
+ })();
180
+ return await Promise.race([request, timeoutPromise]);
181
+ } finally {
182
+ clearTimeoutImpl(timeoutHandle);
183
+ }
206
184
  }
207
185
 
208
186
  return async function app(subcmd, ...rest) {
@@ -364,16 +342,8 @@ ${dashboardLine}
364
342
 
365
343
  infoImpl(`Deleting app "${name}"...`);
366
344
  const requestId = createDeleteRequestIdImpl();
367
- let delRes;
368
- let delData;
369
- try {
370
- ({ response: delRes, data: delData } = await requestAppDelete(creds, name, requestId));
371
- } catch {
372
- errorImpl(
373
- "App deletion request did not complete. Its operation outcome is unknown; retry the same app delete to inspect it.\nCode: APP_DELETE_REQUEST_UNAVAILABLE",
374
- );
375
- return 1;
376
- }
345
+ const delRes = await rpcMutationImpl(creds, "apps.delete", { name, requestId });
346
+ let delData = await readJsonObjectResponse(delRes);
377
347
  if (!isExactAppDeleteResponse(delData, name)) {
378
348
  const operationResult = await pollAppDeleteOperation({
379
349
  creds,
@@ -1,7 +1,6 @@
1
1
  import { isExactAppDeleteResponse } from "./app-response-contract.mjs";
2
2
  import { APP_DELETE_OPERATION_PATTERN } from "./app-response-error.mjs";
3
3
  import { readJsonObjectResponse } from "./http-response-json.mjs";
4
- import { runWithAbortDeadline } from "./request-deadline.mjs";
5
4
 
6
5
  const APP_DELETE_ACTIVE_STATES = new Set([
7
6
  "accepted",
@@ -27,38 +26,6 @@ const APP_DELETE_RESPONSE_STATES = new Set([
27
26
  const CORRELATION_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/u;
28
27
  const DIAGNOSTIC_CODE_PATTERN = /^[A-Za-z][A-Za-z0-9_]{2,127}$/u;
29
28
  const APP_ID_PATTERN = /^(?=.{3,63}$)[a-z][a-z0-9]*(?:-[a-z0-9]+){2,7}$/u;
30
- const DEFAULT_DELETE_OPERATION_TIMEOUT_MS = 120_000;
31
- const DEFAULT_DELETE_STATUS_REQUEST_TIMEOUT_MS = 10_000;
32
-
33
- function resolveTimeoutMs(value, fallback) {
34
- const parsed = Number(value);
35
- return Number.isSafeInteger(parsed) && parsed >= 1 && parsed <= 600_000 ? parsed : fallback;
36
- }
37
-
38
- async function requestDeleteStatus({
39
- clearTimeoutImpl,
40
- createAbortControllerImpl,
41
- creds,
42
- name,
43
- operationId,
44
- requestTimeoutMs,
45
- rpcQueryImpl,
46
- setTimeoutImpl,
47
- }) {
48
- const timeoutError = new Error("App deletion status request timed out");
49
- timeoutError.name = "AbortError";
50
- return runWithAbortDeadline({
51
- clearTimeoutImpl,
52
- createAbortControllerImpl,
53
- request: async (signal) => {
54
- const statusRes = await rpcQueryImpl(creds, "apps.deleteStatus", { name, operationId }, { signal });
55
- return { statusRes, statusData: await readJsonObjectResponse(statusRes) };
56
- },
57
- setTimeoutImpl,
58
- timeoutError,
59
- timeoutMs: requestTimeoutMs,
60
- });
61
- }
62
29
 
63
30
  function isAllowedStatusPath(value, operationId, expectedAppId) {
64
31
  if (value === undefined) return true;
@@ -137,18 +104,12 @@ function isMatchingTerminalFailureResponse(value, accepted, expectedAppId) {
137
104
  }
138
105
 
139
106
  export async function pollAppDeleteOperation({
140
- clearTimeoutImpl = clearTimeout,
141
- createAbortControllerImpl = () => new AbortController(),
142
107
  creds,
143
108
  initialBody,
144
109
  name,
145
- nowImpl = Date.now,
146
- operationTimeoutMs = DEFAULT_DELETE_OPERATION_TIMEOUT_MS,
147
110
  pollAttempts,
148
111
  pollIntervalMs,
149
- requestTimeoutMs = DEFAULT_DELETE_STATUS_REQUEST_TIMEOUT_MS,
150
112
  rpcQueryImpl,
151
- setTimeoutImpl = setTimeout,
152
113
  sleepImpl,
153
114
  }) {
154
115
  const accepted = parseAppDeleteOperationEnvelope(initialBody, name);
@@ -160,25 +121,13 @@ export async function pollAppDeleteOperation({
160
121
  let latest = accepted;
161
122
  let successfulPollResponses = 0;
162
123
  let transportFailures = 0;
163
- const deadlineAt = nowImpl() + resolveTimeoutMs(operationTimeoutMs, DEFAULT_DELETE_OPERATION_TIMEOUT_MS);
164
- const boundedRequestTimeoutMs = resolveTimeoutMs(
165
- requestTimeoutMs,
166
- DEFAULT_DELETE_STATUS_REQUEST_TIMEOUT_MS,
167
- );
168
124
  for (let attempt = 0; attempt < pollAttempts; attempt += 1) {
169
- const remainingMs = deadlineAt - nowImpl();
170
- if (remainingMs <= 0) break;
171
125
  try {
172
- const { statusData, statusRes } = await requestDeleteStatus({
173
- clearTimeoutImpl,
174
- createAbortControllerImpl,
175
- creds,
126
+ const statusRes = await rpcQueryImpl(creds, "apps.deleteStatus", {
176
127
  name,
177
128
  operationId: accepted.operationId,
178
- requestTimeoutMs: Math.min(boundedRequestTimeoutMs, remainingMs),
179
- rpcQueryImpl,
180
- setTimeoutImpl,
181
129
  });
130
+ const statusData = await readJsonObjectResponse(statusRes);
182
131
  // A lifecycle operation is durable. A transient 5xx while the status
183
132
  // query is being served must not turn that durable receipt into a client
184
133
  // contract failure or abandon the operation's correlation identity.
@@ -208,10 +157,7 @@ export async function pollAppDeleteOperation({
208
157
  transportFailures += 1;
209
158
  // The durable receipt remains authoritative across transient polling failures.
210
159
  }
211
- if (attempt + 1 < pollAttempts) {
212
- const remainingAfterRequestMs = deadlineAt - nowImpl();
213
- if (remainingAfterRequestMs > 0) await sleepImpl(Math.min(pollIntervalMs, remainingAfterRequestMs));
214
- }
160
+ if (attempt + 1 < pollAttempts) await sleepImpl(pollIntervalMs);
215
161
  }
216
162
 
217
163
  const pollingUnavailable = successfulPollResponses === 0 && transportFailures > 0;
@@ -20,6 +20,7 @@ import { buildDeployClaimFromArchive } from "./deploy-package-claim.mjs";
20
20
  import { renderDeployFailureDiagnosticLines } from "./deploy-failure-diagnostic.mjs";
21
21
  import { readJsonObjectResponse } from "./http-response-json.mjs";
22
22
  import { pollAcceptedDeploymentOperation } from "./deployment-operation-poll.mjs";
23
+ import { submitDeploymentWithIdempotentRetry } from "./deployment-admission-retry.mjs";
23
24
  import { acquireDeployReceipt, releaseDeployReceipt } from "./deploy-receipt-store.mjs";
24
25
  import { CYAN, DIM, RED, RESET, YELLOW, error, info, log, success, warn } from "./output.mjs";
25
26
  import { platformFetch, rpcMutation } from "./platform-client.mjs";
@@ -42,36 +43,6 @@ import {
42
43
  import { detectProjectConfigFile, warnProjectConfigSelectionOnce } from "./project-config-selection.mjs";
43
44
  import { writeCanonicalDeployBundle } from "./deploy-bundle-staging.mjs";
44
45
 
45
- const INITIAL_DEPLOYMENT_ADMISSION_ATTEMPTS = 3;
46
-
47
- function isRetryableInitialDeploymentAdmission(response) {
48
- return Number.isInteger(response?.status) && response.status >= 500 && response.status <= 599;
49
- }
50
-
51
- /**
52
- * A deploy claim is an idempotency key. Before the Platform returns the
53
- * operation receipt, a transport/5xx outcome is intentionally ambiguous;
54
- * resubmit only that same claim a bounded number of times rather than making
55
- * the customer invent a second operation.
56
- */
57
- export async function submitDeploymentWithIdempotentRetry({
58
- submit,
59
- sleepImpl = (ms) => new Promise((resolveDelay) => setTimeout(resolveDelay, ms)),
60
- }) {
61
- let response;
62
- for (let attempt = 1; attempt <= INITIAL_DEPLOYMENT_ADMISSION_ATTEMPTS; attempt += 1) {
63
- response = await submit();
64
- if (
65
- !isRetryableInitialDeploymentAdmission(response) ||
66
- attempt === INITIAL_DEPLOYMENT_ADMISSION_ATTEMPTS
67
- ) {
68
- return response;
69
- }
70
- await sleepImpl(250 * attempt);
71
- }
72
- return response;
73
- }
74
-
75
46
  async function loadProjectConfigDefault(options) {
76
47
  const { loadConfig } = await import("./cli-project-runtime.mjs");
77
48
  return loadConfig(options);
@@ -0,0 +1,31 @@
1
+ const INITIAL_DEPLOYMENT_ADMISSION_ATTEMPTS = 3;
2
+
3
+ function isRetryableInitialDeploymentAdmission(response) {
4
+ return Number.isInteger(response?.status) && response.status >= 500 && response.status <= 599;
5
+ }
6
+
7
+ /**
8
+ * A deploy claim is an idempotency key. Before Platform returns an operation
9
+ * receipt, retry only the same claim after an ambiguous transport or 5xx
10
+ * outcome; do not create replacement work.
11
+ */
12
+ export async function submitDeploymentWithIdempotentRetry({
13
+ submit,
14
+ sleepImpl = (ms) => new Promise((resolveDelay) => setTimeout(resolveDelay, ms)),
15
+ }) {
16
+ let response;
17
+ for (let attempt = 1; attempt <= INITIAL_DEPLOYMENT_ADMISSION_ATTEMPTS; attempt += 1) {
18
+ try {
19
+ response = await submit();
20
+ } catch (error) {
21
+ if (attempt === INITIAL_DEPLOYMENT_ADMISSION_ATTEMPTS) throw error;
22
+ await sleepImpl(250 * attempt);
23
+ continue;
24
+ }
25
+ if (!isRetryableInitialDeploymentAdmission(response) || attempt === INITIAL_DEPLOYMENT_ADMISSION_ATTEMPTS) {
26
+ return response;
27
+ }
28
+ await sleepImpl(250 * attempt);
29
+ }
30
+ return response;
31
+ }
@@ -2,6 +2,7 @@ import { readJsonObjectResponse } from "./http-response-json.mjs";
2
2
 
3
3
  const PENDING_STATES = new Set(["accepted", "queued", "running"]);
4
4
  const TERMINAL_STATES = new Set(["succeeded", "failed", "blocked", "cancelled"]);
5
+ const TERMINAL_FAILURE_STATES = new Set(["failed", "blocked"]);
5
6
  const OPERATION_ID = /^[1-9][0-9]*$/u;
6
7
  const CORRELATION_ID = /^[A-Za-z0-9_-]{8,128}$/u;
7
8
  const APP_NAME = /^[a-z0-9][a-z0-9-]{0,62}$/u;
@@ -63,12 +64,24 @@ function isNonterminalResponse(response, body, identity) {
63
64
  );
64
65
  }
65
66
 
66
- function isTransientPinnedOperationRead(response, expected) {
67
+ function isMatchingTerminalPinnedFailure(response, identity, expected) {
67
68
  return (
68
69
  expected.operationId !== null &&
69
70
  Number.isInteger(response?.status) &&
70
71
  response.status >= 500 &&
71
- response.status <= 599
72
+ response.status <= 599 &&
73
+ TERMINAL_FAILURE_STATES.has(identity.state) &&
74
+ identityIsValid(identity, expected, false)
75
+ );
76
+ }
77
+
78
+ function isTransientPinnedOperationRead(response, identity, expected) {
79
+ return (
80
+ expected.operationId !== null &&
81
+ Number.isInteger(response?.status) &&
82
+ response.status >= 500 &&
83
+ response.status <= 599 &&
84
+ !isMatchingTerminalPinnedFailure(response, identity, expected)
72
85
  );
73
86
  }
74
87
 
@@ -134,6 +147,12 @@ export async function pollAcceptedDeploymentOperation({
134
147
  platformFetch(creds, expected.pollUrl, { method: "GET", signal: controller.signal }),
135
148
  timedOut,
136
149
  ]);
150
+ } catch {
151
+ // The operation identity was already sealed by the accepted response.
152
+ // A transport failure while observing it is not that operation's
153
+ // terminal result. Present it as a transient pinned read so the loop
154
+ // keeps polling the same receipt until its declared client deadline.
155
+ return { ok: false, status: 503, statusText: "Service Unavailable" };
137
156
  } finally {
138
157
  if (timer !== undefined) deadlineClearTimeoutImpl(timer);
139
158
  }
@@ -165,7 +184,7 @@ export async function pollAcceptedDeploymentOperation({
165
184
  // Once the server has sealed the operation identity, a gateway/database
166
185
  // read failure cannot safely be presented as that operation's terminal
167
186
  // result. Keep polling the same receipt; never submit a second deploy.
168
- if (isTransientPinnedOperationRead(response, expected)) {
187
+ if (isTransientPinnedOperationRead(response, identity, expected)) {
169
188
  if (nowImpl() >= deadlineMs) return pendingResponse();
170
189
  const retryAfter = Number(response.headers?.get?.("Retry-After"));
171
190
  const retryDelayMs =
@@ -96,11 +96,10 @@ export async function platformFetch(creds, path, opts = {}) {
96
96
  });
97
97
  }
98
98
 
99
- export async function rpcQuery(creds, queryName, args = {}, requestOptions = {}) {
99
+ export async function rpcQuery(creds, queryName, args = {}) {
100
100
  return platformFetch(creds, "/api/query", {
101
- ...requestOptions,
102
101
  method: "POST",
103
- headers: { ...requestOptions.headers, "Content-Type": "application/json" },
102
+ headers: { "Content-Type": "application/json" },
104
103
  body: JSON.stringify({ name: queryName, args }),
105
104
  });
106
105
  }
@@ -47,7 +47,17 @@ export async function postStaticDeployWithTransientProxyRetry({
47
47
  }) {
48
48
  let response;
49
49
  for (let attempt = 0; ; attempt += 1) {
50
- response = await send();
50
+ try {
51
+ response = await send();
52
+ } catch (error) {
53
+ // `send` closes over one immutable deploy claim (and, when enabled, one
54
+ // release attempt). Replaying it is admission observation, not a new
55
+ // static deployment. Keep this bounded; the final transport error is
56
+ // still reported to the caller without inventing a terminal outcome.
57
+ if (attempt >= retryDelaysMs.length) throw error;
58
+ await delayImpl(retryDelaysMs[attempt]);
59
+ continue;
60
+ }
51
61
  if (!(await isBodylessTransientStaticDeployResponse(response)) || attempt >= retryDelaysMs.length) {
52
62
  return response;
53
63
  }
@@ -446,14 +456,19 @@ export function createStaticDeployRuntime({
446
456
  setTimeoutImpl,
447
457
  });
448
458
  }
449
- } catch (caught) {
450
- await markAttemptFailed("deploy", "DEPLOY_TRANSPORT_FAILED");
459
+ } catch {
460
+ // No response means the canonical deployment may already exist. Do not
461
+ // let this observer terminalize its release attempt; retain the same
462
+ // claim/attempt so a normal retry converges through the executor owner.
451
463
  try {
452
464
  unlinkSyncImpl(tmpBundle);
453
465
  } catch {
454
466
  /* ignore */
455
467
  }
456
- throw caught;
468
+ errorImpl("Static deployment admission is temporarily unavailable. Retry the same command.");
469
+ infoImpl("Code: PLATFORM_DEPLOYMENT_ADMISSION_UNAVAILABLE");
470
+ exitImpl(1);
471
+ return;
457
472
  }
458
473
 
459
474
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gencow",
3
- "version": "0.1.223",
3
+ "version": "0.1.225",
4
4
  "description": "Gencow — AI Backend Engine",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,9 +33,9 @@
33
33
  "devDependencies": {
34
34
  "@types/node": "^25.9.5",
35
35
  "better-auth": "^1.6.23",
36
+ "@gencow/core": "0.1.43",
36
37
  "@gencow/client": "0.2.7",
37
- "@gencow/react": "0.2.7",
38
- "@gencow/core": "0.1.43"
38
+ "@gencow/react": "0.2.7"
39
39
  },
40
40
  "scripts": {
41
41
  "prebuild": "pnpm --filter @gencow/migration-contract run build && pnpm --filter @gencow/server run build",