@twinsearth/w2m-dsh-plugin 1.3.0 → 1.3.11

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.
Files changed (52) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +601 -0
  4. package/bin/w2m-maintenance.mjs +112 -0
  5. package/codex/mcp-servers.toml +1 -1
  6. package/deploy/maintenance/bootstrap.ps1 +30 -0
  7. package/deploy/maintenance/bootstrap.sh +59 -0
  8. package/deploy/server/w2m-maintenance.path +10 -0
  9. package/deploy/server/w2m-maintenance.service +20 -0
  10. package/deploy/windows/w2m-maintenance.xml +31 -0
  11. package/docs/COLLAB/COLLABORATION-MODEL.md +240 -0
  12. package/docs/COLLAB/EXECUTION-AUTH-MODEL.md +250 -0
  13. package/docs/COLLAB/M1-ACCEPTANCE-RUNBOOK.md +70 -0
  14. package/docs/COLLAB/M1-MAINTENANCE.md +313 -0
  15. package/docs/COLLAB/M2-TASK-GRANT.md +231 -0
  16. package/docs/COLLAB/M3-GIT-NATIVE-TASKS.md +291 -0
  17. package/docs/COLLAB/M4-PROJECT-ROOM.md +260 -0
  18. package/docs/COLLAB/M5-MULTI-CLIENT-PRIMITIVES.md +263 -0
  19. package/docs/COLLAB/M6-CROSS-NETWORK-RELIABILITY.md +231 -0
  20. package/docs/COLLAB/M7-METRICS-ACCEPTANCE.md +263 -0
  21. package/docs/COLLAB/TASK-MODEL.md +281 -0
  22. package/docs/MULTI-MACHINE-COLLABORATION-PLAN.md +186 -0
  23. package/docs/RELEASE-STABLE.md +193 -0
  24. package/docs/acceptance/1.3.10-beta.10/M1-LIVE-DIAG.json +27 -0
  25. package/docs/acceptance/1.3.10-beta.10/M1-LIVE-POSTUPDATE-DIAG.json +18 -0
  26. package/docs/acceptance/1.3.10-beta.10/M1-LIVE-RESTART.json +24 -0
  27. package/docs/acceptance/1.3.10-beta.10/M1-LIVE-UPDATE.json +24 -0
  28. package/docs/acceptance/1.3.11/M1-LIVE-DIAG.json +26 -0
  29. package/package.json +22 -4
  30. package/plugin.json +1 -1
  31. package/src/acceptance/evidence.mjs +79 -0
  32. package/src/agent/agent.mjs +64 -2
  33. package/src/agent/grant-gate.mjs +82 -0
  34. package/src/agent/maintenance-ferry.mjs +111 -0
  35. package/src/agent/outbox.mjs +138 -0
  36. package/src/agent/patch-artifact.mjs +53 -0
  37. package/src/agent/worktree.mjs +109 -0
  38. package/src/maintenance/actions.mjs +203 -0
  39. package/src/maintenance/grant.mjs +139 -0
  40. package/src/maintenance/operator-key.mjs +151 -0
  41. package/src/maintenance/protocol.mjs +204 -0
  42. package/src/maintenance/runner.mjs +189 -0
  43. package/src/plugin/tools.mjs +1 -1
  44. package/src/relay/blob-sessions.mjs +148 -0
  45. package/src/relay/merge-queue.mjs +137 -0
  46. package/src/relay/project-locks.mjs +179 -0
  47. package/src/relay/project-notifications.mjs +182 -0
  48. package/src/relay/project-reviews.mjs +161 -0
  49. package/src/relay/project-room.mjs +163 -0
  50. package/src/relay/reliability-metrics.mjs +180 -0
  51. package/src/relay/server.mjs +164 -1
  52. package/src/relay/state.mjs +128 -1
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * w2m-maintenance — the root/SYSTEM side of the maintenance channel (v1.3.2-beta, M1).
4
+ *
5
+ * Installed as a systemd path unit (Linux) or a scheduled task (Windows). It reads signed request files from a queue
6
+ * directory the unprivileged agent may write to, verifies the operator signature, executes one of the three fixed
7
+ * actions, and writes a result file back. It never interprets a shell string and never accepts an action outside
8
+ * update/restart/diag.
9
+ */
10
+ import { mkdirSync, readFileSync, watch } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { runOnce } from '../src/maintenance/runner.mjs';
13
+ import { executeAction } from '../src/maintenance/actions.mjs';
14
+ import { createPinStore, registrationPayload } from '../src/maintenance/operator-key.mjs';
15
+
16
+ function parseArgs(argv) {
17
+ const args = { once: false, watch: false, allowNonRoot: false };
18
+ for (let i = 0; i < argv.length; i += 1) {
19
+ const token = argv[i];
20
+ const next = () => argv[++i];
21
+ if (token === '--queue-dir') args.queueDir = next();
22
+ else if (token === '--done-dir') args.doneDir = next();
23
+ else if (token === '--claimed-dir') args.claimedDir = next();
24
+ else if (token === '--state-file') args.stateFile = next();
25
+ else if (token === '--pin-file') args.pinFile = next();
26
+ else if (token === '--machine-id') args.machineId = next();
27
+ else if (token === '--operator-key') args.operatorKey = next();
28
+ else if (token === '--operator-key-id') args.operatorKeyId = next();
29
+ else if (token === '--once') args.once = true;
30
+ else if (token === '--watch') args.watch = true;
31
+ else if (token === '--allow-nonroot') args.allowNonRoot = true;
32
+ else if (token === '--help' || token === '-h') args.help = true;
33
+ else throw new Error('unknown argument: ' + token);
34
+ }
35
+ return args;
36
+ }
37
+
38
+ const USAGE = `usage: w2m-maintenance [options]
39
+
40
+ --queue-dir <dir> directory the agent writes requests to (required)
41
+ --done-dir <dir> directory results are written to (required)
42
+ --claimed-dir <dir> default: <queue-dir>/../claimed
43
+ --state-file <file> replay state, default: <queue-dir>/../state.json
44
+ --pin-file <file> operator key pins, default: <queue-dir>/../operator-pins.json
45
+ --machine-id <id> this machine's id (required; or W2M_MACHINE_ID)
46
+ --operator-key <pem> bootstrap: pin this operator public key (PEM file) on first start
47
+ --operator-key-id <id> optional expected key id for the bootstrap key
48
+ --once process the queue once and exit
49
+ --watch poll the queue until interrupted
50
+ --allow-nonroot run without root (development/testing only)
51
+ `;
52
+
53
+ function requireRoot(args) {
54
+ const uid = typeof process.getuid === 'function' ? process.getuid() : null;
55
+ if (uid === null) return; // Windows: the scheduled task decides the account, and there is no uid to check
56
+ if (uid === 0 || args.allowNonRoot) return;
57
+ throw new Error('the maintenance runner must run as root (pass --allow-nonroot only for development)');
58
+ }
59
+
60
+ async function main() {
61
+ const args = parseArgs(process.argv.slice(2));
62
+ if (args.help) { process.stdout.write(USAGE); return 0; }
63
+ if (!args.queueDir || !args.doneDir) { process.stderr.write(USAGE); return 2; }
64
+ requireRoot(args);
65
+ const machineId = args.machineId ?? process.env.W2M_MACHINE_ID;
66
+ if (typeof machineId !== 'string' || machineId === '') throw new Error('--machine-id (or W2M_MACHINE_ID) is required');
67
+ const claimedDir = args.claimedDir ?? join(args.queueDir, '..', 'claimed');
68
+ const stateFile = args.stateFile ?? join(args.queueDir, '..', 'state.json');
69
+ const pinFile = args.pinFile ?? join(args.queueDir, '..', 'operator-pins.json');
70
+ mkdirSync(args.queueDir, { recursive: true });
71
+ mkdirSync(args.doneDir, { recursive: true });
72
+ const store = createPinStore(pinFile);
73
+ if (args.operatorKey) {
74
+ const pin = store.pinFromRegistration(registrationPayload({
75
+ // An absent --operator-key-id means "trust the key that arrives"; an empty string must not be sent as a claimed id,
76
+ // because the pin store verifies a claimed id against the key it receives and an empty one can never match.
77
+ keyId: typeof args.operatorKeyId === 'string' && args.operatorKeyId !== '' ? args.operatorKeyId : undefined,
78
+ publicKeyPem: readFileSync(args.operatorKey, 'utf8'),
79
+ }));
80
+ process.stdout.write('pinned operator key ' + pin.pinned.key_id + (pin.firstContact ? ' (first contact)' : '') + '\n');
81
+ }
82
+ const options = {
83
+ queueDir: args.queueDir,
84
+ doneDir: args.doneDir,
85
+ claimedDir,
86
+ stateFile,
87
+ machineId,
88
+ pinStore: { load: () => store.load(), isRevoked: (keyId) => store.isRevoked(keyId) },
89
+ executeAction,
90
+ log: (level, message, detail) => process.stdout.write('[' + level + '] ' + message + (detail ? ' ' + JSON.stringify(detail) : '') + '\n'),
91
+ };
92
+ const run = async () => {
93
+ const result = await runOnce(options);
94
+ for (const entry of result.processed) process.stdout.write(JSON.stringify(entry) + '\n');
95
+ return result;
96
+ };
97
+ if (args.once) { await run(); return 0; }
98
+ await run();
99
+ if (args.watch) {
100
+ const tick = Number.parseInt(process.env.W2M_MAINTENANCE_INTERVAL_MS ?? '2000', 10);
101
+ watch(args.queueDir, () => { run().catch((error) => process.stderr.write('watch run failed: ' + error.message + '\n')); });
102
+ setInterval(() => { run().catch((error) => process.stderr.write('poll run failed: ' + error.message + '\n')); }, Number.isFinite(tick) && tick >= 500 ? tick : 2000);
103
+ process.stdout.write('watching ' + args.queueDir + '\n');
104
+ return new Promise(() => {});
105
+ }
106
+ return 0;
107
+ }
108
+
109
+ main().then((code) => { process.exitCode = typeof code === 'number' ? code : 0; }).catch((error) => {
110
+ process.stderr.write('w2m-maintenance: ' + (error?.message ?? String(error)) + '\n');
111
+ process.exitCode = 1;
112
+ });
@@ -12,6 +12,6 @@
12
12
 
