@twinsearth/w2m-dsh-plugin 2.0.3 → 2.0.5
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/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +17 -0
- package/README.md +187 -462
- package/bin/w2m-joint.mjs +69 -7
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/2.0.4/RELEASE-2040.json +22 -0
- package/docs/acceptance/2.0.5/RELEASE-205.json +21 -0
- package/docs/acceptance/2.0.5/RELEASE-2050.json +22 -0
- package/docs/adr/0001-collaboration-crdt.md +59 -0
- package/lib/client.js +2754 -2599
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/src/agent/agent.mjs +97 -36
- package/src/agent/artifact-delivery.mjs +89 -0
- package/src/agent/caps.mjs +16 -2
- package/src/agent/sinks/git-sink.mjs +78 -0
- package/src/agent/sinks/ssh-sink.mjs +83 -0
- package/src/agent/sse-cursor.mjs +16 -0
- package/src/agent/sse-watchdog.mjs +71 -0
- package/src/plugin/dataplane-sink-route.mjs +113 -0
- package/src/plugin/fleet-chat-route.mjs +11 -0
- package/src/plugin/gpw.mjs +99 -15
- package/src/plugin/joint-dev/dispatch.mjs +5 -1
- package/src/plugin/joint-dev/merge-approval.mjs +115 -0
- package/src/plugin/joint-dev/progress.mjs +107 -0
- package/src/plugin/joint-dev/relay-dispatch.mjs +52 -22
- package/src/plugin/project-stream-route.mjs +58 -0
- package/src/plugin/tools-joint.mjs +6 -1
- package/src/plugin/tools.mjs +25 -5
- package/src/relay/fleet-messages.mjs +48 -24
- package/src/relay/project-room.mjs +47 -2
- package/src/relay/server.mjs +11 -5
- package/src/relay/state.mjs +63 -3
- package/src/util/chat-crypto.mjs +70 -1
- package/docs/COLLAB/COLLABORATION-MODEL.md +0 -240
- package/docs/COLLAB/EXECUTION-AUTH-MODEL.md +0 -361
- package/docs/COLLAB/M1-ACCEPTANCE-RUNBOOK.md +0 -70
- package/docs/COLLAB/M1-MAINTENANCE.md +0 -313
- package/docs/COLLAB/M2-TASK-GRANT.md +0 -231
- package/docs/COLLAB/M3-GIT-NATIVE-TASKS.md +0 -291
- package/docs/COLLAB/M4-PROJECT-ROOM.md +0 -260
- package/docs/COLLAB/M5-MULTI-CLIENT-PRIMITIVES.md +0 -263
- package/docs/COLLAB/M6-CROSS-NETWORK-RELIABILITY.md +0 -231
- package/docs/COLLAB/M7-METRICS-ACCEPTANCE.md +0 -263
- package/docs/COLLAB/TASK-MODEL.md +0 -281
- package/docs/acceptance/1.9.10/RELEASE-1910.json +0 -21
- package/docs/acceptance/1.9.11/RELEASE-1911.json +0 -20
- package/docs/acceptance/1.9.12/RELEASE-1912.json +0 -20
- package/docs/acceptance/1.9.13/RELEASE-1913.json +0 -20
- package/docs/acceptance/1.9.14/RELEASE-1914.json +0 -20
- package/docs/acceptance/1.9.15/RELEASE-1915.json +0 -22
- package/docs/acceptance/1.9.16/RELEASE-1916.json +0 -21
- package/docs/acceptance/1.9.17/RELEASE-1917.json +0 -20
- package/docs/acceptance/1.9.19/RELEASE-1919.json +0 -21
- package/docs/acceptance/1.9.20/RELEASE-1920.json +0 -22
- package/docs/acceptance/1.9.4/RELEASE-194.json +0 -23
- package/docs/acceptance/1.9.5/RELEASE-195.json +0 -23
- package/docs/acceptance/1.9.6/RELEASE-196.json +0 -22
- package/docs/acceptance/1.9.7/RELEASE-197.json +0 -21
- package/docs/acceptance/1.9.8/RELEASE-198.json +0 -21
- package/docs/acceptance/1.9.9/RELEASE-199.json +0 -21
- package/lib/client.js.bak2 +0 -640
- package/src/plugin/dataflow.mjs +0 -72
- package/src/plugin/fleet-target.mjs.bak-arch2 +0 -812
- package/src/plugin/fleet-target.mjs.bak-archive +0 -812
- package/src/plugin/tofuture/report.mjs +0 -44
- package/src/plugin/tools.mjs.bak-c7 +0 -7029
- package/src/relay/tofuture-whitelist.mjs +0 -46
- package/src/util/data-plane.mjs +0 -180
- package/src/util/sealed-object.mjs +0 -141
- package/src/util/tofuture-hmac.mjs +0 -30
package/src/relay/state.mjs
CHANGED
|
@@ -24,6 +24,8 @@ import { deviceFleetFields } from '../chatops/fleet-wiring.mjs';
|
|
|
24
24
|
import { scopeFleetId } from '../chatops/fleet-wiring.mjs';
|
|
25
25
|
// v0.8.1: the account-derived fleet key, so a machine can join with no pairing code.
|
|
26
26
|
import { planJoin } from '../chatops/fleet-join.mjs';
|
|
27
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
28
|
+
import { dirname, join } from 'node:path';
|
|
27
29
|
import { validateTaskDependencies } from './task-deps.mjs';
|
|
28
30
|
import { buildMembers, isMemberRole, setMemberRole as applyMemberRole } from './fleet-members.mjs';
|
|
29
31
|
import { canonicalSignedPayload, publicKeyFromBase64 } from '../util/envelope-signature.mjs';
|
|
@@ -269,9 +271,22 @@ export const P2P_MODES = Object.freeze(['auto', 'direct', 'relay']);
|
|
|
269
271
|
*/
|
|
270
272
|
export const TRANSPORT_VALUES = Object.freeze(['p2p', 'relay']);
|
|
271
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Canonical form of a relative working directory. Both the relay and the agent hash this string, so
|
|
276
|
+
* it is normalized in exactly one place per side and the two definitions are asserted to agree:
|
|
277
|
+
* backslashes become slashes, './' and repeated/trailing slashes disappear, empty means '.'.
|
|
278
|
+
*/
|
|
279
|
+
export function normalizeCwdRel(value) {
|
|
280
|
+
if (value === null || value === undefined) return '.';
|
|
281
|
+
let out = String(value).replace(/\\/g, '/');
|
|
282
|
+
while (out.startsWith('./')) out = out.slice(2);
|
|
283
|
+
out = out.replace(/\/{2,}/g, '/').replace(/\/+$/, '');
|
|
284
|
+
return out === '' ? '.' : out;
|
|
285
|
+
}
|
|
286
|
+
|
|
272
287
|
/** §4.4 helper: command_hash = sha256(JCS(argv)+"|"+shell_id+"|"+cwd_rel). */
|
|
273
288
|
export function computeCommandHash(commandArgv, shellId = SHELL_ID_DIRECT, cwdRel = '.') {
|
|
274
|
-
return sha256Hex(`${jcs(commandArgv)}|${shellId}|${cwdRel}`);
|
|
289
|
+
return sha256Hex(`${jcs(commandArgv)}|${shellId}|${normalizeCwdRel(cwdRel)}`);
|
|
275
290
|
}
|
|
276
291
|
|
|
277
292
|
/**
|
|
@@ -288,7 +303,7 @@ export function computeCommandHash(commandArgv, shellId = SHELL_ID_DIRECT, cwdRe
|
|
|
288
303
|
export function computePipelineCommandHash(stages, shellId = SHELL_ID_DIRECT) {
|
|
289
304
|
const canonical = stages.map((s) => ({
|
|
290
305
|
command_argv: s.command_argv,
|
|
291
|
-
cwd_rel: s.cwd_rel
|
|
306
|
+
cwd_rel: normalizeCwdRel(s.cwd_rel),
|
|
292
307
|
continue_on_failure: s.continue_on_failure === true,
|
|
293
308
|
}));
|
|
294
309
|
return sha256Hex(`${jcs(canonical)}|${shellId}|pipeline`);
|
|
@@ -560,6 +575,7 @@ export class RabbitState {
|
|
|
560
575
|
const metricsEvent = (event) => {
|
|
561
576
|
try { this.metrics.inc('wm_project_events_total', { type: event.type }); } catch { /* metrics never break the room */ }
|
|
562
577
|
for (const listener of this.projectSubscribers) { try { listener(event); } catch { /* a subscriber never breaks the room */ } }
|
|
578
|
+
if (typeof this._persistProjects === 'function') this._persistProjects();
|
|
563
579
|
const notified = NOTIFIED[event.type];
|
|
564
580
|
if (notified !== undefined) {
|
|
565
581
|
try {
|
|
@@ -568,6 +584,18 @@ export class RabbitState {
|
|
|
568
584
|
}
|
|
569
585
|
};
|
|
570
586
|
this.projects = createProjectRoom({ now: () => this.nowMs(), onEvent: metricsEvent });
|
|
587
|
+
// A6: the room snapshot is the durable form of projects/members/events. Written after every room
|
|
588
|
+
// event (0600, tmp+rename) and restored before the relay answers anything, so a restart keeps the
|
|
589
|
+
// project object and its cursor instead of silently starting from an empty room.
|
|
590
|
+
this._projectsFile = typeof this.persistence?.dir === 'string' && this.persistence.dir !== ''
|
|
591
|
+
? join(this.persistence.dir, 'projects', 'room-snapshot.json')
|
|
592
|
+
: null;
|
|
593
|
+
if (this._projectsFile !== null) {
|
|
594
|
+
try {
|
|
595
|
+
const snapshot = JSON.parse(readFileSync(this._projectsFile, 'utf8').replace(/^\uFEFF/, ''));
|
|
596
|
+
this.projects.restore(snapshot);
|
|
597
|
+
} catch { /* absent or corrupt: start empty rather than failing to boot */ }
|
|
598
|
+
}
|
|
571
599
|
this.defaultProjectId = 'default';
|
|
572
600
|
// M5 primitives share the project room's event stream: a lock, review or notification is a project event with the
|
|
573
601
|
// module's own sequence attached, so one reader can reconstruct "what happened" from a single cursor.
|
|
@@ -622,6 +650,17 @@ export class RabbitState {
|
|
|
622
650
|
/** Inject/replace the persistence backend (also used by tests). */
|
|
623
651
|
setPersistence(persistence) { this._persist = persistence ?? null; return this._persist; }
|
|
624
652
|
|
|
653
|
+
/** A6: atomic write of the project room snapshot (0600). */
|
|
654
|
+
_persistProjects() {
|
|
655
|
+
if (typeof this._projectsFile !== 'string' || this._projectsFile === '') return;
|
|
656
|
+
try {
|
|
657
|
+
mkdirSync(dirname(this._projectsFile), { recursive: true, mode: 0o700 });
|
|
658
|
+
const tmp = this._projectsFile + '.tmp-' + process.pid + '-' + Date.now().toString(36);
|
|
659
|
+
writeFileSync(tmp, JSON.stringify(this.projects.toSnapshot()), { encoding: 'utf8', mode: 0o600 });
|
|
660
|
+
renameSync(tmp, this._projectsFile);
|
|
661
|
+
} catch { /* a failed snapshot write must not break the live room */ }
|
|
662
|
+
}
|
|
663
|
+
|
|
625
664
|
/* ---------------- v1.0.7: the content-addressed blob channel (#1) ---------------- */
|
|
626
665
|
|
|
627
666
|
/**
|
|
@@ -1788,7 +1827,7 @@ export class RabbitState {
|
|
|
1788
1827
|
}
|
|
1789
1828
|
|
|
1790
1829
|
const now = this.nowMs();
|
|
1791
|
-
const cwdRel =
|
|
1830
|
+
const cwdRel = normalizeCwdRel(input.cwd_rel);
|
|
1792
1831
|
const baseTree = input.base_tree ?? null;
|
|
1793
1832
|
// v2 R5: a joint dispatch must be auditable; a malformed binding is refused, never nulled.
|
|
1794
1833
|
for (const key of ['plan_sha256', 'approval_sha256']) {
|
|
@@ -2825,6 +2864,26 @@ export class RabbitState {
|
|
|
2825
2864
|
* of nothing -- a fake "the work is still being done" signal, which is the most
|
|
2826
2865
|
* dangerous lie this relay can tell.
|
|
2827
2866
|
*/
|
|
2867
|
+
/**
|
|
2868
|
+
* Merge a small, typed bag of machine-advertised capabilities into the device row.
|
|
2869
|
+
*
|
|
2870
|
+
* Only scalars are copied (strings <= 64 chars, booleans, null) and only a bounded number of
|
|
2871
|
+
* keys, so a heartbeat cannot turn the roster into an unbounded side channel.
|
|
2872
|
+
*/
|
|
2873
|
+
_mergeAdvertisedCaps(device, caps) {
|
|
2874
|
+
if (device === null || typeof device !== 'object') return;
|
|
2875
|
+
if (caps === null || typeof caps !== 'object' || Array.isArray(caps)) return;
|
|
2876
|
+
const next = { ...(device.caps ?? {}) };
|
|
2877
|
+
let count = 0;
|
|
2878
|
+
for (const [key, value] of Object.entries(caps)) {
|
|
2879
|
+
if (count >= 32) break;
|
|
2880
|
+
if (typeof key !== 'string' || key.length === 0 || key.length > 32) continue;
|
|
2881
|
+
if (typeof value === 'string' && value.length <= 64) { next[key] = value; count += 1; continue; }
|
|
2882
|
+
if (typeof value === 'boolean' || value === null) { next[key] = value; count += 1; }
|
|
2883
|
+
}
|
|
2884
|
+
device.caps = next;
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2828
2887
|
diagnosticHeartbeat(input = {}) {
|
|
2829
2888
|
const machineId = typeof input.machine_id === 'string' && input.machine_id !== ''
|
|
2830
2889
|
? input.machine_id : null;
|
|
@@ -2837,6 +2896,7 @@ export class RabbitState {
|
|
|
2837
2896
|
this._validatePhaseProgress(input);
|
|
2838
2897
|
|
|
2839
2898
|
const device = this.touchDevice(machineId);
|
|
2899
|
+
this._mergeAdvertisedCaps(device, input.caps);
|
|
2840
2900
|
this.recordHeartbeatMetrics(machineId, input);
|
|
2841
2901
|
return {
|
|
2842
2902
|
// No lease is involved, and the response says so instead of inventing one.
|
package/src/util/chat-crypto.mjs
CHANGED
|
@@ -71,7 +71,7 @@ export function encryptChatText(key, text) {
|
|
|
71
71
|
* @param {{text?: string, enc?: object|null}} message
|
|
72
72
|
* @returns {{ok: true, text: string} | {ok: false, code: string}}
|
|
73
73
|
*/
|
|
74
|
-
|
|
74
|
+
function decryptV1Message(key, message = {}) {
|
|
75
75
|
const enc = message?.enc ?? null;
|
|
76
76
|
if (enc === null || enc === undefined) {
|
|
77
77
|
// Plaintext: the pre-encryption shape, still readable by everyone.
|
|
@@ -92,6 +92,75 @@ export function decryptChatMessage(key, message = {}) {
|
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
|
|
96
|
+
/** Chat cipher v2: same AES-256-GCM, with AAD binding the message to its session and sender. */
|
|
97
|
+
export const CHAT_CIPHER_V2 = Object.freeze({ version: 2, alg: 'aes-256-gcm' });
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* AAD = w2m-chat-v2 | session_id | from_machine | message_id
|
|
101
|
+
*
|
|
102
|
+
* The AAD is transmitted in the clear inside `enc.aad` so every reader can rebuild it, but the GCM
|
|
103
|
+
* tag authenticates those exact bytes: changing any of them makes decryption fail. A caller that
|
|
104
|
+
* knows the expected context must pass it, which is what turns a whole encrypted message copied into
|
|
105
|
+
* another session into a refusal instead of a successfully decrypted but mis-attributed message.
|
|
106
|
+
*/
|
|
107
|
+
export function chatAadV2({ sessionId, machineId, messageId }) {
|
|
108
|
+
return Buffer.from('w2m-chat-v2|' + String(sessionId) + '|' + String(machineId) + '|' + String(messageId), 'utf8');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function encryptChatTextV2(key, text, context = {}) {
|
|
112
|
+
if (key === null || !Buffer.isBuffer(key) || key.length !== KEY_BYTES) return { ok: false, code: 'CHAT_NO_KEY' };
|
|
113
|
+
if (typeof text !== 'string' || text === '') return { ok: false, code: 'CHAT_EMPTY_TEXT' };
|
|
114
|
+
const sessionId = typeof context.sessionId === 'string' ? context.sessionId : '';
|
|
115
|
+
const machineId = typeof context.machineId === 'string' ? context.machineId : '';
|
|
116
|
+
const messageId = typeof context.messageId === 'string' ? context.messageId : '';
|
|
117
|
+
if (sessionId === '' || machineId === '' || messageId === '') return { ok: false, code: 'CHAT_AAD_CONTEXT_REQUIRED' };
|
|
118
|
+
const aad = chatAadV2({ sessionId, machineId, messageId });
|
|
119
|
+
const iv = randomBytes(IV_BYTES);
|
|
120
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
121
|
+
cipher.setAAD(aad);
|
|
122
|
+
const ciphertext = Buffer.concat([cipher.update(Buffer.from(text, 'utf8')), cipher.final()]);
|
|
123
|
+
return {
|
|
124
|
+
ok: true,
|
|
125
|
+
text: ciphertext.toString('base64'),
|
|
126
|
+
enc: { v: CHAT_CIPHER_V2.version, alg: CHAT_CIPHER_V2.alg, iv: iv.toString('base64'), tag: cipher.getAuthTag().toString('base64'), aad: { session_id: sessionId, machine_id: machineId, message_id: messageId } },
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function decryptChatMessageV2(key, message = {}, context = {}) {
|
|
131
|
+
if (key === null || !Buffer.isBuffer(key) || key.length !== KEY_BYTES) return { ok: false, code: 'CHAT_NO_KEY' };
|
|
132
|
+
const enc = message?.enc ?? null;
|
|
133
|
+
if (enc === null || typeof enc !== 'object' || enc.v !== CHAT_CIPHER_V2.version || enc.alg !== CHAT_CIPHER_V2.alg) return { ok: false, code: 'CHAT_UNSUPPORTED_CIPHER' };
|
|
134
|
+
const aad = enc.aad ?? {};
|
|
135
|
+
const expectedSession = typeof context.sessionId === 'string' ? context.sessionId : null;
|
|
136
|
+
const expectedMachine = typeof context.machineId === 'string' ? context.machineId : null;
|
|
137
|
+
if (expectedSession !== null && String(aad.session_id ?? '') !== expectedSession) return { ok: false, code: 'CHAT_AAD_MISMATCH', detail: { field: 'session_id' } };
|
|
138
|
+
if (expectedMachine !== null && String(aad.machine_id ?? '') !== expectedMachine) return { ok: false, code: 'CHAT_AAD_MISMATCH', detail: { field: 'machine_id' } };
|
|
139
|
+
try {
|
|
140
|
+
const iv = Buffer.from(String(enc.iv ?? ''), 'base64');
|
|
141
|
+
const tag = Buffer.from(String(enc.tag ?? ''), 'base64');
|
|
142
|
+
if (iv.length !== IV_BYTES || tag.length !== 16) return { ok: false, code: 'CHAT_BAD_ENCRYPTION' };
|
|
143
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
144
|
+
decipher.setAAD(chatAadV2({ sessionId: aad.session_id, machineId: aad.machine_id, messageId: aad.message_id }));
|
|
145
|
+
decipher.setAuthTag(tag);
|
|
146
|
+
const plain = Buffer.concat([decipher.update(Buffer.from(String(message.text ?? ''), 'base64')), decipher.final()]);
|
|
147
|
+
return { ok: true, text: plain.toString('utf8'), version: 2 };
|
|
148
|
+
} catch {
|
|
149
|
+
return { ok: false, code: 'CHAT_DECRYPT_FAILED' };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* v1 is read-only compatible forever; v2 requires the expected context to bind the message to its
|
|
155
|
+
* session/sender. Unknown versions are refused rather than treated as v1.
|
|
156
|
+
*/
|
|
157
|
+
export function decryptChatMessage(key, message = {}, context = {}) {
|
|
158
|
+
const enc = message?.enc ?? null;
|
|
159
|
+
if (enc !== null && typeof enc === 'object' && enc.v === CHAT_CIPHER_V2.version) return decryptChatMessageV2(key, message, context);
|
|
160
|
+
if (enc !== null && typeof enc === 'object' && enc.v !== CHAT_CIPHER.version) return { ok: false, code: 'CHAT_UNSUPPORTED_CIPHER' };
|
|
161
|
+
return decryptV1Message(key, message);
|
|
162
|
+
}
|
|
163
|
+
|
|
95
164
|
/** True when a message carries the encryption metadata, whatever its text looks like. */
|
|
96
165
|
export function isEncrypted(message) {
|
|
97
166
|
return message?.enc !== null && message?.enc !== undefined && typeof message.enc === 'object';
|
|
@@ -1,240 +0,0 @@
|
|
|
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 条/项目是否够用?超出只靠快照是否可接受?
|