@starci/hfs 4.0.7 → 4.0.8

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 (133) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +10 -9
  3. package/bin/hfs.mjs +4 -4
  4. package/lint/run.mjs +1 -1
  5. package/package.json +2 -2
  6. package/runtime/engine/runtime-root.mjs +5 -0
  7. package/runtime/engine/yaml.mjs +3 -3
  8. package/runtime/knowledge/hfs/canon-pins.yaml +10 -10
  9. package/runtime/knowledge/hfs/slots.yaml +5 -5
  10. package/runtime/knowledge/patterns/fe/folder.yaml +2 -2
  11. package/runtime/knowledge/sonar-gate.yaml +2 -2
  12. package/runtime/modules/kernel/failure-codes.yaml +12 -2
  13. package/runtime/scripts/api/fs/lib.mjs +6 -0
  14. package/runtime/scripts/api/fs/rmdir-link.mjs +1 -1
  15. package/runtime/scripts/{lib → api/fs}/safe-remove.mjs +31 -82
  16. package/runtime/scripts/api/git/lib.mjs +1 -1
  17. package/runtime/scripts/api/sops/decrypt.mjs +1 -1
  18. package/runtime/scripts/{lib/hfs-allows.mjs → hfs/allows.mjs} +3 -3
  19. package/runtime/scripts/{checks → hfs}/architecture/backend.mjs +1 -1
  20. package/runtime/scripts/{checks → hfs}/architecture/config.mjs +2 -2
  21. package/runtime/scripts/{checks → hfs}/architecture/connection-map.mjs +1 -1
  22. package/runtime/scripts/{checks → hfs}/architecture/contract-fixture-guard.mjs +1 -1
  23. package/runtime/scripts/{checks → hfs}/architecture/fe-slot-allows.mjs +3 -3
  24. package/runtime/scripts/{checks → hfs}/architecture/feature-shape.mjs +1 -1
  25. package/runtime/scripts/{checks → hfs}/architecture/hfs-graph.mjs +1 -1
  26. package/runtime/scripts/{checks → hfs}/architecture/hfs.mjs +3 -3
  27. package/runtime/scripts/hfs/architecture/next-data-contract.mjs +96 -0
  28. package/runtime/scripts/{checks → hfs}/architecture/next-data.mjs +18 -105
  29. package/runtime/scripts/{checks → hfs}/architecture/required-files.mjs +1 -1
  30. package/runtime/scripts/{checks → hfs}/architecture/surface.mjs +1 -1
  31. package/runtime/scripts/{checks → hfs}/architecture/test-world-files.mjs +2 -2
  32. package/runtime/scripts/{checks → hfs}/architecture/typescript.mjs +1 -1
  33. package/runtime/scripts/{checks → hfs}/architecture.mjs +2 -2
  34. package/runtime/scripts/{lib/hfs-check.mjs → hfs/check.mjs} +33 -25
  35. package/runtime/scripts/hfs/manifest-shape.mjs +170 -0
  36. package/runtime/scripts/{lib/hfs-path-findings.mjs → hfs/path-findings.mjs} +6 -6
  37. package/runtime/scripts/{lib/hfs-rules → hfs/rules}/contract.mjs +3 -2
  38. package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +66 -0
  39. package/runtime/scripts/{lib/hfs-rules → hfs/rules}/integration-specs.mjs +1 -1
  40. package/runtime/scripts/{lib/hfs-rules → hfs/rules}/secrets.mjs +2 -2
  41. package/runtime/scripts/{lib/hfs-rules → hfs/rules}/stacks.mjs +2 -2
  42. package/runtime/scripts/{lib/hfs-slots.mjs → hfs/slots.mjs} +52 -58
  43. package/runtime/scripts/{lib/hfs-tree.mjs → hfs/tree.mjs} +1 -1
  44. package/runtime/scripts/{lib/hfs-view.mjs → hfs/view.mjs} +3 -3
  45. package/runtime/scripts/lib/graphql-contract.mjs +429 -0
  46. package/runtime/scripts/lib/is-main.mjs +13 -0
  47. package/runtime/scripts/lib/language.mjs +2 -2
  48. package/runtime/scripts/lib/path-key.mjs +2 -0
  49. package/runtime/scripts/lib/stack-declaration.mjs +2 -2
  50. package/runtime/scripts/{checks/common.mjs → lib/walk.mjs} +1 -12
  51. package/scaffold/app.mjs +1 -1
  52. package/scaffold/service.mjs +2 -2
  53. package/sync/hygiene.mjs +8 -8
  54. package/sync/index.mjs +48 -11
  55. package/templates/app/quality-config/codecov.yml +1 -1
  56. package/templates/app/quality-config/sonar-project.properties +1 -1
  57. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +2 -2
  58. package/templates/fe/skeleton/apps/__app__/next.config.ts +10 -2
  59. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/error.tsx +2 -2
  60. package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/component.tsx +0 -2
  61. package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/index.tsx +1 -7
  62. package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/component.tsx +0 -2
  63. package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/index.tsx +0 -1
  64. package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/component.tsx +0 -4
  65. package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/index.tsx +1 -1
  66. package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/component.tsx +0 -4
  67. package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/index.tsx +1 -1
  68. package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/component.tsx +0 -4
  69. package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/index.tsx +1 -1
  70. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/request.ts +8 -3
  71. package/templates/fe/skeleton/apps/__app__/src/proxy.ts +1 -1
  72. package/runtime/engine/admission.mjs +0 -284
  73. package/runtime/engine/digest.mjs +0 -10
  74. package/runtime/engine/ledger-db.mjs +0 -1245
  75. package/runtime/engine/machine-db.mjs +0 -1565
  76. package/runtime/engine/migrations/machine/0001-init.sql +0 -887
  77. package/runtime/engine/migrations/machine/0002-worktrees-no-workflow-kind.sql +0 -13
  78. package/runtime/engine/migrations/machine/0003-worktrees-workflow-orca.sql +0 -21
  79. package/runtime/engine/migrations/runtime/0001-init.sql +0 -1072
  80. package/runtime/engine/migrations/runtime/0003-usage-unavailable.sql +0 -13
  81. package/runtime/engine/migrations/runtime/0004-attempt-why.sql +0 -43
  82. package/runtime/engine/migrations/runtime/0005-ended-workflow-views.sql +0 -93
  83. package/runtime/scripts/lib/artifact-hold.mjs +0 -89
  84. package/runtime/scripts/lib/artifact-store.mjs +0 -103
  85. package/runtime/scripts/lib/redact.mjs +0 -148
  86. /package/runtime/scripts/{checks → hfs}/architecture/background-unowned.mjs +0 -0
  87. /package/runtime/scripts/{checks → hfs}/architecture/client-reaches-server.mjs +0 -0
  88. /package/runtime/scripts/{checks → hfs}/architecture/clones.mjs +0 -0
  89. /package/runtime/scripts/{checks → hfs}/architecture/config-unread.mjs +0 -0
  90. /package/runtime/scripts/{checks → hfs}/architecture/constructor-deps.mjs +0 -0
  91. /package/runtime/scripts/{checks → hfs}/architecture/contracts.mjs +0 -0
  92. /package/runtime/scripts/{checks → hfs}/architecture/cross-app-duplicate.mjs +0 -0
  93. /package/runtime/scripts/{checks → hfs}/architecture/dead-exports.mjs +0 -0
  94. /package/runtime/scripts/{checks → hfs}/architecture/default-deny.mjs +0 -0
  95. /package/runtime/scripts/{checks → hfs}/architecture/doc-language.mjs +0 -0
  96. /package/runtime/scripts/{checks → hfs}/architecture/entrypoint.mjs +0 -0
  97. /package/runtime/scripts/{checks → hfs}/architecture/error-codes.mjs +0 -0
  98. /package/runtime/scripts/{checks → hfs}/architecture/error-masked.mjs +0 -0
  99. /package/runtime/scripts/{checks → hfs}/architecture/framework-pinned.mjs +0 -0
  100. /package/runtime/scripts/{checks → hfs}/architecture/frontend.mjs +0 -0
  101. /package/runtime/scripts/{checks → hfs}/architecture/hooks-are-hooks.mjs +0 -0
  102. /package/runtime/scripts/{checks → hfs}/architecture/i18n-keys.mjs +0 -0
  103. /package/runtime/scripts/{checks → hfs}/architecture/index.mjs +0 -0
  104. /package/runtime/scripts/{checks → hfs}/architecture/injection-token-exported.mjs +0 -0
  105. /package/runtime/scripts/{checks → hfs}/architecture/machine-ast.mjs +0 -0
  106. /package/runtime/scripts/{checks → hfs}/architecture/module-per-transport.mjs +0 -0
  107. /package/runtime/scripts/{checks → hfs}/architecture/owners.mjs +0 -0
  108. /package/runtime/scripts/{checks → hfs}/architecture/package-shape.mjs +0 -0
  109. /package/runtime/scripts/{checks → hfs}/architecture/reachability.mjs +0 -0
  110. /package/runtime/scripts/{checks → hfs}/architecture/register-once.mjs +0 -0
  111. /package/runtime/scripts/{checks → hfs}/architecture/registration.mjs +0 -0
  112. /package/runtime/scripts/{checks → hfs}/architecture/route-files-thin.mjs +0 -0
  113. /package/runtime/scripts/{checks → hfs}/architecture/schema-owner.mjs +0 -0
  114. /package/runtime/scripts/{checks → hfs}/architecture/source-names.mjs +0 -0
  115. /package/runtime/scripts/{checks → hfs}/architecture/sql-owner.mjs +0 -0
  116. /package/runtime/scripts/{checks → hfs}/architecture/sql-tokens.mjs +0 -0
  117. /package/runtime/scripts/{checks → hfs}/architecture/symbols.mjs +0 -0
  118. /package/runtime/scripts/{checks → hfs}/architecture/tiers.mjs +0 -0
  119. /package/runtime/scripts/{checks → hfs}/architecture/transport-owner.mjs +0 -0
  120. /package/runtime/scripts/{checks → hfs}/architecture/unit-spec-providers.mjs +0 -0
  121. /package/runtime/scripts/{lib → hfs}/repo-identity.mjs +0 -0
  122. /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/deps.mjs +0 -0
  123. /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/fe-no-tests.mjs +0 -0
  124. /package/runtime/scripts/{lib/hfs-rules/frontend.mjs → hfs/rules/frontend-tree.mjs} +0 -0
  125. /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/lint-suppression.mjs +0 -0
  126. /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/peer-integrations.mjs +0 -0
  127. /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/pipeline.mjs +0 -0
  128. /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/proof-commands.mjs +0 -0
  129. /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/read.mjs +0 -0
  130. /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/repo-local-checks.mjs +0 -0
  131. /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/spec-placement.mjs +0 -0
  132. /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/test-topology.mjs +0 -0
  133. /package/runtime/scripts/{checks → hfs}/typescript-programs.mjs +0 -0
