@astrale-os/cli 0.8.1-alpha.6 → 1.0.0-beta.0

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.
Files changed (98) hide show
  1. package/README.md +18 -5
  2. package/dist/astrale.js +2661 -2714
  3. package/dist/public/connect-core.js +2019 -3050
  4. package/dist/public/keys/index.js +1851 -2885
  5. package/dist/public/paths/index.js +1830 -2872
  6. package/dist/types/connection/auth.d.ts +3 -0
  7. package/dist/types/lib/instance.d.ts +10 -0
  8. package/package.json +9 -9
  9. package/src/commands/__tests__/domain-install-operation.test.ts +121 -0
  10. package/src/commands/__tests__/domain-install-owned.test.ts +66 -0
  11. package/src/commands/__tests__/domain-uninstall.test.ts +53 -0
  12. package/src/commands/__tests__/install-direct.test.ts +3 -2
  13. package/src/commands/__tests__/install-identity-override.test.ts +14 -3
  14. package/src/commands/__tests__/instance-bookmark.test.ts +66 -1
  15. package/src/commands/__tests__/instance-list-rows.test.ts +1 -0
  16. package/src/commands/__tests__/instance-use.test.ts +67 -0
  17. package/src/commands/__tests__/view-build.test.ts +58 -0
  18. package/src/commands/domain/install.ts +84 -20
  19. package/src/commands/domain/uninstall.ts +128 -0
  20. package/src/commands/instance/active.ts +13 -1
  21. package/src/commands/instance/bookmark.ts +26 -3
  22. package/src/commands/instance/list.ts +18 -4
  23. package/src/commands/instance/use.ts +54 -7
  24. package/src/commands/view.ts +28 -16
  25. package/src/connection/.spec/architecture.md +5 -0
  26. package/src/connection/.spec/laws/connection.ts +20 -0
  27. package/src/connection/.spec/layout.ts +1 -0
  28. package/src/connection/__tests__/auth.test.ts +27 -1
  29. package/src/connection/__tests__/ca-fetch.test.ts +8 -1
  30. package/src/connection/__tests__/errors.test.ts +483 -33
  31. package/src/connection/__tests__/exchange.test.ts +46 -5
  32. package/src/connection/__tests__/reasons.test.ts +78 -0
  33. package/src/connection/auth.ts +11 -9
  34. package/src/connection/command.ts +9 -1
  35. package/src/connection/errors.ts +149 -159
  36. package/src/connection/exchange.ts +14 -2
  37. package/src/connection/index.ts +1 -1
  38. package/src/connection/reasons.ts +193 -0
  39. package/src/lib/__tests__/instance.test.ts +51 -1
  40. package/src/lib/__tests__/view-assets.test.ts +33 -1
  41. package/src/lib/__tests__/view-server.test.ts +68 -0
  42. package/src/lib/ca-fetch.ts +9 -3
  43. package/src/lib/instance.ts +31 -0
  44. package/src/lib/view/assets.ts +16 -2
  45. package/src/program/__tests__/program.test.ts +2 -1
  46. package/src/program/build.ts +2 -1
  47. package/studio/client/dist/assets/{elk-api-D0cBetPW.js → elk-api-D2xgMJvi.js} +1 -1
  48. package/studio/client/dist/assets/{index-BQnJ5sgd.css → index-BMdnsIJA.css} +1 -1
  49. package/studio/client/dist/assets/index-D-vRV8w7.js +8 -0
  50. package/studio/client/dist/assets/index-LGSWRrk8.js +81 -0
  51. package/studio/client/dist/index.html +2 -2
  52. package/studio/package.json +8 -9
  53. package/studio/server/agent/prompts/anchors.test.ts +74 -0
  54. package/studio/server/agent/prompts/anchors.ts +85 -15
  55. package/studio/server/agent/prompts/system.test.ts +12 -0
  56. package/studio/server/agent/prompts/system.ts +6 -6
  57. package/studio/server/api.ts +1 -5
  58. package/studio/server/cache.ts +5 -2
  59. package/studio/server/domain.test.ts +67 -0
  60. package/studio/server/domain.ts +29 -6
  61. package/studio/server/index.ts +1 -3
  62. package/studio/server/introspect/anatomy-extras.test.ts +104 -1
  63. package/studio/server/introspect/anatomy-extras.ts +340 -8
  64. package/studio/server/introspect/anatomy.test.ts +33 -0
  65. package/studio/server/introspect/anatomy.ts +22 -7
  66. package/studio/server/introspect/bundle.ts +7 -1
  67. package/studio/server/introspect/canonical-schema.test.ts +395 -0
  68. package/studio/server/introspect/canonical-schema.ts +751 -0
  69. package/studio/server/introspect/core-extractor.ts +30 -10
  70. package/studio/server/introspect/core.ts +4 -2
  71. package/studio/server/introspect/diff.test.ts +124 -0
  72. package/studio/server/introspect/diff.ts +252 -23
  73. package/studio/server/introspect/extractor.ts +46 -14
  74. package/studio/server/introspect/overlay-tsmorph.test.ts +164 -1
  75. package/studio/server/introspect/overlay-tsmorph.ts +381 -106
  76. package/studio/server/introspect/overlay.test.ts +72 -0
  77. package/studio/server/introspect/overlay.ts +16 -6
  78. package/studio/server/introspect/runtime.test.ts +217 -0
  79. package/studio/server/introspect/runtime.ts +18 -6
  80. package/studio/server/introspect/schema-refs.test.ts +100 -0
  81. package/studio/server/introspect/schema-refs.ts +23 -2
  82. package/studio/server/state/baseline.test.ts +51 -0
  83. package/studio/server/state/baseline.ts +31 -2
  84. package/studio/server/state/create.test.ts +37 -0
  85. package/studio/server/state/create.ts +21 -15
  86. package/studio/server/state/instance.test.ts +63 -0
  87. package/studio/server/state/instance.ts +65 -29
  88. package/studio/server/state/views.test.ts +209 -9
  89. package/studio/server/state/views.ts +176 -69
  90. package/studio/server/watch.test.ts +36 -0
  91. package/studio/server/watch.ts +24 -16
  92. package/studio/server/workspace-watch.ts +8 -3
  93. package/studio/shared/types.ts +164 -33
  94. package/viewer/dist/main.js +57 -57
  95. package/studio/client/dist/assets/index-Dspir4w7.js +0 -81
  96. package/studio/client/dist/assets/index-bVD2KJgz.js +0 -8
  97. package/studio/server/view-dev-server.test.ts +0 -111
  98. package/studio/server/view-dev-server.ts +0 -372
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Discovery and launches deliberately go through the public `astrale view`
5
5
  * and `astrale query` commands. The Studio therefore shares the CLI's real
