@starci/hfs 4.0.9 → 4.0.10
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/LICENSE +21 -0
- package/lint/run.mjs +80 -7
- package/package.json +3 -2
- package/runtime/engine/admission.mjs +308 -0
- package/runtime/engine/canonical-json.mjs +10 -0
- package/runtime/engine/config.mjs +36 -44
- package/runtime/engine/db/blob.mjs +315 -0
- package/runtime/engine/db/ledger-paths.mjs +83 -0
- package/runtime/engine/db/ledger.mjs +1118 -0
- package/runtime/engine/db/machine-connection.mjs +135 -0
- package/runtime/engine/db/machine-schema.mjs +84 -0
- package/runtime/engine/db/machine.mjs +1367 -0
- package/runtime/engine/db/migrations/machine/0001-init.sql +924 -0
- package/runtime/engine/db/migrations/runtime/0001-init.sql +1108 -0
- package/runtime/engine/db/provider-reservations.mjs +101 -0
- package/runtime/engine/digest.mjs +16 -0
- package/runtime/engine/refuse.mjs +11 -0
- package/runtime/engine/secrets.mjs +130 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +10 -10
- package/runtime/knowledge/hfs/rules.yaml +8 -8
- package/runtime/modules/kernel/failure-codes.yaml +40 -0
- package/runtime/modules/models/registry.yaml +1 -39
- package/runtime/modules/models/runtimes.yaml +0 -6
- package/runtime/modules/ops/_labels.yaml +53 -0
- package/runtime/scripts/api/fs/lib.mjs +6 -0
- package/runtime/scripts/api/git/lib.mjs +2 -0
- package/runtime/scripts/api/node/lib.mjs +14 -0
- package/runtime/scripts/api/node/spawn-node.mjs +6 -0
- package/runtime/scripts/api/process/lib.mjs +111 -0
- package/runtime/scripts/api/process/owned-process.mjs +78 -0
- package/runtime/scripts/api/process/resolve-real-tool.mjs +46 -0
- package/runtime/scripts/api/process/run-program.mjs +7 -0
- package/runtime/scripts/api/process/stop-owned-process.mjs +9 -0
- package/runtime/scripts/api/sops/decrypt.mjs +9 -16
- package/runtime/scripts/api/sops/lib.mjs +230 -10
- package/runtime/scripts/api/sops/seal.mjs +8 -4
- package/runtime/scripts/connectors/lib.mjs +488 -0
- package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +1 -1
- package/runtime/scripts/hfs/secret.mjs +45 -11
- package/runtime/scripts/lib/clip.mjs +19 -0
- package/runtime/scripts/lib/display-names.mjs +259 -0
- package/runtime/scripts/lib/example-refs.mjs +158 -0
- package/runtime/scripts/lib/fs-kind.mjs +14 -4
- package/runtime/scripts/lib/json-schema.mjs +52 -0
- package/runtime/scripts/lib/mutation-fence.mjs +15 -0
- package/runtime/scripts/lib/path-key.mjs +4 -4
- package/runtime/scripts/lib/process-identity.mjs +6 -0
- package/runtime/scripts/lib/read-yaml.mjs +18 -0
- package/runtime/scripts/lib/redact.mjs +161 -0
- package/runtime/scripts/lib/sleep.mjs +7 -0
- package/runtime/scripts/lib/sops-envelope.mjs +54 -0
- package/runtime/scripts/lib/source-phrases.mjs +34 -0
- package/runtime/scripts/lib/sqlite.mjs +21 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) StarCi contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/lint/run.mjs
CHANGED
|
@@ -19,10 +19,11 @@ import fs from 'node:fs';
|
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
import { createRequire } from 'node:module';
|
|
21
21
|
import { spawnSync } from 'node:child_process';
|
|
22
|
-
import { fileURLToPath } from 'node:url';
|
|
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';
|
|
25
25
|
import { STYLE_GLOB } from '../sync/index.mjs';
|
|
26
|
+
import { braceVariants, globExpression } from '../runtime/scripts/lib/glob.mjs';
|
|
26
27
|
|
|
27
28
|
export const LINT_SCHEMA = 'starci/lint@1';
|
|
28
29
|
/** The side whose stylesheets stylelint judges. */
|
|
@@ -75,12 +76,14 @@ function runLinter({ cwd, pkg, bin, args, bound = null }) {
|
|
|
75
76
|
return { error: `${pkg} is not installed for ${cwd}` };
|
|
76
77
|
}
|
|
77
78
|
const run = spawnSync(process.execPath, [...(bound ? linterBoundArgs() : []), entry, ...args], { cwd, encoding: 'utf8', maxBuffer: 512 * 1024 * 1024, ...(bound ? { env: { ...process.env, [BOUND_ENV]: bound } } : {}) });
|
|
78
|
-
//
|
|
79
|
-
//
|
|
79
|
+
// ESLint prints its report on stdout; stylelint uses stderr. JSON does not prove a completed child: only exits 0/1
|
|
80
|
+
// without a spawn error or signal are measurable, and exit 1 must carry findings.
|
|
80
81
|
let results;
|
|
81
82
|
try { results = JSON.parse(run.stdout.trim() || run.stderr); } catch { return { error: `${pkg} produced no json report in ${path.basename(cwd)}/ (exit ${run.status}): ${String(run.stderr || run.stdout).trim().split('\n')[0]}` }; }
|
|
82
83
|
if (!Array.isArray(results)) return { error: `${pkg} produced a report that is not a result list` };
|
|
83
|
-
|
|
84
|
+
const completed = !run.error && !run.signal && [0, 1].includes(run.status)
|
|
85
|
+
&& (run.status === 0 || results.some((result) => result?.messages?.length || result?.warnings?.length));
|
|
86
|
+
return { results, ...(completed ? {} : { error: `${pkg} could not complete in ${path.basename(cwd)}/ (exit ${run.status}, signal ${run.signal ?? 'none'}): ${String(run.error?.message || run.stderr || run.stdout || '').trim().split('\n')[0]}` }) };
|
|
84
87
|
}
|
|
85
88
|
|
|
86
89
|
const eslintFindings = (results, appRoot) => results.flatMap((result) => (result.messages ?? []).map((message) => ({
|
|
@@ -98,6 +101,69 @@ const byLocation = (a, b) => `${a.path ?? ''}:${String(a.line ?? 0).padStart(7,
|
|
|
98
101
|
/** The files of `changed` (app-relative) below `side`/, relative to the side folder. */
|
|
99
102
|
const onSide = (changed, side) => changed.filter((file) => file.startsWith(`${side}/`)).map((file) => file.slice(side.length + 1));
|
|
100
103
|
|
|
104
|
+
const fileKey = (file) => process.platform === 'win32' ? path.resolve(file).toLowerCase() : path.resolve(file);
|
|
105
|
+
const inside = (root, file) => { const rel = path.relative(root, file); return rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel); };
|
|
106
|
+
|
|
107
|
+
/** Read the installed canon's source selectors, then enumerate that scope; unavailable or unsupported selectors cannot prove coverage. */
|
|
108
|
+
async function sourceFiles({ repoRoot, cwd, side, scope }) {
|
|
109
|
+
const entry = createRequire(path.join(cwd, 'package.json')).resolve(`@starci/eslint-canon-${side}`);
|
|
110
|
+
if (!inside(fs.realpathSync(repoRoot), fs.realpathSync(entry))) throw new Error('the installed canon is outside the app root');
|
|
111
|
+
const canon = await import(pathToFileURL(entry).href);
|
|
112
|
+
const factory = side === 'be' ? canon.starciBeConfig : canon.starciFeConfig;
|
|
113
|
+
if (typeof factory !== 'function' || typeof canon.loadHfs !== 'function') throw new Error('the installed canon has no supported config factory');
|
|
114
|
+
const config = await factory({ hfs: canon.loadHfs(pathToFileURL(path.join(cwd, 'eslint.config.mjs')).href) });
|
|
115
|
+
if (!Array.isArray(config)) throw new Error('the installed canon did not return a flat config');
|
|
116
|
+
const selectors = [], ignored = [];
|
|
117
|
+
for (const block of config) {
|
|
118
|
+
if (!block || typeof block !== 'object') throw new Error('the installed canon returned an invalid config block');
|
|
119
|
+
for (const [field, target] of [['files', selectors], ['ignores', ignored]]) {
|
|
120
|
+
if (block[field] === undefined) continue;
|
|
121
|
+
if (!Array.isArray(block[field]) || block[field].some((glob) => typeof glob !== 'string' || glob.startsWith('!'))) throw new Error(`unsupported canon ${field} selectors`);
|
|
122
|
+
if (field === 'ignores' && Object.keys(block).some((key) => !['ignores', 'name'].includes(key))) throw new Error('unsupported non-global canon ignores');
|
|
123
|
+
target.push(...block[field].flatMap((glob) => braceVariants(glob).map(globExpression)));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (!selectors.length) throw new Error('the installed canon has no source selectors');
|
|
127
|
+
const matches = (patterns, file) => patterns.some((pattern) => pattern.test(file));
|
|
128
|
+
const files = [];
|
|
129
|
+
const visit = (folder) => {
|
|
130
|
+
for (const child of fs.readdirSync(path.join(cwd, folder), { withFileTypes: true })) {
|
|
131
|
+
const file = posix(path.join(folder, child.name));
|
|
132
|
+
// Ask the canon's global ignore patterns before entering output or installed-package trees.
|
|
133
|
+
if (matches(ignored, file) || matches(ignored, `${file}/__starci_scope_probe__`)) continue;
|
|
134
|
+
if (child.isSymbolicLink()) throw new Error(`cannot enumerate symlink ${file}`);
|
|
135
|
+
if (child.isDirectory()) visit(file);
|
|
136
|
+
else if (child.isFile() && matches(selectors, file)) files.push(file);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
visit(scope ?? '');
|
|
140
|
+
return files.sort();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Every requested source needs an explicit, well-formed result; suppressed findings and foreign paths cannot witness a completed lint. */
|
|
144
|
+
function checkedEslintResults({ results, expected, cwd, scope, changed }) {
|
|
145
|
+
const required = new Set(expected.map((file) => fileKey(path.join(cwd, file))));
|
|
146
|
+
const seen = new Set(), valid = [], errors = [];
|
|
147
|
+
for (const result of results) {
|
|
148
|
+
if (!result || typeof result.filePath !== 'string' || !path.isAbsolute(result.filePath) || !Array.isArray(result.messages)
|
|
149
|
+
|| result.messages.some((message) => !message || typeof message.message !== 'string')
|
|
150
|
+
|| (result.suppressedMessages !== undefined && !Array.isArray(result.suppressedMessages))) {
|
|
151
|
+
errors.push('eslint produced a malformed file result'); continue;
|
|
152
|
+
}
|
|
153
|
+
const key = fileKey(result.filePath);
|
|
154
|
+
if (!inside(cwd, result.filePath) || (scope && !inside(path.join(cwd, scope), result.filePath)) || (changed && !required.has(key))) {
|
|
155
|
+
errors.push(`eslint produced a result outside the requested scope: ${result.filePath}`); continue;
|
|
156
|
+
}
|
|
157
|
+
if (seen.has(key)) errors.push(`eslint produced duplicate results for ${result.filePath}`);
|
|
158
|
+
seen.add(key);
|
|
159
|
+
if (result.suppressedMessages?.length) errors.push(`eslint suppressed findings for ${result.filePath}`);
|
|
160
|
+
valid.push(result);
|
|
161
|
+
}
|
|
162
|
+
const missing = expected.filter((file) => !seen.has(fileKey(path.join(cwd, file))));
|
|
163
|
+
if (missing.length) errors.push(`eslint produced no result for requested source files: ${missing.join(', ')}`);
|
|
164
|
+
return { results: valid, errors };
|
|
165
|
+
}
|
|
166
|
+
|
|
101
167
|
/**
|
|
102
168
|
* Run the whole lint of the app at `repoRoot`. `hfsCheck(repoRoot)` returns the `starci app check` result (`{ findings, tracked }`); the CLI
|
|
103
169
|
* injects it. Returns `{ report, sonar, exit }`; `sonar` is the merged Generic Issue Import document.
|
|
@@ -130,9 +196,16 @@ export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles =
|
|
|
130
196
|
if (workspace !== null && side !== STYLE_SIDE) { engines.eslint.sides[side] = { files: 0, skipped: `outside --workspace ${workspace}` }; continue; }
|
|
131
197
|
const sources = onSide(existing, side).filter((file) => /\.(?:[cm]?[jt]sx?)$/.test(file));
|
|
132
198
|
if (changed !== null && !sources.length) { engines.eslint.sides[side] = { files: 0, skipped: 'no changed source file' }; continue; }
|
|
133
|
-
const
|
|
199
|
+
const cwd = path.join(repoRoot, side);
|
|
200
|
+
let expected;
|
|
201
|
+
try { expected = changed ? sources : await sourceFiles({ repoRoot, cwd, side, scope: onFe }); }
|
|
202
|
+
catch (error) { errors.push(`eslint source scope is unavailable in ${side}/: ${String(error?.message ?? error)}`); engines.eslint.sides[side] = { files: 0 }; continue; }
|
|
203
|
+
const linted = runLinter({ cwd, pkg: 'eslint', bin: 'eslint', args: ['--format', 'json', ...(opts.fix ? ['--fix'] : []), ...(changed ? sources : [onFe ?? '.'])], bound: repoRoot });
|
|
134
204
|
if (linted.error) errors.push(linted.error);
|
|
135
|
-
|
|
205
|
+
if (linted.results) {
|
|
206
|
+
const checked = checkedEslintResults({ results: linted.results, expected, cwd, scope: onFe, changed: changed !== null });
|
|
207
|
+
errors.push(...checked.errors);
|
|
208
|
+
linted.results = checked.results;
|
|
136
209
|
// The side canon names side-relative paths in its messages; the report names every path from the app root.
|
|
137
210
|
const appRelative = appRelativeMessages(side, path.join(repoRoot, side));
|
|
138
211
|
for (const result of linted.results) for (const message of result.messages ?? []) message.message = appRelative(message.message);
|
|
@@ -148,7 +221,7 @@ export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles =
|
|
|
148
221
|
if (changed === null || styles.length) {
|
|
149
222
|
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'] : [])] });
|
|
150
223
|
if (linted.error) errors.push(linted.error);
|
|
151
|
-
|
|
224
|
+
if (linted.results) {
|
|
152
225
|
const appRelative = appRelativeMessages(STYLE_SIDE, path.join(repoRoot, STYLE_SIDE));
|
|
153
226
|
for (const result of linted.results) for (const warning of result.warnings ?? []) warning.text = appRelative(warning.text);
|
|
154
227
|
raw.stylelint = linted.results;
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@starci/hfs",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.10",
|
|
4
4
|
"description": "The StarCi app command implementation for scaffolding, linting, checking and synchronizing one product repository.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"license": "
|
|
6
|
+
"license": "MIT",
|
|
7
7
|
"private": false,
|
|
8
8
|
"exports": {
|
|
9
9
|
".": "./src/main.mjs",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"smol-toml": "1.9.0"
|
|
16
16
|
},
|
|
17
17
|
"files": [
|
|
18
|
+
"LICENSE",
|
|
18
19
|
"src/main.mjs",
|
|
19
20
|
"emit/**",
|
|
20
21
|
"lint/**",
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { refuse } from './refuse.mjs';
|
|
3
|
+
|
|
4
|
+
const PATH_LEASE_PREFIX='path:';
|
|
5
|
+
const GLOB_META=/[*?[\]{}]/;
|
|
6
|
+
// Next.js App Router spells route segments as literal directory names: dynamic `[lang]`, catch-all
|
|
7
|
+
// `[...slug]` and optional catch-all `[[...opt]]`, optionally behind an intercept prefix `(.)`, `(..)`,
|
|
8
|
+
// `(...)` or `(..)(..)`. Route groups `(group)`, parallel slots `@slot` and intercepts on a static name
|
|
9
|
+
// carry no glob meta at all. A segment of exactly this shape is a concrete name, never a character class;
|
|
10
|
+
// every consumer that hands an owned path to a glob engine escapes it (git: ownedPathspec below).
|
|
11
|
+
const APP_ROUTER_SEGMENT=/^(?:\(\.{1,3}\))*(?:\[\[\.\.\.[A-Za-z0-9_$-]+\]\]|\[(?:\.\.\.)?[A-Za-z0-9_$-]+\])$/;
|
|
12
|
+
|
|
13
|
+
/** A Next.js App Router bracket segment (`[id]`, `[...slug]`, `[[...opt]]`, `(.)[id]`) — a literal directory name. */
|
|
14
|
+
export const isAppRouterSegment=part=>APP_ROUTER_SEGMENT.test(String(part??''));
|
|
15
|
+
|
|
16
|
+
/** A path segment that is a real glob (`*`, `?`, `{a,b}`, a bare character class), not an App Router name. */
|
|
17
|
+
export const isGlobSegment=part=>GLOB_META.test(String(part??''))&&!isAppRouterSegment(part);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The git pathspec for one concrete owned path. Git reads a plain pathspec as a glob, so `src/app/[id]`
|
|
21
|
+
* would also match a sibling `src/app/i`; `:(literal)` pins it to the named directory. Admission
|
|
22
|
+
* refuses a glob, so every owned path is literal.
|
|
23
|
+
*/
|
|
24
|
+
export const ownedPathspec=spec=>`:(literal)${String(spec??'').replace(/\\/g,'/')||'.'}`;
|
|
25
|
+
|
|
26
|
+
const plainPath=value=>typeof value==='string'?value:value?.path;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Canonical workspace-relative path prefix used by planning, leases and report boundaries. In a
|
|
30
|
+
* multi-repository project the caller includes the repository binding prefix (for example `my-app/`).
|
|
31
|
+
* A directory prefix is spelled either bare (`docs/`) or with a trailing `/**`, which normalizes to
|
|
32
|
+
* the same prefix; every other glob, absolute path and parent traversal is refused because it is not
|
|
33
|
+
* a concrete ownership boundary. A Next.js App Router segment (`[lang]`, `[...slug]`, `[[...opt]]`,
|
|
34
|
+
* `(group)`, `@slot`, `(.)photo`) is a literal directory name and is admitted as one.
|
|
35
|
+
*/
|
|
36
|
+
export function normalizeOwnedPath(value){
|
|
37
|
+
let input=String(plainPath(value)??'').trim().replace(/\\/g,'/');
|
|
38
|
+
input=input.replace(/\/\*\*\/$/,'').replace(/\/\*\*$/,'');
|
|
39
|
+
if(!input||input.startsWith('/')||/^[A-Za-z]:\//.test(input))throw Error(`owned path must be repository-relative: ${JSON.stringify(plainPath(value)??value)}`);
|
|
40
|
+
const parts=[];
|
|
41
|
+
for(const part of input.split('/')){
|
|
42
|
+
if(!part||part==='.')continue;
|
|
43
|
+
if(part==='..')throw Error(`owned path must not traverse its repository: ${JSON.stringify(plainPath(value)??value)}`);
|
|
44
|
+
if(isGlobSegment(part))throw Error(`owned path must be a concrete prefix, not a glob: ${JSON.stringify(plainPath(value)??value)}`);
|
|
45
|
+
parts.push(part);
|
|
46
|
+
}
|
|
47
|
+
if(!parts.length)throw Error(`owned path must name a concrete repository-relative prefix: ${JSON.stringify(plainPath(value)??value)}`);
|
|
48
|
+
return parts.join('/');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Normalize, de-duplicate and collapse descendants already covered by an owned ancestor. */
|
|
52
|
+
export function normalizeOwnedPaths(values=[]){
|
|
53
|
+
const paths=[...new Set(values.map(normalizeOwnedPath))].sort((a,b)=>a.length-b.length||a.localeCompare(b));
|
|
54
|
+
return paths.filter((candidate,index)=>!paths.slice(0,index).some(parent=>ownedPathsIntersect(parent,candidate)));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Path-prefix overlap: equality or either concrete path being below the other. */
|
|
58
|
+
export function ownedPathsIntersect(left,right){
|
|
59
|
+
const a=normalizeOwnedPath(left),b=normalizeOwnedPath(right);
|
|
60
|
+
return a===b||a.startsWith(`${b}/`)||b.startsWith(`${a}/`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The durable resource identity for one normalized concrete owned path. */
|
|
64
|
+
export const ownedPathLeaseKey=value=>`${PATH_LEASE_PREFIX}${normalizeOwnedPath(value)}`;
|
|
65
|
+
|
|
66
|
+
/** One capacity-one request per minimal owned prefix. */
|
|
67
|
+
export const ownedPathLeaseRequests=values=>normalizeOwnedPaths(values).map(path=>({resourceKey:ownedPathLeaseKey(path),units:1}));
|
|
68
|
+
|
|
69
|
+
const leasePath=resourceKey=>String(resourceKey??'').startsWith(PATH_LEASE_PREFIX)
|
|
70
|
+
?String(resourceKey).slice(PATH_LEASE_PREFIX.length):null;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The spelling two lease paths are compared in. The same file must compare equal however a workflow
|
|
74
|
+
* spelled it (a past incident had `apps/app/src/messages/vi.json` spelled bare for
|
|
75
|
+
* the fe repository while the repository-prefixed spelling of the same file never overlapped it). `canonicalOf`
|
|
76
|
+
* (scripts/kernel/lease-canon.mjs) resolves a path to its app-relative form in a bound app
|
|
77
|
+
* (be/<path>, fe/<path>) — for a held row through its holder job, so every spelling of one file
|
|
78
|
+
* compares as one key; paths on Windows compare case-insensitively, as its file
|
|
79
|
+
* systems do.
|
|
80
|
+
*/
|
|
81
|
+
export const leaseCompareForm=(leasePathValue,{canonicalOf=null,row=null,platform=process.platform}={})=>{
|
|
82
|
+
let value=normalizeOwnedPath(leasePathValue);
|
|
83
|
+
if(canonicalOf){try{value=normalizeOwnedPath(canonicalOf(value,row)??value);}catch{/* an unresolvable spelling compares as written */}}
|
|
84
|
+
return platform==='win32'?value.toLowerCase():value;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Find durable path leases that overlap a requested parent/child prefix. Lease-row existence is the
|
|
89
|
+
* fence; expiry is only a recovery signal and does not by itself prove the prior worker has no effect.
|
|
90
|
+
* Both sides are compared in leaseCompareForm, so a bare and a repository-prefixed spelling of one
|
|
91
|
+
* file overlap and the same relative path in two repositories does not.
|
|
92
|
+
*/
|
|
93
|
+
export function findOwnedPathLeaseConflicts(db,requests,{excludeJobId=null,canonicalOf=null,platform=process.platform}={}){
|
|
94
|
+
const requested=[...new Set(requests.map(item=>item?.resourceKey??item).filter(key=>leasePath(key)!==null))];
|
|
95
|
+
if(!requested.length)return [];
|
|
96
|
+
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();
|
|
97
|
+
const formOf=new Map();
|
|
98
|
+
const compare=(key,row)=>{
|
|
99
|
+
const id=`${row?.job_id??''}\0${key}`;
|
|
100
|
+
if(!formOf.has(id))formOf.set(id,leaseCompareForm(leasePath(key),{canonicalOf,row,platform}));
|
|
101
|
+
return formOf.get(id);
|
|
102
|
+
};
|
|
103
|
+
const conflicts=[];
|
|
104
|
+
for(const requestKey of requested){
|
|
105
|
+
const requestPath=compare(requestKey,null);
|
|
106
|
+
for(const row of held){
|
|
107
|
+
if(excludeJobId&&row.job_id===excludeJobId)continue;
|
|
108
|
+
if(ownedPathsIntersect(requestPath,compare(row.resource_key,row)))conflicts.push({requested:requestKey,held:row.resource_key,...row});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return conflicts;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Existing durable path and resource checks, read inside the caller's reservation transaction. */
|
|
115
|
+
export function resourceAdmission(db,repoNeeds,{at,canonicalOf=null}={}){
|
|
116
|
+
const reasons=[];
|
|
117
|
+
const pathConflicts=findOwnedPathLeaseConflicts(db,repoNeeds,{canonicalOf});
|
|
118
|
+
for(const conflict of pathConflicts)reasons.push(`resource ${conflict.requested} overlaps durable lease ${conflict.held} held by ${conflict.job_id}`);
|
|
119
|
+
for(const item of repoNeeds){
|
|
120
|
+
const row=db.prepare('SELECT capacity FROM resources WHERE resource_key=?').get(item.resourceKey);
|
|
121
|
+
const capacity=row?.capacity??1;
|
|
122
|
+
const used=db.prepare('SELECT COALESCE(SUM(units),0) u FROM leases WHERE resource_key=? AND expires_at>?').get(item.resourceKey,at).u;
|
|
123
|
+
if(used+item.units>capacity)reasons.push(`resource ${item.resourceKey} capacity ${capacity} has ${used} used and needs ${item.units}`);
|
|
124
|
+
}
|
|
125
|
+
if(reasons.length)return {ok:false,reason:reasons.join('; '),reasons,pathConflicts:pathConflicts.map(({requested,held,job_id,workflow_id,op_id,expires_at})=>({requested,held,jobId:job_id,workflowId:workflow_id,opId:op_id,expiresAt:expires_at}))};
|
|
126
|
+
return {ok:true};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The concurrent-operation ceiling one workflow is admitted at. Two declared numbers meet here and
|
|
131
|
+
* the LOWER of them admits: the owner's `budgets.maxOps` (per workflow) and `maxParallelOps` from
|
|
132
|
+
* modules/models/runtimes.yaml (worker-wide). A null, absent or non-positive value is unbounded, so
|
|
133
|
+
* a workflow with no owner budget still meets the worker ceiling. A parallelism gear raises what
|
|
134
|
+
* `starci kernel estimate` requests and never raises either of these.
|
|
135
|
+
*/
|
|
136
|
+
export function opSlotCeiling({maxOps=null,maxParallelOps=null}={}){
|
|
137
|
+
const positive=value=>{const n=Number(value);return Number.isInteger(n)&&n>0?n:null;};
|
|
138
|
+
const owner=positive(maxOps),workers=positive(maxParallelOps);
|
|
139
|
+
if(owner===null&&workers===null)return {ceiling:null,source:null};
|
|
140
|
+
if(owner===null)return {ceiling:workers,source:'maxParallelOps'};
|
|
141
|
+
if(workers===null)return {ceiling:owner,source:'budgets.maxOps'};
|
|
142
|
+
return owner<=workers?{ceiling:owner,source:'budgets.maxOps'}:{ceiling:workers,source:'maxParallelOps'};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Admission against that ceiling. `running` is how many operations of the one workflow already hold
|
|
147
|
+
* a slot; a job at or above the ceiling is refused `max-ops` rather than launched and left to
|
|
148
|
+
* discover the cap from a provider.
|
|
149
|
+
*/
|
|
150
|
+
export function admitOpSlot({running=0,maxOps=null,maxParallelOps=null}={}){
|
|
151
|
+
const {ceiling,source}=opSlotCeiling({maxOps,maxParallelOps});
|
|
152
|
+
const held=Math.max(0,Number(running)||0);
|
|
153
|
+
if(ceiling===null)return {ok:true,running:held,ceiling:null,ceilingSource:null,reason:null};
|
|
154
|
+
return held<ceiling
|
|
155
|
+
?{ok:true,running:held,ceiling,ceilingSource:source,reason:null}
|
|
156
|
+
:{ok:false,running:held,ceiling,ceilingSource:source,reason:'max-ops'};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Count this workflow's durable held slots and judge the same declared ceiling at preflight and reservation. */
|
|
160
|
+
export function workflowOpSlots(db,workflowId,{excludeJobId=null,holdingStatuses,maxOps=null,maxParallelOps=null}={}){
|
|
161
|
+
if(!Array.isArray(holdingStatuses)||!holdingStatuses.length)throw Error('workflowOpSlots requires canonical held statuses');
|
|
162
|
+
const running=db.prepare(`SELECT count(*) n FROM jobs WHERE workflow_id=? AND kind<>'kernel' AND job_id<>?
|
|
163
|
+
AND status IN (${holdingStatuses.map(()=>'?').join(',')})`).get(workflowId,excludeJobId??'',...holdingStatuses).n;
|
|
164
|
+
return admitOpSlot({running,maxOps,maxParallelOps});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const rowObject=value=>{
|
|
168
|
+
if(value&&typeof value==='object')return value;
|
|
169
|
+
if(typeof value!=='string'||!value.trim())return {};
|
|
170
|
+
try{const parsed=JSON.parse(value);return parsed&&typeof parsed==='object'?parsed:{};}catch{return {};}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The durable payload/result of a job-shaped row: the parsed object when the row carries the decoded
|
|
175
|
+
* field (a mapped ledger row), else the tolerant parse of its `*_json` text — a missing, blank or
|
|
176
|
+
* unparsable field reads as {}. Several scripts spell this by hand; these are the one pair to cite.
|
|
177
|
+
*/
|
|
178
|
+
export const payloadOf=job=>rowObject(job?.payload??job?.payload_json);
|
|
179
|
+
export const resultOf=job=>rowObject(job?.result??job?.result_json);
|
|
180
|
+
|
|
181
|
+
/** The settled verdict of an attempt that asked the owner and waits for the answer. */
|
|
182
|
+
export const AWAITING_OWNER='awaiting-owner';
|
|
183
|
+
/**
|
|
184
|
+
* jobs.status of a try that ended asking the owner (report outcome ask): settled, but neither a failure nor a spent try
|
|
185
|
+
* (its unit's try budget and business retries ignore it). A retry or resume may follow it exactly as it follows `failed`.
|
|
186
|
+
*/
|
|
187
|
+
export const AWAITING_OWNER_STATUS='awaiting_owner';
|
|
188
|
+
export const RETRYABLE_JOB_STATUSES=Object.freeze(['failed',AWAITING_OWNER_STATUS]);
|
|
189
|
+
/** Every jobs.status that holds nothing the runtime still needs (mirrors engine/db/ledger.mjs JOB_STATUSES.settled). */
|
|
190
|
+
export const SETTLED_JOB_LIST=Object.freeze(['succeeded','failed',AWAITING_OWNER_STATUS,'cancelled']);
|
|
191
|
+
/** The tries of a unit that spent budget: every try but the ones that only waited on the owner. */
|
|
192
|
+
export const spentTries=tries=>tries.filter(job=>job.status!==AWAITING_OWNER_STATUS).length;
|
|
193
|
+
// An attempt the environment killed with effects on the tree (a host terminal wipe: every Orca terminal
|
|
194
|
+
// gone at once, scripts/kernel/cli.mjs hostTerminalWipeOf) settles failed with this retryClass: its retry
|
|
195
|
+
// is a new durable attempt that continues the partial tree and spends no business retry.
|
|
196
|
+
export const RETRY_CLASS_ENVIRONMENT='environment';
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Classify a settled attempt for retry accounting. Infrastructure is free only when the durable result
|
|
200
|
+
* explicitly proves `effectState: none`; unknown or partial effects consume the ordinary business budget.
|
|
201
|
+
* An attempt settled `awaiting-owner` asked a question and did not fail: its successor is a new durable
|
|
202
|
+
* attempt (the ask attempt ran) that spends no business retry. Nor does one settled `peerBlocked`
|
|
203
|
+
* (starci kernel settle: every red check was a peer's change, scripts/kernel/gate-attribution.mjs), nor one settled
|
|
204
|
+
* with retryClass environment (RETRY_CLASS_ENVIRONMENT).
|
|
205
|
+
*/
|
|
206
|
+
export function retryDisposition(job){
|
|
207
|
+
const result=resultOf(job),reason=String(result.reason??'');
|
|
208
|
+
const infrastructure=result.retryClass==='infrastructure'||reason==='dispatch-rejected'||reason==='provider-unavailable';
|
|
209
|
+
const explicitlyReusable=(result.retryable===true&&result.attemptConsumed===false)||result.retryClass==='infrastructure';
|
|
210
|
+
const noEffect=infrastructure&&result.effectState==='none'&&explicitlyReusable;
|
|
211
|
+
const ownerAnswer=!noEffect&&result.verdict===AWAITING_OWNER;
|
|
212
|
+
const peerBlocked=!noEffect&&!ownerAnswer&&result.verdict!=='pass'&&Boolean(result.peerBlocked&&typeof result.peerBlocked==='object');
|
|
213
|
+
const environment=!noEffect&&!ownerAnswer&&!peerBlocked&&result.retryClass===RETRY_CLASS_ENVIRONMENT&&result.attemptConsumed===false;
|
|
214
|
+
return {
|
|
215
|
+
retryClass:noEffect?'infrastructure':ownerAnswer?'owner-answer':peerBlocked?'peer-blocked':environment?RETRY_CLASS_ENVIRONMENT:'business',
|
|
216
|
+
effectState:result.effectState??'unknown',
|
|
217
|
+
resumable:noEffect,
|
|
218
|
+
consumesBusinessRetry:!noEffect&&!ownerAnswer&&!peerBlocked&&!environment,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* A row retired while still queued - `starci kernel reconcile --drop` (result.verdict `dropped`) or a goal revision
|
|
224
|
+
* that superseded it (result.reason `goal-revision-superseded`) - with no dispatch binding in its payload.
|
|
225
|
+
* It ran nothing, so it is no attempt: never a retry predecessor, never a cut seam, never the latest job
|
|
226
|
+
* of its ordinal (inc-5005d003825a: a retry chained to a dropped ordinal-1 row as business attempt 2 and
|
|
227
|
+
* lost the owner-answer lineage; inc-b428eb47fde3: ordinal 2 read a dropped seam as dependency-failed).
|
|
228
|
+
*/
|
|
229
|
+
export function retiredBeforeDispatch(job){
|
|
230
|
+
if(job?.status!=='cancelled')return false;
|
|
231
|
+
const result=resultOf(job),payload=payloadOf(job);
|
|
232
|
+
if(result.verdict!=='dropped'&&result.reason!=='goal-revision-superseded')return false;
|
|
233
|
+
const runtime=payload.hierarchy?.runtime??{};
|
|
234
|
+
const bound=Boolean(job.worker_id||payload.managed||payload.orca||runtime.dispatchId||runtime.terminalHandle
|
|
235
|
+
||(Array.isArray(payload.rejectedDispatches)&&payload.rejectedDispatches.length));
|
|
236
|
+
return !bound;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
/** The cut slice a job row carries, or null: {id, ordinal} identify one bounded SAME-op slice. */
|
|
241
|
+
export function cutOf(job){
|
|
242
|
+
const cut=payloadOf(job).cut;
|
|
243
|
+
return cut&&cut.id!=null&&cut.ordinal!=null?{id:String(cut.id),ordinal:Number(cut.ordinal),total:Number(cut.total)}:null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
/* ------------------------------------------------------------ work units (H3, H4, H5) */
|
|
248
|
+
|
|
249
|
+
/** The default try budget of a unit (Q13; DBTREE work_units.try_budget). Only the owner or the Supervisor raises one. */
|
|
250
|
+
export const UNIT_TRY_BUDGET=5;
|
|
251
|
+
const shortDigest=value=>createHash('sha256').update(value).digest('hex').slice(0,16);
|
|
252
|
+
const lineagePaths=list=>(Array.isArray(list)?list:[]).map(item=>typeof item==='string'?item:item?.path).filter(p=>typeof p==='string'&&p.trim());
|
|
253
|
+
const normList=list=>[...new Set(lineagePaths(list).map(p=>p.replace(/\\/g,'/').replace(/\/\*\*$/,'').replace(/\/+$/,'')))].sort();
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The work identity of a job (DBTREE work_units.subject_key): a cut slice is `cut:<id>#<ordinal>`, an op about one
|
|
257
|
+
* named subject `subject:<s>`, else the digest of its records, else of its owned paths. One op, one subject key and
|
|
258
|
+
* one goal revision are ONE unit (UNIQUE(workflow_id, op_id, subject_key, goal_revision)): a try of the same work can
|
|
259
|
+
* never start a fresh budget.
|
|
260
|
+
*/
|
|
261
|
+
export function unitSubjectKey({cut=null,params=null,records=[],ownedPaths=[]}={}){
|
|
262
|
+
if(cut?.id!=null&&cut?.ordinal!=null)return `cut:${cut.id}#${Number(cut.ordinal)}`;
|
|
263
|
+
const subject=typeof params?.subject==='string'&¶ms.subject.trim()?params.subject.trim():null;
|
|
264
|
+
if(subject)return `subject:${subject}`;
|
|
265
|
+
const recs=normList(records);
|
|
266
|
+
if(recs.length)return `records:${shortDigest(recs.join('|'))}`;
|
|
267
|
+
return `paths:${shortDigest(normList(ownedPaths).join('|'))}`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Two jobs are tries of one work unit (jobs.unit_id). */
|
|
271
|
+
export const sameUnit=(a,b)=>Boolean(a?.unit_id&&a.unit_id===b?.unit_id);
|
|
272
|
+
|
|
273
|
+
const OPEN_TRY=['queued','ready','leased','running','answering','reported','deciding','effect_unknown'];
|
|
274
|
+
|
|
275
|
+
/** The jobs.retry_class of a successor of `last` (DBTREE: business | infra | resume | follow-up). */
|
|
276
|
+
const retryClassOf=(last,disposition)=>last.status==='cancelled'?'resume'
|
|
277
|
+
:disposition?.retryClass==='business'?'business'
|
|
278
|
+
:disposition?.retryClass==='infrastructure'||disposition?.retryClass===RETRY_CLASS_ENVIRONMENT?'infra':'follow-up';
|
|
279
|
+
/**
|
|
280
|
+
* Admit one more try of a unit (the code side of DBTREE jobs_enqueue_guard + work_units_done_guard), pure over the
|
|
281
|
+
* unit's row and its tries. `tries` are the unit's jobs with {job_id, status, try_no, result_json?} (result_json the
|
|
282
|
+
* settle result retryDisposition reads); `unit` is the work_units row. Returns {tryNo, retryOf, resumeOf, retryClass,
|
|
283
|
+
* reopen} or throws a typed refusal:
|
|
284
|
+
* unit-in-flight a try of the unit is still open (edit it, or let it settle first)
|
|
285
|
+
* unit-already-passed the unit is done; a re-run needs an explicit reopen with a reason (H5)
|
|
286
|
+
* retry-lineage-invalid retryOf is not the unit's latest try, or that try did not fail (H4)
|
|
287
|
+
* unit-try-budget-exhausted try_no would pass work_units.try_budget; only the owner or the Supervisor raises it (H3)
|
|
288
|
+
*/
|
|
289
|
+
export function admitUnitTry({unit=null,tries=[],retryOf=null,reopen=null}={}){
|
|
290
|
+
const ordered=[...tries].sort((a,b)=>Number(a.try_no)-Number(b.try_no));
|
|
291
|
+
const last=ordered.at(-1)??null;
|
|
292
|
+
if(!unit||!last){
|
|
293
|
+
if(retryOf)throw refuse(`--retry-of ${retryOf} names no earlier try of this unit`,'retry-lineage-invalid');
|
|
294
|
+
return {tryNo:1,retryOf:null,resumeOf:null,retryClass:null,reopen:null};
|
|
295
|
+
}
|
|
296
|
+
const open=ordered.filter(job=>OPEN_TRY.includes(job.status));
|
|
297
|
+
if(open.length)throw refuse(`unit ${unit.unit_id} already has an open try ${open.map(j=>`${j.job_id} (${j.status})`).join(', ')}: edit that try (starci kernel graph-edit widen|params) or let it settle`,'unit-in-flight',{open:open.map(j=>j.job_id)});
|
|
298
|
+
if(retryOf&&retryOf!==last.job_id)throw refuse(`--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});
|
|
299
|
+
const done=unit.state==='done'||last.status==='succeeded';
|
|
300
|
+
if(done&&!(reopen?.reason&&reopen?.by))throw refuse(`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});
|
|
301
|
+
if(retryOf&&!done&&!RETRYABLE_JOB_STATUSES.includes(last.status))throw refuse(`--retry-of ${retryOf} is ${last.status}: a retry follows a FAILED or awaiting_owner try of the same unit`,'retry-lineage-invalid');
|
|
302
|
+
const tryNo=Number(last.try_no)+1;
|
|
303
|
+
if(tryNo-(ordered.length-spentTries(ordered))>Number(unit.try_budget))throw refuse(`unit ${unit.unit_id} spent ${spentTries(ordered)} of its ${unit.try_budget} tries: the owner or the Supervisor decides (starci kernel unit --raise-budget), never another try`,'unit-try-budget-exhausted',{tries:Number(last.try_no),budget:Number(unit.try_budget)});
|
|
304
|
+
if(done)return {tryNo,retryOf:null,resumeOf:null,retryClass:'follow-up',reopen:{reason:String(reopen.reason),by:String(reopen.by)}};
|
|
305
|
+
const disposition=RETRYABLE_JOB_STATUSES.includes(last.status)?retryDisposition(last):null;
|
|
306
|
+
const resume=last.status==='cancelled';
|
|
307
|
+
return {tryNo,retryOf:resume?null:last.job_id,resumeOf:resume?last.job_id:null,retryClass:retryClassOf(last,disposition),reopen:null};
|
|
308
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// canonical-json.mjs — the one canonical JSON the runtime digests structured values with: object keys
|
|
2
|
+
// sorted, arrays in order, so two equal values always serialize to the same bytes. A leaf beside digest.mjs.
|
|
3
|
+
import {isPlainObject} from './plain-object.mjs';
|
|
4
|
+
|
|
5
|
+
/** The canonical JSON text of `value`: keys sorted at every depth. */
|
|
6
|
+
export function canonicalJSON(value) {
|
|
7
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJSON).join(',')}]`;
|
|
8
|
+
if (isPlainObject(value)) return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${canonicalJSON(value[k])}`).join(',')}}`;
|
|
9
|
+
return JSON.stringify(value);
|
|
10
|
+
}
|