子应用
Spark Work 子应用(Sub-App)完整指南:先分清 V1 单 HTML 与 V2 受管多文件的真实差异,再讲清五个展示面里只有 content / panel / overlay 真有宿主、17 个能力名与权限检查的真实位置、draft/published/archived 状态机(重点:归档在代码里不可逆)、发布对 enabled 的反向影响(V1 自动启用、V2 保持禁用)、版本号递增与「当前生效版本不能删」的保护、应用数据 512 KB 上限与 revision CAS、文件空间与 .sparkapp 分享包的十项导入检查、V2 后台服务与 release pinning 的持久任务,以及 39 个 spark_app_* 工具、19 个 developer_guide 查询主题与 17 条静态诊断。附 18 行排查表。
子应用(Sub-App)是 Spark Work 里唯一允许你把界面装进平台的机制: 一段 HTML/JS(V1)或一个受管多文件项目(V2),可以跑在内容区、侧面板、悬浮层里, 数据和文件长期保存,与会话无关。管理入口是侧栏的**「我的应用」(视图 idsub-apps,带Beta标记), 但创建与改代码主要在对话里由 Agent 完成**。
这一页按真实代码讲清五件事:V1 与 V2 的区别、五个展示面里哪几个真的能跑、 能力与运行时开关的实际强制力、归档为什么不可逆、 以及 39 个 spark_app_*工具分别做什么。
1. 定位与入口
子应用是「长期资源」而不是会话内容:源码、发布版本、应用数据、文件空间都存在本机, 删掉会话不会影响它。
1.1 真实入口与文案
- 侧栏「我的应用」:导航项 id
sub-apps、文案键nav.subApps(值为「我的应用」)、图标Icons.Grid(apps/desktop/src/renderer/App.tsx:287、design/i18n/locales.ts:324)。 - 它是测试期功能:
BETA_NAV_IDS只含workflows与sub-apps, 导航文字后渲染Beta小标签(App.tsx:297、:519; 标签文案locales.ts:327=Beta)。 - 该视图渲染
<SubAppsView />(App.tsx:1971-1972); 点「打开」后切到view === 'sub-app',渲染<SubAppRunView />(App.tsx:1973-1974),这才是应用的运行页。 - 管理页头部有三个动作:刷新、导入、通过 Agent 创建(
design/views/SubAppsView.tsx:412-434); 工具栏是「搜索应用名称 / 描述」+ 状态筛选(全部 / 已发布 / 草稿)+「显示已归档的应用」开关 (SubAppsView.tsx:437-482)。 - 「通过 Agent 创建」弹窗标题是**「让 Agent 创建子应用」**, 正文明确写出强制命令:
/spark-app-create 记账工具(SubAppsView.tsx:808-831)。该命令在命令注册表里是builtin:spark-app-create(packages/agent-runtime/src/core/command-registry.ts:1304-1312), 同一族还有/spark-app、/spark-app-list、/spark-app-publish(command-registry.ts:1388)。 - 空态文案是「还没有子应用 — 在任意对话中让 Agent 帮你创建,例如「帮我做一个读书打卡子应用」」 (
SubAppsView.tsx:507-511),和上面那句一起说明产品意图:管理页不做代码编辑。
1.2 别和另外两个「应用/扩展」搞混
| 东西 | 是什么 | 入口 | 存储 |
|---|---|---|---|
| 子应用 | 跑在宿主界面里的 HTML/JS 应用;有草稿、发布版本、数据、文件 | 侧栏「我的应用」 | sub_apps等 8 张表 +<userData>下的目录 |
| 插件型连接器 | 给 Agent 加工具的运行时(GitHub / Google / Notion / Obsidian) | 扩展中心 → 连接器 | plugins/connector_accounts |
| 团队商店资产 | 把子应用或 Skill 分享给团队,用「安装」而不是「运行」 | 扩展中心 → 团队商店 | 团队资产表 + 本地安装记录 |
子应用卡片上的「发布到团队」走的是第三套(TeamAssetPublishModal,SubAppsView.tsx:837-849)。它发布的是包,不是运行实例: V2 应用发到团队后以「新草稿」安装,连接槽绑定与发布版本不随包迁移 (SubAppsView.tsx:842的 hint)。
2. 数据模型:V1 单文件与 V2 受管多文件
两种格式共用同一批表和同一个appId,靠format/ draft_format区分。V1 是「一条源码字符串」,V2 是「一个目录 + 不可变制品」。
2.1 V1 的四个结构体
定义在packages/protocol/src/sub-app.ts,字段与可选性如下 (format 缺省v1,见sub-app.ts:53-56)。
| 结构体 | 字段 | 说明 |
|---|---|---|
SubAppManifest | name: string | 应用名 |
SubAppManifest | description: string | 描述(创建时缺省'') |
SubAppManifest | icon: string | null | 图标标识;未知标识回落默认图标 |
SubAppManifest | entry: string | 入口文件名,建库缺省index.html |
SubAppManifest | surface: SubAppSurface | 展示面,建库缺省content |
SubAppManifest | permissions: string[] | 能力名数组,创建时缺省DEFAULT_SUB_APP_PERMISSIONS |
SubAppDraft | format?: 'v1' | 'v2' | 可缺省,兼容旧数据 |
SubAppDraft | revision: number | 草稿 revision,所有写操作都要求 CAS 匹配 |
SubAppDraft | source: string | 完整 HTML 源码(V1 唯一产物) |
SubAppDraft | config: Record<string, unknown> | 序列化后 ≤ 512 KB |
SubAppDraft | manifest/updatedAt | 清单与更新时间 |
SubAppRelease | id/appId/version | 发布快照主键、归属与递增版本号 |
SubAppRelease | source/config/manifest | 发布时刻的完整拷贝,不可变 |
SubAppRelease | publishedAt: string | 发布时间(ISO 字符串) |
SubAppSummary | publicationStatus | draft/published/archived |
SubAppSummary | enabled: boolean | 是否出现在应用入口,与发布状态是两个维度 |
SubAppSummary | draftRevision: number | CAS 基线 |
SubAppSummary | publishedVersion: number | null | 当前生效版本号,从未发布为null |
2.2 V2 的spark-app.json
V2 是「受管项目」:草稿是一个磁盘目录下的多文件树,发布时校验并打包成按内容寻址的制品。 包清单固定叫spark-app.json(packages/storage/src/sub-app-package.service.ts:27), 由 SubAppPackageManifestSchema校验(packages/protocol/src/sub-app-v2.ts:347-392,.strict(),多一个字段就报错)。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
schemaVersion | 2字面量 | 是 | 常量SUB_APP_PACKAGE_SCHEMA_VERSION |
name | string,1~120 | 是 | trim 后不得为空 |
description | string ≤ 400 | 否 | 缺省写入'' |
icon | string ≤ 240 / null | 否 | — |
surface | 五选一 | 是 | 与 V1 同一枚举 |
frontend.entry | 包内相对路径 | 是 | 必须真实存在于包内,否则报PACKAGE_ENTRY_MISSING |
service.runtime | 'node'字面量 | 声明 service 时必填 | 只支持 Node |
service.lifecycle | 'on-demand' | 'application' | 声明 service 时必填 | 常量SUB_APP_SERVICE_LIFECYCLES |
service.idleTimeoutSeconds | 整数 30~86400 | 否 | 运行时缺省按300 秒回收(见 §8.1) |
service.healthAction | string 1~120 | 否 | 健康检查调用的 action 名 |
permissions.sparkCapabilities | 能力名数组 ≤ 64 | 是 | 枚举取自SUB_APP_CAPABILITIES |
permissions.osEffects | ('network' | 'filesystem' | 'process')[] | 是 | 数组上限就是枚举长度 3 |
permissions.connections | string 数组 ≤ 32 | 是 | 每个值都必须在connections里有声明 |
connections[slot] | 对象 record | 否 | 见 §9.1;allowedOrigins必须是纯 origin |
contracts.backendActions | 包内相对路径 | 否 | 声明后文件必须存在 |
contracts.jobs | 包内相对路径 | 否 | 同上 |
包体积三道上限是硬编码常量:SUB_APP_PACKAGE_MAX_FILES = 1000、SUB_APP_PACKAGE_MAX_BYTES = 20 MB、SUB_APP_PACKAGE_MAX_FILE_BYTES = 5 MB(sub-app-v2.ts:6-8),在 validatePackageFiles里逐条判定 (sub-app-package.service.ts:749-768)。
路径规则共用packagePath:相对路径、≤ 240 字符、无盘符与协议前缀、 分段中不得出现空串 /./..(sub-app-v2.ts:311-320);落盘侧遍历时还会拒绝符号链接并二次校验 (sub-app-package.service.ts:900-916)。
2.3 两种格式的差异对照
| 维度 | V1 | V2 |
|---|---|---|
| 草稿形态 | sub_apps.draft_source一个字符串 | 磁盘目录projects/<appId>/rev-<n>/多文件树 |
| 草稿版本字段 | draft_revision | 另有draft_project_revision(见迁移097) |
| 清单来源 | UI/Agent 传的 manifest | 包内spark-app.json(schema 严格校验) |
| 发布产物 | sub_app_releases.source一行快照 | sub_app_artifacts按 sha256 内容寻址 + sub_app_release_artifacts关联 |
| 后台服务 | 无 | 可声明受管 Node service,随 release 原子发布 |
| 持久任务 | 无 | sparkApp.jobs.*,固定在 release 上 |
| 受管网络/连接槽 | 无 | connections+sparkApp.network/provider.request |
| 运行沙箱 | 合成文档 +capability-asset://subapp-runtime/<token> | 制品目录 +capability-asset:资源根(<base href>) |
ipc能力 | 可信内部应用可用(legacy) | 发布期直接拒绝:RAW_IPC_FORBIDDEN |
| 发布后 enabled | 自动置 1(发布即启用) | 置 0,需再手动启用 |
V1 侧「发布即启用」写在sub-app.repository.ts:325-329,注释也解释了原因 (菜单只显示「已发布 + 已启用」,否则用户还得再开一次开关)。 V2 侧相反,sub-app-platform.repository.ts:215-217明确写enabled = 0, 与 spark_app_project_publish的文档口径一致: 「每次 V2 发布后都保持禁用,需用户查看 OS effects 后显式重新启用」。 这是两套格式里最容易踩反的一条。
3. 五个展示面里,只有三个真的能跑
SUB_APP_SURFACES声明了五个值 (packages/protocol/src/sub-app.ts:17-23),但渲染路径只覆盖其中三个。 这一节把「声明」和「实际」分开写,避免你按枚举去猜行为。
3.1 每个面的真实宿主
| surface | 界面标签 | 真实渲染路径 | 状态 |
|---|---|---|---|
content | 内容区 | 切到view === 'sub-app'渲染<SubAppRunView />,主内容区 iframe | 可用 |
panel | — | <SubAppSurfaceHost />的侧面板页签 | 可用 |
overlay | 浮层 | <SubAppSurfaceHost />的透明悬浮层 | 可用 |
global-window | 全局窗口 | 没有渲染路径 | 仅声明 |
desktop-pet | 桌面宠物 | 没有渲染路径 | 仅声明 |
判定依据:kindOfSurface()只对overlay与panel 返回类型, 其余一律返回null(design/sub-app/SubAppSurfaceHost.tsx:171-174); 主进程里没有任何为子应用创建BrowserWindow的代码 (在 apps/desktop/src/main/services/SubApp*.ts与相关 IPC 文件中检索BrowserWindow为空)。global-window与desktop-pet只出现在协议枚举、建库 CHECK 约束、MCP 工具枚举、SUB_APP_SURFACE_CONTRACTS(sub-app-developer-contract.mjs:30-51)、 一处 CSS 类和一个switch标签分支里。 写应用时如果把surface 设成这两个值,发布不会报错,但用户点「打开」看不到窗口。
3.2 一个真实的标签 bug:panel 显示成英文
管理页的surfaceLabel()分支写的是case 'sidebar', 但合法枚举里没有sidebar,只有panel(SubAppsView.tsx:64-78vssub-app.ts:17-23)。 于是surfaceLabel('panel')命中default,卡片上直接显示英文panel;而sidebar那个分支永远不会被执行。 这是显示层的小瑕疵,不影响运行,但你会以为它应该是「侧栏」。
3.3 实例上限与入口
content:一次只运行一个,因为它就是当前视图本身。overlay/panel:这两类不进侧栏菜单, 统一入口是主窗口右下角的胶囊启动器(design/sub-app/SubAppSurfaceLauncher.tsx:59-69): 任何视图(含画布模式)都常驻,指针移入即展开菜单、移出延迟收起, 胶囊本体可拖动摆放位置且用localStorage记忆。- 同时运行的实例数有上限:
MAX_OVERLAY_INSTANCES = 3、MAX_PANEL_INSTANCES = 1(SubAppSurfaceHost.tsx:31-32, 在:255处按 kind 取用)。 - 管理页卡片的「更多操作 → 以浮层运行 / 以侧栏运行」只对这两类面出现 (
SubAppsView.tsx:558-571),文案按 surface 二选一。
3.4 沙箱与 CSP
应用不直接加载你的 HTML,而是被宿主合成一份运行文档再注入 CSP (design/sub-app/appRuntimeDocument.ts:34-62)。关键指令:
| 指令 | 值 | 说明 |
|---|---|---|
default-src | 'none' | 默认全禁,其余逐项放行 |
script-src | 'unsafe-inline'+ 可选'unsafe-eval'+ capability-asset:+https:``http: | 'unsafe-eval'由设置开关控制 |
connect-src | https: http:或'none' | 由「允许子应用访问外部网络」开关控制 |
frame-src/object-src/form-action | 'none' | 固定值,代码注释说明「无场景需要」 |
img-src/media-src | data: blob: https: http: safe-file:(+ 包资源) | 允许内嵌与本地文件 |
base-uri | capability-asset:或'none' | V2 用<base href>指向制品根 |
V1 的源码长度在合成文档时判定:source.length > SUB_APP_SOURCE_HARD_LIMIT直接抛错(appRuntimeDocument.ts:134-140);设置里的sourceLengthLimit是另一层更早的校验(见 §4.5)。 V1 文档通过 sub-app:runtime:put-doc登记到主进程后以capability-asset://subapp-runtime/<token>加载, 原因是srcdoc 会继承 renderer 的 CSP 而拦掉应用内联脚本 (sub-app.ts:497-508的注释)。
4. 能力与权限:哪里有真强制力
4.1 17 个能力名
SUB_APP_CAPABILITIES共17个 (sub-app.ts:30-48):
| 能力 | 能做什么(SDK 入口) |
|---|---|
runtime | 读只读运行信息sparkApp.runtime.getInfo() |
theme | 读宿主主题与语义 token、订阅变化 |
ui | 宿主内 toast |
data | 应用隔离 JSON KV:get/list/upsert/delete |
navigation | openApp/openView(视图走白名单) |
files | 应用文件空间 UTF-8 文本读写 |
clipboard | 保留未实现(无 SDK、无 Bridge 路由) |
notifications | 保留未实现(同上) |
agent | sparkApp.agent.send:从应用调宿主 Agent |
canvas | 列出画布项目、追加文本 |
media | 创建与查询文本生成媒体任务 |
browser | 内置浏览器打开、媒体识别、下载及文件后续操作 |
network | 受管 HTTP:sparkApp.network.request(需连接槽) |
provider | 受管 Provider 请求:sparkApp.provider.request |
backend | sparkApp.backend.invoke/status/on(V2 service) |
jobs | sparkApp.jobs.create/get/list/cancel |
ipc | 原始宿主 IPC 与 stream(legacy,V2 禁用) |
4.2 权限检查发生在哪一层
唯一的能力闸门在宿主 renderer 的 Bridge 里 (design/sub-app/bridgeHost.ts:396-411),顺序是:
- 如果该实例
trusted === true,直接放行—— manifest 的permissions只作为旧数据兼容字段保留。 - 否则
runtime与theme(只读宿主信息)免声明。 - 其余能力必须出现在
runtimeInfo.permissions里,否则返回PERMISSION_DENIED,提示是 「应用未声明 "<capability>" 权限,请在应用 manifest permissions 中申请后重新发布。」
MCP 工具侧也有一句必须对齐的口径:spark_app_create的permissions 参数说明写着它是「兼容旧版本的 manifest 字段…… 当前 Spark Work 子应用按平台核心内部应用运行,宿主不会用它裁剪平台 IPC、MCP、 Plugin、Skill、Provider 或模型能力」 (sub-app-mcp-server.mjs:208-209)。 也就是说:别把permissions当成安全边界来推理, 它只影响非 trusted 实例的能力裁剪。
那你的应用到底算不算 trusted?判定条件是格式,界面上没有任何开关: 两个运行入口都写死trusted: !isV2(useSubAppRunner.ts:240、:319),即V1 单 HTML 应用默认 trusted(permissions 不再裁剪能力, 但宿主未实现的域仍会落到CAPABILITY_NOT_IMPLEMENTED),V2 受管多文件应用默认非 trusted。 V2 的能力清单来自 spark-app.json的permissions.sparkCapabilities,由存储层映射为运行时的permissions(sub-app-package.service.ts:120); 漏声明不会在启动时报错,而是在第一次调用该能力时返回PERMISSION_DENIED。因此同一份界面上,一个 V1 应用和一个 V2 应用 可以表现出完全不同的权限行为——这是本节最容易踩空的一点。
过了权限检查但宿主没实现的域,会落到兜底的CAPABILITY_NOT_IMPLEMENTED(bridgeHost.ts:710-711)。clipboard与notifications就走这条路—— 连 SDK 符号都被标成 reserved(sub-app-developer-contract.mjs:337-346), V2 发布时还会被 CAPABILITY_RESERVED直接拦下。
4.3 V2 的三类权限分开声明
sparkCapabilities:平台能力,取值同上面 17 个枚举。osEffects:network/filesystem/process。 这是告知性字段,不参与运行时拦截;它的作用是让启用前能向用户展示 「这个应用会碰系统」。spark_app_set_enabled的说明要求: 含 service 或 OS effects 时必须先向用户展示「以当前用户权限运行且非安全沙箱」, 得到同意才传confirmTrustedLocal=true。connections:连接槽名列表,每个名字都要在connections里有声明对象, 否则CONNECTION_DECLARATION_MISSING。- 另外:声明了 service 却不给
backend能力会报BACKEND_PERMISSION_MISSING;声明ipc会报RAW_IPC_FORBIDDEN(两个校验都在sub-app-package.service.ts:809-842)。
4.4 设置里的三个运行时开关
位置:设置 → 子应用 → 运行时限制(分类键sub-app,卡片组件design/sub-app/SubAppRuntimeSettingsCard.tsx, 在 views/SettingsView.tsx:544注册)。 三个开关的默认值都是「放行」 (appRuntimeDocument.ts:28-32),卡片底部的说明区也直接写明「默认放行」。
| 开关 | 默认 | 关闭后的实际影响 |
|---|---|---|
| 允许子应用访问外部网络 | true | connect-src变为'none':应用不能直接 fetch/XHR 任意地址, 只能用sparkApp桥交换数据 |
| 允许运行时代码编译(unsafe-eval) | true | 去掉script-src里的'unsafe-eval': babel-standalone 这类实时编译不可用,React 需要预编译产物或createElement |
| 源码长度上限(字符) | 0(不限制) | 0表示不限制;> 0 时超限源码在合成运行文档时被拒绝, 报「子应用源码超过设置的限制(N 字符),请在设置「子应用」中调整或精简源码。」 |
这三项存在双层存储里:localStorage(键spark-settings-sub-app,用于首屏同步读取)+ 主进程settings:get/set(categorysub-app、keydata,权威源)。 改动会广播spark-settings-updated,正在运行的实例会按新设置重建运行文档(sub-appRuntimeSettings.ts:1-10、SubAppRuntimeSettingsCard.tsx:22-24)。
注意上限是两层,别只记得一层:sourceLengthLimit是用户可调的那层,SUB_APP_SOURCE_HARD_LIMIT = 5_000_000(5 MB)是 IPC/存储边界的硬上限, 即使把设置调成「不限制」也不会消失 (sub-app.ts:5-12)。协议注释还留了历史值 200_000, 说明这个默认值被刻意放开过。
5. 生命周期:归档是一条单行道
5.1 两个维度:发布状态 + enabled
publicationStatus取draft/published/ archived(sub-app.ts:25,建库侧同样有 CHECK 约束,migrations/083_sub_apps.sql:14-15);enabled是独立布尔量,建库缺省0。
| 操作 | publicationStatus | enabled | 证据 |
|---|---|---|---|
| 创建 | draft | 0 | sub-app.repository.ts:163-166 |
| V1 发布 | published | 1 | sub-app.repository.ts:325-329 |
| V2 发布 | published | 0 | sub-app-platform.repository.ts:215-217 |
| 启用 / 禁用 | 不变 | 按参数写 0/1 | sub-app.repository.ts:336-352 |
| 归档 | archived | 0 | sub-app.repository.ts:353-364 |
「出现在应用入口」的判定是两个条件同时成立:list({ menuOnly: true })会加上publication_status = 'published'且enabled = 1(sub-app.repository.ts:205-208)。 所以「草稿但已启用」不会出现在菜单里;反过来,禁用只影响可见性、不动版本。
另一个刻意的设计:setEnabled不限制「仅已发布可启用」。 代码注释解释了原因——若只允许已发布启用,草稿态应用一旦禁用就再也恢复不了, 会形成状态死角(sub-app.repository.ts:342-345)。
5.2 归档之后没有回头路(重要)
这是本篇最需要你知道的一条:代码里不存在「取消归档 / 恢复」的路径。 归档后应用会变成永久只读,所有能改状态的入口都会被同一条守卫拦下:
| 你想做的事 | 结果 | 证据 |
|---|---|---|
| 直接启用 | 抛错「已归档的子应用不能直接启用,请先恢复到草稿或发布状态。」 | sub-app.repository.ts:339-341 |
| 改草稿 / 发布 / 回滚 | 抛错「已归档的子应用不能修改,请先恢复后再操作。」 | sub-app.repository.ts:779-783(assertMutable) |
| 写 V2 项目 / 发布 V2 | 抛错「已归档的子应用不能修改或发布。」 | sub-app-platform.repository.ts:74-78(assertAppRevision) |
| 写应用数据 | 抛错「已归档的子应用不能写入数据。」 | sub-app.repository.ts:537-541 |
为什么说「没有回头路」:全仓只有三处写publication_status的 SQL——归档写'archived', V1 发布与 V2 发布写'published',而这两处发布都被上面的守卫挡住。 也就是说没有任何代码会把archived改回draft或published。
更要注意两处口径与代码不符,别被它们误导:
spark_app_archive的工具说明写「这是可逆的低风险收尾操作」 (sub-app-mcp-server.mjs:781-783)——按代码它不可逆。- 管理页的归档二次确认只提示「归档后从列表主视图隐藏,可在此页开启「归档」筛选后查看」 (
SubAppsView.tsx:297-301),没有提示从此不能再改。
唯一的「复活」办法是覆盖导入:importApp会先删掉同 id 应用的 关联行、再整条重插,并按包内是否有发布版本写成published或draft、enabled = 1(sub-app.repository.ts:606-635)。 代价是版本历史、数据、连接槽绑定都会被包内内容整体替换。 所以顺序上要先把应用「分享导出」成 .sparkapp(导出不检查归档状态), 归档后悔了再覆盖导入回来。 结论:归档适合「确实不要了但想留个存档」; 如果是暂时不用,用「禁用」(enabled=0)而不是归档。
5.3 删除是真删
管理页的删除确认写得很清楚:「将永久删除源码、全部版本与应用数据,不可恢复。」 (SubAppsView.tsx:310-315)。 数据库侧靠外键级联:sub_app_releases、sub_app_data等 都是ON DELETE CASCADE;仓储层还显式补删一次, 以兼容旧库没开foreign_keyspragma 的情况 (sub-app.repository.ts:424-440)。 删除历史版本另有约束,见 §6.2。
6. 版本与回滚
6.1 版本号怎么涨
两种格式都是取当前最大值 +1:V1 在sub-app.repository.ts:293-299,V2 在sub-app-platform.repository.ts:190-196,语句都是SELECT COALESCE(MAX(version), 0) …。 数据库对(app_id, version) 有唯一约束 (083_sub_apps.sql:43),所以并发发布不会产生重复版本号。
发布同时是一次 CAS 写:WHERE id = ? AND draft_revision = ?, 影响行数不是 1 就抛 SubAppConflictError(sub-app.repository.ts:331-332)。这意味着Agent 在对话里改了草稿之后,你手上的旧 revision 发布会失败, 需要重新读一次草稿。
V1 发布还有一道拦截:草稿源码trim()后为空直接拒绝, 注释说明「空源码发布出去必然白屏」 (sub-app.repository.ts:288-291)。
6.2 当前生效版本不能删
deleteRelease先找出应用行与目标版本,若release.id === app.published_release_id就抛错 「当前生效版本不能删除,请先发布其他版本后再删除。」 (sub-app.repository.ts:404-418)。 界面上也一致:版本历史抽屉里当前生效那一条不显示删除按钮,改成「当前生效」标记 (SubAppsView.tsx:779-793); 删除非当前版本的确认文案是「删除后不能恢复,但不会影响当前生效版本。」SubAppsView.tsx:781-786。
6.3 回滚不是切换指针
两套格式的回滚都把目标版本的内容写回草稿,而不是直接把published_release_id指过去:
- V1:读出该 release 的 source/config/manifest/entry/surface,走一次
updateDraft语义把草稿整体替换,draft_revision + 1(sub-app.repository.ts:455-492)。 - V2:把该 release 的制品目录文件全部拷回草稿项目, 写成新的
projectRevision,再markDraftAsV2(sub-app-package.service.ts:548-580)。
所以界面上「回滚」之后还要再发布一次才会生效;回滚本身不改publishedVersion。管理页的发布确认文案也从侧面说明这条链路: 「将以草稿 revision N 生成新版本,发布后不可修改。」 (SubAppsView.tsx:694-699)。
6.4 V2 制品按内容去重
发布 V2 时会算一次目录摘要(digestFiles:把路径、字节数、内容按路径排序后 哈希进同一个 sha256,sub-app-package.service.ts:871-880), 落库时写 ON CONFLICT(sha256) DO NOTHING再回查真实 id (sub-app-platform.repository.ts:154-170)—— 同样内容的包只存一份。运行时会再校验一次摘要, 不匹配直接抛「子应用发布制品完整性校验失败,已拒绝运行。」 (sub-app-package.service.ts:733-738)。 无引用的制品会被清理,sub_app_release_artifacts.artifact_id用的是ON DELETE RESTRICT(097_sub_app_platform_v2.sql:26), 防止误删仍被引用的制品。
7. 数据、文件与分享包
7.1 应用数据:KV + revision CAS
存储是sub_app_data,主键(app_id, namespace, key)(083_sub_apps.sql:47-56)。 约定与限制:
- 命名空间与键都是
text(120)/text(240): trim 后非空(sub-app.ts的 IPC schema)。 Agent 侧工具的默认命名空间是app(sub-app-mcp-server.mjs:199)。 - 值必须是可序列化 JSON 且序列化后**≤ 512 KB**, 否则抛
SubAppDataValidationError(sub-app.repository.ts:546-552)。 - 每条记录有
revision(建库 CHECK> 0)。 写入可传expectedRevision做乐观锁:键不存在却传了期望值会SubAppDataConflictError,版本不匹配同样冲突 (sub-app.repository.ts:554-580)。 - 删除必须带
expectedRevision, IPC schema 里它是必填(sub-app.ts的'sub-app:data:delete'),Agent 工具spark_app_data_delete同样要求先 get 拿 revision。
7.2 文件空间
与 KV 互补的一套「应用专属目录」:根目录是<userData>/sub-app-files/<appId>/(apps/desktop/src/main/ipc/registerSubAppIpc.ts:28; V2 侧同样在 registerSubAppPlatformIpc.ts:20构造)。 路径由主进程规范化校验,越界抛PERMISSION_DENIED「文件路径超出应用专属目录。」 (SubAppFileStore.ts:27)。
- 路径规则:正斜杠分隔、非空、≤ 240 字符、无
..段、无盘符或协议前缀 (sub-app.ts的filePath),主进程还会再做一次 join+resolve 二次校验(协议注释里明确写了这一点)。 - 单文件写入上限2 MB 文本(IPC schema 的
content: z.string().max(2_000_000)),与分享包的导出上限一致。 - 适合放导出快照、生成的 markdown、素材这类文件型内容;结构化小状态应该用 KV。
7.3 分享包.sparkapp
分享是全量语义:manifest + 草稿 + 全部发布版本 + 全命名空间 data + 文件空间,导入即可用,不做差量或选择性导入 (协议注释sub-app.ts:222-236)。 格式常量SUB_APP_SHARE_FORMAT_VERSION = 1。
| 维度 | 实际值 | 证据 |
|---|---|---|
| 完整性校验 | integrity.sha256针对「不含 integrity 字段的包体 JSON」 | sub-app.ts:222-236 |
| 是否对抗性签名 | 不是,注释明确说「用于拦截截断/误改的文件,不是对抗性签名」 | 同上 |
| 密钥 | 任何 API Key 都不进包;源码/数据里疑似明文密钥只做启发式提示,包内保留原文 | SubAppsView.tsx弹窗文案SubAppShareModals.tsx:115 |
| 导入时草稿源码上限 | 260_000字符 → 诊断为SOURCE_RUNTIME_LIMIT | SubAppShareService.ts:39、:778-790 |
| 导入时单条数据上限 | 512_000→DATA_LIMIT | SubAppShareService.ts:45、:812-818 |
| 导入时文件条数上限 | 500→FILE_LIMIT | SubAppShareService.ts:43、:836-846 |
| 单文件内容上限 | 2_000_000字符 | SubAppShareService.ts:41 |
| 包文件总大小上限 | 128_000_000字节 | SubAppShareService.ts:47、:259 |
冲突处理有两种:包内appId在本机已存在是same-id, 可以覆盖导入,覆盖前会自动把本机当前应用备份成.sparkapp并回报backupPath(sub-app.ts的 SubAppShareImportApplyResponse); 只是同名不同 id 属于same-name, 界面提示「本机有同名但不同身份的应用:默认作为新应用导入,名称会自动加后缀以区分。」 (SubAppShareModals.tsx:342)。 覆盖导入后宿主会关掉该应用的运行中实例, 避免旧源码继续跑在旧数据结构上(SubAppShareModals.tsx:209-212)。
导入前会先做一次能力与兼容性体检,检查码是固定枚举:FORMAT_VERSION、PLATFORM_VERSION、INTEGRITY、IPC_CHANNELS、SOURCE_RUNTIME_LIMIT、SECRET_HINT、DATA_LIMIT、FILE_LIMIT、DRAFT_EMPTY、V2_BUNDLE(sub-app.ts的SubAppShareImportCheck)。formatVersion不识别时会直接拦下导入。
V1 应用导出时还会静态扫一遍源码,产出capabilities报告:可解析的宿主 IPC 通道、Provider 引用、 疑似明文密钥的位置(只记位置不记内容)。 V2 另有可选的v2 段承载受管项目、全部制品与连接槽绑定; 老版本平台解析时忽略该字段,导入会退化成「空壳 V1 草稿」。
8. V2 后台服务与持久任务
这两个能力只有 V2 有,也是 V2 相对 V1 的主要增量。 它们共用一个前提:能力声明要对得上,否则发布就被拦。
8.1 受管 Node 服务
在spark-app.json里声明service即启用,字段见 §2.2。 运行时行为由主进程管理(apps/desktop/src/main/services/SubAppServiceManager.ts):
lifecycle: 'application':应用启用后就常驻,重启平台时 由restoreEnabledServices()重新拉起 (SubAppServiceManager.ts:65); 崩溃/退出在计数nextCount <= 3时仍按常驻处理(:403)。lifecycle: 'on-demand':有请求才起,空闲后按idleTimeoutSeconds回收;缺省 300 秒(SubAppServiceManager.ts:273的(published.manifest.service.idleTimeoutSeconds ?? 300) * 1_000)。- 未启用时启动会被拒:
PERMISSION_DENIED「子应用未启用,不能启动后台服务。」 (SubAppServiceManager.ts:231)。 - 状态枚举
stopped / starting / running / degraded / crashed(sub-app-v2.ts的SubAppServiceRuntimeStatus), 建库侧同集合并带 CHECK(097_sub_app_platform_v2.sql:47-49)。 - 服务日志是有界内存日志,不落库、不分片: 查询上限
limit ≤ 500(IPC schema), 管理界面的「开发与运维」抽屉读它渲染成<pre>(SubAppOperationsDrawer.tsx:203-213)。
服务入口模块的约定形态在脚手架里能直接看到: 默认导出一个 invoke(action, input, context),用context.progress(...)报进度、未知 action 抛错 (sub-app-package.service.ts的scaffoldService())。
8.2 持久任务固定在 release 上
任务表sub_app_jobs,状态枚举queued / running / succeeded / failed / cancelled / interrupted(SUB_APP_JOB_STATUSES,sub-app-v2.ts:255-263),progress是0..1的实数并带 CHECK (097_sub_app_platform_v2.sql:63-69)。
release_id是NOT NULL且外键ON DELETE RESTRICT,这就是「release pinning」: 任务绑在具体某个发布版本上,只要还有任务就不允许删掉那个版本。cancelRequested是单独标志位:取消先置位, 由执行侧在检查点把状态收敛成cancelled; 排队中取消会直接写成cancelled(SubAppJobManager.ts:49-60)。- 平台启动时
restore()会把上次残留的running任务 统一标成interrupted(SubAppJobManager.ts:19-20→interruptRunningJobs())。 所以「重启后任务还在跑」是不成立的,需要你自己按 checkpoint 续做。 - 发布前会检查是否有排队/运行中的任务,有就直接拒绝: 「子应用存在排队中或运行中的持久任务,为保持 release 一致性, 请等待任务结束或取消后再发布。」 (
sub-app-package.service.ts:494-503)。 这条最容易在「刚触发一次长任务就想去点发布」时撞上。
8.3 联合诊断
spark_app_diagnose/sub-app:diagnose会把包校验结果、 服务状态与运行时上报合并成一份结论,带一个correlationId,字段还包括runtimeObservation(最近一次运行上报的 status / errors / audit) (sub-app-v2.ts的SubAppDiagnosticResult)。 管理页的「开发与运维」抽屉最后一节「联合诊断」展示的就绪/需处理状态、 Correlation ID 与诊断条目就是它 (SubAppOperationsDrawer.tsx:229-245)。 排障时先看这里的 correlationId,再对照日志,能少绕很多路。
9. 连接槽与受管网络
9.1 声明与绑定是两件事
先在spark-app.json的connections里声明槽位, 再在运行时把槽位绑定到本机已有的 API Connection 或 Provider Profile。 绑定记录存在sub_app_connection_bindings, 主键(app_id, slot)(097_sub_app_platform_v2.sql:34-45)。
| 层 | 字段 | 取值 / 规则 |
|---|---|---|
| 声明 | kind | http-api或provider |
| 声明 | displayName | 1~120 字符 |
| 声明 | allowedOrigins | 1~20 个纯 origin:必须是 http(s)、路径为/、无查询与片段 |
| 声明 | allowPrivateNetwork | 可选布尔量;不声明则解析到私网地址一律拒绝 |
| 绑定 | bindingKind | api-connection或provider-profile |
| 绑定 | bindingId | 必须是 uuid(IPC schema),且不能超出 manifest 的授权 origin |
| 绑定 | grantedOrigins | 可选,≤ 20,同样必须是纯 origin |
origin 的校验函数叫origin,注释写明「必须是 http(s) origin, 不得包含路径、查询或片段」(sub-app-v2.ts:322-338)。 绑定越界时主进程报PERMISSION_DENIED「授权 origin 超出 manifest 声明范围。」 (apps/desktop/src/main/ipc/subAppBackend.ts:374)。
9.2 受管请求的五道检查
sparkApp.network.request/sparkApp.provider.request都走同一个网关(apps/desktop/src/main/services/SubAppNetworkGateway.ts), 依次校验:
- 应用必须已启用,否则
PERMISSION_DENIED「子应用未启用,不能发起受管网络请求。」 (:126)。 - slot 必须在 manifest 里声明过,否则「应用未声明连接权限:<slot>」(
:133)。 - 授权 origin 不能为空,否则「连接没有可用的授权 origin。」(
:146)。 - 目标 URL 不得内嵌凭据,否则「受管请求仅允许无内嵌凭据的 HTTP(S) URL。」(
:211)。 - 目标 origin 必须在授权集合内(
:214), 且解析到本地/私网地址时必须已获私网授权(:221)。
这套检查的价值在于:密钥由宿主注入,不返回给子应用(sub-app-developer-contract.mjs对sparkApp.network.request的说明)。 所以应用可以调用第三方 API 而自己看不到 Key—— 这是「受管请求」和「设置里直接开外网」的本质区别,后者是裸 fetch。 请求还有容量治理:timeoutMs限制在 1~30 秒 (IPC schema,sub-app-v2.ts的 'sub-app:network:request')。
10. 用 Agent 创建与维护
这是产品的主路径:管理页只管展示与生命周期,写代码靠对话。
10.1 工具从哪来
会话会把子应用 MCP server 以spark_app这个名字挂上 (packages/agent-runtime/src/services/session.service.ts:4481-4482), 所以模型看到的工具名是mcp__spark_app__spark_app_*。 服务实现是 packages/agent-runtime/src/tools/sub-app-mcp-server.mjs, 共39 个工具。
| 分组 | 工具 |
|---|---|
| 生命周期 | spark_app_create、spark_app_get、spark_app_list、spark_app_update_draft、spark_app_publish、spark_app_set_enabled、spark_app_archive、spark_app_rollback、spark_app_delete、spark_app_list_releases、spark_app_delete_release |
| 数据 | spark_app_data_get、spark_app_data_list、spark_app_data_set、spark_app_data_delete |
| V2 受管项目 | spark_app_scaffold、spark_app_project_status、spark_app_project_read_file、spark_app_project_write_file、spark_app_project_delete_file、spark_app_project_publish、spark_app_project_export、spark_app_project_import |
| 运行与制品 | spark_app_export_source、spark_app_service_status、spark_app_service_logs、spark_app_service_restart、spark_app_jobs_create、spark_app_jobs_get、spark_app_jobs_list、spark_app_jobs_cancel |
| 连接与诊断 | spark_app_connections_list、spark_app_connections_bind、spark_app_connections_unbind、spark_app_diagnose |
| 契约与迁移 | spark_app_developer_guide、spark_app_validate、spark_app_migration_report、spark_app_migrate_v1 |
两个对使用方式影响很大的工具说明值得原文记住:spark_app_create明确写了「仅当用户明确要求平台内置子应用时」 才调用,用户只是想开发个工具/网页时应走外部项目;拿不准要先问 (sub-app-mcp-server.mjs:531-535)。 另外 MCP 描述里有一条设计先行流程: 新建或大改必须先出界面预览给用户确认,确认前不要落地,更不要发布 (sub-app-mcp-server.mjs:206-209的DESIGN_PREVIEW_WORKFLOW_GUIDE)。
10.2 开发契约与 19 个查询主题
spark_app_developer_guide不带参数只返回主题目录; 要拿具体内容得按 topic/symbol/surface/query 精确查(工具 schema 里includeExamples默认true)。 主题共 19个 (sub-app-developer-contract.mjs:431-465):overview、surfaces、runtime-sdk、theme、data、files、network、provider、backend、jobs、agent、media、canvas、browser、security、lifecycle、publishing、troubleshooting、recipes。
契约里每个 SDK 符号都带status:implemented/legacy/ reserved,符号集合导出为IMPLEMENTED_SPARK_APP_SYMBOLS与RESERVED_SPARK_APP_SYMBOLS(sub-app-developer-contract.mjs:481-489),校验器就是拿这两集合判对错的。 legacy 一族是原始 IPC 通道:sparkApp.ipc.invoke、sparkApp.ipc.on、sparkApp.platform.ipc/invoke/on/trusted, 契约里明确写了「参数契约不能从 channel 名推断」, 并且ipc.on的 stream channel 必须以stream:开头、 卸载时必须调用异步取消函数。
10.3spark_app_validate的 17 条诊断
写完之后先跑校验比直接发布省事得多。校验器在packages/agent-runtime/src/tools/sub-app-validator.mjs, 可产出的诊断码有:
| 诊断码 | 含义 |
|---|---|
SOURCE_EMPTY | 源码为空 |
SOURCE_TOO_LARGE | 源码超过硬上限 |
UNKNOWN_SDK_CAPABILITY | 用了不存在的sparkApp.<根> |
SDK_CAPABILITY_NOT_IMPLEMENTED | 用了保留但未实现的能力(clipboard/notifications) |
UNKNOWN_SDK_METHOD | 能力域对了但方法名不存在 |
LEGACY_RAW_IPC | 用了 legacy 原始 IPC |
LEGACY_PROVIDER_SECRET_ACCESS | 试图直接读 Provider 密钥 |
POSSIBLE_INLINE_SECRET | 疑似把明文密钥写进源码 |
DIRECT_NETWORK_REQUEST | 直接 fetch/XHR,未走受管请求 |
UNSAFE_EVAL_DEPENDENCY | 依赖实时编译,需要unsafe-eval |
NON_DURABLE_BROWSER_STORAGE | 用 localStorage 之类存不该丢的数据 |
DATA_DELETE_REVISION_MISSING | data.delete缺expectedRevision |
OVERLAY_BACKGROUND_NOT_DECLARED | 浮层应用没声明背景,会看到穿透 |
THEME_INTEGRATION_NOT_DETECTED | 没接入宿主主题 token |
JAVASCRIPT_URL | 出现javascript:URL |
CSP_UNSUPPORTED_ELEMENT | 用了被 CSP 拦掉的元素(如 iframe/object) |
CSP_FORM_SUBMISSION_BLOCKED | 表单提交会被form-action 'none'拦下 |
注意这些都是静态提示,不是发布闸门——真正的发布阻断在 §2.2 那套 PACKAGE_*/MANIFEST_*校验里。 两边代码不同、码表也不通用,别把 spark_app_validate当发布预检通过来看。
10.4 V1 → V2 迁移
spark_app_migration_report先扫一遍 V1 应用,列出迁移前要处理的依赖 (原始 IPC、Provider 明文密钥、相对资源、浏览器存储);spark_app_migrate_v1再把当前 V1 单文件草稿显式转成同appId的 V2 多文件草稿。两者都不自动发布, 原 V1 历史 release 也保留 (仓储侧就是SubAppPackageService.migrateV1,sub-app-package.service.ts:127)。 迁移是显式操作(要求传当前 expectedRevision),不会在你不知情时发生。
11. 数据库与落盘位置
11.1 八张表
| 表 | 用途 | 关键约束 |
|---|---|---|
sub_apps | 应用主表(草稿、发布指针、状态、enabled) | 主键id;surface与publication_status有 CHECK;draft_revision > 0;有 (publication_status, enabled, updated_at DESC)与 name COLLATE NOCASE两个索引(083:6-26) |
sub_app_releases | 不可变发布快照 | UNIQUE(app_id, version);app_id外键CASCADE(083:28-45) |
sub_app_data | 应用 KV 数据 | 主键(app_id, namespace, key);revision > 0;外键 CASCADE |
sub_app_artifacts | V2 内容寻址制品 | sha256唯一;记录schema_version、路径、体积、文件数(097:9-20) |
sub_app_release_artifacts | release ↔ 制品关联 | release_id主键且CASCADE;artifact_id为 ON DELETE RESTRICT;含permission_digest(097:22-32) |
sub_app_connection_bindings | 连接槽绑定 | 主键(app_id, slot);binding_kind有 CHECK(097:34-45) |
sub_app_service_state | 后台服务最新状态 | app_id主键;release_id为ON DELETE SET NULL; status 有 CHECK(097:47-56) |
sub_app_jobs | 持久任务 | release_id为NOT NULL+RESTRICT; status CHECK;progress在 0~1(097:58-75) |
迁移083的开头有一句设计说明值得引用:子应用故意没有 session 外键——会话只是操作入口, 删会话不应影响应用(083_sub_apps.sql:1-4)。
11.2 磁盘目录
| 内容 | 路径 | 证据 |
|---|---|---|
| V2 受管项目与制品 | <SQLite 同级的>/sub-app-platform/ | sub-app-package.service.ts:39(path.dirname(database.path)) |
| 草稿项目(按 revision 分目录) | sub-app-platform/projects/<appId>/rev-<n>/ | sub-app-package.service.ts:744-746 |
| V2 制品 | sub-app-platform/artifacts/<sha256>/ | sub-app-package.service.ts:748-750 |
| 应用文件空间 | <userData>/sub-app-files/<appId>/ | registerSubAppIpc.ts:28 |
删除应用后cleanupDeletedApp会删掉该项目目录并清理无引用制品 (sub-app-package.service.ts:613-621);cleanupOrphanedArtifacts也能单独清理孤儿制品 (:623-628)。
12. 排查表
| 现象 | 先去哪看 | 常见原因 |
|---|---|---|
| 应用不出现在应用入口 | 「我的应用」卡片状态徽标 | 需同时published+enabled=1; V2 发布后默认禁用,要手动启用(§5.1) |
| 点「打开」没有窗口 | 卡片上的 surface 标签 | surface 是global-window/desktop-pet: 这两个面没有宿主,见 §3.1 |
卡片显示英文panel | — | 已知标签 bug,见 §3.2;不影响运行 |
| 白屏 | 应用内 console、开发与运维抽屉 | V1 常见是空源码发布(发布口已拦);CSP 相关看CSP_UNSUPPORTED_ELEMENT/CSP_FORM_SUBMISSION_BLOCKED |
| 发布报「草稿源码为空,无法发布」 | — | V1 草稿trim()后为空,见 §6.1 |
发布报SubAppConflictError | 重新读一次草稿 revision | CAS 不匹配:Agent 在对话里改过草稿,见 §6.1 |
| 发布报「存在排队中或运行中的持久任务」 | 开发与运维 → 持久任务 | 先等任务结束或取消,见 §8.2 |
| V2 发布报「V2 应用包校验失败」 | 开发与运维 → 项目(诊断列表) | 对照 §2.2 各PACKAGE_*/MANIFEST_*码定位; 常见是入口文件不存在、声明 service 没给backend、用了ipc |
| 启用后改不动、也没法重新启用 | 卡片徽标是否「已归档」 | 归档不可逆:所有写入口都被拦,唯一出路是覆盖导入备份的.sparkapp,见 §5.2 |
| 应用读不到数据 / 写数据报错 | — | archived 拒绝写入(§5.2);值超过 512 KB 报SubAppDataValidationError(§7.1) |
受管请求报PERMISSION_DENIED | manifest 的connections与绑定记录 | 五道检查逐个对照 §9.2;最常见是 slot 没声明、目标 origin 不在授权集合、 或目标解析到私网而未声明allowPrivateNetwork |
应用能力报PERMISSION_DENIED | 实例是否 trusted | 非 trusted 实例必须显式声明能力,见 §4.2; 但 MCP 口径是平台子应用不靠 permissions裁剪平台能力 |
能力报CAPABILITY_NOT_IMPLEMENTED | — | clipboard/notifications等保留未实现的域(§4.2) |
| 后台服务反复崩 | 开发与运维 → 后台服务(最近错误 + 日志) | 看restartCount与lastError;application 生命周期在计数 ≤ 3 时仍会常驻(§8.1) |
| 重启后任务没了 | 开发与运维 → 持久任务 | 启动时running会被标成interrupted,见 §8.2 |
| 删不掉某个版本 | 版本历史抽屉 | 当前生效版本不允许删除,见 §6.2 |
| 导入分享包被阻断 | 导入预览的能力检查报告 | 按 §7.3 的十个检查码定位;formatVersion不识别会直接拦 |
| Agent 不去创建子应用 | — | 这是刻意的:spark_app_create只在用户明确要求「平台内置子应用」时才用; 你可以用/spark-app-create强制,见 §10.1 |
速查表
| 项目 | 内容 |
|---|---|
| 入口 | 侧栏「我的应用」(view id sub-apps,带 Beta 标记);创建走对话 / 命令 |
| 强制命令 | /spark-app-create <需求>;同族 /spark-app、/spark-app-list、/spark-app-publish |
| 展示面 | content / panel / overlay 可真跑;global-window / desktop-pet 只有声明 |
| 实例上限 | overlay 3 个、panel 1 个;浮层与侧板走右下角胶囊启动器 |
| 能力数 | 17 个(含 clipboard / notifications 两个保留未实现) |
| 权限闸门 | renderer 的 bridgeHost.checkPermission;trusted 实例直接放行 |
| 运行时开关 | 设置 → 子应用;allowNetworkAccess / allowUnsafeEval / sourceLengthLimit 默认全放行 |
| 源码长度上限 | 设置里可调(0 = 不限制);硬上限 SUB_APP_SOURCE_HARD_LIMIT = 5,000,000 字符 |
| 状态机 | draft / published / archived + enabled;菜单要求 published 且 enabled |
| 发布对 enabled 的影响 | V1 置 1(发布即启用);V2 置 0(需手动启用) |
| 归档 | 不可逆;唯一出路是覆盖导入 .sparkapp |
| 版本号 | MAX(version) + 1;当前生效版本不能删;回滚是把内容写回草稿 |
| V2 包清单 | spark-app.json,schemaVersion 2,字段严格校验(多字段即报错) |
| V2 包上限 | 1000 文件 / 20 MB 总量 / 单文件 5 MB;拒绝符号链接 |
| 应用数据 | sub_app_data,主键 (app_id, namespace, key),值 ≤ 512 KB,带 revision CAS |
| 文件空间 | <userData>/sub-app-files/<appId>/;单文件 ≤ 2 MB 文本 |
| 受管网络 | 五道检查:启用 / slot 已声明 / origin 非空 / URL 无凭据 / origin 已授权(私网需显式授权) |
| service 生命周期 | on-demand / application;idleTimeout 缺省 300 秒 |
| 任务状态 | queued / running / succeeded / failed / cancelled / interrupted;restart 后 running → interrupted |
| 工具面 | MCP server 名 spark_app,39 个 spark_app_* 工具;developer_guide 19 个主题 |
| 分享包 | .sparkapp(formatVersion 1),全量语义,integrity 只防截断不是签名 |
| 数据库 | sub_apps / releases / data(083)+ artifacts / release_artifacts / connection_bindings / service_state / jobs(097) |
上手清单:从零做一个子应用并让它出现在应用入口
走一遍「让 Agent 创建 → 预览 → 发布 → 启用」的完整链路,并避开归档、全局窗口这类坑。
- 在任意对话里描述需求,例如「帮我做一个读书打卡子应用,能按周统计」。也可以用强制命令 /spark-app-create 读书打卡。
- 先确认展示面:要能跑就用 content(内容区)、panel(侧板)或 overlay(浮层)之一。不要选 global-window 或 desktop-pet——它们没有宿主,打开不会有窗口。
- 让 Agent 先给界面设计预览并确认。这是契约要求:新建或大幅改版必须先出预览,你确认后才开发完整实现。
- 让 Agent 用 spark_app_validate 自检。17 条诊断里最常见的是 UNKNOWN_SDK_METHOD(方法名不存在)、LEGACY_RAW_IPC(V2 禁用原始 IPC)、POSSIBLE_INLINE_SECRET(源码里有明文密钥)。
- 如果要 V2(多文件 + 后台服务 + 持久任务),让 Agent 用 spark_app_scaffold 建骨架,再用 spark_app_project_write_file 写文件。需要后台服务就选 fullstack 模板。
- 发布前先跑 spark_app_project_validate。要过 PACKAGE_* / MANIFEST_* 那套校验:入口文件必须存在、声明 service 必须同时给 backend 能力、V2 不得声明 ipc、clipboard、notifications。
- 确认没有排队中或运行中的持久任务,否则发布会被「为保持 release 一致性」拒绝。
- 发布。V1 发布后自动启用、直接进应用入口;V2 发布后是禁用状态,需要下一步。
- 打开侧栏「我的应用」,找到卡片。V2 应用先看卡片上的状态,确认按需启用;含 service 或 OS effects 时要先确认它以当前用户权限运行、不是沙箱。
- 点「打开」进入运行页。运行页可以在「发布版 / 草稿预览」之间切换:调试用草稿,验收用发布版。
- 之后要改动就在对话里改草稿,再回管理页发布。改完发现不满意可以用版本历史里的「回滚」把某个旧版本内容写回草稿——注意回滚后仍要再发布一次才生效。
- 暂时不用了请用「更多操作 → 禁用」(enabled=0),不要用归档:归档在代码里不可逆,之后改不了也启用不了。确实要清掉就用「删除」,它会连版本和数据一起永久删除。
常见问题
子应用在哪里创建?管理页里为什么没有编辑代码的入口?
在任意对话里让 Agent 创建,或直接用强制命令 /spark-app-create <需求>。管理页(侧栏「我的应用」)刻意只做展示、启动与生命周期操作——它的文档注释写明「创建与修改主要通过 Agent 会话完成」,空态文案也是引导你去对话。
我设了 global-window 或 desktop-pet,为什么点「打开」没反应?
因为这两个展示面在代码里只有声明、没有宿主:渲染层只实现了 content(运行页)、panel 与 overlay(浮层/侧板),主进程也没有为子应用创建独立窗口的代码。发布不会报错,但用户看不到窗口。要能跑就用这三个面。
V2 应用发布成功后,为什么没出现在应用入口?
这是刻意的:V2 发布时写的是 enabled = 0(而 V1 发布写 enabled = 1)。菜单可见性要求「已发布 + 已启用」两个条件同时成立,所以 V2 发布后需要你确认过 OS effects 再手动启用一次。
归档之后还能恢复吗?
按代码不能。归档会写 publication_status = archived 且 enabled = 0,之后启用、改草稿、发布、回滚、写数据全部被守卫拦下;全仓没有任何代码把 archived 改回 draft/published。唯一的「复活」方式是覆盖导入之前导出的 .sparkapp 分享包,但那会整体替换版本历史、数据与连接绑定。所以暂时不用请用「禁用」,不要用「归档」。
为什么 Agent 明明能删掉某个版本,却删不掉当前生效的那一个?
因为 published_release_id 必须始终有指向,否则已发布应用会变成没有可运行版本的空壳。deleteRelease 在目标等于当前生效版本时直接抛错「当前生效版本不能删除,请先发布其他版本后再删除。」
应用重启后,之前跑着的持久任务为什么变成 interrupted?
平台启动时 restore() 会把上一次残留的 running 任务统一标成 interrupted(interruptRunningJobs),避免出现没有任何进程在跑、状态却显示运行中的假象。要做长任务就得用 checkpoint 字段自己续做。