@6reduk/workspace-pipeline 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +78 -0
- package/docs/collaboration.md +59 -0
- package/docs/config-fields.md +65 -0
- package/docs/contracts.md +149 -0
- package/docs/doctor.md +165 -0
- package/docs/launch.md +50 -0
- package/docs/lifecycle-cli.md +141 -0
- package/docs/lifecycle.md +42 -0
- package/docs/migrations/unity.md +288 -0
- package/docs/native-provider-format.md +149 -0
- package/docs/provider-bundles.md +81 -0
- package/docs/provider-resources.md +37 -0
- package/docs/release.md +25 -0
- package/docs/remove.md +70 -0
- package/docs/repair.md +80 -0
- package/docs/repositories.md +209 -0
- package/docs/repository-manual-recovery.md +102 -0
- package/docs/repository-observations.md +29 -0
- package/docs/repository-recovery.md +204 -0
- package/docs/repository-retention.md +46 -0
- package/docs/repository-transport-budgets.md +26 -0
- package/docs/retention.md +237 -0
- package/docs/source.md +50 -0
- package/docs/switch.md +412 -0
- package/package.json +39 -0
- package/schemas/common.schema.json +251 -0
- package/schemas/inventory.schema.json +15 -0
- package/schemas/operation.schema.json +286 -0
- package/schemas/pipeline.schema.json +317 -0
- package/schemas/state.schema.json +302 -0
- package/schemas/workspace.schema.json +67 -0
- package/src/cli.js +7 -0
- package/src/commands/adopt.js +2 -0
- package/src/commands/bootstrap-recovery.js +90 -0
- package/src/commands/dispatch.js +394 -0
- package/src/commands/init.js +84 -0
- package/src/commands/launch.js +69 -0
- package/src/commands/migration-apply.js +53 -0
- package/src/commands/migration.js +50 -0
- package/src/commands/repositories.js +61 -0
- package/src/commands/repository-abandon.js +16 -0
- package/src/commands/repository-ancestors.js +21 -0
- package/src/commands/repository-locks.js +77 -0
- package/src/contracts/parse.js +57 -0
- package/src/contracts/semantic.js +240 -0
- package/src/contracts/validate.js +21 -0
- package/src/launch/grok.js +20 -0
- package/src/migrations/legacy-unity-begin.js +35 -0
- package/src/migrations/legacy-unity-compensate.js +82 -0
- package/src/migrations/legacy-unity-deactivate.js +60 -0
- package/src/migrations/legacy-unity-deactivation-resume-apply.js +62 -0
- package/src/migrations/legacy-unity-deactivation-resume.js +81 -0
- package/src/migrations/legacy-unity-finalize.js +88 -0
- package/src/migrations/legacy-unity-install-recovery.js +76 -0
- package/src/migrations/legacy-unity-install-resume.js +60 -0
- package/src/migrations/legacy-unity-install.js +88 -0
- package/src/migrations/legacy-unity-lease.js +136 -0
- package/src/migrations/legacy-unity-preflight.js +67 -0
- package/src/migrations/legacy-unity-preview.js +91 -0
- package/src/migrations/legacy-unity-resume-apply.js +39 -0
- package/src/migrations/legacy-unity-resume.js +41 -0
- package/src/migrations/legacy-unity-resumed-evidence.js +88 -0
- package/src/migrations/legacy-unity.js +99 -0
- package/src/operations/apply.js +576 -0
- package/src/operations/backup.js +94 -0
- package/src/operations/bootstrap-lock.js +87 -0
- package/src/operations/bootstrap-owner-retirement.js +121 -0
- package/src/operations/bundle-update.js +54 -0
- package/src/operations/config-fields.js +24 -0
- package/src/operations/continuation-lifecycle.js +77 -0
- package/src/operations/doctor.js +214 -0
- package/src/operations/history.js +170 -0
- package/src/operations/installer-identity.js +49 -0
- package/src/operations/journal.js +142 -0
- package/src/operations/lifecycle.js +133 -0
- package/src/operations/lineage-guard.js +20 -0
- package/src/operations/lock.js +109 -0
- package/src/operations/maintenance.js +62 -0
- package/src/operations/migration-pending.js +22 -0
- package/src/operations/ownership.js +122 -0
- package/src/operations/plan.js +278 -0
- package/src/operations/reconciliation.js +112 -0
- package/src/operations/recovery-lease.js +66 -0
- package/src/operations/remove.js +140 -0
- package/src/operations/repair.js +188 -0
- package/src/operations/repository-abandon.js +192 -0
- package/src/operations/repository-ancestors.js +158 -0
- package/src/operations/repository-apply.js +122 -0
- package/src/operations/repository-authorization.js +48 -0
- package/src/operations/repository-bootstrap-continuation.js +190 -0
- package/src/operations/repository-bootstrap-reconcile.js +106 -0
- package/src/operations/repository-bootstrap-recover.js +114 -0
- package/src/operations/repository-bootstrap.js +82 -0
- package/src/operations/repository-clone.js +63 -0
- package/src/operations/repository-history.js +108 -0
- package/src/operations/repository-inputs.js +41 -0
- package/src/operations/repository-journal.js +127 -0
- package/src/operations/repository-lock-reconcile.js +401 -0
- package/src/operations/repository-pending.js +29 -0
- package/src/operations/repository-reconcile.js +182 -0
- package/src/operations/repository-resumption-approvals.js +77 -0
- package/src/operations/repository-retention-apply.js +75 -0
- package/src/operations/repository-retention.js +137 -0
- package/src/operations/repository-workspace.js +78 -0
- package/src/operations/retention-apply.js +133 -0
- package/src/operations/retention-combined-scan.js +30 -0
- package/src/operations/retention-combined.js +41 -0
- package/src/operations/retention-policy.js +65 -0
- package/src/operations/retention-receipts.js +126 -0
- package/src/operations/retention-scan.js +86 -0
- package/src/operations/retention.js +56 -0
- package/src/operations/state.js +210 -0
- package/src/operations/switch-activate.js +64 -0
- package/src/operations/switch-backups.js +29 -0
- package/src/operations/switch-continuation-journal.js +94 -0
- package/src/operations/switch-continuation-pending.js +60 -0
- package/src/operations/switch-continuation-records.js +109 -0
- package/src/operations/switch-continuation-recovery.js +91 -0
- package/src/operations/switch-continuation-runtime.js +135 -0
- package/src/operations/switch-continuation-store.js +120 -0
- package/src/operations/switch-continuation.js +76 -0
- package/src/operations/switch-execute.js +68 -0
- package/src/operations/switch-inspect.js +45 -0
- package/src/operations/switch-journal-store.js +109 -0
- package/src/operations/switch-journal.js +71 -0
- package/src/operations/switch-lifecycle.js +72 -0
- package/src/operations/switch-pending.js +43 -0
- package/src/operations/switch-preflight.js +73 -0
- package/src/operations/switch-prepare.js +75 -0
- package/src/operations/switch-records.js +60 -0
- package/src/operations/switch-recovery-store.js +74 -0
- package/src/operations/switch.js +52 -0
- package/src/operations/toml-fields.js +133 -0
- package/src/providers/bundles.js +42 -0
- package/src/providers/common-entry.js +16 -0
- package/src/providers/grok.js +26 -0
- package/src/providers/interface.js +25 -0
- package/src/providers/kimi.js +26 -0
- package/src/providers/native.js +155 -0
- package/src/providers/registry.js +10 -0
- package/src/providers/shared.js +51 -0
- package/src/providers/source.js +30 -0
- package/src/source/git.js +303 -0
- package/src/source/inventory.js +87 -0
- package/src/source/repository-budget.js +18 -0
- package/src/source/snapshot.js +37 -0
- package/src/workspace/paths.js +54 -0
- package/src/workspace/profiles.js +8 -0
- package/src/workspace/repositories.js +57 -0
- package/src/workspace/repository-inventory.js +77 -0
- package/src/workspace/repository-observation.js +38 -0
- package/src/workspace/repository-preflight.js +129 -0
- package/src/workspace/repository-preview.js +196 -0
- package/src/workspace/repository-tree.js +57 -0
- package/src/workspace/reserved.js +11 -0
- package/src/workspace/resolve.js +53 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {opendir,open} from 'node:fs/promises';
|
|
3
|
+
import {fail,MAX_INPUT_BYTES} from '../contracts/parse.js';
|
|
4
|
+
import {contractDigest} from '../contracts/semantic.js';
|
|
5
|
+
import {inspectDirectory} from '../workspace/paths.js';
|
|
6
|
+
import {readRecord} from './state.js';
|
|
7
|
+
import {sha256} from '../source/inventory.js';
|
|
8
|
+
import {assertRecoveryLeaseHeld} from './recovery-lease.js';
|
|
9
|
+
|
|
10
|
+
const exact=(value,keys)=>value && typeof value==='object' && !Array.isArray(value) &&
|
|
11
|
+
Object.keys(value).sort().join(',')===[...keys].sort().join(',');
|
|
12
|
+
const name=index=>'continuation-'+String(index+1).padStart(6,'0')+'.json';
|
|
13
|
+
const MAX_APPROVALS=128,MAX_BYTES=32*1024*1024;
|
|
14
|
+
|
|
15
|
+
function validate(value,binding,bindings) {
|
|
16
|
+
if(!exact(value,['schemaVersion','kind','approval','observation']) || value.schemaVersion!==1 ||
|
|
17
|
+
value.kind!=='repository-lock-continuation-authorization' ||
|
|
18
|
+
!exact(value.approval,['decision','resumptionDigest']) || value.approval.decision!=='approve' ||
|
|
19
|
+
!value.observation || typeof value.observation!=='object')fail('repository-lock.continuation-record');
|
|
20
|
+
const {digest,...body}=value.observation;
|
|
21
|
+
if(digest!==value.approval.resumptionDigest || contractDigest(body)!==digest ||
|
|
22
|
+
body.kind!=='repository-lock-resumption-observation' ||
|
|
23
|
+
!['resumption-in-progress','gate-archived-resumption-pending'].includes(body.status) ||
|
|
24
|
+
body.executionAuthorized!==false || body.canResume!==false || body.canReleaseLock!==false ||
|
|
25
|
+
body.resumptionOwnerLiveness!=='local-pid-absent' ||
|
|
26
|
+
['wrapper','lockDigest','recoveryDigest','ownerDigest','approvalDigest'].some(k=>body[k]!==binding[k]) ||
|
|
27
|
+
contractDigest(body.continuationApprovals??[])!==contractDigest(bindings))
|
|
28
|
+
fail('repository-lock.continuation-binding');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Historical approvals are evidence, never permission to execute again. Each new
|
|
32
|
+
// record binds the complete fresh observation and the immutable preceding prefix.
|
|
33
|
+
export async function readResumptionApprovals(directory,binding) {
|
|
34
|
+
await inspectDirectory(directory);
|
|
35
|
+
const names=[];
|
|
36
|
+
for await(const entry of await opendir(directory)) {
|
|
37
|
+
if(names.length>=MAX_APPROVALS+2)fail('repository-lock.continuation-limit');
|
|
38
|
+
names.push(entry.name);
|
|
39
|
+
}
|
|
40
|
+
const records=names.filter(n=>!['owner.json','approval.json'].includes(n)).sort();
|
|
41
|
+
if(records.some((n,i)=>n!==name(i)))fail('repository-lock.continuation-entries');
|
|
42
|
+
const bindings=[];let bytes=0;
|
|
43
|
+
for(const filename of records) {
|
|
44
|
+
const record=await readRecord(path.join(directory,filename)),value=record.value;
|
|
45
|
+
bytes+=Buffer.byteLength(JSON.stringify(value));
|
|
46
|
+
if(bytes>MAX_BYTES)fail('repository-lock.continuation-limit');
|
|
47
|
+
validate(value,binding,bindings);
|
|
48
|
+
bindings.push({name:filename,digest:record.digest});
|
|
49
|
+
}
|
|
50
|
+
return bindings;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function persistResumptionApproval(directory,observation,approval,lease) {
|
|
54
|
+
assertRecoveryLeaseHeld(lease);
|
|
55
|
+
const before=await readResumptionApprovals(directory,observation);
|
|
56
|
+
if(contractDigest(before)!==contractDigest(observation.continuationApprovals??[]))
|
|
57
|
+
fail('repository-lock.continuation-drift');
|
|
58
|
+
if(before.length>=MAX_APPROVALS)fail('repository-lock.continuation-limit');
|
|
59
|
+
const value={schemaVersion:1,kind:'repository-lock-continuation-authorization',approval,observation};
|
|
60
|
+
validate(value,observation,before);
|
|
61
|
+
const bytes=Buffer.from(JSON.stringify(value)+'\n');
|
|
62
|
+
if(bytes.length>MAX_INPUT_BYTES)fail('repository-lock.continuation-size');
|
|
63
|
+
let total=bytes.length;
|
|
64
|
+
for(const prior of before) {
|
|
65
|
+
const record=await readRecord(path.join(directory,prior.name));
|
|
66
|
+
if(record.digest!==prior.digest)fail('repository-lock.continuation-drift');
|
|
67
|
+
total+=Buffer.byteLength(JSON.stringify(record.value));
|
|
68
|
+
}
|
|
69
|
+
if(total>MAX_BYTES)fail('repository-lock.continuation-limit');
|
|
70
|
+
const filename=name(before.length);assertRecoveryLeaseHeld(lease);
|
|
71
|
+
const handle=await open(path.join(directory,filename),'wx',0o600);
|
|
72
|
+
try{await handle.writeFile(bytes);await handle.sync();}finally{await handle.close();}
|
|
73
|
+
const expected=[...before,{name:filename,digest:sha256(bytes)}];
|
|
74
|
+
if(contractDigest(await readResumptionApprovals(directory,observation))!==contractDigest(expected))
|
|
75
|
+
fail('repository-lock.continuation-readback');
|
|
76
|
+
assertRecoveryLeaseHeld(lease);return expected;
|
|
77
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {randomUUID} from 'node:crypto';
|
|
3
|
+
import {mkdir,unlink,rmdir,lstat} from 'node:fs/promises';
|
|
4
|
+
import {scanRepositoryRetention,inspectRepositoryRetentionFiles} from './repository-retention.js';
|
|
5
|
+
import {assertLockHeld} from './lock.js';
|
|
6
|
+
import {withRecoveryLease,assertRecoveryLeaseHeld} from './recovery-lease.js';
|
|
7
|
+
import {writeCheckedFile} from './apply.js';
|
|
8
|
+
import {readRecord} from './state.js';
|
|
9
|
+
import {contractDigest} from '../contracts/semantic.js';
|
|
10
|
+
import {sha256} from '../source/inventory.js';
|
|
11
|
+
import {parse,fail} from '../contracts/parse.js';
|
|
12
|
+
import {inspectDirectory} from '../workspace/paths.js';
|
|
13
|
+
|
|
14
|
+
// Caller holds an ordinary workspace lock; the kernel lease also excludes
|
|
15
|
+
// recovery writers. Every deletion is an exact inspected file or empty directory.
|
|
16
|
+
export async function applyRepositoryRetention(lock,preview,approval,{boundary=async()=>{},report=async()=>{}}={}) {
|
|
17
|
+
await assertLockHeld(lock);
|
|
18
|
+
return withRecoveryLease(lock.workspace,async lease=>{
|
|
19
|
+
const p=parse(JSON.stringify(preview),'json'),{digest,...body}=p;
|
|
20
|
+
if(p.kind!=='repository-retention-preview'||!p.complete||p.workspace!==lock.workspace||contractDigest(body)!==digest||
|
|
21
|
+
contractDigest(approval)!==contractDigest({decision:'approve',previewDigest:digest}))fail('repository-retention.approval');
|
|
22
|
+
const fresh=await scanRepositoryRetention(lock.workspace,{policy:p.retention.policy,now:p.retention.now,currentRuns:p.currentRuns});
|
|
23
|
+
if(contractDigest(fresh)!==contractDigest(p))fail('repository-retention.drift');
|
|
24
|
+
const selected=p.retention.selected.map(s=>p.groups.find(g=>g.id===s.id));
|
|
25
|
+
if(selected.some(g=>!g||g.status!=='completed'||g.protectionReasons.length||g.paths.length!==2))fail('repository-retention.scope');
|
|
26
|
+
if(!selected.length)return {status:'completed',removedGroups:0,removedFiles:0,reclaimedBytes:0,receiptPath:null,recoverable:false};
|
|
27
|
+
const runId=randomUUID(),relative='.pipeline/repository-cleanup/'+runId+'.json',filename=path.join(lock.workspace,relative),directory=path.dirname(filename);
|
|
28
|
+
const receipt={schemaVersion:1,kind:'repository-retention-result',runId,workspace:lock.workspace,previewDigest:digest,
|
|
29
|
+
authorization:approval,policy:p.retention.policy,status:'in-progress',recoverable:false,
|
|
30
|
+
selected:selected.map(g=>({id:g.id,files:g.files.map(f=>({path:f.path,hash:f.hash})),paths:g.paths})),
|
|
31
|
+
removedFiles:[],removedDirectories:[],completedGroups:[],currentFile:null,reclaimedBytes:0,error:null};
|
|
32
|
+
parse(JSON.stringify(receipt),'json');
|
|
33
|
+
let receiptHash=null,created=false,directoryCreated=false;const removed=new Set();
|
|
34
|
+
const save=async()=>{
|
|
35
|
+
await assertRecoveryLeaseHeld(lease);const bytes=Buffer.from(JSON.stringify(receipt)+'\n');
|
|
36
|
+
await writeCheckedFile(lock,relative,receiptHash,bytes,undefined,async phase=>{if(phase==='opened')created=true;});receiptHash=sha256(bytes);created=true;
|
|
37
|
+
};
|
|
38
|
+
const guard=async()=>{
|
|
39
|
+
await assertLockHeld(lock);await assertRecoveryLeaseHeld(lease);
|
|
40
|
+
const actual=await inspectRepositoryRetentionFiles(lock.workspace);
|
|
41
|
+
if(created) {
|
|
42
|
+
if((await readRecord(filename)).digest!==receiptHash)fail('repository-retention.receipt-drift');
|
|
43
|
+
actual.files=actual.files.filter(f=>f.path!==filename);
|
|
44
|
+
}
|
|
45
|
+
if(directoryCreated)actual.directories=actual.directories.filter(d=>d.path!==directory);
|
|
46
|
+
const expected={files:p.inventory.files.filter(f=>!removed.has(f.path)),directories:p.inventory.directories.filter(d=>!removed.has(d.path))};
|
|
47
|
+
if(contractDigest(actual)!==contractDigest(expected))fail('repository-retention.drift');
|
|
48
|
+
};
|
|
49
|
+
try {
|
|
50
|
+
await report({kind:'cleanup-location',runId,path:filename,status:'planned'});await guard();
|
|
51
|
+
directoryCreated=!(await inspectDirectory(directory)).exists;
|
|
52
|
+
if(directoryCreated)await mkdir(directory);
|
|
53
|
+
await save();await report({kind:'cleanup-location',runId,path:filename,status:'created'});
|
|
54
|
+
for(const group of selected) {
|
|
55
|
+
await boundary('before-group',{id:group.id});await guard();
|
|
56
|
+
for(const file of group.files) {
|
|
57
|
+
receipt.currentFile=file.path;await save();await boundary('before-file',{path:file.path});await guard();
|
|
58
|
+
await inspectDirectory(path.dirname(file.path));const info=await lstat(file.path);
|
|
59
|
+
if(!info.isFile()||info.isSymbolicLink()||info.nlink!==1||String(info.dev)!==file.dev||String(info.ino)!==file.ino)fail('repository-retention.drift');
|
|
60
|
+
await unlink(file.path);removed.add(file.path);receipt.removedFiles.push(file.path);receipt.reclaimedBytes+=file.bytes;
|
|
61
|
+
await boundary('file-removed',{path:file.path});receipt.currentFile=null;await save();
|
|
62
|
+
}
|
|
63
|
+
for(const dir of [...group.paths].sort((a,b)=>b.length-a.length)) {
|
|
64
|
+
await guard();await inspectDirectory(dir);await rmdir(dir);removed.add(dir);receipt.removedDirectories.push(dir);await save();
|
|
65
|
+
}
|
|
66
|
+
receipt.completedGroups.push(group.id);await save();
|
|
67
|
+
}
|
|
68
|
+
await guard();receipt.status='completed';await save();
|
|
69
|
+
}catch(error){receipt.status='failed';receipt.error=error.code??'repository-retention.io';try{if(created)await save();}catch{receipt.receiptUpdate='failed';}}
|
|
70
|
+
const result={status:receipt.status,error:receipt.error,runId,receiptPath:filename,receiptCreated:created,
|
|
71
|
+
removedGroups:receipt.completedGroups.length,removedFiles:receipt.removedFiles.length,reclaimedBytes:receipt.reclaimedBytes,
|
|
72
|
+
uncompletedGroups:selected.filter(g=>!receipt.completedGroups.includes(g.id)).map(g=>g.id),recoverable:false,receiptUpdate:receipt.receiptUpdate??'saved'};
|
|
73
|
+
try{await report({kind:'cleanup-result',...result});}catch{result.outputError='repository-retention.report';}return result;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {lstat,readdir,open} from 'node:fs/promises';
|
|
3
|
+
import {absoluteRoot,inspectDirectory,resolveChild} from '../workspace/paths.js';
|
|
4
|
+
import {fail,parse} from '../contracts/parse.js';
|
|
5
|
+
import {contractDigest} from '../contracts/semantic.js';
|
|
6
|
+
import {sha256} from '../source/inventory.js';
|
|
7
|
+
import {readRecord,readState} from './state.js';
|
|
8
|
+
import {readRepositoryJournal} from './repository-journal.js';
|
|
9
|
+
import {readRepositoryInputs} from './repository-inputs.js';
|
|
10
|
+
import {verifyRepositoryAuthorization} from './repository-authorization.js';
|
|
11
|
+
import {repositoryContentDigest} from './repository-apply.js';
|
|
12
|
+
import {listRepositoryHistory} from './repository-history.js';
|
|
13
|
+
import {planRetention} from './retention.js';
|
|
14
|
+
import {bootstrapLockDirectory} from './bootstrap-lock.js';
|
|
15
|
+
import {validateInventorySummary} from '../workspace/repository-observation.js';
|
|
16
|
+
|
|
17
|
+
function validEvidenceInventory(v) {
|
|
18
|
+
if(v?.kind==='repository-inventory-summary'){validateInventorySummary(v);return true;}
|
|
19
|
+
return Array.isArray(v?.entries) && v.digest===sha256(Buffer.from(JSON.stringify(v.entries)));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const uuid=/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
23
|
+
const same=(a,b)=>contractDigest(a)===contractDigest(b);
|
|
24
|
+
const namespaces=['repository-journals','repository-evidence','repository-inputs','repository-authorizations','repository-completions'];
|
|
25
|
+
const idFor=relative=>{const m=/^\.pipeline\/(repository-journals|repository-evidence|repository-inputs|repository-authorizations|repository-completions)\/([a-f0-9-]{36})(?:\/|\.pending\.json$|\.json$|$)/.exec(relative);return m&&uuid.test(m[2])?m[2]:null;};
|
|
26
|
+
|
|
27
|
+
// Bounded reference scan includes arbitrary retained metadata, not only known
|
|
28
|
+
// recovery formats. Parsed strings catch JSON-escaped identifiers. No contents
|
|
29
|
+
// are returned. Runtime repositories/provider roots are outside this inventory.
|
|
30
|
+
export async function inspectRepositoryRetentionFiles(workspace) {
|
|
31
|
+
workspace=absoluteRoot(workspace);await inspectDirectory(workspace);
|
|
32
|
+
const files=[],directories=[];let count=0,total=0;
|
|
33
|
+
const walk=async(filename,depth)=>{
|
|
34
|
+
if(depth>32||++count>20000)fail('repository-retention.limit');
|
|
35
|
+
const before=await lstat(filename);
|
|
36
|
+
if(before.isSymbolicLink()||(!before.isFile()&&!before.isDirectory())||(before.isFile()&&before.nlink!==1))fail('repository-retention.type');
|
|
37
|
+
if(before.isDirectory()) {
|
|
38
|
+
await inspectDirectory(filename);
|
|
39
|
+
directories.push({path:filename,dev:String(before.dev),ino:String(before.ino)});
|
|
40
|
+
for(const name of (await readdir(filename)).sort()) {
|
|
41
|
+
if(filename===path.join(workspace,'.pipeline')&&name==='lock')continue;
|
|
42
|
+
await walk(path.join(filename,name),depth+1);
|
|
43
|
+
}
|
|
44
|
+
const after=await lstat(filename);
|
|
45
|
+
if(after.dev!==before.dev||after.ino!==before.ino||after.mtimeMs!==before.mtimeMs)fail('repository-retention.drift');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
total+=before.size;if(before.size>8*1024*1024||total>128*1024*1024)fail('repository-retention.limit');
|
|
49
|
+
const handle=await open(filename,'r');let bytes;
|
|
50
|
+
try {
|
|
51
|
+
const opened=await handle.stat();if(!opened.isFile()||opened.nlink!==1||opened.dev!==before.dev||opened.ino!==before.ino||opened.size!==before.size)fail('repository-retention.drift');
|
|
52
|
+
const buffer=Buffer.alloc(before.size+1);let length=0;
|
|
53
|
+
while(length<buffer.length){const read=await handle.read(buffer,length,buffer.length-length,null);if(!read.bytesRead)break;length+=read.bytesRead;}
|
|
54
|
+
bytes=buffer.subarray(0,length);
|
|
55
|
+
}finally{await handle.close();}
|
|
56
|
+
const after=await lstat(filename);
|
|
57
|
+
if(after.dev!==before.dev||after.ino!==before.ino||after.mtimeMs!==before.mtimeMs||bytes.length!==before.size)fail('repository-retention.drift');
|
|
58
|
+
let text=bytes.toString('utf8');if(filename.endsWith('.json'))text=JSON.stringify(parse(text,'json'));
|
|
59
|
+
const references=[...new Set([
|
|
60
|
+
...(text.match(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/gi)??[]).map(s=>s.toLowerCase()),
|
|
61
|
+
...(text.match(/[a-f0-9]{64}/gi)??[]).map(s=>'sha256:'+s.toLowerCase())])].sort();
|
|
62
|
+
files.push({path:filename,hash:sha256(bytes),bytes:bytes.length,mtimeMs:before.mtimeMs,dev:String(before.dev),ino:String(before.ino),references});
|
|
63
|
+
};
|
|
64
|
+
const metadata=path.join(workspace,'.pipeline');if((await inspectDirectory(metadata)).exists)await walk(metadata,0);
|
|
65
|
+
const history=await listRepositoryHistory(workspace);
|
|
66
|
+
if(!history.complete)fail('repository-retention.history-incomplete');
|
|
67
|
+
for(const entry of history.entries)if(!entry.path.startsWith(metadata+path.sep))await walk(entry.path,0);
|
|
68
|
+
files.sort((a,b)=>a.path.localeCompare(b.path));directories.sort((a,b)=>a.path.localeCompare(b.path));
|
|
69
|
+
return {files,directories};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function verifyHistoricalGroup(workspace,id,files) {
|
|
73
|
+
const journal='.pipeline/repository-journals/'+id,base='.pipeline/repository-completions/'+id;
|
|
74
|
+
const receipt=await readRecord(resolveChild(workspace,base+'.json')),r=receipt.value;
|
|
75
|
+
const digest=r.reconciliation?.previewDigest,input=await readRepositoryInputs(workspace,journal,digest),preview=parse(input.previewText,'json');
|
|
76
|
+
const history=await readRepositoryJournal(workspace,journal,preview);
|
|
77
|
+
if(history.phase!=='terminal'||history.operations.some(o=>o.status!=='completed'))fail('repository-retention.incomplete');
|
|
78
|
+
const pending=await readRecord(resolveChild(workspace,base+'.pending.json'));
|
|
79
|
+
if(!same(pending.value,{schemaVersion:1,previewDigest:digest,journal,status:'requires-reconciliation'}))fail('repository-retention.marker');
|
|
80
|
+
const bindings=await verifyRepositoryAuthorization(workspace,journal,preview,history.authorization),observations=[];
|
|
81
|
+
const allowed=new Set([base+'.json',base+'.pending.json','.pipeline/repository-inputs/'+id+'.json',
|
|
82
|
+
...Array.from({length:history.sequence},(_,i)=>journal+'/'+String(i).padStart(6,'0')+'.json')]);
|
|
83
|
+
if(history.authorization)allowed.add(history.authorization.path);
|
|
84
|
+
for(let i=0;i<preview.operations.length;i++) {
|
|
85
|
+
const op=preview.operations[i],relative='.pipeline/repository-evidence/'+id+'/'+String(i).padStart(6,'0')+'.json';
|
|
86
|
+
allowed.add(relative);const record=await readRecord(resolveChild(workspace,relative)),e=record.value;
|
|
87
|
+
if(Object.keys(e).sort().join(',')!=='action,contentDigest,inventory,previewDigest,repository,schemaVersion,sourceAbsent,target'||
|
|
88
|
+
!validEvidenceInventory(e.inventory)||
|
|
89
|
+
record.digest!==history.operations[i].evidenceDigest||e.schemaVersion!==1||e.previewDigest!==digest||e.repository!==op.repository||
|
|
90
|
+
e.action!==op.action||e.target!==op.target||e.inventory?.root!==op.target||e.sourceAbsent!==(op.action==='move'?true:null)||
|
|
91
|
+
e.contentDigest!==repositoryContentDigest(e.inventory))fail('repository-retention.evidence');
|
|
92
|
+
bindings.push({path:relative,digest:record.digest});observations.push({repository:op.repository,claimedStatus:'completed',state:'completed-verified',targetDigest:e.inventory.digest,sourceDigest:null});
|
|
93
|
+
}
|
|
94
|
+
const body={kind:'repository-reconciliation',wrapper:workspace,previewDigest:digest,markerDigest:pending.digest,journal,
|
|
95
|
+
journalHead:history.lastHash,evidenceBindings:bindings,observations,canFinalize:true,executionAuthorized:false};
|
|
96
|
+
if(!same(r,{schemaVersion:1,status:'repository-effects-verified',reconciliation:{...body,digest:contractDigest(body)},pipelineActivated:false}))fail('repository-retention.completion');
|
|
97
|
+
const actual=files.map(f=>path.relative(workspace,f.path).split(path.sep).join('/'));
|
|
98
|
+
if(actual.length!==allowed.size||actual.some(p=>!allowed.has(p)))fail('repository-retention.foreign');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function scanRepositoryRetention(workspace,{policy,now,currentRuns=[]}) {
|
|
102
|
+
workspace=absoluteRoot(workspace);planRetention({journals:[],policy,now});
|
|
103
|
+
if(!Array.isArray(currentRuns)||currentRuns.some(id=>!uuid.test(id)))fail('repository-retention.current-runs');
|
|
104
|
+
const diagnostics=[],groups=[];let inventory;
|
|
105
|
+
try{inventory=await inspectRepositoryRetentionFiles(workspace);}catch(error){diagnostics.push({code:error.code??'repository-retention.io'});inventory={files:[],directories:[]};}
|
|
106
|
+
try {
|
|
107
|
+
const state=await readState(resolveChild(workspace,'.pipeline/state.json'));
|
|
108
|
+
if(state.value.pending!==null)diagnostics.push({code:'repository-retention.configuration-pending'});
|
|
109
|
+
}catch(error){if(error.code!=='record.missing')diagnostics.push({code:error.code??'repository-retention.state'});}
|
|
110
|
+
const relative=f=>path.relative(workspace,f.path).split(path.sep).join('/');
|
|
111
|
+
const ids=[...new Set([...inventory.files,...inventory.directories].map(f=>idFor(relative(f))).filter(Boolean))].sort();
|
|
112
|
+
for(const id of ids) {
|
|
113
|
+
const files=inventory.files.filter(f=>idFor(relative(f))===id),paths=inventory.directories.filter(d=>idFor(relative(d))===id).map(d=>d.path);
|
|
114
|
+
const reasons=[];let status='completed';
|
|
115
|
+
try {
|
|
116
|
+
if(paths.length!==2||!['repository-journals','repository-evidence'].every(n=>paths.includes(path.join(workspace,'.pipeline',n,id))))fail('repository-retention.foreign');
|
|
117
|
+
await verifyHistoricalGroup(workspace,id,files);
|
|
118
|
+
}catch(error){status='unknown';reasons.push('inspection-failed');diagnostics.push({code:error.code??'repository-retention.invalid',subject:id});}
|
|
119
|
+
if(currentRuns.includes(id))reasons.push('current-run');
|
|
120
|
+
const subjects=new Set([id,...files.map(f=>f.hash)]);
|
|
121
|
+
if(inventory.files.some(f=>idFor(relative(f))!==id&&f.references.some(ref=>subjects.has(ref))))reasons.push('retained-reference');
|
|
122
|
+
groups.push({id,status,files,paths,bytes:files.reduce((n,f)=>n+f.bytes,0),completedAt:status==='completed'?Math.floor(Math.max(...files.map(f=>f.mtimeMs))):null,protectionReasons:reasons});
|
|
123
|
+
}
|
|
124
|
+
const marker=inventory.files.some(f=>relative(f)==='.pipeline/repository-operation.json');
|
|
125
|
+
if(marker)diagnostics.push({code:'repository-retention.pending'});
|
|
126
|
+
const bootstrap=bootstrapLockDirectory(workspace);
|
|
127
|
+
if(inventory.directories.some(d=>[bootstrap,bootstrap+'.recovery',bootstrap+'.recovery-resume'].includes(d.path)))
|
|
128
|
+
diagnostics.push({code:'repository-retention.recovery-pending'});
|
|
129
|
+
for(const f of [...inventory.files,...inventory.directories]) {
|
|
130
|
+
const p=relative(f);if(namespaces.some(n=>p.startsWith('.pipeline/'+n+'/'))&&!idFor(p))diagnostics.push({code:'repository-retention.foreign',subject:f.path});
|
|
131
|
+
}
|
|
132
|
+
if(diagnostics.length)for(const g of groups)g.protectionReasons=[...new Set([...g.protectionReasons,'inspection-failed'])];
|
|
133
|
+
if(!diagnostics.length&&!same(inventory,await inspectRepositoryRetentionFiles(workspace)))fail('repository-retention.drift');
|
|
134
|
+
const retention=planRetention({now,policy,journals:groups.map(({id,status,bytes,completedAt,protectionReasons})=>({id,status,bytes,completedAt,protectionReasons}))});
|
|
135
|
+
const body={kind:'repository-retention-preview',workspace,currentRuns,groups,inventory,retention,diagnostics,complete:diagnostics.length===0,
|
|
136
|
+
runtimeVerified:false,automaticActions:false};return {...body,digest:contractDigest(body)};
|
|
137
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { lstat,mkdir,open } from 'node:fs/promises';
|
|
3
|
+
import { fail,MAX_INPUT_BYTES } from '../contracts/parse.js';
|
|
4
|
+
import { contractDigest } from '../contracts/semantic.js';
|
|
5
|
+
import { sha256 } from '../source/inventory.js';
|
|
6
|
+
import { inspectDirectory } from '../workspace/paths.js';
|
|
7
|
+
import { revalidateRepositoryPreview } from '../workspace/repository-preview.js';
|
|
8
|
+
import { inspectRepositoryBootstrap } from './repository-bootstrap-reconcile.js';
|
|
9
|
+
import { createRepositoryWrapper } from './repository-bootstrap.js';
|
|
10
|
+
import { applyRepositoryOperations } from './repository-apply.js';
|
|
11
|
+
import { readRecord } from './state.js';
|
|
12
|
+
|
|
13
|
+
// Internal orchestration of already-authorized wrapper + repository effects.
|
|
14
|
+
// Only the newly created wrapper's identity/ancestor observations may change.
|
|
15
|
+
// This is a mechanical authorization projection, never a new human approval.
|
|
16
|
+
export async function applyRepositoryWorkspace(args) {
|
|
17
|
+
const {pipeline,workspace,wrapper,choices,options={},previewText,approval,
|
|
18
|
+
ioBoundary=async()=>{}}=args;
|
|
19
|
+
if(!approval || Object.keys(approval).sort().join(',')!=='decision,previewDigest' || approval.decision!=='approve')
|
|
20
|
+
fail('repository-workspace.approval');
|
|
21
|
+
const original=await revalidateRepositoryPreview(previewText,approval.previewDigest,pipeline,workspace,wrapper,choices,options);
|
|
22
|
+
if(original.wrapperObservation.action==='keep')return applyRepositoryOperations(args);
|
|
23
|
+
// Refuse unsupported missing ancestors before creating anything.
|
|
24
|
+
if(!(await inspectDirectory(path.dirname(original.wrapper))).exists ||
|
|
25
|
+
original.operations.some(op=>path.dirname(op.target)!==original.wrapper))fail('repository-workspace.ancestors-unsupported');
|
|
26
|
+
const bootstrap=await createRepositoryWrapper(args);
|
|
27
|
+
const checked=await inspectRepositoryBootstrap(original.wrapper,previewText,original.digest);
|
|
28
|
+
if(checked.status!=='handoff-recorded')fail('repository-workspace.bootstrap-state');
|
|
29
|
+
const receipt=await readRecord(bootstrap.receipt);
|
|
30
|
+
if(receipt.digest!==checked.recordDigests.receipt)fail('repository-workspace.bootstrap-drift');
|
|
31
|
+
await ioBoundary('workspace-bootstrap-completed',{receipt:bootstrap.receipt});
|
|
32
|
+
const identity=receipt.value.identity;
|
|
33
|
+
const {digest:oldDigest,...body}=structuredClone(original);
|
|
34
|
+
body.wrapperObservation={action:'keep',path:original.wrapper,identity};
|
|
35
|
+
for(const op of body.operations) {
|
|
36
|
+
if(!op.destination || op.destination.ancestor!==path.dirname(original.wrapper) ||
|
|
37
|
+
contractDigest(op.destination.ancestorIdentity)!==contractDigest(original.wrapperObservation.ancestorIdentity))
|
|
38
|
+
fail('repository-workspace.projection');
|
|
39
|
+
op.destination={...op.destination,ancestor:original.wrapper,ancestorIdentity:identity};
|
|
40
|
+
}
|
|
41
|
+
const derived={...body,digest:contractDigest(body)};
|
|
42
|
+
const derivedText=JSON.stringify(derived);
|
|
43
|
+
// Reconstruct all semantics from the original inputs. Source bytes/commit,
|
|
44
|
+
// manifest bytes, target absence, and prepared remote bindings remain exact.
|
|
45
|
+
await revalidateRepositoryPreview(derivedText,derived.digest,pipeline,workspace,wrapper,choices,options);
|
|
46
|
+
let authorizationFile,authorizationDigest;
|
|
47
|
+
const result=await applyRepositoryOperations({...args,previewText:derivedText,
|
|
48
|
+
approval:{decision:'approve',previewDigest:derived.digest},ioBoundary:async(phase,detail)=>{
|
|
49
|
+
if(phase==='repository-run-started') {
|
|
50
|
+
const current=await readRecord(bootstrap.receipt);
|
|
51
|
+
const history=await readRecord(path.join(original.wrapper,'.pipeline/repository-bootstrap-intent.json'));
|
|
52
|
+
if(current.digest!==receipt.digest || history.digest!==checked.recordDigests.history)
|
|
53
|
+
fail('repository-workspace.bootstrap-drift');
|
|
54
|
+
const stat=await lstat(original.wrapper,{bigint:true});
|
|
55
|
+
if(String(stat.dev)!==identity.dev || String(stat.ino)!==identity.ino)fail('repository-workspace.bootstrap-drift');
|
|
56
|
+
const directory=path.join(original.wrapper,'.pipeline/repository-authorizations');
|
|
57
|
+
try{await mkdir(directory);}catch(e){if(e.code!=='EEXIST')throw e;}
|
|
58
|
+
await inspectDirectory(directory);
|
|
59
|
+
authorizationFile=path.join(directory,path.basename(detail.journal)+'.json');
|
|
60
|
+
const bytes=Buffer.from(JSON.stringify({schemaVersion:1,kind:'wrapper-creation-projection',
|
|
61
|
+
originalPreview:original,originalApproval:{decision:'approve',previewDigest:original.digest},derivedPreview:derived,
|
|
62
|
+
bootstrapReceiptDigest:receipt.digest,bootstrapIntentDigest:history.digest,
|
|
63
|
+
journal:detail.journal,newHumanApproval:false})+'\n');
|
|
64
|
+
if(bytes.length>MAX_INPUT_BYTES)fail('repository-workspace.authorization-size');
|
|
65
|
+
const handle=await open(authorizationFile,'wx',0o600);
|
|
66
|
+
try{await handle.writeFile(bytes);await handle.sync();}finally{await handle.close();}
|
|
67
|
+
authorizationDigest=sha256(bytes);
|
|
68
|
+
if((await readRecord(authorizationFile)).digest!==authorizationDigest)fail('repository-workspace.authorization-readback');
|
|
69
|
+
}
|
|
70
|
+
await ioBoundary(phase,detail);
|
|
71
|
+
if(authorizationFile && (await readRecord(authorizationFile)).digest!==authorizationDigest)
|
|
72
|
+
fail('repository-workspace.authorization-drift');
|
|
73
|
+
if(phase==='repository-run-started')return {
|
|
74
|
+
path:detail.journal.replace('/repository-journals/','/repository-authorizations/')+'.json',digest:authorizationDigest};
|
|
75
|
+
}});
|
|
76
|
+
return {...result,originalPreviewDigest:original.digest,executionPreview:derived,
|
|
77
|
+
authorizationFile,authorizationDigest,bootstrapReceipt:bootstrap.receipt};
|
|
78
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import {mkdir,readdir,lstat,unlink,rmdir} from 'node:fs/promises';
|
|
2
|
+
import {randomUUID} from 'node:crypto';
|
|
3
|
+
import {scanRetention} from './retention-scan.js';
|
|
4
|
+
import {scanCombinedRetention} from './retention-combined-scan.js';
|
|
5
|
+
import {assertLockHeld} from './lock.js';
|
|
6
|
+
import {readRecord} from './state.js';
|
|
7
|
+
import {writeCheckedFile} from './apply.js';
|
|
8
|
+
import {resolveChild,inspectDirectory} from '../workspace/paths.js';
|
|
9
|
+
import {contractDigest} from '../contracts/semantic.js';
|
|
10
|
+
import {parse,fail,ContractError} from '../contracts/parse.js';
|
|
11
|
+
import {requestShape} from './ownership.js';
|
|
12
|
+
import {sha256} from '../source/inventory.js';
|
|
13
|
+
import {readRetentionPolicy,retentionLimits} from './retention-policy.js';
|
|
14
|
+
|
|
15
|
+
// Exact approved journal-group deletion only. No recursive deletion, snapshot or
|
|
16
|
+
// backup cleanup, lock stealing, rollback, or provider/project writes.
|
|
17
|
+
export async function applyRetention(lock,preview,approval,{boundary=async()=>{},report=async()=>{}}={}) {
|
|
18
|
+
const p=parse(JSON.stringify(preview),'json'),a=parse(JSON.stringify(approval),'json');
|
|
19
|
+
const combined=p.kind==='combined-retention-filesystem-preview';
|
|
20
|
+
const key=g=>combined?g.type+':'+g.id:g.id;
|
|
21
|
+
const automatic=a.decision==='workspace-policy';
|
|
22
|
+
requestShape(a,automatic?['decision','policyHash']:['decision','previewDigest'],[],'retention-apply.approval');
|
|
23
|
+
const {digest,...body}=p;
|
|
24
|
+
if(!['retention-filesystem-preview','combined-retention-filesystem-preview'].includes(p.kind) || contractDigest(body)!==digest || !p.complete ||
|
|
25
|
+
p.workspace!==lock.workspace || (!automatic && (a.decision!=='approve' || a.previewDigest!==digest)))fail('retention-apply.approval');
|
|
26
|
+
async function guardPolicy() {
|
|
27
|
+
if(!automatic)return;
|
|
28
|
+
const current=await readRetentionPolicy(lock.workspace);
|
|
29
|
+
if(current.mode!=='automatic' || current.hash!==a.policyHash ||
|
|
30
|
+
(current.policy.schemaVersion===2)!==combined ||
|
|
31
|
+
contractDigest(retentionLimits(current.policy))!==contractDigest(p.retention.policy))fail('retention-apply.policy-drift');
|
|
32
|
+
}
|
|
33
|
+
const options={policy:p.retention.policy,now:p.retention.now,currentRuns:p.currentRuns};
|
|
34
|
+
await assertLockHeld(lock);
|
|
35
|
+
await guardPolicy();
|
|
36
|
+
if(contractDigest(await (combined?scanCombinedRetention:scanRetention)(lock.workspace,options))!==contractDigest(p))fail('retention-apply.drift');
|
|
37
|
+
const selected=p.retention.selected.map(s=>p.groups.find(g=>key(g)===key(s)));
|
|
38
|
+
if(selected.some(g=>!g || g.status!=='journal-completed' || g.protectionReasons.length ||
|
|
39
|
+
(g.type==='cleanup-receipt'?(g.paths.length!==0||g.files.length!==1):g.paths.length!==2)))fail('retention-apply.scope');
|
|
40
|
+
// Neither policy version creates persistent receipts for a no-work pass.
|
|
41
|
+
// Approval and fresh inventory checks above still apply; emit only the result.
|
|
42
|
+
if(selected.length===0) {
|
|
43
|
+
const result={status:'completed',error:null,runId:null,receiptPath:null,receiptCreated:false,
|
|
44
|
+
removedGroups:0,removedFiles:0,reclaimedBytes:0,selectedGroups:0,uncompletedGroups:[],recoverable:false,receiptUpdate:'not-created'};
|
|
45
|
+
try{await report({kind:'cleanup-result',...result});}catch{result.outputError='retention-apply.report';}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
const runId=randomUUID(),relative='.pipeline/cleanup/'+runId+'.json',filename=resolveChild(lock.workspace,relative);
|
|
49
|
+
const receipt={schemaVersion:combined?2:1,kind:'retention-result',runId,workspace:lock.workspace,previewDigest:digest,
|
|
50
|
+
policy:p.retention.policy,authorization:a,status:'in-progress',recoverable:false,
|
|
51
|
+
groups:selected.map(g=>({id:g.id,...combined?{type:g.type}:{},paths:g.paths,files:g.files})),removedFiles:[],removedDirectories:[],completedGroups:[],
|
|
52
|
+
currentFile:null,error:null,reclaimedBytes:0};
|
|
53
|
+
// Bound receipt size before even creating its directory; no payload secrets.
|
|
54
|
+
parse(JSON.stringify(receipt),'json');
|
|
55
|
+
let receiptHash=null,created=false;
|
|
56
|
+
const removedFiles=new Set(),removedDirectories=new Set();
|
|
57
|
+
async function save() {
|
|
58
|
+
const bytes=Buffer.from(JSON.stringify(receipt)+'\n');parse(bytes.toString('utf8'),'json');
|
|
59
|
+
await writeCheckedFile(lock,relative,receiptHash,bytes,undefined,async phase=>{
|
|
60
|
+
if(receiptHash===null && phase==='opened')created=true;
|
|
61
|
+
});receiptHash=sha256(bytes);created=true;
|
|
62
|
+
}
|
|
63
|
+
async function checkFile(file) {
|
|
64
|
+
const target=resolveChild(lock.workspace,file.path),info=await lstat(target);
|
|
65
|
+
if(!info.isFile() || info.isSymbolicLink() || info.nlink!==1 || info.size!==file.bytes || info.mtimeMs!==file.mtimeMs ||
|
|
66
|
+
(await readRecord(target)).digest!==file.hash)fail('retention-apply.drift');
|
|
67
|
+
}
|
|
68
|
+
async function guardReferences() {
|
|
69
|
+
await assertLockHeld(lock);
|
|
70
|
+
await guardPolicy();
|
|
71
|
+
let stateHash=null;
|
|
72
|
+
try{stateHash=(await readRecord(resolveChild(lock.workspace,'.pipeline/state.json'))).digest;}
|
|
73
|
+
catch(error){if(error.code!=='record.missing')throw error;}
|
|
74
|
+
if(stateHash!==p.stateFileHash)fail('retention-apply.state-drift');
|
|
75
|
+
for(const base of ['.pipeline/journals','.pipeline/transactions']) {
|
|
76
|
+
const expected=p.groups.flatMap(g=>g.paths).filter(d=>d.startsWith(base+'/') && !removedDirectories.has(d)).map(d=>d.slice(base.length+1)).sort();
|
|
77
|
+
const directory=resolveChild(lock.workspace,base),exists=(await inspectDirectory(directory)).exists;
|
|
78
|
+
const names=exists?(await readdir(directory)).sort():[];
|
|
79
|
+
if(JSON.stringify(names)!==JSON.stringify(expected))fail('retention-apply.inventory-drift');
|
|
80
|
+
}
|
|
81
|
+
// Bound to the inspected records: new references invalidate cleanup. This is
|
|
82
|
+
// a conservative per-file record check, not a claim of linear overall cost.
|
|
83
|
+
for(const group of p.groups)for(const file of group.files)
|
|
84
|
+
if((file.path.endsWith('/recovery.json') || group.type==='cleanup-receipt') && !removedFiles.has(file.path))await checkFile(file);
|
|
85
|
+
if(combined) {
|
|
86
|
+
const base='.pipeline/cleanup',directory=resolveChild(lock.workspace,base);
|
|
87
|
+
const expected=p.groups.filter(g=>g.type==='cleanup-receipt').flatMap(g=>g.files)
|
|
88
|
+
.filter(f=>!removedFiles.has(f.path)).map(f=>f.path.slice(base.length+1));
|
|
89
|
+
if(created)expected.push(runId+'.json');
|
|
90
|
+
const actual=(await inspectDirectory(directory)).exists?await readdir(directory):[];
|
|
91
|
+
if(JSON.stringify(actual.sort())!==JSON.stringify(expected.sort()))fail('retention-apply.inventory-drift');
|
|
92
|
+
if(created&&(await readRecord(filename)).digest!==receiptHash)fail('retention-apply.receipt-drift');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
await report({kind:'cleanup-location',runId,path:filename,status:'planned'});
|
|
97
|
+
await guardReferences();
|
|
98
|
+
const parent=resolveChild(lock.workspace,'.pipeline/cleanup');
|
|
99
|
+
try{await mkdir(parent);}catch(error){if(error.code!=='EEXIST')throw error;}
|
|
100
|
+
await inspectDirectory(parent);await save();
|
|
101
|
+
await report({kind:'cleanup-location',runId,path:filename,status:'created'});
|
|
102
|
+
for(const group of selected) {
|
|
103
|
+
await boundary('before-group',{id:group.id});await guardReferences();
|
|
104
|
+
for(const directory of group.paths) {
|
|
105
|
+
const expected=group.files.filter(f=>f.path.startsWith(directory+'/')).map(f=>f.path.slice(directory.length+1)).sort();
|
|
106
|
+
if(JSON.stringify((await readdir(resolveChild(lock.workspace,directory))).sort())!==JSON.stringify(expected))fail('retention-apply.inventory-drift');
|
|
107
|
+
}
|
|
108
|
+
for(const file of group.files) {
|
|
109
|
+
receipt.currentFile=file.path;await save();
|
|
110
|
+
await boundary('before-file',{path:file.path});await guardReferences();await checkFile(file);
|
|
111
|
+
await unlink(resolveChild(lock.workspace,file.path));
|
|
112
|
+
removedFiles.add(file.path);receipt.removedFiles.push(file.path);receipt.reclaimedBytes+=file.bytes;
|
|
113
|
+
await boundary('file-removed',{path:file.path});receipt.currentFile=null;await save();
|
|
114
|
+
}
|
|
115
|
+
for(const directory of group.paths) {
|
|
116
|
+
await guardReferences();await inspectDirectory(resolveChild(lock.workspace,directory));
|
|
117
|
+
await rmdir(resolveChild(lock.workspace,directory)); // empty only; never recursive
|
|
118
|
+
removedDirectories.add(directory);receipt.removedDirectories.push(directory);await save();
|
|
119
|
+
}
|
|
120
|
+
receipt.completedGroups.push(key(group));await save();
|
|
121
|
+
}
|
|
122
|
+
await guardReferences();receipt.status='completed';await save();
|
|
123
|
+
}catch(error) {
|
|
124
|
+
receipt.status='failed';receipt.error=error instanceof ContractError?error.code:'retention-apply.io';
|
|
125
|
+
try{if(created)await save();}catch{receipt.receiptUpdate='failed';}
|
|
126
|
+
}
|
|
127
|
+
const result={status:receipt.status,error:receipt.error,runId,receiptPath:filename,receiptCreated:created,
|
|
128
|
+
removedGroups:receipt.completedGroups.length,removedFiles:receipt.removedFiles.length,reclaimedBytes:receipt.reclaimedBytes,
|
|
129
|
+
selectedGroups:selected.length,uncompletedGroups:selected.filter(g=>!receipt.completedGroups.includes(key(g))).map(key),
|
|
130
|
+
recoverable:false,receiptUpdate:receipt.receiptUpdate??'saved'};
|
|
131
|
+
try{await report({kind:'cleanup-result',...result});}catch{result.outputError='retention-apply.report';}
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import {scanRetention} from './retention-scan.js';
|
|
2
|
+
import {scanCleanupReceipts} from './retention-receipts.js';
|
|
3
|
+
import {planCombinedRetention} from './retention-combined.js';
|
|
4
|
+
import {contractDigest} from '../contracts/semantic.js';
|
|
5
|
+
import {fail} from '../contracts/parse.js';
|
|
6
|
+
|
|
7
|
+
export async function scanCombinedRetention(workspace,{policy,now,currentRuns=[]}) {
|
|
8
|
+
planCombinedRetention({journals:[],receipts:[],policy,now});
|
|
9
|
+
const journals=await scanRetention(workspace,{policy:{...policy.journals,maxDeletesPerRun:policy.maxDeletesPerRun},now,currentRuns});
|
|
10
|
+
const receipts=await scanCleanupReceipts(workspace,{currentRuns});
|
|
11
|
+
for(const binding of receipts.bindings) {
|
|
12
|
+
const expected=binding.path==='.pipeline/state.json'?journals.stateFileHash:
|
|
13
|
+
journals.groups.flatMap(g=>g.files).find(f=>f.path===binding.path)?.hash;
|
|
14
|
+
if(expected!==binding.hash)fail('retention-combined.drift');
|
|
15
|
+
}
|
|
16
|
+
const complete=journals.complete&&receipts.complete;
|
|
17
|
+
const groups=[...journals.groups.map(g=>({...g,type:'journal'})),...receipts.records.map(r=>({
|
|
18
|
+
id:r.id,type:'cleanup-receipt',status:r.status==='completed'?'journal-completed':r.status,
|
|
19
|
+
paths:[],files:r.hash?[{path:r.path,hash:r.hash,bytes:r.bytes,mtimeMs:r.mtimeMs}]:[],
|
|
20
|
+
protectionReasons:[...r.protectionReasons],bytes:r.bytes,completedAt:r.completedAt
|
|
21
|
+
}))].map(g=>({...g,protectionReasons:[...new Set([...g.protectionReasons,...complete?[]:['inspection-failed']])].sort()}));
|
|
22
|
+
const observations=type=>groups.filter(g=>g.type===type).map(g=>({id:g.id,
|
|
23
|
+
status:g.status==='journal-completed'?'completed':g.status==='uncertain'?'uncertain':'unknown',
|
|
24
|
+
completedAt:g.completedAt,bytes:g.bytes,protectionReasons:g.protectionReasons}));
|
|
25
|
+
const retention=planCombinedRetention({journals:observations('journal'),receipts:observations('cleanup-receipt'),policy,now});
|
|
26
|
+
const body={kind:'combined-retention-filesystem-preview',workspace:journals.workspace,
|
|
27
|
+
stateFileHash:journals.stateFileHash,currentRuns:[...currentRuns],groups,retention,
|
|
28
|
+
diagnostics:[...journals.diagnostics,...receipts.diagnostics],complete,applySupported:false,automaticActions:false};
|
|
29
|
+
return {...body,digest:contractDigest(body)};
|
|
30
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import {planRetention} from './retention.js';
|
|
2
|
+
import {requestShape} from './ownership.js';
|
|
3
|
+
import {contractDigest} from '../contracts/semantic.js';
|
|
4
|
+
import {fail} from '../contracts/parse.js';
|
|
5
|
+
|
|
6
|
+
// Pure planning only: observations must come from verified filesystem scanners.
|
|
7
|
+
// Never grants permission to delete or treats an observed completed flag as proof.
|
|
8
|
+
export function planCombinedRetention(input) {
|
|
9
|
+
requestShape(input,['journals','receipts','policy','now'],[],'retention-combined.input');
|
|
10
|
+
const {journals,receipts,policy,now}=input;
|
|
11
|
+
requestShape(policy,['journals','cleanupReceipts','maxDeletesPerRun'],[],'retention-combined.policy');
|
|
12
|
+
requestShape(policy.journals,['maxAgeDays','maxJournals'],[],'retention-combined.policy');
|
|
13
|
+
requestShape(policy.cleanupReceipts,['maxAgeDays','maxReceipts'],[],'retention-combined.policy');
|
|
14
|
+
if(!Number.isSafeInteger(policy.maxDeletesPerRun) || policy.maxDeletesPerRun<0)fail('retention-combined.policy');
|
|
15
|
+
const classes=[
|
|
16
|
+
['journal',journals,policy.journals.maxAgeDays,policy.journals.maxJournals],
|
|
17
|
+
['cleanup-receipt',receipts,policy.cleanupReceipts.maxAgeDays,policy.cleanupReceipts.maxReceipts]
|
|
18
|
+
].map(([type,items,maxAgeDays,maxJournals])=>({type,maxAgeDays,maxCount:maxJournals,
|
|
19
|
+
plan:planRetention({journals:items,now,policy:{maxAgeDays,maxJournals,maxDeletesPerRun:Number.MAX_SAFE_INTEGER}})}));
|
|
20
|
+
const order=(a,b)=>a.completedAt-b.completedAt || (a.type<b.type?-1:a.type>b.type?1:a.id<b.id?-1:a.id>b.id?1:0);
|
|
21
|
+
const candidates=classes.flatMap(c=>c.plan.selected.map(item=>({...item,type:c.type}))).sort(order);
|
|
22
|
+
const selected=candidates.slice(0,policy.maxDeletesPerRun),deferred=candidates.slice(policy.maxDeletesPerRun);
|
|
23
|
+
const selectedKeys=new Set(selected.map(item=>item.type+':'+item.id));
|
|
24
|
+
const perClass=classes.map(c=>{
|
|
25
|
+
const chosen=selected.filter(item=>item.type===c.type);
|
|
26
|
+
const remaining=c.plan.observed.filter(item=>!selectedKeys.has(c.type+':'+item.id));
|
|
27
|
+
return {type:c.type,observedCount:c.plan.observed.length,observedBytes:c.plan.totals.observedBytes,
|
|
28
|
+
selectedCount:chosen.length,selectedBytes:chosen.reduce((n,item)=>n+item.bytes,0),
|
|
29
|
+
protectedCount:c.plan.protected.length,remainingCount:remaining.length,
|
|
30
|
+
remainingOverCount:Math.max(0,remaining.length-c.maxCount),
|
|
31
|
+
remainingOverAge:remaining.filter(item=>item.completedAt!==null && now-item.completedAt>c.maxAgeDays*86400000).length};
|
|
32
|
+
});
|
|
33
|
+
const observedBytes=perClass.reduce((n,c)=>n+c.observedBytes,0);
|
|
34
|
+
if(!Number.isSafeInteger(observedBytes))fail('retention-combined.bytes');
|
|
35
|
+
const body={kind:'combined-retention-preview',now,policy:structuredClone(policy),selected,deferred,
|
|
36
|
+
protected:classes.flatMap(c=>c.plan.protected.map(item=>({...item,type:c.type}))),perClass,
|
|
37
|
+
totals:{observedCount:perClass.reduce((n,c)=>n+c.observedCount,0),observedBytes,
|
|
38
|
+
selectedCount:selected.length,selectedBytes:selected.reduce((n,item)=>n+item.bytes,0)},
|
|
39
|
+
requiresFilesystemValidation:true,automaticActions:false};
|
|
40
|
+
return {...body,digest:contractDigest(body)};
|
|
41
|
+
}
|