@astrale-os/cli 1.0.0-beta.26 → 1.0.0-beta.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -5
- package/dist/astrale.js +1201 -358
- package/package.json +3 -2
- package/src/commands/__tests__/domain-install-operation.test.ts +23 -0
- package/src/commands/__tests__/install-identity-override.test.ts +3 -3
- package/src/commands/domain/install.ts +19 -11
- package/src/commands/get.ts +1 -1
- package/src/commands/identity/register.ts +3 -4
- package/src/commands/logs.ts +2 -5
- package/src/commands/session/analyze.ts +26 -4
- package/src/commands/update.ts +61 -40
- package/src/lib/__tests__/skills.test.ts +809 -0
- package/src/lib/skills/lock.ts +117 -0
- package/src/lib/skills/sync.ts +814 -0
- package/src/lib/skills.ts +1 -52
- package/src/program/__tests__/program.test.ts +1 -1
- package/src/setup/steps/skills.ts +40 -38
- package/src/telemetry/__tests__/retention.test.ts +180 -0
- package/src/telemetry/__tests__/settings.test.ts +102 -0
- package/src/telemetry/__tests__/store-scan.test.ts +128 -0
- package/src/telemetry/__tests__/trigger.test.ts +33 -4
- package/src/telemetry/recorder.ts +6 -3
- package/src/telemetry/retention.ts +129 -0
- package/src/telemetry/session.ts +18 -12
- package/src/telemetry/settings.ts +64 -11
- package/src/telemetry/store.ts +87 -9
- package/src/telemetry/trigger.ts +16 -28
- package/studio/client/dist/assets/index-BFVFs_8x.js +81 -0
- package/studio/client/dist/assets/index-DuB9iUdu.css +2 -0
- package/studio/client/dist/assets/schema-studio--a0kX3QU.css +1 -0
- package/studio/client/dist/assets/schema-studio-DH6oEWME.js +8 -0
- package/studio/client/dist/index.html +3 -3
- package/studio/server/agent/prompts/anchors.test.ts +17 -4
- package/studio/server/agent/prompts/anchors.ts +3 -2
- package/studio/server/agent/prompts/system.test.ts +2 -3
- package/studio/server/agent/prompts/system.ts +3 -3
- package/studio/server/agent/run/preparation.ts +1 -1
- package/studio/server/api/context.ts +1 -1
- package/studio/server/api/deployment.ts +1 -1
- package/studio/server/api/views.ts +2 -2
- package/studio/server/api/workspace.ts +1 -1
- package/studio/server/cache.test.ts +3 -8
- package/studio/server/cache.ts +4 -45
- package/studio/server/cli-consumers.test.ts +31 -2
- package/studio/server/handoff/copy.ts +1 -1
- package/studio/server/introspect/canonical-schema.test.ts +154 -128
- package/studio/server/introspect/canonical-schema.ts +183 -302
- package/studio/server/introspect/core.ts +14 -21
- package/studio/server/introspect/extractor.ts +11 -15
- package/studio/server/introspect/overlay-tsmorph.test.ts +1 -5
- package/studio/server/introspect/overlay-tsmorph.ts +0 -1
- package/studio/server/introspect/overlay.ts +3 -24
- package/studio/server/introspect/revision.test.ts +24 -28
- package/studio/server/introspect/revision.ts +10 -21
- package/studio/server/introspect/runtime.test.ts +14 -14
- package/studio/server/introspect/runtime.ts +1 -46
- package/studio/server/lifecycle.ts +4 -1
- package/studio/server/state/documents.test.ts +69 -0
- package/studio/server/state/documents.ts +57 -10
- package/studio/server/workspace/updates.ts +23 -1
- package/studio/shared/contracts/runtime.ts +4 -0
- package/studio/shared/contracts/schema.ts +10 -26
- package/studio/shared/contracts/surface.test.ts +2 -2
- package/studio/shared/contracts/workspace.ts +3 -0
- package/studio/shared/schema/identity.ts +0 -1
- package/tsconfig.json +1 -1
- package/studio/client/dist/assets/index-C0FAnwNw.css +0 -2
- package/studio/client/dist/assets/index-OeJK1TjG.js +0 -81
- package/studio/client/dist/assets/schema-studio-BilUNb6Z.css +0 -1
- package/studio/client/dist/assets/schema-studio-DRdfu_cQ.js +0 -8
- package/studio/server/introspect/core-extractor.ts +0 -30
- package/studio/server/introspect/overlay.test.ts +0 -44
- package/studio/server/introspect/source-overlay/annotations.ts +0 -14
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
/** Bun subprocess that imports only the authored Schema entry and its installed SDK. */
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
projectCanonicalSchema,
|
|
6
|
-
} from './canonical-schema'
|
|
2
|
+
import type { SchemaSdk } from './canonical-schema'
|
|
3
|
+
|
|
4
|
+
import { extractCanonicalSchemaFromSdk, findCanonicalDomainSchemaExport } from './canonical-schema'
|
|
7
5
|
|
|
8
6
|
const schemaPath = process.argv[2]
|
|
9
7
|
const projectRoot = process.argv[3] ?? process.cwd()
|
|
@@ -15,19 +13,17 @@ async function main(): Promise<void> {
|
|
|
15
13
|
if (candidate === null) {
|
|
16
14
|
throw new Error('Schema entry exports no canonical V1 DomainSchema.')
|
|
17
15
|
}
|
|
18
|
-
const sdk = (await import(
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const admission = admitCanonicalSchemaFromSdk(sdk, candidate)
|
|
23
|
-
const projected = projectCanonicalSchema(admission.root, admission.closure)
|
|
16
|
+
const sdk = (await import(
|
|
17
|
+
Bun.resolveSync('@astrale-os/sdk/schema', projectRoot)
|
|
18
|
+
)) as unknown as SchemaSdk
|
|
19
|
+
const extraction = extractCanonicalSchemaFromSdk(sdk, candidate)
|
|
24
20
|
process.stdout.write(
|
|
25
21
|
JSON.stringify({
|
|
26
22
|
ok: true,
|
|
27
|
-
ir:
|
|
28
|
-
root:
|
|
29
|
-
schemaMode:
|
|
30
|
-
revision:
|
|
23
|
+
ir: extraction.ir,
|
|
24
|
+
root: extraction.root,
|
|
25
|
+
schemaMode: extraction.status === 'admitted' ? 'canonical-admitted' : 'canonical-preview',
|
|
26
|
+
revision: extraction.revision,
|
|
31
27
|
}),
|
|
32
28
|
)
|
|
33
29
|
}
|
|
@@ -5,7 +5,7 @@ import { join } from 'node:path'
|
|
|
5
5
|
|
|
6
6
|
import type { SchemaIR } from '../../shared/types'
|
|
7
7
|
|
|
8
|
-
import { buildHandlerLinks,
|
|
8
|
+
import { buildHandlerLinks, buildSourceSpans } from './overlay-tsmorph'
|
|
9
9
|
|
|
10
10
|
const roots: string[] = []
|
|
11
11
|
afterEach(() => {
|
|
@@ -139,8 +139,4 @@ describe('source overlay', () => {
|
|
|
139
139
|
expect(spans['edge.assigned_to.property.note']?.file).toBe('schema/members.ts')
|
|
140
140
|
expect(spans['function.createIssue']?.file).toBe('schema/members.ts')
|
|
141
141
|
})
|
|
142
|
-
|
|
143
|
-
test('does not invent warnings for admitted enum values', () => {
|
|
144
|
-
expect(buildSchemaAnnotations({ ir })).toEqual([])
|
|
145
|
-
})
|
|
146
142
|
})
|
|
@@ -4,6 +4,5 @@
|
|
|
4
4
|
* Keep this path stable for the overlay composer and focused tests. The
|
|
5
5
|
* implementation lives under source-overlay, split by responsibility.
|
|
6
6
|
*/
|
|
7
|
-
export { buildSchemaAnnotations } from './source-overlay/annotations'
|
|
8
7
|
export { buildHandlerLinks } from './source-overlay/handlers'
|
|
9
8
|
export { buildSourceSpans } from './source-overlay/spans'
|
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* carry. The import split and dependency list are computed here; handlerLinks,
|
|
4
|
-
* static parse). handlerLinks, sourceSpans (+JSDoc) and annotations are filled
|
|
5
|
-
* by a ts-morph pass over current Schema, Runtime, Action, and Workflow files.
|
|
2
|
+
* Source-only metadata that the admitted DSL Schema cannot carry.
|
|
6
3
|
*/
|
|
7
|
-
import type {
|
|
4
|
+
import type { SchemaIR, SchemaOverlay } from '../../shared/types'
|
|
8
5
|
|
|
9
|
-
import { buildHandlerLinks,
|
|
6
|
+
import { buildHandlerLinks, buildSourceSpans } from './overlay-tsmorph'
|
|
10
7
|
|
|
11
8
|
export interface OverlayArgs {
|
|
12
9
|
ir: SchemaIR | null
|
|
@@ -15,26 +12,8 @@ export interface OverlayArgs {
|
|
|
15
12
|
}
|
|
16
13
|
|
|
17
14
|
export function buildOverlay({ ir, domainRoot, schemaDir }: OverlayArgs): SchemaOverlay {
|
|
18
|
-
const origin = ir?.domain ?? ''
|
|
19
|
-
const mixins: CrossDomainImport[] = []
|
|
20
|
-
const crossDomainImports: CrossDomainImport[] = []
|
|
21
|
-
for (const descriptor of Object.values(ir?.importsByKey ?? {})) {
|
|
22
|
-
const entry: CrossDomainImport = {
|
|
23
|
-
name: descriptor.ref.name,
|
|
24
|
-
origin: descriptor.origin,
|
|
25
|
-
ref: descriptor.ref,
|
|
26
|
-
}
|
|
27
|
-
if (descriptor.origin === 'kernel.astrale.ai') mixins.push(entry)
|
|
28
|
-
else crossDomainImports.push(entry)
|
|
29
|
-
}
|
|
30
|
-
|
|
31
15
|
return {
|
|
32
|
-
origin,
|
|
33
|
-
requires: (ir?.dependencies ?? []).map(({ origin }) => origin),
|
|
34
|
-
crossDomainImports,
|
|
35
|
-
mixins,
|
|
36
16
|
handlerLinks: buildHandlerLinks({ ir, domainRoot }),
|
|
37
17
|
sourceSpans: buildSourceSpans({ ir, schemaDir }),
|
|
38
|
-
annotations: buildSchemaAnnotations({ ir }),
|
|
39
18
|
}
|
|
40
19
|
}
|
|
@@ -1,53 +1,49 @@
|
|
|
1
|
+
import * as sdk from '@astrale-os/sdk/schema'
|
|
2
|
+
import { defineSchema } from '@astrale-os/sdk/schema'
|
|
1
3
|
import { describe, expect, test } from 'bun:test'
|
|
2
4
|
|
|
3
5
|
import { admittedBundleRevisionFromSdk } from './revision'
|
|
4
6
|
|
|
5
|
-
const
|
|
7
|
+
const schema = defineSchema('notes.example.dev', {})
|
|
8
|
+
const wire = JSON.parse(JSON.stringify(sdk.bundle.create(schema)))
|
|
6
9
|
|
|
7
10
|
describe('installed Bundle revision', () => {
|
|
8
|
-
test('uses
|
|
11
|
+
test('uses Bundle admission and the resolved Domain identity', () => {
|
|
9
12
|
const calls: string[] = []
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
const sdk = {
|
|
13
|
+
const wrapped = {
|
|
14
|
+
...sdk,
|
|
13
15
|
bundle: {
|
|
16
|
+
...sdk.bundle,
|
|
14
17
|
accept(value: unknown) {
|
|
15
18
|
calls.push('bundle.accept')
|
|
16
|
-
|
|
17
|
-
return { root, closure: [] }
|
|
19
|
+
return sdk.bundle.accept(value)
|
|
18
20
|
},
|
|
19
21
|
},
|
|
20
22
|
schema: {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
return
|
|
23
|
+
...sdk.schema,
|
|
24
|
+
resolve(value: typeof schema) {
|
|
25
|
+
calls.push('schema.resolve')
|
|
26
|
+
return sdk.schema.resolve(value)
|
|
25
27
|
},
|
|
26
28
|
},
|
|
27
|
-
}
|
|
29
|
+
} as unknown as typeof sdk
|
|
28
30
|
|
|
29
|
-
expect(admittedBundleRevisionFromSdk(
|
|
30
|
-
expect(calls).toEqual(['bundle.accept', 'schema.
|
|
31
|
+
expect(admittedBundleRevisionFromSdk(wrapped, wire)).toBe(sdk.schema.revision(schema))
|
|
32
|
+
expect(calls).toEqual(['bundle.accept', 'schema.resolve'])
|
|
31
33
|
})
|
|
32
34
|
|
|
33
|
-
test('fails closed when
|
|
34
|
-
expect(
|
|
35
|
-
admittedBundleRevisionFromSdk(
|
|
36
|
-
{ bundle: { accept: () => ({ root: {} }) }, schema: { revision: () => 'sha-short' } },
|
|
37
|
-
{},
|
|
38
|
-
),
|
|
39
|
-
).toBeNull()
|
|
35
|
+
test('fails closed when Bundle admission or the resolved identity is invalid', () => {
|
|
36
|
+
expect(admittedBundleRevisionFromSdk({} as typeof sdk, {})).toBeNull()
|
|
40
37
|
expect(
|
|
41
38
|
admittedBundleRevisionFromSdk(
|
|
42
39
|
{
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
},
|
|
40
|
+
...sdk,
|
|
41
|
+
schema: {
|
|
42
|
+
...sdk.schema,
|
|
43
|
+
resolve: () => ({ source: schema, revision: 'sha-short' }),
|
|
47
44
|
},
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
{},
|
|
45
|
+
} as unknown as typeof sdk,
|
|
46
|
+
wire,
|
|
51
47
|
),
|
|
52
48
|
).toBeNull()
|
|
53
49
|
})
|
|
@@ -1,28 +1,23 @@
|
|
|
1
1
|
import type { SchemaRevision } from '../../shared/types'
|
|
2
|
+
import type { SchemaAdmissionSdk } from './canonical-schema'
|
|
2
3
|
|
|
3
4
|
import { isSchemaRevision } from '../../shared/types'
|
|
4
5
|
|
|
5
|
-
type AnyRecord = Record<string, unknown>
|
|
6
|
-
|
|
7
6
|
/**
|
|
8
7
|
* Cross the installed-bundle JSON boundary through the Domain's installed SDK, then
|
|
9
8
|
* ask the DSL owner for the root revision. Invalid/unavailable bundles return
|
|
10
9
|
* null so Studio reports unknown drift instead of inventing an identity.
|
|
11
10
|
*/
|
|
12
11
|
export function admittedBundleRevisionFromSdk(
|
|
13
|
-
|
|
12
|
+
sdk: SchemaAdmissionSdk,
|
|
14
13
|
input: unknown,
|
|
15
14
|
): SchemaRevision | null {
|
|
16
|
-
const bundleApi = asRecord(sdkModule.bundle)
|
|
17
|
-
const schemaApi = asRecord(sdkModule.schema)
|
|
18
|
-
if (typeof bundleApi?.accept !== 'function' || typeof schemaApi?.revision !== 'function') {
|
|
19
|
-
return null
|
|
20
|
-
}
|
|
21
15
|
try {
|
|
22
|
-
const accepted =
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
16
|
+
const accepted = sdk.bundle.accept(input)
|
|
17
|
+
const domain = sdk.schema.resolve(accepted.root)
|
|
18
|
+
return domain.source === accepted.root && isSchemaRevision(domain.revision)
|
|
19
|
+
? domain.revision
|
|
20
|
+
: null
|
|
26
21
|
} catch {
|
|
27
22
|
return null
|
|
28
23
|
}
|
|
@@ -33,17 +28,11 @@ export async function admittedBundleRevision(
|
|
|
33
28
|
input: unknown,
|
|
34
29
|
): Promise<SchemaRevision | null> {
|
|
35
30
|
try {
|
|
36
|
-
const
|
|
31
|
+
const sdk = (await import(
|
|
37
32
|
Bun.resolveSync('@astrale-os/sdk/schema', domainRoot)
|
|
38
|
-
)
|
|
39
|
-
return admittedBundleRevisionFromSdk(
|
|
33
|
+
)) as unknown as SchemaAdmissionSdk
|
|
34
|
+
return admittedBundleRevisionFromSdk(sdk, input)
|
|
40
35
|
} catch {
|
|
41
36
|
return null
|
|
42
37
|
}
|
|
43
38
|
}
|
|
44
|
-
|
|
45
|
-
function asRecord(value: unknown): AnyRecord | null {
|
|
46
|
-
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
47
|
-
? (value as AnyRecord)
|
|
48
|
-
: null
|
|
49
|
-
}
|
|
@@ -6,9 +6,10 @@ import { join } from 'node:path'
|
|
|
6
6
|
import type { DomainHandle } from '../domain'
|
|
7
7
|
|
|
8
8
|
import { buildBundle } from './bundle'
|
|
9
|
+
import { buildCore } from './core'
|
|
9
10
|
import { renderFingerprintOf } from './hash'
|
|
10
11
|
import { admittedBundleRevision } from './revision'
|
|
11
|
-
import {
|
|
12
|
+
import { runtimeExtract } from './runtime'
|
|
12
13
|
|
|
13
14
|
const roots: string[] = []
|
|
14
15
|
afterEach(() => {
|
|
@@ -128,19 +129,18 @@ describe('SDK V1 schema extractor', () => {
|
|
|
128
129
|
|
|
129
130
|
test('extracts Core through the pure Schema entry without importing Application', async () => {
|
|
130
131
|
const handle = currentFixture()
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
},
|
|
132
|
+
const bundle = await buildBundle(handle)
|
|
133
|
+
expect(buildCore(handle, bundle)).toMatchObject({
|
|
134
|
+
domain: 'documents.runtime.test',
|
|
135
|
+
nodes: [
|
|
136
|
+
{
|
|
137
|
+
path: '/:documents.runtime.test:core.welcome',
|
|
138
|
+
className: 'Document',
|
|
139
|
+
data: {},
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
edges: [],
|
|
143
|
+
error: null,
|
|
144
144
|
})
|
|
145
145
|
})
|
|
146
146
|
})
|
|
@@ -5,12 +5,11 @@
|
|
|
5
5
|
* render IR plus the raw canonical root (when present), or an error render-state
|
|
6
6
|
* — never throws.
|
|
7
7
|
*/
|
|
8
|
-
import type { SchemaIR, SchemaRevision,
|
|
8
|
+
import type { SchemaIR, SchemaRevision, StudioSchemaBundle } from '../../shared/types'
|
|
9
9
|
|
|
10
10
|
import { isSchemaRevision } from '../../shared/types'
|
|
11
11
|
|
|
12
12
|
const EXTRACTOR = new URL('./extractor.ts', import.meta.url).pathname
|
|
13
|
-
const CORE_EXTRACTOR = new URL('./core-extractor.ts', import.meta.url).pathname
|
|
14
13
|
|
|
15
14
|
export interface RuntimeExtractResult {
|
|
16
15
|
ok: boolean
|
|
@@ -94,47 +93,3 @@ export async function runtimeExtract(
|
|
|
94
93
|
}
|
|
95
94
|
}
|
|
96
95
|
}
|
|
97
|
-
|
|
98
|
-
export interface CoreExtractResult {
|
|
99
|
-
ok: boolean
|
|
100
|
-
/** the resolved core graph, or null when the domain defines no core */
|
|
101
|
-
core: Pick<StudioCore, 'domain' | 'nodes' | 'edges'> | null
|
|
102
|
-
error?: { message: string }
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Spawn the Core extractor over the pure Schema entry. Returns the projected
|
|
107
|
-
* graph or an error and never imports Application or Runtime modules.
|
|
108
|
-
*/
|
|
109
|
-
export async function coreExtract(
|
|
110
|
-
schemaIndexPath: string,
|
|
111
|
-
domainDir: string,
|
|
112
|
-
timeoutMs = 20000,
|
|
113
|
-
): Promise<CoreExtractResult> {
|
|
114
|
-
try {
|
|
115
|
-
const proc = Bun.spawn(['bun', 'run', CORE_EXTRACTOR, schemaIndexPath, domainDir], {
|
|
116
|
-
cwd: domainDir,
|
|
117
|
-
stdout: 'pipe',
|
|
118
|
-
stderr: 'pipe',
|
|
119
|
-
})
|
|
120
|
-
const timer = setTimeout(() => proc.kill(9), timeoutMs)
|
|
121
|
-
const out = await new Response(proc.stdout).text()
|
|
122
|
-
await proc.exited
|
|
123
|
-
clearTimeout(timer)
|
|
124
|
-
|
|
125
|
-
if (!out.trim()) {
|
|
126
|
-
const err = await new Response(proc.stderr).text()
|
|
127
|
-
return {
|
|
128
|
-
ok: false,
|
|
129
|
-
core: null,
|
|
130
|
-
error: { message: err.trim() || 'core extractor produced no output' },
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
const parsed = JSON.parse(out)
|
|
134
|
-
if (!parsed.ok)
|
|
135
|
-
return { ok: false, core: null, error: parsed.error ?? { message: 'core extraction failed' } }
|
|
136
|
-
return { ok: true, core: (parsed.core ?? null) as CoreExtractResult['core'] }
|
|
137
|
-
} catch (e: any) {
|
|
138
|
-
return { ok: false, core: null, error: { message: String(e?.message ?? e) } }
|
|
139
|
-
}
|
|
140
|
-
}
|
|
@@ -9,6 +9,7 @@ import type { DomainHandle } from './domain'
|
|
|
9
9
|
*/
|
|
10
10
|
import { getBundle } from './cache'
|
|
11
11
|
import { captureBaseline, hashAnatomyFiles, loadBaseline } from './state/baseline'
|
|
12
|
+
import { migrateDocuments } from './state/documents'
|
|
12
13
|
import { initDotDir } from './state/store'
|
|
13
14
|
import { watchDomain } from './watch'
|
|
14
15
|
|
|
@@ -22,6 +23,8 @@ export interface BootedDomain {
|
|
|
22
23
|
/** Initialize + start watching one domain. Returns its origin + a stop handle. */
|
|
23
24
|
export async function bootDomain(handle: DomainHandle): Promise<BootedDomain> {
|
|
24
25
|
initDotDir(handle.root)
|
|
26
|
+
// one-shot: uuid-named documents become readable file names under context/docs
|
|
27
|
+
migrateDocuments(handle.root)
|
|
25
28
|
const bundle = await getBundle(handle.id)
|
|
26
29
|
if (!loadBaseline(handle.root))
|
|
27
30
|
captureBaseline(
|
|
@@ -35,7 +38,7 @@ export async function bootDomain(handle: DomainHandle): Promise<BootedDomain> {
|
|
|
35
38
|
)
|
|
36
39
|
const stop = watchDomain(handle)
|
|
37
40
|
return {
|
|
38
|
-
origin: bundle?.
|
|
41
|
+
origin: bundle?.ir?.domain ?? handle.origin ?? handle.id,
|
|
39
42
|
depsInstalled: !!bundle?.depsInstalled,
|
|
40
43
|
stop,
|
|
41
44
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { afterEach, expect, test } from 'bun:test'
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { addDocument, listDocuments, migrateDocuments, readDocument } from './documents'
|
|
7
|
+
import { statePath, writeJson, writeStateBuffer } from './store'
|
|
8
|
+
|
|
9
|
+
const roots: string[] = []
|
|
10
|
+
|
|
11
|
+
function root(): string {
|
|
12
|
+
const created = mkdtempSync(join(tmpdir(), 'studio-documents-'))
|
|
13
|
+
roots.push(created)
|
|
14
|
+
return created
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const bytes = (text: string) => new TextEncoder().encode(text)
|
|
22
|
+
|
|
23
|
+
test('a document is stored under a readable name, not its id', () => {
|
|
24
|
+
const domain = root()
|
|
25
|
+
const meta = addDocument(domain, 'Pricing Decisions.MD', 'text/markdown', bytes('# pricing'))
|
|
26
|
+
|
|
27
|
+
expect(meta.stored).toBe('context/docs/pricing-decisions.md')
|
|
28
|
+
expect(readFileSync(statePath(domain, meta.stored), 'utf8')).toBe('# pricing')
|
|
29
|
+
expect(readDocument(domain, meta.id)?.meta.name).toBe('Pricing Decisions.MD')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('same-named documents never overwrite each other', () => {
|
|
33
|
+
const domain = root()
|
|
34
|
+
const first = addDocument(domain, 'notes.md', 'text/markdown', bytes('first'))
|
|
35
|
+
const second = addDocument(domain, 'notes.md', 'text/markdown', bytes('second'))
|
|
36
|
+
|
|
37
|
+
expect([first.stored, second.stored]).toEqual([
|
|
38
|
+
'context/docs/notes.md',
|
|
39
|
+
'context/docs/notes-2.md',
|
|
40
|
+
])
|
|
41
|
+
expect(readFileSync(statePath(domain, first.stored), 'utf8')).toBe('first')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('uuid-named documents are migrated in place, once', () => {
|
|
45
|
+
const domain = root()
|
|
46
|
+
const legacy = 'context/documents/2b0d9d7e-1f2a-4a10-9f0c-1a2b3c4d5e6f.md'
|
|
47
|
+
writeStateBuffer(domain, legacy, bytes('legacy body'))
|
|
48
|
+
writeJson(domain, 'context/documents/index.json', [
|
|
49
|
+
{
|
|
50
|
+
id: '2b0d9d7e-1f2a-4a10-9f0c-1a2b3c4d5e6f',
|
|
51
|
+
name: 'Meeting notes.md',
|
|
52
|
+
type: 'text/markdown',
|
|
53
|
+
size: 11,
|
|
54
|
+
addedAt: new Date(0).toISOString(),
|
|
55
|
+
stored: legacy,
|
|
56
|
+
},
|
|
57
|
+
])
|
|
58
|
+
|
|
59
|
+
migrateDocuments(domain)
|
|
60
|
+
const [migrated] = listDocuments(domain)
|
|
61
|
+
|
|
62
|
+
expect(migrated?.stored).toBe('context/docs/meeting-notes.md')
|
|
63
|
+
expect(readFileSync(statePath(domain, migrated!.stored), 'utf8')).toBe('legacy body')
|
|
64
|
+
expect(existsSync(statePath(domain, legacy))).toBe(false)
|
|
65
|
+
|
|
66
|
+
// idempotent: a second boot leaves the already-migrated store alone
|
|
67
|
+
migrateDocuments(domain)
|
|
68
|
+
expect(listDocuments(domain)[0]?.stored).toBe('context/docs/meeting-notes.md')
|
|
69
|
+
})
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* documents.ts — context DOCUMENTS the user drops in for the AI agent. Files
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* documents.ts — context DOCUMENTS the user drops in for the AI agent. Files live
|
|
3
|
+
* under `.domain-studio/context/docs/`, named after the document (not its id), so
|
|
4
|
+
* the folder is readable by a human and by the agent that is handed its path.
|
|
5
|
+
* They travel with the domain and are part of the agent handoff context.
|
|
5
6
|
*/
|
|
6
7
|
import { randomUUID } from 'node:crypto'
|
|
7
|
-
import { existsSync } from 'node:fs'
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
8
9
|
import { extname } from 'node:path'
|
|
9
10
|
|
|
10
11
|
import type { DocMeta } from '../../shared/types'
|
|
@@ -13,7 +14,54 @@ import { asFiniteNumber, asJsonRecord, asString } from '../json'
|
|
|
13
14
|
import { readJson, removeState, statePath, writeJson, writeStateBuffer } from './store'
|
|
14
15
|
|
|
15
16
|
const INDEX = 'context/documents/index.json'
|
|
16
|
-
const
|
|
17
|
+
const DIR = 'context/docs'
|
|
18
|
+
/** Where documents lived before they were named: `context/documents/<uuid>.<ext>`. */
|
|
19
|
+
const LEGACY_DIR = 'context/documents'
|
|
20
|
+
|
|
21
|
+
/** `Pricing decisions.md` → `pricing-decisions` — a file name you can read. */
|
|
22
|
+
function slugify(name: string): string {
|
|
23
|
+
const base = name.replace(/\.[^.]+$/, '')
|
|
24
|
+
const slug = base
|
|
25
|
+
.normalize('NFKD')
|
|
26
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
27
|
+
.toLowerCase()
|
|
28
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
29
|
+
.replace(/^-+|-+$/g, '')
|
|
30
|
+
.slice(0, 60)
|
|
31
|
+
return slug || 'document'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A stored path no other document (and no file on disk) already claims. */
|
|
35
|
+
function uniqueStoredPath(root: string, docs: DocMeta[], name: string): string {
|
|
36
|
+
const extension = extname(name).toLowerCase()
|
|
37
|
+
const slug = slugify(name)
|
|
38
|
+
const taken = new Set(docs.map((doc) => doc.stored))
|
|
39
|
+
for (let attempt = 0; ; attempt++) {
|
|
40
|
+
const candidate = `${DIR}/${slug}${attempt === 0 ? '' : `-${attempt + 1}`}${extension}`
|
|
41
|
+
if (!taken.has(candidate) && !existsSync(statePath(root, candidate))) return candidate
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Move documents written under the old uuid-named layout into `context/docs/`.
|
|
47
|
+
* Idempotent and only ever renames inside the studio's own state directory.
|
|
48
|
+
*/
|
|
49
|
+
export function migrateDocuments(root: string): void {
|
|
50
|
+
const docs = listDocuments(root)
|
|
51
|
+
if (!docs.some((doc) => doc.stored.startsWith(`${LEGACY_DIR}/`))) return
|
|
52
|
+
let moved = false
|
|
53
|
+
for (const doc of docs) {
|
|
54
|
+
if (!doc.stored.startsWith(`${LEGACY_DIR}/`)) continue
|
|
55
|
+
const from = statePath(root, doc.stored)
|
|
56
|
+
if (!existsSync(from)) continue
|
|
57
|
+
const next = uniqueStoredPath(root, docs, doc.name)
|
|
58
|
+
writeStateBuffer(root, next, readFileSync(from))
|
|
59
|
+
removeState(root, doc.stored)
|
|
60
|
+
doc.stored = next
|
|
61
|
+
moved = true
|
|
62
|
+
}
|
|
63
|
+
if (moved) writeJson(root, INDEX, docs)
|
|
64
|
+
}
|
|
17
65
|
|
|
18
66
|
function decodeDocument(value: unknown): DocMeta | undefined {
|
|
19
67
|
const record = asJsonRecord(value)
|
|
@@ -30,7 +78,7 @@ function decodeDocument(value: unknown): DocMeta | undefined {
|
|
|
30
78
|
size === undefined ||
|
|
31
79
|
size < 0 ||
|
|
32
80
|
!addedAt ||
|
|
33
|
-
!stored?.startsWith(
|
|
81
|
+
!(stored?.startsWith(`${DIR}/`) || stored?.startsWith(`${LEGACY_DIR}/`)) ||
|
|
34
82
|
stored.split('/').includes('..')
|
|
35
83
|
) {
|
|
36
84
|
return undefined
|
|
@@ -60,18 +108,17 @@ export function listDocuments(root: string): DocMeta[] {
|
|
|
60
108
|
}
|
|
61
109
|
|
|
62
110
|
export function addDocument(root: string, name: string, type: string, data: Uint8Array): DocMeta {
|
|
63
|
-
const
|
|
64
|
-
const stored =
|
|
111
|
+
const docs = listDocuments(root)
|
|
112
|
+
const stored = uniqueStoredPath(root, docs, name || 'untitled')
|
|
65
113
|
writeStateBuffer(root, stored, data)
|
|
66
114
|
const meta: DocMeta = {
|
|
67
|
-
id,
|
|
115
|
+
id: randomUUID(),
|
|
68
116
|
name: name || 'untitled',
|
|
69
117
|
type: type || 'application/octet-stream',
|
|
70
118
|
size: data.byteLength,
|
|
71
119
|
addedAt: new Date().toISOString(),
|
|
72
120
|
stored,
|
|
73
121
|
}
|
|
74
|
-
const docs = listDocuments(root)
|
|
75
122
|
docs.unshift(meta)
|
|
76
123
|
writeJson(root, INDEX, docs)
|
|
77
124
|
return meta
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* We don't reimplement staleness: the `astrale` CLI owns it. `astrale update
|
|
5
5
|
* --check --json`, run in the DOMAIN ROOT (so its @astrale-os/* SDK-dep axis sees
|
|
6
|
-
* THIS project), emits a unified `{ stale, cli, sdk }` report. We run it with
|
|
6
|
+
* THIS project), emits a unified `{ stale, cli, skills, sdk }` report. We run it with
|
|
7
7
|
* stdout piped and read it regardless of exit code — a stale result exits 10 by
|
|
8
8
|
* design, which is not an error here. Best-effort: anything unexpected (no
|
|
9
9
|
* bad output) collapses to "nothing stale" so the badge stays
|
|
@@ -16,6 +16,7 @@ import { decodeJsonObject, runStudioCliJson, runStudioCliText } from '../cli'
|
|
|
16
16
|
const NOT_STALE: StaleReport = {
|
|
17
17
|
stale: false,
|
|
18
18
|
cli: { stale: false, managed: true },
|
|
19
|
+
skills: { status: 'current' },
|
|
19
20
|
sdk: { stale: false, inProject: false, outdated: [] },
|
|
20
21
|
}
|
|
21
22
|
|
|
@@ -30,6 +31,7 @@ export async function getUpdates(root: string): Promise<StaleReport> {
|
|
|
30
31
|
function decodeStaleReport(value: unknown): StaleReport | null {
|
|
31
32
|
const report = decodeJsonObject(value)
|
|
32
33
|
const cli = decodeJsonObject(report?.cli)
|
|
34
|
+
const skills = decodeJsonObject(report?.skills)
|
|
33
35
|
const sdk = decodeJsonObject(report?.sdk)
|
|
34
36
|
if (
|
|
35
37
|
typeof report?.stale !== 'boolean' ||
|
|
@@ -50,6 +52,22 @@ function decodeStaleReport(value: unknown): StaleReport | null {
|
|
|
50
52
|
: []
|
|
51
53
|
})
|
|
52
54
|
if (outdated.length !== sdk.outdated.length) return null
|
|
55
|
+
const skillStatuses = new Set([
|
|
56
|
+
'current',
|
|
57
|
+
'update-available',
|
|
58
|
+
'repair-needed',
|
|
59
|
+
'unavailable',
|
|
60
|
+
'skipped',
|
|
61
|
+
])
|
|
62
|
+
// Additive compatibility: Studio can briefly run a newer server against an
|
|
63
|
+
// older CLI report while a binary update is being applied.
|
|
64
|
+
const skillStatus =
|
|
65
|
+
skills === null || skills === undefined
|
|
66
|
+
? 'current'
|
|
67
|
+
: typeof skills.status === 'string' && skillStatuses.has(skills.status)
|
|
68
|
+
? (skills.status as StaleReport['skills']['status'])
|
|
69
|
+
: null
|
|
70
|
+
if (skillStatus === null) return null
|
|
53
71
|
return {
|
|
54
72
|
stale: report.stale,
|
|
55
73
|
cli: {
|
|
@@ -59,6 +77,10 @@ function decodeStaleReport(value: unknown): StaleReport | null {
|
|
|
59
77
|
...(typeof cli.latest === 'string' ? { latest: cli.latest } : {}),
|
|
60
78
|
...(typeof cli.channel === 'string' ? { channel: cli.channel } : {}),
|
|
61
79
|
},
|
|
80
|
+
skills: {
|
|
81
|
+
status: skillStatus,
|
|
82
|
+
...(typeof skills?.error === 'string' ? { error: skills.error } : {}),
|
|
83
|
+
},
|
|
62
84
|
sdk: { stale: sdk.stale, inProject: sdk.inProject, outdated },
|
|
63
85
|
}
|
|
64
86
|
}
|
|
@@ -42,6 +42,10 @@ export interface StudioSettings {
|
|
|
42
42
|
export interface StaleReport {
|
|
43
43
|
stale: boolean
|
|
44
44
|
cli: { stale: boolean; managed: boolean; current?: string; latest?: string; channel?: string }
|
|
45
|
+
skills: {
|
|
46
|
+
status: 'current' | 'update-available' | 'repair-needed' | 'unavailable' | 'skipped'
|
|
47
|
+
error?: string
|
|
48
|
+
}
|
|
45
49
|
sdk: {
|
|
46
50
|
stale: boolean
|
|
47
51
|
inProject: boolean
|