6
- * View:resolve, identity, instance, delegation, proxy, and shell-handshake
7
- * behavior instead of maintaining a second preview implementation.
6
+ * View resolution, identity, instance, delegation, proxy, and Shell behavior
7
+ * instead of maintaining a second preview implementation.
8
8
  */
9
9
  import type {
10
10
  RememberedViewTarget,
@@ -16,7 +16,6 @@ import type {
16
16
  ViewTargetResult,
17
17
  } from '../../shared/types'
18
18
 
19
- import { ensureViewDevServer } from '../view-dev-server'
20
19
  import { activeInstanceName } from './instance'
21
20
  import { readJson, writeJson } from './store'
22
21
 
@@ -50,10 +49,6 @@ interface RawQueryResult {
50
49
  // commands and make the close complete before the next open allocates a port.
51
50
  let viewSessionCommandTail: Promise<void> = Promise.resolve()
52
51
 
53
- export function localViewUrl(serverUrl: string, mount: string): string {
54
- return new URL(mount.replace(/^\//, ''), `${new URL(serverUrl).origin}/`).toString()
55
- }
56
-
57
52
  export async function getViewRuntime(
58
53
  root: string,
59
54
  origin: string,
@@ -62,49 +57,70 @@ export async function getViewRuntime(
62
57
  timeoutMs: number,
63
58
  ): Promise<ViewRuntime> {
64
59
  const instance = await activeInstanceName()
65
- const serverPromise = !instance
66
- ? Promise.resolve({
67
- status: 'unavailable' as const,
68
- reason: 'Choose an Astrale instance before opening a local view.',
69
- })
70
- : view.mount
71
- ? ensureViewDevServer(root)
72
- : Promise.resolve({
73
- status: 'unavailable' as const,
74
- reason: 'This view has no local SPA mount.',
75
- })
76
-
77
- const targetRequired = viewClasses(view).length > 0
78
- const targetsPromise = targetRequired
60
+ const targetRequired = viewDefinitionBindings(origin, view, bundle).length > 0
61
+ const targets = targetRequired
79
62
  ? instance
80
- ? listViewTargets(root, origin, view, bundle, instance, timeoutMs)
81
- : Promise.resolve<ViewTargetResult>({
63
+ ? await listViewTargets(root, origin, view, bundle, instance, timeoutMs)
64
+ : ({
82
65
  status: 'unavailable',
83
66
  items: [],
84
67
  selected: null,
85
68
  stale: null,
86
69
  truncated: false,
87
70
  reason: 'No active Astrale instance.',
88
- })
89
- : Promise.resolve<ViewTargetResult>({
71
+ } satisfies ViewTargetResult)
72
+ : ({
90
73
  status: 'available',
91
74
  items: [],
92
75
  selected: null,
93
76
  stale: null,
94
77
  truncated: false,
95
- })
96
-
97
- const [server, targets] = await Promise.all([serverPromise, targetsPromise])
78
+ } satisfies ViewTargetResult)
98
79
 
99
80
  return {
100
81
  slug: view.slug,
101
82
  instance,
102
83
  targetRequired,
103
- server,
104
84
  targets,
105
85
  }
106
86
  }
107
87
 
88
+ export function viewSessionArgs(
89
+ origin: string,
90
+ slug: string,
91
+ instance: string,
92
+ targetRef?: string,
93
+ ): string[] {
94
+ const args = ['view', `/:${assertOrigin(origin)}:view.${assertViewSlug(slug)}`]
95
+ if (targetRef) args.push('--target', targetRef)
96
+ args.push('--no-open', '--json', '-i', instance)
97
+ return args
98
+ }
99
+
100
+ interface OpenedViewPayload {
101
+ session?: {
102
+ id?: string
103
+ pageUrl?: string
104
+ view?: { route?: { href?: string } }
105
+ }
106
+ }
107
+
108
+ export function readyViewSession(
109
+ opened: OpenedViewPayload | null,
110
+ target: ViewTargetCandidate | null,
111
+ ): Extract<ViewSessionResult, { status: 'ready' }> | null {
112
+ const session = opened?.session
113
+ const viewUrl = session?.view?.route?.href
114
+ if (!session?.id || !session.pageUrl || !viewUrl) return null
115
+ return {
116
+ status: 'ready',
117
+ sessionId: session.id,
118
+ pageUrl: session.pageUrl,
119
+ viewUrl,
120
+ target,
121
+ }
122
+ }
123
+
108
124
  export async function launchViewSession(
109
125
  root: string,
110
126
  origin: string,
@@ -115,12 +131,9 @@ export async function launchViewSession(
115
131
  ): Promise<ViewSessionResult> {
116
132
  const instance = await activeInstanceName()
117
133
  if (!instance) return { status: 'unavailable', reason: 'No active Astrale instance.' }
118
- if (!view.mount) {
119
- return { status: 'unavailable', reason: 'This view has no local SPA mount.' }
120
- }
121
134
 
122
135
  let target: ViewTargetCandidate | null = null
123
- if (viewClasses(view).length > 0) {
136
+ if (viewDefinitionBindings(origin, view, bundle).length > 0) {
124
137
  const targetId = typeof request.targetId === 'string' ? request.targetId.trim() : ''
125
138
  if (!targetId) return { status: 'unavailable', reason: 'Select a target before opening.' }
126
139
  const targets = await listViewTargets(root, origin, view, bundle, instance, timeoutMs)
@@ -136,26 +149,13 @@ export async function launchViewSession(
136
149
  }
137
150
  }
138
151
 
139
- const server = await ensureViewDevServer(root)
140
- if (server.status !== 'running') {
141
- return {
142
- status: 'unavailable',
143
- reason: server.reason,
144
- }
145
- }
146
-
147
- const args = ['view', '--view-url', localViewUrl(server.url, view.mount), '--handshake', 'shell']
148
-
149
- if (target) {
150
- args.push('--target', target.ref)
151
- }
152
-
153
- args.push('--no-open', '--json', '-i', instance)
154
- const opened = await runViewSessionCommand<{
155
- session?: { id?: string; pageUrl?: string; view?: { url?: string } }
156
- }>(args, Math.max(20_000, timeoutMs + 12_000))
157
- const session = opened.data?.session
158
- if (!opened.ok || !session?.id || !session.pageUrl || !session.view?.url) {
152
+ const args = viewSessionArgs(origin, view.slug, instance, target?.ref)
153
+ const opened = await runViewSessionCommand<OpenedViewPayload>(
154
+ args,
155
+ Math.max(20_000, timeoutMs + 12_000),
156
+ )
157
+ const session = opened.ok ? readyViewSession(opened.data, target) : null
158
+ if (!session) {
159
159
  return {
160
160
  status: 'unavailable',
161
161
  reason: opened.detail || '`astrale view` could not start the preview session.',
@@ -163,13 +163,7 @@ export async function launchViewSession(
163
163
  }
164
164
 
165
165
  if (target) rememberTarget(root, instance, view.slug, target)
166
- return {
167
- status: 'ready',
168
- sessionId: session.id,
169
- pageUrl: session.pageUrl,
170
- viewUrl: session.view.url,
171
- target,
172
- }
166
+ return session
173
167
  }
174
168
 
175
169
  export async function closeViewSession(sessionId: string): Promise<{ ok: true }> {
@@ -186,10 +180,7 @@ async function listViewTargets(
186
180
  instance: string,
187
181
  timeoutMs: number,
188
182
  ): Promise<ViewTargetResult> {
189
- const bindings = viewClasses(view).map((className) => ({
190
- className,
191
- classOrigin: bundle?.ir?.imports[className]?.origin ?? origin,
192
- }))
183
+ const bindings = viewDefinitionBindings(origin, view, bundle)
193
184
  if (bindings.length === 0) {
194
185
  return {
195
186
  status: 'available',
@@ -202,7 +193,7 @@ async function listViewTargets(
202
193
 
203
194
  const queried = await Promise.all(
204
195
  bindings.map(async (binding) => {
205
- const definition = targetDefinition(binding.className, binding.classOrigin)
196
+ const definition = targetDefinition(binding.className, binding.classOrigin, binding.kind)
206
197
  const result = await runAstraleJson<RawQueryResult>(
207
198
  [
208
199
  'query',
@@ -294,12 +285,123 @@ export function targetFromRow(
294
285
  }
295
286
  }
296
287
 
297
- export function targetDefinition(className: string, classOrigin: string): string {
298
- return `/:${assertOrigin(classOrigin)}:class.${assertSchemaName(className)}`
288
+ export function targetDefinition(
289
+ className: string,
290
+ classOrigin: string,
291
+ kind: 'class' | 'interface' = 'class',
292
+ ): string {
293
+ return `/:${assertOrigin(classOrigin)}:${kind}.${assertSchemaName(className)}`
294
+ }
295
+
296
+ interface ViewDefinitionBinding {
297
+ className: string
298
+ classOrigin: string
299
+ kind: 'class' | 'interface'
300
+ }
301
+
302
+ /** Preserve exact canonical View target coordinates. The short-name anatomy
303
+ * fallback exists only for legacy defineView registries. */
304
+ export function viewDefinitionBindings(
305
+ origin: string,
306
+ view: ViewInfo,
307
+ bundle: StudioSchemaBundle | null,
308
+ ): ViewDefinitionBinding[] {
309
+ const target = bundle?.ir?.views?.[view.slug]?.target
310
+ if (target) {
311
+ if (target.kind === 'domain') return []
312
+ return uniqueViewBindings(
313
+ target.definitions
314
+ .filter(
315
+ (definition): definition is typeof definition & { kind: 'class' | 'interface' } =>
316
+ definition.kind === 'class' || definition.kind === 'interface',
317
+ )
318
+ .map((definition) => ({
319
+ className: definition.name,
320
+ classOrigin: definition.origin,
321
+ kind: definition.kind,
322
+ })),
323
+ )
324
+ }
325
+
326
+ const names = Array.isArray(view.viewFor) ? view.viewFor : view.viewFor ? [view.viewFor] : []
327
+ const ir = bundle?.ir
328
+ if (!ir) {
329
+ return uniqueViewBindings(
330
+ names.map((className) => ({ className, classOrigin: origin, kind: 'class' })),
331
+ )
332
+ }
333
+
334
+ return uniqueViewBindings(
335
+ names.flatMap((className) => {
336
+ const local: ViewDefinitionBinding[] = []
337
+ const localClass = ir.classes[className]
338
+ const localInterface = ir.interfaces[className]
339
+ if (localClass) {
340
+ local.push({
341
+ className,
342
+ classOrigin: localClass.ref?.origin ?? localClass.origin ?? ir.domain ?? origin,
343
+ kind: 'class',
344
+ })
345
+ }
346
+ if (localInterface) {
347
+ local.push({
348
+ className,
349
+ classOrigin: localInterface.ref?.origin ?? localInterface.origin ?? ir.domain ?? origin,
350
+ kind: 'interface',
351
+ })
352
+ }
353
+
354
+ // Presence of the exact index makes it authoritative, including when it
355
+ // has no candidate for this short anatomy name. Since this API is plural,
356
+ // retain every exact homonym instead of picking one by object order.
357
+ if (ir.importsByKey !== undefined) {
358
+ const imported = Object.keys(ir.importsByKey).flatMap((key) => {
359
+ const binding = viewBindingFromDefinitionKey(key)
360
+ return binding?.className === className ? [binding] : []
361
+ })
362
+ return [...local, ...imported]
363
+ }
364
+
365
+ const legacy = ir.imports[className]
366
+ if (legacy) {
367
+ return [
368
+ ...local,
369
+ {
370
+ className,
371
+ classOrigin: legacy.origin,
372
+ kind: legacy.definition,
373
+ } satisfies ViewDefinitionBinding,
374
+ ]
375
+ }
376
+
377
+ // Old serializer projections did not retain exact indexes or declaration
378
+ // refs. Preserve their historical local-class assumption only here.
379
+ return local.length > 0 ? local : [{ className, classOrigin: origin, kind: 'class' }]
380
+ }),
381
+ )
382
+ }
383
+
384
+ function viewBindingFromDefinitionKey(key: string): ViewDefinitionBinding | null {
385
+ const separator = key.lastIndexOf(':')
386
+ if (separator <= 0) return null
387
+ const classOrigin = key.slice(0, separator)
388
+ const ref = key.slice(separator + 1)
389
+ const match = /^(class|interface)\.([A-Za-z_$][\w$]*)$/.exec(ref)
390
+ if (!match) return null
391
+ return {
392
+ className: match[2]!,
393
+ classOrigin,
394
+ kind: match[1] as ViewDefinitionBinding['kind'],
395
+ }
299
396
  }
300
397
 
301
- function viewClasses(view: ViewInfo): string[] {
302
- return Array.isArray(view.viewFor) ? view.viewFor : view.viewFor ? [view.viewFor] : []
398
+ function uniqueViewBindings(bindings: ViewDefinitionBinding[]): ViewDefinitionBinding[] {
399
+ const unique = new Map<string, ViewDefinitionBinding>()
400
+ for (const binding of bindings) {
401
+ const key = `${binding.classOrigin}:${binding.kind}.${binding.className}`
402
+ if (!unique.has(key)) unique.set(key, binding)
403
+ }
404
+ return [...unique.values()]
303
405
  }
304
406
 
305
407
  function rememberTarget(
@@ -338,6 +440,11 @@ function assertSchemaName(value: string): string {
338
440
  return value
339
441
  }
340
442
 
443
+ function assertViewSlug(value: string): string {
444
+ if (!/^[a-z][a-z0-9-]*$/.test(value)) throw new Error(`Invalid view slug: ${value}`)
445
+ return value
446
+ }
447
+
341
448
  function stringProp(props: Record<string, unknown>, suffixes: string[]): string | undefined {
342
449
  for (const suffix of suffixes) {
343
450
  for (const [key, value] of Object.entries(props)) {
@@ -0,0 +1,36 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { join } from 'node:path'
3
+
4
+ import type { DomainHandle } from './domain'
5
+
6
+ import { affectsBundle, ANATOMY_PATHS } from './watch'
7
+ import { DOMAIN_SET_TRIGGER_FILES } from './workspace-watch'
8
+
9
+ const handle: DomainHandle = {
10
+ id: 'current',
11
+ root: '/workspace/current',
12
+ configFile: '/workspace/current/astrale.config.ts',
13
+ domainFile: '/workspace/current/implementation.ts',
14
+ schemaDirName: 'schema',
15
+ schemaDir: '/workspace/current/schema',
16
+ schemaIndex: '/workspace/current/schema/index.ts',
17
+ }
18
+
19
+ test('watches current layout roots and reconciles either composition entry', () => {
20
+ expect(ANATOMY_PATHS).toContain('implementation.ts')
21
+ expect(ANATOMY_PATHS).toContain('ui')
22
+ expect(ANATOMY_PATHS).toContain('handlers')
23
+ expect(ANATOMY_PATHS).toContain('queries')
24
+ expect(ANATOMY_PATHS).toContain('workflows')
25
+ expect(DOMAIN_SET_TRIGGER_FILES.has('implementation.ts')).toBe(true)
26
+ expect(DOMAIN_SET_TRIGGER_FILES.has('domain.ts')).toBe(true)
27
+ })
28
+
29
+ test('composition and handler source changes invalidate the schema bundle', () => {
30
+ expect(affectsBundle(handle, join(handle.root, 'implementation.ts'))).toBe(true)
31
+ expect(affectsBundle(handle, join(handle.root, 'domain.ts'))).toBe(true)
32
+ expect(affectsBundle(handle, join(handle.root, 'actions', 'risk', 'create.ts'))).toBe(true)
33
+ expect(affectsBundle(handle, join(handle.root, 'functions', 'user', 'ensure.ts'))).toBe(true)
34
+ expect(affectsBundle(handle, join(handle.root, 'handlers', 'create.ts'))).toBe(true)
35
+ expect(affectsBundle(handle, join(handle.root, 'views', 'routes.ts'))).toBe(false)
36
+ })
@@ -11,20 +11,24 @@ import type { DomainHandle } from './domain'
11
11
 
12
12
  import { getBundle, invalidate } from './cache'
13
13
  import { broadcast } from './sse'
14
+ import { ANATOMY_GLOBS } from './state/baseline'
14
15
 
15
- const ANATOMY = [
16
- 'domain.ts',
17
- 'core.ts',
18
- 'core',
19
- 'runtime',
20
- 'views',
16
+ export const ANATOMY_PATHS = [...ANATOMY_GLOBS.files, ...ANATOMY_GLOBS.dirs]
17
+
18
+ // These paths can feed the ts-morph handler/source overlay. Their content is
19
+ // part of the schema bundle cache even though they are outside schema/ itself.
20
+ const BUNDLE_SOURCE_DIRS = new Set([
21
+ 'actions',
22
+ 'capabilities',
21
23
  'functions',
22
- 'client/src',
23
- 'deps.ts',
24
- 'env.ts',
25
- 'package.json',
26
- 'astrale.config.ts',
27
- ]
24
+ 'handlers',
25
+ 'mutations',
26
+ 'queries',
27
+ 'rules',
28
+ 'runtime',
29
+ 'utils',
30
+ 'workflows',
31
+ ])
28
32
 
29
33
  function ignored(p: string): boolean {
30
34
  return (
@@ -35,15 +39,16 @@ function ignored(p: string): boolean {
35
39
  )
36
40
  }
37
41
 
38
- function affectsBundle(handle: DomainHandle, path: string): boolean {
42
+ export function affectsBundle(handle: DomainHandle, path: string): boolean {
39
43
  const rel = relative(handle.root, path).split('\\').join('/')
40
- return rel === 'domain.ts' || rel === 'runtime' || rel.startsWith('runtime/')
44
+ const topLevel = rel.split('/')[0]
45
+ return rel === 'implementation.ts' || rel === 'domain.ts' || BUNDLE_SOURCE_DIRS.has(topLevel)
41
46
  }
42
47
 
43
48
  export function watchDomain(handle: DomainHandle): () => void {
44
49
  const schemaW = chokidar.watch(handle.schemaDir, { ignoreInitial: true, ignored })
45
50
  const anatomyW = chokidar.watch(
46
- ANATOMY.map((p) => join(handle.root, p)),
51
+ ANATOMY_PATHS.map((p) => join(handle.root, p)),
47
52
  { ignoreInitial: true, ignored },
48
53
  )
49
54
 
@@ -53,7 +58,9 @@ export function watchDomain(handle: DomainHandle): () => void {
53
58
  schemaW.on('all', () => {
54
59
  clearTimeout(st)
55
60
  st = setTimeout(async () => {
56
- invalidate(handle.id, 'schema')
61
+ // Origin and View declarations are part of the current schema, so schema
62
+ // edits invalidate both render surfaces.
63
+ invalidate(handle.id, 'all')
57
64
  broadcast({ type: 'resolving', domainId: handle.id })
58
65
  const b = await getBundle(handle.id, true)
59
66
  if (b?.error)
@@ -63,6 +70,7 @@ export function watchDomain(handle: DomainHandle): () => void {
63
70
  domainId: handle.id,
64
71
  schemaHash: b?.schemaHash ?? 'sha-none',
65
72
  })
73
+ broadcast({ type: 'anatomy-diff', domainId: handle.id })
66
74
  }, 150)
67
75
  })
68
76
 
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * workspace-watch.ts — keeps the domain registry in sync with the workspace while
3
3
  * the studio is running. A domain dropped in (or one whose astrale.config.ts +
4
- * domain.ts + schema/index.ts triple just completed) is registered, booted, and
4
+ * composition entry + schema/index.ts triple just completed) is registered, booted, and
5
5
  * announced over SSE; one whose triple vanished is unregistered. It reuses
6
6
  * `scanWorkspace` (detection) + `bootDomain` (lifecycle), so there is no second
7
7
  * source of truth for "what is a domain" or "how a domain comes online".
@@ -17,7 +17,12 @@ import { broadcast } from './sse'
17
17
  const IGNORED =
18
18
  /(^|[/\\])(node_modules|\.git|\.astrale|\.domain-studio|dist|\.dist|\.next|\.cache|\.turbo|\.vercel|coverage)([/\\]|$)/
19
19
  // only these file changes can change the domain SET (vs. ordinary in-domain edits)
20
- const TRIGGERS = new Set(['astrale.config.ts', 'domain.ts', 'index.ts'])
20
+ export const DOMAIN_SET_TRIGGER_FILES = new Set([
21
+ 'astrale.config.ts',
22
+ 'implementation.ts',
23
+ 'domain.ts',
24
+ 'index.ts',
25
+ ])
21
26
 
22
27
  /**
23
28
  * Watch `root` for domains appearing/disappearing.
@@ -84,7 +89,7 @@ export function watchWorkspace(
84
89
  timer = setTimeout(() => void run(), 400)
85
90
  }
86
91
  const onFile = (p: string) => {
87
- if (TRIGGERS.has(basename(p))) schedule()
92
+ if (DOMAIN_SET_TRIGGER_FILES.has(basename(p))) schedule()
88
93
  }
89
94
 
90
95
  const watcher = chokidar.watch(root, {