@lark-apaas/fullstack-cli 1.1.59-alpha.20260719124023 → 1.1.59-alpha.20260720153322
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js
CHANGED
|
@@ -8660,6 +8660,7 @@ function previewArtifactBuildEnvironment(environment, appBasePath) {
|
|
|
8660
8660
|
buildEnvironment[key] = value;
|
|
8661
8661
|
}
|
|
8662
8662
|
}
|
|
8663
|
+
buildEnvironment.ASSETS_CDN_PATH = appBasePath;
|
|
8663
8664
|
buildEnvironment.CLIENT_BASE_PATH = appBasePath;
|
|
8664
8665
|
buildEnvironment.NODE_ENV = environment?.NODE_ENV ?? "production";
|
|
8665
8666
|
return buildEnvironment;
|
package/package.json
CHANGED
package/templates/scripts/dev.js
CHANGED
|
@@ -8,7 +8,9 @@ const readline = require('readline');
|
|
|
8
8
|
const {
|
|
9
9
|
applyPreviewArtifactHostOverrides,
|
|
10
10
|
buildListeningPortLookupArgs,
|
|
11
|
+
closePreviewPhaseReporterWithRetry,
|
|
11
12
|
createDeferredTypecheckGate,
|
|
13
|
+
createPreviewPhaseFrameDecoder,
|
|
12
14
|
createPreviewPhaseReporter,
|
|
13
15
|
normalizeTcpPort,
|
|
14
16
|
parseTscWatchSummary,
|
|
@@ -199,6 +201,7 @@ function startProcess({
|
|
|
199
201
|
args,
|
|
200
202
|
cleanupPort,
|
|
201
203
|
phaseDetail = {},
|
|
204
|
+
phaseChannel = false,
|
|
202
205
|
onReady,
|
|
203
206
|
onOutputLine,
|
|
204
207
|
}) {
|
|
@@ -236,12 +239,23 @@ function startProcess({
|
|
|
236
239
|
}
|
|
237
240
|
|
|
238
241
|
while (!stopping) {
|
|
242
|
+
const childEnv = { ...process.env };
|
|
243
|
+
// The parent is the only outbox writer. Child processes receive a narrow
|
|
244
|
+
// producer channel, never the filesystem destination.
|
|
245
|
+
delete childEnv.MIAODA_PREVIEW_PHASE_OUTBOX_ROOT;
|
|
246
|
+
if (phaseChannel) {
|
|
247
|
+
childEnv.MIAODA_PREVIEW_PHASE_FD = '3';
|
|
248
|
+
} else {
|
|
249
|
+
delete childEnv.MIAODA_PREVIEW_PHASE_FD;
|
|
250
|
+
}
|
|
239
251
|
const child = spawn(command, args, {
|
|
240
252
|
detached: true,
|
|
241
|
-
stdio:
|
|
253
|
+
stdio: phaseChannel
|
|
254
|
+
? ['ignore', 'pipe', 'pipe', 'pipe']
|
|
255
|
+
: ['ignore', 'pipe', 'pipe'],
|
|
242
256
|
shell: true,
|
|
243
257
|
cwd: PROJECT_ROOT,
|
|
244
|
-
env:
|
|
258
|
+
env: childEnv,
|
|
245
259
|
});
|
|
246
260
|
|
|
247
261
|
entry.pid = child.pid;
|
|
@@ -307,13 +321,24 @@ function startProcess({
|
|
|
307
321
|
|
|
308
322
|
// Pipe stdout and stderr through readline for timestamped logging
|
|
309
323
|
let typecheckSummaryReported = false;
|
|
310
|
-
|
|
324
|
+
let phaseReported = false;
|
|
325
|
+
const pipeLines = (stream, allowPhaseFallback = false) => {
|
|
311
326
|
const rl = readline.createInterface({
|
|
312
327
|
input: stream,
|
|
313
328
|
crlfDelay: Infinity,
|
|
314
329
|
});
|
|
315
330
|
rl.on('line', line => {
|
|
316
331
|
if (onOutputLine) onOutputLine(line);
|
|
332
|
+
const legacyPhaseCandidate =
|
|
333
|
+
allowPhaseFallback && line.startsWith('[MiaodaPreviewPhase] ');
|
|
334
|
+
if (legacyPhaseCandidate && !phaseReported) {
|
|
335
|
+
phaseReported = previewPhaseReporter.ingest(line, {
|
|
336
|
+
producer: 'vite_plugin',
|
|
337
|
+
allowed_phases: ['vite_http_listening'],
|
|
338
|
+
attempt,
|
|
339
|
+
allow_legacy_stdout: true,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
317
342
|
if (name === 'server-typecheck' && !typecheckSummaryReported) {
|
|
318
343
|
const summary = parseTscWatchSummary(line);
|
|
319
344
|
if (summary) {
|
|
@@ -334,11 +359,38 @@ function startProcess({
|
|
|
334
359
|
try {
|
|
335
360
|
fs.writeSync(logFd, msg);
|
|
336
361
|
} catch {}
|
|
337
|
-
|
|
362
|
+
// Phase fallbacks are normalized by the parent reporter before they
|
|
363
|
+
// enter the shared log. Keep the raw child claim quarantined in the
|
|
364
|
+
// component log so collectors never observe two representations.
|
|
365
|
+
if (!legacyPhaseCandidate) writeOutput(msg);
|
|
338
366
|
});
|
|
339
367
|
};
|
|
340
|
-
if (child.stdout) pipeLines(child.stdout);
|
|
368
|
+
if (child.stdout) pipeLines(child.stdout, phaseChannel);
|
|
341
369
|
if (child.stderr) pipeLines(child.stderr);
|
|
370
|
+
if (phaseChannel && child.stdio[3]) {
|
|
371
|
+
let oversizedPhaseFrameReported = false;
|
|
372
|
+
const phaseDecoder = createPreviewPhaseFrameDecoder({
|
|
373
|
+
max_bytes: 16 * 1024,
|
|
374
|
+
on_oversized_frame: () => {
|
|
375
|
+
if (oversizedPhaseFrameReported) return;
|
|
376
|
+
oversizedPhaseFrameReported = true;
|
|
377
|
+
logEvent(
|
|
378
|
+
'WARN',
|
|
379
|
+
name,
|
|
380
|
+
'Dropped an oversized preview phase producer frame'
|
|
381
|
+
);
|
|
382
|
+
},
|
|
383
|
+
on_frame: line => {
|
|
384
|
+
if (phaseReported) return;
|
|
385
|
+
phaseReported = previewPhaseReporter.ingest(line, {
|
|
386
|
+
producer: 'vite_plugin',
|
|
387
|
+
allowed_phases: ['vite_http_listening'],
|
|
388
|
+
attempt,
|
|
389
|
+
});
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
child.stdio[3].on('data', chunk => phaseDecoder.push(chunk));
|
|
393
|
+
}
|
|
342
394
|
|
|
343
395
|
// Wait for the direct child to exit.
|
|
344
396
|
// NOTE: must use 'exit', not 'close'. With shell:true, grandchild processes
|
|
@@ -468,6 +520,16 @@ async function cleanup() {
|
|
|
468
520
|
|
|
469
521
|
logEvent('INFO', 'main', 'All processes stopped');
|
|
470
522
|
|
|
523
|
+
const previewPhaseSealed =
|
|
524
|
+
await closePreviewPhaseReporterWithRetry(previewPhaseReporter);
|
|
525
|
+
if (!previewPhaseSealed) {
|
|
526
|
+
logEvent(
|
|
527
|
+
'ERROR',
|
|
528
|
+
'main',
|
|
529
|
+
'Preview phase outbox did not seal after bounded shutdown retries'
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
471
533
|
try {
|
|
472
534
|
fs.closeSync(devStdLogFd);
|
|
473
535
|
} catch {}
|
|
@@ -475,7 +537,7 @@ async function cleanup() {
|
|
|
475
537
|
fs.closeSync(devLogFd);
|
|
476
538
|
} catch {}
|
|
477
539
|
|
|
478
|
-
process.exit(0);
|
|
540
|
+
process.exit(previewPhaseSealed ? 0 : 1);
|
|
479
541
|
}
|
|
480
542
|
|
|
481
543
|
process.on('SIGTERM', cleanup);
|
|
@@ -644,6 +706,7 @@ async function main() {
|
|
|
644
706
|
command: 'npm',
|
|
645
707
|
args: ['run', 'dev:client'],
|
|
646
708
|
cleanupPort: CLIENT_DEV_PORT,
|
|
709
|
+
phaseChannel: true,
|
|
647
710
|
});
|
|
648
711
|
|
|
649
712
|
writeOutput(`📋 Dev processes running. Press Ctrl+C to stop.\n`);
|
|
@@ -657,7 +720,14 @@ async function main() {
|
|
|
657
720
|
}
|
|
658
721
|
}
|
|
659
722
|
|
|
660
|
-
main().catch(err => {
|
|
723
|
+
main().catch(async err => {
|
|
661
724
|
console.error('Fatal error:', err);
|
|
725
|
+
const previewPhaseSealed =
|
|
726
|
+
await closePreviewPhaseReporterWithRetry(previewPhaseReporter);
|
|
727
|
+
if (!previewPhaseSealed) {
|
|
728
|
+
console.error(
|
|
729
|
+
'Preview phase outbox did not seal after bounded fatal-exit retries'
|
|
730
|
+
);
|
|
731
|
+
}
|
|
662
732
|
process.exit(1);
|
|
663
733
|
});
|
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const DEFAULT_OUTBOX_ROOT = '/tmp/event/miaoda-preview-phase/v1';
|
|
7
|
+
const MAX_EVENT_BYTES = 16 * 1024;
|
|
8
|
+
const DEFAULT_MAX_RUN_BYTES = 2 * 1024 * 1024;
|
|
9
|
+
const DEFAULT_MAX_ROOT_BYTES = 64 * 1024 * 1024;
|
|
10
|
+
const DEFAULT_MAX_ROOT_DIRECTORIES = 128;
|
|
11
|
+
const DEFAULT_MAX_ROOT_SCAN_DIRECTORIES = 256;
|
|
12
|
+
const RUN_ID_PATTERN = /^prv_[A-Za-z0-9_-]{1,60}$/;
|
|
13
|
+
const STATE_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
|
|
14
|
+
const PROCESS_START_TICKS_PATTERN = /^\d+$/;
|
|
15
|
+
|
|
16
|
+
function readLinuxProcessStartTicks(fileSystem, pid) {
|
|
17
|
+
try {
|
|
18
|
+
const stat = fileSystem.readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
19
|
+
// comm (field 2) may contain spaces or parentheses, so split only after
|
|
20
|
+
// its final closing parenthesis. The remaining first field is state (3),
|
|
21
|
+
// therefore starttime (22) is at index 19.
|
|
22
|
+
const commEnd = stat.lastIndexOf(')');
|
|
23
|
+
if (commEnd < 0) return null;
|
|
24
|
+
const fields = stat
|
|
25
|
+
.slice(commEnd + 1)
|
|
26
|
+
.trim()
|
|
27
|
+
.split(/\s+/);
|
|
28
|
+
const startTicks = fields[19];
|
|
29
|
+
return PROCESS_START_TICKS_PATTERN.test(startTicks || '')
|
|
30
|
+
? startTicks
|
|
31
|
+
: null;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function assertSafeDirectory(fileSystem, directoryPath) {
|
|
38
|
+
if (!fileSystem.existsSync(directoryPath)) {
|
|
39
|
+
fileSystem.mkdirSync(directoryPath, { recursive: true, mode: 0o700 });
|
|
40
|
+
}
|
|
41
|
+
const stat = fileSystem.lstatSync(directoryPath);
|
|
42
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
43
|
+
throw new Error(`unsafe preview phase outbox path: ${directoryPath}`);
|
|
44
|
+
}
|
|
45
|
+
fileSystem.chmodSync(directoryPath, 0o700);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function createPreviewPhaseOutbox(options) {
|
|
49
|
+
const fileSystem = options.fs || fs;
|
|
50
|
+
const root = options.root || DEFAULT_OUTBOX_ROOT;
|
|
51
|
+
const randomUUID = options.random_uuid || require('crypto').randomUUID;
|
|
52
|
+
const now = options.now || Date.now;
|
|
53
|
+
const writerInstanceID = options.writer_instance_id || randomUUID();
|
|
54
|
+
const getProcessStartTicks =
|
|
55
|
+
options.get_process_start_ticks ||
|
|
56
|
+
(pid => readLinuxProcessStartTicks(fileSystem, pid));
|
|
57
|
+
const configuredProcessStartTicks = options.process_start_ticks;
|
|
58
|
+
const currentProcessStartTicks =
|
|
59
|
+
configuredProcessStartTicks == null
|
|
60
|
+
? getProcessStartTicks(process.pid)
|
|
61
|
+
: configuredProcessStartTicks;
|
|
62
|
+
const maxRunBytes = options.max_run_bytes ?? DEFAULT_MAX_RUN_BYTES;
|
|
63
|
+
const maxRootBytes = options.max_root_bytes ?? DEFAULT_MAX_ROOT_BYTES;
|
|
64
|
+
const maxRootDirectories =
|
|
65
|
+
options.max_root_directories ?? DEFAULT_MAX_ROOT_DIRECTORIES;
|
|
66
|
+
const maxRootScanDirectories =
|
|
67
|
+
options.max_root_scan_directories ?? DEFAULT_MAX_ROOT_SCAN_DIRECTORIES;
|
|
68
|
+
if (!RUN_ID_PATTERN.test(options.run_id || '')) {
|
|
69
|
+
throw new Error('invalid preview run_id for phase outbox');
|
|
70
|
+
}
|
|
71
|
+
if (!STATE_ID_PATTERN.test(writerInstanceID)) {
|
|
72
|
+
throw new Error('invalid preview phase writer_instance_id');
|
|
73
|
+
}
|
|
74
|
+
if (
|
|
75
|
+
currentProcessStartTicks != null &&
|
|
76
|
+
!PROCESS_START_TICKS_PATTERN.test(currentProcessStartTicks)
|
|
77
|
+
) {
|
|
78
|
+
throw new Error('invalid preview phase process_start_ticks');
|
|
79
|
+
}
|
|
80
|
+
if (!Number.isSafeInteger(maxRunBytes) || maxRunBytes <= 0) {
|
|
81
|
+
throw new Error('invalid preview phase max_run_bytes');
|
|
82
|
+
}
|
|
83
|
+
if (!Number.isSafeInteger(maxRootBytes) || maxRootBytes <= 0) {
|
|
84
|
+
throw new Error('invalid preview phase max_root_bytes');
|
|
85
|
+
}
|
|
86
|
+
if (!Number.isSafeInteger(maxRootDirectories) || maxRootDirectories <= 0) {
|
|
87
|
+
throw new Error('invalid preview phase max_root_directories');
|
|
88
|
+
}
|
|
89
|
+
if (
|
|
90
|
+
!Number.isSafeInteger(maxRootScanDirectories) ||
|
|
91
|
+
maxRootScanDirectories < maxRootDirectories
|
|
92
|
+
) {
|
|
93
|
+
throw new Error('invalid preview phase max_root_scan_directories');
|
|
94
|
+
}
|
|
95
|
+
const runDir = path.join(root, options.run_id);
|
|
96
|
+
const eventsPath = path.join(runDir, 'events.jsonl');
|
|
97
|
+
const metaPath = path.join(runDir, 'meta.json');
|
|
98
|
+
const writerLockPath = path.join(runDir, 'writer.lock');
|
|
99
|
+
const sealPath = path.join(runDir, 'sealed.json');
|
|
100
|
+
const degradedPath = path.join(runDir, 'degraded.json');
|
|
101
|
+
const quiesceRequestPath = path.join(runDir, 'quiesce.request.json');
|
|
102
|
+
let recovered = false;
|
|
103
|
+
let metadata = null;
|
|
104
|
+
let writerLockOwned = false;
|
|
105
|
+
let eventsFD = null;
|
|
106
|
+
let committedBytes = null;
|
|
107
|
+
let degraded = false;
|
|
108
|
+
let fallbackRequired = false;
|
|
109
|
+
let sealRequested = false;
|
|
110
|
+
let closed = false;
|
|
111
|
+
let closeResult = false;
|
|
112
|
+
|
|
113
|
+
function rootQuotaError(message) {
|
|
114
|
+
const error = new Error(message);
|
|
115
|
+
error.code = 'PREVIEW_PHASE_ROOT_QUOTA_EXCEEDED';
|
|
116
|
+
return error;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function inspectRootUsage() {
|
|
120
|
+
const entries = fileSystem.readdirSync(root, { withFileTypes: true });
|
|
121
|
+
if (entries.length > maxRootScanDirectories) {
|
|
122
|
+
throw rootQuotaError(
|
|
123
|
+
`preview phase root scan exceeds ${maxRootScanDirectories} entries`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
let directories = 0;
|
|
127
|
+
let bytes = 0;
|
|
128
|
+
const disappearingEntry = error =>
|
|
129
|
+
error?.code === 'ENOENT' || error?.code === 'ENOTDIR';
|
|
130
|
+
for (const entry of entries) {
|
|
131
|
+
const entryPath = path.join(root, entry.name);
|
|
132
|
+
let stat;
|
|
133
|
+
try {
|
|
134
|
+
stat = fileSystem.lstatSync(entryPath);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
// The Go consumer atomically renames and removes acknowledged runs.
|
|
137
|
+
// A root snapshot can therefore contain an entry that is gone by the
|
|
138
|
+
// following lstat; disappearance is successful GC, not degradation.
|
|
139
|
+
if (disappearingEntry(error)) continue;
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
if (stat.isSymbolicLink()) {
|
|
143
|
+
throw new Error(`unsafe preview phase outbox path: ${entryPath}`);
|
|
144
|
+
}
|
|
145
|
+
if (stat.isFile()) {
|
|
146
|
+
bytes += stat.size;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (!stat.isDirectory()) {
|
|
150
|
+
throw new Error(`unsafe preview phase outbox path: ${entryPath}`);
|
|
151
|
+
}
|
|
152
|
+
directories += 1;
|
|
153
|
+
let runEntries;
|
|
154
|
+
try {
|
|
155
|
+
runEntries = fileSystem.readdirSync(entryPath, {
|
|
156
|
+
withFileTypes: true,
|
|
157
|
+
});
|
|
158
|
+
} catch (error) {
|
|
159
|
+
if (disappearingEntry(error)) continue;
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
for (const runEntry of runEntries) {
|
|
163
|
+
const statePath = path.join(entryPath, runEntry.name);
|
|
164
|
+
let stateStat;
|
|
165
|
+
try {
|
|
166
|
+
stateStat = fileSystem.lstatSync(statePath);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (disappearingEntry(error)) continue;
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
if (stateStat.isSymbolicLink() || !stateStat.isFile()) {
|
|
172
|
+
throw new Error(`unsafe preview phase outbox path: ${statePath}`);
|
|
173
|
+
}
|
|
174
|
+
bytes += stateStat.size;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { bytes, directories };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function assertRootDirectoryQuota() {
|
|
181
|
+
const usage = inspectRootUsage();
|
|
182
|
+
if (
|
|
183
|
+
!fileSystem.existsSync(runDir) &&
|
|
184
|
+
usage.directories >= maxRootDirectories
|
|
185
|
+
) {
|
|
186
|
+
throw rootQuotaError(
|
|
187
|
+
`preview phase root directory quota reached ${maxRootDirectories}`
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function assertRootByteQuota(recordBytes) {
|
|
193
|
+
const usage = inspectRootUsage();
|
|
194
|
+
if (usage.bytes + recordBytes > maxRootBytes) {
|
|
195
|
+
throw rootQuotaError(
|
|
196
|
+
`preview phase root quota would exceed ${maxRootBytes} bytes`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function readRegularJSON(filePath, description) {
|
|
202
|
+
const stat = fileSystem.lstatSync(filePath);
|
|
203
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
204
|
+
throw new Error(`unsafe preview phase outbox path: ${filePath}`);
|
|
205
|
+
}
|
|
206
|
+
const value = JSON.parse(fileSystem.readFileSync(filePath, 'utf8'));
|
|
207
|
+
if (!value || typeof value !== 'object') {
|
|
208
|
+
throw new Error(`invalid preview phase ${description}: ${filePath}`);
|
|
209
|
+
}
|
|
210
|
+
return value;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function syncRunDirectory() {
|
|
214
|
+
const flags =
|
|
215
|
+
fs.constants.O_RDONLY |
|
|
216
|
+
(fs.constants.O_DIRECTORY || 0) |
|
|
217
|
+
(fs.constants.O_NOFOLLOW || 0);
|
|
218
|
+
const fd = fileSystem.openSync(runDir, flags);
|
|
219
|
+
try {
|
|
220
|
+
fileSystem.fsyncSync(fd);
|
|
221
|
+
} finally {
|
|
222
|
+
fileSystem.closeSync(fd);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function writeAll(fd, payload) {
|
|
227
|
+
let offset = 0;
|
|
228
|
+
while (offset < payload.length) {
|
|
229
|
+
const written = fileSystem.writeSync(
|
|
230
|
+
fd,
|
|
231
|
+
payload,
|
|
232
|
+
offset,
|
|
233
|
+
payload.length - offset
|
|
234
|
+
);
|
|
235
|
+
if (!Number.isInteger(written) || written <= 0) {
|
|
236
|
+
throw new Error('preview phase state write made no progress');
|
|
237
|
+
}
|
|
238
|
+
offset += written;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function publishImmutableState(filePath, label, value) {
|
|
243
|
+
const tempPath = path.join(
|
|
244
|
+
runDir,
|
|
245
|
+
`.${label}.${process.pid}.${writerInstanceID}.tmp`
|
|
246
|
+
);
|
|
247
|
+
let fd = null;
|
|
248
|
+
try {
|
|
249
|
+
const flags =
|
|
250
|
+
fs.constants.O_CREAT |
|
|
251
|
+
fs.constants.O_EXCL |
|
|
252
|
+
fs.constants.O_WRONLY |
|
|
253
|
+
(fs.constants.O_NOFOLLOW || 0);
|
|
254
|
+
fd = fileSystem.openSync(tempPath, flags, 0o600);
|
|
255
|
+
writeAll(fd, Buffer.from(`${JSON.stringify(value)}\n`, 'utf8'));
|
|
256
|
+
fileSystem.fsyncSync(fd);
|
|
257
|
+
fileSystem.closeSync(fd);
|
|
258
|
+
fd = null;
|
|
259
|
+
fileSystem.linkSync(tempPath, filePath);
|
|
260
|
+
syncRunDirectory();
|
|
261
|
+
} finally {
|
|
262
|
+
if (fd != null) {
|
|
263
|
+
try {
|
|
264
|
+
fileSystem.closeSync(fd);
|
|
265
|
+
} catch {}
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
fileSystem.rmSync(tempPath, { force: true });
|
|
269
|
+
} catch {
|
|
270
|
+
// A stale temp file is harmless and never consumed by the reader.
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function readAndValidateMetadata() {
|
|
276
|
+
const stat = fileSystem.lstatSync(metaPath);
|
|
277
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
278
|
+
throw new Error(`unsafe preview phase outbox path: ${metaPath}`);
|
|
279
|
+
}
|
|
280
|
+
fileSystem.chmodSync(metaPath, 0o600);
|
|
281
|
+
const metadata = JSON.parse(fileSystem.readFileSync(metaPath, 'utf8'));
|
|
282
|
+
if (
|
|
283
|
+
metadata.protocol !== 'preview-phase-outbox-v1' ||
|
|
284
|
+
metadata.schema_version !== 1 ||
|
|
285
|
+
metadata.run_id !== options.run_id ||
|
|
286
|
+
!/^[A-Za-z0-9_-]{1,128}$/.test(metadata.generation || '') ||
|
|
287
|
+
!Number.isFinite(metadata.created_at_ms) ||
|
|
288
|
+
metadata.created_at_ms <= 0 ||
|
|
289
|
+
(metadata.quiesce_supported !== undefined &&
|
|
290
|
+
metadata.quiesce_supported !== true)
|
|
291
|
+
) {
|
|
292
|
+
throw new Error(`invalid preview phase outbox metadata: ${metaPath}`);
|
|
293
|
+
}
|
|
294
|
+
return metadata;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function ensureMetadata() {
|
|
298
|
+
if (metadata) return;
|
|
299
|
+
if (fileSystem.existsSync(metaPath)) {
|
|
300
|
+
metadata = readAndValidateMetadata();
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const candidate = {
|
|
304
|
+
protocol: 'preview-phase-outbox-v1',
|
|
305
|
+
schema_version: 1,
|
|
306
|
+
generation: randomUUID(),
|
|
307
|
+
run_id: options.run_id,
|
|
308
|
+
created_at_ms: now(),
|
|
309
|
+
quiesce_supported: true,
|
|
310
|
+
};
|
|
311
|
+
const tempPath = path.join(
|
|
312
|
+
runDir,
|
|
313
|
+
`.meta.${process.pid}.${candidate.generation}.tmp`
|
|
314
|
+
);
|
|
315
|
+
try {
|
|
316
|
+
fileSystem.writeFileSync(tempPath, `${JSON.stringify(candidate)}\n`, {
|
|
317
|
+
encoding: 'utf8',
|
|
318
|
+
flag: 'wx',
|
|
319
|
+
mode: 0o600,
|
|
320
|
+
});
|
|
321
|
+
try {
|
|
322
|
+
fileSystem.linkSync(tempPath, metaPath);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
325
|
+
}
|
|
326
|
+
} finally {
|
|
327
|
+
try {
|
|
328
|
+
fileSystem.rmSync(tempPath, { force: true });
|
|
329
|
+
} catch {
|
|
330
|
+
// A stale temp file is harmless and never consumed by the reader.
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
metadata = readAndValidateMetadata();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function validateProcessIdentityState(state, description) {
|
|
337
|
+
if (
|
|
338
|
+
!Number.isSafeInteger(state.pid) ||
|
|
339
|
+
state.pid <= 0 ||
|
|
340
|
+
(state.process_start_ticks !== undefined &&
|
|
341
|
+
!PROCESS_START_TICKS_PATTERN.test(state.process_start_ticks))
|
|
342
|
+
) {
|
|
343
|
+
throw new Error(`invalid preview phase ${description}`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function validateWriterLock(lock) {
|
|
348
|
+
assertStateGeneration(lock, 'writer lock');
|
|
349
|
+
validateProcessIdentityState(lock, 'writer lock');
|
|
350
|
+
if (
|
|
351
|
+
lock.protocol !== 'preview-phase-writer-v1' ||
|
|
352
|
+
lock.schema_version !== 1 ||
|
|
353
|
+
!STATE_ID_PATTERN.test(lock.writer_instance_id || '') ||
|
|
354
|
+
!Number.isFinite(lock.created_at_ms) ||
|
|
355
|
+
lock.created_at_ms <= 0
|
|
356
|
+
) {
|
|
357
|
+
throw new Error(`invalid preview phase writer lock: ${writerLockPath}`);
|
|
358
|
+
}
|
|
359
|
+
return lock;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function writerBusyError() {
|
|
363
|
+
const busy = new Error(
|
|
364
|
+
`preview phase generation already has an active writer: ${options.run_id}`
|
|
365
|
+
);
|
|
366
|
+
busy.code = 'PREVIEW_PHASE_WRITER_BUSY';
|
|
367
|
+
return busy;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function acquireWriterLock() {
|
|
371
|
+
if (writerLockOwned) return;
|
|
372
|
+
const lock = {
|
|
373
|
+
protocol: 'preview-phase-writer-v1',
|
|
374
|
+
schema_version: 1,
|
|
375
|
+
run_id: options.run_id,
|
|
376
|
+
generation: metadata.generation,
|
|
377
|
+
writer_instance_id: writerInstanceID,
|
|
378
|
+
pid: process.pid,
|
|
379
|
+
...(currentProcessStartTicks == null
|
|
380
|
+
? {}
|
|
381
|
+
: { process_start_ticks: currentProcessStartTicks }),
|
|
382
|
+
created_at_ms: now(),
|
|
383
|
+
};
|
|
384
|
+
const tempPath = path.join(
|
|
385
|
+
runDir,
|
|
386
|
+
`.writer.${process.pid}.${writerInstanceID}.tmp`
|
|
387
|
+
);
|
|
388
|
+
try {
|
|
389
|
+
fileSystem.writeFileSync(tempPath, `${JSON.stringify(lock)}\n`, {
|
|
390
|
+
encoding: 'utf8',
|
|
391
|
+
flag: 'wx',
|
|
392
|
+
mode: 0o600,
|
|
393
|
+
});
|
|
394
|
+
try {
|
|
395
|
+
fileSystem.linkSync(tempPath, writerLockPath);
|
|
396
|
+
writerLockOwned = true;
|
|
397
|
+
syncRunDirectory();
|
|
398
|
+
} catch (error) {
|
|
399
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
400
|
+
// Producers never delete or replace a lock they did not publish. Only
|
|
401
|
+
// the shell-server can prove owner death and finalize the generation;
|
|
402
|
+
// this removes path-ABA races between concurrent JS successors.
|
|
403
|
+
validateWriterLock(readRegularJSON(writerLockPath, 'writer lock'));
|
|
404
|
+
throw writerBusyError();
|
|
405
|
+
}
|
|
406
|
+
} finally {
|
|
407
|
+
try {
|
|
408
|
+
fileSystem.rmSync(tempPath, { force: true });
|
|
409
|
+
} catch {
|
|
410
|
+
// A stale temp file is harmless and never consumed by the reader.
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function assertStateGeneration(state, description) {
|
|
416
|
+
if (
|
|
417
|
+
state.run_id !== options.run_id ||
|
|
418
|
+
state.generation !== metadata.generation
|
|
419
|
+
) {
|
|
420
|
+
const error = new Error(
|
|
421
|
+
`preview phase ${description} generation mismatch: ${options.run_id}`
|
|
422
|
+
);
|
|
423
|
+
error.code = 'PREVIEW_PHASE_GENERATION_MISMATCH';
|
|
424
|
+
throw error;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function assertGenerationIsOpen() {
|
|
429
|
+
if (!fileSystem.existsSync(sealPath)) return;
|
|
430
|
+
const seal = readAndValidateSeal();
|
|
431
|
+
const error = new Error(
|
|
432
|
+
`preview phase sealed generation cannot be reopened: ${options.run_id}`
|
|
433
|
+
);
|
|
434
|
+
error.code = 'PREVIEW_PHASE_GENERATION_SEALED';
|
|
435
|
+
throw error;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function readAndValidateSeal() {
|
|
439
|
+
const seal = readRegularJSON(sealPath, 'seal');
|
|
440
|
+
assertStateGeneration(seal, 'seal');
|
|
441
|
+
if (
|
|
442
|
+
seal.protocol !== 'preview-phase-seal-v1' ||
|
|
443
|
+
seal.schema_version !== 1 ||
|
|
444
|
+
!Number.isSafeInteger(seal.final_size_bytes) ||
|
|
445
|
+
seal.final_size_bytes < 0 ||
|
|
446
|
+
!Number.isFinite(seal.sealed_at_ms) ||
|
|
447
|
+
seal.sealed_at_ms <= 0 ||
|
|
448
|
+
(seal.fallback_required !== undefined &&
|
|
449
|
+
typeof seal.fallback_required !== 'boolean')
|
|
450
|
+
) {
|
|
451
|
+
throw new Error(`invalid preview phase seal: ${sealPath}`);
|
|
452
|
+
}
|
|
453
|
+
return seal;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function observeDegradedMarker() {
|
|
457
|
+
if (!fileSystem.existsSync(degradedPath)) return false;
|
|
458
|
+
const marker = readRegularJSON(degradedPath, 'degraded marker');
|
|
459
|
+
assertStateGeneration(marker, 'degraded marker');
|
|
460
|
+
if (
|
|
461
|
+
marker.protocol !== 'preview-phase-degraded-v1' ||
|
|
462
|
+
marker.schema_version !== 1 ||
|
|
463
|
+
typeof marker.first_failed_event_id !== 'string' ||
|
|
464
|
+
!Number.isFinite(marker.degraded_at_ms) ||
|
|
465
|
+
marker.degraded_at_ms <= 0 ||
|
|
466
|
+
!STATE_ID_PATTERN.test(marker.reason || '')
|
|
467
|
+
) {
|
|
468
|
+
throw new Error(`invalid preview phase degraded marker: ${degradedPath}`);
|
|
469
|
+
}
|
|
470
|
+
degraded = true;
|
|
471
|
+
fallbackRequired = true;
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function assertGenerationIsNotDegraded() {
|
|
476
|
+
if (!observeDegradedMarker()) return;
|
|
477
|
+
throw degradedError();
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function removeOwnedWriterLock() {
|
|
481
|
+
const lock = readRegularJSON(writerLockPath, 'writer lock');
|
|
482
|
+
if (
|
|
483
|
+
lock.protocol !== 'preview-phase-writer-v1' ||
|
|
484
|
+
lock.schema_version !== 1 ||
|
|
485
|
+
lock.run_id !== options.run_id ||
|
|
486
|
+
lock.generation !== metadata.generation ||
|
|
487
|
+
lock.writer_instance_id !== writerInstanceID
|
|
488
|
+
) {
|
|
489
|
+
throw new Error('preview phase writer lock ownership changed');
|
|
490
|
+
}
|
|
491
|
+
fileSystem.rmSync(writerLockPath);
|
|
492
|
+
writerLockOwned = false;
|
|
493
|
+
syncRunDirectory();
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function recoverTornTail() {
|
|
497
|
+
if (recovered) return;
|
|
498
|
+
if (!fileSystem.existsSync(eventsPath)) {
|
|
499
|
+
recovered = true;
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
const readFlags = fs.constants.O_RDWR | (fs.constants.O_NOFOLLOW || 0);
|
|
503
|
+
const fd = fileSystem.openSync(eventsPath, readFlags);
|
|
504
|
+
try {
|
|
505
|
+
const size = fileSystem.fstatSync(fd).size;
|
|
506
|
+
if (size === 0) {
|
|
507
|
+
recovered = true;
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
const tailByte = Buffer.allocUnsafe(1);
|
|
511
|
+
fileSystem.readSync(fd, tailByte, 0, 1, size - 1);
|
|
512
|
+
if (tailByte[0] === 0x0a) {
|
|
513
|
+
recovered = true;
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const chunk = Buffer.allocUnsafe(64 * 1024);
|
|
518
|
+
let scanEnd = size;
|
|
519
|
+
while (scanEnd > 0) {
|
|
520
|
+
const scanStart = Math.max(0, scanEnd - chunk.length);
|
|
521
|
+
const length = scanEnd - scanStart;
|
|
522
|
+
const bytesRead = fileSystem.readSync(fd, chunk, 0, length, scanStart);
|
|
523
|
+
const lastNewline = chunk.lastIndexOf(0x0a, bytesRead - 1);
|
|
524
|
+
if (lastNewline >= 0) {
|
|
525
|
+
fileSystem.ftruncateSync(fd, scanStart + lastNewline + 1);
|
|
526
|
+
recovered = true;
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
scanEnd = scanStart;
|
|
530
|
+
}
|
|
531
|
+
fileSystem.ftruncateSync(fd, 0);
|
|
532
|
+
recovered = true;
|
|
533
|
+
} finally {
|
|
534
|
+
fileSystem.closeSync(fd);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function degradedError() {
|
|
539
|
+
const error = new Error('preview phase outbox is degraded');
|
|
540
|
+
error.code = 'PREVIEW_PHASE_OUTBOX_DEGRADED';
|
|
541
|
+
return error;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function latchDegraded(event, cause) {
|
|
545
|
+
fallbackRequired = true;
|
|
546
|
+
if (degraded) return;
|
|
547
|
+
degraded = true;
|
|
548
|
+
if (!metadata) return;
|
|
549
|
+
const reason =
|
|
550
|
+
typeof cause?.code === 'string' && STATE_ID_PATTERN.test(cause.code)
|
|
551
|
+
? cause.code
|
|
552
|
+
: 'append_failed';
|
|
553
|
+
try {
|
|
554
|
+
publishImmutableState(degradedPath, 'degraded', {
|
|
555
|
+
protocol: 'preview-phase-degraded-v1',
|
|
556
|
+
schema_version: 1,
|
|
557
|
+
run_id: options.run_id,
|
|
558
|
+
generation: metadata.generation,
|
|
559
|
+
first_failed_event_id:
|
|
560
|
+
typeof event?.event_id === 'string' ? event.event_id : '',
|
|
561
|
+
degraded_at_ms: now(),
|
|
562
|
+
reason,
|
|
563
|
+
});
|
|
564
|
+
} catch {
|
|
565
|
+
// A total filesystem failure cannot be made durable. The in-memory latch
|
|
566
|
+
// still prevents this writer from creating holes in the JSONL prefix.
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function append(event) {
|
|
571
|
+
if (closed) throw new Error('preview phase outbox is closed');
|
|
572
|
+
if (degraded) throw degradedError();
|
|
573
|
+
const payload = JSON.stringify(event);
|
|
574
|
+
if (Buffer.byteLength(payload, 'utf8') > MAX_EVENT_BYTES) {
|
|
575
|
+
const error = new Error(
|
|
576
|
+
`preview phase event exceeds ${MAX_EVENT_BYTES} bytes`
|
|
577
|
+
);
|
|
578
|
+
error.code = 'PREVIEW_PHASE_EVENT_TOO_LARGE';
|
|
579
|
+
throw error;
|
|
580
|
+
}
|
|
581
|
+
const record = Buffer.from(`${payload}\n`, 'utf8');
|
|
582
|
+
try {
|
|
583
|
+
assertSafeDirectory(fileSystem, root);
|
|
584
|
+
assertRootDirectoryQuota();
|
|
585
|
+
assertSafeDirectory(fileSystem, runDir);
|
|
586
|
+
ensureMetadata();
|
|
587
|
+
assertGenerationIsOpen();
|
|
588
|
+
acquireWriterLock();
|
|
589
|
+
// Any existing writer identity is authoritative to JS. Dead-owner
|
|
590
|
+
// recovery and torn-tail finalization belong exclusively to shell-server.
|
|
591
|
+
assertGenerationIsNotDegraded();
|
|
592
|
+
assertRootByteQuota(record.length);
|
|
593
|
+
if (eventsFD == null) {
|
|
594
|
+
if (fileSystem.existsSync(eventsPath)) {
|
|
595
|
+
const stat = fileSystem.lstatSync(eventsPath);
|
|
596
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
597
|
+
throw new Error(`unsafe preview phase outbox path: ${eventsPath}`);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
recoverTornTail();
|
|
601
|
+
const flags =
|
|
602
|
+
fs.constants.O_CREAT |
|
|
603
|
+
fs.constants.O_WRONLY |
|
|
604
|
+
fs.constants.O_APPEND |
|
|
605
|
+
(fs.constants.O_NOFOLLOW || 0);
|
|
606
|
+
eventsFD = fileSystem.openSync(eventsPath, flags, 0o600);
|
|
607
|
+
fileSystem.fchmodSync(eventsFD, 0o600);
|
|
608
|
+
committedBytes = fileSystem.fstatSync(eventsFD).size;
|
|
609
|
+
}
|
|
610
|
+
if (committedBytes + record.length > maxRunBytes) {
|
|
611
|
+
const error = new Error(
|
|
612
|
+
`preview phase run quota would exceed ${maxRunBytes} bytes`
|
|
613
|
+
);
|
|
614
|
+
error.code = 'PREVIEW_PHASE_RUN_QUOTA_EXCEEDED';
|
|
615
|
+
throw error;
|
|
616
|
+
}
|
|
617
|
+
let offset = 0;
|
|
618
|
+
while (offset < record.length) {
|
|
619
|
+
const written = fileSystem.writeSync(
|
|
620
|
+
eventsFD,
|
|
621
|
+
record,
|
|
622
|
+
offset,
|
|
623
|
+
record.length - offset
|
|
624
|
+
);
|
|
625
|
+
if (!Number.isInteger(written) || written <= 0) {
|
|
626
|
+
throw new Error('preview phase outbox write made no progress');
|
|
627
|
+
}
|
|
628
|
+
offset += written;
|
|
629
|
+
}
|
|
630
|
+
committedBytes += record.length;
|
|
631
|
+
return true;
|
|
632
|
+
} catch (error) {
|
|
633
|
+
if (eventsFD != null) {
|
|
634
|
+
try {
|
|
635
|
+
fileSystem.closeSync(eventsFD);
|
|
636
|
+
} catch {}
|
|
637
|
+
eventsFD = null;
|
|
638
|
+
committedBytes = null;
|
|
639
|
+
recovered = false;
|
|
640
|
+
}
|
|
641
|
+
latchDegraded(event, error);
|
|
642
|
+
throw error;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function flush() {
|
|
647
|
+
if (eventsFD == null) return true;
|
|
648
|
+
try {
|
|
649
|
+
fileSystem.fdatasyncSync(eventsFD);
|
|
650
|
+
return true;
|
|
651
|
+
} catch (error) {
|
|
652
|
+
fallbackRequired = true;
|
|
653
|
+
throw error;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function pollQuiesce() {
|
|
658
|
+
if (closed) return closeResult;
|
|
659
|
+
if (sealRequested) return close();
|
|
660
|
+
// A reporter that never acquired this generation's writer lock must not
|
|
661
|
+
// acknowledge or act on a request intended for the actual owner.
|
|
662
|
+
if (!metadata || !writerLockOwned) return false;
|
|
663
|
+
if (!fileSystem.existsSync(quiesceRequestPath)) return false;
|
|
664
|
+
const request = readRegularJSON(quiesceRequestPath, 'quiesce request');
|
|
665
|
+
assertStateGeneration(request, 'quiesce request');
|
|
666
|
+
if (
|
|
667
|
+
request.protocol !== 'preview-phase-quiesce-v1' ||
|
|
668
|
+
request.schema_version !== 1 ||
|
|
669
|
+
!STATE_ID_PATTERN.test(request.request_id || '') ||
|
|
670
|
+
!Number.isFinite(request.requested_at_ms) ||
|
|
671
|
+
request.requested_at_ms <= 0
|
|
672
|
+
) {
|
|
673
|
+
throw new Error(
|
|
674
|
+
`invalid preview phase quiesce request: ${quiesceRequestPath}`
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
return close();
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function close() {
|
|
681
|
+
if (closed) return closeResult;
|
|
682
|
+
sealRequested = true;
|
|
683
|
+
let finalSize = null;
|
|
684
|
+
try {
|
|
685
|
+
// A reporter that never attempted persistence has no generation to
|
|
686
|
+
// close. Persistence failures set degraded/fallbackRequired and retry
|
|
687
|
+
// this initialization when the filesystem becomes available again.
|
|
688
|
+
if (!metadata && !degraded && !fallbackRequired) {
|
|
689
|
+
closed = true;
|
|
690
|
+
closeResult = true;
|
|
691
|
+
return true;
|
|
692
|
+
}
|
|
693
|
+
assertSafeDirectory(fileSystem, root);
|
|
694
|
+
assertRootDirectoryQuota();
|
|
695
|
+
assertSafeDirectory(fileSystem, runDir);
|
|
696
|
+
ensureMetadata();
|
|
697
|
+
|
|
698
|
+
if (fileSystem.existsSync(sealPath)) {
|
|
699
|
+
const seal = readAndValidateSeal();
|
|
700
|
+
fallbackRequired ||= seal.fallback_required === true;
|
|
701
|
+
if (writerLockOwned) {
|
|
702
|
+
removeOwnedWriterLock();
|
|
703
|
+
} else if (fileSystem.existsSync(writerLockPath)) {
|
|
704
|
+
closeResult = false;
|
|
705
|
+
return false;
|
|
706
|
+
}
|
|
707
|
+
closed = true;
|
|
708
|
+
closeResult = true;
|
|
709
|
+
return true;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (!writerLockOwned) {
|
|
713
|
+
acquireWriterLock();
|
|
714
|
+
}
|
|
715
|
+
observeDegradedMarker();
|
|
716
|
+
if (eventsFD != null) {
|
|
717
|
+
fileSystem.fdatasyncSync(eventsFD);
|
|
718
|
+
finalSize = fileSystem.fstatSync(eventsFD).size;
|
|
719
|
+
fileSystem.closeSync(eventsFD);
|
|
720
|
+
eventsFD = null;
|
|
721
|
+
committedBytes = null;
|
|
722
|
+
} else {
|
|
723
|
+
recoverTornTail();
|
|
724
|
+
// The first append can fail after the writer lock is acquired but
|
|
725
|
+
// before events.jsonl is created (for example, aggregate quota). Such
|
|
726
|
+
// a degraded generation still needs an empty, sealed prefix so the
|
|
727
|
+
// lock is releasable and the reader can merge stdout_fallback safely.
|
|
728
|
+
const flags =
|
|
729
|
+
fs.constants.O_CREAT |
|
|
730
|
+
fs.constants.O_RDWR |
|
|
731
|
+
(fs.constants.O_NOFOLLOW || 0);
|
|
732
|
+
const fd = fileSystem.openSync(eventsPath, flags, 0o600);
|
|
733
|
+
try {
|
|
734
|
+
fileSystem.fchmodSync(fd, 0o600);
|
|
735
|
+
fileSystem.fdatasyncSync(fd);
|
|
736
|
+
finalSize = fileSystem.fstatSync(fd).size;
|
|
737
|
+
} finally {
|
|
738
|
+
fileSystem.closeSync(fd);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
publishImmutableState(sealPath, 'sealed', {
|
|
742
|
+
protocol: 'preview-phase-seal-v1',
|
|
743
|
+
schema_version: 1,
|
|
744
|
+
run_id: options.run_id,
|
|
745
|
+
generation: metadata.generation,
|
|
746
|
+
final_size_bytes: finalSize,
|
|
747
|
+
sealed_at_ms: now(),
|
|
748
|
+
fallback_required: fallbackRequired,
|
|
749
|
+
});
|
|
750
|
+
removeOwnedWriterLock();
|
|
751
|
+
closeResult = true;
|
|
752
|
+
closed = true;
|
|
753
|
+
} catch {
|
|
754
|
+
closeResult = false;
|
|
755
|
+
fallbackRequired = true;
|
|
756
|
+
if (eventsFD != null) {
|
|
757
|
+
try {
|
|
758
|
+
fileSystem.closeSync(eventsFD);
|
|
759
|
+
} catch {}
|
|
760
|
+
eventsFD = null;
|
|
761
|
+
committedBytes = null;
|
|
762
|
+
}
|
|
763
|
+
// Keep the unique writer owner and pending close intent for transient
|
|
764
|
+
// retries. If the process dies, shell-server is the sole authority that
|
|
765
|
+
// verifies PID+starttime, repairs the tail, and seals the generation.
|
|
766
|
+
}
|
|
767
|
+
return closeResult;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
return {
|
|
771
|
+
append,
|
|
772
|
+
flush,
|
|
773
|
+
close,
|
|
774
|
+
poll_quiesce: pollQuiesce,
|
|
775
|
+
events_path: eventsPath,
|
|
776
|
+
meta_path: metaPath,
|
|
777
|
+
seal_path: sealPath,
|
|
778
|
+
degraded_path: degradedPath,
|
|
779
|
+
quiesce_request_path: quiesceRequestPath,
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
module.exports = {
|
|
784
|
+
DEFAULT_OUTBOX_ROOT,
|
|
785
|
+
DEFAULT_MAX_RUN_BYTES,
|
|
786
|
+
DEFAULT_MAX_ROOT_BYTES,
|
|
787
|
+
DEFAULT_MAX_ROOT_DIRECTORIES,
|
|
788
|
+
DEFAULT_MAX_ROOT_SCAN_DIRECTORIES,
|
|
789
|
+
MAX_EVENT_BYTES,
|
|
790
|
+
createPreviewPhaseOutbox,
|
|
791
|
+
};
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
|
+
const crypto = require('crypto');
|
|
4
5
|
const net = require('net');
|
|
5
6
|
const path = require('path');
|
|
7
|
+
const { createPreviewPhaseOutbox } = require('./preview-phase-outbox.cjs');
|
|
6
8
|
|
|
7
9
|
const PREFIX = '[MiaodaPreviewPhase] ';
|
|
8
|
-
const RUN_ID_PATTERN = /^prv_[A-Za-z0-9_-]{1,
|
|
10
|
+
const RUN_ID_PATTERN = /^prv_[A-Za-z0-9_-]{1,60}$/;
|
|
9
11
|
|
|
10
12
|
function resolveAppId(env) {
|
|
11
13
|
const basePath = env.CLIENT_BASE_PATH || '';
|
|
@@ -13,30 +15,340 @@ function resolveAppId(env) {
|
|
|
13
15
|
return match ? match[1] : env.MIAODA_APP_ID || '';
|
|
14
16
|
}
|
|
15
17
|
|
|
18
|
+
function createPreviewPhaseFrameDecoder(options = {}) {
|
|
19
|
+
const maxBytes = options.max_bytes || 16 * 1024;
|
|
20
|
+
const onFrame = options.on_frame;
|
|
21
|
+
const onOversizedFrame = options.on_oversized_frame || (() => {});
|
|
22
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
|
|
23
|
+
throw new Error('preview phase frame max_bytes must be a positive integer');
|
|
24
|
+
}
|
|
25
|
+
if (typeof onFrame !== 'function') {
|
|
26
|
+
throw new Error('preview phase frame on_frame callback is required');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let pending = [];
|
|
30
|
+
let pendingBytes = 0;
|
|
31
|
+
let droppingOversizedFrame = false;
|
|
32
|
+
|
|
33
|
+
function reset() {
|
|
34
|
+
pending = [];
|
|
35
|
+
pendingBytes = 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function push(chunk) {
|
|
39
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
40
|
+
let offset = 0;
|
|
41
|
+
while (offset < buffer.length) {
|
|
42
|
+
const newline = buffer.indexOf(0x0a, offset);
|
|
43
|
+
const end = newline < 0 ? buffer.length : newline;
|
|
44
|
+
const segment = buffer.subarray(offset, end);
|
|
45
|
+
|
|
46
|
+
if (!droppingOversizedFrame) {
|
|
47
|
+
if (pendingBytes + segment.length > maxBytes) {
|
|
48
|
+
reset();
|
|
49
|
+
droppingOversizedFrame = true;
|
|
50
|
+
onOversizedFrame();
|
|
51
|
+
} else if (segment.length > 0) {
|
|
52
|
+
pending.push(segment);
|
|
53
|
+
pendingBytes += segment.length;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (newline < 0) return;
|
|
58
|
+
if (!droppingOversizedFrame) {
|
|
59
|
+
onFrame(Buffer.concat(pending, pendingBytes).toString('utf8'));
|
|
60
|
+
}
|
|
61
|
+
reset();
|
|
62
|
+
droppingOversizedFrame = false;
|
|
63
|
+
offset = newline + 1;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { push };
|
|
68
|
+
}
|
|
69
|
+
|
|
16
70
|
function createPreviewPhaseReporter(options = {}) {
|
|
17
71
|
const env = options.env || process.env;
|
|
18
72
|
const write = options.write || (line => process.stdout.write(`${line}\n`));
|
|
19
73
|
const now = options.now || Date.now;
|
|
74
|
+
const randomUUID = options.random_uuid || crypto.randomUUID;
|
|
75
|
+
const producer = options.producer || 'dev_orchestrator';
|
|
76
|
+
const onDiagnostic =
|
|
77
|
+
options.on_diagnostic ||
|
|
78
|
+
(diagnostic => {
|
|
79
|
+
try {
|
|
80
|
+
process.stderr.write(
|
|
81
|
+
`[MiaodaPreviewOutbox] ${JSON.stringify(diagnostic)}\n`
|
|
82
|
+
);
|
|
83
|
+
} catch {
|
|
84
|
+
// Diagnostics must never block preview startup.
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
const producerInstanceID = randomUUID();
|
|
88
|
+
const runId = env.MIAODA_PREVIEW_RUN_ID || '';
|
|
89
|
+
const outbox = RUN_ID_PATTERN.test(runId)
|
|
90
|
+
? createPreviewPhaseOutbox({
|
|
91
|
+
root: options.outbox_root || env.MIAODA_PREVIEW_PHASE_OUTBOX_ROOT,
|
|
92
|
+
run_id: runId,
|
|
93
|
+
fs: options.fs,
|
|
94
|
+
random_uuid: randomUUID,
|
|
95
|
+
now,
|
|
96
|
+
})
|
|
97
|
+
: null;
|
|
98
|
+
const producerSequences = new Map();
|
|
99
|
+
const startupTerminalPhases = new Set();
|
|
100
|
+
let reporterSealed = false;
|
|
101
|
+
let quiescePollTimer = null;
|
|
102
|
+
|
|
103
|
+
function reportDiagnostic(diagnostic) {
|
|
104
|
+
try {
|
|
105
|
+
onDiagnostic(diagnostic);
|
|
106
|
+
} catch {
|
|
107
|
+
// Diagnostics must never become a preview-startup failure mode.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
20
110
|
|
|
21
|
-
function
|
|
22
|
-
|
|
23
|
-
if (!RUN_ID_PATTERN.test(runId)) return false;
|
|
111
|
+
function emitForProducer(eventProducer, phase, status, detail = {}) {
|
|
112
|
+
if (!RUN_ID_PATTERN.test(runId) || reporterSealed) return false;
|
|
24
113
|
const atMs = detail.at_ms == null ? now() : detail.at_ms;
|
|
114
|
+
const producerSeq = (producerSequences.get(eventProducer) || 0) + 1;
|
|
115
|
+
producerSequences.set(eventProducer, producerSeq);
|
|
25
116
|
const event = {
|
|
26
117
|
...detail,
|
|
27
118
|
schema_version: 1,
|
|
119
|
+
event_id: `${runId}:${randomUUID()}`,
|
|
28
120
|
run_id: runId,
|
|
29
121
|
app_id: resolveAppId(env),
|
|
30
122
|
sandbox_id: env.SANDBOX_ID || '',
|
|
31
123
|
phase,
|
|
32
124
|
status,
|
|
33
125
|
at_ms: atMs,
|
|
126
|
+
producer: eventProducer,
|
|
127
|
+
producer_instance_id:
|
|
128
|
+
eventProducer === producer
|
|
129
|
+
? producerInstanceID
|
|
130
|
+
: `${producerInstanceID}:${eventProducer}`,
|
|
131
|
+
producer_seq: producerSeq,
|
|
132
|
+
recorded_at_ms: now(),
|
|
133
|
+
transport: 'outbox',
|
|
34
134
|
};
|
|
35
|
-
|
|
36
|
-
|
|
135
|
+
let persisted = false;
|
|
136
|
+
try {
|
|
137
|
+
persisted = outbox.append(event);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (error?.code === 'PREVIEW_PHASE_EVENT_TOO_LARGE') {
|
|
140
|
+
reportDiagnostic({
|
|
141
|
+
code: 'event_rejected',
|
|
142
|
+
reason: 'event_too_large',
|
|
143
|
+
run_id: runId,
|
|
144
|
+
phase,
|
|
145
|
+
event_id: event.event_id,
|
|
146
|
+
});
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
event.transport = 'stdout_fallback';
|
|
150
|
+
if (error?.code !== 'PREVIEW_PHASE_OUTBOX_DEGRADED') {
|
|
151
|
+
reportDiagnostic({
|
|
152
|
+
code: 'outbox_append_failed',
|
|
153
|
+
run_id: runId,
|
|
154
|
+
phase,
|
|
155
|
+
event_id: event.event_id,
|
|
156
|
+
error: error instanceof Error ? error.message : String(error),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
let mirrored = false;
|
|
161
|
+
try {
|
|
162
|
+
write(`${PREFIX}${JSON.stringify(event)}`);
|
|
163
|
+
mirrored = true;
|
|
164
|
+
} catch (error) {
|
|
165
|
+
reportDiagnostic({
|
|
166
|
+
code: 'stdout_mirror_failed',
|
|
167
|
+
run_id: runId,
|
|
168
|
+
phase,
|
|
169
|
+
event_id: event.event_id,
|
|
170
|
+
error: error instanceof Error ? error.message : String(error),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
const delivered = persisted || mirrored;
|
|
174
|
+
if (
|
|
175
|
+
delivered &&
|
|
176
|
+
status !== 'start' &&
|
|
177
|
+
(phase === 'backend_tcp_ready' || phase === 'vite_http_listening')
|
|
178
|
+
) {
|
|
179
|
+
startupTerminalPhases.add(phase);
|
|
180
|
+
if (startupTerminalPhases.size === 2) {
|
|
181
|
+
flush();
|
|
182
|
+
close();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return delivered;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function emit(phase, status, detail = {}) {
|
|
189
|
+
return emitForProducer(producer, phase, status, detail);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function ingest(rawFrame, context = {}) {
|
|
193
|
+
if (
|
|
194
|
+
typeof rawFrame !== 'string' ||
|
|
195
|
+
Buffer.byteLength(rawFrame, 'utf8') > 16 * 1024
|
|
196
|
+
) {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
const isLegacyStdoutFrame =
|
|
200
|
+
context.allow_legacy_stdout === true && rawFrame.startsWith(PREFIX);
|
|
201
|
+
const serializedFrame = isLegacyStdoutFrame
|
|
202
|
+
? rawFrame.slice(PREFIX.length).trim()
|
|
203
|
+
: rawFrame;
|
|
204
|
+
let frame;
|
|
205
|
+
try {
|
|
206
|
+
frame = JSON.parse(serializedFrame);
|
|
207
|
+
} catch {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
const allowedPhases = Array.isArray(context.allowed_phases)
|
|
211
|
+
? context.allowed_phases
|
|
212
|
+
: [];
|
|
213
|
+
if (
|
|
214
|
+
(!isLegacyStdoutFrame &&
|
|
215
|
+
frame?.protocol !== 'preview_phase_producer_v1') ||
|
|
216
|
+
frame.schema_version !== 1 ||
|
|
217
|
+
frame.run_id !== runId ||
|
|
218
|
+
!allowedPhases.includes(frame.phase) ||
|
|
219
|
+
!/^(?:start|success|error|fallback|skipped|cancelled)$/.test(
|
|
220
|
+
frame.status || ''
|
|
221
|
+
) ||
|
|
222
|
+
!Number.isFinite(frame.at_ms) ||
|
|
223
|
+
frame.at_ms <= 0
|
|
224
|
+
) {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
const detail = {
|
|
228
|
+
at_ms: frame.at_ms,
|
|
229
|
+
...(Number.isFinite(frame.duration_ms) && frame.duration_ms >= 0
|
|
230
|
+
? { duration_ms: frame.duration_ms }
|
|
231
|
+
: {}),
|
|
232
|
+
...(Number.isFinite(frame.precision_ms) && frame.precision_ms >= 0
|
|
233
|
+
? { precision_ms: frame.precision_ms }
|
|
234
|
+
: {}),
|
|
235
|
+
...(typeof frame.exact === 'boolean' ? { exact: frame.exact } : {}),
|
|
236
|
+
...(Number.isSafeInteger(context.attempt) && context.attempt > 0
|
|
237
|
+
? { attempt: context.attempt }
|
|
238
|
+
: {}),
|
|
239
|
+
};
|
|
240
|
+
return emitForProducer(
|
|
241
|
+
context.producer || 'child_process',
|
|
242
|
+
frame.phase,
|
|
243
|
+
frame.status,
|
|
244
|
+
detail
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function flush() {
|
|
249
|
+
if (reporterSealed) return true;
|
|
250
|
+
if (!outbox) return true;
|
|
251
|
+
try {
|
|
252
|
+
return outbox.flush();
|
|
253
|
+
} catch (error) {
|
|
254
|
+
reportDiagnostic({
|
|
255
|
+
code: 'outbox_flush_failed',
|
|
256
|
+
run_id: runId,
|
|
257
|
+
error: error instanceof Error ? error.message : String(error),
|
|
258
|
+
});
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
37
261
|
}
|
|
38
262
|
|
|
39
|
-
|
|
263
|
+
function stopQuiescePolling() {
|
|
264
|
+
if (quiescePollTimer == null) return;
|
|
265
|
+
clearInterval(quiescePollTimer);
|
|
266
|
+
quiescePollTimer = null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function pollQuiesce() {
|
|
270
|
+
if (reporterSealed) return true;
|
|
271
|
+
if (!outbox) return false;
|
|
272
|
+
try {
|
|
273
|
+
const sealed = outbox.poll_quiesce();
|
|
274
|
+
if (sealed) {
|
|
275
|
+
reporterSealed = true;
|
|
276
|
+
stopQuiescePolling();
|
|
277
|
+
}
|
|
278
|
+
return sealed;
|
|
279
|
+
} catch (error) {
|
|
280
|
+
reportDiagnostic({
|
|
281
|
+
code: 'outbox_quiesce_failed',
|
|
282
|
+
run_id: runId,
|
|
283
|
+
error: error instanceof Error ? error.message : String(error),
|
|
284
|
+
});
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function close() {
|
|
290
|
+
if (reporterSealed) return true;
|
|
291
|
+
if (!outbox) return true;
|
|
292
|
+
try {
|
|
293
|
+
const sealed = outbox.close();
|
|
294
|
+
if (sealed) {
|
|
295
|
+
reporterSealed = true;
|
|
296
|
+
stopQuiescePolling();
|
|
297
|
+
}
|
|
298
|
+
return sealed;
|
|
299
|
+
} catch (error) {
|
|
300
|
+
reportDiagnostic({
|
|
301
|
+
code: 'outbox_close_failed',
|
|
302
|
+
run_id: runId,
|
|
303
|
+
error: error instanceof Error ? error.message : String(error),
|
|
304
|
+
});
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (outbox) {
|
|
310
|
+
const intervalMs = options.quiesce_poll_interval_ms ?? 100;
|
|
311
|
+
if (Number.isSafeInteger(intervalMs) && intervalMs > 0) {
|
|
312
|
+
quiescePollTimer = setInterval(pollQuiesce, intervalMs);
|
|
313
|
+
if (typeof quiescePollTimer.unref === 'function') {
|
|
314
|
+
quiescePollTimer.unref();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return { emit, ingest, flush, close, pollQuiesce };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function closePreviewPhaseReporterWithRetry(reporter, options = {}) {
|
|
323
|
+
const retryDelaysMs = options.retry_delays_ms || [0, 25, 100, 250, 500, 1000];
|
|
324
|
+
const sleep =
|
|
325
|
+
options.sleep ||
|
|
326
|
+
(delayMs => new Promise(resolve => setTimeout(resolve, delayMs)));
|
|
327
|
+
if (
|
|
328
|
+
!reporter ||
|
|
329
|
+
typeof reporter.flush !== 'function' ||
|
|
330
|
+
typeof reporter.close !== 'function' ||
|
|
331
|
+
!Array.isArray(retryDelaysMs) ||
|
|
332
|
+
retryDelaysMs.length === 0 ||
|
|
333
|
+
retryDelaysMs.some(delayMs => !Number.isSafeInteger(delayMs) || delayMs < 0)
|
|
334
|
+
) {
|
|
335
|
+
throw new Error('invalid preview phase reporter close retry configuration');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
for (const delayMs of retryDelaysMs) {
|
|
339
|
+
if (delayMs > 0) await sleep(delayMs);
|
|
340
|
+
try {
|
|
341
|
+
reporter.flush();
|
|
342
|
+
} catch {
|
|
343
|
+
// close() repeats the durable flush and reports the authoritative result.
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
if (reporter.close()) return true;
|
|
347
|
+
} catch {
|
|
348
|
+
// A later bounded attempt may succeed after a transient filesystem fault.
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return false;
|
|
40
352
|
}
|
|
41
353
|
|
|
42
354
|
function createTscServerCommand(reason, missingModules = []) {
|
|
@@ -449,7 +761,9 @@ async function waitForTcpReady(options) {
|
|
|
449
761
|
module.exports = {
|
|
450
762
|
applyPreviewArtifactHostOverrides,
|
|
451
763
|
buildListeningPortLookupArgs,
|
|
764
|
+
closePreviewPhaseReporterWithRetry,
|
|
452
765
|
createDeferredTypecheckGate,
|
|
766
|
+
createPreviewPhaseFrameDecoder,
|
|
453
767
|
createPreviewPhaseReporter,
|
|
454
768
|
normalizeTcpPort,
|
|
455
769
|
parseTscWatchSummary,
|