@whaletalk/dsh-codex-collab 0.1.7 → 0.1.9
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/CHANGELOG.md +35 -0
- package/README.md +96 -5
- package/codex-plugin/plugins/dsh/.codex-plugin/plugin.json +1 -1
- package/codex-plugin/plugins/dsh/skills/dsh-collab/SKILL.md +41 -5
- package/codex-plugin/plugins/dsh/skills/dsh-collab/scripts/task.mjs +134 -11
- package/docs/CODEX_DSH_TASK_REPLY_PLAN.md +206 -0
- package/docs/DSH_TO_CODEX_INBOX.md +167 -0
- package/docs/DSH_TO_CODEX_MESSAGING_PLAN.md +140 -0
- package/docs/codex-desktop-attachment-evidence.md +116 -0
- package/harness/codex-associations.mjs +265 -0
- package/harness/codex-outbox.mjs +432 -0
- package/harness/dsh-bridge.mjs +538 -35
- package/harness/fs-lock.mjs +208 -0
- package/harness/session-target.mjs +134 -0
- package/harness/task-table.mjs +32 -0
- package/mcp/codex-desktop-probe.mjs +407 -0
- package/mcp/codex-inbox.mjs +271 -0
- package/mcp/dsh-mcp.mjs +147 -17
- package/package.json +3 -1
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# M0 证据:Codex Desktop 桌面接入能力(本机 Windows)
|
|
2
|
+
|
|
3
|
+
- 首次记录:2026-10-10;**复核更新:2026-10-12**(桌面重启后 PID 变化,结论未变)
|
|
4
|
+
- 仓库:`E:/11-Github/dsh-codex-collab`
|
|
5
|
+
- 计划:`docs/DSH_TO_CODEX_MESSAGING_PLAN.md`
|
|
6
|
+
- **结论:M0 未通过 —— 本机不存在可附着的桌面端点。桌面自动唤醒处于 blocked。**
|
|
7
|
+
- 文中 PID / 安装路径均为**当次现场信息**,仅作证据;实现里一律不硬编码(探针每次现场读取)。
|
|
8
|
+
|
|
9
|
+
## 1. 一句话结论
|
|
10
|
+
|
|
11
|
+
桌面 Codex(ChatGPT.exe)把 app-server 以**默认 `stdio://`** 启动,stdin/stdout 归父进程私有;
|
|
12
|
+
本机既没有 `--listen` 出的 ws/TCP 端点,也没有文档所说的 app-server 控制套接字。
|
|
13
|
+
因此在"**不启动第二份 app-server**"的前提下,第三方无法连到桌面正在使用的那个后台,
|
|
14
|
+
`thread/loaded/list` 与 canary 都无法进行。协议本身具备所需方法(见 §4),缺的只是**传输**。
|
|
15
|
+
|
|
16
|
+
## 2. 现场事实与证据(2026-10-12 复核)
|
|
17
|
+
|
|
18
|
+
| # | 探测项 | 实测结果 | 证据来源 |
|
|
19
|
+
|---|---|---|---|
|
|
20
|
+
| 1 | 桌面 App | `ChatGPT.exe`(PID 11768),Store 包 `OpenAI.Codex_26.1007.2314.0_x64__2p2nqsd0c76g0` | 进程扫描 |
|
|
21
|
+
| 2 | 桌面 app-server | PID 17068,`codex-cli 0.162.0-alpha.17.2`(bundled CLI),**祖先链**:`17068 ← ChatGPT.exe(11768)` | 探针 `desktop[].why` |
|
|
22
|
+
| 3 | 同一桌面的其它 app-server | PID 19848、53256,同样 `--listen stdio://`;祖先链 `app-server ← node_repl.exe ← node.exe ← 17068 ← ChatGPT.exe(11768)` | 探针 `desktop[].ancestry` |
|
|
23
|
+
| 4 | app-server 启动参数 | 桌面主实例:`-c features.code_mode_host=true app-server --analytics-default-enabled …`(**无 `--listen`** → 默认 `stdio://`);另两个显式 `app-server --listen stdio://` | 进程命令行 |
|
|
24
|
+
| 5 | TCP 监听 | 三个实例 **tcp=[]**(无任何 LISTEN 端点) | 探针 `listenerScan` |
|
|
25
|
+
| 6 | 控制套接字 | `C:\Users\Marx\.codex\app-server-control\app-server-control.sock` **不存在** | 探针 `controlSocket` |
|
|
26
|
+
| 7 | daemon 路径(bundled CLI) | `app-server daemon version` → `Error: failed to connect to …app-server-control.sock` / `os error 10050` | 探针 `daemonProbe` |
|
|
27
|
+
| 8 | daemon 路径(全局 CLI 0.147.0) | `app-server daemon version` → `Error: codex app-server daemon lifecycle is only supported on Unix platforms` | 2026-10-10 实跑 |
|
|
28
|
+
| 9 | `proxy --sock` | 帮助里出现,但目标是"app-server **Unix domain socket**";实跑 → `failed to connect to socket at …` + `os error 10050`。**帮助里有 ≠ 可用** | 2026-10-10 实跑 |
|
|
29
|
+
| 10 | 命名管道 | 41 个 codex 管道(`codex-browser-use-*` / `codex-computer-use-*` / `codex-ipc` / 沙箱服务)。`codex-ipc` 字符串**存在于 CLI 二进制内** → Codex 内部 IPC,非文档传输;**未连接、未使用** | 探针 `namedPipes` |
|
|
30
|
+
| 11 | 协议方法面 | `app-server generate-json-schema --out <临时目录>` 成功;含 `v1/InitializeParams`、`v2/ThreadLoadedListParams`、`ThreadRead/List`、`TurnStart/Steer/Interrupt`、`ThreadInjectItems` | 探针 `schema.keyMethods` |
|
|
31
|
+
| 12 | 历史(2026-10-10) | 当时为 `ChatGPT.exe 44100 → app-server 24776`,其余结论相同 | 前一版记录(PID 仅是历史,不作依据) |
|
|
32
|
+
|
|
33
|
+
## 3. 被排除的替代路径(以及为什么)
|
|
34
|
+
|
|
35
|
+
| 路径 | 为什么不用 |
|
|
36
|
+
|---|---|
|
|
37
|
+
| 自己起 `codex app-server --listen ws://…` | 那是**第二份 app-server**(独立存储/独立线程注册表)。用它 `thread/resume` 同一 ID 只能证明"磁盘上能读到这个 ID",**不能证明连上了桌面正在用的后台**——计划明文禁止以此冒充投递成功 |
|
|
38
|
+
| `app-server daemon start/bootstrap` | 该生命周期在本平台不可用(§2 #7/#8);即便可用也仍是第二实例;`enable-remote-control` 属未授权变更 |
|
|
39
|
+
| `codex app-server proxy` | 依赖不存在的 Unix 控制套接字(§2 #9) |
|
|
40
|
+
| 直写桌面会话存储 / rollout 文件 | 计划禁止;写入磁盘不会让那条聊天产生回合,不满足验收 |
|
|
41
|
+
| **同一份存储目录** | **不能证明是同一后台**:探针刻意不把 storage 当证据;判定只看祖先链与端点是否存在 |
|
|
42
|
+
| `\\.\pipe\codex-ipc` 私有 IPC | 计划明令不得以"私有 IPC 猜测"凑通路;未做连接尝试 |
|
|
43
|
+
| 普通 MCP notification / Work Cloud 的 MCP Events | 前者不会唤醒本地聊天,后者适用范围是 Work 产品线,**都不算本地桌面唤醒** |
|
|
44
|
+
| 重启 Codex/DSH 以带 `--listen` 启动 | 未获授权(且会中断正在进行的回合) |
|
|
45
|
+
|
|
46
|
+
## 4. 结论与能力边界
|
|
47
|
+
|
|
48
|
+
- **协议可行、传输阻断**:所需方法在 schema 中都存在,"投递并观察回合"在协议层可表达;
|
|
49
|
+
缺的是**一条被支持的、指向桌面现有后台的传输**。
|
|
50
|
+
- 降级交付 = **收件箱模式**。能力如实上报(`codex-inbox capabilities` / `codex-desktop-probe`):
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"available": false,
|
|
55
|
+
"deliveryMode": "inbox",
|
|
56
|
+
"canWakeIdle": false,
|
|
57
|
+
"canObserveTurns": false,
|
|
58
|
+
"reason": "desktop-attachment-unavailable"
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
- **明确不宣称**:桌面自动唤醒、在线长等待、回合事件观察均**未实现**。
|
|
63
|
+
- 授权目标线程 `01a100f1-045c-7e10-84c7-ac8ec37eff3f` 与来源会话
|
|
64
|
+
`session-dd9655aa-b99f-4e36-b914-a5035fd094e1` 仅登记在**本地实验绑定**里(收件箱用),
|
|
65
|
+
**未**写入任何发布包默认值。因为没有投递入口,**canary 未发送**(无路可发,不是失败)。
|
|
66
|
+
|
|
67
|
+
## 5. 探针的判定纪律(2026-10-12 强化)
|
|
68
|
+
|
|
69
|
+
`mcp/codex-desktop-probe.mjs` 的判定是**纯函数**(`classifyProcesses` / `decideCapabilities`),
|
|
70
|
+
采集层解析(`parseScanOutput`)也是纯函数,都可离线测试:
|
|
71
|
+
|
|
72
|
+
1. **桌面身份靠祖先链证明,且另一份 app-server 会遮断归属**:命令行里出现 `app-server` 不足以认定桌面;
|
|
73
|
+
必须**先**遇到安装式桌面 App(`ChatGPT.exe`/`Codex.exe` 位于 `WindowsApps`/`Programs`)。
|
|
74
|
+
祖先链里若**先**遇到另一份 app-server,说明该实例是由那份后台派生的(桌面运行时 `node_repl`/`node`
|
|
75
|
+
就会派生这种实例)→ 单列为 `standalone`,**不能算作桌面正在使用的后台**。
|
|
76
|
+
祖先链拿不到的进 `unidentified`。
|
|
77
|
+
2. **同一份存储不能证明是同一后台**:探针不使用 storage 做判定,只在报告里附注。
|
|
78
|
+
3. **采集层必须 fail loud**:PowerShell 查询一律 `-ErrorAction Stop` 并显式传播失败;脚本用
|
|
79
|
+
`DSH_PROBE_JSON:` 标记 + 退出码区分"结果为空"与"没有结果"。**非终止错误 + exit 0 + 空 stdout
|
|
80
|
+
绝不能被当成"扫描成功且结果为空"**——那会让探针从"没查到"误推成"没有端点"。采集解析失败 →
|
|
81
|
+
`probe-inconclusive`,不是 `unavailable`。
|
|
82
|
+
4. **存在端点信号 → `endpoint-present-unverified`**:桌面 app-server 用了非 stdio 的 `--listen`、
|
|
83
|
+
或控制套接字存在、或 daemon 探测成功,任一成立即报此结论(**不因为"没有 TCP 监听"就声称没有端点**)。
|
|
84
|
+
5. **`canWakeIdle` / `canObserveTurns` 恒为 false**,直到真的完成协议校验与同一聊天 canary。
|
|
85
|
+
6. `--schema` 生成的临时目录在**递归删除前**显式校验解析后的目标位于系统临时根之内。
|
|
86
|
+
|
|
87
|
+
## 6. 什么条件下可以重新评估 M0
|
|
88
|
+
|
|
89
|
+
出现以下任一情形即可重跑 `node mcp/codex-desktop-probe.mjs --schema` 复核:
|
|
90
|
+
|
|
91
|
+
1. 桌面版本/配置使 app-server 以 `--listen ws://127.0.0.1:<port>`(或 Windows 上可用的 `unix://`)启动;
|
|
92
|
+
2. 桌面提供**文档化的**本机控制端点(例如 `app-server-control` 在本平台真实存在且可连);
|
|
93
|
+
3. 官方为 Windows 提供可用的 app-server daemon 生命周期。
|
|
94
|
+
|
|
95
|
+
届时 M0 需重做:连上**同一后台** → `initialize/initialized` → `thread/loaded/list` 命中目标 →
|
|
96
|
+
`thread/read` 取证 → 观察运行中回合 → 空闲后 canary。
|
|
97
|
+
|
|
98
|
+
## 7. 本次未做的操作(合规声明)
|
|
99
|
+
|
|
100
|
+
未修改 Codex 配置或权限模式、未启用远程控制、未重启 Codex/DSH、未读取或输出任何凭据
|
|
101
|
+
(`auth.json` 等一律未读)、未改写会话存储、未模拟点击、未修改上游、未打 tag、未 npm 发布。
|
|
102
|
+
除可选的临时 schema 目录(用后即删,且删除前校验路径)外探针只读;收件箱默认写在
|
|
103
|
+
`~/.dsh-codex-inbox`,**不写入仓库**。
|
|
104
|
+
|
|
105
|
+
## 8. 复现命令
|
|
106
|
+
|
|
107
|
+
```powershell
|
|
108
|
+
# 一条命令给出结论(含祖先链归属与协议方法面)
|
|
109
|
+
node mcp/codex-desktop-probe.mjs --schema
|
|
110
|
+
|
|
111
|
+
# 手动复核
|
|
112
|
+
Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match 'app-server' } |
|
|
113
|
+
Select-Object ProcessId,ParentProcessId,ExecutablePath,CommandLine
|
|
114
|
+
Get-NetTCPConnection -State Listen | Where-Object { $_.OwningProcess -in <上面的 pid> } # 期望为空
|
|
115
|
+
Test-Path "$env:USERPROFILE\.codex\app-server-control\app-server-control.sock" # 期望 False
|
|
116
|
+
```
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* codex-associations.mjs —— 「来源 Codex 对话 → 任务 → 执行方 DSH」的持久关联记录(零依赖)。
|
|
3
|
+
*
|
|
4
|
+
* 目的(R1):Codex 在**某个回合**派活时带上来源对话 ID(originThreadId),此后**任何回合**都能
|
|
5
|
+
* 用 originThreadId + taskId 精确取回这次任务的结果;只给 originThreadId 时能列出该对话登记过的任务,
|
|
6
|
+
* 用来找回丢失的 taskId。
|
|
7
|
+
*
|
|
8
|
+
* 存储:`<root>/<namespace>/bindings.jsonl`,**追加型事件日志**(bind / update 各一行),读取时折叠成记录。
|
|
9
|
+
* - 追加前修记录边界(末字节不是换行就先补一个),残尾不会吞掉后续写入;
|
|
10
|
+
* - 写入在 fs-lock.mjs 的独占写锁内串行化(与收件箱共用同一把锁的算法);
|
|
11
|
+
* - 读不动(权限/IO)抛 store-unreadable;文件有内容但一行都解析不出抛 store-corrupt——
|
|
12
|
+
* **绝不静默降级成空记录**。
|
|
13
|
+
*
|
|
14
|
+
* 隔离:命名空间是**校验过的短标识**(`[A-Za-z0-9._-]{1,64}`),只用来选子目录;origin/taskId
|
|
15
|
+
* 永远不参与路径拼接。多个网关共用同一命名空间也是安全的:日志是追加型、taskId 全局唯一,
|
|
16
|
+
* 不存在"别人覆盖我的记录";需要按 profile 分隔时用 DSH_CODEX_TASK_ASSOC_NAMESPACE 指定。
|
|
17
|
+
*
|
|
18
|
+
* 注意:originThreadId 是**关联元数据**,不是认证/授权凭据。
|
|
19
|
+
*/
|
|
20
|
+
import fs from 'node:fs'
|
|
21
|
+
import os from 'node:os'
|
|
22
|
+
import path from 'node:path'
|
|
23
|
+
import { createFileLock } from './fs-lock.mjs'
|
|
24
|
+
|
|
25
|
+
export const ASSOC_REASONS = {
|
|
26
|
+
/** originThreadId 不是合法的非空字符串。 */
|
|
27
|
+
INVALID_ORIGIN: 'invalid-origin',
|
|
28
|
+
/** 需要来源才能精确取值/恢复,但调用方没给。 */
|
|
29
|
+
MISSING_ORIGIN: 'origin-required',
|
|
30
|
+
/** 给了来源,但这条任务的登记来源不是它——**绝不返回别的对话的结果**。 */
|
|
31
|
+
ORIGIN_MISMATCH: 'origin-mismatch',
|
|
32
|
+
/** taskId 没有关联登记。 */
|
|
33
|
+
UNKNOWN_TASK: 'unknown-task',
|
|
34
|
+
/** 同一 taskId 试图改投另一个来源。 */
|
|
35
|
+
ORIGIN_CONFLICT: 'origin-conflict',
|
|
36
|
+
STORE_UNREADABLE: 'store-unreadable',
|
|
37
|
+
STORE_CORRUPT: 'store-corrupt',
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const MAX_ORIGIN_LENGTH = 200
|
|
41
|
+
const NAMESPACE_RE = /^[A-Za-z0-9._-]{1,64}$/
|
|
42
|
+
|
|
43
|
+
/** origin 必须是**非空**字符串(去空白后仍非空)、不含控制字符、长度有界。 */
|
|
44
|
+
export const isValidOrigin = (value) => typeof value === 'string'
|
|
45
|
+
&& value.trim() !== ''
|
|
46
|
+
&& value.trim().length <= MAX_ORIGIN_LENGTH
|
|
47
|
+
&& !/[\u0000-\u001f\u007f]/.test(value)
|
|
48
|
+
|
|
49
|
+
/** 命名空间只允许 `[A-Za-z0-9._-]{1,64}`,且显式排除 `.` / `..`(它们会让 path.join 逃出存储根)。 */
|
|
50
|
+
export const isValidNamespace = (value) => typeof value === 'string'
|
|
51
|
+
&& NAMESPACE_RE.test(value)
|
|
52
|
+
&& value !== '.'
|
|
53
|
+
&& value !== '..'
|
|
54
|
+
|
|
55
|
+
/** 存储根:显式参数 > DSH_CODEX_TASK_ASSOC_DIR > 用户数据目录(**绝不默认写进仓库**)。 */
|
|
56
|
+
export const resolveAssocRoot = (explicit) => {
|
|
57
|
+
if (typeof explicit === 'string' && explicit.trim() !== '') return path.resolve(explicit)
|
|
58
|
+
const fromEnv = process.env.DSH_CODEX_TASK_ASSOC_DIR
|
|
59
|
+
if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return path.resolve(fromEnv)
|
|
60
|
+
return path.join(os.homedir(), '.dsh-codex-task-assoc')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 命名空间:显式参数 > 环境变量 > 'default'。非法命名空间直接抛错(不拼路径)。 */
|
|
64
|
+
export const resolveNamespace = (explicit) => {
|
|
65
|
+
const raw = (typeof explicit === 'string' && explicit.trim() !== '')
|
|
66
|
+
? explicit.trim()
|
|
67
|
+
: (typeof process.env.DSH_CODEX_TASK_ASSOC_NAMESPACE === 'string' && process.env.DSH_CODEX_TASK_ASSOC_NAMESPACE.trim() !== ''
|
|
68
|
+
? process.env.DSH_CODEX_TASK_ASSOC_NAMESPACE.trim()
|
|
69
|
+
: 'default')
|
|
70
|
+
if (!isValidNamespace(raw)) {
|
|
71
|
+
throw Object.assign(new Error('invalid association namespace: ' + JSON.stringify(raw) + ' (expected [A-Za-z0-9._-]{1,64})'), { reason: ASSOC_REASONS.INVALID_ORIGIN })
|
|
72
|
+
}
|
|
73
|
+
return raw
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const fail = (reason, message, extra) => Object.assign(new Error(message), { reason, ...(extra === undefined ? {} : extra) })
|
|
77
|
+
|
|
78
|
+
const readJsonLines = (file) => {
|
|
79
|
+
let text
|
|
80
|
+
try {
|
|
81
|
+
text = fs.readFileSync(file, 'utf8')
|
|
82
|
+
} catch (e) {
|
|
83
|
+
if (e !== null && typeof e === 'object' && e.code === 'ENOENT') return []
|
|
84
|
+
throw fail(ASSOC_REASONS.STORE_UNREADABLE, 'cannot read ' + file + ': ' + e.message)
|
|
85
|
+
}
|
|
86
|
+
if (text.trim() === '') return []
|
|
87
|
+
const events = []
|
|
88
|
+
let bad = 0
|
|
89
|
+
for (const line of text.split('\n')) {
|
|
90
|
+
const trimmed = line.trim()
|
|
91
|
+
if (trimmed === '') continue
|
|
92
|
+
try {
|
|
93
|
+
const value = JSON.parse(trimmed)
|
|
94
|
+
if (value !== null && typeof value === 'object' && typeof value.type === 'string') events.push(value)
|
|
95
|
+
else bad++
|
|
96
|
+
} catch (e) { bad++ }
|
|
97
|
+
}
|
|
98
|
+
if (events.length === 0 && bad > 0) {
|
|
99
|
+
// 有内容却一条都解析不出来:这是损坏,不是"空存储"。
|
|
100
|
+
throw fail(ASSOC_REASONS.STORE_CORRUPT, file + ' has content but no readable records (' + bad + ' bad line(s))')
|
|
101
|
+
}
|
|
102
|
+
return events
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const foldRecords = (events) => {
|
|
106
|
+
const records = new Map()
|
|
107
|
+
for (const event of events) {
|
|
108
|
+
if (event.type === 'bind') {
|
|
109
|
+
if (typeof event.taskId !== 'string' || typeof event.originThreadId !== 'string') continue
|
|
110
|
+
records.set(event.taskId, {
|
|
111
|
+
originThreadId: event.originThreadId,
|
|
112
|
+
taskId: event.taskId,
|
|
113
|
+
kind: event.kind,
|
|
114
|
+
target: event.target,
|
|
115
|
+
profile: event.profile,
|
|
116
|
+
status: typeof event.status === 'string' ? event.status : 'accepted',
|
|
117
|
+
createdAt: event.at,
|
|
118
|
+
updatedAt: event.at,
|
|
119
|
+
delivered: event.delivered,
|
|
120
|
+
result: undefined,
|
|
121
|
+
error: undefined,
|
|
122
|
+
reason: undefined,
|
|
123
|
+
stopReason: undefined,
|
|
124
|
+
})
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
if (event.type === 'update') {
|
|
128
|
+
const record = records.get(event.taskId)
|
|
129
|
+
if (record === undefined) continue
|
|
130
|
+
for (const key of ['status', 'delivered', 'result', 'error', 'reason', 'stopReason', 'requestId', 'dshTurn']) {
|
|
131
|
+
if (event[key] !== undefined) record[key] = event[key]
|
|
132
|
+
}
|
|
133
|
+
record.updatedAt = event.at
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return records
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 打开任务关联存储。方法与收件箱同风格:同步、语义确定。
|
|
141
|
+
* @param {{root?: string, namespace?: string, lockTimeoutMs?: number, profile?: string}} options
|
|
142
|
+
*/
|
|
143
|
+
export const openAssociations = (options = {}) => {
|
|
144
|
+
const root = resolveAssocRoot(options.root)
|
|
145
|
+
const namespace = resolveNamespace(options.namespace)
|
|
146
|
+
const dir = path.join(root, namespace)
|
|
147
|
+
const file = path.join(dir, 'bindings.jsonl')
|
|
148
|
+
const lock = createFileLock({ lockFile: path.join(dir, '.write.lock'), timeoutMs: options.lockTimeoutMs })
|
|
149
|
+
const profile = typeof options.profile === 'string' && options.profile !== '' ? options.profile : (process.env.DSH_PROFILE || undefined)
|
|
150
|
+
|
|
151
|
+
const ensureDir = () => { fs.mkdirSync(dir, { recursive: true }) }
|
|
152
|
+
|
|
153
|
+
const load = () => foldRecords(readJsonLines(file))
|
|
154
|
+
|
|
155
|
+
/** 追加一行;先修记录边界(末字节不是换行就先补换行),避免残尾吞掉新记录。 */
|
|
156
|
+
const appendEvent = (event) => {
|
|
157
|
+
ensureDir()
|
|
158
|
+
let size = 0
|
|
159
|
+
try { size = fs.statSync(file).size } catch (e) { size = 0 }
|
|
160
|
+
if (size > 0) {
|
|
161
|
+
let last = 0
|
|
162
|
+
const fd = fs.openSync(file, 'r')
|
|
163
|
+
try {
|
|
164
|
+
const buf = Buffer.alloc(1)
|
|
165
|
+
fs.readSync(fd, buf, 0, 1, size - 1)
|
|
166
|
+
last = buf[0]
|
|
167
|
+
} finally {
|
|
168
|
+
fs.closeSync(fd)
|
|
169
|
+
}
|
|
170
|
+
if (last !== 0x0a) fs.appendFileSync(file, '\n', 'utf8')
|
|
171
|
+
}
|
|
172
|
+
fs.appendFileSync(file, JSON.stringify(event) + '\n', 'utf8')
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
root,
|
|
177
|
+
namespace,
|
|
178
|
+
dir,
|
|
179
|
+
file,
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 登记一条关联。**必须在执行前调用**:写盘失败就抛错,让调用方放弃启动 DSH 执行
|
|
183
|
+
* (绝不出现"执行了却没绑定")。
|
|
184
|
+
*/
|
|
185
|
+
bind({ originThreadId, taskId, kind, target, delivered }) {
|
|
186
|
+
if (!isValidOrigin(originThreadId)) {
|
|
187
|
+
throw fail(ASSOC_REASONS.INVALID_ORIGIN, 'originThreadId must be a non-empty string of at most ' + MAX_ORIGIN_LENGTH + ' characters without control characters')
|
|
188
|
+
}
|
|
189
|
+
if (typeof taskId !== 'string' || taskId.trim() === '') throw fail(ASSOC_REASONS.UNKNOWN_TASK, 'taskId is required')
|
|
190
|
+
return lock.withLock(() => {
|
|
191
|
+
const existing = load().get(taskId)
|
|
192
|
+
if (existing !== undefined && existing.originThreadId !== originThreadId.trim()) {
|
|
193
|
+
throw fail(ASSOC_REASONS.ORIGIN_CONFLICT, 'task ' + taskId + ' is already bound to another origin', { taskId })
|
|
194
|
+
}
|
|
195
|
+
if (existing !== undefined) return existing
|
|
196
|
+
const at = Date.now()
|
|
197
|
+
appendEvent({
|
|
198
|
+
type: 'bind',
|
|
199
|
+
version: 1,
|
|
200
|
+
originThreadId: originThreadId.trim(),
|
|
201
|
+
taskId,
|
|
202
|
+
kind,
|
|
203
|
+
target,
|
|
204
|
+
profile,
|
|
205
|
+
status: 'accepted',
|
|
206
|
+
delivered: delivered === true ? true : undefined,
|
|
207
|
+
at,
|
|
208
|
+
})
|
|
209
|
+
return load().get(taskId)
|
|
210
|
+
})
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
/** 更新终态/交付信息。任务未登记 → unknown-task(不静默新建)。 */
|
|
214
|
+
update(taskId, patch = {}) {
|
|
215
|
+
if (typeof taskId !== 'string' || taskId.trim() === '') throw fail(ASSOC_REASONS.UNKNOWN_TASK, 'taskId is required')
|
|
216
|
+
return lock.withLock(() => {
|
|
217
|
+
const record = load().get(taskId)
|
|
218
|
+
if (record === undefined) throw fail(ASSOC_REASONS.UNKNOWN_TASK, 'task ' + taskId + ' has no association record', { taskId })
|
|
219
|
+
appendEvent({
|
|
220
|
+
type: 'update',
|
|
221
|
+
version: 1,
|
|
222
|
+
taskId,
|
|
223
|
+
status: typeof patch.status === 'string' ? patch.status : undefined,
|
|
224
|
+
delivered: patch.delivered === undefined ? undefined : patch.delivered === true,
|
|
225
|
+
result: typeof patch.result === 'string' ? patch.result : undefined,
|
|
226
|
+
error: typeof patch.error === 'string' ? patch.error : undefined,
|
|
227
|
+
reason: typeof patch.reason === 'string' ? patch.reason : undefined,
|
|
228
|
+
stopReason: typeof patch.stopReason === 'string' ? patch.stopReason : undefined,
|
|
229
|
+
requestId: typeof patch.requestId === 'string' ? patch.requestId : undefined,
|
|
230
|
+
dshTurn: typeof patch.dshTurn === 'number' ? patch.dshTurn : undefined,
|
|
231
|
+
at: Date.now(),
|
|
232
|
+
})
|
|
233
|
+
return load().get(taskId)
|
|
234
|
+
})
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
record(taskId) {
|
|
238
|
+
return load().get(taskId)
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
/** 精确取值:必须 origin 匹配;不匹配/缺失一律拒绝,**失败时不返回任何 record**(不泄漏别人的结果)。 */
|
|
242
|
+
fetch({ originThreadId, taskId }) {
|
|
243
|
+
if (typeof taskId !== 'string' || taskId.trim() === '') return { ok: false, reason: ASSOC_REASONS.UNKNOWN_TASK }
|
|
244
|
+
const record = load().get(taskId)
|
|
245
|
+
if (record === undefined) return { ok: false, reason: ASSOC_REASONS.UNKNOWN_TASK }
|
|
246
|
+
if (!isValidOrigin(originThreadId)) return { ok: false, reason: ASSOC_REASONS.MISSING_ORIGIN }
|
|
247
|
+
if (record.originThreadId !== originThreadId.trim()) return { ok: false, reason: ASSOC_REASONS.ORIGIN_MISMATCH }
|
|
248
|
+
return { ok: true, record }
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
/** 列出某个来源登记过的任务(找回丢失的 taskId 用),最新在前。 */
|
|
252
|
+
byOrigin(originThreadId, filter = {}) {
|
|
253
|
+
if (!isValidOrigin(originThreadId)) return { ok: false, reason: ASSOC_REASONS.INVALID_ORIGIN }
|
|
254
|
+
const origin = originThreadId.trim()
|
|
255
|
+
const all = Array.from(load().values()).filter((r) => r.originThreadId === origin)
|
|
256
|
+
all.sort((a, b) => b.createdAt - a.createdAt)
|
|
257
|
+
const limit = typeof filter.limit === 'number' && filter.limit > 0 ? filter.limit : undefined
|
|
258
|
+
return { ok: true, originThreadId: origin, tasks: limit === undefined ? all : all.slice(0, limit) }
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
count() {
|
|
262
|
+
return load().size
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
}
|