@starci/hfs 4.8.0 → 4.9.1
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 +10 -0
- package/emit/operation-schemas.mjs +3 -0
- package/lint/bound-sys.cjs +2 -2
- package/package.json +1 -1
- package/runtime/engine/admission.mjs +1 -1
- package/runtime/engine/config.mjs +2 -2
- package/runtime/engine/db/machine.mjs +2 -3
- package/runtime/engine/invalid-config.mjs +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +22 -14
- package/runtime/knowledge/hfs/rules.yaml +23 -0
- package/runtime/modules/kernel/failure-codes.yaml +10 -0
- package/runtime/scripts/guards/git-policy.mjs +2 -3
- package/runtime/scripts/hfs/rules/pipeline.mjs +5 -5
- package/runtime/scripts/lib/package-at.mjs +5 -6
- package/runtime/scripts/lib/roots.mjs +2 -2
- package/runtime/scripts/work/draw/draw-dna-findings.mjs +23 -12
- package/runtime/scripts/work/draw/draw-dna.mjs +14 -8
- package/scaffold/app.mjs +4 -4
- package/src/main.mjs +3 -3
- package/sync/index.mjs +14 -1
- package/sync/managed.mjs +3 -2
- package/templates/app/codegen-graphql.mjs.partial +4 -4
- package/templates/app/quality-config/sonar-project.properties +4 -2
- package/runtime/engine/canonical-json.mjs +0 -14
- package/runtime/engine/db/ledger-open.mjs +0 -38
- package/runtime/engine/db/ledger-paths.mjs +0 -87
- package/runtime/engine/db/ledger.mjs +0 -1068
- package/runtime/engine/db/provision-authority.mjs +0 -76
- package/runtime/engine/db/provision-operations.mjs +0 -146
- package/runtime/packages/grammar/scripts/build-stamp.mjs +0 -98
- package/runtime/scripts/agent/codex-app-server.mjs +0 -79
- package/runtime/scripts/agent/codex-mirror-source.mjs +0 -34
- package/runtime/scripts/agent/host-agents.mjs +0 -80
- package/runtime/scripts/agent/launch-trust-policy.mjs +0 -53
- package/runtime/scripts/agent/trust-launch.mjs +0 -191
- package/runtime/scripts/agent/trust.mjs +0 -523
- package/runtime/scripts/api/fs/temp-path.mjs +0 -8
- package/runtime/scripts/api/git/diff-names.mjs +0 -8
- package/runtime/scripts/api/git/head-time.mjs +0 -10
- package/runtime/scripts/api/git/index-lock.mjs +0 -78
- package/runtime/scripts/api/git/log.mjs +0 -5
- package/runtime/scripts/api/git/merge-tree.mjs +0 -5
- package/runtime/scripts/api/git/rev-list.mjs +0 -5
- package/runtime/scripts/api/git/rev-parse.mjs +0 -13
- package/runtime/scripts/api/git/status-query.mjs +0 -5
- package/runtime/scripts/api/git/worktree-list-query.mjs +0 -5
- package/runtime/scripts/api/node/spawn-node.mjs +0 -6
- package/runtime/scripts/api/npm/dist-tag.mjs +0 -8
- package/runtime/scripts/api/npm/lib.mjs +0 -32
- package/runtime/scripts/api/npm/pack-dry-run.mjs +0 -9
- package/runtime/scripts/api/npm/run-npm.mjs +0 -6
- package/runtime/scripts/api/npm/view.mjs +0 -30
- package/runtime/scripts/api/npm/whoami.mjs +0 -8
- package/runtime/scripts/api/process/hide-child-windows.mjs +0 -31
- package/runtime/scripts/api/process/kill-tree.mjs +0 -22
- package/runtime/scripts/api/process/lib.mjs +0 -125
- package/runtime/scripts/api/process/probe-guard-command.mjs +0 -74
- package/runtime/scripts/api/process/process-list.mjs +0 -28
- package/runtime/scripts/api/process/system-tool.mjs +0 -37
- package/runtime/scripts/gates/canon-digest.mjs +0 -81
- package/runtime/scripts/gates/canon-pins.mjs +0 -119
- package/runtime/scripts/gates/gate-input.mjs +0 -98
- package/runtime/scripts/gates/gate.mjs +0 -636
- package/runtime/scripts/gates/grammar-dist.mjs +0 -206
- package/runtime/scripts/gates/output.mjs +0 -40
- package/runtime/scripts/gates/runtime-package-selection.mjs +0 -27
- package/runtime/scripts/gates/test-world-run.mjs +0 -252
- package/runtime/scripts/gates/type-impact.mjs +0 -277
- package/runtime/scripts/gates/type-program.mjs +0 -113
- package/runtime/scripts/guards/assigned-command.mjs +0 -12
- package/runtime/scripts/guards/call-rights.mjs +0 -47
- package/runtime/scripts/guards/command-guard.mjs +0 -468
- package/runtime/scripts/guards/command-policy.mjs +0 -342
- package/runtime/scripts/guards/critic-reach.mjs +0 -82
- package/runtime/scripts/guards/deps-guard.mjs +0 -150
- package/runtime/scripts/guards/env-dump-verdict.mjs +0 -89
- package/runtime/scripts/guards/guards-root.mjs +0 -27
- package/runtime/scripts/guards/hook-install.mjs +0 -343
- package/runtime/scripts/guards/hook-verdict.mjs +0 -12
- package/runtime/scripts/guards/install-verdict.mjs +0 -41
- package/runtime/scripts/guards/kernel-seat.mjs +0 -63
- package/runtime/scripts/guards/launch-verdict.mjs +0 -74
- package/runtime/scripts/guards/op-starci.mjs +0 -16
- package/runtime/scripts/guards/op-write-scope.mjs +0 -27
- package/runtime/scripts/guards/orca-self-lifecycle.mjs +0 -39
- package/runtime/scripts/guards/process-kill-verdict.mjs +0 -106
- package/runtime/scripts/guards/protected-zone.mjs +0 -56
- package/runtime/scripts/guards/refusals.mjs +0 -27
- package/runtime/scripts/guards/rights.mjs +0 -268
- package/runtime/scripts/guards/seat-tools.mjs +0 -47
- package/runtime/scripts/guards/shell-commands.mjs +0 -328
- package/runtime/scripts/guards/supervisor-seat.mjs +0 -80
- package/runtime/scripts/guards/verdict-process.mjs +0 -81
- package/runtime/scripts/lib/app-data.mjs +0 -11
- package/runtime/scripts/lib/canon-binding-text.mjs +0 -129
- package/runtime/scripts/lib/check-format.mjs +0 -7
- package/runtime/scripts/lib/failure-code-reads.mjs +0 -100
- package/runtime/scripts/lib/failure-code-template-errors.mjs +0 -161
- package/runtime/scripts/lib/git-dir.mjs +0 -120
- package/runtime/scripts/lib/guard-command.mjs +0 -24
- package/runtime/scripts/lib/interrupt-cleanup.mjs +0 -38
- package/runtime/scripts/lib/json-formats.mjs +0 -34
- package/runtime/scripts/lib/managed-json.mjs +0 -51
- package/runtime/scripts/lib/menu-parts.mjs +0 -11
- package/runtime/scripts/lib/op-gate.mjs +0 -15
- package/runtime/scripts/lib/provision-control-failure.mjs +0 -32
- package/runtime/scripts/lib/release-version.mjs +0 -134
- package/runtime/scripts/lib/runtime-location.mjs +0 -21
- package/runtime/scripts/lib/runtime-package-inventory.mjs +0 -21
- package/runtime/scripts/lib/runtime-package-manifests.mjs +0 -84
- package/runtime/scripts/lib/runtime-publication-tags.mjs +0 -56
- package/runtime/scripts/lib/seat-env.mjs +0 -43
- package/runtime/scripts/lib/sonar-new-code.mjs +0 -58
- package/runtime/scripts/lib/source-package-imports.mjs +0 -111
- package/runtime/scripts/lib/tar-files.mjs +0 -39
- package/runtime/scripts/lib/uat-run-folder.mjs +0 -118
- package/runtime/scripts/lib/worktree-record.mjs +0 -14
- package/runtime/scripts/machine/ask-receipts.mjs +0 -113
- package/runtime/scripts/machine/evidence-store.mjs +0 -223
- package/runtime/scripts/machine/home.mjs +0 -216
- package/runtime/scripts/machine/host-lock.mjs +0 -184
- package/runtime/scripts/machine/lock-recovery.mjs +0 -55
- package/runtime/scripts/machine/roles-contract.mjs +0 -90
- package/runtime/scripts/machine/runtime-change.mjs +0 -20
- package/runtime/scripts/machine/workflow-tree.mjs +0 -122
- package/runtime/scripts/reconciler/checklist-items.mjs +0 -7
- package/runtime/scripts/reconciler/guard-row.mjs +0 -25
- package/runtime/scripts/work/brand/brand-colour.mjs +0 -125
- package/runtime/scripts/work/brand/brand-tokens.mjs +0 -167
- package/runtime/scripts/work/brand/brand-value-source.mjs +0 -106
- package/runtime/scripts/work/brand/brand.mjs +0 -892
- package/runtime/scripts/work/draw/draw-layer.mjs +0 -306
- package/runtime/scripts/work/draw/draw-rationale-page.mjs +0 -237
- package/runtime/scripts/work/draw/draw-rationale-ref.mjs +0 -14
- package/runtime/scripts/work/draw/draw-rationale.mjs +0 -353
- package/runtime/scripts/work/draw/draw-source.mjs +0 -412
- package/runtime/scripts/work/draw/draw-taste.mjs +0 -278
- package/runtime/scripts/work/draw/draw-typecheck.mjs +0 -72
- package/runtime/scripts/work/draw/rationale-findings.mjs +0 -205
- package/runtime/scripts/work/draw-grammar.mjs +0 -175
- package/runtime/scripts/work/draw-render-page.mjs +0 -188
- package/runtime/scripts/work/draw-render.mjs +0 -626
- package/runtime/scripts/work/png.mjs +0 -237
- package/runtime/scripts/work/render-tools.mjs +0 -64
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
// Source-package imports preserve published export and exact dependency contracts without a workspace install.
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { createRequire, registerHooks } from 'node:module';
|
|
5
|
-
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
|
-
import { insidePath, pathKey, realPath } from './path-key.mjs';
|
|
7
|
-
|
|
8
|
-
const packageNameOf = (specifier) => specifier.startsWith('@') ? specifier.split('/').slice(0, 2).join('/') : specifier.split('/')[0];
|
|
9
|
-
|
|
10
|
-
function conditionalTarget(value, conditions) {
|
|
11
|
-
if (typeof value === 'string' || value === null) return value;
|
|
12
|
-
if (Array.isArray(value)) return value.map(entry => conditionalTarget(entry, conditions)).find(entry => entry !== undefined);
|
|
13
|
-
if (!value || typeof value !== 'object') return undefined;
|
|
14
|
-
for (const [condition, target] of Object.entries(value)) {
|
|
15
|
-
if (condition === 'default' || conditions.includes(condition)) {
|
|
16
|
-
const selected = conditionalTarget(target, conditions);
|
|
17
|
-
if (selected !== undefined) return selected;
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Resolve only exports that the package publishes; private paths remain unavailable in a source checkout. */
|
|
23
|
-
function exportedTarget(exports, subpath, conditions) {
|
|
24
|
-
if (!exports || typeof exports !== 'object' || Array.isArray(exports)) return subpath === '.' ? conditionalTarget(exports, conditions) : undefined;
|
|
25
|
-
const keys = Object.keys(exports);
|
|
26
|
-
if (keys.every(key => !key.startsWith('.'))) return subpath === '.' ? conditionalTarget(exports, conditions) : undefined;
|
|
27
|
-
if (Object.hasOwn(exports, subpath)) return conditionalTarget(exports[subpath], conditions);
|
|
28
|
-
const patterns = keys.filter(key => key.includes('*')).sort((a, b) => b.indexOf('*') - a.indexOf('*') || b.length - a.length);
|
|
29
|
-
for (const pattern of patterns) {
|
|
30
|
-
const [prefix, suffix] = pattern.split('*');
|
|
31
|
-
if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) continue;
|
|
32
|
-
const match = subpath.slice(prefix.length, suffix ? -suffix.length : undefined);
|
|
33
|
-
const target = conditionalTarget(exports[pattern], conditions);
|
|
34
|
-
return typeof target === 'string' ? target.replaceAll('*', match) : target;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function sourcePackages(root, packages) {
|
|
39
|
-
return Object.entries(packages).filter(([, pin]) => typeof pin?.source === 'string').map(([name, pin]) => {
|
|
40
|
-
const manifestFile = realPath(path.resolve(root, pin.source));
|
|
41
|
-
if (!insidePath(root, manifestFile, { key: pathKey })) throw new Error(`Source package ${name} is outside the source tree`);
|
|
42
|
-
return { name, pin, manifestFile, root: path.dirname(manifestFile), manifest: null };
|
|
43
|
-
}).sort((a, b) => b.root.length - a.root.length);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function manifestOf(source) {
|
|
47
|
-
source.manifest ??= JSON.parse(fs.readFileSync(source.manifestFile, 'utf8'));
|
|
48
|
-
if (source.manifest.name !== source.name || source.manifest.version !== source.pin.version) {
|
|
49
|
-
throw new Error(`Source package ${source.name} does not match its pinned name and version`);
|
|
50
|
-
}
|
|
51
|
-
return source.manifest;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Settle each declared bare dependency before installing a hook, using the consumer first and source install second. */
|
|
55
|
-
function consumerDependencyTargets(sources, consumerRoot) {
|
|
56
|
-
const consumer = createRequire(path.join(consumerRoot, 'package.json'));
|
|
57
|
-
for (const source of sources) {
|
|
58
|
-
const manifest = manifestOf(source), local = createRequire(source.manifestFile);
|
|
59
|
-
source.consumerDependencies = new Map();
|
|
60
|
-
for (const name of Object.keys({ ...manifest.peerDependencies, ...manifest.optionalDependencies, ...manifest.dependencies })) {
|
|
61
|
-
for (const require of [consumer, local]) {
|
|
62
|
-
try { source.consumerDependencies.set(name, pathToFileURL(require.resolve(name)).href); break; }
|
|
63
|
-
catch { /* The next install may carry the dependency; a missing import still fails through the native resolver. */ }
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function resolveSource(specifier, context, nextResolve, sources, consumerRoot) {
|
|
70
|
-
if (!context.parentURL?.startsWith('file:')) return nextResolve(specifier, context);
|
|
71
|
-
const parent = fileURLToPath(context.parentURL);
|
|
72
|
-
const owner = sources.find(source => insidePath(source.root, parent, { key: pathKey }));
|
|
73
|
-
const target = sources.find(source => source.name === packageNameOf(specifier));
|
|
74
|
-
if (!target) {
|
|
75
|
-
const dependency = owner?.consumerDependencies?.get(specifier);
|
|
76
|
-
return dependency ? { url: dependency, shortCircuit: true } : nextResolve(specifier, context);
|
|
77
|
-
}
|
|
78
|
-
if (!owner && (!consumerRoot || !insidePath(consumerRoot, parent, { key: pathKey }))) return nextResolve(specifier, context);
|
|
79
|
-
const manifest = manifestOf(target);
|
|
80
|
-
if (owner) {
|
|
81
|
-
const caller = manifestOf(owner);
|
|
82
|
-
const dependency = caller.dependencies?.[target.name] ?? caller.devDependencies?.[target.name] ?? caller.optionalDependencies?.[target.name];
|
|
83
|
-
if (owner !== target && dependency !== manifest.version) throw new Error(`${owner.name} must declare its source dependency ${target.name}@${manifest.version} exactly`);
|
|
84
|
-
}
|
|
85
|
-
const subpath = `.${specifier.slice(target.name.length)}`;
|
|
86
|
-
const published = exportedTarget(manifest.exports, subpath, context.conditions);
|
|
87
|
-
if (typeof published !== 'string' || !published.startsWith('./')) throw new Error(`${specifier} is not a published source-package export`);
|
|
88
|
-
const resolved = path.resolve(target.root, published);
|
|
89
|
-
if (!insidePath(target.root, resolved, { key: pathKey }) || published.split('/').includes('node_modules')) throw new Error(`${specifier} has an invalid source-package export target`);
|
|
90
|
-
return { url: pathToFileURL(resolved).href, shortCircuit: true };
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Await source imports with pinned local packages resolved through their own public exports.
|
|
95
|
-
* Imports originating inside a declared source package obey exact dependency versions and public exports.
|
|
96
|
-
* An explicit consumerRoot also redirects that consumer's selected source-package imports and resolves source
|
|
97
|
-
* packages' declared bare imports and requires from the consumer install first. This keeps a parser and its
|
|
98
|
-
* typed rules on the same dependency instances. The hook is removed on success or failure; callers await
|
|
99
|
-
* every import and dependent action. Normal installed-package resolution remains in force afterward.
|
|
100
|
-
* @param {{ root: string, packages: Record<string, { version: string, source?: string }>, consumerRoot?: string }} input - Canonical source pins and an optional bounded consumer source root.
|
|
101
|
-
* @param {() => Promise<unknown>} action - Imports whose package contracts are being checked.
|
|
102
|
-
* @returns {Promise<unknown>} The action's result, without a persistent resolver change.
|
|
103
|
-
*/
|
|
104
|
-
export async function withSourcePackageImports({ root, packages, consumerRoot }, action) {
|
|
105
|
-
const sources = sourcePackages(realPath(root), packages);
|
|
106
|
-
const consumer = consumerRoot === undefined ? null : realPath(consumerRoot);
|
|
107
|
-
if (consumer) consumerDependencyTargets(sources, consumer);
|
|
108
|
-
const hook = registerHooks({ resolve: (specifier, context, nextResolve) => resolveSource(specifier, context, nextResolve, sources, consumer) });
|
|
109
|
-
try { return await action(); }
|
|
110
|
-
finally { hook.deregister(); }
|
|
111
|
-
}
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
// tar-files.mjs - the regular files of a gzipped tar (an npm tarball), read in memory: {path -> Buffer}. Pure: no disk, no
|
|
2
|
-
// child process. Only what `npm pack` writes is understood (ustar headers, a `path` pax record and the ustar prefix
|
|
3
|
-
// honoured, other pax records skipped); a link or a device entry is ignored.
|
|
4
|
-
import zlib from 'node:zlib';
|
|
5
|
-
|
|
6
|
-
const BLOCK = 512;
|
|
7
|
-
const text = (buf, from, len) => {
|
|
8
|
-
const value = buf.toString('utf8', from, from + len);
|
|
9
|
-
const nul = value.indexOf('\0');
|
|
10
|
-
return nul < 0 ? value : value.slice(0, nul);
|
|
11
|
-
};
|
|
12
|
-
const octal = (buf, from, len) => Number.parseInt(text(buf, from, len).trim() || '0', 8);
|
|
13
|
-
|
|
14
|
-
/** Map<string, Buffer> of the regular files in `tgz` (a Buffer). Throws on a truncated or corrupt archive. */
|
|
15
|
-
export function tarFiles(tgz) {
|
|
16
|
-
const tar = zlib.gunzipSync(tgz);
|
|
17
|
-
const files = new Map();
|
|
18
|
-
let at = 0;
|
|
19
|
-
let paxPath = null;
|
|
20
|
-
while (at + BLOCK <= tar.length) {
|
|
21
|
-
const header = tar.subarray(at, at + BLOCK);
|
|
22
|
-
if (header.every((b) => b === 0)) break;
|
|
23
|
-
const size = octal(header, 124, 12);
|
|
24
|
-
const type = String.fromCodePoint(header[156] || 48);
|
|
25
|
-
const prefix = text(header, 345, 155);
|
|
26
|
-
const name = paxPath ?? (prefix ? `${prefix}/${text(header, 0, 100)}` : text(header, 0, 100));
|
|
27
|
-
const body = tar.subarray(at + BLOCK, at + BLOCK + size);
|
|
28
|
-
if (body.length < size) throw new Error(`tar entry ${name} is truncated`);
|
|
29
|
-
at += BLOCK + Math.ceil(size / BLOCK) * BLOCK;
|
|
30
|
-
if (type === 'x') {
|
|
31
|
-
const record = /(?:^|\n)\d+ path=([^\n]*)\n/.exec(body.toString('utf8'));
|
|
32
|
-
paxPath = record ? record[1] : null;
|
|
33
|
-
continue;
|
|
34
|
-
}
|
|
35
|
-
paxPath = null;
|
|
36
|
-
if (type === '0') files.set(name, Buffer.from(body));
|
|
37
|
-
}
|
|
38
|
-
return files;
|
|
39
|
-
}
|
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
// uat-run-folder.mjs - the pure half of `starci uat record` (scripts/uat/uat-record.mjs): what a finished UAT run folder must
|
|
2
|
-
// hold, how a verified blob is curated into an example's fixture store, the work/evidence@1 document of one run and its outcome
|
|
3
|
-
// rule. The verb owns the browser, the UAT slot and the artifact store; this module owns the shapes. It is bundled into
|
|
4
|
-
// @starci/hfs (sync-runtime.mjs), whose spec src/uat-run-folder.spec.mjs proves it (owner ruling no-runtime-specs).
|
|
5
|
-
import crypto from 'node:crypto';
|
|
6
|
-
import { sha256 } from '../../engine/digest.mjs';
|
|
7
|
-
import fs from 'node:fs';
|
|
8
|
-
import path from 'node:path';
|
|
9
|
-
import { parseYaml } from '../../engine/yaml.mjs';
|
|
10
|
-
import { byCodeUnit } from '../../engine/by-code-unit.mjs';
|
|
11
|
-
|
|
12
|
-
export const UAT_RECORD_FLOW_UNKNOWN = 'UAT_RECORD_FLOW_UNKNOWN';
|
|
13
|
-
export const UAT_RECORD_RUN_INCOMPLETE = 'UAT_RECORD_RUN_INCOMPLETE';
|
|
14
|
-
export const UAT_RECORD_ASSET_INVALID = 'UAT_RECORD_ASSET_INVALID';
|
|
15
|
-
const OUTCOMES = new Set(['pass', 'fail', 'partial-pass']);
|
|
16
|
-
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
17
|
-
const WEBM = Buffer.from([0x1a, 0x45, 0xdf, 0xa3]);
|
|
18
|
-
const RUN_PARTS = Object.freeze(['manifest.yaml', 'result.md', 'screens', 'videos']);
|
|
19
|
-
const FOLDERS = Object.freeze([['screens', '.png', 'image/png', PNG], ['videos', '.webm', 'video/webm', WEBM]]);
|
|
20
|
-
|
|
21
|
-
/** A typed refusal of `starci uat record`. */
|
|
22
|
-
export const uatRecordRefusal = (code, message) => Object.assign(new Error(`${code}: ${message}`), { code });
|
|
23
|
-
|
|
24
|
-
/** A run id in the evidence schema's shape: <UTC yyyymmddThhmmssZ>-<8 hex>. */
|
|
25
|
-
export const newRunId = (now = new Date(), random = crypto.randomBytes(4).toString('hex')) =>
|
|
26
|
-
`${now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')}-${random}`;
|
|
27
|
-
|
|
28
|
-
const startsWith = (file, signature) => {
|
|
29
|
-
const fd = fs.openSync(file, 'r');
|
|
30
|
-
try {
|
|
31
|
-
const head = Buffer.alloc(signature.length);
|
|
32
|
-
fs.readSync(fd, head, 0, head.length, 0);
|
|
33
|
-
return head.equals(signature);
|
|
34
|
-
} finally { fs.closeSync(fd); }
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
/** The refusal text of a run folder whose top level is not exactly its four parts, or null. */
|
|
38
|
-
function partsProblem(top) {
|
|
39
|
-
const missing = RUN_PARTS.filter((name) => !top.includes(name));
|
|
40
|
-
const extra = top.filter((name) => !RUN_PARTS.includes(name));
|
|
41
|
-
if (!missing.length && !extra.length) return null;
|
|
42
|
-
const said = [`the run folder holds exactly ${RUN_PARTS.join(', ')}`];
|
|
43
|
-
if (missing.length) said.push(`missing ${missing.join(', ')}`);
|
|
44
|
-
if (extra.length) said.push(`unexpected ${extra.join(', ')}`);
|
|
45
|
-
return said.join('; ');
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Check a finished run folder: exactly manifest.yaml, result.md, screens/ (one or more PNG) and videos/ (one or more WebM), every
|
|
50
|
-
* file non-empty and of its type by its bytes. Returns {manifest, files: [{name, file, mediaType}]} or throws a refusal.
|
|
51
|
-
*/
|
|
52
|
-
export function readRunFolder(runDir) {
|
|
53
|
-
const problem = partsProblem(fs.readdirSync(runDir).sort(byCodeUnit));
|
|
54
|
-
if (problem) throw uatRecordRefusal(UAT_RECORD_RUN_INCOMPLETE, problem);
|
|
55
|
-
const files = [];
|
|
56
|
-
const add = (name, mediaType, signature) => {
|
|
57
|
-
const file = path.join(runDir, ...name.split('/'));
|
|
58
|
-
const stat = fs.statSync(file);
|
|
59
|
-
if (!stat.isFile() || stat.size === 0) throw uatRecordRefusal(UAT_RECORD_ASSET_INVALID, `${name} is empty or not a file`);
|
|
60
|
-
if (signature && !startsWith(file, signature)) throw uatRecordRefusal(UAT_RECORD_ASSET_INVALID, `${name} is not a ${mediaType} file by its bytes`);
|
|
61
|
-
files.push({ name, file, mediaType });
|
|
62
|
-
};
|
|
63
|
-
add('manifest.yaml', 'application/yaml');
|
|
64
|
-
add('result.md', 'text/markdown');
|
|
65
|
-
for (const [dir, ext, mediaType, signature] of FOLDERS) {
|
|
66
|
-
const names = fs.readdirSync(path.join(runDir, dir)).sort(byCodeUnit);
|
|
67
|
-
if (!names.length) throw uatRecordRefusal(UAT_RECORD_RUN_INCOMPLETE, `${dir}/ holds no ${ext} file`);
|
|
68
|
-
for (const name of names) {
|
|
69
|
-
if (path.extname(name).toLowerCase() !== ext) throw uatRecordRefusal(UAT_RECORD_RUN_INCOMPLETE, `${dir}/${name} is not a ${ext} file`);
|
|
70
|
-
add(`${dir}/${name}`, mediaType, signature);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
let manifest;
|
|
74
|
-
try { manifest = parseYaml(fs.readFileSync(path.join(runDir, 'manifest.yaml'), 'utf8')); }
|
|
75
|
-
catch (error) { throw uatRecordRefusal(UAT_RECORD_RUN_INCOMPLETE, `manifest.yaml does not parse: ${error.message}`); }
|
|
76
|
-
if (!manifest || !OUTCOMES.has(manifest.outcome)) throw uatRecordRefusal(UAT_RECORD_RUN_INCOMPLETE, 'manifest.yaml carries no outcome of pass, fail or partial-pass');
|
|
77
|
-
return { manifest, files };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Where one verified blob goes in an example's fixture store (`root`) and what its sidecar says: <root>/<ab>/<sha> and <sha>.json.
|
|
82
|
-
* The fixture store is tracked source written only by `starci uat record`, which writes these paths and re-hashes the result with
|
|
83
|
-
* curatedProblem. Pure: no write here.
|
|
84
|
-
*/
|
|
85
|
-
export function curationOf(root, { sha256: digest, bytes, mediaType, createdAt }) {
|
|
86
|
-
const dir = path.join(root, digest.slice(0, 2));
|
|
87
|
-
const file = path.join(dir, digest);
|
|
88
|
-
return { dir, file, sidecar: `${file}.json`, sidecarText: JSON.stringify({ size: bytes.length, mediaType, createdAt }) };
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** The refusal of a curated blob whose stored bytes do not hash to its name, or null. */
|
|
92
|
-
export const curatedProblem = (digest, storedBytes) => (sha256(storedBytes) === digest
|
|
93
|
-
? null
|
|
94
|
-
: uatRecordRefusal(UAT_RECORD_ASSET_INVALID, `the curated blob ${digest.slice(0, 12)} does not hash to its name`));
|
|
95
|
-
|
|
96
|
-
/** The verdict: pass only when the walk exited 0 and its own manifest says pass. */
|
|
97
|
-
export const recordedOutcome = (exitCode, manifest) => (exitCode === 0 ? manifest.outcome : 'fail');
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* The work/evidence@1 document of one recorded run. `cited` is [{name, sha256}] for every file of the run folder; `recordDigest`
|
|
101
|
-
* is the sha256 of the flow record's index.yaml as it stands.
|
|
102
|
-
*/
|
|
103
|
-
export function evidenceOf({ recordId, recordDigest, runId, outcome, cited, command, environment, capturedAt, baseUrl }) {
|
|
104
|
-
const byDir = (dir) => cited.filter((entry) => entry.name.startsWith(`${dir}/`)).map(({ name, sha256 }) => ({ name, sha256 }));
|
|
105
|
-
const result = cited.find((entry) => entry.name === 'result.md');
|
|
106
|
-
const stack = baseUrl
|
|
107
|
-
? { stack: `The walk drove the app served at ${baseUrl} and the dev stack its .starcistacks declares, in a real browser recorded by the runtime's Playwright recorder.` }
|
|
108
|
-
: {};
|
|
109
|
-
return {
|
|
110
|
-
schema: 'work/evidence@1',
|
|
111
|
-
record: recordId,
|
|
112
|
-
recordDigest,
|
|
113
|
-
run: { id: runId, outcome, result: { name: result.name, sha256: result.sha256 }, screens: byDir('screens'), videos: byDir('videos') },
|
|
114
|
-
outcome,
|
|
115
|
-
assets: cited.map(({ name, sha256 }) => ({ name: `runs/${runId}/${name}`, sha256 })),
|
|
116
|
-
provenance: { actor: 'uat-record', tool: `starci uat record --flow ${recordId} -- ${command.join(' ')}`, environment, capturedAt, ...stack },
|
|
117
|
-
};
|
|
118
|
-
}
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
// worktree-record.mjs — the workflow-worktree registry row a checkpoint or a gate needs.
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* `rec` must be `workflowId`'s live worktree row: present in the registry, its path on disk, a branch
|
|
6
|
-
* recorded. Throws {code: 'workflow-worktree-missing'} otherwise; returns `rec`.
|
|
7
|
-
*/
|
|
8
|
-
export function requireWorktreeRecord(rec, workflowId) {
|
|
9
|
-
const missing = (message) => { throw Object.assign(new Error(message), { code: 'workflow-worktree-missing' }); };
|
|
10
|
-
if (!rec) missing(`workflow ${workflowId} has no workflow worktree in the registry`);
|
|
11
|
-
if (!rec.path || !fs.existsSync(rec.path)) missing(`the worktree of workflow ${workflowId} (${rec.path ?? '-'}) is gone`);
|
|
12
|
-
if (!rec.branch) missing(`the registry records no branch for the worktree of workflow ${workflowId} (${rec.path})`);
|
|
13
|
-
return rec;
|
|
14
|
-
}
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
// ask-receipts.mjs — where the answer to an ask is kept (docs/ledger-db.md §§5–6): serve-ask answers →
|
|
2
|
-
// decisions + blobs; never a file under .starciwork/kernel-evidence.
|
|
3
|
-
//
|
|
4
|
-
// An answer (owner form, Telegram reply, config auto-accept, autopilot) is one starci/ask-answer@1 receipt. It is put
|
|
5
|
-
// in the blob store (redacted), indexed as a kernel artifact of the attempt that asked - job_artifacts name
|
|
6
|
-
// asks/<dispatchId>/answer-<ms>.json, origin kernel - and recorded as one `decisions` row (decider owner | the
|
|
7
|
-
// answering actor, subject the attempt, result {receiptSha256, artifactId, ...}). The receipt's file is the blob
|
|
8
|
-
// file (receiptPath, read-only), and a Work record cites it as `blob:<sha256>` (or by its decision id), never by a
|
|
9
|
-
// repository path.
|
|
10
|
-
import fs from 'node:fs';
|
|
11
|
-
import path from 'node:path';
|
|
12
|
-
import { recordDecision } from '../../engine/db/ledger.mjs';
|
|
13
|
-
import { sha256 } from '../../engine/digest.mjs';
|
|
14
|
-
import { artifactRoot, blobPath } from '../../engine/db/blob.mjs';
|
|
15
|
-
import { stageBlob, putArtifact } from './evidence-store.mjs';
|
|
16
|
-
import { slash } from '../lib/path-key.mjs';
|
|
17
|
-
|
|
18
|
-
const RECEIPT_PREFIX = 'asks/';
|
|
19
|
-
const receiptNameOf = (dispatchId, at) => `${RECEIPT_PREFIX}${dispatchId}/answer-${at}.json`;
|
|
20
|
-
const BLOB_REF = /^blob:([a-f0-9]{64})$/;
|
|
21
|
-
|
|
22
|
-
/** The receipt as a staged blob (outside any transaction). */
|
|
23
|
-
export const stageReceipt = (receipt) => stageBlob(Buffer.from(JSON.stringify(receipt, null, 2)), { mediaType: 'application/json' });
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Inside the caller's transaction: index a staged receipt as the asking attempt's kernel artifact and record the
|
|
27
|
-
* answer as a decisions row. Returns {receiptPath, receiptSha, receiptRef, artifactId, decisionId}.
|
|
28
|
-
*/
|
|
29
|
-
function fileAskReceipt(db, { workflowId, dispatchId, receipt, blob, at = Date.now() }) {
|
|
30
|
-
const report = db.prepare('SELECT attempt_id,job_id FROM reports WHERE workflow_id=? AND dispatch_id=?').get(workflowId, dispatchId) ?? null;
|
|
31
|
-
const attempt = report ? db.prepare('SELECT attempt_id,job_id,op_id FROM op_attempts WHERE attempt_id=?').get(report.attempt_id) : null;
|
|
32
|
-
let name = receiptNameOf(dispatchId, at);
|
|
33
|
-
let n = at + 1;
|
|
34
|
-
while (db.prepare('SELECT 1 FROM job_artifacts WHERE workflow_id=? AND name=?').get(workflowId, name)) { name = receiptNameOf(dispatchId, n); n += 1; }
|
|
35
|
-
const { artifactId } = putArtifact(db, { workflowId, attemptId: attempt?.attempt_id ?? null, jobId: attempt?.job_id ?? report?.job_id ?? null, opId: attempt?.op_id ?? null,
|
|
36
|
-
role: 'other', kind: 'report', name, blob, origin: 'kernel', now: at });
|
|
37
|
-
const answeredBy = String(receipt.answeredBy ?? 'unknown');
|
|
38
|
-
const option = receipt.option ?? (receipt.optionIndex != null ? `option ${Number(receipt.optionIndex) + 1}` : 'answered');
|
|
39
|
-
const decisionId = recordDecision(db, { workflowId, decider: answeredBy === 'owner' ? 'owner' : answeredBy, subjectType: attempt ? 'attempt' : null,
|
|
40
|
-
subjectId: attempt ? String(attempt.attempt_id) : null, choice: String(option), rationale: typeof receipt.note === 'string' ? receipt.note.slice(0, 2000) : null,
|
|
41
|
-
result: { kind: 'ask-answer', dispatchId, answeredBy, optionIndex: receipt.optionIndex ?? null, receiptSha256: blob.sha, artifactId }, decidedAt: at });
|
|
42
|
-
return { receiptPath: blob.fileUri, receiptSha: blob.sha, receiptRef: `blob:${blob.sha}`, artifactId, decisionId };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
/** Atomically accept one answer with its receipt, decision and terminal lifecycle event. */
|
|
47
|
-
export function commitAskAnswer(ledger, { workflowId, dispatchId, receipt, at = Date.now(), payload = {}, events = [], blob: staged = null }) {
|
|
48
|
-
const blob = staged ?? stageReceipt(receipt);
|
|
49
|
-
const answerDigest = sha256(JSON.stringify({ ...receipt, at: null }));
|
|
50
|
-
return ledger.transaction(db => {
|
|
51
|
-
const latest = db.prepare(`SELECT kind,payload_json FROM events WHERE workflow_id=?
|
|
52
|
-
AND kind IN ('ask-answered','ask-superseded')
|
|
53
|
-
AND json_extract(payload_json,'$.dispatchId')=? ORDER BY seq DESC LIMIT 1`).get(workflowId, dispatchId);
|
|
54
|
-
if (latest?.kind === 'ask-answered' || latest?.kind === 'ask-superseded') {
|
|
55
|
-
const prior = JSON.parse(latest.payload_json);
|
|
56
|
-
return prior.answerDigest === answerDigest
|
|
57
|
-
? { accepted: true, replayed: true, ...prior }
|
|
58
|
-
: { accepted: false, why: latest.kind === 'ask-answered' ? 'already-answered' : 'retired' };
|
|
59
|
-
}
|
|
60
|
-
const filed = fileAskReceipt(db, { workflowId, dispatchId, receipt, blob, at });
|
|
61
|
-
const answer = { dispatchId, receiptPath: filed.receiptPath, receiptSha: filed.receiptSha, decisionId: filed.decisionId,
|
|
62
|
-
answeredBy: receipt.answeredBy, optionIndex: receipt.optionIndex ?? null,
|
|
63
|
-
custodyWritten: receipt.custodyWritten ?? [], envWritten: receipt.envWritten ?? [], pointersWritten: receipt.pointersWritten ?? [], errors: receipt.errors ?? [],
|
|
64
|
-
...payload, answerDigest };
|
|
65
|
-
ledger.appendEvent({ workflowId, entityType: 'report', entityId: dispatchId, kind: 'ask-answered', payload: answer });
|
|
66
|
-
for (const event of events) ledger.appendEvent({ workflowId, entityType: 'report', entityId: dispatchId, ...event,
|
|
67
|
-
payload: { ...event.payload, receiptPath: filed.receiptPath } });
|
|
68
|
-
return { accepted: true, replayed: false, ...filed };
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** True when `file` is a blob in the store (a receipt's file lives there). */
|
|
73
|
-
const isBlobFile = (file) => {
|
|
74
|
-
const rel = path.relative(artifactRoot(), path.resolve(file));
|
|
75
|
-
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
/** The file a receipt reference names: `blob:<sha256>`, a blob file, or a path (resolved against `base`). */
|
|
79
|
-
export function receiptFileOf(ref, { base = process.cwd() } = {}) {
|
|
80
|
-
if (typeof ref !== 'string' || !ref.trim()) return null;
|
|
81
|
-
const m = BLOB_REF.exec(ref.trim());
|
|
82
|
-
if (m) return blobPath(m[1]);
|
|
83
|
-
const abs = path.resolve(base, ref);
|
|
84
|
-
return fs.existsSync(abs) ? abs : null;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** How a Work record cites a receipt file: `blob:<sha256>` for a blob, else the path relative to `repoRoot`. */
|
|
88
|
-
export const receiptRefOf = (file, repoRoot) => (isBlobFile(file) ? `blob:${path.basename(file)}` : path.relative(repoRoot, file).replaceAll('\\', '/'));
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* The prelude of every `apply` that settles from a serve-ask answer (scripts/work/draw-review.mjs,
|
|
92
|
-
* scripts/work/brand-direction.mjs): resolve the receipt ref (a `blob:<sha256>` the store kept, or a path inside
|
|
93
|
-
* `repoRoot`), refuse a file outside the repository, and parse it as the `schema` receipt. Returns
|
|
94
|
-
* {receipt, receiptAbs, receiptRel} - receiptRel is how the record cites it.
|
|
95
|
-
*/
|
|
96
|
-
export function readAnswerReceipt(receiptFile, { repoRoot, base = process.cwd(), schema = 'starci/ask-answer@1' } = {}) {
|
|
97
|
-
// The receipt is the blob serve-ask stored (receiptPath, or `blob:<sha256>`); a Work record cites it as
|
|
98
|
-
// blob:<sha256>. A path inside the repository is still read (an example tree's own receipt).
|
|
99
|
-
const receiptAbs = receiptFileOf(String(receiptFile), { base }) ?? path.resolve(String(receiptFile));
|
|
100
|
-
const receiptRel = receiptRefOf(receiptAbs, repoRoot);
|
|
101
|
-
if (!isBlobFile(receiptAbs) && (receiptRel.startsWith('../') || path.isAbsolute(receiptRel))) throw new Error(`receipt ${slash(receiptFile)} is neither a stored answer (blob:<sha256>, the receiptPath serve-ask returned) nor a file inside the repository ${slash(repoRoot)}`);
|
|
102
|
-
let receipt;
|
|
103
|
-
try { receipt = JSON.parse(fs.readFileSync(receiptAbs, 'utf8')); } catch (error) { throw new Error(`receipt ${slash(receiptFile)} is unreadable: ${error.message}`); }
|
|
104
|
-
if (receipt?.schema !== schema) throw new Error(`${slash(receiptFile)} is ${receipt?.schema ?? 'not a receipt'}, not ${schema}`);
|
|
105
|
-
return { receipt, receiptAbs, receiptRel };
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** Every receipt answering `dispatchId` in a ledger, newest first: [{file, receipt, name}]. */
|
|
109
|
-
export function receiptsAnswering(db, dispatchId) {
|
|
110
|
-
return db.prepare(`SELECT a.name,a.sha256 FROM job_artifacts a WHERE a.origin='kernel' AND a.name LIKE ? ORDER BY a.artifact_id DESC`).all(`${RECEIPT_PREFIX}${dispatchId}/answer-%`)
|
|
111
|
-
.map((r) => { const file = blobPath(r.sha256); let receipt = null; try { receipt = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { receipt = null; } return { file, receipt, name: r.name }; })
|
|
112
|
-
.filter((r) => r.file && r.receipt);
|
|
113
|
-
}
|
|
@@ -1,223 +0,0 @@
|
|
|
1
|
-
// evidence-store.mjs — agent output into runtime.sqlite + the blob store (docs/ledger-db.md).
|
|
2
|
-
//
|
|
3
|
-
// Bytes live in the content-addressed store (engine/db/blob.mjs, <runtime root>/.runtime/artifacts/<sha[0:2]>/<sha>);
|
|
4
|
-
// the ledger holds only the index rows that point at them:
|
|
5
|
-
// blobs one row per sha this ledger references (media type, redaction, file uri)
|
|
6
|
-
// job_artifacts every file output, keyed (attempt_id, name) — or (workflow_id, name) for the Kernel's
|
|
7
|
-
// own outputs — immutable: a second put with other bytes is refused (H10)
|
|
8
|
-
// check_runs every op / settler / parity / integrate check: the RAW exit the runner saw apart from
|
|
9
|
-
// the exit the op DECLARED (H8); 'unavailable' is its own status and never red (H7)
|
|
10
|
-
// attempt_transcript_snapshots the redacted scrollback of a live op terminal, every 60 s
|
|
11
|
-
// op_attempts.transcript_sha the full redacted scrollback when the attempt ends
|
|
12
|
-
// Text bytes pass scripts/lib/redact.mjs before every put (blobs.redaction='v1'); media is stored as is ('binary').
|
|
13
|
-
// The rows go through the runtime writer (engine/db/ledger.mjs recordBlob, recordArtifact, recordCheckRun,
|
|
14
|
-
// recordTranscriptSnapshot, setAttemptTranscript); this module is the evidence policy on top of it: redaction,
|
|
15
|
-
// naming, kinds and roles, the H7/H8 status rules. Every write runs inside the caller's ledger.transaction; the blob
|
|
16
|
-
// put happens BEFORE the transaction (stageBlob), so a rolled-back transaction leaves only an unreferenced blob for GC.
|
|
17
|
-
import fs from 'node:fs';
|
|
18
|
-
import path from 'node:path';
|
|
19
|
-
import { putBlob, blobPath } from '../../engine/db/blob.mjs';
|
|
20
|
-
import { redactBytes } from '../lib/redact.mjs';
|
|
21
|
-
import { recordBlob, recordArtifact, recordCheckRun as writeCheckRun, recordTranscriptSnapshot, attachToReport, setAttemptTranscript,
|
|
22
|
-
JOB_ARTIFACT_KINDS, JOB_ARTIFACT_SUBKINDS, JOB_ARTIFACT_ROLES } from '../../engine/db/ledger.mjs';
|
|
23
|
-
import { newSpanId } from '../../engine/db/machine.mjs';
|
|
24
|
-
import { refuse } from '../../engine/refuse.mjs';
|
|
25
|
-
|
|
26
|
-
const ARTIFACT_ROLES = JOB_ARTIFACT_ROLES;
|
|
27
|
-
export const ARTIFACT_KINDS = JOB_ARTIFACT_KINDS;
|
|
28
|
-
export const ARTIFACT_SUBKINDS = JOB_ARTIFACT_SUBKINDS;
|
|
29
|
-
const ARTIFACT_ORIGINS = Object.freeze(['op', 'settler', 'checker', 'kernel']);
|
|
30
|
-
const CHECK_PHASES = Object.freeze(['before', 'after', 'verify', 'parity', 'integrate']);
|
|
31
|
-
const CHECK_RUNNERS = Object.freeze(['op', 'settler', 'kernel', 'parity', 'integrate']);
|
|
32
|
-
export const CHECK_STATUSES = Object.freeze(['pass', 'fail', 'unavailable', 'error', 'skipped']);
|
|
33
|
-
/** A check whose status counts as red. 'unavailable' (the checker could not run: infra) and 'skipped' never do (H7). */
|
|
34
|
-
const RED_CHECK_STATUSES = Object.freeze(['fail', 'error']);
|
|
35
|
-
export const TRANSCRIPT_SNAPSHOT_MS = 60_000;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const slash = (p) => String(p).replaceAll('\\', '/');
|
|
39
|
-
|
|
40
|
-
const IMAGE = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif', '.svg': 'image/svg+xml' };
|
|
41
|
-
const VIDEO = { '.webm': 'video/webm', '.mp4': 'video/mp4', '.mov': 'video/quicktime' };
|
|
42
|
-
const OTHER = { '.json': 'application/json', '.md': 'text/markdown', '.yaml': 'text/yaml', '.yml': 'text/yaml', '.txt': 'text/plain',
|
|
43
|
-
'.log': 'text/plain', '.jsonl': 'application/x-ndjson', '.out': 'text/plain', '.patch': 'text/x-diff', '.diff': 'text/x-diff', '.html': 'text/html',
|
|
44
|
-
'.htm': 'text/html', '.mhtml': 'multipart/related', '.zip': 'application/zip', '.trace': 'application/octet-stream', '.csv': 'text/csv', '.xml': 'application/xml' };
|
|
45
|
-
/** The media type of a file, from its extension. */
|
|
46
|
-
export const mediaTypeOf = (file) => { const ext = path.extname(String(file)).toLowerCase(); return IMAGE[ext] ?? VIDEO[ext] ?? OTHER[ext] ?? 'application/octet-stream'; };
|
|
47
|
-
|
|
48
|
-
/** The job_artifacts.kind of a file, from its name. */
|
|
49
|
-
export function kindOf(file) {
|
|
50
|
-
const base = path.basename(String(file)).toLowerCase(), ext = path.extname(base);
|
|
51
|
-
if (ext === '.patch') return 'patch';
|
|
52
|
-
if (ext === '.diff') return 'diff';
|
|
53
|
-
if (IMAGE[ext]) return 'image';
|
|
54
|
-
if (VIDEO[ext]) return 'video';
|
|
55
|
-
if (ext === '.trace' || (ext === '.zip' && base.includes('trace'))) return 'trace';
|
|
56
|
-
if (/^(report|result)([.-].*)?\.(json|md|ya?ml)$/.test(base)) return 'report';
|
|
57
|
-
if (['.log', '.txt', '.out', '.jsonl'].includes(ext)) return 'log';
|
|
58
|
-
return 'file';
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** The artifact role of an attached file, from its name. */
|
|
62
|
-
export function roleOf(file) {
|
|
63
|
-
const kind = kindOf(file), ext = path.extname(String(file)).toLowerCase();
|
|
64
|
-
if (kind === 'image') return 'screenshot';
|
|
65
|
-
if (kind === 'video') return 'video';
|
|
66
|
-
if (kind === 'trace') return 'trace';
|
|
67
|
-
if (kind === 'patch') return 'patch';
|
|
68
|
-
if (kind === 'diff') return 'diff';
|
|
69
|
-
if (kind === 'log') return 'log';
|
|
70
|
-
if (['.html', '.htm', '.mhtml'].includes(ext)) return 'dom';
|
|
71
|
-
return 'report-attachment';
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// --------------------------------------------------------------------------------------------- blobs
|
|
75
|
-
/**
|
|
76
|
-
* Put bytes (a Buffer, a string, or a file path) in the blob store, redacted first when they are text. Runs OUTSIDE
|
|
77
|
-
* the ledger transaction. Returns {sha, bytes, mediaType, redaction, fileUri} for registerBlob / putArtifact.
|
|
78
|
-
* @param {string|Uint8Array|ArrayBuffer|ArrayLike<number>} source
|
|
79
|
-
* @param {{mediaType?: string|null, file?: boolean|null, repoRoots?: string[]}} [options]
|
|
80
|
-
*/
|
|
81
|
-
export function stageBlob(source, { mediaType = null, file = null, repoRoots = [] } = {}) {
|
|
82
|
-
let raw;
|
|
83
|
-
if (Buffer.isBuffer(source)) raw = source;
|
|
84
|
-
else if (typeof source === 'string' && file === false) raw = Buffer.from(source, 'utf8');
|
|
85
|
-
else if (typeof source === 'string') raw = fs.readFileSync(source);
|
|
86
|
-
else raw = Buffer.from(source);
|
|
87
|
-
const type = mediaType ?? (typeof source === 'string' && file !== false ? mediaTypeOf(source) : 'application/octet-stream');
|
|
88
|
-
const { bytes, redaction } = redactBytes(raw, type, { repoRoots });
|
|
89
|
-
const put = putBlob(bytes, { mediaType: type });
|
|
90
|
-
return { sha: put.sha, bytes: put.size, mediaType: type, redaction, fileUri: slash(blobPath(put.sha)) };
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Text as a staged blob (redacted), or null for empty text. */
|
|
94
|
-
export const stageText = (text, { mediaType = 'text/plain', repoRoots = [] } = {}) =>
|
|
95
|
-
(typeof text === 'string' && text.length ? stageBlob(text, { mediaType, file: false, repoRoots }) : null);
|
|
96
|
-
|
|
97
|
-
/** The blobs row of one staged blob; a sha already known keeps its first row. Returns the sha. */
|
|
98
|
-
export function registerBlob(db, staged, { now = Date.now() } = {}) {
|
|
99
|
-
if (!staged?.sha) return null;
|
|
100
|
-
recordBlob(db, { sha256: staged.sha, bytes: staged.bytes, mediaType: staged.mediaType, fileUri: staged.fileUri, redaction: staged.redaction ?? null, createdAt: now });
|
|
101
|
-
return staged.sha;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// ----------------------------------------------------------------------------------------- artifacts
|
|
105
|
-
/**
|
|
106
|
-
* One job_artifacts row for a staged blob. `name` is the logical name, unique per attempt (per workflow for a
|
|
107
|
-
* Kernel artifact, attemptId null). Filing the same name with the same bytes again returns the existing row;
|
|
108
|
-
* other bytes under a filed name are refused `artifact-immutable` (H10). Returns {artifactId, created}.
|
|
109
|
-
*/
|
|
110
|
-
export function putArtifact(db, { workflowId, attemptId = null, jobId = null, opId = null, cut = null, role, kind = null, subkind = null,
|
|
111
|
-
name, blob, label = null, scopeRef = null, round = null, runId = null, origin, baseSha = null, headSha = null, integratedSha = null, now = Date.now() }) {
|
|
112
|
-
if (!ARTIFACT_ROLES.includes(role)) throw refuse(`artifact role must be ${ARTIFACT_ROLES.join('|')}, got '${role}'`, 'artifact-role-unknown');
|
|
113
|
-
if (!ARTIFACT_ORIGINS.includes(origin)) throw refuse(`artifact origin must be ${ARTIFACT_ORIGINS.join('|')}, got '${origin}'`, 'artifact-origin-unknown');
|
|
114
|
-
const logical = slash(name ?? '').replace(/^\/+/, '');
|
|
115
|
-
if (!logical || logical.split('/').includes('..')) throw refuse(`unsafe artifact name: ${name}`, 'artifact-name-invalid');
|
|
116
|
-
const k = kind ?? kindOf(logical);
|
|
117
|
-
if (!ARTIFACT_KINDS.includes(k)) throw refuse(`artifact kind must be ${ARTIFACT_KINDS.join('|')}, got '${k}'`, 'artifact-kind-unknown');
|
|
118
|
-
if (subkind != null && !ARTIFACT_SUBKINDS.includes(subkind)) throw refuse(`artifact subkind must be ${ARTIFACT_SUBKINDS.join('|')}, got '${subkind}'`, 'artifact-subkind-unknown');
|
|
119
|
-
registerBlob(db, blob, { now });
|
|
120
|
-
const before = attemptId != null ? db.prepare('SELECT artifact_id FROM job_artifacts WHERE attempt_id=? AND name=?').get(attemptId, logical)
|
|
121
|
-
: db.prepare('SELECT artifact_id FROM job_artifacts WHERE workflow_id=? AND attempt_id IS NULL AND name=?').get(workflowId, logical);
|
|
122
|
-
let row;
|
|
123
|
-
try {
|
|
124
|
-
row = recordArtifact(db, { workflowId, attemptId, name: logical, sha256: blob.sha, role, kind: k, origin, subkind, label, scopeRef, round, runId, cut,
|
|
125
|
-
baseSha, headSha, integratedSha, createdAt: now, ...(attemptId == null ? { jobId, opId } : {}) });
|
|
126
|
-
} catch (error) {
|
|
127
|
-
if (error?.code === 'STARCI_ARTIFACT_IMMUTABLE') throw refuse(`artifact '${logical}' of attempt ${attemptId ?? '(kernel)'} is already filed with other bytes; artifacts are immutable, file a new name`, 'artifact-immutable', { name: logical });
|
|
128
|
-
throw error;
|
|
129
|
-
}
|
|
130
|
-
return { artifactId: row.artifact_id, created: !before };
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** Link report → artifact (report_attachments). */
|
|
134
|
-
export const linkReportAttachment = (db, { reportId, artifactId }) => attachToReport(db, { reportId, artifactId });
|
|
135
|
-
|
|
136
|
-
// ------------------------------------------------------------------------------------------- attempts
|
|
137
|
-
/** The op_attempts row of a dispatch (workflow + dispatch id), else of the job's latest dispatch, else null. */
|
|
138
|
-
export function attemptOf(db, { workflowId = null, dispatchId = null, jobId = null, attemptId = null } = {}) {
|
|
139
|
-
if (attemptId != null) return db.prepare('SELECT * FROM op_attempts WHERE attempt_id=?').get(attemptId) ?? null;
|
|
140
|
-
if (workflowId && dispatchId) { const row = db.prepare('SELECT * FROM op_attempts WHERE workflow_id=? AND dispatch_id=?').get(workflowId, dispatchId); if (row) return row; }
|
|
141
|
-
if (jobId) return db.prepare('SELECT * FROM op_attempts WHERE job_id=? ORDER BY dispatch_seq DESC LIMIT 1').get(jobId) ?? null;
|
|
142
|
-
return null;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// ------------------------------------------------------------------------------------------- checks
|
|
146
|
-
/**
|
|
147
|
-
* The status of one check run. `unavailable` (the checker could not run: missing tool, host down, spawn error)
|
|
148
|
-
* is infra, never red (H7); a runner-observed non-zero exit is never 'pass' (H8).
|
|
149
|
-
* @param {{exitCode?: number|null, declaredExitCode?: number|null, unavailable?: boolean, error?: boolean,
|
|
150
|
-
* skipped?: boolean, status?: string|null}} [options]
|
|
151
|
-
*/
|
|
152
|
-
function checkStatusOf({ exitCode = null, declaredExitCode = null, unavailable = false, error = false, skipped = false, status = null } = {}) {
|
|
153
|
-
if (status && !CHECK_STATUSES.includes(status)) throw refuse(`check status must be ${CHECK_STATUSES.join('|')}, got '${status}'`, 'check-status-unknown');
|
|
154
|
-
if (skipped || status === 'skipped') return 'skipped';
|
|
155
|
-
if (unavailable || status === 'unavailable') return 'unavailable';
|
|
156
|
-
if (error || status === 'error') return 'error';
|
|
157
|
-
// The raw exit wins over the declared one; an explicit 'fail' (a green exit with red findings) stays fail.
|
|
158
|
-
let exit = null;
|
|
159
|
-
if (Number.isInteger(exitCode)) exit = exitCode;
|
|
160
|
-
else if (Number.isInteger(declaredExitCode)) exit = declaredExitCode;
|
|
161
|
-
if (exit == null) return status === 'pass' || status === 'fail' ? status : 'error';
|
|
162
|
-
return exit === 0 && status !== 'fail' ? 'pass' : 'fail';
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const integerOrNull = (value) => (Number.isInteger(value) ? value : null);
|
|
166
|
-
|
|
167
|
-
// The raw exit the runtime observed and the exit an op declared: an op's own exit is only its claim.
|
|
168
|
-
function exitCodesOf(runner, exitCode, declaredExitCode) {
|
|
169
|
-
if (runner !== 'op') return { raw: integerOrNull(exitCode), declared: integerOrNull(declaredExitCode) };
|
|
170
|
-
return { raw: null, declared: Number.isInteger(exitCode) ? exitCode : integerOrNull(declaredExitCode) };
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* One check_runs row (engine/db/ledger.mjs recordCheckRun). `runner` 'op' is always authority 'declared' (the op's own claim: exitCode is stored as
|
|
175
|
-
* declared_exit_code, exit_code stays NULL — the runtime did not observe it); the runtime's own runners store the
|
|
176
|
-
* raw exit they observed in exit_code. stdout/stderr/output are staged blobs (stageBlob/stageText) or null.
|
|
177
|
-
* run_seq numbers re-runs of the same (attempt, runner, phase, name). Returns {checkId, runSeq, status, red}.
|
|
178
|
-
*/
|
|
179
|
-
export function recordCheck(db, { attemptId, name, phase, runner, authority = null, command = null, cwd = null,
|
|
180
|
-
inputDigest = null, exitCode = null, declaredExitCode = null, attribution = null, status = null, unavailable = false, error = false, skipped = false,
|
|
181
|
-
startedAt = null, finishedAt = null, wallMs = null, stdout = null, stderr = null, output = null, summary = null, note = null,
|
|
182
|
-
spanId = newSpanId(), now = Date.now() }) {
|
|
183
|
-
if (!CHECK_RUNNERS.includes(runner)) throw refuse(`check runner must be ${CHECK_RUNNERS.join('|')}, got '${runner}'`, 'check-runner-unknown');
|
|
184
|
-
if (!CHECK_PHASES.includes(phase)) throw refuse(`check phase must be ${CHECK_PHASES.join('|')}, got '${phase}'`, 'check-phase-unknown');
|
|
185
|
-
if (attemptId == null) throw refuse(`check '${name}' has no attempt`, 'check-attempt-missing');
|
|
186
|
-
const auth = runner === 'op' ? 'declared' : authority ?? 'runtime';
|
|
187
|
-
const { raw, declared } = exitCodesOf(runner, exitCode, declaredExitCode);
|
|
188
|
-
const st = checkStatusOf({ exitCode: raw, declaredExitCode: declared, unavailable, error, skipped, status });
|
|
189
|
-
for (const b of [stdout, stderr, output]) if (b) registerBlob(db, b, { now });
|
|
190
|
-
const wall = wallMs ?? (Number.isFinite(startedAt) && Number.isFinite(finishedAt) ? finishedAt - startedAt : null);
|
|
191
|
-
const row = writeCheckRun(db, { attemptId, name, phase, runner, authority: auth, status: st, spanId, createdAt: now, command, cwd, inputDigest,
|
|
192
|
-
exitCode: raw, declaredExitCode: declared, attributionJson: attribution == null ? null : JSON.stringify(attribution), startedAt, finishedAt, wallMs: wall,
|
|
193
|
-
stdoutSha: stdout?.sha ?? null, stderrSha: stderr?.sha ?? null, outputSha: output?.sha ?? null,
|
|
194
|
-
summaryJson: summary == null ? null : JSON.stringify(summary), note });
|
|
195
|
-
return { checkId: row.check_id, runSeq: row.run_seq, status: st, red: RED_CHECK_STATUSES.includes(st) };
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/** The latest run of every check of an attempt (optionally one runner): [row] ordered by check_id. */
|
|
199
|
-
export const latestCheckRuns = (db, attemptId, { runner = null } = {}) => db.prepare(`SELECT c.* FROM check_runs c
|
|
200
|
-
WHERE c.attempt_id=? ${runner ? 'AND c.runner=?' : ''} AND c.run_seq=(SELECT max(run_seq) FROM check_runs x
|
|
201
|
-
WHERE x.attempt_id=c.attempt_id AND x.runner=c.runner AND x.phase=c.phase AND x.name=c.name) ORDER BY c.check_id`)
|
|
202
|
-
.all(attemptId, ...(runner ? [runner] : []));
|
|
203
|
-
|
|
204
|
-
// ---------------------------------------------------------------------------------------- transcripts
|
|
205
|
-
const linesOf = (text) => (text ? text.split('\n').length : 0);
|
|
206
|
-
|
|
207
|
-
/** A periodic scrollback snapshot of a live attempt; an unchanged scrollback adds no row. Returns {written, sha}. */
|
|
208
|
-
export function recordAttemptSnapshot(db, { attemptId, text, blob = null, at = Date.now() }) {
|
|
209
|
-
const staged = blob ?? stageText(text);
|
|
210
|
-
if (!staged) return { written: false, sha: null };
|
|
211
|
-
registerBlob(db, staged, { now: at });
|
|
212
|
-
const written = recordTranscriptSnapshot(db, { attemptId, sha256: staged.sha, lines: linesOf(text ?? ''), bytes: staged.bytes, at });
|
|
213
|
-
return { written, sha: staged.sha };
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/** The attempt's full redacted scrollback at its end: op_attempts.transcript_sha. Returns the sha or null. */
|
|
217
|
-
export function recordFinalTranscript(db, { attemptId, text = null, blob = null, at = Date.now() }) {
|
|
218
|
-
const staged = blob ?? stageText(text);
|
|
219
|
-
if (!staged || attemptId == null) return null;
|
|
220
|
-
registerBlob(db, staged, { now: at });
|
|
221
|
-
setAttemptTranscript(db, { attemptId, transcriptSha: staged.sha, at });
|
|
222
|
-
return staged.sha;
|
|
223
|
-
}
|