@astrale-os/cli 1.0.0-beta.3 → 1.0.0-beta.30
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 +80 -6
- package/dist/astrale.js +55471 -58674
- package/dist/public/connect-core.js +2158 -30706
- package/dist/public/keys/index.js +1016 -29541
- package/dist/public/paths/index.js +370 -28654
- package/dist/types/admin/contract.d.ts +26 -0
- package/dist/types/admin/instance/client.d.ts +20 -0
- package/dist/types/admin/instance/index.d.ts +2 -0
- package/dist/types/admin/instance/model.d.ts +6 -10
- package/dist/types/connection/auth.d.ts +3 -0
- package/dist/types/connection/call.d.ts +3 -0
- package/dist/types/connection/command.d.ts +27 -0
- package/dist/types/connection/credential.d.ts +14 -0
- package/dist/types/connection/errors.d.ts +1 -0
- package/dist/types/connection/exchange.d.ts +9 -0
- package/dist/types/connection/failure/classify.d.ts +2 -0
- package/dist/types/connection/failure/index.d.ts +5 -0
- package/dist/types/connection/failure/model.d.ts +25 -0
- package/dist/types/connection/failure/render.d.ts +3 -0
- package/dist/types/connection/index.d.ts +8 -0
- package/dist/types/connection/reasons.d.ts +37 -0
- package/dist/types/connection/self.d.ts +37 -0
- package/dist/types/connection/session.d.ts +31 -0
- package/dist/types/connection/target.d.ts +38 -0
- package/dist/types/errors.d.ts +1 -1
- package/dist/types/lib/admin-instance.d.ts +17 -0
- package/dist/types/lib/admin-target.d.ts +11 -36
- package/dist/types/lib/config.d.ts +13 -59
- package/dist/types/lib/failure-debug.d.ts +1 -0
- package/dist/types/lib/idp.d.ts +25 -173
- package/dist/types/lib/instance-target.d.ts +1 -1
- package/dist/types/lib/instance.d.ts +30 -88
- package/dist/types/lib/log.d.ts +5 -3
- package/dist/types/lib/output.d.ts +1 -1
- package/dist/types/lib/proc.d.ts +43 -0
- package/dist/types/lib/self.d.ts +9 -0
- package/dist/types/lib/update.d.ts +109 -0
- package/dist/types/lib/validation.d.ts +4 -1
- package/dist/types/state/exchange-credentials.d.ts +6 -2
- package/dist/types/state/files.d.ts +2 -0
- package/dist/types/state/index.d.ts +3 -2
- package/dist/types/state/paths.d.ts +2 -0
- package/dist/types/state/session-routes.d.ts +10 -0
- package/package.json +16 -19
- package/src/__tests__/fixtures/publication.ts +20 -0
- package/src/admin/.spec/architecture.md +12 -5
- package/src/admin/__tests__/fixture.ts +25 -0
- package/src/admin/catalog/.spec/api.d.ts +1 -3
- package/src/admin/catalog/.spec/architecture.md +5 -4
- package/src/admin/catalog/__tests__/client.test.ts +143 -69
- package/src/admin/catalog/client.ts +40 -54
- package/src/admin/catalog/model.ts +3 -4
- package/src/admin/contract.ts +47 -0
- package/src/admin/graph/.spec/api.d.ts +4 -10
- package/src/admin/graph/nodes.ts +1 -1
- package/src/admin/instance/.spec/api.d.ts +5 -9
- package/src/admin/instance/.spec/architecture.md +7 -7
- package/src/admin/instance/__tests__/client.test.ts +157 -175
- package/src/admin/instance/client.ts +39 -128
- package/src/admin/instance/index.ts +1 -2
- package/src/admin/instance/model.ts +12 -18
- package/src/commands/__tests__/admin-instance.test.ts +11 -8
- package/src/commands/__tests__/call-describe.test.ts +46 -33
- package/src/commands/__tests__/call.test.ts +34 -0
- package/src/commands/__tests__/domain-install-operation.test.ts +30 -22
- package/src/commands/__tests__/domain-install-owned.test.ts +1 -1
- package/src/commands/__tests__/domain-list.test.ts +5 -25
- package/src/commands/__tests__/install-direct.test.ts +115 -8
- package/src/commands/__tests__/install-identity-override.test.ts +6 -17
- package/src/commands/__tests__/instance-list-rows.test.ts +1 -1
- package/src/commands/__tests__/instance-status.test.ts +139 -0
- package/src/commands/__tests__/introspect-parse.test.ts +80 -1
- package/src/commands/__tests__/logs.test.ts +250 -2
- package/src/commands/__tests__/read-commands.test.ts +118 -10
- package/src/commands/__tests__/token-ttl.test.ts +49 -4
- package/src/commands/__tests__/update.test.ts +25 -3
- package/src/commands/__tests__/view.test.ts +50 -2
- package/src/commands/auth/logout.ts +2 -1
- package/src/commands/browser.ts +29 -0
- package/src/commands/call-describe.ts +60 -68
- package/src/commands/call.ts +71 -59
- package/src/commands/domain/install.ts +73 -46
- package/src/commands/domain/list.ts +1 -1
- package/src/commands/domain/publish.ts +14 -11
- package/src/commands/domain/uninstall.ts +2 -2
- package/src/commands/get.ts +15 -16
- package/src/commands/identity/__tests__/register.test.ts +3 -3
- package/src/commands/identity/register.ts +8 -8
- package/src/commands/instance/create.ts +8 -16
- package/src/commands/instance/delete.ts +12 -9
- package/src/commands/instance/list.ts +2 -2
- package/src/commands/instance/status.ts +66 -20
- package/src/commands/instance/use.ts +16 -12
- package/src/commands/introspect.ts +53 -41
- package/src/commands/logs.ts +123 -36
- package/src/commands/mutate.ts +6 -5
- package/src/commands/query.ts +35 -32
- package/src/commands/session/analyze.ts +26 -4
- package/src/commands/token.ts +42 -27
- package/src/commands/ui/__tests__/commands.test.ts +152 -0
- package/src/commands/ui/add.ts +51 -0
- package/src/commands/ui/doctor.ts +21 -0
- package/src/commands/ui/init.ts +38 -0
- package/src/commands/ui/list.ts +26 -0
- package/src/commands/ui/preset-apply.ts +19 -0
- package/src/commands/ui/preset-list.ts +12 -0
- package/src/commands/ui/shared.ts +25 -0
- package/src/commands/update.ts +89 -48
- package/src/commands/view.ts +82 -37
- package/src/connection/.spec/api.d.ts +7 -8
- package/src/connection/.spec/architecture.md +14 -5
- package/src/connection/.spec/flows/session.ts +2 -2
- package/src/connection/.spec/laws/connection.ts +6 -2
- package/src/connection/.spec/layout.ts +1 -0
- package/src/connection/__tests__/auth.test.ts +1 -0
- package/src/connection/__tests__/credential.test.ts +21 -1
- package/src/connection/__tests__/errors.test.ts +27 -20
- package/src/connection/__tests__/exchange.test.ts +64 -11
- package/src/connection/__tests__/failure-fixtures.ts +39 -0
- package/src/connection/__tests__/failure-safety.test.ts +75 -0
- package/src/connection/__tests__/self.test.ts +31 -11
- package/src/connection/__tests__/session.test.ts +8 -4
- package/src/connection/__tests__/target.test.ts +2 -0
- package/src/connection/auth.ts +13 -0
- package/src/connection/call.ts +1 -1
- package/src/connection/credential.ts +2 -2
- package/src/connection/errors.ts +1 -355
- package/src/connection/exchange.ts +110 -61
- package/src/connection/failure/classify.ts +110 -0
- package/src/connection/failure/index.ts +18 -0
- package/src/connection/failure/model.ts +28 -0
- package/src/connection/failure/render.ts +116 -0
- package/src/connection/reasons.ts +16 -0
- package/src/connection/self.ts +39 -21
- package/src/connection/session.ts +27 -9
- package/src/errors.ts +3 -2
- package/src/graph/.spec/api.d.ts +12 -5
- package/src/graph/.spec/layout.ts +9 -1
- package/src/graph/__tests__/mutation.test.ts +2 -2
- package/src/graph/__tests__/query.test.ts +25 -6
- package/src/graph/class.ts +30 -0
- package/src/graph/index.ts +1 -0
- package/src/graph/query.ts +21 -32
- package/src/identity/.spec/api.d.ts +2 -2
- package/src/identity/__tests__/fixtures/registry-journey.ts +13 -1
- package/src/identity/__tests__/registration.test.ts +1 -1
- package/src/identity/__tests__/registry.test.ts +4 -0
- package/src/identity/registration.ts +3 -3
- package/src/identity/registry.ts +2 -0
- package/src/keys/__tests__/keys.test.ts +4 -0
- package/src/keys/credential.ts +1 -0
- package/src/lib/__tests__/admin-target.test.ts +12 -0
- package/src/lib/__tests__/binary.test.ts +16 -1
- package/src/lib/__tests__/browser-retention.test.ts +212 -0
- package/src/lib/__tests__/command-dx.test.ts +1 -1
- package/src/lib/__tests__/config.test.ts +27 -0
- package/src/lib/__tests__/domain-publication.test.ts +10 -16
- package/src/lib/__tests__/idp.test.ts +34 -4
- package/src/lib/__tests__/instance-candidates.test.ts +1 -3
- package/src/lib/__tests__/instance-target.test.ts +7 -39
- package/src/lib/__tests__/log-errors.test.ts +64 -0
- package/src/lib/__tests__/skills.test.ts +809 -0
- package/src/lib/__tests__/studio-server-deps.test.ts +1 -1
- package/src/lib/__tests__/update.test.ts +264 -11
- package/src/lib/__tests__/view-assets.test.ts +25 -0
- package/src/lib/__tests__/view-external-open-intent.test.ts +150 -0
- package/src/lib/__tests__/view-external-open-origins.test.ts +27 -0
- package/src/lib/__tests__/view-open-intent.test.ts +0 -2
- package/src/lib/__tests__/view-server.test.ts +26 -3
- package/src/lib/__tests__/view-session.test.ts +2 -1
- package/src/lib/admin-instance.ts +14 -8
- package/src/lib/admin-target.ts +21 -2
- package/src/lib/binary.ts +22 -5
- package/src/lib/browser-retention.ts +210 -0
- package/src/lib/command-dx.ts +9 -9
- package/src/lib/config.ts +12 -1
- package/src/lib/domain-publication.ts +9 -7
- package/src/lib/failure-debug.ts +26 -0
- package/src/lib/idp.ts +34 -3
- package/src/lib/instance-candidates.ts +2 -2
- package/src/lib/instance-target.ts +3 -14
- package/src/lib/instance.ts +16 -11
- package/src/lib/log.ts +16 -9
- package/src/lib/output.ts +1 -1
- package/src/lib/proc.ts +7 -2
- package/src/lib/provision-instance.ts +34 -57
- 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/lib/update.ts +298 -45
- package/src/lib/validation.ts +2 -2
- package/src/lib/view/assets.ts +11 -2
- package/src/lib/view/external-open-intent.ts +41 -0
- package/src/lib/view/external-open-origins.ts +37 -0
- package/src/lib/view/host-capabilities.ts +17 -0
- package/src/lib/view/resolve.ts +1 -1
- package/src/lib/view/server.ts +18 -3
- package/src/lib/view/session.ts +2 -0
- package/src/program/.spec/api.d.ts +1 -0
- package/src/program/__tests__/program.test.ts +163 -6
- package/src/program/argv.ts +14 -0
- package/src/program/build.ts +26 -4
- package/src/program/command.ts +1 -0
- package/src/program/index.ts +1 -0
- package/src/program/registry.ts +2 -1
- package/src/setup/__tests__/instance-step.test.ts +27 -10
- package/src/setup/steps/instance.ts +22 -15
- package/src/setup/steps/skills.ts +40 -38
- package/src/state/.spec/api.d.ts +21 -2
- package/src/state/.spec/architecture.md +18 -4
- package/src/state/.spec/layout.ts +1 -0
- package/src/state/__tests__/exchange-credentials.test.ts +88 -42
- package/src/state/__tests__/files.test.ts +24 -1
- package/src/state/__tests__/fixtures/session-route-process.ts +93 -0
- package/src/state/__tests__/paths.test.ts +1 -0
- package/src/state/__tests__/session-routes.test.ts +138 -0
- package/src/state/exchange-credentials.ts +22 -9
- package/src/state/files.ts +41 -0
- package/src/state/index.ts +3 -1
- package/src/state/paths.ts +3 -0
- package/src/state/session-routes.ts +34 -0
- package/src/telemetry/__tests__/analyze-log.test.ts +38 -0
- package/src/telemetry/__tests__/recorder.test.ts +1 -0
- package/src/telemetry/__tests__/retention.test.ts +267 -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/analyze.ts +24 -2
- package/src/telemetry/recorder.ts +15 -4
- package/src/telemetry/retention.ts +176 -0
- package/src/telemetry/session.ts +18 -12
- package/src/telemetry/settings.ts +64 -11
- package/src/telemetry/store.ts +92 -9
- package/src/telemetry/trigger.ts +16 -28
- package/src/ui/.spec/api.d.ts +14 -0
- package/src/ui/.spec/architecture.md +14 -0
- package/src/ui/.spec/laws.ts +50 -0
- package/src/ui/.spec/layout.ts +16 -0
- package/src/ui/__tests__/ui.test.ts +1742 -0
- package/src/ui/index.ts +13 -0
- package/src/ui/lock.ts +89 -0
- package/src/ui/model.ts +83 -0
- package/src/ui/operations.ts +1044 -0
- package/src/ui/project.ts +333 -0
- package/src/ui/release.ts +398 -0
- package/src/ui/runner.ts +18 -0
- package/studio/client/dist/assets/elk-api-DjmKsHXc.js +1 -0
- 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/rolldown-runtime-Dd_uD5pT.js +1 -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 +4 -3
- package/studio/package.json +8 -14
- package/studio/server/agent/ask.test.ts +1 -1
- package/studio/server/agent/bridge/grant.test.ts +1 -1
- package/studio/server/agent/bridge/routes.test.ts +1 -1
- package/studio/server/agent/harness/claude/adapter.test.ts +15 -5
- package/studio/server/agent/harness/gateway/token.test.ts +1 -1
- package/studio/server/agent/harness/gateway/token.ts +3 -3
- package/studio/server/agent/prompts/anchors.test.ts +41 -32
- package/studio/server/agent/prompts/anchors.ts +23 -51
- package/studio/server/agent/prompts/system.test.ts +4 -4
- package/studio/server/agent/prompts/system.ts +5 -5
- package/studio/server/agent/routes.test.ts +1 -1
- package/studio/server/agent/run/coordinator.test.ts +1 -1
- package/studio/server/agent/run/preparation.ts +1 -1
- package/studio/server/api/canvas.ts +0 -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/api-agent-loadout.test.ts +1 -1
- package/studio/server/api.test.ts +1 -1
- package/studio/server/cache.test.ts +24 -18
- package/studio/server/cache.ts +10 -54
- package/studio/server/cli-consumers.test.ts +31 -3
- package/studio/server/domain.test.ts +50 -68
- package/studio/server/domain.ts +82 -63
- package/studio/server/handoff/copy.ts +2 -2
- package/studio/server/index.ts +1 -1
- package/studio/server/instances/probe.test.ts +1 -1
- package/studio/server/introspect/anatomy/views/routes.ts +46 -37
- package/studio/server/introspect/anatomy/views.ts +10 -16
- package/studio/server/introspect/anatomy-extras.test.ts +29 -56
- package/studio/server/introspect/anatomy.test.ts +22 -14
- package/studio/server/introspect/anatomy.ts +6 -9
- package/studio/server/introspect/bundle.ts +0 -3
- package/studio/server/introspect/canonical-schema.test.ts +155 -424
- package/studio/server/introspect/canonical-schema.ts +248 -601
- package/studio/server/introspect/core.ts +14 -22
- package/studio/server/introspect/diff.test.ts +21 -9
- package/studio/server/introspect/diff.ts +19 -64
- package/studio/server/introspect/extractor.ts +23 -122
- package/studio/server/introspect/overlay-tsmorph.test.ts +116 -241
- package/studio/server/introspect/overlay-tsmorph.ts +0 -1
- package/studio/server/introspect/overlay.ts +3 -51
- package/studio/server/introspect/revision.test.ts +24 -28
- package/studio/server/introspect/revision.ts +11 -22
- package/studio/server/introspect/runtime.test.ts +77 -195
- package/studio/server/introspect/runtime.ts +5 -67
- package/studio/server/introspect/schema-ir-json.test.ts +81 -0
- package/studio/server/introspect/schema-ir-json.ts +39 -69
- package/studio/server/introspect/schema-refs.test.ts +43 -88
- package/studio/server/introspect/schema-refs.ts +14 -49
- package/studio/server/introspect/source-overlay/handlers.ts +116 -636
- package/studio/server/introspect/source-overlay/kernel-calls.ts +0 -3
- package/studio/server/introspect/source-overlay/spans.ts +15 -45
- package/studio/server/lifecycle.ts +4 -1
- package/studio/server/state/baseline.test.ts +14 -9
- package/studio/server/state/baseline.ts +3 -5
- package/studio/server/state/documents.test.ts +69 -0
- package/studio/server/state/documents.ts +57 -10
- package/studio/server/state/visibility.ts +1 -5
- package/studio/server/views/model.ts +3 -3
- package/studio/server/views/target.test.ts +18 -76
- package/studio/server/views/target.ts +24 -50
- package/studio/server/watch.test.ts +11 -11
- package/studio/server/watch.ts +8 -4
- package/studio/server/workspace/updates.ts +23 -1
- package/studio/server/workspace-watch.ts +2 -2
- package/studio/shared/contracts/runtime.ts +4 -0
- package/studio/shared/contracts/schema.ts +44 -131
- package/studio/shared/contracts/surface.test.ts +15 -17
- package/studio/shared/contracts/workspace.ts +5 -11
- package/studio/shared/schema/identity.test.ts +20 -44
- package/studio/shared/schema/identity.ts +9 -24
- package/studio/tsconfig.json +2 -1
- package/tsconfig.json +1 -1
- package/viewer/dist/index.html +21 -0
- package/viewer/dist/main.js +41 -62
- package/.check-workspace.cjs +0 -40
- package/src/admin/binding.ts +0 -168
- package/src/commands/__tests__/instance-create-hosts.test.ts +0 -29
- package/studio/client/dist/assets/elk-api-lwhFo7vB.js +0 -1
- package/studio/client/dist/assets/index-B-c-smo9.js +0 -8
- package/studio/client/dist/assets/index-BckHuAWk.js +0 -81
- package/studio/client/dist/assets/index-C4aNQ6dz.css +0 -1
- package/studio/client/dist/assets/index-ChOr3yP0.css +0 -1
- package/studio/server/introspect/anatomy/views/legacy.ts +0 -232
- package/studio/server/introspect/core-extractor.ts +0 -139
- package/studio/server/introspect/overlay.test.ts +0 -72
- package/studio/server/introspect/source-overlay/annotations.ts +0 -14
|
@@ -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
|
})
|
package/src/telemetry/analyze.ts
CHANGED
|
@@ -5,18 +5,20 @@
|
|
|
5
5
|
* through the native `issues.astrale.ai` domain.
|
|
6
6
|
*/
|
|
7
7
|
import { spawn } from 'node:child_process'
|
|
8
|
-
import {
|
|
8
|
+
import { writeFileSync } from 'node:fs'
|
|
9
9
|
import { join } from 'node:path'
|
|
10
10
|
|
|
11
11
|
import type { AnalyzedMarker, SessionSignals } from './types'
|
|
12
12
|
|
|
13
13
|
import { defaultAdapters, discoverAll } from './adapters'
|
|
14
14
|
import { extractSignals, hasSignals, readEvents } from './gate'
|
|
15
|
+
import { tidySession } from './retention'
|
|
15
16
|
import { eventsPath, inspectSession, markerPath, sessionDir } from './store'
|
|
16
17
|
|
|
17
18
|
const ANALYZER_TIMEOUT_MS = 15 * 60 * 1000
|
|
18
19
|
const WINDOW_PAD_MS = 10 * 60 * 1000
|
|
19
20
|
const MAX_TRANSCRIPTS = 6
|
|
21
|
+
const MAX_ANALYZER_LOG_BYTES = 32 * 1024
|
|
20
22
|
// Transcripts embed content the developer's agent pulled from anywhere — treat
|
|
21
23
|
// them as injection vectors: no --dangerously-skip-permissions; unlisted tools
|
|
22
24
|
// are simply denied in -p mode. git/astrale cover inspection + reproduction +
|
|
@@ -29,6 +31,16 @@ function writeMarker(id: string, marker: AnalyzedMarker): void {
|
|
|
29
31
|
writeFileSync(markerPath(id), JSON.stringify(marker, null, 2) + '\n')
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
/** Head and tail, never the middle: the JSON envelope opens at the top, a stack
|
|
35
|
+
* or an error message closes at the bottom, and what sits between them is the
|
|
36
|
+
* part nobody reads. A log is a diagnostic aid, not a place to put a megabyte. */
|
|
37
|
+
export function clampLog(text: string, max = MAX_ANALYZER_LOG_BYTES): string {
|
|
38
|
+
if (text.length <= max) return text
|
|
39
|
+
const half = Math.floor((max - 64) / 2)
|
|
40
|
+
const elided = text.length - 2 * half
|
|
41
|
+
return `${text.slice(0, half)}\n… ${elided} bytes elided …\n${text.slice(-half)}`
|
|
42
|
+
}
|
|
43
|
+
|
|
32
44
|
/** Compact per-command digest so the agent starts from facts, not raw logs. */
|
|
33
45
|
function eventDigest(signals: SessionSignals): string {
|
|
34
46
|
const lines: string[] = [`events: ${signals.eventCount}`]
|
|
@@ -146,6 +158,7 @@ export async function analyzeSession(
|
|
|
146
158
|
note: `${signals.eventCount} events, all green, no transcripts`,
|
|
147
159
|
}
|
|
148
160
|
writeMarker(id, marker)
|
|
161
|
+
tidySession(id)
|
|
149
162
|
return marker
|
|
150
163
|
}
|
|
151
164
|
|
|
@@ -161,6 +174,9 @@ export async function analyzeSession(
|
|
|
161
174
|
note: outcome.note,
|
|
162
175
|
}
|
|
163
176
|
writeMarker(id, marker)
|
|
177
|
+
// Keep the prompt only when the run failed — that is the one case where what
|
|
178
|
+
// the analyzer was asked still matters.
|
|
179
|
+
tidySession(id, { keepPrompt: marker.outcome === 'error' })
|
|
164
180
|
return { ...marker, reportPath: join(dir, 'report.md') }
|
|
165
181
|
}
|
|
166
182
|
|
|
@@ -200,7 +216,13 @@ function runClaude(
|
|
|
200
216
|
child.on('close', (code) => {
|
|
201
217
|
clearTimeout(timer)
|
|
202
218
|
try {
|
|
203
|
-
|
|
219
|
+
// Write rather than append: the output is one JSON envelope per run, so
|
|
220
|
+
// stacking several produced a file that parsed as none of them. A
|
|
221
|
+
// re-analysis replaces its predecessor's log instead of growing it.
|
|
222
|
+
writeFileSync(
|
|
223
|
+
join(cwd, 'analyzer.log'),
|
|
224
|
+
clampLog(out + (err ? `\n--- stderr ---\n${err}` : '')),
|
|
225
|
+
)
|
|
204
226
|
} catch {
|
|
205
227
|
/* best effort */
|
|
206
228
|
}
|
|
@@ -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'
|
|
@@ -17,22 +18,32 @@ import { eventsPath } from './store'
|
|
|
17
18
|
export type Finalizer = (exitCode: number, errorName?: string) => void
|
|
18
19
|
|
|
19
20
|
const NOOP: Finalizer = () => {}
|
|
20
|
-
const HELP_VERSION = new Set([
|
|
21
|
+
const HELP_VERSION = new Set([
|
|
22
|
+
'-h',
|
|
23
|
+
'--help',
|
|
24
|
+
'-V',
|
|
25
|
+
'--version',
|
|
26
|
+
'--cli-version',
|
|
27
|
+
'help',
|
|
28
|
+
'version',
|
|
29
|
+
])
|
|
21
30
|
|
|
22
31
|
/** Bare invocation (every() is true for []) or only help/version tokens — skip. */
|
|
23
32
|
function isHelpOrVersion(args: string[]): boolean {
|
|
24
33
|
return args.every((a) => HELP_VERSION.has(a))
|
|
25
34
|
}
|
|
26
35
|
|
|
27
|
-
/** Begin recording an invocation; returns a finalizer to call at process end.
|
|
28
|
-
|
|
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 {
|
|
29
40
|
try {
|
|
30
41
|
const args = argv.slice(2)
|
|
31
42
|
if (!telemetryEnabled() || isHelpOrVersion(args)) return NOOP
|
|
32
43
|
const startMs = Date.now()
|
|
33
44
|
const ts = new Date(startMs).toISOString()
|
|
34
45
|
const cwd = process.cwd()
|
|
35
|
-
const { id, root } = ensureSession(cwd)
|
|
46
|
+
const { id, root } = ensureSession(cwd, sessions)
|
|
36
47
|
return (exitCode: number, errorName?: string) => {
|
|
37
48
|
try {
|
|
38
49
|
const event: TelemetryEvent = {
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-store retention: two bounds, applied in that order.
|
|
3
|
+
*
|
|
4
|
+
* Age is a RELEVANCE bound — a session idle for a month has nothing left to
|
|
5
|
+
* say, and the harness transcripts its report would cite are long gone. It is
|
|
6
|
+
* free to enforce (scanSessions already stats every events.jsonl), so it runs
|
|
7
|
+
* on every CLI start, telemetry on or off: switching telemetry off must mean
|
|
8
|
+
* "and clean up what is already there", not "freeze the store forever".
|
|
9
|
+
*
|
|
10
|
+
* Size is a SAFETY bound — the analyzer runs with Write inside the session
|
|
11
|
+
* directory, so one pathological session can grow without limit. Enforcing it
|
|
12
|
+
* means stat-ing every file of every session, so it runs only in the detached
|
|
13
|
+
* analyzer, never on the user's critical path.
|
|
14
|
+
*
|
|
15
|
+
* Neither bound replaces the other: age bounds what is worth keeping, size
|
|
16
|
+
* bounds what can go wrong. Both are configurable — see settings.ts.
|
|
17
|
+
*/
|
|
18
|
+
import { type Dirent, readdirSync, rmSync } from 'node:fs'
|
|
19
|
+
import { join } from 'node:path'
|
|
20
|
+
|
|
21
|
+
import type { RetentionBudget } from './settings'
|
|
22
|
+
import type { SessionScan } from './store'
|
|
23
|
+
|
|
24
|
+
import { retentionBudget } from './settings'
|
|
25
|
+
import { readMarker, scanSessions, sessionBytes, sessionDir } from './store'
|
|
26
|
+
|
|
27
|
+
/** What an analyzed session is allowed to keep. Everything else is scratch: the
|
|
28
|
+
* analyzer runs with Write inside the session directory, so without this a
|
|
29
|
+
* session's size is whatever the agent felt like writing — one here was left
|
|
30
|
+
* holding a 462 KB calls.txt, a quarter of the entire store. */
|
|
31
|
+
const KEEP = new Set(['meta.json', 'events.jsonl', 'report.md', '.analyzed', 'analyzer.log'])
|
|
32
|
+
|
|
33
|
+
/** Reproducible from events.jsonl in the normal case, and dropped there — but
|
|
34
|
+
* it is the only record of what the analyzer was actually asked when it
|
|
35
|
+
* failed, which is exactly when someone will want to look. */
|
|
36
|
+
const ANALYZER_PROMPT = 'analyzer-prompt.md'
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Reduce one session to its durable artifacts, called once the analyzer has
|
|
40
|
+
* written its marker. This is what makes a session's footprint a property of
|
|
41
|
+
* the CLI rather than of whatever the agent decided to leave behind.
|
|
42
|
+
*/
|
|
43
|
+
export function tidySession(id: string, options: { keepPrompt?: boolean } = {}): string[] {
|
|
44
|
+
const dir = sessionDir(id)
|
|
45
|
+
let entries: Dirent[]
|
|
46
|
+
try {
|
|
47
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
48
|
+
} catch {
|
|
49
|
+
return []
|
|
50
|
+
}
|
|
51
|
+
const removed: string[] = []
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
if (KEEP.has(entry.name)) continue
|
|
54
|
+
if (entry.name === ANALYZER_PROMPT && options.keepPrompt === true) continue
|
|
55
|
+
try {
|
|
56
|
+
rmSync(join(dir, entry.name), { recursive: true, force: true })
|
|
57
|
+
removed.push(entry.name)
|
|
58
|
+
} catch {
|
|
59
|
+
/* best effort — tidying must never fail the analysis it follows */
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return removed
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Cap for the age sweep on the CLI's critical path, so a large backlog drains
|
|
66
|
+
* over several runs instead of stalling one command on hundreds of rmSync. */
|
|
67
|
+
export const MAX_REMOVALS_ON_START = 20
|
|
68
|
+
|
|
69
|
+
export type SweepOptions = {
|
|
70
|
+
/** Defaults to the resolved config budget. */
|
|
71
|
+
budget?: RetentionBudget
|
|
72
|
+
now?: number
|
|
73
|
+
/** Cap on removals; unbounded when omitted. */
|
|
74
|
+
limit?: number
|
|
75
|
+
/** Ids that must survive — the session being recorded or analyzed right now. */
|
|
76
|
+
protect?: ReadonlySet<string>
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Session ids removed, in removal order. */
|
|
80
|
+
export type SweepResult = { removed: string[] }
|
|
81
|
+
|
|
82
|
+
function remove(id: string, into: string[]): void {
|
|
83
|
+
try {
|
|
84
|
+
rmSync(sessionDir(id), { recursive: true, force: true })
|
|
85
|
+
into.push(id)
|
|
86
|
+
} catch {
|
|
87
|
+
/* best effort — retention must never affect the CLI */
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Drop every session whose last activity predates the age bound, analyzed or
|
|
93
|
+
* not: a session that went a month without being analyzed never will be.
|
|
94
|
+
* Sessions with no events yet are exempt — one of them is the invocation
|
|
95
|
+
* running right now, created by ensureSession and not written until exit.
|
|
96
|
+
*/
|
|
97
|
+
export function sweepByAge(
|
|
98
|
+
sessions: readonly SessionScan[],
|
|
99
|
+
options: SweepOptions = {},
|
|
100
|
+
): SweepResult {
|
|
101
|
+
const { maxAgeMs } = options.budget ?? retentionBudget()
|
|
102
|
+
const cutoff = (options.now ?? Date.now()) - maxAgeMs
|
|
103
|
+
const limit = options.limit ?? Number.POSITIVE_INFINITY
|
|
104
|
+
const protect = options.protect
|
|
105
|
+
const removed: string[] = []
|
|
106
|
+
for (const session of sessions) {
|
|
107
|
+
if (removed.length >= limit) break
|
|
108
|
+
if (protect?.has(session.id)) continue
|
|
109
|
+
if (session.lastEventAt !== null && session.lastEventAt.getTime() < cutoff) {
|
|
110
|
+
remove(session.id, removed)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { removed }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Trim the store to the size bound, evicting oldest-first and analyzed before
|
|
118
|
+
* unanalyzed — an analyzed session has already handed its evidence to a report,
|
|
119
|
+
* an unanalyzed one has not. Open sessions are never touched: one of them is
|
|
120
|
+
* being recorded right now.
|
|
121
|
+
*/
|
|
122
|
+
export function sweepToBudget(
|
|
123
|
+
sessions: readonly SessionScan[],
|
|
124
|
+
options: SweepOptions = {},
|
|
125
|
+
): SweepResult {
|
|
126
|
+
const { maxBytes } = options.budget ?? retentionBudget()
|
|
127
|
+
const protect = options.protect
|
|
128
|
+
const removed: string[] = []
|
|
129
|
+
|
|
130
|
+
const sized = sessions.map((session) => ({ session, bytes: sessionBytes(session.id) }))
|
|
131
|
+
let total = sized.reduce((sum, entry) => sum + entry.bytes, 0)
|
|
132
|
+
if (total <= maxBytes) return { removed }
|
|
133
|
+
|
|
134
|
+
// Oldest first; a session with no events yet sorts to the front — it holds
|
|
135
|
+
// nothing but a meta.json, so it is the cheapest thing to lose.
|
|
136
|
+
const evictable = sized
|
|
137
|
+
.filter((entry) => entry.session.closed && !protect?.has(entry.session.id))
|
|
138
|
+
.sort(
|
|
139
|
+
(a, b) => (a.session.lastEventAt?.getTime() ?? 0) - (b.session.lastEventAt?.getTime() ?? 0),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
for (const analyzedFirst of [true, false]) {
|
|
143
|
+
for (const entry of evictable) {
|
|
144
|
+
if (total <= maxBytes) return { removed }
|
|
145
|
+
if (entry.session.analyzed !== analyzedFirst) continue
|
|
146
|
+
const before = removed.length
|
|
147
|
+
remove(entry.session.id, removed)
|
|
148
|
+
if (removed.length > before) total -= entry.bytes
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { removed }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Both bounds over the live store, for the detached analyzer: age first (cheap,
|
|
156
|
+
* and it may free enough on its own), then size over whatever survives.
|
|
157
|
+
*/
|
|
158
|
+
export function sweepStore(options: SweepOptions = {}): SweepResult {
|
|
159
|
+
const budget = options.budget ?? retentionBudget()
|
|
160
|
+
const sessions = scanSessions()
|
|
161
|
+
const byAge = sweepByAge(sessions, { ...options, budget })
|
|
162
|
+
const gone = new Set(byAge.removed)
|
|
163
|
+
const survivors = sessions.filter((session) => !gone.has(session.id))
|
|
164
|
+
|
|
165
|
+
// Tidy before measuring. Doing it after would let a session be evicted for
|
|
166
|
+
// holding scratch that was about to be deleted anyway — and it is what makes
|
|
167
|
+
// the artifact bound retroactive rather than only applying to new analyses.
|
|
168
|
+
for (const session of survivors) {
|
|
169
|
+
if (session.analyzed) {
|
|
170
|
+
tidySession(session.id, { keepPrompt: readMarker(session.id)?.outcome === 'error' })
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const bySize = sweepToBudget(survivors, { ...options, budget })
|
|
175
|
+
return { removed: [...byAge.removed, ...bySize.removed] }
|
|
176
|
+
}
|
package/src/telemetry/session.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { dirname, join } from 'node:path'
|
|
|
10
10
|
|
|
11
11
|
import type { SessionMeta } from './types'
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import { metaPath, readMeta, scanSessions, sessionDir, type SessionScan } from './store'
|
|
14
14
|
|
|
15
15
|
export type ResolvedSession = { id: string; root: string; explicit: boolean }
|
|
16
16
|
|
|
@@ -40,12 +40,14 @@ function stamp(d = new Date()): string {
|
|
|
40
40
|
|
|
41
41
|
/** id of an open, ambient session already bucketed to `root`, else null.
|
|
42
42
|
* Analyzed sessions are never reused — a straggler event may "reopen" one
|
|
43
|
-
* after its report, and new work funneled there would never be analyzed.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
43
|
+
* after its report, and new work funneled there would never be analyzed.
|
|
44
|
+
* Only the open, unanalyzed candidates get their meta.json read: on a store of
|
|
45
|
+
* hundreds at most a couple qualify, and this runs before every command. */
|
|
46
|
+
function findOpenAmbient(root: string, sessions: readonly SessionScan[]): string | null {
|
|
47
|
+
for (const s of sessions) {
|
|
48
|
+
if (s.closed || s.analyzed) continue
|
|
49
|
+
const meta = readMeta(s.id)
|
|
50
|
+
if (meta?.root === root && meta.explicit === false) return s.id
|
|
49
51
|
}
|
|
50
52
|
return null
|
|
51
53
|
}
|
|
@@ -55,20 +57,24 @@ function mintAmbientId(root: string): string {
|
|
|
55
57
|
return `amb-${hash8}-${stamp()}`
|
|
56
58
|
}
|
|
57
59
|
|
|
58
|
-
/** Resolve the session identity for `cwd` without creating anything on disk.
|
|
59
|
-
|
|
60
|
+
/** Resolve the session identity for `cwd` without creating anything on disk.
|
|
61
|
+
* `sessions` lets the caller hand over a scan it already paid for — the CLI
|
|
62
|
+
* start path shares one between recording and retention. */
|
|
63
|
+
export function resolveSession(cwd: string, sessions?: readonly SessionScan[]): ResolvedSession {
|
|
60
64
|
const root = findGitRoot(cwd) ?? cwd
|
|
61
65
|
const pinned = process.env.ASTRALE_SESSION
|
|
62
66
|
if (pinned) {
|
|
63
67
|
const id = sanitizeId(pinned)
|
|
64
68
|
if (id.length > 0) return { id, root, explicit: true }
|
|
65
69
|
}
|
|
66
|
-
|
|
70
|
+
// Only scan when a pinned id did not already settle it.
|
|
71
|
+
const scan = sessions ?? scanSessions()
|
|
72
|
+
return { id: findOpenAmbient(root, scan) ?? mintAmbientId(root), root, explicit: false }
|
|
67
73
|
}
|
|
68
74
|
|
|
69
75
|
/** Resolve, then create the session dir + meta.json on first sight. */
|
|
70
|
-
export function ensureSession(cwd: string): ResolvedSession {
|
|
71
|
-
const resolved = resolveSession(cwd)
|
|
76
|
+
export function ensureSession(cwd: string, sessions?: readonly SessionScan[]): ResolvedSession {
|
|
77
|
+
const resolved = resolveSession(cwd, sessions)
|
|
72
78
|
const dir = sessionDir(resolved.id)
|
|
73
79
|
if (!existsSync(dir)) {
|
|
74
80
|
try {
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Telemetry kill-switch,
|
|
3
|
-
*
|
|
4
|
-
* is false; on by default.
|
|
2
|
+
* Telemetry kill-switch and retention budget, both read synchronously from
|
|
3
|
+
* `~/.astrale/config.json` with env overrides. Off when ASTRALE_TELEMETRY is
|
|
4
|
+
* 0/false/off, or when config telemetry.enabled is false; on by default.
|
|
5
|
+
* Every read silent-fails to the default — a broken config must never break
|
|
6
|
+
* the CLI, and must never leave the session store unbounded.
|
|
5
7
|
*/
|
|
6
8
|
import { readFileSync } from 'node:fs'
|
|
7
9
|
|
|
@@ -9,18 +11,69 @@ import { createPaths } from '../state/index'
|
|
|
9
11
|
|
|
10
12
|
const OFF_VALUES = new Set(['0', 'false', 'off'])
|
|
11
13
|
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
/** Sessions idle longer than this are dropped: a month-old session has nothing
|
|
15
|
+
* left to say, and the harness transcripts its report would cite are gone. */
|
|
16
|
+
export const DEFAULT_MAX_AGE_DAYS = 30
|
|
17
|
+
|
|
18
|
+
/** Hard ceiling on the whole session store. At ~35 KB per session that is some
|
|
19
|
+
* 1,400 sessions — never reached in normal use. It exists for the pathological
|
|
20
|
+
* case: the analyzer runs with Write inside the session directory, so a single
|
|
21
|
+
* session can grow without bound. */
|
|
22
|
+
export const DEFAULT_MAX_BYTES = 50 * 1024 * 1024
|
|
23
|
+
|
|
24
|
+
type TelemetryConfig = {
|
|
25
|
+
enabled?: boolean
|
|
26
|
+
maxAgeDays?: number
|
|
27
|
+
maxBytes?: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The `telemetry` block, or {} when the config is missing or unreadable. */
|
|
31
|
+
function readConfig(): TelemetryConfig {
|
|
16
32
|
try {
|
|
17
33
|
// Call-time path resolution — see sessionsRoot() in store.ts for why.
|
|
18
34
|
const parsed = JSON.parse(readFileSync(createPaths().config, 'utf-8')) as {
|
|
19
|
-
telemetry?:
|
|
35
|
+
telemetry?: TelemetryConfig
|
|
20
36
|
}
|
|
21
|
-
|
|
37
|
+
const telemetry = parsed.telemetry
|
|
38
|
+
return typeof telemetry === 'object' && telemetry !== null ? telemetry : {}
|
|
22
39
|
} catch {
|
|
23
|
-
/* missing or broken config →
|
|
40
|
+
/* missing or broken config → defaults */
|
|
41
|
+
return {}
|
|
24
42
|
}
|
|
25
|
-
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Whether telemetry recording is enabled for this process. */
|
|
46
|
+
export function telemetryEnabled(): boolean {
|
|
47
|
+
const env = process.env.ASTRALE_TELEMETRY
|
|
48
|
+
if (env !== undefined && OFF_VALUES.has(env.trim().toLowerCase())) return false
|
|
49
|
+
return readConfig().enabled !== false
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The two bounds on the session store — see retention.ts for how they apply. */
|
|
53
|
+
export type RetentionBudget = {
|
|
54
|
+
maxAgeMs: number
|
|
55
|
+
maxBytes: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** First finite, strictly positive candidate. Zero, negatives and garbage fall
|
|
59
|
+
* through to the next candidate (ultimately the default) rather than
|
|
60
|
+
* disabling the bound, so a typo can never make the store unbounded. */
|
|
61
|
+
function firstPositive(candidates: (number | string | undefined)[]): number | null {
|
|
62
|
+
for (const candidate of candidates) {
|
|
63
|
+
if (candidate === undefined) continue
|
|
64
|
+
const n = typeof candidate === 'string' ? Number(candidate.trim()) : candidate
|
|
65
|
+
if (Number.isFinite(n) && n > 0) return n
|
|
66
|
+
}
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Resolved retention budget: env over config over defaults. */
|
|
71
|
+
export function retentionBudget(): RetentionBudget {
|
|
72
|
+
const config = readConfig()
|
|
73
|
+
const days =
|
|
74
|
+
firstPositive([process.env.ASTRALE_TELEMETRY_MAX_AGE_DAYS, config.maxAgeDays]) ??
|
|
75
|
+
DEFAULT_MAX_AGE_DAYS
|
|
76
|
+
const bytes =
|
|
77
|
+
firstPositive([process.env.ASTRALE_TELEMETRY_MAX_BYTES, config.maxBytes]) ?? DEFAULT_MAX_BYTES
|
|
78
|
+
return { maxAgeMs: days * 24 * 60 * 60 * 1000, maxBytes: bytes }
|
|
26
79
|
}
|