@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
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// clip.mjs — shortening text for a message, a log line or a finding.
|
|
2
|
+
|
|
3
|
+
/** `text` cut to at most `n` characters; a cut text ends in an ellipsis. */
|
|
4
|
+
export const clip = (text, n) => { const s = String(text ?? ''); return s.length > n ? `${s.slice(0, n - 1)}…` : s; };
|
|
5
|
+
|
|
6
|
+
/** `text` squashed to one line: whitespace runs collapsed, ends trimmed. */
|
|
7
|
+
export const squash = (text) => String(text ?? '').replace(/\s+/g, ' ').trim();
|
|
8
|
+
|
|
9
|
+
/** `text` on one line (whitespace runs collapsed, ends trimmed), cut to at most `n` characters. */
|
|
10
|
+
export const clipLine = (text, n) => clip(squash(text), n);
|
|
11
|
+
|
|
12
|
+
/** `text` on one line (whitespace runs collapsed, ends trimmed), cut to at most `n` characters — a plain cut, no ellipsis. */
|
|
13
|
+
export const oneLine = (text, n = 380) => squash(text).slice(0, n);
|
|
14
|
+
|
|
15
|
+
/** The last `n` lines of `text` (ends trimmed) joined with `join`, cut to at most `max` characters. */
|
|
16
|
+
export const tailLines = (text, n = 30, { join = '\n', max = Infinity } = {}) => {
|
|
17
|
+
const tail = String(text ?? '').trim().split(/\r?\n/).slice(-n).join(join);
|
|
18
|
+
return Number.isFinite(max) ? tail.slice(0, max) : tail;
|
|
19
|
+
};
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// display-names.mjs — the names a person reads for a workflow, an operation and an op job.
|
|
2
|
+
//
|
|
3
|
+
// Owner request 2026-09-27: kernels, workflows and op workers need easy-to-understand names.
|
|
4
|
+
// The ids stay the keys everywhere (workflow_id, op_id, job_id); these are labels only:
|
|
5
|
+
// workflow workflows.display_name (starci kernel rename; derived at define-goal), else the goal slug in
|
|
6
|
+
// workflows.title, else the workflow id. Vietnamese, `<Product> · <what it does>`.
|
|
7
|
+
// operation modules/ops/_labels.yaml (vi/en), else the op id.
|
|
8
|
+
// op job `<op label> · <what> · <workflow name>`: `what` is the job's target — an explicit
|
|
9
|
+
// enqueue --what, the work-graph node it covers, the Work record it owns, the slice
|
|
10
|
+
// its title names, its feature path, its cut — at most JOB_WHAT_MAX characters.
|
|
11
|
+
// Every human-facing surface reads these helpers: the [Kernel]/[Op] Orca titles, starci kernel status,
|
|
12
|
+
// the supervisor digest and progress report, Telegram notices and the harness UI.
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { clipLine, squash } from './clip.mjs';
|
|
16
|
+
import { ownerLanguage, translator } from './i18n.mjs';
|
|
17
|
+
import { list } from './list.mjs';
|
|
18
|
+
import { parseJson } from './json.mjs';
|
|
19
|
+
import { readYamlFile } from './read-yaml.mjs';
|
|
20
|
+
import { normRel, pathsOverlap } from './path-key.mjs';
|
|
21
|
+
import { PRODUCT_NAME_SEGMENT } from './example-refs.mjs';
|
|
22
|
+
|
|
23
|
+
export const WORKFLOW_NAME_MAX = 48;
|
|
24
|
+
const DISPLAY_NAME_LIMIT = 80;
|
|
25
|
+
export const JOB_WHAT_MAX = 40;
|
|
26
|
+
const NAME_SEPARATOR = ' · ';
|
|
27
|
+
|
|
28
|
+
const LABELS_FILE = new URL('../../modules/ops/_labels.yaml', import.meta.url);
|
|
29
|
+
let labelsCache = null;
|
|
30
|
+
/** {op: {vi, en}} from modules/ops/_labels.yaml; {} when the file is unreadable. */
|
|
31
|
+
export function opLabelMap() {
|
|
32
|
+
if (labelsCache) return labelsCache;
|
|
33
|
+
labelsCache = Object.freeze({ ...(readYamlFile(LABELS_FILE)?.labels ?? {}) });
|
|
34
|
+
return labelsCache;
|
|
35
|
+
}
|
|
36
|
+
/** The human label of an op (a `op#instance` leg label reads as its op), else the op id itself. */
|
|
37
|
+
export function opLabel(op, language = ownerLanguage()) {
|
|
38
|
+
const id = String(op ?? '').trim();
|
|
39
|
+
if (!id) return '';
|
|
40
|
+
const entry = opLabelMap()[id] ?? opLabelMap()[id.split('#')[0]];
|
|
41
|
+
const label = entry?.[language] ?? entry?.vi;
|
|
42
|
+
return typeof label === 'string' && label.trim() ? label.trim() : id;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One line, whitespace collapsed; '' for nothing. */
|
|
46
|
+
/** `text` cut at a word boundary to at most `max` characters, ending in an ellipsis when cut. */
|
|
47
|
+
function clipWords(text, max) {
|
|
48
|
+
const s = squash(text);
|
|
49
|
+
if (s.length <= max) return s;
|
|
50
|
+
const room = s.slice(0, max - 1);
|
|
51
|
+
const at = room.lastIndexOf(' ');
|
|
52
|
+
return `${(at >= max / 2 ? room.slice(0, at) : room).replace(/[\s,;:.·–—-]+$/, '')}…`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A display name as the api stores it: one line, trimmed, 1..DISPLAY_NAME_LIMIT characters. Throws
|
|
57
|
+
* {code: 'rename-bad-title'} otherwise.
|
|
58
|
+
*/
|
|
59
|
+
export function normalizeDisplayName(name) {
|
|
60
|
+
const s = squash(name);
|
|
61
|
+
if (!s) throw Object.assign(new Error('the display name is empty'), { code: 'rename-bad-title' });
|
|
62
|
+
if (s.length > DISPLAY_NAME_LIMIT) throw Object.assign(new Error(`the display name is ${s.length} characters; at most ${DISPLAY_NAME_LIMIT}`), { code: 'rename-bad-title' });
|
|
63
|
+
return s;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A workflows row's name: display_name, else the goal slug (title), else the workflow id. */
|
|
67
|
+
export function workflowDisplayName(row) {
|
|
68
|
+
if (!row) return null;
|
|
69
|
+
return squash(row.display_name) || squash(row.title) || row.workflow_id || null;
|
|
70
|
+
}
|
|
71
|
+
/** The name of one workflow of `db` (its id when the row is missing). */
|
|
72
|
+
export function workflowNameOf(db, workflowId) {
|
|
73
|
+
if (!workflowId) return null;
|
|
74
|
+
try { return workflowDisplayName(db.prepare('SELECT * FROM workflows WHERE workflow_id=?').get(workflowId)) ?? workflowId; }
|
|
75
|
+
catch { return workflowId; }
|
|
76
|
+
}
|
|
77
|
+
/** workflow_id -> name for every workflow of `db` (`displayOnly` keeps only rows with a display_name). */
|
|
78
|
+
export function workflowNames(db, { displayOnly = false } = {}) {
|
|
79
|
+
try {
|
|
80
|
+
const rows = db.prepare('SELECT * FROM workflows').all().filter((row) => !displayOnly || row.display_name);
|
|
81
|
+
return new Map(rows.map((row) => [row.workflow_id, displayOnly ? row.display_name : workflowDisplayName(row)]));
|
|
82
|
+
} catch { return new Map(); }
|
|
83
|
+
}
|
|
84
|
+
/** `Name (id)` for a line that must still carry the key, or the id alone when there is no other name. */
|
|
85
|
+
export const nameWithId = (name, id) => (name && name !== id ? `${name} (${id})` : String(id ?? name ?? ''));
|
|
86
|
+
|
|
87
|
+
/** The name of an attached file record ({name?, abs}): its declared name, else its basename, forward-slashed. */
|
|
88
|
+
export const attachedNameOf = (file) => String(file.name ?? path.basename(String(file.abs ?? ''))).replace(/\\/g, '/');
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------------- workflow names
|
|
91
|
+
const BRAND_CASE = { starci: 'StarCi' };
|
|
92
|
+
const REPO_ROLE_SUFFIX = /[-_](backend|be|frontend|fe|api|server|web|app)$/i;
|
|
93
|
+
/** The product's display word(s) from a project or repository name: `shop-be` -> `Shop`. */
|
|
94
|
+
export function productName(raw) {
|
|
95
|
+
const base = squash(raw).replace(REPO_ROLE_SUFFIX, '');
|
|
96
|
+
if (!base) return '';
|
|
97
|
+
return base.split(/[-_\s]+/).filter(Boolean)
|
|
98
|
+
.map((w) => BRAND_CASE[w.toLowerCase()] ?? (w.length <= 3 && /^[a-z]+$/i.test(w) && w === w.toUpperCase() ? w : `${w[0].toUpperCase()}${w.slice(1)}`)).join(' ');
|
|
99
|
+
}
|
|
100
|
+
/** The first clause of the owner's goal text: up to the first sentence end, line break, colon or dash aside. */
|
|
101
|
+
function firstClause(text) {
|
|
102
|
+
const s = String(text ?? '').split(/\r?\n/).map(squash).find(Boolean) ?? '';
|
|
103
|
+
const clause = s.split(/(?<=[.!?;:])\s|\s[—–-]\s|\s\(/)[0] ?? '';
|
|
104
|
+
return clause.replace(/[\s.!?;:,]+$/, '').trim();
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* A readable name for a new goal (define-goal): `<Product> · <first clause of the goal text>`, at
|
|
108
|
+
* most WORKFLOW_NAME_MAX characters; `fallback` (the goal slug) when the text yields nothing usable.
|
|
109
|
+
*/
|
|
110
|
+
export function deriveWorkflowDisplayName({ text, product = null, fallback = null, max = WORKFLOW_NAME_MAX } = {}) {
|
|
111
|
+
const prod = productName(product);
|
|
112
|
+
// The product leads the name already: a goal text that opens with it ("<product>: …") starts after it.
|
|
113
|
+
let body = squash(text);
|
|
114
|
+
if (prod && body.toLowerCase().startsWith(prod.toLowerCase())) body = body.slice(prod.length).replace(/^[\s:·,–—-]+/, '');
|
|
115
|
+
const clause = firstClause(body);
|
|
116
|
+
const head = prod ? `${prod}${NAME_SEPARATOR}` : '';
|
|
117
|
+
const room = max - head.length;
|
|
118
|
+
if (!clause || !/[\p{L}\p{N}]/u.test(clause) || room < 8) return fallback ? clipWords(fallback, max) : (prod || null);
|
|
119
|
+
return `${head}${clipWords(`${clause[0].toUpperCase()}${clause.slice(1)}`, room)}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ------------------------------------------------------------------------------------ op job names
|
|
123
|
+
const keyOf = (p) => normRel(p, { fold: true });
|
|
124
|
+
// A path this broad names no target in particular (the whole Work tree, every feature).
|
|
125
|
+
const BROAD = /^(\.starciwork|\.starciwork\/index\.yaml|\.starciwork\/features|\.starciwork\/features\/index\.yaml|src|apps|packages)?$/;
|
|
126
|
+
const meets = pathsOverlap;
|
|
127
|
+
|
|
128
|
+
/** The work-graph node the job covers most specifically (its longest owned path that meets the job's). */
|
|
129
|
+
function coveredNode(nodes, paths) {
|
|
130
|
+
const keys = paths.map(keyOf).filter((k) => k && !BROAD.test(k));
|
|
131
|
+
let best = null, bestLen = -1;
|
|
132
|
+
for (const node of list(nodes)) {
|
|
133
|
+
for (const owned of list(node?.ownedPaths).map(keyOf).filter((k) => k && !BROAD.test(k))) {
|
|
134
|
+
if (keys.some((k) => meets(k, owned)) && owned.length > bestLen) { best = node; bestLen = owned.length; }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return best;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const titleCache = new Map();
|
|
141
|
+
/** The `title:` of the Work record at `<repo>/<p>` (a record folder or its index.yaml), else null. */
|
|
142
|
+
function recordTitle(repo, p) {
|
|
143
|
+
if (!repo || !p) return null;
|
|
144
|
+
const rel = String(p).replaceAll('\\', '/').replace(/\/+$/, '');
|
|
145
|
+
if (!rel.startsWith('.starciwork/') || BROAD.test(rel.toLowerCase())) return null;
|
|
146
|
+
const file = path.join(repo, rel.endsWith('.yaml') ? rel : `${rel}/index.yaml`);
|
|
147
|
+
if (!file.endsWith('index.yaml')) return null;
|
|
148
|
+
let mtime = null;
|
|
149
|
+
try { mtime = fs.statSync(file).mtimeMs; } catch { return null; }
|
|
150
|
+
const hit = titleCache.get(file);
|
|
151
|
+
if (hit && hit.mtime === mtime) return hit.title;
|
|
152
|
+
let title = null;
|
|
153
|
+
try {
|
|
154
|
+
const head = fs.readFileSync(file, 'utf8').slice(0, 4096);
|
|
155
|
+
const m = /^title:[ \t]*(.*)$/m.exec(head);
|
|
156
|
+
let value = m ? m[1] : '';
|
|
157
|
+
// A folded or literal block scalar (title: >-) continues on the indented lines right below it.
|
|
158
|
+
if (m && /^[>|][-+]?\s*$/.test(value)) {
|
|
159
|
+
const below = head.slice(m.index + m[0].length).split(/\r?\n/).slice(1);
|
|
160
|
+
const end = below.findIndex((l) => !/^\s+\S/.test(l));
|
|
161
|
+
value = (end < 0 ? below : below.slice(0, end)).join(' ');
|
|
162
|
+
}
|
|
163
|
+
title = squash(value.replace(/^(['"])(.*)\1$/, '$2')) || null;
|
|
164
|
+
} catch { title = null; }
|
|
165
|
+
if (titleCache.size > 2000) titleCache.clear();
|
|
166
|
+
titleCache.set(file, { mtime, title });
|
|
167
|
+
return title;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Folder names that say what kind of record sits there, not which one.
|
|
171
|
+
const FAMILY = new Set(['ui', 'fr', 'br', 'nfr', 'uat', 'sds', 'contract', 'data', 'journey', 'decision', 'scope', 'business', 'integration',
|
|
172
|
+
'impl', 'operations', 'evidence', 'e', 'assets', 'tools', 'src', 'apps', 'features', 'provision', 'shell', 'brand', 'lib', 'components', 'pages']);
|
|
173
|
+
const words = (s) => s.replace(/[-_]+/g, ' ').trim();
|
|
174
|
+
/** A short label from an owned path: `features/collab/ui/office` -> `collab / office`. */
|
|
175
|
+
export function pathLabel(p) {
|
|
176
|
+
const segs = String(p ?? '').replaceAll('\\', '/').split('/').filter(Boolean).filter((s) => s !== '.starciwork');
|
|
177
|
+
const at = segs.indexOf('features');
|
|
178
|
+
if (at >= 0 && segs[at + 1]) {
|
|
179
|
+
const feature = segs[at + 1];
|
|
180
|
+
const rest = segs.slice(at + 2).filter((s) => !s.includes('.') && !FAMILY.has(s.toLowerCase()) && !PRODUCT_NAME_SEGMENT.test(s));
|
|
181
|
+
const leaf = rest.at(-1);
|
|
182
|
+
return leaf && leaf !== feature ? `${words(feature)} / ${words(leaf)}` : words(feature);
|
|
183
|
+
}
|
|
184
|
+
const plain = segs.filter((s) => !FAMILY.has(s.toLowerCase()) && !/^wf-/i.test(s));
|
|
185
|
+
const last = plain.at(-1);
|
|
186
|
+
return last ? words(last.replace(/\.[a-z0-9]+$/i, '')) : null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** A work-graph node's short label from its id: `login.sign-in` -> `login / sign in`. */
|
|
190
|
+
export function nodeLabel(node) {
|
|
191
|
+
const id = String(node?.id ?? '');
|
|
192
|
+
const domain = String(node?.domain ?? '');
|
|
193
|
+
const leaf = domain && id.startsWith(`${domain}.`) ? id.slice(domain.length + 1) : id;
|
|
194
|
+
return domain && leaf && leaf !== domain ? `${words(domain)} / ${words(leaf.replaceAll('.', ' '))}` : words(id.replaceAll('.', ' '));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* What an op job works on, for a human: at most JOB_WHAT_MAX characters, or null when nothing names it.
|
|
199
|
+
* `payload` is the job payload, `op` its op id, `nodes` the workflow's latest work-graph nodes, `repo`
|
|
200
|
+
* the ledger owner (to read Work record titles).
|
|
201
|
+
*/
|
|
202
|
+
export function jobWhat({ payload, op = null, nodes = null, repo = null, max = JOB_WHAT_MAX, language = ownerLanguage() } = {}) {
|
|
203
|
+
const p = payload ?? {};
|
|
204
|
+
const owned = list(p.owned_paths ?? p.ownedPaths).map((x) => (typeof x === 'string' ? x : x?.path)).filter(Boolean);
|
|
205
|
+
const records = list(p.records).filter((x) => typeof x === 'string');
|
|
206
|
+
const cut = p.cut && typeof p.cut === 'object' ? p.cut : null;
|
|
207
|
+
const title = squash(p.title);
|
|
208
|
+
const opId = String(op ?? p.opId ?? '');
|
|
209
|
+
const rest = opId && title.toLowerCase().startsWith(opId.toLowerCase()) ? title.slice(opId.length).replace(/^[\s:#·–—-]+/, '') : null;
|
|
210
|
+
const specific = [...owned, ...records].filter((x) => !BROAD.test(keyOf(x)));
|
|
211
|
+
// A title that already fits is the target's own name; a sentence-long one gives way to the short id.
|
|
212
|
+
const fits = (t) => (t && squash(t).length <= max ? t : null);
|
|
213
|
+
const node = coveredNode(nodes, owned);
|
|
214
|
+
const candidates = [
|
|
215
|
+
() => squash(p.displayWhat),
|
|
216
|
+
() => { for (const x of specific) { const t = fits(recordTitle(repo, x)); if (t) return t; } return null; },
|
|
217
|
+
() => fits(node?.title),
|
|
218
|
+
() => (node?.id ? nodeLabel(node) : null),
|
|
219
|
+
() => rest,
|
|
220
|
+
() => (specific[0] ? pathLabel(specific[0]) : null),
|
|
221
|
+
() => (rest === null && title && title !== opId ? firstClause(title) : null),
|
|
222
|
+
() => (cut?.id ? words(String(cut.id)) : null),
|
|
223
|
+
];
|
|
224
|
+
let what = null;
|
|
225
|
+
for (const pick of candidates) { const v = squash(pick()); if (v) { what = v; break; } }
|
|
226
|
+
const tail = cut && Number(cut.total) > 1 && Number.isInteger(Number(cut.ordinal)) ? ` ${cut.ordinal}/${cut.total}` : '';
|
|
227
|
+
if (!what) return tail ? translator(language)('part{tail}', { tail }) : null;
|
|
228
|
+
return `${clipWords(what, Math.max(8, max - tail.length))}${tail}`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** `<op label> · <what> · <workflow name>`, skipping a missing part. */
|
|
232
|
+
export function jobDisplayName({ op, what = null, workflowName = null, language = ownerLanguage() } = {}) {
|
|
233
|
+
return [opLabel(op, language), squash(what), squash(workflowName)].filter(Boolean).join(NAME_SEPARATOR);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The display name of one jobs row of `db`: reads its workflow's name and latest work-graph nodes unless
|
|
238
|
+
* given. `cache` (a Map) shares the workflow reads across many jobs.
|
|
239
|
+
*/
|
|
240
|
+
export function jobDisplayNameOf(db, job, { repo = null, workflowName = null, nodes = undefined, cache = null, language = ownerLanguage() } = {}) {
|
|
241
|
+
if (!job) return null;
|
|
242
|
+
const payload = typeof job.payload_json === 'string' ? parseJson(job.payload_json, {}) : (job.payload ?? {});
|
|
243
|
+
const op = job.op_id ?? job.opId ?? payload.opId ?? null;
|
|
244
|
+
const wf = job.workflow_id ?? job.workflowId ?? null;
|
|
245
|
+
const memo = cache ?? new Map();
|
|
246
|
+
const wfInfo = memo.get(wf) ?? (() => {
|
|
247
|
+
const info = { name: workflowNameOf(db, wf), nodes: null, repo: null };
|
|
248
|
+
// The ledger owner's root (to read Work record titles) when the caller names none: the workflow's first source root.
|
|
249
|
+
try { info.repo = parseJson(db.prepare('SELECT source_roots_json FROM workflows WHERE workflow_id=?').get(wf)?.source_roots_json)?.[0] ?? null; } catch { info.repo = null; }
|
|
250
|
+
try {
|
|
251
|
+
const row = db.prepare("SELECT graph_json FROM work_graph_versions WHERE workflow_id=? ORDER BY version DESC LIMIT 1").get(wf);
|
|
252
|
+
info.nodes = row ? parseJson(row.graph_json)?.nodes ?? null : null;
|
|
253
|
+
} catch { info.nodes = null; }
|
|
254
|
+
memo.set(wf, info);
|
|
255
|
+
return info;
|
|
256
|
+
})();
|
|
257
|
+
const what = jobWhat({ payload, op, nodes: nodes === undefined ? wfInfo.nodes : nodes, repo: repo ?? wfInfo.repo, language });
|
|
258
|
+
return jobDisplayName({ op, what, workflowName: workflowName ?? wfInfo.name, language });
|
|
259
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// example-refs.mjs — the ONE runtime source file that may spell a path into the runtime's own examples/
|
|
2
|
+
// tree or a product name the runtime legitimately knows (R206, NO_EXAMPLE_COUPLING): every other source
|
|
3
|
+
// file reaches these through the constants below, and scripts/checks/check-example-coupling.mjs exempts
|
|
4
|
+
// this file's name only - a declared read, never a path allowlist. A constant belongs here only when the
|
|
5
|
+
// runtime genuinely reads that tree or names that product at run time; a pointer that can be generic must
|
|
6
|
+
// be generic in the file that holds it.
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { parseYaml } from '../../engine/yaml.mjs';
|
|
11
|
+
import { validateAgainstSchema } from './json-schema.mjs';
|
|
12
|
+
import { insidePath, samePath, slash } from './path-key.mjs';
|
|
13
|
+
|
|
14
|
+
/** The runtime's example tree root. */
|
|
15
|
+
export const EXAMPLES_ROOT = 'examples';
|
|
16
|
+
|
|
17
|
+
/** Curated basic sample source; operational artifact writers remain external. */
|
|
18
|
+
export const EXAMPLE_RUNTIMES_ROOT = `${EXAMPLES_ROOT}/.runtimes`;
|
|
19
|
+
|
|
20
|
+
/** Every examples/<name>/hfs.json declaration of kind app, sorted by name. */
|
|
21
|
+
export function discoverExampleApps(root) {
|
|
22
|
+
const directory = path.join(root, EXAMPLES_ROOT);
|
|
23
|
+
let entries;
|
|
24
|
+
try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch { return []; }
|
|
25
|
+
return entries.filter((entry) => entry.isDirectory()).filter((entry) => {
|
|
26
|
+
try { return JSON.parse(fs.readFileSync(path.join(directory, entry.name, 'hfs.json'), 'utf8'))?.kind === 'app'; }
|
|
27
|
+
catch { return false; }
|
|
28
|
+
}).map((entry) => entry.name).sort();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The generic services-block template a starcistacks follow-up leg is pointed at (S11-02: one template
|
|
32
|
+
* for every repository - the per-repository blocks it replaced are spec fixtures under
|
|
33
|
+
* tests/fixtures/starcistacks-services/, never a run-time read). */
|
|
34
|
+
export const STACK_DECLARATION_TEMPLATE = `${EXAMPLES_ROOT}/starcistacks-services/template.services.yaml`;
|
|
35
|
+
|
|
36
|
+
/** The reference app declaration scripts/hfs/derived-fields.mjs samples for per-field slot ownership. */
|
|
37
|
+
export const REFERENCE_APP_MANIFEST = `${EXAMPLES_ROOT}/ecommerce-app/hfs.json`;
|
|
38
|
+
|
|
39
|
+
/** The example app whose installed tree an fe-kit peer link points at by default. */
|
|
40
|
+
export const REFERENCE_EXAMPLE_APP = 'ecommerce-app';
|
|
41
|
+
|
|
42
|
+
/** The grammar css families a product repository may declare (`[data-grammar-family="<id>"]`); the
|
|
43
|
+
* runtime's own product maps to the core sheet. */
|
|
44
|
+
export const GRAMMAR_FAMILIES = Object.freeze({ starci: 'core', nivo: 'nivo' });
|
|
45
|
+
|
|
46
|
+
/** Display aliases of legacy workflow slugs that predate workflows.display_name (the progress report's
|
|
47
|
+
* English fallback; the i18n catalog carries the owner's wording for each value). */
|
|
48
|
+
export const WORKFLOW_ALIASES = Object.freeze({
|
|
49
|
+
'nivo-app-auth': 'AUTH (sign-in)', 'nivo-workspace-provision': 'WSPV (buy & provision workspace)',
|
|
50
|
+
'nivo-modules-agentos': 'Modules (AgentOS)', 'nivo-collab-group-chat': 'Collab (group chat)',
|
|
51
|
+
'starci-next-work-and-stacks': 'StarCi Next – work & stacks', 'starci-next-base-repos': 'StarCi Next – base repos',
|
|
52
|
+
'miamia-work-and-stacks': 'Mia Mia – work & stacks', 'miamia-base-repos': 'Mia Mia – base repos',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/** A feature-folder segment that is only the product's own name - no useful leaf (display-names pathLabel). */
|
|
56
|
+
export const PRODUCT_NAME_SEGMENT = /^(nivo|starci|mia)[-\w]*$/i;
|
|
57
|
+
|
|
58
|
+
/** The closed list of product names this runtime shipped against - the names R206
|
|
59
|
+
* (check-example-coupling.mjs) refuses anywhere in runtime source outside this file (a retired
|
|
60
|
+
* name is judged by RT_RETIRED_NAME_LIVE, R207, from modules/kernel/retired-paths.yaml). */
|
|
61
|
+
export const PRODUCT_NAMES = Object.freeze(['mia-mia', 'miamia', 'nivo', 'starci-academy', 'ecommerce-app', 'shape-slot']);
|
|
62
|
+
|
|
63
|
+
/** The catalog of stable pattern IDs pointing at actual executable example source. */
|
|
64
|
+
export const EXAMPLE_CATALOG_FILE = `${EXAMPLES_ROOT}/index.yaml`;
|
|
65
|
+
|
|
66
|
+
function referenceFile(root, relative, { directory = false, exact = false } = {}) {
|
|
67
|
+
if (typeof relative !== 'string' || !relative || relative.includes('\\') || relative.includes(':') || path.isAbsolute(relative)
|
|
68
|
+
|| relative.split('/').some((part) => !part || part === '.' || part === '..')) throw new Error(`invalid example input: ${relative}`);
|
|
69
|
+
const file = path.resolve(root, relative);
|
|
70
|
+
if (!insidePath(root, file)) throw new Error(`example input is outside its declared root: ${relative}`);
|
|
71
|
+
let at = path.parse(file).root;
|
|
72
|
+
for (const segment of file.slice(at.length).split(path.sep).filter(Boolean)) {
|
|
73
|
+
at = path.join(at, segment);
|
|
74
|
+
if (fs.lstatSync(at).isSymbolicLink() || !samePath(fs.realpathSync.native(at), at)
|
|
75
|
+
|| (exact && !fs.readdirSync(path.dirname(at)).includes(path.basename(at))))
|
|
76
|
+
throw new Error(`linked example input: ${relative}`);
|
|
77
|
+
}
|
|
78
|
+
if (!(directory ? fs.lstatSync(file).isDirectory() : fs.lstatSync(file).isFile())
|
|
79
|
+
|| !insidePath(fs.realpathSync.native(root), fs.realpathSync.native(file)))
|
|
80
|
+
throw new Error(`example input is not a contained regular file: ${relative}`);
|
|
81
|
+
return file;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Read and validate complete source references. Duplicate IDs refuse before
|
|
85
|
+
* lookup; source, compiler and test pointers stay within one declared HFS app. */
|
|
86
|
+
export function loadExampleCatalog(root, { file = EXAMPLE_CATALOG_FILE } = {}) {
|
|
87
|
+
const catalog = parseYaml(fs.readFileSync(referenceFile(root, file), 'utf8'));
|
|
88
|
+
const schema = parseYaml(fs.readFileSync(referenceFile(root, 'modules/schemas/code-example-catalog.schema.yaml'), 'utf8'));
|
|
89
|
+
const errors = validateAgainstSchema(catalog, schema);
|
|
90
|
+
if (errors.length) throw new Error(`invalid example catalog: ${errors.join('; ')}`);
|
|
91
|
+
const ids = new Set();
|
|
92
|
+
for (const row of catalog.examples) {
|
|
93
|
+
if (ids.has(row.id)) throw new Error(`duplicate example id: ${row.id}`);
|
|
94
|
+
ids.add(row.id);
|
|
95
|
+
}
|
|
96
|
+
for (const row of catalog.examples) {
|
|
97
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(row.path)) throw new Error(`invalid example app: ${row.path}`);
|
|
98
|
+
const app = path.join(root, EXAMPLES_ROOT, row.path);
|
|
99
|
+
const declaration = JSON.parse(fs.readFileSync(referenceFile(root, `${EXAMPLES_ROOT}/${row.path}/hfs.json`), 'utf8'));
|
|
100
|
+
if (declaration.kind !== 'app') throw new Error(`example has no HFS app declaration: ${row.id}`);
|
|
101
|
+
if (!row.files.includes(row.entrypoint) || !/\.tsx?$/.test(row.entrypoint)) throw new Error(`example entrypoint is not a listed source: ${row.id}`);
|
|
102
|
+
if (new Set(row.relatedRules).size !== row.relatedRules.length) throw new Error(`duplicate example relatedRules: ${row.id}`);
|
|
103
|
+
for (const key of ['files', 'projects', 'tests']) {
|
|
104
|
+
if (new Set(row[key]).size !== row[key].length) throw new Error(`duplicate example ${key}: ${row.id}`);
|
|
105
|
+
for (const relative of row[key]) {
|
|
106
|
+
if (relative.split('/').some((part) => ['node_modules', 'dist', '.next', '_derived', '.git'].includes(part)))
|
|
107
|
+
throw new Error(`derived or installed example input: ${relative}`);
|
|
108
|
+
referenceFile(app, relative);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (row.files.some((relative) => !/\.(?:tsx?|json)$/.test(relative))) throw new Error(`unsupported example source: ${row.id}`);
|
|
112
|
+
}
|
|
113
|
+
return catalog;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Source, compiler and test inputs of selected stable IDs, relative to the runtime.
|
|
117
|
+
* A null selection requests the declared whole catalog; unknown IDs refuse. */
|
|
118
|
+
export function exampleSourcePaths(root, ids = null, options = {}) {
|
|
119
|
+
const catalog = loadExampleCatalog(root, options), selected = new Set(ids ?? catalog.examples.map((row) => row.id));
|
|
120
|
+
const known = new Set(catalog.examples.map((row) => row.id));
|
|
121
|
+
for (const id of selected) if (!known.has(id)) throw new Error(`unknown example id: ${id}`);
|
|
122
|
+
return [...new Set(catalog.examples.filter((row) => selected.has(row.id))
|
|
123
|
+
.flatMap((row) => [...row.files, ...row.projects, ...row.tests].map((relative) => `${EXAMPLES_ROOT}/${row.path}/${relative}`)))];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Actual example apps' own Work trees in app-name order. Dependency/template
|
|
127
|
+
* trees are not app Work; an absent own tree contributes no checker input. */
|
|
128
|
+
export function exampleWorkRoots(root) {
|
|
129
|
+
return discoverExampleApps(root).flatMap((app) => {
|
|
130
|
+
const relative = `${EXAMPLES_ROOT}/${app}/.starciwork/index.yaml`;
|
|
131
|
+
const directory = path.dirname(path.join(root, relative)), stat = fs.lstatSync(directory, { throwIfNoEntry: false });
|
|
132
|
+
if (!stat) return [];
|
|
133
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`invalid example Work directory: ${app}`);
|
|
134
|
+
return [path.dirname(referenceFile(root, relative))];
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Per-call READ options for an actual HFS app's own Work tree. A real project
|
|
139
|
+
* uses the default external store; an example never falls back to that store.
|
|
140
|
+
* The fixture CAS is curated source, not a destination for operational writers. */
|
|
141
|
+
export function exampleArtifactReadOptions(runtimeRoot, workRoot, { resolveRoot = workRoot } = {}) {
|
|
142
|
+
if (typeof runtimeRoot !== 'string' || typeof workRoot !== 'string') throw new TypeError('example artifact context needs runtime and Work paths');
|
|
143
|
+
const base = path.resolve(runtimeRoot), target = path.resolve(workRoot);
|
|
144
|
+
const relative = slash(path.relative(base, target)), parts = relative.split('/');
|
|
145
|
+
if (parts[0]?.toLowerCase() !== EXAMPLES_ROOT) return Object.freeze({});
|
|
146
|
+
const app = parts[1];
|
|
147
|
+
if (parts[0] !== EXAMPLES_ROOT || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(app ?? '')
|
|
148
|
+
|| !discoverExampleApps(base).includes(app) || parts[2] !== '.starciwork')
|
|
149
|
+
throw new Error(`invalid example Work artifact context: ${relative}`);
|
|
150
|
+
referenceFile(base, `${EXAMPLES_ROOT}/${app}/hfs.json`, { exact: true });
|
|
151
|
+
referenceFile(base, `${EXAMPLES_ROOT}/${app}/.starciwork/index.yaml`, { exact: true });
|
|
152
|
+
referenceFile(base, relative, { directory: true, exact: true });
|
|
153
|
+
const resolution = path.resolve(resolveRoot), ownWork = path.join(base, EXAMPLES_ROOT, app, '.starciwork');
|
|
154
|
+
if (!insidePath(resolution, target, { includeSelf: true }) || !insidePath(ownWork, resolution, { includeSelf: true }))
|
|
155
|
+
throw new Error('selected example validation and resolution must share its own Work root');
|
|
156
|
+
referenceFile(base, slash(path.relative(base, resolution)), { directory: true, exact: true });
|
|
157
|
+
return Object.freeze({ root: path.join(base, EXAMPLE_RUNTIMES_ROOT, app, 'artifacts') });
|
|
158
|
+
}
|
|
@@ -2,9 +2,19 @@
|
|
|
2
2
|
// A missing or unreadable path is false/null, never a throw; the stat syscall decides kind.
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
|
|
5
|
-
/** `p` names a file
|
|
6
|
-
export const isFile = (p
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
/** `p` names a file within `maxBytes`; false on any stat error. `followLinks: false` probes the entry itself. */
|
|
6
|
+
export const isFile = (p, { followLinks = true, maxBytes = Infinity } = {}) => {
|
|
7
|
+
try {
|
|
8
|
+
const stat = followLinks ? fs.statSync(p) : fs.lstatSync(p);
|
|
9
|
+
return stat.isFile() && (followLinks || !stat.isSymbolicLink()) && stat.size <= maxBytes;
|
|
10
|
+
} catch { return false; }
|
|
11
|
+
};
|
|
12
|
+
/** `p` names a directory; false on any stat error. `followLinks: false` refuses linked entries. */
|
|
13
|
+
export const isDir = (p, { followLinks = true } = {}) => {
|
|
14
|
+
try {
|
|
15
|
+
const stat = followLinks ? fs.statSync(p) : fs.lstatSync(p);
|
|
16
|
+
return stat.isDirectory() && (followLinks || !stat.isSymbolicLink());
|
|
17
|
+
} catch { return false; }
|
|
18
|
+
};
|
|
9
19
|
/** `p`'s canonical spelling via the filesystem (links resolved), or null when it cannot be read. */
|
|
10
20
|
export const realpathOr = (p) => { try { return fs.realpathSync.native(p); } catch { return null; } };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// json-schema.mjs - a minimal draft-2020-12 JSON schema walker over the keywords the runtime's yaml schemas use.
|
|
2
|
+
import {isPlainObject} from '../../engine/plain-object.mjs';
|
|
3
|
+
|
|
4
|
+
// A minimal draft-2020-12 walker over the keywords op.schema.yaml uses. ajv is
|
|
5
|
+
// a devDependency; the runtime has no npm dependencies, so the walker is here.
|
|
6
|
+
export function validateAgainstSchema(value, schema) {
|
|
7
|
+
const errors = [];
|
|
8
|
+
const resolve = (ref) => String(ref).replace(/^#\//, '').split('/')
|
|
9
|
+
.reduce((node, key) => node?.[key.replaceAll('~1', '/').replaceAll('~0', '~')], schema);
|
|
10
|
+
const typeOk = (node, type) => {
|
|
11
|
+
if (Array.isArray(type)) return type.some((t) => typeOk(node, t));
|
|
12
|
+
if (type === 'object') return isPlainObject(node);
|
|
13
|
+
if (type === 'array') return Array.isArray(node);
|
|
14
|
+
if (type === 'integer') return Number.isInteger(node);
|
|
15
|
+
if (type === 'null') return node === null;
|
|
16
|
+
return typeof node === type;
|
|
17
|
+
};
|
|
18
|
+
const walk = (node, shape, at) => {
|
|
19
|
+
if (!isPlainObject(shape)) return;
|
|
20
|
+
if (shape.$ref) { walk(node, resolve(shape.$ref), at); return; }
|
|
21
|
+
if (shape.type !== undefined && !typeOk(node, shape.type)) {
|
|
22
|
+
errors.push(`${at}: expected ${Array.isArray(shape.type) ? shape.type.join('|') : shape.type}`);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (Object.hasOwn(shape, 'const') && node !== shape.const) errors.push(`${at}: must be ${JSON.stringify(shape.const)}`);
|
|
26
|
+
if (Array.isArray(shape.enum) && !shape.enum.includes(node)) errors.push(`${at}: ${JSON.stringify(node)} is outside [${shape.enum.join(', ')}]`);
|
|
27
|
+
if (typeof node === 'string') {
|
|
28
|
+
if (shape.minLength !== undefined && node.length < shape.minLength) errors.push(`${at}: empty or too short`);
|
|
29
|
+
if (shape.pattern && !new RegExp(shape.pattern, 'u').test(node)) errors.push(`${at}: ${JSON.stringify(node)} does not match ${shape.pattern}`);
|
|
30
|
+
}
|
|
31
|
+
if (typeof node === 'number') {
|
|
32
|
+
if (shape.minimum !== undefined && node < shape.minimum) errors.push(`${at}: below minimum ${shape.minimum}`);
|
|
33
|
+
if (shape.maximum !== undefined && node > shape.maximum) errors.push(`${at}: above maximum ${shape.maximum}`);
|
|
34
|
+
}
|
|
35
|
+
if (Array.isArray(node)) {
|
|
36
|
+
if (shape.minItems !== undefined && node.length < shape.minItems) errors.push(`${at}: needs at least ${shape.minItems} item(s)`);
|
|
37
|
+
if (shape.items) node.forEach((item, i) => walk(item, shape.items, `${at}[${i}]`));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (!isPlainObject(node)) return;
|
|
41
|
+
if (shape.minProperties !== undefined && Object.keys(node).length < shape.minProperties) errors.push(`${at}: needs at least ${shape.minProperties} entr(y|ies)`);
|
|
42
|
+
for (const key of shape.required ?? []) if (!Object.hasOwn(node, key)) errors.push(`${at}: missing ${key}`);
|
|
43
|
+
for (const [key, child] of Object.entries(node)) {
|
|
44
|
+
const where = at === '$' ? `$.${key}` : `${at}.${key}`;
|
|
45
|
+
if (Object.hasOwn(shape.properties ?? {}, key)) walk(child, shape.properties[key], where);
|
|
46
|
+
else if (shape.additionalProperties === false) errors.push(`${where}: unknown key`);
|
|
47
|
+
else if (isPlainObject(shape.additionalProperties)) walk(child, shape.additionalProperties, where);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
walk(value, schema, '$');
|
|
51
|
+
return errors;
|
|
52
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// mutation-fence.mjs — ephemeral admission carried through awaited work, never a state store.
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
|
+
const scope = new AsyncLocalStorage();
|
|
4
|
+
|
|
5
|
+
/** Run one admitted call; the checker is synchronous and reads its existing durable owners again. */
|
|
6
|
+
export const withMutationFence = (check, authority, fn) => scope.run({ check, authority, checking: false }, fn);
|
|
7
|
+
/** Recheck after the actual transaction lock or immediately before a native effect. */
|
|
8
|
+
export function assertMutationFence(boundary) {
|
|
9
|
+
const current = scope.getStore();
|
|
10
|
+
if (!current || current.checking) return;
|
|
11
|
+
current.checking = true;
|
|
12
|
+
try { current.check(boundary); } finally { current.checking = false; }
|
|
13
|
+
}
|
|
14
|
+
/** Identity stamped by existing write owners, never accepted from a command argument. */
|
|
15
|
+
export const mutationAuthority = () => scope.getStore()?.authority ?? null;
|
|
@@ -54,12 +54,12 @@ export const resolvedKey = (p) => foldCase(path.resolve(p));
|
|
|
54
54
|
/** Whether `a` and `b` resolve to the same path (always case-folded, so differently-cased spellings count). */
|
|
55
55
|
export const sameResolvedPath = (a, b) => pathKey(a, { fold: true }) === pathKey(b, { fold: true });
|
|
56
56
|
/**
|
|
57
|
-
* Whether `file` sits
|
|
58
|
-
* realpath-folded treeKey) when
|
|
57
|
+
* Whether `file` sits under `root`; `includeSelf` also admits the root itself. `key` normalizes
|
|
58
|
+
* both sides first (e.g. the registry's realpath-folded treeKey) when their spellings need it.
|
|
59
59
|
*/
|
|
60
|
-
export const insidePath = (root, file, { key = (p) => p } = {}) => {
|
|
60
|
+
export const insidePath = (root, file, { key = (p) => p, includeSelf = false } = {}) => {
|
|
61
61
|
const rel = path.relative(key(root), key(file));
|
|
62
|
-
return Boolean(rel) && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
62
|
+
return (includeSelf || Boolean(rel)) && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
63
63
|
};
|
|
64
64
|
/**
|
|
65
65
|
* A path-ish value (a string or a {path} entry) in relative compare form: trimmed, forward slashes,
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Pure identity shape and receipt schema shared by the process API and its domain consumers.
|
|
2
|
+
export const OWNED_PROCESS_SCHEMA = 'starci/owned-process@1';
|
|
3
|
+
|
|
4
|
+
/** A complete Windows process identity: positive PID, native birth and absolute executable; presence grants no launch authority. */
|
|
5
|
+
export const validProcessIdentity = (value) => Number.isInteger(value?.pid) && value.pid > 0
|
|
6
|
+
&& /^[1-9]\d{15,19}$/.test(String(value?.birth ?? '')) && typeof value?.exe === 'string' && /^(?:[a-z]:[\\/]|\\\\)/i.test(value.exe);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// yaml.mjs — the forgiving YAML read: a module file a peer wrote, a missing agent card or a truncated
|
|
2
|
+
// template is the caller's fallback, never a throw (the JSON twin is ./json.mjs readJsonFile).
|
|
3
|
+
import { parseYaml } from '../../engine/yaml.mjs';
|
|
4
|
+
import { readParsedFile } from './read-text.mjs';
|
|
5
|
+
|
|
6
|
+
/** `file` read and parsed as YAML, or `fallback` when it is missing, unreadable or malformed. */
|
|
7
|
+
export const readYamlFile = (file, fallback = null) => readParsedFile(file, parseYaml, fallback);
|
|
8
|
+
|
|
9
|
+
/** `file` (YAML) overlaid on `defaults`: each declared key whose doc value is a positive finite number. */
|
|
10
|
+
export const yamlNumberSettings = (file, defaults) => {
|
|
11
|
+
const doc = readYamlFile(file) ?? {};
|
|
12
|
+
const out = { ...defaults };
|
|
13
|
+
for (const key of Object.keys(defaults)) {
|
|
14
|
+
const n = Number(doc[key]);
|
|
15
|
+
if (Number.isFinite(n) && n > 0) out[key] = n;
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
};
|