@astrale-os/cli 0.4.0-alpha.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.check-workspace.cjs +40 -0
- package/README.md +151 -0
- package/dist/astrale.js +59749 -0
- package/package.json +90 -0
- package/src/command.ts +40 -0
- package/src/commands/__tests__/admin-instance.test.ts +73 -0
- package/src/commands/__tests__/auth-login.test.ts +178 -0
- package/src/commands/__tests__/auth-token.test.ts +223 -0
- package/src/commands/__tests__/call.test.ts +72 -0
- package/src/commands/__tests__/domain-list.test.ts +74 -0
- package/src/commands/__tests__/help-contract.test.ts +136 -0
- package/src/commands/__tests__/install-identity-override.test.ts +65 -0
- package/src/commands/__tests__/instance-bookmark.test.ts +101 -0
- package/src/commands/__tests__/instance-create-hosts.test.ts +29 -0
- package/src/commands/__tests__/instance-list-rows.test.ts +63 -0
- package/src/commands/__tests__/logs.test.ts +117 -0
- package/src/commands/__tests__/ls.test.ts +25 -0
- package/src/commands/__tests__/setup-plan.test.ts +61 -0
- package/src/commands/admin/status.ts +61 -0
- package/src/commands/admin/use.ts +77 -0
- package/src/commands/auth/login.ts +82 -0
- package/src/commands/auth/logout.ts +50 -0
- package/src/commands/auth/status.ts +86 -0
- package/src/commands/auth/token.ts +162 -0
- package/src/commands/browser.ts +207 -0
- package/src/commands/call.ts +300 -0
- package/src/commands/describe.ts +182 -0
- package/src/commands/domain/install.ts +420 -0
- package/src/commands/domain/list.ts +154 -0
- package/src/commands/domain/publish.ts +155 -0
- package/src/commands/get.ts +60 -0
- package/src/commands/identity/create.ts +26 -0
- package/src/commands/identity/delete.ts +18 -0
- package/src/commands/identity/export.ts +66 -0
- package/src/commands/identity/import.ts +101 -0
- package/src/commands/identity/list.ts +56 -0
- package/src/commands/identity/register.ts +170 -0
- package/src/commands/identity/sync.ts +32 -0
- package/src/commands/identity/unsync.ts +24 -0
- package/src/commands/identity/use.ts +18 -0
- package/src/commands/identity/whoami.ts +34 -0
- package/src/commands/idp/add.ts +150 -0
- package/src/commands/idp/list.ts +57 -0
- package/src/commands/idp/refresh.ts +38 -0
- package/src/commands/idp/remove.ts +36 -0
- package/src/commands/idp/show.ts +29 -0
- package/src/commands/instance/active.ts +64 -0
- package/src/commands/instance/bookmark.ts +72 -0
- package/src/commands/instance/create.ts +69 -0
- package/src/commands/instance/delete.ts +72 -0
- package/src/commands/instance/forget.ts +26 -0
- package/src/commands/instance/list.ts +149 -0
- package/src/commands/instance/status.ts +42 -0
- package/src/commands/instance/use.ts +210 -0
- package/src/commands/logs.ts +347 -0
- package/src/commands/ls.ts +229 -0
- package/src/commands/query.ts +32 -0
- package/src/commands/setup.ts +54 -0
- package/src/commands/status.ts +60 -0
- package/src/commands/studio.ts +401 -0
- package/src/commands/token.ts +77 -0
- package/src/commands/update.ts +267 -0
- package/src/commands/use.ts +87 -0
- package/src/errors.ts +65 -0
- package/src/kernel/__tests__/auth.test.ts +77 -0
- package/src/kernel/__tests__/errors.test.ts +43 -0
- package/src/kernel/__tests__/remote-routing.test.ts +70 -0
- package/src/kernel/auth.ts +234 -0
- package/src/kernel/ca-fetch.ts +119 -0
- package/src/kernel/client.ts +191 -0
- package/src/kernel/errors.ts +280 -0
- package/src/kernel/expand.ts +217 -0
- package/src/kernel/index.ts +14 -0
- package/src/kernel/options.ts +22 -0
- package/src/kernel/remote-routing.ts +88 -0
- package/src/kernel/run.ts +63 -0
- package/src/kernel/types.ts +14 -0
- package/src/lib/__tests__/admin-target.test.ts +112 -0
- package/src/lib/__tests__/binary.test.ts +56 -0
- package/src/lib/__tests__/command-dx.test.ts +58 -0
- package/src/lib/__tests__/concurrency.test.ts +62 -0
- package/src/lib/__tests__/config.test.ts +53 -0
- package/src/lib/__tests__/design.test.ts +99 -0
- package/src/lib/__tests__/domain-identity.test.ts +60 -0
- package/src/lib/__tests__/format.test.ts +22 -0
- package/src/lib/__tests__/fs-atomic.test.ts +104 -0
- package/src/lib/__tests__/identity.test.ts +79 -0
- package/src/lib/__tests__/idp-session.driver.ts +53 -0
- package/src/lib/__tests__/idp-session.test.ts +357 -0
- package/src/lib/__tests__/idp.test.ts +385 -0
- package/src/lib/__tests__/instance-candidates.test.ts +73 -0
- package/src/lib/__tests__/instance-target.test.ts +183 -0
- package/src/lib/__tests__/instance.test.ts +136 -0
- package/src/lib/__tests__/keys.test.ts +129 -0
- package/src/lib/__tests__/local-status.test.ts +202 -0
- package/src/lib/__tests__/output.test.ts +150 -0
- package/src/lib/__tests__/panel.test.ts +40 -0
- package/src/lib/__tests__/port.test.ts +44 -0
- package/src/lib/__tests__/prompt.test.ts +25 -0
- package/src/lib/__tests__/sdk-deps.test.ts +68 -0
- package/src/lib/__tests__/self.test.ts +272 -0
- package/src/lib/__tests__/studio-server-deps.test.ts +74 -0
- package/src/lib/__tests__/table.test.ts +53 -0
- package/src/lib/__tests__/update.test.ts +246 -0
- package/src/lib/__tests__/use-target.test.ts +56 -0
- package/src/lib/__tests__/validation.test.ts +34 -0
- package/src/lib/admin-domain.ts +25 -0
- package/src/lib/admin-instance.ts +26 -0
- package/src/lib/admin-target.ts +217 -0
- package/src/lib/binary.ts +131 -0
- package/src/lib/browser.ts +150 -0
- package/src/lib/command-dx.ts +161 -0
- package/src/lib/concurrency.ts +31 -0
- package/src/lib/config.ts +45 -0
- package/src/lib/domain-identity.ts +49 -0
- package/src/lib/env.ts +49 -0
- package/src/lib/format.ts +4 -0
- package/src/lib/fs-atomic.ts +126 -0
- package/src/lib/identity.ts +256 -0
- package/src/lib/idp-session.ts +134 -0
- package/src/lib/idp.ts +876 -0
- package/src/lib/instance-candidates.ts +49 -0
- package/src/lib/instance-target.ts +182 -0
- package/src/lib/instance.ts +395 -0
- package/src/lib/keys.ts +294 -0
- package/src/lib/local-status.ts +152 -0
- package/src/lib/log.ts +116 -0
- package/src/lib/login-flow.ts +164 -0
- package/src/lib/meta.ts +86 -0
- package/src/lib/output.ts +222 -0
- package/src/lib/panel.ts +61 -0
- package/src/lib/paths.ts +11 -0
- package/src/lib/port.ts +41 -0
- package/src/lib/proc.ts +82 -0
- package/src/lib/prompt.ts +136 -0
- package/src/lib/provision-instance.ts +170 -0
- package/src/lib/sdk-deps.ts +104 -0
- package/src/lib/self.ts +166 -0
- package/src/lib/skills.ts +171 -0
- package/src/lib/table.ts +62 -0
- package/src/lib/update.ts +315 -0
- package/src/lib/use-target.ts +24 -0
- package/src/lib/validation.ts +59 -0
- package/src/program.ts +200 -0
- package/src/registry.ts +59 -0
- package/src/setup/__tests__/util.test.ts +29 -0
- package/src/setup/engine.ts +83 -0
- package/src/setup/render.ts +109 -0
- package/src/setup/steps/admin.ts +78 -0
- package/src/setup/steps/agent-browser.ts +81 -0
- package/src/setup/steps/auth.ts +54 -0
- package/src/setup/steps/domain.ts +68 -0
- package/src/setup/steps/index.ts +18 -0
- package/src/setup/steps/instance.ts +119 -0
- package/src/setup/steps/skills-bridge.ts +70 -0
- package/src/setup/steps/skills.ts +59 -0
- package/src/setup/types.ts +61 -0
- package/src/setup/util.ts +34 -0
- package/src/test-utils.ts +18 -0
- package/studio/client/dist/assets/index-DOwzZAEK.css +1 -0
- package/studio/client/dist/assets/index-wtU0Zxhy.js +183 -0
- package/studio/client/dist/index.html +13 -0
- package/studio/package.json +62 -0
- package/studio/server/agent/ask.ts +68 -0
- package/studio/server/agent/bridge-mcp.ts +182 -0
- package/studio/server/agent/bridge.ts +188 -0
- package/studio/server/agent/claude.ts +666 -0
- package/studio/server/agent/mock.ts +186 -0
- package/studio/server/agent/prompt.ts +202 -0
- package/studio/server/agent/registry.ts +29 -0
- package/studio/server/agent/runner.ts +484 -0
- package/studio/server/agent/schema-map.ts +112 -0
- package/studio/server/agent/types.ts +120 -0
- package/studio/server/api.ts +574 -0
- package/studio/server/cache.ts +138 -0
- package/studio/server/detect.ts +81 -0
- package/studio/server/domain.ts +70 -0
- package/studio/server/index.ts +136 -0
- package/studio/server/introspect/anatomy-extras.ts +398 -0
- package/studio/server/introspect/anatomy.ts +108 -0
- package/studio/server/introspect/bundle.ts +57 -0
- package/studio/server/introspect/core-extractor.ts +119 -0
- package/studio/server/introspect/core.ts +44 -0
- package/studio/server/introspect/diff.ts +133 -0
- package/studio/server/introspect/extractor.ts +102 -0
- package/studio/server/introspect/hash.ts +21 -0
- package/studio/server/introspect/overlay-tsmorph.ts +874 -0
- package/studio/server/introspect/overlay.ts +57 -0
- package/studio/server/introspect/runtime.ts +99 -0
- package/studio/server/introspect/schema-refs.ts +46 -0
- package/studio/server/lifecycle.ts +38 -0
- package/studio/server/sse.ts +57 -0
- package/studio/server/state/baseline.ts +211 -0
- package/studio/server/state/catalog.ts +117 -0
- package/studio/server/state/comments.ts +321 -0
- package/studio/server/state/context.ts +167 -0
- package/studio/server/state/copy.ts +156 -0
- package/studio/server/state/create.ts +156 -0
- package/studio/server/state/documents.ts +70 -0
- package/studio/server/state/env.ts +161 -0
- package/studio/server/state/git.ts +75 -0
- package/studio/server/state/handoff.ts +55 -0
- package/studio/server/state/harness-gateway.ts +181 -0
- package/studio/server/state/harness-token.ts +0 -0
- package/studio/server/state/instance.ts +244 -0
- package/studio/server/state/integrations.ts +55 -0
- package/studio/server/state/layout.ts +55 -0
- package/studio/server/state/settings.ts +39 -0
- package/studio/server/state/store.ts +97 -0
- package/studio/server/state/updates.ts +63 -0
- package/studio/server/state/usage.ts +37 -0
- package/studio/server/state/views.ts +138 -0
- package/studio/server/state/visibility.ts +33 -0
- package/studio/server/watch.ts +81 -0
- package/studio/server/workspace-state.ts +26 -0
- package/studio/server/workspace-watch.ts +101 -0
- package/studio/shared/types.ts +873 -0
- package/studio/tsconfig.json +23 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/types.ts — the harness-agnostic contract for "run one agent turn".
|
|
3
|
+
*
|
|
4
|
+
* A *harness* is whatever local AI agent does the edits (Claude Code, Codex, …).
|
|
5
|
+
* The studio never talks to a model API directly; it shells out to the harness
|
|
6
|
+
* the user already has running locally (no cloud API key of our own). Each
|
|
7
|
+
* harness maps its native streaming output onto `AgentStreamEvent`s and returns
|
|
8
|
+
* a final text blob (which carries the machine-state reply block).
|
|
9
|
+
*/
|
|
10
|
+
import type { AgentEffort, AgentEventKind, HarnessLoadout } from '../../shared/types'
|
|
11
|
+
|
|
12
|
+
/** A single normalized activity event emitted while the harness runs. */
|
|
13
|
+
export interface AgentStreamEvent {
|
|
14
|
+
kind: AgentEventKind
|
|
15
|
+
text: string
|
|
16
|
+
tool?: string
|
|
17
|
+
target?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AgentTurnInput {
|
|
21
|
+
/** the domain repo root — the agent's working directory */
|
|
22
|
+
root: string
|
|
23
|
+
/** the turn message (scaffolded handoff / delta) */
|
|
24
|
+
prompt: string
|
|
25
|
+
/** appended to the harness's default system prompt — the reply protocol */
|
|
26
|
+
appendSystemPrompt?: string
|
|
27
|
+
/** prior harness session id → resume the same conversation */
|
|
28
|
+
sessionId?: string
|
|
29
|
+
/** harness reasoning effort for this turn */
|
|
30
|
+
effort?: AgentEffort
|
|
31
|
+
/** path to a generated MCP config exposing the studio write-back bridge */
|
|
32
|
+
mcpConfigPath?: string
|
|
33
|
+
/** extra env merged into the harness CHILD process only (e.g. ANTHROPIC_BASE_URL
|
|
34
|
+
* / ANTHROPIC_AUTH_TOKEN to route through a custom model gateway). Never touches
|
|
35
|
+
* the studio's own env or the user's shell / global `claude`. */
|
|
36
|
+
env?: Record<string, string>
|
|
37
|
+
/** abort the turn (user pressed Cancel) */
|
|
38
|
+
signal: AbortSignal
|
|
39
|
+
/** called for every normalized activity event */
|
|
40
|
+
onEvent: (e: AgentStreamEvent) => void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface AgentTurnResult {
|
|
44
|
+
/** session id to persist for the next turn's resume */
|
|
45
|
+
sessionId?: string
|
|
46
|
+
/** the agent's final message text (parsed for the machine-state reply block) */
|
|
47
|
+
finalText: string
|
|
48
|
+
costUsd?: number
|
|
49
|
+
numTurns?: number
|
|
50
|
+
/** total tokens processed (input + output + cache) */
|
|
51
|
+
tokens?: number
|
|
52
|
+
isError: boolean
|
|
53
|
+
errorMessage?: string
|
|
54
|
+
/** the resume sessionId we passed was rejected by the harness (the conversation
|
|
55
|
+
* no longer exists / expired). The runner uses this to transparently restart the
|
|
56
|
+
* turn as a fresh conversation instead of failing — and to know it's safe to drop
|
|
57
|
+
* the stored id (vs. keeping it across an unrelated transient failure). */
|
|
58
|
+
resumeRejected?: boolean
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A quick, ephemeral "side question" — a FORK of the conversation that inherits
|
|
62
|
+
* the parent's context but writes nothing back to it (see agent/ask.ts). Streamed. */
|
|
63
|
+
export interface AskInput {
|
|
64
|
+
root: string
|
|
65
|
+
/** the composed question (prefix + injected target context + the user's text) */
|
|
66
|
+
prompt: string
|
|
67
|
+
/** the ask protocol, appended to the harness default system prompt */
|
|
68
|
+
appendSystemPrompt?: string
|
|
69
|
+
/** the domain's live conversation session id to FORK from (undefined ⇒ fresh, no fork) */
|
|
70
|
+
sessionId?: string
|
|
71
|
+
/** harness reasoning effort for this side question */
|
|
72
|
+
effort?: AgentEffort
|
|
73
|
+
/** extra env merged into the harness child only (custom model gateway) — see AgentTurnInput.env */
|
|
74
|
+
env?: Record<string, string>
|
|
75
|
+
signal: AbortSignal
|
|
76
|
+
/** called with each chunk of the answer as it streams in */
|
|
77
|
+
onDelta: (text: string) => void
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface AskResult {
|
|
81
|
+
text: string
|
|
82
|
+
isError: boolean
|
|
83
|
+
errorMessage?: string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A richer install/health probe result for the UI (binary found? which version?). */
|
|
87
|
+
export interface HarnessHealth {
|
|
88
|
+
ok: boolean
|
|
89
|
+
version?: string
|
|
90
|
+
/** the binary / command probed (so the user can fix PATH) */
|
|
91
|
+
bin?: string
|
|
92
|
+
/** human-readable reason when not ok */
|
|
93
|
+
detail?: string
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface AgentHarness {
|
|
97
|
+
/** stable id, e.g. 'claude' | 'mock' | 'codex' */
|
|
98
|
+
id: string
|
|
99
|
+
/** human label for the UI */
|
|
100
|
+
label: string
|
|
101
|
+
/** is the underlying CLI/binary actually invokable here? */
|
|
102
|
+
isAvailable(): Promise<boolean>
|
|
103
|
+
/** optional: a richer install probe for the UI (version + reason). Harnesses that
|
|
104
|
+
* omit it fall back to isAvailable(). */
|
|
105
|
+
health?(): Promise<HarnessHealth>
|
|
106
|
+
run(input: AgentTurnInput): Promise<AgentTurnResult>
|
|
107
|
+
/** optional: answer a quick forked side question (Ask). Harnesses without a
|
|
108
|
+
* fork concept simply omit this; the studio falls back gracefully. */
|
|
109
|
+
ask?(input: AskInput): Promise<AskResult>
|
|
110
|
+
/** optional: report what the harness ACTUALLY loaded for `root` (skills, MCP,
|
|
111
|
+
* tools, agents) — a read-only window into the agent for the Settings page.
|
|
112
|
+
* Harnesses without an introspectable loadout omit it. `env` (e.g. a custom
|
|
113
|
+
* model gateway) is merged into the probe child so the reported model reflects it. */
|
|
114
|
+
loadout?(root: string, env?: Record<string, string>): Promise<HarnessLoadout>
|
|
115
|
+
/** optional: the raw SKILL.md for a skill command, so the UI can show it. */
|
|
116
|
+
skillContent?(
|
|
117
|
+
root: string,
|
|
118
|
+
command: string,
|
|
119
|
+
): Promise<{ command: string; content: string; path: string } | null>
|
|
120
|
+
}
|
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* api.ts — the JSON API router. Pure read endpoints come from the cache; writes
|
|
3
|
+
* go through the allow-listed state modules. Returns null for non-/api paths.
|
|
4
|
+
*/
|
|
5
|
+
import type { StudioEvent } from '../shared/types'
|
|
6
|
+
|
|
7
|
+
import { buildSystemPrompt } from './agent/prompt'
|
|
8
|
+
import { getHarness, listHarnesses } from './agent/registry'
|
|
9
|
+
import {
|
|
10
|
+
cancelRun,
|
|
11
|
+
getSessionId,
|
|
12
|
+
getSnapshot,
|
|
13
|
+
resetConversation,
|
|
14
|
+
setSessionId,
|
|
15
|
+
submitRun,
|
|
16
|
+
} from './agent/runner'
|
|
17
|
+
import { getBundle, getAnatomy, getCore, invalidate } from './cache'
|
|
18
|
+
import { type DomainHandle, allDomains, depsInstalled, getDomain } from './domain'
|
|
19
|
+
import { schemaRefs } from './introspect/schema-refs'
|
|
20
|
+
import { buildCatalog } from './state/catalog'
|
|
21
|
+
import {
|
|
22
|
+
addThreadEntry,
|
|
23
|
+
deleteComment,
|
|
24
|
+
editThreadEntry,
|
|
25
|
+
markOrphans,
|
|
26
|
+
mergeReply,
|
|
27
|
+
readComments,
|
|
28
|
+
setStatus,
|
|
29
|
+
upsertComment,
|
|
30
|
+
} from './state/comments'
|
|
31
|
+
import {
|
|
32
|
+
addUserContext,
|
|
33
|
+
deleteContext,
|
|
34
|
+
readContext,
|
|
35
|
+
setAutoInclude,
|
|
36
|
+
updateContext,
|
|
37
|
+
} from './state/context'
|
|
38
|
+
import { buildCopyMarkdown } from './state/copy'
|
|
39
|
+
import { createDomain } from './state/create'
|
|
40
|
+
import {
|
|
41
|
+
addDocument,
|
|
42
|
+
deleteDocument,
|
|
43
|
+
listDocuments,
|
|
44
|
+
readDocument,
|
|
45
|
+
updateDocument,
|
|
46
|
+
} from './state/documents'
|
|
47
|
+
import { isEnvName, readEnvModel, writeEnvUpdates } from './state/env'
|
|
48
|
+
import { detectGit } from './state/git'
|
|
49
|
+
import { refreshAuto } from './state/handoff'
|
|
50
|
+
import {
|
|
51
|
+
activeInstanceName,
|
|
52
|
+
instanceStatus,
|
|
53
|
+
listInstances,
|
|
54
|
+
runDeploy,
|
|
55
|
+
setActiveInstance,
|
|
56
|
+
} from './state/instance'
|
|
57
|
+
import {
|
|
58
|
+
clearHarnessGateway,
|
|
59
|
+
gatewayAudience,
|
|
60
|
+
getHarnessGatewayState,
|
|
61
|
+
resolveHarnessEnv,
|
|
62
|
+
setHarnessGateway,
|
|
63
|
+
} from './state/harness-gateway'
|
|
64
|
+
import { setHostToken } from './state/harness-token'
|
|
65
|
+
import { deleteIntegration, readIntegrations, upsertIntegration } from './state/integrations'
|
|
66
|
+
import { readLayout, resetLayout, saveLayout, setNodePositions } from './state/layout'
|
|
67
|
+
import { readSettings, updateSettings } from './state/settings'
|
|
68
|
+
import { applyUpdates, getUpdates } from './state/updates'
|
|
69
|
+
import { readUsage } from './state/usage'
|
|
70
|
+
import { mintPreviewToken, resolveViewUrl } from './state/views'
|
|
71
|
+
import { readVisibility, resetVisibility, saveVisibility } from './state/visibility'
|
|
72
|
+
|
|
73
|
+
function json(data: unknown, status = 200): Response {
|
|
74
|
+
return new Response(JSON.stringify(data), {
|
|
75
|
+
status,
|
|
76
|
+
headers: { 'content-type': 'application/json' },
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
const notFound = () => json({ error: 'not found' }, 404)
|
|
80
|
+
const badReq = (m: string) => json({ error: m }, 400)
|
|
81
|
+
|
|
82
|
+
/** The deploy-target INSTANCE name, or null for route-based / unset targets (which have no instance to query). */
|
|
83
|
+
function instanceTarget(prodTarget?: string): string | null {
|
|
84
|
+
const m = prodTarget?.match(/^instance:\s*(.+)$/)
|
|
85
|
+
return m ? m[1].trim() || null : null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Block cross-site state-changing requests (CSRF defense-in-depth on top of the
|
|
90
|
+
* loopback bind). A mutation triggers the local agent (file edits + shell), so a
|
|
91
|
+
* malicious page the user visits must NOT be able to POST here. The studio's own
|
|
92
|
+
* SPA is same-origin; curl/CLI/the MCP bridge send no Origin/Sec-Fetch-Site.
|
|
93
|
+
*/
|
|
94
|
+
function crossSiteBlocked(req: Request, url: URL): boolean {
|
|
95
|
+
if (req.method === 'GET' || req.method === 'HEAD') return false
|
|
96
|
+
const sfs = req.headers.get('sec-fetch-site')
|
|
97
|
+
if (sfs) return !(sfs === 'same-origin' || sfs === 'none')
|
|
98
|
+
const origin = req.headers.get('origin')
|
|
99
|
+
if (origin) {
|
|
100
|
+
try {
|
|
101
|
+
return new URL(origin).host !== url.host
|
|
102
|
+
} catch {
|
|
103
|
+
return true
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return false // no Origin + no Sec-Fetch-Site → a non-browser client (curl/CLI/MCP)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type Notify = (e: StudioEvent) => void
|
|
110
|
+
|
|
111
|
+
export async function handleApi(req: Request, url: URL, notify: Notify): Promise<Response | null> {
|
|
112
|
+
const path = url.pathname
|
|
113
|
+
if (!path.startsWith('/api/')) return null
|
|
114
|
+
if (crossSiteBlocked(req, url)) return json({ error: 'cross-site request blocked' }, 403)
|
|
115
|
+
|
|
116
|
+
if (path === '/api/workspace') {
|
|
117
|
+
const out = await Promise.all(
|
|
118
|
+
allDomains().map(async (h) => {
|
|
119
|
+
const bundle = await getBundle(h.id)
|
|
120
|
+
return {
|
|
121
|
+
id: h.id,
|
|
122
|
+
origin: bundle?.overlay.origin || h.origin || h.id,
|
|
123
|
+
path: h.root,
|
|
124
|
+
schemaDir: h.schemaDirName,
|
|
125
|
+
depsInstalled: depsInstalled(h.root),
|
|
126
|
+
hasGit: detectGit(h.root).hasGit,
|
|
127
|
+
configFile: h.configFile,
|
|
128
|
+
}
|
|
129
|
+
}),
|
|
130
|
+
)
|
|
131
|
+
return json(out)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Scaffold a brand-new domain into the workspace (the one write that ADDS a domain).
|
|
135
|
+
if (path === '/api/workspace/create' && req.method === 'POST') {
|
|
136
|
+
const body = await req.json().catch(() => ({}))
|
|
137
|
+
const name = String(body.name ?? '')
|
|
138
|
+
const result = await createDomain(name, await activeInstanceName())
|
|
139
|
+
if (result.ok) notify({ type: 'workspace', domains: allDomains().map((d) => d.id) })
|
|
140
|
+
return json(result) // 200 with {ok:false,error} on failure — the SPA reads the result shape
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (path === '/api/catalog') {
|
|
144
|
+
return json(buildCatalog(allDomains().map((h) => ({ origin: h.origin ?? h.id, id: h.id }))))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── instances (GLOBAL — the active instance is CLI-owned, not per-domain) ──
|
|
148
|
+
if (path === '/api/instances' && req.method === 'GET') return json(await listInstances())
|
|
149
|
+
if (path === '/api/instances/use' && req.method === 'POST') {
|
|
150
|
+
const body = await req.json().catch(() => ({}))
|
|
151
|
+
const name = String(body.name ?? '').trim()
|
|
152
|
+
if (!name) return badReq('name is required')
|
|
153
|
+
return json(await setActiveInstance(name))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// /api/domain/:id/...
|
|
157
|
+
const m = path.match(/^\/api\/domain\/([^/]+)(\/.*)?$/)
|
|
158
|
+
if (!m) return notFound()
|
|
159
|
+
const id = decodeURIComponent(m[1])
|
|
160
|
+
const rest = m[2] ?? ''
|
|
161
|
+
const handle = getDomain(id)
|
|
162
|
+
if (!handle) return notFound()
|
|
163
|
+
const root = handle.root
|
|
164
|
+
|
|
165
|
+
// ── context documents (handled BEFORE the json body parse — uploads are multipart) ──
|
|
166
|
+
if (rest === '/context/documents' && req.method === 'GET') return json(listDocuments(root))
|
|
167
|
+
if (rest === '/context/documents' && req.method === 'POST') {
|
|
168
|
+
const form = await req.formData()
|
|
169
|
+
const added = []
|
|
170
|
+
for (const value of form.getAll('files')) {
|
|
171
|
+
if (value instanceof File)
|
|
172
|
+
added.push(
|
|
173
|
+
addDocument(root, value.name, value.type, new Uint8Array(await value.arrayBuffer())),
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
return json(added)
|
|
177
|
+
}
|
|
178
|
+
const rawDoc = rest.match(/^\/context\/documents\/([^/]+)\/raw$/)
|
|
179
|
+
if (rawDoc && req.method === 'GET') {
|
|
180
|
+
const d = readDocument(root, decodeURIComponent(rawDoc[1]))
|
|
181
|
+
if (!d) return notFound()
|
|
182
|
+
return new Response(Bun.file(d.abs), { headers: { 'content-type': d.meta.type } })
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const body = req.method === 'POST' ? await req.json().catch(() => ({})) : {}
|
|
186
|
+
|
|
187
|
+
if (rest === '/context/documents/delete' && req.method === 'POST')
|
|
188
|
+
return json({ ok: deleteDocument(root, body.id) })
|
|
189
|
+
if (rest === '/context/documents/update' && req.method === 'POST') {
|
|
190
|
+
const meta = updateDocument(root, body.id, new TextEncoder().encode(String(body.content ?? '')))
|
|
191
|
+
return meta ? json(meta) : notFound()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── schema bundle ──
|
|
195
|
+
if (rest === '/bundle') return json(await getBundle(id, url.searchParams.has('fresh')))
|
|
196
|
+
if (rest === '/anatomy') return json(getAnatomy(id, url.searchParams.has('fresh')))
|
|
197
|
+
|
|
198
|
+
// ── update staleness (CLI + SDK deps, via `astrale update --check --json`) ──
|
|
199
|
+
if (rest === '/updates' && req.method === 'GET') return json(await getUpdates(root))
|
|
200
|
+
// ── apply the update (the "Update now" button) — runs `astrale update --yes` ──
|
|
201
|
+
if (rest === '/updates/apply' && req.method === 'POST') return json(await applyUpdates(root))
|
|
202
|
+
|
|
203
|
+
// ── core (genesis) data ──
|
|
204
|
+
if (rest === '/core') return json(await getCore(id, url.searchParams.has('fresh')))
|
|
205
|
+
|
|
206
|
+
// ── views: resolve a view's LIVE serving URL on its target instance (for the in-studio modal) ──
|
|
207
|
+
const viewUrlM = rest.match(/^\/views\/([^/]+)\/url$/)
|
|
208
|
+
if (viewUrlM && req.method === 'GET') {
|
|
209
|
+
const slug = decodeURIComponent(viewUrlM[1])
|
|
210
|
+
// slug goes into a kernel path arg — reject anything but a plain view slug (no `..`, no `/` via %2F)
|
|
211
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(slug)) return badReq('invalid view slug')
|
|
212
|
+
const target = instanceTarget(getAnatomy(id)?.overview.prodTarget)
|
|
213
|
+
const bundle = await getBundle(id)
|
|
214
|
+
return json(
|
|
215
|
+
await resolveViewUrl(
|
|
216
|
+
bundle?.overlay.origin ?? null,
|
|
217
|
+
target,
|
|
218
|
+
slug,
|
|
219
|
+
readSettings(root).viewProbeTimeoutMs,
|
|
220
|
+
),
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── views: mint a delegation token so the studio can HOST a live (authenticated) preview ──
|
|
225
|
+
const viewTokenM = rest.match(/^\/views\/([^/]+)\/preview-token$/)
|
|
226
|
+
if (viewTokenM && req.method === 'GET') {
|
|
227
|
+
const slug = decodeURIComponent(viewTokenM[1])
|
|
228
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(slug)) return badReq('invalid view slug')
|
|
229
|
+
const target = instanceTarget(getAnatomy(id)?.overview.prodTarget)
|
|
230
|
+
const bundle = await getBundle(id)
|
|
231
|
+
return json(await mintPreviewToken(bundle?.overlay.origin ?? null, target, slug))
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── instance / deploy ──
|
|
235
|
+
if (rest === '/instance') {
|
|
236
|
+
const target = instanceTarget(getAnatomy(id)?.overview.prodTarget)
|
|
237
|
+
const bundle = await getBundle(id)
|
|
238
|
+
return json(
|
|
239
|
+
await instanceStatus(
|
|
240
|
+
handle,
|
|
241
|
+
target,
|
|
242
|
+
bundle?.overlay.origin ?? null,
|
|
243
|
+
bundle?.schemaHash ?? null,
|
|
244
|
+
),
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
if (rest === '/instance/deploy' && req.method === 'POST') {
|
|
248
|
+
const bundle = await getBundle(id)
|
|
249
|
+
const result = await runDeploy(handle, bundle?.schemaHash ?? null)
|
|
250
|
+
return json(result)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── comments ──
|
|
254
|
+
if (rest === '/comments') {
|
|
255
|
+
if (req.method === 'GET') {
|
|
256
|
+
const bundle = await getBundle(id)
|
|
257
|
+
// Valid schema targets come from the IR (authoritative), not source spans —
|
|
258
|
+
// so inherited / span-less members are never falsely orphaned. Spans are
|
|
259
|
+
// unioned in only as a belt-and-suspenders for refs the IR walk might miss.
|
|
260
|
+
const valid = new Set<string>(bundle ? schemaRefs(bundle) : [])
|
|
261
|
+
for (const k of Object.keys(bundle?.overlay.sourceSpans ?? {})) valid.add(k)
|
|
262
|
+
const store = readComments(root)
|
|
263
|
+
for (const c of store.comments)
|
|
264
|
+
for (const a of c.anchorRefs) if (a.kind !== 'schema') valid.add(a.ref)
|
|
265
|
+
return json(markOrphans(root, [...valid]))
|
|
266
|
+
}
|
|
267
|
+
if (body.action === 'create') {
|
|
268
|
+
const bundle = await getBundle(id)
|
|
269
|
+
const c = upsertComment(root, {
|
|
270
|
+
anchors: body.anchors ?? [],
|
|
271
|
+
anchorRefs: body.anchorRefs ?? [],
|
|
272
|
+
text: body.text,
|
|
273
|
+
firstRole: body.firstRole,
|
|
274
|
+
type: body.type,
|
|
275
|
+
options: body.options,
|
|
276
|
+
schemaVersion: bundle?.schemaHash,
|
|
277
|
+
})
|
|
278
|
+
notify({ type: 'comments', domainId: id })
|
|
279
|
+
return json(c)
|
|
280
|
+
}
|
|
281
|
+
if (body.action === 'reply') {
|
|
282
|
+
const c = addThreadEntry(root, body.id, body.entry)
|
|
283
|
+
notify({ type: 'comments', domainId: id })
|
|
284
|
+
return c ? json(c) : notFound()
|
|
285
|
+
}
|
|
286
|
+
if (body.action === 'edit') {
|
|
287
|
+
const c = editThreadEntry(root, body.id, body.entryId, String(body.text ?? ''))
|
|
288
|
+
notify({ type: 'comments', domainId: id })
|
|
289
|
+
return c ? json(c) : notFound()
|
|
290
|
+
}
|
|
291
|
+
if (body.action === 'status') {
|
|
292
|
+
const c = setStatus(root, body.id, body.status, body.closeNote)
|
|
293
|
+
notify({ type: 'comments', domainId: id })
|
|
294
|
+
return c ? json(c) : notFound()
|
|
295
|
+
}
|
|
296
|
+
if (body.action === 'delete') {
|
|
297
|
+
const ok = deleteComment(root, body.id)
|
|
298
|
+
notify({ type: 'comments', domainId: id })
|
|
299
|
+
return json({ ok })
|
|
300
|
+
}
|
|
301
|
+
return badReq('unknown comments action')
|
|
302
|
+
}
|
|
303
|
+
if (rest === '/comments/merge' && req.method === 'POST') {
|
|
304
|
+
const bundle = await getBundle(id)
|
|
305
|
+
try {
|
|
306
|
+
const result = mergeReply(root, bundle?.schemaHash ?? '', String(body.text ?? ''), {
|
|
307
|
+
dedupeAuthorText: true,
|
|
308
|
+
})
|
|
309
|
+
notify({ type: 'comments', domainId: id })
|
|
310
|
+
return json(result)
|
|
311
|
+
} catch (e: any) {
|
|
312
|
+
return badReq(String(e?.message ?? e))
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ── agent loop (live, harness-agnostic) ──
|
|
317
|
+
if (rest === '/agent') {
|
|
318
|
+
if (req.method === 'GET') return json(await getSnapshot(id))
|
|
319
|
+
return badReq('use /agent/submit or /agent/cancel')
|
|
320
|
+
}
|
|
321
|
+
if (rest === '/agent/submit' && req.method === 'POST') {
|
|
322
|
+
const r = await submitRun(handle, notify, {
|
|
323
|
+
message: typeof body.message === 'string' ? body.message : undefined,
|
|
324
|
+
resume: body.resume === true,
|
|
325
|
+
})
|
|
326
|
+
// 200 + {error} (not 409) — the rejections ("already running" / "no open threads")
|
|
327
|
+
// are expected states, surfaced as a friendly client toast rather than a thrown error.
|
|
328
|
+
return r.error ? json({ error: r.error }) : json(r.run)
|
|
329
|
+
}
|
|
330
|
+
if (rest === '/agent/cancel' && req.method === 'POST') {
|
|
331
|
+
return json({ ok: cancelRun(id) })
|
|
332
|
+
}
|
|
333
|
+
if (rest === '/agent/reset' && req.method === 'POST') {
|
|
334
|
+
// forget the resumable conversation — the next submit starts a fresh session
|
|
335
|
+
return json({ ok: resetConversation(id) })
|
|
336
|
+
}
|
|
337
|
+
if (rest === '/agent/session') {
|
|
338
|
+
if (req.method === 'GET') return json(getSessionId(id))
|
|
339
|
+
if (req.method === 'POST') {
|
|
340
|
+
const ok = setSessionId(id, typeof body.sessionId === 'string' ? body.sessionId : '')
|
|
341
|
+
if (!ok) return badReq('the session id cannot be changed while a turn is running')
|
|
342
|
+
return json(getSessionId(id))
|
|
343
|
+
}
|
|
344
|
+
return badReq('GET or POST')
|
|
345
|
+
}
|
|
346
|
+
if (rest === '/agent/prompt/system' && req.method === 'GET') {
|
|
347
|
+
return json({ bridge: true, systemPrompt: buildSystemPrompt({ bridge: true }) })
|
|
348
|
+
}
|
|
349
|
+
if (rest === '/agent/harness' && req.method === 'GET') {
|
|
350
|
+
const h = getHarness()
|
|
351
|
+
const health = h.health
|
|
352
|
+
? await h.health()
|
|
353
|
+
: { ok: await h.isAvailable(), bin: h.id, version: undefined, detail: undefined }
|
|
354
|
+
const options = listHarnesses().filter((o) => o.id !== 'mock' || o.id === h.id)
|
|
355
|
+
const message = health.ok
|
|
356
|
+
? `Detected${health.version ? ` — ${health.version}` : ''}`
|
|
357
|
+
: (health.detail ?? `${h.label} is not detected. Is it installed and on your PATH?`)
|
|
358
|
+
return json({
|
|
359
|
+
id: h.id,
|
|
360
|
+
label: h.label,
|
|
361
|
+
bin: health.bin ?? h.id,
|
|
362
|
+
ok: health.ok,
|
|
363
|
+
version: health.version,
|
|
364
|
+
message,
|
|
365
|
+
options,
|
|
366
|
+
})
|
|
367
|
+
}
|
|
368
|
+
// What the harness ACTUALLY loaded for this domain's cwd — skills (reconciled against
|
|
369
|
+
// on-disk installs), MCP servers + status, tools, agents. Read-only window into the agent.
|
|
370
|
+
if (rest === '/agent/loadout' && req.method === 'GET') {
|
|
371
|
+
const h = getHarness()
|
|
372
|
+
if (!h.loadout)
|
|
373
|
+
return json({
|
|
374
|
+
ok: false,
|
|
375
|
+
detail: `${h.label} does not expose a loadout`,
|
|
376
|
+
tools: [],
|
|
377
|
+
mcpServers: [],
|
|
378
|
+
skills: [],
|
|
379
|
+
agents: [],
|
|
380
|
+
builtinCommandCount: 0,
|
|
381
|
+
probedAt: Date.now(),
|
|
382
|
+
})
|
|
383
|
+
const er = await resolveHarnessEnv(root)
|
|
384
|
+
return json(await h.loadout(root, er.ok ? er.env : undefined))
|
|
385
|
+
}
|
|
386
|
+
// Custom model-gateway config for the harness (per-domain + studio-global), e.g.
|
|
387
|
+
// routing Claude Code through an Astrale ai-gateway model node. The token/url are
|
|
388
|
+
// injected into the harness CHILD only — never the studio env or the user's shell.
|
|
389
|
+
if (rest === '/agent/harness-gateway') {
|
|
390
|
+
if (req.method === 'GET') return json(getHarnessGatewayState(root))
|
|
391
|
+
if (req.method === 'POST') {
|
|
392
|
+
const scope = body.scope === 'global' ? 'global' : 'domain'
|
|
393
|
+
if (body.action === 'set')
|
|
394
|
+
return json(setHarnessGateway(root, { scope, config: body.config ?? {} }))
|
|
395
|
+
if (body.action === 'clear') return json(clearHarnessGateway(root, scope))
|
|
396
|
+
return badReq('unknown harness-gateway action')
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
// EMBED seam: when the studio runs inside the Astrale GUI, the host (which holds
|
|
400
|
+
// the shell connection) relays a delegation token here for `host` auth mode. It
|
|
401
|
+
// is keyed by the configured gateway's audience and kept in memory only.
|
|
402
|
+
if (rest === '/agent/harness-gateway/host-token' && req.method === 'POST') {
|
|
403
|
+
const audience = gatewayAudience(root)
|
|
404
|
+
if (!audience) return badReq('no gateway base URL configured for this domain')
|
|
405
|
+
return json({ ok: setHostToken(audience, String(body.token ?? '')) })
|
|
406
|
+
}
|
|
407
|
+
// Domain-attributable agent spend (this studio's runs on this domain only).
|
|
408
|
+
if (rest === '/agent/usage' && req.method === 'GET') return json(readUsage(root))
|
|
409
|
+
// Raw SKILL.md for a skill command (the "view skill" action).
|
|
410
|
+
if (rest === '/agent/skill' && req.method === 'GET') {
|
|
411
|
+
const command = url.searchParams.get('command')
|
|
412
|
+
const h = getHarness()
|
|
413
|
+
if (!command || !h.skillContent) return notFound()
|
|
414
|
+
const c = await h.skillContent(root, command)
|
|
415
|
+
return c ? json(c) : notFound()
|
|
416
|
+
}
|
|
417
|
+
// Ask: a quick, ephemeral, FORKED side-question — streamed as newline-delimited
|
|
418
|
+
// JSON ({delta} chunks then a final {done}|{error}). Bypasses the run lock and
|
|
419
|
+
// never touches comments.json or the parent session.
|
|
420
|
+
if (rest === '/agent/ask' && req.method === 'POST') {
|
|
421
|
+
const { runAsk } = await import('./agent/ask')
|
|
422
|
+
const ctrl = new AbortController()
|
|
423
|
+
req.signal?.addEventListener('abort', () => ctrl.abort(), { once: true }) // client disconnect → kill the fork
|
|
424
|
+
const enc = new TextEncoder()
|
|
425
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
426
|
+
async start(controller) {
|
|
427
|
+
const send = (o: unknown) => controller.enqueue(enc.encode(`${JSON.stringify(o)}\n`))
|
|
428
|
+
try {
|
|
429
|
+
const result = await runAsk(handle, body, ctrl.signal, (delta) => send({ delta }))
|
|
430
|
+
send(
|
|
431
|
+
result.isError ? { error: result.errorMessage || 'ask failed' } : { done: result.text },
|
|
432
|
+
)
|
|
433
|
+
} catch (e: any) {
|
|
434
|
+
send({ error: String(e?.message ?? e) })
|
|
435
|
+
} finally {
|
|
436
|
+
controller.close()
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
cancel() {
|
|
440
|
+
ctrl.abort()
|
|
441
|
+
},
|
|
442
|
+
})
|
|
443
|
+
return new Response(stream, {
|
|
444
|
+
headers: { 'content-type': 'application/x-ndjson', 'cache-control': 'no-cache' },
|
|
445
|
+
})
|
|
446
|
+
}
|
|
447
|
+
// bridge write-back routes (token-guarded) handled by the agent bridge module
|
|
448
|
+
if (rest.startsWith('/agent/bridge/')) {
|
|
449
|
+
const { handleBridge } = await import('./agent/bridge')
|
|
450
|
+
return handleBridge(handle, rest.slice('/agent/bridge/'.length), req, body, notify)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ── context ──
|
|
454
|
+
if (rest === '/context') {
|
|
455
|
+
if (req.method === 'GET') return json(readContext(root))
|
|
456
|
+
if (body.action === 'add') return notifyJson(notify, id, 'comments', addUserContext(root, body))
|
|
457
|
+
if (body.action === 'update') {
|
|
458
|
+
const it = updateContext(root, body.id, body)
|
|
459
|
+
return it ? json(it) : notFound()
|
|
460
|
+
}
|
|
461
|
+
if (body.action === 'delete') return json({ ok: deleteContext(root, body.id) })
|
|
462
|
+
if (body.action === 'include') {
|
|
463
|
+
const it = setAutoInclude(root, body.id, body.include)
|
|
464
|
+
return it ? json(it) : notFound()
|
|
465
|
+
}
|
|
466
|
+
return badReq('unknown context action')
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ── integrations ──
|
|
470
|
+
if (rest === '/integrations') {
|
|
471
|
+
const detected = getAnatomy(id)?.detectedIntegrations ?? []
|
|
472
|
+
if (req.method === 'GET') return json(readIntegrations(root, detected))
|
|
473
|
+
if (body.action === 'upsert') return json(upsertIntegration(root, body))
|
|
474
|
+
if (body.action === 'delete') return json({ ok: deleteIntegration(root, body.id) })
|
|
475
|
+
return badReq('unknown integrations action')
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ── settings (per-domain power-user overrides) ──
|
|
479
|
+
if (rest === '/settings') {
|
|
480
|
+
if (req.method === 'GET') return json(readSettings(root))
|
|
481
|
+
if (body.action === 'update') {
|
|
482
|
+
const next = updateSettings(root, body.settings ?? {})
|
|
483
|
+
invalidate(id, 'anatomy') // integrationsDir changes what detection scans
|
|
484
|
+
notify({ type: 'anatomy-diff', domainId: id })
|
|
485
|
+
return json(next)
|
|
486
|
+
}
|
|
487
|
+
return badReq('unknown settings action')
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ── env vars editor: read/parse + WRITE `.env.<env>` (the read-only studio's one
|
|
491
|
+
// sanctioned domain-file writer), reconciled against env.ts's Env contract ──
|
|
492
|
+
if (rest === '/env') {
|
|
493
|
+
const envName = req.method === 'GET' ? url.searchParams.get('env') : body.env
|
|
494
|
+
if (!isEnvName(envName)) return badReq('env must be "dev" or "prod"')
|
|
495
|
+
if (req.method === 'GET') return json(readEnvModel(root, envName))
|
|
496
|
+
if (req.method === 'POST') {
|
|
497
|
+
if (!body.updates || typeof body.updates !== 'object')
|
|
498
|
+
return badReq('updates object required')
|
|
499
|
+
const updates: Record<string, string | null> = {}
|
|
500
|
+
for (const [k, v] of Object.entries(body.updates as Record<string, unknown>)) {
|
|
501
|
+
if (!/^[A-Za-z_]\w*$/.test(k)) continue // ignore bad keys
|
|
502
|
+
updates[k] = v === null ? null : String(v)
|
|
503
|
+
}
|
|
504
|
+
try {
|
|
505
|
+
const model = writeEnvUpdates(root, envName, updates)
|
|
506
|
+
notify({ type: 'anatomy-diff', domainId: id }) // env.ts/.env changed — nudge consumers
|
|
507
|
+
return json(model)
|
|
508
|
+
} catch (e: any) {
|
|
509
|
+
return badReq(String(e?.message ?? e))
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return badReq('GET or POST')
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ── graph layout (persisted manual positions) ──
|
|
516
|
+
if (rest === '/layout') {
|
|
517
|
+
const bundle = await getBundle(id)
|
|
518
|
+
if (req.method === 'GET') return json(readLayout(root))
|
|
519
|
+
if (body.action === 'set')
|
|
520
|
+
return json(setNodePositions(root, body.positions ?? {}, bundle?.schemaHash))
|
|
521
|
+
if (body.action === 'save')
|
|
522
|
+
return json(saveLayout(root, body.positions ?? {}, bundle?.schemaHash))
|
|
523
|
+
if (body.action === 'reset') {
|
|
524
|
+
resetLayout(root)
|
|
525
|
+
return json({ ok: true })
|
|
526
|
+
}
|
|
527
|
+
return badReq('unknown layout action')
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// ── canvas visibility (persisted hide-set + inherited-edge toggle) ──
|
|
531
|
+
if (rest === '/visibility') {
|
|
532
|
+
if (req.method === 'GET') return json(readVisibility(root))
|
|
533
|
+
if (body.action === 'set')
|
|
534
|
+
return json(
|
|
535
|
+
saveVisibility(root, {
|
|
536
|
+
hidden: body.hidden ?? {},
|
|
537
|
+
showInheritedEdges: body.showInheritedEdges ?? true,
|
|
538
|
+
materializedInterfaces: body.materializedInterfaces ?? {},
|
|
539
|
+
}),
|
|
540
|
+
)
|
|
541
|
+
if (body.action === 'reset') return json(resetVisibility(root))
|
|
542
|
+
return badReq('unknown visibility action')
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// ── copy payload ──
|
|
546
|
+
if (rest === '/copy-payload' && req.method === 'POST') {
|
|
547
|
+
await refreshAuto(handle)
|
|
548
|
+
const bundle = await getBundle(id)
|
|
549
|
+
const ctx = readContext(root)
|
|
550
|
+
const open = readComments(root).comments.filter((c) => c.status === 'open')
|
|
551
|
+
const md = buildCopyMarkdown({
|
|
552
|
+
origin: bundle?.overlay.origin ?? id,
|
|
553
|
+
root,
|
|
554
|
+
schemaHash: bundle?.schemaHash ?? '',
|
|
555
|
+
openComments: open,
|
|
556
|
+
userContext: ctx.user,
|
|
557
|
+
autoContext: body.includeAuto ? ctx.auto.filter((a) => a.includeInHandoff) : [],
|
|
558
|
+
documents: listDocuments(root),
|
|
559
|
+
})
|
|
560
|
+
return json({ markdown: md, openComments: open.length })
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
return notFound()
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function notifyJson(
|
|
567
|
+
notify: Notify,
|
|
568
|
+
id: string,
|
|
569
|
+
type: StudioEvent['type'],
|
|
570
|
+
data: unknown,
|
|
571
|
+
): Response {
|
|
572
|
+
notify({ type, domainId: id } as StudioEvent)
|
|
573
|
+
return json(data)
|
|
574
|
+
}
|