@redue/cli 0.1.0-alpha.2 → 0.1.0-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +3 -0
- package/docs/user/receipt-selection-notice.md +60 -0
- package/package.json +1 -1
- package/src/cached-state.mjs +22 -7
- package/src/cli.mjs +14 -3
- package/src/daemon.mjs +20 -5
- package/src/state-store.mjs +37 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0-alpha.3 — 2026-10-06
|
|
4
|
+
|
|
5
|
+
Install: `npm install --global @redue/cli@alpha`.
|
|
6
|
+
|
|
7
|
+
- Fix selected execution outcomes remaining hidden behind an older observer/cache
|
|
8
|
+
after a missed reload notification. A newer failure must not leave an older
|
|
9
|
+
passing receipt eligible for reuse.
|
|
10
|
+
- Withhold applicability during incomplete receipt persistence, unreadable or
|
|
11
|
+
changing selection, and client connections to an older observer.
|
|
12
|
+
- Preserve immutable run history. Upgrade requires an observer stop/start;
|
|
13
|
+
receipts need not be deleted. See the [maintainer notice](docs/user/receipt-selection-notice.md).
|
|
14
|
+
|
|
3
15
|
## 0.1.0-alpha.2 — 2026-10-06
|
|
4
16
|
|
|
5
17
|
Install: `npm install --global @redue/cli@alpha`.
|
package/README.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# REDUE
|
|
2
2
|
|
|
3
|
+
Maintainer notice: alpha.2 users should review the
|
|
4
|
+
[receipt-selection correction and interim guidance](docs/user/receipt-selection-notice.md).
|
|
5
|
+
|
|
3
6
|
**Know what still holds. Redo what's due.**
|
|
4
7
|
|
|
5
8
|
You ran the checks. Then the code changed—or a new coding agent started with no
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Receipt selection correction in alpha.3
|
|
2
|
+
|
|
3
|
+
**Known affected release: `@redue/cli@0.1.0-alpha.2`. Fixed in
|
|
4
|
+
`@redue/cli@0.1.0-alpha.3`.** No exploit or affected-user count is established.
|
|
5
|
+
REDUE remains an alpha.
|
|
6
|
+
|
|
7
|
+
If a wrapper persisted a newer outcome but its observer reload notification
|
|
8
|
+
failed, the observer could continue selecting an older passing receipt. Cached
|
|
9
|
+
and synchronized status could then present that older result as CURRENT and
|
|
10
|
+
eligible for reuse. A separate reproduced write-failure window left a new
|
|
11
|
+
immutable outcome recorded while replacing the selected receipt failed.
|
|
12
|
+
|
|
13
|
+
Until upgraded, do not rely solely on affected CURRENT output to skip checks.
|
|
14
|
+
Run verification when needed and inspect its actual outcome; a successful status
|
|
15
|
+
command is not proof that the latest execution passed.
|
|
16
|
+
|
|
17
|
+
## What changes
|
|
18
|
+
|
|
19
|
+
The observer refreshes REDUE-owned receipt selection before publishing status.
|
|
20
|
+
Clients check that a response represents the current readable selection. Missing,
|
|
21
|
+
unreadable, malformed, incompatible, or replaced selection cannot authorize
|
|
22
|
+
reuse. A new client connecting to an alpha.2 observer returns UNVERIFIED and
|
|
23
|
+
asks for a restart, even when the old response says CURRENT.
|
|
24
|
+
|
|
25
|
+
A small owned pending-update record also withholds applicability after an
|
|
26
|
+
incomplete receipt write. Successful recording clears only that check's pending
|
|
27
|
+
entry. It does not discard another check's incomplete outcome or automatically
|
|
28
|
+
promote an orphaned journal entry. Inspect the failed run and storage error, fix
|
|
29
|
+
the storage problem, then record that check again. An unreadable/malformed pending
|
|
30
|
+
record requires operator inspection; do not delete receipts to obtain green status.
|
|
31
|
+
|
|
32
|
+
Historical PASS/FAIL and immutable `runs-v1` files remain intact. Stable applicable
|
|
33
|
+
FAIL remains a failure and is never reuse-eligible. This guard tracks owned
|
|
34
|
+
receipt replacement; it does not replace input-content validation or promise an
|
|
35
|
+
atomic decision against concurrent edits/writes.
|
|
36
|
+
|
|
37
|
+
## Upgrade and restart
|
|
38
|
+
|
|
39
|
+
From each initialized project, stop its observer before replacing the installation.
|
|
40
|
+
After all observers for that installation have stopped, upgrade it once, then
|
|
41
|
+
restart each observer with its existing configuration and state selection:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
redue stop
|
|
45
|
+
npm install --global @redue/cli@0.1.0-alpha.3
|
|
46
|
+
redue --version
|
|
47
|
+
redue start
|
|
48
|
+
redue status --sync --json
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Use the same npm `--prefix`, project `--config`, and `--state-dir` you already use,
|
|
52
|
+
if customized. On Windows use `npm.cmd`/`redue.cmd` where required by the existing
|
|
53
|
+
shell policy. Stop all observers belonging to the installation before replacing
|
|
54
|
+
it, then start each with the upgraded binary. For an independently installed
|
|
55
|
+
candidate, its `stop` command can stop the older observer in the same explicitly
|
|
56
|
+
selected owned state. Do not run `remove-state`; receipts need not be deleted.
|
|
57
|
+
|
|
58
|
+
Restart preserves historical receipts and reconciles inputs. It does not promise
|
|
59
|
+
CURRENT: a newer failure, unstable execution, incomplete contract, changed input,
|
|
60
|
+
or observation uncertainty remains visible under the normal rules.
|
package/package.json
CHANGED
package/src/cached-state.mjs
CHANGED
|
@@ -2,22 +2,37 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import {processAlive} from './process-liveness.mjs';
|
|
5
|
+
import {readReceipts,readReceiptSnapshot,receiptRevision} from './state-store.mjs';
|
|
5
6
|
const unknown=(reason,checks)=>({schema:1,state:'unverified',current:0,stale:0,failed:0,observed_failed:0,
|
|
6
7
|
unverified:checks.length,
|
|
7
8
|
checks:checks.map(c=>({name:c.name,result:null,freshness:'UNVERIFIED',reason,
|
|
8
9
|
invocation:null,changed_inputs:[],reuse_eligible:false})),
|
|
9
10
|
observation:{healthy:false,reason}});
|
|
10
11
|
export function unavailableCached(state,reason,checks=[]) {
|
|
11
|
-
try
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
12
|
+
let value;try{value=JSON.parse(fs.readFileSync(path.join(state,'status.json')));}
|
|
13
|
+
catch{value=unknown(reason,checks);}
|
|
14
|
+
if(!value||!Array.isArray(value.checks))value=unknown(reason,checks);
|
|
15
|
+
let receipts=null,revision=null;
|
|
16
|
+
try{const snapshot=readReceiptSnapshot(state);receipts=snapshot.value.checks;revision=snapshot.revision;}
|
|
17
|
+
catch(error){reason+=`; latest receipt unavailable; prior outcome only: ${error.message}`;
|
|
18
|
+
try{receipts=readReceipts(state).checks;}catch{/* retain cached historical rows */}}
|
|
19
|
+
const rows=(value.checks||[]).map(row=>{
|
|
20
|
+
const latest=receipts?.[row.name];
|
|
21
|
+
return {...row,...(receipts?{result:latest?.result||null,invocation:latest?.invocation||null,
|
|
22
|
+
target_provenance:latest?.target||null,coverage_at_run:latest?.coverage||null}:{}),
|
|
23
|
+
freshness:'UNVERIFIED',reason,reuse_eligible:false,declared_inputs_match:null};
|
|
24
|
+
});
|
|
25
|
+
return {...value,receipt_revision:revision,state:'unverified',current:0,stale:0,failed:0,
|
|
26
|
+
observed_failed:rows.filter(row=>row.result==='FAIL').length,
|
|
27
|
+
unverified:Math.max(1,rows.length),checks:rows,
|
|
28
|
+
observation:{...value.observation,healthy:false,reason}};
|
|
18
29
|
}
|
|
19
30
|
export function readCachedStatus(state,checks=[]) {
|
|
20
31
|
try{const value=JSON.parse(fs.readFileSync(path.join(state,'status.json')));
|
|
32
|
+
if(typeof value.receipt_revision!=='string')
|
|
33
|
+
return unavailableCached(state,'receipt selection is unconfirmed; synchronize status or restart an older observer',checks);
|
|
34
|
+
if(value.receipt_revision!==receiptRevision(state))
|
|
35
|
+
return unavailableCached(state,'receipt selection changed; synchronize status to evaluate the latest outcome',checks);
|
|
21
36
|
if(Date.now()>=value.updated_at&&Date.now()<=value.expires_at&&
|
|
22
37
|
value.expires_at-value.updated_at<=3000){
|
|
23
38
|
if(processAlive(value.pid))return value;
|
package/src/cli.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import {createHash,randomUUID} from 'node:crypto';
|
|
|
15
15
|
import {assertOwnedStatePlacement} from './owned-state.mjs';
|
|
16
16
|
import {permittedRoots} from './installed-inputs.mjs';
|
|
17
17
|
import {acquireRunLock,inspectRunLock,recoverRunLock} from './run-lock.mjs';
|
|
18
|
-
import {atomicJson,commitReceipt} from './state-store.mjs';
|
|
18
|
+
import {atomicJson,commitReceipt,receiptRevision} from './state-store.mjs';
|
|
19
19
|
import {timing} from './decision-profile.mjs';
|
|
20
20
|
import {readCachedStatus,unavailableCached} from './cached-state.mjs';
|
|
21
21
|
import {renderRun} from './presentation.mjs';
|
|
@@ -284,7 +284,7 @@ async function execute() {
|
|
|
284
284
|
const persistenceStarted=performance.now();
|
|
285
285
|
commitReceipt(state,name,receipt);
|
|
286
286
|
timing('cli.receipt_persistence',persistenceStarted);
|
|
287
|
-
try{await call({action:'reload'});}catch{/*
|
|
287
|
+
try{await call({action:'reload'});}catch{/* receipt revisions invalidate older status even when reload IPC is unavailable */}
|
|
288
288
|
const summary={check:name,result,target:target.status,
|
|
289
289
|
invocation:invocation.status,...(invocation.errorCode?{error_code:invocation.errorCode}:{}),
|
|
290
290
|
stable,coverage_qualified:receipt.coverage.qualified};
|
|
@@ -376,7 +376,18 @@ async function main(){
|
|
|
376
376
|
if(action==='sync'||action==='detail'){
|
|
377
377
|
const started=performance.now();
|
|
378
378
|
let value;try{value=await decisionCall({action:'sync',contextHashes:contextHashes()},
|
|
379
|
-
Number(process.env.VSTATE_SYNC_TIMEOUT_MS||decisionDeadlineMs));
|
|
379
|
+
Number(process.env.VSTATE_SYNC_TIMEOUT_MS||decisionDeadlineMs));
|
|
380
|
+
// A client upgrade can encounter an older running observer. Its successful
|
|
381
|
+
// control response does not establish that it evaluated the latest receipt.
|
|
382
|
+
if(value.receipt_revision===null){
|
|
383
|
+
value=unavailableCached(state,'latest receipt selection unavailable; inspect receipt state before reuse',config.checks);
|
|
384
|
+
}else if(typeof value.receipt_revision!=='string'){
|
|
385
|
+
value=unavailableCached(state,'observer cannot confirm latest receipt selection; stop and start the observer after upgrading',config.checks);
|
|
386
|
+
process.exitCode=2;
|
|
387
|
+
}else if(value.receipt_revision!==receiptRevision(state)){
|
|
388
|
+
value=unavailableCached(state,'receipt selection changed during synchronization; retry synchronized status',config.checks);
|
|
389
|
+
process.exitCode=2;
|
|
390
|
+
}}
|
|
380
391
|
catch(e){value=unavailableCached(state,e.message,config.checks);process.exitCode=2;}
|
|
381
392
|
timing('cli.synchronized_status',started,{available:value.observation?.healthy?1:0});
|
|
382
393
|
if(action==='sync')console.log(JSON.stringify(value));
|
package/src/daemon.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import {sha} from './plan.mjs';
|
|
|
14
14
|
import {InputIndex} from './index.mjs';
|
|
15
15
|
import {permittedRoots} from './installed-inputs.mjs';
|
|
16
16
|
import {assertOwnedStatePlacement} from './owned-state.mjs';
|
|
17
|
-
import {atomicJson,readReceipts} from './state-store.mjs';
|
|
17
|
+
import {atomicJson,readReceipts,readReceiptSnapshot,receiptRevision} from './state-store.mjs';
|
|
18
18
|
import {history} from './observation.mjs';
|
|
19
19
|
import {inputKey} from './decision-validation.mjs';
|
|
20
20
|
import {timing} from './decision-profile.mjs';
|
|
@@ -33,6 +33,7 @@ fs.writeFileSync(path.join(lock,'pid'),String(process.pid),{mode:0o600});
|
|
|
33
33
|
const generation=randomUUID(), startedAt=Date.now();
|
|
34
34
|
let index, receipts={}, watcher, healthy=false, reason='initial reconciliation', stopping=false;
|
|
35
35
|
let receiptStateError=null;
|
|
36
|
+
let receiptStateRevision=null;
|
|
36
37
|
const external=new Map();
|
|
37
38
|
for(const check of config.checks)for(const boundary of permittedRoots(root,check.allowedExternalRoots||[]))
|
|
38
39
|
if(!external.has(boundary))external.set(boundary,{root:boundary,watcher:null,index:null,
|
|
@@ -77,9 +78,20 @@ function event(kind,fields={}) {
|
|
|
77
78
|
repository:root,...fields})+'\n');
|
|
78
79
|
}
|
|
79
80
|
function loadReceipts() {
|
|
80
|
-
try {
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
try {const snapshot=readReceiptSnapshot(state);receipts=snapshot.value.checks;
|
|
82
|
+
receiptStateRevision=snapshot.revision;receiptStateError=null;}
|
|
83
|
+
catch(e){receiptStateRevision=null;
|
|
84
|
+
// A readable selector can still supply explicitly historical outcomes after
|
|
85
|
+
// restart. The failed snapshot guard remains authoritative for applicability.
|
|
86
|
+
try{receipts=readReceipts(state).checks;}catch{/* retain previous historical rows */}
|
|
87
|
+
const next=`latest receipt unavailable; prior outcome only: ${e.message}`;
|
|
88
|
+
if(receiptStateError!==next)event('receipt_state_unavailable',
|
|
89
|
+
{classification:e.name||'Error'});
|
|
90
|
+
receiptStateError=next;}
|
|
91
|
+
}
|
|
92
|
+
function refreshReceipts() {
|
|
93
|
+
try {if(receiptStateRevision===null||receiptRevision(state)!==receiptStateRevision)loadReceipts();}
|
|
94
|
+
catch {loadReceipts();}
|
|
83
95
|
}
|
|
84
96
|
function persistedPlan(bundle) {
|
|
85
97
|
return {schema:1,provider:bundle.provider,root:bundle.root,discoveredAt:bundle.discoveredAt,
|
|
@@ -298,13 +310,16 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
298
310
|
});
|
|
299
311
|
}
|
|
300
312
|
function status(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=false) {
|
|
313
|
+
// Reload notifications accelerate visibility but are not required for it: a
|
|
314
|
+
// wrapper may persist its outcome even when its control connection is denied.
|
|
315
|
+
refreshReceipts();
|
|
301
316
|
const checks=rows(contextHashes,synchronizedInstalled,synchronizedFilesystem), current=checks.filter(r=>r.freshness==='CURRENT'&&r.result==='PASS').length,
|
|
302
317
|
failed=checks.filter(r=>r.freshness==='CURRENT'&&r.result==='FAIL').length,
|
|
303
318
|
observedFailed=checks.filter(r=>r.result==='FAIL').length,
|
|
304
319
|
stale=checks.filter(r=>r.freshness==='STALE').length,
|
|
305
320
|
unverified=checks.filter(r=>r.freshness==='UNVERIFIED').length||(!checks.length?1:0);
|
|
306
321
|
const stateName=failed?'failed':unverified?'unverified':stale?'stale':'current';
|
|
307
|
-
const now=Date.now();return {schema:1,state:stateName,current,failed,observed_failed:observedFailed,
|
|
322
|
+
const now=Date.now();return {schema:1,receipt_revision:receiptStateRevision,state:stateName,current,failed,observed_failed:observedFailed,
|
|
308
323
|
stale,unverified,checks,
|
|
309
324
|
observation:{healthy:healthy&&!planning&&!recovering&&!index?.unavailable,
|
|
310
325
|
generation,observed_at:lastObservation,
|
package/src/state-store.mjs
CHANGED
|
@@ -19,19 +19,55 @@ export function readReceipts(state){
|
|
|
19
19
|
let value;
|
|
20
20
|
try{value=JSON.parse(fs.readFileSync(path.join(state,'receipts-v1.json'),'utf8'));}
|
|
21
21
|
catch(e){if(e.code==='ENOENT')return {schema:STATE_SCHEMA,checks:{}};throw e;}
|
|
22
|
-
if(value
|
|
22
|
+
if(!value||typeof value!=='object'||Array.isArray(value)||value.schema!==STATE_SCHEMA||
|
|
23
|
+
!value.checks||typeof value.checks!=='object'||Array.isArray(value.checks))
|
|
23
24
|
throw Error(`incompatible receipt state in ${state}; inspect or remove only this vstate state directory`);
|
|
24
25
|
return value;
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
// This identity detects replacement of REDUE's owned, atomically written receipt
|
|
29
|
+
// selector. It is not an input-content fingerprint or permission to reuse inputs.
|
|
30
|
+
export function receiptRevision(state){
|
|
31
|
+
// A failed/crashed owned writer may have persisted a new immutable outcome
|
|
32
|
+
// without replacing the selector. Never certify its older selection.
|
|
33
|
+
try {fs.lstatSync(path.join(state,'receipt-selection-pending-v1.json'));
|
|
34
|
+
throw Error('receipt selection update incomplete; inspect the failed run, then record a new execution to recover');
|
|
35
|
+
}catch(error){if(error.code!=='ENOENT')throw error;}
|
|
36
|
+
try {const stat=fs.statSync(path.join(state,'receipts-v1.json'),{bigint:true});
|
|
37
|
+
if(!stat.isFile())throw Error('receipt selector is not a file');
|
|
38
|
+
return JSON.stringify(['dev','ino','size','mtimeNs','ctimeNs'].map(key=>String(stat[key])));
|
|
39
|
+
}catch(error){if(error.code==='ENOENT')return 'absent';throw error;}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function readReceiptSnapshot(state){
|
|
43
|
+
const revision=receiptRevision(state),value=readReceipts(state);
|
|
44
|
+
if(receiptRevision(state)!==revision)
|
|
45
|
+
throw Error('receipt selection changed during read; retry status');
|
|
46
|
+
return {revision,value};
|
|
47
|
+
}
|
|
48
|
+
|
|
27
49
|
export function commitReceipt(state,name,receipt){
|
|
28
50
|
const prior=readReceipts(state),runId=receipt.invocation?.runId;
|
|
29
51
|
if(!/^[0-9a-f-]{36}$/.test(runId||''))throw Error('receipt lacks a valid run ID');
|
|
30
52
|
const runs=path.join(state,'runs-v1');
|
|
31
53
|
fs.mkdirSync(runs,{recursive:true,mode:0o700});
|
|
54
|
+
// The caller holds the state run lock. Leave this marker on any write failure;
|
|
55
|
+
// only a later successful owned commit may clear it. No journal scan guesses
|
|
56
|
+
// which incomplete execution should become the selected evidence.
|
|
57
|
+
const pending=path.join(state,'receipt-selection-pending-v1.json');
|
|
58
|
+
let updates={schema:STATE_SCHEMA,checks:{}};
|
|
59
|
+
try {updates=JSON.parse(fs.readFileSync(pending,'utf8'));
|
|
60
|
+
if(updates?.schema!==STATE_SCHEMA||!updates.checks||typeof updates.checks!=='object'||Array.isArray(updates.checks))
|
|
61
|
+
throw Error('incompatible incomplete receipt update; inspect owned state before recording');
|
|
62
|
+
}catch(error){if(error.code!=='ENOENT')throw error;}
|
|
63
|
+
updates.checks[name]={runId};
|
|
64
|
+
atomicJson(pending,updates);
|
|
32
65
|
// One immutable outcome per invocation. Never overwrite an existing run.
|
|
33
66
|
fs.writeFileSync(path.join(runs,runId+'.json'),
|
|
34
67
|
JSON.stringify({schema:STATE_SCHEMA,check:name,receipt}),{flag:'wx',mode:0o600});
|
|
35
68
|
prior.checks[name]=receipt;
|
|
36
69
|
atomicJson(path.join(state,'receipts-v1.json'),prior);
|
|
70
|
+
delete updates.checks[name];
|
|
71
|
+
if(Object.keys(updates.checks).length)atomicJson(pending,updates);
|
|
72
|
+
else fs.unlinkSync(pending);
|
|
37
73
|
}
|