@astrale-os/cli 1.0.0-beta.18 → 1.0.0-beta.19
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/astrale.js +76 -15
- package/dist/types/lib/output.d.ts +1 -1
- package/package.json +1 -1
- package/src/commands/__tests__/logs.test.ts +250 -2
- package/src/commands/logs.ts +104 -13
- package/src/lib/output.ts +1 -1
- package/src/program/__tests__/program.test.ts +1 -1
package/dist/astrale.js
CHANGED
|
@@ -2578,7 +2578,7 @@ var package_default;
|
|
|
2578
2578
|
var init_package = __esm(() => {
|
|
2579
2579
|
package_default = {
|
|
2580
2580
|
name: "@astrale-os/cli",
|
|
2581
|
-
version: "1.0.0-beta.
|
|
2581
|
+
version: "1.0.0-beta.19",
|
|
2582
2582
|
description: "Astrale CLI — connect to existing Astrale kernels",
|
|
2583
2583
|
keywords: [
|
|
2584
2584
|
"astrale",
|
|
@@ -9845,7 +9845,7 @@ var init_output = __esm(() => {
|
|
|
9845
9845
|
init_dist();
|
|
9846
9846
|
init_table();
|
|
9847
9847
|
RAW_OUTPUT_OPTIONS = [
|
|
9848
|
-
{ flags: "--json", description: "
|
|
9848
|
+
{ flags: "--json", description: "JSON; streaming commands emit one JSON value per line" },
|
|
9849
9849
|
{ flags: "--raw", description: "Unwrapped: bare scalar / raw bytes / JSON for objects" }
|
|
9850
9850
|
];
|
|
9851
9851
|
NOISE_KEYS = new Set(["schema", "icon", "code", "inputSchema", "outputSchema"]);
|
|
@@ -82400,7 +82400,10 @@ var exports_logs = {};
|
|
|
82400
82400
|
__export(exports_logs, {
|
|
82401
82401
|
acceptJournalPage: () => acceptJournalPage,
|
|
82402
82402
|
buildJournalInput: () => buildJournalInput,
|
|
82403
|
-
default: () => logs_default
|
|
82403
|
+
default: () => logs_default,
|
|
82404
|
+
followLogs: () => followLogs,
|
|
82405
|
+
formatFollowRecord: () => formatFollowRecord,
|
|
82406
|
+
validateLogsOpts: () => validateLogsOpts
|
|
82404
82407
|
});
|
|
82405
82408
|
function buildJournalInput(opts) {
|
|
82406
82409
|
const exact13 = nonEmpty4(opts.topic);
|
|
@@ -82473,8 +82476,12 @@ async function runOnce(opts) {
|
|
|
82473
82476
|
}
|
|
82474
82477
|
});
|
|
82475
82478
|
}
|
|
82476
|
-
async function
|
|
82477
|
-
|
|
82479
|
+
async function followLogs(opts, dependencies = {
|
|
82480
|
+
run: runKernelCommand,
|
|
82481
|
+
pause: (milliseconds) => new Promise((resolve6) => setTimeout(resolve6, milliseconds))
|
|
82482
|
+
}) {
|
|
82483
|
+
validateLogsOpts(opts);
|
|
82484
|
+
await dependencies.run({
|
|
82478
82485
|
opts,
|
|
82479
82486
|
label: "Kernel journal",
|
|
82480
82487
|
fn: async (context) => {
|
|
@@ -82483,9 +82490,9 @@ async function follow(opts) {
|
|
|
82483
82490
|
for (;; ) {
|
|
82484
82491
|
const page = await fetchPage(context, { ...resolved, cursor });
|
|
82485
82492
|
for (const record12 of page.records)
|
|
82486
|
-
printRecord(record12);
|
|
82493
|
+
printRecord(record12, opts);
|
|
82487
82494
|
cursor = page.cursor ?? cursor;
|
|
82488
|
-
await
|
|
82495
|
+
await dependencies.pause(FOLLOW_INTERVAL_MS);
|
|
82489
82496
|
}
|
|
82490
82497
|
}
|
|
82491
82498
|
});
|
|
@@ -82508,10 +82515,24 @@ function journalProjection(records) {
|
|
|
82508
82515
|
paths: records.map((record12) => String(record12.sequence))
|
|
82509
82516
|
};
|
|
82510
82517
|
}
|
|
82511
|
-
function printRecord(record12) {
|
|
82518
|
+
function printRecord(record12, opts) {
|
|
82519
|
+
if (isMachine(opts) || opts.format === "json") {
|
|
82520
|
+
process.stdout.write(formatFollowRecord(record12));
|
|
82521
|
+
return;
|
|
82522
|
+
}
|
|
82512
82523
|
process.stdout.write(`${source_default.dim(String(record12.sequence).padStart(6))} ${source_default.dim(record12.timestamp)} ${source_default.cyan(record12.topic)} ${source_default.dim(record12.principal ?? "")}
|
|
82513
82524
|
`);
|
|
82514
82525
|
}
|
|
82526
|
+
function formatFollowRecord(record12) {
|
|
82527
|
+
return `${JSON.stringify(record12)}
|
|
82528
|
+
`;
|
|
82529
|
+
}
|
|
82530
|
+
function validateLogsOpts(opts) {
|
|
82531
|
+
buildJournalInput(opts);
|
|
82532
|
+
if (opts.follow && opts.format === "yaml" && !opts.json && !opts.raw) {
|
|
82533
|
+
throw new TypeError("--follow does not support YAML; use --json for an NDJSON stream");
|
|
82534
|
+
}
|
|
82535
|
+
}
|
|
82515
82536
|
function acceptRecord(input, index3) {
|
|
82516
82537
|
if (!isRecord7(input) || !Number.isSafeInteger(input.sequence) || typeof input.topic !== "string") {
|
|
82517
82538
|
throw new TypeError(`Kernel journal record ${index3} is invalid`);
|
|
@@ -82521,8 +82542,13 @@ function acceptRecord(input, index3) {
|
|
|
82521
82542
|
if (timestamp === undefined) {
|
|
82522
82543
|
throw new TypeError(`Kernel journal record ${index3} is missing occurredAt/timestamp`);
|
|
82523
82544
|
}
|
|
82524
|
-
const correlation =
|
|
82525
|
-
const
|
|
82545
|
+
const correlation = acceptCorrelation(input.correlation, index3);
|
|
82546
|
+
const legacyCorrelationId = optionalIdentifier(input.correlationId, index3, "correlationId");
|
|
82547
|
+
const structuredCorrelationId = correlation?.invocationId;
|
|
82548
|
+
if (legacyCorrelationId !== undefined && structuredCorrelationId !== undefined && legacyCorrelationId !== structuredCorrelationId) {
|
|
82549
|
+
throw new TypeError(`Kernel journal record ${index3} has conflicting correlation identifiers`);
|
|
82550
|
+
}
|
|
82551
|
+
const correlationId = structuredCorrelationId ?? legacyCorrelationId;
|
|
82526
82552
|
const principal = optionalText2(input.principal, index3, "principal");
|
|
82527
82553
|
return Object.freeze({
|
|
82528
82554
|
sequence: input.sequence,
|
|
@@ -82532,10 +82558,35 @@ function acceptRecord(input, index3) {
|
|
|
82532
82558
|
...occurredAt === undefined ? {} : { occurredAt },
|
|
82533
82559
|
...optionalText2(input.committedAt, index3, "committedAt") === undefined ? {} : { committedAt: input.committedAt },
|
|
82534
82560
|
...principal === undefined ? {} : { principal },
|
|
82561
|
+
...correlation === undefined ? {} : { correlation },
|
|
82535
82562
|
...correlationId === undefined ? {} : { correlationId },
|
|
82536
|
-
...
|
|
82563
|
+
...optionalIdentifier(input.causationId, index3, "causationId") === undefined ? {} : { causationId: input.causationId }
|
|
82537
82564
|
});
|
|
82538
82565
|
}
|
|
82566
|
+
function acceptCorrelation(input, index3) {
|
|
82567
|
+
if (input === undefined)
|
|
82568
|
+
return;
|
|
82569
|
+
if (!isRecord7(input)) {
|
|
82570
|
+
throw new TypeError(`Kernel journal record ${index3}.correlation must be an object`);
|
|
82571
|
+
}
|
|
82572
|
+
const unknown2 = Object.keys(input).find((field) => !correlationFields.includes(field));
|
|
82573
|
+
if (unknown2 !== undefined) {
|
|
82574
|
+
throw new TypeError(`Kernel journal record ${index3}.correlation.${unknown2} is unsupported`);
|
|
82575
|
+
}
|
|
82576
|
+
return Object.freeze(Object.fromEntries(correlationFields.flatMap((field) => {
|
|
82577
|
+
const value3 = optionalIdentifier(input[field], index3, `correlation.${field}`);
|
|
82578
|
+
return value3 === undefined ? [] : [[field, value3]];
|
|
82579
|
+
})));
|
|
82580
|
+
}
|
|
82581
|
+
function optionalIdentifier(input, index3, field) {
|
|
82582
|
+
const value3 = optionalText2(input, index3, field);
|
|
82583
|
+
if (value3 === undefined)
|
|
82584
|
+
return;
|
|
82585
|
+
if (value3.trim() === "" || new TextEncoder().encode(value3).byteLength > 256) {
|
|
82586
|
+
throw new TypeError(`Kernel journal record ${index3}.${field} must be non-empty and at most 256 UTF-8 bytes`);
|
|
82587
|
+
}
|
|
82588
|
+
return value3;
|
|
82589
|
+
}
|
|
82539
82590
|
function optionalText2(input, index3, field) {
|
|
82540
82591
|
if (input === undefined)
|
|
82541
82592
|
return;
|
|
@@ -82567,7 +82618,7 @@ function nonEmpty4(input) {
|
|
|
82567
82618
|
function isRecord7(input) {
|
|
82568
82619
|
return input !== null && typeof input === "object" && !Array.isArray(input);
|
|
82569
82620
|
}
|
|
82570
|
-
var JOURNAL_PATH, DEFAULT_LIMIT2 = 200, FOLLOW_INTERVAL_MS = 2000, ISO_TIMESTAMP, CURSOR_TOKEN, logs_default;
|
|
82621
|
+
var JOURNAL_PATH, DEFAULT_LIMIT2 = 200, FOLLOW_INTERVAL_MS = 2000, ISO_TIMESTAMP, CURSOR_TOKEN, correlationFields, logs_default;
|
|
82571
82622
|
var init_logs = __esm(() => {
|
|
82572
82623
|
init_path3();
|
|
82573
82624
|
init_schema9();
|
|
@@ -82578,6 +82629,15 @@ var init_logs = __esm(() => {
|
|
|
82578
82629
|
JOURNAL_PATH = Path.project(K.functions.journal.ref).raw;
|
|
82579
82630
|
ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
82580
82631
|
CURSOR_TOKEN = /^[A-Za-z0-9._:+=/-]{8,}$/;
|
|
82632
|
+
correlationFields = [
|
|
82633
|
+
"operationId",
|
|
82634
|
+
"parentOperationId",
|
|
82635
|
+
"invocationId",
|
|
82636
|
+
"rootInvocationId",
|
|
82637
|
+
"parentInvocationId",
|
|
82638
|
+
"traceId",
|
|
82639
|
+
"spanId"
|
|
82640
|
+
];
|
|
82581
82641
|
logs_default = {
|
|
82582
82642
|
name: "logs",
|
|
82583
82643
|
description: "Read or follow the authorized Kernel journal",
|
|
@@ -82586,7 +82646,8 @@ Behavior:
|
|
|
82586
82646
|
Calls the public Kernel journal syscall and emits its { records, cursor }
|
|
82587
82647
|
page. Topic selection is exact or prefix-based; cursors and timestamps are
|
|
82588
82648
|
opaque strings owned by the journal backend. --follow reuses one Client Session
|
|
82589
|
-
and advances only with the returned cursor.
|
|
82649
|
+
and advances only with the returned cursor. Machine follow output is NDJSON:
|
|
82650
|
+
one complete admitted record per line; YAML follow is unsupported.
|
|
82590
82651
|
|
|
82591
82652
|
Historical event-glob lowering and the application-specific services-domain
|
|
82592
82653
|
log buffer are not part of the Kernel V2 journal contract.
|
|
@@ -82608,12 +82669,12 @@ Examples:
|
|
|
82608
82669
|
],
|
|
82609
82670
|
action: async (opts) => {
|
|
82610
82671
|
try {
|
|
82611
|
-
|
|
82672
|
+
validateLogsOpts(opts);
|
|
82612
82673
|
} catch (error52) {
|
|
82613
82674
|
failInput(error52, opts);
|
|
82614
82675
|
}
|
|
82615
82676
|
if (opts.follow)
|
|
82616
|
-
await
|
|
82677
|
+
await followLogs(opts);
|
|
82617
82678
|
else
|
|
82618
82679
|
await runOnce(opts);
|
|
82619
82680
|
}
|
|
@@ -11,7 +11,7 @@ export type MachineOpts = RawOutputOpts & {
|
|
|
11
11
|
};
|
|
12
12
|
export declare const RAW_OUTPUT_OPTIONS: readonly [{
|
|
13
13
|
readonly flags: '--json';
|
|
14
|
-
readonly description: '
|
|
14
|
+
readonly description: 'JSON; streaming commands emit one JSON value per line';
|
|
15
15
|
}, {
|
|
16
16
|
readonly flags: '--raw';
|
|
17
17
|
readonly description: 'Unwrapped: bare scalar / raw bytes / JSON for objects';
|
package/package.json
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import logsCommand, {
|
|
4
|
+
acceptJournalPage,
|
|
5
|
+
buildJournalInput,
|
|
6
|
+
followLogs,
|
|
7
|
+
formatFollowRecord,
|
|
8
|
+
} from '../logs'
|
|
4
9
|
|
|
5
10
|
describe('buildJournalInput', () => {
|
|
6
11
|
/** @evidence TEST-CLI-LOGS-MAPS-EXACT-JOURNAL-INPUT */
|
|
@@ -81,7 +86,15 @@ describe('acceptJournalPage', () => {
|
|
|
81
86
|
occurredAt: '2026-08-19T16:51:10.049Z',
|
|
82
87
|
committedAt: '2026-08-19T16:51:10.070Z',
|
|
83
88
|
payload: { outcome: 'rejected' },
|
|
84
|
-
correlation: {
|
|
89
|
+
correlation: {
|
|
90
|
+
operationId: 'operation-child',
|
|
91
|
+
parentOperationId: 'operation-parent',
|
|
92
|
+
invocationId: 'cf862a64-3aa1-4343-ba86-f9b516c4ff95',
|
|
93
|
+
rootInvocationId: 'invocation-root',
|
|
94
|
+
parentInvocationId: 'invocation-parent',
|
|
95
|
+
traceId: 'trace-1',
|
|
96
|
+
spanId: 'span-1',
|
|
97
|
+
},
|
|
85
98
|
},
|
|
86
99
|
],
|
|
87
100
|
})
|
|
@@ -91,7 +104,242 @@ describe('acceptJournalPage', () => {
|
|
|
91
104
|
timestamp: '2026-08-19T16:51:10.049Z',
|
|
92
105
|
occurredAt: '2026-08-19T16:51:10.049Z',
|
|
93
106
|
committedAt: '2026-08-19T16:51:10.070Z',
|
|
107
|
+
correlation: {
|
|
108
|
+
operationId: 'operation-child',
|
|
109
|
+
parentOperationId: 'operation-parent',
|
|
110
|
+
invocationId: 'cf862a64-3aa1-4343-ba86-f9b516c4ff95',
|
|
111
|
+
rootInvocationId: 'invocation-root',
|
|
112
|
+
parentInvocationId: 'invocation-parent',
|
|
113
|
+
traceId: 'trace-1',
|
|
114
|
+
spanId: 'span-1',
|
|
115
|
+
},
|
|
94
116
|
correlationId: 'cf862a64-3aa1-4343-ba86-f9b516c4ff95',
|
|
95
117
|
})
|
|
96
118
|
})
|
|
119
|
+
|
|
120
|
+
test('rejects malformed or invented structured correlation fields', () => {
|
|
121
|
+
const record = {
|
|
122
|
+
sequence: 1,
|
|
123
|
+
topic: 'function.invoke',
|
|
124
|
+
occurredAt: '2026-08-19T16:51:10.049Z',
|
|
125
|
+
payload: {},
|
|
126
|
+
}
|
|
127
|
+
expect(() => acceptJournalPage({ records: [{ ...record, correlation: 'opaque' }] })).toThrow(
|
|
128
|
+
'correlation must be an object',
|
|
129
|
+
)
|
|
130
|
+
expect(() =>
|
|
131
|
+
acceptJournalPage({ records: [{ ...record, correlation: { authority: 'forged' } }] }),
|
|
132
|
+
).toThrow('correlation.authority is unsupported')
|
|
133
|
+
for (const field of [
|
|
134
|
+
'operationId',
|
|
135
|
+
'parentOperationId',
|
|
136
|
+
'invocationId',
|
|
137
|
+
'rootInvocationId',
|
|
138
|
+
'parentInvocationId',
|
|
139
|
+
'traceId',
|
|
140
|
+
'spanId',
|
|
141
|
+
]) {
|
|
142
|
+
expect(() =>
|
|
143
|
+
acceptJournalPage({ records: [{ ...record, correlation: { [field]: 7 } }] }),
|
|
144
|
+
).toThrow(`correlation.${field}`)
|
|
145
|
+
}
|
|
146
|
+
expect(() =>
|
|
147
|
+
acceptJournalPage({ records: [{ ...record, correlation: { invocationId: ' ' } }] }),
|
|
148
|
+
).toThrow('must be non-empty')
|
|
149
|
+
expect(() =>
|
|
150
|
+
acceptJournalPage({
|
|
151
|
+
records: [{ ...record, correlation: { invocationId: 'x'.repeat(257) } }],
|
|
152
|
+
}),
|
|
153
|
+
).toThrow('at most 256 UTF-8 bytes')
|
|
154
|
+
expect(
|
|
155
|
+
acceptJournalPage({
|
|
156
|
+
records: [{ ...record, correlation: { invocationId: 'x'.repeat(256) } }],
|
|
157
|
+
}).records[0].correlation?.invocationId,
|
|
158
|
+
).toHaveLength(256)
|
|
159
|
+
expect(
|
|
160
|
+
acceptJournalPage({
|
|
161
|
+
records: [{ ...record, correlation: { invocationId: 'é'.repeat(128) } }],
|
|
162
|
+
}).records[0].correlation?.invocationId,
|
|
163
|
+
).toHaveLength(128)
|
|
164
|
+
expect(() =>
|
|
165
|
+
acceptJournalPage({
|
|
166
|
+
records: [{ ...record, correlation: { invocationId: `${'é'.repeat(127)}€` } }],
|
|
167
|
+
}),
|
|
168
|
+
).toThrow('at most 256 UTF-8 bytes')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('keeps legacy identity compatibility coherent with structured correlation', () => {
|
|
172
|
+
const record = {
|
|
173
|
+
sequence: 1,
|
|
174
|
+
topic: 'function.invoke',
|
|
175
|
+
occurredAt: '2026-08-19T16:51:10.049Z',
|
|
176
|
+
payload: {},
|
|
177
|
+
}
|
|
178
|
+
expect(
|
|
179
|
+
acceptJournalPage({
|
|
180
|
+
records: [{ ...record, correlationId: 'legacy-only', causationId: 'legacy-cause' }],
|
|
181
|
+
}).records[0],
|
|
182
|
+
).toMatchObject({ correlationId: 'legacy-only', causationId: 'legacy-cause' })
|
|
183
|
+
expect(
|
|
184
|
+
acceptJournalPage({
|
|
185
|
+
records: [
|
|
186
|
+
{
|
|
187
|
+
...record,
|
|
188
|
+
correlationId: 'same',
|
|
189
|
+
correlation: { invocationId: 'same' },
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
}).records[0],
|
|
193
|
+
).toMatchObject({ correlationId: 'same', correlation: { invocationId: 'same' } })
|
|
194
|
+
expect(() =>
|
|
195
|
+
acceptJournalPage({
|
|
196
|
+
records: [
|
|
197
|
+
{
|
|
198
|
+
...record,
|
|
199
|
+
correlationId: 'legacy',
|
|
200
|
+
correlation: { invocationId: 'structured' },
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
}),
|
|
204
|
+
).toThrow('conflicting correlation identifiers')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('serializes one complete structured record per machine-follow line', () => {
|
|
208
|
+
const record = acceptJournalPage({
|
|
209
|
+
records: [
|
|
210
|
+
{
|
|
211
|
+
sequence: 2,
|
|
212
|
+
topic: 'function.invoke',
|
|
213
|
+
occurredAt: '2026-08-19T16:51:10.049Z',
|
|
214
|
+
payload: { outcome: 'completed' },
|
|
215
|
+
correlation: {
|
|
216
|
+
operationId: 'operation-child',
|
|
217
|
+
parentOperationId: 'operation-parent',
|
|
218
|
+
invocationId: 'invocation-child',
|
|
219
|
+
rootInvocationId: 'invocation-root',
|
|
220
|
+
parentInvocationId: 'invocation-parent',
|
|
221
|
+
traceId: 'trace-1',
|
|
222
|
+
spanId: 'span-1',
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
],
|
|
226
|
+
}).records[0]
|
|
227
|
+
expect(formatFollowRecord(record).endsWith('\n')).toBe(true)
|
|
228
|
+
expect(JSON.parse(formatFollowRecord(record))).toEqual(record)
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
describe('follow output routing', () => {
|
|
233
|
+
const inputRecord = {
|
|
234
|
+
sequence: 2,
|
|
235
|
+
topic: 'function.invoke',
|
|
236
|
+
occurredAt: '2026-08-19T16:51:10.049Z',
|
|
237
|
+
payload: { outcome: 'completed' },
|
|
238
|
+
principal: 'principal-1',
|
|
239
|
+
correlation: {
|
|
240
|
+
invocationId: 'invocation-child',
|
|
241
|
+
rootInvocationId: 'invocation-root',
|
|
242
|
+
parentInvocationId: 'invocation-parent',
|
|
243
|
+
},
|
|
244
|
+
}
|
|
245
|
+
const admittedRecord = acceptJournalPage({ records: [inputRecord] }).records[0]
|
|
246
|
+
|
|
247
|
+
test('routes every effective machine mode through complete NDJSON records', async () => {
|
|
248
|
+
for (const { opts, tty } of [
|
|
249
|
+
{ opts: { json: true }, tty: true },
|
|
250
|
+
{ opts: { raw: true }, tty: true },
|
|
251
|
+
{ opts: { format: 'json' as const }, tty: true },
|
|
252
|
+
{ opts: { ci: true }, tty: true },
|
|
253
|
+
{ opts: {}, tty: false },
|
|
254
|
+
{ opts: { format: 'yaml' as const, json: true }, tty: true },
|
|
255
|
+
{ opts: { format: 'yaml' as const, raw: true }, tty: true },
|
|
256
|
+
]) {
|
|
257
|
+
const stdout = await captureFollowOutput({ ...opts, follow: true }, tty)
|
|
258
|
+
expect(stdout.endsWith('\n')).toBe(true)
|
|
259
|
+
expect(JSON.parse(stdout)).toEqual(admittedRecord)
|
|
260
|
+
}
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
test('keeps an unflagged TTY human-readable', async () => {
|
|
264
|
+
const stdout = await captureFollowOutput({ follow: true }, true)
|
|
265
|
+
expect(stdout).toContain('function.invoke')
|
|
266
|
+
expect(stdout).toContain('principal-1')
|
|
267
|
+
expect(stdout).not.toContain('invocation-child')
|
|
268
|
+
expect(() => JSON.parse(stdout)).toThrow()
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
test('rejects effective YAML before opening a Kernel session with INVALID_INPUT', async () => {
|
|
272
|
+
let runCalls = 0
|
|
273
|
+
await expect(
|
|
274
|
+
followLogs(
|
|
275
|
+
{ follow: true, format: 'yaml' },
|
|
276
|
+
{
|
|
277
|
+
run: async () => {
|
|
278
|
+
runCalls += 1
|
|
279
|
+
},
|
|
280
|
+
pause: async () => {},
|
|
281
|
+
},
|
|
282
|
+
),
|
|
283
|
+
).rejects.toThrow('--follow does not support YAML')
|
|
284
|
+
expect(runCalls).toBe(0)
|
|
285
|
+
|
|
286
|
+
const originalExit = process.exit
|
|
287
|
+
const originalStderrWrite = process.stderr.write
|
|
288
|
+
let stderr = ''
|
|
289
|
+
process.stderr.write = ((chunk: string | Uint8Array) => {
|
|
290
|
+
stderr += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')
|
|
291
|
+
return true
|
|
292
|
+
}) as typeof process.stderr.write
|
|
293
|
+
process.exit = ((code?: string | number | null) => {
|
|
294
|
+
throw new Error(`exit:${String(code)}`)
|
|
295
|
+
}) as typeof process.exit
|
|
296
|
+
try {
|
|
297
|
+
await expect(logsCommand.action({ follow: true, format: 'yaml' })).rejects.toThrow('exit:1')
|
|
298
|
+
expect(JSON.parse(stderr)).toEqual({
|
|
299
|
+
error: 'INVALID_INPUT',
|
|
300
|
+
message: '--follow does not support YAML; use --json for an NDJSON stream',
|
|
301
|
+
})
|
|
302
|
+
} finally {
|
|
303
|
+
process.exit = originalExit
|
|
304
|
+
process.stderr.write = originalStderrWrite
|
|
305
|
+
}
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
async function captureFollowOutput(
|
|
309
|
+
opts: Parameters<typeof followLogs>[0],
|
|
310
|
+
tty: boolean,
|
|
311
|
+
): Promise<string> {
|
|
312
|
+
const originalWrite = process.stdout.write
|
|
313
|
+
const originalTty = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY')
|
|
314
|
+
let stdout = ''
|
|
315
|
+
let pages = 0
|
|
316
|
+
process.stdout.write = ((chunk: string | Uint8Array) => {
|
|
317
|
+
stdout += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')
|
|
318
|
+
return true
|
|
319
|
+
}) as typeof process.stdout.write
|
|
320
|
+
Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: tty })
|
|
321
|
+
try {
|
|
322
|
+
await expect(
|
|
323
|
+
followLogs(opts, {
|
|
324
|
+
run: async (input) => {
|
|
325
|
+
await input.fn({
|
|
326
|
+
session: {
|
|
327
|
+
call: async () => {
|
|
328
|
+
pages += 1
|
|
329
|
+
if (pages === 1) return { records: [inputRecord] }
|
|
330
|
+
throw new Error('end of controlled stream')
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
} as never)
|
|
334
|
+
},
|
|
335
|
+
pause: async () => {},
|
|
336
|
+
}),
|
|
337
|
+
).rejects.toThrow('end of controlled stream')
|
|
338
|
+
return stdout
|
|
339
|
+
} finally {
|
|
340
|
+
process.stdout.write = originalWrite
|
|
341
|
+
if (originalTty === undefined) delete (process.stdout as { isTTY?: boolean }).isTTY
|
|
342
|
+
else Object.defineProperty(process.stdout, 'isTTY', originalTty)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
97
345
|
})
|
package/src/commands/logs.ts
CHANGED
|
@@ -33,10 +33,21 @@ export interface JournalRecord {
|
|
|
33
33
|
readonly occurredAt?: string
|
|
34
34
|
readonly committedAt?: string
|
|
35
35
|
readonly principal?: string
|
|
36
|
+
readonly correlation?: JournalCorrelation
|
|
36
37
|
readonly correlationId?: string
|
|
37
38
|
readonly causationId?: string
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
export interface JournalCorrelation {
|
|
42
|
+
readonly operationId?: string
|
|
43
|
+
readonly parentOperationId?: string
|
|
44
|
+
readonly invocationId?: string
|
|
45
|
+
readonly rootInvocationId?: string
|
|
46
|
+
readonly parentInvocationId?: string
|
|
47
|
+
readonly traceId?: string
|
|
48
|
+
readonly spanId?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
40
51
|
export interface JournalPage {
|
|
41
52
|
readonly records: readonly JournalRecord[]
|
|
42
53
|
readonly cursor?: string
|
|
@@ -133,8 +144,21 @@ async function runOnce(opts: LogsOpts): Promise<void> {
|
|
|
133
144
|
})
|
|
134
145
|
}
|
|
135
146
|
|
|
136
|
-
|
|
137
|
-
|
|
147
|
+
type FollowDependencies = {
|
|
148
|
+
readonly run: typeof runKernelCommand
|
|
149
|
+
readonly pause: (milliseconds: number) => Promise<void>
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Follow one admitted journal stream. Dependencies are explicit so routing is proven at the command boundary. */
|
|
153
|
+
export async function followLogs(
|
|
154
|
+
opts: LogsOpts,
|
|
155
|
+
dependencies: FollowDependencies = {
|
|
156
|
+
run: runKernelCommand,
|
|
157
|
+
pause: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
|
158
|
+
},
|
|
159
|
+
): Promise<void> {
|
|
160
|
+
validateLogsOpts(opts)
|
|
161
|
+
await dependencies.run({
|
|
138
162
|
opts,
|
|
139
163
|
label: 'Kernel journal',
|
|
140
164
|
fn: async (context): Promise<never> => {
|
|
@@ -142,9 +166,9 @@ async function follow(opts: LogsOpts): Promise<void> {
|
|
|
142
166
|
let cursor = resolved.cursor
|
|
143
167
|
for (;;) {
|
|
144
168
|
const page = await fetchPage(context, { ...resolved, cursor })
|
|
145
|
-
for (const record of page.records) printRecord(record)
|
|
169
|
+
for (const record of page.records) printRecord(record, opts)
|
|
146
170
|
cursor = page.cursor ?? cursor
|
|
147
|
-
await
|
|
171
|
+
await dependencies.pause(FOLLOW_INTERVAL_MS)
|
|
148
172
|
}
|
|
149
173
|
},
|
|
150
174
|
})
|
|
@@ -169,12 +193,28 @@ function journalProjection(records: JournalRecord[]): ListProjection {
|
|
|
169
193
|
}
|
|
170
194
|
}
|
|
171
195
|
|
|
172
|
-
function printRecord(record: JournalRecord): void {
|
|
196
|
+
function printRecord(record: JournalRecord, opts: LogsOpts): void {
|
|
197
|
+
if (isMachine(opts) || opts.format === 'json') {
|
|
198
|
+
process.stdout.write(formatFollowRecord(record))
|
|
199
|
+
return
|
|
200
|
+
}
|
|
173
201
|
process.stdout.write(
|
|
174
202
|
`${chalk.dim(String(record.sequence).padStart(6))} ${chalk.dim(record.timestamp)} ${chalk.cyan(record.topic)} ${chalk.dim(record.principal ?? '')}\n`,
|
|
175
203
|
)
|
|
176
204
|
}
|
|
177
205
|
|
|
206
|
+
/** Machine follow is an NDJSON stream: one complete admitted record per line. */
|
|
207
|
+
export function formatFollowRecord(record: JournalRecord): string {
|
|
208
|
+
return `${JSON.stringify(record)}\n`
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function validateLogsOpts(opts: LogsOpts): void {
|
|
212
|
+
buildJournalInput(opts)
|
|
213
|
+
if (opts.follow && opts.format === 'yaml' && !opts.json && !opts.raw) {
|
|
214
|
+
throw new TypeError('--follow does not support YAML; use --json for an NDJSON stream')
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
178
218
|
function acceptRecord(input: unknown, index: number): JournalRecord {
|
|
179
219
|
if (
|
|
180
220
|
!isRecord(input) ||
|
|
@@ -188,10 +228,17 @@ function acceptRecord(input: unknown, index: number): JournalRecord {
|
|
|
188
228
|
if (timestamp === undefined) {
|
|
189
229
|
throw new TypeError(`Kernel journal record ${index} is missing occurredAt/timestamp`)
|
|
190
230
|
}
|
|
191
|
-
const correlation =
|
|
192
|
-
const correlationId
|
|
193
|
-
|
|
194
|
-
|
|
231
|
+
const correlation = acceptCorrelation(input.correlation, index)
|
|
232
|
+
const legacyCorrelationId = optionalIdentifier(input.correlationId, index, 'correlationId')
|
|
233
|
+
const structuredCorrelationId = correlation?.invocationId
|
|
234
|
+
if (
|
|
235
|
+
legacyCorrelationId !== undefined &&
|
|
236
|
+
structuredCorrelationId !== undefined &&
|
|
237
|
+
legacyCorrelationId !== structuredCorrelationId
|
|
238
|
+
) {
|
|
239
|
+
throw new TypeError(`Kernel journal record ${index} has conflicting correlation identifiers`)
|
|
240
|
+
}
|
|
241
|
+
const correlationId = structuredCorrelationId ?? legacyCorrelationId
|
|
195
242
|
const principal = optionalText(input.principal, index, 'principal')
|
|
196
243
|
return Object.freeze({
|
|
197
244
|
sequence: input.sequence as number,
|
|
@@ -203,13 +250,56 @@ function acceptRecord(input: unknown, index: number): JournalRecord {
|
|
|
203
250
|
? {}
|
|
204
251
|
: { committedAt: input.committedAt as string }),
|
|
205
252
|
...(principal === undefined ? {} : { principal }),
|
|
253
|
+
...(correlation === undefined ? {} : { correlation }),
|
|
206
254
|
...(correlationId === undefined ? {} : { correlationId }),
|
|
207
|
-
...(
|
|
255
|
+
...(optionalIdentifier(input.causationId, index, 'causationId') === undefined
|
|
208
256
|
? {}
|
|
209
257
|
: { causationId: input.causationId as string }),
|
|
210
258
|
})
|
|
211
259
|
}
|
|
212
260
|
|
|
261
|
+
const correlationFields = [
|
|
262
|
+
'operationId',
|
|
263
|
+
'parentOperationId',
|
|
264
|
+
'invocationId',
|
|
265
|
+
'rootInvocationId',
|
|
266
|
+
'parentInvocationId',
|
|
267
|
+
'traceId',
|
|
268
|
+
'spanId',
|
|
269
|
+
] as const
|
|
270
|
+
|
|
271
|
+
function acceptCorrelation(input: unknown, index: number): JournalCorrelation | undefined {
|
|
272
|
+
if (input === undefined) return undefined
|
|
273
|
+
if (!isRecord(input)) {
|
|
274
|
+
throw new TypeError(`Kernel journal record ${index}.correlation must be an object`)
|
|
275
|
+
}
|
|
276
|
+
const unknown = Object.keys(input).find(
|
|
277
|
+
(field) => !correlationFields.includes(field as (typeof correlationFields)[number]),
|
|
278
|
+
)
|
|
279
|
+
if (unknown !== undefined) {
|
|
280
|
+
throw new TypeError(`Kernel journal record ${index}.correlation.${unknown} is unsupported`)
|
|
281
|
+
}
|
|
282
|
+
return Object.freeze(
|
|
283
|
+
Object.fromEntries(
|
|
284
|
+
correlationFields.flatMap((field) => {
|
|
285
|
+
const value = optionalIdentifier(input[field], index, `correlation.${field}`)
|
|
286
|
+
return value === undefined ? [] : [[field, value]]
|
|
287
|
+
}),
|
|
288
|
+
),
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function optionalIdentifier(input: unknown, index: number, field: string): string | undefined {
|
|
293
|
+
const value = optionalText(input, index, field)
|
|
294
|
+
if (value === undefined) return undefined
|
|
295
|
+
if (value.trim() === '' || new TextEncoder().encode(value).byteLength > 256) {
|
|
296
|
+
throw new TypeError(
|
|
297
|
+
`Kernel journal record ${index}.${field} must be non-empty and at most 256 UTF-8 bytes`,
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
return value
|
|
301
|
+
}
|
|
302
|
+
|
|
213
303
|
function optionalText(input: unknown, index: number, field: string): string | undefined {
|
|
214
304
|
if (input === undefined) return undefined
|
|
215
305
|
if (typeof input !== 'string') {
|
|
@@ -251,7 +341,8 @@ Behavior:
|
|
|
251
341
|
Calls the public Kernel journal syscall and emits its { records, cursor }
|
|
252
342
|
page. Topic selection is exact or prefix-based; cursors and timestamps are
|
|
253
343
|
opaque strings owned by the journal backend. --follow reuses one Client Session
|
|
254
|
-
and advances only with the returned cursor.
|
|
344
|
+
and advances only with the returned cursor. Machine follow output is NDJSON:
|
|
345
|
+
one complete admitted record per line; YAML follow is unsupported.
|
|
255
346
|
|
|
256
347
|
Historical event-glob lowering and the application-specific services-domain
|
|
257
348
|
log buffer are not part of the Kernel V2 journal contract.
|
|
@@ -273,11 +364,11 @@ Examples:
|
|
|
273
364
|
],
|
|
274
365
|
action: async (opts: LogsOpts) => {
|
|
275
366
|
try {
|
|
276
|
-
|
|
367
|
+
validateLogsOpts(opts)
|
|
277
368
|
} catch (error) {
|
|
278
369
|
failInput(error, opts)
|
|
279
370
|
}
|
|
280
|
-
if (opts.follow) await
|
|
371
|
+
if (opts.follow) await followLogs(opts)
|
|
281
372
|
else await runOnce(opts)
|
|
282
373
|
},
|
|
283
374
|
} satisfies CommandDefinition
|
package/src/lib/output.ts
CHANGED
|
@@ -17,7 +17,7 @@ export type RawOutputOpts = Pick<OutputOpts, 'raw' | 'json'>
|
|
|
17
17
|
export type MachineOpts = RawOutputOpts & { readonly ci?: boolean }
|
|
18
18
|
|
|
19
19
|
export const RAW_OUTPUT_OPTIONS = [
|
|
20
|
-
{ flags: '--json', description: '
|
|
20
|
+
{ flags: '--json', description: 'JSON; streaming commands emit one JSON value per line' },
|
|
21
21
|
{ flags: '--raw', description: 'Unwrapped: bare scalar / raw bytes / JSON for objects' },
|
|
22
22
|
] as const
|
|
23
23
|
|
|
@@ -195,7 +195,7 @@ describe('program composition', () => {
|
|
|
195
195
|
'whoami',
|
|
196
196
|
])
|
|
197
197
|
expect(createHash('sha256').update(JSON.stringify(surface)).digest('hex')).toBe(
|
|
198
|
-
'
|
|
198
|
+
'958b54c4eeee1bab77efeac734bd11fe5a0b103aae69788de7db5418e5652f64',
|
|
199
199
|
)
|
|
200
200
|
})
|
|
201
201
|
|