@astrale-os/cli 1.0.0-beta.0 → 1.0.0-beta.2

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 (180) hide show
  1. package/README.md +7 -11
  2. package/dist/astrale.js +3769 -3549
  3. package/dist/public/connect-core.js +396 -392
  4. package/dist/public/keys/index.js +371 -378
  5. package/dist/public/paths/index.js +371 -378
  6. package/dist/types/lib/idp.d.ts +6 -0
  7. package/dist/types/lib/meta.d.ts +2 -2
  8. package/package.json +6 -4
  9. package/src/admin/binding.ts +168 -0
  10. package/src/admin/catalog/__tests__/client.test.ts +2 -2
  11. package/src/admin/catalog/client.ts +4 -9
  12. package/src/admin/instance/__tests__/client.test.ts +5 -3
  13. package/src/admin/instance/client.ts +9 -27
  14. package/src/commands/__tests__/auth-login.test.ts +54 -1
  15. package/src/commands/__tests__/call.test.ts +5 -1
  16. package/src/commands/__tests__/domain-install-operation.test.ts +23 -7
  17. package/src/commands/__tests__/domain-install-owned.test.ts +5 -1
  18. package/src/commands/__tests__/domain-list.test.ts +83 -4
  19. package/src/commands/__tests__/install-identity-override.test.ts +30 -19
  20. package/src/commands/__tests__/studio-descriptor.test.ts +22 -0
  21. package/src/commands/__tests__/update.test.ts +73 -0
  22. package/src/commands/domain/install.ts +11 -15
  23. package/src/commands/domain/list.ts +17 -12
  24. package/src/commands/studio.ts +20 -0
  25. package/src/commands/update.ts +39 -13
  26. package/src/connection/__tests__/errors.test.ts +16 -12
  27. package/src/connection/__tests__/exchange.test.ts +6 -5
  28. package/src/connection/exchange.ts +5 -4
  29. package/src/lib/__tests__/domain-publication.test.ts +144 -0
  30. package/src/lib/__tests__/idp-session.test.ts +22 -0
  31. package/src/lib/__tests__/idp.test.ts +2 -0
  32. package/src/lib/__tests__/instance.test.ts +6 -0
  33. package/src/lib/__tests__/studio-server-deps.test.ts +53 -16
  34. package/src/lib/__tests__/update.test.ts +12 -0
  35. package/src/lib/domain-publication.ts +102 -0
  36. package/src/lib/idp.ts +5 -1
  37. package/src/lib/instance.ts +6 -1
  38. package/src/lib/login-flow.ts +13 -3
  39. package/src/lib/meta.ts +2 -2
  40. package/src/lib/update.ts +4 -4
  41. package/src/program/__tests__/program.test.ts +1 -1
  42. package/studio/client/dist/assets/{elk-api-D2xgMJvi.js → elk-api-lwhFo7vB.js} +1 -1
  43. package/studio/client/dist/assets/index-B-c-smo9.js +8 -0
  44. package/studio/client/dist/assets/index-BckHuAWk.js +81 -0
  45. package/studio/client/dist/assets/index-C4aNQ6dz.css +1 -0
  46. package/studio/client/dist/index.html +2 -2
  47. package/studio/package.json +3 -1
  48. package/studio/server/agent/bridge/stdio.ts +8 -1
  49. package/studio/server/agent/conversation.test.ts +10 -5
  50. package/studio/server/agent/conversation.ts +56 -8
  51. package/studio/server/agent/harness/codex/loadout.ts +18 -21
  52. package/studio/server/agent/harness/gateway/config.ts +20 -18
  53. package/studio/server/agent/harness/selection.ts +9 -1
  54. package/studio/server/agent/prompts/system.ts +3 -2
  55. package/studio/server/agent/prompts/turn.ts +13 -4
  56. package/studio/server/agent/run/completion.ts +2 -2
  57. package/studio/server/agent/run/preparation.ts +6 -5
  58. package/studio/server/agent/run/transcript.test.ts +35 -1
  59. package/studio/server/agent/run/transcript.ts +169 -3
  60. package/studio/server/agent/run/usage.test.ts +24 -0
  61. package/studio/server/agent/run/usage.ts +26 -1
  62. package/studio/server/api/canvas.ts +44 -0
  63. package/studio/server/api/comments.ts +85 -0
  64. package/studio/server/api/context.ts +61 -0
  65. package/studio/server/api/deployment.ts +39 -0
  66. package/studio/server/api/documents.ts +55 -0
  67. package/studio/server/api/domain.ts +59 -0
  68. package/studio/server/api/http.ts +45 -0
  69. package/studio/server/api/project.ts +57 -0
  70. package/studio/server/api/schema.ts +12 -0
  71. package/studio/server/api/views.ts +57 -0
  72. package/studio/server/api/workspace.ts +60 -0
  73. package/studio/server/api.test.ts +147 -0
  74. package/studio/server/api.ts +20 -422
  75. package/studio/server/cache.test.ts +111 -0
  76. package/studio/server/cache.ts +163 -9
  77. package/studio/server/cli-consumers.test.ts +494 -0
  78. package/studio/server/cli.test.ts +88 -0
  79. package/studio/server/cli.ts +240 -0
  80. package/studio/server/client-package.ts +25 -1
  81. package/studio/server/domain.test.ts +30 -2
  82. package/studio/server/domain.ts +14 -2
  83. package/studio/server/environment/dotenv-preview.test.ts +39 -0
  84. package/studio/server/environment/dotenv-preview.ts +27 -0
  85. package/studio/server/environment/files.test.ts +53 -0
  86. package/studio/server/{state/env.ts → environment/files.ts} +7 -48
  87. package/studio/server/{state → handoff}/copy.ts +21 -6
  88. package/studio/server/handoff/service.test.ts +22 -0
  89. package/studio/server/{state/handoff.ts → handoff/service.ts} +21 -14
  90. package/studio/server/instances/active.ts +72 -0
  91. package/studio/server/instances/deploy-record.test.ts +31 -0
  92. package/studio/server/instances/deploy-record.ts +37 -0
  93. package/studio/server/instances/deploy.ts +56 -0
  94. package/studio/server/{state/instance.test.ts → instances/probe.test.ts} +25 -21
  95. package/studio/server/instances/probe.ts +67 -0
  96. package/studio/server/instances/status.test.ts +12 -0
  97. package/studio/server/instances/status.ts +49 -0
  98. package/studio/server/introspect/anatomy/client-tree.ts +57 -0
  99. package/studio/server/introspect/anatomy/env-fields.ts +66 -0
  100. package/studio/server/introspect/anatomy/schema-definition.ts +53 -0
  101. package/studio/server/introspect/anatomy/source.ts +181 -0
  102. package/studio/server/introspect/anatomy/views/legacy.ts +232 -0
  103. package/studio/server/introspect/anatomy/views/routes.ts +105 -0
  104. package/studio/server/introspect/anatomy/views.ts +58 -0
  105. package/studio/server/introspect/anatomy-extras.test.ts +91 -4
  106. package/studio/server/introspect/anatomy-extras.ts +7 -726
  107. package/studio/server/introspect/anatomy.ts +46 -19
  108. package/studio/server/introspect/bundle.ts +15 -6
  109. package/studio/server/introspect/canonical-schema.test.ts +79 -0
  110. package/studio/server/introspect/canonical-schema.ts +80 -18
  111. package/studio/server/introspect/config-preview.test.ts +32 -0
  112. package/studio/server/introspect/config-preview.ts +119 -0
  113. package/studio/server/introspect/diff.test.ts +7 -11
  114. package/studio/server/introspect/diff.ts +28 -37
  115. package/studio/server/introspect/extractor.ts +8 -6
  116. package/studio/server/introspect/hash.ts +5 -2
  117. package/studio/server/introspect/overlay-tsmorph.ts +7 -1305
  118. package/studio/server/introspect/revision.test.ts +54 -0
  119. package/studio/server/introspect/revision.ts +49 -0
  120. package/studio/server/introspect/runtime.test.ts +63 -5
  121. package/studio/server/introspect/runtime.ts +49 -3
  122. package/studio/server/introspect/schema-ir-json.ts +181 -0
  123. package/studio/server/introspect/source-overlay/annotations.ts +14 -0
  124. package/studio/server/introspect/source-overlay/handlers.ts +681 -0
  125. package/studio/server/introspect/source-overlay/kernel-calls.ts +49 -0
  126. package/studio/server/introspect/source-overlay/project.ts +248 -0
  127. package/studio/server/introspect/source-overlay/spans.ts +360 -0
  128. package/studio/server/json.ts +46 -0
  129. package/studio/server/lifecycle.ts +5 -1
  130. package/studio/server/sse.test.ts +26 -0
  131. package/studio/server/sse.ts +4 -1
  132. package/studio/server/state/baseline.test.ts +212 -1
  133. package/studio/server/state/baseline.ts +145 -29
  134. package/studio/server/state/comments.test.ts +66 -1
  135. package/studio/server/state/comments.ts +173 -10
  136. package/studio/server/state/context.ts +34 -2
  137. package/studio/server/state/documents.ts +42 -1
  138. package/studio/server/state/integrations.ts +27 -1
  139. package/studio/server/state/layout.test.ts +34 -0
  140. package/studio/server/state/layout.ts +44 -17
  141. package/studio/server/state/settings.ts +29 -22
  142. package/studio/server/state/store.test.ts +122 -0
  143. package/studio/server/state/store.ts +66 -17
  144. package/studio/server/state/visibility.ts +22 -7
  145. package/studio/server/views/model.test.ts +57 -0
  146. package/studio/server/views/model.ts +66 -0
  147. package/studio/server/views/runtime.ts +40 -0
  148. package/studio/server/views/selection-repository.test.ts +31 -0
  149. package/studio/server/views/selection-repository.ts +60 -0
  150. package/studio/server/views/session.test.ts +73 -0
  151. package/studio/server/views/session.ts +166 -0
  152. package/studio/server/{state/views.test.ts → views/target.test.ts} +8 -159
  153. package/studio/server/views/target.ts +248 -0
  154. package/studio/server/watch.ts +1 -1
  155. package/studio/server/workspace/catalog.test.ts +17 -0
  156. package/studio/server/workspace/catalog.ts +43 -0
  157. package/studio/server/{state → workspace}/create.test.ts +1 -1
  158. package/studio/server/{state → workspace}/create.ts +1 -1
  159. package/studio/server/{state → workspace}/git.ts +1 -1
  160. package/studio/server/workspace/updates.ts +83 -0
  161. package/studio/server/workspace-state.ts +1 -1
  162. package/studio/shared/contracts/README.md +16 -0
  163. package/studio/shared/contracts/agent.ts +270 -0
  164. package/studio/shared/contracts/runtime.ts +50 -0
  165. package/studio/shared/contracts/schema.ts +462 -0
  166. package/studio/shared/contracts/surface.test.ts +319 -0
  167. package/studio/shared/contracts/workspace.ts +274 -0
  168. package/studio/shared/layout.test.ts +116 -0
  169. package/studio/shared/schema/identity.test.ts +58 -0
  170. package/studio/shared/schema/identity.ts +82 -0
  171. package/studio/shared/types.ts +11 -1099
  172. package/studio/tsconfig.json +1 -1
  173. package/viewer/dist/main.js +35 -35
  174. package/studio/client/dist/assets/index-BMdnsIJA.css +0 -1
  175. package/studio/client/dist/assets/index-D-vRV8w7.js +0 -8
  176. package/studio/client/dist/assets/index-LGSWRrk8.js +0 -81
  177. package/studio/server/state/catalog.ts +0 -117
  178. package/studio/server/state/instance.ts +0 -280
  179. package/studio/server/state/updates.ts +0 -63
  180. package/studio/server/state/views.ts +0 -535
