@astrale-os/cli 1.0.0-beta.1 → 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 +3737 -3523
  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 +2 -2
  13. package/src/admin/instance/client.ts +6 -11
  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 +23 -16
  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 +9 -5
  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
@@ -31,7 +31,7 @@ export async function completeRun(
31
31
  settings,
32
32
  session,
33
33
  resume,
34
- schemaHash,
34
+ renderFingerprint,
35
35
  model,
36
36
  effort,
37
37
  harnessEnv,
@@ -141,7 +141,7 @@ export async function completeRun(
141
141
  result.finalText.trim()
142
142
  ) {
143
143
  try {
144
- const merged = mergeReply(root, schemaHash, result.finalText, {
144
+ const merged = mergeReply(root, renderFingerprint, result.finalText, {
145
145
  skipByComment: liveByComment,
146
146
  })
147
147
  run.merge = merged
@@ -13,10 +13,10 @@ import type { Bridge } from '../bridge/grant'
13
13
  import type { AgentHarness } from '../harness/adapter'
14
14
 
15
15
  import { getBundle } from '../../cache'
16
+ import { refreshAuto } from '../../handoff/service'
16
17
  import { readComments } from '../../state/comments'
17
18
  import { readContext } from '../../state/context'
18
19
  import { listDocuments } from '../../state/documents'
19
- import { refreshAuto } from '../../state/handoff'
20
20
  import { startBridge } from '../bridge/grant'
21
21
  import { readConversation } from '../conversation'
22
22
  import { getHarness, resolveHarnessConfiguration } from '../harness/selection'
@@ -36,7 +36,7 @@ export interface PreparedRun {
36
36
  settings: StudioSettings
37
37
  session: ReturnType<typeof readConversation>
38
38
  resume?: string
39
- schemaHash: string
39
+ renderFingerprint: string
40
40
  model?: string
41
41
  effort?: AgentEffort
42
42
  harnessEnv: Record<string, string>
@@ -80,7 +80,7 @@ export async function prepareRun(
80
80
  const bundle = await getBundle(domainId)
81
81
  if (controller.signal.aborted) return { error: 'agent run canceled during setup' }
82
82
 
83
- const schemaHash = bundle?.schemaHash ?? ''
83
+ const renderFingerprint = bundle?.renderFingerprint ?? ''
84
84
  const context = readContext(root)
85
85
  const documents = listDocuments(root)
86
86
  const configuration = await resolveHarnessConfiguration(root, harness)
@@ -96,7 +96,8 @@ export async function prepareRun(
96
96
  : buildTurnPrompt({
97
97
  origin: bundle?.overlay.origin ?? domainId,
98
98
  root,
99
- schemaHash,
99
+ renderFingerprint,
100
+ schemaRevision: bundle?.schemaRevision,
100
101
  awaitingThreads: awaiting,
101
102
  userContext: context.user,
102
103
  autoContext: context.auto.filter((item) => item.includeInHandoff),
@@ -150,7 +151,7 @@ export async function prepareRun(
150
151
  settings,
151
152
  session,
152
153
  resume,
153
- schemaHash,
154
+ renderFingerprint,
154
155
  model,
155
156
  effort,
156
157
  harnessEnv,
@@ -5,6 +5,7 @@ import { join } from 'node:path'
5
5
 
6
6
  import type { AgentRun } from '../../../shared/types'
7
7
 
8
+ import { asJsonRecord } from '../../json'
8
9
  import { readJson, writeJson } from '../../state/store'
9
10
  import { currentRun, hydrateRun } from './live-state'
10
11
  import { readLastRun } from './transcript'
@@ -37,7 +38,7 @@ test('reconciles a run orphaned by restart and hydrates its terminal snapshot',
37
38
  sessionId: 'thread-1',
38
39
  error: expect.stringContaining('studio restarted'),
39
40
  })
40
- expect(readJson<AgentRun>(root, '.cache/agent/last-run.json', running).status).toBe('interrupted')
41
+ expect(readJson(root, '.cache/agent/last-run.json', asJsonRecord, {}).status).toBe('interrupted')
41
42
 
42
43
  hydrateRun(domainId, root)
43
44
  expect(currentRun(domainId)).toMatchObject({
@@ -45,3 +46,36 @@ test('reconciles a run orphaned by restart and hydrates its terminal snapshot',
45
46
  sessionId: 'thread-1',
46
47
  })
47
48
  })
49
+
50
+ test('admits future run fields but rejects malformed persisted run structure', () => {
51
+ const root = mkdtempSync(join(tmpdir(), 'studio-agent-transcript-boundary-'))
52
+ roots.push(root)
53
+ const domainId = `domain-${crypto.randomUUID()}`
54
+ writeJson(root, '.cache/agent/last-run.json', {
55
+ id: crypto.randomUUID(),
56
+ domainId,
57
+ harness: 'codex',
58
+ status: 'succeeded',
59
+ createdAt: '2026-08-20T00:00:00.000Z',
60
+ summary: 'finished work',
61
+ targetCommentIds: [],
62
+ events: [],
63
+ futureRunField: { version: 2 },
64
+ })
65
+ expect(readLastRun(domainId, root)).toMatchObject({
66
+ status: 'succeeded',
67
+ summary: 'finished work',
68
+ })
69
+
70
+ writeJson(root, '.cache/agent/last-run.json', {
71
+ id: crypto.randomUUID(),
72
+ domainId,
73
+ harness: 'codex',
74
+ status: 42,
75
+ createdAt: '2026-08-20T00:00:00.000Z',
76
+ summary: 'corrupt',
77
+ targetCommentIds: [],
78
+ events: [],
79
+ })
80
+ expect(readLastRun(domainId, root)).toBeNull()
81
+ })
@@ -1,10 +1,176 @@
1
- import type { AgentRun } from '../../../shared/types'
2
-
1
+ import {
2
+ AGENT_ACCESS_LEVELS,
3
+ AGENT_EFFORT_LEVELS,
4
+ type AgentEvent,
5
+ type AgentPromptSnapshot,
6
+ type AgentRun,
7
+ type MergeResult,
8
+ } from '../../../shared/types'
9
+ import { asBoolean, asFiniteNumber, asJsonRecord, asString, asStringArray } from '../../json'
3
10
  import { readJson, writeJson } from '../../state/store'
4
11
 
5
12
  const LAST_RUN_FILE = '.cache/agent/last-run.json'
6
13
  const runFile = (id: string) => `.cache/agent/runs/${id}.json`
7
14
 
15
+ const RUN_STATUSES = new Set<AgentRun['status']>([
16
+ 'queued',
17
+ 'running',
18
+ 'succeeded',
19
+ 'failed',
20
+ 'canceled',
21
+ 'interrupted',
22
+ ])
23
+ const EVENT_KINDS = new Set<AgentEvent['kind']>([
24
+ 'status',
25
+ 'thinking',
26
+ 'message',
27
+ 'tool',
28
+ 'reply',
29
+ 'error',
30
+ ])
31
+
32
+ function decodeAgentEvent(value: unknown): AgentEvent | undefined {
33
+ const record = asJsonRecord(value)
34
+ const id = asString(record?.id)
35
+ const ts = asString(record?.ts)
36
+ const kind = asString(record?.kind)
37
+ const text = asString(record?.text)
38
+ if (!id || !ts || !kind || !EVENT_KINDS.has(kind as AgentEvent['kind']) || text === undefined)
39
+ return undefined
40
+ const tool = asString(record?.tool)
41
+ const target = asString(record?.target)
42
+ const commentId = asString(record?.commentId)
43
+ return {
44
+ id,
45
+ ts,
46
+ kind: kind as AgentEvent['kind'],
47
+ text,
48
+ ...(tool === undefined ? {} : { tool }),
49
+ ...(target === undefined ? {} : { target }),
50
+ ...(commentId === undefined ? {} : { commentId }),
51
+ }
52
+ }
53
+
54
+ function decodeMergeResult(value: unknown): MergeResult | undefined {
55
+ const record = asJsonRecord(value)
56
+ const merged = asFiniteNumber(record?.merged)
57
+ const closed = asFiniteNumber(record?.closed)
58
+ const unknownIds = asStringArray(record?.unknownIds)
59
+ const schemaMismatch = asBoolean(record?.schemaMismatch)
60
+ if (
61
+ merged === undefined ||
62
+ !Number.isInteger(merged) ||
63
+ closed === undefined ||
64
+ !Number.isInteger(closed) ||
65
+ !unknownIds ||
66
+ schemaMismatch === undefined
67
+ ) {
68
+ return undefined
69
+ }
70
+ const pastedSchemaVersion = asString(record?.pastedSchemaVersion)
71
+ return {
72
+ merged,
73
+ closed,
74
+ unknownIds,
75
+ schemaMismatch,
76
+ ...(pastedSchemaVersion === undefined ? {} : { pastedSchemaVersion }),
77
+ }
78
+ }
79
+
80
+ function decodePrompt(value: unknown): AgentPromptSnapshot | undefined {
81
+ const record = asJsonRecord(value)
82
+ const createdAt = asString(record?.createdAt)
83
+ const systemPrompt = asString(record?.systemPrompt)
84
+ const turnPrompt = asString(record?.turnPrompt)
85
+ const firstTurn = asBoolean(record?.firstTurn)
86
+ const resumed = asBoolean(record?.resumed)
87
+ const mcpTools = asStringArray(record?.mcpTools)
88
+ if (
89
+ !createdAt ||
90
+ systemPrompt === undefined ||
91
+ turnPrompt === undefined ||
92
+ firstTurn === undefined ||
93
+ resumed === undefined ||
94
+ !mcpTools
95
+ ) {
96
+ return undefined
97
+ }
98
+ const sessionId = asString(record?.sessionId)
99
+ const model = asString(record?.model)
100
+ const effort = AGENT_EFFORT_LEVELS.find((candidate) => candidate === record?.effort)
101
+ const access = AGENT_ACCESS_LEVELS.find((candidate) => candidate === record?.access)
102
+ return {
103
+ createdAt,
104
+ systemPrompt,
105
+ turnPrompt,
106
+ firstTurn,
107
+ resumed,
108
+ mcpTools,
109
+ ...(sessionId === undefined ? {} : { sessionId }),
110
+ ...(model === undefined ? {} : { model }),
111
+ ...(effort === undefined ? {} : { effort }),
112
+ ...(access === undefined ? {} : { access }),
113
+ }
114
+ }
115
+
116
+ function decodeAgentRun(value: unknown): AgentRun | undefined {
117
+ const record = asJsonRecord(value)
118
+ const id = asString(record?.id)
119
+ const domainId = asString(record?.domainId)
120
+ const harness = asString(record?.harness)
121
+ const status = asString(record?.status)
122
+ const createdAt = asString(record?.createdAt)
123
+ const summary = asString(record?.summary)
124
+ const targetCommentIds = asStringArray(record?.targetCommentIds)
125
+ if (
126
+ !id ||
127
+ !domainId ||
128
+ !harness ||
129
+ !status ||
130
+ !RUN_STATUSES.has(status as AgentRun['status']) ||
131
+ !createdAt ||
132
+ summary === undefined ||
133
+ !targetCommentIds ||
134
+ !Array.isArray(record?.events)
135
+ ) {
136
+ return undefined
137
+ }
138
+ const events = record.events.flatMap((event) => {
139
+ const decoded = decodeAgentEvent(event)
140
+ return decoded ? [decoded] : []
141
+ })
142
+ const finishedAt = asString(record.finishedAt)
143
+ const sessionId = asString(record.sessionId)
144
+ const resumed = asBoolean(record.resumed)
145
+ const costUsd = asFiniteNumber(record.costUsd)
146
+ const numTurns = asFiniteNumber(record.numTurns)
147
+ const tokens = asFiniteNumber(record.tokens)
148
+ const error = asString(record.error)
149
+ const liveReplies = asFiniteNumber(record.liveReplies)
150
+ const merge = decodeMergeResult(record.merge)
151
+ const prompt = decodePrompt(record.prompt)
152
+ return {
153
+ id,
154
+ domainId,
155
+ harness,
156
+ status: status as AgentRun['status'],
157
+ createdAt,
158
+ summary,
159
+ targetCommentIds,
160
+ events,
161
+ ...(finishedAt === undefined ? {} : { finishedAt }),
162
+ ...(sessionId === undefined ? {} : { sessionId }),
163
+ ...(resumed === undefined ? {} : { resumed }),
164
+ ...(costUsd === undefined ? {} : { costUsd }),
165
+ ...(numTurns === undefined ? {} : { numTurns }),
166
+ ...(tokens === undefined ? {} : { tokens }),
167
+ ...(error === undefined ? {} : { error }),
168
+ ...(liveReplies === undefined ? {} : { liveReplies }),
169
+ ...(merge === undefined ? {} : { merge }),
170
+ ...(prompt === undefined ? {} : { prompt }),
171
+ }
172
+ }
173
+
8
174
  /** Persist the latest run pointer and, when terminal, its transcript. */
9
175
  export function persistRun(root: string, run: AgentRun, transcript = false): void {
10
176
  try {
@@ -17,7 +183,7 @@ export function persistRun(root: string, run: AgentRun, transcript = false): voi
17
183
 
18
184
  /** Rehydrate the latest run and reconcile one orphaned by a Studio restart. */
19
185
  export function readLastRun(domainId: string, root: string): AgentRun | null {
20
- const last = readJson<AgentRun | null>(root, LAST_RUN_FILE, null)
186
+ const last = readJson(root, LAST_RUN_FILE, decodeAgentRun, null)
21
187
  if (!last || last.domainId !== domainId) return null
22
188
  if (last.status === 'running' || last.status === 'queued') {
23
189
  last.status = 'interrupted'
@@ -5,6 +5,7 @@ import { join } from 'node:path'
5
5
 
6
6
  import type { AgentRun } from '../../../shared/types'
7
7
 
8
+ import { writeJson } from '../../state/store'
8
9
  import { readUsage, recordRun } from './usage'
9
10
 
10
11
  const roots: string[] = []
@@ -44,3 +45,26 @@ test('records only reported usage and accumulates domain totals', () => {
44
45
  lastCostUsd: 0.1,
45
46
  })
46
47
  })
48
+
49
+ test('normalizes malformed usage fields while accepting future fields', () => {
50
+ const root = mkdtempSync(join(tmpdir(), 'studio-agent-usage-boundary-'))
51
+ roots.push(root)
52
+ writeJson(root, 'usage.json', {
53
+ runs: -2,
54
+ tokens: 'many',
55
+ costUsd: -1,
56
+ lastRunAt: 42,
57
+ lastTokens: -10,
58
+ futureUsageField: { version: 2 },
59
+ })
60
+
61
+ expect(readUsage(root)).toEqual({ runs: 0, tokens: 0, costUsd: 0 })
62
+
63
+ writeJson(root, 'usage.json', {
64
+ runs: 3,
65
+ tokens: 120,
66
+ costUsd: 1.25,
67
+ futureUsageField: { version: 2 },
68
+ })
69
+ expect(readUsage(root)).toEqual({ runs: 3, tokens: 120, costUsd: 1.25 })
70
+ })
@@ -6,14 +6,39 @@
6
6
  */
7
7
  import type { AgentRun, DomainUsage } from '../../../shared/types'
8
8
 
9
+ import { asFiniteNumber, asJsonRecord, asString } from '../../json'
9
10
  import { readJson, writeJson } from '../../state/store'
10
11
 
11
12
  const PATH = 'usage.json'
12
13
 
13
14
  const EMPTY: DomainUsage = { runs: 0, tokens: 0, costUsd: 0 }
14
15
 
16
+ function nonNegative(value: unknown): number | undefined {
17
+ const number = asFiniteNumber(value)
18
+ return number !== undefined && number >= 0 ? number : undefined
19
+ }
20
+
21
+ function decodeUsage(value: unknown): DomainUsage | undefined {
22
+ const record = asJsonRecord(value)
23
+ if (!record) return undefined
24
+ const runs = nonNegative(record.runs)
25
+ const tokens = nonNegative(record.tokens)
26
+ const costUsd = nonNegative(record.costUsd)
27
+ const lastRunAt = asString(record.lastRunAt)
28
+ const lastTokens = nonNegative(record.lastTokens)
29
+ const lastCostUsd = nonNegative(record.lastCostUsd)
30
+ return {
31
+ runs: runs !== undefined && Number.isInteger(runs) ? runs : EMPTY.runs,
32
+ tokens: tokens ?? EMPTY.tokens,
33
+ costUsd: costUsd ?? EMPTY.costUsd,
34
+ ...(lastRunAt === undefined ? {} : { lastRunAt }),
35
+ ...(lastTokens === undefined ? {} : { lastTokens }),
36
+ ...(lastCostUsd === undefined ? {} : { lastCostUsd }),
37
+ }
38
+ }
39
+
15
40
  export function readUsage(root: string): DomainUsage {
16
- return { ...EMPTY, ...readJson<Partial<DomainUsage>>(root, PATH, {}) }
41
+ return readJson(root, PATH, decodeUsage, { ...EMPTY })
17
42
  }
18
43
 
19
44
  /** Fold a finished run into the running total. A run with no reported usage (e.g. it
@@ -0,0 +1,44 @@
1
+ /** Persisted canvas layout and visibility preference routes. */
2
+ import { getBundle } from '../cache'
3
+ import { readLayout, resetLayout, saveLayout, setNodePositions } from '../state/layout'
4
+ import { readVisibility, resetVisibility, saveVisibility } from '../state/visibility'
5
+ import { badRequest, json, type DomainRouteContext } from './http'
6
+
7
+ export async function handleCanvasRoute(context: DomainRouteContext): Promise<Response | null> {
8
+ const { req, rest, body, handle } = context
9
+ const id = handle.id
10
+ const root = handle.root
11
+
12
+ if (rest === '/layout') {
13
+ const bundle = await getBundle(id)
14
+ if (req.method === 'GET') return json(readLayout(root))
15
+ if (body.action === 'set') {
16
+ return json(setNodePositions(root, body.positions ?? {}, bundle?.renderFingerprint))
17
+ }
18
+ if (body.action === 'save') {
19
+ return json(saveLayout(root, body.positions ?? {}, bundle?.renderFingerprint))
20
+ }
21
+ if (body.action === 'reset') {
22
+ resetLayout(root)
23
+ return json({ ok: true })
24
+ }
25
+ return badRequest('unknown layout action')
26
+ }
27
+
28
+ if (rest === '/visibility') {
29
+ if (req.method === 'GET') return json(readVisibility(root))
30
+ if (body.action === 'set') {
31
+ return json(
32
+ saveVisibility(root, {
33
+ hidden: body.hidden ?? {},
34
+ showInheritedEdges: body.showInheritedEdges ?? true,
35
+ materializedInterfaces: body.materializedInterfaces ?? {},
36
+ }),
37
+ )
38
+ }
39
+ if (body.action === 'reset') return json(resetVisibility(root))
40
+ return badRequest('unknown visibility action')
41
+ }
42
+
43
+ return null
44
+ }
@@ -0,0 +1,85 @@
1
+ /** Schema-anchored review comment routes and agent reply merge. */
2
+ import { getBundle } from '../cache'
3
+ import { schemaRefs } from '../introspect/schema-refs'
4
+ import {
5
+ addThreadEntry,
6
+ deleteComment,
7
+ editThreadEntry,
8
+ markOrphans,
9
+ mergeReply,
10
+ readComments,
11
+ setStatus,
12
+ upsertComment,
13
+ } from '../state/comments'
14
+ import { badRequest, json, notFound, type DomainRouteContext } from './http'
15
+
16
+ export async function handleCommentRoute(context: DomainRouteContext): Promise<Response | null> {
17
+ const { req, rest, body, handle, notify } = context
18
+ const id = handle.id
19
+ const root = handle.root
20
+
21
+ if (rest === '/comments') {
22
+ if (req.method === 'GET') {
23
+ const bundle = await getBundle(id)
24
+ const valid = new Set<string>(bundle ? schemaRefs(bundle) : [])
25
+ for (const key of Object.keys(bundle?.overlay.sourceSpans ?? {})) valid.add(key)
26
+ const store = readComments(root)
27
+ for (const comment of store.comments) {
28
+ for (const anchor of comment.anchorRefs) {
29
+ if (anchor.kind !== 'schema') valid.add(anchor.ref)
30
+ }
31
+ }
32
+ return json(markOrphans(root, [...valid]))
33
+ }
34
+ if (body.action === 'create') {
35
+ const bundle = await getBundle(id)
36
+ const comment = upsertComment(root, {
37
+ anchors: body.anchors ?? [],
38
+ anchorRefs: body.anchorRefs ?? [],
39
+ text: body.text,
40
+ firstRole: body.firstRole,
41
+ type: body.type,
42
+ options: body.options,
43
+ schemaVersion: bundle?.renderFingerprint,
44
+ })
45
+ notify({ type: 'comments', domainId: id })
46
+ return json(comment)
47
+ }
48
+ if (body.action === 'reply') {
49
+ const comment = addThreadEntry(root, body.id, body.entry)
50
+ notify({ type: 'comments', domainId: id })
51
+ return comment ? json(comment) : notFound()
52
+ }
53
+ if (body.action === 'edit') {
54
+ const comment = editThreadEntry(root, body.id, body.entryId, String(body.text ?? ''))
55
+ notify({ type: 'comments', domainId: id })
56
+ return comment ? json(comment) : notFound()
57
+ }
58
+ if (body.action === 'status') {
59
+ const comment = setStatus(root, body.id, body.status, body.closeNote)
60
+ notify({ type: 'comments', domainId: id })
61
+ return comment ? json(comment) : notFound()
62
+ }
63
+ if (body.action === 'delete') {
64
+ const ok = deleteComment(root, body.id)
65
+ notify({ type: 'comments', domainId: id })
66
+ return json({ ok })
67
+ }
68
+ return badRequest('unknown comments action')
69
+ }
70
+
71
+ if (rest === '/comments/merge' && req.method === 'POST') {
72
+ const bundle = await getBundle(id)
73
+ try {
74
+ const result = mergeReply(root, bundle?.renderFingerprint ?? '', String(body.text ?? ''), {
75
+ dedupeAuthorText: true,
76
+ })
77
+ notify({ type: 'comments', domainId: id })
78
+ return json(result)
79
+ } catch (error: any) {
80
+ return badRequest(String(error?.message ?? error))
81
+ }
82
+ }
83
+
84
+ return null
85
+ }
@@ -0,0 +1,61 @@
1
+ /** Authored/automatic agent context and copy-handoff routes. */
2
+ import { getBundle } from '../cache'
3
+ import { buildCopyMarkdown } from '../handoff/copy'
4
+ import { refreshAuto } from '../handoff/service'
5
+ import { readComments } from '../state/comments'
6
+ import {
7
+ addUserContext,
8
+ deleteContext,
9
+ readContext,
10
+ setAutoInclude,
11
+ updateContext,
12
+ } from '../state/context'
13
+ import { listDocuments } from '../state/documents'
14
+ import { badRequest, json, notFound, type DomainRouteContext } from './http'
15
+
16
+ export async function handleContextRoute(context: DomainRouteContext): Promise<Response | null> {
17
+ const { req, rest, body, handle, notify } = context
18
+ const id = handle.id
19
+ const root = handle.root
20
+
21
+ if (rest === '/context') {
22
+ if (req.method === 'GET') return json(readContext(root))
23
+ if (body.action === 'add') {
24
+ const item = addUserContext(root, body)
25
+ notify({ type: 'comments', domainId: id })
26
+ return json(item)
27
+ }
28
+ if (body.action === 'update') {
29
+ const item = updateContext(root, body.id, body)
30
+ return item ? json(item) : notFound()
31
+ }
32
+ if (body.action === 'delete') return json({ ok: deleteContext(root, body.id) })
33
+ if (body.action === 'include') {
34
+ const item = setAutoInclude(root, body.id, body.include)
35
+ return item ? json(item) : notFound()
36
+ }
37
+ return badRequest('unknown context action')
38
+ }
39
+
40
+ if (rest === '/copy-payload' && req.method === 'POST') {
41
+ await refreshAuto(handle)
42
+ const bundle = await getBundle(id)
43
+ const storedContext = readContext(root)
44
+ const openComments = readComments(root).comments.filter((comment) => comment.status === 'open')
45
+ const markdown = buildCopyMarkdown({
46
+ origin: bundle?.overlay.origin ?? id,
47
+ root,
48
+ renderFingerprint: bundle?.renderFingerprint ?? '',
49
+ schemaRevision: bundle?.schemaRevision,
50
+ openComments,
51
+ userContext: storedContext.user,
52
+ autoContext: body.includeAuto
53
+ ? storedContext.auto.filter((item) => item.includeInHandoff)
54
+ : [],
55
+ documents: listDocuments(root),
56
+ })
57
+ return json({ markdown, openComments: openComments.length })
58
+ }
59
+
60
+ return null
61
+ }
@@ -0,0 +1,39 @@
1
+ /** CLI-owned update, installation status, and deploy routes for one domain. */
2
+ import { getAnatomy, getBundle } from '../cache'
3
+ import { runDeploy } from '../instances/deploy'
4
+ import { instanceStatus } from '../instances/status'
5
+ import { applyUpdates, getUpdates } from '../workspace/updates'
6
+ import { json, type DomainRouteContext } from './http'
7
+
8
+ function instanceTarget(prodTarget?: string): string | null {
9
+ const match = prodTarget?.match(/^instance:\s*(.+)$/)
10
+ return match ? match[1].trim() || null : null
11
+ }
12
+
13
+ export async function handleDeploymentRoute(context: DomainRouteContext): Promise<Response | null> {
14
+ const { req, rest, handle } = context
15
+ if (rest === '/updates' && req.method === 'GET') return json(await getUpdates(handle.root))
16
+ if (rest === '/updates/apply' && req.method === 'POST') {
17
+ return json(await applyUpdates(handle.root))
18
+ }
19
+
20
+ if (rest === '/instance') {
21
+ const target = instanceTarget((await getAnatomy(handle.id))?.overview.prodTarget)
22
+ const bundle = await getBundle(handle.id)
23
+ return json(
24
+ await instanceStatus(
25
+ handle,
26
+ target,
27
+ bundle?.overlay.origin ?? null,
28
+ bundle?.schemaRevision ?? null,
29
+ ),
30
+ )
31
+ }
32
+
33
+ if (rest === '/instance/deploy' && req.method === 'POST') {
34
+ const bundle = await getBundle(handle.id)
35
+ return json(await runDeploy(handle, bundle?.renderFingerprint ?? null))
36
+ }
37
+
38
+ return null
39
+ }
@@ -0,0 +1,55 @@
1
+ /** Context-document HTTP transport, including multipart uploads and raw bodies. */
2
+ import {
3
+ addDocument,
4
+ deleteDocument,
5
+ listDocuments,
6
+ readDocument,
7
+ updateDocument,
8
+ } from '../state/documents'
9
+ import { json, notFound, type DomainRouteContext } from './http'
10
+
11
+ /** Routes that must run before POST bodies are parsed as JSON. */
12
+ export async function handleDocumentTransport(
13
+ req: Request,
14
+ rest: string,
15
+ root: string,
16
+ ): Promise<Response | null> {
17
+ if (rest === '/context/documents' && req.method === 'GET') return json(listDocuments(root))
18
+ if (rest === '/context/documents' && req.method === 'POST') {
19
+ const form = await req.formData()
20
+ const added = []
21
+ for (const value of form.getAll('files')) {
22
+ if (value instanceof File) {
23
+ added.push(
24
+ addDocument(root, value.name, value.type, new Uint8Array(await value.arrayBuffer())),
25
+ )
26
+ }
27
+ }
28
+ return json(added)
29
+ }
30
+
31
+ const raw = rest.match(/^\/context\/documents\/([^/]+)\/raw$/)
32
+ if (raw && req.method === 'GET') {
33
+ const document = readDocument(root, decodeURIComponent(raw[1]))
34
+ if (!document) return notFound()
35
+ return new Response(Bun.file(document.abs), {
36
+ headers: { 'content-type': document.meta.type },
37
+ })
38
+ }
39
+
40
+ return null
41
+ }
42
+
43
+ /** JSON document mutations dispatched with the rest of the domain routes. */
44
+ export function handleDocumentMutation(context: DomainRouteContext): Response | null {
45
+ const { req, rest, body, handle } = context
46
+ const root = handle.root
47
+ if (rest === '/context/documents/delete' && req.method === 'POST') {
48
+ return json({ ok: deleteDocument(root, body.id) })
49
+ }
50
+ if (rest === '/context/documents/update' && req.method === 'POST') {
51
+ const meta = updateDocument(root, body.id, new TextEncoder().encode(String(body.content ?? '')))
52
+ return meta ? json(meta) : notFound()
53
+ }
54
+ return null
55
+ }