@astrale-os/cli 1.0.0-beta.27 → 1.0.0-beta.28
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 +26 -3
- package/dist/astrale.js +243 -76
- package/package.json +1 -1
- package/src/commands/__tests__/domain-install-operation.test.ts +23 -0
- package/src/commands/__tests__/install-identity-override.test.ts +3 -3
- package/src/commands/domain/install.ts +19 -11
- package/src/commands/get.ts +1 -1
- package/src/commands/identity/register.ts +3 -4
- package/src/commands/logs.ts +2 -5
- package/src/commands/session/analyze.ts +26 -4
- package/src/lib/__tests__/skills.test.ts +8 -2
- package/src/program/__tests__/program.test.ts +1 -1
- package/src/telemetry/__tests__/retention.test.ts +180 -0
- package/src/telemetry/__tests__/settings.test.ts +102 -0
- package/src/telemetry/__tests__/store-scan.test.ts +128 -0
- package/src/telemetry/__tests__/trigger.test.ts +33 -4
- package/src/telemetry/recorder.ts +6 -3
- package/src/telemetry/retention.ts +129 -0
- package/src/telemetry/session.ts +18 -12
- package/src/telemetry/settings.ts +64 -11
- package/src/telemetry/store.ts +87 -9
- package/src/telemetry/trigger.ts +16 -28
- package/studio/client/dist/assets/index-BFVFs_8x.js +81 -0
- package/studio/client/dist/assets/index-DuB9iUdu.css +2 -0
- package/studio/client/dist/assets/schema-studio--a0kX3QU.css +1 -0
- package/studio/client/dist/assets/schema-studio-DH6oEWME.js +8 -0
- package/studio/client/dist/index.html +3 -3
- package/studio/server/agent/prompts/anchors.test.ts +17 -4
- package/studio/server/agent/prompts/anchors.ts +3 -2
- package/studio/server/agent/prompts/system.test.ts +2 -3
- package/studio/server/agent/prompts/system.ts +3 -3
- package/studio/server/agent/run/preparation.ts +1 -1
- package/studio/server/api/context.ts +1 -1
- package/studio/server/api/deployment.ts +1 -1
- package/studio/server/api/views.ts +2 -2
- package/studio/server/api/workspace.ts +1 -1
- package/studio/server/cache.test.ts +3 -8
- package/studio/server/cache.ts +4 -45
- package/studio/server/handoff/copy.ts +1 -1
- package/studio/server/introspect/canonical-schema.test.ts +154 -128
- package/studio/server/introspect/canonical-schema.ts +183 -302
- package/studio/server/introspect/core.ts +14 -21
- package/studio/server/introspect/extractor.ts +11 -15
- package/studio/server/introspect/overlay-tsmorph.test.ts +1 -5
- package/studio/server/introspect/overlay-tsmorph.ts +0 -1
- package/studio/server/introspect/overlay.ts +3 -24
- package/studio/server/introspect/revision.test.ts +24 -28
- package/studio/server/introspect/revision.ts +10 -21
- package/studio/server/introspect/runtime.test.ts +14 -14
- package/studio/server/introspect/runtime.ts +1 -46
- package/studio/server/lifecycle.ts +4 -1
- package/studio/server/state/documents.test.ts +69 -0
- package/studio/server/state/documents.ts +57 -10
- package/studio/shared/contracts/schema.ts +10 -26
- package/studio/shared/contracts/surface.test.ts +2 -2
- package/studio/shared/contracts/workspace.ts +3 -0
- package/studio/shared/schema/identity.ts +0 -1
- package/studio/client/dist/assets/index-C0FAnwNw.css +0 -2
- package/studio/client/dist/assets/index-CpBed0V1.js +0 -81
- package/studio/client/dist/assets/schema-studio-BilUNb6Z.css +0 -1
- package/studio/client/dist/assets/schema-studio-DJm9guI8.js +0 -8
- package/studio/server/introspect/core-extractor.ts +0 -30
- package/studio/server/introspect/overlay.test.ts +0 -44
- package/studio/server/introspect/source-overlay/annotations.ts +0 -14
|
@@ -426,6 +426,7 @@ export async function installDirect(
|
|
|
426
426
|
target,
|
|
427
427
|
host,
|
|
428
428
|
opts.allowIdentityOverride ?? false,
|
|
429
|
+
isMachine(opts),
|
|
429
430
|
)
|
|
430
431
|
} catch (e) {
|
|
431
432
|
fatal(e, opts)
|
|
@@ -451,11 +452,13 @@ export async function installDirect(
|
|
|
451
452
|
// aliases the host and the pre-install gate never consented to THAT
|
|
452
453
|
// origin (lying or unavailable Publication), say so loudly after the fact.
|
|
453
454
|
if (isIdentityOverride(installed.origin, host) && installed.origin !== consentedOrigin) {
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
`
|
|
457
|
-
|
|
458
|
-
|
|
455
|
+
if (!isMachine(fmtOpts)) {
|
|
456
|
+
log.warn(
|
|
457
|
+
`Installed origin "${installed.origin}" differs from the serving host "${host}" ` +
|
|
458
|
+
`and was not confirmed before install (the worker Publication was unavailable). ` +
|
|
459
|
+
`Every ${installed.origin}/* call on this instance now routes to ${host}.`,
|
|
460
|
+
)
|
|
461
|
+
}
|
|
459
462
|
}
|
|
460
463
|
},
|
|
461
464
|
})
|
|
@@ -512,20 +515,25 @@ async function ensureIdentityOverrideConsent(
|
|
|
512
515
|
url: string,
|
|
513
516
|
host: string,
|
|
514
517
|
allow: boolean,
|
|
518
|
+
machine: boolean,
|
|
515
519
|
): Promise<string | undefined> {
|
|
516
520
|
const origin = await probeDeclaredOrigin(url)
|
|
517
521
|
if (origin === undefined) {
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
522
|
+
if (!machine) {
|
|
523
|
+
log.warn(
|
|
524
|
+
`Could not read a declared origin from ` +
|
|
525
|
+
`${new URL('/.well-known/astrale/domain.json', url).href} — ` +
|
|
526
|
+
`skipping the pre-install identity check (the installed origin is verified after install).`,
|
|
527
|
+
)
|
|
528
|
+
}
|
|
523
529
|
return undefined
|
|
524
530
|
}
|
|
525
531
|
if (!isIdentityOverride(origin, host)) return undefined
|
|
526
532
|
|
|
527
533
|
if (allow) {
|
|
528
|
-
|
|
534
|
+
if (!machine) {
|
|
535
|
+
log.warn(`Identity override consented via --allow-identity-override: ${origin} ← ${host}`)
|
|
536
|
+
}
|
|
529
537
|
return origin
|
|
530
538
|
}
|
|
531
539
|
|
package/src/commands/get.ts
CHANGED
|
@@ -46,7 +46,7 @@ export default {
|
|
|
46
46
|
description: 'Get one canonical node by Path or ID',
|
|
47
47
|
afterHelpText: `
|
|
48
48
|
Behavior:
|
|
49
|
-
Resolves one exact Kernel
|
|
49
|
+
Resolves one exact Kernel Path and prints the canonical Node
|
|
50
50
|
{ id, class, props }. Missing and authorization-masked nodes remain
|
|
51
51
|
intentionally indistinguishable. @self is expanded before dispatch.
|
|
52
52
|
|
|
@@ -45,10 +45,9 @@ Behavior:
|
|
|
45
45
|
its result and stores the same target-bound registration. The callable owns
|
|
46
46
|
authorization; the CLI never receives installed Domain authority.
|
|
47
47
|
|
|
48
|
-
Kernel
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
the returned (issuer, subject) for subsequent calls.
|
|
48
|
+
The Kernel assigns each Node ID; callers do not choose a storage path. The
|
|
49
|
+
CLI binds the key proof to the exact provision fingerprint and target Kernel
|
|
50
|
+
audience, then caches the returned (issuer, subject) for subsequent calls.
|
|
52
51
|
|
|
53
52
|
Example:
|
|
54
53
|
$ astrale identity register alice --class /:accounts.example:class.User \
|
package/src/commands/logs.ts
CHANGED
|
@@ -341,11 +341,8 @@ Behavior:
|
|
|
341
341
|
Calls the public Kernel journal syscall and emits its { records, cursor }
|
|
342
342
|
page. Topic selection is exact or prefix-based; cursors and timestamps are
|
|
343
343
|
opaque strings owned by the journal backend. --follow reuses one Client Session
|
|
344
|
-
and advances only with the returned cursor.
|
|
345
|
-
one complete admitted record per line; YAML follow is unsupported.
|
|
346
|
-
|
|
347
|
-
Historical event-glob lowering and the application-specific services-domain
|
|
348
|
-
log buffer are not part of the Kernel V2 journal contract.
|
|
344
|
+
and advances only with the returned cursor. With --json, follow output is
|
|
345
|
+
NDJSON with one complete admitted record per line; YAML follow is unsupported.
|
|
349
346
|
|
|
350
347
|
Examples:
|
|
351
348
|
$ astrale logs -i staging --limit 50
|
|
@@ -4,13 +4,23 @@ import type { CommandDefinition } from '../../program/index'
|
|
|
4
4
|
|
|
5
5
|
import { log } from '../../lib/log'
|
|
6
6
|
import { analyzeSession } from '../../telemetry/analyze'
|
|
7
|
-
import {
|
|
7
|
+
import { sweepStore } from '../../telemetry/retention'
|
|
8
|
+
import { scanSessions } from '../../telemetry/store'
|
|
8
9
|
import { restampLock, releaseLock } from '../../telemetry/trigger'
|
|
9
10
|
|
|
10
11
|
export default {
|
|
11
12
|
name: 'analyze',
|
|
12
13
|
description: 'Analyze a recorded session for DX frictions (dry-run: writes report.md)',
|
|
13
|
-
arguments: [
|
|
14
|
+
arguments: [
|
|
15
|
+
// Optional: the action resolves the most recent closed, unanalyzed session
|
|
16
|
+
// when no id is given, which is how a human is meant to invoke this. The
|
|
17
|
+
// opportunistic trigger always passes an explicit id.
|
|
18
|
+
{
|
|
19
|
+
name: 'id',
|
|
20
|
+
description: 'Session id (default: most recent closed, unanalyzed)',
|
|
21
|
+
required: false,
|
|
22
|
+
},
|
|
23
|
+
],
|
|
14
24
|
options: [
|
|
15
25
|
{ flags: '--file', description: 'File cleared findings as issues on the admin tracker' },
|
|
16
26
|
{ flags: '--model <model>', description: 'Model for the analyzer pass' },
|
|
@@ -22,10 +32,10 @@ export default {
|
|
|
22
32
|
opts: { file?: boolean; model?: string; force?: boolean; auto?: boolean },
|
|
23
33
|
) => {
|
|
24
34
|
if (opts.auto) restampLock()
|
|
35
|
+
let target = id
|
|
25
36
|
try {
|
|
26
|
-
let target = id
|
|
27
37
|
if (!target) {
|
|
28
|
-
const candidate =
|
|
38
|
+
const candidate = scanSessions().find((s) => s.closed && (opts.force || !s.analyzed))
|
|
29
39
|
if (!candidate) {
|
|
30
40
|
if (!opts.auto) log.dim('Nothing to analyze — no closed, unanalyzed session.')
|
|
31
41
|
return
|
|
@@ -45,6 +55,18 @@ export default {
|
|
|
45
55
|
}
|
|
46
56
|
} finally {
|
|
47
57
|
if (opts.auto) releaseLock()
|
|
58
|
+
// The size bound is enforced here, not on the CLI's critical path:
|
|
59
|
+
// measuring every file of every session is only affordable in this
|
|
60
|
+
// process, and this is precisely where the store just grew. The session
|
|
61
|
+
// just analyzed is protected — it is the freshest evidence on disk.
|
|
62
|
+
try {
|
|
63
|
+
const swept = sweepStore({ protect: new Set(target === undefined ? [] : [target]) })
|
|
64
|
+
if (!opts.auto && swept.removed.length > 0) {
|
|
65
|
+
log.dim(`retention: removed ${swept.removed.length} session(s)`)
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
/* retention must never fail the command that triggered it */
|
|
69
|
+
}
|
|
48
70
|
}
|
|
49
71
|
},
|
|
50
72
|
} satisfies CommandDefinition
|
|
@@ -31,7 +31,7 @@ afterEach(async () => {
|
|
|
31
31
|
})
|
|
32
32
|
|
|
33
33
|
async function makeSource(
|
|
34
|
-
names = ['astrale-cli', 'astrale-domain', 'astrale-services'],
|
|
34
|
+
names = ['astrale-cli', 'astrale-domain', 'astrale-frontend-design', 'astrale-services'],
|
|
35
35
|
revision = 'new',
|
|
36
36
|
): Promise<{ root: string; snapshot: AstraleSkillSourceSnapshot }> {
|
|
37
37
|
const root = await mkdtemp(join(tmpdir(), 'astrale-skills-source-'))
|
|
@@ -566,7 +566,13 @@ describe('Astrale skill reconciliation', () => {
|
|
|
566
566
|
|
|
567
567
|
test('a retired source skill is removed without touching unrelated skills', async () => {
|
|
568
568
|
const oldSource = await makeSource(
|
|
569
|
-
[
|
|
569
|
+
[
|
|
570
|
+
'astrale-cli',
|
|
571
|
+
'astrale-domain',
|
|
572
|
+
'astrale-frontend-design',
|
|
573
|
+
'astrale-services',
|
|
574
|
+
'astrale-retired',
|
|
575
|
+
],
|
|
570
576
|
'old',
|
|
571
577
|
)
|
|
572
578
|
const latest = await makeSource(undefined, 'latest')
|
|
@@ -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
|
+
'092b8f4f737740105c8baed9d01a81604711b5b2719dfcb834eeeac414732c61',
|
|
199
199
|
)
|
|
200
200
|
})
|
|
201
201
|
|
|
@@ -0,0 +1,180 @@
|
|
|
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 scanSessions: () => SessionScan[]
|
|
17
|
+
let sessionDir: (id: string) => string
|
|
18
|
+
let sessionBytes: (id: string) => number
|
|
19
|
+
let sessionsRoot: () => string
|
|
20
|
+
|
|
21
|
+
const DAY = 24 * 60 * 60 * 1000
|
|
22
|
+
const BUDGET: RetentionBudget = { maxAgeMs: 30 * DAY, maxBytes: 10_000 }
|
|
23
|
+
|
|
24
|
+
beforeAll(async () => {
|
|
25
|
+
;({ sweepByAge, sweepToBudget, sweepStore } = await import('../retention'))
|
|
26
|
+
;({ scanSessions, sessionDir, sessionBytes, sessionsRoot } = await import('../store'))
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
// Destructive cleanup must be provably confined to this file's mkdtemp home.
|
|
31
|
+
if (!sessionsRoot().startsWith(tmpdir())) throw new Error('refusing to clean a non-tmp home')
|
|
32
|
+
rmSync(sessionsRoot(), { recursive: true, force: true })
|
|
33
|
+
delete process.env.ASTRALE_TELEMETRY_MAX_AGE_DAYS
|
|
34
|
+
delete process.env.ASTRALE_TELEMETRY_MAX_BYTES
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
rmSync(sessionsRoot(), { recursive: true, force: true })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
type Seed = { ageMs?: number; analyzed?: boolean; bytes?: number; events?: boolean }
|
|
42
|
+
|
|
43
|
+
function seed(id: string, { ageMs = 0, analyzed = false, bytes = 0, events = true }: Seed): void {
|
|
44
|
+
const dir = sessionDir(id)
|
|
45
|
+
mkdirSync(dir, { recursive: true })
|
|
46
|
+
writeFileSync(join(dir, 'meta.json'), JSON.stringify({ id, root: '/w', explicit: false }))
|
|
47
|
+
if (bytes > 0) writeFileSync(join(dir, 'report.md'), 'x'.repeat(bytes))
|
|
48
|
+
if (analyzed) {
|
|
49
|
+
writeFileSync(
|
|
50
|
+
join(dir, '.analyzed'),
|
|
51
|
+
JSON.stringify({ analyzedAt: new Date().toISOString(), outcome: 'reported' }),
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
if (!events) return
|
|
55
|
+
writeFileSync(join(dir, 'events.jsonl'), '{}\n')
|
|
56
|
+
const when = new Date(Date.now() - ageMs)
|
|
57
|
+
utimesSync(join(dir, 'events.jsonl'), when, when)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const alive = (id: string): boolean => existsSync(sessionDir(id))
|
|
61
|
+
|
|
62
|
+
describe('sweepByAge', () => {
|
|
63
|
+
test('drops sessions past the age bound whether or not they were analyzed', () => {
|
|
64
|
+
seed('old-analyzed', { ageMs: 40 * DAY, analyzed: true })
|
|
65
|
+
seed('old-unanalyzed', { ageMs: 40 * DAY })
|
|
66
|
+
seed('recent-analyzed', { ageMs: 2 * DAY, analyzed: true })
|
|
67
|
+
seed('recent-unanalyzed', { ageMs: 2 * DAY })
|
|
68
|
+
|
|
69
|
+
const { removed } = sweepByAge(scanSessions(), { budget: BUDGET })
|
|
70
|
+
|
|
71
|
+
expect(removed.sort()).toEqual(['old-analyzed', 'old-unanalyzed'])
|
|
72
|
+
expect(alive('recent-analyzed')).toBe(true)
|
|
73
|
+
expect(alive('recent-unanalyzed')).toBe(true)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('never touches a session with no events — that is the invocation running now', () => {
|
|
77
|
+
seed('no-events-yet', { events: false })
|
|
78
|
+
expect(sweepByAge(scanSessions(), { budget: BUDGET }).removed).toEqual([])
|
|
79
|
+
expect(alive('no-events-yet')).toBe(true)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('honours the removal cap so a backlog drains over several runs', () => {
|
|
83
|
+
for (let i = 0; i < 5; i++) seed(`stale-${i}`, { ageMs: 40 * DAY, analyzed: true })
|
|
84
|
+
expect(sweepByAge(scanSessions(), { budget: BUDGET, limit: 2 }).removed).toHaveLength(2)
|
|
85
|
+
expect(scanSessions()).toHaveLength(3)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('respects the configured age bound', () => {
|
|
89
|
+
seed('week-old', { ageMs: 8 * DAY, analyzed: true })
|
|
90
|
+
const strict: RetentionBudget = { ...BUDGET, maxAgeMs: 7 * DAY }
|
|
91
|
+
expect(sweepByAge(scanSessions(), { budget: strict }).removed).toEqual(['week-old'])
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('protected ids survive the age bound', () => {
|
|
95
|
+
seed('old-but-mine', { ageMs: 40 * DAY, analyzed: true })
|
|
96
|
+
const options = { budget: BUDGET, protect: new Set(['old-but-mine']) }
|
|
97
|
+
expect(sweepByAge(scanSessions(), options).removed).toEqual([])
|
|
98
|
+
expect(alive('old-but-mine')).toBe(true)
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('sweepToBudget', () => {
|
|
103
|
+
test('does nothing while the store fits', () => {
|
|
104
|
+
seed('small', { ageMs: DAY, analyzed: true, bytes: 100 })
|
|
105
|
+
expect(sweepToBudget(scanSessions(), { budget: BUDGET }).removed).toEqual([])
|
|
106
|
+
expect(alive('small')).toBe(true)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('evicts oldest-first until the store is back under budget', () => {
|
|
110
|
+
seed('oldest', { ageMs: 5 * DAY, analyzed: true, bytes: 4_000 })
|
|
111
|
+
seed('middle', { ageMs: 3 * DAY, analyzed: true, bytes: 4_000 })
|
|
112
|
+
seed('newest', { ageMs: DAY, analyzed: true, bytes: 4_000 })
|
|
113
|
+
|
|
114
|
+
// 3 × ~4 KB against a 5 KB bound: two must go, and the newest must stay.
|
|
115
|
+
const { removed } = sweepToBudget(scanSessions(), { budget: { ...BUDGET, maxBytes: 5_000 } })
|
|
116
|
+
|
|
117
|
+
expect(removed).toEqual(['oldest', 'middle'])
|
|
118
|
+
expect(alive('newest')).toBe(true)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('evicts analyzed sessions before unanalyzed ones of any age', () => {
|
|
122
|
+
seed('unanalyzed-oldest', { ageMs: 9 * DAY, bytes: 6_000 })
|
|
123
|
+
seed('analyzed-newest', { ageMs: DAY, analyzed: true, bytes: 6_000 })
|
|
124
|
+
|
|
125
|
+
expect(sweepToBudget(scanSessions(), { budget: BUDGET }).removed).toEqual(['analyzed-newest'])
|
|
126
|
+
expect(alive('unanalyzed-oldest')).toBe(true)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('falls through to unanalyzed sessions when analyzed ones are not enough', () => {
|
|
130
|
+
seed('unanalyzed-old', { ageMs: 9 * DAY, bytes: 6_000 })
|
|
131
|
+
seed('analyzed-old', { ageMs: 8 * DAY, analyzed: true, bytes: 6_000 })
|
|
132
|
+
seed('unanalyzed-new', { ageMs: DAY, bytes: 6_000 })
|
|
133
|
+
|
|
134
|
+
const { removed } = sweepToBudget(scanSessions(), { budget: BUDGET })
|
|
135
|
+
|
|
136
|
+
expect(removed).toEqual(['analyzed-old', 'unanalyzed-old'])
|
|
137
|
+
expect(alive('unanalyzed-new')).toBe(true)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('never evicts an open session, even when that leaves the store over budget', () => {
|
|
141
|
+
// No age offset → inside IDLE_WINDOW_MS → open.
|
|
142
|
+
seed('open-and-huge', { analyzed: true, bytes: 20_000 })
|
|
143
|
+
expect(sweepToBudget(scanSessions(), { budget: BUDGET }).removed).toEqual([])
|
|
144
|
+
expect(alive('open-and-huge')).toBe(true)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('never evicts a protected session', () => {
|
|
148
|
+
seed('just-analyzed', { ageMs: DAY, analyzed: true, bytes: 20_000 })
|
|
149
|
+
const options = { budget: BUDGET, protect: new Set(['just-analyzed']) }
|
|
150
|
+
expect(sweepToBudget(scanSessions(), options).removed).toEqual([])
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('counts nested analyzer output — a session is bounded by all it holds', () => {
|
|
154
|
+
seed('nested', { ageMs: DAY, analyzed: true, bytes: 100 })
|
|
155
|
+
mkdirSync(join(sessionDir('nested'), 'scratch'), { recursive: true })
|
|
156
|
+
writeFileSync(join(sessionDir('nested'), 'scratch', 'dump.txt'), 'x'.repeat(5_000))
|
|
157
|
+
|
|
158
|
+
expect(sessionBytes('nested')).toBeGreaterThan(5_000)
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
describe('sweepStore', () => {
|
|
163
|
+
test('applies age first, then trims what survives to the size bound', () => {
|
|
164
|
+
seed('expired', { ageMs: 40 * DAY, analyzed: true, bytes: 6_000 })
|
|
165
|
+
seed('fresh-big', { ageMs: 2 * DAY, analyzed: true, bytes: 6_000 })
|
|
166
|
+
seed('fresh-small', { ageMs: DAY, analyzed: true, bytes: 100 })
|
|
167
|
+
|
|
168
|
+
// Age alone frees 6 KB, leaving ~6.1 KB — already under the 10 KB bound,
|
|
169
|
+
// so the size pass must find nothing left to do.
|
|
170
|
+
expect(sweepStore({ budget: BUDGET }).removed).toEqual(['expired'])
|
|
171
|
+
expect(alive('fresh-big')).toBe(true)
|
|
172
|
+
expect(alive('fresh-small')).toBe(true)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('reads its budget from the environment when none is passed', () => {
|
|
176
|
+
process.env.ASTRALE_TELEMETRY_MAX_AGE_DAYS = '1'
|
|
177
|
+
seed('two-days-old', { ageMs: 2 * DAY, analyzed: true })
|
|
178
|
+
expect(sweepStore().removed).toEqual(['two-days-old'])
|
|
179
|
+
})
|
|
180
|
+
})
|
|
@@ -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
|
|
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
|
})
|