@astrale-os/cli 1.0.0-beta.17 → 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 +2089 -1190
- package/dist/public/connect-core.js +4 -4
- package/dist/types/lib/output.d.ts +1 -1
- package/package.json +2 -2
- package/src/commands/__tests__/domain-install-operation.test.ts +4 -8
- package/src/commands/__tests__/install-direct.test.ts +115 -8
- package/src/commands/__tests__/introspect-parse.test.ts +80 -1
- package/src/commands/__tests__/logs.test.ts +250 -2
- package/src/commands/domain/install.ts +54 -35
- package/src/commands/introspect.ts +40 -20
- package/src/commands/logs.ts +104 -13
- package/src/lib/output.ts +1 -1
- package/src/program/__tests__/program.test.ts +1 -1
- package/studio/package.json +1 -1
- package/viewer/dist/main.js +40 -40
|
@@ -8242,14 +8242,14 @@ function isPidAlive(pid) {
|
|
|
8242
8242
|
function sleep(milliseconds) {
|
|
8243
8243
|
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
8244
8244
|
}
|
|
8245
|
-
// node_modules/.pnpm/@astrale-os+kernel-dsl@0.2.0-beta.
|
|
8245
|
+
// node_modules/.pnpm/@astrale-os+kernel-dsl@0.2.0-beta.12/node_modules/@astrale-os/kernel-dsl/dist/value/limits.js
|
|
8246
8246
|
var defaultLimits = Object.freeze({
|
|
8247
8247
|
maxBytes: 16 * 1024 * 1024,
|
|
8248
8248
|
maxDepth: 64,
|
|
8249
8249
|
maxMembers: 1e5,
|
|
8250
8250
|
maxStringBytes: 1024 * 1024
|
|
8251
8251
|
});
|
|
8252
|
-
// node_modules/.pnpm/@astrale-os+kernel-dsl@0.2.0-beta.
|
|
8252
|
+
// node_modules/.pnpm/@astrale-os+kernel-dsl@0.2.0-beta.12/node_modules/@astrale-os/kernel-dsl/dist/value/canonical.js
|
|
8253
8253
|
var encoder2 = new TextEncoder;
|
|
8254
8254
|
// node_modules/.pnpm/jsonc-parser@3.3.1/node_modules/jsonc-parser/lib/esm/impl/scanner.js
|
|
8255
8255
|
var CharacterCodes;
|
|
@@ -8429,7 +8429,7 @@ var ParseErrorCode;
|
|
|
8429
8429
|
ParseErrorCode2[ParseErrorCode2["InvalidCharacter"] = 16] = "InvalidCharacter";
|
|
8430
8430
|
})(ParseErrorCode || (ParseErrorCode = {}));
|
|
8431
8431
|
|
|
8432
|
-
// node_modules/.pnpm/@astrale-os+kernel-dsl@0.2.0-beta.
|
|
8432
|
+
// node_modules/.pnpm/@astrale-os+kernel-dsl@0.2.0-beta.12/node_modules/@astrale-os/kernel-dsl/dist/value/decode.js
|
|
8433
8433
|
var decoder2 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
8434
8434
|
// node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/classic/external.js
|
|
8435
8435
|
var exports_external = {};
|
|
@@ -23659,7 +23659,7 @@ var log = {
|
|
|
23659
23659
|
dim: (msg) => console.log(source_default.dim(msg))
|
|
23660
23660
|
};
|
|
23661
23661
|
var IS_CI = !!(process.env.CI || process.env.CONTINUOUS_INTEGRATION || process.env.NO_SPINNER);
|
|
23662
|
-
// node_modules/.pnpm/@astrale-os+kernel-core@0.9.0-beta.
|
|
23662
|
+
// node_modules/.pnpm/@astrale-os+kernel-core@0.9.0-beta.15/node_modules/@astrale-os/kernel-core/dist/dns-label.js
|
|
23663
23663
|
var DNS_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
23664
23664
|
function isDnsLabel(value) {
|
|
23665
23665
|
return DNS_LABEL_RE.test(value);
|
|
@@ -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,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astrale-os/cli",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.19",
|
|
4
4
|
"description": "Astrale CLI — connect to existing Astrale kernels",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"astrale",
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
64
|
"@astrale-os/ox": ">=0.1.3 <1.0.0",
|
|
65
|
-
"@astrale-os/sdk": "0.5.0-beta.
|
|
65
|
+
"@astrale-os/sdk": "0.5.0-beta.49",
|
|
66
66
|
"@astrale-os/shell": "0.4.2-beta.3",
|
|
67
67
|
"@astrale/commitlint-config": "npm:@jsr/astrale__commitlint-config@~2.0.1",
|
|
68
68
|
"@commitlint/cli": "21.2.2",
|
|
@@ -38,9 +38,7 @@ describe('direct domain install operation identity', () => {
|
|
|
38
38
|
recovery = input.recovery
|
|
39
39
|
requests.push(
|
|
40
40
|
await input.fn({
|
|
41
|
-
session: {
|
|
42
|
-
call: async (call: { input: unknown }) => call.input,
|
|
43
|
-
},
|
|
41
|
+
session: { schema: { install: async (input: unknown) => input } },
|
|
44
42
|
} as never),
|
|
45
43
|
)
|
|
46
44
|
},
|
|
@@ -51,7 +49,7 @@ describe('direct domain install operation identity', () => {
|
|
|
51
49
|
expect(requests).toEqual([
|
|
52
50
|
{
|
|
53
51
|
operation: GENERATED,
|
|
54
|
-
domains: [{
|
|
52
|
+
domains: [{ publication: { url: 'https://crm.test' } }],
|
|
55
53
|
},
|
|
56
54
|
])
|
|
57
55
|
expect(recovery).toEqual({
|
|
@@ -78,9 +76,7 @@ describe('direct domain install operation identity', () => {
|
|
|
78
76
|
runKernelCommand: async (input) => {
|
|
79
77
|
requests.push(
|
|
80
78
|
await input.fn({
|
|
81
|
-
session: {
|
|
82
|
-
call: async (call: { input: unknown }) => call.input,
|
|
83
|
-
},
|
|
79
|
+
session: { schema: { install: async (input: unknown) => input } },
|
|
84
80
|
} as never),
|
|
85
81
|
)
|
|
86
82
|
},
|
|
@@ -92,7 +88,7 @@ describe('direct domain install operation identity', () => {
|
|
|
92
88
|
expect(requests).toEqual([
|
|
93
89
|
{
|
|
94
90
|
operation: RETRY,
|
|
95
|
-
domains: [{
|
|
91
|
+
domains: [{ publication: { url: 'https://crm.test' } }],
|
|
96
92
|
},
|
|
97
93
|
])
|
|
98
94
|
})
|
|
@@ -1,21 +1,128 @@
|
|
|
1
|
+
import type { InstallResult } from '@astrale-os/sdk/client/schema'
|
|
2
|
+
|
|
3
|
+
import { defineSchema, schema } from '@astrale-os/sdk/schema'
|
|
1
4
|
import { describe, expect, test } from 'bun:test'
|
|
2
5
|
|
|
3
|
-
import { directInstallCallInput } from '../domain/install'
|
|
6
|
+
import { directInstallCallInput, directInstallPresentation } from '../domain/install'
|
|
7
|
+
|
|
8
|
+
const currentRevision = schema.revision(defineSchema('tasks.astrale.ai', {}))
|
|
4
9
|
|
|
5
10
|
describe('directInstallCallInput', () => {
|
|
6
11
|
test('sends the current remote install syscall, not a legacy url list', () => {
|
|
7
12
|
const operation = '4a4c9a18-50f6-4d84-a7b7-2d83e3e45dc8'
|
|
8
|
-
|
|
9
|
-
|
|
13
|
+
const input = directInstallCallInput('https://tasks.example.test', operation, 'secret')
|
|
14
|
+
|
|
15
|
+
expect(String(input.operation)).toBe(operation)
|
|
16
|
+
expect(input.domains).toEqual([
|
|
17
|
+
{
|
|
18
|
+
publication: {
|
|
19
|
+
url: 'https://tasks.example.test',
|
|
20
|
+
token: 'secret',
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
])
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('presents a committed install from its receipt', () => {
|
|
27
|
+
const operation = directInstallCallInput(
|
|
28
|
+
'https://tasks.example.test',
|
|
29
|
+
'4a4c9a18-50f6-4d84-a7b7-2d83e3e45dc8',
|
|
30
|
+
).operation
|
|
31
|
+
const result = {
|
|
32
|
+
changed: true,
|
|
33
|
+
receipt: {
|
|
34
|
+
operation,
|
|
35
|
+
transitions: [
|
|
36
|
+
{
|
|
37
|
+
intent: {
|
|
38
|
+
transition: 'transition-1',
|
|
39
|
+
operation,
|
|
40
|
+
origin: 'tasks.astrale.ai',
|
|
41
|
+
previous: null,
|
|
42
|
+
generation: {
|
|
43
|
+
origin: 'tasks.astrale.ai',
|
|
44
|
+
revision: currentRevision,
|
|
45
|
+
generation: 'sha256:generation',
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
phase: 'cutover',
|
|
49
|
+
state: 'committed',
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
} satisfies InstallResult
|
|
54
|
+
|
|
55
|
+
expect(directInstallPresentation(result, 'ignored')).toEqual({
|
|
56
|
+
operation: '4a4c9a18-50f6-4d84-a7b7-2d83e3e45dc8',
|
|
57
|
+
origin: 'tasks.astrale.ai',
|
|
58
|
+
revision: currentRevision,
|
|
59
|
+
status: 'installed',
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('presents an idempotent install from the current Domain observation', () => {
|
|
64
|
+
const operation = '4a4c9a18-50f6-4d84-a7b7-2d83e3e45dc8'
|
|
65
|
+
const result = {
|
|
66
|
+
changed: false,
|
|
10
67
|
domains: [
|
|
11
68
|
{
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
69
|
+
origin: 'tasks.astrale.ai',
|
|
70
|
+
revision: currentRevision,
|
|
71
|
+
generation: 'sha256:generation',
|
|
72
|
+
publication: null,
|
|
73
|
+
readiness: 'sha256:readiness',
|
|
74
|
+
capabilities: { requested: {}, materialized: {} },
|
|
75
|
+
bindings: { callables: [], views: [] },
|
|
17
76
|
},
|
|
18
77
|
],
|
|
78
|
+
} satisfies InstallResult
|
|
79
|
+
|
|
80
|
+
expect(directInstallPresentation(result, operation)).toEqual({
|
|
81
|
+
operation,
|
|
82
|
+
origin: 'tasks.astrale.ai',
|
|
83
|
+
revision: currentRevision,
|
|
84
|
+
status: 'already current',
|
|
19
85
|
})
|
|
20
86
|
})
|
|
87
|
+
|
|
88
|
+
test('rejects a changed result without a committed transition', () => {
|
|
89
|
+
const result = {
|
|
90
|
+
changed: true,
|
|
91
|
+
receipt: { operation: '4a4c9a18-50f6-4d84-a7b7-2d83e3e45dc8', transitions: [] },
|
|
92
|
+
} as unknown as InstallResult
|
|
93
|
+
|
|
94
|
+
expect(() => directInstallPresentation(result, 'ignored')).toThrow(
|
|
95
|
+
'Kernel install returned no committed Domain transition.',
|
|
96
|
+
)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('rejects a committed transition without an installed generation', () => {
|
|
100
|
+
const operation = directInstallCallInput(
|
|
101
|
+
'https://tasks.example.test',
|
|
102
|
+
'4a4c9a18-50f6-4d84-a7b7-2d83e3e45dc8',
|
|
103
|
+
).operation
|
|
104
|
+
const result = {
|
|
105
|
+
changed: true,
|
|
106
|
+
receipt: {
|
|
107
|
+
operation,
|
|
108
|
+
transitions: [
|
|
109
|
+
{
|
|
110
|
+
intent: {
|
|
111
|
+
transition: 'transition-1',
|
|
112
|
+
operation,
|
|
113
|
+
origin: 'tasks.astrale.ai',
|
|
114
|
+
previous: null,
|
|
115
|
+
generation: null,
|
|
116
|
+
},
|
|
117
|
+
phase: 'cutover',
|
|
118
|
+
state: 'committed',
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
} satisfies InstallResult
|
|
123
|
+
|
|
124
|
+
expect(() => directInstallPresentation(result, 'ignored')).toThrow(
|
|
125
|
+
'Kernel install returned a committed transition without a Domain generation.',
|
|
126
|
+
)
|
|
127
|
+
})
|
|
21
128
|
})
|
|
@@ -1,6 +1,44 @@
|
|
|
1
|
+
import type { DomainBundle, DomainInfo } from '@astrale-os/sdk/client/schema'
|
|
2
|
+
|
|
3
|
+
import { bundle, defineSchema, func, method, nodeClass, schema } from '@astrale-os/sdk/schema'
|
|
1
4
|
import { describe, expect, test } from 'bun:test'
|
|
2
5
|
|
|
3
|
-
import { parseIntrospectTarget } from '../introspect'
|
|
6
|
+
import { introspectCommand, parseIntrospectTarget } from '../introspect'
|
|
7
|
+
|
|
8
|
+
const source = defineSchema('host.astrale.ai', {
|
|
9
|
+
classes: {
|
|
10
|
+
Manager: nodeClass({
|
|
11
|
+
methods: {
|
|
12
|
+
createInstance: method({
|
|
13
|
+
auth: 'authenticated',
|
|
14
|
+
input: { type: 'object', properties: {}, additionalProperties: false },
|
|
15
|
+
output: { type: 'object', additionalProperties: true },
|
|
16
|
+
static: true,
|
|
17
|
+
}),
|
|
18
|
+
},
|
|
19
|
+
}),
|
|
20
|
+
},
|
|
21
|
+
functions: {
|
|
22
|
+
journal: func({
|
|
23
|
+
auth: 'authenticated',
|
|
24
|
+
input: { type: 'object', properties: {}, additionalProperties: false },
|
|
25
|
+
output: { type: 'object', additionalProperties: true },
|
|
26
|
+
}),
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const info = {
|
|
31
|
+
origin: source.origin,
|
|
32
|
+
revision: schema.revision(source),
|
|
33
|
+
generation: 'sha256:generation',
|
|
34
|
+
publication: null,
|
|
35
|
+
readiness: 'sha256:readiness',
|
|
36
|
+
capabilities: { requested: {}, materialized: {} },
|
|
37
|
+
bindings: { callables: [], views: [] },
|
|
38
|
+
} satisfies DomainInfo
|
|
39
|
+
|
|
40
|
+
const installed = bundle.create(source)
|
|
41
|
+
const bundled = { domain: info, bundle: installed } satisfies DomainBundle
|
|
4
42
|
|
|
5
43
|
describe('parseIntrospectTarget', () => {
|
|
6
44
|
test('accepts a bare origin', () => {
|
|
@@ -18,4 +56,45 @@ describe('parseIntrospectTarget', () => {
|
|
|
18
56
|
test('rejects an @id', () => {
|
|
19
57
|
expect(() => parseIntrospectTarget('@abc')).toThrow('not an @id')
|
|
20
58
|
})
|
|
59
|
+
|
|
60
|
+
test('routes bare, bundle, Method, and Function commands through the current Schema API', async () => {
|
|
61
|
+
const calls: string[] = []
|
|
62
|
+
const schema = {
|
|
63
|
+
inspect: async (origin: string) => {
|
|
64
|
+
calls.push(`inspect:${origin}`)
|
|
65
|
+
return info
|
|
66
|
+
},
|
|
67
|
+
bundle: async (origin: string) => {
|
|
68
|
+
calls.push(`bundle:${origin}`)
|
|
69
|
+
return bundled
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
const results: unknown[] = []
|
|
73
|
+
const runKernelCommand = async (input: {
|
|
74
|
+
fn: (context: unknown) => Promise<unknown>
|
|
75
|
+
}): Promise<void> => {
|
|
76
|
+
results.push(await input.fn({ session: { schema } }))
|
|
77
|
+
}
|
|
78
|
+
const dependencies = { runKernelCommand: runKernelCommand as never }
|
|
79
|
+
|
|
80
|
+
await introspectCommand('host.astrale.ai', { json: true }, dependencies)
|
|
81
|
+
await introspectCommand('host.astrale.ai', { bundle: true, json: true }, dependencies)
|
|
82
|
+
await introspectCommand(
|
|
83
|
+
'/:host.astrale.ai:class.Manager:createInstance',
|
|
84
|
+
{ json: true },
|
|
85
|
+
dependencies,
|
|
86
|
+
)
|
|
87
|
+
await introspectCommand('/:host.astrale.ai:function.journal', { json: true }, dependencies)
|
|
88
|
+
|
|
89
|
+
expect(results[0]).toBe(info)
|
|
90
|
+
expect(results[1]).toBe(bundled)
|
|
91
|
+
expect(results[2]).toMatchObject({ class: 'Manager', method: 'createInstance' })
|
|
92
|
+
expect(results[3]).toMatchObject({ function: 'journal' })
|
|
93
|
+
expect(calls).toEqual([
|
|
94
|
+
'inspect:host.astrale.ai',
|
|
95
|
+
'bundle:host.astrale.ai',
|
|
96
|
+
'bundle:host.astrale.ai',
|
|
97
|
+
'bundle:host.astrale.ai',
|
|
98
|
+
])
|
|
99
|
+
})
|
|
21
100
|
})
|
|
@@ -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
|
})
|