@@ -2,26 +2,129 @@
2
2
  * comments.ts — the comments / open-questions store. ALL persistence goes through
3
3
  * store.ts (writes are allow-listed to <domain>/.domain-studio/). The on-disk file
4
4
  * is `comments.json`, shaped { schemaVersion, comments: Comment[] }.
5
+ * `schemaVersion` is the historical annotate wire key for Studio's render
6
+ * fingerprint; it is not a canonical DSL schema revision.
5
7
  *
6
8
  * Comments are annotate-compatible: each thread entry is
7
9
  * { id, role:'user'|'author', type:'text'|'choice', text, options?, answer? }
8
10
  * and `kind` is derived from the FIRST thread entry's role
9
11
  * (author-seeded ⇒ 'question', else 'comment').
10
12
  */
11
- import type { Comment, CommentStore, ThreadEntry } from '../../shared/types'
13
+ import type { AnchorRef, Comment, CommentStore, ThreadEntry } from '../../shared/types'
12
14
 
15
+ import {
16
+ asBoolean,
17
+ asFiniteNumber,
18
+ asJsonRecord,
19
+ asString,
20
+ asStringArray,
21
+ parseJson,
22
+ } from '../json'
13
23
  import { readJson, writeJson } from './store'
14
24
 
15
25
  const FILE = 'comments.json'
