@agilno-tech/rivet 0.1.0-alpha.0 → 0.1.0-alpha.2
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/README.md +5 -5
- package/dist/v2/protocols/design-authority.md +6 -0
- package/dist/v2/protocols/qa-evidence.md +14 -0
- package/dist/v2/schemas/quality.schema.json +289 -21
- package/dist/v2/templates/github-actions/rivet-deploy.yml +8 -6
- package/dist/v2/templates/harness/SKILL.md +9 -1
- package/package.json +2 -2
- package/protocols/design-authority.md +6 -0
- package/protocols/qa-evidence.md +14 -0
- package/schemas/quality.schema.json +289 -21
- package/src/cli/main.js +4 -0
- package/src/cli/parse-args.js +2 -2
- package/src/commands/human-task.js +10 -2
- package/src/commands/task-governance.js +53 -0
- package/src/commands/work.js +1 -1
- package/src/config/load.js +1 -1
- package/src/config/validate.js +2 -1
- package/src/delivery/contract.js +13 -2
- package/src/delivery/prepare.js +34 -0
- package/src/delivery/review-request.js +12 -8
- package/src/feature/decisions.js +187 -0
- package/src/feature/governance.js +203 -0
- package/src/feature/host-execution.js +11 -3
- package/src/feature/runtime-bridge.js +4 -0
- package/src/feature/workflow.js +12 -1
- package/src/git/client.js +12 -0
- package/src/quality/content-review.js +225 -0
- package/src/quality/content-scan.js +120 -0
- package/src/state/paths.js +10 -0
- package/templates/github-actions/rivet-deploy.yml +8 -6
- package/templates/harness/SKILL.md +9 -1
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { types } from 'node:util';
|
|
3
|
+
import { containsSecretMaterial } from '../clients/contract.js';
|
|
4
|
+
|
|
5
|
+
const ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
6
|
+
const DIGEST = /^[a-f0-9]{64}$/;
|
|
7
|
+
const INPUT = ['id', 'tier', 'title', 'options', 'choice', 'rationale', 'approvedPlanDecisionId'];
|
|
8
|
+
const RECORD = ['schemaVersion', ...INPUT, 'runId', 'requestDigest', 'planDigest', 'actor', 'createdAt', 'status', 'approval', 'digest'];
|
|
9
|
+
const BINDINGS = ['runId', 'requestDigest', 'planDigest'];
|
|
10
|
+
const CONTEXT = [...BINDINGS, 'actor', 'now', 'approvedPlanDecisions', 'secretValues', 'humanConfirmed'];
|
|
11
|
+
|
|
12
|
+
export class DecisionError extends Error {
|
|
13
|
+
constructor(reason = 'invalid-decision') {
|
|
14
|
+
super({
|
|
15
|
+
'invalid-decision': 'Decision data is invalid.',
|
|
16
|
+
'secret-material': 'Decision data contains sensitive material. Remove credentials before recording it.',
|
|
17
|
+
'binding-mismatch': 'Decision does not match the current task and approved plan.',
|
|
18
|
+
'digest-mismatch': 'Decision content changed after it was recorded.',
|
|
19
|
+
'plan-reference-required': 'Tier 0 requires a matching decision reference from the approved plan.',
|
|
20
|
+
'human-confirmation-required': 'This decision requires explicit human confirmation.',
|
|
21
|
+
'approval-conflict': 'Only a pending decision can be approved. Read the current decision before retrying.',
|
|
22
|
+
}[reason] ?? 'Decision data is invalid.');
|
|
23
|
+
this.name = 'DecisionError';
|
|
24
|
+
this.code = 'ERR_INVALID_DECISION';
|
|
25
|
+
this.safeMessage = this.message;
|
|
26
|
+
this.details = Object.freeze({ reason });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const fail = reason => { throw new DecisionError(reason); };
|
|
30
|
+
|
|
31
|
+
function record(value, allowed, required = allowed) {
|
|
32
|
+
if (!value || typeof value !== 'object' || types.isProxy(value) || Array.isArray(value)
|
|
33
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(value))) fail();
|
|
34
|
+
const keys = Reflect.ownKeys(value);
|
|
35
|
+
if (keys.length > allowed.length || keys.some(key => typeof key !== 'string' || !allowed.includes(key))) fail();
|
|
36
|
+
const copy = {};
|
|
37
|
+
for (const key of keys) {
|
|
38
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
39
|
+
if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value') || descriptor.value === undefined) fail();
|
|
40
|
+
Object.defineProperty(copy, key, { value: descriptor.value, enumerable: true });
|
|
41
|
+
}
|
|
42
|
+
if (required.some(key => !Object.hasOwn(copy, key))) fail();
|
|
43
|
+
return copy;
|
|
44
|
+
}
|
|
45
|
+
function array(value, max, min = 0) {
|
|
46
|
+
if (!Array.isArray(value) || types.isProxy(value) || Object.getPrototypeOf(value) !== Array.prototype) fail();
|
|
47
|
+
const length = Object.getOwnPropertyDescriptor(value, 'length')?.value;
|
|
48
|
+
if (!Number.isInteger(length) || length < min || length > max) fail();
|
|
49
|
+
const keys = Reflect.ownKeys(value);
|
|
50
|
+
if (keys.length !== length + 1) fail();
|
|
51
|
+
const result = [];
|
|
52
|
+
for (let index = 0; index < length; index++) {
|
|
53
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
54
|
+
if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value') || descriptor.value === undefined) fail();
|
|
55
|
+
result.push(descriptor.value);
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
function text(value, max, secrets) {
|
|
60
|
+
if (typeof value !== 'string' || value.length < 1 || value.length > max || value.trim() !== value
|
|
61
|
+
|| /[\u0000-\u001f\u007f-\u009f\p{Cf}]/u.test(value)) fail();
|
|
62
|
+
if (containsSecretMaterial(value) || /\b(?:npm_|glpat-|hf_)[A-Za-z0-9_-]{16,}\b/.test(value) || /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(value)
|
|
63
|
+
|| /\b[a-z][a-z0-9+.-]*:\/\/[^\s/@:]+:[^\s/@]+@/i.test(value)
|
|
64
|
+
|| secrets.some(secret => value.includes(secret))) fail('secret-material');
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
function id(value, secrets = []) {
|
|
68
|
+
if (typeof value !== 'string' || value.length > 64 || !ID.test(value)) fail();
|
|
69
|
+
return text(value, 64, secrets);
|
|
70
|
+
}
|
|
71
|
+
function digest(value) { if (typeof value !== 'string' || !DIGEST.test(value)) fail(); return value; }
|
|
72
|
+
function instant(value) {
|
|
73
|
+
if (typeof value !== 'string' || value.length !== 24 || !Number.isFinite(Date.parse(value))
|
|
74
|
+
|| new Date(value).toISOString() !== value) fail();
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
function actor(value, secrets) {
|
|
78
|
+
const result = record(value, ['kind', 'id']);
|
|
79
|
+
if (!['human', 'agent'].includes(result.kind)) fail();
|
|
80
|
+
return { kind: result.kind, id: text(result.id, 128, secrets) };
|
|
81
|
+
}
|
|
82
|
+
function context(value, mode = 'validate') {
|
|
83
|
+
const result = record(value, CONTEXT, mode === 'validate' ? BINDINGS : [...BINDINGS, 'actor', 'now']);
|
|
84
|
+
id(result.runId); digest(result.requestDigest); digest(result.planDigest);
|
|
85
|
+
const secrets = result.secretValues === undefined ? [] : array(result.secretValues, 64).map(secret => {
|
|
86
|
+
if (typeof secret !== 'string' || !secret || secret.length > 4096) fail();
|
|
87
|
+
return secret;
|
|
88
|
+
});
|
|
89
|
+
const approved = result.approvedPlanDecisions === undefined ? [] : array(result.approvedPlanDecisions, 128).map(item => {
|
|
90
|
+
const reference = record(item, ['id', 'choice']);
|
|
91
|
+
return { id: id(reference.id), choice: id(reference.choice) };
|
|
92
|
+
});
|
|
93
|
+
if (new Set(approved.map(item => item.id)).size !== approved.length) fail();
|
|
94
|
+
return { ...result, secretValues: secrets, approvedPlanDecisions: approved,
|
|
95
|
+
...(result.actor === undefined ? {} : { actor: actor(result.actor, secrets) }),
|
|
96
|
+
...(result.now === undefined ? {} : { now: instant(result.now) }),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function input(value, ctx, stored = false) {
|
|
100
|
+
const source = record(value, INPUT, stored ? INPUT : INPUT.filter(key => key !== 'approvedPlanDecisionId'));
|
|
101
|
+
if (!Number.isInteger(source.tier) || source.tier < 0 || source.tier > 3) fail();
|
|
102
|
+
const options = array(source.options, 8, 2).map(option => {
|
|
103
|
+
const candidate = record(option, ['id', 'description']);
|
|
104
|
+
return { id: id(candidate.id, ctx.secretValues), description: text(candidate.description, 1000, ctx.secretValues) };
|
|
105
|
+
});
|
|
106
|
+
if (new Set(options.map(option => option.id)).size !== options.length || !options.some(option => option.id === source.choice)) fail();
|
|
107
|
+
if (Object.hasOwn(source, 'approvedPlanDecisionId') && source.approvedPlanDecisionId !== null && typeof source.approvedPlanDecisionId !== 'string') fail();
|
|
108
|
+
const reference = source.approvedPlanDecisionId ?? null;
|
|
109
|
+
if (source.tier === 0) {
|
|
110
|
+
if (!reference || !ctx.approvedPlanDecisions.some(item => item.id === reference && item.choice === source.choice)) fail('plan-reference-required');
|
|
111
|
+
id(reference, ctx.secretValues);
|
|
112
|
+
} else if (reference !== null) fail();
|
|
113
|
+
return { id: id(source.id, ctx.secretValues), tier: source.tier, title: text(source.title, 160, ctx.secretValues), options,
|
|
114
|
+
choice: id(source.choice, ctx.secretValues), rationale: text(source.rationale, 4000, ctx.secretValues), approvedPlanDecisionId: reference };
|
|
115
|
+
}
|
|
116
|
+
function canonical(value) {
|
|
117
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
118
|
+
if (value !== null && typeof value === 'object') return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
|
|
119
|
+
return JSON.stringify(value);
|
|
120
|
+
}
|
|
121
|
+
function hash(value) { return createHash('sha256').update(canonical(value)).digest('hex'); }
|
|
122
|
+
function freeze(value) {
|
|
123
|
+
if (value && typeof value === 'object') { for (const child of Object.values(value)) freeze(child); Object.freeze(value); }
|
|
124
|
+
return value;
|
|
125
|
+
}
|
|
126
|
+
function sealed(value) { return freeze({ ...value, digest: hash(value) }); }
|
|
127
|
+
|
|
128
|
+
export function createDecision(value, suppliedContext) {
|
|
129
|
+
const ctx = context(suppliedContext, 'create');
|
|
130
|
+
const fields = input(value, ctx);
|
|
131
|
+
return sealed({ schemaVersion: 1, ...fields, runId: ctx.runId, requestDigest: ctx.requestDigest,
|
|
132
|
+
planDigest: ctx.planDigest, actor: ctx.actor, createdAt: ctx.now,
|
|
133
|
+
status: fields.tier < 2 ? 'recorded' : 'pending', approval: null });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function validateDecision(value, suppliedContext) {
|
|
137
|
+
const ctx = context(suppliedContext);
|
|
138
|
+
const source = record(value, RECORD);
|
|
139
|
+
if (source.schemaVersion !== 1) fail();
|
|
140
|
+
for (const key of BINDINGS) if (source[key] !== ctx[key]) fail('binding-mismatch');
|
|
141
|
+
const fields = input(Object.fromEntries(INPUT.map(key => [key, source[key]])), ctx, true);
|
|
142
|
+
if (!['recorded', 'pending', 'approved'].includes(source.status)
|
|
143
|
+
|| (fields.tier < 2 ? source.status !== 'recorded' : source.status === 'recorded')) fail();
|
|
144
|
+
const result = { schemaVersion: 1, ...fields, runId: ctx.runId, requestDigest: ctx.requestDigest,
|
|
145
|
+
planDigest: ctx.planDigest, actor: actor(source.actor, ctx.secretValues), createdAt: instant(source.createdAt),
|
|
146
|
+
status: source.status, approval: null };
|
|
147
|
+
if (source.status === 'approved') {
|
|
148
|
+
const approval = record(source.approval, ['actor', 'approvedAt', 'decisionDigest']);
|
|
149
|
+
const approvedBy = actor(approval.actor, ctx.secretValues);
|
|
150
|
+
if (approvedBy.kind !== 'human' || instant(approval.approvedAt) < result.createdAt) fail();
|
|
151
|
+
if (digest(approval.decisionDigest) !== hash({ ...result, status: 'pending' })) fail('digest-mismatch');
|
|
152
|
+
result.approval = { actor: approvedBy, approvedAt: approval.approvedAt, decisionDigest: approval.decisionDigest };
|
|
153
|
+
} else if (source.approval !== null) fail();
|
|
154
|
+
if (digest(source.digest) !== hash(result)) fail('digest-mismatch');
|
|
155
|
+
return sealed(result);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function approveDecision(value, suppliedContext) {
|
|
159
|
+
const ctx = context(suppliedContext, 'approve');
|
|
160
|
+
if (ctx.humanConfirmed !== true || ctx.actor.kind !== 'human') fail('human-confirmation-required');
|
|
161
|
+
const original = validateDecision(value, ctx);
|
|
162
|
+
if (original.status !== 'pending') fail('approval-conflict');
|
|
163
|
+
if (ctx.now < original.createdAt) fail();
|
|
164
|
+
const { digest: previousDigest, ...fields } = original;
|
|
165
|
+
return sealed({ ...fields, status: 'approved', approval: { actor: ctx.actor, approvedAt: ctx.now, decisionDigest: previousDigest } });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function markdown(value) {
|
|
169
|
+
// Encode Markdown punctuation and HTML to prevent links, mentions and injected structure.
|
|
170
|
+
return value.replace(/[&<>"'`*_{}[\]()#+.!|\\@~-]/g, character => `&#${character.codePointAt(0)};`);
|
|
171
|
+
}
|
|
172
|
+
export function renderDecisions(values, suppliedContext) {
|
|
173
|
+
const ctx = context(suppliedContext);
|
|
174
|
+
const decisions = array(values, 128).map(value => validateDecision(value, ctx));
|
|
175
|
+
if (new Set(decisions.map(item => item.id)).size !== decisions.length) fail();
|
|
176
|
+
const lines = ['## Decisions', '', 'Decision records do not grant execution, file access, merge, deployment, or other permissions.'];
|
|
177
|
+
if (!decisions.length) return `${lines.join('\n')}\n\nNo decisions recorded.\n`;
|
|
178
|
+
for (const decision of decisions) {
|
|
179
|
+
lines.push('', `### ${markdown(decision.title)}`, '', `Tier ${decision.tier} · ${decision.status}`,
|
|
180
|
+
`Decision: ${markdown(decision.id)}`, `Choice: ${markdown(decision.choice)}`, `Rationale: ${markdown(decision.rationale)}`,
|
|
181
|
+
`Recorded by: ${markdown(decision.actor.id)} (${decision.actor.kind}) at ${decision.createdAt}`, '', 'Options:');
|
|
182
|
+
for (const option of decision.options) lines.push(`- ${markdown(option.id)}: ${markdown(option.description)}`);
|
|
183
|
+
if (decision.approvedPlanDecisionId) lines.push('', `Approved plan reference: ${markdown(decision.approvedPlanDecisionId)}`);
|
|
184
|
+
if (decision.approval) lines.push('', `Confirmed by: ${markdown(decision.approval.actor.id)} at ${decision.approval.approvedAt}`);
|
|
185
|
+
}
|
|
186
|
+
return `${lines.join('\n')}\n`;
|
|
187
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import {createHash} from 'node:crypto';
|
|
2
|
+
import {isDeepStrictEqual} from 'node:util';
|
|
3
|
+
import {immutableJson,containsSecretMaterial} from '../clients/contract.js';
|
|
4
|
+
import {loadProjectConfig,parseYaml} from '../config/load.js';
|
|
5
|
+
import {createSnapshotStore,readSnapshotWithoutLock} from '../state/snapshot-store.js';
|
|
6
|
+
import {governancePaths,resolveExistingFeatureRunPaths,acceptedIntegrationPaths} from '../state/paths.js';
|
|
7
|
+
import {createFeatureRunStore} from './run-store.js';
|
|
8
|
+
import {createAcceptedIntegrationStore} from './accepted-integration.js';
|
|
9
|
+
import {acquireHostRunLock} from './host-run-lock.js';
|
|
10
|
+
import {createDecision,validateDecision,approveDecision,renderDecisions} from './decisions.js';
|
|
11
|
+
import {normalizeReviewPolicy,evaluateReviewReports} from '../quality/content-review.js';
|
|
12
|
+
import {scanChangedContent,validateContentScanPolicy} from '../quality/content-scan.js';
|
|
13
|
+
|
|
14
|
+
export class GovernanceError extends Error {
|
|
15
|
+
constructor(message='Task decision or review evidence is invalid or stale.') {
|
|
16
|
+
super(message);this.name='GovernanceError';this.code='ERR_TASK_GOVERNANCE';this.safeMessage=message;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const fail=message=>{throw new GovernanceError(message);};
|
|
20
|
+
const digest=value=>createHash('sha256').update(typeof value==='string'?value:JSON.stringify(value)).digest('hex');
|
|
21
|
+
const binding=run=>({runId:run.runId,requestDigest:run.workRequest.digest,planDigest:run.proposalDigest});
|
|
22
|
+
const sameBinding=(left,right)=>Object.entries(right).every(([key,value])=>left[key]===value);
|
|
23
|
+
const approved=decisions=>decisions.filter(value=>value.status==='approved').map(({id,choice})=>({id,choice}));
|
|
24
|
+
|
|
25
|
+
async function journal(paths,run) {
|
|
26
|
+
const selected=await governancePaths(paths), snapshot=await readSnapshotWithoutLock(selected);
|
|
27
|
+
const events=snapshot?.data?.events??[];
|
|
28
|
+
if(snapshot&&(snapshot.data.schemaVersion!==1||!sameBinding(snapshot.data,binding(run))
|
|
29
|
+
||!Array.isArray(events)||events.length>1000||Buffer.byteLength(JSON.stringify(snapshot.data))>1024*1024))fail();
|
|
30
|
+
const decisions=new Map(),reviews=[];
|
|
31
|
+
for(const event of events){
|
|
32
|
+
if(!event||!['decision.recorded','decision.approved','review.recorded','scan.recorded'].includes(event.type))fail();
|
|
33
|
+
if(event.type.startsWith('decision.')){
|
|
34
|
+
const context={...binding(run),approvedPlanDecisions:approved([...decisions.values()])};
|
|
35
|
+
const value=validateDecision(event.data,context),prior=decisions.get(value.id);
|
|
36
|
+
if(event.type==='decision.recorded' ? prior!==undefined : !prior||prior.status!=='pending'||value.status!=='approved')fail();
|
|
37
|
+
if(prior&&value.approval?.decisionDigest!==prior.digest)fail();
|
|
38
|
+
decisions.set(value.id,value);
|
|
39
|
+
}else if(event.type==='review.recorded'){
|
|
40
|
+
if(!sameBinding(event.subject,binding(run)))fail();
|
|
41
|
+
evaluateReviewReports(event.policy,event.subject,[event.data]);reviews.push(event);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return {paths:selected,version:snapshot?.version??0,events,decisions:[...decisions.values()],reviews};
|
|
45
|
+
}
|
|
46
|
+
async function append(state,run,event){
|
|
47
|
+
const data={schemaVersion:1,...binding(run),events:[...state.events,event]};
|
|
48
|
+
if(data.events.length>1000||Buffer.byteLength(JSON.stringify(data))>1024*1024)fail('Task evidence storage limit reached. Start a new bounded task.');
|
|
49
|
+
await createSnapshotStore(state.paths,{environment:{}}).write(data,{expectedVersion:state.version});
|
|
50
|
+
}
|
|
51
|
+
async function inputs(project,runId){
|
|
52
|
+
const paths=await resolveExistingFeatureRunPaths(project,runId);if(!paths)fail('Task was not found.');
|
|
53
|
+
const run=await createFeatureRunStore(paths).readOnly();if(!run)fail();
|
|
54
|
+
return {paths,run};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function assertGovernancePolicy({project,baselineCommit,gitClient,config}) {
|
|
58
|
+
const baselineQuality=await gitClient.readCommittedFile(project,baselineCommit,'.rivet/quality.yaml');
|
|
59
|
+
if(!baselineQuality){
|
|
60
|
+
if(config.quality.review!==undefined||config.quality.contentScan!==undefined)
|
|
61
|
+
fail('Commit the quality policy before proposing a task with review or content scanning.');
|
|
62
|
+
return; // Preserve existing local-only configuration when governance is not enabled.
|
|
63
|
+
}
|
|
64
|
+
if(!isDeepStrictEqual(parseYaml(baselineQuality.content,'quality.yaml'),config.quality))
|
|
65
|
+
fail('Quality policy changed since the task was proposed. Restore the approved policy or propose a new task.');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function inspectGovernance({project,run,gitClient,config,phase,checkout,forceReview=false}) {
|
|
69
|
+
const paths=await resolveExistingFeatureRunPaths(project,run.runId);if(!paths)fail();
|
|
70
|
+
const state=await journal(paths,run);
|
|
71
|
+
const configured=config??await loadProjectConfig(project);
|
|
72
|
+
await assertGovernancePolicy({project,baselineCommit:run.featurePlan.baselineCommit,gitClient,config:configured});
|
|
73
|
+
const policy=normalizeReviewPolicy(forceReview?{...configured.quality.review,required:true}:configured.quality.review);
|
|
74
|
+
const selectedPhase=phase??(run.status==='proposed'?'plan':'final');
|
|
75
|
+
if(!['plan','final'].includes(selectedPhase))fail();
|
|
76
|
+
let target=checkout;
|
|
77
|
+
if(selectedPhase==='final'&&!target){
|
|
78
|
+
const accepted=await createAcceptedIntegrationStore(await acceptedIntegrationPaths(paths)).readOnly();
|
|
79
|
+
if(accepted)target={path:accepted.path,commitSha:accepted.commitSha,branch:accepted.branch};
|
|
80
|
+
}
|
|
81
|
+
const baseSha=run.featurePlan.baselineCommit;
|
|
82
|
+
let headSha=baseSha,patch='',changedPaths=[];
|
|
83
|
+
if(target){
|
|
84
|
+
const identity=await gitClient.inspectRepository(target.path);
|
|
85
|
+
if(identity.dirty||identity.headSha!==target.commitSha||identity.branch!==target.branch)fail('Review checkout changed. Inspect task status before continuing.');
|
|
86
|
+
headSha=target.commitSha;
|
|
87
|
+
changedPaths=await gitClient.changedPaths(target.path,baseSha,headSha);
|
|
88
|
+
if(policy.required||state.reviews.length)patch=(await gitClient.reviewDiff(target.path,{fromSha:baseSha,toSha:headSha})).patch;
|
|
89
|
+
}
|
|
90
|
+
const subject={phase:selectedPhase,...binding(run),baseSha,headSha,
|
|
91
|
+
diffDigest:digest(selectedPhase==='plan'?run.featurePlan:patch),
|
|
92
|
+
acceptanceCriteria:run.workRequest.acceptanceCriteria,
|
|
93
|
+
planNodeIds:run.featurePlan.nodes.filter(node=>node.role==='worker').map(node=>node.id),
|
|
94
|
+
criterionPlanNodeIds:run.workRequest.acceptanceCriteria.map(criterion=>run.featurePlan.nodes.filter(node=>node.role==='worker'&&node.acceptanceCriteria.includes(criterion)).map(node=>node.id)),changedPaths,
|
|
95
|
+
workerActorIds:['host-agent',...run.featurePlan.nodes.filter(node=>node.role==='worker').map(node=>node.id)]};
|
|
96
|
+
// Historical reports remain auditable; only reports for these exact inputs can satisfy a gate.
|
|
97
|
+
const phaseReviews=state.reviews.filter(event=>event.subject.phase===selectedPhase);
|
|
98
|
+
const reports=phaseReviews.filter(event=>event.subject.headSha===headSha
|
|
99
|
+
&&event.subject.diffDigest===subject.diffDigest).map(event=>event.data);
|
|
100
|
+
const evaluated=evaluateReviewReports(policy,subject,reports);
|
|
101
|
+
// Corrections consume the task phase's budget even when their new inputs invalidate old evidence.
|
|
102
|
+
const rounds=Math.max(0,...phaseReviews.map(event=>event.data.round));
|
|
103
|
+
const otherInputRound=Math.max(0,...phaseReviews.filter(event=>event.subject.headSha!==headSha
|
|
104
|
+
||event.subject.diffDigest!==subject.diffDigest).map(event=>event.data.round));
|
|
105
|
+
const pendingAtCap=otherInputRound<policy.maxRounds?evaluated.requiredReviewerIds.filter(reviewerId=>
|
|
106
|
+
!reports.some(report=>report.reviewerId===reviewerId&&report.round===policy.maxRounds)):[];
|
|
107
|
+
const repairableAtCap=blocker=>['missing-review','blocking-review'].includes(blocker.code)
|
|
108
|
+
?pendingAtCap.includes(blocker.reviewerId):blocker.code==='unreviewed-path'
|
|
109
|
+
&&pendingAtCap.some(reviewerId=>evaluated.reviewerPaths[reviewerId].includes(blocker.path));
|
|
110
|
+
const review={...evaluated,rounds,humanEscalation:!evaluated.valid&&rounds>=policy.maxRounds
|
|
111
|
+
&&!evaluated.blockers.every(repairableAtCap)};
|
|
112
|
+
const blockers=[...review.blockers];
|
|
113
|
+
if(selectedPhase==='final'&&!target&&policy.required)blockers.push({code:'integration-not-ready'});
|
|
114
|
+
for(const decision of state.decisions)if(decision.status==='pending')blockers.push({code:'decision-needs-human-approval',decisionId:decision.id});
|
|
115
|
+
let scan=null;
|
|
116
|
+
if(selectedPhase==='final'&&target&&configured.quality.contentScan!==undefined){
|
|
117
|
+
const scanPolicy=validateContentScanPolicy(configured.quality.contentScan);
|
|
118
|
+
if(changedPaths.length>scanPolicy.maxFiles)fail('Content scan file limit exceeded. Split the task or review its scan policy.');
|
|
119
|
+
const files=[];let bytes=0;
|
|
120
|
+
for(const path of changedPaths){
|
|
121
|
+
const file=await gitClient.readCommittedFile(target.path,headSha,path);
|
|
122
|
+
if(file){
|
|
123
|
+
bytes+=Buffer.byteLength(file.content);
|
|
124
|
+
if(bytes>scanPolicy.maxBytes)fail('Content scan byte limit exceeded. Split the task or review its scan policy.');
|
|
125
|
+
files.push({path,content:file.content});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
scan=scanChangedContent({files,policy:configured.quality.contentScan});
|
|
129
|
+
if(scan.status!=='passed')blockers.push({code:'content-scan-failed'});
|
|
130
|
+
}
|
|
131
|
+
return immutableJson({subject,policy,decisions:state.decisions,decisionMarkdown:renderDecisions(state.decisions,{...binding(run),approvedPlanDecisions:approved(state.decisions)}),
|
|
132
|
+
review,scan,blockers,ready:blockers.length===0,patch,
|
|
133
|
+
nextAction:blockers.length?'Use rivet task decisions and rivet task review to inspect required evidence. Human approval never replaces failed required checks.':'Decision and content review requirements are satisfied.'});
|
|
134
|
+
}
|
|
135
|
+
export async function requireGovernance(input){
|
|
136
|
+
const result=await inspectGovernance(input);
|
|
137
|
+
if(result.scan){
|
|
138
|
+
const {paths}=await inputs(input.project,input.run.runId),state=await journal(paths,input.run);
|
|
139
|
+
const identity=digest({subject:result.subject,scan:result.scan});
|
|
140
|
+
if(!state.events.some(event=>event.type==='scan.recorded'&&event.digest===identity))
|
|
141
|
+
await append(state,input.run,{type:'scan.recorded',digest:identity,subject:result.subject,data:result.scan,checkedAt:new Date().toISOString()});
|
|
142
|
+
}
|
|
143
|
+
if(!result.ready)fail(`${result.nextAction} Blocking: ${result.blockers.map(item=>item.code).join(', ')}.`);
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function taskGovernance({project,runId,gitClient,operation,input,phase,decisionId,confirm,signal,now=()=>new Date().toISOString()}){
|
|
148
|
+
const {paths}=await inputs(project,runId),lock=await acquireHostRunLock(paths);
|
|
149
|
+
try{
|
|
150
|
+
const run=await createFeatureRunStore(paths).readOnly(),state=await journal(paths,run);
|
|
151
|
+
if(['decide','approve-decision','review-submit'].includes(operation)&&['completed','cancelled'].includes(run.status))fail('Completed tasks cannot accept new decision or review evidence.');
|
|
152
|
+
const context={...binding(run),approvedPlanDecisions:approved(state.decisions)};
|
|
153
|
+
if(operation==='decisions')return {decisions:state.decisions,markdown:renderDecisions(state.decisions,context)};
|
|
154
|
+
signal?.throwIfAborted();
|
|
155
|
+
if(operation==='decide'){
|
|
156
|
+
if(state.decisions.length>=128)fail('Decision journal limit reached. Start a new bounded task.');
|
|
157
|
+
const record=createDecision(input,{...context,actor:{kind:'agent',id:'host-agent'},now:now()});
|
|
158
|
+
if(state.decisions.some(value=>value.id===record.id))fail('Decision ID already exists. Record a new decision that explains the change.');
|
|
159
|
+
await append(state,run,{type:'decision.recorded',data:record});return record;
|
|
160
|
+
}
|
|
161
|
+
if(operation==='approve-decision'){
|
|
162
|
+
const record=state.decisions.find(value=>value.id===decisionId);if(!record)fail('Decision was not found.');
|
|
163
|
+
if(typeof confirm!=='function'||await confirm(record)!==true)return {status:'declined'};
|
|
164
|
+
signal?.throwIfAborted();
|
|
165
|
+
const updated=approveDecision(record,{...context,actor:{kind:'human',id:'human-cli-operator'},now:now(),humanConfirmed:true});
|
|
166
|
+
await append(state,run,{type:'decision.approved',data:updated});return updated;
|
|
167
|
+
}
|
|
168
|
+
if(operation==='review'||operation==='review-submit'){
|
|
169
|
+
const config=await loadProjectConfig(project);
|
|
170
|
+
// Explicit review exports always include the actual diff, even for optional policies.
|
|
171
|
+
const observed=await inspectGovernance({project,run,gitClient,config,phase,forceReview:true});
|
|
172
|
+
if(operation==='review'){
|
|
173
|
+
const keys=['phase','runId','requestDigest','planDigest','baseSha','headSha','diffDigest'];
|
|
174
|
+
const identity=Object.fromEntries(keys.map(key=>[key,observed.subject[key]]));
|
|
175
|
+
const atCap=observed.review.rounds>=observed.policy.maxRounds;
|
|
176
|
+
const otherInputRound=Math.max(0,...state.reviews.filter(event=>event.subject.phase===observed.subject.phase
|
|
177
|
+
&&(event.subject.headSha!==observed.subject.headSha||event.subject.diffDigest!==observed.subject.diffDigest)).map(event=>event.data.round));
|
|
178
|
+
const templateReviewers=atCap&&observed.review.valid?[]:atCap?observed.review.requiredReviewerIds.filter(reviewerId=>
|
|
179
|
+
otherInputRound<observed.policy.maxRounds&&!observed.review.reports.some(report=>report.reviewerId===reviewerId&&report.round===observed.policy.maxRounds))
|
|
180
|
+
:observed.review.requiredReviewerIds;
|
|
181
|
+
const reportTemplates=templateReviewers.map(reviewerId=>({
|
|
182
|
+
...identity,reviewerId,actorId:'replace-with-reviewer-identity',round:atCap?observed.policy.maxRounds:observed.review.rounds+1,
|
|
183
|
+
status:'FAIL',blocking:true,coverage:[],findings:[{kind:'general',summary:'Review has not been performed yet.',blocking:true}],
|
|
184
|
+
filesReviewed:[],commandsExecuted:[],checkedAt:now(),
|
|
185
|
+
}));
|
|
186
|
+
return {...observed,request:run.workRequest,plan:run.featurePlan,reportTemplates,
|
|
187
|
+
reportInstructions:'Complete the review before submitting. For each acceptance criterion provide its 1-based criterionIndex, assigned planNodeIds, reviewed paths and evidence [{kind:test|manual,reference,summary}]. Record missing or unrequested work as blocking findings. PASS requires complete coverage and no blocking findings. Templates intentionally do not claim a passing review.'};
|
|
188
|
+
}
|
|
189
|
+
const report=evaluateReviewReports(observed.policy,observed.subject,[input]).reports[0];
|
|
190
|
+
if(containsSecretMaterial(JSON.stringify(report)))fail('Review evidence contains credential-like material. Remove it before recording the report.');
|
|
191
|
+
const phaseReviews=state.reviews.filter(event=>event.subject.phase===observed.subject.phase);
|
|
192
|
+
const matchesCurrent=event=>event.subject.headSha===observed.subject.headSha&&event.subject.diffDigest===observed.subject.diffDigest;
|
|
193
|
+
const prior=phaseReviews.filter(matchesCurrent).map(event=>event.data);
|
|
194
|
+
const previousInputRound=Math.max(0,...phaseReviews.filter(event=>!matchesCurrent(event)).map(event=>event.data.round));
|
|
195
|
+
if(report.round<=previousInputRound)fail('A revised review subject must advance the phase review round.');
|
|
196
|
+
if(prior.some(value=>value.reviewerId===report.reviewerId&&value.round===report.round))fail('This reviewer round is already recorded.');
|
|
197
|
+
evaluateReviewReports(observed.policy,observed.subject,[...prior,report]);
|
|
198
|
+
await append(state,run,{type:'review.recorded',subject:observed.subject,policy:observed.policy,data:report});
|
|
199
|
+
return {status:'recorded',reviewerId:report.reviewerId,round:report.round};
|
|
200
|
+
}
|
|
201
|
+
fail('Unknown task evidence operation.');
|
|
202
|
+
}finally{await lock.release();}
|
|
203
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { inspectGovernance, requireGovernance } from './governance.js';
|
|
1
2
|
import {assertSelectedProtocolRefs, selectedProtocolStatus} from '../protocols/project.js';
|
|
2
3
|
import {createProtocolPresentation, protocolLookupContext} from '../protocols/presentation.js';
|
|
3
4
|
import { lstat, realpath } from 'node:fs/promises';
|
|
@@ -260,6 +261,7 @@ export function createHostExecution(input) {
|
|
|
260
261
|
const value = request(inputValue, 'expectedRunVersion');
|
|
261
262
|
const { store, run } = await runRecord(value.project, value.runId);
|
|
262
263
|
if (!['approved', 'running'].includes(run.status) || run.version !== value.expectedRunVersion) fail('state-conflict');
|
|
264
|
+
await requireGovernance({project:value.project,run,gitClient,phase:'plan'});
|
|
263
265
|
const preparedContext = await runtimeContext(value.project, run);
|
|
264
266
|
const running = run.status === 'running' ? run : await store.update({
|
|
265
267
|
status: 'running',
|
|
@@ -299,6 +301,7 @@ export function createHostExecution(input) {
|
|
|
299
301
|
try {
|
|
300
302
|
const { run } = await runRecord(value.project, value.runId);
|
|
301
303
|
if (run.status !== 'running') fail('state-conflict');
|
|
304
|
+
await requireGovernance({project:value.project,run,gitClient,phase:'plan'});
|
|
302
305
|
const { instance, runtime } = await runtimeContext(value.project, run);
|
|
303
306
|
const prepared = await runtime.prepareAction(instance, { expectedVersion: value.expectedRuntimeVersion });
|
|
304
307
|
if (prepared.action === null) {
|
|
@@ -450,6 +453,7 @@ export function createHostExecution(input) {
|
|
|
450
453
|
gates: await configuredFeatureGates(config, resolveCommandExecutable, integration.path),
|
|
451
454
|
environment,
|
|
452
455
|
}, { gitClient, now: () => featureNowMilliseconds(now) });
|
|
456
|
+
if(quality.status==='pass') await requireGovernance({project:value.project,run,gitClient,config,phase:'final',checkout:{path:integration.path,branch:integration.branch,commitSha:integrated.headSha}});
|
|
453
457
|
} catch (error) {
|
|
454
458
|
failure = typeof error?.safeMessage === 'string' ? error.safeMessage : 'Verification could not complete safely.';
|
|
455
459
|
}
|
|
@@ -462,7 +466,7 @@ export function createHostExecution(input) {
|
|
|
462
466
|
run, state, integration, commitSha: integrated.headSha, changedPaths,
|
|
463
467
|
quality, failure, checkedAt: now(),
|
|
464
468
|
}));
|
|
465
|
-
if (quality?.status !== 'pass') fail('verification-failed');
|
|
469
|
+
if (failure || quality?.status !== 'pass') fail('verification-failed');
|
|
466
470
|
const beforeFinal = await gitClient.inspectRepository(integration.path);
|
|
467
471
|
if (beforeFinal.dirty || beforeFinal.branch !== integration.branch
|
|
468
472
|
|| beforeFinal.headSha !== accepted.commitSha) fail('repository');
|
|
@@ -592,7 +596,10 @@ export function createHostExecution(input) {
|
|
|
592
596
|
const verificationStale = verification !== null && accepted !== null
|
|
593
597
|
&& (verification.commitSha !== accepted.commitSha || checkout?.status !== 'clean');
|
|
594
598
|
const protocols = selectedProtocolStatus(value.project, run.workRequest.contextRefs);
|
|
595
|
-
|
|
599
|
+
let governance;
|
|
600
|
+
try { governance = await inspectGovernance({project:value.project,run,gitClient,phase:readyForVerification?'final':'plan'}); }
|
|
601
|
+
catch (error) { governance = {ready:false,decisions:[],review:{humanEscalation:false},blockers:[{code:'governance-unavailable'}],nextAction:error.safeMessage ?? 'Decision or review evidence could not be read safely. Inspect private task state before continuing.'}; }
|
|
602
|
+
const deliverable = governance.ready && protocols.status === 'current' && run.status === 'awaiting-final-approval'
|
|
596
603
|
&& runtime !== null && readyForVerification
|
|
597
604
|
&& accepted !== null && checkout?.status === 'clean'
|
|
598
605
|
&& verification?.status === 'pass'
|
|
@@ -603,6 +610,7 @@ export function createHostExecution(input) {
|
|
|
603
610
|
&& verification.integration.branch === accepted.branch;
|
|
604
611
|
const nextAction = protocols.status === 'changed' ? protocols.message : checkout?.status === 'stale' || verificationStale
|
|
605
612
|
? 'The integration checkout no longer matches the accepted tested commit. Do not deliver it; create a new reviewed proposal for source changes.'
|
|
613
|
+
: !governance.ready ? governance.nextAction
|
|
606
614
|
: run.status === 'awaiting-final-approval' && !deliverable
|
|
607
615
|
? 'Final approval evidence is missing or inconsistent. Do not deliver this run; inspect private state and create a new reviewed proposal if it cannot be restored.'
|
|
608
616
|
: deliverable
|
|
@@ -626,7 +634,7 @@ export function createHostExecution(input) {
|
|
|
626
634
|
? 'Use work prepare with run.version to create the isolated execution state.'
|
|
627
635
|
: 'Use rivet task resume to continue the approved spawned task.'
|
|
628
636
|
: 'Review the proposal and current run state before proceeding.';
|
|
629
|
-
return immutableJson({ run, runtime, protocols, verification, checkout, workerCheckouts, deliveryReady: deliverable, blockedNodes, nextAction });
|
|
637
|
+
return immutableJson({ run, runtime, protocols, governance, verification, checkout, workerCheckouts, deliveryReady: deliverable, blockedNodes, nextAction });
|
|
630
638
|
}
|
|
631
639
|
|
|
632
640
|
return Object.freeze({ prepare, nextAction, submitResult, verify, status, recover });
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { requireGovernance } from './governance.js';
|
|
1
2
|
import {assertSelectedProtocolRefs, selectedProtocolStatus} from '../protocols/project.js';
|
|
2
3
|
import {createProtocolPresentation} from '../protocols/presentation.js';
|
|
3
4
|
import { createHash } from 'node:crypto';
|
|
@@ -517,6 +518,7 @@ export function createFeatureExecutor(input) {
|
|
|
517
518
|
if (repository.root !== project || repository.detached || repository.dirty
|
|
518
519
|
|| repository.branch !== config.project.repository.defaultBranch
|
|
519
520
|
|| repository.headSha !== run.featurePlan.baselineCommit) fail();
|
|
521
|
+
await requireGovernance({project,run,gitClient:configured.gitClient,config,phase:'plan'});
|
|
520
522
|
// Only select adapters after the saved approval and current role configuration
|
|
521
523
|
// are validated. Probe before creating integration or worker checkouts.
|
|
522
524
|
const selectedClients = new Map();
|
|
@@ -743,6 +745,7 @@ export function createFeatureExecutor(input) {
|
|
|
743
745
|
}
|
|
744
746
|
const maximumTicks = run.featurePlan.nodes.length * 4 + 8;
|
|
745
747
|
for (let tick = 0; tick < maximumTicks; tick += 1) {
|
|
748
|
+
await requireGovernance({project,run,gitClient:configured.gitClient,phase:'plan'});
|
|
746
749
|
const outcome = await runtime.tick(instance, { expectedVersion: state.version, maxActiveNodes: 1, signal });
|
|
747
750
|
state = await inspectFeatureRuntime(instance);
|
|
748
751
|
if (['blocked', 'failed', 'budget-exhausted', 'cancelled'].includes(outcome.terminal)) {
|
|
@@ -803,6 +806,7 @@ export function createFeatureExecutor(input) {
|
|
|
803
806
|
gates: await configuredFeatureGates(config, configured.resolveCommandExecutable, integration.path),
|
|
804
807
|
environment: configured.environment,
|
|
805
808
|
}, { gitClient: configured.gitClient, now: nowMs, signal });
|
|
809
|
+
if(quality.status==='pass') await requireGovernance({project,run,gitClient:configured.gitClient,config,phase:'final',checkout:{path:integration.path,branch:integration.branch,commitSha:identity.commitSha}});
|
|
806
810
|
} catch (error) {
|
|
807
811
|
failure = typeof error?.safeMessage === 'string' ? error.safeMessage : 'Configured quality gates could not complete safely.';
|
|
808
812
|
}
|
package/src/feature/workflow.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertGovernancePolicy, requireGovernance } from './governance.js';
|
|
1
2
|
import {assertSelectedProtocolRefs, selectedProtocolStatus} from '../protocols/project.js';
|
|
2
3
|
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
3
4
|
|
|
@@ -212,6 +213,8 @@ export function createFeatureWorkflow(input) {
|
|
|
212
213
|
new Set(['project', 'source']),
|
|
213
214
|
);
|
|
214
215
|
const { observed, config } = await repository(request.project);
|
|
216
|
+
if(config.quality.review!==undefined||config.quality.contentScan!==undefined)
|
|
217
|
+
await assertGovernancePolicy({project:request.project,baselineCommit:observed.headSha,gitClient,config});
|
|
215
218
|
const selectedClient = request.client ?? 'claude';
|
|
216
219
|
if (!CLIENTS.has(selectedClient)) fail('invalid-input');
|
|
217
220
|
const source = capture(request.source, new Set(['kind', 'value']));
|
|
@@ -286,13 +289,14 @@ export function createFeatureWorkflow(input) {
|
|
|
286
289
|
baselineCommit: record.featurePlan.baselineCommit, client: record.featurePlan.client });
|
|
287
290
|
}
|
|
288
291
|
|
|
289
|
-
async function
|
|
292
|
+
async function startLocked(raw) {
|
|
290
293
|
const request = capture(raw, new Set(['project', 'runId', 'expectedVersion', 'proposalDigest']));
|
|
291
294
|
const { store, record } = await readRun(request.project, request.runId);
|
|
292
295
|
if (record.status !== 'proposed' || record.version !== expectedVersion(request.expectedVersion)) fail('state-conflict');
|
|
293
296
|
if (typeof request.proposalDigest !== 'string' || !DIGEST.test(request.proposalDigest)
|
|
294
297
|
|| request.proposalDigest !== record.proposalDigest) fail('proposal-mismatch');
|
|
295
298
|
await validateSavedPlan(request.project, record);
|
|
299
|
+
await requireGovernance({project:request.project,run:record,gitClient,config:await loadConfig(request.project),phase:'plan'});
|
|
296
300
|
const at = now();
|
|
297
301
|
const updated = await store.update({
|
|
298
302
|
status: 'approved', updatedAt: at,
|
|
@@ -305,6 +309,13 @@ export function createFeatureWorkflow(input) {
|
|
|
305
309
|
return lifecycleView(updated);
|
|
306
310
|
}
|
|
307
311
|
|
|
312
|
+
async function start(raw) {
|
|
313
|
+
const request = capture(raw, new Set(['project', 'runId', 'expectedVersion', 'proposalDigest']));
|
|
314
|
+
const paths = await resolveFeatureRunPaths(absolute(request.project), exactRunId(request.runId));
|
|
315
|
+
const lock = await acquireHostRunLock(paths);
|
|
316
|
+
try { return await startLocked(raw); } finally { await lock.release(); }
|
|
317
|
+
}
|
|
318
|
+
|
|
308
319
|
async function status(raw) {
|
|
309
320
|
const request = capture(raw, new Set(['project', 'runId']));
|
|
310
321
|
const record = (await readRun(request.project, request.runId)).record;
|
package/src/git/client.js
CHANGED
|
@@ -425,6 +425,18 @@ export async function createGitClient(options = {}) {
|
|
|
425
425
|
}
|
|
426
426
|
},
|
|
427
427
|
|
|
428
|
+
async readCommittedFile(cwd, commit, path) {
|
|
429
|
+
if (!validSha(commit)) fail('invalid-input');
|
|
430
|
+
repositoryPath(path);
|
|
431
|
+
const tree = await run(absoluteInput(cwd), ['ls-tree', '-z', commit, '--', path]);
|
|
432
|
+
if (!tree.output) return null; // Deleted paths have no blob in the target tree.
|
|
433
|
+
const match = /^(100644|100755) blob ([0-9a-f]{40}|[0-9a-f]{64})\t([^\0]+)\0$/.exec(tree.output);
|
|
434
|
+
if (!match || match[3] !== path) fail('repository-path-unsafe');
|
|
435
|
+
const result = await run(absoluteInput(cwd), ['cat-file', 'blob', match[2]], [0], 1024 * 1024);
|
|
436
|
+
if (result.output.includes('\0')) fail('git-output-invalid');
|
|
437
|
+
return Object.freeze({path, blob:match[2], content:result.output});
|
|
438
|
+
},
|
|
439
|
+
|
|
428
440
|
async inspectTrackedFile(cwd, commit, path) {
|
|
429
441
|
if (!validSha(commit)) fail('invalid-input');
|
|
430
442
|
repositoryPath(path);
|