Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 14 additions & 12 deletions .oo/docs/asset-directories.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,18 @@ __ONEWORKS_PROJECT_ENTITIES_DIR__=knowledge/entities

## 影响范围

### 从知识库新建资产

知识库可以新建项目内实体、流程和规则。输入名称与可选描述;流程还可声明具名参数。表单会预览生成后的项目相对文件名,文件只会写入当前受信 workspace 内的已配置项目资产目录;服务端完成发布后会自动刷新对应列表。若刷新失败,重试刷新不会再次创建文件。

读取仍支持上文所述的绝对资产目录;为保证创建过程的目录权限与原子发布边界,知识库“新建”不写入 workspace 之外的绝对目录。若配置后的目标位于 workspace 外,预览和创建都会拒绝,需改用 workspace 内目录或由项目维护者在外部目录中手工维护资产。

新建模板与既有资产使用同一套发现约定:`entities/<name>.md`、`specs/<name>.md`、`rules/<name>.md`。名称会转换为共享的 canonical slug;本地或 plugin 中存在语义冲突的定义时会被拒绝。

发布一旦可能对工作区可见,服务端不会再通过路径名删除或恢复该目标。若后续持久化、身份确认或响应传输失败,结果会标记为“已提交但降级”或“提交状态待确认”;知识库会关闭本次提交并刷新列表进行协调,不会自动重复创建请求。只有服务端明确返回尚未提交时,才可安全地再次提交。

在原生身份绑定清理能力不可用时,服务端会保留仅由本次 generation 拥有的私有暂存文件并报告降级,而不会冒险按路径删除可能已被替换的文件。项目维护者可根据服务端告警检查这些 `.asset-create-*.tmp` 残留;它们不会被资产 loader 当作定义加载。

这些环境变量会影响项目数据资产的主要消费链路:

- workspace assets:`rules`、`skills`、`specs`、`entities`、`mcp`
Expand All @@ -140,45 +152,35 @@ __ONEWORKS_PROJECT_ENTITIES_DIR__=knowledge/entities
- 启动入口:CLI、server、client、hook loader、desktop、VS Code extension
- benchmark 运行时目录

其中:

- `__ONEWORKS_PROJECT_BASE_DIR__` 会影响整棵项目数据资产树
- `__ONEWORKS_PROJECT_ENTITIES_DIR__` 只影响 `entities` 的扫描与加载位置

`<project-home>/.local` 用于当前项目的私有本地数据,不应提交到 Git。

未显式设置 `DB_PATH` 时,Server 会把会话数据库放在 `~/.oneworks/projects/<project-key>/.local/server/db.sqlite`。这样同一 Git 项目的多个 worktree 共享会话与 Agent Room 状态,而不同项目不会串数据,也不会把数据库写到工作区。

当前主要用途包括:

- server 会话数据库
- adapter 多账号凭据快照
- adapter 账号的来源、auth digest 与额度快照元数据
- 只应保存在本机的认证状态或临时元数据

例如 `codex` 当前会在:

- `<project-home>/.local/server/db.sqlite`
- `<project-home>/.local/adapters/codex/accounts/<accountKey>/auth.json`
- `<project-home>/.local/adapters/codex/accounts/<accountKey>/meta.json`

保存账号快照与账号元数据`meta.json` 里可能包含:
保存账号快照与账号元数据`meta.json` 里可能包含:

- 账号来源说明
- auth 摘要
- 最近一次 quota / rate-limit 快照
- quota 快照更新时间

如果当前目录是 Git worktree,adapter 账号目录会共享到主 worktree:

- 写入和导入优先落到主 worktree 对应的 `<project-home>/.local`
- 如果当前目录是 Git worktree,写入和导入优先落到主 worktree 对应的 `<project-home>/.local`
- 启动或账号命令会先把旧 `.oo/.local` 复制到 project home
- 读取只使用 project home 下的共享目录,不再兼容读取旧工作区目录

## 不受影响的内容

当前不会跟随这些环境变量一起变化的内容:

