@ultimat3/cli 1.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.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -0
  3. package/package.json +60 -0
  4. package/src/app-agents-md.ts +27 -0
  5. package/src/app-boundaries.ts +206 -0
  6. package/src/app-evals.ts +74 -0
  7. package/src/app-load.ts +136 -0
  8. package/src/app-manifest.ts +137 -0
  9. package/src/app-openapi.ts +12 -0
  10. package/src/app-root.ts +57 -0
  11. package/src/bin.ts +17 -0
  12. package/src/boundary-cuts.ts +219 -0
  13. package/src/budgets.ts +92 -0
  14. package/src/cmd-build.ts +109 -0
  15. package/src/cmd-db.ts +187 -0
  16. package/src/cmd-deploy.ts +124 -0
  17. package/src/cmd-dev.ts +286 -0
  18. package/src/cmd-doctor.ts +178 -0
  19. package/src/cmd-errors.ts +99 -0
  20. package/src/cmd-fix.ts +126 -0
  21. package/src/cmd-generate.ts +434 -0
  22. package/src/cmd-help.ts +94 -0
  23. package/src/cmd-i18n.ts +212 -0
  24. package/src/cmd-jobs.ts +237 -0
  25. package/src/cmd-manifest.ts +97 -0
  26. package/src/cmd-mcp.ts +176 -0
  27. package/src/cmd-new.ts +133 -0
  28. package/src/cmd-planned.ts +119 -0
  29. package/src/cmd-policy.ts +136 -0
  30. package/src/cmd-registries.ts +195 -0
  31. package/src/cmd-routes.ts +73 -0
  32. package/src/cmd-tasks.ts +151 -0
  33. package/src/cmd-test.ts +109 -0
  34. package/src/cmd-verify.ts +265 -0
  35. package/src/command.ts +33 -0
  36. package/src/dev-assets.ts +177 -0
  37. package/src/dev-dashboard.ts +242 -0
  38. package/src/dev-hooks.ts +51 -0
  39. package/src/dev-policy.ts +82 -0
  40. package/src/dev-queue.ts +109 -0
  41. package/src/dev-render.ts +129 -0
  42. package/src/dev-replicator.ts +92 -0
  43. package/src/dev-roles.ts +246 -0
  44. package/src/dev-runtime.ts +203 -0
  45. package/src/dev-services.ts +75 -0
  46. package/src/dev-traces.ts +141 -0
  47. package/src/dispatch.ts +98 -0
  48. package/src/drift.ts +86 -0
  49. package/src/error-catalog.ts +156 -0
  50. package/src/error-contract.ts +212 -0
  51. package/src/errors.ts +367 -0
  52. package/src/exec.ts +70 -0
  53. package/src/hold.ts +48 -0
  54. package/src/i18n-audit.ts +183 -0
  55. package/src/index.ts +179 -0
  56. package/src/jobs-drain.ts +151 -0
  57. package/src/jobs-json.ts +134 -0
  58. package/src/jobs-report.ts +132 -0
  59. package/src/jobs-table.ts +34 -0
  60. package/src/json-merge.ts +40 -0
  61. package/src/mcp-db-target.ts +50 -0
  62. package/src/mcp-errors.ts +99 -0
  63. package/src/mcp-host.ts +282 -0
  64. package/src/mcp-test-output.ts +57 -0
  65. package/src/messages.ts +119 -0
  66. package/src/output.ts +174 -0
  67. package/src/parse.ts +243 -0
  68. package/src/policy-facts.ts +196 -0
  69. package/src/policy-fixture.ts +71 -0
  70. package/src/registry.ts +73 -0
  71. package/src/scaffold-fixture.ts +69 -0
  72. package/src/scaffold-typecheck.ts +240 -0
  73. package/src/source-files.ts +38 -0
  74. package/src/table.ts +19 -0
  75. package/src/tasks-facts.ts +113 -0
  76. package/src/templates/action.ts +193 -0
  77. package/src/templates/admin.ts +46 -0
  78. package/src/templates/catalog-json.ts +17 -0
  79. package/src/templates/entity.ts +157 -0
  80. package/src/templates/index.ts +23 -0
  81. package/src/templates/job.ts +148 -0
  82. package/src/templates/locales.ts +93 -0
  83. package/src/templates/naming.ts +97 -0
  84. package/src/templates/policy.ts +120 -0
  85. package/src/templates/query.ts +116 -0
  86. package/src/templates/resource.ts +199 -0
  87. package/src/templates/route.ts +138 -0
  88. package/src/templates/scaffold-app.ts +320 -0
  89. package/src/templates/scaffold-docs.ts +156 -0
  90. package/src/templates/scaffold-i18n.ts +149 -0
  91. package/src/templates/scaffold-icon.ts +54 -0
  92. package/src/templates/scaffold-package-shape.ts +49 -0
  93. package/src/templates/scaffold-repo.ts +427 -0
  94. package/src/test-select.ts +130 -0
  95. package/src/test-shards.ts +188 -0
  96. package/src/thrown-by.ts +24 -0
  97. package/src/ts-scan.ts +217 -0
  98. package/src/verify-step.ts +83 -0
  99. package/src/verify-tests.ts +166 -0
  100. package/src/version-loader.ts +16 -0
  101. package/src/workspace-checks.ts +288 -0
