@qoretechnologies/qlip 0.1.3-pr.30.gffaaa15 → 0.1.3-pr.32.g3ecd7a4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -165,6 +165,15 @@ qlip prints a one-time warning when it detects a shallow clone. It
165
165
  never fails the upload over this — accuracy degrades, the run still
166
166
  succeeds.
167
167
 
168
+ ### Who triggered the build
169
+
170
+ On GitHub Actions qlip also sends the user who triggered the run
171
+ (`$GITHUB_ACTOR`, with `$GITHUB_ACTOR_ID` for a rename-proof avatar), so
172
+ the dashboard shows who pushed each build. Nothing to configure; set
173
+ `upload.triggeredByLogin` (and optionally `upload.triggeredById`) to
174
+ override it, for example from a CI system that isn't GitHub Actions.
175
+ Outside CI the field is simply omitted.
176
+
168
177
  ## Manual screenshots inside play
169
178
 
170
179
  Use the existing **"Logged In"** story as a real-world example:
@@ -308,6 +317,10 @@ Several `vitest run` invocations (or `test-storybook` shards) can capture into o
308
317
 
309
318
  Add `--fail-on-partial-build` to `qlip-upload` (or `upload.failOnPartialBuild` on the plugin) to turn "screenshots on disk that never reached the manifest" from a warning into a red build.
310
319
 
320
+ ### Large builds: processing timeout
321
+
322
+ The server diffs the whole build before it answers the final upload request, which takes minutes for a large Storybook (about 8–12 minutes for ~2,200 snapshots). `qlip-upload` waits up to 30 minutes for that answer; raise it with `--processing-timeout <seconds>` (or `$QLIP_PROCESSING_TIMEOUT`, or `upload.processingTimeoutMs` on the plugin). A timeout or network failure is reported with the failing phase and its cause; add `--fail-on-upload-error` (or `upload.failOnUploadError`) to make it fail the CI step instead of logging it.
323
+
311
324
  ## Manifest
312
325
 
313
326
  Each run writes `manifest.json` inside the build folder with tool metadata, defaults, stats, and per-screenshot entries (auto + manual).
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { qlipVitestPlugin } from './plugin/vitestPlugin.js';
2
2
  export { screenshot } from './runtime/screenshot.js';
3
- export { uploadBuild, QlipUploadError, type QlipUploadInput, type QlipUploadResult, } from './upload/upload.js';
3
+ export { uploadBuild, QlipUploadError, QlipUploadNetworkError, DEFAULT_PROCESSING_TIMEOUT_MS, type QlipUploadInput, type QlipUploadResult, } from './upload/upload.js';
4
4
  export { autodetectBranch, autodetectCommit, autodetectBaseBranch, autodetectAncestorCommits, } from './upload/autodetect.js';
5
5
  export type { QlipConsoleLevel, QlipConsoleMessage, QlipManifest, QlipManifestEntry, QlipParameters, QlipPluginOptions, QlipResolvedDefaults, QlipScreenshotOptions, QlipUploadOptions, QlipViewport, } from './types.js';
6
6
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAC5D,OAAO,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AACrD,OAAO,EACL,WAAW,EACX,eAAe,EACf,KAAK,eAAe,EACpB,KAAK,gBAAgB,GACtB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,gBAAgB,EAChB,gBAAgB,EAChB,oBAAoB,EACpB,yBAAyB,GAC1B,MAAM,wBAAwB,CAAC;AAChC,YAAY,EACV,gBAAgB,EAChB,kBAAkB,EAClB,YAAY,EACZ,iBAAiB,EACjB,cAAc,EACd,iBAAiB,EACjB,oBAAoB,EACpB,qBAAqB,EACrB,iBAAiB,EACjB,YAAY,GACb,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAC5D,OAAO,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AACrD,OAAO,EACL,WAAW,EACX,eAAe,EACf,sBAAsB,EACtB,6BAA6B,EAC7B,KAAK,eAAe,EACpB,KAAK,gBAAgB,GACtB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,gBAAgB,EAChB,gBAAgB,EAChB,oBAAoB,EACpB,yBAAyB,GAC1B,MAAM,wBAAwB,CAAC;AAChC,YAAY,EACV,gBAAgB,EAChB,kBAAkB,EAClB,YAAY,EACZ,iBAAiB,EACjB,cAAc,EACd,iBAAiB,EACjB,oBAAoB,EACpB,qBAAqB,EACrB,iBAAiB,EACjB,YAAY,GACb,MAAM,YAAY,CAAC"}
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export { qlipVitestPlugin } from './plugin/vitestPlugin.js';
2
2
  export { screenshot } from './runtime/screenshot.js';
3
- export { uploadBuild, QlipUploadError, } from './upload/upload.js';
3
+ export { uploadBuild, QlipUploadError, QlipUploadNetworkError, DEFAULT_PROCESSING_TIMEOUT_MS, } from './upload/upload.js';
4
4
  export { autodetectBranch, autodetectCommit, autodetectBaseBranch, autodetectAncestorCommits, } from './upload/autodetect.js';
@@ -1 +1 @@
1
- {"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../../../src/test-runner/cli/upload.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAoIH,MAAM,WAAW,gBAAgB;IAC/B,+CAA+C;IAC/C,QAAQ,EAAE,MAAM,CAAC;IACjB,uEAAuE;IACvE,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;GAIG;AACH,eAAO,MAAM,SAAS,GACpB,MAAM,MAAM,EAAE,EACd,MAAK,MAAM,CAAC,UAAwB,EACpC,SAAQ;IAAE,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAAC,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAAY,KAC7E,OAAO,CAAC,gBAAgB,CAsE1B,CAAC"}
1
+ {"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../../../src/test-runner/cli/upload.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AA2IH,MAAM,WAAW,gBAAgB;IAC/B,+CAA+C;IAC/C,QAAQ,EAAE,MAAM,CAAC;IACjB,uEAAuE;IACvE,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;GAIG;AACH,eAAO,MAAM,SAAS,GACpB,MAAM,MAAM,EAAE,EACd,MAAK,MAAM,CAAC,UAAwB,EACpC,SAAQ;IAAE,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAAC,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAAY,KAC7E,OAAO,CAAC,gBAAgB,CAqF1B,CAAC"}
@@ -52,6 +52,9 @@ Options:
52
52
  --token <token> Bearer token. Default: \$QLIP_UPLOAD_TOKEN.
53
53
  --branch <branch> Override branch detection.
54
54
  --commit <sha> Override commit detection.
55
+ --processing-timeout <s> Seconds to wait for the server to process
56
+ the build. Default:
57
+ \$QLIP_PROCESSING_TIMEOUT or 1800.
55
58
  --fail-on-upload-error Exit non-zero on upload failure (default:
56
59
  log error + exit 0).
57
60
  --fail-on-partial-build Exit non-zero when screenshots on disk are
