@starci/hfs 4.1.0 → 4.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/lint/run.mjs +62 -22
- package/package.json +1 -1
- package/runtime/config.example.yaml +7 -0
- package/runtime/engine/config.mjs +4 -3
- package/runtime/engine/release-config.mjs +28 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
- package/runtime/knowledge/hfs/rules.yaml +63 -0
- package/runtime/modules/kernel/removed-vocabulary.yaml +100 -0
- package/runtime/modules/models/runtimes.yaml +38 -13
- package/runtime/modules/models/tiers.yaml +1 -0
- package/runtime/scripts/hfs/architecture/index.mjs +6 -2
- package/runtime/scripts/hfs/architecture/required-files.mjs +21 -2
- package/runtime/scripts/hfs/catalog-once.mjs +15 -0
- package/runtime/scripts/hfs/check.mjs +2 -1
- package/runtime/scripts/hfs/repo-identity.mjs +16 -2
- package/runtime/scripts/hfs/rule-catalog.mjs +2 -2
- package/runtime/scripts/hfs/slots.mjs +2 -2
- package/runtime/scripts/lib/env.mjs +10 -0
- package/runtime/scripts/lib/yaml-cached.mjs +14 -0
- package/templates/be/patterns/queues/platform/bullmq-queue-transport.client.spec.ts.tpl +3 -2
- package/templates/be/patterns/queues/platform/bullmq-queue-transport.client.ts.tpl +6 -1
- package/templates/be/patterns/queues/platform/queue-relay.service.spec.ts.tpl +1 -0
- package/templates/be/patterns/queues/platform/queue-transport.port.ts.tpl +2 -0
- package/templates/be/patterns/queues/platform/queue-worker.service.spec.ts.tpl +1 -0
- package/templates/be/patterns/queues/platform/queue.config.ts.tpl +1 -0
- package/templates/be/patterns/queues/platform/queue.options.ts.tpl +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.2.1 - 2026-10-10
|
|
4
|
+
|
|
5
|
+
- Fixed: queue templates preserve the declared Redis database for both Queue and Worker connections, with a nonzero-database adapter regression.
|
|
6
|
+
|
|
7
|
+
## 4.2.0 - 2026-10-09
|
|
8
|
+
|
|
9
|
+
- Changed: the lint runner (`lint/run.mjs`) and the bundled runtime copies follow the runtime of 1.0.0-alpha.9. Version 4.1.0 on the registry carries the copies of an earlier runtime.
|
|
10
|
+
|
|
3
11
|
## 4.1.0 - 2026-10-08
|
|
4
12
|
|
|
5
13
|
- Breaking (runtime alpha.4 CLI unification): remove the `hfs` bin and expose `main(argv, io)` for the sole `starci app ...` grammar; the app flags `--repo` and `--root` are replaced by the global `--cwd` flag.
|
package/lint/run.mjs
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import fs from 'node:fs';
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
import { createRequire } from 'node:module';
|
|
21
|
-
import {
|
|
21
|
+
import { spawn } from 'node:child_process';
|
|
22
22
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
23
23
|
import { linterReport, mergeReports, sonarReport, sourceRootsOf } from '../report/sonar.mjs';
|
|
24
24
|
import { SIDES, appRelativeMessages, loadSlotManifest, readRepoDeclaration } from '../runtime/scripts/hfs/slots.mjs';
|
|
@@ -66,8 +66,22 @@ export function parseLintArgs(argv) {
|
|
|
66
66
|
export const BOUND_ENV = 'STARCI_LINT_BOUND';
|
|
67
67
|
export const linterBoundArgs = () => ['--require', fileURLToPath(new URL('./bound-sys.cjs', import.meta.url))];
|
|
68
68
|
|
|
69
|
-
/**
|
|
70
|
-
function
|
|
69
|
+
/** The child `node` ran with `args` from `cwd`: its exit status, signal, spawn error and the text of both streams, once it closes. */
|
|
70
|
+
function runNode(args, options) {
|
|
71
|
+
return new Promise((resolve) => {
|
|
72
|
+
const child = spawn(process.execPath, args, { ...options, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
73
|
+
const out = [];
|
|
74
|
+
const err = [];
|
|
75
|
+
let failure;
|
|
76
|
+
child.stdout.on('data', (chunk) => out.push(chunk));
|
|
77
|
+
child.stderr.on('data', (chunk) => err.push(chunk));
|
|
78
|
+
child.on('error', (error) => { failure = error; });
|
|
79
|
+
child.on('close', (status, signal) => resolve({ status, signal, error: failure, stdout: Buffer.concat(out).toString('utf8'), stderr: Buffer.concat(err).toString('utf8') }));
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A linter's json results through the app's own install, run from `cwd` (a side folder): `{ results }` or `{ error }`. The child runs while the caller goes on. */
|
|
84
|
+
async function runLinter({ cwd, pkg, bin, args, bound = null }) {
|
|
71
85
|
let entry;
|
|
72
86
|
try {
|
|
73
87
|
const manifest = createRequire(path.join(cwd, 'package.json')).resolve(`${pkg}/package.json`);
|
|
@@ -76,7 +90,7 @@ function runLinter({ cwd, pkg, bin, args, bound = null }) {
|
|
|
76
90
|
} catch {
|
|
77
91
|
return { error: `${pkg} is not installed for ${cwd}` };
|
|
78
92
|
}
|
|
79
|
-
const run =
|
|
93
|
+
const run = await runNode([...(bound ? linterBoundArgs() : []), entry, ...args], { cwd, ...(bound ? { env: { ...process.env, [BOUND_ENV]: bound } } : {}) });
|
|
80
94
|
// ESLint prints its report on stdout; stylelint uses stderr. JSON does not prove a completed child: only exits 0/1
|
|
81
95
|
// without a spawn error or signal are measurable, and exit 1 must carry findings.
|
|
82
96
|
let results;
|
|
@@ -165,6 +179,29 @@ function checkedEslintResults({ results, expected, cwd, scope, changed }) {
|
|
|
165
179
|
return { results: valid, errors };
|
|
166
180
|
}
|
|
167
181
|
|
|
182
|
+
/** Starts the ESLint child of every side that has work and returns one record per side: skipped, a scope error, or the running child. */
|
|
183
|
+
async function launchEslint({ app, repoRoot, opts, changed, existing, workspace, onFe }) {
|
|
184
|
+
const launches = [];
|
|
185
|
+
for (const side of app ? SIDES : []) {
|
|
186
|
+
if (workspace !== null && side !== STYLE_SIDE) { launches.push({ side, skipped: `outside --workspace ${workspace}` }); continue; }
|
|
187
|
+
const sources = onSide(existing, side).filter((file) => /\.(?:[cm]?[jt]sx?)$/.test(file));
|
|
188
|
+
if (changed !== null && !sources.length) { launches.push({ side, skipped: 'no changed source file' }); continue; }
|
|
189
|
+
const cwd = path.join(repoRoot, side);
|
|
190
|
+
let expected;
|
|
191
|
+
try { expected = changed ? sources : await sourceFiles({ repoRoot, cwd, side, scope: onFe }); }
|
|
192
|
+
catch (error) { launches.push({ side, scopeError: `eslint source scope is unavailable in ${side}/: ${String(error?.message ?? error)}` }); continue; }
|
|
193
|
+
launches.push({ side, cwd, expected, run: runLinter({ cwd, pkg: 'eslint', bin: 'eslint', args: ['--format', 'json', ...(opts.fix ? ['--fix'] : []), ...(changed ? sources : [onFe ?? '.'])], bound: repoRoot }) });
|
|
194
|
+
}
|
|
195
|
+
return launches;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Starts stylelint over the fe stylesheets (the running child), or returns null when no stylesheet is in scope. */
|
|
199
|
+
function launchStylelint({ repoRoot, opts, changed, existing, onFe }) {
|
|
200
|
+
const styles = onSide(existing, STYLE_SIDE).filter((file) => file.endsWith('.css'));
|
|
201
|
+
if (changed !== null && !styles.length) return null;
|
|
202
|
+
return runLinter({ cwd: path.join(repoRoot, STYLE_SIDE), pkg: 'stylelint', bin: 'stylelint', args: [...(changed ? styles : [onFe ? `${onFe}/src/**/*.css` : STYLE_GLOB]), '--formatter', 'json', ...(opts.fix ? ['--fix'] : [])] });
|
|
203
|
+
}
|
|
204
|
+
|
|
168
205
|
/**
|
|
169
206
|
* Run the whole lint of the app at `repoRoot`. `hfsCheck(repoRoot)` returns the `starci app check` result (`{ findings, tracked }`); the CLI
|
|
170
207
|
* injects it. Returns `{ report, sonar, exit }`; `sonar` is the merged Generic Issue Import document.
|
|
@@ -191,22 +228,24 @@ export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles =
|
|
|
191
228
|
/** The workspace folder relative to the fe side folder (apps/<app> or packages/<pkg>). */
|
|
192
229
|
const onFe = workspace === null ? null : workspace.slice(STYLE_SIDE.length + 1);
|
|
193
230
|
|
|
231
|
+
// The linters and the app check are independent: every child starts first, the app check runs while they work, and the results are read in a fixed order.
|
|
232
|
+
const eslintLaunches = await launchEslint({ app, repoRoot, opts, changed, existing, workspace, onFe });
|
|
233
|
+
const styleLaunch = app ? launchStylelint({ repoRoot, opts, changed, existing, onFe }) : null;
|
|
234
|
+
const checkRun = app ? Promise.resolve().then(() => hfsCheck(repoRoot)).then((value) => ({ value }), (error) => ({ error })) : null;
|
|
235
|
+
|
|
194
236
|
// 1. ESLint, once per side, from the side folder with that side's config.
|
|
195
237
|
engines.eslint = { sides: {} };
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const
|
|
199
|
-
if (
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
try { expected = changed ? sources : await sourceFiles({ repoRoot, cwd, side, scope: onFe }); }
|
|
203
|
-
catch (error) { errors.push(`eslint source scope is unavailable in ${side}/: ${String(error?.message ?? error)}`); engines.eslint.sides[side] = { files: 0 }; continue; }
|
|
204
|
-
const linted = runLinter({ cwd, pkg: 'eslint', bin: 'eslint', args: ['--format', 'json', ...(opts.fix ? ['--fix'] : []), ...(changed ? sources : [onFe ?? '.'])], bound: repoRoot });
|
|
238
|
+
const eslintRuns = await Promise.all(eslintLaunches.map((launch) => launch.run ?? null));
|
|
239
|
+
eslintLaunches.forEach((launch, at) => {
|
|
240
|
+
const { side } = launch;
|
|
241
|
+
if (launch.skipped) { engines.eslint.sides[side] = { files: 0, skipped: launch.skipped }; return; }
|
|
242
|
+
if (launch.scopeError) { errors.push(launch.scopeError); engines.eslint.sides[side] = { files: 0 }; return; }
|
|
243
|
+
const linted = eslintRuns[at];
|
|
205
244
|
if (linted.error) errors.push(linted.error);
|
|
206
245
|
if (linted.results) {
|
|
207
|
-
const
|
|
208
|
-
errors.push(...
|
|
209
|
-
linted.results =
|
|
246
|
+
const checkedResults = checkedEslintResults({ results: linted.results, expected: launch.expected, cwd: launch.cwd, scope: onFe, changed: changed !== null });
|
|
247
|
+
errors.push(...checkedResults.errors);
|
|
248
|
+
linted.results = checkedResults.results;
|
|
210
249
|
// The side canon names side-relative paths in its messages; the report names every path from the app root.
|
|
211
250
|
const appRelative = appRelativeMessages(side, path.join(repoRoot, side));
|
|
212
251
|
for (const result of linted.results) for (const message of result.messages ?? []) message.message = appRelative(message.message);
|
|
@@ -214,13 +253,12 @@ export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles =
|
|
|
214
253
|
findings.push(...eslintFindings(linted.results, repoRoot));
|
|
215
254
|
}
|
|
216
255
|
engines.eslint.sides[side] = { files: linted.results?.length ?? 0 };
|
|
217
|
-
}
|
|
256
|
+
});
|
|
218
257
|
|
|
219
258
|
// 3. stylelint over the fe side's stylesheets.
|
|
220
259
|
if (app) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const linted = runLinter({ cwd: path.join(repoRoot, STYLE_SIDE), pkg: 'stylelint', bin: 'stylelint', args: [...(changed ? styles : [onFe ? `${onFe}/src/**/*.css` : STYLE_GLOB]), '--formatter', 'json', ...(opts.fix ? ['--fix'] : [])] });
|
|
260
|
+
if (styleLaunch) {
|
|
261
|
+
const linted = await styleLaunch;
|
|
224
262
|
if (linted.error) errors.push(linted.error);
|
|
225
263
|
if (linted.results) {
|
|
226
264
|
const appRelative = appRelativeMessages(STYLE_SIDE, path.join(repoRoot, STYLE_SIDE));
|
|
@@ -234,8 +272,10 @@ export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles =
|
|
|
234
272
|
|
|
235
273
|
// 2. The app check: root and sides.
|
|
236
274
|
let checked = { findings: [] };
|
|
237
|
-
if (
|
|
238
|
-
|
|
275
|
+
if (checkRun) {
|
|
276
|
+
const settled = await checkRun;
|
|
277
|
+
if (settled.error) errors.push(`starci app check could not run: ${String(settled.error?.message ?? settled.error)}`);
|
|
278
|
+
else checked = settled.value;
|
|
239
279
|
}
|
|
240
280
|
const repoErrors = checked.findings.filter((finding) => finding.level === 'error');
|
|
241
281
|
// A workspace lint keeps only the app findings inside the workspace; the rest belong to the root lint of the whole app.
|
package/package.json
CHANGED
|
@@ -185,3 +185,10 @@ sonar: {organization: null}
|
|
|
185
185
|
# minFreeRamPct free-RAM percentage below which no new heavy op starts
|
|
186
186
|
# set them only in the gitignored config.yaml copy, e.g.
|
|
187
187
|
# resources: {minFreeDiskGb: 5, minFreeDiskPct: 1, minFreeRamPct: 10}
|
|
188
|
+
# release — where the full test suite of a release is judged (scripts/guards/release-suite-mode.mjs; docs/releasing.md "Where the suite runs"). One key:
|
|
189
|
+
# suite: local the shipped default: `starci release cut` runs the root suite and the Linux parity container before it pushes, and the pre-push gate asks for their green rows
|
|
190
|
+
# suite: ci the owner's recorded choice (2026-10-09): no local full suite and no Linux container; the GitHub ci workflow runs the suite (with coverage, Codecov, SonarCloud) AFTER the push.
|
|
191
|
+
# The cut still runs npm run check, npm run test:packages, the specs affected by the release, the live Orca smokes and the example apps; the record lists the root suite as
|
|
192
|
+
# delegated, never green. A release can reach GitHub and its tag before the suite has run; a red CI is fixed forward with the next pre-release (starci release ci-status).
|
|
193
|
+
# "none" is refused: the suite does not vanish, it moves to CI. Set it only in the gitignored config.yaml copy: release: {suite: ci}
|
|
194
|
+
release: {suite: local}
|
|
@@ -6,6 +6,7 @@ import {parseYaml} from './yaml.mjs';
|
|
|
6
6
|
import {isPlainObject as plain} from './plain-object.mjs';
|
|
7
7
|
import {invalid,validateRoots,ROOT_KEYS} from './invalid-config.mjs';
|
|
8
8
|
import {validateSonar} from './sonar-config.mjs';
|
|
9
|
+
import {validateRelease,RELEASE_KEYS} from './release-config.mjs';
|
|
9
10
|
import {validateOrca,ORCA_KEYS} from './orca-config.mjs';
|
|
10
11
|
import {validateResources,RESOURCE_KEYS} from './resources-config.mjs';
|
|
11
12
|
import {ENV_NAME,secretEnv,connectorSecret} from './secrets.mjs';
|
|
@@ -242,7 +243,7 @@ export function uatSettings(config=loadConfig()){
|
|
|
242
243
|
const GRANT=/^([a-z0-9][a-z0-9.-]*)=(\d+)@([a-z]+(?:\+[a-z]+)*)$/;
|
|
243
244
|
/** One grant string `<pool>=<slots>@<role>+<role>` as {pool, slots, roles}, or null when it is not that shape. */
|
|
244
245
|
/** The closed set of top-level config.yaml blocks: the validator accepts these and refuses every other key. */
|
|
245
|
-
export const CONFIG_BLOCKS=Object.freeze(['language','model','effort','models','allocation','kernel','budgets','supervisor','parallel','delegation','connectors','asks','uat','specs','reconciler','debugLoop','orca','roots','resources','sonar','launchTrust','retention']);
|
|
246
|
+
export const CONFIG_BLOCKS=Object.freeze(['language','model','effort','models','allocation','kernel','budgets','supervisor','parallel','delegation','connectors','asks','uat','specs','reconciler','debugLoop','orca','roots','resources','sonar','release','launchTrust','retention']);
|
|
246
247
|
export function parseAllocationGrant(text){
|
|
247
248
|
const m=typeof text==='string'?GRANT.exec(text.trim()):null;
|
|
248
249
|
return m?{pool:m[1],slots:Number(m[2]),roles:m[3].split('+')}:null;
|
|
@@ -323,7 +324,7 @@ const validateEarlyConfigBlocks=(config)=>{
|
|
|
323
324
|
if(config?.launchTrust!==undefined){launchTrustSettings(config);} if(config?.retention!==undefined){workflowPurgeSettings(config);}
|
|
324
325
|
if(config?.connectors!==undefined){validateConnectors(config.connectors);} if(config?.asks!==undefined){validateAsks(config.asks);}
|
|
325
326
|
if(config?.uat!==undefined){validateUat(config.uat);} if(config?.debugLoop!==undefined){validateDebugLoop(config.debugLoop);}
|
|
326
|
-
if(config?.orca!==undefined){validateOrca(config.orca);} if(config?.roots!==undefined){validateRoots(config.roots);} if(config?.resources!==undefined){validateResources(config.resources);} if(config?.sonar!==undefined){validateSonar(config.sonar);}
|
|
327
|
+
if(config?.orca!==undefined){validateOrca(config.orca);} if(config?.roots!==undefined){validateRoots(config.roots);} if(config?.resources!==undefined){validateResources(config.resources);} if(config?.sonar!==undefined){validateSonar(config.sonar);} if(config?.release!==undefined){validateRelease(config.release);}
|
|
327
328
|
};
|
|
328
329
|
const validateConfigBlocks=(config,knownProviders,runtimes,profile)=>{
|
|
329
330
|
// specs (owner 2026-09-28): {harness?, unit?, e2e?} booleans - each family a boolean; absent = its default (SPEC_DEFAULTS: harness off, unit on, e2e off; specsSettings).
|
|
@@ -416,7 +417,7 @@ export const CONFIG_KEY_TREE=Object.freeze({
|
|
|
416
417
|
supervisor:Object.freeze(['mode','pollIntervalMs','repos','stallMinutes','kernel','workers','landGate','frozenMinutes']),
|
|
417
418
|
delegation:Object.freeze(['asks','until','excludes','note']),asks:Object.freeze(['autoAcceptRecommended','excludes']),uat:Object.freeze(['maxConcurrent']),
|
|
418
419
|
specs:SPEC_FAMILIES,reconciler:Object.freeze(['enabled','profile','controllers']),debugLoop:Object.freeze(['interval','worktreeLimit']),
|
|
419
|
-
orca:ORCA_KEYS,roots:ROOT_KEYS,resources:RESOURCE_KEYS,
|
|
420
|
+
orca:ORCA_KEYS,roots:ROOT_KEYS,resources:RESOURCE_KEYS,release:RELEASE_KEYS,
|
|
420
421
|
});
|
|
421
422
|
export const SPEC_DEFAULTS=Object.freeze({harness:false,unit:true,e2e:false});
|
|
422
423
|
export function specsSettings(config){const specs=plain(config?.specs)?config.specs:{};return Object.fromEntries(SPEC_FAMILIES.map(key=>[key,typeof specs[key]==='boolean'?specs[key]:SPEC_DEFAULTS[key]]));}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {isPlainObject as plain} from './plain-object.mjs';
|
|
2
|
+
import {invalid} from './invalid-config.mjs';
|
|
3
|
+
|
|
4
|
+
/** The keys of the release block. */
|
|
5
|
+
export const RELEASE_KEYS=Object.freeze(['suite']);
|
|
6
|
+
/** Where the full test suite of a release is judged: `local` (the release cut runs it, the shipped default) or `ci` (the GitHub workflow runs it after the push; the cut runs only the affected specs of the release and the rows CI cannot run). */
|
|
7
|
+
export const SUITE_MODES=Object.freeze(['local','ci']);
|
|
8
|
+
export const RELEASE_DEFAULTS=Object.freeze({suite:'local'});
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* config.yaml `release` - {suite?: local | ci}: who judges the full suite of a release (docs/releasing.md "Where the suite runs"). `local` is the shipped
|
|
12
|
+
* default: `starci release cut` runs the root suite and the Linux parity container before it pushes. `ci` is the owner's recorded choice (2026-10-09) that
|
|
13
|
+
* the full suite runs on GitHub only: the cut runs no root suite and no Linux container, and the CI workflow's verdict is read afterwards (`starci release ci-status`).
|
|
14
|
+
* `none` is refused: the suite does not vanish, it moves to CI, and the value says so.
|
|
15
|
+
*/
|
|
16
|
+
export function validateRelease(release){
|
|
17
|
+
if(release===null)return;
|
|
18
|
+
const bad=invalid('release');
|
|
19
|
+
if(!plain(release))bad(` must be {suite?: ${SUITE_MODES.join(' | ')}} or null.`);
|
|
20
|
+
for(const key of Object.keys(release))if(!RELEASE_KEYS.includes(key))bad(` has unknown key ${key} (allowed: ${RELEASE_KEYS.join(', ')}).`);
|
|
21
|
+
const value=release.suite;
|
|
22
|
+
if(value===undefined||value===null)return;
|
|
23
|
+
if(value==='none')bad('.suite: "none" is not a mode: write "ci" (no local full suite, CI judges) or "local".');
|
|
24
|
+
if(!SUITE_MODES.includes(value))bad(`.suite must be ${SUITE_MODES.join(' or ')}, or null.`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The suite mode a validated owner config names; an absent block or key is the shipped default. */
|
|
28
|
+
export const releaseSuiteMode=config=>config?.release?.suite??RELEASE_DEFAULTS.suite;
|
|
@@ -29,14 +29,14 @@ pins:
|
|
|
29
29
|
source: packages/grammar/package.json
|
|
30
30
|
why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind and the IconButton disclosure props. The runtime source version wins for a @starci package.'
|
|
31
31
|
'@starci/eslint-canon-be':
|
|
32
|
-
version: 3.1
|
|
32
|
+
version: 3.2.1
|
|
33
33
|
group: starci
|
|
34
34
|
install: registry
|
|
35
35
|
side: be
|
|
36
36
|
source: packages/eslint/be/package.json
|
|
37
37
|
why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
|
|
38
38
|
'@starci/eslint-canon-fe':
|
|
39
|
-
version: 8.1
|
|
39
|
+
version: 8.2.1
|
|
40
40
|
group: starci
|
|
41
41
|
install: registry
|
|
42
42
|
side: fe
|
|
@@ -76,14 +76,14 @@ pins:
|
|
|
76
76
|
source: packages/test-world/package.json
|
|
77
77
|
why: 'the shared e2e library of every back end (R47, R48), a devDependency of every back end: the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs and the outage lock. Kafka is real own infra (the one digest-pinned apache/kafka KRaft image, a broker listener and proxy per slot, slot-prefixed topics, consumer groups and client ids) and Postgres is schema-per-context (a schema and a login role per context in a shared database). Each worker has its own data slot (<ns>_w<k>) with its own databases, Keycloak realm, leased Redis DB, bucket/collection/topic prefixes, fakes host, toxiproxy proxies and outage lock; a run provisions min(jest workers, workers cap, default 2) slots, and state file protocol 2 pairs exactly with @starci/jest-preset (TEST_WORLD_PAIR_MISMATCH otherwise). A modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client, world.keycloak.events/sessions read the realm user events and live sessions, world.infra.postgresql.connection(name) takes one database connection down while the others serve, and world.resolve and scope.resolve take any Nest token. Every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json); the globalSetup registers the path aliases as TypeScript resolves them. Its CLI main is exported at ./cli for starci app stack.'
|
|
78
78
|
'@starci/cli':
|
|
79
|
-
version: 1.1
|
|
79
|
+
version: 1.2.1
|
|
80
80
|
group: starci
|
|
81
81
|
install: registry
|
|
82
82
|
side: both
|
|
83
83
|
source: packages/cli/package.json
|
|
84
84
|
why: 'the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
|
|
85
85
|
'@starci/hfs':
|
|
86
|
-
version: 4.1
|
|
86
|
+
version: 4.2.1
|
|
87
87
|
group: starci
|
|
88
88
|
install: registry
|
|
89
89
|
side: both
|
|
@@ -2090,3 +2090,66 @@ rules:
|
|
|
2090
2090
|
- {kind: eslint-fe, id: no-unused-import}
|
|
2091
2091
|
- {kind: eslint-fe, id: prefer-export-from}
|
|
2092
2092
|
- {kind: eslint-fe, id: no-unused-prop-types}
|
|
2093
|
+
- id: R236
|
|
2094
|
+
code: "RT_GATE_LOOSENING"
|
|
2095
|
+
law: "A change that loosens a gate or check is owner-class: a check removed from a gate file's list, a floor lowered or a ceiling raised there, an allowlist entry added, a spec deleted or weakened or skipped together with product code (modules/kernel/gate-loosening.yaml). It lands, and passes the check, only when the tree it is judged against already holds the owner's approval of exactly that loosening, an owner-rulings entry gate-loosening-<fingerprint>."
|
|
2096
|
+
scope: runtime
|
|
2097
|
+
kinds: [check]
|
|
2098
|
+
gates: [land, runtime]
|
|
2099
|
+
failureCodes: ["RT_GATE_LOOSENING"]
|
|
2100
|
+
enforcers:
|
|
2101
|
+
- {kind: runtime, id: gate-loosening, at: scripts/checks/check-gate-loosening.mjs}
|
|
2102
|
+
- id: R237
|
|
2103
|
+
code: "RT_REVISION_SCOPE"
|
|
2104
|
+
law: "What a change of the runtime tree asks of each role is declared once, in modules/kernel/revision-scope.yaml: every tracked path matches a row, the engine-loaded files are derived from the import graph of the engine's entries and never hand-listed, the files each seat's launch prompt is built from are the files its generator reads, and a wording-only declaration holds only for an edit that keeps the file's structure."
|
|
2105
|
+
scope: runtime
|
|
2106
|
+
kinds: [check]
|
|
2107
|
+
gates: [land, runtime]
|
|
2108
|
+
failureCodes: ["RT_REVISION_SCOPE"]
|
|
2109
|
+
enforcers:
|
|
2110
|
+
- {kind: runtime, id: revision-scope, at: scripts/checks/check-revision-scope.mjs}
|
|
2111
|
+
- id: R238
|
|
2112
|
+
code: "RT_EVENT_SPILL_BYPASS"
|
|
2113
|
+
law: "A ledger event payload that grows with its input (a file list, a manifest, a digest, a report, a critique, a menu) goes to the blob store through the one path of appendEvent: engine/db/event-compact.mjs eventPayloadRecord keeps a bounded inline view of at most 16 KiB and the whole payload behind events.payload_sha, and readers resolve it through engine/db/event-payload.mjs. No runtime source file other than engine/db/ledger.mjs and engine/db/event-compact.mjs passes a payloadSha to a write, catches the payload-too-large refusal to spill by hand, or inserts into the events table directly. Specs, tests, node_modules, the generated runtime copies and packages are out of scope."
|
|
2114
|
+
scope: runtime
|
|
2115
|
+
kinds: [check]
|
|
2116
|
+
gates: [land, runtime]
|
|
2117
|
+
failureCodes: ["RT_EVENT_SPILL_BYPASS"]
|
|
2118
|
+
enforcers:
|
|
2119
|
+
- {kind: runtime, id: event-spill, at: scripts/checks/check-event-spill.mjs}
|
|
2120
|
+
- id: R239
|
|
2121
|
+
code: "RT_STORAGE_UNDECLARED"
|
|
2122
|
+
law: "Content lives as a file in the content-addressed blob store under the runtime state dir (<runtime root>/.runtime/artifacts, engine/db/blob.mjs) and a database row holds only the reference (the sha) plus the small scalars it is queried by (owner ruling 2026-10-09). Always content, whatever its size: a report, a critique or verdict, a prompt or dispatch contract, a read manifest, a list of files, a log or command output, a menu or digest snapshot, an image or render, a diff. What may stay inline: ids, states, timestamps, counts, short codes and one-line reasons up to 256 bytes; any other free text up to 1024 bytes; a payload of any size is class spill (appendEvent and supEvent keep the whole payload behind the sha and a small inline form). Every text column of the product ledger and of machine.sqlite is declared in modules/schemas/storage-convention.yaml with its class (scalar, bounded, reference, spill, migrate), so a new table or column cannot appear without one; a column that holds content inline today is class migrate with its measured size, and the migration to a reference is a proposed runtime step, idempotent and journalled, never a hand rewrite of a live store."
|
|
2123
|
+
scope: runtime
|
|
2124
|
+
kinds: [check]
|
|
2125
|
+
gates: [land, runtime]
|
|
2126
|
+
failureCodes: ["RT_STORAGE_UNDECLARED"]
|
|
2127
|
+
enforcers:
|
|
2128
|
+
- {kind: runtime, id: storage-convention, at: scripts/checks/check-storage-convention.mjs}
|
|
2129
|
+
- id: R240
|
|
2130
|
+
code: "RT_ROOT_ADHOC"
|
|
2131
|
+
law: "Which directory a component reads or writes is decided in one module, scripts/lib/roots.mjs: the runtime tree, its state directory, the temp root, the invocation directory, the Work directory name, the order in which two trees that can hold the same record are tried (the tree the work happens in first, then the product's main checkout: workDirHolding, workRecordPath) and the order a tool is looked up (the tree the work happens in, then the project, the runtime's own install last: toolSearchDirs). A runtime source file does not resolve a root by itself: it neither spells process.cwd() nor the Work directory name as a literal outside that module more often than the tree the check landed on held it: a file keeps the count it had and only loses it as it is converted, and a new file starts at none. Four times in two days a component read or wrote the wrong tree because it resolved the directory itself."
|
|
2132
|
+
scope: runtime
|
|
2133
|
+
kinds: [check]
|
|
2134
|
+
gates: [land, runtime]
|
|
2135
|
+
failureCodes: ["RT_ROOT_ADHOC"]
|
|
2136
|
+
enforcers:
|
|
2137
|
+
- {kind: runtime, id: root-adhoc, at: scripts/checks/check-root-adhoc.mjs}
|
|
2138
|
+
- id: R241
|
|
2139
|
+
code: "RT_HELD_UNSURFACED"
|
|
2140
|
+
law: "A failure code the catalogue gives to the Supervisor or the owner as a runtime fault is a state that role must be able to learn of; a stop that nobody is told of stands for hours until someone reads source. Each such entry of modules/kernel/failure-codes.yaml names the house mechanism that reaches its owner (surfacedBy: decision-item, sla-clock, incident, digest, notifier, caller or open) with surfacedAs (the item kind, clock code, incident kind, digest problem line or urgent class) and surfacedAt (the file that does it), and the check proves the mechanism exists and is wired in that file: the kind is a Decision Item kind and the file opens items naming it, the code is an SLA code with an owner and the file sets its clock, the problem line is a departure of the operating standard, a caller is a verb the owning role ran itself and never a reconciler file (a loop is no owner). An entry nothing reaches is surfacedBy open and names an open edge-case registry entry with the exact reason; it is never exempted silently."
|
|
2141
|
+
scope: runtime
|
|
2142
|
+
kinds: [check]
|
|
2143
|
+
gates: [land, runtime]
|
|
2144
|
+
failureCodes: ["RT_HELD_UNSURFACED"]
|
|
2145
|
+
enforcers:
|
|
2146
|
+
- {kind: runtime, id: held-surfacing, at: scripts/checks/check-held-surfacing.mjs}
|
|
2147
|
+
- id: R242
|
|
2148
|
+
code: "RT_OP_JUDGE_UNDECLARED"
|
|
2149
|
+
law: "Every op declares who judges its product (principle P2: the maker never judges its own work). The contract of each op (modules/ops/ops/<op>.yaml) carries the required field `judge`, a list of at least one entry, each with a one-line why: machine (independent measures the runtime owns or re-runs at settle, named from modules/kernel/op-judges.yaml, and the contract proofs they rely on; a test file the same attempt wrote is never one by itself), critic (the independent Critic and its rubric, declared exactly for the op kinds that modules/kernel/critic.yaml coverage marks covered), owner (only where modules/kernel/op-judges.yaml ownerGates holds an owner gate) and next-leg (a registered reviewing leg whose contract is to review this product). Nobody cannot be declared. The registry and the contracts are checked against the runtime's own tables (op-gate and sonar-gate enforcedOps, opProofs, proof media, the Critic coverage), so the declarations and what the runtime does cannot drift apart. Surfaced in the full status text of a leg and in the op prompt."
|
|
2150
|
+
scope: runtime
|
|
2151
|
+
kinds: [check]
|
|
2152
|
+
gates: [land, runtime]
|
|
2153
|
+
failureCodes: ["RT_OP_JUDGE_UNDECLARED"]
|
|
2154
|
+
enforcers:
|
|
2155
|
+
- {kind: runtime, id: op-judge, at: scripts/checks/check-op-judge.mjs}
|
|
@@ -154,6 +154,58 @@ removed:
|
|
|
154
154
|
use: 'skills/starci/references/host-startup.md'
|
|
155
155
|
since: 1.0.0-alpha.6
|
|
156
156
|
match: '(?<![\w-])host-maintenance\b'
|
|
157
|
+
- id: config-drawloop-critic
|
|
158
|
+
kind: config-key
|
|
159
|
+
name: allocation.drawLoop.critic
|
|
160
|
+
use: 'the Critic is picked from its tier (modules/models/tiers.yaml seats.critic, modules/kernel/critic.yaml) with provider independence as a hard filter; its wall bound is allocation.drawLoop.criticTimeoutMs'
|
|
161
|
+
since: 1.0.0-alpha.9
|
|
162
|
+
match: 'allocation\.drawLoop\.critic(?![A-Za-z])'
|
|
163
|
+
- id: config-drawloop-criticwhendrawer
|
|
164
|
+
kind: config-key
|
|
165
|
+
name: allocation.drawLoop.criticWhenDrawer
|
|
166
|
+
use: 'the Critic tier holds a member of every provider and the picker removes the provider of the maker; there is no per-drawer table'
|
|
167
|
+
since: 1.0.0-alpha.9
|
|
168
|
+
match: 'criticWhenDrawer'
|
|
169
|
+
- id: registry-critic-coverage-define-architecture
|
|
170
|
+
kind: vocabulary
|
|
171
|
+
name: critic-coverage-define-architecture
|
|
172
|
+
use: 'the edge-case registry entry is critic-coverage-decision-legs'
|
|
173
|
+
since: 1.0.0-alpha.9
|
|
174
|
+
- id: supervisor-grammar-release
|
|
175
|
+
kind: vocabulary
|
|
176
|
+
name: grammarRelease
|
|
177
|
+
use: 'the owner publishes @starci/grammar through starci release publish; the Supervisor changes no runtime code'
|
|
178
|
+
since: 1.0.0-alpha.9
|
|
179
|
+
- id: supervise-yaml-worker-land-blocks
|
|
180
|
+
kind: vocabulary
|
|
181
|
+
name: supervise.yaml workers
|
|
182
|
+
use: 'the Supervisor spawns no fix workers and lands nothing: it answers its menu (starci supervisor status) and records runtime defects for Debug'
|
|
183
|
+
since: 1.0.0-alpha.9
|
|
184
|
+
match: 'supervise\.yaml\s+(?:workers|landGate)\b|supervise\.yaml\s+kernelSeat/workers'
|
|
185
|
+
- id: supervisor-fixes-through-lanes
|
|
186
|
+
kind: vocabulary
|
|
187
|
+
name: the Supervisor fixes through lanes
|
|
188
|
+
use: 'the Supervisor records a runtime defect (starci supervisor actions record --item runtime-defect:<cause>) and Debug changes .claude'
|
|
189
|
+
since: 1.0.0-alpha.9
|
|
190
|
+
match: 'Supervisor[^\n.]{0,60}\bfix(?:es)?\s+through\s+(?:its\s+own\s+)?(?:staged\s+)?lanes?\b'
|
|
191
|
+
- id: kernel-ranked-actions
|
|
192
|
+
kind: vocabulary
|
|
193
|
+
name: ranked actions
|
|
194
|
+
use: "the Kernel's menu: the Decide section of starci kernel status, answered with starci kernel decide"
|
|
195
|
+
since: 1.0.0-alpha.9
|
|
196
|
+
match: '[Ww]ork the ranked actions|[Ff]irst rca\.actions entry'
|
|
197
|
+
- id: kernel-run-next-actions
|
|
198
|
+
kind: vocabulary
|
|
199
|
+
name: run nextActions
|
|
200
|
+
use: "the Kernel answers the items of its menu; the runtime performs the mechanical next actions"
|
|
201
|
+
since: 1.0.0-alpha.9
|
|
202
|
+
match: 'run nextActions|run each\s+in order and never choose'
|
|
203
|
+
- id: event-op-reported
|
|
204
|
+
kind: vocabulary
|
|
205
|
+
name: op-reported
|
|
206
|
+
use: 'the event a filed report records is report-filed; no event named op-reported was ever emitted'
|
|
207
|
+
since: 1.0.0-alpha.9
|
|
208
|
+
match: '(?<![\w-])op-reported(?![\w-])'
|
|
157
209
|
- id: file-sonar-secrets-keys
|
|
158
210
|
kind: file
|
|
159
211
|
name: KEYS.md
|
|
@@ -166,3 +218,51 @@ removed:
|
|
|
166
218
|
use: 'the examples are analysed on SonarCloud with the SONAR_TOKEN of the runtime secret.env (runtimeSecrets in their services.sonar declaration)'
|
|
167
219
|
since: 1.0.0-alpha.7
|
|
168
220
|
match: 'sonarqube-(?:lite-app|shape-slot|starci-ecommerce-app)-token'
|
|
221
|
+
- id: vocab-menu-kinds-moved-to-controllers
|
|
222
|
+
kind: vocabulary
|
|
223
|
+
name: the menu kinds of the origins the controllers now move
|
|
224
|
+
use: 'the Job controller enqueues a red node, a stale proof or attempt, the asset leg and the credential ask itself (scripts/kernel/next-moves.mjs); the Kernel menu keeps leg-ready, draw-redraw, seam-duty and move-unbuilt'
|
|
225
|
+
since: 1.0.0-alpha.9
|
|
226
|
+
match: '(?<![\w-])(?:node-rework|stale-redispatch|enqueue-asset-leg|enqueue-credential-ask|rework-node)(?![\w-])'
|
|
227
|
+
- id: vocab-sonar-stack-sealed-members
|
|
228
|
+
kind: vocabulary
|
|
229
|
+
name: ext/sonar secrets custody
|
|
230
|
+
use: 'the stack secrets are the secret.env variables SONARQUBE_DB_PASSWORD, SONARQUBE_ADMIN_PASSWORD, SONARQUBE_ADMIN_TOKEN and CLOUDFLARE_TUNNEL_TOKEN, passed by starci gate sonar up; ext/sonar tracks no sealed member'
|
|
231
|
+
since: 1.0.0-alpha.9
|
|
232
|
+
match: 'ext/sonar/secrets/(?:sonarqube-(?:db|admin|analysis)-(?:password|token)|cloudflare-starci-local-services-tunnel-token)'
|
|
233
|
+
- id: vocab-ecommerce-sealed-demo-secrets
|
|
234
|
+
kind: vocabulary
|
|
235
|
+
name: ecommerce sealed demo secrets
|
|
236
|
+
use: 'the ecommerce example carries demo-only defaults in its compose files and declares no sealed secret (secrets: [])'
|
|
237
|
+
since: 1.0.0-alpha.9
|
|
238
|
+
match: '(?<![\w-])(?:keycloak|minio)-env(?:\.enc)?(?![\w-])'
|
|
239
|
+
- id: vocab-handover-step-mechanical-pending
|
|
240
|
+
kind: vocabulary
|
|
241
|
+
name: the handover-step menu mode mechanical-pending and its choice rerun-handover-review
|
|
242
|
+
use: 'the Job controller enqueues handover.review itself when the handover is due or its ask was answered approve or question (next action handover-review, scripts/kernel/handover-move.mjs); the handover-step item holds only the typed routing of a reported defect'
|
|
243
|
+
since: 1.0.0-alpha.9
|
|
244
|
+
match: '(?<![\w-])(?:mechanical-pending|rerun-handover-review)(?![\w-])'
|
|
245
|
+
- id: vocab-card-delivery-bound
|
|
246
|
+
kind: vocabulary
|
|
247
|
+
name: the agent card delivery keys maxInlineChars, fileName and fileDirectory
|
|
248
|
+
use: 'one bound, one directory and one cleanup serve every card: allocation.promptFile in modules/models/runtimes.yaml (scripts/agent/prompt-file.mjs); a card keeps delivery.mode and delivery.prompt'
|
|
249
|
+
since: 1.0.0-alpha.9
|
|
250
|
+
match: '(?<![\w-])(?:maxInlineChars|fileDirectory)(?![\w-])'
|
|
251
|
+
- id: vocab-backoff-factor-keys
|
|
252
|
+
kind: vocabulary
|
|
253
|
+
name: the growth factor of allocation.dispatchRefusal and of the backoff of modules/reconciler/host.yaml
|
|
254
|
+
use: 'every backoff is the one retry budget (scripts/lib/retry-budget.mjs), which doubles its interval up to a cap: declare baseMs/capMs or minMs/maxMs only'
|
|
255
|
+
since: 1.0.0-alpha.9
|
|
256
|
+
match: '(?<![\w-])(?:dispatchRefusal|backoff):\s*\{[^}\n]*\bfactor\b'
|
|
257
|
+
- id: vocab-wake-terminal-bound
|
|
258
|
+
kind: vocabulary
|
|
259
|
+
name: the independent wake character bound
|
|
260
|
+
use: 'allocation.promptFile.maxChars is the terminal bound owned by scripts/agent/prompt-file.mjs'
|
|
261
|
+
since: 1.0.0-alpha.9
|
|
262
|
+
match: '(?<![\w-])(?:allocation\.)?wake\.maxChars|\bwake:\s*\{\s*maxChars\b'
|
|
263
|
+
- id: vocab-draft-probe-mutations
|
|
264
|
+
kind: vocabulary
|
|
265
|
+
name: draft probe mutation fields
|
|
266
|
+
use: 'probeDraft reads only and returns verdict, draft and sends; a foreign draft receives no keys'
|
|
267
|
+
since: 1.0.0-alpha.9
|
|
268
|
+
match: '\b(?:draftProbe|probeDraft)\.(?:restore|restored|removed|after)\b'
|
|
@@ -93,9 +93,20 @@ allocation:
|
|
|
93
93
|
# the decision doorbell; still the same turn turnInterruptGraceMs later, the seat is replaced (KERNEL_TURN_OVERDUE,
|
|
94
94
|
# scripts/reconciler/controllers/host.mjs turnStep). A busy turn defers every doorbell and notice (kernel-busy).
|
|
95
95
|
liveness: {activeUnclassifiedMs: 120000, activeStaleMs: 600000, quietMs: 1200000, launchGraceMs: 90000,
|
|
96
|
-
kernelTurnBudgetMs: 1200000, supervisorTurnBudgetMs: 1800000, turnInterruptGraceMs: 300000
|
|
96
|
+
kernelTurnBudgetMs: 1200000, supervisorTurnBudgetMs: 1800000, turnInterruptGraceMs: 300000,
|
|
97
|
+
# The watchdog's replacement of a Kernel seat runs start-workflow (worktree check, install, Orca launch, attestation): bounded here, under the Host controller's
|
|
98
|
+
# seats.kernel.timeoutMs that kills the whole watchdog pass. A start that outlives it is killed and recorded kernel-start-failed (start-timeout), never lost silently.
|
|
99
|
+
kernelStartTimeoutMs: 600000}
|
|
97
100
|
# one local git read over a job's owned paths (scripts/kernel/owned-path-effects.mjs ownedPathEffects, job-artifacts.mjs).
|
|
98
101
|
settleGit: {commandMs: 15000}
|
|
102
|
+
# prompt-file.mjs owns terminal delivery, wake bounds, file placement and cleanup.
|
|
103
|
+
promptFile: {maxChars: 2000, ttlMs: 86400000}
|
|
104
|
+
# opVerbs: the bound of the "your verbs" block of an [Op] prompt (scripts/kernel/op-prompt-verbs.mjs): the starci calls the op's contract names,
|
|
105
|
+
# each with the flags the contract uses and the required ones, one line per verb. A worker that has the exact call never runs --help
|
|
106
|
+
# (a measured op spent 15 of its 135 turns on it). `standing` verbs are printed in full by the prompt's own reporting and logging blocks;
|
|
107
|
+
# `omitGroups` are the verbs of the Kernel and above, which an op's contract names only to say who runs them.
|
|
108
|
+
opVerbs: {maxVerbs: 20, maxChars: 3000, lineChars: 190, standing: ["kernel report", "kernel log", "kernel op-contract"],
|
|
109
|
+
omitGroups: [kernel, supervisor, workflow, reconciler]}
|
|
99
110
|
# Typed logs (scripts/kernel/typed-logs.mjs, the ledger's logs table): perJobCap rows a job's own writes
|
|
100
111
|
# may store before one log.truncated row closes them (derived event rows are never capped); dataMaxBytes bounds
|
|
101
112
|
# one row's data after redaction. diff: the caps of a job patch's pre-structured <patch>.json
|
|
@@ -187,6 +198,13 @@ allocation:
|
|
|
187
198
|
# replacement takes over the loop's lock within handoverMs or is stopped. The reconciler engine checks every checkMs; an engine whose
|
|
188
199
|
# revision still differs from the live one that long past minIntervalMs + checkMs + handoverMs is a drift row (scripts/reconciler/drift.mjs).
|
|
189
200
|
selfReload: {minIntervalMs: 300000, handoverMs: 30000, checkMs: 60000, driftGraceMs: 120000}
|
|
201
|
+
# deploy (scripts/reconciler/runtime-deploy.mjs): a deploy waits up to waitMs, polling every pollMs, for the settles, Critic runs and prepared
|
|
202
|
+
# decisions in flight to finish; after the engine restart it waits up to verifyMs for the new leader's fresh heartbeat on the new revision. The affected-spec child
|
|
203
|
+
# may run its declared budget (modules/supervisor/affected-tests.yaml) plus affectedMarginMs before the deploy stops it.
|
|
204
|
+
deploy: {waitMs: 1800000, pollMs: 5000, verifyMs: 120000, affectedMarginMs: 300000}
|
|
205
|
+
# dispatchRefusal (scripts/kernel/dispatch-refusal-memo.mjs): a ready job whose dispatch is refused for the same cause is tried again after baseMs, then twice as long each time (the one retry budget), up to capMs; a change of
|
|
206
|
+
# the cause's fingerprint (runtime revision, a ruling, a resource the refusal names) tries it at once.
|
|
207
|
+
dispatchRefusal: {baseMs: 60000, capMs: 900000}
|
|
190
208
|
# footprint (scripts/guards/footprint-scan.mjs): one host-wide scan at most every everyMs; a crashed
|
|
191
209
|
# tick's claim lock is cleared after lockStaleMs.
|
|
192
210
|
footprint: {everyMs: 600000, lockStaleMs: 60000}
|
|
@@ -194,12 +212,15 @@ allocation:
|
|
|
194
212
|
jobGuard: {ttlMs: 604800000}
|
|
195
213
|
# landGate (scripts/supervisor/land.mjs): a land waits waitMs for its turn at the gate; its spec run may take
|
|
196
214
|
# specsBaseMs plus perSpecMs for every spec it runs, specConcurrency spec files at a time.
|
|
197
|
-
landGate: {waitMs: 1800000, specsBaseMs: 600000, perSpecMs: 60000, specConcurrency: 6}
|
|
215
|
+
landGate: {waitMs: 1800000, specsBaseMs: 600000, perSpecMs: 60000, specConcurrency: 6, cliVerbs: 12}
|
|
198
216
|
# workerClose (scripts/machine/worker-close.mjs; owner rule: a finished worker is closed COMPLETELY): after worker-release and the terminal
|
|
199
217
|
# close the runtime waits verifyMs for every process of that terminal's shell tree to end (polled every pollMs); a survivor proven to belong
|
|
200
218
|
# to the tree is stopped and given stopVerifyMs to end before the finding is raised.
|
|
201
219
|
# A finished [Worker] job whose terminal has no recorded closure is closed again by the Job controller's worker sweep (scripts/supervisor/supervisor-watchdog.mjs):
|
|
202
220
|
# every leftoverRetryMs (doubling to leftoverRetryMaxMs) for at most leftoverRetryAttempts attempts, each failure recorded with its reason on the job.
|
|
221
|
+
# uatSlot (scripts/uat/slot-collect.mjs, slot-lessee.mjs): a held UAT run checks its lessee (its attempt, the process that launched it) every watchMs and ends with it;
|
|
222
|
+
# the Host pass ends a slot whose lessee is gone, and a slot that recorded no lessee at all once it has been held unknownHoldMs (3 hours: a UAT run is minutes).
|
|
223
|
+
uatSlot: {watchMs: 5000, unknownHoldMs: 10800000}
|
|
203
224
|
workerClose: {verifyMs: 10000, pollMs: 1000, stopVerifyMs: 5000, leftoverRetryMs: 60000, leftoverRetryMaxMs: 900000, leftoverRetryAttempts: 12}
|
|
204
225
|
# providerReservation (scripts/machine/provider-reservation-reap.mjs): an admission whose receipt stayed `reserved` this long never reached
|
|
205
226
|
# worker-start (the consume moves it to `launching` first), so its launcher died and the slot is released. A receipt with no terminal handle and no
|
|
@@ -238,20 +259,26 @@ allocation:
|
|
|
238
259
|
quitAgent: {waitMs: 6000}
|
|
239
260
|
# drawRender (scripts/work/draw-render.mjs): a capture waits settleMs after document.fonts.ready.
|
|
240
261
|
drawRender: {settleMs: 500}
|
|
262
|
+
# layoutRender (scripts/work/layout-render.mjs): the runtime serves the product app for one capture; readyMs is how long the dev server may take to answer
|
|
263
|
+
# its first request (a cold Next compile), navigateMs the page load, settleMs the wait after fonts are ready, stopMs the grace before a still-running server is killed.
|
|
264
|
+
# publicOriginKeys are the scaffold's public-origin variables (its README: the product app's own address and the product app the landing hands over to); each gets the render's origin.
|
|
265
|
+
layoutRender: {readyMs: 180000, navigateMs: 90000, settleMs: 500, stopMs: 5000, publicOriginKeys: [NEXT_PUBLIC_SITE_URL, NEXT_PUBLIC_APP_URL]}
|
|
241
266
|
# drawLoop (scripts/work/draw-loop.mjs, scripts/work/draw/draw-taste.mjs; owner rulings 2026-09-27): interface.draw
|
|
242
267
|
# draws -> shoots -> evaluates -> fixes in rounds. A loop stops when every machine metric passes and the
|
|
243
268
|
# independent critic's beauty is at least beautyMin, after maxRounds, or after stallRounds rounds in a row that
|
|
244
269
|
# neither cut the failures nor raised the beauty; the best round is kept. Taste thresholds: accentBudget is the
|
|
245
270
|
# largest share of the render the accent colour may fill (the brand art band excluded), bandsPerCardMax the most
|
|
246
271
|
# hairline bands one card splits into, badgesPerEntityMax the most badges one entity carries. critic is the fresh,
|
|
247
|
-
# context-free Orca worker that scores beauty and hierarchy against the product's brand.direction rubric:
|
|
272
|
+
# context-free Orca worker that scores beauty and hierarchy against the product's brand.direction rubric: picked through
|
|
273
|
+
# the tier picker from the Critic tier (modules/models/tiers.yaml seats.critic, modules/kernel/critic.yaml) with the
|
|
274
|
+
# independence rule as a hard filter, then started
|
|
248
275
|
# through orchestration worker-start --agent <provider> --model --effort (scripts/agent/lib.mjs startAgent) on a
|
|
249
276
|
# runtime worktree detached at the empty tree (draw-critic.mjs criticWorkspace; Orca refuses a bare temp dir) holding only the PNGs, the HTML and the rubric; it writes verdict.json and reports worker_done; a
|
|
250
|
-
# critic with no worker_done within
|
|
251
|
-
#
|
|
252
|
-
# (
|
|
253
|
-
#
|
|
254
|
-
#
|
|
277
|
+
# critic with no worker_done within criticTimeoutMs is stopped and released (outcome timeout, no beauty). The critic is a member of ANOTHER PROVIDER than
|
|
278
|
+
# the drawer: the members of its tier of that provider are removed before the pick, and a tier with no other provider's member refuses the critique with
|
|
279
|
+
# CRITIC_NO_INDEPENDENT_MEMBER (the drawer never judges itself). The images a drawer asks of the imagegen call
|
|
280
|
+
# (starci work imagegen) do not make the call's model the drawer. The drawer is the op's provider, bound to its
|
|
281
|
+
# terminal (scripts/guards/op-context.mjs) or named by draw-loop.mjs round --drawer; a round with no independent critic has no beauty. directionPrerequisite: interface.draw needs an ACCEPTED brand.direction
|
|
255
282
|
# archetype for its ui record's ui.archetype - brand.mjs checkDirection evidence.ready, the owner's receipt
|
|
256
283
|
# (scripts/kernel/prerequisites.mjs direction-unaccepted; the Kernel enqueues brand.decide --param
|
|
257
284
|
# directionArchetype=<archetype> first). false switches the gate off.
|
|
@@ -262,9 +289,7 @@ allocation:
|
|
|
262
289
|
accentBudget: 0.10
|
|
263
290
|
bandsPerCardMax: 3
|
|
264
291
|
badgesPerEntityMax: 2
|
|
265
|
-
|
|
266
|
-
criticWhenDrawer:
|
|
267
|
-
codex: {provider: claude, model: claude-opus-5-5, effort: high, timeoutMs: 900000}
|
|
292
|
+
criticTimeoutMs: 900000
|
|
268
293
|
directionPrerequisite: true
|
|
269
294
|
# How long the workflow that claimed foundation `shell` (the layout chain an interface.draw draws) may hold it before a
|
|
270
295
|
# waiting draw opens a Supervisor Decision Item (scripts/kernel/shell-foundation.mjs; owner ruling 2026-09-29 draw-from-todo).
|
|
@@ -303,8 +328,8 @@ allocation:
|
|
|
303
328
|
# reportWindowMs.
|
|
304
329
|
workerJobs: {leaseTtlMs: 604800000, reportWindowMs: 604800000}
|
|
305
330
|
# draft (scripts/kernel/clear-draft.mjs): the beat between one Ctrl+U and the re-read that judges
|
|
306
|
-
# the input box's answer - a TUI repaints a beat later. clearDraft
|
|
307
|
-
#
|
|
331
|
+
# the input box's answer - a TUI repaints a beat later. clearDraft defaults to it and
|
|
332
|
+
# wake-delivery caps its runtime-owned draft clear interval at it.
|
|
308
333
|
draft: {intervalMs: 300}
|
|
309
334
|
# housekeeping (scripts/housekeeping/housekeeping.mjs): every retention window and root the host
|
|
310
335
|
# cleanup reads - no literal may live in the script. tmp: top-level %TEMP% entries whose name starts
|
|
@@ -14,7 +14,7 @@ import { buildHfsGraph } from './hfs-graph.mjs';
|
|
|
14
14
|
import { checkTiers, TIER_RULE_IDS } from './tiers.mjs';
|
|
15
15
|
import { checkReachability, REACHABILITY_RULE_IDS } from './reachability.mjs';
|
|
16
16
|
import { checkDeadExports, DEAD_EXPORT_RULE_IDS } from './dead-exports.mjs';
|
|
17
|
-
import { checkRequiredFiles, REQUIRED_FILE_RULE_IDS } from './required-files.mjs';
|
|
17
|
+
import { checkRequiredFiles, REQUIRED_FILE_RULE_IDS, withTreeCache } from './required-files.mjs';
|
|
18
18
|
import { checkClones, CLONE_RULE_IDS } from './clones.mjs';
|
|
19
19
|
import { checkSymbols, SYMBOL_RULE_IDS } from './symbols.mjs';
|
|
20
20
|
import { checkConnectionMap, CONNECTION_RULE_IDS } from './connection-map.mjs';
|
|
@@ -321,7 +321,11 @@ function scopeFindings({ config, context, violations, paths, surface }) {
|
|
|
321
321
|
* Check a target repository. injectedTypeScript exists only for hermetic rule fixtures. `fast` leaves out the checks
|
|
322
322
|
* that read the whole repository to answer (clones, dead exports, repository-wide symbols); the pre-push check of the changed owners uses it.
|
|
323
323
|
*/
|
|
324
|
-
export function checkArchitecture(
|
|
324
|
+
export function checkArchitecture(options = {}) {
|
|
325
|
+
return withTreeCache(() => checkArchitectureOnce(options));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function checkArchitectureOnce({ repositoryRoot, injectedTypeScript, paths = [], base, fast = false, hfs: openedHfs, surface = 'all' }) {
|
|
325
329
|
if (!['all', 'lint', 'check'].includes(surface)) throw new Error(`unknown surface ${surface}`);
|
|
326
330
|
let config;
|
|
327
331
|
try {
|
|
@@ -49,8 +49,20 @@ function diskFiles(root, relative = '') {
|
|
|
49
49
|
return out;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
let runTrees = null;
|
|
53
|
+
|
|
54
|
+
/** Runs `run` with one tree read per root: every checker of one machine run shares the repository listing instead of spawning git again. */
|
|
55
|
+
export function withTreeCache(run) {
|
|
56
|
+
const outer = runTrees;
|
|
57
|
+
runTrees = outer ?? new Map();
|
|
58
|
+
try {
|
|
59
|
+
return run();
|
|
60
|
+
} finally {
|
|
61
|
+
runTrees = outer;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readTree(root) {
|
|
54
66
|
const list = gitFiles(root) ?? diskFiles(root);
|
|
55
67
|
const files = new Set(list);
|
|
56
68
|
const directories = new Set();
|
|
@@ -61,6 +73,13 @@ export function treeOf(root) {
|
|
|
61
73
|
return { files, directories };
|
|
62
74
|
}
|
|
63
75
|
|
|
76
|
+
/** The repository tree as file and directory sets over posix relatives. */
|
|
77
|
+
export function treeOf(root) {
|
|
78
|
+
if (!runTrees) return readTree(root);
|
|
79
|
+
if (!runTrees.has(root)) runTrees.set(root, readTree(root));
|
|
80
|
+
return runTrees.get(root);
|
|
81
|
+
}
|
|
82
|
+
|
|
64
83
|
/**
|
|
65
84
|
* The prelude every tree-walking architecture checker shares: the input's config/graph/context, the compiler,
|
|
66
85
|
* the slot resolver and the tracked repository tree.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { parseYaml } from '../../engine/yaml.mjs';
|
|
3
|
+
|
|
4
|
+
const parsed = new Map();
|
|
5
|
+
|
|
6
|
+
/** The parsed YAML catalog at `file`; the megabyte of text is parsed once per file state (path, size, mtime), not once per check. */
|
|
7
|
+
export function readCatalogOnce(file) {
|
|
8
|
+
const { size, mtimeMs } = fs.statSync(file);
|
|
9
|
+
const key = `${file}|${size}|${mtimeMs}`;
|
|
10
|
+
if (!parsed.has(key)) {
|
|
11
|
+
parsed.clear();
|
|
12
|
+
parsed.set(key, parseYaml(fs.readFileSync(file, 'utf8')));
|
|
13
|
+
}
|
|
14
|
+
return parsed.get(key);
|
|
15
|
+
}
|
|
@@ -57,6 +57,7 @@ import path from 'node:path';
|
|
|
57
57
|
import { skillRoot } from '../../engine/runtime-root.mjs';
|
|
58
58
|
import { ARCHITECTURE_RULE_IDS, checkArchitecture } from './architecture/index.mjs';
|
|
59
59
|
import { parseYaml } from '../../engine/yaml.mjs';
|
|
60
|
+
import { readCatalogOnce } from './catalog-once.mjs';
|
|
60
61
|
import { APP_SCOPE, HFS_DECLARATION_FILE, appRelativeMessages, HfsSlotsError, SIDES, createSlotResolver, loadRuleCatalog, loadSlotManifest, readRepoDeclaration, resolveRepoDeclaration } from './slots.mjs';
|
|
61
62
|
import { RUNTIME_KIND } from './manifest-shape.mjs';
|
|
62
63
|
import { lsFiles } from '../api/git/ls-files.mjs';
|
|
@@ -114,7 +115,7 @@ const refuse = (code, message, details = {}) => { throw new HfsSlotsError(code,
|
|
|
114
115
|
|
|
115
116
|
/** {code: {title, title_vi, meaning_vi, nextStep_vi}} for the codes asked for, read from the failure-code catalog under `root`. */
|
|
116
117
|
export function readWhy(root = skillRoot, codes = CHECK_CODES) {
|
|
117
|
-
const catalog =
|
|
118
|
+
const catalog = readCatalogOnce(path.join(root, FAILURE_CODES_FILE));
|
|
118
119
|
const why = {};
|
|
119
120
|
for (const code of codes) {
|
|
120
121
|
const entry = catalog?.[code];
|
|
@@ -14,14 +14,28 @@ const real = (p) => { try { return fs.realpathSync(p); } catch { return path.res
|
|
|
14
14
|
const GIT_SUFFIX_SOURCE = String.raw`\.git$`;
|
|
15
15
|
const GIT_SUFFIX = new RegExp(GIT_SUFFIX_SOURCE, 'iu');
|
|
16
16
|
|
|
17
|
+
// The identity of a folder that has its own .git entry holds while that entry is the same file system object (same inode, same modification time), so a
|
|
18
|
+
// process asks Git once per such folder state instead of at every check. A folder with no .git entry of its own is asked every time.
|
|
19
|
+
const known = new Map();
|
|
20
|
+
const stateOf = (root) => { try { const stat = fs.statSync(path.join(root, '.git')); return `${real(root)}|${stat.ino}|${stat.mtimeMs}`; } catch { return null; } };
|
|
21
|
+
|
|
17
22
|
/** { repositoryRoot, inWorkTree, home } for `root`: home is the main checkout folder when root is a repository top level. */
|
|
18
23
|
function identityOf(root) {
|
|
24
|
+
const state = stateOf(root);
|
|
25
|
+
if (state === null) return askGit(root);
|
|
26
|
+
if (!known.has(state)) known.set(state, askGit(root));
|
|
27
|
+
return known.get(state);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function askGit(root) {
|
|
19
31
|
let inWorkTree = false, repositoryRoot = false, home = null;
|
|
20
32
|
try {
|
|
21
33
|
// Identity comes from Git only when root IS a repository (or worktree) top level, never a folder inside one.
|
|
22
|
-
|
|
34
|
+
// One git call answers both questions: the top level on the first line, the common git dir on the second.
|
|
35
|
+
const [top, commonDir] = git(revParseQuery, root, ['--show-toplevel', '--git-common-dir']).split(String.fromCodePoint(10)).map((line) => line.trim());
|
|
36
|
+
repositoryRoot = real(top) === real(root);
|
|
23
37
|
inWorkTree = true;
|
|
24
|
-
const common = path.resolve(root,
|
|
38
|
+
const common = path.resolve(root, commonDir);
|
|
25
39
|
if (repositoryRoot && path.basename(common) === '.git') home = path.dirname(common);
|
|
26
40
|
} catch { /* Not a Git work tree; the caller falls back to the folder and package name. */ }
|
|
27
41
|
return { inWorkTree, repositoryRoot, home };
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { skillRoot } from '../../engine/runtime-root.mjs';
|
|
5
|
-
import {
|
|
5
|
+
import { parseYamlCached } from '../lib/yaml-cached.mjs';
|
|
6
6
|
import { isPlainObject } from '../../engine/plain-object.mjs';
|
|
7
7
|
import { SEMVER } from './manifest-shape.mjs';
|
|
8
8
|
import { enforcerJudgedInEdition, judgedInEdition, ruleEditionProblems } from './edition-slots.mjs';
|
|
@@ -153,7 +153,7 @@ const deepFreeze = (value) => { if (value && typeof value === 'object') { Object
|
|
|
153
153
|
*/
|
|
154
154
|
export function loadRuleCatalog({ root = skillRoot, file = path.join(root, HFS_RULES_FILE), text, manifest } = {}) {
|
|
155
155
|
let doc;
|
|
156
|
-
try { doc =
|
|
156
|
+
try { doc = parseYamlCached(text ?? fs.readFileSync(file, 'utf8')); } catch (error) { fail('HFS_RULES_INVALID', `the rule catalog cannot be read (${String(error?.message ?? error).split('\n')[0]})`, { file }); }
|
|
157
157
|
const problems = ruleCatalogProblems(doc);
|
|
158
158
|
if (problems.length) { fail('HFS_RULES_INVALID', `the rule catalog breaks its schema: ${problems.slice(0, 5).join('; ')}${problems.length > 5 ? '; and ' + (problems.length - 5) + ' more' : ''}`, { file, problems }); }
|
|
159
159
|
const [major, minor, patch] = doc.version.split('.').map(Number);
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import fs from 'node:fs';
|
|
22
22
|
import path from 'node:path';
|
|
23
23
|
import { skillRoot } from '../../engine/runtime-root.mjs';
|
|
24
|
-
import {
|
|
24
|
+
import { parseYamlCached } from '../lib/yaml-cached.mjs';
|
|
25
25
|
import { braceVariants } from '../lib/glob.mjs';
|
|
26
26
|
import { APP_KIND, RUNTIME_KIND, manifestKind } from './manifest-shape.mjs';
|
|
27
27
|
import { APP_SCOPE, manifestShapeProblems, PROFILES } from './slot-manifest-shape.mjs';
|
|
@@ -73,7 +73,7 @@ export function appRelativeMessages(side, sideRoot) {
|
|
|
73
73
|
*/
|
|
74
74
|
export function loadSlotManifest({ root = skillRoot, file = path.join(root, HFS_MANIFEST_FILE), text } = {}) {
|
|
75
75
|
let doc;
|
|
76
|
-
try { doc =
|
|
76
|
+
try { doc = parseYamlCached(text ?? fs.readFileSync(file, 'utf8')); } catch (error) { fail('HFS_MANIFEST_INVALID', `the slot manifest cannot be read (${String(error?.message ?? error).split('\n')[0]})`, { file }); }
|
|
77
77
|
const problems = manifestShapeProblems(doc);
|
|
78
78
|
if (!problems.length) problems.push(...manifestSemanticProblems(doc, { varsOf, braceVariants, compileVariant }));
|
|
79
79
|
if (problems.length) fail('HFS_MANIFEST_INVALID', `the slot manifest breaks its schema: ${problems.slice(0, 5).join('; ')}${problems.length > 5 ? '; and ' + (problems.length - 5) + ' more' : ''}`, { file, problems });
|
|
@@ -8,3 +8,13 @@ export const readEnv = (name, env = process.env) => env[name];
|
|
|
8
8
|
|
|
9
9
|
/** True when `env` (default: the process environment) belongs to a spec run (`node --test` marks its children with NODE_TEST_CONTEXT). */
|
|
10
10
|
export const isSpecRun = (env = process.env) => Boolean(env.NODE_TEST_CONTEXT);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The environment for a child that is itself a test runner: `env` without the mark of an enclosing spec run. A `node --test` that inherits NODE_TEST_CONTEXT believes it is a
|
|
14
|
+
* subtest of that run: it reports to its parent and exits 0 whatever its tests did, so a verb that runs specs as a child (starci test affected, the land gate, the release cut, the
|
|
15
|
+
* suite) would pass red specs from inside a test process. The runner marks the spec files it starts itself, so isSpecRun stays true inside them.
|
|
16
|
+
*/
|
|
17
|
+
export function withoutTestRunner(env = process.env) {
|
|
18
|
+
const { NODE_TEST_CONTEXT: _enclosing, ...rest } = env;
|
|
19
|
+
return rest;
|
|
20
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// yaml-cached.mjs - parse a YAML text once per distinct text in a process.
|
|
2
|
+
//
|
|
3
|
+
// The HFS slot manifest and rule catalog are large documents that one process asks for again at every check, every render and every fixture. Their
|
|
4
|
+
// bytes do not change between those asks, so the parse (the dominant cost of a load) is keyed by the text itself; each caller gets its own copy of the
|
|
5
|
+
// parsed document, so nothing a caller does to it reaches the next one. A changed file is a different text and parses again.
|
|
6
|
+
import { parseYaml } from '../../engine/yaml.mjs';
|
|
7
|
+
|
|
8
|
+
const parsed = new Map();
|
|
9
|
+
|
|
10
|
+
/** The parsed document of `text`: parsed on the first ask for this text, a structured copy afterwards. Throws what parseYaml throws, every time. */
|
|
11
|
+
export function parseYamlCached(text) {
|
|
12
|
+
if (!parsed.has(text)) parsed.set(text, parseYaml(text));
|
|
13
|
+
return structuredClone(parsed.get(text));
|
|
14
|
+
}
|
|
@@ -10,6 +10,7 @@ import type { BullmqJob, BullmqQueue, BullmqWorker, QueueFactory } from "./queue
|
|
|
10
10
|
const options: QueueOptions = {
|
|
11
11
|
redisHost: "redis.test",
|
|
12
12
|
redisPort: 6380,
|
|
13
|
+
redisDb: 7,
|
|
13
14
|
prefix: "spec.",
|
|
14
15
|
relayIntervalMs: 100,
|
|
15
16
|
relayBatch: 50,
|
|
@@ -45,7 +46,7 @@ describe("BullmqQueueTransportClient", () => {
|
|
|
45
46
|
expect(factory.queue).toHaveBeenCalledTimes(1)
|
|
46
47
|
expect(factory.queue).toHaveBeenCalledWith("orders", {
|
|
47
48
|
prefix: "spec.",
|
|
48
|
-
connection: { host: "redis.test", port: 6380, maxRetriesPerRequest: null },
|
|
49
|
+
connection: { host: "redis.test", port: 6380, db: 7, maxRetriesPerRequest: null },
|
|
49
50
|
})
|
|
50
51
|
expect(queue.add).toHaveBeenNthCalledWith(1, "orders", payload, {
|
|
51
52
|
jobId: "outbox-1",
|
|
@@ -84,7 +85,7 @@ describe("BullmqQueueTransportClient", () => {
|
|
|
84
85
|
|
|
85
86
|
expect(factory.worker).toHaveBeenCalledWith("orders", expect.any(Function), {
|
|
86
87
|
prefix: "spec.",
|
|
87
|
-
connection: { host: "redis.test", port: 6380, maxRetriesPerRequest: null },
|
|
88
|
+
connection: { host: "redis.test", port: 6380, db: 7, maxRetriesPerRequest: null },
|
|
88
89
|
concurrency: 3,
|
|
89
90
|
})
|
|
90
91
|
const processor = factory.worker.mock.calls[0]?.[1]
|
|
@@ -67,7 +67,12 @@ export class BullmqQueueTransportClient implements QueueTransport {
|
|
|
67
67
|
private connection(): BullmqConnection {
|
|
68
68
|
return {
|
|
69
69
|
prefix: this.options.prefix,
|
|
70
|
-
connection: {
|
|
70
|
+
connection: {
|
|
71
|
+
host: this.options.redisHost,
|
|
72
|
+
port: this.options.redisPort,
|
|
73
|
+
db: this.options.redisDb,
|
|
74
|
+
maxRetriesPerRequest: null,
|
|
75
|
+
},
|
|
71
76
|
}
|
|
72
77
|
}
|
|
73
78
|
|
|
@@ -20,6 +20,8 @@ export interface BullmqRedis {
|
|
|
20
20
|
readonly host: string
|
|
21
21
|
/** The port. */
|
|
22
22
|
readonly port: number
|
|
23
|
+
/** The Redis database that holds this app's queue state. */
|
|
24
|
+
readonly db: number
|
|
23
25
|
/** BullMQ answers to commands only through a client that does not retry on its own. */
|
|
24
26
|
readonly maxRetriesPerRequest: number | null
|
|
25
27
|
}
|
|
@@ -8,6 +8,7 @@ export type QueueConfig = Omit<QueueOptions, "connections" | "schedulers">
|
|
|
8
8
|
export const parseQueueConfig = (env: EnvSource): QueueConfig => ({
|
|
9
9
|
redisHost: env.string("QUEUE_REDIS_HOST"),
|
|
10
10
|
redisPort: env.int("QUEUE_REDIS_PORT", 6379),
|
|
11
|
+
redisDb: env.int("QUEUE_REDIS_DB", 0),
|
|
11
12
|
prefix: env.optional("QUEUE_PREFIX") ?? "queue",
|
|
12
13
|
relayIntervalMs: env.duration("QUEUE_RELAY_INTERVAL", 200),
|
|
13
14
|
relayBatch: env.int("QUEUE_RELAY_BATCH", 50),
|
|
@@ -7,6 +7,8 @@ export interface QueueOptions {
|
|
|
7
7
|
readonly redisHost: string
|
|
8
8
|
/** The Redis port. */
|
|
9
9
|
readonly redisPort: number
|
|
10
|
+
/** The Redis database that holds this app's queue state. */
|
|
11
|
+
readonly redisDb: number
|
|
10
12
|
/** What every BullMQ key starts with: the shared default in a deployment, the run prefix of a test world. */
|
|
11
13
|
readonly prefix: string
|
|
12
14
|
/** The pause between two relay passes over an empty outbox. */
|