16
26
 
27
+ function decodeAnchorRef(value: unknown): AnchorRef | undefined {
28
+ const record = asJsonRecord(value)
29
+ const ref = asString(record?.ref)
30
+ const kind = record?.kind
31
+ if (!ref || !['schema', 'section', 'file', 'free'].includes(String(kind))) return undefined
32
+ const file = asString(record?.file)
33
+ const startLine = asFiniteNumber(record?.startLine)
34
+ const endLine = asFiniteNumber(record?.endLine)
35
+ const label = asString(record?.label)
36
+ const x = asFiniteNumber(record?.x)
37
+ const y = asFiniteNumber(record?.y)
38
+ return {
39
+ ref,
40
+ kind: kind as AnchorRef['kind'],
41
+ ...(file === undefined ? {} : { file }),
42
+ ...(startLine === undefined ? {} : { startLine }),
43
+ ...(endLine === undefined ? {} : { endLine }),
44
+ ...(label === undefined ? {} : { label }),
45
+ ...(x === undefined ? {} : { x }),
46
+ ...(y === undefined ? {} : { y }),
47
+ }
48
+ }
49
+
50
+ function decodeThreadEntry(value: unknown): ThreadEntry | undefined {
51
+ const record = asJsonRecord(value)
52
+ if (!record) return undefined
53
+ const id = asString(record.id)
54
+ const role = record.role
55
+ const type = record.type
56
+ const text = asString(record.text)
57
+ if (
58
+ !id ||
59
+ (role !== 'user' && role !== 'author') ||
60
+ (type !== 'text' && type !== 'choice') ||
61
+ text === undefined
62
+ ) {
63
+ return undefined
64
+ }
65
+ const options = asStringArray(record.options)
66
+ const answer = record.answer === null ? null : asString(record.answer)
67
+ return {
68
+ id,
69
+ role,
70
+ type,
71
+ text,
72
+ ...(options === undefined ? {} : { options }),
73
+ ...(answer === undefined ? {} : { answer }),
74
+ }
75
+ }
76
+
77
+ function decodeComment(value: unknown): Comment | undefined {
78
+ const record = asJsonRecord(value)
79
+ if (!record) return undefined
80
+ const id = asString(record.id)
81
+ if (!id) return undefined
82
+ const anchors = asStringArray(record.anchors) ?? []
83
+ const anchorRefs = Array.isArray(record.anchorRefs)
84
+ ? record.anchorRefs.flatMap((anchor) => {
85
+ const decoded = decodeAnchorRef(anchor)
86
+ return decoded ? [decoded] : []
87
+ })
88
+ : []
89
+ const thread = Array.isArray(record.thread)
90
+ ? record.thread.flatMap((entry) => {
91
+ const decoded = decodeThreadEntry(entry)
92
+ return decoded ? [decoded] : []
93
+ })
94
+ : []
95
+ const status = record.status === 'closed' ? 'closed' : 'open'
96
+ const closeNote = asString(record.closeNote)
97
+ const createdAt = asString(record.createdAt) ?? ''
98
+ const orphaned = asBoolean(record.orphaned)
99
+ return {
100
+ id,
101
+ anchors,
102
+ anchorRefs,
103
+ status,
104
+ thread,
105
+ createdAt,
106
+ kind: deriveKind(thread),
107
+ ...(closeNote === undefined ? {} : { closeNote }),
108
+ ...(orphaned === undefined ? {} : { orphaned }),
109
+ }
110
+ }
111
+
112
+ function decodeCommentStore(value: unknown): CommentStore | undefined {
113
+ const record = asJsonRecord(value)
114
+ if (!record) return undefined
115
+ const comments = Array.isArray(record.comments)
116
+ ? record.comments.flatMap((comment) => {
117
+ const decoded = decodeComment(comment)
118
+ return decoded ? [decoded] : []
119
+ })
120
+ : []
121
+ return { schemaVersion: asString(record.schemaVersion) ?? '', comments }
122
+ }
123
+
17
124
  export function readComments(root: string): CommentStore {
18
125
  // Allocate a fresh array for every missing store. A module-level EMPTY object
19
126
  // would share its nested `comments` array across domains until each persisted.
20
- const store = readJson<CommentStore>(root, FILE, { schemaVersion: '', comments: [] })
21
- return {
22
- schemaVersion: store.schemaVersion ?? '',
23
- comments: Array.isArray(store.comments) ? store.comments : [],
24
- }
127
+ return readJson(root, FILE, decodeCommentStore, { schemaVersion: '', comments: [] })
25
128
  }