package/src/hold.ts ADDED
@@ -0,0 +1,48 @@
1
+ // Staying up. A command whose server is still listening when `run` resolves is a command whose
2
+ // process `bin.ts` exits out from under — so it hands back a hold, and `dispatch` awaits that
3
+ // before the exit code. Ctrl-C then takes core's own three-phase drain (stop accepting, finish
4
+ // in-flight, close) instead of killing a query mid-round-trip.
5
+
6
+ import { drain, installSignalHandlers, onShutdown } from '@ultimat3/core';
7
+
8
+ /**
9
+ * Wait for a shutdown, then release what core's lifecycle does not own.
10
+ *
11
+ * The wait is on the drain's first phase, never on a signal table of our own: core already owns
12
+ * which signals mean stop, and a second list here would be a second answer to that question. It
13
+ * also means anything else that calls `drain()` — a test, a supervisor, a later role — releases
14
+ * this command too.
15
+ *
16
+ * `release` runs after the drain completes, so in-flight requests still see the database the
17
+ * handler opened them against. It is the resources core never learned about: the embedded
18
+ * Postgres, the worker, the file watcher.
19
+ */
20
+ export function holdUntilShutdown(name: string, release: () => Promise<void>): () => Promise<void> {
21
+ const uninstall = installSignalHandlers({ exit: false });
22
+ let unregister = (): void => {};
23
+ const shuttingDown = new Promise<void>((resolve) => {
24
+ unregister = onShutdown(
25
+ `cli:${name}:hold`,
26
+ () => {
27
+ resolve();
28
+ },
29
+ { phase: 'accept' },
30
+ );
31
+ });
32
+
33
+ let held: Promise<void> | undefined;
34
+ return () => {
35
+ // Memoised: awaiting a hold twice must not release twice, and `dispatch` is not the only
36
+ // caller a test can be.
37
+ held ??= (async () => {
38
+ await shuttingDown;
39
+ // Idempotent in core: this joins the drain already in flight and resolves when its last
40
+ // phase is done. Calling it is what makes `release` the step after the drain, not beside it.
41
+ await drain();
42
+ unregister();
43
+ uninstall();
44
+ await release();
45
+ })();
46
+ return held;
47
+ };
48
+ }
@@ -0,0 +1,183 @@
1
+ // Pure facts behind `x i18n`: the source scan, the catalogs on disk, the audit, and the seed/sync
2
+ // key sets. No CLI shapes and no msg() — an app root in, plain data out, so every path here is
3
+ // testable without a ParsedArgs or a rendered message.
4
+
5
+ // `node:` and not Bun: Bun exposes no existence check (`existsSync`, which is how a missing
6
+ // `catalogs/` directory reads as "nothing shipped" instead of a throw) and no path API at all —
7
+ // `join` builds the absolute path `Bun.file` reads, and `relative`/`sep` turn it back into the
8
+ // root-relative POSIX shape every CLI-reported path is keyed by.
9
+ import { existsSync } from 'node:fs';
10
+ import { join, relative, sep } from 'node:path';
11
+ import type { Catalog, Extraction, ExtractReport, Locale } from '@ultimat3/i18n';
12
+ import {
13
+ auditCatalogs,
14
+ catalogInvalid,
15
+ catalogKeys,
16
+ DEFAULT_LOCALE,
17
+ extractFromFiles,
18
+ loadCatalog,
19
+ missingFrom,
20
+ nestCatalog,
21
+ } from '@ultimat3/i18n';
22
+ import { eachSourceFile, isTest } from './source-files';
23
+ import { CATALOG_ROOT, catalogPath } from './templates/locales';
24
+
25
+ /**
26
+ * Every `t()` call the app's own source makes. `source-files.ts` (`eachSourceFile`) is the one
27
+ * glob set every text-scanning gate step already shares (`errors`, `filesize`) — a second glob
28
+ * here would mean this command and `x verify` disagree on what "the app's source" is. Test files
29
+ * are excluded: a fixture's `t('fixture.key')` is not a gap the shipped catalogs owe an answer to.
30
+ * `extractFromFiles` reads by absolute path, so its `file` label is rewritten back to the same
31
+ * root-relative POSIX shape `app-load.ts` uses for every other CLI-reported path.
32
+ */
33
+ export async function scanSource(root: string): Promise<Extraction> {
34
+ const files: string[] = [];
35
+ for await (const file of eachSourceFile(root)) {
36
+ if (!isTest(file)) files.push(file);
37
+ }
38
+ const extraction = await extractFromFiles(files.map((file) => join(root, file)));
39
+ const toRelative = (file: string): string => relative(root, file).split(sep).join('/');
40
+ return {
41
+ usages: extraction.usages.map((usage) => ({ ...usage, file: toRelative(usage.file) })),
42
+ dynamic: extraction.dynamic.map((entry) => ({ ...entry, file: toRelative(entry.file) })),
43
+ };
44
+ }
45
+
46
+ /**
47
+ * A `JSON.parse` failure is still a catalog problem, not a bare-Error crash through this command —
48
+ * reported through the same factory a structural violation (`loadCatalog` below) uses.
49
+ */
50
+ function parseCatalogJson(path: string, raw: string): unknown {
51
+ try {
52
+ return JSON.parse(raw);
53
+ } catch (error) {
54
+ throw catalogInvalid(path, error instanceof Error ? error.message : String(error));
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Every `packages/i18n/catalogs/*.json` on disk, parsed and flattened through `loadCatalog` —
60
+ * never a bare `JSON.parse`: a nested `{ one, other }` plural authored by hand is a known past
61
+ * bug, and `loadCatalog` is the seam that fails it loud (`X_CATALOG_INVALID`) instead of auditing
62
+ * it as an empty catalog. No `catalogs/` directory yet (a fresh app, or `packages/i18n` never
63
+ * scaffolded) yields `{}` — every caller here treats that as "nothing shipped", not an error.
64
+ */
65
+ export async function loadCatalogs(root: string): Promise<Readonly<Record<Locale, Catalog>>> {
66
+ const dir = join(root, CATALOG_ROOT);
67
+ if (!existsSync(dir)) return {};
68
+ const catalogs: Record<string, Catalog> = {};
69
+ for await (const entry of new Bun.Glob('*.json').scan({ cwd: dir, absolute: false })) {
70
+ const locale = entry.replace(/\.json$/, '');
71
+ const path = catalogPath(locale);
72
+ const raw = await Bun.file(join(root, path)).text();
73
+ catalogs[locale] = loadCatalog(parseCatalogJson(path, raw));
74
+ }
75
+ return catalogs;
76
+ }
77
+
78
+ export interface AuditFacts {
79
+ readonly report: ExtractReport;
80
+ readonly catalogs: Readonly<Record<Locale, Catalog>>;
81
+ }
82
+
83
+ /** The static head of a template literal, up to its first interpolation. */
84
+ const TEMPLATE_HEAD = /^`([^`$]*)\$\{/;
85
+
86
+ /**
87
+ * `unused` is the half of the audit an agent acts on destructively — it reads as "safe to delete".
88
+ * A key only ever reached through `t(`plans.${plan}.name`)` is used, and reporting it unused is
89
+ * how a live key gets deleted. `AuditInput.ignoreUnused` exists for exactly this, so every dynamic
90
+ * call contributes its own static head as a `prefix*` pattern. An expression that is not a template
91
+ * literal (a ternary over string literals, a bare variable) contributes nothing: guessing a prefix
92
+ * from it would suppress real findings, and the `dynamic` list already names it for a human.
93
+ */
94
+ export function runtimeKeyPatterns(extraction: Extraction): readonly string[] {
95
+ const patterns = new Set<string>();
96
+ for (const entry of extraction.dynamic) {
97
+ const head = TEMPLATE_HEAD.exec(entry.expression)?.[1];
98
+ if (head !== undefined && head.length > 0) patterns.add(`${head}*`);
99
+ }
100
+ return [...patterns].sort();
101
+ }
102
+
103
+ /** The source scan and the catalogs on disk, audited together — the one fact `x i18n check` reports. */
104
+ export async function auditApp(root: string): Promise<AuditFacts> {
105
+ const [extraction, catalogs] = await Promise.all([scanSource(root), loadCatalogs(root)]);
106
+ const ignoreUnused = runtimeKeyPatterns(extraction);
107
+ return { report: auditCatalogs({ extraction, catalogs, ignoreUnused }), catalogs };
108
+ }
109
+
110
+ /**
111
+ * Which locale is the source of truth for seeding (`x i18n add`) and syncing (`x i18n sync`).
112
+ * `declared` is the app's own `defineCatalogs({ default })`, projected by `app-load.ts` from
113
+ * `@ultimat3/i18n`'s `localeConfig()` — the framework's answer to its own question, never a parse
114
+ * of the app's source. Three rules, in order:
115
+ * 1. `declared`, trusted only when a catalog for it actually exists on disk.
116
+ * 2. `en`, when a catalog for it exists — every app `x new` scaffolds has one, so this covers
117
+ * every real app even when the i18n module would not import.
118
+ * 3. The sole catalog on disk, when there is exactly one.
119
+ * `undefined` when none of the three resolve (no catalogs yet, or several with no `en` and nothing
120
+ * on disk answering `declared`) — callers seed/sync from an empty source rather than guess at one.
121
+ */
122
+ export function resolveDefaultLocale(
123
+ declared: string | undefined,
124
+ catalogs: Readonly<Record<Locale, Catalog>>,
125
+ ): Locale | undefined {
126
+ if (declared !== undefined && Object.hasOwn(catalogs, declared)) return declared;
127
+ if (Object.hasOwn(catalogs, DEFAULT_LOCALE)) return DEFAULT_LOCALE;
128
+ const locales = Object.keys(catalogs);
129
+ return locales.length === 1 ? locales[0] : undefined;
130
+ }
131
+
132
+ /**
133
+ * A sorted copy of `catalog`. Every write below goes through this, so a later `sync` (or a second
134
+ * `add`) diffs only the keys that actually changed, never a reshuffle.
135
+ */
136
+ function sortCatalog(catalog: Catalog): Catalog {
137
+ const out: Record<string, string> = {};
138
+ for (const key of catalogKeys(catalog)) {
139
+ const value = catalog[key];
140
+ if (value !== undefined) out[key] = value;
141
+ }
142
+ return out;
143
+ }
144
+
145
+ /**
146
+ * `x i18n add`'s seed: every key the default locale defines, values copied verbatim — an
147
+ * untranslated string that renders is strictly better than a missing key that renders `⟦key⟧`.
148
+ */
149
+ export function seedCatalog(source: Catalog): Catalog {
150
+ return sortCatalog(source);
151
+ }
152
+
153
+ export interface SyncResult {
154
+ readonly merged: Catalog;
155
+ readonly added: readonly string[];
156
+ }
157
+
158
+ /**
159
+ * `x i18n sync`: every key `source` has that `target` does not, added; every key `target` already
160
+ * has stays exactly as written, translated or not.
161
+ */
162
+ export function syncCatalog(target: Catalog, source: Catalog): SyncResult {
163
+ const added = missingFrom(source, target);
164
+ if (added.length === 0) return { merged: target, added };
165
+ const merged: Record<string, string> = { ...target };
166
+ for (const key of added) {
167
+ const value = source[key];
168
+ if (value !== undefined) merged[key] = value;
169
+ }
170
+ return { merged: sortCatalog(merged), added };
171
+ }
172
+
173
+ /**
174
+ * `Bun.write`'s contents for any catalog this command writes: **nested**, sorted, 2-space indent,
175
+ * a trailing newline — the shape a hand-authored catalog already has, so the next `sync` or edit
176
+ * produces a clean diff. Nested is load-bearing, not cosmetic: `Catalog` is the flat dot-key form
177
+ * the translator reads, and a file written in it is one `loadCatalog` refuses on the very next
178
+ * read (`X_CATALOG_INVALID` — a dot is not a key segment). `nestCatalog` is `@ultimat3/i18n`'s own
179
+ * inverse of the flatten every read does, so a catalog this command writes round-trips through it.
180
+ */
181
+ export function serializeCatalog(catalog: Catalog): string {
182
+ return `${JSON.stringify(nestCatalog(sortCatalog(catalog)), null, 2)}\n`;
183
+ }
package/src/index.ts ADDED
@@ -0,0 +1,179 @@
1
+ // Public API of @ultimat3/cli. Explicit re-exports only: create-ultimate and the test suite build
2
+ // on these, and a barrel that re-exports everything would make every internal a compatibility
3
+ // promise.
4
+
5
+ export type { BoundaryCode, SourceFile } from './app-boundaries';
6
+ export {
7
+ appImportGraph,
8
+ BOUNDARY_CODES,
9
+ checkAppBoundaries,
10
+ checkImportRules,
11
+ readAppSources,
12
+ resolveSpecifier,
13
+ scanRuntimeImports,
14
+ } from './app-boundaries';
15
+ export type { LoadedApp } from './app-load';
16
+ export { loadApp, resetAppLoad } from './app-load';
17
+ export type { AppManifest } from './app-manifest';
18
+ export { appManifest, policyFacts, readAppManifest, writeAppManifest } from './app-manifest';
19
+ export { OPENAPI_FILE, openApiJson } from './app-openapi';
20
+ export type { AppRoot } from './app-root';
21
+ export { findAppRoot, requireAppRoot, requireBunVersion, versionAtLeast } from './app-root';
22
+ export type { BoundaryCut, BoundarySplit } from './boundary-cuts';
23
+ export { planBoundaryCuts } from './boundary-cuts';
24
+ export type { BuildStats, RouteStats } from './budgets';
25
+ export { BUILD_STATS_FILE, checkBudgets, readBuildStats } from './budgets';
26
+ export type { BuildTarget } from './cmd-build';
27
+ export { argsFor, BUILD_TARGETS, buildCommand, readTarget } from './cmd-build';
28
+ export { branchDatabaseName, branchSql, dbCommand, previewUrl } from './cmd-db';
29
+ export type { DeployPlan } from './cmd-deploy';
30
+ export { deployCommand, planDeploy } from './cmd-deploy';
31
+ export type { DevServer, StartDevOptions } from './cmd-dev';
32
+ export { devCommand, startDev } from './cmd-dev';
33
+ export type { DoctorProbe } from './cmd-doctor';
34
+ export { doctorCommand, OFFLINE_FALLBACK, probeFor, runDoctor } from './cmd-doctor';
35
+ export { ERRORS_SUBCOMMANDS, errorsCommand } from './cmd-errors';
36
+ export { FIX_SUBCOMMANDS, fixCommand } from './cmd-fix';
37
+ export type { GenerateOptions, Generator } from './cmd-generate';
38
+ export { GENERATORS, generate, generateCommand, writeFiles } from './cmd-generate';
39
+ export { createHelpCommand, createVersionCommand, renderHelp } from './cmd-help';
40
+ export { buildDrainTarget, JOBS_SUBCOMMANDS, jobsCommand } from './cmd-jobs';
41
+ export { manifestCommand } from './cmd-manifest';
42
+ export type { McpHttpServer } from './cmd-mcp';
43
+ export { mcpCommand, startMcpHttp } from './cmd-mcp';
44
+ export type { NewAppOptions, WrittenApp } from './cmd-new';
45
+ export { newCommand, planNewApp, writeNewApp } from './cmd-new';
46
+ export type { PlannedCommand } from './cmd-planned';
47
+ export { PLANNED_COMMANDS, plannedCommands } from './cmd-planned';
48
+ export { actionsCommand, entitiesCommand, queriesCommand } from './cmd-registries';
49
+ export { renderRouteTable, routesCommand } from './cmd-routes';
50
+ export { availableCpus, testCommand } from './cmd-test';
51
+ export { runVerify, VERIFY_STEPS, verifyCommand, verifyStepNames } from './cmd-verify';
52
+ export type { CliCommand, CommandContext } from './command';
53
+ export { failed, ok } from './command';
54
+ export type { AssetRoutesOptions } from './dev-assets';
55
+ export {
56
+ assetRoutes,
57
+ ICON_BASE_PATH,
58
+ ICON_SOURCE,
59
+ MEDIA_BASE_PATH,
60
+ } from './dev-assets';
61
+ export type { DevDashboardInput, DevStatus } from './dev-dashboard';
62
+ export { devDashboardRoutes, devPanels, devSources } from './dev-dashboard';
63
+ export { devHooks } from './dev-hooks';
64
+ export type { DevDbClient, RunningQueue } from './dev-queue';
65
+ export { startQueue } from './dev-queue';
66
+ export type { DevRenderOptions, DevRouteData } from './dev-render';
67
+ export { appRoutes } from './dev-render';
68
+ export type { RunningRoles, StartRolesOptions } from './dev-roles';
69
+ export { DEV_ROLES, selectRoles, startRoles } from './dev-roles';
70
+ export type { RunningServices } from './dev-runtime';
71
+ export { startServices } from './dev-runtime';
72
+ export type { DevServices, ServiceBinding } from './dev-services';
73
+ export { describeServices, resolveServices } from './dev-services';
74
+ export type { DispatchOptions } from './dispatch';
75
+ export { dispatch } from './dispatch';
76
+ export { checkDrift, recordedHashes, schemaHash, writeSchemaHash } from './drift';
77
+ export type { ErrorCatalog } from './error-catalog';
78
+ export {
79
+ buildErrorCatalog,
80
+ CATALOG_PACKAGES,
81
+ loadErrorCatalog,
82
+ registeredErrorCodes,
83
+ resetErrorCatalog,
84
+ } from './error-catalog';
85
+ export {
86
+ BANNED_PHRASES,
87
+ COMMAND_TOKENS,
88
+ checkErrorCodeDocs,
89
+ checkErrorCodeRegistry,
90
+ checkErrorFixes,
91
+ collectDeclaredCodes,
92
+ documentedCodes,
93
+ fixProblem,
94
+ liveCodes,
95
+ RESERVED_HEADING,
96
+ staticFix,
97
+ } from './error-contract';
98
+ export type { CliErrorCode } from './errors';
99
+ export {
100
+ BadFlagError,
101
+ BunVersionError,
102
+ CatalogExistsError,
103
+ CLI_ERROR_CODES,
104
+ CLI_ERROR_TITLES,
105
+ CliNotImplementedError,
106
+ DeclarationUnknownError,
107
+ ErrorCodeUnknownError,
108
+ FixTargetUnknownError,
109
+ JobUnknownError,
110
+ NoTestFilesError,
111
+ NotInAppError,
112
+ UnknownCommandError,
113
+ VerifyFailedError,
114
+ } from './errors';
115
+ export type { ExecOptions, ExecResult, Runner } from './exec';
116
+ export { exec, execOutput } from './exec';
117
+ export type { DrainFailure, DrainOutcome, DrainSkip } from './jobs-drain';
118
+ export { drainJobs } from './jobs-drain';
119
+ export type { JobsListFilter, JobsListResult } from './jobs-report';
120
+ export { JOB_STATES, listJobs, retryJob, showJob } from './jobs-report';
121
+ export { renderJobTable } from './jobs-table';
122
+ export type { CliMcpServer, DevHostInput } from './mcp-host';
123
+ export { createDevMcpServer, DEV_TOOL_SCOPES, localCaller } from './mcp-host';
124
+ export { messageKeys, msg } from './messages';
125
+ export type { CommandResult, Finding, JsonValue, StepResult } from './output';
126
+ export {
127
+ exitCodeFor,
128
+ findingFrom,
129
+ isUltimateErrorShape,
130
+ render,
131
+ renderFinding,
132
+ renderHuman,
133
+ renderJson,
134
+ renderUltimateError,
135
+ } from './output';
136
+ export type { CommandSpec, FlagSpec, ParsedArgs } from './parse';
137
+ export { flagBool, flagList, flagString, GLOBAL_FLAGS, nearest, parseArgs } from './parse';
138
+ export { CLI_VERSION, COMMANDS, commandFor, SPECS } from './registry';
139
+ export {
140
+ eachSourceFile,
141
+ isGenerated,
142
+ isTest,
143
+ isVendored,
144
+ SOURCE_GLOBS,
145
+ } from './source-files';
146
+ export type { TestFile } from './test-select';
147
+ export { belongsToType, discoverTests, sampleFiles } from './test-select';
148
+ export type { ReproduceOptions, RunShardsOptions, Shard } from './test-shards';
149
+ export { planShards, quoteArg, reproduceFor, runShards, shardArgs } from './test-shards';
150
+ export type { CodeSite, FixSite, SourceSite } from './ts-scan';
151
+ export {
152
+ isCodeRegistry,
153
+ maskLiterals,
154
+ scanBorrowedCodes,
155
+ scanCodes,
156
+ scanFixes,
157
+ stripComments,
158
+ } from './ts-scan';
159
+ export type {
160
+ HostCheck,
161
+ StepOutcome,
162
+ VerifyContext,
163
+ VerifyStep,
164
+ VerifyStepName,
165
+ } from './verify-step';
166
+ export { VERIFY_STEP_NAMES } from './verify-step';
167
+ export type { TestType } from './verify-tests';
168
+ export { TEST_STEPS, TEST_TYPES, testStepCommand, typeFilterOf } from './verify-tests';
169
+ export type { ManifestFacts } from './workspace-checks';
170
+ export {
171
+ checkFileSizes,
172
+ checkLockstep,
173
+ checkPackageShape,
174
+ frameworkDepsOf,
175
+ hasWorkspacePackages,
176
+ LINE_CEILING,
177
+ PACKAGE_FILES,
178
+ workspacePackages,
179
+ } from './workspace-checks';
@@ -0,0 +1,151 @@
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 { JobDriver, JobRecord, JobState } from '@ultimat3/jobs';
7
+ import { 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 JobRecord[]> {
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. It parks as `suspended`, which every driver claims.
79
+ */
80
+ async function releaseLease(source: JobDriver, id: string): Promise<void> {
81
+ try {
82
+ await source.nack(id, { delayMs: 0, countsAsAttempt: false });
83
+ } catch {
84
+ // The lease expires on its own. Masking the transfer's real error with this one helps nobody.
85
+ }
86
+ }
87
+
88
+ const toSkip = (record: JobRecord): DrainSkip => ({
89
+ id: record.id,
90
+ name: record.name,
91
+ queue: record.queue,
92
+ state: record.state,
93
+ reason: NO_LEASE,
94
+ });
95
+
96
+ /**
97
+ * Move every pending job the drain can take a lease on from `source` onto `target`. `--dry-run`
98
+ * reports the candidate list, takes no lease and enqueues nothing. Per-record try/catch, not a
99
+ * batch operation: one record that cannot enqueue on the target (a redis/nats stub, a transient
100
+ * error) must not stop the rest from moving, and the caller reports each failure on its own.
101
+ */
102
+ export async function drainJobs(
103
+ source: JobDriver,
104
+ target: JobDriver,
105
+ dryRun: boolean,
106
+ ): Promise<DrainOutcome> {
107
+ const lists = await Promise.all(PENDING_STATES.map((state) => inspectJobList(source, { state })));
108
+ const candidates = lists.flat();
109
+ const base = { from: source.name, to: target.name, candidates };
110
+ if (dryRun) return { ...base, dryRun: true, moved: [], skipped: [], failures: [] };
111
+
112
+ const leased = await leaseCandidates(source, candidates);
113
+ const held = new Set(leased.map((record) => record.id));
114
+ const found = new Map(candidates.map((record) => [record.id, record]));
115
+ const skipped = candidates.filter((record) => !held.has(record.id)).map(toSkip);
116
+
117
+ const moved: JobRecord[] = [];
118
+ const failures: DrainFailure[] = [];
119
+ for (const record of leased) {
120
+ let enqueued = false;
121
+ try {
122
+ // Steps BEFORE the job: a worker on the target must never be able to claim a run whose
123
+ // checkpoint has not landed, or it repeats completed steps or loses them outright.
124
+ await copySteps(source, target, record.runId);
125
+ await target.enqueue({
126
+ name: record.name,
127
+ queue: record.queue,
128
+ input: record.input,
129
+ idempotencyKey: record.idempotencyKey,
130
+ runId: record.runId,
131
+ maxAttempts: record.maxAttempts,
132
+ runAt: record.runAt,
133
+ ...(record.tenantId === undefined ? {} : { tenantId: record.tenantId }),
134
+ });
135
+ enqueued = true;
136
+ // Only now is the ack the drain's to make: the lease proves no source worker holds this
137
+ // job, and the target already has both the row and its steps. A crash between the two
138
+ // leaves the job live on both drivers, where `idempotencyKey` dedupes it.
139
+ await source.ack(record.id);
140
+ // Report the row as the drain FOUND it: `claim()` returns it mid-lease (`running`, one
141
+ // attempt higher), a state nothing on either driver is in once this returns.
142
+ moved.push(found.get(record.id) ?? record);
143
+ } catch (error) {
144
+ // A failed enqueue left nothing on the target, so the lease goes back. A failed ack did
145
+ // not: the job is already live there, and releasing it would race the target's worker.
146
+ if (!enqueued) await releaseLease(source, record.id);
147
+ failures.push({ id: record.id, name: record.name, finding: findingFrom(error) });
148
+ }
149
+ }
150
+ return { ...base, dryRun: false, moved, skipped, failures };
151
+ }