@astrale-os/cli 1.0.0-beta.3 → 1.0.0-beta.31
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 +84 -6
- package/dist/astrale.js +57586 -60753
- 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 +119 -11
- 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 +39 -36
- 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 +90 -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 +13 -6
- package/src/graph/.spec/laws/documents.ts +7 -7
- package/src/graph/.spec/layout.ts +9 -1
- package/src/graph/__tests__/mutation.test.ts +2 -2
- package/src/graph/__tests__/query.test.ts +35 -9
- package/src/graph/class.ts +30 -0
- package/src/graph/index.ts +1 -0
- package/src/graph/query.ts +27 -40
- 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 +834 -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 +878 -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 +201 -7
- 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 +32 -4
- package/studio/server/cli.test.ts +2 -2
- 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 +25 -51
- 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
|
@@ -5,24 +5,28 @@ import { dirname } from 'node:path'
|
|
|
5
5
|
import { atomicWrite, withFileLock } from './files'
|
|
6
6
|
import { EXCHANGE_CREDENTIALS_PATH } from './paths'
|
|
7
7
|
|
|
8
|
-
const VERSION =
|
|
8
|
+
const VERSION = 2
|
|
9
9
|
const MINIMUM_REMAINING_SECONDS = 30
|
|
10
10
|
|
|
11
11
|
export namespace exchange {
|
|
12
12
|
export interface Artifact {
|
|
13
|
-
readonly version:
|
|
13
|
+
readonly version: 2
|
|
14
14
|
readonly entries: Record<string, Entry>
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export interface Key {
|
|
18
18
|
readonly kernelIssuer: string
|
|
19
19
|
readonly domainIssuer: string
|
|
20
|
-
readonly
|
|
20
|
+
readonly sourceIssuer: string
|
|
21
|
+
readonly sourceSubject: string
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export interface Entry {
|
|
24
25
|
readonly credential: string
|
|
25
26
|
readonly expiresAt: number
|
|
27
|
+
readonly user: string
|
|
28
|
+
readonly sourceIssuer: string
|
|
29
|
+
readonly sourceSubject: string
|
|
26
30
|
}
|
|
27
31
|
}
|
|
28
32
|
|
|
@@ -140,11 +144,15 @@ function validEntry(
|
|
|
140
144
|
if (
|
|
141
145
|
entry === null ||
|
|
142
146
|
typeof entry !== 'object' ||
|
|
143
|
-
Reflect.ownKeys(entry).length !==
|
|
147
|
+
Reflect.ownKeys(entry).length !== 5 ||
|
|
144
148
|
typeof entry.credential !== 'string' ||
|
|
145
149
|
entry.credential.length === 0 ||
|
|
146
150
|
!Number.isSafeInteger(entry.expiresAt) ||
|
|
147
|
-
entry.expiresAt - now < minimumRemaining
|
|
151
|
+
entry.expiresAt - now < minimumRemaining ||
|
|
152
|
+
typeof entry.user !== 'string' ||
|
|
153
|
+
entry.user.length === 0 ||
|
|
154
|
+
entry.sourceIssuer !== key.sourceIssuer ||
|
|
155
|
+
entry.sourceSubject !== key.sourceSubject
|
|
148
156
|
) {
|
|
149
157
|
return false
|
|
150
158
|
}
|
|
@@ -178,7 +186,7 @@ function validEntry(
|
|
|
178
186
|
inspected.claims.exp === entry.expiresAt &&
|
|
179
187
|
!Object.hasOwn(inspected.claims, 'delegation') &&
|
|
180
188
|
proof.iss === key.kernelIssuer &&
|
|
181
|
-
proof.sub ===
|
|
189
|
+
proof.sub === entry.user &&
|
|
182
190
|
proof.aud === key.kernelIssuer
|
|
183
191
|
)
|
|
184
192
|
} catch {
|
|
@@ -187,7 +195,7 @@ function validEntry(
|
|
|
187
195
|
}
|
|
188
196
|
|
|
189
197
|
function encodeKey(key: exchange.Key): string {
|
|
190
|
-
return JSON.stringify([key.kernelIssuer, key.domainIssuer, key.
|
|
198
|
+
return JSON.stringify([key.kernelIssuer, key.domainIssuer, key.sourceIssuer, key.sourceSubject])
|
|
191
199
|
}
|
|
192
200
|
|
|
193
201
|
function decodeKey(input: string): exchange.Key | undefined {
|
|
@@ -195,12 +203,17 @@ function decodeKey(input: string): exchange.Key | undefined {
|
|
|
195
203
|
const value = JSON.parse(input) as unknown
|
|
196
204
|
if (
|
|
197
205
|
!Array.isArray(value) ||
|
|
198
|
-
value.length !==
|
|
206
|
+
value.length !== 4 ||
|
|
199
207
|
value.some((part) => typeof part !== 'string' || part.length === 0)
|
|
200
208
|
) {
|
|
201
209
|
return undefined
|
|
202
210
|
}
|
|
203
|
-
return {
|
|
211
|
+
return {
|
|
212
|
+
kernelIssuer: value[0]!,
|
|
213
|
+
domainIssuer: value[1]!,
|
|
214
|
+
sourceIssuer: value[2]!,
|
|
215
|
+
sourceSubject: value[3]!,
|
|
216
|
+
}
|
|
204
217
|
} catch {
|
|
205
218
|
return undefined
|
|
206
219
|
}
|
package/src/state/files.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
fsyncSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
openSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from 'node:fs'
|
|
2
11
|
import { mkdir, open, readFile, rename, stat, unlink } from 'node:fs/promises'
|
|
3
12
|
import { dirname } from 'node:path'
|
|
4
13
|
|
|
@@ -30,6 +39,38 @@ export async function atomicWrite(path: string, data: string): Promise<void> {
|
|
|
30
39
|
}
|
|
31
40
|
}
|
|
32
41
|
|
|
42
|
+
/** Atomically publish one complete private state file for synchronous consumer capabilities. */
|
|
43
|
+
export function atomicWriteSync(path: string, data: string): void {
|
|
44
|
+
const directory = dirname(path)
|
|
45
|
+
const temporary = `${path}.${randomUUID()}.tmp`
|
|
46
|
+
mkdirSync(directory, { recursive: true })
|
|
47
|
+
|
|
48
|
+
let descriptor: number | undefined
|
|
49
|
+
try {
|
|
50
|
+
descriptor = openSync(temporary, 'wx', 0o600)
|
|
51
|
+
writeFileSync(descriptor, data)
|
|
52
|
+
fsyncSync(descriptor)
|
|
53
|
+
closeSync(descriptor)
|
|
54
|
+
descriptor = undefined
|
|
55
|
+
renameSync(temporary, path)
|
|
56
|
+
|
|
57
|
+
const directoryDescriptor = openSync(directory, 'r')
|
|
58
|
+
try {
|
|
59
|
+
fsyncSync(directoryDescriptor)
|
|
60
|
+
} finally {
|
|
61
|
+
closeSync(directoryDescriptor)
|
|
62
|
+
}
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (descriptor !== undefined) closeSync(descriptor)
|
|
65
|
+
try {
|
|
66
|
+
unlinkSync(temporary)
|
|
67
|
+
} catch {
|
|
68
|
+
// The temporary file was either never created or was already renamed.
|
|
69
|
+
}
|
|
70
|
+
throw error
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
33
74
|
export interface FileLockOptions {
|
|
34
75
|
readonly pollIntervalMs?: number
|
|
35
76
|
readonly staleAfterMs?: number
|
package/src/state/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { atomicWrite, withFileLock } from './files'
|
|
1
|
+
export { atomicWrite, atomicWriteSync, withFileLock } from './files'
|
|
2
2
|
export type { FileLockOptions } from './files'
|
|
3
3
|
export { ExchangeCredentialCache } from './exchange-credentials'
|
|
4
4
|
export type { exchange } from './exchange-credentials'
|
|
@@ -23,7 +23,9 @@ export {
|
|
|
23
23
|
INSTALL_PATH,
|
|
24
24
|
INSTANCES_PATH,
|
|
25
25
|
KEYS_DIR,
|
|
26
|
+
SESSION_ROUTES_PATH,
|
|
26
27
|
createPaths,
|
|
27
28
|
paths,
|
|
28
29
|
} from './paths'
|
|
29
30
|
export type { PathEnvironment, Paths } from './paths'
|
|
31
|
+
export { FileSessionRouteStore, SESSION_ROUTE_STORE } from './session-routes'
|
package/src/state/paths.ts
CHANGED
|
@@ -18,6 +18,7 @@ export interface Paths {
|
|
|
18
18
|
readonly idps: string
|
|
19
19
|
readonly idpSessionsDir: string
|
|
20
20
|
readonly exchangeCredentials: string
|
|
21
|
+
readonly sessionRoutes: string
|
|
21
22
|
idpDir(name: string): string
|
|
22
23
|
idpSession(identityName: string): string
|
|
23
24
|
}
|
|
@@ -43,6 +44,7 @@ export function createPaths(home?: string, environment?: PathEnvironment): Paths
|
|
|
43
44
|
idps: join(idpsDir, 'index.json'),
|
|
44
45
|
idpSessionsDir,
|
|
45
46
|
exchangeCredentials: join(base, 'exchange', 'credentials.json'),
|
|
47
|
+
sessionRoutes: join(base, 'session', 'routes.json'),
|
|
46
48
|
idpDir: (name: string) => join(idpsDir, name),
|
|
47
49
|
idpSession: (identityName: string) => join(idpSessionsDir, `${identityName}.json`),
|
|
48
50
|
})
|
|
@@ -59,3 +61,4 @@ export const INSTANCES_PATH = paths.instances
|
|
|
59
61
|
export const IDPS_PATH = paths.idps
|
|
60
62
|
export const IDP_SESSIONS_DIR = paths.idpSessionsDir
|
|
61
63
|
export const EXCHANGE_CREDENTIALS_PATH = paths.exchangeCredentials
|
|
64
|
+
export const SESSION_ROUTES_PATH = paths.sessionRoutes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { SessionRouteArtifact, SessionRouteStore } from '@astrale-os/sdk/client/session'
|
|
2
|
+
|
|
3
|
+
import { chmodSync, mkdirSync, readFileSync, unlinkSync } from 'node:fs'
|
|
4
|
+
import { dirname } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { atomicWriteSync } from './files'
|
|
7
|
+
import { SESSION_ROUTES_PATH } from './paths'
|
|
8
|
+
|
|
9
|
+
/** CLI filesystem representation for Kernel Client's admitted confidential route artifact. */
|
|
10
|
+
export class FileSessionRouteStore implements SessionRouteStore {
|
|
11
|
+
constructor(private readonly path = SESSION_ROUTES_PATH) {}
|
|
12
|
+
|
|
13
|
+
read(): unknown {
|
|
14
|
+
return JSON.parse(readFileSync(this.path, 'utf8')) as unknown
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
write(artifact: SessionRouteArtifact): void {
|
|
18
|
+
const directory = dirname(this.path)
|
|
19
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 })
|
|
20
|
+
chmodSync(directory, 0o700)
|
|
21
|
+
atomicWriteSync(this.path, `${JSON.stringify(artifact)}\n`)
|
|
22
|
+
chmodSync(this.path, 0o600)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
clear(): void {
|
|
26
|
+
try {
|
|
27
|
+
unlinkSync(this.path)
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if ((error as { readonly code?: unknown }).code !== 'ENOENT') throw error
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const SESSION_ROUTE_STORE = Object.freeze(new FileSessionRouteStore())
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { clampLog } from '../analyze'
|
|
4
|
+
|
|
5
|
+
describe('clampLog', () => {
|
|
6
|
+
test('short output is written through untouched', () => {
|
|
7
|
+
const json = '{"is_error":false,"num_turns":3}'
|
|
8
|
+
expect(clampLog(json)).toBe(json)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test('output at exactly the limit is not clamped', () => {
|
|
12
|
+
const text = 'x'.repeat(1000)
|
|
13
|
+
expect(clampLog(text, 1000)).toBe(text)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('oversized output keeps its head and its tail', () => {
|
|
17
|
+
// The JSON envelope opens at the top; a stack or error message closes at
|
|
18
|
+
// the bottom. Both must survive; the middle is what nobody reads.
|
|
19
|
+
const text = `HEAD${'x'.repeat(50_000)}TAIL`
|
|
20
|
+
const clamped = clampLog(text, 1000)
|
|
21
|
+
expect(clamped.startsWith('HEAD')).toBe(true)
|
|
22
|
+
expect(clamped.endsWith('TAIL')).toBe(true)
|
|
23
|
+
expect(clamped).toContain('bytes elided')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('the result stays within a small constant of the limit', () => {
|
|
27
|
+
const clamped = clampLog('y'.repeat(10_000_000), 4096)
|
|
28
|
+
// head + tail + the elision marker, never the input.
|
|
29
|
+
expect(clamped.length).toBeLessThan(4096 + 128)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('the elision count reports what was actually dropped', () => {
|
|
33
|
+
const clamped = clampLog('z'.repeat(10_000), 1000)
|
|
34
|
+
const elided = Number(/… (\d+) bytes elided …/u.exec(clamped)?.[1])
|
|
35
|
+
const kept = clamped.length - `\n… ${elided} bytes elided …\n`.length
|
|
36
|
+
expect(elided + kept).toBe(10_000)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -91,6 +91,7 @@ describe('beginInvocation', () => {
|
|
|
91
91
|
test('writes nothing for help/version-only invocations', () => {
|
|
92
92
|
beginInvocation(['node', 'astrale', '--help'])(0)
|
|
93
93
|
beginInvocation(['node', 'astrale', '--version'])(0)
|
|
94
|
+
beginInvocation(['node', 'astrale', '--cli-version'])(0)
|
|
94
95
|
beginInvocation(['node', 'astrale'])(0)
|
|
95
96
|
expect(listSessions()).toHaveLength(0)
|
|
96
97
|
})
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import type { SweepOptions, SweepResult } from '../retention'
|
|
7
|
+
import type { RetentionBudget } from '../settings'
|
|
8
|
+
import type { SessionScan } from '../store'
|
|
9
|
+
|
|
10
|
+
// Set the home before the paths singleton is captured (dynamic imports below).
|
|
11
|
+
process.env.ASTRALE_HOME = mkdtempSync(join(tmpdir(), 'astrale-tele-retention-'))
|
|
12
|
+
|
|
13
|
+
let sweepByAge: (sessions: readonly SessionScan[], options?: SweepOptions) => SweepResult
|
|
14
|
+
let sweepToBudget: (sessions: readonly SessionScan[], options?: SweepOptions) => SweepResult
|
|
15
|
+
let sweepStore: (options?: SweepOptions) => SweepResult
|
|
16
|
+
let tidySession: (id: string, options?: { keepPrompt?: boolean }) => string[]
|
|
17
|
+
let scanSessions: () => SessionScan[]
|
|
18
|
+
let sessionDir: (id: string) => string
|
|
19
|
+
let sessionBytes: (id: string) => number
|
|
20
|
+
let sessionsRoot: () => string
|
|
21
|
+
|
|
22
|
+
const DAY = 24 * 60 * 60 * 1000
|
|
23
|
+
const BUDGET: RetentionBudget = { maxAgeMs: 30 * DAY, maxBytes: 10_000 }
|
|
24
|
+
|
|
25
|
+
beforeAll(async () => {
|
|
26
|
+
;({ sweepByAge, sweepToBudget, sweepStore, tidySession } = await import('../retention'))
|
|
27
|
+
;({ scanSessions, sessionDir, sessionBytes, sessionsRoot } = await import('../store'))
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
// Destructive cleanup must be provably confined to this file's mkdtemp home.
|
|
32
|
+
if (!sessionsRoot().startsWith(tmpdir())) throw new Error('refusing to clean a non-tmp home')
|
|
33
|
+
rmSync(sessionsRoot(), { recursive: true, force: true })
|
|
34
|
+
delete process.env.ASTRALE_TELEMETRY_MAX_AGE_DAYS
|
|
35
|
+
delete process.env.ASTRALE_TELEMETRY_MAX_BYTES
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
rmSync(sessionsRoot(), { recursive: true, force: true })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
type Seed = { ageMs?: number; analyzed?: boolean; bytes?: number; events?: boolean }
|
|
43
|
+
|
|
44
|
+
function seed(id: string, { ageMs = 0, analyzed = false, bytes = 0, events = true }: Seed): void {
|
|
45
|
+
const dir = sessionDir(id)
|
|
46
|
+
mkdirSync(dir, { recursive: true })
|
|
47
|
+
writeFileSync(join(dir, 'meta.json'), JSON.stringify({ id, root: '/w', explicit: false }))
|
|
48
|
+
if (bytes > 0) writeFileSync(join(dir, 'report.md'), 'x'.repeat(bytes))
|
|
49
|
+
if (analyzed) {
|
|
50
|
+
writeFileSync(
|
|
51
|
+
join(dir, '.analyzed'),
|
|
52
|
+
JSON.stringify({ analyzedAt: new Date().toISOString(), outcome: 'reported' }),
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
if (!events) return
|
|
56
|
+
writeFileSync(join(dir, 'events.jsonl'), '{}\n')
|
|
57
|
+
const when = new Date(Date.now() - ageMs)
|
|
58
|
+
utimesSync(join(dir, 'events.jsonl'), when, when)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const alive = (id: string): boolean => existsSync(sessionDir(id))
|
|
62
|
+
|
|
63
|
+
describe('sweepByAge', () => {
|
|
64
|
+
test('drops sessions past the age bound whether or not they were analyzed', () => {
|
|
65
|
+
seed('old-analyzed', { ageMs: 40 * DAY, analyzed: true })
|
|
66
|
+
seed('old-unanalyzed', { ageMs: 40 * DAY })
|
|
67
|
+
seed('recent-analyzed', { ageMs: 2 * DAY, analyzed: true })
|
|
68
|
+
seed('recent-unanalyzed', { ageMs: 2 * DAY })
|
|
69
|
+
|
|
70
|
+
const { removed } = sweepByAge(scanSessions(), { budget: BUDGET })
|
|
71
|
+
|
|
72
|
+
expect(removed.sort()).toEqual(['old-analyzed', 'old-unanalyzed'])
|
|
73
|
+
expect(alive('recent-analyzed')).toBe(true)
|
|
74
|
+
expect(alive('recent-unanalyzed')).toBe(true)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('never touches a session with no events — that is the invocation running now', () => {
|
|
78
|
+
seed('no-events-yet', { events: false })
|
|
79
|
+
expect(sweepByAge(scanSessions(), { budget: BUDGET }).removed).toEqual([])
|
|
80
|
+
expect(alive('no-events-yet')).toBe(true)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('honours the removal cap so a backlog drains over several runs', () => {
|
|
84
|
+
for (let i = 0; i < 5; i++) seed(`stale-${i}`, { ageMs: 40 * DAY, analyzed: true })
|
|
85
|
+
expect(sweepByAge(scanSessions(), { budget: BUDGET, limit: 2 }).removed).toHaveLength(2)
|
|
86
|
+
expect(scanSessions()).toHaveLength(3)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('respects the configured age bound', () => {
|
|
90
|
+
seed('week-old', { ageMs: 8 * DAY, analyzed: true })
|
|
91
|
+
const strict: RetentionBudget = { ...BUDGET, maxAgeMs: 7 * DAY }
|
|
92
|
+
expect(sweepByAge(scanSessions(), { budget: strict }).removed).toEqual(['week-old'])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('protected ids survive the age bound', () => {
|
|
96
|
+
seed('old-but-mine', { ageMs: 40 * DAY, analyzed: true })
|
|
97
|
+
const options = { budget: BUDGET, protect: new Set(['old-but-mine']) }
|
|
98
|
+
expect(sweepByAge(scanSessions(), options).removed).toEqual([])
|
|
99
|
+
expect(alive('old-but-mine')).toBe(true)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('sweepToBudget', () => {
|
|
104
|
+
test('does nothing while the store fits', () => {
|
|
105
|
+
seed('small', { ageMs: DAY, analyzed: true, bytes: 100 })
|
|
106
|
+
expect(sweepToBudget(scanSessions(), { budget: BUDGET }).removed).toEqual([])
|
|
107
|
+
expect(alive('small')).toBe(true)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('evicts oldest-first until the store is back under budget', () => {
|
|
111
|
+
seed('oldest', { ageMs: 5 * DAY, analyzed: true, bytes: 4_000 })
|
|
112
|
+
seed('middle', { ageMs: 3 * DAY, analyzed: true, bytes: 4_000 })
|
|
113
|
+
seed('newest', { ageMs: DAY, analyzed: true, bytes: 4_000 })
|
|
114
|
+
|
|
115
|
+
// 3 × ~4 KB against a 5 KB bound: two must go, and the newest must stay.
|
|
116
|
+
const { removed } = sweepToBudget(scanSessions(), { budget: { ...BUDGET, maxBytes: 5_000 } })
|
|
117
|
+
|
|
118
|
+
expect(removed).toEqual(['oldest', 'middle'])
|
|
119
|
+
expect(alive('newest')).toBe(true)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('evicts analyzed sessions before unanalyzed ones of any age', () => {
|
|
123
|
+
seed('unanalyzed-oldest', { ageMs: 9 * DAY, bytes: 6_000 })
|
|
124
|
+
seed('analyzed-newest', { ageMs: DAY, analyzed: true, bytes: 6_000 })
|
|
125
|
+
|
|
126
|
+
expect(sweepToBudget(scanSessions(), { budget: BUDGET }).removed).toEqual(['analyzed-newest'])
|
|
127
|
+
expect(alive('unanalyzed-oldest')).toBe(true)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('falls through to unanalyzed sessions when analyzed ones are not enough', () => {
|
|
131
|
+
seed('unanalyzed-old', { ageMs: 9 * DAY, bytes: 6_000 })
|
|
132
|
+
seed('analyzed-old', { ageMs: 8 * DAY, analyzed: true, bytes: 6_000 })
|
|
133
|
+
seed('unanalyzed-new', { ageMs: DAY, bytes: 6_000 })
|
|
134
|
+
|
|
135
|
+
const { removed } = sweepToBudget(scanSessions(), { budget: BUDGET })
|
|
136
|
+
|
|
137
|
+
expect(removed).toEqual(['analyzed-old', 'unanalyzed-old'])
|
|
138
|
+
expect(alive('unanalyzed-new')).toBe(true)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('never evicts an open session, even when that leaves the store over budget', () => {
|
|
142
|
+
// No age offset → inside IDLE_WINDOW_MS → open.
|
|
143
|
+
seed('open-and-huge', { analyzed: true, bytes: 20_000 })
|
|
144
|
+
expect(sweepToBudget(scanSessions(), { budget: BUDGET }).removed).toEqual([])
|
|
145
|
+
expect(alive('open-and-huge')).toBe(true)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('never evicts a protected session', () => {
|
|
149
|
+
seed('just-analyzed', { ageMs: DAY, analyzed: true, bytes: 20_000 })
|
|
150
|
+
const options = { budget: BUDGET, protect: new Set(['just-analyzed']) }
|
|
151
|
+
expect(sweepToBudget(scanSessions(), options).removed).toEqual([])
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('counts nested analyzer output — a session is bounded by all it holds', () => {
|
|
155
|
+
seed('nested', { ageMs: DAY, analyzed: true, bytes: 100 })
|
|
156
|
+
mkdirSync(join(sessionDir('nested'), 'scratch'), { recursive: true })
|
|
157
|
+
writeFileSync(join(sessionDir('nested'), 'scratch', 'dump.txt'), 'x'.repeat(5_000))
|
|
158
|
+
|
|
159
|
+
expect(sessionBytes('nested')).toBeGreaterThan(5_000)
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
describe('sweepStore', () => {
|
|
164
|
+
test('applies age first, then trims what survives to the size bound', () => {
|
|
165
|
+
seed('expired', { ageMs: 40 * DAY, analyzed: true, bytes: 6_000 })
|
|
166
|
+
seed('fresh-big', { ageMs: 2 * DAY, analyzed: true, bytes: 6_000 })
|
|
167
|
+
seed('fresh-small', { ageMs: DAY, analyzed: true, bytes: 100 })
|
|
168
|
+
|
|
169
|
+
// Age alone frees 6 KB, leaving ~6.1 KB — already under the 10 KB bound,
|
|
170
|
+
// so the size pass must find nothing left to do.
|
|
171
|
+
expect(sweepStore({ budget: BUDGET }).removed).toEqual(['expired'])
|
|
172
|
+
expect(alive('fresh-big')).toBe(true)
|
|
173
|
+
expect(alive('fresh-small')).toBe(true)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('reads its budget from the environment when none is passed', () => {
|
|
177
|
+
process.env.ASTRALE_TELEMETRY_MAX_AGE_DAYS = '1'
|
|
178
|
+
seed('two-days-old', { ageMs: 2 * DAY, analyzed: true })
|
|
179
|
+
expect(sweepStore().removed).toEqual(['two-days-old'])
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
describe('sweepStore tidying', () => {
|
|
184
|
+
test('tidies analyzed survivors, so the bound applies to sessions already on disk', () => {
|
|
185
|
+
seed('store-tidy', { ageMs: DAY, analyzed: true, bytes: 100 })
|
|
186
|
+
writeFileSync(join(sessionDir('store-tidy'), 'calls.txt'), 'x'.repeat(50_000))
|
|
187
|
+
|
|
188
|
+
sweepStore({ budget: BUDGET })
|
|
189
|
+
|
|
190
|
+
expect(existsSync(join(sessionDir('store-tidy'), 'calls.txt'))).toBe(false)
|
|
191
|
+
expect(existsSync(join(sessionDir('store-tidy'), 'report.md'))).toBe(true)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test('an unanalyzed session is left alone — its analyzer may still be running', () => {
|
|
195
|
+
seed('store-pending', { ageMs: DAY, bytes: 100 })
|
|
196
|
+
writeFileSync(join(sessionDir('store-pending'), 'analyzer-prompt.md'), '# in flight')
|
|
197
|
+
|
|
198
|
+
sweepStore({ budget: BUDGET })
|
|
199
|
+
|
|
200
|
+
expect(existsSync(join(sessionDir('store-pending'), 'analyzer-prompt.md'))).toBe(true)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('scratch is tidied before the size bound is measured, not after', () => {
|
|
204
|
+
// 60 KB of scratch against a 10 KB budget: tidying first brings the store
|
|
205
|
+
// back under on its own, so nothing should be evicted.
|
|
206
|
+
seed('store-bloated', { ageMs: DAY, analyzed: true, bytes: 100 })
|
|
207
|
+
writeFileSync(join(sessionDir('store-bloated'), 'calls.txt'), 'x'.repeat(60_000))
|
|
208
|
+
|
|
209
|
+
expect(sweepStore({ budget: BUDGET }).removed).toEqual([])
|
|
210
|
+
expect(existsSync(sessionDir('store-bloated'))).toBe(true)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test('a failed analysis keeps its prompt through the sweep', () => {
|
|
214
|
+
const dir = sessionDir('store-failed')
|
|
215
|
+
seed('store-failed', { ageMs: DAY, analyzed: true })
|
|
216
|
+
writeFileSync(
|
|
217
|
+
join(dir, '.analyzed'),
|
|
218
|
+
JSON.stringify({ analyzedAt: new Date().toISOString(), outcome: 'error', note: 'boom' }),
|
|
219
|
+
)
|
|
220
|
+
writeFileSync(join(dir, 'analyzer-prompt.md'), '# what we asked')
|
|
221
|
+
|
|
222
|
+
sweepStore({ budget: BUDGET })
|
|
223
|
+
|
|
224
|
+
expect(existsSync(join(dir, 'analyzer-prompt.md'))).toBe(true)
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
describe('tidySession', () => {
|
|
229
|
+
test('removes what the analyzer left behind, keeps the durable artifacts', () => {
|
|
230
|
+
const dir = sessionDir('tidy-scratch')
|
|
231
|
+
seed('tidy-scratch', { ageMs: DAY, analyzed: true, bytes: 100 })
|
|
232
|
+
writeFileSync(join(dir, 'analyzer.log'), 'exit 0')
|
|
233
|
+
writeFileSync(join(dir, 'analyzer-prompt.md'), '# prompt')
|
|
234
|
+
// Scratch: the analyzer runs with Write in here and answers to nobody.
|
|
235
|
+
writeFileSync(join(dir, 'calls.txt'), 'x'.repeat(50_000))
|
|
236
|
+
mkdirSync(join(dir, 'notes'), { recursive: true })
|
|
237
|
+
writeFileSync(join(dir, 'notes', 'draft.md'), 'scratch')
|
|
238
|
+
|
|
239
|
+
const removed = tidySession('tidy-scratch')
|
|
240
|
+
|
|
241
|
+
expect(removed.sort()).toEqual(['analyzer-prompt.md', 'calls.txt', 'notes'])
|
|
242
|
+
for (const keep of ['meta.json', 'events.jsonl', 'report.md', '.analyzed', 'analyzer.log']) {
|
|
243
|
+
expect(existsSync(join(dir, keep))).toBe(true)
|
|
244
|
+
}
|
|
245
|
+
expect(existsSync(join(dir, 'notes'))).toBe(false)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test('keepPrompt spares the prompt — the failing case is when it matters', () => {
|
|
249
|
+
const dir = sessionDir('tidy-failed')
|
|
250
|
+
seed('tidy-failed', { ageMs: DAY, analyzed: true })
|
|
251
|
+
writeFileSync(join(dir, 'analyzer-prompt.md'), '# prompt')
|
|
252
|
+
writeFileSync(join(dir, 'scratch.json'), '{}')
|
|
253
|
+
|
|
254
|
+
expect(tidySession('tidy-failed', { keepPrompt: true })).toEqual(['scratch.json'])
|
|
255
|
+
expect(existsSync(join(dir, 'analyzer-prompt.md'))).toBe(true)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('a session with nothing to tidy is left exactly as it was', () => {
|
|
259
|
+
seed('tidy-clean', { ageMs: DAY, analyzed: true, bytes: 10 })
|
|
260
|
+
expect(tidySession('tidy-clean')).toEqual([])
|
|
261
|
+
expect(existsSync(sessionDir('tidy-clean'))).toBe(true)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test('a missing session directory is a no-op, not an error', () => {
|
|
265
|
+
expect(tidySession('tidy-absent')).toEqual([])
|
|
266
|
+
})
|
|
267
|
+
})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import type { RetentionBudget } from '../settings'
|
|
7
|
+
|
|
8
|
+
// Set the home before the paths singleton is captured (dynamic imports below).
|
|
9
|
+
process.env.ASTRALE_HOME = mkdtempSync(join(tmpdir(), 'astrale-tele-settings-'))
|
|
10
|
+
|
|
11
|
+
let retentionBudget: () => RetentionBudget
|
|
12
|
+
let telemetryEnabled: () => boolean
|
|
13
|
+
let DEFAULT_MAX_AGE_DAYS: number
|
|
14
|
+
let DEFAULT_MAX_BYTES: number
|
|
15
|
+
let configPath: string
|
|
16
|
+
|
|
17
|
+
const DAY = 24 * 60 * 60 * 1000
|
|
18
|
+
|
|
19
|
+
beforeAll(async () => {
|
|
20
|
+
const settings = await import('../settings')
|
|
21
|
+
retentionBudget = settings.retentionBudget
|
|
22
|
+
telemetryEnabled = settings.telemetryEnabled
|
|
23
|
+
DEFAULT_MAX_AGE_DAYS = settings.DEFAULT_MAX_AGE_DAYS
|
|
24
|
+
DEFAULT_MAX_BYTES = settings.DEFAULT_MAX_BYTES
|
|
25
|
+
configPath = join(process.env.ASTRALE_HOME!, 'config.json')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
function writeConfig(telemetry: unknown): void {
|
|
29
|
+
writeFileSync(configPath, JSON.stringify({ telemetry }))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
rmSync(configPath, { force: true })
|
|
34
|
+
delete process.env.ASTRALE_TELEMETRY
|
|
35
|
+
delete process.env.ASTRALE_TELEMETRY_MAX_AGE_DAYS
|
|
36
|
+
delete process.env.ASTRALE_TELEMETRY_MAX_BYTES
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
rmSync(configPath, { force: true })
|
|
41
|
+
delete process.env.ASTRALE_TELEMETRY_MAX_AGE_DAYS
|
|
42
|
+
delete process.env.ASTRALE_TELEMETRY_MAX_BYTES
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('retentionBudget', () => {
|
|
46
|
+
test('defaults when nothing is configured', () => {
|
|
47
|
+
expect(retentionBudget()).toEqual({
|
|
48
|
+
maxAgeMs: DEFAULT_MAX_AGE_DAYS * DAY,
|
|
49
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('reads both bounds from config.json', () => {
|
|
54
|
+
writeConfig({ maxAgeDays: 7, maxBytes: 1_048_576 })
|
|
55
|
+
expect(retentionBudget()).toEqual({ maxAgeMs: 7 * DAY, maxBytes: 1_048_576 })
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('env wins over config', () => {
|
|
59
|
+
writeConfig({ maxAgeDays: 7, maxBytes: 1_048_576 })
|
|
60
|
+
process.env.ASTRALE_TELEMETRY_MAX_AGE_DAYS = '2'
|
|
61
|
+
process.env.ASTRALE_TELEMETRY_MAX_BYTES = '4096'
|
|
62
|
+
expect(retentionBudget()).toEqual({ maxAgeMs: 2 * DAY, maxBytes: 4096 })
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('a bad env value falls through to config rather than unbounding the store', () => {
|
|
66
|
+
writeConfig({ maxAgeDays: 7 })
|
|
67
|
+
process.env.ASTRALE_TELEMETRY_MAX_AGE_DAYS = 'soon'
|
|
68
|
+
expect(retentionBudget().maxAgeMs).toBe(7 * DAY)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test.each([
|
|
72
|
+
['zero', 0],
|
|
73
|
+
['negative', -1],
|
|
74
|
+
['not a number', 'lots'],
|
|
75
|
+
['null', null],
|
|
76
|
+
])('%s config values fall back to the default', (_label, maxBytes) => {
|
|
77
|
+
writeConfig({ maxBytes })
|
|
78
|
+
expect(retentionBudget().maxBytes).toBe(DEFAULT_MAX_BYTES)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('a broken config yields defaults instead of throwing', () => {
|
|
82
|
+
writeFileSync(configPath, '{ not json')
|
|
83
|
+
expect(retentionBudget().maxBytes).toBe(DEFAULT_MAX_BYTES)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('telemetryEnabled', () => {
|
|
88
|
+
test('on by default, and unaffected by retention keys', () => {
|
|
89
|
+
writeConfig({ maxAgeDays: 7 })
|
|
90
|
+
expect(telemetryEnabled()).toBe(true)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('off via config', () => {
|
|
94
|
+
writeConfig({ enabled: false })
|
|
95
|
+
expect(telemetryEnabled()).toBe(false)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test.each(['0', 'false', 'off', 'OFF', ' off '])('off via ASTRALE_TELEMETRY=%p', (value) => {
|
|
99
|
+
process.env.ASTRALE_TELEMETRY = value
|
|
100
|
+
expect(telemetryEnabled()).toBe(false)
|
|
101
|
+
})
|
|
102
|
+
})
|