@@ -95,6 +98,9 @@ const parseArgs = (argv) => {
95
98
  case '--commit':
96
99
  args.commit = next();
97
100
  break;
101
+ case '--processing-timeout':
102
+ args.processingTimeout = next();
103
+ break;
98
104
  case '--fail-on-upload-error':
99
105
  args.failOnUploadError = true;
100
106
  break;
@@ -172,6 +178,16 @@ export const runUpload = async (argv, env = process.env, logger = console) => {
172
178
  logger.error(`qlip-upload: ${err.message}`);
173
179
  return { exitCode: 1 };
174
180
  }
181
+ const processingTimeout = parsed.processingTimeout ?? env['QLIP_PROCESSING_TIMEOUT'];
182
+ let processingTimeoutMs;
183
+ if (processingTimeout !== undefined && processingTimeout !== '') {
184
+ const seconds = Number(processingTimeout);
185
+ if (!Number.isFinite(seconds) || seconds <= 0) {
186
+ logger.error(`qlip-upload: --processing-timeout must be a positive number of seconds, got "${processingTimeout}"`);
187
+ return { exitCode: 1 };
188
+ }
189
+ processingTimeoutMs = seconds * 1000;
190
+ }
175
191
  const project = parsed.project ?? env['QLIP_PROJECT'] ?? 'default';
176
192
  const token = parsed.token ?? env['QLIP_UPLOAD_TOKEN'];
177
193
  const upload = {
@@ -180,6 +196,7 @@ export const runUpload = async (argv, env = process.env, logger = console) => {
180
196
  ...(token !== undefined && token !== '' ? { uploadToken: token } : {}),
181
197
  ...(parsed.branch !== undefined ? { branch: parsed.branch } : {}),
182
198
  ...(parsed.commit !== undefined ? { commit: parsed.commit } : {}),
199
+ ...(processingTimeoutMs !== undefined ? { processingTimeoutMs } : {}),
183
200
  failOnUploadError: parsed.failOnUploadError,
184
201
  failOnPartialBuild: parsed.failOnPartialBuild,
185
202
  };
package/dist/types.d.ts CHANGED
@@ -149,6 +149,19 @@ export interface QlipUploadOptions {
149
149
  * "View PR" link. See `design/UPLOAD.md`.
150
150
  */
151
151
  pullRequestUrl?: string;
152
+ /**
153
+ * GitHub login of the user who triggered the build — shown as the
154
+ * build's author in the dashboard. Auto-detected from `$GITHUB_ACTOR`
155
+ * when omitted. Undefined (not sent) outside GitHub Actions; a
156
+ * dashboard upload is attributed from the signed-in session instead.
157
+ */
158
+ triggeredByLogin?: string;
159
+ /**
160
+ * GitHub account id of that user (`$GITHUB_ACTOR_ID`). Stable across
161
+ * a login rename, so the server prefers it for the avatar. Only sent
162
+ * alongside `triggeredByLogin`.
163
+ */
164
+ triggeredById?: number;
152
165
  /** Skip upload entirely (handy for local-only runs). */
153
166
  disabled?: boolean;
154
167
  /**
@@ -157,6 +170,12 @@ export interface QlipUploadOptions {
157
170
  * Set to `true` in CI when you want red builds on upload errors.
158
171
  */
159
172
  failOnUploadError?: boolean;
173
+ /**
174
+ * How long to wait for the server to answer the requests it answers only
175
+ * after processing the build (finalize, or the legacy upload), in ms.
176
+ * Default: 30 minutes. See `design/UPLOAD.md` "Long-running requests".
177
+ */
178
+ processingTimeoutMs?: number;
160
179
  /**
161
180
  * Whether a partial build should fail the run. A build is partial
162
181
  * when PNGs exist on disk that no manifest entry references — i.e.
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,CAAC,EAAE,OAAO,CAAC;IACf;;;;OAIG;IACH,QAAQ,CAAC,EAAE,YAAY,CAAC;IACxB;;;;;;;;;;;;;;;;;OAiBG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B;;;;;;;;;OASG;IACH,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;;;;OAOG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,oBAAoB,CAAC,EAAE,gBAAgB,EAAE,CAAC;IAC1C,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,yBAAyB,CAAC,EAAE,MAAM,EAAE,CAAC;CACtC;AAED,MAAM,WAAW,cAAe,SAAQ,kBAAkB;IACxD,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED;;;GAGG;AACH,MAAM,WAAW,iBAAiB;IAChC;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;;;;;OAUG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,2DAA2D;IAC3D,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,wDAAwD;IACxD,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B;;;;;OAKG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;CAC9B;AAED,MAAM,WAAW,iBAAkB,SAAQ,kBAAkB;IAC3D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;;;OAGG;IACH,MAAM,CAAC,EAAE,iBAAiB,CAAC;IAC3B;;;;;OAKG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED,MAAM,MAAM,oBAAoB,GAAG,QAAQ,CAAC,kBAAkB,CAAC,GAAG;IAChE,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,OAAO,CAAC;CACzB,CAAC;AAEF,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,oBAAoB,CAAC;IAC/B,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IACxC,2CAA2C;IAC3C,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED,MAAM,WAAW,qBAAsB,SAAQ,kBAAkB;IAC/D,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,gBAAgB;IAC/B,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;;;;OAOG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE;QACX,IAAI,CAAC,EAAE,cAAc,CAAC;QACtB;;;;;;;WAOG;QACH,IAAI,CAAC,EAAE;YAAE,WAAW,CAAC,EAAE;gBAAE,KAAK,CAAC,EAAE,MAAM,CAAA;aAAE,CAAA;SAAE,CAAC;KAC7C,CAAC;CACH;AAED,MAAM,MAAM,gBAAgB,GAAG,OAAO,GAAG,MAAM,CAAC;AAEhD,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,gBAAgB,CAAC;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,QAAQ,GAAG,OAAO,CAAC;AACxD,MAAM,MAAM,eAAe,GAAG,UAAU,GAAG,SAAS,GAAG,QAAQ,CAAC;AAEhE,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,aAAa,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;;;;;;OAWG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;;;OAQG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,MAAM,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,YAAY,CAAC;IACvB,MAAM,EAAE,eAAe,CAAC;IACxB,KAAK,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAClD,OAAO,EAAE;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC;IACxB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IACxC,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,oBAAoB,CAAC;IAC/B,KAAK,EAAE;QACL,YAAY,EAAE,MAAM,CAAC;QACrB,YAAY,EAAE,MAAM,CAAC;QACrB,cAAc,EAAE,MAAM,CAAC;QACvB,OAAO,EAAE,MAAM,CAAC;QAChB,MAAM,EAAE,MAAM,CAAC;QACf,UAAU,EAAE,MAAM,CAAC;KACpB,CAAC;IACF,OAAO,EAAE,iBAAiB,EAAE,CAAC;CAC9B;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,aAAa,CAAC;CACrB;AAED;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,oBAAqB,SAAQ,YAAY;IACxD,wDAAwD;IACxD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,oEAAoE;IACpE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,qBAAqB,EAAE,CAAC;CACtC"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
@@ -38,6 +38,29 @@ export declare const autodetectBaseBranch: () => string | undefined;
38
38
  * `qlip-server/design/UPLOAD.md`.
39
39
  */
40
40
  export declare const autodetectPullRequestUrl: () => string | undefined;
41
+ /** The GitHub user whose action produced the build. */
42
+ export interface IUploadTriggeredBy {
43
+ /** GitHub login (`$GITHUB_ACTOR`). */
44
+ login: string;
45
+ /**
46
+ * GitHub account id (`$GITHUB_ACTOR_ID`). Stable across a rename, so
47
+ * the server prefers it for the avatar; omitted on an older runner
48
+ * that doesn't set it.
49
+ */
50
+ id?: number;
51
+ }
52
+ /**
53
+ * Who triggered the CI run — the user the dashboard shows as the
54
+ * build's author. GitHub Actions sets `$GITHUB_ACTOR` (the login) and
55
+ * `$GITHUB_ACTOR_ID` (the numeric account id) on every run.
56
+ *
57
+ * Env-only by design: outside CI there is no GitHub identity to read
58
+ * (a git author is an email, not a login), and a dashboard upload is
59
+ * attributed server-side from the signed-in session. Returns undefined
60
+ * for local runs; the server stores it as nullable, so absence just
61
+ * means "author unknown". See `qlip-server/design/UPLOAD.md`.
62
+ */
63
+ export declare const autodetectTriggeredBy: () => IUploadTriggeredBy | undefined;
41
64
  /**
42
65
  * Commit SHAs from HEAD backward, nearest-first, capped at
43
66
  * MAX_ANCESTOR_COMMITS. The server walks this list to find the nearest
@@ -1 +1 @@
1
- {"version":3,"file":"autodetect.d.ts","sourceRoot":"","sources":["../../src/upload/autodetect.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAqBH,eAAO,MAAM,gBAAgB,QAAO,MAAM,GAAG,SAW5C,CAAC;AAkCF,eAAO,MAAM,gBAAgB,QAAO,MAAM,GAAG,SAS5C,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,QAAO,MAAM,GAAG,SAGhD,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,wBAAwB,QAAO,MAAM,GAAG,SAcpD,CAAC;AA0BF;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB,QAAO,MAAM,EAAE,GAAG,SAgBvD,CAAC"}
1
+ {"version":3,"file":"autodetect.d.ts","sourceRoot":"","sources":["../../src/upload/autodetect.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAqBH,eAAO,MAAM,gBAAgB,QAAO,MAAM,GAAG,SAW5C,CAAC;AAkCF,eAAO,MAAM,gBAAgB,QAAO,MAAM,GAAG,SAS5C,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,QAAO,MAAM,GAAG,SAGhD,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,wBAAwB,QAAO,MAAM,GAAG,SAcpD,CAAC;AAEF,uDAAuD;AACvD,MAAM,WAAW,kBAAkB;IACjC,sCAAsC;IACtC,KAAK,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,EAAE,CAAC,EAAE,MAAM,CAAC;CACb;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,qBAAqB,QAAO,kBAAkB,GAAG,SAM7D,CAAC;AA0BF;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB,QAAO,MAAM,EAAE,GAAG,SAgBvD,CAAC"}
@@ -127,6 +127,25 @@ export const autodetectPullRequestUrl = () => {
127
127
  const base = serverUrl.replace(/\/+$/, '');
128
128
  return `${base}/${repo}/pull/${prNumber}`;
129
129
  };
130
+ /**
131
+ * Who triggered the CI run — the user the dashboard shows as the
132
+ * build's author. GitHub Actions sets `$GITHUB_ACTOR` (the login) and
133
+ * `$GITHUB_ACTOR_ID` (the numeric account id) on every run.
134
+ *
135
+ * Env-only by design: outside CI there is no GitHub identity to read
136
+ * (a git author is an email, not a login), and a dashboard upload is
137
+ * attributed server-side from the signed-in session. Returns undefined
138
+ * for local runs; the server stores it as nullable, so absence just
139
+ * means "author unknown". See `qlip-server/design/UPLOAD.md`.
140
+ */
141
+ export const autodetectTriggeredBy = () => {
142
+ const login = process.env['GITHUB_ACTOR']?.trim();
143
+ if (!login || login.length === 0)
144
+ return undefined;
145
+ const rawId = process.env['GITHUB_ACTOR_ID']?.trim();
146
+ const id = rawId && /^\d+$/.test(rawId) ? Number(rawId) : undefined;
147
+ return id !== undefined ? { login, id } : { login };
148
+ };
130
149
  let shallowWarningEmitted = false;
131
150
  /**
132
151
  * A shallow clone (CI default for `actions/checkout`) only has the tip
@@ -1 +1 @@
1
- {"version":3,"file":"finalize.d.ts","sourceRoot":"","sources":["../../src/upload/finalize.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAoBxE;;;;GAIG;AACH,qBAAa,qBAAsB,SAAQ,KAAK;gBAClC,OAAO,EAAE,MAAM;CAI5B;AA6BD,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,iBAAiB,CAAC;IAC3B,MAAM,CAAC,EAAE,iBAAiB,CAAC;CAC5B;AAED,eAAO,MAAM,aAAa,GACxB,MAAM,qBAAqB,KAC1B,OAAO,CAAC,IAAI,CA6Gd,CAAC"}
1
+ {"version":3,"file":"finalize.d.ts","sourceRoot":"","sources":["../../src/upload/finalize.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAqBxE;;;;GAIG;AACH,qBAAa,qBAAsB,SAAQ,KAAK;gBAClC,OAAO,EAAE,MAAM;CAI5B;AA6BD,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,iBAAiB,CAAC;IAC3B,MAAM,CAAC,EAAE,iBAAiB,CAAC;CAC5B;AAED,eAAO,MAAM,aAAa,GACxB,MAAM,qBAAqB,KAC1B,OAAO,CAAC,IAAI,CA2Hd,CAAC"}
@@ -17,7 +17,7 @@
17
17
  * dedupes — if both paths fire in the same run, only the first
18
18
  * actually does the work.
19
19
  */
20
- import { autodetectAncestorCommits, autodetectBaseBranch, autodetectBranch, autodetectCommit, autodetectPullRequestUrl, } from './autodetect.js';
20
+ import { autodetectAncestorCommits, autodetectBaseBranch, autodetectBranch, autodetectCommit, autodetectPullRequestUrl, autodetectTriggeredBy, } from './autodetect.js';
21
21
  import { buildCaptureReport, describeBaselineCollisions, describeCollisions, describeMissingCaptures, describePartialBuild, writeCaptureReport, } from './capture-report.js';
22
22
  import { mergeManifestFragments } from './manifest.js';
23
23
  import { QlipUploadError, uploadBuild, resolveServerUrl } from './upload.js';
@@ -118,6 +118,15 @@ export const finalizeBuild = async (opts) => {
118
118
  const baseBranch = opts.upload.baseBranch ?? autodetectBaseBranch();
119
119
  const ancestorCommits = opts.upload.ancestorCommits ?? autodetectAncestorCommits();
120
120
  const pullRequestUrl = opts.upload.pullRequestUrl ?? autodetectPullRequestUrl();
121
+ // An explicit login wins outright (with whatever id came with it);
122
+ // otherwise both come from the CI env together.
123
+ const detectedTriggeredBy = opts.upload.triggeredByLogin === undefined
124
+ ? autodetectTriggeredBy()
125
+ : undefined;
126
+ const triggeredByLogin = opts.upload.triggeredByLogin ?? detectedTriggeredBy?.login;
127
+ const triggeredById = opts.upload.triggeredByLogin !== undefined
128
+ ? opts.upload.triggeredById
129
+ : detectedTriggeredBy?.id;
121
130
  const resolved = {
122
131
  ...opts.upload,
123
132
  ...(branch !== undefined ? { branch } : {}),
@@ -125,6 +134,8 @@ export const finalizeBuild = async (opts) => {
125
134
  ...(baseBranch !== undefined ? { baseBranch } : {}),
126
135
  ...(ancestorCommits !== undefined ? { ancestorCommits } : {}),
127
136
  ...(pullRequestUrl !== undefined ? { pullRequestUrl } : {}),
137
+ ...(triggeredByLogin !== undefined ? { triggeredByLogin } : {}),
138
+ ...(triggeredById !== undefined ? { triggeredById } : {}),
128
139
  };
129
140
  try {
130
141
  const result = await uploadBuild({
@@ -0,0 +1,15 @@
1
+ /**
2
+ * HTTP transport for the upload requests qlip-server answers only once it
3
+ * has processed the whole build: `finalize` and the legacy multipart POST.
4
+ *
5
+ * Node's global `fetch` (undici) fails any request whose response headers
6
+ * take longer than 300 s (`headersTimeout`) with a bare "fetch failed", and
7
+ * the limit can't be raised without depending on the `undici` package. A
8
+ * large build's diff runs longer than that, so these requests go through
9
+ * `node:http`, which imposes no response deadline: the caller's `signal` is
10
+ * the only one. See design/UPLOAD.md "Long-running requests".
11
+ */
12
+ /** The subset of `fetch` the upload client uses; `fetch` itself satisfies it. */
13
+ export type TFetchLike = (url: string, init: RequestInit) => Promise<Response>;
14
+ export declare const fetchAwaitingProcessing: TFetchLike;
15
+ //# sourceMappingURL=processing-request.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"processing-request.d.ts","sourceRoot":"","sources":["../../src/upload/processing-request.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAKH,iFAAiF;AACjF,MAAM,MAAM,UAAU,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;AAqC/E,eAAO,MAAM,uBAAuB,EAAE,UAsDrC,CAAC"}
@@ -0,0 +1,89 @@
1
+ /**
2
+ * HTTP transport for the upload requests qlip-server answers only once it
3
+ * has processed the whole build: `finalize` and the legacy multipart POST.
4
+ *
5
+ * Node's global `fetch` (undici) fails any request whose response headers
6
+ * take longer than 300 s (`headersTimeout`) with a bare "fetch failed", and
7
+ * the limit can't be raised without depending on the `undici` package. A
8
+ * large build's diff runs longer than that, so these requests go through
9
+ * `node:http`, which imposes no response deadline: the caller's `signal` is
10
+ * the only one. See design/UPLOAD.md "Long-running requests".
11
+ */
12
+ import http from 'node:http';
13
+ import https from 'node:https';
14
+ const NULL_BODY_STATUSES = new Set([204, 205, 304]);
15
+ const encodeBody = async (body) => {
16
+ if (body === undefined || body === null) {
17
+ return { bytes: new Uint8Array(0), contentType: null };
18
+ }
19
+ // Response serializes every BodyInit (FormData gets its multipart boundary).
20
+ const encoded = new Response(body);
21
+ return {
22
+ bytes: new Uint8Array(await encoded.arrayBuffer()),
23
+ contentType: encoded.headers.get('content-type'),
24
+ };
25
+ };
26
+ const toResponse = (res, body) => {
27
+ const headers = new Headers();
28
+ for (const [name, value] of Object.entries(res.headers)) {
29
+ if (value === undefined)
30
+ continue;
31
+ for (const item of Array.isArray(value) ? value : [value]) {
32
+ headers.append(name, item);
33
+ }
34
+ }
35
+ const status = res.statusCode ?? 0;
36
+ return new Response(NULL_BODY_STATUSES.has(status) ? null : new Uint8Array(body), {
37
+ status,
38
+ statusText: res.statusMessage ?? '',
39
+ headers,
40
+ });
41
+ };
42
+ export const fetchAwaitingProcessing = async (url, init) => {
43
+ const target = new URL(url);
44
+ const transport = target.protocol === 'https:'
45
+ ? https
46
+ : target.protocol === 'http:'
47
+ ? http
48
+ : undefined;
49
+ if (transport === undefined) {
50
+ throw new TypeError(`unsupported protocol: ${target.protocol}`);
51
+ }
52
+ const { bytes, contentType } = await encodeBody(init.body);
53
+ const headers = {};
54
+ if (contentType !== null)
55
+ headers['content-type'] = contentType;
56
+ new Headers(init.headers).forEach((value, name) => {
57
+ headers[name] = value;
58
+ });
59
+ headers['content-length'] = String(bytes.byteLength);
60
+ return new Promise((resolve, reject) => {
61
+ const req = transport.request(target, {
62
+ method: init.method ?? 'GET',
63
+ headers,
64
+ ...(init.signal ? { signal: init.signal } : {}),
65
+ }, (res) => {
66
+ const chunks = [];
67
+ res.on('data', (chunk) => {
68
+ chunks.push(chunk);
69
+ });
70
+ res.on('error', reject);
71
+ // A connection dropped mid-body ends the stream without `end`.
72
+ res.on('close', () => {
73
+ if (!res.complete) {
74
+ reject(new Error('connection closed before the response completed'));
75
+ }
76
+ });
77
+ res.on('end', () => {
78
+ try {
79
+ resolve(toResponse(res, Buffer.concat(chunks)));
80
+ }
81
+ catch (err) {
82
+ reject(err);
83
+ }
84
+ });
85
+ });
86
+ req.on('error', reject);
87
+ req.end(bytes);
88
+ });
89
+ };
@@ -3,6 +3,7 @@
3
3
  * Wire contract: `qlip-server/design/UPLOAD.md §0`.
4
4
  */
5
5
  import type { QlipUploadOptions } from '../types.js';
6
+ import type { TFetchLike } from './processing-request.js';
6
7
  export interface QlipUploadResult {
7
8
  /** Build ID echoed by the server (matches the manifest's buildId). */
8
9
  buildId: string;
@@ -20,13 +21,24 @@ export interface QlipUploadInput {
20
21
  buildDir: string;
21
22
  /** Resolved upload options (branch/commit already auto-detected). */
22
23
  options: QlipUploadOptions;
23
- /** Override for tests. Defaults to global fetch. */
24
- fetchImpl?: typeof fetch;
24
+ /**
25
+ * Override for tests. Defaults to global fetch for create + blobs, and to
26
+ * a `node:http` transport for the finalize / legacy requests the server
27
+ * answers only after processing (see `processing-request.ts`). An
28
+ * override is used for every phase, so its own response timeouts apply.
29
+ */
30
+ fetchImpl?: TFetchLike;
25
31
  /** Base backoff (ms) between blob-PUT retries; test seam, attempt N waits N×. */
26
32
  retryBaseDelayMs?: number;
27
33
  }
28
34
  /** Default server when `upload.serverUrl` is omitted: the hosted instance. */
29
35
  export declare const DEFAULT_SERVER_URL = "https://qlip.qoretechnologies.com";
36
+ /**
37
+ * Default deadline for the finalize / legacy response. qorus-ide builds of
38
+ * ~2.1k snapshots measured 470–713 s of server processing (2026-10, see
39
+ * design/UPLOAD.md "Long-running requests"); 30 min leaves 2.5× headroom.
40
+ */
41
+ export declare const DEFAULT_PROCESSING_TIMEOUT_MS: number;
30
42
  export type TUploadPhase = 'create' | 'blob' | 'finalize' | 'legacy';
31
43
  /**
32
44
  * Resolve the effective server base URL (default applied, trailing slash
@@ -39,5 +51,15 @@ export declare class QlipUploadError extends Error {
39
51
  readonly phase: TUploadPhase;
40
52
  constructor(status: number, statusText: string, body: string, phase?: TUploadPhase);
41
53
  }
54
+ /**
55
+ * The request never produced an HTTP response: a network error, or no
56
+ * response before the client's deadline. `status`-less, unlike
57
+ * `QlipUploadError`.
58
+ */
59
+ export declare class QlipUploadNetworkError extends Error {
60
+ readonly phase: TUploadPhase;
61
+ readonly cause: unknown;
62
+ constructor(phase: TUploadPhase, cause: unknown, detail?: string);
63
+ }
42
64
  export declare const uploadBuild: (input: QlipUploadInput) => Promise<QlipUploadResult>;
43
65
  //# sourceMappingURL=upload.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../../src/upload/upload.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAIH,OAAO,KAAK,EAAgB,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAGnE,MAAM,WAAW,gBAAgB;IAC/B,sEAAsE;IACtE,OAAO,EAAE,MAAM,CAAC;IAChB,uEAAuE;IACvE,MAAM,EAAE,MAAM,CAAC;IACf,wCAAwC;IACxC,QAAQ,EAAE,IAAI,GAAG,QAAQ,CAAC;IAC1B,wEAAwE;IACxE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,kEAAkE;IAClE,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,eAAe;IAC9B,qEAAqE;IACrE,QAAQ,EAAE,MAAM,CAAC;IACjB,qEAAqE;IACrE,OAAO,EAAE,iBAAiB,CAAC;IAC3B,oDAAoD;IACpD,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,iFAAiF;IACjF,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,8EAA8E;AAC9E,eAAO,MAAM,kBAAkB,sCAAsC,CAAC;AAOtE,MAAM,MAAM,YAAY,GAAG,QAAQ,GAAG,MAAM,GAAG,UAAU,GAAG,QAAQ,CAAC;AAErE;;;GAGG;AACH,eAAO,MAAM,gBAAgB,GAC3B,SAAS,IAAI,CAAC,iBAAiB,EAAE,WAAW,CAAC,KAC5C,MAAsE,CAAC;AAE1E,qBAAa,eAAgB,SAAQ,KAAK;IACxC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAC;gBAG3B,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,EACZ,KAAK,GAAE,YAAuB;CAUjC;AAuJD,eAAO,MAAM,WAAW,GACtB,OAAO,eAAe,KACrB,OAAO,CAAC,gBAAgB,CA4F1B,CAAC"}
1
+ {"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../../src/upload/upload.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAIH,OAAO,KAAK,EAAgB,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAGnE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAE1D,MAAM,WAAW,gBAAgB;IAC/B,sEAAsE;IACtE,OAAO,EAAE,MAAM,CAAC;IAChB,uEAAuE;IACvE,MAAM,EAAE,MAAM,CAAC;IACf,wCAAwC;IACxC,QAAQ,EAAE,IAAI,GAAG,QAAQ,CAAC;IAC1B,wEAAwE;IACxE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,kEAAkE;IAClE,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,eAAe;IAC9B,qEAAqE;IACrE,QAAQ,EAAE,MAAM,CAAC;IACjB,qEAAqE;IACrE,OAAO,EAAE,iBAAiB,CAAC;IAC3B;;;;;OAKG;IACH,SAAS,CAAC,EAAE,UAAU,CAAC;IACvB,iFAAiF;IACjF,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,8EAA8E;AAC9E,eAAO,MAAM,kBAAkB,sCAAsC,CAAC;AAMtE;;;;GAIG;AACH,eAAO,MAAM,6BAA6B,QAAc,CAAC;AAEzD,MAAM,MAAM,YAAY,GAAG,QAAQ,GAAG,MAAM,GAAG,UAAU,GAAG,QAAQ,CAAC;AAErE;;;GAGG;AACH,eAAO,MAAM,gBAAgB,GAC3B,SAAS,IAAI,CAAC,iBAAiB,EAAE,WAAW,CAAC,KAC5C,MAAsE,CAAC;AAE1E,qBAAa,eAAgB,SAAQ,KAAK;IACxC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAC;gBAG3B,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,EACZ,KAAK,GAAE,YAAuB;CAUjC;AA4BD;;;;GAIG;AACH,qBAAa,sBAAuB,SAAQ,KAAK;IAC/C,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAC;IAC7B,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;gBAEZ,KAAK,EAAE,YAAY,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,MAAM;CAQjE;AAiND,eAAO,MAAM,WAAW,GACtB,OAAO,eAAe,KACrB,OAAO,CAAC,gBAAgB,CAgH1B,CAAC"}
@@ -5,12 +5,19 @@
5
5
  import { readFile } from 'node:fs/promises';
6
6
  import path from 'node:path';
7
7
  import { hashFile } from './hash.js';
8
+ import { fetchAwaitingProcessing } from './processing-request.js';
8
9
  /** Default server when `upload.serverUrl` is omitted: the hosted instance. */
9
10
  export const DEFAULT_SERVER_URL = 'https://qlip.qoretechnologies.com';
10
11
  const TRANSFER_CONCURRENCY = 10;
11
12
  const BLOB_RETRIES = 2;
12
13
  const BLOB_TIMEOUT_MS = 30000;
13
14
  const DEFAULT_RETRY_BASE_DELAY_MS = 500;
15
+ /**
16
+ * Default deadline for the finalize / legacy response. qorus-ide builds of
17
+ * ~2.1k snapshots measured 470–713 s of server processing (2026-10, see
18
+ * design/UPLOAD.md "Long-running requests"); 30 min leaves 2.5× headroom.
19
+ */
20
+ export const DEFAULT_PROCESSING_TIMEOUT_MS = 30 * 60000;
14
21
  /**
15
22
  * Resolve the effective server base URL (default applied, trailing slash
16
23
  * stripped). Exported so log lines report the URL the upload actually hits.
@@ -25,6 +32,76 @@ export class QlipUploadError extends Error {
25
32
  this.phase = phase;
26
33
  }
27
34
  }
35
+ /**
36
+ * `fetch failed` alone hides why: the reason (a timeout, a reset, a DNS
37
+ * failure) lives on the `cause` chain, so the message spells that out.
38
+ */
39
+ const describeCause = (err) => {
40
+ const parts = [];
41
+ const seen = new Set();
42
+ let current = err;
43
+ while (current !== undefined && current !== null && !seen.has(current)) {
44
+ seen.add(current);
45
+ if (current instanceof Error) {
46
+ const code = current.code;
47
+ parts.push(typeof code === 'string' && code !== ''
48
+ ? `${current.message} (${code})`
49
+ : current.message);
50
+ current = current.cause;
51
+ }
52
+ else {
53
+ parts.push(String(current));
54
+ break;
55
+ }
56
+ }
57
+ return parts.join(': ');
58
+ };
59
+ /**
60
+ * The request never produced an HTTP response: a network error, or no
61
+ * response before the client's deadline. `status`-less, unlike
62
+ * `QlipUploadError`.
63
+ */
64
+ export class QlipUploadNetworkError extends Error {
65
+ constructor(phase, cause, detail) {
66
+ super(`qlip upload failed [${phase}]: ${detail ?? `network error — ${describeCause(cause)}`}`);
67
+ this.name = 'QlipUploadNetworkError';
68
+ this.phase = phase;
69
+ this.cause = cause;
70
+ }
71
+ }
72
+ /** `fetchImpl`, with a transport failure reported as a phase-tagged error. */
73
+ const withPhase = (phase, fetchImpl) => async (url, init) => {
74
+ try {
75
+ return await fetchImpl(url, init);
76
+ }
77
+ catch (err) {
78
+ throw new QlipUploadNetworkError(phase, err);
79
+ }
80
+ };
81
+ /**
82
+ * Send a request the server answers only after processing the build. No
83
+ * retry: the server keeps processing after a dropped connection, and a
84
+ * second finalize would race the first.
85
+ */
86
+ const sendAwaitingProcessing = async (phase, fetchImpl, url, init, timeoutMs) => {
87
+ const signal = AbortSignal.timeout(timeoutMs);
88
+ try {
89
+ return await fetchImpl(url, { ...init, signal });
90
+ }
91
+ catch (err) {
92
+ if (signal.aborted) {
93
+ throw new QlipUploadNetworkError(phase, err, `no response within ${String(timeoutMs / 1000)} s; the server may still be processing the build — check it in the dashboard, and raise the processing timeout if builds this size take longer`);
94
+ }
95
+ throw new QlipUploadNetworkError(phase, err);
96
+ }
97
+ };
98
+ const resolveProcessingTimeoutMs = (options) => {
99
+ const value = options.processingTimeoutMs ?? DEFAULT_PROCESSING_TIMEOUT_MS;
100
+ if (!Number.isFinite(value) || value <= 0) {
101
+ throw new RangeError(`upload.processingTimeoutMs must be a positive number of milliseconds, got ${String(value)}`);
102
+ }
103
+ return value;
104
+ };
28
105
  /** Contextual hint appended to 413s, tailored to the phase that hit it. */
29
106
  const hintFor = (status, phase) => {
30
107
  if (status !== 413)
@@ -97,10 +174,12 @@ const putBlob = async (fetchImpl, url, bytes, baseHeaders, retryBaseDelayMs) =>
97
174
  if (attempt < BLOB_RETRIES)
98
175
  await delay(retryBaseDelayMs * (attempt + 1));
99
176
  }
100
- throw lastError;
177
+ if (lastError instanceof QlipUploadError)
178
+ throw lastError;
179
+ throw new QlipUploadNetworkError('blob', lastError);
101
180
  };
102
181
  /** Pre-v2 single-multipart upload; fallback when the server lacks v2 routes. */
103
- const uploadBuildLegacy = async (fetchImpl, baseUrl, buildDir, options, manifest) => {
182
+ const uploadBuildLegacy = async (fetchImpl, baseUrl, buildDir, options, manifest, processingTimeoutMs) => {
104
183
  const form = new FormData();
105
184
  form.append('manifest', new Blob([JSON.stringify(manifest)], { type: 'application/json' }), 'manifest.json');
106
185
  const captured = manifest.entries.filter((e) => e.status === 'captured');
@@ -125,18 +204,22 @@ const uploadBuildLegacy = async (fetchImpl, baseUrl, buildDir, options, manifest
125
204
  if (options.pullRequestUrl !== undefined) {
126
205
  form.append('pullRequestUrl', options.pullRequestUrl);
127
206
  }
128
- const response = await fetchImpl(`${baseUrl}/api/builds/upload`, {
129
- method: 'POST',
130
- body: form,
131
- headers: authHeaders(options),
132
- });
207
+ if (options.triggeredByLogin !== undefined) {
208
+ form.append('triggeredByLogin', options.triggeredByLogin);
209
+ }
210
+ if (options.triggeredById !== undefined) {
211
+ form.append('triggeredById', String(options.triggeredById));
212
+ }
213
+ const response = await sendAwaitingProcessing('legacy', fetchImpl, `${baseUrl}/api/builds/upload`, { method: 'POST', body: form, headers: authHeaders(options) }, processingTimeoutMs);
133
214
  if (!response.ok)
134
215
  throw await errorFromResponse(response, 'legacy');
135
216
  return { buildId: manifest.buildId, status: response.status, protocol: 'legacy' };
136
217
  };
137
218
  export const uploadBuild = async (input) => {
138
219
  const { buildDir, options } = input;
220
+ const processingTimeoutMs = resolveProcessingTimeoutMs(options);
139
221
  const fetchImpl = input.fetchImpl ?? globalThis.fetch.bind(globalThis);
222
+ const processingFetch = input.fetchImpl ?? fetchAwaitingProcessing;
140
223
  const retryBaseDelayMs = input.retryBaseDelayMs ?? DEFAULT_RETRY_BASE_DELAY_MS;
141
224
  const baseUrl = resolveServerUrl(options);
142
225
  const headers = authHeaders(options);
@@ -148,7 +231,8 @@ export const uploadBuild = async (input) => {
148
231
  entry.sha256 = sha256;
149
232
  entry.sizeBytes = sizeBytes;
150
233
  });
151
- const createResponse = await fetchImpl(`${baseUrl}/api/builds`, {
234
+ const createFetch = withPhase('create', fetchImpl);
235
+ const createResponse = await createFetch(`${baseUrl}/api/builds`, {
152
236
  method: 'POST',
153
237
  headers: { ...headers, 'Content-Type': 'application/json' },
154
238
  body: JSON.stringify({
@@ -165,11 +249,17 @@ export const uploadBuild = async (input) => {
165
249
  ...(options.pullRequestUrl !== undefined
166
250
  ? { pullRequestUrl: options.pullRequestUrl }
167
251
  : {}),
252
+ ...(options.triggeredByLogin !== undefined
253
+ ? { triggeredByLogin: options.triggeredByLogin }
254
+ : {}),
255
+ ...(options.triggeredById !== undefined
256
+ ? { triggeredById: options.triggeredById }
257
+ : {}),
168
258
  }),
169
259
  });
170
260
  // 404 ⇒ server predates the v2 routes; fall back to legacy multipart.
171
261
  if (createResponse.status === 404) {
172
- return uploadBuildLegacy(fetchImpl, baseUrl, buildDir, options, manifest);
262
+ return uploadBuildLegacy(processingFetch, baseUrl, buildDir, options, manifest, processingTimeoutMs);
173
263
  }
174
264
  if (!createResponse.ok)
175
265
  throw await errorFromResponse(createResponse, 'create');
@@ -189,7 +279,7 @@ export const uploadBuild = async (input) => {
189
279
  const bytes = await readFile(path.join(buildDir, relPath));
190
280
  await putBlob(fetchImpl, `${baseUrl}/api/builds/${buildPath}/blobs/${key}`, bytes, headers, retryBaseDelayMs);
191
281
  });
192
- const finalizeResponse = await fetchImpl(`${baseUrl}/api/builds/${buildPath}/finalize`, { method: 'POST', headers });
282
+ const finalizeResponse = await sendAwaitingProcessing('finalize', processingFetch, `${baseUrl}/api/builds/${buildPath}/finalize`, { method: 'POST', headers }, processingTimeoutMs);
193
283
  if (!finalizeResponse.ok) {
194
284
  throw await errorFromResponse(finalizeResponse, 'finalize');
195
285
  }
@@ -58,6 +58,9 @@ Options:
58
58
  --token <token> Bearer token. Default: \$QLIP_UPLOAD_TOKEN.
59
59
  --branch <branch> Override branch detection.
60
60
  --commit <sha> Override commit detection.
61
+ --processing-timeout <s> Seconds to wait for the server to process
62
+ the build. Default:
63
+ \$QLIP_PROCESSING_TIMEOUT or 1800.
61
64
  --fail-on-upload-error Exit non-zero on upload failure (default:
62
65
  log error + exit 0).
63
66
  --fail-on-partial-build Exit non-zero when screenshots on disk are
@@ -101,6 +104,9 @@ const parseArgs = (argv) => {
101
104
  case '--commit':
102
105
  args.commit = next();
103
106
  break;
107
+ case '--processing-timeout':
108
+ args.processingTimeout = next();
109
+ break;
104
110
  case '--fail-on-upload-error':
105
111
  args.failOnUploadError = true;
106
112
  break;
@@ -178,6 +184,16 @@ const runUpload = async (argv, env = process.env, logger = console) => {
178
184
  logger.error(`qlip-upload: ${err.message}`);
179
185
  return { exitCode: 1 };
180
186
  }
187
+ const processingTimeout = parsed.processingTimeout ?? env['QLIP_PROCESSING_TIMEOUT'];
188
+ let processingTimeoutMs;
189
+ if (processingTimeout !== undefined && processingTimeout !== '') {
190
+ const seconds = Number(processingTimeout);
191
+ if (!Number.isFinite(seconds) || seconds <= 0) {
192
+ logger.error(`qlip-upload: --processing-timeout must be a positive number of seconds, got "${processingTimeout}"`);
193
+ return { exitCode: 1 };
194
+ }
195
+ processingTimeoutMs = seconds * 1000;
196
+ }
181
197
  const project = parsed.project ?? env['QLIP_PROJECT'] ?? 'default';
182
198
  const token = parsed.token ?? env['QLIP_UPLOAD_TOKEN'];
183
199
  const upload = {
@@ -186,6 +202,7 @@ const runUpload = async (argv, env = process.env, logger = console) => {
186
202
  ...(token !== undefined && token !== '' ? { uploadToken: token } : {}),
187
203
  ...(parsed.branch !== undefined ? { branch: parsed.branch } : {}),
188
204
  ...(parsed.commit !== undefined ? { commit: parsed.commit } : {}),
205
+ ...(processingTimeoutMs !== undefined ? { processingTimeoutMs } : {}),
189
206
  failOnUploadError: parsed.failOnUploadError,
190
207
  failOnPartialBuild: parsed.failOnPartialBuild,
191
208
  };
@@ -15,7 +15,7 @@
15
15
  * `qlip-server/design/UPLOAD.md`.
16
16
  */
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
- exports.autodetectAncestorCommits = exports.autodetectPullRequestUrl = exports.autodetectBaseBranch = exports.autodetectCommit = exports.autodetectBranch = void 0;
18
+ exports.autodetectAncestorCommits = exports.autodetectTriggeredBy = exports.autodetectPullRequestUrl = exports.autodetectBaseBranch = exports.autodetectCommit = exports.autodetectBranch = void 0;
19
19
  const node_child_process_1 = require("node:child_process");
20
20
  const node_fs_1 = require("node:fs");
21
21
  /** Server-side cap on the ancestry list (`qlip-server/design/UPLOAD.md`). */
@@ -134,6 +134,26 @@ const autodetectPullRequestUrl = () => {
134
134
  return `${base}/${repo}/pull/${prNumber}`;
135
135
  };
136
136
  exports.autodetectPullRequestUrl = autodetectPullRequestUrl;
137
+ /**
138
+ * Who triggered the CI run — the user the dashboard shows as the
139
+ * build's author. GitHub Actions sets `$GITHUB_ACTOR` (the login) and
140
+ * `$GITHUB_ACTOR_ID` (the numeric account id) on every run.
141
+ *
142
+ * Env-only by design: outside CI there is no GitHub identity to read
143
+ * (a git author is an email, not a login), and a dashboard upload is
144
+ * attributed server-side from the signed-in session. Returns undefined
145
+ * for local runs; the server stores it as nullable, so absence just
146
+ * means "author unknown". See `qlip-server/design/UPLOAD.md`.
147
+ */
148
+ const autodetectTriggeredBy = () => {
149
+ const login = process.env['GITHUB_ACTOR']?.trim();
150
+ if (!login || login.length === 0)
151
+ return undefined;
152
+ const rawId = process.env['GITHUB_ACTOR_ID']?.trim();
153
+ const id = rawId && /^\d+$/.test(rawId) ? Number(rawId) : undefined;
154
+ return id !== undefined ? { login, id } : { login };
155
+ };
156
+ exports.autodetectTriggeredBy = autodetectTriggeredBy;
137
157
  let shallowWarningEmitted = false;
138
158
  /**
139
159
  * A shallow clone (CI default for `actions/checkout`) only has the tip
@@ -122,6 +122,15 @@ const finalizeBuild = async (opts) => {
122
122
  const baseBranch = opts.upload.baseBranch ?? (0, autodetect_js_1.autodetectBaseBranch)();
123
123
  const ancestorCommits = opts.upload.ancestorCommits ?? (0, autodetect_js_1.autodetectAncestorCommits)();
124
124
  const pullRequestUrl = opts.upload.pullRequestUrl ?? (0, autodetect_js_1.autodetectPullRequestUrl)();
125
+ // An explicit login wins outright (with whatever id came with it);
126
+ // otherwise both come from the CI env together.
127
+ const detectedTriggeredBy = opts.upload.triggeredByLogin === undefined
128
+ ? (0, autodetect_js_1.autodetectTriggeredBy)()
129
+ : undefined;
130
+ const triggeredByLogin = opts.upload.triggeredByLogin ?? detectedTriggeredBy?.login;
131
+ const triggeredById = opts.upload.triggeredByLogin !== undefined
132
+ ? opts.upload.triggeredById
133
+ : detectedTriggeredBy?.id;
125
134
  const resolved = {
126
135
  ...opts.upload,
127
136
  ...(branch !== undefined ? { branch } : {}),
@@ -129,6 +138,8 @@ const finalizeBuild = async (opts) => {
129
138
  ...(baseBranch !== undefined ? { baseBranch } : {}),
130
139
  ...(ancestorCommits !== undefined ? { ancestorCommits } : {}),
131
140
  ...(pullRequestUrl !== undefined ? { pullRequestUrl } : {}),
141
+ ...(triggeredByLogin !== undefined ? { triggeredByLogin } : {}),
142
+ ...(triggeredById !== undefined ? { triggeredById } : {}),
132
143
  };
133
144
  try {
134
145
  const result = await (0, upload_js_1.uploadBuild)({
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ /**
3
+ * HTTP transport for the upload requests qlip-server answers only once it
4
+ * has processed the whole build: `finalize` and the legacy multipart POST.
5
+ *
6
+ * Node's global `fetch` (undici) fails any request whose response headers
7
+ * take longer than 300 s (`headersTimeout`) with a bare "fetch failed", and
8
+ * the limit can't be raised without depending on the `undici` package. A
9
+ * large build's diff runs longer than that, so these requests go through
10
+ * `node:http`, which imposes no response deadline: the caller's `signal` is
11
+ * the only one. See design/UPLOAD.md "Long-running requests".
12
+ */
13
+ var __importDefault = (this && this.__importDefault) || function (mod) {
14
+ return (mod && mod.__esModule) ? mod : { "default": mod };
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.fetchAwaitingProcessing = void 0;
18
+ const node_http_1 = __importDefault(require("node:http"));
19
+ const node_https_1 = __importDefault(require("node:https"));
20
+ const NULL_BODY_STATUSES = new Set([204, 205, 304]);
21
+ const encodeBody = async (body) => {
22
+ if (body === undefined || body === null) {
23
+ return { bytes: new Uint8Array(0), contentType: null };
24
+ }
25
+ // Response serializes every BodyInit (FormData gets its multipart boundary).
26
+ const encoded = new Response(body);
27
+ return {
28
+ bytes: new Uint8Array(await encoded.arrayBuffer()),
29
+ contentType: encoded.headers.get('content-type'),
30
+ };
31
+ };
32
+ const toResponse = (res, body) => {
33
+ const headers = new Headers();
34
+ for (const [name, value] of Object.entries(res.headers)) {
35
+ if (value === undefined)
36
+ continue;
37
+ for (const item of Array.isArray(value) ? value : [value]) {
38
+ headers.append(name, item);
39
+ }
40
+ }
41
+ const status = res.statusCode ?? 0;
42
+ return new Response(NULL_BODY_STATUSES.has(status) ? null : new Uint8Array(body), {
43
+ status,
44
+ statusText: res.statusMessage ?? '',
45
+ headers,
46
+ });
47
+ };
48
+ const fetchAwaitingProcessing = async (url, init) => {
49
+ const target = new URL(url);
50
+ const transport = target.protocol === 'https:'
51
+ ? node_https_1.default
52
+ : target.protocol === 'http:'
53
+ ? node_http_1.default
54
+ : undefined;
55
+ if (transport === undefined) {
56
+ throw new TypeError(`unsupported protocol: ${target.protocol}`);
57
+ }
58
+ const { bytes, contentType } = await encodeBody(init.body);
59
+ const headers = {};
60
+ if (contentType !== null)
61
+ headers['content-type'] = contentType;
62
+ new Headers(init.headers).forEach((value, name) => {
63
+ headers[name] = value;
64
+ });
65
+ headers['content-length'] = String(bytes.byteLength);
66
+ return new Promise((resolve, reject) => {
67
+ const req = transport.request(target, {
68
+ method: init.method ?? 'GET',
69
+ headers,
70
+ ...(init.signal ? { signal: init.signal } : {}),
71
+ }, (res) => {
72
+ const chunks = [];
73
+ res.on('data', (chunk) => {
74
+ chunks.push(chunk);
75
+ });
76
+ res.on('error', reject);
77
+ // A connection dropped mid-body ends the stream without `end`.
78
+ res.on('close', () => {
79
+ if (!res.complete) {
80
+ reject(new Error('connection closed before the response completed'));
81
+ }
82
+ });
83
+ res.on('end', () => {
84
+ try {
85
+ resolve(toResponse(res, Buffer.concat(chunks)));
86
+ }
87
+ catch (err) {
88
+ reject(err);
89
+ }
90
+ });
91
+ });
92
+ req.on('error', reject);
93
+ req.end(bytes);
94
+ });
95
+ };
96
+ exports.fetchAwaitingProcessing = fetchAwaitingProcessing;
@@ -7,16 +7,23 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
7
7
  return (mod && mod.__esModule) ? mod : { "default": mod };
8
8
  };
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
- exports.uploadBuild = exports.QlipUploadError = exports.resolveServerUrl = exports.DEFAULT_SERVER_URL = void 0;
10
+ exports.uploadBuild = exports.QlipUploadNetworkError = exports.QlipUploadError = exports.resolveServerUrl = exports.DEFAULT_PROCESSING_TIMEOUT_MS = exports.DEFAULT_SERVER_URL = void 0;
11
11
  const promises_1 = require("node:fs/promises");
12
12
  const node_path_1 = __importDefault(require("node:path"));
13
13
  const hash_js_1 = require("./hash.js");
14
+ const processing_request_js_1 = require("./processing-request.js");
14
15
  /** Default server when `upload.serverUrl` is omitted: the hosted instance. */
15
16
  exports.DEFAULT_SERVER_URL = 'https://qlip.qoretechnologies.com';
16
17
  const TRANSFER_CONCURRENCY = 10;
17
18
  const BLOB_RETRIES = 2;
18
19
  const BLOB_TIMEOUT_MS = 30000;
19
20
  const DEFAULT_RETRY_BASE_DELAY_MS = 500;
21
+ /**
22
+ * Default deadline for the finalize / legacy response. qorus-ide builds of
23
+ * ~2.1k snapshots measured 470–713 s of server processing (2026-10, see
24
+ * design/UPLOAD.md "Long-running requests"); 30 min leaves 2.5× headroom.
25
+ */
26
+ exports.DEFAULT_PROCESSING_TIMEOUT_MS = 30 * 60000;
20
27
  /**
21
28
  * Resolve the effective server base URL (default applied, trailing slash
22
29
  * stripped). Exported so log lines report the URL the upload actually hits.
@@ -33,6 +40,77 @@ class QlipUploadError extends Error {
33
40
  }
34
41
  }
35
42
  exports.QlipUploadError = QlipUploadError;
43
+ /**
44
+ * `fetch failed` alone hides why: the reason (a timeout, a reset, a DNS
45
+ * failure) lives on the `cause` chain, so the message spells that out.
46
+ */
47
+ const describeCause = (err) => {
48
+ const parts = [];
49
+ const seen = new Set();
50
+ let current = err;
51
+ while (current !== undefined && current !== null && !seen.has(current)) {
52
+ seen.add(current);
53
+ if (current instanceof Error) {
54
+ const code = current.code;
55
+ parts.push(typeof code === 'string' && code !== ''
56
+ ? `${current.message} (${code})`
57
+ : current.message);
58
+ current = current.cause;
59
+ }
60
+ else {
61
+ parts.push(String(current));
62
+ break;
63
+ }
64
+ }
65
+ return parts.join(': ');
66
+ };
67
+ /**
68
+ * The request never produced an HTTP response: a network error, or no
69
+ * response before the client's deadline. `status`-less, unlike
70
+ * `QlipUploadError`.
71
+ */
72
+ class QlipUploadNetworkError extends Error {
73
+ constructor(phase, cause, detail) {
74
+ super(`qlip upload failed [${phase}]: ${detail ?? `network error — ${describeCause(cause)}`}`);
75
+ this.name = 'QlipUploadNetworkError';
76
+ this.phase = phase;
77
+ this.cause = cause;
78
+ }
79
+ }
80
+ exports.QlipUploadNetworkError = QlipUploadNetworkError;
81
+ /** `fetchImpl`, with a transport failure reported as a phase-tagged error. */
82
+ const withPhase = (phase, fetchImpl) => async (url, init) => {
83
+ try {
84
+ return await fetchImpl(url, init);
85
+ }
86
+ catch (err) {
87
+ throw new QlipUploadNetworkError(phase, err);
88
+ }
89
+ };
90
+ /**
91
+ * Send a request the server answers only after processing the build. No
92
+ * retry: the server keeps processing after a dropped connection, and a
93
+ * second finalize would race the first.
94
+ */
95
+ const sendAwaitingProcessing = async (phase, fetchImpl, url, init, timeoutMs) => {
96
+ const signal = AbortSignal.timeout(timeoutMs);
97
+ try {
98
+ return await fetchImpl(url, { ...init, signal });
99
+ }
100
+ catch (err) {
101
+ if (signal.aborted) {
102
+ throw new QlipUploadNetworkError(phase, err, `no response within ${String(timeoutMs / 1000)} s; the server may still be processing the build — check it in the dashboard, and raise the processing timeout if builds this size take longer`);
103
+ }
104
+ throw new QlipUploadNetworkError(phase, err);
105
+ }
106
+ };
107
+ const resolveProcessingTimeoutMs = (options) => {
108
+ const value = options.processingTimeoutMs ?? exports.DEFAULT_PROCESSING_TIMEOUT_MS;
109
+ if (!Number.isFinite(value) || value <= 0) {
110
+ throw new RangeError(`upload.processingTimeoutMs must be a positive number of milliseconds, got ${String(value)}`);
111
+ }
112
+ return value;
113
+ };
36
114
  /** Contextual hint appended to 413s, tailored to the phase that hit it. */
37
115
  const hintFor = (status, phase) => {
38
116
  if (status !== 413)
@@ -105,10 +183,12 @@ const putBlob = async (fetchImpl, url, bytes, baseHeaders, retryBaseDelayMs) =>
105
183
  if (attempt < BLOB_RETRIES)
106
184
  await delay(retryBaseDelayMs * (attempt + 1));
107
185
  }
108
- throw lastError;
186
+ if (lastError instanceof QlipUploadError)
187
+ throw lastError;
188
+ throw new QlipUploadNetworkError('blob', lastError);
109
189
  };
110
190
  /** Pre-v2 single-multipart upload; fallback when the server lacks v2 routes. */
111
- const uploadBuildLegacy = async (fetchImpl, baseUrl, buildDir, options, manifest) => {
191
+ const uploadBuildLegacy = async (fetchImpl, baseUrl, buildDir, options, manifest, processingTimeoutMs) => {
112
192
  const form = new FormData();
113
193
  form.append('manifest', new Blob([JSON.stringify(manifest)], { type: 'application/json' }), 'manifest.json');
114
194
  const captured = manifest.entries.filter((e) => e.status === 'captured');
@@ -133,18 +213,22 @@ const uploadBuildLegacy = async (fetchImpl, baseUrl, buildDir, options, manifest
133
213
  if (options.pullRequestUrl !== undefined) {
134
214
  form.append('pullRequestUrl', options.pullRequestUrl);
135
215
  }
136
- const response = await fetchImpl(`${baseUrl}/api/builds/upload`, {
137
- method: 'POST',
138
- body: form,
139
- headers: authHeaders(options),
140
- });
216
+ if (options.triggeredByLogin !== undefined) {
217
+ form.append('triggeredByLogin', options.triggeredByLogin);
218
+ }
219
+ if (options.triggeredById !== undefined) {
220
+ form.append('triggeredById', String(options.triggeredById));
221
+ }
222
+ const response = await sendAwaitingProcessing('legacy', fetchImpl, `${baseUrl}/api/builds/upload`, { method: 'POST', body: form, headers: authHeaders(options) }, processingTimeoutMs);
141
223
  if (!response.ok)
142
224
  throw await errorFromResponse(response, 'legacy');
143
225
  return { buildId: manifest.buildId, status: response.status, protocol: 'legacy' };
144
226
  };
145
227
  const uploadBuild = async (input) => {
146
228
  const { buildDir, options } = input;
229
+ const processingTimeoutMs = resolveProcessingTimeoutMs(options);
147
230
  const fetchImpl = input.fetchImpl ?? globalThis.fetch.bind(globalThis);
231
+ const processingFetch = input.fetchImpl ?? processing_request_js_1.fetchAwaitingProcessing;
148
232
  const retryBaseDelayMs = input.retryBaseDelayMs ?? DEFAULT_RETRY_BASE_DELAY_MS;
149
233
  const baseUrl = (0, exports.resolveServerUrl)(options);
150
234
  const headers = authHeaders(options);
@@ -156,7 +240,8 @@ const uploadBuild = async (input) => {
156
240
  entry.sha256 = sha256;
157
241
  entry.sizeBytes = sizeBytes;
158
242
  });
159
- const createResponse = await fetchImpl(`${baseUrl}/api/builds`, {
243
+ const createFetch = withPhase('create', fetchImpl);
244
+ const createResponse = await createFetch(`${baseUrl}/api/builds`, {
160
245
  method: 'POST',
161
246
  headers: { ...headers, 'Content-Type': 'application/json' },
162
247
  body: JSON.stringify({
@@ -173,11 +258,17 @@ const uploadBuild = async (input) => {
173
258
  ...(options.pullRequestUrl !== undefined
174
259
  ? { pullRequestUrl: options.pullRequestUrl }
175
260
  : {}),
261
+ ...(options.triggeredByLogin !== undefined
262
+ ? { triggeredByLogin: options.triggeredByLogin }
263
+ : {}),
264
+ ...(options.triggeredById !== undefined
265
+ ? { triggeredById: options.triggeredById }
266
+ : {}),
176
267
  }),
177
268
  });
178
269
  // 404 ⇒ server predates the v2 routes; fall back to legacy multipart.
179
270
  if (createResponse.status === 404) {
180
- return uploadBuildLegacy(fetchImpl, baseUrl, buildDir, options, manifest);
271
+ return uploadBuildLegacy(processingFetch, baseUrl, buildDir, options, manifest, processingTimeoutMs);
181
272
  }
182
273
  if (!createResponse.ok)
183
274
  throw await errorFromResponse(createResponse, 'create');
@@ -197,7 +288,7 @@ const uploadBuild = async (input) => {
197
288
  const bytes = await (0, promises_1.readFile)(node_path_1.default.join(buildDir, relPath));
198
289
  await putBlob(fetchImpl, `${baseUrl}/api/builds/${buildPath}/blobs/${key}`, bytes, headers, retryBaseDelayMs);
199
290
  });
200
- const finalizeResponse = await fetchImpl(`${baseUrl}/api/builds/${buildPath}/finalize`, { method: 'POST', headers });
291
+ const finalizeResponse = await sendAwaitingProcessing('finalize', processingFetch, `${baseUrl}/api/builds/${buildPath}/finalize`, { method: 'POST', headers }, processingTimeoutMs);
201
292
  if (!finalizeResponse.ok) {
202
293
  throw await errorFromResponse(finalizeResponse, 'finalize');
203
294
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qoretechnologies/qlip",
3
- "version": "0.1.3-pr.30.gffaaa15",
3
+ "version": "0.1.3-pr.32.g3ecd7a4",
4
4
  "description": "Storybook screenshot capture for the Vitest addon",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",