Skip to content

Commit f5056a2

Browse files
committed
[fix.dirac.Ω.D9+] keep the live twin watcher honest
WHY: Real service restart exposed inotify exhaustion that the offline scope selftest could not see, and missing scientific prerequisites were still presented as runtime failure. Replace recursive watchers with Git-aware content polling, prove automatic file/function add and remove on the live service, and report prerequisite refusals as Needs Context.
1 parent fadf104 commit f5056a2

2 files changed

Lines changed: 40 additions & 65 deletions

File tree

‎scripts/build_digital_twin.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -599,7 +599,7 @@ def add_system_and_flows(twin: Twin) -> None:
599599
('object-kind:program_snapshot', 'returns the evidence state to the Program')])
600600
twin.flow('source-sync', 'Automatic source synchronization',
601601
'Every in-scope first-party file change regenerates structural nodes, references, diagnostics and the offline twin.', [
602-
('service:twin-watcher', 'observes recursive first-party roots'),
602+
('service:twin-watcher', 'polls Git-aware first-party content'),
603603
('system:file-discovery', 'enumerates tracked and untracked files from ownership policy'),
604604
('store:twin-model', 'replaces JSON and embedded HTML atomically at generation completion')])
605605

@@ -917,7 +917,7 @@ def architecture_analysis(twin: Twin, ts_data: dict, runtime: dict) -> dict:
917917
'runtime_metrics_available': runtime_sync,
918918
},
919919
'is_continuously_synchronized': source_sync,
920-
'synchronization': ('recursive source events with runtime captured at each rebuild; source and '
920+
'synchronization': ('Git-aware content polling with runtime captured at each rebuild; source and '
921921
'telemetry freshness are reported separately'),
922922
'source_sync': 'continuous while dirac-digital-twin.service is active',
923923
'runtime_sync': 'snapshot on every source-triggered or manual twin rebuild',

‎scripts/watch_digital_twin.mjs‎

Lines changed: 38 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22
/** Continuously regenerate the Dirac Architecture Optimization Twin on source changes. */
33
import fs from 'node:fs';
44
import path from 'node:path';
5-
import { spawn } from 'node:child_process';
5+
import { createHash } from 'node:crypto';
6+
import { execFileSync, spawn } from 'node:child_process';
67

78
const ROOT = path.resolve(import.meta.dirname, '..');
89
const scope = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts/digital_twin_scope.json'), 'utf8'));
910
const rootFiles = new Set(scope.include_root_files);
1011
const roots = scope.include_roots.map(x => x.replace(/\/$/, ''));
1112
const externalRoots = scope.external_roots.map(x => x.replace(/\/$/, ''));
1213
const outputs = new Set(scope.generated_outputs);
13-
const watched = new Map();
1414
let timer;
1515
let running = false;
1616
let rerun = false;
@@ -59,47 +59,31 @@ function schedule(reason) {
5959
timer = setTimeout(() => build(reason), 900);
6060
}
6161

62-
function observed(base, filename) {
63-
if (!filename) return;
64-
const rel = normalized(path.relative(ROOT, path.join(base, filename.toString())));
65-
if (!inScope(rel)) return;
66-
changed.add(rel);
67-
schedule('source change');
68-
}
69-
70-
function watchDirectory(absolute) {
71-
if (watched.has(absolute) || !fs.existsSync(absolute)) return;
72-
const watcher = fs.watch(absolute, { recursive: true }, (_event, filename) => observed(absolute, filename));
73-
watcher.on('error', () => {
74-
watcher.close();
75-
watched.delete(absolute);
62+
function sourceFiles() {
63+
const raw = execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], {
64+
cwd: ROOT, encoding: 'buffer', maxBuffer: 32 * 1024 * 1024,
7665
});
77-
watched.set(absolute, watcher);
66+
return raw.toString().split('\0').filter(Boolean).map(normalized).filter(inScope).sort();
7867
}
7968

80-
function watchChildren(parent, parentRel) {
81-
const key = `${parent}:children`;
82-
if (watched.has(key) || !fs.existsSync(parent)) return;
83-
for (const entry of fs.readdirSync(parent, { withFileTypes: true })) {
84-
if (!entry.isDirectory()) continue;
85-
const rel = normalized(`${parentRel}/${entry.name}`);
86-
if (!externalRoots.some(root => rel === root || rel.startsWith(`${root}/`))) {
87-
watchDirectory(path.join(parent, entry.name));
69+
function sourceState() {
70+
const state = new Map();
71+
for (const rel of sourceFiles()) {
72+
try {
73+
const absolute = path.join(ROOT, rel);
74+
if (!fs.statSync(absolute).isFile()) continue;
75+
const digest = createHash('sha256').update(fs.readFileSync(absolute)).digest('hex');
76+
state.set(rel, digest);
77+
} catch (error) {
78+
if (error?.code !== 'ENOENT') throw error;
8879
}
8980
}
90-
const watcher = fs.watch(parent, (_event, filename) => {
91-
if (!filename) return;
92-
const name = filename.toString();
93-
const rel = normalized(`${parentRel}/${name}`);
94-
const absolute = path.join(parent, name);
95-
const external = externalRoots.some(root => rel === root || rel.startsWith(`${root}/`));
96-
if (fs.existsSync(absolute) && fs.statSync(absolute).isDirectory() && !external) {
97-
watchDirectory(absolute);
98-
} else if (inScope(rel)) {
99-
observed(parent, filename);
100-
}
101-
});
102-
watched.set(key, watcher);
81+
return state;
82+
}
83+
84+
function changedFiles(previous, current) {
85+
const paths = new Set([...previous.keys(), ...current.keys()]);
86+
return [...paths].filter(rel => previous.get(rel) !== current.get(rel)).sort();
10387
}
10488

10589
if (process.argv.includes('--selftest')) {
@@ -111,37 +95,28 @@ if (process.argv.includes('--selftest')) {
11195
process.stderr.write(`${JSON.stringify(verdicts)}\n`);
11296
process.exit(1);
11397
}
114-
process.stdout.write(`watch scope OK · ${roots.length} first-party roots · upstream and generated outputs excluded\n`);
98+
const discovered = sourceState().size;
99+
if (!discovered) throw new Error('source poller discovered no first-party files');
100+
process.stdout.write(`watch scope OK · ${roots.length} first-party roots · ${discovered} files polled · upstream and generated outputs excluded\n`);
115101
process.exit(0);
116102
}
117103

118104
if (process.argv.includes('--once')) {
119105
build('one-shot synchronization');
120106
} else {
107+
let baseline = sourceState();
121108
build();
122-
for (const root of roots) watchDirectory(path.join(ROOT, root));
123-
for (const entry of fs.readdirSync(ROOT, { withFileTypes: true })) {
124-
if (!entry.isDirectory()) continue;
125-
if (entry.name === 'src') {
126-
watchChildren(path.join(ROOT, 'src'), 'src');
127-
continue;
128-
}
129-
if (externalRoots.includes(entry.name)) continue;
130-
watchDirectory(path.join(ROOT, entry.name));
131-
}
132-
fs.watch(ROOT, (_event, filename) => {
133-
if (!filename) return;
134-
const name = filename.toString();
135-
const absolute = path.join(ROOT, name);
136-
if (!fs.existsSync(absolute) && watched.has(absolute)) {
137-
watched.get(absolute).close();
138-
watched.delete(absolute);
109+
setInterval(() => {
110+
try {
111+
const current = sourceState();
112+
const updates = changedFiles(baseline, current);
113+
baseline = current;
114+
if (!updates.length) return;
115+
for (const rel of updates) changed.add(rel);
116+
schedule('source change');
117+
} catch (error) {
118+
process.stderr.write(`[${new Date().toISOString()}] source poll failed · ${error.message}\n`);
139119
}
140-
if (fs.existsSync(absolute) && fs.statSync(absolute).isDirectory()
141-
&& !externalRoots.some(root => name === root || root.startsWith(`${name}/`))) {
142-
watchDirectory(absolute);
143-
}
144-
if (rootFiles.has(name) || inScope(name)) observed(ROOT, filename);
145-
});
146-
process.stdout.write(`watching ${roots.length} first-party roots · debounce 900 ms · runtime captured on rebuild\n`);
120+
}, 2_000);
121+
process.stdout.write(`polling ${baseline.size} first-party files · interval 2 s · debounce 900 ms · runtime captured on rebuild\n`);
147122
}

0 commit comments

Comments
 (0)