@astrale-os/cli 1.0.0-beta.28 → 1.0.0-beta.29
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/README.md +33 -0
- package/dist/astrale.js +1629 -1088
- package/dist/public/connect-core.js +86 -17
- package/dist/public/keys/index.js +69 -2
- package/dist/public/paths/index.js +67 -0
- package/dist/types/connection/session.d.ts +2 -2
- package/dist/types/lib/config.d.ts +6 -0
- package/dist/types/state/exchange-credentials.d.ts +6 -2
- package/dist/types/state/files.d.ts +2 -0
- package/dist/types/state/index.d.ts +3 -2
- package/dist/types/state/paths.d.ts +2 -0
- package/dist/types/state/session-routes.d.ts +10 -0
- package/package.json +2 -2
- package/src/commands/auth/logout.ts +2 -1
- package/src/commands/browser.ts +29 -0
- package/src/connection/.spec/architecture.md +9 -1
- package/src/connection/__tests__/auth.test.ts +1 -0
- package/src/connection/__tests__/credential.test.ts +1 -0
- package/src/connection/__tests__/exchange.test.ts +34 -10
- package/src/connection/__tests__/session.test.ts +5 -1
- package/src/connection/__tests__/target.test.ts +1 -0
- package/src/connection/exchange.ts +63 -38
- package/src/connection/session.ts +8 -1
- package/src/identity/__tests__/fixtures/registry-journey.ts +13 -1
- package/src/identity/__tests__/registry.test.ts +4 -0
- package/src/identity/registry.ts +2 -0
- package/src/lib/__tests__/browser-retention.test.ts +212 -0
- package/src/lib/__tests__/config.test.ts +27 -0
- package/src/lib/browser-retention.ts +210 -0
- package/src/lib/config.ts +12 -1
- package/src/state/.spec/api.d.ts +21 -2
- package/src/state/.spec/architecture.md +18 -4
- package/src/state/.spec/layout.ts +1 -0
- package/src/state/__tests__/exchange-credentials.test.ts +88 -42
- package/src/state/__tests__/files.test.ts +24 -1
- package/src/state/__tests__/fixtures/session-route-process.ts +93 -0
- package/src/state/__tests__/paths.test.ts +1 -0
- package/src/state/__tests__/session-routes.test.ts +138 -0
- package/src/state/exchange-credentials.ts +22 -9
- package/src/state/files.ts +41 -0
- package/src/state/index.ts +3 -1
- package/src/state/paths.ts +3 -0
- package/src/state/session-routes.ts +34 -0
- package/src/telemetry/__tests__/analyze-log.test.ts +38 -0
- package/src/telemetry/__tests__/retention.test.ts +88 -1
- package/src/telemetry/analyze.ts +24 -2
- package/src/telemetry/retention.ts +53 -6
- package/src/telemetry/store.ts +5 -0
- package/studio/package.json +1 -1
- package/viewer/dist/main.js +28 -28
|
@@ -20,6 +20,7 @@ describe('state paths', () => {
|
|
|
20
20
|
expect(explicit.home).toBe('/explicit/home')
|
|
21
21
|
expect(explicit.keys).toBe('/environment/keys')
|
|
22
22
|
expect(explicit.config).toBe(join('/explicit/home', 'config.json'))
|
|
23
|
+
expect(explicit.sessionRoutes).toBe(join('/explicit/home', 'session', 'routes.json'))
|
|
23
24
|
expect(inherited.home).toBe('/environment/home')
|
|
24
25
|
expect(inherited.keys).toBe('/environment/keys')
|
|
25
26
|
expect(Object.isFrozen(explicit)).toBe(true)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { SessionRouteArtifact } from '@astrale-os/sdk/client/session'
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
4
|
+
import { access, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
|
|
8
|
+
import { FileSessionRouteStore } from '../session-routes'
|
|
9
|
+
|
|
10
|
+
let directory: string
|
|
11
|
+
let path: string
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
directory = await mkdtemp(join(tmpdir(), 'astrale-session-routes-'))
|
|
15
|
+
path = join(directory, 'private', 'routes.json')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
await rm(directory, { recursive: true, force: true })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('session route file store', () => {
|
|
23
|
+
test('round-trips the Kernel-owned artifact under owner-private filesystem modes', async () => {
|
|
24
|
+
const artifact: SessionRouteArtifact = { version: 1, entries: {} }
|
|
25
|
+
const store = new FileSessionRouteStore(path)
|
|
26
|
+
|
|
27
|
+
store.write(artifact)
|
|
28
|
+
|
|
29
|
+
expect(store.read()).toEqual(artifact)
|
|
30
|
+
expect((await stat(path)).mode & 0o777).toBe(0o600)
|
|
31
|
+
expect((await stat(join(directory, 'private'))).mode & 0o777).toBe(0o700)
|
|
32
|
+
expect(await readFile(path, 'utf8')).toBe('{"version":1,"entries":{}}\n')
|
|
33
|
+
|
|
34
|
+
store.clear()
|
|
35
|
+
await expect(access(path)).rejects.toThrow()
|
|
36
|
+
expect(() => store.clear()).not.toThrow()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('leaves malformed representation recovery to Kernel Client as a cold miss', async () => {
|
|
40
|
+
await mkdir(join(directory, 'private'))
|
|
41
|
+
await writeFile(path, '{invalid')
|
|
42
|
+
const fixture = join(import.meta.dir, 'fixtures', 'session-route-process.ts')
|
|
43
|
+
|
|
44
|
+
expect(await runFixture(fixture, path)).toEqual({
|
|
45
|
+
value: 'done',
|
|
46
|
+
sourceAttempts: 1,
|
|
47
|
+
destinationAttempts: 1,
|
|
48
|
+
})
|
|
49
|
+
expect(JSON.parse(await readFile(path, 'utf8'))).toMatchObject({ version: 1 })
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('reuses Kernel-admitted routing state in a separate operating-system process', async () => {
|
|
53
|
+
const routePath = join(directory, 'private', 'routes.json')
|
|
54
|
+
const fixture = join(import.meta.dir, 'fixtures', 'session-route-process.ts')
|
|
55
|
+
|
|
56
|
+
const first = await runFixture(fixture, routePath)
|
|
57
|
+
const second = await runFixture(fixture, routePath)
|
|
58
|
+
|
|
59
|
+
expect(first).toEqual({ value: 'done', sourceAttempts: 1, destinationAttempts: 1 })
|
|
60
|
+
expect(second).toEqual({ value: 'done', sourceAttempts: 0, destinationAttempts: 1 })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('publishes only complete owner-private artifacts under concurrent process writers', async () => {
|
|
64
|
+
const routePath = join(directory, 'private', 'routes.json')
|
|
65
|
+
const fixture = join(import.meta.dir, 'fixtures', 'session-route-process.ts')
|
|
66
|
+
|
|
67
|
+
const results = await Promise.all(
|
|
68
|
+
Array.from({ length: 8 }, () => runFixture(fixture, routePath)),
|
|
69
|
+
)
|
|
70
|
+
expect(results).toHaveLength(8)
|
|
71
|
+
expect(results.every((result) => result.destinationAttempts === 1)).toBe(true)
|
|
72
|
+
expect(JSON.parse(await readFile(routePath, 'utf8'))).toMatchObject({ version: 1 })
|
|
73
|
+
expect((await stat(routePath)).mode & 0o777).toBe(0o600)
|
|
74
|
+
expect(await runFixture(fixture, routePath)).toEqual({
|
|
75
|
+
value: 'done',
|
|
76
|
+
sourceAttempts: 0,
|
|
77
|
+
destinationAttempts: 1,
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('auth logout clears every persisted bearer cache through the real CLI command', async () => {
|
|
82
|
+
const routePath = join(directory, 'session', 'routes.json')
|
|
83
|
+
const exchangePath = join(directory, 'exchange', 'credentials.json')
|
|
84
|
+
await mkdir(join(directory, 'session'), { recursive: true })
|
|
85
|
+
await mkdir(join(directory, 'exchange'), { recursive: true })
|
|
86
|
+
await writeFile(routePath, '{"version":1,"entries":{"confidential":{}}}\n')
|
|
87
|
+
await writeFile(
|
|
88
|
+
exchangePath,
|
|
89
|
+
'{"version":2,"entries":{"confidential":{"credential":"bearer"}}}\n',
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
const child = Bun.spawn(
|
|
93
|
+
[
|
|
94
|
+
process.execPath,
|
|
95
|
+
join(import.meta.dir, '../../..', 'bin', 'astrale.ts'),
|
|
96
|
+
'auth',
|
|
97
|
+
'logout',
|
|
98
|
+
'--all',
|
|
99
|
+
'--json',
|
|
100
|
+
],
|
|
101
|
+
{
|
|
102
|
+
env: { ...process.env, ASTRALE_HOME: directory },
|
|
103
|
+
stdout: 'pipe',
|
|
104
|
+
stderr: 'pipe',
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
108
|
+
child.exited,
|
|
109
|
+
new Response(child.stdout).text(),
|
|
110
|
+
new Response(child.stderr).text(),
|
|
111
|
+
])
|
|
112
|
+
|
|
113
|
+
expect(exitCode, stderr).toBe(0)
|
|
114
|
+
expect(JSON.parse(stdout)).toEqual({ cleared: [] })
|
|
115
|
+
await expect(access(routePath)).rejects.toThrow()
|
|
116
|
+
expect(JSON.parse(await readFile(exchangePath, 'utf8'))).toEqual({ version: 2, entries: {} })
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
interface FixtureResult {
|
|
121
|
+
readonly value: string
|
|
122
|
+
readonly sourceAttempts: number
|
|
123
|
+
readonly destinationAttempts: number
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function runFixture(fixture: string, routePath: string): Promise<FixtureResult> {
|
|
127
|
+
const child = Bun.spawn([process.execPath, fixture, routePath], {
|
|
128
|
+
stdout: 'pipe',
|
|
129
|
+
stderr: 'pipe',
|
|
130
|
+
})
|
|
131
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
132
|
+
child.exited,
|
|
133
|
+
new Response(child.stdout).text(),
|
|
134
|
+
new Response(child.stderr).text(),
|
|
135
|
+
])
|
|
136
|
+
expect(exitCode, stderr).toBe(0)
|
|
137
|
+
return JSON.parse(stdout) as FixtureResult
|
|
138
|
+
}
|
|
@@ -5,24 +5,28 @@ import { dirname } from 'node:path'
|
|
|
5
5
|
import { atomicWrite, withFileLock } from './files'
|
|
6
6
|
import { EXCHANGE_CREDENTIALS_PATH } from './paths'
|
|
7
7
|
|
|
8
|
-
const VERSION =
|
|
8
|
+
const VERSION = 2
|
|
9
9
|
const MINIMUM_REMAINING_SECONDS = 30
|
|
10
10
|
|
|
11
11
|
export namespace exchange {
|
|
12
12
|
export interface Artifact {
|
|
13
|
-
readonly version:
|
|
13
|
+
readonly version: 2
|
|
14
14
|
readonly entries: Record<string, Entry>
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export interface Key {
|
|
18
18
|
readonly kernelIssuer: string
|
|
19
19
|
readonly domainIssuer: string
|
|
20
|
-
readonly
|
|
20
|
+
readonly sourceIssuer: string
|
|
21
|
+
readonly sourceSubject: string
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export interface Entry {
|
|
24
25
|
readonly credential: string
|
|
25
26
|
readonly expiresAt: number
|
|
27
|
+
readonly user: string
|
|
28
|
+
readonly sourceIssuer: string
|
|
29
|
+
readonly sourceSubject: string
|
|
26
30
|
}
|
|
27
31
|
}
|
|
28
32
|
|
|
@@ -140,11 +144,15 @@ function validEntry(
|
|
|
140
144
|
if (
|
|
141
145
|
entry === null ||
|
|
142
146
|
typeof entry !== 'object' ||
|
|
143
|
-
Reflect.ownKeys(entry).length !==
|
|
147
|
+
Reflect.ownKeys(entry).length !== 5 ||
|
|
144
148
|
typeof entry.credential !== 'string' ||
|
|
145
149
|
entry.credential.length === 0 ||
|
|
146
150
|
!Number.isSafeInteger(entry.expiresAt) ||
|
|
147
|
-
entry.expiresAt - now < minimumRemaining
|
|
151
|
+
entry.expiresAt - now < minimumRemaining ||
|
|
152
|
+
typeof entry.user !== 'string' ||
|
|
153
|
+
entry.user.length === 0 ||
|
|
154
|
+
entry.sourceIssuer !== key.sourceIssuer ||
|
|
155
|
+
entry.sourceSubject !== key.sourceSubject
|
|
148
156
|
) {
|
|
149
157
|
return false
|
|
150
158
|
}
|
|
@@ -178,7 +186,7 @@ function validEntry(
|
|
|
178
186
|
inspected.claims.exp === entry.expiresAt &&
|
|
179
187
|
!Object.hasOwn(inspected.claims, 'delegation') &&
|
|
180
188
|
proof.iss === key.kernelIssuer &&
|
|
181
|
-
proof.sub ===
|
|
189
|
+
proof.sub === entry.user &&
|
|
182
190
|
proof.aud === key.kernelIssuer
|
|
183
191
|
)
|
|
184
192
|
} catch {
|
|
@@ -187,7 +195,7 @@ function validEntry(
|
|
|
187
195
|
}
|
|
188
196
|
|
|
189
197
|
function encodeKey(key: exchange.Key): string {
|
|
190
|
-
return JSON.stringify([key.kernelIssuer, key.domainIssuer, key.
|
|
198
|
+
return JSON.stringify([key.kernelIssuer, key.domainIssuer, key.sourceIssuer, key.sourceSubject])
|
|
191
199
|
}
|
|
192
200
|
|
|
193
201
|
function decodeKey(input: string): exchange.Key | undefined {
|
|
@@ -195,12 +203,17 @@ function decodeKey(input: string): exchange.Key | undefined {
|
|
|
195
203
|
const value = JSON.parse(input) as unknown
|
|
196
204
|
if (
|
|
197
205
|
!Array.isArray(value) ||
|
|
198
|
-
value.length !==
|
|
206
|
+
value.length !== 4 ||
|
|
199
207
|
value.some((part) => typeof part !== 'string' || part.length === 0)
|
|
200
208
|
) {
|
|
201
209
|
return undefined
|
|
202
210
|
}
|
|
203
|
-
return {
|
|
211
|
+
return {
|
|
212
|
+
kernelIssuer: value[0]!,
|
|
213
|
+
domainIssuer: value[1]!,
|
|
214
|
+
sourceIssuer: value[2]!,
|
|
215
|
+
sourceSubject: value[3]!,
|
|
216
|
+
}
|
|
204
217
|
} catch {
|
|
205
218
|
return undefined
|
|
206
219
|
}
|
package/src/state/files.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
fsyncSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
openSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from 'node:fs'
|
|
2
11
|
import { mkdir, open, readFile, rename, stat, unlink } from 'node:fs/promises'
|
|
3
12
|
import { dirname } from 'node:path'
|
|
4
13
|
|
|
@@ -30,6 +39,38 @@ export async function atomicWrite(path: string, data: string): Promise<void> {
|
|
|
30
39
|
}
|
|
31
40
|
}
|
|
32
41
|
|
|
42
|
+
/** Atomically publish one complete private state file for synchronous consumer capabilities. */
|
|
43
|
+
export function atomicWriteSync(path: string, data: string): void {
|
|
44
|
+
const directory = dirname(path)
|
|
45
|
+
const temporary = `${path}.${randomUUID()}.tmp`
|
|
46
|
+
mkdirSync(directory, { recursive: true })
|
|
47
|
+
|
|
48
|
+
let descriptor: number | undefined
|
|
49
|
+
try {
|
|
50
|
+
descriptor = openSync(temporary, 'wx', 0o600)
|
|
51
|
+
writeFileSync(descriptor, data)
|
|
52
|
+
fsyncSync(descriptor)
|
|
53
|
+
closeSync(descriptor)
|
|
54
|
+
descriptor = undefined
|
|
55
|
+
renameSync(temporary, path)
|
|
56
|
+
|
|
57
|
+
const directoryDescriptor = openSync(directory, 'r')
|
|
58
|
+
try {
|
|
59
|
+
fsyncSync(directoryDescriptor)
|
|
60
|
+
} finally {
|
|
61
|
+
closeSync(directoryDescriptor)
|
|
62
|
+
}
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (descriptor !== undefined) closeSync(descriptor)
|
|
65
|
+
try {
|
|
66
|
+
unlinkSync(temporary)
|
|
67
|
+
} catch {
|
|
68
|
+
// The temporary file was either never created or was already renamed.
|
|
69
|
+
}
|
|
70
|
+
throw error
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
33
74
|
export interface FileLockOptions {
|
|
34
75
|
readonly pollIntervalMs?: number
|
|
35
76
|
readonly staleAfterMs?: number
|
package/src/state/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { atomicWrite, withFileLock } from './files'
|
|
1
|
+
export { atomicWrite, atomicWriteSync, withFileLock } from './files'
|
|
2
2
|
export type { FileLockOptions } from './files'
|
|
3
3
|
export { ExchangeCredentialCache } from './exchange-credentials'
|
|
4
4
|
export type { exchange } from './exchange-credentials'
|
|
@@ -23,7 +23,9 @@ export {
|
|
|
23
23
|
INSTALL_PATH,
|
|
24
24
|
INSTANCES_PATH,
|
|
25
25
|
KEYS_DIR,
|
|
26
|
+
SESSION_ROUTES_PATH,
|
|
26
27
|
createPaths,
|
|
27
28
|
paths,
|
|
28
29
|
} from './paths'
|
|
29
30
|
export type { PathEnvironment, Paths } from './paths'
|
|
31
|
+
export { FileSessionRouteStore, SESSION_ROUTE_STORE } from './session-routes'
|
package/src/state/paths.ts
CHANGED
|
@@ -18,6 +18,7 @@ export interface Paths {
|
|
|
18
18
|
readonly idps: string
|
|
19
19
|
readonly idpSessionsDir: string
|
|
20
20
|
readonly exchangeCredentials: string
|
|
21
|
+
readonly sessionRoutes: string
|
|
21
22
|
idpDir(name: string): string
|
|
22
23
|
idpSession(identityName: string): string
|
|
23
24
|
}
|
|
@@ -43,6 +44,7 @@ export function createPaths(home?: string, environment?: PathEnvironment): Paths
|
|
|
43
44
|
idps: join(idpsDir, 'index.json'),
|
|
44
45
|
idpSessionsDir,
|
|
45
46
|
exchangeCredentials: join(base, 'exchange', 'credentials.json'),
|
|
47
|
+
sessionRoutes: join(base, 'session', 'routes.json'),
|
|
46
48
|
idpDir: (name: string) => join(idpsDir, name),
|
|
47
49
|
idpSession: (identityName: string) => join(idpSessionsDir, `${identityName}.json`),
|
|
48
50
|
})
|
|
@@ -59,3 +61,4 @@ export const INSTANCES_PATH = paths.instances
|
|
|
59
61
|
export const IDPS_PATH = paths.idps
|
|
60
62
|
export const IDP_SESSIONS_DIR = paths.idpSessionsDir
|
|
61
63
|
export const EXCHANGE_CREDENTIALS_PATH = paths.exchangeCredentials
|
|
64
|
+
export const SESSION_ROUTES_PATH = paths.sessionRoutes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { SessionRouteArtifact, SessionRouteStore } from '@astrale-os/sdk/client/session'
|
|
2
|
+
|
|
3
|
+
import { chmodSync, mkdirSync, readFileSync, unlinkSync } from 'node:fs'
|
|
4
|
+
import { dirname } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { atomicWriteSync } from './files'
|
|
7
|
+
import { SESSION_ROUTES_PATH } from './paths'
|
|
8
|
+
|
|
9
|
+
/** CLI filesystem representation for Kernel Client's admitted confidential route artifact. */
|
|
10
|
+
export class FileSessionRouteStore implements SessionRouteStore {
|
|
11
|
+
constructor(private readonly path = SESSION_ROUTES_PATH) {}
|
|
12
|
+
|
|
13
|
+
read(): unknown {
|
|
14
|
+
return JSON.parse(readFileSync(this.path, 'utf8')) as unknown
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
write(artifact: SessionRouteArtifact): void {
|
|
18
|
+
const directory = dirname(this.path)
|
|
19
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 })
|
|
20
|
+
chmodSync(directory, 0o700)
|
|
21
|
+
atomicWriteSync(this.path, `${JSON.stringify(artifact)}\n`)
|
|
22
|
+
chmodSync(this.path, 0o600)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
clear(): void {
|
|
26
|
+
try {
|
|
27
|
+
unlinkSync(this.path)
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if ((error as { readonly code?: unknown }).code !== 'ENOENT') throw error
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const SESSION_ROUTE_STORE = Object.freeze(new FileSessionRouteStore())
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { clampLog } from '../analyze'
|
|
4
|
+
|
|
5
|
+
describe('clampLog', () => {
|
|
6
|
+
test('short output is written through untouched', () => {
|
|
7
|
+
const json = '{"is_error":false,"num_turns":3}'
|
|
8
|
+
expect(clampLog(json)).toBe(json)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test('output at exactly the limit is not clamped', () => {
|
|
12
|
+
const text = 'x'.repeat(1000)
|
|
13
|
+
expect(clampLog(text, 1000)).toBe(text)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('oversized output keeps its head and its tail', () => {
|
|
17
|
+
// The JSON envelope opens at the top; a stack or error message closes at
|
|
18
|
+
// the bottom. Both must survive; the middle is what nobody reads.
|
|
19
|
+
const text = `HEAD${'x'.repeat(50_000)}TAIL`
|
|
20
|
+
const clamped = clampLog(text, 1000)
|
|
21
|
+
expect(clamped.startsWith('HEAD')).toBe(true)
|
|
22
|
+
expect(clamped.endsWith('TAIL')).toBe(true)
|
|
23
|
+
expect(clamped).toContain('bytes elided')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('the result stays within a small constant of the limit', () => {
|
|
27
|
+
const clamped = clampLog('y'.repeat(10_000_000), 4096)
|
|
28
|
+
// head + tail + the elision marker, never the input.
|
|
29
|
+
expect(clamped.length).toBeLessThan(4096 + 128)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('the elision count reports what was actually dropped', () => {
|
|
33
|
+
const clamped = clampLog('z'.repeat(10_000), 1000)
|
|
34
|
+
const elided = Number(/… (\d+) bytes elided …/u.exec(clamped)?.[1])
|
|
35
|
+
const kept = clamped.length - `\n… ${elided} bytes elided …\n`.length
|
|
36
|
+
expect(elided + kept).toBe(10_000)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -13,6 +13,7 @@ process.env.ASTRALE_HOME = mkdtempSync(join(tmpdir(), 'astrale-tele-retention-')
|
|
|
13
13
|
let sweepByAge: (sessions: readonly SessionScan[], options?: SweepOptions) => SweepResult
|
|
14
14
|
let sweepToBudget: (sessions: readonly SessionScan[], options?: SweepOptions) => SweepResult
|
|
15
15
|
let sweepStore: (options?: SweepOptions) => SweepResult
|
|
16
|
+
let tidySession: (id: string, options?: { keepPrompt?: boolean }) => string[]
|
|
16
17
|
let scanSessions: () => SessionScan[]
|
|
17
18
|
let sessionDir: (id: string) => string
|
|
18
19
|
let sessionBytes: (id: string) => number
|
|
@@ -22,7 +23,7 @@ const DAY = 24 * 60 * 60 * 1000
|
|
|
22
23
|
const BUDGET: RetentionBudget = { maxAgeMs: 30 * DAY, maxBytes: 10_000 }
|
|
23
24
|
|
|
24
25
|
beforeAll(async () => {
|
|
25
|
-
;({ sweepByAge, sweepToBudget, sweepStore } = await import('../retention'))
|
|
26
|
+
;({ sweepByAge, sweepToBudget, sweepStore, tidySession } = await import('../retention'))
|
|
26
27
|
;({ scanSessions, sessionDir, sessionBytes, sessionsRoot } = await import('../store'))
|
|
27
28
|
})
|
|
28
29
|
|
|
@@ -178,3 +179,89 @@ describe('sweepStore', () => {
|
|
|
178
179
|
expect(sweepStore().removed).toEqual(['two-days-old'])
|
|
179
180
|
})
|
|
180
181
|
})
|
|
182
|
+
|
|
183
|
+
describe('sweepStore tidying', () => {
|
|
184
|
+
test('tidies analyzed survivors, so the bound applies to sessions already on disk', () => {
|
|
185
|
+
seed('store-tidy', { ageMs: DAY, analyzed: true, bytes: 100 })
|
|
186
|
+
writeFileSync(join(sessionDir('store-tidy'), 'calls.txt'), 'x'.repeat(50_000))
|
|
187
|
+
|
|
188
|
+
sweepStore({ budget: BUDGET })
|
|
189
|
+
|
|
190
|
+
expect(existsSync(join(sessionDir('store-tidy'), 'calls.txt'))).toBe(false)
|
|
191
|
+
expect(existsSync(join(sessionDir('store-tidy'), 'report.md'))).toBe(true)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test('an unanalyzed session is left alone — its analyzer may still be running', () => {
|
|
195
|
+
seed('store-pending', { ageMs: DAY, bytes: 100 })
|
|
196
|
+
writeFileSync(join(sessionDir('store-pending'), 'analyzer-prompt.md'), '# in flight')
|
|
197
|
+
|
|
198
|
+
sweepStore({ budget: BUDGET })
|
|
199
|
+
|
|
200
|
+
expect(existsSync(join(sessionDir('store-pending'), 'analyzer-prompt.md'))).toBe(true)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('scratch is tidied before the size bound is measured, not after', () => {
|
|
204
|
+
// 60 KB of scratch against a 10 KB budget: tidying first brings the store
|
|
205
|
+
// back under on its own, so nothing should be evicted.
|
|
206
|
+
seed('store-bloated', { ageMs: DAY, analyzed: true, bytes: 100 })
|
|
207
|
+
writeFileSync(join(sessionDir('store-bloated'), 'calls.txt'), 'x'.repeat(60_000))
|
|
208
|
+
|
|
209
|
+
expect(sweepStore({ budget: BUDGET }).removed).toEqual([])
|
|
210
|
+
expect(existsSync(sessionDir('store-bloated'))).toBe(true)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test('a failed analysis keeps its prompt through the sweep', () => {
|
|
214
|
+
const dir = sessionDir('store-failed')
|
|
215
|
+
seed('store-failed', { ageMs: DAY, analyzed: true })
|
|
216
|
+
writeFileSync(
|
|
217
|
+
join(dir, '.analyzed'),
|
|
218
|
+
JSON.stringify({ analyzedAt: new Date().toISOString(), outcome: 'error', note: 'boom' }),
|
|
219
|
+
)
|
|
220
|
+
writeFileSync(join(dir, 'analyzer-prompt.md'), '# what we asked')
|
|
221
|
+
|
|
222
|
+
sweepStore({ budget: BUDGET })
|
|
223
|
+
|
|
224
|
+
expect(existsSync(join(dir, 'analyzer-prompt.md'))).toBe(true)
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
describe('tidySession', () => {
|
|
229
|
+
test('removes what the analyzer left behind, keeps the durable artifacts', () => {
|
|
230
|
+
const dir = sessionDir('tidy-scratch')
|
|
231
|
+
seed('tidy-scratch', { ageMs: DAY, analyzed: true, bytes: 100 })
|
|
232
|
+
writeFileSync(join(dir, 'analyzer.log'), 'exit 0')
|
|
233
|
+
writeFileSync(join(dir, 'analyzer-prompt.md'), '# prompt')
|
|
234
|
+
// Scratch: the analyzer runs with Write in here and answers to nobody.
|
|
235
|
+
writeFileSync(join(dir, 'calls.txt'), 'x'.repeat(50_000))
|
|
236
|
+
mkdirSync(join(dir, 'notes'), { recursive: true })
|
|
237
|
+
writeFileSync(join(dir, 'notes', 'draft.md'), 'scratch')
|
|
238
|
+
|
|
239
|
+
const removed = tidySession('tidy-scratch')
|
|
240
|
+
|
|
241
|
+
expect(removed.sort()).toEqual(['analyzer-prompt.md', 'calls.txt', 'notes'])
|
|
242
|
+
for (const keep of ['meta.json', 'events.jsonl', 'report.md', '.analyzed', 'analyzer.log']) {
|
|
243
|
+
expect(existsSync(join(dir, keep))).toBe(true)
|
|
244
|
+
}
|
|
245
|
+
expect(existsSync(join(dir, 'notes'))).toBe(false)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test('keepPrompt spares the prompt — the failing case is when it matters', () => {
|
|
249
|
+
const dir = sessionDir('tidy-failed')
|
|
250
|
+
seed('tidy-failed', { ageMs: DAY, analyzed: true })
|
|
251
|
+
writeFileSync(join(dir, 'analyzer-prompt.md'), '# prompt')
|
|
252
|
+
writeFileSync(join(dir, 'scratch.json'), '{}')
|
|
253
|
+
|
|
254
|
+
expect(tidySession('tidy-failed', { keepPrompt: true })).toEqual(['scratch.json'])
|
|
255
|
+
expect(existsSync(join(dir, 'analyzer-prompt.md'))).toBe(true)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('a session with nothing to tidy is left exactly as it was', () => {
|
|
259
|
+
seed('tidy-clean', { ageMs: DAY, analyzed: true, bytes: 10 })
|
|
260
|
+
expect(tidySession('tidy-clean')).toEqual([])
|
|
261
|
+
expect(existsSync(sessionDir('tidy-clean'))).toBe(true)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test('a missing session directory is a no-op, not an error', () => {
|
|
265
|
+
expect(tidySession('tidy-absent')).toEqual([])
|
|
266
|
+
})
|
|
267
|
+
})
|
package/src/telemetry/analyze.ts
CHANGED
|
@@ -5,18 +5,20 @@
|
|
|
5
5
|
* through the native `issues.astrale.ai` domain.
|
|
6
6
|
*/
|
|
7
7
|
import { spawn } from 'node:child_process'
|
|
8
|
-
import {
|
|
8
|
+
import { writeFileSync } from 'node:fs'
|
|
9
9
|
import { join } from 'node:path'
|
|
10
10
|
|
|
11
11
|
import type { AnalyzedMarker, SessionSignals } from './types'
|
|
12
12
|
|
|
13
13
|
import { defaultAdapters, discoverAll } from './adapters'
|
|
14
14
|
import { extractSignals, hasSignals, readEvents } from './gate'
|
|
15
|
+
import { tidySession } from './retention'
|
|
15
16
|
import { eventsPath, inspectSession, markerPath, sessionDir } from './store'
|
|
16
17
|
|
|
17
18
|
const ANALYZER_TIMEOUT_MS = 15 * 60 * 1000
|
|
18
19
|
const WINDOW_PAD_MS = 10 * 60 * 1000
|
|
19
20
|
const MAX_TRANSCRIPTS = 6
|
|
21
|
+
const MAX_ANALYZER_LOG_BYTES = 32 * 1024
|
|
20
22
|
// Transcripts embed content the developer's agent pulled from anywhere — treat
|
|
21
23
|
// them as injection vectors: no --dangerously-skip-permissions; unlisted tools
|
|
22
24
|
// are simply denied in -p mode. git/astrale cover inspection + reproduction +
|
|
@@ -29,6 +31,16 @@ function writeMarker(id: string, marker: AnalyzedMarker): void {
|
|
|
29
31
|
writeFileSync(markerPath(id), JSON.stringify(marker, null, 2) + '\n')
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
/** Head and tail, never the middle: the JSON envelope opens at the top, a stack
|
|
35
|
+
* or an error message closes at the bottom, and what sits between them is the
|
|
36
|
+
* part nobody reads. A log is a diagnostic aid, not a place to put a megabyte. */
|
|
37
|
+
export function clampLog(text: string, max = MAX_ANALYZER_LOG_BYTES): string {
|
|
38
|
+
if (text.length <= max) return text
|
|
39
|
+
const half = Math.floor((max - 64) / 2)
|
|
40
|
+
const elided = text.length - 2 * half
|
|
41
|
+
return `${text.slice(0, half)}\n… ${elided} bytes elided …\n${text.slice(-half)}`
|
|
42
|
+
}
|
|
43
|
+
|
|
32
44
|
/** Compact per-command digest so the agent starts from facts, not raw logs. */
|
|
33
45
|
function eventDigest(signals: SessionSignals): string {
|
|
34
46
|
const lines: string[] = [`events: ${signals.eventCount}`]
|
|
@@ -146,6 +158,7 @@ export async function analyzeSession(
|
|
|
146
158
|
note: `${signals.eventCount} events, all green, no transcripts`,
|
|
147
159
|
}
|
|
148
160
|
writeMarker(id, marker)
|
|
161
|
+
tidySession(id)
|
|
149
162
|
return marker
|
|
150
163
|
}
|
|
151
164
|
|
|
@@ -161,6 +174,9 @@ export async function analyzeSession(
|
|
|
161
174
|
note: outcome.note,
|
|
162
175
|
}
|
|
163
176
|
writeMarker(id, marker)
|
|
177
|
+
// Keep the prompt only when the run failed — that is the one case where what
|
|
178
|
+
// the analyzer was asked still matters.
|
|
179
|
+
tidySession(id, { keepPrompt: marker.outcome === 'error' })
|
|
164
180
|
return { ...marker, reportPath: join(dir, 'report.md') }
|
|
165
181
|
}
|
|
166
182
|
|
|
@@ -200,7 +216,13 @@ function runClaude(
|
|
|
200
216
|
child.on('close', (code) => {
|
|
201
217
|
clearTimeout(timer)
|
|
202
218
|
try {
|
|
203
|
-
|
|
219
|
+
// Write rather than append: the output is one JSON envelope per run, so
|
|
220
|
+
// stacking several produced a file that parsed as none of them. A
|
|
221
|
+
// re-analysis replaces its predecessor's log instead of growing it.
|
|
222
|
+
writeFileSync(
|
|
223
|
+
join(cwd, 'analyzer.log'),
|
|
224
|
+
clampLog(out + (err ? `\n--- stderr ---\n${err}` : '')),
|
|
225
|
+
)
|
|
204
226
|
} catch {
|
|
205
227
|
/* best effort */
|
|
206
228
|
}
|
|
@@ -15,13 +15,52 @@
|
|
|
15
15
|
* Neither bound replaces the other: age bounds what is worth keeping, size
|
|
16
16
|
* bounds what can go wrong. Both are configurable — see settings.ts.
|
|
17
17
|
*/
|
|
18
|
-
import { rmSync } from 'node:fs'
|
|
18
|
+
import { type Dirent, readdirSync, rmSync } from 'node:fs'
|
|
19
|
+
import { join } from 'node:path'
|
|
19
20
|
|
|
20
21
|
import type { RetentionBudget } from './settings'
|
|
21
22
|
import type { SessionScan } from './store'
|
|
22
23
|
|
|
23
24
|
import { retentionBudget } from './settings'
|
|
24
|
-
import { scanSessions, sessionBytes, sessionDir } from './store'
|
|
25
|
+
import { readMarker, scanSessions, sessionBytes, sessionDir } from './store'
|
|
26
|
+
|
|
27
|
+
/** What an analyzed session is allowed to keep. Everything else is scratch: the
|
|
28
|
+
* analyzer runs with Write inside the session directory, so without this a
|
|
29
|
+
* session's size is whatever the agent felt like writing — one here was left
|
|
30
|
+
* holding a 462 KB calls.txt, a quarter of the entire store. */
|
|
31
|
+
const KEEP = new Set(['meta.json', 'events.jsonl', 'report.md', '.analyzed', 'analyzer.log'])
|
|
32
|
+
|
|
33
|
+
/** Reproducible from events.jsonl in the normal case, and dropped there — but
|
|
34
|
+
* it is the only record of what the analyzer was actually asked when it
|
|
35
|
+
* failed, which is exactly when someone will want to look. */
|
|
36
|
+
const ANALYZER_PROMPT = 'analyzer-prompt.md'
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Reduce one session to its durable artifacts, called once the analyzer has
|
|
40
|
+
* written its marker. This is what makes a session's footprint a property of
|
|
41
|
+
* the CLI rather than of whatever the agent decided to leave behind.
|
|
42
|
+
*/
|
|
43
|
+
export function tidySession(id: string, options: { keepPrompt?: boolean } = {}): string[] {
|
|
44
|
+
const dir = sessionDir(id)
|
|
45
|
+
let entries: Dirent[]
|
|
46
|
+
try {
|
|
47
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
48
|
+
} catch {
|
|
49
|
+
return []
|
|
50
|
+
}
|
|
51
|
+
const removed: string[] = []
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
if (KEEP.has(entry.name)) continue
|
|
54
|
+
if (entry.name === ANALYZER_PROMPT && options.keepPrompt === true) continue
|
|
55
|
+
try {
|
|
56
|
+
rmSync(join(dir, entry.name), { recursive: true, force: true })
|
|
57
|
+
removed.push(entry.name)
|
|
58
|
+
} catch {
|
|
59
|
+
/* best effort — tidying must never fail the analysis it follows */
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return removed
|
|
63
|
+
}
|
|
25
64
|
|
|
26
65
|
/** Cap for the age sweep on the CLI's critical path, so a large backlog drains
|
|
27
66
|
* over several runs instead of stalling one command on hundreds of rmSync. */
|
|
@@ -121,9 +160,17 @@ export function sweepStore(options: SweepOptions = {}): SweepResult {
|
|
|
121
160
|
const sessions = scanSessions()
|
|
122
161
|
const byAge = sweepByAge(sessions, { ...options, budget })
|
|
123
162
|
const gone = new Set(byAge.removed)
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
163
|
+
const survivors = sessions.filter((session) => !gone.has(session.id))
|
|
164
|
+
|
|
165
|
+
// Tidy before measuring. Doing it after would let a session be evicted for
|
|
166
|
+
// holding scratch that was about to be deleted anyway — and it is what makes
|
|
167
|
+
// the artifact bound retroactive rather than only applying to new analyses.
|
|
168
|
+
for (const session of survivors) {
|
|
169
|
+
if (session.analyzed) {
|
|
170
|
+
tidySession(session.id, { keepPrompt: readMarker(session.id)?.outcome === 'error' })
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const bySize = sweepToBudget(survivors, { ...options, budget })
|
|
128
175
|
return { removed: [...byAge.removed, ...bySize.removed] }
|
|
129
176
|
}
|
package/src/telemetry/store.ts
CHANGED
|
@@ -133,6 +133,11 @@ export function readMeta(id: string): SessionMeta | null {
|
|
|
133
133
|
return readJsonSafe<SessionMeta>(metaPath(id))
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/** One session's analyzer marker, or null when absent or unparseable. */
|
|
137
|
+
export function readMarker(id: string): AnalyzedMarker | null {
|
|
138
|
+
return readJsonSafe<AnalyzedMarker>(markerPath(id))
|
|
139
|
+
}
|
|
140
|
+
|
|
136
141
|
function readJsonSafe<T>(path: string): T | null {
|
|
137
142
|
try {
|
|
138
143
|
return JSON.parse(readFileSync(path, 'utf-8')) as T
|
package/studio/package.json
CHANGED