13
13
  [mcp_servers.w2m-fleet]
14
14
  command = "npx"
15
- args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.3.0", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.3.11", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
@@ -0,0 +1,30 @@
1
+ # Bootstrap the M1 maintenance runner on Windows (run ONCE as Administrator).
2
+ #
3
+ # Registers a SYSTEM scheduled task that runs the fixed update/restart/diag runner.
4
+ # It does not widen any allow-list and does not execute arbitrary input.
5
+ #
6
+ # usage: powershell -ExecutionPolicy Bypass -File bootstrap.ps1 -AppDir C:\w2m\app -MachineId <id> [-OperatorKey C:\path\op.pub.pem]
7
+ param(
8
+ [Parameter(Mandatory=$true)][string]$AppDir,
9
+ [Parameter(Mandatory=$true)][string]$MachineId,
10
+ [string]$OperatorKey = ''
11
+ )
12
+ $ErrorActionPreference = 'Stop'
13
+ $principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
14
+ if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'run as Administrator' }
15
+ if (-not (Test-Path (Join-Path $AppDir 'bin\w2m-maintenance.mjs'))) { throw "missing $AppDir\bin\w2m-maintenance.mjs — deploy the package first" }
16
+ $Base = Join-Path $env:ProgramData 'w2m\maintenance'
17
+ foreach ($sub in 'queue','done','claimed') { New-Item -ItemType Directory -Force -Path (Join-Path $Base $sub) | Out-Null }
18
+ $envFile = Join-Path $Base 'machine.env'
19
+ "W2M_MACHINE_ID=$MachineId" | Set-Content -Encoding ASCII $envFile
20
+ if ($OperatorKey) {
21
+ & node (Join-Path $AppDir 'bin\w2m-maintenance.mjs') --once --allow-nonroot --queue-dir (Join-Path $Base 'queue') --done-dir (Join-Path $Base 'done') --machine-id $MachineId --pin-file (Join-Path $Base 'operator-pins.json') --operator-key $OperatorKey
22
+ }
23
+ $xml = Get-Content (Join-Path $AppDir 'deploy\windows\w2m-maintenance.xml') -Raw
24
+ $xml = $xml.Replace('C:\w2m\app', $AppDir).Replace('%W2M_MACHINE_ID%', $MachineId)
25
+ $tmp = Join-Path $env:TEMP 'w2m-maintenance-task.xml'
26
+ $xml | Set-Content -Encoding Unicode $tmp
27
+ Register-ScheduledTask -TaskName 'W2M Maintenance' -Xml (Get-Content $tmp -Raw) -Force | Out-Null
28
+ Start-ScheduledTask -TaskName 'W2M Maintenance'
29
+ Get-ScheduledTask -TaskName 'W2M Maintenance' | Select-Object TaskName, State
30
+ Write-Output "maintenance runner bootstrapped for machine $MachineId"
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env bash
2
+ # Bootstrap the M1 maintenance runner on a Linux machine (run ONCE as root).
3
+ #
4
+ # What it does, and deliberately does not do:
5
+ # * installs the root path unit + service that execute ONLY update/restart/diag
6
+ # * creates the queue/done directories the unprivileged agent writes to
7
+ # * optionally pins the operator public key supplied with --operator-key
8
+ # * never runs a user-supplied shell string; the unit's ExecStart is fixed
9
+ #
10
+ # usage: sudo bash bootstrap.sh --app-dir /opt/w2m/app --machine-id <id> [--operator-key /path/op.pub.pem]
11
+ set -euo pipefail
12
+ APP_DIR="/opt/w2m/app"
13
+ MACHINE_ID=""
14
+ OPERATOR_KEY=""
15
+ while [[ $# -gt 0 ]]; do
16
+ case "$1" in
17
+ --app-dir) APP_DIR="$2"; shift 2 ;;
18
+ --machine-id) MACHINE_ID="$2"; shift 2 ;;
19
+ --operator-key) OPERATOR_KEY="$2"; shift 2 ;;
20
+ *) echo "unknown argument: $1" >&2; exit 2 ;;
21
+ esac
22
+ done
23
+ if [[ -z "$MACHINE_ID" ]]; then echo "--machine-id is required" >&2; exit 2; fi
24
+ if [[ "$(id -u)" -ne 0 ]]; then echo "run as root" >&2; exit 2; fi
25
+
26
+ BASE=/var/lib/w2m/maintenance
27
+ mkdir -p "$BASE/queue" "$BASE/done" "$BASE/claimed"
28
+ # The agent runs as an unprivileged user; it must be able to create request files, and must NOT be able to execute them.
29
+ chown -R root:w2m "$BASE"
30
+ chmod 0770 "$BASE/queue"
31
+ chmod 0750 "$BASE/done" "$BASE/claimed"
32
+
33
+ if [[ ! -f "$APP_DIR/bin/w2m-maintenance.mjs" ]]; then
34
+ echo "missing $APP_DIR/bin/w2m-maintenance.mjs — deploy the package first" >&2
35
+ exit 1
36
+ fi
37
+
38
+ sed -e "s|/opt/w2m/app|$APP_DIR|g" "$APP_DIR/deploy/server/w2m-maintenance.service" > /etc/systemd/system/w2m-maintenance.service
39
+ cp "$APP_DIR/deploy/server/w2m-maintenance.path" /etc/systemd/system/w2m-maintenance.path
40
+ printf 'W2M_MACHINE_ID=%s\n' "$MACHINE_ID" > /etc/w2m-maintenance.env
41
+ mkdir -p /etc
42
+ mv /etc/w2m-maintenance.env /etc/w2m/maintenance.env 2>/dev/null || true
43
+ mkdir -p /etc/w2m
44
+ printf 'W2M_MACHINE_ID=%s\n' "$MACHINE_ID" > /etc/w2m/maintenance.env
45
+
46
+ if [[ -n "$OPERATOR_KEY" ]]; then
47
+ node "$APP_DIR/bin/w2m-maintenance.mjs" --once \
48
+ --allow-nonroot \
49
+ --queue-dir "$BASE/queue" --done-dir "$BASE/done" \
50
+ --machine-id "$MACHINE_ID" \
51
+ --pin-file "$BASE/operator-pins.json" \
52
+ --operator-key "$OPERATOR_KEY" >/dev/null
53
+ chown root:root "$BASE/operator-pins.json"
54
+ fi
55
+
56
+ systemctl daemon-reload
57
+ systemctl enable --now w2m-maintenance.path
58
+ systemctl status w2m-maintenance.path --no-pager | head -5
59
+ echo "maintenance runner bootstrapped for machine $MACHINE_ID"
@@ -0,0 +1,10 @@
1
+ # w2m-maintenance.path — triggers the runner whenever the agent drops a signed request.
2
+ [Unit]
3
+ Description=W2M maintenance queue watcher
4
+
5
+ [Path]
6
+ PathModified=/var/lib/w2m/maintenance/queue
7
+ Unit=w2m-maintenance.service
8
+
9
+ [Install]
10
+ WantedBy=multi-user.target
@@ -0,0 +1,20 @@
1
+ # w2m-maintenance.service — M1 root runner (v1.3.2-beta)
2
+ # Started by w2m-maintenance.path when a signed request lands in the queue directory.
3
+ [Unit]
4
+ Description=W2M maintenance runner (signed update/restart/diag requests)
5
+ Documentation=https://github.com/TwinsEarth/dsh-windows2macos/blob/main/docs/COLLAB/M1-MAINTENANCE.md
6
+ After=network-online.target
7
+ Wants=network-online.target
8
+
9
+ [Service]
10
+ Type=oneshot
11
+ User=root
12
+ Group=root
13
+ ExecStart=/usr/bin/env node /opt/w2m/app/bin/w2m-maintenance.mjs --once --queue-dir /var/lib/w2m/maintenance/queue --done-dir /var/lib/w2m/maintenance/done --machine-id ${W2M_MACHINE_ID} --pin-file /var/lib/w2m/maintenance/operator-pins.json
14
+ EnvironmentFile=-/etc/w2m/maintenance.env
15
+ ProtectSystem=strict
16
+ ProtectHome=yes
17
+ PrivateTmp=yes
18
+ RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
19
+ ReadWritePaths=/opt/w2m /var/lib/w2m/maintenance
20
+ NoNewPrivileges=yes
@@ -0,0 +1,31 @@
1
+ <?xml version="1.0" encoding="UTF-16"?>
2
+ <!-- w2m-maintenance.xml — M1 runner as a SYSTEM scheduled task (v1.3.2-beta).
3
+ Mirrors deploy/server/w2m-maintenance.service: fixed action set, queue directory, operator-signed requests. -->
4
+ <Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
5
+ <RegistrationInfo>
6
+ <Description>W2M maintenance runner (signed update/restart/diag requests)</Description>
7
+ </RegistrationInfo>
8
+ <Triggers>
9
+ <BootTrigger><Enabled>true</Enabled></BootTrigger>
10
+ <RegistrationTrigger><Enabled>true</Enabled></RegistrationTrigger>
11
+ </Triggers>
12
+ <Principals>
13
+ <Principal id="Author">
14
+ <UserId>S-1-5-18</UserId>
15
+ <RunLevel>HighestAvailable</RunLevel>
16
+ </Principal>
17
+ </Principals>
18
+ <Settings>
19
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
20
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
21
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
22
+ <ExecutionTimeLimit>PT15M</ExecutionTimeLimit>
23
+ <Enabled>true</Enabled>
24
+ </Settings>
25
+ <Actions Context="Author">
26
+ <Exec>
27
+ <Command>node</Command>
28
+ <Arguments>C:\w2m\app\bin\w2m-maintenance.mjs --watch --queue-dir C:\ProgramData\w2m\maintenance\queue --done-dir C:\ProgramData\w2m\maintenance\done --machine-id %W2M_MACHINE_ID% --pin-file C:\ProgramData\w2m\maintenance\operator-pins.json</Arguments>
29
+ </Exec>
30
+ </Actions>
31
+ </Task>
@@ -0,0 +1,240 @@
1
+ # 协作模型(Collaboration Model)设计规格 — v1.4.2-beta
2
+
3
+ > 状态:**模型级设计稿**。把 M4(项目房间)与 M5(多客户端原语)归纳为统一协作模型:共享对象、成员与角色、事件一致性、多客户端视图。
4
+ > 版本:`1.4.2-beta.*`;实现按 M4(1.3.5)与 M5(1.3.6)分步交付,模型一致性验收在 1.4.2-beta 完成。
5
+ > 相关:`docs/COLLAB/M4-PROJECT-ROOM.md`、`docs/COLLAB/M5-MULTI-CLIENT-PRIMITIVES.md`、`docs/COLLAB/TASK-MODEL.md`。
6
+
7
+ ---
8
+
9
+ ## 1. 问题陈述
10
+
11
+ 现状的"协作"只有三件互不关联的东西:
12
+
13
+ ```
14
+ · relay 的派发/聚合(任务属于调用者,不属于任何共享对象)
15
+ · 各 DSH 实例自己的会话(多客户端各看各的)
16
+ · fleet chat 消息流(是消息,不是共享状态)
17
+ ```
18
+
19
+ 缺的是**共享对象**:project / room / board / artifact / decision。没有它们:
20
+
21
+ - 多客户端无法看到同一份任务板与制品;
22
+ - 权限、成员、角色无处附着;
23
+ - 通知没有"项目"这个投递范围;
24
+ - 内存/决策是全局的,多项目互相污染。
25
+
26
+ ---
27
+
28
+ ## 2. 模型总览
29
+
30
+ ```
31
+ 协作 = 主体 × 项目 × 对象 × 关系 × 交互 × 事件
32
+
33
+ 主体 machine(设备身份) / operator(操作员密钥身份)
34
+ 项目 project(聚合根):成员、角色、设置、origin
35
+ 对象 任务板(board)、制品(artifact)、分支 refs、memory 文档、决策(decision)、
36
+ 审查(review)、锁(lock)、通知(notification)
37
+ 关系 成员资格 + 角色(owner/maintainer/worker/observer)
38
+ 交互 派发任务、提交/裁决审查、获取/释放锁、读写通知、读板/制品
39
+ 事件 追加式事件流(seq 单调),快照由事件投影而来;客户端只是视图
40
+ ```
41
+
42
+ **单一事实源**:项目事件流(+ ledger 长期审计)。客户端不做本地权威状态,只保存视图与水位(已应用 seq、已读通知水位)。
43
+
44
+ ---
45
+
46
+ ## 3. 核心概念与不变量
47
+
48
+ ### 3.1 项目(聚合根)
49
+
50
+ - 一个 project 有唯一 `project_id`、`origin`(git 或无)、`settings`、`members`。
51
+ - 任务、制品、refs、memory、decision、review、lock、notification 都必须挂在 project 下(旧任务惰性归入 default 项目)。
52
+ - 项目隔离:非成员不能读项目快照/事件/制品(错误信息不泄露存在性)。
53
+
54
+ ### 3.2 成员与角色
55
+
56
+ | 角色 | 可以做什么 |
57
+ |---|---|
58
+ | observer | 只读(板/制品/决策/refs/通知) |
59
+ | worker | + 派发任务、登记制品、申请/持有锁 |
60
+ | maintainer | + 写 memory/决策、审查裁决、项目设置 |
61
+ | owner | + 管理成员、创建项目、强制释放锁 |
62
+
63
+ 权限判定统一为 `mayAct(role, action)`(拒绝默认,返回 `required_role/current_role`)。角色变更本身是事件(`member.role_changed`),可审计。
64
+
65
+ ### 3.3 共享对象
66
+
67
+ | 对象 | 事实源 | 视图 |
68
+ |---|---|---|
69
+ | 任务板 | 任务状态事件(M3 任务模型) | 四列(waiting/active/done/failed)+ 计数 |
70
+ | 制品 | blob(内容寻址)+ 索引 | 列表/sha 下载 |
71
+ | refs | 合并队列推进(M3) | 分支 head |
72
+ | memory | CAS 文档(项目命名空间) | 索引 + 最近记录 |
73
+ | 决策 | 审查/council 决策事件 | 决策列表(含异议) |
74
+ | 审查 | review 对象(M5) | 待审/已审 |
75
+ | 锁 | lock 对象(M5) | 持有人/TTL |
76
+ | 通知 | 事件流派生 | 未读 + 列表 |
77
+
78
+ ### 3.4 事件与一致性
79
+
80
+ ```
81
+ · 每个项目一条追加式事件流,seq 单调(relay 权威排序)
82
+ · 快照 = 事件流在某个 seq 的投影,快照携带 last_event_seq(一致性点)
83
+ · 客户端增量:只应用 seq > 本地最大;发现缺口 → 重拉快照(绝不静默跳过)
84
+ · 客户端写后读:自己的写请求返回事件 seq;后续读至少看到该 seq(read-your-writes)
85
+ · 其他客户端:最终一致(通常 < 3s,实测目标)
86
+ · 消息/聊天不进事实源;聊天是旁路通道,不产生共享状态
87
+ ```
88
+
89
+ ### 3.5 存在性(presence)与水位
90
+
91
+ - presence = 设备在线状态 + 最后心跳(复用 M0 的设备表),用于显示"谁在线"。
92
+ - 每个客户端维护两类水位:`applied_seq`(视图一致性)与 `read_watermark_ms`(通知已读)。
93
+ - 水位持久化在客户端;服务端不追踪"某人读到哪"(隐私与规模考虑),只用未读计数接口。
94
+
95
+ ---
96
+
97
+ ## 4. 交互协议(模型级)
98
+
99
+ | 交互 | 入口 | 语义 |
100
+ |---|---|---|
101
+ | 选择/创建项目 | 项目 API + 面板 | 显式创建或选择 default |
102
+ | 派发任务 | task API + `project_id` | 任务归属项目,进板 |
103
+ | 提交审查 | review API | 指向 patch 制品,指定审查者 |
104
+ | 审查裁决 | review decision API | approve / changes_requested + findings |
105
+ | 获取/释放锁 | lock API | 分支/文件,TTL,强制释放(owner) |
106
+ | 读板/制品/决策/refs | 快照 + 增量 | 只读视图 |
107
+ | 通知 | 事件流 + unread + read | 实时 SSE + 离线补齐 |
108
+ | 订阅 | SSE `since_seq` | 断线重连续传 |
109
+
110
+ 所有写操作都要求成员资格 + 角色权限;所有写操作产生事件;所有事件可重放。
111
+
112
+ ---
113
+
114
+ ## 5. 多客户端的典型场景
115
+
116
+ ### 场景 A:两人联合修一个 bug
117
+
118
+ ```
119
+ A(机器 1)打开 project → 派发 worktree 任务 → 产出 patch 制品
120
+ A 提交审查(指定 B)→ B(机器 2)收到通知 → 拉 patch → 批准
121
+ 合并队列执行 → ref.updated → 双方板/refs/通知同步更新
122
+ ```
123
+
124
+ ### 场景 B:离线后补齐
125
+
126
+ ```
127
+ B 离线 2 分钟 → 重连:applied_seq 落后 → 拉增量事件补齐(或超窗口则重拉快照)
128
+ unread 通知计数 → 标记已读 → 归零;期间任务状态变化全部可见
129
+ ```
130
+
131
+ ### 场景 C:并发冲突
132
+
133
+ ```
134
+ A/B 同时改同一文件:enforced 模式下 A 持文件锁,B 获取失败(LOCK_HELD)
135
+ B 等待/改方案;A 释放后 B 继续;最终合并或结构化 MERGE_CONFLICT 由人解决
136
+ ```
137
+
138
+ ### 场景 D:relay 重启
139
+
140
+ ```
141
+ 持久化恢复:项目/成员/事件 seq 连续;客户端断线重连后按 seq 补齐;
142
+ 任务租约重派发;不丢事件、不重复应用
143
+ ```
144
+
145
+ ---
146
+
147
+ ## 6. 故障与保证
148
+
149
+ | 故障 | 保证 |
150
+ |---|---|
151
+ | 客户端断线 | 重连补齐;缺口→快照;不丢事件 |
152
+ | 客户端离线写 | 写请求失败即明确报错(不做本地离线写队列,避免冲突) |
153
+ | relay 重启 | 状态持久化 + seq 连续;快照可重建 |
154
+ | 成员被移除 | 立即失去读写(事件 `member.removed`);在途任务按任务语义完成或取消 |
155
+ | 权限不足 | 结构化拒绝(code + required/current role),不泄露对象是否存在 |
156
+ | 通知风暴 | 去重 + 限流 + digest(M5) |
157
+ | 事件流损坏 | 跳过并告警;ledger 可重建;快照兜底 |
158
+ | 制品被删 | 引用处明确报错 `ARTIFACT_MISSING`,不返回空内容 |
159
+
160
+ 投递语义:
161
+
162
+ - 事件:at-least-once 应用(客户端按 seq 幂等);
163
+ - 通知:at-least-once + 去重 + 水位;
164
+ - 审查结论:不可变,重复同向结论拒绝;
165
+ - 锁:TTL 过期自动释放,强占需 owner 且留痕。
166
+
167
+ ---
168
+
169
+ ## 7. 安全与隐私
170
+
171
+ 1. **身份分离**:machine(设备令牌)与 operator(签名密钥)是两类主体,成员资格显式绑定。
172
+ 2. **项目隔离**:跨项目不可见;错误不泄露存在性;制品按成员资格授权。
173
+ 3. **最小权限**:角色矩阵(§3.2);拒绝默认。
174
+ 4. **审计**:成员变更、角色变更、锁强制释放、审查结论、决策全部进事件 + ledger。
175
+ 5. **隐私**:客户端水位本地保存;服务端不追踪个人阅读明细(只给未读计数)。
176
+ 6. **不把聊天当状态**:消息可被编辑策略删除/分享,因此不进入项目事实源。
177
+
178
+ ---
179
+
180
+ ## 8. 兼容与迁移
181
+
182
+ - 新 API 全部新增;旧客户端/旧 relay 不受影响(旧 relay → `PROJECT_ROOM_UNSUPPORTED_RELAY`)。
183
+ - 旧任务:启动时惰性归入 default 项目(不重写 ledger)。
184
+ - 旧 fleet memory:保留为 fleet 级;项目决策写 `project:<id>:decisions`。
185
+ - fleet chat:保留为旁路通道(人不执行聊天内容;聊天不产生任务)。
186
+ - 三个模型的关系:执行授权(能否执行)→ 任务(执行什么)→ 协作(谁/在哪/共享什么)。
187
+
188
+ ---
189
+
190
+ ## 9. 验收(模型一致性,真实任务)
191
+
192
+ ### 正向
193
+
194
+ 1. **同一任务板**:A 派发任务 → B(另一台机器)3 秒内看到;状态变化同步。
195
+ 2. **制品共享**:A 的 patch 制品登记 → B 可下载且 sha 一致。
196
+ 3. **决策共享**:A 记录决策(含异议)→ B 决策列表可见。
197
+ 4. **审查门禁联动**:A 提交审查 → B 批准 → 合并门禁放行。
198
+ 5. **锁协作**:enforced 模式下 A 持锁 B 被拒;释放后 B 成功;双方收到锁事件。
199
+ 6. **离线补齐**:B 断线 2 分钟 → 重连后事件补齐、未读通知计数正确、已读归零。
200
+ 7. **重启恢复**:relay 重启后项目/成员/seq 连续,客户端补齐无重复。
201
+
202
+ ### 负向(自动化矩阵)
203
+
204
+ ```
205
+ 非成员读写 → 403/404(不泄露)
206
+ observer 派发/登记 → 拒绝
207
+ worker 审查裁决 → REVIEW_PERMISSION
208
+ 移除成员后访问 → 立即失败
209
+ seq 缺口 → 强制快照(不静默)
210
+ 重复事件应用 → 幂等
211
+ 通知风暴 → digest 且不丢
212
+ 锁被盗用/过期操作 → LOCK_*
213
+ 制品被删后的引用 → ARTIFACT_MISSING
214
+ ```
215
+
216
+ ---
217
+
218
+ ## 10. 与版本/模块的映射
219
+
220
+ ```
221
+ 1.3.5-beta M4 实现:project 对象、快照+增量、权限、面板只读视图
222
+ 1.3.6-beta M5 实现:锁/审查/通知
223
+ 1.4.2-beta 模型一致性验收:统一对象/事件/权限/水位语义,双客户端真实任务证据
224
+ 模块:
225
+ src/relay/project-room.mjs 项目/成员/角色/设置
226
+ src/relay/project-events.mjs 事件流/快照/增量/SSE
227
+ src/relay/project-artifacts.mjs 制品索引
228
+ src/relay/locks.mjs / reviews.mjs / notifications.mjs(M5)
229
+ src/plugin/tools.mjs + fleet-chat-route.mjs + lib/client.js(客户端视图)
230
+ ```
231
+
232
+ ## 11. 开放问题(实现前确认)
233
+
234
+ 1. **身份维度**:本版本用 machine+operator,还是先引入"人"的账号模型(影响成员/通知/审查)?
235
+ 2. **项目创建权**:仅 owner,还是 maintainer 也可创建?
236
+ 3. **default 项目可见性**:所有成员可见,还是仅 owner(旧任务迁移策略)?
237
+ 4. **通知渠道**:只做项目房间内(推荐),还是同时接 fleet chat mentions?
238
+ 5. **presence 范围**:只显示在线/离线,还是显示"正在编辑哪个任务/持有哪些锁"?
239
+ 6. **任务多项目归属**:beta 单项目(推荐)还是允许一任务挂多个项目?
240
+ 7. **事件保留**:5000 条/项目是否够用?超出只靠快照是否可接受?