@starci/hfs 4.0.9 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1 -1
- package/LICENSE +21 -0
- package/emit/operations.mjs +1 -1
- package/emit/type-schema.mjs +2 -1
- package/lint/run.mjs +82 -8
- package/package.json +3 -2
- package/report/order.mjs +2 -0
- package/runtime/config.example.yaml +187 -0
- package/runtime/engine/by-code-unit.mjs +12 -0
- package/runtime/engine/config.mjs +247 -273
- package/runtime/engine/invalid-config.mjs +5 -5
- package/runtime/engine/model-config.mjs +90 -0
- package/runtime/engine/orca-config.mjs +3 -1
- package/runtime/engine/removed-vocabulary.mjs +28 -0
- package/runtime/engine/resources-config.mjs +52 -0
- package/runtime/engine/runtime-root.mjs +17 -0
- package/runtime/engine/secrets.mjs +134 -0
- package/runtime/engine/sonar-config.mjs +21 -0
- package/runtime/engine/temp-root.mjs +32 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +18 -18
- package/runtime/knowledge/hfs/peer-integrations.yaml +2 -3
- package/runtime/knowledge/hfs/rules.yaml +102 -69
- package/runtime/knowledge/hfs/slots.yaml +11 -14
- package/runtime/knowledge/patterns/be/api.yaml +7 -24
- package/runtime/knowledge/patterns/be/cli.yaml +3 -12
- package/runtime/knowledge/patterns/be/realtime.yaml +1 -14
- package/runtime/knowledge/patterns/be/webhooks.yaml +0 -19
- package/runtime/modules/kernel/failure-codes.yaml +1 -1
- package/runtime/modules/kernel/removed-vocabulary.yaml +168 -0
- package/runtime/modules/models/registry.yaml +25 -397
- package/runtime/modules/models/runtimes.yaml +92 -262
- package/runtime/modules/models/tiers.yaml +65 -0
- package/runtime/scripts/api/fs/claim-file.mjs +30 -0
- package/runtime/scripts/api/fs/ensure-temp-root.mjs +27 -0
- package/runtime/scripts/api/fs/forbidden-root.mjs +2 -1
- package/runtime/scripts/api/fs/lib.mjs +6 -0
- package/runtime/scripts/api/fs/make-temp-dir.mjs +11 -0
- package/runtime/scripts/api/fs/safe-remove.mjs +52 -35
- package/runtime/scripts/api/git/lib.mjs +36 -1
- package/runtime/scripts/api/process/resolve-real-tool.mjs +52 -0
- package/runtime/scripts/api/process/run-program.mjs +8 -0
- package/runtime/scripts/api/sops/decrypt.mjs +9 -16
- package/runtime/scripts/api/sops/lib.mjs +309 -11
- package/runtime/scripts/api/sops/seal.mjs +8 -4
- package/runtime/scripts/hfs/allows.mjs +3 -3
- package/runtime/scripts/hfs/architecture/ast-walks.mjs +63 -50
- package/runtime/scripts/hfs/architecture/automatic-gates.mjs +96 -0
- package/runtime/scripts/hfs/architecture/backend.mjs +261 -175
- package/runtime/scripts/hfs/architecture/background-unowned.mjs +69 -34
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +55 -54
- package/runtime/scripts/hfs/architecture/clones.mjs +179 -106
- package/runtime/scripts/hfs/architecture/config-unread.mjs +5 -22
- package/runtime/scripts/hfs/architecture/config.mjs +95 -80
- package/runtime/scripts/hfs/architecture/connection-map.mjs +233 -141
- package/runtime/scripts/hfs/architecture/constructor-deps.mjs +15 -9
- package/runtime/scripts/hfs/architecture/context-coupling.mjs +76 -52
- package/runtime/scripts/hfs/architecture/context-map.mjs +215 -141
- package/runtime/scripts/hfs/architecture/context-owner.mjs +39 -30
- package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +31 -17
- package/runtime/scripts/hfs/architecture/context-transaction.mjs +38 -25
- package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +86 -40
- package/runtime/scripts/hfs/architecture/contracts-readonly.mjs +342 -0
- package/runtime/scripts/hfs/architecture/contracts.mjs +263 -455
- package/runtime/scripts/hfs/architecture/cross-app-duplicate.mjs +43 -34
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +201 -154
- package/runtime/scripts/hfs/architecture/default-deny.mjs +118 -93
- package/runtime/scripts/hfs/architecture/doc-language.mjs +2 -1
- package/runtime/scripts/hfs/architecture/entrypoint.mjs +45 -30
- package/runtime/scripts/hfs/architecture/error-codes.mjs +19 -12
- package/runtime/scripts/hfs/architecture/error-masked.mjs +39 -26
- package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +3 -2
- package/runtime/scripts/hfs/architecture/feature-shape.mjs +26 -20
- package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
- package/runtime/scripts/hfs/architecture/frontend-grammar.mjs +190 -0
- package/runtime/scripts/hfs/architecture/frontend-routing.mjs +186 -0
- package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +311 -0
- package/runtime/scripts/hfs/architecture/frontend-world.mjs +328 -0
- package/runtime/scripts/hfs/architecture/frontend.mjs +109 -829
- package/runtime/scripts/hfs/architecture/hfs-graph.mjs +14 -2
- package/runtime/scripts/hfs/architecture/hfs.mjs +120 -288
- package/runtime/scripts/hfs/architecture/hooks-are-hooks.mjs +46 -26
- package/runtime/scripts/hfs/architecture/i18n-keys.mjs +114 -76
- package/runtime/scripts/hfs/architecture/index.mjs +158 -102
- package/runtime/scripts/hfs/architecture/injection-token-exported.mjs +33 -28
- package/runtime/scripts/hfs/architecture/machine-ast.mjs +180 -144
- package/runtime/scripts/hfs/architecture/managed-scripts.mjs +8 -2
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +150 -101
- package/runtime/scripts/hfs/architecture/next-data-contract.mjs +51 -43
- package/runtime/scripts/hfs/architecture/next-data-key.mjs +338 -0
- package/runtime/scripts/hfs/architecture/next-data.mjs +179 -427
- package/runtime/scripts/hfs/architecture/owners.mjs +27 -13
- package/runtime/scripts/hfs/architecture/package-shape.mjs +51 -23
- package/runtime/scripts/hfs/architecture/presentation.mjs +165 -0
- package/runtime/scripts/hfs/architecture/reachability.mjs +127 -61
- package/runtime/scripts/hfs/architecture/register-once.mjs +112 -63
- package/runtime/scripts/hfs/architecture/registration.mjs +187 -110
- package/runtime/scripts/hfs/architecture/required-files.mjs +100 -68
- package/runtime/scripts/hfs/architecture/route-files-thin.mjs +83 -58
- package/runtime/scripts/hfs/architecture/schema-owner.mjs +182 -108
- package/runtime/scripts/hfs/architecture/source-names-shape.mjs +318 -0
- package/runtime/scripts/hfs/architecture/source-names.mjs +83 -248
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +175 -118
- package/runtime/scripts/hfs/architecture/sql-returning.mjs +39 -28
- package/runtime/scripts/hfs/architecture/sql-tokens.mjs +302 -172
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +1 -1
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +55 -45
- package/runtime/scripts/hfs/architecture/supabase-results.mjs +182 -0
- package/runtime/scripts/hfs/architecture/supabase-tables.mjs +20 -12
- package/runtime/scripts/hfs/architecture/supabase.mjs +191 -263
- package/runtime/scripts/hfs/architecture/symbols.mjs +175 -99
- package/runtime/scripts/hfs/architecture/test-world-files.mjs +82 -41
- package/runtime/scripts/hfs/architecture/tiers.mjs +120 -69
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +84 -57
- package/runtime/scripts/hfs/architecture/type-context.mjs +282 -0
- package/runtime/scripts/hfs/architecture/typescript.mjs +116 -276
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +84 -62
- package/runtime/scripts/hfs/architecture.mjs +3 -4
- package/runtime/scripts/hfs/check.mjs +61 -70
- package/runtime/scripts/hfs/coverage-scope.mjs +84 -38
- package/runtime/scripts/hfs/declaration-shape.mjs +77 -36
- package/runtime/scripts/hfs/declaration-slots.mjs +1 -1
- package/runtime/scripts/hfs/edition-slots.mjs +5 -3
- package/runtime/scripts/hfs/linear-text.mjs +30 -0
- package/runtime/scripts/hfs/manifest-shape.mjs +164 -63
- package/runtime/scripts/hfs/path-findings.mjs +58 -38
- package/runtime/scripts/hfs/pin-findings.mjs +31 -0
- package/runtime/scripts/hfs/repo-identity.mjs +5 -2
- package/runtime/scripts/hfs/rule-catalog.mjs +194 -0
- package/runtime/scripts/hfs/rule-params-shape.mjs +33 -27
- package/runtime/scripts/hfs/rules/cli.mjs +5 -1
- package/runtime/scripts/hfs/rules/contract-compat.mjs +40 -22
- package/runtime/scripts/hfs/rules/contract.mjs +35 -27
- package/runtime/scripts/hfs/rules/database-config.mjs +57 -32
- package/runtime/scripts/hfs/rules/database-migrations.mjs +52 -31
- package/runtime/scripts/hfs/rules/database-plpgsql.mjs +106 -91
- package/runtime/scripts/hfs/rules/database-sql.mjs +254 -173
- package/runtime/scripts/hfs/rules/database.mjs +36 -23
- package/runtime/scripts/hfs/rules/deps.mjs +64 -32
- package/runtime/scripts/hfs/rules/docker.mjs +113 -65
- package/runtime/scripts/hfs/rules/edition.mjs +118 -111
- package/runtime/scripts/hfs/rules/event-bus.mjs +91 -45
- package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +17 -16
- package/runtime/scripts/hfs/rules/fe-no-tests.mjs +36 -16
- package/runtime/scripts/hfs/rules/frontend-tree.mjs +4 -3
- package/runtime/scripts/hfs/rules/integration-specs.mjs +75 -41
- package/runtime/scripts/hfs/rules/kinds.mjs +59 -25
- package/runtime/scripts/hfs/rules/lint-suppression.mjs +9 -3
- package/runtime/scripts/hfs/rules/monorepo.mjs +82 -45
- package/runtime/scripts/hfs/rules/peer-integrations.mjs +24 -3
- package/runtime/scripts/hfs/rules/pipeline.mjs +58 -9
- package/runtime/scripts/hfs/rules/proof-commands.mjs +14 -8
- package/runtime/scripts/hfs/rules/repo-local-checks.mjs +11 -6
- package/runtime/scripts/hfs/rules/saga.mjs +128 -61
- package/runtime/scripts/hfs/rules/secrets.mjs +4 -3
- package/runtime/scripts/hfs/rules/services.mjs +34 -16
- package/runtime/scripts/hfs/rules/stacks.mjs +21 -14
- package/runtime/scripts/hfs/rules/supabase-secrets.mjs +32 -23
- package/runtime/scripts/hfs/rules/test-topology.mjs +25 -11
- package/runtime/scripts/hfs/secret.mjs +90 -45
- package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
- package/runtime/scripts/hfs/slot-classify.mjs +65 -0
- package/runtime/scripts/hfs/slot-errors.mjs +11 -0
- package/runtime/scripts/hfs/slot-imports.mjs +76 -0
- package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
- package/runtime/scripts/hfs/slot-match.mjs +153 -0
- package/runtime/scripts/hfs/slot-path.mjs +8 -0
- package/runtime/scripts/hfs/slot-required.mjs +76 -0
- package/runtime/scripts/hfs/slot-semantic-problems.mjs +148 -0
- package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
- package/runtime/scripts/hfs/slots.mjs +72 -693
- package/runtime/scripts/hfs/sql/pg-parse.mjs +5 -2
- package/runtime/scripts/hfs/trailing-slashes.mjs +8 -0
- package/runtime/scripts/hfs/tree.mjs +19 -14
- package/runtime/scripts/hfs/typescript-programs.mjs +6 -4
- package/runtime/scripts/hfs/view.mjs +1 -1
- package/runtime/scripts/lib/dockerfile.mjs +56 -33
- package/runtime/scripts/lib/event-contract.mjs +17 -13
- package/runtime/scripts/lib/fs-kind.mjs +14 -4
- package/runtime/scripts/lib/git.mjs +2 -2
- package/runtime/scripts/lib/graphql-contract.mjs +158 -322
- package/runtime/scripts/lib/graphql-sdl.mjs +262 -0
- package/runtime/scripts/lib/i18n.mjs +3 -2
- package/runtime/scripts/lib/in-order.mjs +71 -0
- package/runtime/scripts/lib/language.mjs +3 -3
- package/runtime/scripts/lib/list.mjs +8 -1
- package/runtime/scripts/lib/mutation-fence.mjs +15 -0
- package/runtime/scripts/lib/path-key.mjs +42 -9
- package/runtime/scripts/lib/pid-alive.mjs +7 -0
- package/runtime/scripts/lib/regex.mjs +1 -1
- package/runtime/scripts/lib/same-text.mjs +1 -1
- package/runtime/scripts/lib/secret-patterns.mjs +6 -2
- package/runtime/scripts/lib/sleep-sync.mjs +2 -2
- package/runtime/scripts/lib/sops-envelope.mjs +103 -2
- package/runtime/scripts/lib/stack-services.mjs +1 -2
- package/runtime/scripts/lib/ts-ast.mjs +9 -0
- package/runtime/scripts/lib/walk.mjs +6 -1
- package/scaffold/add-cli-lite.mjs +2 -1
- package/scaffold/add-table.mjs +2 -2
- package/scaffold/add.mjs +3 -2
- package/scaffold/app.mjs +4 -2
- package/scaffold/edition-gate.mjs +25 -25
- package/scaffold/lite-exports.mjs +2 -1
- package/scaffold/service.mjs +5 -4
- package/sync/hygiene.mjs +15 -9
- package/sync/index.mjs +7 -3
- package/templates/app/ci-workflows/github/workflows/ci.yml +4 -4
- package/templates/app/ci-workflows/github/workflows/e2e.yml +3 -3
- package/templates/app/ci-workflows/github/workflows/images.yml +3 -3
- package/templates/app/ci-workflows/sonar-steps.yml +2 -2
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +6 -6
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -3
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +3 -3
- package/templates/app/starciwork.gitignore +1 -1
- package/templates/be/image/api/Dockerfile +1 -1
- package/templates/be/image/cli/Dockerfile +1 -1
- package/templates/be/image/worker/Dockerfile +1 -1
- package/templates/be/patterns/cli/group.cli.spec.ts.tpl +1 -1
- package/templates/be/patterns/cli/group.cli.ts.tpl +2 -2
- package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +13 -0
- package/templates/be/patterns/event-bus/platform/event-runner.service.ts.tpl +7 -2
- package/templates/be/patterns/event-bus/platform/event.policy.ts.tpl +4 -1
- package/templates/be/patterns/event-bus/platform/kafka-event-transport.client.ts.tpl +2 -1
- package/templates/be/patterns/outbox/platform/outbox-relay.policy.ts.tpl +24 -9
- package/templates/be/patterns/queues/platform/queue-relay.service.ts.tpl +3 -2
- package/templates/be/patterns/queues/platform/queue-worker.service.ts.tpl +8 -5
- package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.spec.ts +1 -1
- package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.ts +2 -2
- package/templates/be/skeleton/src/features/cli/seed/seed.cli.spec.ts +1 -1
- package/templates/be/skeleton/src/features/cli/seed/seed.cli.ts +2 -2
- package/templates/be/skeleton/src/modules/platform/database/migrate-connections.client.ts +3 -2
- package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +5 -4
- package/templates/be/skeleton/src/modules/platform/primitives/index.ts +1 -0
- package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +47 -0
- package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.ts +16 -0
- package/templates/be/skeleton-lite/apps/api/Dockerfile +1 -0
- package/templates/be/skeleton-lite/src/modules/platform/primitives/index.ts +2 -0
- package/templates/fe/image/next/Dockerfile +2 -1
- package/templates/fe/skeleton/apps/app/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton/apps/landing/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -2
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +1 -2
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignInForm/index.tsx +7 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +1 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +10 -2
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +10 -3
- package/upgrade/index.mjs +9 -2
|
@@ -4,17 +4,19 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
import {skillRoot} from './runtime-root.mjs';
|
|
5
5
|
import {parseYaml} from './yaml.mjs';
|
|
6
6
|
import {isPlainObject as plain} from './plain-object.mjs';
|
|
7
|
-
import {invalid,validateRoots} from './invalid-config.mjs';
|
|
8
|
-
import {
|
|
7
|
+
import {invalid,validateRoots,ROOT_KEYS} from './invalid-config.mjs';
|
|
8
|
+
import {validateSonar} from './sonar-config.mjs';
|
|
9
|
+
import {validateOrca,ORCA_KEYS} from './orca-config.mjs';
|
|
10
|
+
import {validateResources,RESOURCE_KEYS} from './resources-config.mjs';
|
|
11
|
+
import {ENV_NAME,secretEnv,connectorSecret} from './secrets.mjs';
|
|
12
|
+
import {byCodeUnit} from './by-code-unit.mjs';
|
|
13
|
+
import {EFFORT_LEVELS,MODELS_KEYS,validateModelsBlock,refuseRemovedKeys,shippedTiers} from './model-config.mjs';
|
|
14
|
+
const knownNames=names=>[...names].sort(byCodeUnit).join(', ');
|
|
15
|
+
export {readDotenv,connectorSecret} from './secrets.mjs';
|
|
9
16
|
|
|
10
17
|
export const configRoot=skillRoot;
|
|
11
|
-
export const NON_OPERATION_ROLES={planner:'plan',kernelManager:'decide',validator:'verify'};
|
|
12
|
-
export const DEFAULT_MODEL_POOLS={'sol-opus':['claude-agent','codex-agent']};
|
|
13
18
|
/** The owner-facing language when config.yaml `language` is absent — the one default every reader shares (scripts/lib/i18n.mjs ownerLanguage). A base-tier value: machine (home.mjs) re-exports it, not the reverse. */
|
|
14
19
|
export const DEFAULT_OWNER_LANGUAGE='en';
|
|
15
|
-
const ADAPTIVE_ALLOCATION_MODE='adaptive';
|
|
16
|
-
/** The effort vocabulary, ordered weakest to strongest — the only list of it. */
|
|
17
|
-
const EFFORT_LEVELS=['none','minimal','low','medium','high','xhigh','max','ultra'];
|
|
18
20
|
// Parsed once per file version (mtime + size) per process; each caller gets its own copy.
|
|
19
21
|
// The one runtimes.yaml loader: it throws on a missing or unparsable file, so no caller ever
|
|
20
22
|
// reasons on a silent empty document. The pool map (provider, roles, models, maxParallel)
|
|
@@ -24,13 +26,13 @@ let runtimeProfileCache=null;
|
|
|
24
26
|
export function runtimeProfile(){
|
|
25
27
|
const source=fileURLToPath(new URL('../modules/models/runtimes.yaml',import.meta.url));
|
|
26
28
|
const registryFile=fileURLToPath(new URL('../modules/models/registry.yaml',import.meta.url));
|
|
27
|
-
let stat;try{stat=fs.statSync(source);}catch{throw Error('Missing modules/models/runtimes.yaml');}
|
|
28
|
-
let rstat;try{rstat=fs.statSync(registryFile);}catch{throw Error('Missing modules/models/registry.yaml');}
|
|
29
|
+
let stat;try{stat=fs.statSync(source);}catch{throw new Error('Missing modules/models/runtimes.yaml');}
|
|
30
|
+
let rstat;try{rstat=fs.statSync(registryFile);}catch{throw new Error('Missing modules/models/registry.yaml');}
|
|
29
31
|
const version=`${stat.mtimeMs}:${stat.size}:${rstat.mtimeMs}:${rstat.size}`;
|
|
30
32
|
if(runtimeProfileCache?.version!==version){
|
|
31
33
|
const doc=parseYaml(fs.readFileSync(source,'utf8'));
|
|
32
34
|
const registry=parseYaml(fs.readFileSync(registryFile,'utf8'));
|
|
33
|
-
runtimeProfileCache={version,profile:{...
|
|
35
|
+
runtimeProfileCache={version,profile:{...doc,runtimes:registry?.pools??{},models:registry?.models??{}}};
|
|
34
36
|
}
|
|
35
37
|
return structuredClone(runtimeProfileCache.profile);
|
|
36
38
|
}
|
|
@@ -39,12 +41,38 @@ export function runtimeProfile(){
|
|
|
39
41
|
* TTL, the liveness and cadence windows, the slicing weights, the failure cooldowns. Code reads them from
|
|
40
42
|
* here; a literal copy of any of them in a source file would be a second authority.
|
|
41
43
|
*/
|
|
42
|
-
export function allocationSettings(){
|
|
44
|
+
export function allocationSettings(){
|
|
45
|
+
const allocation=runtimeProfile()?.allocation??{};
|
|
46
|
+
return allocation.admission?{...allocation,admission:{...allocation.admission,...shippedTiers().usage}}:allocation;
|
|
47
|
+
}
|
|
48
|
+
// These are current owner declarations, never a historical runtime owner's consent.
|
|
49
|
+
function validateOwnerProfile(value,name,pathsKey){
|
|
50
|
+
if(value===null)return;
|
|
51
|
+
const bad=invalid(name);
|
|
52
|
+
if(!plain(value))bad(' must be an owner approval mapping or null.');
|
|
53
|
+
const keys=new Set(['approvedBy','approvalRef',pathsKey,...(name==='launchTrust'?['profile']:[])]);
|
|
54
|
+
for(const key of Object.keys(value))if(!keys.has(key))bad(` has unknown key ${key}.`);
|
|
55
|
+
if(value.approvedBy!=='owner')bad('.approvedBy must be owner; adoption must come from the current owner.');
|
|
56
|
+
if(typeof value.approvalRef!=='string'||!value.approvalRef.trim())bad('.approvalRef must identify the current owner approval.');
|
|
57
|
+
if(name==='launchTrust'&&!['automatic','declined'].includes(value.profile))bad('.profile must be automatic or declined.');
|
|
58
|
+
const paths=value[pathsKey];
|
|
59
|
+
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.`);
|
|
60
|
+
}
|
|
61
|
+
export function launchTrustSettings(config=loadConfig()){
|
|
62
|
+
const value=config?.launchTrust??null;validateOwnerProfile(value,'launchTrust','roots');return value;
|
|
63
|
+
}
|
|
64
|
+
export function workflowPurgeSettings(config=loadConfig()){
|
|
65
|
+
const retention=config?.retention??null;
|
|
66
|
+
if(retention===null)return null;
|
|
67
|
+
const bad=invalid('retention');
|
|
68
|
+
if(!plain(retention)||Object.keys(retention).some(k=>k!=='workflowPurge'))bad(' must be {workflowPurge?} or null.');
|
|
69
|
+
const value=retention.workflowPurge??null;validateOwnerProfile(value,'retention.workflowPurge','repos');return value;
|
|
70
|
+
}
|
|
43
71
|
/** One positive millisecond value out of `allocation`, by dotted key. Refuses when the contract omits it. */
|
|
44
72
|
export function allocationMs(dotted){
|
|
45
73
|
const raw=dotted.split('.').reduce((node,key)=>(node==null?node:node[key]),allocationSettings());
|
|
46
74
|
const value=Number(raw);
|
|
47
|
-
if(!Number.isFinite(value)||value<=0)throw Error(`modules/models/runtimes.yaml allocation.${dotted} must declare a positive number of milliseconds`);
|
|
75
|
+
if(!Number.isFinite(value)||value<=0)throw new Error(`modules/models/runtimes.yaml allocation.${dotted} must declare a positive number of milliseconds`);
|
|
48
76
|
return value;
|
|
49
77
|
}
|
|
50
78
|
/**
|
|
@@ -55,7 +83,7 @@ export function allocationMs(dotted){
|
|
|
55
83
|
export function slicingGears(){
|
|
56
84
|
const gears=allocationSettings()?.slicing?.gears;
|
|
57
85
|
if(!Array.isArray(gears)||!gears.length||gears.some(value=>!Number.isInteger(value)||value<1))
|
|
58
|
-
throw Error('modules/models/runtimes.yaml allocation.slicing.gears must declare a non-empty list of positive integers');
|
|
86
|
+
throw new Error('modules/models/runtimes.yaml allocation.slicing.gears must declare a non-empty list of positive integers');
|
|
59
87
|
return [...gears];
|
|
60
88
|
}
|
|
61
89
|
/** The gear an absent `parallel` block means. The first declared gear, never a literal. */
|
|
@@ -75,121 +103,94 @@ const CLOUDFLARE_MODES=['off','quick','named'];
|
|
|
75
103
|
/** The serve-ask port band, both ends included (scripts/kernel/ask-server.mjs scans [first..last]); the gateway stays outside it. */
|
|
76
104
|
export const ASK_PORT_BAND=[6969,7069];
|
|
77
105
|
export const CONNECTOR_DEFAULTS=Object.freeze({
|
|
78
|
-
secretsFile:null,
|
|
79
106
|
repos:[],
|
|
80
107
|
gateway:{port:7070},
|
|
81
108
|
cloudflare:{mode:'off',tunnel:null,credentialsFile:null,tokenEnv:'CLOUDFLARE_TUNNEL_TOKEN',hostname:null,access:false},
|
|
82
109
|
telegram:{enabled:false,botTokenEnv:'TELEGRAM_BOT_TOKEN',chatId:null,exposeCredentialAsks:false},
|
|
83
110
|
});
|
|
84
|
-
const ENV_NAME=/^[A-Z_][A-Z0-9_]{0,63}$/;
|
|
85
111
|
const HOSTNAME=/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
|
|
86
112
|
const TUNNEL_REF=/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
|
|
87
|
-
const CHAT_ID=/^(?:-?\d{1,20}|@[A-Za-z]
|
|
88
|
-
const SECRET_KEYS=['token','tunnelToken','botToken','secret','apiToken','password'];
|
|
113
|
+
const CHAT_ID=/^(?:-?\d{1,20}|@[A-Za-z]\w{4,31})$/;
|
|
114
|
+
const SECRET_KEYS=new Set(['token','tunnelToken','botToken','secret','apiToken','password']);
|
|
115
|
+
const closedConnectorShape=(bad,node,where,keys)=>{
|
|
116
|
+
if(!plain(node))bad(`.${where} must be a mapping.`);
|
|
117
|
+
for(const key of Object.keys(node)){
|
|
118
|
+
if(SECRET_KEYS.has(key))bad(`.${where}.${key}: secrets never live in config.yaml — set the environment variable the *Env key names instead.`);
|
|
119
|
+
if(!keys.includes(key))bad(`.${where} has unknown key ${key} (allowed: ${keys.join(', ')}).`);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const connectorEnvName=(bad,where,value)=>{if(value!==undefined&&(typeof value!=='string'||!ENV_NAME.test(value)))bad(`.${where} must name an environment variable (UPPER_SNAKE), never hold the secret itself.`);};
|
|
123
|
+
const validateCloudflareMode=(cf,bad)=>{if(cf.mode!==undefined&&!CLOUDFLARE_MODES.includes(cf.mode))bad(`.cloudflare.mode must be one of ${CLOUDFLARE_MODES.join(' | ')}.`);};
|
|
124
|
+
const validateCloudflareDetails=(cf,bad)=>{
|
|
125
|
+
if(cf.hostname!==undefined&&cf.hostname!==null&&(typeof cf.hostname!=='string'||!HOSTNAME.test(cf.hostname)))bad('.cloudflare.hostname must be a bare hostname (no scheme, no path) or null.');
|
|
126
|
+
if(cf.access!==undefined&&typeof cf.access!=='boolean')bad('.cloudflare.access must be true or false.');
|
|
127
|
+
if(cf.tunnel!==undefined&&cf.tunnel!==null&&(typeof cf.tunnel!=='string'||!TUNNEL_REF.test(cf.tunnel)))bad('.cloudflare.tunnel must be the named tunnel UUID (or name) or null.');
|
|
128
|
+
if(cf.credentialsFile!==undefined&&cf.credentialsFile!==null&&(typeof cf.credentialsFile!=='string'||!cf.credentialsFile.trim()))bad('.cloudflare.credentialsFile must be the tunnel credentials JSON path or null.');
|
|
129
|
+
};
|
|
130
|
+
const validateCloudflareModeConstraints=(cf,bad)=>{if(cf.credentialsFile&&!cf.tunnel){bad('.cloudflare.credentialsFile needs cloudflare.tunnel (the tunnel UUID the credentials belong to).');}if(cf.mode==='named'&&!cf.hostname){bad('.cloudflare.mode named needs cloudflare.hostname (the public hostname routed to the gateway).');}if(cf.mode==='quick'&&(cf.tunnel||cf.credentialsFile)){bad('.cloudflare.mode quick runs no named tunnel; tunnel/credentialsFile are for mode named.');}if(cf.mode==='quick'&&cf.hostname){bad('.cloudflare.mode quick gets a random trycloudflare.com hostname; set hostname only for mode named.');}};
|
|
131
|
+
const validateCloudflareConnector=cf=>{
|
|
132
|
+
const bad=invalid('connectors');
|
|
133
|
+
closedConnectorShape(bad,cf,'cloudflare',['mode','tunnel','credentialsFile','tokenEnv','hostname','access']);
|
|
134
|
+
validateCloudflareMode(cf,bad);
|
|
135
|
+
connectorEnvName(bad,'cloudflare.tokenEnv',cf.tokenEnv);
|
|
136
|
+
validateCloudflareDetails(cf,bad);
|
|
137
|
+
validateCloudflareModeConstraints(cf,bad);
|
|
138
|
+
};
|
|
139
|
+
const validateTelegramConnector=tg=>{
|
|
140
|
+
const bad=invalid('connectors');
|
|
141
|
+
closedConnectorShape(bad,tg,'telegram',['enabled','botTokenEnv','chatId','exposeCredentialAsks']);
|
|
142
|
+
if(tg.enabled!==undefined&&typeof tg.enabled!=='boolean')bad('.telegram.enabled must be true or false.');
|
|
143
|
+
connectorEnvName(bad,'telegram.botTokenEnv',tg.botTokenEnv);
|
|
144
|
+
if(tg.chatId!==undefined&&tg.chatId!==null&&!((Number.isSafeInteger(tg.chatId))||(typeof tg.chatId==='string'&&CHAT_ID.test(tg.chatId))))
|
|
145
|
+
bad('.telegram.chatId must be a numeric chat id, an @channel name, or null.');
|
|
146
|
+
if(tg.exposeCredentialAsks!==undefined&&typeof tg.exposeCredentialAsks!=='boolean')bad('.telegram.exposeCredentialAsks must be true or false.');
|
|
147
|
+
if(tg.enabled===true&&(tg.chatId===undefined||tg.chatId===null))bad('.telegram.enabled needs telegram.chatId.');
|
|
148
|
+
};
|
|
89
149
|
function validateConnectors(connectors){
|
|
90
150
|
if(connectors===null)return;
|
|
91
151
|
const bad=invalid('connectors');
|
|
92
|
-
if(!plain(connectors))bad(' must be {
|
|
93
|
-
|
|
94
|
-
if(!plain(node))bad(`.${where} must be a mapping.`);
|
|
95
|
-
for(const key of Object.keys(node)){
|
|
96
|
-
if(SECRET_KEYS.includes(key))bad(`.${where}.${key}: secrets never live in config.yaml — set the environment variable the *Env key names instead.`);
|
|
97
|
-
if(!keys.includes(key))bad(`.${where} has unknown key ${key} (allowed: ${keys.join(', ')}).`);
|
|
98
|
-
}
|
|
99
|
-
};
|
|
100
|
-
closed(connectors,'',['secretsFile','repos','gateway','cloudflare','telegram']);
|
|
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.');
|
|
152
|
+
if(!plain(connectors))bad(' must be {repos?, gateway?, cloudflare?, telegram?} or null.');
|
|
153
|
+
closedConnectorShape(bad,connectors,'',['repos','gateway','cloudflare','telegram']);
|
|
102
154
|
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
155
|
if(connectors.gateway!==undefined){
|
|
104
|
-
|
|
156
|
+
closedConnectorShape(bad,connectors.gateway,'gateway',['port']);
|
|
105
157
|
const port=connectors.gateway.port;
|
|
106
158
|
if(port!==undefined&&(!Number.isInteger(port)||port<1024||port>65535||(port>=ASK_PORT_BAND[0]&&port<=ASK_PORT_BAND[1])))
|
|
107
159
|
bad(`.gateway.port must be an integer port in 1024..65535 outside the serve-ask band ${ASK_PORT_BAND.join('..')}.`);
|
|
108
160
|
}
|
|
109
|
-
|
|
110
|
-
if(connectors.
|
|
111
|
-
const cf=connectors.cloudflare;
|
|
112
|
-
closed(cf,'cloudflare',['mode','tunnel','credentialsFile','tokenEnv','hostname','access']);
|
|
113
|
-
if(cf.mode!==undefined&&!CLOUDFLARE_MODES.includes(cf.mode))bad(`.cloudflare.mode must be one of ${CLOUDFLARE_MODES.join(' | ')}.`);
|
|
114
|
-
envName('cloudflare.tokenEnv',cf.tokenEnv);
|
|
115
|
-
if(cf.hostname!==undefined&&cf.hostname!==null&&(typeof cf.hostname!=='string'||!HOSTNAME.test(cf.hostname)))bad('.cloudflare.hostname must be a bare hostname (no scheme, no path) or null.');
|
|
116
|
-
if(cf.access!==undefined&&typeof cf.access!=='boolean')bad('.cloudflare.access must be true or false.');
|
|
117
|
-
if(cf.tunnel!==undefined&&cf.tunnel!==null&&(typeof cf.tunnel!=='string'||!TUNNEL_REF.test(cf.tunnel)))bad('.cloudflare.tunnel must be the named tunnel UUID (or name) or null.');
|
|
118
|
-
if(cf.credentialsFile!==undefined&&cf.credentialsFile!==null&&(typeof cf.credentialsFile!=='string'||!cf.credentialsFile.trim()))bad('.cloudflare.credentialsFile must be the tunnel credentials JSON path or null.');
|
|
119
|
-
if(cf.credentialsFile&&!cf.tunnel)bad('.cloudflare.credentialsFile needs cloudflare.tunnel (the tunnel UUID the credentials belong to).');
|
|
120
|
-
if(cf.mode==='named'&&!cf.hostname)bad('.cloudflare.mode named needs cloudflare.hostname (the public hostname routed to the gateway).');
|
|
121
|
-
if(cf.mode==='quick'&&(cf.tunnel||cf.credentialsFile))bad('.cloudflare.mode quick runs no named tunnel; tunnel/credentialsFile are for mode named.');
|
|
122
|
-
if(cf.mode==='quick'&&cf.hostname)bad('.cloudflare.mode quick gets a random trycloudflare.com hostname; set hostname only for mode named.');
|
|
123
|
-
}
|
|
124
|
-
if(connectors.telegram!==undefined){
|
|
125
|
-
const tg=connectors.telegram;
|
|
126
|
-
closed(tg,'telegram',['enabled','botTokenEnv','chatId','exposeCredentialAsks']);
|
|
127
|
-
if(tg.enabled!==undefined&&typeof tg.enabled!=='boolean')bad('.telegram.enabled must be true or false.');
|
|
128
|
-
envName('telegram.botTokenEnv',tg.botTokenEnv);
|
|
129
|
-
if(tg.chatId!==undefined&&tg.chatId!==null&&!((Number.isSafeInteger(tg.chatId))||(typeof tg.chatId==='string'&&CHAT_ID.test(tg.chatId))))
|
|
130
|
-
bad('.telegram.chatId must be a numeric chat id, an @channel name, or null.');
|
|
131
|
-
if(tg.exposeCredentialAsks!==undefined&&typeof tg.exposeCredentialAsks!=='boolean')bad('.telegram.exposeCredentialAsks must be true or false.');
|
|
132
|
-
if(tg.enabled===true&&(tg.chatId===undefined||tg.chatId===null))bad('.telegram.enabled needs telegram.chatId.');
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
/**
|
|
136
|
-
* The secret an env var NAME resolves to: `env[name]`, else the contents of the file `env[name + '_FILE']`
|
|
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;}
|
|
161
|
+
if(connectors.cloudflare!==undefined)validateCloudflareConnector(connectors.cloudflare);
|
|
162
|
+
if(connectors.telegram!==undefined)validateTelegramConnector(connectors.telegram);
|
|
167
163
|
}
|
|
164
|
+
/** Load connector credentials from the explicitly verified runtime main; never print the returned environment. */
|
|
165
|
+
export function connectorEnv(config,env=process.env,verifiedRuntimeRoot=undefined){return secretEnv(verifiedRuntimeRoot,env);}
|
|
168
166
|
/**
|
|
169
167
|
* The normalized `connectors` block: defaults filled in, the secrets' PRESENCE (booleans, never values),
|
|
170
168
|
* whether each connector is ready to run, and the owner-facing warnings the security posture earns.
|
|
171
169
|
*/
|
|
170
|
+
const connectorWarnings=(cloudflare,telegram)=>{
|
|
171
|
+
const warnings=[];
|
|
172
|
+
if(cloudflare.mode==='quick')warnings.push('cloudflare.mode quick: the ask link is public and unauthenticated — anyone holding the URL can open and answer the form; the hostname changes on every restart.');
|
|
173
|
+
if(cloudflare.mode==='named'&&!cloudflare.access)warnings.push(`cloudflare.access is false: ${cloudflare.hostname} is not fronted by Cloudflare Access, so the nonce URL is the only credential.`);
|
|
174
|
+
if(cloudflare.auth==='credentials-file'&&!cloudflare.credentialsPresent)warnings.push(`cloudflare.credentialsFile ${cloudflare.credentialsFile} does not exist.`);
|
|
175
|
+
if(cloudflare.auth==='token'&&!cloudflare.tokenPresent)warnings.push(`cloudflare.mode named: the tunnel token is not set — export ${cloudflare.tokenEnv} (or ${cloudflare.tokenEnv}_FILE).`);
|
|
176
|
+
if(telegram.enabled&&!telegram.botTokenPresent)warnings.push(`telegram.enabled: the bot token is not set — export ${telegram.botTokenEnv} (or ${telegram.botTokenEnv}_FILE).`);
|
|
177
|
+
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.');
|
|
178
|
+
return warnings;
|
|
179
|
+
};
|
|
172
180
|
export function connectorsConfig(config=loadConfig(),env=process.env,root=configRoot){
|
|
173
181
|
validateConfig(config);
|
|
174
|
-
env=connectorEnv(config,env,root);
|
|
175
182
|
const raw=plain(config?.connectors)?config.connectors:{},d=CONNECTOR_DEFAULTS;
|
|
176
|
-
const cloudflare={...d.cloudflare,...
|
|
183
|
+
const cloudflare={...d.cloudflare,...raw.cloudflare},telegram={...d.telegram,...raw.telegram};
|
|
177
184
|
if(cloudflare.credentialsFile)cloudflare.credentialsFile=path.resolve(root,cloudflare.credentialsFile.replace(/^~(?=[\\/])/,env.USERPROFILE??env.HOME??'~'));
|
|
178
185
|
cloudflare.tokenPresent=connectorSecret(cloudflare.tokenEnv,env)!==null;
|
|
179
186
|
cloudflare.credentialsPresent=Boolean(cloudflare.credentialsFile&&fs.existsSync(cloudflare.credentialsFile));
|
|
180
|
-
|
|
187
|
+
const namedAuth=cloudflare.credentialsFile?'credentials-file':'token';
|
|
188
|
+
cloudflare.auth=cloudflare.mode==='named'?namedAuth:null;
|
|
181
189
|
cloudflare.publicBase=cloudflare.mode==='named'?`https://${cloudflare.hostname}`:null;
|
|
182
190
|
telegram.chatId=telegram.chatId===null?null:String(telegram.chatId);
|
|
183
191
|
telegram.botTokenPresent=connectorSecret(telegram.botTokenEnv,env)!==null;
|
|
184
192
|
telegram.configured=telegram.enabled===true&&telegram.chatId!==null&&telegram.botTokenPresent;
|
|
185
|
-
|
|
186
|
-
if(cloudflare.mode==='quick')warnings.push('cloudflare.mode quick: the ask link is public and unauthenticated — anyone holding the URL can open and answer the form; the hostname changes on every restart.');
|
|
187
|
-
if(cloudflare.mode==='named'&&!cloudflare.access)warnings.push(`cloudflare.access is false: ${cloudflare.hostname} is not fronted by Cloudflare Access, so the nonce URL is the only credential.`);
|
|
188
|
-
if(cloudflare.auth==='credentials-file'&&!cloudflare.credentialsPresent)warnings.push(`cloudflare.credentialsFile ${cloudflare.credentialsFile} does not exist.`);
|
|
189
|
-
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
|
-
if(telegram.enabled&&!telegram.botTokenPresent)warnings.push(`telegram.enabled: the bot token is not set — export ${telegram.botTokenEnv} (or ${telegram.botTokenEnv}_FILE).`);
|
|
191
|
-
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 {secretsFile:connectorSecretsFile(config,root),repos:[...(raw.repos??d.repos)],gateway:{...d.gateway,...(raw.gateway??{})},cloudflare,telegram,warnings};
|
|
193
|
+
return {repos:[...(raw.repos??d.repos)],gateway:{...d.gateway,...raw.gateway},cloudflare,telegram,warnings:connectorWarnings(cloudflare,telegram)};
|
|
193
194
|
}
|
|
194
195
|
/**
|
|
195
196
|
* config.yaml `asks` — whether an owner ask that carries a recommended option is answered with it
|
|
@@ -202,18 +203,19 @@ export function connectorsConfig(config=loadConfig(),env=process.env,root=config
|
|
|
202
203
|
export const ASK_KINDS=Object.freeze(['information','credential','account','access','consent','authority','business-decision','irreversible-confirmation']);
|
|
203
204
|
const ASK_EXCLUDE_CLASSES=Object.freeze([...ASK_KINDS,'handover','draw-review']);
|
|
204
205
|
export const ASKS_DEFAULTS=Object.freeze({autoAcceptRecommended:false,excludes:Object.freeze(['credential','irreversible-confirmation','handover'])});
|
|
206
|
+
const validateAskExcludes=(bad,excludes)=>{
|
|
207
|
+
if(!Array.isArray(excludes))bad(`.excludes must be a list of ask classes (${ASK_EXCLUDE_CLASSES.join(', ')}).`);
|
|
208
|
+
for(const entry of excludes)if(typeof entry!=='string'||!ASK_EXCLUDE_CLASSES.includes(entry))bad(`.excludes entry ${JSON.stringify(entry)} is not an ask class (${ASK_EXCLUDE_CLASSES.join(', ')}).`);
|
|
209
|
+
if(new Set(excludes).size!==excludes.length)bad('.excludes names each class once.');
|
|
210
|
+
};
|
|
205
211
|
function validateAsks(asks){
|
|
206
212
|
if(asks===null)return;
|
|
207
213
|
const bad=invalid('asks');
|
|
208
214
|
if(!plain(asks))bad(' must be {autoAcceptRecommended?, excludes?} or null.');
|
|
209
|
-
for(const key of Object.keys(asks))if(!
|
|
215
|
+
for(const key of Object.keys(asks))if(!CONFIG_KEY_TREE.asks.includes(key))bad(` has unknown key ${key} (allowed: ${CONFIG_KEY_TREE.asks.join(', ')}).`);
|
|
210
216
|
if(asks.autoAcceptRecommended!==undefined&&typeof asks.autoAcceptRecommended!=='boolean')bad('.autoAcceptRecommended must be true or false.');
|
|
211
217
|
const excludes=asks.excludes;
|
|
212
|
-
if(excludes!==undefined&&excludes!==null)
|
|
213
|
-
if(!Array.isArray(excludes))bad(`.excludes must be a list of ask classes (${ASK_EXCLUDE_CLASSES.join(', ')}).`);
|
|
214
|
-
for(const entry of excludes)if(typeof entry!=='string'||!ASK_EXCLUDE_CLASSES.includes(entry))bad(`.excludes entry ${JSON.stringify(entry)} is not an ask class (${ASK_EXCLUDE_CLASSES.join(', ')}).`);
|
|
215
|
-
if(new Set(excludes).size!==excludes.length)bad('.excludes names each class once.');
|
|
216
|
-
}
|
|
218
|
+
if(excludes!==undefined&&excludes!==null)validateAskExcludes(bad,excludes);
|
|
217
219
|
}
|
|
218
220
|
/**
|
|
219
221
|
* config.yaml `uat` — the machine-wide UAT ceiling: at most `maxConcurrent` UAT runs (browsers, players,
|
|
@@ -224,7 +226,7 @@ function validateUat(uat){
|
|
|
224
226
|
if(uat===null)return;
|
|
225
227
|
const bad=invalid('uat');
|
|
226
228
|
if(!plain(uat))bad(' must be {maxConcurrent?} or null.');
|
|
227
|
-
for(const key of Object.keys(uat))if(key
|
|
229
|
+
for(const key of Object.keys(uat))if(!CONFIG_KEY_TREE.uat.includes(key))bad(` has unknown key ${key} (allowed: ${CONFIG_KEY_TREE.uat.join(', ')}).`);
|
|
228
230
|
if(uat.maxConcurrent!==undefined&&uat.maxConcurrent!==null&&!(Number.isInteger(uat.maxConcurrent)&&uat.maxConcurrent>=1))bad('.maxConcurrent must be a positive integer (default 10) or null.');
|
|
229
231
|
}
|
|
230
232
|
/** The owner's UAT concurrency settings: {maxConcurrent, source}. An absent or null block is the default. */
|
|
@@ -234,187 +236,149 @@ export function uatSettings(config=loadConfig()){
|
|
|
234
236
|
return Number.isInteger(value)?{maxConcurrent:value,source:'uat'}:{maxConcurrent:UAT_DEFAULTS.maxConcurrent,source:'default'};
|
|
235
237
|
}
|
|
236
238
|
/**
|
|
237
|
-
* config.yaml `allocation`
|
|
238
|
-
* (
|
|
239
|
-
* policy: prefer-then-overflow (the runtimes.yaml default) | balanced — among the eligible pools, the one
|
|
240
|
-
* furthest below its target share of the recent dispatches wins.
|
|
241
|
-
* shares: {<runtime pool>: <weight >= 0>} — target shares, normalized over the named pools. Absent = equal.
|
|
242
|
-
* windowHours: how far back the recent dispatches are counted (default 24).
|
|
243
|
-
* grants: ['<pool>=<slots>@<role>+<role>'] — the owner's default grant, applied to every workflow, that opens
|
|
244
|
-
* a capacityAuthority explicit-workflow-quota pool (Devin) for those roles up to <slots> running jobs.
|
|
239
|
+
* config.yaml `allocation` — {grants?}: ['<pool>=<slots>@<role>+<role>'], the owner's default grant, applied to every
|
|
240
|
+
* workflow, that opens a capacityAuthority explicit-workflow-quota pool (Devin) for those roles up to <slots> running jobs.
|
|
245
241
|
*/
|
|
246
|
-
export const ALLOCATION_POLICIES=Object.freeze(['prefer-then-overflow','balanced']);
|
|
247
|
-
const ALLOCATION_KEYS=Object.freeze(['mode','preferredProvider','policy','shares','windowHours','grants']);
|
|
248
|
-
export const DEFAULT_ALLOCATION_WINDOW_HOURS=24;
|
|
249
242
|
const GRANT=/^([a-z0-9][a-z0-9.-]*)=(\d+)@([a-z]+(?:\+[a-z]+)*)$/;
|
|
250
243
|
/** One grant string `<pool>=<slots>@<role>+<role>` as {pool, slots, roles}, or null when it is not that shape. */
|
|
244
|
+
/** The closed set of top-level config.yaml blocks: the validator accepts these and refuses every other key. */
|
|
245
|
+
export const CONFIG_BLOCKS=Object.freeze(['language','model','effort','models','allocation','kernel','budgets','supervisor','parallel','delegation','connectors','asks','uat','specs','reconciler','debugLoop','orca','roots','resources','sonar','launchTrust','retention']);
|
|
251
246
|
export function parseAllocationGrant(text){
|
|
252
247
|
const m=typeof text==='string'?GRANT.exec(text.trim()):null;
|
|
253
248
|
return m?{pool:m[1],slots:Number(m[2]),roles:m[3].split('+')}:null;
|
|
254
249
|
}
|
|
255
|
-
|
|
256
|
-
const
|
|
257
|
-
if(
|
|
258
|
-
|
|
259
|
-
if(
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
bad('windowHours must be a number of hours in (0, 720].');
|
|
270
|
-
if(allocation.grants!==undefined&&allocation.grants!==null){
|
|
271
|
-
if(!Array.isArray(allocation.grants))bad('grants must be a list of "<pool>=<slots>@<role>+<role>" strings.');
|
|
272
|
-
const seen=new Set();
|
|
273
|
-
for(const text of allocation.grants){
|
|
274
|
-
const grant=parseAllocationGrant(text);
|
|
275
|
-
if(!grant)bad(`grants entry ${JSON.stringify(text)} is not "<pool>=<slots>@<role>+<role>" (e.g. devin-agent=10@implement+verify+write).`);
|
|
276
|
-
const runtime=runtimes[grant.pool];
|
|
277
|
-
if(!plain(runtime))bad(`grants entry ${text}: ${grant.pool} is not a modules/models/registry.yaml pool.`);
|
|
278
|
-
if(seen.has(grant.pool))bad(`grants names ${grant.pool} more than once.`);
|
|
279
|
-
seen.add(grant.pool);
|
|
280
|
-
const max=Number(runtime.maxParallel);
|
|
281
|
-
if(!(grant.slots>=1&&(!Number.isFinite(max)||grant.slots<=max)))bad(`grants entry ${text}: slots must be 1..${Number.isFinite(max)?max:'maxParallel'} (registry.yaml maxParallel).`);
|
|
282
|
-
const unserved=grant.roles.filter(role=>!(runtime.roles??[]).includes(role));
|
|
283
|
-
if(unserved.length)bad(`grants entry ${text}: ${grant.pool} does not serve role ${unserved.join(', ')} (registry.yaml roles: ${(runtime.roles??[]).join(', ')}).`);
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
/** Kernel and Supervisor seats share one authoritative pin/group grammar. */
|
|
250
|
+
const validateGrant=(bad,text,runtimes,seen)=>{
|
|
251
|
+
const grant=parseAllocationGrant(text);
|
|
252
|
+
if(!grant)bad(`grants entry ${JSON.stringify(text)} is not "<pool>=<slots>@<role>+<role>" (e.g. devin-agent=10@implement+verify+write).`);
|
|
253
|
+
const runtime=runtimes[grant.pool];
|
|
254
|
+
if(!plain(runtime))bad(`grants entry ${text}: ${grant.pool} is not a modules/models/registry.yaml pool.`);
|
|
255
|
+
if(seen.has(grant.pool)){bad(`grants names ${grant.pool} more than once.`);} seen.add(grant.pool);
|
|
256
|
+
const max=Number(runtime.maxParallel); if(!(grant.slots>=1&&(!Number.isFinite(max)||grant.slots<=max)))bad(`grants entry ${text}: slots must be 1..${Number.isFinite(max)?max:'maxParallel'} (registry.yaml maxParallel).`);
|
|
257
|
+
const unserved=grant.roles.filter(role=>!(runtime.roles??[]).includes(role)); if(unserved.length)bad(`grants entry ${text}: ${grant.pool} does not serve role ${unserved.join(', ')} (registry.yaml roles: ${(runtime.roles??[]).join(', ')}).`);
|
|
258
|
+
};
|
|
259
|
+
const validateGrants=(bad,grants,runtimes)=>{
|
|
260
|
+
if(!Array.isArray(grants))bad('grants must be a list of "<pool>=<slots>@<role>+<role>" strings.');
|
|
261
|
+
const seen=new Set();
|
|
262
|
+
for(const text of grants)validateGrant(bad,text,runtimes,seen);
|
|
263
|
+
};
|
|
288
264
|
function validateAgentSeat(seat,name,profile){
|
|
289
265
|
const runtimes=profile?.runtimes??{},knownProviders=new Set(Object.values(runtimes).map(runtime=>runtime?.provider).filter(Boolean));
|
|
290
|
-
if(plain(seat)
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
if(new Set(group.map(member=>member.agent)).size!==group.length)throw Error(`Invalid config.yaml: ${name}.group names each agent once — availability is per provider.`);
|
|
295
|
-
for(const {agent,model} of group){
|
|
296
|
-
if(!knownProviders.has(agent))throw Error(`Invalid config.yaml: ${name}.group agent ${agent} is not declared by a runtime (known: ${[...knownProviders].sort().join(', ')}).`);
|
|
297
|
-
if(typeof model==='string'&&profile.models?.[model]?.provider!==agent&&!Object.values(runtimes).some(runtime=>runtime?.provider===agent&&(runtime.target===model||Object.values(runtime.models??{}).includes(model))))
|
|
298
|
-
throw Error(`Invalid config.yaml: ${name}.group model ${model} is not declared by a ${agent} runtime.`);
|
|
299
|
-
}
|
|
300
|
-
}else{
|
|
301
|
-
if(!plain(seat)||Object.keys(seat).some(key=>!['agent','model','effort'].includes(key))||Object.values(seat).some(value=>value!==null&&(typeof value!=='string'||!value.trim())))
|
|
302
|
-
throw Error(`Invalid config.yaml: ${name} must be {agent?, model?, effort?} with string-or-null values, or {group: [{agent, model?}, ...], effort?}.`);
|
|
303
|
-
if(typeof seat.agent==='string'&&!knownProviders.has(seat.agent))throw Error(`Invalid config.yaml: ${name}.agent ${seat.agent} is not declared by a runtime (known: ${[...knownProviders].sort().join(', ')}).`);
|
|
304
|
-
}
|
|
305
|
-
if(seat.effort!==undefined&&seat.effort!==null&&!EFFORT_LEVELS.includes(seat.effort))throw Error(`Invalid config.yaml: ${name}.effort must use the effort vocabulary.`);
|
|
266
|
+
if(!plain(seat)||Object.keys(seat).some(key=>!SEAT_KEYS.includes(key))||Object.values(seat).some(value=>value!==null&&(typeof value!=='string'||!value.trim())))
|
|
267
|
+
throw new Error(`Invalid config.yaml: ${name} must be {agent?, model?, effort?} with string-or-null values (agent and model pin the seat: an \`only\` bias).`);
|
|
268
|
+
if(typeof seat.agent==='string'&&!knownProviders.has(seat.agent))throw new Error(`Invalid config.yaml: ${name}.agent ${seat.agent} is not declared by a runtime (known: ${knownNames(knownProviders)}).`);
|
|
269
|
+
if(seat.effort!==undefined&&seat.effort!==null&&!EFFORT_LEVELS.has(seat.effort))throw new Error(`Invalid config.yaml: ${name}.effort must use the effort vocabulary.`);
|
|
306
270
|
}
|
|
307
|
-
|
|
308
|
-
const
|
|
309
|
-
if(
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
if(
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
271
|
+
const validateReconcilerBlock=r=>{
|
|
272
|
+
const ctl=r?.controllers;
|
|
273
|
+
if(!plain(r)||Object.keys(r).some(key=>!CONFIG_KEY_TREE.reconciler.includes(key))||(r.enabled!==undefined&&typeof r.enabled!=='boolean')||!(r.profile===undefined||r.profile===null||['operational','observe'].includes(r.profile))||!(ctl===undefined||ctl===null||(plain(ctl)&&Object.entries(ctl).every(([name,c])=>/^[a-z][a-z0-9-]*$/.test(name)&&plain(c)&&Object.keys(c).every(key=>key==='mode')&&['off','shadow','active'].includes(c.mode)))))
|
|
274
|
+
throw new Error('Invalid config.yaml: reconciler must be {enabled?: boolean, profile?: operational|observe, controllers?: {<name>: {mode: off|shadow|active}}}, or null.');
|
|
275
|
+
};
|
|
276
|
+
const validateAllocationBlock=(allocation,runtimes)=>{
|
|
277
|
+
if(!plain(allocation)||Object.keys(allocation).some(key=>!CONFIG_KEY_TREE.allocation.includes(key)))
|
|
278
|
+
throw new Error('Invalid config.yaml: allocation must be {grants?: [<pool>=<slots>@<role>+<role>]}.');
|
|
279
|
+
if(allocation.grants!==undefined&&allocation.grants!==null)validateGrants(invalid('allocation.'),allocation.grants,runtimes);
|
|
280
|
+
};
|
|
281
|
+
const validateParallelBlock=parallel=>{
|
|
282
|
+
const gears=slicingGears();
|
|
283
|
+
if(!plain(parallel)||Object.keys(parallel).some(key=>!CONFIG_KEY_TREE.parallel.includes(key))||!Number.isInteger(parallel.gear))
|
|
284
|
+
throw new Error('Invalid config.yaml: parallel must be {gear: <integer>}.');
|
|
285
|
+
if(!gears.includes(parallel.gear))
|
|
286
|
+
throw new Error(`Invalid config.yaml: parallel.gear ${parallel.gear} is not declared by modules/models/runtimes.yaml allocation.slicing.gears (known: ${gears.join(', ')}).`);
|
|
287
|
+
};
|
|
288
|
+
const validateSupervisorBlock=(supervisor,profile)=>{
|
|
289
|
+
const interval=supervisor?.pollIntervalMs,repos=supervisor?.repos,stall=supervisor?.stallMinutes;
|
|
290
|
+
if(!plain(supervisor)||Object.keys(supervisor).some(key=>!CONFIG_KEY_TREE.supervisor.includes(key))||!(interval===null||interval===undefined||(Number.isInteger(interval)&&interval>=60000)))
|
|
291
|
+
throw new Error('Invalid config.yaml: supervisor must be {mode?, pollIntervalMs?, repos?, stallMinutes?, kernel?, workers?, landGate?, frozenMinutes?} with an integer of at least 60000 ms, or null.');
|
|
292
|
+
// mode: where the Supervisor role runs (scripts/machine/home.mjs supervisorMode) - chat (default: the owner's desktop
|
|
293
|
+
// chat session owns channel 'main' and ticks itself) or kernel (the optional [Supervisor] Orca kernel, start-supervisor.mjs).
|
|
294
|
+
if(!(supervisor.mode===undefined||supervisor.mode===null||['chat','kernel'].includes(supervisor.mode)))
|
|
295
|
+
throw new Error('Invalid config.yaml: supervisor.mode must be chat or kernel, or null.');
|
|
296
|
+
// The [Supervisor] kernel seat (scripts/machine/home.mjs supervisorSettings): kernel {agent?, model?, effort?}
|
|
297
|
+
// pins its agent (default: the kernel pin), workers {base?, max?} its adaptive [Worker] cap (0 to 10; the shipped default is 0 fix workers),
|
|
298
|
+
// landGate {mode?: shared|exclusive} the land gate (scripts/supervisor/land.mjs).
|
|
299
|
+
const seat=supervisor.kernel,workers=supervisor.workers,gate=supervisor.landGate;
|
|
300
|
+
if(seat!==undefined&&seat!==null)validateAgentSeat(seat,'supervisor.kernel',profile);
|
|
301
|
+
if(!(workers===undefined||workers===null||(plain(workers)&&Object.keys(workers).every(key=>['base','max'].includes(key)&&Number.isInteger(workers[key])&&workers[key]>=0&&workers[key]<=10))))
|
|
302
|
+
throw new Error('Invalid config.yaml: supervisor.workers must be {base?, max?} integers from 0 to 10, or null.');
|
|
303
|
+
if(!(gate===undefined||gate===null||(plain(gate)&&Object.keys(gate).every(key=>key==='mode'&&['shared','exclusive'].includes(gate.mode)))))
|
|
304
|
+
throw new Error('Invalid config.yaml: supervisor.landGate must be {mode?: shared|exclusive}, or null.');
|
|
305
|
+
// stallMinutes: scripts/supervisor/stall.mjs calls a running workflow STALLED after this many minutes with no progress.
|
|
306
|
+
// frozenMinutes: scripts/supervisor/supervisor-watchdog.mjs reads a busy seat frame with no turn progress for this long as frozen.
|
|
307
|
+
const frozen=supervisor.frozenMinutes;
|
|
308
|
+
if(!(frozen===undefined||frozen===null||(Number.isInteger(frozen)&&frozen>=1)))
|
|
309
|
+
throw new Error('Invalid config.yaml: supervisor.frozenMinutes must be an integer of at least 1, or null.');
|
|
310
|
+
if(!(stall===undefined||stall===null||(Number.isInteger(stall)&&stall>=5)))
|
|
311
|
+
throw new Error('Invalid config.yaml: supervisor.stallMinutes must be an integer of at least 5, or null.');
|
|
312
|
+
if(!(repos===undefined||repos===null||(Array.isArray(repos)&&repos.every(repo=>typeof repo==='string'&&repo.trim()))))
|
|
313
|
+
throw new Error('Invalid config.yaml: supervisor.repos must be a list of ledger-owner repository paths, or null.');
|
|
314
|
+
};
|
|
315
|
+
const validateDelegationBlock=d=>{if(!plain(d)||Object.keys(d).some(key=>!CONFIG_KEY_TREE.delegation.includes(key))||typeof d.asks!=='string'||!d.asks.trim()||typeof d.until!=='string'||Number.isNaN(Date.parse(d.until))||(d.excludes!==undefined&&(!Array.isArray(d.excludes)||d.excludes.some(x=>typeof x!=='string'))))throw new Error('Invalid config.yaml: delegation must be {asks: <delegate>, until: <ISO time>, excludes?: [<class>], note?} or null.');};
|
|
316
|
+
const validateBudgetsBlock=budgets=>{if(!plain(budgets)||Object.keys(budgets).some(key=>!CONFIG_KEY_TREE.budgets.includes(key))||Object.values(budgets).some(value=>value!==null&&!(Number.isInteger(value)&&value>0)))throw new Error('Invalid config.yaml: budgets must be {maxOps?} with positive-integer-or-null values.');};
|
|
317
|
+
const validateRootKeys=(config)=>{
|
|
318
|
+
const allowed=new Set(CONFIG_BLOCKS);
|
|
319
|
+
if(!plain(config)||Object.keys(config).some(key=>!allowed.has(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.has(config.effort))
|
|
320
|
+
throw new Error('Invalid config.yaml: expected language, model, effort and the closed set of config blocks.');
|
|
321
|
+
};
|
|
322
|
+
const validateEarlyConfigBlocks=(config)=>{
|
|
323
|
+
if(config?.launchTrust!==undefined){launchTrustSettings(config);} if(config?.retention!==undefined){workflowPurgeSettings(config);}
|
|
324
|
+
if(config?.connectors!==undefined){validateConnectors(config.connectors);} if(config?.asks!==undefined){validateAsks(config.asks);}
|
|
325
|
+
if(config?.uat!==undefined){validateUat(config.uat);} if(config?.debugLoop!==undefined){validateDebugLoop(config.debugLoop);}
|
|
326
|
+
if(config?.orca!==undefined){validateOrca(config.orca);} if(config?.roots!==undefined){validateRoots(config.roots);} if(config?.resources!==undefined){validateResources(config.resources);} if(config?.sonar!==undefined){validateSonar(config.sonar);}
|
|
327
|
+
};
|
|
328
|
+
const validateConfigBlocks=(config,knownProviders,runtimes,profile)=>{
|
|
317
329
|
// specs (owner 2026-09-28): {harness?, unit?, e2e?} booleans - each family a boolean; absent = its default (SPEC_DEFAULTS: harness off, unit on, e2e off; specsSettings).
|
|
318
|
-
if(config?.specs!==undefined&&config.specs!==null&&(!plain(config.specs)||Object.keys(config.specs).some(key=>!
|
|
330
|
+
if(config?.specs!==undefined&&config.specs!==null&&(!plain(config.specs)||Object.keys(config.specs).some(key=>!CONFIG_KEY_TREE.specs.includes(key)||typeof config.specs[key]!=='boolean')))throw new Error(`Invalid config.yaml: specs must be {${SPEC_FAMILIES.map(k=>k+'?: boolean').join(', ')}}, or null.`);
|
|
319
331
|
// reconciler (scripts/reconciler/state.mjs reconcilerConfig): {enabled?: boolean, profile?: operational|observe, controllers?: {<name>: {mode: off|shadow|active}}}, or null.
|
|
320
|
-
if(config?.reconciler!==undefined&&config.reconciler!==null){
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
}
|
|
333
|
-
if(config?.kernel!==undefined)validateAgentSeat(config.kernel,'kernel',profile);
|
|
334
|
-
if(config?.parallel!==undefined){
|
|
335
|
-
const parallel=config.parallel,gears=slicingGears();
|
|
336
|
-
if(!plain(parallel)||Object.keys(parallel).some(key=>key!=='gear')||!Number.isInteger(parallel.gear))
|
|
337
|
-
throw Error('Invalid config.yaml: parallel must be {gear: <integer>}.');
|
|
338
|
-
if(!gears.includes(parallel.gear))
|
|
339
|
-
throw Error(`Invalid config.yaml: parallel.gear ${parallel.gear} is not declared by modules/models/runtimes.yaml allocation.slicing.gears (known: ${gears.join(', ')}).`);
|
|
340
|
-
}
|
|
341
|
-
if(config?.supervisor!==undefined){
|
|
342
|
-
const supervisor=config.supervisor,interval=supervisor?.pollIntervalMs,repos=supervisor?.repos,stall=supervisor?.stallMinutes;
|
|
343
|
-
if(!plain(supervisor)||Object.keys(supervisor).some(key=>!['mode','pollIntervalMs','repos','stallMinutes','kernel','workers','landGate','frozenMinutes'].includes(key))||!(interval===null||interval===undefined||(Number.isInteger(interval)&&interval>=60000)))
|
|
344
|
-
throw Error('Invalid config.yaml: supervisor must be {mode?, pollIntervalMs?, repos?, stallMinutes?, kernel?, workers?, landGate?, frozenMinutes?} with an integer of at least 60000 ms, or null.');
|
|
345
|
-
// mode: where the Supervisor role runs (scripts/machine/home.mjs supervisorMode) - chat (default: the owner's desktop
|
|
346
|
-
// chat session owns channel 'main' and ticks itself) or kernel (the optional [Supervisor] Orca kernel, start-supervisor.mjs).
|
|
347
|
-
if(!(supervisor.mode===undefined||supervisor.mode===null||['chat','kernel'].includes(supervisor.mode)))
|
|
348
|
-
throw Error('Invalid config.yaml: supervisor.mode must be chat or kernel, or null.');
|
|
349
|
-
// The [Supervisor] kernel seat (scripts/machine/home.mjs supervisorSettings): kernel {agent?, model?, effort?}
|
|
350
|
-
// pins its agent (default: the kernel pin), workers {base?, max?} its adaptive [Worker] cap (max <= 10),
|
|
351
|
-
// landGate {mode?: shared|exclusive, push?} the land gate (scripts/supervisor/land.mjs).
|
|
352
|
-
const seat=supervisor.kernel,workers=supervisor.workers,gate=supervisor.landGate;
|
|
353
|
-
if(seat!==undefined&&seat!==null)validateAgentSeat(seat,'supervisor.kernel',profile);
|
|
354
|
-
if(!(workers===undefined||workers===null||(plain(workers)&&Object.keys(workers).every(key=>['base','max'].includes(key)&&Number.isInteger(workers[key])&&workers[key]>=1&&workers[key]<=10))))
|
|
355
|
-
throw Error('Invalid config.yaml: supervisor.workers must be {base?, max?} integers from 1 to 10, or null.');
|
|
356
|
-
if(!(gate===undefined||gate===null||(plain(gate)&&Object.keys(gate).every(key=>(key==='mode'&&['shared','exclusive'].includes(gate.mode))||(key==='push'&&typeof gate.push==='boolean')))))
|
|
357
|
-
throw Error('Invalid config.yaml: supervisor.landGate must be {mode?: shared|exclusive, push?: boolean}, or null.');
|
|
358
|
-
// stallMinutes: scripts/supervisor/stall.mjs calls a running workflow STALLED after this many minutes with no progress.
|
|
359
|
-
// frozenMinutes: scripts/supervisor/supervisor-watchdog.mjs reads a busy seat frame with no turn progress for this long as frozen.
|
|
360
|
-
const frozen=supervisor.frozenMinutes;
|
|
361
|
-
if(!(frozen===undefined||frozen===null||(Number.isInteger(frozen)&&frozen>=1)))
|
|
362
|
-
throw Error('Invalid config.yaml: supervisor.frozenMinutes must be an integer of at least 1, or null.');
|
|
363
|
-
if(!(stall===undefined||stall===null||(Number.isInteger(stall)&&stall>=5)))
|
|
364
|
-
throw Error('Invalid config.yaml: supervisor.stallMinutes must be an integer of at least 5, or null.');
|
|
365
|
-
if(!(repos===undefined||repos===null||(Array.isArray(repos)&&repos.every(repo=>typeof repo==='string'&&repo.trim()))))
|
|
366
|
-
throw Error('Invalid config.yaml: supervisor.repos must be a list of ledger-owner repository paths, or null.');
|
|
367
|
-
}
|
|
368
|
-
if(config?.delegation!==undefined&&config.delegation!==null){
|
|
369
|
-
const d=config.delegation;
|
|
370
|
-
if(!plain(d)||Object.keys(d).some(key=>!['asks','until','excludes','note'].includes(key))||typeof d.asks!=='string'||!d.asks.trim()||typeof d.until!=='string'||Number.isNaN(Date.parse(d.until))||(d.excludes!==undefined&&(!Array.isArray(d.excludes)||d.excludes.some(x=>typeof x!=='string'))))
|
|
371
|
-
throw Error('Invalid config.yaml: delegation must be {asks: <delegate>, until: <ISO time>, excludes?: [<class>], note?} or null.');
|
|
372
|
-
}
|
|
373
|
-
if(config?.budgets!==undefined){
|
|
374
|
-
const budgets=config.budgets;
|
|
375
|
-
if(!plain(budgets)||Object.keys(budgets).some(key=>!['maxOps','perOpMs','dailyTokens'].includes(key))||Object.values(budgets).some(value=>value!==null&&!(Number.isInteger(value)&&value>0)))
|
|
376
|
-
throw Error('Invalid config.yaml: budgets must be {maxOps?, perOpMs?, dailyTokens?} with positive-integer-or-null values.');
|
|
377
|
-
}
|
|
378
|
-
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
|
-
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.`);
|
|
380
|
-
for(const [role,required] of Object.entries(NON_OPERATION_ROLES)){const pool=models.nonOperation[role],members=models.pools[pool];if(typeof pool!=='string'||!members||members.some(id=>!runtimes[id].roles?.includes(required)))throw Error(`Invalid config.yaml: models.nonOperation.${role} must name a pool whose members carry the ${required} role.`);}
|
|
332
|
+
if(config?.reconciler!==undefined&&config.reconciler!==null){validateReconcilerBlock(config.reconciler);} if(config?.allocation!==undefined){validateAllocationBlock(config.allocation,runtimes);}
|
|
333
|
+
if(config?.kernel!==undefined){validateAgentSeat(config.kernel,'kernel',profile);} if(config?.parallel!==undefined){validateParallelBlock(config.parallel);}
|
|
334
|
+
if(config?.supervisor!==undefined){validateSupervisorBlock(config.supervisor,profile);} if(config?.delegation!==undefined&&config.delegation!==null){validateDelegationBlock(config.delegation);} if(config?.budgets!==undefined){validateBudgetsBlock(config.budgets);}
|
|
335
|
+
};
|
|
336
|
+
export function validateConfig(config){
|
|
337
|
+
const profile=runtimeProfile(),runtimes=profile?.runtimes??{};
|
|
338
|
+
refuseRemovedKeys(config);
|
|
339
|
+
validateEarlyConfigBlocks(config);
|
|
340
|
+
const knownProviders=new Set(Object.values(runtimes).map(runtime=>runtime?.provider).filter(Boolean));
|
|
341
|
+
validateConfigBlocks(config,knownProviders,runtimes,profile);
|
|
342
|
+
validateRootKeys(config);
|
|
343
|
+
validateModelsBlock(config,profile);
|
|
381
344
|
return config;
|
|
382
345
|
}
|
|
383
|
-
export function effectiveNonOperationModels(config=loadConfig()){const models=validateConfig(config).models;return Object.fromEntries(Object.keys(NON_OPERATION_ROLES).map(role=>[role,{pool:models.nonOperation[role],runtimes:[...models.pools[models.nonOperation[role]]],selection:models.selection}]));}
|
|
384
346
|
/**
|
|
385
|
-
* The owner allocation control
|
|
386
|
-
*
|
|
347
|
+
* The owner allocation control: `allocation.grants` only. null = the owner declared no grants list: grant-gated pools
|
|
348
|
+
* keep their ungated routing; a declared list, even [], is the whole set of grants.
|
|
387
349
|
*/
|
|
388
350
|
export function configuredAllocationPolicy(config=loadConfig()){
|
|
389
351
|
validateConfig(config);
|
|
390
352
|
const allocation=plain(config.allocation)?config.allocation:null;
|
|
391
|
-
// null = the owner declared no grants list: grant-gated pools keep their pre-grant (ungated) routing.
|
|
392
|
-
// A declared list, even [], is the whole set of grants.
|
|
393
353
|
const grants=Array.isArray(allocation?.grants)
|
|
394
354
|
?Object.fromEntries(allocation.grants.map(parseAllocationGrant).map(({pool,slots,roles})=>[pool,{slots,roles}]))
|
|
395
355
|
:null;
|
|
396
|
-
return {
|
|
397
|
-
mode:ADAPTIVE_ALLOCATION_MODE,
|
|
398
|
-
preferredProvider:allocation?.preferredProvider??null,
|
|
399
|
-
// null = the runtimes.yaml allocation.policy default
|
|
400
|
-
policy:allocation?.policy??null,
|
|
401
|
-
shares:allocation?.shares?{...allocation.shares}:null,
|
|
402
|
-
windowHours:allocation?.windowHours??DEFAULT_ALLOCATION_WINDOW_HOURS,
|
|
403
|
-
grants,
|
|
404
|
-
source:allocation?'allocation':'default',
|
|
405
|
-
};
|
|
356
|
+
return {grants,source:allocation?'allocation':'default'};
|
|
406
357
|
}
|
|
407
|
-
|
|
358
|
+
/** The variable that confines the owner's `config.yaml` to a directory tree: set, an owner file is read only from under that directory (tests/setup/isolated-temp.mjs points it at a spec's own temp root). */
|
|
359
|
+
export const OWNER_CONFIG_WITHIN_ENV='STARCI_OWNER_CONFIG_WITHIN';
|
|
360
|
+
const foldPath=p=>process.platform==='win32'?path.resolve(p).toLowerCase():path.resolve(p);
|
|
361
|
+
/**
|
|
362
|
+
* Whether the owner's `config.yaml` under `root` is visible to this process. Unconfined (the variable unset) every owner file is visible; confined, only the files under the named
|
|
363
|
+
* directory, where a fixture wrote them, so the checkout's live file (roots.temp, supervisor.landGate, launch trust) never decides a result and a lane clone, the release host and a
|
|
364
|
+
* clean checkout run the same suite. The engine knows nothing of why a caller confines it: the spec preload sets the variable.
|
|
365
|
+
*/
|
|
366
|
+
const ownerFileVisible=(root,env=process.env)=>{
|
|
367
|
+
const within=env[OWNER_CONFIG_WITHIN_ENV];
|
|
368
|
+
if(!within)return true;
|
|
369
|
+
const rel=path.relative(foldPath(within),foldPath(root));
|
|
370
|
+
return !rel.startsWith('..')&&!path.isAbsolute(rel);
|
|
371
|
+
};
|
|
408
372
|
/** The validated config one yaml file holds, or null when the file is absent. */
|
|
409
373
|
const readYamlConfig=(root,name)=>{const yaml=path.join(root,name);return fs.existsSync(yaml)?validateConfig(parseYaml(fs.readFileSync(yaml,'utf8'))):null;};
|
|
410
|
-
function readExample(root=configRoot){const example=readYamlConfig(root,'config.example.yaml');if(example!==null)return example;throw Error('Missing config.example.yaml');}
|
|
374
|
+
function readExample(root=configRoot){const example=readYamlConfig(root,'config.example.yaml');if(example!==null){return example;}throw new Error('Missing config.example.yaml');}
|
|
411
375
|
/**
|
|
412
376
|
* The owner config reader: `config.yaml` is the per-project file (gitignored,
|
|
413
377
|
* seeded verbatim from `config.example.yaml` by the installer — comments and
|
|
414
378
|
* all). Returns the validated owner config, or null when that file does not
|
|
415
379
|
* exist; falling back to the example's defaults is `loadConfig`.
|
|
416
380
|
*/
|
|
417
|
-
function readOwnerConfig(root=configRoot){return readYamlConfig(root,'config.yaml');}
|
|
381
|
+
function readOwnerConfig(root=configRoot){return ownerFileVisible(root)?readYamlConfig(root,'config.yaml'):null;}
|
|
418
382
|
/**
|
|
419
383
|
* The tolerant read the kernel boot and the router share: an owner file that is absent, unparsable or
|
|
420
384
|
* short of the closed schema must never stop a workflow from routing. Returns
|
|
@@ -424,7 +388,7 @@ function readOwnerConfig(root=configRoot){return readYamlConfig(root,'config.yam
|
|
|
424
388
|
*/
|
|
425
389
|
export function inspectOwnerConfig(root=configRoot){
|
|
426
390
|
const file=path.join(root,'config.yaml');
|
|
427
|
-
if(!fs.existsSync(file))return {file,config:null,error:null,invalid:null};
|
|
391
|
+
if(!ownerFileVisible(root)||!fs.existsSync(file))return {file,config:null,error:null,invalid:null};
|
|
428
392
|
let parsed;
|
|
429
393
|
try{parsed=parseYaml(fs.readFileSync(file,'utf8'))??null;}
|
|
430
394
|
catch(error){return {file,config:null,error:`config.yaml unparsable: ${error.message}`,invalid:null};}
|
|
@@ -444,33 +408,43 @@ export function inspectOwnerConfig(root=configRoot){
|
|
|
444
408
|
* or `starci kernel run-deferred-tests`); e2e.verify then runs the FULL e2e suite.
|
|
445
409
|
* An absent, null or unreadable owner file reads as the defaults: harness off, unit on, e2e off.
|
|
446
410
|
*/
|
|
447
|
-
|
|
411
|
+
const SPEC_FAMILIES=Object.freeze(['harness','unit','e2e']);
|
|
412
|
+
const SEAT_KEYS=Object.freeze(['agent','model','effort']);
|
|
413
|
+
/** The keys each config block accepts directly under it (the validators read these lists; docs/config-format.md and the example are checked against them). Blocks whose keys are open or deeper than this are not listed. */
|
|
414
|
+
export const CONFIG_KEY_TREE=Object.freeze({
|
|
415
|
+
models:MODELS_KEYS,allocation:Object.freeze(['grants']),kernel:SEAT_KEYS,budgets:Object.freeze(['maxOps']),parallel:Object.freeze(['gear']),
|
|
416
|
+
supervisor:Object.freeze(['mode','pollIntervalMs','repos','stallMinutes','kernel','workers','landGate','frozenMinutes']),
|
|
417
|
+
delegation:Object.freeze(['asks','until','excludes','note']),asks:Object.freeze(['autoAcceptRecommended','excludes']),uat:Object.freeze(['maxConcurrent']),
|
|
418
|
+
specs:SPEC_FAMILIES,reconciler:Object.freeze(['enabled','profile','controllers']),debugLoop:Object.freeze(['interval','worktreeLimit']),
|
|
419
|
+
orca:ORCA_KEYS,roots:ROOT_KEYS,resources:RESOURCE_KEYS,
|
|
420
|
+
});
|
|
448
421
|
export const SPEC_DEFAULTS=Object.freeze({harness:false,unit:true,e2e:false});
|
|
449
422
|
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
423
|
/** specs.harness of the owner file under `root` (tolerant read: inspectOwnerConfig): true only when the owner opted in to `--specs all`. */
|
|
451
424
|
export function harnessSpecsEnabled(root=configRoot){return specsSettings(inspectOwnerConfig(root).config).harness;}
|
|
452
425
|
/**
|
|
453
|
-
* config.yaml `
|
|
454
|
-
* interval <n>s | <n>m | <n>h — the
|
|
426
|
+
* config.yaml `debugLoop` (skills/starci, scripts/debug/digest): {interval?, worktreeLimit?}, each optional.
|
|
427
|
+
* interval <n>s | <n>m | <n>h — the cadence of the chat /loop that runs `starci debug digest`.
|
|
455
428
|
* worktreeLimit integer >= 1 — more registered worktrees than this in one repository is a core-watch alert.
|
|
456
|
-
* Both keys are required when the block is present; code carries no default (config.example.yaml does).
|
|
457
429
|
*/
|
|
458
|
-
export const DURATION_PATTERN=/^(\d+)(
|
|
430
|
+
export const DURATION_PATTERN=/^(\d+)([smh])$/;
|
|
431
|
+
export const DEBUG_LOOP_DEFAULTS=Object.freeze({interval:'10m',worktreeLimit:40});
|
|
459
432
|
/** '10m' | '90s' | '1h' in milliseconds, or null for anything else (zero included). */
|
|
460
433
|
export function durationMs(text){const m=DURATION_PATTERN.exec(String(text??'').trim());const ms=m?Number(m[1])*{s:1000,m:60000,h:3600000}[m[2]]:0;return ms>0?ms:null;}
|
|
461
|
-
function
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
if(
|
|
434
|
+
function validateDebugLoop(block){
|
|
435
|
+
if(block===null)return;
|
|
436
|
+
const bad=invalid('debugLoop');
|
|
437
|
+
if(!plain(block))bad(' must be {interval?: <n>s|<n>m|<n>h, worktreeLimit?: <integer >= 1>} or null.');
|
|
438
|
+
for(const key of Object.keys(block))if(!CONFIG_KEY_TREE.debugLoop.includes(key))bad(` has unknown key ${key} (allowed: ${CONFIG_KEY_TREE.debugLoop.join(', ')}).`);
|
|
439
|
+
if(block.interval!==undefined&&durationMs(block.interval)===null)bad('.interval must be <n>s, <n>m or <n>h with n >= 1 (e.g. 10m).');
|
|
440
|
+
if(block.worktreeLimit!==undefined&&!(Number.isInteger(block.worktreeLimit)&&block.worktreeLimit>=1))bad('.worktreeLimit must be an integer >= 1.');
|
|
467
441
|
}
|
|
468
|
-
/** The owner's
|
|
469
|
-
export function
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
return {interval
|
|
442
|
+
/** The owner's debug-loop settings: {interval, intervalMs, worktreeLimit}; an absent block or key is the default. */
|
|
443
|
+
export function debugLoopSettings(config=loadConfig()){
|
|
444
|
+
validateDebugLoop(config?.debugLoop??null);
|
|
445
|
+
const block=plain(config?.debugLoop)?config.debugLoop:{};
|
|
446
|
+
const interval=block.interval??DEBUG_LOOP_DEFAULTS.interval;
|
|
447
|
+
return {interval,intervalMs:durationMs(interval),worktreeLimit:block.worktreeLimit??DEBUG_LOOP_DEFAULTS.worktreeLimit};
|
|
474
448
|
}
|
|
475
449
|
/** The owner config of `root` (config.yaml), else the shipped example. The installer seeds config.yaml (scripts/install/install.mjs seedConfig); this reader never writes. */
|
|
476
450
|
export function loadConfig(root=configRoot){
|
|
@@ -478,7 +452,7 @@ export function loadConfig(root=configRoot){
|
|
|
478
452
|
}
|
|
479
453
|
|
|
480
454
|
/** The owner's standing delegation of ask answers (config.yaml `delegation`), or null when absent or expired. */
|
|
481
|
-
export function activeDelegation(config=loadConfig(),now=Date.now()){const d=config?.delegation;if(!d||Date.parse(d.until)<=now)return null;return {asks:d.asks,until:d.until,excludes:d.excludes??[],note:d.note??null};}
|
|
455
|
+
export function activeDelegation(config=loadConfig(),now=Date.now()){const d=config?.delegation;if(!d||Date.parse(d.until)<=now){return null;}return {asks:d.asks,until:d.until,excludes:d.excludes??[],note:d.note??null};}
|
|
482
456
|
|
|
483
457
|
/**
|
|
484
458
|
* The owner's auto-accept policy for recommended asks (config.yaml `asks`): {autoAcceptRecommended,
|