@astrale-os/cli 1.0.0-beta.27 → 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.
Files changed (108) hide show
  1. package/README.md +59 -3
  2. package/dist/astrale.js +1765 -1057
  3. package/dist/public/connect-core.js +86 -17
  4. package/dist/public/keys/index.js +69 -2
  5. package/dist/public/paths/index.js +67 -0
  6. package/dist/types/connection/session.d.ts +2 -2
  7. package/dist/types/lib/config.d.ts +6 -0
  8. package/dist/types/state/exchange-credentials.d.ts +6 -2
  9. package/dist/types/state/files.d.ts +2 -0
  10. package/dist/types/state/index.d.ts +3 -2
  11. package/dist/types/state/paths.d.ts +2 -0
  12. package/dist/types/state/session-routes.d.ts +10 -0
  13. package/package.json +2 -2
  14. package/src/commands/__tests__/domain-install-operation.test.ts +23 -0
  15. package/src/commands/__tests__/install-identity-override.test.ts +3 -3
  16. package/src/commands/auth/logout.ts +2 -1
  17. package/src/commands/browser.ts +29 -0
  18. package/src/commands/domain/install.ts +19 -11
  19. package/src/commands/get.ts +1 -1
  20. package/src/commands/identity/register.ts +3 -4
  21. package/src/commands/logs.ts +2 -5
  22. package/src/commands/session/analyze.ts +26 -4
  23. package/src/connection/.spec/architecture.md +9 -1
  24. package/src/connection/__tests__/auth.test.ts +1 -0
  25. package/src/connection/__tests__/credential.test.ts +1 -0
  26. package/src/connection/__tests__/exchange.test.ts +34 -10
  27. package/src/connection/__tests__/session.test.ts +5 -1
  28. package/src/connection/__tests__/target.test.ts +1 -0
  29. package/src/connection/exchange.ts +63 -38
  30. package/src/connection/session.ts +8 -1
  31. package/src/identity/__tests__/fixtures/registry-journey.ts +13 -1
  32. package/src/identity/__tests__/registry.test.ts +4 -0
  33. package/src/identity/registry.ts +2 -0
  34. package/src/lib/__tests__/browser-retention.test.ts +212 -0
  35. package/src/lib/__tests__/config.test.ts +27 -0
  36. package/src/lib/__tests__/skills.test.ts +8 -2
  37. package/src/lib/browser-retention.ts +210 -0
  38. package/src/lib/config.ts +12 -1
  39. package/src/program/__tests__/program.test.ts +1 -1
  40. package/src/state/.spec/api.d.ts +21 -2
  41. package/src/state/.spec/architecture.md +18 -4
  42. package/src/state/.spec/layout.ts +1 -0
  43. package/src/state/__tests__/exchange-credentials.test.ts +88 -42
  44. package/src/state/__tests__/files.test.ts +24 -1
  45. package/src/state/__tests__/fixtures/session-route-process.ts +93 -0
  46. package/src/state/__tests__/paths.test.ts +1 -0
  47. package/src/state/__tests__/session-routes.test.ts +138 -0
  48. package/src/state/exchange-credentials.ts +22 -9
  49. package/src/state/files.ts +41 -0
  50. package/src/state/index.ts +3 -1
  51. package/src/state/paths.ts +3 -0
  52. package/src/state/session-routes.ts +34 -0
  53. package/src/telemetry/__tests__/analyze-log.test.ts +38 -0
  54. package/src/telemetry/__tests__/retention.test.ts +267 -0
  55. package/src/telemetry/__tests__/settings.test.ts +102 -0
  56. package/src/telemetry/__tests__/store-scan.test.ts +128 -0
  57. package/src/telemetry/__tests__/trigger.test.ts +33 -4
  58. package/src/telemetry/analyze.ts +24 -2
  59. package/src/telemetry/recorder.ts +6 -3
  60. package/src/telemetry/retention.ts +176 -0
  61. package/src/telemetry/session.ts +18 -12
  62. package/src/telemetry/settings.ts +64 -11
  63. package/src/telemetry/store.ts +92 -9
  64. package/src/telemetry/trigger.ts +16 -28
  65. package/studio/client/dist/assets/index-BFVFs_8x.js +81 -0
  66. package/studio/client/dist/assets/index-DuB9iUdu.css +2 -0
  67. package/studio/client/dist/assets/schema-studio--a0kX3QU.css +1 -0
  68. package/studio/client/dist/assets/schema-studio-DH6oEWME.js +8 -0
  69. package/studio/client/dist/index.html +3 -3
  70. package/studio/package.json +1 -1
  71. package/studio/server/agent/prompts/anchors.test.ts +17 -4
  72. package/studio/server/agent/prompts/anchors.ts +3 -2
  73. package/studio/server/agent/prompts/system.test.ts +2 -3
  74. package/studio/server/agent/prompts/system.ts +3 -3
  75. package/studio/server/agent/run/preparation.ts +1 -1
  76. package/studio/server/api/context.ts +1 -1
  77. package/studio/server/api/deployment.ts +1 -1
  78. package/studio/server/api/views.ts +2 -2
  79. package/studio/server/api/workspace.ts +1 -1
  80. package/studio/server/cache.test.ts +3 -8
  81. package/studio/server/cache.ts +4 -45
  82. package/studio/server/handoff/copy.ts +1 -1
  83. package/studio/server/introspect/canonical-schema.test.ts +154 -128
  84. package/studio/server/introspect/canonical-schema.ts +183 -302
  85. package/studio/server/introspect/core.ts +14 -21
  86. package/studio/server/introspect/extractor.ts +11 -15
  87. package/studio/server/introspect/overlay-tsmorph.test.ts +1 -5
  88. package/studio/server/introspect/overlay-tsmorph.ts +0 -1
  89. package/studio/server/introspect/overlay.ts +3 -24
  90. package/studio/server/introspect/revision.test.ts +24 -28
  91. package/studio/server/introspect/revision.ts +10 -21
  92. package/studio/server/introspect/runtime.test.ts +14 -14
  93. package/studio/server/introspect/runtime.ts +1 -46
  94. package/studio/server/lifecycle.ts +4 -1
  95. package/studio/server/state/documents.test.ts +69 -0
  96. package/studio/server/state/documents.ts +57 -10
  97. package/studio/shared/contracts/schema.ts +10 -26
  98. package/studio/shared/contracts/surface.test.ts +2 -2
  99. package/studio/shared/contracts/workspace.ts +3 -0
  100. package/studio/shared/schema/identity.ts +0 -1
  101. package/viewer/dist/main.js +28 -28
  102. package/studio/client/dist/assets/index-C0FAnwNw.css +0 -2
  103. package/studio/client/dist/assets/index-CpBed0V1.js +0 -81
  104. package/studio/client/dist/assets/schema-studio-BilUNb6Z.css +0 -1
  105. package/studio/client/dist/assets/schema-studio-DJm9guI8.js +0 -8
  106. package/studio/server/introspect/core-extractor.ts +0 -30
  107. package/studio/server/introspect/overlay.test.ts +0 -44
  108. package/studio/server/introspect/source-overlay/annotations.ts +0 -14
