@ultimat3/cli 23.0.0 → 25.0.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/CLAUDE.md +28 -13
- package/README.md +32 -7
- package/package.json +29 -29
- package/src/admin-catalog-keys.ts +2 -2
- package/src/affected.ts +30 -36
- package/src/api-registration.ts +89 -0
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +17 -4
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +26 -25
- package/src/app-load.ts +38 -14
- package/src/app-manifest.ts +4 -10
- package/src/app-mcp.ts +31 -46
- package/src/app-name.ts +52 -0
- package/src/app-permissions-borrowed.ts +123 -0
- package/src/app-permissions-site.ts +48 -0
- package/src/app-permissions.ts +7 -17
- package/src/app-root.ts +9 -5
- package/src/bin.ts +3 -7
- package/src/browser-launcher.ts +4 -2
- package/src/budgets-charged.ts +60 -0
- package/src/budgets-scan.ts +76 -0
- package/src/budgets-unmeasured.ts +91 -0
- package/src/budgets.ts +117 -154
- package/src/cdp-shot-driver.ts +20 -1
- package/src/cdp-shot-watch.ts +35 -31
- package/src/ci-log.ts +0 -0
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +151 -22
- package/src/cmd-db-backfill.ts +26 -13
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db.ts +10 -6
- package/src/cmd-deploy-compose.ts +94 -0
- package/src/cmd-deploy-drain.ts +48 -0
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy-start-first.ts +178 -0
- package/src/cmd-deploy.ts +158 -36
- package/src/cmd-dev.ts +18 -356
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +57 -80
- package/src/cmd-env.ts +55 -16
- package/src/cmd-generate.ts +89 -32
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +21 -17
- package/src/cmd-jobs-spec.ts +7 -22
- package/src/cmd-jobs.ts +33 -140
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +71 -22
- package/src/cmd-new.ts +36 -8
- package/src/cmd-planned.ts +24 -8
- package/src/cmd-routes.ts +15 -5
- package/src/cmd-secrets.ts +47 -6
- package/src/cmd-shot-matrix.ts +22 -7
- package/src/cmd-shot-spec.ts +10 -2
- package/src/cmd-shot.ts +46 -41
- package/src/cmd-tasks.ts +2 -2
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +14 -30
- package/src/cmd-verify.ts +4 -4
- package/src/coverage-lcov.ts +4 -2
- package/src/db-backfill.ts +2 -2
- package/src/db-replay-engine.ts +4 -10
- package/src/db-subscribes.ts +27 -16
- package/src/dev-boot.ts +290 -0
- package/src/dev-child-watch.ts +63 -12
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +56 -30
- package/src/dev-lock.ts +100 -31
- package/src/dev-rebuild.ts +80 -0
- package/src/dev-restart-retire.ts +46 -0
- package/src/dev-route-table.ts +30 -8
- package/src/dev-supervisor.ts +114 -22
- package/src/dev-traces.ts +53 -20
- package/src/dispatch.ts +17 -13
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/doctor-offline.ts +6 -5
- package/src/document-options.ts +7 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/drift.ts +29 -6
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +29 -3
- package/src/error-page-csp.ts +14 -15
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/exec.ts +8 -0
- package/src/favicon.ts +2 -2
- package/src/fix-command.ts +9 -1
- package/src/framework-env.ts +148 -0
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +25 -3
- package/src/generate-catalog-locales.ts +79 -0
- package/src/generate-files.ts +4 -3
- package/src/generate-kinds.ts +18 -0
- package/src/generate-plural.ts +50 -0
- package/src/generate-shadow.ts +95 -0
- package/src/generate-write.ts +47 -13
- package/src/gh-target.ts +12 -7
- package/src/hold.ts +56 -1
- package/src/i18n-audit.ts +25 -9
- package/src/i18n-index.ts +5 -6
- package/src/i18n-registration.ts +7 -9
- package/src/import-scan.ts +28 -3
- package/src/index.ts +2 -6
- package/src/island-bundle.ts +57 -79
- package/src/island-capture.ts +1 -1
- package/src/island-duplicate-refusal.ts +57 -0
- package/src/island-duplicates.ts +221 -0
- package/src/island-entry-missing.ts +64 -0
- package/src/island-harness-route.ts +20 -4
- package/src/island-harness-script.ts +23 -2
- package/src/island-identity.ts +17 -14
- package/src/island-package-dedupe.ts +150 -0
- package/src/island-realtime.ts +36 -16
- package/src/island-routes.ts +2 -2
- package/src/island-runtime.ts +241 -0
- package/src/island-sources.ts +105 -0
- package/src/island-states-load.ts +6 -0
- package/src/island-store.ts +90 -6
- package/src/jobs-json.ts +0 -26
- package/src/jobs-report.ts +36 -20
- package/src/local-cli-handoff.ts +90 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +20 -4
- package/src/mcp-host.ts +22 -14
- package/src/mcp-test-run.ts +38 -0
- package/src/mcp-ui-inspect.ts +3 -2
- package/src/mcp-ui-interact.ts +3 -2
- package/src/mcp-ui.ts +8 -7
- package/src/measure-scope.ts +15 -10
- package/src/messages.ts +29 -16
- package/src/metrics-endpoint.ts +28 -3
- package/src/migration-extensions.ts +1 -1
- package/src/otlp-export.ts +2 -2
- package/src/output.ts +24 -0
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +19 -31
- package/src/parse.ts +0 -8
- package/src/permission-grants.ts +3 -3
- package/src/posix-path.ts +30 -0
- package/src/prerender-locales.ts +6 -4
- package/src/prerender-out.ts +34 -7
- package/src/prerender-report.ts +58 -0
- package/src/prerender-served.ts +52 -0
- package/src/prerender.ts +71 -103
- package/src/pwa-artifacts.ts +33 -94
- package/src/pwa-manifest-locales.ts +9 -9
- package/src/registry.ts +2 -0
- package/src/retry-memo.ts +1 -1
- package/src/revalidated-response.ts +4 -3
- package/src/role-realtime.ts +7 -1
- package/src/role-replicator.ts +124 -29
- package/src/role-start.ts +69 -85
- package/src/role-sync.ts +119 -25
- package/src/root-env.ts +157 -28
- package/src/route-islands.ts +69 -0
- package/src/runtime-admin.ts +4 -1
- package/src/runtime-assets.ts +26 -9
- package/src/runtime-bindings.ts +27 -7
- package/src/runtime-cache.ts +46 -55
- package/src/runtime-hooks.ts +2 -1
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr-outcome.ts +100 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +76 -41
- package/src/runtime-live-feed.ts +1 -1
- package/src/runtime-mail.ts +26 -0
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +4 -4
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +43 -22
- package/src/runtime-realtime.ts +11 -45
- package/src/runtime-render.ts +136 -114
- package/src/runtime-replica.ts +3 -8
- package/src/runtime-services.ts +49 -26
- package/src/runtime-storage-upload.ts +2 -2
- package/src/runtime-storage.ts +13 -8
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/secrets-rotation.ts +8 -9
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +51 -12
- package/src/serve-drain.ts +11 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-retire.ts +95 -0
- package/src/serve-web.ts +31 -15
- package/src/serve.ts +27 -10
- package/src/server-barrels.ts +6 -3
- package/src/shot-browser.ts +1 -1
- package/src/shot-locale.ts +6 -33
- package/src/shot-settle.ts +3 -3
- package/src/shot-theme.ts +1 -1
- package/src/shot-viewport.ts +152 -0
- package/src/signal-shred.ts +26 -12
- package/src/site-asset-routes.ts +2 -2
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +7 -5
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-files.ts +19 -3
- package/src/static-report.ts +5 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/style-routes.ts +2 -2
- package/src/sw-artifacts.ts +8 -8
- package/src/sw-precache-plan.ts +34 -4
- package/src/sync-authenticator.ts +3 -3
- package/src/sync-url.ts +3 -3
- package/src/tasks-facts.ts +3 -29
- package/src/templates/action.ts +7 -4
- package/src/templates/backfill.ts +4 -4
- package/src/templates/entity.ts +2 -2
- package/src/templates/github/ci.yml.ts +28 -18
- package/src/templates/github/image.yml.ts +92 -0
- package/src/templates/github/runner.ts +18 -0
- package/src/templates/guard-bare-error.ts +16 -2
- package/src/templates/guard-unzoned-date.ts +56 -6
- package/src/templates/index.ts +1 -1
- package/src/templates/job-body-test.ts +2 -2
- package/src/templates/job.ts +6 -6
- package/src/templates/naming.ts +28 -0
- package/src/templates/query.ts +9 -8
- package/src/templates/resource-page.ts +9 -7
- package/src/templates/resource-service.ts +2 -2
- package/src/templates/route.ts +26 -14
- package/src/templates/scaffold-api.ts +2 -3
- package/src/templates/scaffold-app.ts +8 -3
- package/src/templates/scaffold-auth.ts +205 -70
- package/src/templates/scaffold-bin.ts +122 -0
- package/src/templates/scaffold-claude-commands.ts +11 -11
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +16 -8
- package/src/templates/scaffold-dashboard-bare.ts +8 -4
- package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
- package/src/templates/scaffold-dashboard-example.ts +112 -172
- package/src/templates/scaffold-dashboard-shared.ts +62 -1
- package/src/templates/scaffold-dashboard.ts +9 -5
- package/src/templates/scaffold-db-client.ts +4 -2
- package/src/templates/scaffold-db-package.ts +2 -2
- package/src/templates/scaffold-docs.ts +20 -77
- package/src/templates/scaffold-domain-package.ts +0 -4
- package/src/templates/scaffold-entries.ts +4 -3
- package/src/templates/scaffold-env.ts +6 -3
- package/src/templates/scaffold-gitattributes.ts +59 -0
- package/src/templates/scaffold-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +100 -19
- package/src/templates/scaffold-helm.ts +62 -5
- package/src/templates/scaffold-http.ts +1 -1
- package/src/templates/scaffold-i18n.ts +15 -2
- package/src/templates/scaffold-icon.ts +2 -2
- package/src/templates/scaffold-repo.ts +27 -16
- package/src/templates/scaffold-shell.ts +55 -37
- package/src/test-counts.ts +2 -6
- package/src/test-ignores.ts +25 -0
- package/src/test-only-shipped.ts +193 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +7 -15
- package/src/test-shards.ts +40 -95
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +8 -33
- package/src/theme-brand.ts +66 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/trusted-hops.ts +49 -0
- package/src/unscanned-admin.ts +2 -2
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +29 -48
- package/src/verify-coverage-run.ts +3 -2
- package/src/verify-deadline.ts +207 -33
- package/src/verify-merge.ts +108 -43
- package/src/verify-part-step.ts +98 -0
- package/src/verify-role-load-probe.ts +12 -2
- package/src/verify-role-load.ts +9 -4
- package/src/verify-run.ts +32 -8
- package/src/verify-shard.ts +13 -2
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +2 -0
- package/src/verify-test-run.ts +3 -2
- package/src/verify-tests.ts +5 -3
- package/src/worker-bundle.ts +2 -2
- package/src/workspace-checks.ts +53 -7
- package/src/workspace-graph.ts +3 -2
- package/src/write-line.ts +82 -7
- package/src/browser-launcher-fake-html.ts +0 -80
- package/src/browser-launcher-fake.ts +0 -168
- package/src/build-errors.ts +0 -54
- package/src/jobs-drain.ts +0 -163
- package/src/scaffold-typecheck.ts +0 -227
- package/src/thrown-by.ts +0 -24
- package/src/verify-errors.ts +0 -58
package/src/workspace-graph.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// for `tsc` and is invisible to bun, to `--filter` ordering and to any change-detection tool.
|
|
5
5
|
|
|
6
6
|
import { join } from 'node:path';
|
|
7
|
-
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
7
|
+
import { ERROR_DOCS_URL, maskLiterals } from '@ultimat3/core';
|
|
8
8
|
import { scanAllImports } from './import-scan';
|
|
9
9
|
import type { Finding } from './output';
|
|
10
10
|
import { eachSourceFile, isGenerated, isTest, isVendored } from './source-files';
|
|
@@ -148,10 +148,11 @@ export function packageOfSpecifier(specifier: string): string | undefined {
|
|
|
148
148
|
* included, because a type edge still needs its manifest line. The transpiler is the parser, so a
|
|
149
149
|
* generator's template literal (even one nested in a `${…}`, #493) is a string and never an import,
|
|
150
150
|
* and `from<Row>('posts')` is a call. A file it cannot parse throws rather than reading as empty.
|
|
151
|
+
* The mask is what reads a type-position `typeof import('x')`, which the transpiler erases whole.
|
|
151
152
|
*/
|
|
152
153
|
export function importedPackages(source: string, path = 'source.ts'): readonly string[] {
|
|
153
154
|
const packages = new Set<string>();
|
|
154
|
-
for (const specifier of scanAllImports({ path, source })) {
|
|
155
|
+
for (const specifier of scanAllImports({ path, source }, maskLiterals(source))) {
|
|
155
156
|
const name = packageOfSpecifier(specifier);
|
|
156
157
|
if (name !== undefined) packages.add(name);
|
|
157
158
|
}
|
package/src/write-line.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { writeSync } from 'node:fs';
|
|
8
8
|
// The one import beyond `node:fs`, and it costs nothing here: `create-ultimate` reaches this
|
|
9
9
|
// module through `@ultimat3/cli`'s barrel, which has already evaluated core.
|
|
10
|
-
import { stringField } from '@ultimat3/core';
|
|
10
|
+
import { backoffDelay, stringField } from '@ultimat3/core';
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Write to stdout and be certain it arrived, even if the next statement exits the process.
|
|
@@ -22,22 +22,97 @@ import { stringField } from '@ultimat3/core';
|
|
|
22
22
|
*
|
|
23
23
|
* `EAGAIN` is "the pipe is full right now", not a failure. CI hands the process a NON-BLOCKING
|
|
24
24
|
* stdout, where `writeSync` throws rather than blocking — so the loop that fixed the truncation
|
|
25
|
-
* took the whole command down on a runner, emitting nothing at all.
|
|
26
|
-
* microseconds
|
|
25
|
+
* took the whole command down on a runner, emitting nothing at all. A draining reader clears it in
|
|
26
|
+
* microseconds, so it is retried — but BOUNDED: a reader that never drains (a stopped `| less`, a
|
|
27
|
+
* wedged parent) turned the unbounded retry into a command that never exits, spinning a core.
|
|
28
|
+
* `EAGAIN_ATTEMPTS` consecutive stalls, each waiting longer (capped), then the line is dropped and
|
|
29
|
+
* counted (`droppedLineCount()`): one lost line beats a process that cannot finish. After a drop
|
|
30
|
+
* the fd is DEGRADED: each later line gets one attempt and no sleep, or a never-draining reader
|
|
31
|
+
* would cost ~1.5 s per line forever. Any byte arriving clears it.
|
|
27
32
|
*/
|
|
28
33
|
function writeTo(fd: 1 | 2, line: string): void {
|
|
29
|
-
|
|
34
|
+
writeAll(fd, Buffer.from(`${line}\n`));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Consecutive stalls one write may absorb before its line is dropped. Progress resets it. */
|
|
38
|
+
export const EAGAIN_ATTEMPTS = 50;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Core's one curve, exponential and capped: 1, 2, 4 … 32 ms — under 1.5 s for a reader that never
|
|
42
|
+
* drains at all. No jitter: one process waiting on its own pipe has no herd to decorrelate.
|
|
43
|
+
*/
|
|
44
|
+
const backoffMs = (stall: number): number => backoffDelay({ attempt: stall, base: 1, max: 32 });
|
|
45
|
+
|
|
46
|
+
/** `writeSync`'s shape, so a test can hand in a pipe that is full forever. */
|
|
47
|
+
export type WriteSync = (fd: number, buffer: Uint8Array, offset: number, length: number) => number;
|
|
48
|
+
|
|
49
|
+
let dropped = 0;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Per fd, because fd 1 and fd 2 are two readers. `degraded`: the last line was dropped, so the next
|
|
53
|
+
* gets one attempt. `torn`: it was dropped HALF-written, so the next line that arrives opens with
|
|
54
|
+
* the newline the torn one never got — otherwise the two glue into one unparseable line.
|
|
55
|
+
*/
|
|
56
|
+
const pipes = new Map<number, { degraded: boolean; torn: boolean }>();
|
|
57
|
+
|
|
58
|
+
const pipeOf = (fd: number): { degraded: boolean; torn: boolean } => {
|
|
59
|
+
let pipe = pipes.get(fd);
|
|
60
|
+
if (pipe === undefined) {
|
|
61
|
+
pipe = { degraded: false, torn: false };
|
|
62
|
+
pipes.set(fd, pipe);
|
|
63
|
+
}
|
|
64
|
+
return pipe;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const NEWLINE = 0x0a;
|
|
68
|
+
|
|
69
|
+
/** Lines this process dropped to a reader that never drained. */
|
|
70
|
+
export const droppedLineCount = (): number => dropped;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Write every byte of `buffer` to `fd`, or drop it after `EAGAIN_ATTEMPTS` stalls in a row — one
|
|
74
|
+
* stall while the fd is degraded. Returns whether it all arrived. A write of zero bytes is a stall
|
|
75
|
+
* too, so no path spins.
|
|
76
|
+
*/
|
|
77
|
+
export function writeAll(
|
|
78
|
+
fd: number,
|
|
79
|
+
buffer: Uint8Array,
|
|
80
|
+
write: WriteSync = writeSync,
|
|
81
|
+
sleep: (ms: number) => void = Bun.sleepSync,
|
|
82
|
+
): boolean {
|
|
83
|
+
const pipe = pipeOf(fd);
|
|
84
|
+
const prefix = pipe.torn ? 1 : 0;
|
|
85
|
+
const bytes = prefix === 0 ? buffer : Buffer.concat([Uint8Array.of(NEWLINE), buffer]);
|
|
30
86
|
let written = 0;
|
|
31
|
-
|
|
87
|
+
let stalls = 0;
|
|
88
|
+
while (written < bytes.length) {
|
|
89
|
+
let progress = 0;
|
|
32
90
|
try {
|
|
33
|
-
|
|
91
|
+
progress = write(fd, bytes, written, bytes.length - written);
|
|
34
92
|
} catch (cause) {
|
|
35
93
|
// `stringField`, never a cast plus a property read — the rule `metrics-endpoint.ts` states
|
|
36
94
|
// and `caught-value-reads.test.ts` enforces. Here it is also the difference between
|
|
37
|
-
// rethrowing and
|
|
95
|
+
// rethrowing and retrying: a `code` that cannot be read must not read as `EAGAIN`.
|
|
38
96
|
if (stringField(cause, 'code') !== 'EAGAIN') throw cause;
|
|
39
97
|
}
|
|
98
|
+
if (progress > 0) {
|
|
99
|
+
written += progress;
|
|
100
|
+
stalls = 0;
|
|
101
|
+
pipe.degraded = false; // The reader drained: it gets the full budget again.
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
stalls += 1;
|
|
105
|
+
if (stalls >= (pipe.degraded ? 1 : EAGAIN_ATTEMPTS)) {
|
|
106
|
+
dropped += 1;
|
|
107
|
+
pipe.degraded = true;
|
|
108
|
+
// Nothing written leaves a torn line torn; only the prefix written closes it cleanly.
|
|
109
|
+
if (written > 0) pipe.torn = written > prefix;
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
sleep(backoffMs(stalls));
|
|
40
113
|
}
|
|
114
|
+
pipe.torn = false;
|
|
115
|
+
return true;
|
|
41
116
|
}
|
|
42
117
|
|
|
43
118
|
export function writeLine(line: string): void {
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
// CSS selectors over an HTML string, on Bun's own `HTMLRewriter`: the `query()` the offline
|
|
2
|
-
// `fakeShotDriver` answers from markup. The selector subset is lol-html's — type, `#id`, `.class`,
|
|
3
|
-
// `[attr]`, `[attr=value]`, descendant and child combinators. No box and no hit-target on any
|
|
4
|
-
// answer: this has no layout engine, and a fabricated box would make a covered button clickable.
|
|
5
|
-
import type { ElementSnapshot } from './browser-launcher-port';
|
|
6
|
-
|
|
7
|
-
/** Elements with no end tag: `onEndTag` never fires for them, so they close on open. */
|
|
8
|
-
const VOID_TAGS = new Set([
|
|
9
|
-
'area',
|
|
10
|
-
'base',
|
|
11
|
-
'br',
|
|
12
|
-
'col',
|
|
13
|
-
'embed',
|
|
14
|
-
'hr',
|
|
15
|
-
'img',
|
|
16
|
-
'input',
|
|
17
|
-
'link',
|
|
18
|
-
'meta',
|
|
19
|
-
'source',
|
|
20
|
-
'track',
|
|
21
|
-
'wbr',
|
|
22
|
-
]);
|
|
23
|
-
|
|
24
|
-
const HIDDEN_STYLE = /(?:^|;)\s*(?:display\s*:\s*none|visibility\s*:\s*hidden)\s*(?:;|$)/i;
|
|
25
|
-
|
|
26
|
-
const visible = (tag: string, attrs: ReadonlyMap<string, string>): boolean =>
|
|
27
|
-
!attrs.has('hidden') &&
|
|
28
|
-
!(tag === 'input' && attrs.get('type')?.toLowerCase() === 'hidden') &&
|
|
29
|
-
!HIDDEN_STYLE.test(attrs.get('style') ?? '') &&
|
|
30
|
-
attrs.get('aria-hidden') !== 'true';
|
|
31
|
-
|
|
32
|
-
interface Open {
|
|
33
|
-
readonly tag: string;
|
|
34
|
-
readonly attrs: Map<string, string>;
|
|
35
|
-
readonly parts: string[];
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const finish = (open: Open): ElementSnapshot => ({
|
|
39
|
-
tag: open.tag,
|
|
40
|
-
attrs: Object.fromEntries(open.attrs),
|
|
41
|
-
text: open.parts.join('').replaceAll(/\s+/g, ' ').trim(),
|
|
42
|
-
value: open.attrs.get('value') ?? '',
|
|
43
|
-
visible: visible(open.tag, open.attrs),
|
|
44
|
-
enabled: !open.attrs.has('disabled') && open.attrs.get('aria-disabled') !== 'true',
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
/** Every element matching `selector`, as the markup states it. A selector lol-html refuses throws. */
|
|
48
|
-
export async function queryHtml(
|
|
49
|
-
html: string,
|
|
50
|
-
selector: string,
|
|
51
|
-
): Promise<readonly ElementSnapshot[]> {
|
|
52
|
-
const done: ElementSnapshot[] = [];
|
|
53
|
-
const open: Open[] = [];
|
|
54
|
-
const rewriter = new HTMLRewriter().on(selector, {
|
|
55
|
-
element(element): void {
|
|
56
|
-
const tag = element.tagName.toLowerCase();
|
|
57
|
-
const attrs = new Map<string, string>();
|
|
58
|
-
for (const [name, value] of element.attributes) attrs.set(name.toLowerCase(), value);
|
|
59
|
-
const record: Open = { tag, attrs, parts: [] };
|
|
60
|
-
if (VOID_TAGS.has(tag)) {
|
|
61
|
-
done.push(finish(record));
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
open.push(record);
|
|
65
|
-
element.onEndTag(() => {
|
|
66
|
-
const index = open.lastIndexOf(record);
|
|
67
|
-
if (index !== -1) open.splice(index, 1);
|
|
68
|
-
done.push(finish(record));
|
|
69
|
-
});
|
|
70
|
-
},
|
|
71
|
-
text(chunk): void {
|
|
72
|
-
// Every OPEN match: `div, span` matches both, and both contain the text.
|
|
73
|
-
for (const record of open) record.parts.push(chunk.text);
|
|
74
|
-
},
|
|
75
|
-
});
|
|
76
|
-
await rewriter.transform(new Response(html)).text();
|
|
77
|
-
// Never closed is malformed markup; the element still matched, so it is still reported.
|
|
78
|
-
for (const record of open) done.push(finish(record));
|
|
79
|
-
return done;
|
|
80
|
-
}
|
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
// `fakeShotDriver`: a `ShotDriver` over recorded pages, so every `x shot` and `ui.*` test runs with
|
|
2
|
-
// no Chrome. It parses markup and runs none of it — `query()` is answered from the HTML,
|
|
3
|
-
// `evaluate()` from a recorded table, and a click follows `data-goto` or an `<a href>`. What it
|
|
4
|
-
// cannot know it refuses by name rather than inventing: no layout box, and no accessibility tree.
|
|
5
|
-
import { hostDecision, notImplemented } from '@ultimat3/core';
|
|
6
|
-
import { CdpCallFailedError } from '@ultimat3/testing';
|
|
7
|
-
import { queryHtml } from './browser-launcher-fake-html';
|
|
8
|
-
import type {
|
|
9
|
-
CaptureClip,
|
|
10
|
-
ElementSnapshot,
|
|
11
|
-
NetworkEntry,
|
|
12
|
-
ShotColorScheme,
|
|
13
|
-
ShotDriver,
|
|
14
|
-
ShotPage,
|
|
15
|
-
ShotSession,
|
|
16
|
-
ShotSessionInit,
|
|
17
|
-
} from './browser-launcher-port';
|
|
18
|
-
import { awaitReady } from './cdp-shot-element';
|
|
19
|
-
import { ShotHostRefusedError } from './cdp-shot-errors';
|
|
20
|
-
import { parseKeyChord } from './cdp-shot-keys';
|
|
21
|
-
|
|
22
|
-
export const FAKE_SHOT_DRIVER = 'fake';
|
|
23
|
-
|
|
24
|
-
/** One recorded page: its url, its markup, and `expression -> JSON text` for `evaluate()`. */
|
|
25
|
-
export interface FakeShotPage {
|
|
26
|
-
readonly url: string;
|
|
27
|
-
readonly html: string;
|
|
28
|
-
readonly evaluate?: Readonly<Record<string, string>> | undefined;
|
|
29
|
-
/** The document's HTTP status. Absent is 200 — the page was recorded because it exists. */
|
|
30
|
-
readonly status?: number | undefined;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** A PNG signature, and nothing behind it: deterministic bytes, never a render. */
|
|
34
|
-
export const FAKE_PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Different deterministic bytes for every framing fact that was set — one per rectangle, one per
|
|
38
|
-
* colour preference — so a caller that dropped the clip or the scheme fails a test here, which is
|
|
39
|
-
* the only place a framing knob can be proved without a browser (issue #338).
|
|
40
|
-
*/
|
|
41
|
-
export const framedPng = (clip: CaptureClip | undefined, scheme: ShotColorScheme): Uint8Array => {
|
|
42
|
-
const notes =
|
|
43
|
-
(clip === undefined
|
|
44
|
-
? ''
|
|
45
|
-
: ` clip ${String(clip.x)},${String(clip.y)},${String(clip.width)},${String(clip.height)}`) +
|
|
46
|
-
(scheme === 'no-preference' ? '' : ` scheme ${scheme}`);
|
|
47
|
-
if (notes === '') return FAKE_PNG;
|
|
48
|
-
const suffix = new TextEncoder().encode(notes);
|
|
49
|
-
const out = new Uint8Array(FAKE_PNG.length + suffix.length);
|
|
50
|
-
out.set(FAKE_PNG);
|
|
51
|
-
out.set(suffix, FAKE_PNG.length);
|
|
52
|
-
return out;
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
const normalise = (url: string): string => {
|
|
56
|
-
try {
|
|
57
|
-
return new URL(url).toString();
|
|
58
|
-
} catch {
|
|
59
|
-
return url;
|
|
60
|
-
}
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
function fakePage(byUrl: ReadonlyMap<string, FakeShotPage>, init: ShotSessionInit): ShotPage {
|
|
64
|
-
let current: FakeShotPage | undefined;
|
|
65
|
-
let scheme: ShotColorScheme = 'no-preference';
|
|
66
|
-
const network: NetworkEntry[] = [];
|
|
67
|
-
const url = (): string => current?.url ?? 'about:blank';
|
|
68
|
-
|
|
69
|
-
const navigate = (to: string): void => {
|
|
70
|
-
const page = byUrl.get(normalise(to));
|
|
71
|
-
if (page === undefined) {
|
|
72
|
-
throw new CdpCallFailedError({
|
|
73
|
-
method: `Page.navigate to ${to}`,
|
|
74
|
-
detail: 'fakeShotDriver() has no page recorded at that url',
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
current = page;
|
|
78
|
-
network.push({
|
|
79
|
-
method: 'GET',
|
|
80
|
-
url: page.url,
|
|
81
|
-
// Data the page answers with, never a bound — a recorded page exists, so absent is a 200.
|
|
82
|
-
status: page.status === undefined ? 200 : page.status,
|
|
83
|
-
resourceType: 'document',
|
|
84
|
-
at: init.clock.now().getTime(),
|
|
85
|
-
});
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
const query = (selector: string): Promise<readonly ElementSnapshot[]> =>
|
|
89
|
-
queryHtml(current?.html ?? '', selector);
|
|
90
|
-
|
|
91
|
-
const ready = (selector: string, options?: Parameters<ShotPage['waitFor']>[1]) =>
|
|
92
|
-
awaitReady({
|
|
93
|
-
selector,
|
|
94
|
-
state: options?.state ?? 'actionable',
|
|
95
|
-
timeoutMs: options?.timeout ?? init.timeoutMs,
|
|
96
|
-
clock: init.clock,
|
|
97
|
-
url,
|
|
98
|
-
snapshot: async () => (await query(selector))[0],
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
return {
|
|
102
|
-
url,
|
|
103
|
-
async goto(to) {
|
|
104
|
-
if (!hostDecision(to, init.rules.allowHosts).allowed) {
|
|
105
|
-
throw new ShotHostRefusedError({ url: to, allowHosts: init.rules.allowHosts });
|
|
106
|
-
}
|
|
107
|
-
navigate(to);
|
|
108
|
-
},
|
|
109
|
-
waitFor: (selector, options) => ready(selector, options),
|
|
110
|
-
async click(selector, options) {
|
|
111
|
-
const target = await ready(selector, options);
|
|
112
|
-
// What the browser would do with the click, as far as markup can say: follow the link.
|
|
113
|
-
const next =
|
|
114
|
-
target.attrs['data-goto'] ?? (target.tag === 'a' ? target.attrs['href'] : undefined);
|
|
115
|
-
if (next !== undefined) navigate(new URL(next, url()).toString());
|
|
116
|
-
},
|
|
117
|
-
async type(selector, _text, options) {
|
|
118
|
-
await ready(selector, options);
|
|
119
|
-
},
|
|
120
|
-
async focus(selector, options) {
|
|
121
|
-
await ready(selector, options);
|
|
122
|
-
},
|
|
123
|
-
async press(chord) {
|
|
124
|
-
parseKeyChord(chord);
|
|
125
|
-
},
|
|
126
|
-
async accessibility() {
|
|
127
|
-
return notImplemented(
|
|
128
|
-
'accessibility() on fakeShotDriver()',
|
|
129
|
-
'assert on the markup with page.query(selector) — a role read off the HTML would hide the element a screen reader cannot name, which is the finding this read exists for',
|
|
130
|
-
);
|
|
131
|
-
},
|
|
132
|
-
query,
|
|
133
|
-
async evaluate(expression) {
|
|
134
|
-
const table = current?.evaluate ?? {};
|
|
135
|
-
const recorded = Object.hasOwn(table, expression) ? table[expression] : undefined;
|
|
136
|
-
if (recorded === undefined) {
|
|
137
|
-
throw new CdpCallFailedError({
|
|
138
|
-
method: 'Runtime.evaluate',
|
|
139
|
-
detail: 'fakeShotDriver() has no answer recorded for that expression on this page',
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
return JSON.parse(recorded) as unknown;
|
|
143
|
-
},
|
|
144
|
-
async screenshot(options) {
|
|
145
|
-
return framedPng(options?.clip, scheme);
|
|
146
|
-
},
|
|
147
|
-
async colorScheme(next) {
|
|
148
|
-
scheme = next;
|
|
149
|
-
},
|
|
150
|
-
async prepare() {},
|
|
151
|
-
console: () => [],
|
|
152
|
-
pageErrors: () => [],
|
|
153
|
-
pageErrorsDropped: () => 0,
|
|
154
|
-
network: () => [...network],
|
|
155
|
-
networkDropped: () => 0,
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/** A driver whose every session reads from `pages`. An unrecorded url is refused, never fetched. */
|
|
160
|
-
export function fakeShotDriver(pages: readonly FakeShotPage[]): ShotDriver {
|
|
161
|
-
const byUrl = new Map(pages.map((page) => [normalise(page.url), page]));
|
|
162
|
-
return {
|
|
163
|
-
name: FAKE_SHOT_DRIVER,
|
|
164
|
-
async open(init): Promise<ShotSession> {
|
|
165
|
-
return { page: fakePage(byUrl, init), close: async () => undefined };
|
|
166
|
-
},
|
|
167
|
-
};
|
|
168
|
-
}
|
package/src/build-errors.ts
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
// The CLI's build and bundle refusals: an entry an app does not have, an island that will not
|
|
2
|
-
// compile, a framework browser script that will not bundle. Split from `errors.ts` at its 500-line
|
|
3
|
-
// ceiling; `errors.ts` re-exports every name, so no import site moves.
|
|
4
|
-
|
|
5
|
-
import { UltimateError } from '@ultimat3/core';
|
|
6
|
-
import { quoteArg } from './shell-quote';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* A build target names an entry file the app does not have. `x build` refuses before it spawns the
|
|
10
|
-
* builder: `bun build`'s own "module not found" says nothing about which file an Ultimate app is
|
|
11
|
-
* supposed to own, and `docker build`'s says nothing about which target wanted it.
|
|
12
|
-
*/
|
|
13
|
-
export class BuildEntryMissingError extends UltimateError {
|
|
14
|
-
constructor(input: { target: string; entry: string }) {
|
|
15
|
-
super({
|
|
16
|
-
code: 'X_BUILD_ENTRY_MISSING',
|
|
17
|
-
cause: `x build --target ${input.target} builds from ${input.entry}, and the app does not have it`,
|
|
18
|
-
fix: `x new scratch-app --dry-run --json # its file list carries ${input.entry}; copy that file into this app`,
|
|
19
|
-
});
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* A client entry would not compile. `X_BUILD_FAILED`, not a code of its own: an island is a bundle
|
|
25
|
-
* entry point like any other, and the target's own logs are what says which line. The fix builds
|
|
26
|
-
* exactly that one file, so the next message an author reads is the compiler's and not the CLI's.
|
|
27
|
-
*/
|
|
28
|
-
export class IslandBuildFailedError extends UltimateError {
|
|
29
|
-
constructor(input: { file: string; logs: string }) {
|
|
30
|
-
super({
|
|
31
|
-
code: 'X_BUILD_FAILED',
|
|
32
|
-
cause: `${input.file} is an island entry point and would not bundle: ${input.logs}`,
|
|
33
|
-
fix: `bun build --target browser ${input.file}`,
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** Which framework script a page ships: the sync worker, the page boot, or the client router. */
|
|
39
|
-
export type FrameworkScriptKind = 'sync worker' | 'page boot' | 'client router';
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* One of the page's framework scripts would not bundle. Same code as an island: "a browser entry
|
|
43
|
-
* the framework builds did not build" is one condition, and the entry here is framework code, not
|
|
44
|
-
* the app's — the cause names WHICH script, so the reader knows what the page is now missing.
|
|
45
|
-
*/
|
|
46
|
-
export class FrameworkScriptBuildFailedError extends UltimateError {
|
|
47
|
-
constructor(input: { what: FrameworkScriptKind; entry: string; logs: string }) {
|
|
48
|
-
super({
|
|
49
|
-
code: 'X_BUILD_FAILED',
|
|
50
|
-
cause: `the ${input.what} (${input.entry}) would not bundle for the browser: ${input.logs}`,
|
|
51
|
-
fix: `bun build --target browser --format iife ${quoteArg(input.entry)}`,
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
}
|
package/src/jobs-drain.ts
DELETED
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
// `x jobs drain`: moving in-flight work from one queue driver onto another. Its own file because
|
|
2
|
-
// it is the only command here that WRITES to two drivers at once, and the ordering rule that makes
|
|
3
|
-
// that safe — lease, copy steps, enqueue, then ack — has to be readable in one screen.
|
|
4
|
-
|
|
5
|
-
import { uuid } from '@ultimat3/core';
|
|
6
|
-
import type { ClaimedJob, JobDriver, JobRecord, JobState } from '@ultimat3/jobs';
|
|
7
|
-
import { claimOf, inspectJobList } from '@ultimat3/jobs';
|
|
8
|
-
import type { Finding } from './output';
|
|
9
|
-
import { findingFrom } from './output';
|
|
10
|
-
|
|
11
|
-
/** `running` is deliberately excluded: a job a worker is mid-execution on is not "pending". */
|
|
12
|
-
const PENDING_STATES: readonly JobState[] = ['ready', 'delayed', 'suspended'];
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* The lease has to outlive the WHOLE transfer loop, not one record: the batch is claimed up
|
|
16
|
-
* front, and a lease expiring mid-drain would hand a half-transferred job back to a source
|
|
17
|
-
* worker. The snapshot is bounded by the drivers' own 100-row-per-state list cap, so this is a
|
|
18
|
-
* ceiling over a few hundred sequential enqueues rather than a guess about one.
|
|
19
|
-
*/
|
|
20
|
-
const DRAIN_LEASE_MS = 300_000;
|
|
21
|
-
|
|
22
|
-
const NO_LEASE = 'no lease could be taken — not yet due, or a worker is already running it';
|
|
23
|
-
|
|
24
|
-
export interface DrainFailure {
|
|
25
|
-
readonly id: string;
|
|
26
|
-
readonly name: string;
|
|
27
|
-
readonly finding: Finding;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** A candidate the drain deliberately left alone, because it could not prove it owned the job. */
|
|
31
|
-
export interface DrainSkip {
|
|
32
|
-
readonly id: string;
|
|
33
|
-
readonly name: string;
|
|
34
|
-
readonly queue: string;
|
|
35
|
-
readonly state: JobState;
|
|
36
|
-
readonly reason: string;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface DrainOutcome {
|
|
40
|
-
readonly from: string;
|
|
41
|
-
readonly to: string;
|
|
42
|
-
readonly dryRun: boolean;
|
|
43
|
-
readonly candidates: readonly JobRecord[];
|
|
44
|
-
readonly moved: readonly JobRecord[];
|
|
45
|
-
readonly skipped: readonly DrainSkip[];
|
|
46
|
-
readonly failures: readonly DrainFailure[];
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* The lease IS the proof of ownership. `ack()` takes only a job id, so a drain that ack'd rows
|
|
51
|
-
* off a snapshot could acknowledge a job a source worker claimed and is executing right now —
|
|
52
|
-
* the target would then run a duplicate of a job still in flight. One batch claim over the
|
|
53
|
-
* candidates' own queues; whatever comes back is the drain's to move, and everything else is
|
|
54
|
-
* left alone. `queues` is always explicit because the pg driver reads an empty list as
|
|
55
|
-
* `['default']` rather than as "every queue".
|
|
56
|
-
*/
|
|
57
|
-
function leaseCandidates(
|
|
58
|
-
source: JobDriver,
|
|
59
|
-
candidates: readonly JobRecord[],
|
|
60
|
-
): Promise<readonly ClaimedJob[]> {
|
|
61
|
-
if (candidates.length === 0) return Promise.resolve([]);
|
|
62
|
-
return source.claim({
|
|
63
|
-
queues: [...new Set(candidates.map((record) => record.queue))],
|
|
64
|
-
limit: candidates.length,
|
|
65
|
-
visibilityTimeoutMs: DRAIN_LEASE_MS,
|
|
66
|
-
workerId: `x-jobs-drain:${uuid()}`,
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** Steps carry their own `runId`, so a copy lands under the same key the target job resumes on. */
|
|
71
|
-
async function copySteps(source: JobDriver, target: JobDriver, runId: string): Promise<void> {
|
|
72
|
-
for (const record of await source.steps.list(runId)) await target.steps.put(record);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Hand a leased job back exactly as the drain found it. `countsAsAttempt: false` is the point:
|
|
77
|
-
* a transfer that failed is not a failed attempt, and burning one per `x jobs drain` retry would
|
|
78
|
-
* dead-letter a job nobody ever ran.
|
|
79
|
-
*
|
|
80
|
-
* It returns to `ready`, which is what "as the drain found it" means — the drain leased a ready
|
|
81
|
-
* job and could not move it. It used to land in `suspended`, not by intent but because
|
|
82
|
-
* `countsAsAttempt: false` was the only bit the drivers had and `step.sleep` had claimed it;
|
|
83
|
-
* `NackOptions.park` now carries the suspension, so the two callers no longer share one meaning.
|
|
84
|
-
*/
|
|
85
|
-
async function releaseLease(source: JobDriver, leased: ClaimedJob): Promise<void> {
|
|
86
|
-
try {
|
|
87
|
-
// Fenced on the drain's own claim, like every settle: a lease that lapsed is not handed back.
|
|
88
|
-
await source.nack(leased.id, {
|
|
89
|
-
...claimOf(leased),
|
|
90
|
-
delayMs: 0,
|
|
91
|
-
countsAsAttempt: false,
|
|
92
|
-
});
|
|
93
|
-
} catch {
|
|
94
|
-
// The lease expires on its own. Masking the transfer's real error with this one helps nobody.
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const toSkip = (record: JobRecord): DrainSkip => ({
|
|
99
|
-
id: record.id,
|
|
100
|
-
name: record.name,
|
|
101
|
-
queue: record.queue,
|
|
102
|
-
state: record.state,
|
|
103
|
-
reason: NO_LEASE,
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Move every pending job the drain can take a lease on from `source` onto `target`. `--dry-run`
|
|
108
|
-
* reports the candidate list, takes no lease and enqueues nothing. Per-record try/catch, not a
|
|
109
|
-
* batch operation: one record that cannot enqueue on the target (a redis/nats stub, a transient
|
|
110
|
-
* error) must not stop the rest from moving, and the caller reports each failure on its own.
|
|
111
|
-
*/
|
|
112
|
-
export async function drainJobs(
|
|
113
|
-
source: JobDriver,
|
|
114
|
-
target: JobDriver,
|
|
115
|
-
dryRun: boolean,
|
|
116
|
-
): Promise<DrainOutcome> {
|
|
117
|
-
const lists = await Promise.all(PENDING_STATES.map((state) => inspectJobList(source, { state })));
|
|
118
|
-
const candidates = lists.flat();
|
|
119
|
-
const base = { from: source.name, to: target.name, candidates };
|
|
120
|
-
if (dryRun) return { ...base, dryRun: true, moved: [], skipped: [], failures: [] };
|
|
121
|
-
|
|
122
|
-
const leased = await leaseCandidates(source, candidates);
|
|
123
|
-
const held = new Set(leased.map((record) => record.id));
|
|
124
|
-
const found = new Map(candidates.map((record) => [record.id, record]));
|
|
125
|
-
const skipped = candidates.filter((record) => !held.has(record.id)).map(toSkip);
|
|
126
|
-
|
|
127
|
-
const moved: JobRecord[] = [];
|
|
128
|
-
const failures: DrainFailure[] = [];
|
|
129
|
-
for (const record of leased) {
|
|
130
|
-
let enqueued = false;
|
|
131
|
-
try {
|
|
132
|
-
// Steps BEFORE the job: a worker on the target must never be able to claim a run whose
|
|
133
|
-
// checkpoint has not landed, or it repeats completed steps or loses them outright.
|
|
134
|
-
await copySteps(source, target, record.runId);
|
|
135
|
-
await target.enqueue({
|
|
136
|
-
name: record.name,
|
|
137
|
-
queue: record.queue,
|
|
138
|
-
input: record.input,
|
|
139
|
-
idempotencyKey: record.idempotencyKey,
|
|
140
|
-
runId: record.runId,
|
|
141
|
-
maxAttempts: record.maxAttempts,
|
|
142
|
-
runAt: record.runAt,
|
|
143
|
-
...(record.tenantId === undefined ? {} : { tenantId: record.tenantId }),
|
|
144
|
-
});
|
|
145
|
-
enqueued = true;
|
|
146
|
-
// Only now is the ack the drain's to make: the lease proves no source worker holds this
|
|
147
|
-
// job, and the target already has both the row and its steps. A crash between the two
|
|
148
|
-
// leaves the job live on both drivers, where `idempotencyKey` dedupes it.
|
|
149
|
-
// `counted: false`: the row is finished HERE because it left, and a job that was moved is
|
|
150
|
-
// not a job that completed — it must add nothing to the source's `done` history.
|
|
151
|
-
await source.ack(record.id, { ...claimOf(record), counted: false });
|
|
152
|
-
// Report the row as the drain FOUND it: `claim()` returns it mid-lease (`running`, one
|
|
153
|
-
// attempt higher), a state nothing on either driver is in once this returns.
|
|
154
|
-
moved.push(found.get(record.id) ?? record);
|
|
155
|
-
} catch (error) {
|
|
156
|
-
// A failed enqueue left nothing on the target, so the lease goes back. A failed ack did
|
|
157
|
-
// not: the job is already live there, and releasing it would race the target's worker.
|
|
158
|
-
if (!enqueued) await releaseLease(source, record);
|
|
159
|
-
failures.push({ id: record.id, name: record.name, finding: findingFrom(error) });
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
return { ...base, dryRun: false, moved, skipped, failures };
|
|
163
|
-
}
|