26
129
 
27
130
  function persist(root: string, store: CommentStore): void {
@@ -206,6 +309,63 @@ interface PastedStore {
206
309
  comments?: PastedComment[]
207
310
  }
208
311
 
312
+ function decodePastedEntry(value: unknown): PastedThreadEntry | undefined {
313
+ const record = asJsonRecord(value)
314
+ if (!record) return undefined
315
+ const role = record.role === 'user' || record.role === 'author' ? record.role : undefined
316
+ const type = record.type === 'text' || record.type === 'choice' ? record.type : undefined
317
+ const id = asString(record.id)
318
+ const text = asString(record.text)
319
+ const options = asStringArray(record.options)
320
+ const answer = record.answer === null ? null : asString(record.answer)
321
+ return {
322
+ ...(id === undefined ? {} : { id }),
323
+ ...(role === undefined ? {} : { role }),
324
+ ...(type === undefined ? {} : { type }),
325
+ ...(text === undefined ? {} : { text }),
326
+ ...(options === undefined ? {} : { options }),
327
+ ...(answer === undefined ? {} : { answer }),
328
+ }
329
+ }
330
+
331
+ function decodePastedComment(value: unknown): PastedComment | undefined {
332
+ const record = asJsonRecord(value)
333
+ if (!record) return undefined
334
+ const id = asString(record.id)
335
+ const anchors = asStringArray(record.anchors)
336
+ const status = record.status === 'open' || record.status === 'closed' ? record.status : undefined
337
+ const closeNote = asString(record.closeNote)
338
+ const thread = Array.isArray(record.thread)
339
+ ? record.thread.flatMap((entry) => {
340
+ const decoded = decodePastedEntry(entry)
341
+ return decoded ? [decoded] : []
342
+ })
343
+ : undefined
344
+ return {
345
+ ...(id === undefined ? {} : { id }),
346
+ ...(anchors === undefined ? {} : { anchors }),
347
+ ...(status === undefined ? {} : { status }),
348
+ ...(closeNote === undefined ? {} : { closeNote }),
349
+ ...(thread === undefined ? {} : { thread }),
350
+ }
351
+ }
352
+
353
+ function decodePastedStore(value: unknown): PastedStore | undefined {
354
+ const record = asJsonRecord(value)
355
+ if (!record) return undefined
356
+ const schemaVersion = asString(record.schemaVersion)
357
+ const comments = Array.isArray(record.comments)
358
+ ? record.comments.flatMap((comment) => {
359
+ const decoded = decodePastedComment(comment)
360
+ return decoded ? [decoded] : []
361
+ })
362
+ : undefined
363
+ return {
364
+ ...(schemaVersion === undefined ? {} : { schemaVersion }),
365
+ ...(comments === undefined ? {} : { comments }),
366
+ }
367
+ }
368
+
209
369
  /** Extract the LAST fenced ```json … ``` block from arbitrary prose. */
210
370
  function extractLastJsonBlock(text: string): string | null {
211
371
  const fence = /```json\s*\n?([\s\S]*?)```/gi
@@ -234,11 +394,11 @@ function normalizePastedEntry(e: PastedThreadEntry): ThreadEntry {
234
394
  * - LAST ```json``` block is parsed.
235
395
  * - For each pasted comment by id: if known locally, append NEW thread entries
236
396
  * (dedupe by entry id) and apply status/closeNote; if unknown → unknownIds.
237
- * - schemaMismatch when parsed.schemaVersion differs from currentSchemaHash.
397
+ * - schemaMismatch when parsed.schemaVersion differs from the current render fingerprint.
238
398
  */
239
399
  export function mergeReply(
240
400
  root: string,
241
- currentSchemaHash: string,
401
+ currentRenderFingerprint: string,
242
402
  pastedText: string,
243
403
  opts?: {
244
404
  /** live loop: skip ONLY the texts already applied live this run, per comment id */
@@ -252,7 +412,8 @@ export function mergeReply(
252
412
  if (block == null) {
253
413
  throw new Error('no machine-state json block found in pasted text')
254
414
  }
255
- const parsed = JSON.parse(block) as PastedStore
415
+ const parsed = decodePastedStore(parseJson(block))
416
+ if (!parsed) throw new Error('invalid machine-state json block')
256
417
  const pastedComments = Array.isArray(parsed.comments) ? parsed.comments : []
257
418
 
258
419
  const store = readComments(root)
@@ -309,7 +470,9 @@ export function mergeReply(
309
470
  persist(root, store)
310
471
 
311
472
  const pastedSchemaVersion = parsed.schemaVersion
312
- const schemaMismatch = Boolean(pastedSchemaVersion && pastedSchemaVersion !== currentSchemaHash)
473
+ const schemaMismatch = Boolean(
474
+ pastedSchemaVersion && pastedSchemaVersion !== currentRenderFingerprint,
475
+ )
313
476
 
314
477
  return {
315
478
  merged,
@@ -7,17 +7,49 @@
7
7
  */
8
8
  import type { ContextItem, ContextStore } from '../../shared/types'
9
9
 
10
+ import { asBoolean, asJsonRecord, asString } from '../json'
10
11
  import { readJson, writeJson, writeState } from './store'
11
12
 
12
13
  const USER_PATH = 'context/user/index.json'
13
14
  const AUTO_PATH = 'context/auto/index.json'
14
15
 
16
+ function decodeContextItem(value: unknown): ContextItem | undefined {
17
+ const record = asJsonRecord(value)
18
+ const id = asString(record?.id)
19
+ const bucket = record?.bucket === 'user' || record?.bucket === 'auto' ? record.bucket : undefined
20
+ const title = asString(record?.title)
21
+ const body = asString(record?.body)
22
+ const updatedAt = asString(record?.updatedAt)
23
+ if (!id || !bucket || title === undefined || body === undefined || !updatedAt) return undefined
24
+ const source = asString(record?.source)
25
+ const includeInHandoff = asBoolean(record?.includeInHandoff)
26
+ const freshness = asString(record?.freshness)
27
+ return {
28
+ id,
29
+ bucket,
30
+ title,
31
+ body,
32
+ updatedAt,
33
+ ...(source === undefined ? {} : { source }),
34
+ ...(includeInHandoff === undefined ? {} : { includeInHandoff }),
35
+ ...(freshness === undefined ? {} : { freshness }),
36
+ }
37
+ }
38
+
39
+ function decodeContextItems(value: unknown): ContextItem[] | undefined {
40
+ if (!Array.isArray(value)) return undefined
41
+ return value.flatMap((item) => {
42
+ const decoded = decodeContextItem(item)
43
+ return decoded ? [decoded] : []
44
+ })
45
+ }
46
+
15
47
  function readUser(root: string): ContextItem[] {
16
- return readJson<ContextItem[]>(root, USER_PATH, [])
48
+ return readJson(root, USER_PATH, decodeContextItems, [])
17
49
  }
18
50
 
19
51
  function readAuto(root: string): ContextItem[] {
20
- return readJson<ContextItem[]>(root, AUTO_PATH, [])
52
+ return readJson(root, AUTO_PATH, decodeContextItems, [])
21
53
  }
22
54
 
23
55
  function writeUser(root: string, items: ContextItem[]): void {
@@ -9,13 +9,54 @@ import { extname } from 'node:path'
9
9
 
10
10
  import type { DocMeta } from '../../shared/types'
11
11
 
12
+ import { asFiniteNumber, asJsonRecord, asString } from '../json'
12
13
  import { readJson, removeState, statePath, writeJson, writeStateBuffer } from './store'
13
14
 
14
15
  const INDEX = 'context/documents/index.json'
15
16
  const storedPath = (id: string, name: string) => `context/documents/${id}${extname(name)}`
16
17
 
18
+ function decodeDocument(value: unknown): DocMeta | undefined {
19
+ const record = asJsonRecord(value)
20
+ const id = asString(record?.id)
21
+ const name = asString(record?.name)
22
+ const type = asString(record?.type)
23
+ const size = asFiniteNumber(record?.size)
24
+ const addedAt = asString(record?.addedAt)
25
+ const stored = asString(record?.stored)
26
+ if (
27
+ !id ||
28
+ name === undefined ||
29
+ type === undefined ||
30
+ size === undefined ||
31
+ size < 0 ||
32
+ !addedAt ||
33
+ !stored?.startsWith('context/documents/') ||
34
+ stored.split('/').includes('..')
35
+ ) {
36
+ return undefined
37
+ }
38
+ const updatedAt = asString(record?.updatedAt)
39
+ return {
40
+ id,
41
+ name,
42
+ type,
43
+ size,
44
+ addedAt,
45
+ stored,
46
+ ...(updatedAt === undefined ? {} : { updatedAt }),
47
+ }
48
+ }
49
+
50
+ function decodeDocuments(value: unknown): DocMeta[] | undefined {
51
+ if (!Array.isArray(value)) return undefined
52
+ return value.flatMap((item) => {
53
+ const document = decodeDocument(item)
54
+ return document ? [document] : []
55
+ })
56
+ }
57
+
17
58
  export function listDocuments(root: string): DocMeta[] {
18
- return readJson<DocMeta[]>(root, INDEX, [])
59
+ return readJson(root, INDEX, decodeDocuments, [])
19
60
  }
20
61
 
21
62
  export function addDocument(root: string, name: string, type: string, data: Uint8Array): DocMeta {
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import type { Integration, IntegrationsState } from '../../shared/types'
8
8
 
9
+ import { asJsonRecord, asString } from '../json'
9
10
  import { readJson, writeJson } from './store'
10
11
 
11
12
  const PATH = 'integrations.json'
@@ -14,8 +15,33 @@ interface IntegrationsFile {
14
15
  integrations: Integration[]
15
16
  }
16
17
 
18
+ function decodeIntegration(value: unknown): Integration | undefined {
19
+ const record = asJsonRecord(value)
20
+ const id = asString(record?.id)
21
+ const name = asString(record?.name)
22
+ const kind = asString(record?.kind)
23
+ const status = asString(record?.status)
24
+ if (id === undefined || name === undefined || kind === undefined || status === undefined)
25
+ return undefined
26
+ const notes = asString(record?.notes)
27
+ return { id, name, kind, status, ...(notes === undefined ? {} : { notes }) }
28
+ }
29
+
30
+ function decodeIntegrationsFile(value: unknown): IntegrationsFile | undefined {
31
+ const record = asJsonRecord(value)
32
+ if (!record) return undefined
33
+ return {
34
+ integrations: Array.isArray(record.integrations)
35
+ ? record.integrations.flatMap((item) => {
36
+ const integration = decodeIntegration(item)
37
+ return integration ? [integration] : []
38
+ })
39
+ : [],
40
+ }
41
+ }
42
+
17
43
  function read(root: string): Integration[] {
18
- return readJson<IntegrationsFile>(root, PATH, { integrations: [] }).integrations
44
+ return readJson(root, PATH, decodeIntegrationsFile, { integrations: [] }).integrations
19
45
  }
20
46
 
21
47
  function write(root: string, integrations: Integration[]): void {
@@ -0,0 +1,34 @@
1
+ import { afterEach, expect, test } from 'bun:test'
2
+ import { mkdtempSync, rmSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import { asJsonRecord } from '../json'
7
+ import { readLayout, saveLayout } from './layout'
8
+ import { readJson, writeJson } from './store'
9
+
10
+ const roots: string[] = []
11
+
12
+ afterEach(() => {
13
+ while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true })
14
+ })
15
+
16
+ test('reads the old layout hash key but writes only renderFingerprint', () => {
17
+ const root = mkdtempSync(join(tmpdir(), 'studio-layout-'))
18
+ roots.push(root)
19
+ writeJson(root, 'layout.json', {
20
+ schemaHash: 'sha-legacy',
21
+ positions: { 'class.Note': { x: 1, y: 2 } },
22
+ })
23
+
24
+ expect(readLayout(root)).toEqual({
25
+ renderFingerprint: 'sha-legacy',
26
+ positions: { 'class.Note': { x: 1, y: 2 } },
27
+ })
28
+
29
+ saveLayout(root, { 'class.Note': { x: 3, y: 4 } }, 'sha-current')
30
+ expect(readJson(root, 'layout.json', asJsonRecord, {})).toEqual({
31
+ renderFingerprint: 'sha-current',
32
+ positions: { 'class.Note': { x: 3, y: 4 } },
33
+ })
34
+ })
@@ -2,36 +2,63 @@
2
2
  * layout.ts — persisted manual graph layout per domain. Positions are keyed by
3
3
  * node id (e.g. `class.Monitor`), so they survive schema changes: removed nodes'
4
4
  * positions are harmless, new nodes fall back to auto-layout. Stamped with the
5
- * schemaHash for reference. Persisted via the allow-listed store (layout.json).
5
+ * render fingerprint for reference. Persisted via the allow-listed store
6
+ * (layout.json).
6
7
  */
8
+ import type { LayoutState, NodePosition } from '../../shared/types'
9
+
10
+ import { asFiniteNumber, asJsonRecord, asString } from '../json'
7
11
  import { readJson, removeState, writeJson } from './store'
8
12
 
9
- export interface NodePosition {
10
- x: number
11
- y: number
12
- /** persisted size — only expanded module containers carry one. */
13
- w?: number
14
- h?: number
15
- }
13
+ const FILE = 'layout.json'
16
14
 
17
- export interface LayoutState {
15
+ interface StoredLayout extends LayoutState {
18
16
  schemaHash?: string
19
- /** node id → manual position (only nodes the user has moved) */
20
- positions: Record<string, NodePosition>
21
17
  }
22
18
 
23
- const FILE = 'layout.json'
19
+ function decodePosition(value: unknown): NodePosition | undefined {
20
+ const record = asJsonRecord(value)
21
+ const x = asFiniteNumber(record?.x)
22
+ const y = asFiniteNumber(record?.y)
23
+ if (x === undefined || y === undefined) return undefined
24
+ const w = asFiniteNumber(record?.w)
25
+ const h = asFiniteNumber(record?.h)
26
+ return { x, y, ...(w === undefined ? {} : { w }), ...(h === undefined ? {} : { h }) }
27
+ }
28
+
29
+ function decodeStoredLayout(value: unknown): StoredLayout | undefined {
30
+ const record = asJsonRecord(value)
31
+ if (!record) return undefined
32
+ const positions: Record<string, NodePosition> = {}
33
+ for (const [id, candidate] of Object.entries(asJsonRecord(record.positions) ?? {})) {
34
+ const position = decodePosition(candidate)
35
+ if (position) positions[id] = position
36
+ }
37
+ const renderFingerprint = asString(record.renderFingerprint)
38
+ const schemaHash = asString(record.schemaHash)
39
+ return {
40
+ positions,
41
+ ...(renderFingerprint === undefined ? {} : { renderFingerprint }),
42
+ ...(schemaHash === undefined ? {} : { schemaHash }),
43
+ }
44
+ }
24
45
 
25
46
  export function readLayout(root: string): LayoutState {
26
- return readJson<LayoutState>(root, FILE, { positions: {} })
47
+ const stored = readJson(root, FILE, decodeStoredLayout, { positions: {} })
48
+ return {
49
+ ...((stored.renderFingerprint ?? stored.schemaHash)
50
+ ? { renderFingerprint: stored.renderFingerprint ?? stored.schemaHash }
51
+ : {}),
52
+ positions: stored.positions ?? {},
53
+ }
27
54
  }
28
55
 
29
56
  export function saveLayout(
30
57
  root: string,
31
58
  positions: Record<string, NodePosition>,
32
- schemaHash?: string,
59
+ renderFingerprint?: string,
33
60
  ): LayoutState {
34
- const next: LayoutState = { schemaHash, positions }
61
+ const next: LayoutState = { renderFingerprint, positions }
35
62
  writeJson(root, FILE, next)
36
63
  return next
37
64
  }
@@ -39,11 +66,11 @@ export function saveLayout(
39
66
  export function setNodePositions(
40
67
  root: string,
41
68
  updates: Record<string, NodePosition>,
42
- schemaHash?: string,
69
+ renderFingerprint?: string,
43
70
  ): LayoutState {
44
71
  const cur = readLayout(root)
45
72
  const next: LayoutState = {
46
- schemaHash: schemaHash ?? cur.schemaHash,
73
+ renderFingerprint: renderFingerprint ?? cur.renderFingerprint,
47
74
  positions: { ...cur.positions, ...updates },
48
75
  }
49
76
  writeJson(root, FILE, next)
@@ -10,6 +10,7 @@ import {
10
10
  type NumericStudioSetting,
11
11
  } from '../../shared/settings-values'
12
12
  import { AGENT_ACCESS_LEVELS, AGENT_EFFORT_LEVELS, type StudioSettings } from '../../shared/types'
13
+ import { asJsonRecord, asString } from '../json'
13
14
  import { readJson, writeJson } from './store'
14
15
 
15
16
  const PATH = 'settings.json'
@@ -25,37 +26,43 @@ export const DEFAULT_SETTINGS: StudioSettings = {
25
26
  viewProbeTimeoutMs: 8000,
26
27
  }
27
28
 
28
- function normalizeSettings(input: Partial<StudioSettings>): Partial<StudioSettings> {
29
- const out = { ...input }
29
+ function normalizeSettings(input: unknown): Partial<StudioSettings> {
30
+ const record = asJsonRecord(input)
31
+ if (!record) return {}
32
+ const out: Partial<StudioSettings> = {}
30
33
  for (const key of Object.keys(STUDIO_NUMERIC_LIMITS) as NumericStudioSetting[]) {
31
- if (out[key] === undefined) continue
32
- const value = parseStudioNumericSetting(key, out[key])
33
- if (value === null) delete out[key]
34
- else out[key] = value
34
+ const value = parseStudioNumericSetting(key, record[key])
35
+ if (value !== null) out[key] = value
35
36
  }
36
- if (out.agentEffort !== undefined && !AGENT_EFFORT_LEVELS.includes(out.agentEffort as any))
37
- delete out.agentEffort
38
- if (out.agentAccess !== undefined && !AGENT_ACCESS_LEVELS.includes(out.agentAccess as any))
39
- delete out.agentAccess
40
- if (out.agentModels !== undefined) {
41
- if (!out.agentModels || typeof out.agentModels !== 'object' || Array.isArray(out.agentModels)) {
42
- delete out.agentModels
43
- } else {
44
- out.agentModels = Object.fromEntries(
45
- Object.entries(out.agentModels)
46
- .filter((entry): entry is [string, string] => typeof entry[1] === 'string')
47
- .map(([harness, model]) => [harness.trim().toLowerCase(), model.trim()])
48
- .filter(([harness, model]) => !!harness && !!model),
49
- )
50
- }
37
+
38
+ const effort = AGENT_EFFORT_LEVELS.find((candidate) => candidate === record.agentEffort)
39
+ if (effort) out.agentEffort = effort
40
+ const access = AGENT_ACCESS_LEVELS.find((candidate) => candidate === record.agentAccess)
41
+ if (access) out.agentAccess = access
42
+
43
+ const integrationsDir = asString(record.integrationsDir)
44
+ if (integrationsDir !== undefined) out.integrationsDir = integrationsDir
45
+
46
+ const models = asJsonRecord(record.agentModels)
47
+ if (models) {
48
+ out.agentModels = Object.fromEntries(
49
+ Object.entries(models)
50
+ .filter((entry): entry is [string, string] => typeof entry[1] === 'string')
51
+ .map(([harness, model]) => [harness.trim().toLowerCase(), model.trim()])
52
+ .filter(([harness, model]) => !!harness && !!model),
53
+ )
51
54
  }
52
55
  return out
53
56
  }
54
57
 
58
+ function decodeSettings(value: unknown): Partial<StudioSettings> | undefined {
59
+ return asJsonRecord(value) ? normalizeSettings(value) : undefined
60
+ }
61
+
55
62
  export function readSettings(root: string): StudioSettings {
56
63
  return {
57
64
  ...DEFAULT_SETTINGS,
58
- ...normalizeSettings(readJson<Partial<StudioSettings>>(root, PATH, {})),
65
+ ...readJson(root, PATH, decodeSettings, {}),
59
66
  }
60
67
  }
61
68