package/sync/index.mjs CHANGED
@@ -8,8 +8,8 @@
8
8
  // side (be, fe) for a side slot, whose files land under that side's folder. Two targets are not whole files of a managedBy slot
9
9
  // and are listed in this module: the marked block of the root .gitignore, and .starciwork/.gitignore, which lives inside the
10
10
  // .starciwork directory slot. Every file is rendered with the app's hfs.json (both sides and their apps) and, for the Sonar
11
- // exclusions and the one coverage scope (sonar.coverage.inclusions and codecov.yml alike), the jest preset the app installs for
12
- // its be side. `--check` compares the sha256 of the rendered content with the
11
+ // exclusions and the one coverage scope (sonar.coverage.exclusions, its complement, and the codecov.yml paths alike), the jest
12
+ // preset the app installs for its be side. `--check` compares the sha256 of the rendered content with the
13
13
  // file on disk and fails on any drift; `--write` rewrites the drifted files. `.gitignore` is the one shared file: only the marked
14
14
  // block is managed and the app's own lines around it are left alone. The root package.json is managed by its `scripts` block
15
15
  // only (mode scripts, compared as parsed JSON): the rest of the file (dependencies, npm workspaces of fe/packages/*) is the app's.
@@ -18,7 +18,7 @@ import { createRequire } from 'node:module';
18
18
  import fs from 'node:fs';
19
19
  import path from 'node:path';
20
20
  import { braceVariants } from '../runtime/scripts/lib/glob.mjs';
21
- import { APP_SCOPE, SIDES, loadSlotManifest, resolveRepoDeclaration } from '../runtime/scripts/lib/hfs-slots.mjs';
21
+ import { APP_SCOPE, SIDES, loadSlotManifest, resolveRepoDeclaration } from '../runtime/scripts/hfs/slots.mjs';
22
22
  import { readDeclaredSonarKey } from './sonar-key.mjs';
23
23
 
24
24
  export const TEMPLATES_DIR = path.join(import.meta.dirname, '..', 'templates');
@@ -121,8 +121,8 @@ export const LCOV_REPORT = 'be/coverage/lcov.info';
121
121
 
122
122
  /**
123
123
  * THE coverage scope of an app, from the app root: the preset's coverage sources on the be side (`be/src/**` + `/*.service.ts`).
124
- * It is the one source of sonar.coverage.inclusions and of the codecov.yml status paths, so the two can never drift; fe/ is
125
- * outside it (a front end has no tests).
124
+ * It is the one source of the codecov.yml status paths and, through coverageExclusions, of Sonar's coverage scope, so the two
125
+ * can never drift; fe/ is outside it (a front end has no tests).
126
126
  */
