本页目录(46 节)

子应用

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 行排查表。

阅读约 22 分钟最后核对 2026-09-19

子应用(Sub-App)是 SparkWork 里唯一允许你把界面装进平台的机制: 一段 HTML/JS(V1)或一个受管多文件项目(V2),可以跑在内容区、侧面板、悬浮层里, 数据和文件长期保存,与会话无关。管理入口是侧栏的「我的应用」(视图 id sub-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)。

结构体字段说明
SubAppManifestname: string应用名
description: string描述(创建时缺省 '')
icon: string | null图标标识;未知标识回落默认图标
entry: string入口文件名,建库缺省 index.html
surface: SubAppSurface展示面,建库缺省 content
permissions: string[]能力名数组,创建时缺省 DEFAULT_SUB_APP_PERMISSIONS
SubAppDraftformat?: 'v1' | 'v2'可缺省,兼容旧数据
revision: number草稿 revision,所有写操作都要求 CAS 匹配
source: string完整 HTML 源码(V1 唯一产物)
config: Record<string, unknown>序列化后 ≤ 512 KB
manifest / updatedAt清单与更新时间
SubAppReleaseid / appId / version发布快照主键、归属与递增版本号
source / config / manifest发布时刻的完整拷贝,不可变
publishedAt: string发布时间(ISO 字符串)
SubAppSummarypublicationStatusdraft / published / archived
enabled: boolean是否出现在应用入口,与发布状态是两个维度
draftRevision: numberCAS 基线
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(),多一个字段就报错)。

字段类型必填说明
schemaVersion2 字面量是常量 SUB_APP_PACKAGE_SCHEMA_VERSION
namestring,1~120是trim 后不得为空
descriptionstring ≤ 400否缺省写入 ''
iconstring ≤ 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.healthActionstring 1~120否健康检查调用的 action 名
permissions.sparkCapabilities能力名数组 ≤ 64是枚举取自 SUB_APP_CAPABILITIES
permissions.osEffects('network' | 'filesystem' | 'process')[]是数组上限就是枚举长度 3
permissions.connectionsstring 数组 ≤ 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 两种格式的差异对照

维度V1V2
草稿形态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-78 vs sub-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-srchttps: http: 或 'none'由「允许子应用访问外部网络」开关控制
frame-src / object-src / form-action'none'固定值,代码注释说明「无场景需要」
img-src / media-srcdata: blob: https: http: safe-file:(+ 包资源)允许内嵌与本地文件
base-uricapability-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
navigationopenApp / openView(视图走白名单)
files应用文件空间 UTF-8 文本读写
clipboard保留未实现(无 SDK、无 Bridge 路由)
notifications保留未实现(同上)
agentsparkApp.agent.send:从应用调宿主 Agent
canvas列出画布项目、追加文本
media创建与查询文本生成媒体任务
browser内置浏览器打开、媒体识别、下载及文件后续操作
network受管 HTTP:sparkApp.network.request(需连接槽)
provider受管 Provider 请求:sparkApp.provider.request
backendsparkApp.backend.invoke/status/on(V2 service)
jobssparkApp.jobs.create/get/list/cancel
ipc原始宿主 IPC 与 stream(legacy,V2 禁用)

4.2 权限检查发生在哪一层

唯一的能力闸门在宿主 renderer 的 Bridge 里 (design/sub-app/bridgeHost.ts:396-411),顺序是:

  1. 如果该实例 trusted === true,直接放行—— manifest 的 permissions 只作为旧数据兼容字段保留。
  2. 否则 runtime 与 theme(只读宿主信息)免声明。
  3. 其余能力必须出现在 runtimeInfo.permissions 里,否则返回PERMISSION_DENIED,提示是 「应用未声明 "<capability>" 权限,请在应用 manifest permissions 中申请后重新发布。」

MCP 工具侧也有一句必须对齐的口径:spark_app_create 的permissions 参数说明写着它是「兼容旧版本的 manifest 字段…… 当前 SparkWork 子应用按平台核心内部应用运行,宿主不会用它裁剪平台 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),卡片底部的说明区也直接写明「默认放行」。

开关默认关闭后的实际影响
允许子应用访问外部网络trueconnect-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(category sub-app、key data,权威源)。 改动会广播 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。

操作publicationStatusenabled证据
创建draft0sub-app.repository.ts:163-166
V1 发布published1sub-app.repository.ts:325-329
V2 发布published0sub-app-platform.repository.ts:215-217
启用 / 禁用不变按参数写 0/1sub-app.repository.ts:336-352
归档archived0sub-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_keys pragma 的情况 (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_LIMITSubAppShareService.ts:39、:778-790
导入时单条数据上限512_000 → DATA_LIMITSubAppShareService.ts:45、:812-818
导入时文件条数上限500 → FILE_LIMITSubAppShareService.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)。

层字段取值 / 规则
声明kindhttp-api 或 provider
声明displayName1~120 字符
声明allowedOrigins1~20 个纯 origin:必须是 http(s)、路径为 /、无查询与片段
声明allowPrivateNetwork可选布尔量;不声明则解析到私网地址一律拒绝
绑定bindingKindapi-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), 依次校验:

  1. 应用必须已启用,否则 PERMISSION_DENIED「子应用未启用,不能发起受管网络请求。」 (:126)。
  2. slot 必须在 manifest 里声明过,否则「应用未声明连接权限:<slot>」(:133)。
  3. 授权 origin 不能为空,否则「连接没有可用的授权 origin。」(:146)。
  4. 目标 URL 不得内嵌凭据,否则「受管请求仅允许无内嵌凭据的 HTTP(S) URL。」(:211)。
  5. 目标 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.3 spark_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_MISSINGdata.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_artifactsV2 内容寻址制品sha256 唯一;记录 schema_version、路径、体积、文件数(097:9-20)
sub_app_release_artifactsrelease ↔ 制品关联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重新读一次草稿 revisionCAS 不匹配: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_DENIEDmanifest 的 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)

常见问题

展开 6 条常见问题
  • 子应用在哪里创建?管理页里为什么没有编辑代码的入口?

    在任意对话里让 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 字段自己续做。