@ngockhoale/ukit 3.0.12 → 3.1.1
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 +21 -0
- package/README.md +1 -0
- package/manifests/documentation.yaml +12 -0
- package/manifests/platform.full.yaml +24 -0
- package/package.json +1 -1
- package/scripts/bench/data-foundation.mjs +368 -50
- package/src/cli/commands/doctor.js +232 -3
- package/src/cli/commands/feedback.js +64 -1
- package/src/cli/commands/install.js +18 -0
- package/src/cli/commands/memory.js +42 -37
- package/src/cli/commands/telemetry.js +460 -0
- package/src/cli/index.js +7 -0
- package/src/core/agentRuntime/adapters.js +83 -2
- package/src/core/agentRuntime/diagnostics.js +104 -0
- package/src/core/agentRuntime/supervisor.js +137 -0
- package/src/core/agentRuntime/telemetry.js +204 -0
- package/src/core/memory/memoryEmit.js +131 -0
- package/src/core/memory/memoryHit.js +1 -1
- package/src/core/memory/migrate.js +18 -11
- package/src/core/memory/migrateMapping.js +15 -7
- package/src/core/memory/mutateMemory.js +22 -4
- package/src/core/memory/recordIndex.js +10 -3
- package/src/core/memory/recordStore.js +28 -3
- package/src/core/memory/retrieval.js +79 -38
- package/src/core/memory/store.js +37 -37
- package/src/core/memory/storeV2.js +28 -25
- package/src/core/memory/storeV2Loader.js +2 -2
- package/src/core/observability/adapters/ingest.js +576 -0
- package/src/core/observability/analytics/anomalies.js +415 -0
- package/src/core/observability/analytics/summary.js +16 -1
- package/src/core/observability/emit/config.js +69 -1
- package/src/core/observability/emit/crash.js +434 -0
- package/src/core/observability/emit/lifecycle.js +349 -0
- package/src/core/observability/emit/recorder.js +135 -9
- package/src/core/observability/evaluation/aiPacket.js +52 -10
- package/src/core/observability/evaluation/outcomes.js +95 -0
- package/src/core/observability/evaluation/runner.js +225 -0
- package/src/core/observability/privacy/allowlist.js +23 -3
- package/src/core/observability/schema/compatibility.js +48 -3
- package/src/core/observability/schema/constants.js +5 -0
- package/src/core/observability/schema/registry.js +57 -0
- package/src/core/observability/schema/validate.js +68 -6
- package/src/core/observability/segments/internal.js +42 -8
- package/src/core/observability/segments/readSegments.js +35 -1
- package/src/core/observability/segments/recovery.js +3 -2
- package/src/core/observability/segments/retention.js +137 -33
- package/src/core/observability/support/projector.js +88 -18
- package/src/core/observability/support/provision.js +160 -0
- package/src/core/observability/support/renderer.js +2 -2
- package/src/core/observability/support/schedule.js +174 -0
- package/src/decision/registry.js +144 -0
- package/src/decision/reviewVerdict.js +309 -0
- package/template_project/.claude/agents/code-reviewer.md +25 -1
- package/template_project/.claude/agents/ukit-small-task-maintainer.md +16 -0
- package/template_project/.claude/commands/ukit/handoff-fullstack.md +2 -0
- package/template_project/.claude/commands/ukit/handoff-review.md +12 -0
- package/template_project/.claude/hooks/auto-allow-bash.sh +7 -1
- package/template_project/.claude/hooks/auto-prune-bash.sh +16 -7
- package/template_project/.claude/hooks/verification-guard.sh +13 -4
- package/template_project/.claude/ukit/index/review-verdict.mjs +592 -0
- package/template_project/.claude/ukit/index/sidecar-decision.mjs +595 -0
- package/template_project/.claude/ukit/index/unic-decision.mjs +10 -1
- package/template_project/.claude/ukit/runtime/async-lock.mjs +26 -0
- package/template_project/.codex/settings.json +3 -0
- package/template_project/.omp/agents/code-reviewer.md +25 -1
- package/template_project/.omp/agents/ukit-small-task-maintainer.md +16 -0
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
// validateSemanticRecord(record, context?) → { ok: boolean, errors: string[] }
|
|
4
4
|
//
|
|
5
5
|
// Validates one semantic envelope against SCHEMA_VERSION 1. Never throws:
|
|
6
|
-
// malformed input returns { ok: false, errors }. Unknown
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// malformed input returns { ok: false, errors }. Unknown ENVELOPE fields
|
|
7
|
+
// reject (the envelope is the frozen contract surface — DF2-FR01); unknown
|
|
8
|
+
// payload fields are tolerated. Unknown REQUIRED-field values
|
|
9
|
+
// (schema_version, record_type, semantic_name, vocabularies) are rejected.
|
|
9
10
|
//
|
|
10
11
|
// `context` is optional cross-record state for stream validation:
|
|
11
12
|
// { knownSpanIds?: Set<string>, seenSequences?: Set<string> }
|
|
@@ -17,6 +18,7 @@
|
|
|
17
18
|
|
|
18
19
|
import {
|
|
19
20
|
SCHEMA_VERSION,
|
|
21
|
+
ENVELOPE_FIELDS,
|
|
20
22
|
REQUIRED_ENVELOPE_FIELDS,
|
|
21
23
|
RECORD_TYPES,
|
|
22
24
|
PRIVACY_CLASSES,
|
|
@@ -26,8 +28,23 @@ import {
|
|
|
26
28
|
} from './constants.js';
|
|
27
29
|
import { SEMANTIC_REGISTRY, REASON_CODES } from './registry.js';
|
|
28
30
|
|
|
29
|
-
const OPTIONAL_ID_FIELDS = [
|
|
31
|
+
const OPTIONAL_ID_FIELDS = [
|
|
32
|
+
'trace_id',
|
|
33
|
+
'span_id',
|
|
34
|
+
'parent_span_id',
|
|
35
|
+
'execution_id',
|
|
36
|
+
'session_id',
|
|
37
|
+
'project_ref',
|
|
38
|
+
'agent_id',
|
|
39
|
+
'project_instance_id',
|
|
40
|
+
];
|
|
30
41
|
const REASON_CODE_FIELDS = ['error_code', 'reason_code', 'wait_code'];
|
|
42
|
+
// Fields a v1 reader knows about: the frozen envelope plus the stamp
|
|
43
|
+
// sanitizeObserved adds before persistence. Anything else at the top level
|
|
44
|
+
// is an unknown envelope field and rejects.
|
|
45
|
+
const KNOWN_ENVELOPE_FIELDS = new Set([...ENVELOPE_FIELDS, 'redaction_version']);
|
|
46
|
+
// DF2-FR01: correlation ids are bounded opaque strings.
|
|
47
|
+
const MAX_ID_FIELD_CHARS = 128;
|
|
31
48
|
const ISO_8601_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
32
49
|
|
|
33
50
|
function isPlainObject(value) {
|
|
@@ -106,8 +123,19 @@ function validatePayload(payload, errors) {
|
|
|
106
123
|
errors.push(`payload.${field} ${JSON.stringify(code)} is not in the reason-code registry`);
|
|
107
124
|
}
|
|
108
125
|
}
|
|
109
|
-
|
|
110
|
-
|
|
126
|
+
// Confidence is either the typed enum (v1) or the v1.1 scored shape
|
|
127
|
+
// {type: CONFIDENCE_TYPE, value: finite number} used by anomaly.detected.
|
|
128
|
+
if (payload.confidence !== undefined && payload.confidence !== null) {
|
|
129
|
+
if (isPlainObject(payload.confidence)) {
|
|
130
|
+
if (!CONFIDENCE_TYPES.includes(payload.confidence.type)) {
|
|
131
|
+
errors.push(`payload.confidence.type must be one of ${CONFIDENCE_TYPES.join('|')} (got ${JSON.stringify(payload.confidence.type)})`);
|
|
132
|
+
}
|
|
133
|
+
if (typeof payload.confidence.value !== 'number' || !Number.isFinite(payload.confidence.value)) {
|
|
134
|
+
errors.push('payload.confidence.value must be a finite number');
|
|
135
|
+
}
|
|
136
|
+
} else if (!CONFIDENCE_TYPES.includes(payload.confidence)) {
|
|
137
|
+
errors.push(`payload.confidence must be one of ${CONFIDENCE_TYPES.join('|')}`);
|
|
138
|
+
}
|
|
111
139
|
}
|
|
112
140
|
if (payload.telemetry_complete !== undefined && typeof payload.telemetry_complete !== 'boolean') {
|
|
113
141
|
errors.push('payload.telemetry_complete must be a boolean');
|
|
@@ -128,6 +156,16 @@ export function validateSemanticRecord(record, context) {
|
|
|
128
156
|
return { ok: false, errors: ['record must be a plain object'] };
|
|
129
157
|
}
|
|
130
158
|
|
|
159
|
+
|
|
160
|
+
// The envelope is the frozen contract surface: any top-level key outside
|
|
161
|
+
// the known field set (envelope + the persisted redaction stamp) rejects.
|
|
162
|
+
// New fields arrive only via schema-versioned additive releases.
|
|
163
|
+
for (const key of Object.keys(record)) {
|
|
164
|
+
if (!KNOWN_ENVELOPE_FIELDS.has(key)) {
|
|
165
|
+
errors.push(`unknown envelope field: ${key}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
131
169
|
for (const field of REQUIRED_ENVELOPE_FIELDS) {
|
|
132
170
|
if (record[field] === undefined || record[field] === null) {
|
|
133
171
|
errors.push(`missing required field: ${field}`);
|
|
@@ -199,6 +237,30 @@ export function validateSemanticRecord(record, context) {
|
|
|
199
237
|
}
|
|
200
238
|
}
|
|
201
239
|
|
|
240
|
+
// DF2-FR01 v1.1 additive shapes — correlation ids are bounded opaque
|
|
241
|
+
// strings; sampling stamps the policy that produced this record.
|
|
242
|
+
for (const field of ['agent_id', 'project_instance_id']) {
|
|
243
|
+
const value = record[field];
|
|
244
|
+
if (value !== undefined && value !== null) {
|
|
245
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_ID_FIELD_CHARS) {
|
|
246
|
+
errors.push(`${field} must be a non-empty string of at most ${MAX_ID_FIELD_CHARS} characters`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (record.sampling !== undefined && record.sampling !== null) {
|
|
251
|
+
const sampling = record.sampling;
|
|
252
|
+
if (!isPlainObject(sampling)) {
|
|
253
|
+
errors.push('sampling must be an object {policy_version, rate}');
|
|
254
|
+
} else {
|
|
255
|
+
if (!isNonEmptyString(sampling.policy_version) || sampling.policy_version.length > MAX_ID_FIELD_CHARS) {
|
|
256
|
+
errors.push(`sampling.policy_version must be a non-empty string of at most ${MAX_ID_FIELD_CHARS} characters`);
|
|
257
|
+
}
|
|
258
|
+
if (typeof sampling.rate !== 'number' || !Number.isFinite(sampling.rate) || sampling.rate < 0 || sampling.rate > 1) {
|
|
259
|
+
errors.push('sampling.rate must be a number in [0, 1]');
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
202
264
|
if (!isPlainObject(record.payload)) {
|
|
203
265
|
errors.push('payload must be an object');
|
|
204
266
|
} else {
|
|
@@ -10,11 +10,38 @@
|
|
|
10
10
|
import fs from 'node:fs';
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import crypto from 'node:crypto';
|
|
13
|
+
import zlib from 'node:zlib';
|
|
13
14
|
|
|
14
15
|
export const ACTIVE_FILE = 'active.jsonl';
|
|
15
16
|
export const STATE_FILE = '_state.json';
|
|
16
17
|
export const QUARANTINE_DIR = 'quarantine';
|
|
17
18
|
export const SEALED_RE = /^seg-(\d+)-([A-Za-z0-9_-]+)-([A-Za-z0-9_-]+)\.jsonl$/;
|
|
19
|
+
export const SEALED_ZST_RE = /^seg-(\d+)-([A-Za-z0-9_-]+)-([A-Za-z0-9_-]+)\.jsonl\.zst$/;
|
|
20
|
+
|
|
21
|
+
// Meta sidecar `compression` field values (SPEC §10): 'zst' sealed via
|
|
22
|
+
// zlib.zstdCompressSync, 'none' plain JSONL (legacy segments and Node <22.15).
|
|
23
|
+
export const COMPRESSION_ZST = 'zst';
|
|
24
|
+
export const COMPRESSION_NONE = 'none';
|
|
25
|
+
|
|
26
|
+
/** zlib provider for a policy/opts object — injectable for tests (policy.zlib). */
|
|
27
|
+
export function zlibOf(source) {
|
|
28
|
+
const z = source && source.zlib;
|
|
29
|
+
return z && typeof z === 'object' ? z : zlib;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let zstdProbe;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* True when zstd compression is usable on this runtime (Node ≥22.15).
|
|
36
|
+
* Presence-checked against `zlib.zstdCompressSync`; the real-zlib result is
|
|
37
|
+
* cached per process — an injected `source.zlib` is evaluated every call.
|
|
38
|
+
*/
|
|
39
|
+
export function zstdAvailable(source) {
|
|
40
|
+
const z = source && source.zlib !== undefined ? zlibOf(source) : null;
|
|
41
|
+
if (z) return typeof z.zstdCompressSync === 'function';
|
|
42
|
+
if (zstdProbe === undefined) zstdProbe = typeof zlib.zstdCompressSync === 'function';
|
|
43
|
+
return zstdProbe;
|
|
44
|
+
}
|
|
18
45
|
|
|
19
46
|
export const DEFAULT_IO_TIMEOUT_MS = 5000;
|
|
20
47
|
|
|
@@ -136,7 +163,11 @@ export async function resolveFileInDir(filePath, timeoutMs = DEFAULT_IO_TIMEOUT_
|
|
|
136
163
|
}
|
|
137
164
|
|
|
138
165
|
export function metaPathFor(segmentPath) {
|
|
139
|
-
|
|
166
|
+
// Per-format sidecar: a .zst segment pairs with seg-*.jsonl.zst.meta.json so
|
|
167
|
+
// an orphaned plain .jsonl (crash mid-seal) can never inherit the zst meta.
|
|
168
|
+
return segmentPath.endsWith('.jsonl.zst')
|
|
169
|
+
? `${segmentPath}.meta.json`
|
|
170
|
+
: segmentPath.replace(/\.jsonl$/, '.meta.json');
|
|
140
171
|
}
|
|
141
172
|
|
|
142
173
|
export function sha256Buffer(buf) {
|
|
@@ -181,11 +212,12 @@ export function sanitizeNameComponent(value) {
|
|
|
181
212
|
|
|
182
213
|
/**
|
|
183
214
|
* Enumerate the segment directory.
|
|
184
|
-
* → { ok: true, sealed: [{ name, seq, path, size }], active: { path, size } | null,
|
|
215
|
+
* → { ok: true, sealed: [{ name, seq, path, size, compressed }], active: { path, size } | null,
|
|
185
216
|
* skipped: [{ name, reason }] }
|
|
186
217
|
* | { ok: false, reason }
|
|
187
|
-
* Symlinks are never followed — reported as skipped. Foreign .jsonl
|
|
188
|
-
* match neither the active nor sealed naming scheme are skipped,
|
|
218
|
+
* Symlinks are never followed — reported as skipped. Foreign .jsonl/.jsonl.zst
|
|
219
|
+
* files that match neither the active nor sealed naming scheme are skipped,
|
|
220
|
+
* never read. `compressed` marks seg-*.jsonl.zst entries (zstd-sealed).
|
|
189
221
|
*/
|
|
190
222
|
export async function listSegments(root, timeoutMs = DEFAULT_IO_TIMEOUT_MS) {
|
|
191
223
|
let entries;
|
|
@@ -201,7 +233,7 @@ export async function listSegments(root, timeoutMs = DEFAULT_IO_TIMEOUT_MS) {
|
|
|
201
233
|
for (const entry of entries) {
|
|
202
234
|
const name = entry.name;
|
|
203
235
|
if (name === QUARANTINE_DIR || name === STATE_FILE || name.endsWith('.meta.json')) continue;
|
|
204
|
-
if (!name.endsWith('.jsonl')) continue;
|
|
236
|
+
if (!name.endsWith('.jsonl') && !name.endsWith('.jsonl.zst')) continue;
|
|
205
237
|
if (entry.isSymbolicLink()) {
|
|
206
238
|
skipped.push({ name, reason: 'symlink_skipped' });
|
|
207
239
|
continue;
|
|
@@ -223,8 +255,10 @@ export async function listSegments(root, timeoutMs = DEFAULT_IO_TIMEOUT_MS) {
|
|
|
223
255
|
continue;
|
|
224
256
|
}
|
|
225
257
|
const m = SEALED_RE.exec(name);
|
|
226
|
-
|
|
227
|
-
|
|
258
|
+
const mz = m ? null : SEALED_ZST_RE.exec(name);
|
|
259
|
+
if (m || mz) {
|
|
260
|
+
const hit = m || mz;
|
|
261
|
+
sealed.push({ name, seq: Number.parseInt(hit[1], 10), path: full, size, compressed: Boolean(mz) });
|
|
228
262
|
} else {
|
|
229
263
|
skipped.push({ name, reason: 'unrecognized_segment_name' });
|
|
230
264
|
}
|
|
@@ -233,7 +267,7 @@ export async function listSegments(root, timeoutMs = DEFAULT_IO_TIMEOUT_MS) {
|
|
|
233
267
|
return { ok: true, sealed, active, skipped };
|
|
234
268
|
}
|
|
235
269
|
|
|
236
|
-
/** Total bytes held in live
|
|
270
|
+
/** Total bytes held in live segment files (meta/state excluded). */
|
|
237
271
|
export function totalSegmentBytes(listing) {
|
|
238
272
|
let total = listing.active ? listing.active.size : 0;
|
|
239
273
|
for (const seg of listing.sealed) total += seg.size;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Typed reader over the append-only JSONL segment store.
|
|
5
5
|
*
|
|
6
|
-
* readSegments(root, { fromInclusive?, limit?, ioTimeoutMs? })
|
|
6
|
+
* readSegments(root, { fromInclusive?, limit?, ioTimeoutMs?, zlib? })
|
|
7
7
|
* → AsyncIterable<record> with attached:
|
|
8
8
|
* .coverage → { expired_segments, quarantined_segments, corrupt_lines,
|
|
9
9
|
* partial_tail_bytes, cursor_missed, degraded[] }
|
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
* Integrity: every sealed segment's sha256 is verified against its meta
|
|
20
20
|
* sidecar before a single row is served; a mismatch or missing meta sends the
|
|
21
21
|
* segment to quarantine/ — corrupt data is never falsified into the stream.
|
|
22
|
+
* seg-*.jsonl.zst payloads are decoded via zlib.zstdDecompressSync after the
|
|
23
|
+
* checksum passes (checksum covers the stored bytes); a decode failure is
|
|
24
|
+
* quarantined like a corrupt .jsonl, and a missing decoder on old Node
|
|
25
|
+
* degrades 'zstd_unavailable' instead of throwing.
|
|
22
26
|
* A partial tail row in the active segment (hard-kill artifact) is skipped
|
|
23
27
|
* and counted, never parsed. Symlinked or foreign-named files are never
|
|
24
28
|
* followed or read.
|
|
@@ -38,6 +42,7 @@ import {
|
|
|
38
42
|
resolveRoot,
|
|
39
43
|
sha256Buffer,
|
|
40
44
|
withTimeout,
|
|
45
|
+
zlibOf,
|
|
41
46
|
} from './internal.js';
|
|
42
47
|
|
|
43
48
|
export function readSegments(root, opts = {}) {
|
|
@@ -129,6 +134,35 @@ export function readSegments(root, opts = {}) {
|
|
|
129
134
|
}
|
|
130
135
|
continue;
|
|
131
136
|
}
|
|
137
|
+
if (seg.compressed) {
|
|
138
|
+
const decompress = zlibOf(opts).zstdDecompressSync;
|
|
139
|
+
let decoded = null;
|
|
140
|
+
if (typeof decompress !== 'function') {
|
|
141
|
+
// Old Node can't decode — degrade typed, never claim the records.
|
|
142
|
+
degrade('zstd_unavailable', seg.name);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
decoded = decompress(buf);
|
|
147
|
+
} catch {
|
|
148
|
+
decoded = null;
|
|
149
|
+
}
|
|
150
|
+
if (decoded === null || decoded.length === 0) {
|
|
151
|
+
// Stored bytes verified but not a valid/non-empty zstd stream →
|
|
152
|
+
// corrupt like a bad .jsonl: quarantine, never falsify rows.
|
|
153
|
+
const q = await quarantineSegment(resolved.root, seg.name, 'decode_failed', {
|
|
154
|
+
ioTimeoutMs: timeoutMs,
|
|
155
|
+
});
|
|
156
|
+
if (q.ok) {
|
|
157
|
+
coverage.quarantined_segments += 1;
|
|
158
|
+
degrade('decode_failed_quarantined', seg.name);
|
|
159
|
+
} else {
|
|
160
|
+
degrade(`quarantine_failed:${q.reason}`, seg.name);
|
|
161
|
+
}
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
buf = decoded;
|
|
165
|
+
}
|
|
132
166
|
const text = buf.toString('utf8');
|
|
133
167
|
const complete = text.endsWith('\n') ? text.slice(0, -1) : text;
|
|
134
168
|
for (const line of complete.split('\n')) {
|
|
@@ -31,14 +31,15 @@ import {
|
|
|
31
31
|
} from './internal.js';
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
* Move a corrupt sealed segment (and its meta sidecar)
|
|
34
|
+
* Move a corrupt sealed segment (.jsonl or .jsonl.zst, and its meta sidecar)
|
|
35
|
+
* into `quarantine/`.
|
|
35
36
|
* The segment is never deleted and never silently repaired — it is preserved
|
|
36
37
|
* for inspection outside the live read path.
|
|
37
38
|
*/
|
|
38
39
|
export async function quarantineSegment(root, segmentName, reason, opts = {}) {
|
|
39
40
|
const timeoutMs = Number.isFinite(opts.ioTimeoutMs) ? opts.ioTimeoutMs : DEFAULT_IO_TIMEOUT_MS;
|
|
40
41
|
const base = path.basename(String(segmentName));
|
|
41
|
-
if (base !== segmentName || !base.endsWith('.jsonl')) {
|
|
42
|
+
if (base !== segmentName || !(base.endsWith('.jsonl') || base.endsWith('.jsonl.zst'))) {
|
|
42
43
|
return { ok: false, reason: 'unsafe_path' };
|
|
43
44
|
}
|
|
44
45
|
const quarantineDir = path.join(root, QUARANTINE_DIR);
|
|
@@ -6,9 +6,13 @@
|
|
|
6
6
|
*
|
|
7
7
|
* active.jsonl shared append target (O_APPEND — concurrent
|
|
8
8
|
* writers interleave whole rows, never bytes)
|
|
9
|
-
* seg-<seq>-<boot>-<writer>.jsonl sealed segment
|
|
9
|
+
* seg-<seq>-<boot>-<writer>.jsonl sealed segment (or .jsonl.zst when the
|
|
10
|
+
* runtime has zlib.zstdCompressSync)
|
|
10
11
|
* seg-<...>.meta.json sidecar: checksum, record/seq/importance
|
|
11
|
-
* stats, sealed_at_ms, recovered flag
|
|
12
|
+
* stats, sealed_at_ms, recovered flag,
|
|
13
|
+
* compression ('zst'|'none')
|
|
14
|
+
* seg-<seq>.claim 0-byte seq allocator: 'wx' claim at seal,
|
|
15
|
+
* never released — a seq can never be reused
|
|
12
16
|
* quarantine/ corrupt sealed segments (never falsified)
|
|
13
17
|
* _state.json next_segment_seq, expired/quarantined
|
|
14
18
|
* counters, disabled flag, active_since_ms
|
|
@@ -32,7 +36,8 @@
|
|
|
32
36
|
* | { ok: false, reason }
|
|
33
37
|
*
|
|
34
38
|
* policy: { maxSegmentBytes, maxSegmentAgeMs, maxTotalBytes, ioTimeoutMs,
|
|
35
|
-
* now } — `now`
|
|
39
|
+
* now, zlib } — `now` and `zlib` are injectable for deterministic
|
|
40
|
+
* tests (zlib stub → compression 'none' fallback).
|
|
36
41
|
*/
|
|
37
42
|
|
|
38
43
|
import fs from 'node:fs';
|
|
@@ -42,6 +47,8 @@ import { validateSemanticRecord } from '../schema/validate.js';
|
|
|
42
47
|
import { IMPORTANCE_LEVELS } from '../schema/constants.js';
|
|
43
48
|
import {
|
|
44
49
|
ACTIVE_FILE,
|
|
50
|
+
COMPRESSION_NONE,
|
|
51
|
+
COMPRESSION_ZST,
|
|
45
52
|
DEFAULT_IO_TIMEOUT_MS,
|
|
46
53
|
ioReason,
|
|
47
54
|
ioTimeoutMsOf,
|
|
@@ -56,6 +63,8 @@ import {
|
|
|
56
63
|
totalSegmentBytes,
|
|
57
64
|
withTimeout,
|
|
58
65
|
writeState,
|
|
66
|
+
zlibOf,
|
|
67
|
+
zstdAvailable,
|
|
59
68
|
} from './internal.js';
|
|
60
69
|
|
|
61
70
|
function importanceRank(level) {
|
|
@@ -64,10 +73,15 @@ function importanceRank(level) {
|
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
/**
|
|
67
|
-
* Seal the current active segment:
|
|
68
|
-
* move
|
|
69
|
-
*
|
|
70
|
-
* and
|
|
76
|
+
* Seal the current active segment: claim a seq via seg-<seq>.claim ('wx',
|
|
77
|
+
* never released), move the file aside via an atomic rename, then parse
|
|
78
|
+
* stats + checksum the exact moved bytes, compress to seg-*.jsonl.zst when
|
|
79
|
+
* zstd is available (compression:'zst'), and write its meta sidecar.
|
|
80
|
+
* The meta write is the commit point — the plain
|
|
81
|
+
* .jsonl is deleted only after meta lands, so a crash mid-seal leaves an
|
|
82
|
+
* orphan .jsonl whose missing/meta-less state routes it to quarantine rather
|
|
83
|
+
* than being served twice. Concurrent rotators lose the race on ENOENT and
|
|
84
|
+
* simply report not-sealed.
|
|
71
85
|
*/
|
|
72
86
|
async function sealActive(root, state, policy) {
|
|
73
87
|
const timeoutMs = ioTimeoutMsOf(policy);
|
|
@@ -87,9 +101,91 @@ async function sealActive(root, state, policy) {
|
|
|
87
101
|
return { ok: true, sealed: false };
|
|
88
102
|
}
|
|
89
103
|
|
|
104
|
+
// Peek only to name the segment — boot/writer from the first valid row.
|
|
105
|
+
// Authoritative stats/checksum come from a post-rename read: the file may
|
|
106
|
+
// gain rows between this read and the move.
|
|
107
|
+
let bootId = 'unknown';
|
|
108
|
+
let writerId = 'unknown';
|
|
109
|
+
try {
|
|
110
|
+
const peek = await withTimeout(fs.promises.readFile(activePath), timeoutMs);
|
|
111
|
+
for (const line of peek.toString('utf8').split('\n')) {
|
|
112
|
+
if (line.length === 0) continue;
|
|
113
|
+
let parsed = null;
|
|
114
|
+
try {
|
|
115
|
+
parsed = JSON.parse(line);
|
|
116
|
+
} catch {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (bootId === 'unknown' && typeof parsed.boot_id === 'string') bootId = parsed.boot_id;
|
|
120
|
+
if (writerId === 'unknown' && typeof parsed.writer_id === 'string') writerId = parsed.writer_id;
|
|
121
|
+
if (bootId !== 'unknown' && writerId !== 'unknown') break;
|
|
122
|
+
}
|
|
123
|
+
} catch (err) {
|
|
124
|
+
if (err && err.code === 'ENOENT') return { ok: true, sealed: false };
|
|
125
|
+
return { ok: false, reason: ioReason(err) };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Segments are named seg-<seq>-<boot>-<writer>.* — the name alone cannot
|
|
129
|
+
// arbitrate seq ownership because concurrent sealers see different
|
|
130
|
+
// boot/writer components (a stale next_segment_seq otherwise commits the
|
|
131
|
+
// same seq twice under different names and scrambles read order). The
|
|
132
|
+
// seg-<seq>.claim marker is the atomic allocator: 'wx' creation wins the
|
|
133
|
+
// seq, and a claim is NEVER released — a burned seq cannot be reused, so a
|
|
134
|
+
// segment sealed later can never sort before an earlier commit even when
|
|
135
|
+
// _state.json reads stale. Claims happen before the move, so seq follows
|
|
136
|
+
// segment append order. Orphan claims from a lost race or crash are
|
|
137
|
+
// 0-byte and ignored by the listing.
|
|
138
|
+
//
|
|
139
|
+
// The move itself is rename(active → seg-N.jsonl), not link+unlink: under
|
|
140
|
+
// concurrent sealers link+unlink lets BOTH movers commit the same inode —
|
|
141
|
+
// a recreated active.jsonl satisfies the second unlink. Rename is atomic:
|
|
142
|
+
// exactly one mover wins; the loser sees ENOENT and reports not-sealed.
|
|
143
|
+
let seq = state.next_segment_seq;
|
|
144
|
+
let name = null;
|
|
145
|
+
let sealedPath = null;
|
|
146
|
+
for (let attempt = 0; attempt < 64; attempt++) {
|
|
147
|
+
const padded = String(seq).padStart(6, '0');
|
|
148
|
+
name = `seg-${padded}-${sanitizeNameComponent(bootId)}-${sanitizeNameComponent(writerId)}.jsonl`;
|
|
149
|
+
const target = path.join(root, name);
|
|
150
|
+
try {
|
|
151
|
+
await withTimeout(
|
|
152
|
+
fs.promises.writeFile(path.join(root, `seg-${padded}.claim`), '', { flag: 'wx' }),
|
|
153
|
+
timeoutMs,
|
|
154
|
+
);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
if (err && err.code === 'EEXIST') {
|
|
157
|
+
seq += 1;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
return { ok: false, reason: ioReason(err) };
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
// Claim won — but a crash-orphan segment may still sit at this name
|
|
164
|
+
// (its claim predates this run). Never rename over it: burn the seq
|
|
165
|
+
// and let the reader quarantine the meta-less orphan.
|
|
166
|
+
await withTimeout(fs.promises.lstat(target), timeoutMs);
|
|
167
|
+
seq += 1;
|
|
168
|
+
continue;
|
|
169
|
+
} catch (err) {
|
|
170
|
+
if (!(err && err.code === 'ENOENT')) return { ok: false, reason: ioReason(err) };
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
await withTimeout(fs.promises.rename(activePath, target), timeoutMs);
|
|
174
|
+
sealedPath = target;
|
|
175
|
+
break;
|
|
176
|
+
} catch (err) {
|
|
177
|
+
// ENOENT: another sealer moved active.jsonl first — our claim stays
|
|
178
|
+
// (the seq is burned), nothing else to unwind.
|
|
179
|
+
if (err && err.code === 'ENOENT') return { ok: true, sealed: false };
|
|
180
|
+
return { ok: false, reason: ioReason(err) };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// Every attempt collided — never write meta for a name that was not sealed.
|
|
184
|
+
if (!sealedPath) return { ok: false, reason: 'seal_conflict' };
|
|
185
|
+
|
|
90
186
|
let buf;
|
|
91
187
|
try {
|
|
92
|
-
buf = await withTimeout(fs.promises.readFile(
|
|
188
|
+
buf = await withTimeout(fs.promises.readFile(sealedPath), timeoutMs);
|
|
93
189
|
} catch (err) {
|
|
94
190
|
return { ok: false, reason: ioReason(err) };
|
|
95
191
|
}
|
|
@@ -101,8 +197,6 @@ async function sealActive(root, state, policy) {
|
|
|
101
197
|
let seqMax = null;
|
|
102
198
|
let importanceMax = 'low';
|
|
103
199
|
let maxRank = -1;
|
|
104
|
-
let bootId = 'unknown';
|
|
105
|
-
let writerId = 'unknown';
|
|
106
200
|
const text = buf.toString('utf8');
|
|
107
201
|
const complete = text.endsWith('\n') ? text.slice(0, -1) : text;
|
|
108
202
|
for (const line of complete.split('\n')) {
|
|
@@ -128,30 +222,29 @@ async function sealActive(root, state, policy) {
|
|
|
128
222
|
}
|
|
129
223
|
}
|
|
130
224
|
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
let
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
225
|
+
// Compression happens only here, at seal time — the emit/append hot path is
|
|
226
|
+
// untouched. The payload lands via tmp+rename (atomic, never a half-written
|
|
227
|
+
// .zst); any compress/write failure drops back to 'none' on the plain
|
|
228
|
+
// .jsonl — a seal never fails over compression.
|
|
229
|
+
let payloadPath = sealedPath;
|
|
230
|
+
let payloadBuf = buf;
|
|
231
|
+
let compression = COMPRESSION_NONE;
|
|
232
|
+
if (zstdAvailable(policy)) {
|
|
139
233
|
try {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
234
|
+
const out = zlibOf(policy).zstdCompressSync(buf);
|
|
235
|
+
if (Buffer.isBuffer(out) && out.length > 0) {
|
|
236
|
+
const zstPath = `${sealedPath}.zst`;
|
|
237
|
+
const tmpPath = `${zstPath}.tmp-${process.pid}-${seq}`;
|
|
238
|
+
await withTimeout(fs.promises.writeFile(tmpPath, out), timeoutMs);
|
|
239
|
+
await withTimeout(fs.promises.rename(tmpPath, zstPath), timeoutMs);
|
|
240
|
+
payloadPath = zstPath;
|
|
241
|
+
payloadBuf = out;
|
|
242
|
+
compression = COMPRESSION_ZST;
|
|
149
243
|
}
|
|
150
|
-
|
|
244
|
+
} catch {
|
|
245
|
+
// 'none' fallback — the reserved .jsonl simply stays the segment.
|
|
151
246
|
}
|
|
152
247
|
}
|
|
153
|
-
// Every attempt collided — never write meta for a name that was not sealed.
|
|
154
|
-
if (!linked) return { ok: false, reason: 'seal_conflict' };
|
|
155
248
|
|
|
156
249
|
const meta = {
|
|
157
250
|
sealed_seq: seq,
|
|
@@ -164,21 +257,32 @@ async function sealActive(root, state, policy) {
|
|
|
164
257
|
importance_max: importanceMax,
|
|
165
258
|
corrupt_lines: corruptLines,
|
|
166
259
|
recovered: false,
|
|
167
|
-
|
|
260
|
+
compression,
|
|
261
|
+
checksum: sha256Buffer(payloadBuf),
|
|
168
262
|
};
|
|
169
263
|
try {
|
|
170
264
|
await withTimeout(
|
|
171
|
-
fs.promises.writeFile(metaPathFor(
|
|
265
|
+
fs.promises.writeFile(metaPathFor(payloadPath), JSON.stringify(meta, null, 2)),
|
|
172
266
|
timeoutMs,
|
|
173
267
|
);
|
|
174
268
|
} catch (err) {
|
|
175
269
|
return { ok: false, reason: ioReason(err) };
|
|
176
270
|
}
|
|
177
271
|
|
|
272
|
+
// Commit point passed: drop the plain .jsonl when a .zst payload exists.
|
|
273
|
+
if (compression === COMPRESSION_ZST) {
|
|
274
|
+
try {
|
|
275
|
+
await withTimeout(fs.promises.unlink(sealedPath), timeoutMs);
|
|
276
|
+
} catch {
|
|
277
|
+
// Orphan .jsonl beside a committed .zst: it has no meta of its own, so
|
|
278
|
+
// the reader quarantines it — never falsified, never double-served.
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
178
282
|
state.next_segment_seq = Math.max(state.next_segment_seq, seq + 1);
|
|
179
283
|
state.active_since_ms = null;
|
|
180
284
|
await writeState(root, state, timeoutMs);
|
|
181
|
-
return { ok: true, sealed: true, name };
|
|
285
|
+
return { ok: true, sealed: true, name: path.basename(payloadPath) };
|
|
182
286
|
}
|
|
183
287
|
|
|
184
288
|
/**
|