- `.oo.config.json` / `.oo.config.yaml` / `.oo.config.yml`
- `.oo.dev.config.*`
- `~/.oneworks/.oo.config.json`
Expand Down
12 changes: 12 additions & 0 deletions .oo/docs/en/asset-directories.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,18 @@ This resolves the AI base directory to `./.oo` and the entities directory to `./

## What Changes

### Create from the Knowledge Base

The Knowledge Base can create project-owned entities, flows, and rules. Enter a name and optional description; flows can also declare named parameters. The form previews the generated project-relative file name, creates only in configured project asset directories that remain inside the current trusted workspace, and refreshes the affected list after the server completes publication. If the refresh fails, retrying refresh does not create a second file.

Reading still supports the absolute asset directories described above. To preserve the creation authority and atomic-publication boundary, Knowledge Base creation does not write to an absolute directory outside the workspace. Preview and create reject such a destination; use a workspace-contained directory or have a project maintainer manage the external asset manually.

The created templates use the same discovery conventions as existing assets: `entities/<name>.md`, `specs/<name>.md`, and `rules/<name>.md`. Names are converted to a shared canonical slug and conflicting local or plugin definitions are rejected.

Once publication may be visible in the workspace, the server never deletes or restores the target by path. A later durability, identity-probe, or response failure is reported as committed-degraded or committed-indeterminate. The Knowledge Base closes that submission and refreshes the list to reconcile status; it never automatically repeats the create request. A deliberate retry is safe only when the server explicitly reports that nothing committed.

When native identity-bound cleanup is unavailable, the server retains the private staging file owned by that generation and reports degradation instead of risking deletion of a replacement through its path. Project maintainers can inspect reported `.asset-create-*.tmp` residue; asset loaders do not treat those files as definitions.

These variables affect the main project asset consumers:

- workspace assets: `rules`, `skills`, `specs`, `entities`, `mcp`
Expand Down
137 changes: 137 additions & 0 deletions apps/client/__tests__/knowledge-asset-create-api.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import {
AssetCreateCommitIndeterminateError,
createAsset,
isAssetCreateCommitIndeterminateError
} from '#~/api/knowledge'

vi.mock('#~/runtime-config.js', () => ({
createServerUrl: (path: string) => new URL(path.replace(/^\/+/, ''), 'http://api.example.com:8787/').toString(),
getServerBaseUrl: () => 'http://api.example.com:8787'
}))

vi.mock('#~/homepage-preview/runtime-loader', () => ({
handleHomepagePreviewFetchIfEnabled: () => undefined
}))

const response = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json' },
status
})

describe('asset create API commit classification', () => {
const fetchMock = vi.fn<typeof fetch>()

beforeEach(() => {
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
vi.spyOn(console, 'error').mockImplementation(() => {})
})

afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})

it('accepts a 202 indeterminate success result without converting it to failure', async () => {
fetchMock.mockResolvedValue(response({
success: true,
data: {
asset: {
commitState: 'committed-indeterminate',
kind: 'rule',
path: '.oo/rules/review.md'
}
}
}, 202))

await expect(createAsset({ kind: 'rule', name: 'Review' })).resolves.toMatchObject({
asset: { commitState: 'committed-indeterminate' }
})
})

it.each([
['missing state', 202, undefined],
['confirmed state', 202, 'committed'],
['degraded state', 202, 'committed-degraded'],
['indeterminate on 201', 201, 'committed-indeterminate'],
['unexpected 200', 200, undefined]
])('rejects protocol mismatch %s', async (_label, status, commitState) => {
fetchMock.mockResolvedValue(response({
success: true,
data: {
asset: {
...(commitState == null ? {} : { commitState }),
kind: 'rule',
path: '.oo/rules/review.md'
}
}
}, status))

const error = await createAsset({ kind: 'rule', name: 'Review' }).catch(value => value)
expect(error).toBeInstanceOf(AssetCreateCommitIndeterminateError)
})

it('rejects a 202 error envelope even when it claims committed false', async () => {
fetchMock.mockResolvedValue(response({
success: false,
error: {
code: 'asset_publish_failed',
details: { committed: false },
message: 'contradictory response'
}
}, 202))

const error = await createAsset({ kind: 'rule', name: 'Review' }).catch(value => value)
expect(error).toBeInstanceOf(AssetCreateCommitIndeterminateError)
})

it.each([
['lost transport', new TypeError('connection closed')],
[
'unknown 5xx',
response({
success: false,
error: { code: 'gateway_failure', message: 'gateway failed' }
}, 502)
],
['malformed 2xx', response({ success: true, data: { ok: true } })]
])('marks %s as commit-indeterminate', async (_label, failure) => {
if (failure instanceof Response) fetchMock.mockResolvedValue(failure)
else fetchMock.mockRejectedValue(failure)

const error = await createAsset({ kind: 'rule', name: 'Review' }).catch(value => value)
expect(error).toBeInstanceOf(AssetCreateCommitIndeterminateError)
expect(isAssetCreateCommitIndeterminateError(error)).toBe(true)
})

it.each([401, 409])('marks an unknown %i as commit-indeterminate', async (status) => {
fetchMock.mockResolvedValue(response({
success: false,
error: { code: 'unknown_failure', message: 'unknown state' }
}, status))

const error = await createAsset({ kind: 'rule', name: 'Review' }).catch(value => value)
expect(error).toBeInstanceOf(AssetCreateCommitIndeterminateError)
})

it.each([409, 500])('preserves explicit committed-false at %i as safely retryable', async (status) => {
fetchMock.mockResolvedValue(response({
success: false,
error: {
code: 'asset_publish_failed',
details: { committed: false, privateStaging: 'retained' },
message: 'not published'
}
}, status))

const error = await createAsset({ kind: 'rule', name: 'Review' }).catch(value => value)
expect(error).toMatchObject({
code: 'asset_publish_failed',
details: { committed: false, privateStaging: 'retained' }
})
expect(isAssetCreateCommitIndeterminateError(error)).toBe(false)
})
})
Loading
Loading