127
127
  export function coverageScope(presets) {
128
128
  const sources = presets?.coverageSources;
@@ -130,6 +130,43 @@ export function coverageScope(presets) {
130
130
  return sources.map(glob => `be/${glob}`);
131
131
  }
132
132
 
133
+ const COVERED_SUFFIX = /\*\.([a-z][a-z0-9-]*)\.ts$/;
134
+ const asGlob = (text) => String(text).replace(/<[^>]+>/g, '*');
135
+
136
+ /**
137
+ * Sonar's coverage scope of an app as `sonar.coverage.exclusions` globs: the COMPLEMENT of coverageScope. SonarQube has no
138
+ * coverage inclusions and its globs have no negation, so every executable file that is not a coverage source is excluded:
139
+ * - every be source role of the slot manifest's closed suffix vocabulary (ruleParams.be.suffixes, R89) other than the
140
+ * roles the coverage sources name (`service`), as `be/**` + `/*.<role>.ts`;
141
+ * - every be file name outside that vocabulary a be slot declares (`main.ts`, `index.ts`, `connection.ts`, the migrations,
142
+ * the test world's files, dto/ and kit/ files), as `be/<slot path>/<name>` with each `<placeholder>` a `*`;
143
+ * - all of fe/ (a front end has no tests).
144
+ * The result is sorted and has no duplicate. A coverage source that is not `<dir>/**` + `/*.<role>.ts` is refused: its
145
+ * complement cannot be written in Sonar's globs.
146
+ */
147
+ export function coverageExclusions(presets, manifest = loadSlotManifest()) {
148
+ const covered = new Set(coverageScope(presets).map(glob => {
149
+ const role = COVERED_SUFFIX.exec(glob)?.[1];
150
+ if (!role) throw new SyncError('HFS_SYNC_COVERAGE_SCOPE', `coverage source ${glob} is not <dir>/**/*.<role>.ts: Sonar's coverage exclusions cannot express its complement`);
151
+ return role;
152
+ }));
153
+ const suffixes = manifest.ruleParams?.be?.suffixes ?? [];
154
+ const isRole = new RegExp(`\\.(?:${suffixes.map(s => s.replace(/[-]/g, '\\-')).join('|')})\\.ts$`);
155
+ const globs = new Set(suffixes.filter(role => !covered.has(role)).map(role => `be/**/*.${role}.ts`));
156
+ for (const slot of manifest.slots) {
157
+ if (!slot.profiles.includes('be') || slot.presence === 'forbidden') continue;
158
+ for (const entry of [...(slot.requires ?? []), ...(slot.allows ?? [])]) {
159
+ if (!/\.ts$/.test(entry) || entry.includes('<role>')) continue;
160
+ for (const name of braceVariants(entry)) {
161
+ if (isRole.test(asGlob(name).replace(/\*/g, 'x'))) continue;
162
+ for (const dir of braceVariants(slot.path)) globs.add(`be/${asGlob(dir)}${asGlob(name)}`.replace(/\/\/+/g, '/'));
163
+ }
164
+ }
165
+ }
166
+ globs.add('fe/**');
167
+ return [...globs].sort();
168
+ }
169
+
133
170
  /**
134
171
  * The script lines the apps add to the root package.json, each ending with a comma (the template puts them mid-object). Every
135
172
  * script runs a side from the app root: a be script from be/ (`cd be && ...`, the folder its tsconfig and jest configuration
@@ -146,9 +183,9 @@ export function appScripts(app) {
146
183
  const watch = entry => `cd be && ts-node-dev --respawn -r tsconfig-paths/register apps/${entry.name}/src/main.ts`;
147
184
  return [
148
185
  ...(apis.length === 1 ? [line('dev:be', watch(apis[0]))] : apis.map(entry => line(`dev:be:${entry.name}`, watch(entry)))),
149
- ...(fe.length === 1 ? [line('dev:fe', `npm run codegen --silent && cd fe && next dev apps/${fe[0].name}`)] : fe.map(entry => line(`dev:fe:${entry.name}`, `npm run codegen --silent && cd fe && next dev apps/${entry.name}`))),
186
+ ...(fe.length === 1 ? [line('dev:fe', `npm run codegen --silent && cd fe/apps/${fe[0].name} && next dev`)] : fe.map(entry => line(`dev:fe:${entry.name}`, `npm run codegen --silent && cd fe/apps/${entry.name} && next dev`))),
150
187
  ...be.map(entry => line(entry.kind === 'migrate' ? (migrates.length === 1 ? 'migrate' : `migrate:${entry.name}`) : `start:${entry.name}`, `node be/dist/apps/${entry.name}/src/main.js`)),
151
- ...fe.map(entry => line(`start:${entry.name}`, `cd fe && next start apps/${entry.name}`)),
188
+ ...fe.map(entry => line(`start:${entry.name}`, `cd fe/apps/${entry.name} && next start`)),
152
189
  ].join('\n ');
153
190
  }
154
191
 
@@ -159,7 +196,7 @@ export const opensPackages = side => (side.optionalSlots ?? []).some(id => id ==
159
196
  export const STYLE_GLOB = '{apps,packages}/*/src/**/*.css';
160
197
 
161
198
  /** Every value a template of `scope` can name, derived from hfs.json and the presets. */
162
- export function variables(app, scope, presets, sonarKey) {
199
+ export function variables(app, scope, presets, sonarKey, manifest = loadSlotManifest()) {
163
200
  const fe = app.sides.fe;
164
201
  const packages = opensPackages(fe);
165
202
  const feApps = fe.apps.map(entry => `fe/apps/${entry.name}`);
@@ -167,7 +204,7 @@ export function variables(app, scope, presets, sonarKey) {
167
204
  return {
168
205
  header: HEADER(scope),
169
206
  appScripts: appScripts(app),
170
- buildFe: ['npm run codegen --silent', ...(packages ? ['npm run build --workspaces --if-present'] : []), `cd fe && ${fe.apps.map(entry => `next build apps/${entry.name}`).join(' && ')}`].join(' && '),
207
+ buildFe: ['npm run codegen --silent', ...(packages ? ['npm run build --workspaces --if-present'] : []), fe.apps.map(entry => `(cd fe/apps/${entry.name} && next build)`).join(' && ')].join(' && '),
171
208
  // The fe apps import the workspace packages from their dist/, so the packages are built before the apps are type-checked.
172
209
  typecheck: ['npm run codegen --silent', 'tsc -p be/tsconfig.json', ...(packages ? ['npm run build --workspaces --if-present'] : []), ...feTsconfigs.filter(file => !file.includes('*')).map(file => `tsc -p ${file} --noEmit`), ...(packages ? ['npm run typecheck --workspaces --if-present'] : [])].join(' && '),
173
210
  nodeMajor: String(NODE_MAJOR),
@@ -175,7 +212,7 @@ export function variables(app, scope, presets, sonarKey) {
175
212
  sonarExclusions: [presets?.sonarExclusions, '**/.next/**', '**/node_modules/**', '**/src/messages/**'].filter(Boolean).join(','),
176
213
  sonarSources: ['be/apps', 'be/src', 'fe/apps', ...(packages ? ['fe/packages'] : [])].join(','),
177
214
  lcovReport: LCOV_REPORT,
178
- coverageInclusions: scope === APP_SCOPE ? coverageScope(presets).join(',') : '',
215
+ coverageExclusions: scope === APP_SCOPE ? coverageExclusions(presets, manifest).join(',') : '',
179
216
  codecovPaths: scope === APP_SCOPE ? coverageScope(presets).map(glob => ` - ${JSON.stringify(glob)}`).join('\n') : '',
180
217
  tsconfigPaths: ['be/tsconfig.json', ...feTsconfigs].join(','),
181
218
  styleGlob: STYLE_GLOB,
@@ -203,7 +240,7 @@ export function renderTargets(hfs, presets, { sonarKey, readTemplate = readBundl
203
240
  const app = validateHfs(hfs, manifest);
204
241
  const hasTemplate = name => { try { return readTemplate(name) !== undefined; } catch { return false; } };
205
242
  return SCOPES.flatMap(scope => {
206
- const vars = variables(app, scope, presets, sonarKey);
243
+ const vars = variables(app, scope, presets, sonarKey, manifest);
207
244
  return targetsOf(scope, { manifest, hasTemplate }).map(target => {
208
245
  const body = render(readTemplate(target.template), vars, readTemplate);
209
246
  const where = onSide(scope, target.path);
@@ -1,6 +1,6 @@
1
1
  {{header}}
2
2
  # The be unit run's lcov ({{lcovReport}}), uploaded by the managed CI workflow. Coverage is the services' alone: the status
3
- # paths are the one coverage scope hfs sync renders into sonar.coverage.inclusions too, held at 100 on the project and on
3
+ # paths are the one coverage scope whose complement hfs sync renders into sonar.coverage.exclusions, held at 100 on the project and on
4
4
  # the patch. fe/ is outside coverage (a front end has no tests).
5
5
  codecov:
6
6
  require_ci_to_pass: true
@@ -8,5 +8,5 @@ sonar.test.inclusions=**/*.spec.ts
8
8
  sonar.typescript.tsconfigPaths={{tsconfigPaths}}
9
9
  sonar.externalIssuesReportPaths=reports/lint.sonar.json
10
10
  sonar.javascript.lcov.reportPaths={{lcovReport}}
11
- sonar.coverage.inclusions={{coverageInclusions}}
11
+ sonar.coverage.exclusions={{coverageExclusions}}
12
12
  sonar.nodejs.maxspace=8192
@@ -5,7 +5,7 @@ import { requestOf } from "./execution-request.mapper"
5
5
  import { InjectHttpSecurityOptions } from "./http-security.decorators"
6
6
  import type { HttpSecurityOptions } from "./http-security.options"
7
7
 
8
- const SAFE_METHODS: ReadonlyArray<string> = ["GET", "HEAD", "OPTIONS"]
8
+ const SAFE_METHODS: ReadonlySet<string> = new Set(["GET", "HEAD", "OPTIONS"])
9
9
 
10
10
  const originOf = (origin: string | undefined, referer: string | undefined): string | undefined => {
11
11
  if (origin) return origin
@@ -23,7 +23,7 @@ export class OriginGuard implements CanActivate {
23
23
  /** Refuses a state-changing request from an origin outside the allowlist. */
24
24
  canActivate(context: ExecutionContext): boolean {
25
25
  const request = requestOf(context)
26
- if (SAFE_METHODS.includes(request.method)) return true
26
+ if (SAFE_METHODS.has(request.method)) return true
27
27
  const origin = originOf(request.headers.origin, request.headers.referer)
28
28
  if (origin === undefined || this.options.allowedOrigins.includes(origin)) return true
29
29
  throw new HttpSecurityError({ code: HttpSecurityErrorCode.OriginRejected })
@@ -1,11 +1,19 @@
1
+ import { dirname, join } from "node:path"
2
+ import { fileURLToPath } from "node:url"
1
3
  import type { NextConfig } from "next"
2
4
  import createNextIntlPlugin from "next-intl/plugin"
3
5
 
6
+ /** The request config lives in this app's i18n module; next-intl resolves it from the directory the build runs in. */
4
7
  const withNextIntl = createNextIntlPlugin("./src/modules/i18n/request.ts")
5
8
 
6
- /** Next config of the {{app}} app: next-intl wired to the request config, Turbopack rooted at the repository. */
9
+ /** The npm workspace root (three levels above this app): Turbopack resolves the workspace packages from it, and output tracing is pinned to it. */
10
+ const WORKSPACE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..")
11
+
12
+ /** Next config of the {{app}} app: next-intl wired to the request config, root params on (the locale is read from the route). */
7
13
  const nextConfig: NextConfig = {
8
- turbopack: { root: process.cwd() },
14
+ experimental: { rootParams: true },
15
+ outputFileTracingRoot: WORKSPACE_ROOT,
16
+ turbopack: { root: WORKSPACE_ROOT },
9
17
  }
10
18
 
11
19
  export default withNextIntl(nextConfig)
@@ -5,6 +5,6 @@ import { ErrorPage } from "@/features/pages/ErrorPage"
5
5
  type ErrorProps = { readonly reset: () => void }
6
6
 
7
7
  /** The locale segment's error boundary slot: it mounts the error page and hands it the segment's retry. */
8
- const Error = (props: ErrorProps) => <ErrorPage onRetry={props.reset} />
8
+ const ErrorBoundary = (props: ErrorProps) => <ErrorPage onRetry={props.reset} />
9
9
 
10
- export default Error
10
+ export default ErrorBoundary
@@ -2,8 +2,6 @@ import { FailureScreen } from "@/components/composites/FailureScreen"
2
2
 
3
3
  /** Props for {@link ErrorPageBase}. */
4
4
  export type ErrorPageBaseProps = {
5
- /** Whole-screen situations this surface settles; the error page only ever shows the failure. */
6
- readonly state: "failed"
7
5
  /** The words the failure shows. */
8
6
  readonly props: {
9
7
  readonly title: string
@@ -7,11 +7,5 @@ type ErrorPageProps = { readonly onRetry: () => void }
7
7
  /** A render failure of the locale segment shows this instead of a blank page; retry re-renders the segment. */
8
8
  export const ErrorPage = (props: ErrorPageProps) => {
9
9
  const t = useTranslations("errors.page")
10
- return (
11
- <ErrorPageBase
12
- state="failed"
13
- props={{ title: t("title"), retryLabel: t("retry") }}
14
- on={{ retry: props.onRetry }}
15
- />
16
- )
10
+ return <ErrorPageBase props={{ title: t("title"), retryLabel: t("retry") }} on={{ retry: props.onRetry }} />
17
11
  }
@@ -2,8 +2,6 @@ import { FailureScreen } from "@/components/composites/FailureScreen"
2
2
 
3
3
  /** Props for {@link GlobalErrorPageBase}. */
4
4
  export type GlobalErrorPageBaseProps = {
5
- /** Whole-screen situations this surface settles; the global error page only ever shows the failure. */
6
- readonly state: "failed"
7
5
  /** The language of the document and the words the failure shows. */
8
6
  readonly props: {
9
7
  readonly lang: string
@@ -8,7 +8,6 @@ type GlobalErrorPageProps = { readonly onRetry: () => void }
8
8
  /** The last-resort boundary above the locale layout: it has no provider, so it reads the default catalog directly. */
9
9
  export const GlobalErrorPage = (props: GlobalErrorPageProps) => (
10
10
  <GlobalErrorPageBase
11
- state="failed"
12
11
  props={{ lang: DEFAULT_LOCALE, title: messages.errors.global.title, retryLabel: messages.errors.global.retry }}
13
12
  on={{ retry: props.onRetry }}
14
13
  />
@@ -2,12 +2,8 @@ import { GrammarRoot, Heading, PageContainer, WorkspaceShell } from "@starci/gra
2
2
 
3
3
  /** Props for {@link HomePageBase}. */
4
4
  export type HomePageBaseProps = {
5
- /** Whole-screen situations this surface settles; the home page only ever shows its title. */
6
- readonly state: "ready"
7
5
  /** The words the page shows. */
8
6
  readonly props: { readonly title: string }
9
- /** What the surface reports upward; the page reports nothing. */
10
- readonly on: Record<never, never>
11
7
  }
12
8
 
13
9
  /** Draw the front door of the app. */
@@ -11,5 +11,5 @@ export const homeMetadata = async (): Promise<Metadata> => {
11
11
  /** The first page of the app: a server component, so no catalog is shipped for it. */
12
12
  export const HomePage = async () => {
13
13
  const t = await getTranslations("home")
14
- return <HomePageBase state="ready" props={{ title: t("title") }} on={{}} />
14
+ return <HomePageBase props={{ title: t("title") }} />
15
15
  }
@@ -2,12 +2,8 @@ import { GrammarRoot, PageContainer, Text, WorkspaceShell } from "@starci/gramma
2
2
 
3
3
  /** Props for {@link LoadingPageBase}. */
4
4
  export type LoadingPageBaseProps = {
5
- /** Whole-screen situations this surface settles; the loading page only ever waits. */
6
- readonly state: "loading"
7
5
  /** The words the wait announces. */
8
6
  readonly props: { readonly message: string }
9
- /** What the surface reports upward; a wait reports nothing. */
10
- readonly on: Record<never, never>
11
7
  }
12
8
 
13
9
  /** Draw the wait as one politely announced line. */
@@ -4,5 +4,5 @@ import { LoadingPageBase } from "./component"
4
4
  /** Shown while a route of the locale segment resolves. */
5
5
  export const LoadingPage = async () => {
6
6
  const t = await getTranslations("loading")
7
- return <LoadingPageBase state="loading" props={{ message: t("message") }} on={{}} />
7
+ return <LoadingPageBase props={{ message: t("message") }} />
8
8
  }
@@ -3,15 +3,11 @@ import { ROUTES } from "@/modules/routes"
3
3
 
4
4
  /** Props for {@link NotFoundPageBase}. */
5
5
  export type NotFoundPageBaseProps = {
6
- /** Whole-screen situations this surface settles; the not-found page only ever says the page is missing. */
7
- readonly state: "missing"
8
6
  /** The words the page shows. */
9
7
  readonly props: {
10
8
  readonly title: string
11
9
  readonly homeLabel: string
12
10
  }
13
- /** What the surface reports upward; the page reports nothing. */
14
- readonly on: Record<never, never>
15
11
  }
16
12
 
17
13
  /** Draw the missing-page message and the way back to the front door. */
@@ -4,5 +4,5 @@ import { NotFoundPageBase } from "./component"
4
4
  /** Shown when a route calls `notFound()`. */
5
5
  export const NotFoundPage = async () => {
6
6
  const t = await getTranslations("notFound")
7
- return <NotFoundPageBase state="missing" props={{ title: t("title"), homeLabel: t("home") }} on={{}} />
7
+ return <NotFoundPageBase props={{ title: t("title"), homeLabel: t("home") }} />
8
8
  }
@@ -1,11 +1,16 @@
1
1
  import "server-only"
2
2
  import { hasLocale } from "next-intl"
3
3
  import { getRequestConfig } from "next-intl/server"
4
+ import { locale as routeLocale } from "next/root-params"
4
5
  import { routing } from "./routing"
5
6
 
6
- /** Resolves the locale of a request and loads its catalog; an unknown locale falls back to the default. */
7
- export default getRequestConfig(async ({ requestLocale }) => {
8
- const requested = await requestLocale
7
+ /**
8
+ * Resolves the locale of a request and loads its catalog; an unknown locale falls back to the default. The locale is the
9
+ * `[locale]` route param read through `next/root-params` (`experimental.rootParams` in next.config.ts), not the deprecated
10
+ * `requestLocale`.
11
+ */
12
+ export default getRequestConfig(async () => {
13
+ const requested: unknown = await routeLocale()
9
14
  const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale
10
15
  return {
11
16
  locale,
@@ -6,5 +6,5 @@ export default createMiddleware(routing)
6
6
 
7
7
  /** Everything except the API, the health probe, framework files and files with an extension. */
8
8
  export const config = {
9
- matcher: ["/((?!api|health|_next|_vercel|.*\\..*).*)"],
9
+ matcher: ["/((?!api|health|_next|_vercel|.*[.].*).*)"],
10
10
  }
@@ -1,284 +0,0 @@
1
- import { createHash } from 'node:crypto';
2
-
3
- const PATH_LEASE_PREFIX='path:';
4
- const GLOB_META=/[*?[\]{}]/;
5
- // Next.js App Router spells route segments as literal directory names: dynamic `[lang]`, catch-all
6
- // `[...slug]` and optional catch-all `[[...opt]]`, optionally behind an intercept prefix `(.)`, `(..)`,
7
- // `(...)` or `(..)(..)`. Route groups `(group)`, parallel slots `@slot` and intercepts on a static name
8
- // carry no glob meta at all. A segment of exactly this shape is a concrete name, never a character class;
9
- // every consumer that hands an owned path to a glob engine escapes it (git: ownedPathspec below).
10
- const APP_ROUTER_SEGMENT=/^(?:\(\.{1,3}\))*(?:\[\[\.\.\.[A-Za-z0-9_$-]+\]\]|\[(?:\.\.\.)?[A-Za-z0-9_$-]+\])$/;
11
-
12
- /** A Next.js App Router bracket segment (`[id]`, `[...slug]`, `[[...opt]]`, `(.)[id]`) — a literal directory name. */
13
- export const isAppRouterSegment=part=>APP_ROUTER_SEGMENT.test(String(part??''));
14
-
15
- /** A path segment that is a real glob (`*`, `?`, `{a,b}`, a bare character class), not an App Router name. */
16
- export const isGlobSegment=part=>GLOB_META.test(String(part??''))&&!isAppRouterSegment(part);
17
-
18
- /**
19
- * The git pathspec for one concrete owned path. Git reads a plain pathspec as a glob, so `src/app/[id]`
20
- * would also match a sibling `src/app/i`; `:(literal)` pins it to the named directory. Admission
21
- * refuses a glob, so every owned path is literal.
22
- */
23
- export const ownedPathspec=spec=>`:(literal)${String(spec??'').replace(/\\/g,'/')||'.'}`;
24
-
25
- const plainPath=value=>typeof value==='string'?value:value?.path;
26
-
27
- /**
28
- * Canonical workspace-relative path prefix used by planning, leases and report boundaries. In a
29
- * multi-repository project the caller includes the repository binding prefix (for example `nivo-fe/`).
30
- * A directory prefix is spelled either bare (`docs/`) or with a trailing `/**`, which normalizes to
31
- * the same prefix; every other glob, absolute path and parent traversal is refused because it is not
32
- * a concrete ownership boundary. A Next.js App Router segment (`[lang]`, `[...slug]`, `[[...opt]]`,
33
- * `(group)`, `@slot`, `(.)photo`) is a literal directory name and is admitted as one.
34
- */
35
- export function normalizeOwnedPath(value){
36
- let input=String(plainPath(value)??'').trim().replace(/\\/g,'/');
37
- input=input.replace(/\/\*\*\/$/,'').replace(/\/\*\*$/,'');
38
- if(!input||input.startsWith('/')||/^[A-Za-z]:\//.test(input))throw Error(`owned path must be repository-relative: ${JSON.stringify(plainPath(value)??value)}`);
39
- const parts=[];
40
- for(const part of input.split('/')){
41
- if(!part||part==='.')continue;
42
- if(part==='..')throw Error(`owned path must not traverse its repository: ${JSON.stringify(plainPath(value)??value)}`);
43
- if(isGlobSegment(part))throw Error(`owned path must be a concrete prefix, not a glob: ${JSON.stringify(plainPath(value)??value)}`);
44
- parts.push(part);
45
- }
46
- if(!parts.length)throw Error(`owned path must name a concrete repository-relative prefix: ${JSON.stringify(plainPath(value)??value)}`);
47
- return parts.join('/');
48
- }
49
-
50
- /** Normalize, de-duplicate and collapse descendants already covered by an owned ancestor. */
51
- export function normalizeOwnedPaths(values=[]){
52
- const paths=[...new Set(values.map(normalizeOwnedPath))].sort((a,b)=>a.length-b.length||a.localeCompare(b));
53
- return paths.filter((candidate,index)=>!paths.slice(0,index).some(parent=>ownedPathsIntersect(parent,candidate)));
54
- }
55
-
56
- /** Path-prefix overlap: equality or either concrete path being below the other. */
57
- export function ownedPathsIntersect(left,right){
58
- const a=normalizeOwnedPath(left),b=normalizeOwnedPath(right);
59
- return a===b||a.startsWith(`${b}/`)||b.startsWith(`${a}/`);
60
- }
61
-
62
- /** The durable resource identity for one normalized concrete owned path. */
63
- export const ownedPathLeaseKey=value=>`${PATH_LEASE_PREFIX}${normalizeOwnedPath(value)}`;
64
-
65
- /** One capacity-one request per minimal owned prefix. */
66
- export const ownedPathLeaseRequests=values=>normalizeOwnedPaths(values).map(path=>({resourceKey:ownedPathLeaseKey(path),units:1}));
67
-
68
- const leasePath=resourceKey=>String(resourceKey??'').startsWith(PATH_LEASE_PREFIX)
69
- ?String(resourceKey).slice(PATH_LEASE_PREFIX.length):null;
70
-
71
- /**
72
- * The spelling two lease paths are compared in. The same file must compare equal however a workflow
73
- * spelled it (nivo wf-nivo-fe-debt-mug06w7h inc-52a4a5ee5b12: `apps/app/src/messages/vi.json` bare for
74
- * the fe repository vs `nivo-fe/apps/app/src/messages` repository-prefixed never overlapped). `canonicalOf`
75
- * (scripts/kernel/lease-canon.mjs) resolves a path to its app-relative form in a bound app
76
- * (be/<path>, fe/<path>) — for a held row through its holder job, so every spelling of one file
77
- * compares as one key; paths on Windows compare case-insensitively, as its file
78
- * systems do.
79
- */
80
- export const leaseCompareForm=(leasePathValue,{canonicalOf=null,row=null,platform=process.platform}={})=>{
81
- let value=normalizeOwnedPath(leasePathValue);
82
- if(canonicalOf){try{value=normalizeOwnedPath(canonicalOf(value,row)??value);}catch{/* an unresolvable spelling compares as written */}}
83
- return platform==='win32'?value.toLowerCase():value;
84
- };
85
-
86
- /**
87
- * Find durable path leases that overlap a requested parent/child prefix. Lease-row existence is the
88
- * fence; expiry is only a recovery signal and does not by itself prove the prior worker has no effect.
89
- * Both sides are compared in leaseCompareForm, so a bare and a repository-prefixed spelling of one
90
- * file overlap and the same relative path in two repositories does not.
91
- */
92
- export function findOwnedPathLeaseConflicts(db,requests,{excludeJobId=null,canonicalOf=null,platform=process.platform}={}){
93
- const requested=[...new Set(requests.map(item=>item?.resourceKey??item).filter(key=>leasePath(key)!==null))];
94
- if(!requested.length)return [];
95
- const held=db.prepare("SELECT resource_key,job_id,workflow_id,op_id,try_no AS attempt,generation,expires_at FROM leases WHERE resource_key LIKE 'path:%' ORDER BY resource_key,job_id").all();
96
- const formOf=new Map();
97
- const compare=(key,row)=>{
98
- const id=`${row?.job_id??''}\0${key}`;
99
- if(!formOf.has(id))formOf.set(id,leaseCompareForm(leasePath(key),{canonicalOf,row,platform}));
100
- return formOf.get(id);
101
- };
102
- const conflicts=[];
103
- for(const requestKey of requested){
104
- const requestPath=compare(requestKey,null);
105
- for(const row of held){
106
- if(excludeJobId&&row.job_id===excludeJobId)continue;
107
- if(ownedPathsIntersect(requestPath,compare(row.resource_key,row)))conflicts.push({requested:requestKey,held:row.resource_key,...row});
108
- }
109
- }
110
- return conflicts;
111
- }
112
-
113
- /**
114
- * The concurrent-operation ceiling one workflow is admitted at. Two declared numbers meet here and
115
- * the LOWER of them admits: the owner's `budgets.maxOps` (per workflow) and `maxParallelOps` from
116
- * modules/models/runtimes.yaml (fleet-wide). A null, absent or non-positive value is unbounded, so
117
- * a workflow with no owner budget still meets the fleet ceiling. A parallelism gear raises what
118
- * `api estimate` requests and never raises either of these.
119
- */
120
- export function opSlotCeiling({maxOps=null,maxParallelOps=null}={}){
121
- const positive=value=>{const n=Number(value);return Number.isInteger(n)&&n>0?n:null;};
122
- const owner=positive(maxOps),fleet=positive(maxParallelOps);
123
- if(owner===null&&fleet===null)return {ceiling:null,source:null};
124
- if(owner===null)return {ceiling:fleet,source:'maxParallelOps'};
125
- if(fleet===null)return {ceiling:owner,source:'budgets.maxOps'};
126
- return owner<=fleet?{ceiling:owner,source:'budgets.maxOps'}:{ceiling:fleet,source:'maxParallelOps'};
127
- }
128
-
129
- /**
130
- * Admission against that ceiling. `running` is how many operations of the one workflow already hold
131
- * a slot; a job at or above the ceiling is refused `max-ops` rather than launched and left to
132
- * discover the cap from a provider.
133
- */
134
- export function admitOpSlot({running=0,maxOps=null,maxParallelOps=null}={}){
135
- const {ceiling,source}=opSlotCeiling({maxOps,maxParallelOps});
136
- const held=Math.max(0,Number(running)||0);
137
- if(ceiling===null)return {ok:true,running:held,ceiling:null,ceilingSource:null,reason:null};
138
- return held<ceiling
139
- ?{ok:true,running:held,ceiling,ceilingSource:source,reason:null}
140
- :{ok:false,running:held,ceiling,ceilingSource:source,reason:'max-ops'};
141
- }
142
-
143
- const rowObject=value=>{
144
- if(value&&typeof value==='object')return value;
145
- if(typeof value!=='string'||!value.trim())return {};
146
- try{const parsed=JSON.parse(value);return parsed&&typeof parsed==='object'?parsed:{};}catch{return {};}
147
- };
148
-
149
- /**
150
- * The durable payload/result of a job-shaped row: the parsed object when the row carries the decoded
151
- * field (a mapped ledger row), else the tolerant parse of its `*_json` text — a missing, blank or
152
- * unparsable field reads as {}. Several scripts spell this by hand; these are the one pair to cite.
153
- */
154
- export const payloadOf=job=>rowObject(job?.payload??job?.payload_json);
155
- export const resultOf=job=>rowObject(job?.result??job?.result_json);
156
-
157
- /** The settled verdict of an attempt that asked the owner and waits for the answer. */
158
- export const AWAITING_OWNER='awaiting-owner';
159
- /**
160
- * jobs.status of a try that ended asking the owner (report outcome ask): settled, but neither a failure nor a spent try
161
- * (its unit's try budget and business retries ignore it). A retry or resume may follow it exactly as it follows `failed`.
162
- */
163
- export const AWAITING_OWNER_STATUS='awaiting_owner';
164
- export const RETRYABLE_JOB_STATUSES=Object.freeze(['failed',AWAITING_OWNER_STATUS]);
165
- /** Every jobs.status that holds nothing the runtime still needs (mirrors engine/ledger-db.mjs JOB_STATUSES.settled). */
166
- export const SETTLED_JOB_LIST=Object.freeze(['succeeded','failed',AWAITING_OWNER_STATUS,'cancelled']);
167
- /** The tries of a unit that spent budget: every try but the ones that only waited on the owner. */
168
- export const spentTries=tries=>tries.filter(job=>job.status!==AWAITING_OWNER_STATUS).length;
169
- // An attempt the environment killed with effects on the tree (a host terminal wipe: every Orca terminal
170
- // gone at once, scripts/kernel/api.mjs hostTerminalWipeOf) settles failed with this retryClass: its retry
171
- // is a new durable attempt that continues the partial tree and spends no business retry.
172
- export const RETRY_CLASS_ENVIRONMENT='environment';
173
-
174
- /**
175
- * Classify a settled attempt for retry accounting. Infrastructure is free only when the durable result
176
- * explicitly proves `effectState: none`; unknown or partial effects consume the ordinary business budget.
177
- * An attempt settled `awaiting-owner` asked a question and did not fail: its successor is a new durable
178
- * attempt (the ask attempt ran) that spends no business retry. Nor does one settled `peerBlocked`
179
- * (api settle: every red check was a peer's change, scripts/kernel/gate-attribution.mjs), nor one settled
180
- * with retryClass environment (RETRY_CLASS_ENVIRONMENT).
181
- */
182
- export function retryDisposition(job){
183
- const result=resultOf(job),reason=String(result.reason??'');
184
- const infrastructure=result.retryClass==='infrastructure'||reason==='dispatch-rejected'||reason==='provider-unavailable';
185
- const explicitlyReusable=(result.retryable===true&&result.attemptConsumed===false)||result.retryClass==='infrastructure';
186
- const noEffect=infrastructure&&result.effectState==='none'&&explicitlyReusable;
187
- const ownerAnswer=!noEffect&&result.verdict===AWAITING_OWNER;
188
- const peerBlocked=!noEffect&&!ownerAnswer&&result.verdict!=='pass'&&Boolean(result.peerBlocked&&typeof result.peerBlocked==='object');
189
- const environment=!noEffect&&!ownerAnswer&&!peerBlocked&&result.retryClass===RETRY_CLASS_ENVIRONMENT&&result.attemptConsumed===false;
190
- return {
191
- retryClass:noEffect?'infrastructure':ownerAnswer?'owner-answer':peerBlocked?'peer-blocked':environment?RETRY_CLASS_ENVIRONMENT:'business',
192
- effectState:result.effectState??'unknown',
193
- resumable:noEffect,
194
- consumesBusinessRetry:!noEffect&&!ownerAnswer&&!peerBlocked&&!environment,
195
- };
196
- }
197
-
198
- /**
199
- * A row retired while still queued - `api reconcile --drop` (result.verdict `dropped`) or a goal revision
200
- * that superseded it (result.reason `goal-revision-superseded`) - with no dispatch binding in its payload.
201
- * It ran nothing, so it is no attempt: never a retry predecessor, never a cut seam, never the latest job
202
- * of its ordinal (inc-5005d003825a: a retry chained to a dropped ordinal-1 row as business attempt 2 and
203
- * lost the owner-answer lineage; inc-b428eb47fde3: ordinal 2 read a dropped seam as dependency-failed).
204
- */
205
- export function retiredBeforeDispatch(job){
206
- if(job?.status!=='cancelled')return false;
207
- const result=resultOf(job),payload=payloadOf(job);
208
- if(result.verdict!=='dropped'&&result.reason!=='goal-revision-superseded')return false;
209
- const runtime=payload.hierarchy?.runtime??{};
210
- const bound=Boolean(job.worker_id||payload.managed||payload.orca||runtime.dispatchId||runtime.terminalHandle
211
- ||(Array.isArray(payload.rejectedDispatches)&&payload.rejectedDispatches.length));
212
- return !bound;
213
- }
214
-
215
-
216
- /** The cut slice a job row carries, or null: {id, ordinal} identify one bounded SAME-op slice. */
217
- export function cutOf(job){
218
- const cut=payloadOf(job).cut;
219
- return cut&&cut.id!=null&&cut.ordinal!=null?{id:String(cut.id),ordinal:Number(cut.ordinal),total:Number(cut.total)}:null;
220
- }
221
-
222
-
223
- /* ------------------------------------------------------------ work units (H3, H4, H5) */
224
-
225
- /** The default try budget of a unit (Q13; DBTREE work_units.try_budget). Only the owner or the Supervisor raises one. */
226
- export const UNIT_TRY_BUDGET=5;
227
- const shortDigest=value=>createHash('sha256').update(value).digest('hex').slice(0,16);
228
- const lineagePaths=list=>(Array.isArray(list)?list:[]).map(item=>typeof item==='string'?item:item?.path).filter(p=>typeof p==='string'&&p.trim());
229
- const normList=list=>[...new Set(lineagePaths(list).map(p=>p.replace(/\\/g,'/').replace(/\/\*\*$/,'').replace(/\/+$/,'')))].sort();
230
-
231
- /**
232
- * The work identity of a job (DBTREE work_units.subject_key): a cut slice is `cut:<id>#<ordinal>`, an op about one
233
- * named subject `subject:<s>`, else the digest of its records, else of its owned paths. One op, one subject key and
234
- * one goal revision are ONE unit (UNIQUE(workflow_id, op_id, subject_key, goal_revision)): a try of the same work can
235
- * never start a fresh budget.
236
- */
237
- export function unitSubjectKey({cut=null,params=null,records=[],ownedPaths=[]}={}){
238
- if(cut?.id!=null&&cut?.ordinal!=null)return `cut:${cut.id}#${Number(cut.ordinal)}`;
239
- const subject=typeof params?.subject==='string'&&params.subject.trim()?params.subject.trim():null;
240
- if(subject)return `subject:${subject}`;
241
- const recs=normList(records);
242
- if(recs.length)return `records:${shortDigest(recs.join('|'))}`;
243
- return `paths:${shortDigest(normList(ownedPaths).join('|'))}`;
244
- }
245
-
246
- /** Two jobs are tries of one work unit (jobs.unit_id). */
247
- export const sameUnit=(a,b)=>Boolean(a?.unit_id&&a.unit_id===b?.unit_id);
248
-
249
- const OPEN_TRY=['queued','ready','leased','running','answering','reported','deciding','effect_unknown'];
250
- const refuseUnit=(message,code,extra={})=>Object.assign(new Error(message),{code,...extra});
251
- /** The jobs.retry_class of a successor of `last` (DBTREE: business | infra | resume | follow-up). */
252
- const retryClassOf=(last,disposition)=>last.status==='cancelled'?'resume'
253
- :disposition?.retryClass==='business'?'business'
254
- :disposition?.retryClass==='infrastructure'||disposition?.retryClass===RETRY_CLASS_ENVIRONMENT?'infra':'follow-up';
255
- /**
256
- * Admit one more try of a unit (the code side of DBTREE jobs_enqueue_guard + work_units_done_guard), pure over the
257
- * unit's row and its tries. `tries` are the unit's jobs with {job_id, status, try_no, result_json?} (result_json the
258
- * settle result retryDisposition reads); `unit` is the work_units row. Returns {tryNo, retryOf, resumeOf, retryClass,
259
- * reopen} or throws a typed refusal:
260
- * unit-in-flight a try of the unit is still open (edit it, or let it settle first)
261
- * unit-already-passed the unit is done; a re-run needs an explicit reopen with a reason (H5)
262
- * retry-lineage-invalid retryOf is not the unit's latest try, or that try did not fail (H4)
263
- * unit-try-budget-exhausted try_no would pass work_units.try_budget; only the owner or the Supervisor raises it (H3)
264
- */
265
- export function admitUnitTry({unit=null,tries=[],retryOf=null,reopen=null}={}){
266
- const ordered=[...tries].sort((a,b)=>Number(a.try_no)-Number(b.try_no));
267
- const last=ordered.at(-1)??null;
268
- if(!unit||!last){
269
- if(retryOf)throw refuseUnit(`--retry-of ${retryOf} names no earlier try of this unit`,'retry-lineage-invalid');
270
- return {tryNo:1,retryOf:null,resumeOf:null,retryClass:null,reopen:null};
271
- }
272
- const open=ordered.filter(job=>OPEN_TRY.includes(job.status));
273
- if(open.length)throw refuseUnit(`unit ${unit.unit_id} already has an open try ${open.map(j=>`${j.job_id} (${j.status})`).join(', ')}: edit that try (api graph-edit widen|params) or let it settle`,'unit-in-flight',{open:open.map(j=>j.job_id)});
274
- if(retryOf&&retryOf!==last.job_id)throw refuseUnit(`--retry-of ${retryOf} is not the latest try of unit ${unit.unit_id} (${last.job_id} is): a retry follows the unit's latest failed try`,'retry-lineage-invalid',{latest:last.job_id});
275
- const done=unit.state==='done'||last.status==='succeeded';
276
- if(done&&!(reopen?.reason&&reopen?.by))throw refuseUnit(`unit ${unit.unit_id} already passed (${last.job_id}); running it again needs an explicit reopen with a reason (--reopen <reason>)`,'unit-already-passed',{passed:last.job_id});
277
- if(retryOf&&!done&&!RETRYABLE_JOB_STATUSES.includes(last.status))throw refuseUnit(`--retry-of ${retryOf} is ${last.status}: a retry follows a FAILED or awaiting_owner try of the same unit`,'retry-lineage-invalid');
278
- const tryNo=Number(last.try_no)+1;
279
- if(tryNo-(ordered.length-spentTries(ordered))>Number(unit.try_budget))throw refuseUnit(`unit ${unit.unit_id} spent ${spentTries(ordered)} of its ${unit.try_budget} tries: the owner or the Supervisor decides (api unit --raise-budget), never another try`,'unit-try-budget-exhausted',{tries:Number(last.try_no),budget:Number(unit.try_budget)});
280
- if(done)return {tryNo,retryOf:null,resumeOf:null,retryClass:'follow-up',reopen:{reason:String(reopen.reason),by:String(reopen.by)}};
281
- const disposition=RETRYABLE_JOB_STATUSES.includes(last.status)?retryDisposition(last):null;
282
- const resume=last.status==='cancelled';
283
- return {tryNo,retryOf:resume?null:last.job_id,resumeOf:resume?last.job_id:null,retryClass:retryClassOf(last,disposition),reopen:null};
284
- }
@@ -1,10 +0,0 @@
1
- // digest.mjs — the one SHA-256 the runtime hashes with. A leaf: every file's integrity digest,
2
- // every ledger input ref and every stamped asset is this function, so a byte stream and a file
3
- // never disagree about what their digest is.
4
- import { createHash } from 'node:crypto';
5
- import fs from 'node:fs';
6
-
7
- /** The lowercase hex SHA-256 of `value` (string, Buffer or TypedArray). */
8
- export const sha256 = (value) => createHash('sha256').update(value).digest('hex');
9
- /** The SHA-256 of the file's exact bytes; throws when the file cannot be read. */
10
- export const sha256File = (file) => sha256(fs.readFileSync(file));