@astrale-os/cli 1.0.0-beta.26 → 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 +31 -5
- package/dist/astrale.js +1201 -358
- package/package.json +3 -2
- 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/commands/update.ts +61 -40
- package/src/lib/__tests__/skills.test.ts +809 -0
- package/src/lib/skills/lock.ts +117 -0
- package/src/lib/skills/sync.ts +814 -0
- package/src/lib/skills.ts +1 -52
- package/src/program/__tests__/program.test.ts +1 -1
- package/src/setup/steps/skills.ts +40 -38
- 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/cli-consumers.test.ts +31 -2
- 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/server/workspace/updates.ts +23 -1
- package/studio/shared/contracts/runtime.ts +4 -0
- 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/tsconfig.json +1 -1
- package/studio/client/dist/assets/index-C0FAnwNw.css +0 -2
- package/studio/client/dist/assets/index-OeJK1TjG.js +0 -81
- package/studio/client/dist/assets/schema-studio-BilUNb6Z.css +0 -1
- package/studio/client/dist/assets/schema-studio-DRdfu_cQ.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
package/src/lib/skills.ts
CHANGED
|
@@ -3,62 +3,11 @@ import { homedir } from 'node:os'
|
|
|
3
3
|
import { dirname, join } from 'node:path'
|
|
4
4
|
|
|
5
5
|
import { findAgentBrowser } from './browser'
|
|
6
|
-
import { run } from './proc'
|
|
7
6
|
|
|
8
|
-
|
|
9
|
-
* Agent skills and the agent-browser tool are owned by the coding-agent harness
|
|
10
|
-
* (Claude Code et al.) and by npm — not by this CLI. `astrale setup` and
|
|
11
|
-
* `astrale update` only *detect* them and delegate installation to their real
|
|
12
|
-
* installers (`npx skills add`, `npm i -g agent-browser`). This module is that
|
|
13
|
-
* detection + delegation layer.
|
|
14
|
-
*/
|
|
7
|
+
export * from './skills/sync'
|
|
15
8
|
|
|
16
|
-
/** The skill name the agent harness looks up under `<dir>/<name>/SKILL.md`. */
|
|
17
|
-
export const ASTRALE_CLI_SKILL = 'astrale-cli'
|
|
18
|
-
export const ASTRALE_DOMAIN_SKILL = 'astrale-domain'
|
|
19
9
|
export const AGENT_BROWSER_SKILL = 'agent-browser'
|
|
20
10
|
|
|
21
|
-
/**
|
|
22
|
-
* Published skill source consumed by `npx skills add`. The public `astrale-os/cli`
|
|
23
|
-
* repo hosts BOTH the astrale-cli and astrale-domain skills (under `skills/`), so
|
|
24
|
-
* one `npx skills add astrale-os/cli` installs both; address one with
|
|
25
|
-
* `astrale-os/cli@astrale-cli` or `astrale-os/cli@astrale-domain`.
|
|
26
|
-
*/
|
|
27
|
-
export const ASTRALE_CLI_SKILL_SOURCE = 'astrale-os/cli'
|
|
28
|
-
|
|
29
|
-
/** Human-facing command that installs (or refreshes) both astrale skills, globally. */
|
|
30
|
-
export const SKILL_INSTALL_HINT = `npx skills add ${ASTRALE_CLI_SKILL_SOURCE} -g`
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Install or refresh the astrale agent skills by delegating to the skill package
|
|
34
|
-
* manager (`npx skills add`). We install GLOBALLY (`-g`, user-level): one run
|
|
35
|
-
* equips every project on the machine — the harness resolves the skills from the
|
|
36
|
-
* user Claude dir (see {@link detectSkill}) regardless of cwd, and installs land
|
|
37
|
-
* in `~/.agents/skills` rather than clobbering any project's own `.agents/skills`
|
|
38
|
-
* (notably this repo's symlinked source). We don't reimplement skill installation
|
|
39
|
-
* — re-running the real installer is also how an existing install updates to the
|
|
40
|
-
* latest published SKILL.md, so this doubles as the update path.
|
|
41
|
-
*
|
|
42
|
-
* We CAPTURE the installer's output rather than stream it, and surface it only on
|
|
43
|
-
* failure. A global install reports a benign per-agent note for any agent format
|
|
44
|
-
* that can't go user-level (e.g. PromptScript: "does not support global skill
|
|
45
|
-
* installation") as a "Failed to install N" banner — even though `npx skills`
|
|
46
|
-
* still exits 0 and the skills land for every other agent. Streaming that made a
|
|
47
|
-
* successful refresh look broken; suppressing it on success lets the caller print
|
|
48
|
-
* a clean line. Resolves true on exit 0. Requires Node/`npx` on PATH + network.
|
|
49
|
-
*/
|
|
50
|
-
export async function installSkills(): Promise<boolean> {
|
|
51
|
-
const { code, stdout, stderr } = await run('npx', [
|
|
52
|
-
'skills',
|
|
53
|
-
'add',
|
|
54
|
-
ASTRALE_CLI_SKILL_SOURCE,
|
|
55
|
-
'-g',
|
|
56
|
-
'-y',
|
|
57
|
-
])
|
|
58
|
-
if (code !== 0) process.stderr.write(stdout + stderr)
|
|
59
|
-
return code === 0
|
|
60
|
-
}
|
|
61
|
-
|
|
62
11
|
/**
|
|
63
12
|
* The directories the agent harness ACTUALLY loads skills from, in resolution
|
|
64
13
|
* order: a project `.claude/skills` found by walking UP from cwd (the harness
|
|
@@ -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
|
|
|
@@ -1,59 +1,61 @@
|
|
|
1
1
|
import type { SetupStep } from '../types'
|
|
2
2
|
|
|
3
3
|
import { log } from '../../lib/log'
|
|
4
|
-
import {
|
|
5
|
-
ASTRALE_CLI_SKILL,
|
|
6
|
-
ASTRALE_DOMAIN_SKILL,
|
|
7
|
-
detectSkill,
|
|
8
|
-
installSkills,
|
|
9
|
-
SKILL_INSTALL_HINT,
|
|
10
|
-
} from '../../lib/skills'
|
|
4
|
+
import { checkAstraleSkills, SKILL_INSTALL_HINT, syncAstraleSkills } from '../../lib/skills'
|
|
11
5
|
|
|
12
6
|
const FIX = SKILL_INSTALL_HINT
|
|
13
7
|
|
|
14
8
|
/**
|
|
15
|
-
* Equip —
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* reimplement skill installation; we detect presence and delegate to `npx skills
|
|
19
|
-
* add` (the skill package manager). A global (user-level) install equips every
|
|
20
|
-
* project on the machine, so a freshly scaffolded domain has the skills without
|
|
21
|
-
* shipping its own copy.
|
|
9
|
+
* Equip — reconcile every top-level Astrale-owned skill from astrale-os/cli main.
|
|
10
|
+
* Setup and update deliberately share one owner, so an existence-only setup check
|
|
11
|
+
* can never bless an incomplete or stale global cohort.
|
|
22
12
|
*/
|
|
23
|
-
const bothInstalled = (): boolean =>
|
|
24
|
-
detectSkill(ASTRALE_CLI_SKILL).installed && detectSkill(ASTRALE_DOMAIN_SKILL).installed
|
|
25
|
-
|
|
26
13
|
export const skillsStep: SetupStep = {
|
|
27
14
|
id: 'skills',
|
|
28
|
-
title: '
|
|
15
|
+
title: 'Astrale agent skills',
|
|
29
16
|
group: 'equip',
|
|
30
17
|
|
|
31
18
|
async detect() {
|
|
32
|
-
|
|
33
|
-
|
|
19
|
+
const result = await checkAstraleSkills()
|
|
20
|
+
if (result.status === 'current') {
|
|
21
|
+
return { state: 'satisfied', summary: 'Astrale skills are installed and up to date' }
|
|
22
|
+
}
|
|
23
|
+
if (result.status === 'unavailable') {
|
|
24
|
+
return {
|
|
25
|
+
state: 'broken',
|
|
26
|
+
summary: 'Astrale skills could not be verified',
|
|
27
|
+
detail: result.error,
|
|
28
|
+
fixHint: FIX,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
state: 'gap',
|
|
33
|
+
summary:
|
|
34
|
+
result.status === 'repair-needed'
|
|
35
|
+
? 'Astrale skills need repair'
|
|
36
|
+
: 'Astrale skills need installation or update',
|
|
37
|
+
fixHint: FIX,
|
|
34
38
|
}
|
|
35
|
-
const have = detectSkill(ASTRALE_CLI_SKILL).installed
|
|
36
|
-
? 'astrale-domain'
|
|
37
|
-
: 'astrale agent skills'
|
|
38
|
-
return { state: 'gap', summary: `${have} not installed`, fixHint: FIX }
|
|
39
39
|
},
|
|
40
40
|
|
|
41
41
|
async ensure() {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
42
|
+
log.step('Ensuring Astrale agent skills are current and healthy')
|
|
43
|
+
try {
|
|
44
|
+
const result = await syncAstraleSkills()
|
|
45
|
+
if (result.status === 'unchanged') {
|
|
46
|
+
log.success('Astrale skills already up to date')
|
|
47
|
+
return 'unchanged'
|
|
48
|
+
}
|
|
49
|
+
if (result.status === 'installed') log.success('Astrale skills installed')
|
|
50
|
+
else if (result.status === 'updated') log.success('Astrale skills updated')
|
|
51
|
+
else if (result.status === 'repaired') log.success('Astrale skills repaired and updated')
|
|
52
|
+
return 'fixed'
|
|
53
|
+
} catch (error) {
|
|
54
|
+
log.warn(
|
|
55
|
+
`Astrale skills could not be installed safely${error instanceof Error ? `: ${error.message}` : ''}`,
|
|
56
|
+
)
|
|
57
|
+
log.dim(` Retry: ${FIX}`)
|
|
50
58
|
return 'failed'
|
|
51
59
|
}
|
|
52
|
-
if (bothInstalled()) {
|
|
53
|
-
log.success('astrale-cli + astrale-domain skills installed')
|
|
54
|
-
} else {
|
|
55
|
-
log.success('astrale-cli skill installed')
|
|
56
|
-
}
|
|
57
|
-
return 'fixed'
|
|
58
60
|
},
|
|
59
61
|
}
|
|
@@ -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
|
})
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { appendFileSync } from 'node:fs'
|
|
8
8
|
|
|
9
|
+
import type { SessionScan } from './store'
|
|
9
10
|
import type { TelemetryEvent } from './types'
|
|
10
11
|
|
|
11
12
|
import { redactArgv } from './redact'
|
|
@@ -32,15 +33,17 @@ function isHelpOrVersion(args: string[]): boolean {
|
|
|
32
33
|
return args.every((a) => HELP_VERSION.has(a))
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
/** Begin recording an invocation; returns a finalizer to call at process end.
|
|
36
|
-
|
|
36
|
+
/** Begin recording an invocation; returns a finalizer to call at process end.
|
|
37
|
+
* `sessions` is an already-paid-for store scan the caller shares with
|
|
38
|
+
* retention — see bin/astrale.ts. */
|
|
39
|
+
export function beginInvocation(argv: string[], sessions?: readonly SessionScan[]): Finalizer {
|
|
37
40
|
try {
|
|
38
41
|
const args = argv.slice(2)
|
|
39
42
|
if (!telemetryEnabled() || isHelpOrVersion(args)) return NOOP
|
|
40
43
|
const startMs = Date.now()
|
|
41
44
|
const ts = new Date(startMs).toISOString()
|
|
42
45
|
const cwd = process.cwd()
|
|
43
|
-
const { id, root } = ensureSession(cwd)
|
|
46
|
+
const { id, root } = ensureSession(cwd, sessions)
|
|
44
47
|
return (exitCode: number, errorName?: string) => {
|
|
45
48
|
try {
|
|
46
49
|
const event: TelemetryEvent = {
|