@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
|
@@ -6,6 +6,8 @@ import {parseYaml} from './yaml.mjs';
|
|
|
6
6
|
import {isPlainObject as plain} from './plain-object.mjs';
|
|
7
7
|
import {invalid,validateRoots} from './invalid-config.mjs';
|
|
8
8
|
import {validateOrca} from './orca-config.mjs';
|
|
9
|
+
import {ENV_NAME,secretEnv,connectorSecret} from './secrets.mjs';
|
|
10
|
+
export {readDotenv,connectorSecret} from './secrets.mjs';
|
|
9
11
|
|
|
10
12
|
export const configRoot=skillRoot;
|
|
11
13
|
export const NON_OPERATION_ROLES={planner:'plan',kernelManager:'decide',validator:'verify'};
|
|
@@ -40,6 +42,29 @@ export function runtimeProfile(){
|
|
|
40
42
|
* here; a literal copy of any of them in a source file would be a second authority.
|
|
41
43
|
*/
|
|
42
44
|
export function allocationSettings(){return runtimeProfile()?.allocation??{};}
|
|
45
|
+
// These are current owner declarations, never a historical runtime owner's consent.
|
|
46
|
+
function validateOwnerProfile(value,name,pathsKey){
|
|
47
|
+
if(value===null)return;
|
|
48
|
+
const bad=invalid(name);
|
|
49
|
+
if(!plain(value))bad(' must be an owner approval mapping or null.');
|
|
50
|
+
const keys=['approvedBy','approvalRef',pathsKey,...(name==='launchTrust'?['profile']:[])];
|
|
51
|
+
for(const key of Object.keys(value))if(!keys.includes(key))bad(` has unknown key ${key}.`);
|
|
52
|
+
if(value.approvedBy!=='owner')bad('.approvedBy must be owner; adoption must come from the current owner.');
|
|
53
|
+
if(typeof value.approvalRef!=='string'||!value.approvalRef.trim())bad('.approvalRef must identify the current owner approval.');
|
|
54
|
+
if(name==='launchTrust'&&!['automatic','declined'].includes(value.profile))bad('.profile must be automatic or declined.');
|
|
55
|
+
const paths=value[pathsKey];
|
|
56
|
+
if(!Array.isArray(paths)||!paths.length||paths.some(p=>typeof p!=='string'||!(path.isAbsolute(p)||path.win32.isAbsolute(p))||/[\r\n\0]/.test(p)))bad(`.${pathsKey} must list exact absolute repository roots.`);
|
|
57
|
+
}
|
|
58
|
+
export function launchTrustSettings(config=loadConfig()){
|
|
59
|
+
const value=config?.launchTrust??null;validateOwnerProfile(value,'launchTrust','roots');return value;
|
|
60
|
+
}
|
|
61
|
+
export function workflowPurgeSettings(config=loadConfig()){
|
|
62
|
+
const retention=config?.retention??null;
|
|
63
|
+
if(retention===null)return null;
|
|
64
|
+
const bad=invalid('retention');
|
|
65
|
+
if(!plain(retention)||Object.keys(retention).some(k=>k!=='workflowPurge'))bad(' must be {workflowPurge?} or null.');
|
|
66
|
+
const value=retention.workflowPurge??null;validateOwnerProfile(value,'retention.workflowPurge','repos');return value;
|
|
67
|
+
}
|
|
43
68
|
/** One positive millisecond value out of `allocation`, by dotted key. Refuses when the contract omits it. */
|
|
44
69
|
export function allocationMs(dotted){
|
|
45
70
|
const raw=dotted.split('.').reduce((node,key)=>(node==null?node:node[key]),allocationSettings());
|
|
@@ -75,13 +100,11 @@ const CLOUDFLARE_MODES=['off','quick','named'];
|
|
|
75
100
|
/** The serve-ask port band, both ends included (scripts/kernel/ask-server.mjs scans [first..last]); the gateway stays outside it. */
|
|
76
101
|
export const ASK_PORT_BAND=[6969,7069];
|
|
77
102
|
export const CONNECTOR_DEFAULTS=Object.freeze({
|
|
78
|
-
secretsFile:null,
|
|
79
103
|
repos:[],
|
|
80
104
|
gateway:{port:7070},
|
|
81
105
|
cloudflare:{mode:'off',tunnel:null,credentialsFile:null,tokenEnv:'CLOUDFLARE_TUNNEL_TOKEN',hostname:null,access:false},
|
|
82
106
|
telegram:{enabled:false,botTokenEnv:'TELEGRAM_BOT_TOKEN',chatId:null,exposeCredentialAsks:false},
|
|
83
107
|
});
|
|
84
|
-
const ENV_NAME=/^[A-Z_][A-Z0-9_]{0,63}$/;
|
|
85
108
|
const HOSTNAME=/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
|
|
86
109
|
const TUNNEL_REF=/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
|
|
87
110
|
const CHAT_ID=/^(?:-?\d{1,20}|@[A-Za-z][A-Za-z0-9_]{4,31})$/;
|
|
@@ -89,7 +112,7 @@ const SECRET_KEYS=['token','tunnelToken','botToken','secret','apiToken','passwor
|
|
|
89
112
|
function validateConnectors(connectors){
|
|
90
113
|
if(connectors===null)return;
|
|
91
114
|
const bad=invalid('connectors');
|
|
92
|
-
if(!plain(connectors))bad(' must be {
|
|
115
|
+
if(!plain(connectors))bad(' must be {repos?, gateway?, cloudflare?, telegram?} or null.');
|
|
93
116
|
const closed=(node,where,keys)=>{
|
|
94
117
|
if(!plain(node))bad(`.${where} must be a mapping.`);
|
|
95
118
|
for(const key of Object.keys(node)){
|
|
@@ -97,8 +120,7 @@ function validateConnectors(connectors){
|
|
|
97
120
|
if(!keys.includes(key))bad(`.${where} has unknown key ${key} (allowed: ${keys.join(', ')}).`);
|
|
98
121
|
}
|
|
99
122
|
};
|
|
100
|
-
closed(connectors,'',['
|
|
101
|
-
if(connectors.secretsFile!==undefined&&connectors.secretsFile!==null&&(typeof connectors.secretsFile!=='string'||!connectors.secretsFile.trim()))bad('.secretsFile must be a dotenv file path (relative to the skill root) or null.');
|
|
123
|
+
closed(connectors,'',['repos','gateway','cloudflare','telegram']);
|
|
102
124
|
if(connectors.repos!==undefined&&(!Array.isArray(connectors.repos)||connectors.repos.some(repo=>typeof repo!=='string'||!repo.trim())))bad('.repos must be a list of repository paths.');
|
|
103
125
|
if(connectors.gateway!==undefined){
|
|
104
126
|
closed(connectors.gateway,'gateway',['port']);
|
|
@@ -132,46 +154,14 @@ function validateConnectors(connectors){
|
|
|
132
154
|
if(tg.enabled===true&&(tg.chatId===undefined||tg.chatId===null))bad('.telegram.enabled needs telegram.chatId.');
|
|
133
155
|
}
|
|
134
156
|
}
|
|
135
|
-
/**
|
|
136
|
-
|
|
137
|
-
* points at (the custody pointer convention). Returns null when neither is set. Callers pass the value to
|
|
138
|
-
* the one API that needs it and never print it.
|
|
139
|
-
*/
|
|
140
|
-
/** Parse a dotenv file (KEY=VALUE lines, # comments, optional export/quotes). An absent file is {}. */
|
|
141
|
-
export function readDotenv(file){
|
|
142
|
-
let text='';try{text=fs.readFileSync(file,'utf8');}catch(error){if(error?.code==='ENOENT')return {};throw error;}
|
|
143
|
-
const out={};
|
|
144
|
-
for(const line of text.split(/\r?\n/)){
|
|
145
|
-
const m=line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
|
146
|
-
if(!m)continue;
|
|
147
|
-
let value=m[2];
|
|
148
|
-
if(value.length>=2&&(value[0]==='"'||value[0]==="'")&&value.at(-1)===value[0])value=value.slice(1,-1);
|
|
149
|
-
out[m[1]]=value;
|
|
150
|
-
}
|
|
151
|
-
return out;
|
|
152
|
-
}
|
|
153
|
-
/** The dotenv file connectors.secretsFile names, resolved against the skill root; null when unset. */
|
|
154
|
-
const connectorSecretsFile=(config,root=configRoot)=>{const file=config?.connectors?.secretsFile;return typeof file==='string'&&file.trim()?path.resolve(root,file):null;};
|
|
155
|
-
/**
|
|
156
|
-
* The environment the connectors resolve secrets from: connectors.secretsFile's values under the process
|
|
157
|
-
* environment (a real env var wins). It holds secrets — hand it to connectorSecret, never print it.
|
|
158
|
-
*/
|
|
159
|
-
export function connectorEnv(config,env=process.env,root=configRoot){const file=connectorSecretsFile(config,root);return file?{...readDotenv(file),...env}:env;}
|
|
160
|
-
export function connectorSecret(name,env=process.env){
|
|
161
|
-
if(typeof name!=='string'||!ENV_NAME.test(name))return null;
|
|
162
|
-
const direct=typeof env[name]==='string'?env[name].trim():'';
|
|
163
|
-
if(direct)return direct;
|
|
164
|
-
const pointer=typeof env[`${name}_FILE`]==='string'?env[`${name}_FILE`].trim():'';
|
|
165
|
-
if(!pointer)return null;
|
|
166
|
-
try{const value=fs.readFileSync(pointer,'utf8').trim();return value||null;}catch{return null;}
|
|
167
|
-
}
|
|
157
|
+
/** Load connector credentials from the explicitly verified runtime main; never print the returned environment. */
|
|
158
|
+
export function connectorEnv(config,env=process.env,verifiedRuntimeRoot){return secretEnv(verifiedRuntimeRoot,env);}
|
|
168
159
|
/**
|
|
169
160
|
* The normalized `connectors` block: defaults filled in, the secrets' PRESENCE (booleans, never values),
|
|
170
161
|
* whether each connector is ready to run, and the owner-facing warnings the security posture earns.
|
|
171
162
|
*/
|
|
172
163
|
export function connectorsConfig(config=loadConfig(),env=process.env,root=configRoot){
|
|
173
164
|
validateConfig(config);
|
|
174
|
-
env=connectorEnv(config,env,root);
|
|
175
165
|
const raw=plain(config?.connectors)?config.connectors:{},d=CONNECTOR_DEFAULTS;
|
|
176
166
|
const cloudflare={...d.cloudflare,...(raw.cloudflare??{})},telegram={...d.telegram,...(raw.telegram??{})};
|
|
177
167
|
if(cloudflare.credentialsFile)cloudflare.credentialsFile=path.resolve(root,cloudflare.credentialsFile.replace(/^~(?=[\\/])/,env.USERPROFILE??env.HOME??'~'));
|
|
@@ -189,7 +179,7 @@ export function connectorsConfig(config=loadConfig(),env=process.env,root=config
|
|
|
189
179
|
if(cloudflare.auth==='token'&&!cloudflare.tokenPresent)warnings.push(`cloudflare.mode named: the tunnel token is not set — export ${cloudflare.tokenEnv} (or ${cloudflare.tokenEnv}_FILE).`);
|
|
190
180
|
if(telegram.enabled&&!telegram.botTokenPresent)warnings.push(`telegram.enabled: the bot token is not set — export ${telegram.botTokenEnv} (or ${telegram.botTokenEnv}_FILE).`);
|
|
191
181
|
if(telegram.exposeCredentialAsks)warnings.push('telegram.exposeCredentialAsks is true: credential asks are posted as public links; Telegram cloud chats are not end-to-end encrypted.');
|
|
192
|
-
return {
|
|
182
|
+
return {repos:[...(raw.repos??d.repos)],gateway:{...d.gateway,...(raw.gateway??{})},cloudflare,telegram,warnings};
|
|
193
183
|
}
|
|
194
184
|
/**
|
|
195
185
|
* config.yaml `asks` — whether an owner ask that carries a recommended option is answered with it
|
|
@@ -305,7 +295,9 @@ function validateAgentSeat(seat,name,profile){
|
|
|
305
295
|
if(seat.effort!==undefined&&seat.effort!==null&&!EFFORT_LEVELS.includes(seat.effort))throw Error(`Invalid config.yaml: ${name}.effort must use the effort vocabulary.`);
|
|
306
296
|
}
|
|
307
297
|
export function validateConfig(config){
|
|
308
|
-
const allowed=['language','model','effort','models','debug','allocation','kernel','budgets','supervisor','parallel','delegation','connectors','asks','uat','specs','reconciler','coreDebug','orca','roots'],models=config?.models,profile=runtimeProfile(),runtimes=profile?.runtimes??{};
|
|
298
|
+
const allowed=['language','model','effort','models','debug','allocation','kernel','budgets','supervisor','parallel','delegation','connectors','asks','uat','specs','reconciler','coreDebug','orca','roots','launchTrust','retention'],models=config?.models,profile=runtimeProfile(),runtimes=profile?.runtimes??{};
|
|
299
|
+
if(config?.launchTrust!==undefined)launchTrustSettings(config);
|
|
300
|
+
if(config?.retention!==undefined)workflowPurgeSettings(config);
|
|
309
301
|
if(config?.connectors!==undefined)validateConnectors(config.connectors);
|
|
310
302
|
if(config?.asks!==undefined)validateAsks(config.asks);
|
|
311
303
|
if(config?.uat!==undefined)validateUat(config.uat);
|
|
@@ -372,8 +364,8 @@ export function validateConfig(config){
|
|
|
372
364
|
}
|
|
373
365
|
if(config?.budgets!==undefined){
|
|
374
366
|
const budgets=config.budgets;
|
|
375
|
-
if(!plain(budgets)||Object.keys(budgets).some(key=>!['maxOps'
|
|
376
|
-
throw Error('Invalid config.yaml: budgets must be {maxOps
|
|
367
|
+
if(!plain(budgets)||Object.keys(budgets).some(key=>!['maxOps'].includes(key))||Object.values(budgets).some(value=>value!==null&&!(Number.isInteger(value)&&value>0)))
|
|
368
|
+
throw Error('Invalid config.yaml: budgets must be {maxOps?} with positive-integer-or-null values.');
|
|
377
369
|
}
|
|
378
370
|
if(!plain(config)||Object.keys(config).some(key=>!allowed.includes(key))||typeof config.language!=='string'||!/^[a-z]{2,3}(?:-[A-Za-z0-9]+)*$/.test(config.language)||!(config.model===null||typeof config.model==='string'&&config.model.trim())||!EFFORT_LEVELS.includes(config.effort)||!plain(models)||Object.keys(models).some(key=>!['pools','nonOperation','selection'].includes(key))||models.selection!=='quota-aware'||!plain(models.pools)||!plain(models.nonOperation)||Object.keys(models.pools).length!==Object.keys(DEFAULT_MODEL_POOLS).length||Object.keys(models.pools).some(key=>!Object.hasOwn(DEFAULT_MODEL_POOLS,key))||Object.keys(models.nonOperation).length!==Object.keys(NON_OPERATION_ROLES).length||Object.keys(models.nonOperation).some(key=>!Object.hasOwn(NON_OPERATION_ROLES,key)))throw Error('Invalid config.yaml: expected language, model, effort and the closed quota-aware model pools/non-operation role map.');
|
|
379
371
|
for(const [pool,members] of Object.entries(models.pools))if(!Array.isArray(members)||members.length!==2||new Set(members).size!==2||members.some(id=>typeof id!=='string'||!plain(runtimes[id]))||!(members.length===DEFAULT_MODEL_POOLS[pool].length&&members.every(id=>DEFAULT_MODEL_POOLS[pool].includes(id))))throw Error(`Invalid config.yaml: models.pools.${pool} must contain its canonical pair of two unique known runtime ids.`);
|
|
@@ -444,7 +436,7 @@ export function inspectOwnerConfig(root=configRoot){
|
|
|
444
436
|
* or `starci kernel run-deferred-tests`); e2e.verify then runs the FULL e2e suite.
|
|
445
437
|
* An absent, null or unreadable owner file reads as the defaults: harness off, unit on, e2e off.
|
|
446
438
|
*/
|
|
447
|
-
|
|
439
|
+
const SPEC_FAMILIES=Object.freeze(['harness','unit','e2e']);
|
|
448
440
|
export const SPEC_DEFAULTS=Object.freeze({harness:false,unit:true,e2e:false});
|
|
449
441
|
export function specsSettings(config){const specs=plain(config?.specs)?config.specs:{};return Object.fromEntries(SPEC_FAMILIES.map(key=>[key,typeof specs[key]==='boolean'?specs[key]:SPEC_DEFAULTS[key]]));}
|
|
450
442
|
/** specs.harness of the owner file under `root` (tolerant read: inspectOwnerConfig): true only when the owner opted in to `--specs all`. */
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// blob.mjs — the content-addressed blob store (operational evidence and agent output) beside the two SQLite stores:
|
|
2
|
+
// putBlob/getBlob/statBlob/putJson write and read it; blob bytes are immutable and never removed here.
|
|
3
|
+
//
|
|
4
|
+
// Reading agent output back out of the blob store (alpha.3, ARCHITECTURE-DB §5.3). A Work record and the
|
|
5
|
+
// draw subsystem cite agent output by {artifact?, sha256}; this is the one place that turns such a citation back into
|
|
6
|
+
// bytes, a readable file with an extension, or a directory.
|
|
7
|
+
// resolveBlob(ref, {db}) ref = {artifact?, sha256?} | 'blob:<sha>' | '<sha>' → {sha256, file} | null (an artifact id
|
|
8
|
+
// alone resolves through the ledger's job_artifacts)
|
|
9
|
+
// readBlobRef(ref) the verified bytes
|
|
10
|
+
// blobAsFile(ref, {ext}) a read-only copy with an extension beside the store (<artifact root>-views; tools that key on the
|
|
11
|
+
// extension: an image decoder, a browser), cached by sha
|
|
12
|
+
// putBundle(dir) every file under `dir` put as a blob + a starci/blob-bundle@1 manifest {files: {rel: sha}}
|
|
13
|
+
// put as a blob; returns the manifest sha (a draw loop: loop.json and every round)
|
|
14
|
+
// bundleDir(sha) the bundle materialized as a directory under <artifact root>-views (cached by sha), or null
|
|
15
|
+
// Each materialized view is verified against its cited digest before reuse.
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import crypto from 'node:crypto';
|
|
20
|
+
|
|
21
|
+
export const ARTIFACT_ROOT_ENV = 'STARCI_ARTIFACT_ROOT';
|
|
22
|
+
export const artifactRoot = (env = process.env) => path.resolve(env[ARTIFACT_ROOT_ENV] || path.join(os.homedir(), '.starci', 'artifacts'));
|
|
23
|
+
const SHA = /^[a-f0-9]{64}$/;
|
|
24
|
+
const assertSha = sha => {
|
|
25
|
+
if (typeof sha !== 'string' || !SHA.test(sha)) throw new TypeError('blob sha must be a lowercase sha256 hex digest');
|
|
26
|
+
return sha;
|
|
27
|
+
};
|
|
28
|
+
// An explicit root scopes READs only; writers continue to use the default external root.
|
|
29
|
+
const location = (sha, root = null, suffix = '') => {
|
|
30
|
+
if (root !== null && (typeof root !== 'string' || !root || !path.isAbsolute(root)))
|
|
31
|
+
throw new TypeError('blob read root must be an absolute path');
|
|
32
|
+
const file = path.join(root === null ? artifactRoot() : path.resolve(root), assertSha(sha).slice(0, 2), `${sha}${suffix}`);
|
|
33
|
+
if (root !== null) {
|
|
34
|
+
regularParents(file, { strict: true });
|
|
35
|
+
const stat = fs.lstatSync(file, { throwIfNoEntry: false });
|
|
36
|
+
if (stat && (!stat.isFile() || stat.isSymbolicLink()
|
|
37
|
+
|| fs.realpathSync.native(file) !== path.join(fs.realpathSync.native(path.dirname(file)), path.basename(file))
|
|
38
|
+
|| !fs.readdirSync(path.dirname(file)).includes(path.basename(file))))
|
|
39
|
+
throw new Error(`blob read path is not a contained regular file: ${file}`);
|
|
40
|
+
}
|
|
41
|
+
return file;
|
|
42
|
+
};
|
|
43
|
+
const metadataPath = (sha, root = null) => location(sha, root, '.json');
|
|
44
|
+
const digest = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
|
|
45
|
+
|
|
46
|
+
// Reject roots inside a checkout, including worktrees whose .git is a file.
|
|
47
|
+
function ensureExternalRoot(root) {
|
|
48
|
+
let cursor = root;
|
|
49
|
+
while (true) {
|
|
50
|
+
if (fs.existsSync(path.join(cursor, '.git'))) throw new Error(`artifact root is inside a git checkout: ${root}`);
|
|
51
|
+
const parent = path.dirname(cursor);
|
|
52
|
+
if (parent === cursor) return;
|
|
53
|
+
cursor = parent;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sourceBytes(bufferOrPath) {
|
|
58
|
+
if (Buffer.isBuffer(bufferOrPath)) return bufferOrPath;
|
|
59
|
+
if (bufferOrPath instanceof Uint8Array) return Buffer.from(bufferOrPath);
|
|
60
|
+
if (typeof bufferOrPath === 'string') return fs.readFileSync(bufferOrPath);
|
|
61
|
+
throw new TypeError('putBlob expects a Buffer, Uint8Array, or file path');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function publishOnce(destination, bytes) {
|
|
65
|
+
const dir = path.dirname(destination);
|
|
66
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
67
|
+
const temp = path.join(dir, `.${path.basename(destination)}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
68
|
+
let owned=false;
|
|
69
|
+
try {
|
|
70
|
+
const fd=fs.openSync(temp,'wx');
|
|
71
|
+
owned=true;
|
|
72
|
+
try{fs.writeFileSync(fd,bytes);fs.fsyncSync(fd);}finally{fs.closeSync(fd);}
|
|
73
|
+
try { fs.linkSync(temp, destination); }
|
|
74
|
+
catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
75
|
+
} finally { if(owned)try { fs.unlinkSync(temp); } catch (error) { if (error.code !== 'ENOENT') throw error; } }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Store original bytes under their sha256. A concurrent or repeated put never replaces published bytes. */
|
|
79
|
+
export function putBlob(bufferOrPath, { mediaType = 'application/octet-stream' } = {}) {
|
|
80
|
+
if (typeof mediaType !== 'string' || !mediaType.trim()) throw new TypeError('mediaType must be a nonempty string');
|
|
81
|
+
const root = artifactRoot();
|
|
82
|
+
ensureExternalRoot(root);
|
|
83
|
+
const bytes = sourceBytes(bufferOrPath);
|
|
84
|
+
const sha = digest(bytes);
|
|
85
|
+
const destination = location(sha);
|
|
86
|
+
const metadata = { size: bytes.length, mediaType, createdAt: new Date().toISOString() };
|
|
87
|
+
// Metadata is published first so a visible blob always has a sidecar. A crash can leave an orphan sidecar;
|
|
88
|
+
// the next put completes it. The first published media type and creation time win.
|
|
89
|
+
publishOnce(metadataPath(sha), Buffer.from(JSON.stringify(metadata)));
|
|
90
|
+
publishOnce(destination, bytes);
|
|
91
|
+
const stored = fs.statSync(destination);
|
|
92
|
+
if (stored.size !== bytes.length) throw new Error(`blob size mismatch: ${sha}`);
|
|
93
|
+
// Reuse must verify bytes, including a corrupt same-size destination.
|
|
94
|
+
getBlob(sha);
|
|
95
|
+
const info = statBlob(sha);
|
|
96
|
+
// Flush reused files too. Directory/link persistence remains platform-specific;
|
|
97
|
+
// this local profile does not promise power-loss recovery of acknowledged refs.
|
|
98
|
+
for(const file of [destination,metadataPath(sha)]){const fd=fs.openSync(file,'r+');try{fs.fsyncSync(fd);}finally{fs.closeSync(fd);}}
|
|
99
|
+
return { sha, size: info.size, mediaType: info.mediaType };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function blobPath(sha, { root = null } = {}) {
|
|
103
|
+
const file = location(sha, root);
|
|
104
|
+
return fs.existsSync(file) && fs.statSync(file).isFile() ? file : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function getBlob(sha, { root = null, verifyBundle = false } = {}) {
|
|
108
|
+
const file = blobPath(sha, { root });
|
|
109
|
+
if (!file) throw Object.assign(new Error(`blob not found: ${sha}`), { code: 'ENOENT' });
|
|
110
|
+
const bytes = fs.readFileSync(file);
|
|
111
|
+
if (digest(bytes) !== sha) throw new Error(`blob hash mismatch: ${sha}`);
|
|
112
|
+
if (root !== null) statBlob(sha, { root });
|
|
113
|
+
if (verifyBundle) {
|
|
114
|
+
let manifest;
|
|
115
|
+
try { manifest = JSON.parse(bytes.toString('utf8')); } catch { /* ordinary blobs need not be JSON */ }
|
|
116
|
+
if (typeof manifest?.schema === 'string' && manifest.schema.startsWith('starci/blob-bundle@')
|
|
117
|
+
&& !bundleDir(sha, { root }))
|
|
118
|
+
throw Object.assign(new Error(`blob bundle cannot be verified: ${sha}`), { code: 'EINVALBUNDLE' });
|
|
119
|
+
}
|
|
120
|
+
return bytes;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function statBlob(sha, { root = null } = {}) {
|
|
124
|
+
const file = blobPath(sha, { root });
|
|
125
|
+
if (!file) return null;
|
|
126
|
+
const { size } = fs.statSync(file);
|
|
127
|
+
const metadata = JSON.parse(fs.readFileSync(metadataPath(sha, root), 'utf8'));
|
|
128
|
+
if (metadata.size !== size) throw new Error(`blob sidecar size mismatch: ${sha}`);
|
|
129
|
+
if(typeof metadata.mediaType!=='string'||!metadata.mediaType.trim()||!Number.isFinite(Date.parse(metadata.createdAt)))throw Error(`blob sidecar metadata invalid: ${sha}`);
|
|
130
|
+
return { sha, size, mediaType: metadata.mediaType, createdAt: metadata.createdAt };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function canonical(value) {
|
|
134
|
+
if (value && typeof value === 'object') {
|
|
135
|
+
if (typeof value.toJSON === 'function') return canonical(value.toJSON());
|
|
136
|
+
if (Array.isArray(value)) return value.map(item => item === undefined ? null : canonical(item));
|
|
137
|
+
return Object.fromEntries(Object.keys(value).sort().filter(key => value[key] !== undefined).map(key => [key, canonical(value[key])]));
|
|
138
|
+
}
|
|
139
|
+
return value;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* ------------------------------------------------------------ citations, views and bundles */
|
|
143
|
+
|
|
144
|
+
const BUNDLE_SCHEMA = 'starci/blob-bundle@1';
|
|
145
|
+
// Read-only views (files with an extension, materialized bundles) live beside the store, e.g. ~/.starci/artifacts-views.
|
|
146
|
+
const viewRoot = (root = null) => `${root === null ? artifactRoot() : path.resolve(root)}-views`;
|
|
147
|
+
const slash = (p) => String(p).replace(/\\/g, '/');
|
|
148
|
+
|
|
149
|
+
/** The sha256 a citation names: {sha256}, 'blob:<sha>' or a bare sha; an {artifact} alone needs `db`. */
|
|
150
|
+
function shaOfRef(ref, { db = null } = {}) {
|
|
151
|
+
if (typeof ref === 'string') { const s = ref.trim().replace(/^blob:/, ''); return SHA.test(s) ? s : null; }
|
|
152
|
+
if (!ref || typeof ref !== 'object') return null;
|
|
153
|
+
if (typeof ref.sha256 === 'string' && SHA.test(ref.sha256)) return ref.sha256;
|
|
154
|
+
if (ref.artifact != null && db) return db.prepare('SELECT sha256 FROM job_artifacts WHERE artifact_id=?').get(Number(ref.artifact))?.sha256 ?? null;
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** {sha256, file} of a citation whose bytes are in the local store, else null. */
|
|
159
|
+
export function resolveBlob(ref, { db = null, root = null } = {}) {
|
|
160
|
+
const sha = shaOfRef(ref, { db });
|
|
161
|
+
const file = sha ? blobPath(sha, { root }) : null;
|
|
162
|
+
return file ? { sha256: sha, file } : null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** A read-only copy of the blob with `ext` (e.g. '.png'), cached beside the store; null when missing. */
|
|
166
|
+
export function blobAsFile(ref, { ext = '', db = null, root = null } = {}) {
|
|
167
|
+
const hit = resolveBlob(ref, { db, root });
|
|
168
|
+
if (!hit) return null;
|
|
169
|
+
if (typeof ext !== 'string' || (ext !== '' && !/^\.[a-z0-9]{1,16}$/i.test(ext))) throw new TypeError('blob view extension must be a simple suffix');
|
|
170
|
+
const bytes = getBlob(hit.sha256, { root });
|
|
171
|
+
const file = path.join(viewRoot(root), 'files', `${hit.sha256}${ext}`);
|
|
172
|
+
regularParents(file, { strict: root !== null });
|
|
173
|
+
publishOnce(file, bytes);
|
|
174
|
+
if (!fs.lstatSync(file).isFile() || fs.lstatSync(file).isSymbolicLink() || digest(fs.readFileSync(file)) !== hit.sha256)
|
|
175
|
+
throw new Error(`blob view hash mismatch: ${hit.sha256}`);
|
|
176
|
+
if (root !== null && (fs.realpathSync.native(file) !== path.join(fs.realpathSync.native(path.dirname(file)), path.basename(file))
|
|
177
|
+
|| !fs.readdirSync(path.dirname(file)).includes(path.basename(file))))
|
|
178
|
+
throw new Error(`blob view path is linked or has a different spelling: ${file}`);
|
|
179
|
+
return file;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function regularParents(file, { strict = false } = {}) {
|
|
183
|
+
let cursor = path.dirname(file);
|
|
184
|
+
while (true) {
|
|
185
|
+
const stat = strict ? fs.lstatSync(cursor, { throwIfNoEntry: false }) : (fs.existsSync(cursor) ? fs.lstatSync(cursor) : null);
|
|
186
|
+
if (stat) {
|
|
187
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`blob view parent is not a regular directory: ${cursor}`);
|
|
188
|
+
const parent = path.dirname(cursor);
|
|
189
|
+
if (strict && parent !== cursor
|
|
190
|
+
&& (fs.realpathSync.native(cursor) !== path.join(fs.realpathSync.native(parent), path.basename(cursor))
|
|
191
|
+
|| !fs.readdirSync(parent).includes(path.basename(cursor))))
|
|
192
|
+
throw new Error(`blob read parent is linked or has a different spelling: ${cursor}`);
|
|
193
|
+
}
|
|
194
|
+
const parent = path.dirname(cursor);
|
|
195
|
+
if (parent === cursor) return;
|
|
196
|
+
cursor = parent;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function bundleEntries(manifest) {
|
|
201
|
+
if (!manifest.files || typeof manifest.files !== 'object' || Array.isArray(manifest.files)) throw new TypeError('blob bundle files must be a mapping');
|
|
202
|
+
const entries = Object.entries(manifest.files);
|
|
203
|
+
const names = new Set();
|
|
204
|
+
for (const [rel, sha] of entries) {
|
|
205
|
+
const parts = rel.split('/');
|
|
206
|
+
if (!rel || rel.includes('\\') || path.posix.isAbsolute(rel) || path.win32.isAbsolute(rel)
|
|
207
|
+
|| parts.some(part => !part || part === '.' || part === '..' || /[<>:"|?*\x00-\x1f]/.test(part) || /[. ]$/.test(part)
|
|
208
|
+
|| /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part)) || parts[0] === '.complete')
|
|
209
|
+
throw new TypeError(`unsafe blob bundle path: ${rel}`);
|
|
210
|
+
assertSha(sha);
|
|
211
|
+
const key = rel.toLowerCase();
|
|
212
|
+
if (names.has(key)) throw new TypeError(`blob bundle path collision: ${rel}`);
|
|
213
|
+
names.add(key);
|
|
214
|
+
}
|
|
215
|
+
for (const name of names) {
|
|
216
|
+
const parts = name.split('/');
|
|
217
|
+
for (let index = 1; index < parts.length; index += 1) if (names.has(parts.slice(0, index).join('/')))
|
|
218
|
+
throw new TypeError(`blob bundle file is also a directory: ${name}`);
|
|
219
|
+
}
|
|
220
|
+
return entries;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function verifiedBundle(dir, entries, { strict = false } = {}) {
|
|
224
|
+
regularParents(path.join(dir, '.complete'), { strict });
|
|
225
|
+
if (!fs.existsSync(dir)) return false;
|
|
226
|
+
const expected = new Map(entries);
|
|
227
|
+
const seen = new Set();
|
|
228
|
+
const walk = current => {
|
|
229
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
230
|
+
const file = path.join(current, entry.name);
|
|
231
|
+
const rel = slash(path.relative(dir, file));
|
|
232
|
+
if (entry.isSymbolicLink()) throw new Error(`blob bundle view contains a link: ${rel}`);
|
|
233
|
+
if (strict && fs.realpathSync.native(file) !== path.join(fs.realpathSync.native(current), entry.name))
|
|
234
|
+
throw new Error(`blob bundle view contains a redirected path: ${rel}`);
|
|
235
|
+
if (entry.isDirectory()) { walk(file); continue; }
|
|
236
|
+
if (!entry.isFile()) throw new Error(`blob bundle view contains a special file: ${rel}`);
|
|
237
|
+
if (rel === '.complete') continue;
|
|
238
|
+
if (!expected.has(rel) || digest(fs.readFileSync(file)) !== expected.get(rel)) throw new Error(`blob bundle view hash mismatch: ${rel}`);
|
|
239
|
+
seen.add(rel);
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
walk(dir);
|
|
243
|
+
return seen.size === entries.length && fs.existsSync(path.join(dir, '.complete'));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const filesUnder = (dir, out = []) => {
|
|
247
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
248
|
+
const full = path.join(dir, e.name);
|
|
249
|
+
if (e.isDirectory()) filesUnder(full, out); else if (e.isFile()) out.push(full);
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/** Put every file under `dir` and its manifest in the blob store; returns the manifest sha. */
|
|
255
|
+
export function putBundle(dir, { mediaTypeOf = () => 'application/octet-stream' } = {}) {
|
|
256
|
+
const files = {};
|
|
257
|
+
for (const abs of filesUnder(dir)) files[slash(path.relative(dir, abs))] = putBlob(abs, { mediaType: mediaTypeOf(abs) }).sha;
|
|
258
|
+
return putBlob(Buffer.from(JSON.stringify({ schema: BUNDLE_SCHEMA, files }, null, 2)), { mediaType: 'application/json' }).sha;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The manifest of a bundle: {schema, files: {rel: sha}} or null. */
|
|
262
|
+
function bundleManifest(ref, { db = null, root = null } = {}) {
|
|
263
|
+
const hit = resolveBlob(ref, { db, root });
|
|
264
|
+
if (!hit) return null;
|
|
265
|
+
const bytes = getBlob(hit.sha256, { root });
|
|
266
|
+
let doc;
|
|
267
|
+
try { doc = JSON.parse(bytes.toString('utf8')); } catch { return null; }
|
|
268
|
+
return doc?.schema === BUNDLE_SCHEMA ? doc : null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** The bundle as a directory beside the store (cached by sha), or null when it or one file is missing. */
|
|
272
|
+
export function bundleDir(ref, { db = null, root = null } = {}) {
|
|
273
|
+
const sha = shaOfRef(ref, { db });
|
|
274
|
+
const manifest = sha ? bundleManifest(sha, { root }) : null;
|
|
275
|
+
if (!manifest) return null;
|
|
276
|
+
const entries = bundleEntries(manifest);
|
|
277
|
+
const members = [];
|
|
278
|
+
for (const [rel, fileSha] of entries) {
|
|
279
|
+
try { members.push([rel, getBlob(fileSha, { root })]); }
|
|
280
|
+
catch (error) { if (error?.code === 'ENOENT') return null; throw error; }
|
|
281
|
+
}
|
|
282
|
+
const dir = path.join(viewRoot(root), 'bundles', sha);
|
|
283
|
+
if (verifiedBundle(dir, entries, { strict: root !== null })) return dir;
|
|
284
|
+
if (fs.existsSync(dir)) throw new Error(`blob bundle view is incomplete: ${sha}`);
|
|
285
|
+
regularParents(dir, { strict: root !== null });
|
|
286
|
+
fs.mkdirSync(path.dirname(dir), { recursive: true });
|
|
287
|
+
const staging = fs.mkdtempSync(path.join(path.dirname(dir), `.${sha}-`));
|
|
288
|
+
try {
|
|
289
|
+
for (const [rel, bytes] of members) {
|
|
290
|
+
const to = path.resolve(staging, ...rel.split('/'));
|
|
291
|
+
const relative = path.relative(staging, to);
|
|
292
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) throw new Error(`unsafe blob bundle target: ${rel}`);
|
|
293
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
294
|
+
fs.writeFileSync(to, bytes, { flag: 'wx' });
|
|
295
|
+
}
|
|
296
|
+
fs.writeFileSync(path.join(staging, '.complete'), sha, { flag: 'wx' });
|
|
297
|
+
try { fs.renameSync(staging, dir); }
|
|
298
|
+
catch (error) { if (!fs.existsSync(dir) || !verifiedBundle(dir, entries, { strict: root !== null })) throw error; }
|
|
299
|
+
} finally {
|
|
300
|
+
if (fs.existsSync(staging)) fs.rmSync(staging, { recursive: true, force: true });
|
|
301
|
+
}
|
|
302
|
+
if (!verifiedBundle(dir, entries, { strict: root !== null })) throw new Error(`blob bundle publication is incomplete: ${sha}`);
|
|
303
|
+
return dir;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The readable file of one record asset: a product asset kept in the tree ({path}, a ui direction the owner accepted,
|
|
308
|
+
* decision Q2) resolves against the record's directory; agent output ({artifact?, sha256, name}) resolves in the blob
|
|
309
|
+
* store as a copy carrying the extension of its artifact name. Null when neither is readable.
|
|
310
|
+
*/
|
|
311
|
+
export function assetFileOf(recordDir, asset, { db = null, root = null } = {}) {
|
|
312
|
+
if (!asset || typeof asset !== 'object') return null;
|
|
313
|
+
if (typeof asset.path === 'string' && asset.path) { const file = path.resolve(recordDir, asset.path); return fs.existsSync(file) ? file : null; }
|
|
314
|
+
return blobAsFile(asset, { ext: path.extname(String(asset.name ?? '')).toLowerCase(), db, root });
|
|
315
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Ledger path identity and ownership resolution; connection policy and caches remain in ledger.mjs.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {sha256} from '../digest.mjs';
|
|
6
|
+
import {isUnderTempDir,localProjectsRoot,readMachine} from './machine.mjs';
|
|
7
|
+
import {isSpecRun} from '../../scripts/lib/env.mjs';
|
|
8
|
+
import {pathKey} from '../../scripts/lib/path-key.mjs';
|
|
9
|
+
/** Overrides the projects root (the directory holding <ledger_id>/runtime.sqlite) for this process tree; narrower than machine-db.mjs LOCAL_ROOT_ENV, which this still honors through starciLocalRoot when unset. */
|
|
10
|
+
export const PROJECTS_ROOT_ENV='STARCI_PROJECTS_ROOT';
|
|
11
|
+
const normDir=file=>pathKey(file);
|
|
12
|
+
/** %LOCALAPPDATA%/StarCi/projects (starciLocalRoot, itself overridable by STARCI_LOCAL_ROOT; a node --test process tree gets one under the OS temp directory). */
|
|
13
|
+
export const projectsRootFor=(env=process.env)=>{
|
|
14
|
+
if(env[PROJECTS_ROOT_ENV])return path.resolve(env[PROJECTS_ROOT_ENV]);
|
|
15
|
+
const root=localProjectsRoot(env);
|
|
16
|
+
if(isSpecRun(env)&&!isUnderTempDir(root,{env}))return path.join(os.tmpdir(),'starci-test-projects');
|
|
17
|
+
return root;
|
|
18
|
+
};
|
|
19
|
+
/** Repository identity: resolved/realpath, forward slashes, case-folded only on Windows. */
|
|
20
|
+
export const repoRootKey=repoRoot=>{let root=path.resolve(repoRoot);try{root=fs.realpathSync.native(root);}catch{}return normDir(root);};
|
|
21
|
+
/**
|
|
22
|
+
* The ledger id of a repository root until the machine registry (a3-2 machine.ledgers) resolves it: a name-based
|
|
23
|
+
* UUID of the normalized root, so the same root always names the same ledger and no side registry is needed.
|
|
24
|
+
*/
|
|
25
|
+
const ledgerIdFromKey=key=>{
|
|
26
|
+
const h=sha256(`starci-ledger:${key}`);
|
|
27
|
+
return `${h.slice(0,8)}-${h.slice(8,12)}-5${h.slice(13,16)}-${(8|(parseInt(h[16],16)&3)).toString(16)}${h.slice(17,20)}-${h.slice(20,32)}`;
|
|
28
|
+
};
|
|
29
|
+
export const ledgerIdForRepo=repoRoot=>ledgerIdFromKey(repoRootKey(repoRoot));
|
|
30
|
+
/** The registered runtime.sqlite of `repoRoot` in machine.ledgers (a3-2 resolveLedger), or null. */
|
|
31
|
+
function registeredLedgerFile(root,env,openReader){
|
|
32
|
+
let row;try{row=readMachine(m=>m.resolveLedger({repoRoot:root}),null,{env});}catch{return null;}
|
|
33
|
+
const file=row&&row.state!=='retired'&&row.file?path.resolve(row.file):null;
|
|
34
|
+
if(file&&fs.existsSync(file))assertLedgerRoot(file,root,openReader);
|
|
35
|
+
return file;
|
|
36
|
+
}
|
|
37
|
+
function assertLedgerRoot(file,root,openReader){
|
|
38
|
+
const db=openReader(file);let own;try{own=db.prepare("SELECT value FROM meta WHERE key='repo_root'").get()?.value;}finally{db.close();}
|
|
39
|
+
if(!own||repoRootKey(own)!==repoRootKey(root))throw Object.assign(Error(`ledger-root-mismatch: ${file} belongs to another or unverified repository; preserve the database and resolve the owner binding before migration`),{code:'STARCI_LEDGER_ROOT_MISMATCH'});
|
|
40
|
+
}
|
|
41
|
+
function unregisteredLedgerFile(root,env,openReader){
|
|
42
|
+
const base=projectsRootFor(env),current=path.join(base,ledgerIdForRepo(root),'runtime.sqlite');
|
|
43
|
+
if(fs.existsSync(current)){assertLedgerRoot(current,root,openReader);return current;}
|
|
44
|
+
// Preserve a pre-fix POSIX binding only when its own exact repo_root proves
|
|
45
|
+
// this repository. Never silently move, rename or merge an old folded store.
|
|
46
|
+
const legacy=path.join(base,ledgerIdFromKey(repoRootKey(root).toLowerCase()),'runtime.sqlite');
|
|
47
|
+
if(legacy!==current&&fs.existsSync(legacy)){assertLedgerRoot(legacy,root,openReader);return legacy;}
|
|
48
|
+
return current;
|
|
49
|
+
}
|
|
50
|
+
/** Resolve the registry or exact owned store; the ledger owner supplies its verified read-only opener. */
|
|
51
|
+
export function resolveLedgerFile(root,{env,openReader}){
|
|
52
|
+
return registeredLedgerFile(root,env,openReader)??unregisteredLedgerFile(root,env,openReader);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const SYNTHETIC_LEDGER_ID=/^00000000-0000-4000-8000-[0-9a-f]{12}$/;
|
|
56
|
+
const fixtureRefused=message=>{throw new TypeError(`ledger-fixture-refused: ${message}`);};
|
|
57
|
+
/** Validate a fresh, initialization-only sample identity. Samples carry portable metadata,
|
|
58
|
+
* never a repository/machine binding; the ordinary project resolver is not bypassed. */
|
|
59
|
+
export function ledgerFixtureInit(fixture,{file,repoRoot,product,machine,mapped,checkpointer,now}){
|
|
60
|
+
if(fixture===null){
|
|
61
|
+
if(typeof file==='string'&&SYNTHETIC_LEDGER_ID.test(path.basename(path.dirname(path.resolve(file)))))fixtureRefused('synthetic identity is reserved for a sample');
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
if(!fixture||typeof fixture!=='object'||Array.isArray(fixture)
|
|
65
|
+
||![Object.prototype,null].includes(Object.getPrototypeOf(fixture))
|
|
66
|
+
||Object.keys(fixture).sort().join(',')!=='blobRoot,createdAt,ledgerId')fixtureRefused('needs exactly ledgerId, createdAt and blobRoot');
|
|
67
|
+
if(typeof fixture.ledgerId!=='string'||!SYNTHETIC_LEDGER_ID.test(fixture.ledgerId))fixtureRefused('ledgerId must be a synthetic UUID');
|
|
68
|
+
if(!Number.isSafeInteger(fixture.createdAt)||fixture.createdAt<=0||!Number.isFinite(new Date(fixture.createdAt).getTime()))fixtureRefused('createdAt must be a positive safe epoch millisecond');
|
|
69
|
+
const root=fixture.blobRoot;
|
|
70
|
+
if(typeof root!=='string'||!root||root.includes('\\')||root.trim()!==root||/[<>:"|?*\x00-\x1f]/.test(root)
|
|
71
|
+
||path.posix.isAbsolute(root)||root.split('/').some(p=>!p||p==='.'||p==='..'))fixtureRefused('blobRoot must be a portable relative path');
|
|
72
|
+
if(repoRoot!==null||product!==null||machine!==null||mapped)fixtureRefused('a sample cannot bind a repository, product, machine or cached project path');
|
|
73
|
+
if(!checkpointer||now!==Date.now)fixtureRefused('initialization owns its frozen clock and requires a checkpointer');
|
|
74
|
+
if(typeof file!=='string'||!path.isAbsolute(file)||path.basename(file)!=='runtime.sqlite')fixtureRefused('file must be an explicit absolute runtime.sqlite');
|
|
75
|
+
for(const suffix of ['','-wal','-shm'])if(fs.lstatSync(file+suffix,{throwIfNoEntry:false}))fixtureRefused('file and WAL/SHM must be absent');
|
|
76
|
+
const parent=path.dirname(file),stat=fs.lstatSync(parent,{throwIfNoEntry:false});
|
|
77
|
+
if(!stat?.isDirectory()||stat.isSymbolicLink()||fs.realpathSync.native(parent)!==path.resolve(parent))fixtureRefused('sample parent must be an existing physical directory');
|
|
78
|
+
return Object.freeze({...fixture,marker:'starci/basic-runtime-fixture@1'});
|
|
79
|
+
}
|
|
80
|
+
/** Refuse sample bytes as an operational ledger before opening a writer or beginning a transaction. */
|
|
81
|
+
export function assertOperationalLedger(meta){
|
|
82
|
+
if(meta.fixture||SYNTHETIC_LEDGER_ID.test(meta.ledger_id??''))throw new Error('ledger-fixture-read-only: preserve the sample; initialize a new live project store');
|
|
83
|
+
}
|