@@ -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
+ })
@@ -0,0 +1,267 @@
1
+ import { afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import type { SweepOptions, SweepResult } from '../retention'
7
+ import type { RetentionBudget } from '../settings'
8
+ import type { SessionScan } from '../store'
9
+
10
+ // Set the home before the paths singleton is captured (dynamic imports below).
11
+ process.env.ASTRALE_HOME = mkdtempSync(join(tmpdir(), 'astrale-tele-retention-'))
12
+
13
+ let sweepByAge: (sessions: readonly SessionScan[], options?: SweepOptions) => SweepResult
14
+ let sweepToBudget: (sessions: readonly SessionScan[], options?: SweepOptions) => SweepResult
15
+ let sweepStore: (options?: SweepOptions) => SweepResult
16
+ let tidySession: (id: string, options?: { keepPrompt?: boolean }) => string[]
17
+ let scanSessions: () => SessionScan[]
18
+ let sessionDir: (id: string) => string
19
+ let sessionBytes: (id: string) => number
20
+ let sessionsRoot: () => string
21
+
22
+ const DAY = 24 * 60 * 60 * 1000
23
+ const BUDGET: RetentionBudget = { maxAgeMs: 30 * DAY, maxBytes: 10_000 }
24
+
25
+ beforeAll(async () => {
26
+ ;({ sweepByAge, sweepToBudget, sweepStore, tidySession } = await import('../retention'))
27
+ ;({ scanSessions, sessionDir, sessionBytes, sessionsRoot } = await import('../store'))
28
+ })
29
+
30
+ beforeEach(() => {
31
+ // Destructive cleanup must be provably confined to this file's mkdtemp home.
32
+ if (!sessionsRoot().startsWith(tmpdir())) throw new Error('refusing to clean a non-tmp home')
33
+ rmSync(sessionsRoot(), { recursive: true, force: true })
34
+ delete process.env.ASTRALE_TELEMETRY_MAX_AGE_DAYS
35
+ delete process.env.ASTRALE_TELEMETRY_MAX_BYTES
36
+ })
37
+
38
+ afterEach(() => {
39
+ rmSync(sessionsRoot(), { recursive: true, force: true })
40
+ })
41
+
42
+ type Seed = { ageMs?: number; analyzed?: boolean; bytes?: number; events?: boolean }
43
+
44
+ function seed(id: string, { ageMs = 0, analyzed = false, bytes = 0, events = true }: Seed): void {
45
+ const dir = sessionDir(id)
46
+ mkdirSync(dir, { recursive: true })
47
+ writeFileSync(join(dir, 'meta.json'), JSON.stringify({ id, root: '/w', explicit: false }))
48
+ if (bytes > 0) writeFileSync(join(dir, 'report.md'), 'x'.repeat(bytes))
49
+ if (analyzed) {
50
+ writeFileSync(
51
+ join(dir, '.analyzed'),
52
+ JSON.stringify({ analyzedAt: new Date().toISOString(), outcome: 'reported' }),
53
+ )
54
+ }
55
+ if (!events) return
56
+ writeFileSync(join(dir, 'events.jsonl'), '{}\n')
57
+ const when = new Date(Date.now() - ageMs)
58
+ utimesSync(join(dir, 'events.jsonl'), when, when)
59
+ }
60
+
61
+ const alive = (id: string): boolean => existsSync(sessionDir(id))
62
+
63
+ describe('sweepByAge', () => {
64
+ test('drops sessions past the age bound whether or not they were analyzed', () => {
65
+ seed('old-analyzed', { ageMs: 40 * DAY, analyzed: true })
66
+ seed('old-unanalyzed', { ageMs: 40 * DAY })
67
+ seed('recent-analyzed', { ageMs: 2 * DAY, analyzed: true })
68
+ seed('recent-unanalyzed', { ageMs: 2 * DAY })
69
+
70
+ const { removed } = sweepByAge(scanSessions(), { budget: BUDGET })
71
+
72
+ expect(removed.sort()).toEqual(['old-analyzed', 'old-unanalyzed'])
73
+ expect(alive('recent-analyzed')).toBe(true)
74
+ expect(alive('recent-unanalyzed')).toBe(true)
75
+ })
76
+
77
+ test('never touches a session with no events — that is the invocation running now', () => {
78
+ seed('no-events-yet', { events: false })
79
+ expect(sweepByAge(scanSessions(), { budget: BUDGET }).removed).toEqual([])
80
+ expect(alive('no-events-yet')).toBe(true)
81
+ })
82
+
83
+ test('honours the removal cap so a backlog drains over several runs', () => {
84
+ for (let i = 0; i < 5; i++) seed(`stale-${i}`, { ageMs: 40 * DAY, analyzed: true })
85
+ expect(sweepByAge(scanSessions(), { budget: BUDGET, limit: 2 }).removed).toHaveLength(2)
86
+ expect(scanSessions()).toHaveLength(3)
87
+ })
88
+
89
+ test('respects the configured age bound', () => {
90
+ seed('week-old', { ageMs: 8 * DAY, analyzed: true })
91
+ const strict: RetentionBudget = { ...BUDGET, maxAgeMs: 7 * DAY }
92
+ expect(sweepByAge(scanSessions(), { budget: strict }).removed).toEqual(['week-old'])
93
+ })
94
+
95
+ test('protected ids survive the age bound', () => {
96
+ seed('old-but-mine', { ageMs: 40 * DAY, analyzed: true })
97
+ const options = { budget: BUDGET, protect: new Set(['old-but-mine']) }
98
+ expect(sweepByAge(scanSessions(), options).removed).toEqual([])
99
+ expect(alive('old-but-mine')).toBe(true)
100
+ })
101
+ })
102
+
103
+ describe('sweepToBudget', () => {
104
+ test('does nothing while the store fits', () => {
105
+ seed('small', { ageMs: DAY, analyzed: true, bytes: 100 })
106
+ expect(sweepToBudget(scanSessions(), { budget: BUDGET }).removed).toEqual([])
107
+ expect(alive('small')).toBe(true)
108
+ })
109
+
110
+ test('evicts oldest-first until the store is back under budget', () => {
111
+ seed('oldest', { ageMs: 5 * DAY, analyzed: true, bytes: 4_000 })
112
+ seed('middle', { ageMs: 3 * DAY, analyzed: true, bytes: 4_000 })
113
+ seed('newest', { ageMs: DAY, analyzed: true, bytes: 4_000 })
114
+
115
+ // 3 × ~4 KB against a 5 KB bound: two must go, and the newest must stay.
116
+ const { removed } = sweepToBudget(scanSessions(), { budget: { ...BUDGET, maxBytes: 5_000 } })
117
+
118
+ expect(removed).toEqual(['oldest', 'middle'])
119
+ expect(alive('newest')).toBe(true)
120
+ })
121
+
122
+ test('evicts analyzed sessions before unanalyzed ones of any age', () => {
123
+ seed('unanalyzed-oldest', { ageMs: 9 * DAY, bytes: 6_000 })
124
+ seed('analyzed-newest', { ageMs: DAY, analyzed: true, bytes: 6_000 })
125
+
126
+ expect(sweepToBudget(scanSessions(), { budget: BUDGET }).removed).toEqual(['analyzed-newest'])
127
+ expect(alive('unanalyzed-oldest')).toBe(true)
128
+ })
129
+
130
+ test('falls through to unanalyzed sessions when analyzed ones are not enough', () => {
131
+ seed('unanalyzed-old', { ageMs: 9 * DAY, bytes: 6_000 })
132
+ seed('analyzed-old', { ageMs: 8 * DAY, analyzed: true, bytes: 6_000 })
133
+ seed('unanalyzed-new', { ageMs: DAY, bytes: 6_000 })
134
+
135
+ const { removed } = sweepToBudget(scanSessions(), { budget: BUDGET })
136
+
137
+ expect(removed).toEqual(['analyzed-old', 'unanalyzed-old'])
138
+ expect(alive('unanalyzed-new')).toBe(true)
139
+ })
140
+
141
+ test('never evicts an open session, even when that leaves the store over budget', () => {
142
+ // No age offset → inside IDLE_WINDOW_MS → open.
143
+ seed('open-and-huge', { analyzed: true, bytes: 20_000 })
144
+ expect(sweepToBudget(scanSessions(), { budget: BUDGET }).removed).toEqual([])
145
+ expect(alive('open-and-huge')).toBe(true)
146
+ })
147
+
148
+ test('never evicts a protected session', () => {
149
+ seed('just-analyzed', { ageMs: DAY, analyzed: true, bytes: 20_000 })
150
+ const options = { budget: BUDGET, protect: new Set(['just-analyzed']) }
151
+ expect(sweepToBudget(scanSessions(), options).removed).toEqual([])
152
+ })
153
+
154
+ test('counts nested analyzer output — a session is bounded by all it holds', () => {
155
+ seed('nested', { ageMs: DAY, analyzed: true, bytes: 100 })
156
+ mkdirSync(join(sessionDir('nested'), 'scratch'), { recursive: true })
157
+ writeFileSync(join(sessionDir('nested'), 'scratch', 'dump.txt'), 'x'.repeat(5_000))
158
+
159
+ expect(sessionBytes('nested')).toBeGreaterThan(5_000)
160
+ })
161
+ })
162
+
163
+ describe('sweepStore', () => {
164
+ test('applies age first, then trims what survives to the size bound', () => {
165
+ seed('expired', { ageMs: 40 * DAY, analyzed: true, bytes: 6_000 })
166
+ seed('fresh-big', { ageMs: 2 * DAY, analyzed: true, bytes: 6_000 })
167
+ seed('fresh-small', { ageMs: DAY, analyzed: true, bytes: 100 })
168
+
169
+ // Age alone frees 6 KB, leaving ~6.1 KB — already under the 10 KB bound,
170
+ // so the size pass must find nothing left to do.
171
+ expect(sweepStore({ budget: BUDGET }).removed).toEqual(['expired'])
172
+ expect(alive('fresh-big')).toBe(true)
173
+ expect(alive('fresh-small')).toBe(true)
174
+ })
175
+
176
+ test('reads its budget from the environment when none is passed', () => {
177
+ process.env.ASTRALE_TELEMETRY_MAX_AGE_DAYS = '1'
178
+ seed('two-days-old', { ageMs: 2 * DAY, analyzed: true })
179
+ expect(sweepStore().removed).toEqual(['two-days-old'])
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
+ })
@@ -0,0 +1,102 @@
1
+ import { afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import type { RetentionBudget } from '../settings'
7
+
8
+ // Set the home before the paths singleton is captured (dynamic imports below).
9
+ process.env.ASTRALE_HOME = mkdtempSync(join(tmpdir(), 'astrale-tele-settings-'))
10
+
11
+ let retentionBudget: () => RetentionBudget
12
+ let telemetryEnabled: () => boolean
13
+ let DEFAULT_MAX_AGE_DAYS: number
14
+ let DEFAULT_MAX_BYTES: number
15
+ let configPath: string
16
+
17
+ const DAY = 24 * 60 * 60 * 1000
18
+
19
+ beforeAll(async () => {
20
+ const settings = await import('../settings')
21
+ retentionBudget = settings.retentionBudget
22
+ telemetryEnabled = settings.telemetryEnabled
23
+ DEFAULT_MAX_AGE_DAYS = settings.DEFAULT_MAX_AGE_DAYS
24
+ DEFAULT_MAX_BYTES = settings.DEFAULT_MAX_BYTES
25
+ configPath = join(process.env.ASTRALE_HOME!, 'config.json')
26
+ })
27
+
28
+ function writeConfig(telemetry: unknown): void {
29
+ writeFileSync(configPath, JSON.stringify({ telemetry }))
30
+ }
31
+
32
+ beforeEach(() => {
33
+ rmSync(configPath, { force: true })
34
+ delete process.env.ASTRALE_TELEMETRY
35
+ delete process.env.ASTRALE_TELEMETRY_MAX_AGE_DAYS
36
+ delete process.env.ASTRALE_TELEMETRY_MAX_BYTES
37
+ })
38
+
39
+ afterEach(() => {
40
+ rmSync(configPath, { force: true })
41
+ delete process.env.ASTRALE_TELEMETRY_MAX_AGE_DAYS
42
+ delete process.env.ASTRALE_TELEMETRY_MAX_BYTES
43
+ })
44
+
45
+ describe('retentionBudget', () => {
46
+ test('defaults when nothing is configured', () => {
47
+ expect(retentionBudget()).toEqual({
48
+ maxAgeMs: DEFAULT_MAX_AGE_DAYS * DAY,
49
+ maxBytes: DEFAULT_MAX_BYTES,
50
+ })
51
+ })
52
+
53
+ test('reads both bounds from config.json', () => {
54
+ writeConfig({ maxAgeDays: 7, maxBytes: 1_048_576 })
55
+ expect(retentionBudget()).toEqual({ maxAgeMs: 7 * DAY, maxBytes: 1_048_576 })
56
+ })
57
+
58
+ test('env wins over config', () => {
59
+ writeConfig({ maxAgeDays: 7, maxBytes: 1_048_576 })
60
+ process.env.ASTRALE_TELEMETRY_MAX_AGE_DAYS = '2'
61
+ process.env.ASTRALE_TELEMETRY_MAX_BYTES = '4096'
62
+ expect(retentionBudget()).toEqual({ maxAgeMs: 2 * DAY, maxBytes: 4096 })
63
+ })
64
+
65
+ test('a bad env value falls through to config rather than unbounding the store', () => {
66
+ writeConfig({ maxAgeDays: 7 })
67
+ process.env.ASTRALE_TELEMETRY_MAX_AGE_DAYS = 'soon'
68
+ expect(retentionBudget().maxAgeMs).toBe(7 * DAY)
69
+ })
70
+
71
+ test.each([
72
+ ['zero', 0],
73
+ ['negative', -1],
74
+ ['not a number', 'lots'],
75
+ ['null', null],
76
+ ])('%s config values fall back to the default', (_label, maxBytes) => {
77
+ writeConfig({ maxBytes })
78
+ expect(retentionBudget().maxBytes).toBe(DEFAULT_MAX_BYTES)
79
+ })
80
+
81
+ test('a broken config yields defaults instead of throwing', () => {
82
+ writeFileSync(configPath, '{ not json')
83
+ expect(retentionBudget().maxBytes).toBe(DEFAULT_MAX_BYTES)
84
+ })
85
+ })
86
+
87
+ describe('telemetryEnabled', () => {
88
+ test('on by default, and unaffected by retention keys', () => {
89
+ writeConfig({ maxAgeDays: 7 })
90
+ expect(telemetryEnabled()).toBe(true)
91
+ })
92
+
93
+ test('off via config', () => {
94
+ writeConfig({ enabled: false })
95
+ expect(telemetryEnabled()).toBe(false)
96
+ })
97
+
98
+ test.each(['0', 'false', 'off', 'OFF', ' off '])('off via ASTRALE_TELEMETRY=%p', (value) => {
99
+ process.env.ASTRALE_TELEMETRY = value
100
+ expect(telemetryEnabled()).toBe(false)
101
+ })
102
+ })
@@ -0,0 +1,128 @@
1
+ import { beforeAll, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import type { SessionInfo, SessionScan } from '../store'
7
+
8
+ // Set the home before the paths singleton is captured (dynamic imports below).
9
+ process.env.ASTRALE_HOME = mkdtempSync(join(tmpdir(), 'astrale-tele-scan-'))
10
+
11
+ let scanSessions: (now?: number) => SessionScan[]
12
+ let listSessions: (now?: number) => SessionInfo[]
13
+ let readMeta: (id: string) => { root: string; explicit: boolean } | null
14
+ let sessionDir: (id: string) => string
15
+ let sessionsRoot: () => string
16
+ let IDLE_WINDOW_MS: number
17
+
18
+ const DAY = 24 * 60 * 60 * 1000
19
+
20
+ beforeAll(async () => {
21
+ const store = await import('../store')
22
+ scanSessions = store.scanSessions
23
+ listSessions = store.listSessions
24
+ readMeta = store.readMeta as typeof readMeta
25
+ sessionDir = store.sessionDir
26
+ sessionsRoot = store.sessionsRoot
27
+ IDLE_WINDOW_MS = store.IDLE_WINDOW_MS
28
+ })
29
+
30
+ beforeEach(() => {
31
+ // Destructive cleanup must be provably confined to this file's mkdtemp home.
32
+ if (!sessionsRoot().startsWith(tmpdir())) throw new Error('refusing to clean a non-tmp home')
33
+ rmSync(sessionsRoot(), { recursive: true, force: true })
34
+ })
35
+
36
+ type Seed = { ageMs?: number; analyzed?: boolean; events?: boolean; root?: string }
37
+
38
+ function seed(id: string, { ageMs = 0, analyzed = false, events = true, root = '/w' }: Seed): void {
39
+ const dir = sessionDir(id)
40
+ mkdirSync(dir, { recursive: true })
41
+ writeFileSync(join(dir, 'meta.json'), JSON.stringify({ id, root, explicit: false }))
42
+ if (analyzed) {
43
+ writeFileSync(
44
+ join(dir, '.analyzed'),
45
+ JSON.stringify({ analyzedAt: new Date().toISOString(), outcome: 'reported' }),
46
+ )
47
+ }
48
+ if (!events) return
49
+ writeFileSync(join(dir, 'events.jsonl'), '{}\n')
50
+ const when = new Date(Date.now() - ageMs)
51
+ utimesSync(join(dir, 'events.jsonl'), when, when)
52
+ }
53
+
54
+ describe('scanSessions', () => {
55
+ test('reports the same id set, ordering and open/closed verdict as listSessions', () => {
56
+ seed('scan-oldest', { ageMs: 5 * DAY, analyzed: true })
57
+ seed('scan-middle', { ageMs: 2 * DAY })
58
+ seed('scan-open', {})
59
+
60
+ const scan = scanSessions()
61
+ const full = listSessions()
62
+
63
+ expect(scan.map((s) => s.id)).toEqual(full.map((s) => s.id))
64
+ expect(scan.map((s) => s.closed)).toEqual(full.map((s) => s.closed))
65
+ expect(scan.map((s) => s.lastEventAt?.getTime() ?? null)).toEqual(
66
+ full.map((s) => s.lastEventAt?.getTime() ?? null),
67
+ )
68
+ })
69
+
70
+ test('analyzed is presence of the marker, matching listSessions', () => {
71
+ seed('scan-done', { ageMs: DAY, analyzed: true })
72
+ seed('scan-pending', { ageMs: DAY })
73
+
74
+ const byId = new Map(scanSessions().map((s) => [s.id, s.analyzed]))
75
+ expect(byId.get('scan-done')).toBe(true)
76
+ expect(byId.get('scan-pending')).toBe(false)
77
+ expect(listSessions().map((s) => s.analyzed !== null)).toEqual(
78
+ listSessions().map((s) => byId.get(s.id)!),
79
+ )
80
+ })
81
+
82
+ test('an unparseable marker still counts as analyzed — presence is the fact', () => {
83
+ seed('scan-broken-marker', { ageMs: DAY })
84
+ writeFileSync(join(sessionDir('scan-broken-marker'), '.analyzed'), '{ not json')
85
+ expect(scanSessions()[0]?.analyzed).toBe(true)
86
+ })
87
+
88
+ test('a session with no events yet is neither closed nor dated', () => {
89
+ seed('scan-no-events', { events: false })
90
+ const [only] = scanSessions()
91
+ expect(only?.lastEventAt).toBeNull()
92
+ expect(only?.closed).toBe(false)
93
+ })
94
+
95
+ test('closed is decided by the idle window', () => {
96
+ seed('scan-just-inside', { ageMs: IDLE_WINDOW_MS - 60_000 })
97
+ seed('scan-just-outside', { ageMs: IDLE_WINDOW_MS + 60_000 })
98
+ const byId = new Map(scanSessions().map((s) => [s.id, s.closed]))
99
+ expect(byId.get('scan-just-inside')).toBe(false)
100
+ expect(byId.get('scan-just-outside')).toBe(true)
101
+ })
102
+
103
+ test('a missing store directory scans to an empty list', () => {
104
+ rmSync(sessionsRoot(), { recursive: true, force: true })
105
+ expect(scanSessions()).toEqual([])
106
+ })
107
+
108
+ test('dotfiles in the store root are not sessions', () => {
109
+ seed('scan-real', { ageMs: DAY })
110
+ mkdirSync(sessionsRoot(), { recursive: true })
111
+ writeFileSync(join(sessionsRoot(), '.analyzer.lock'), '{}')
112
+ expect(scanSessions().map((s) => s.id)).toEqual(['scan-real'])
113
+ })
114
+ })
115
+
116
+ describe('readMeta', () => {
117
+ test('returns the parsed meta for one session', () => {
118
+ seed('meta-one', { ageMs: DAY, root: '/workspace/alpha' })
119
+ expect(readMeta('meta-one')?.root).toBe('/workspace/alpha')
120
+ })
121
+
122
+ test('missing or broken meta reads as null rather than throwing', () => {
123
+ expect(readMeta('meta-absent')).toBeNull()
124
+ mkdirSync(sessionDir('meta-broken'), { recursive: true })
125
+ writeFileSync(join(sessionDir('meta-broken'), 'meta.json'), '{ not json')
126
+ expect(readMeta('meta-broken')).toBeNull()
127
+ })
128
+ })
@@ -35,15 +35,44 @@ function seed(id: string, ageMs: number, analyzed: boolean): void {
35
35
  utimesSync(join(dir, 'events.jsonl'), when, when)
36
36
  }
37
37
 
38
+ const DAY = 24 * 60 * 60 * 1000
39
+
40
+ // NOTE: none of these seed a closed-unanalyzed session, so the trigger finds no
41
+ // analysis target and no test here ever spawns a child process.
38
42
  describe('opportunistic GC', () => {
39
- test('removes analyzed sessions past retention, keeps recent and unanalyzed ones', () => {
40
- const DAY = 24 * 60 * 60 * 1000
43
+ test('removes sessions past retention, keeps recent ones', () => {
41
44
  seed('gc-old-analyzed', 40 * DAY, true)
42
45
  seed('gc-recent-analyzed', 2 * DAY, true)
43
- // NOTE: no closed-unanalyzed sessions seeded — the trigger must find no
44
- // analysis target, so this test never spawns a child process.
45
46
  maybeTriggerAnalysis(['bun', 'astrale', 'status'])
46
47
  expect(existsSync(sessionDir('gc-old-analyzed'))).toBe(false)
47
48
  expect(existsSync(sessionDir('gc-recent-analyzed'))).toBe(true)
48
49
  })
50
+
51
+ test('runs with telemetry disabled — switching it off must drain the store', () => {
52
+ seed('gc-off-old', 40 * DAY, true)
53
+ process.env.ASTRALE_TELEMETRY = '0'
54
+ try {
55
+ maybeTriggerAnalysis(['bun', 'astrale', 'status'])
56
+ } finally {
57
+ delete process.env.ASTRALE_TELEMETRY
58
+ }
59
+ expect(existsSync(sessionDir('gc-off-old'))).toBe(false)
60
+ })
61
+
62
+ test('runs on `session` commands, which are exempt from analysis only', () => {
63
+ seed('gc-session-cmd-old', 40 * DAY, true)
64
+ maybeTriggerAnalysis(['bun', 'astrale', 'session', 'list'])
65
+ expect(existsSync(sessionDir('gc-session-cmd-old'))).toBe(false)
66
+ })
67
+
68
+ test('honours a configured age bound', () => {
69
+ seed('gc-two-days', 2 * DAY, true)
70
+ process.env.ASTRALE_TELEMETRY_MAX_AGE_DAYS = '1'
71
+ try {
72
+ maybeTriggerAnalysis(['bun', 'astrale', 'status'])
73
+ } finally {
74
+ delete process.env.ASTRALE_TELEMETRY_MAX_AGE_DAYS
75
+ }
76
+ expect(existsSync(sessionDir('gc-two-days'))).toBe(false)
77
+ })
49
78
  })