agentic-workflow-manager 8.4.0 → 8.5.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/dist/src/commands/evidence/index.js +160 -0
- package/dist/src/core/dashboard/collect.js +201 -5
- package/dist/src/core/dashboard/render-html.js +9 -3
- package/dist/src/core/dashboard/render-terminal.js +2 -0
- package/dist/src/core/evidence/capture.js +66 -0
- package/dist/src/core/evidence/history.js +50 -0
- package/dist/src/core/evidence/store.js +32 -0
- package/dist/src/core/evidence/types.js +97 -0
- package/dist/src/index.js +2 -0
- package/dist/tests/core/dashboard/collect.test.js +83 -4
- package/dist/tests/core/dashboard/production-adapters.test.js +89 -0
- package/dist/tests/core/dashboard/render-html.test.js +16 -0
- package/dist/tests/core/dashboard/render-terminal.test.js +7 -0
- package/dist/tests/core/evidence/capture.test.js +82 -0
- package/dist/tests/core/evidence/command.test.js +60 -0
- package/dist/tests/core/evidence/history.test.js +41 -0
- package/dist/tests/core/evidence/store.test.js +32 -0
- package/dist/tests/core/evidence/types.test.js +47 -0
- package/dist/tests/helpers/evidence-fixtures.js +17 -0
- package/dist/tests/integration/doctor-dashboard.e2e.test.js +177 -0
- package/dist/tests/integration/published-doctor-evidence.e2e.test.js +180 -0
- package/dist/tests/structural/release-registry-provenance.test.js +21 -0
- package/package.json +1 -1
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.registerEvidenceCommand = registerEvidenceCommand;
|
|
7
|
+
exports.runEvidenceCapture = runEvidenceCapture;
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const child_process_1 = require("child_process");
|
|
11
|
+
const capture_1 = require("../../core/evidence/capture");
|
|
12
|
+
const store_1 = require("../../core/evidence/store");
|
|
13
|
+
const store_2 = require("../../core/journal/store");
|
|
14
|
+
const store_3 = require("../../core/ledger/store");
|
|
15
|
+
const plan_state_1 = require("../../core/dashboard/plan-state");
|
|
16
|
+
function assertRepoRelativePlan(value) {
|
|
17
|
+
if (typeof value !== 'string' || !value || value.startsWith('--') || path_1.default.isAbsolute(value)
|
|
18
|
+
|| value.includes('\\') || value.split('/').some((part) => part === '' || part === '.' || part === '..')) {
|
|
19
|
+
throw new Error('--plan requires a repo-relative path');
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
function currentRelease(lines) {
|
|
24
|
+
const releases = lines.flatMap((line) => {
|
|
25
|
+
const match = /^\s*\d+\.\s+\*\*Release\s+([A-Za-z0-9][A-Za-z0-9_-]*)\s*\/\s*#\d+:\*\*/.exec(line);
|
|
26
|
+
return match ? [match[1]] : [];
|
|
27
|
+
});
|
|
28
|
+
return releases.length > 1 ? releases.at(-1) : undefined;
|
|
29
|
+
}
|
|
30
|
+
function marker(lines, name, release) {
|
|
31
|
+
const expression = new RegExp(`^\\s*<!--\\s*${name}(?:\\s*:\\s*[^\\r\\n]*?)?\\s*-->\\s*$`);
|
|
32
|
+
if (release === undefined)
|
|
33
|
+
return lines.some((line) => expression.test(line));
|
|
34
|
+
const releaseExpression = new RegExp(`\\bRelease\\s+${release.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\b`, 'i');
|
|
35
|
+
return lines.some((line) => expression.test(line) && releaseExpression.test(line));
|
|
36
|
+
}
|
|
37
|
+
/** Reads only structural lifecycle syntax; plan prose never crosses into evidence. */
|
|
38
|
+
function planState(root, planPath, journal) {
|
|
39
|
+
const file = path_1.default.join(root, planPath);
|
|
40
|
+
const stat = fs_1.default.lstatSync(file);
|
|
41
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
42
|
+
throw new Error('--plan must reference a regular file');
|
|
43
|
+
let source;
|
|
44
|
+
try {
|
|
45
|
+
source = new TextDecoder('utf-8', { fatal: true }).decode(fs_1.default.readFileSync(file));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
throw new Error('--plan must be valid UTF-8');
|
|
49
|
+
}
|
|
50
|
+
if (source.includes('\0'))
|
|
51
|
+
throw new Error('--plan must not contain NUL bytes');
|
|
52
|
+
let fenced = false;
|
|
53
|
+
let total = 0;
|
|
54
|
+
let completed = 0;
|
|
55
|
+
const visibleLines = [];
|
|
56
|
+
for (const line of source.split(/\r?\n/)) {
|
|
57
|
+
if (/^\s*(?:```|~~~)/.test(line)) {
|
|
58
|
+
fenced = !fenced;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (fenced)
|
|
62
|
+
continue;
|
|
63
|
+
visibleLines.push(line);
|
|
64
|
+
if (!/^\s*[-*+]\s+\[/.test(line))
|
|
65
|
+
continue;
|
|
66
|
+
const candidate = /^\s*[-*+]\s+\[([^\]])\](?:\s+(.*))?$/.exec(line);
|
|
67
|
+
if (!candidate)
|
|
68
|
+
throw new Error('--plan contains an invalid checklist task');
|
|
69
|
+
if ((candidate[1] !== ' ' && candidate[1] !== 'x' && candidate[1] !== 'X') || !candidate[2]?.trim())
|
|
70
|
+
throw new Error('--plan contains an invalid checklist task');
|
|
71
|
+
total += 1;
|
|
72
|
+
if (candidate[1] === 'x' || candidate[1] === 'X')
|
|
73
|
+
completed += 1;
|
|
74
|
+
}
|
|
75
|
+
if (fenced)
|
|
76
|
+
throw new Error('--plan contains an unclosed fenced block');
|
|
77
|
+
const status = journal.cycle?.status;
|
|
78
|
+
if (status !== 'IN_PROGRESS' && status !== 'COMPLETE' && status !== 'BLOCKED')
|
|
79
|
+
throw new Error('journal cycle status is invalid');
|
|
80
|
+
const release = currentRelease(visibleLines);
|
|
81
|
+
return (0, plan_state_1.classifyPlanState)({
|
|
82
|
+
...(status === 'IN_PROGRESS' ? { journal: { state: 'active' } } : status === 'BLOCKED' ? { journal: { state: 'blocked' } } : {}),
|
|
83
|
+
markers: { qaComplete: marker(visibleLines, 'awm-qa-complete', release), retroComplete: marker(visibleLines, 'awm-retro-complete', release) },
|
|
84
|
+
tasks: { total, completed },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function registerEvidenceCommand(program) {
|
|
88
|
+
const evidence = program.command('evidence').description('durable privacy-preserving cycle observations');
|
|
89
|
+
evidence.command('capture')
|
|
90
|
+
.description('capture one local cycle observation')
|
|
91
|
+
.option('--plan <path>', 'repo-relative plan path')
|
|
92
|
+
.option('--pr-provider <provider>', 'github | gitlab | other')
|
|
93
|
+
.option('--pr-number <number>', 'pull request number')
|
|
94
|
+
.action((opts) => {
|
|
95
|
+
const result = runEvidenceCapture(process.cwd(), opts.plan, { prProvider: opts.prProvider, prNumber: opts.prNumber });
|
|
96
|
+
if (result.code === 0)
|
|
97
|
+
process.stdout.write(result.stdout);
|
|
98
|
+
else {
|
|
99
|
+
process.stderr.write(`awm evidence capture: ${result.error}\n`);
|
|
100
|
+
process.exitCode = 2;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
function firstEvaluationGates(state) {
|
|
105
|
+
return state.cycleVerificationPlan.map((gate) => {
|
|
106
|
+
if (gate.kind === 'review') {
|
|
107
|
+
const verdict = state.verdicts.filter((candidate) => candidate.obligationId === gate.id)
|
|
108
|
+
.sort((left, right) => left.receivedAt.localeCompare(right.receivedAt) || left.id.localeCompare(right.id))[0];
|
|
109
|
+
return { required: true, passed: verdict?.result === 'pass' };
|
|
110
|
+
}
|
|
111
|
+
const evaluated = Object.values(state.jobs).filter((job) => job.satisfies?.includes(gate.id));
|
|
112
|
+
const first = evaluated.filter((job) => job.attemptOf === undefined || !evaluated.some((candidate) => candidate.id === job.attemptOf))
|
|
113
|
+
.sort((left, right) => jobTimestamp(left).localeCompare(jobTimestamp(right)) || left.id.localeCompare(right.id))[0];
|
|
114
|
+
return { required: true, passed: first?.verdict === 'pass' };
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
function jobTimestamp(job) {
|
|
118
|
+
const timestamp = job.result?.endedAt ?? job.phaseTimestamps.received ?? job.phaseTimestamps.exited;
|
|
119
|
+
if (typeof timestamp !== 'string' || Number.isNaN(Date.parse(timestamp)))
|
|
120
|
+
throw new Error(`gate job ${job.id} lacks a durable evaluation timestamp`);
|
|
121
|
+
return timestamp;
|
|
122
|
+
}
|
|
123
|
+
function repositoryIdentity(root, supplied) {
|
|
124
|
+
if (supplied !== undefined) {
|
|
125
|
+
if (typeof supplied !== 'string' || !supplied || supplied.length > 4096 || /[\r\n]/.test(supplied))
|
|
126
|
+
throw new Error('repository identity is invalid');
|
|
127
|
+
return supplied;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
const remote = (0, child_process_1.execFileSync)('git', ['config', '--get', 'remote.origin.url'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000 }).trim();
|
|
131
|
+
if (!remote || remote.length > 4096 || /[\r\n]/.test(remote))
|
|
132
|
+
throw new Error('invalid');
|
|
133
|
+
return remote;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
throw new Error('repository identity unavailable: configure remote.origin.url');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function runEvidenceCapture(root, plan, overrides) {
|
|
140
|
+
try {
|
|
141
|
+
const planPath = assertRepoRelativePlan(plan);
|
|
142
|
+
if (!fs_1.default.existsSync(path_1.default.join(root, planPath)))
|
|
143
|
+
throw new Error('--plan must reference an existing file');
|
|
144
|
+
const branch = (0, store_3.detectBranch)(root);
|
|
145
|
+
const read = overrides?.journal === undefined ? (0, store_2.readJournal)(root, branch) : { corrupt: false, state: overrides.journal };
|
|
146
|
+
if (read.corrupt || !read.state)
|
|
147
|
+
throw new Error('current journal is unavailable or corrupt');
|
|
148
|
+
let pr;
|
|
149
|
+
if (overrides?.prProvider !== undefined || overrides?.prNumber !== undefined) {
|
|
150
|
+
if (overrides.prProvider === undefined || overrides.prNumber === undefined || typeof overrides.prNumber !== 'string' || !/^\d+$/.test(overrides.prNumber))
|
|
151
|
+
throw new Error('--pr-provider and --pr-number must be supplied together');
|
|
152
|
+
pr = { provider: overrides.prProvider, number: Number(overrides.prNumber) };
|
|
153
|
+
}
|
|
154
|
+
const saved = (0, store_1.writeCycleEvidence)(root, (0, capture_1.captureCycleEvidence)({ root, repositoryIdentity: repositoryIdentity(root, overrides?.repositoryIdentity), planPath, journal: read.state, gates: firstEvaluationGates(read.state), ledger: overrides?.ledger ?? (0, store_3.listEntries)(root, branch), pr, planState: planState(root, planPath, read.state) }));
|
|
155
|
+
return { code: 0, stdout: `${saved.cycleId}\n` };
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
return { code: 2, stdout: '', error: error.message };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
exports.REMEDIATION_BY_FINDING_ID = void 0;
|
|
4
7
|
exports.productionDashboardAdapters = productionDashboardAdapters;
|
|
5
8
|
exports.collectDashboardSnapshot = collectDashboardSnapshot;
|
|
6
9
|
const profile_1 = require("../profile");
|
|
10
|
+
const fs_1 = __importDefault(require("fs"));
|
|
11
|
+
const path_1 = __importDefault(require("path"));
|
|
12
|
+
const history_1 = require("../evidence/history");
|
|
13
|
+
const types_1 = require("../evidence/types");
|
|
14
|
+
const store_1 = require("../ledger/store");
|
|
15
|
+
const paths_1 = require("../journal/paths");
|
|
16
|
+
const store_2 = require("../journal/store");
|
|
7
17
|
const sanitize_1 = require("./sanitize");
|
|
8
18
|
const validate_1 = require("./validate");
|
|
9
19
|
const plan_state_1 = require("./plan-state");
|
|
@@ -16,6 +26,7 @@ exports.REMEDIATION_BY_FINDING_ID = {
|
|
|
16
26
|
'planning.source.unavailable': 'awm preflight',
|
|
17
27
|
'execution.source.unavailable': 'awm sensors status',
|
|
18
28
|
};
|
|
29
|
+
const BLOCKED_CYCLE_REMEDIATION = 'awm preflight';
|
|
19
30
|
const EMPTY_ADAPTERS = {
|
|
20
31
|
machine: () => ({ findings: [] }), project: () => ({ findings: [] }), plans: () => [], execution: () => undefined,
|
|
21
32
|
};
|
|
@@ -77,8 +88,88 @@ function productionDashboardAdapters(context) {
|
|
|
77
88
|
return {
|
|
78
89
|
machine: () => ({ findings: machineFindings }),
|
|
79
90
|
project: () => ({ label: 'Project detected', findings: projectFindings }),
|
|
80
|
-
plans: () =>
|
|
81
|
-
|
|
91
|
+
plans: ({ root }) => {
|
|
92
|
+
const history = readEvidenceHistory(root);
|
|
93
|
+
const journal = readJournalOverlay(root);
|
|
94
|
+
const latestCycleId = history.cycles.at(-1)?.cycleId;
|
|
95
|
+
const latestByPlan = new Map();
|
|
96
|
+
for (const cycle of history.cycles)
|
|
97
|
+
latestByPlan.set(cycle.plan.ref, cycle);
|
|
98
|
+
if (latestByPlan.size === 0 && journal)
|
|
99
|
+
return [journalPlanFinding(journal)];
|
|
100
|
+
return [...latestByPlan.values()].map((cycle) => {
|
|
101
|
+
const overlay = cycle.cycleId === latestCycleId ? journal : undefined;
|
|
102
|
+
const blocked = overlay?.state === 'blocked' || cycle.plan.state === 'blocked';
|
|
103
|
+
return {
|
|
104
|
+
id: `planning.cycle.${cycle.cycleId}`,
|
|
105
|
+
// The plan reference is intentionally never rendered: a plan path
|
|
106
|
+
// can expose repository-specific names. Its opaque cycle ID gives
|
|
107
|
+
// the dashboard deterministic identity without exporting it.
|
|
108
|
+
label: 'Project context',
|
|
109
|
+
state: blocked ? 'attention' : 'ok',
|
|
110
|
+
...(blocked ? { remediation: BLOCKED_CYCLE_REMEDIATION, remediationVerified: true } : {}),
|
|
111
|
+
lifecycle: overlay ? lifecycleForJournal(overlay) : lifecycleForCycle(cycle),
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
},
|
|
115
|
+
execution: ({ root }) => {
|
|
116
|
+
const journal = readJournalOverlay(root);
|
|
117
|
+
if (!evidenceDirectoryExists(root))
|
|
118
|
+
return journal ? journalExecutionFinding(journal) : undefined;
|
|
119
|
+
const history = readEvidenceHistory(root);
|
|
120
|
+
if (history.cycles.length === 0)
|
|
121
|
+
return journal ? journalExecutionFinding(journal) : undefined;
|
|
122
|
+
return {
|
|
123
|
+
execution: history.cycles.map((cycle) => cycleFinding('execution', 'Static preflight', cycle, cycle.cycleState === 'blocked')),
|
|
124
|
+
qa: history.cycles.map((cycle) => cycleFinding('qa', 'Sensors', cycle, cycle.qa.fixes < cycle.qa.findings)),
|
|
125
|
+
retro: history.cycles.map((cycle) => cycleFinding('retro', 'Project context', cycle, cycle.plan.state !== 'executed')),
|
|
126
|
+
};
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/** A journal has no durable plan reference. Use a generic fixed row so its
|
|
131
|
+
* authoritative lifecycle remains visible without publishing branch or path. */
|
|
132
|
+
function journalPlanFinding(overlay) {
|
|
133
|
+
const blocked = overlay.state === 'blocked';
|
|
134
|
+
return {
|
|
135
|
+
id: 'planning.current-journal', label: 'Project context', state: blocked ? 'attention' : 'ok',
|
|
136
|
+
...(blocked ? { remediation: BLOCKED_CYCLE_REMEDIATION, remediationVerified: true } : {}),
|
|
137
|
+
lifecycle: lifecycleForJournal(overlay),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function journalExecutionFinding(overlay) {
|
|
141
|
+
const blocked = overlay.state === 'blocked';
|
|
142
|
+
return {
|
|
143
|
+
execution: [{
|
|
144
|
+
id: 'execution.current-journal', label: 'Static preflight', state: blocked ? 'attention' : 'ok',
|
|
145
|
+
...(blocked ? { remediation: BLOCKED_CYCLE_REMEDIATION, remediationVerified: true } : {}),
|
|
146
|
+
}],
|
|
147
|
+
qa: [], retro: [],
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function lifecycleForJournal(overlay) {
|
|
151
|
+
return { journal: { state: overlay.state }, markers: { qaComplete: false, retroComplete: false }, tasks: overlay.tasks };
|
|
152
|
+
}
|
|
153
|
+
function lifecycleForCycle(cycle) {
|
|
154
|
+
const total = cycle.tasks.length;
|
|
155
|
+
if (cycle.plan.state === 'blocked' || cycle.cycleState === 'blocked')
|
|
156
|
+
return { journal: { state: 'blocked' }, markers: { qaComplete: false, retroComplete: false }, tasks: { total, completed: 0 } };
|
|
157
|
+
if (cycle.plan.state === 'active')
|
|
158
|
+
return { journal: { state: 'active' }, markers: { qaComplete: false, retroComplete: false }, tasks: { total, completed: 0 } };
|
|
159
|
+
if (cycle.plan.state === 'executed')
|
|
160
|
+
return { markers: { qaComplete: true, retroComplete: true }, tasks: { total, completed: total } };
|
|
161
|
+
if (cycle.plan.state === 'retro_pending')
|
|
162
|
+
return { markers: { qaComplete: true, retroComplete: false }, tasks: { total, completed: total } };
|
|
163
|
+
if (cycle.plan.state === 'qa_pending')
|
|
164
|
+
return { markers: { qaComplete: false, retroComplete: false }, tasks: { total, completed: total } };
|
|
165
|
+
return { markers: { qaComplete: false, retroComplete: false }, tasks: { total: 0, completed: 0 } };
|
|
166
|
+
}
|
|
167
|
+
function cycleFinding(section, label, cycle, actionable) {
|
|
168
|
+
return {
|
|
169
|
+
id: `${section}.cycle.${cycle.cycleId}`,
|
|
170
|
+
label,
|
|
171
|
+
state: actionable ? 'attention' : 'ok',
|
|
172
|
+
...(actionable ? { remediation: BLOCKED_CYCLE_REMEDIATION, remediationVerified: true } : {}),
|
|
82
173
|
};
|
|
83
174
|
}
|
|
84
175
|
function findings(items, optional = false) {
|
|
@@ -118,6 +209,111 @@ function canonicalOptionalFailure(failure) {
|
|
|
118
209
|
return [];
|
|
119
210
|
return [{ id: failure.findingId, label: 'Optional source unavailable', state: 'unavailable', remediation: exports.REMEDIATION_BY_FINDING_ID[failure.findingId] }];
|
|
120
211
|
}
|
|
212
|
+
function evidenceDirectoryExists(root) {
|
|
213
|
+
try {
|
|
214
|
+
return fs_1.default.lstatSync(path_1.default.join(root, '.awm', 'evidence', 'cycles')).isDirectory();
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
if (error && typeof error === 'object' && error.code === 'ENOENT')
|
|
218
|
+
return false;
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/** Reads only the current branch journal. Missing journals are not observations;
|
|
223
|
+
* corrupt or redirected journal paths fail closed rather than changing lifecycle
|
|
224
|
+
* state from untrusted content. */
|
|
225
|
+
function readJournalOverlay(root) {
|
|
226
|
+
const branch = (0, store_1.detectBranch)(root);
|
|
227
|
+
const file = (0, paths_1.statePath)(root, branch);
|
|
228
|
+
if (!safeJournalStateExists(root, branch, file))
|
|
229
|
+
return undefined;
|
|
230
|
+
const journal = (0, store_2.readJournal)(root, branch);
|
|
231
|
+
if (journal.corrupt || !journal.state)
|
|
232
|
+
throw new Error('current journal is unavailable or corrupt');
|
|
233
|
+
if (journal.state.cycle.status === 'COMPLETE')
|
|
234
|
+
return undefined;
|
|
235
|
+
return {
|
|
236
|
+
state: journal.state.cycle.status === 'BLOCKED' ? 'blocked' : 'active',
|
|
237
|
+
tasks: { total: journal.state.tasks.length, completed: journal.state.tasks.filter((task) => task.status === 'done').length },
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
function safeJournalStateExists(root, branch, file) {
|
|
241
|
+
for (const directory of [path_1.default.join(root, '.awm'), path_1.default.join(root, '.awm', 'journal'), (0, paths_1.journalDir)(root, branch)]) {
|
|
242
|
+
let stat;
|
|
243
|
+
try {
|
|
244
|
+
stat = fs_1.default.lstatSync(directory);
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
if (error && typeof error === 'object' && error.code === 'ENOENT')
|
|
248
|
+
return false;
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
252
|
+
throw new Error('journal directory is unsafe');
|
|
253
|
+
}
|
|
254
|
+
let stat;
|
|
255
|
+
try {
|
|
256
|
+
stat = fs_1.default.lstatSync(file);
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
if (error && typeof error === 'object' && error.code === 'ENOENT')
|
|
260
|
+
return false;
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
264
|
+
throw new Error('journal state is unsafe');
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
function readEvidenceHistory(root) {
|
|
268
|
+
const awmDirectory = path_1.default.join(root, '.awm');
|
|
269
|
+
const evidenceDirectory = path_1.default.join(awmDirectory, 'evidence');
|
|
270
|
+
const directory = path_1.default.join(evidenceDirectory, 'cycles');
|
|
271
|
+
for (const ancestor of [awmDirectory, evidenceDirectory, directory]) {
|
|
272
|
+
let stat;
|
|
273
|
+
try {
|
|
274
|
+
stat = fs_1.default.lstatSync(ancestor);
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
if (error && typeof error === 'object' && error.code === 'ENOENT')
|
|
278
|
+
return (0, history_1.buildEvidenceHistory)([]);
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
282
|
+
throw new Error('evidence history directory is unsafe');
|
|
283
|
+
}
|
|
284
|
+
const seenCycleIds = new Set();
|
|
285
|
+
const records = fs_1.default.readdirSync(directory, { withFileTypes: true })
|
|
286
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
287
|
+
.map((entry) => {
|
|
288
|
+
if (!entry.isFile() || !entry.name.endsWith('.json'))
|
|
289
|
+
throw new Error('evidence history contains an unsupported entry');
|
|
290
|
+
const file = path_1.default.join(directory, entry.name);
|
|
291
|
+
if (fs_1.default.lstatSync(file).isSymbolicLink())
|
|
292
|
+
throw new Error('evidence history file is unsafe');
|
|
293
|
+
const evidence = (0, types_1.validateCycleEvidence)(JSON.parse(fs_1.default.readFileSync(file, 'utf8')));
|
|
294
|
+
if (entry.name !== `${evidence.cycleId}.json` || seenCycleIds.has(evidence.cycleId))
|
|
295
|
+
throw new Error('evidence history filename is invalid');
|
|
296
|
+
seenCycleIds.add(evidence.cycleId);
|
|
297
|
+
return evidence;
|
|
298
|
+
});
|
|
299
|
+
return (0, history_1.buildEvidenceHistory)(records);
|
|
300
|
+
}
|
|
301
|
+
function evidenceHistoryItems(root) {
|
|
302
|
+
const history = readEvidenceHistory(root);
|
|
303
|
+
return {
|
|
304
|
+
confidence: history.confidence,
|
|
305
|
+
items: history.cycles.map((cycle) => {
|
|
306
|
+
const cures = cycle.cureEfficacy.length === 0 ? 'none' : cycle.cureEfficacy.map((cure) => cure.efficacy).join(', ');
|
|
307
|
+
return {
|
|
308
|
+
id: `history.cycle.${cycle.cycleId}`,
|
|
309
|
+
label: `Cycle ${cycle.cycleId.slice(0, 12)}`,
|
|
310
|
+
state: cycle.cycleState === 'blocked' ? 'attention' : 'ok',
|
|
311
|
+
...(cycle.cycleState === 'blocked' ? { remediation: BLOCKED_CYCLE_REMEDIATION } : {}),
|
|
312
|
+
detail: `plan ${cycle.plan.state}; tasks ${cycle.tasks.length}; retries ${cycle.retries}; QA ${cycle.qa.findings}/${cycle.qa.fixes}; first-pass ${cycle.gates.firstPass ? 'yes' : 'no'}; cures ${cures}`,
|
|
313
|
+
};
|
|
314
|
+
}),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
121
317
|
function isolatedFindings(items) {
|
|
122
318
|
return optional(() => findings(items, true));
|
|
123
319
|
}
|
|
@@ -150,7 +346,7 @@ function collectDashboardSnapshot(options) {
|
|
|
150
346
|
const executionItems = isolatedFindings(execution?.execution);
|
|
151
347
|
const qaItems = isolatedFindings(execution?.qa);
|
|
152
348
|
const retroItems = isolatedFindings(execution?.retro);
|
|
153
|
-
const
|
|
349
|
+
const evidenceResult = optional(() => evidenceHistoryItems(root));
|
|
154
350
|
const executionUnavailable = !executionResult.failed && execution === undefined;
|
|
155
351
|
const sections = [
|
|
156
352
|
machineSection,
|
|
@@ -163,8 +359,8 @@ function collectDashboardSnapshot(options) {
|
|
|
163
359
|
// absent execution source is not evidence of a successful empty cycle.
|
|
164
360
|
section('qa', executionResult.failed || executionUnavailable || qaItems.failed ? 'unavailable' : 'available', qaItems.value),
|
|
165
361
|
section('retro', executionResult.failed || executionUnavailable || retroItems.failed ? 'unavailable' : 'available', retroItems.value),
|
|
166
|
-
section('history',
|
|
362
|
+
section('history', evidenceResult.failed ? 'unavailable' : 'available', evidenceResult.failed ? [] : evidenceResult.value.items),
|
|
167
363
|
];
|
|
168
364
|
const degraded = sections.some((entry) => entry.availability === 'unavailable' || entry.items.some((item) => item.state !== 'ok' && item.state !== 'not_applicable'));
|
|
169
|
-
return (0, validate_1.validateDashboardSnapshotV1)({ schema: 1, generatedAt: options.now, overall: degraded ? 'degraded' : 'healthy', project: { detected: true, label: projectSource?.label || 'Project detected' }, confidence: '
|
|
365
|
+
return (0, validate_1.validateDashboardSnapshotV1)({ schema: 1, generatedAt: options.now, overall: degraded ? 'degraded' : 'healthy', project: { detected: true, label: projectSource?.label || 'Project detected' }, confidence: evidenceResult.failed ? 'none' : evidenceResult.value.confidence, sections });
|
|
170
366
|
}
|
|
@@ -45,8 +45,14 @@ function projectHeaderActions() {
|
|
|
45
45
|
function projectChrome(project) {
|
|
46
46
|
return `<div class="project-chrome"><p class="eyebrow" data-project-breadcrumb>AWM / Proyecto / ${project}</p><nav data-project-nav aria-label="Project navigation"><span aria-current="page">Proyecto</span><span>Ejecución</span><span>Configuración</span></nav></div>`;
|
|
47
47
|
}
|
|
48
|
-
function projectEvidenceComposition() {
|
|
49
|
-
|
|
48
|
+
function projectEvidenceComposition(snapshot) {
|
|
49
|
+
const planning = snapshot.sections.find((section) => section.id === 'planning')?.items ?? [];
|
|
50
|
+
const history = snapshot.sections.find((section) => section.id === 'history')?.items ?? [];
|
|
51
|
+
const planCards = planning.length === 0
|
|
52
|
+
? `<div class="plan-card active" data-plan-card="active"><div><strong>Plan activo</strong><span class="state attention">Sin observación</span></div><p>Progreso: sin observación</p><button type="button" disabled aria-describedby="static-controls-note">Ver plan (estático)</button></div><div class="plan-card blocked" data-plan-card="blocked"><div><strong>Plan bloqueado</strong><span class="state unavailable">Sin observación</span></div><p>Progreso: sin observación</p><button type="button" disabled aria-describedby="static-controls-note">Ver plan (estático)</button></div>`
|
|
53
|
+
: planning.map((plan) => `<div class="plan-card ${plan.detail === 'blocked' ? 'blocked' : 'active'}" data-plan-card="${plan.detail === 'blocked' ? 'blocked' : 'active'}"><div><strong>${escapeHtml(plan.label)}</strong><span class="state ${plan.state}">${STATE_GLYPH[plan.state]} ${STATE_TEXT[plan.state]}</span></div><p>Estado: ${escapeHtml(plan.detail ?? 'sin observación')}</p><button type="button" disabled aria-describedby="static-controls-note">Ver plan (estático)</button></div>`).join('');
|
|
54
|
+
const evidenceRows = history.length === 0 ? '<tr><td colspan="4" class="empty">No hay evidencia de impacto disponible en este snapshot.</td></tr>' : history.map((item) => `<tr><td>ciclo</td><td>${escapeHtml(item.label)}</td><td><span class="state ${item.state}">${STATE_GLYPH[item.state]} ${STATE_TEXT[item.state]}</span><br>${escapeHtml(item.detail ?? '—')}</td><td>—</td></tr>`).join('');
|
|
55
|
+
return `<div data-project-evidence role="group" aria-labelledby="project-evidence-heading"><h3 id="project-evidence-heading" class="sr-only">Project evidence</h3><div class="evidence-grid compact-composition"><div><h3>Planes de trabajo</h3>${planCards}</div><div><h3>Impacto y trazabilidad</h3><table class="evidence-table"><thead><tr><th scope="col">Tipo</th><th scope="col">Fuente</th><th scope="col">Estado</th><th scope="col">Fecha</th></tr></thead><tbody>${evidenceRows}</tbody></table></div></div><div class="closure-actions" data-closure-actions role="group" aria-labelledby="closure-actions-heading"><div><h3 id="closure-actions-heading">Qué falta para cerrar el ciclo</h3><p class="empty">La ausencia de marcador no equivale a cero hallazgos.</p></div><div><button type="button" disabled aria-describedby="static-controls-note">Completar QA (estático)</button><button type="button" disabled aria-describedby="static-controls-note">Registrar retro (estático)</button><button type="button" disabled aria-describedby="static-controls-note">Adjuntar evidencia (estático)</button></div></div></div>`;
|
|
50
56
|
}
|
|
51
57
|
function projectComposition(snapshot) {
|
|
52
58
|
const stageSections = ['planning', 'execution', 'qa', 'retro', 'history'];
|
|
@@ -62,7 +68,7 @@ function projectComposition(snapshot) {
|
|
|
62
68
|
const prepNames = ['installation', 'sensors', 'persistence'];
|
|
63
69
|
const preparation = `<div class="machine-preparation-strip" data-machine-preparation role="group" aria-labelledby="machine-preparation-heading"><h3 id="machine-preparation-heading">Preparación de máquina</h3><ul class="diagnostic-grid">${prepNames.map((name, index) => { const item = prepItems[index]; return `<li data-machine-preparation-card="${name}">${item ? `<span class="state ${item.state}">${STATE_GLYPH[item.state]} ${STATE_TEXT[item.state]}</span><strong>${escapeHtml(item.label)}</strong><span>${item.detail ? escapeHtml(item.detail) : 'Sin detalle adicional'}</span>` : '<span class="state unavailable">⊘ Unavailable</span><strong>Sin observación</strong><span>Fuente no disponible</span>'}</li>`; }).join('')}</ul></div>`;
|
|
64
70
|
const machineSupplement = `${preparation}<nav class="lifecycle-timeline" data-lifecycle-timeline aria-labelledby="lifecycle-timeline-heading"><h3 id="lifecycle-timeline-heading">Línea de ciclo</h3><ol class="timeline connected-timeline">${stages}</ol></nav>${provisional}`;
|
|
65
|
-
const historySupplement = projectEvidenceComposition();
|
|
71
|
+
const historySupplement = projectEvidenceComposition(snapshot);
|
|
66
72
|
return snapshot.sections.map((section) => sectionHtml(section, section.id === 'machine' ? machineSupplement : section.id === 'history' ? historySupplement : '')).join('');
|
|
67
73
|
}
|
|
68
74
|
/** Renders a portable, static, share-safe dashboard document. */
|
|
@@ -28,6 +28,8 @@ function renderFullTerminal(input) {
|
|
|
28
28
|
];
|
|
29
29
|
for (const section of snapshot.sections) {
|
|
30
30
|
lines.push('', SECTION_TITLES[section.id]);
|
|
31
|
+
if (section.id === 'history')
|
|
32
|
+
lines.push(` Eligible evidence rows: ${section.items.length}`);
|
|
31
33
|
if (section.availability !== 'available')
|
|
32
34
|
lines.push(` ⊘ source ${section.availability.replace('_', ' ')}`);
|
|
33
35
|
if (section.items.length === 0)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.captureCycleEvidence = captureCycleEvidence;
|
|
7
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
8
|
+
const types_1 = require("./types");
|
|
9
|
+
const hash = (value) => crypto_1.default.createHash('sha256').update(value).digest('hex');
|
|
10
|
+
const object = (value, name) => { if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
11
|
+
throw new Error(`${name} must be an object`); return value; };
|
|
12
|
+
const iso = (value, name) => { if (typeof value !== 'string' || Number.isNaN(Date.parse(value)))
|
|
13
|
+
throw new Error(`${name} must be an ISO timestamp`); return value; };
|
|
14
|
+
const nonNegative = (value, name) => { if (!Number.isSafeInteger(value) || value < 0)
|
|
15
|
+
throw new Error(`${name} must be a non-negative integer`); return value; };
|
|
16
|
+
function captureCycleEvidence(input) {
|
|
17
|
+
if (!input || typeof input.root !== 'string' || !input.root)
|
|
18
|
+
throw new Error('capture root is required');
|
|
19
|
+
if (typeof input.repositoryIdentity !== 'string' || input.repositoryIdentity.length === 0 || input.repositoryIdentity.length > 4096 || /[\r\n]/.test(input.repositoryIdentity))
|
|
20
|
+
throw new Error('repository identity is invalid');
|
|
21
|
+
const repositoryIdentity = hash(input.repositoryIdentity);
|
|
22
|
+
if (typeof input.planPath !== 'string' || !input.planPath)
|
|
23
|
+
throw new Error('capture planPath is required');
|
|
24
|
+
const journal = object(input.journal, 'journal');
|
|
25
|
+
const cycle = object(journal.cycle, 'journal cycle');
|
|
26
|
+
const startedAt = iso(cycle.startedAt, 'cycle startedAt');
|
|
27
|
+
const endedAt = iso(cycle.completedAt ?? journal.controllerHeartbeatAt, 'cycle endedAt');
|
|
28
|
+
if (cycle.status !== 'COMPLETE' && cycle.status !== 'BLOCKED')
|
|
29
|
+
throw new Error('journal cycle must be complete or blocked');
|
|
30
|
+
if (!Array.isArray(journal.tasks) || !Array.isArray(journal.verdicts) || !Array.isArray(journal.fixes) || !Array.isArray(input.gates) || !Array.isArray(input.ledger))
|
|
31
|
+
throw new Error('capture sources are invalid');
|
|
32
|
+
const tasks = journal.tasks.map((item, index) => { const task = object(item, `task ${index}`); if (typeof task.id !== 'string')
|
|
33
|
+
throw new Error('task id is invalid'); const attempts = nonNegative(task.attempts, 'task attempts'); return { id: task.id, attempts, retries: Math.max(attempts - 1, 0) }; });
|
|
34
|
+
const verdictIds = new Set();
|
|
35
|
+
const adverseVerdictIds = new Set();
|
|
36
|
+
const adverseVerdicts = journal.verdicts.filter((item, index) => {
|
|
37
|
+
const verdict = object(item, `verdict ${index}`);
|
|
38
|
+
if (typeof verdict.id !== 'string' || !verdict.id)
|
|
39
|
+
throw new Error('verdict id is invalid');
|
|
40
|
+
if (verdictIds.has(verdict.id))
|
|
41
|
+
throw new Error('verdict id is duplicated');
|
|
42
|
+
verdictIds.add(verdict.id);
|
|
43
|
+
if (verdict.result !== 'pass' && verdict.result !== 'fail' && verdict.result !== 'inconclusive')
|
|
44
|
+
throw new Error('verdict result is invalid');
|
|
45
|
+
iso(verdict.receivedAt, 'verdict receivedAt');
|
|
46
|
+
if (verdict.result !== 'pass')
|
|
47
|
+
adverseVerdictIds.add(verdict.id);
|
|
48
|
+
return verdict.result !== 'pass';
|
|
49
|
+
});
|
|
50
|
+
const signatures = adverseVerdicts.map((item) => { const verdict = object(item, 'verdict'); if (typeof verdict.fingerprint !== 'string' || !verdict.fingerprint)
|
|
51
|
+
throw new Error('verdict fingerprint is required'); return hash(`signature:${verdict.fingerprint}`); });
|
|
52
|
+
const fixes = journal.fixes.filter((item, index) => {
|
|
53
|
+
const fix = object(item, `fix ${index}`);
|
|
54
|
+
if (typeof fix.closed !== 'boolean')
|
|
55
|
+
throw new Error('fix closed is invalid');
|
|
56
|
+
if (typeof fix.verdictId !== 'string' || !fix.verdictId || !adverseVerdictIds.has(fix.verdictId))
|
|
57
|
+
throw new Error('fix verdictId must reference an adverse verdict');
|
|
58
|
+
return fix.closed;
|
|
59
|
+
}).length;
|
|
60
|
+
const gates = input.gates.map((item, index) => { const gate = object(item, `gate ${index}`); if (typeof gate.required !== 'boolean' || typeof gate.passed !== 'boolean')
|
|
61
|
+
throw new Error('gate is invalid'); return gate; }).filter((gate) => gate.required === true);
|
|
62
|
+
const cures = input.ledger.filter((item, index) => { const entry = object(item, `ledger ${index}`); if (typeof entry.signature !== 'string' || (entry.polarity !== 'finding' && entry.polarity !== 'win'))
|
|
63
|
+
throw new Error('ledger entry is invalid'); iso(entry.ts, 'ledger timestamp'); return entry.polarity === 'win'; }).map((entry) => { const source = object(entry, 'ledger'); return { signature: hash(`signature:${source.signature}`), curedAt: iso(source.ts, 'ledger timestamp') }; });
|
|
64
|
+
const pr = input.pr === undefined ? undefined : (() => { const raw = object(input.pr, 'pr'); return { provider: raw.provider, number: raw.number }; })();
|
|
65
|
+
return (0, types_1.validateCycleEvidence)({ schema: 1, cycleId: hash(`${repositoryIdentity}\0${input.planPath}\0${startedAt}`), startedAt, endedAt, durationMs: Date.parse(endedAt) - Date.parse(startedAt), cycleState: cycle.status === 'COMPLETE' ? 'completed' : 'blocked', plan: { ref: input.planPath, state: input.planState ?? (cycle.status === 'BLOCKED' ? 'blocked' : 'executed') }, tasks, qa: { findings: signatures.length, fixes: Math.min(fixes, signatures.length), signatures }, gates: { required: gates.length, firstEvaluationsPassed: gates.map((gate) => gate.passed), firstPass: gates.every((gate) => gate.passed === true) }, cures, ...(pr ? { pr } : {}) });
|
|
66
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.confidenceForCycles = confidenceForCycles;
|
|
4
|
+
exports.classifyCure = classifyCure;
|
|
5
|
+
exports.buildEvidenceHistory = buildEvidenceHistory;
|
|
6
|
+
const types_1 = require("./types");
|
|
7
|
+
function confidenceForCycles(count) {
|
|
8
|
+
if (!Number.isSafeInteger(count) || count < 0)
|
|
9
|
+
throw new Error('eligible cycle count must be a non-negative integer');
|
|
10
|
+
const eligibleCycles = count;
|
|
11
|
+
if (eligibleCycles === 0)
|
|
12
|
+
return 'none';
|
|
13
|
+
if (eligibleCycles === 1)
|
|
14
|
+
return 'provisional';
|
|
15
|
+
if (eligibleCycles < 5)
|
|
16
|
+
return 'observing';
|
|
17
|
+
return 'supported';
|
|
18
|
+
}
|
|
19
|
+
function classifyCure(input) {
|
|
20
|
+
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
21
|
+
throw new Error('cure observation must be an object');
|
|
22
|
+
const value = input;
|
|
23
|
+
if (Object.keys(value).some((key) => key !== 'laterEligibleCycles' && key !== 'recurred') || !Number.isSafeInteger(value.laterEligibleCycles) || value.laterEligibleCycles < 0 || typeof value.recurred !== 'boolean')
|
|
24
|
+
throw new Error('cure observation is invalid');
|
|
25
|
+
const laterEligibleCycles = value.laterEligibleCycles;
|
|
26
|
+
if (value.recurred)
|
|
27
|
+
return 'recurred';
|
|
28
|
+
if (laterEligibleCycles === 0)
|
|
29
|
+
return 'awaiting_observation';
|
|
30
|
+
return laterEligibleCycles >= 3 ? 'supported' : 'observing';
|
|
31
|
+
}
|
|
32
|
+
/** Validates, retains, and deterministically orders every eligible local observation. */
|
|
33
|
+
function buildEvidenceHistory(records) {
|
|
34
|
+
if (!Array.isArray(records))
|
|
35
|
+
throw new Error('evidence history records must be an array');
|
|
36
|
+
const valid = records.map((record) => (0, types_1.validateCycleEvidence)(record)).sort((left, right) => left.startedAt.localeCompare(right.startedAt) || left.cycleId.localeCompare(right.cycleId));
|
|
37
|
+
const completed = valid.filter((cycle) => cycle.cycleState === 'completed');
|
|
38
|
+
const cycles = valid.map((cycle, index) => ({
|
|
39
|
+
...cycle,
|
|
40
|
+
retries: cycle.tasks.reduce((total, task) => total + task.retries, 0),
|
|
41
|
+
cureEfficacy: cycle.cures.map((cure) => {
|
|
42
|
+
const later = valid.slice(index + 1).filter((candidate) => candidate.cycleState === 'completed');
|
|
43
|
+
return {
|
|
44
|
+
signature: cure.signature,
|
|
45
|
+
efficacy: classifyCure({ laterEligibleCycles: later.length, recurred: later.some((candidate) => candidate.qa.signatures.includes(cure.signature)) }),
|
|
46
|
+
};
|
|
47
|
+
}),
|
|
48
|
+
}));
|
|
49
|
+
return { confidence: confidenceForCycles(completed.length), empty: cycles.length === 0, cycles };
|
|
50
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.writeCycleEvidence = writeCycleEvidence;
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const atomic_file_1 = require("../atomic-file");
|
|
10
|
+
const types_1 = require("./types");
|
|
11
|
+
function writeCycleEvidence(root, evidence) {
|
|
12
|
+
if (typeof root !== 'string' || root.length === 0)
|
|
13
|
+
throw new Error('evidence root must be a non-empty string');
|
|
14
|
+
const valid = (0, types_1.validateCycleEvidence)(evidence);
|
|
15
|
+
const safeRoot = safeDirectory(root, 'root');
|
|
16
|
+
let directory = safeRoot;
|
|
17
|
+
for (const segment of ['.awm', 'evidence', 'cycles']) {
|
|
18
|
+
directory = path_1.default.join(directory, segment);
|
|
19
|
+
if (!fs_1.default.existsSync(directory))
|
|
20
|
+
fs_1.default.mkdirSync(directory, { mode: 0o700 });
|
|
21
|
+
directory = safeDirectory(directory, `evidence ${segment}`);
|
|
22
|
+
}
|
|
23
|
+
const file = path_1.default.join(directory, `${valid.cycleId}.json`);
|
|
24
|
+
(0, atomic_file_1.writeFileAtomicDurable)(file, JSON.stringify(valid, null, 2) + '\n', 0o600);
|
|
25
|
+
return valid;
|
|
26
|
+
}
|
|
27
|
+
function safeDirectory(directory, label) {
|
|
28
|
+
const stat = fs_1.default.lstatSync(directory);
|
|
29
|
+
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
30
|
+
throw new Error(`unsafe evidence ${label} directory symlink or non-directory`);
|
|
31
|
+
return fs_1.default.realpathSync(directory);
|
|
32
|
+
}
|