@walkhi/code-relax 0.1.0-beta.27 → 0.1.0-beta.28
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/README.md +80 -67
- package/dist/bin/codex-remote.mjs +286 -239
- package/dist/shared/app-server-events.cjs +102 -16
- package/dist/shared/code-browser.cjs +159 -0
- package/dist/shared/file-changes.cjs +31 -0
- package/dist/shared/file-citations.cjs +117 -82
- package/dist/shared/file-mentions.cjs +80 -35
- package/dist/shared/message-delivery.cjs +38 -0
- package/dist/shared/p2p-file-channel.cjs +74 -0
- package/dist/shared/server-requests.cjs +149 -41
- package/dist/shared/service-endpoints.cjs +11 -11
- package/dist/shared/timeline-sync.cjs +173 -156
- package/dist/skills/code-relax/SKILL.md +2 -1
- package/dist/src/bounded-process-log.mjs +63 -0
- package/dist/src/celsius-client.mjs +107 -0
- package/dist/src/code-browser.mjs +101 -0
- package/dist/src/desktop-launcher.mjs +30 -30
- package/dist/src/dots-approval-timeline.mjs +99 -0
- package/dist/src/dots-avatar.mjs +42 -0
- package/dist/src/dots-chat-projection.mjs +50 -0
- package/dist/src/dots-chat-server.mjs +412 -0
- package/dist/src/dots-message-images.mjs +319 -0
- package/dist/src/dots-profile.mjs +77 -0
- package/dist/src/dots-room-activity.mjs +94 -0
- package/dist/src/dots-room-client.mjs +135 -0
- package/dist/src/dots-room-projection.mjs +94 -0
- package/dist/src/dots-schedules.mjs +66 -0
- package/dist/src/dots-summary-tasks.mjs +32 -0
- package/dist/src/dots-task-catalog.mjs +411 -0
- package/dist/src/file-transfer-range.mjs +22 -0
- package/dist/src/file-upload-session.mjs +130 -124
- package/dist/src/hosted-history-app-server.mjs +103 -0
- package/dist/src/hosted-history-cache.mjs +113 -0
- package/dist/src/lan-socket-server.mjs +205 -160
- package/dist/src/managed-app-server.mjs +241 -238
- package/dist/src/message-upload-files.mjs +74 -0
- package/dist/src/platform/windows/activate-desktop.ps1 +48 -0
- package/dist/src/platform/windows/codex-runtime.mjs +253 -251
- package/dist/src/platform/windows/desktop-host.mjs +38 -32
- package/dist/src/platform/windows/desktop-info.ps1 +2 -2
- package/dist/src/platform/windows/desktop-project-registration.mjs +153 -0
- package/dist/src/platform/windows/dots-profile.mjs +49 -0
- package/dist/src/platform/windows/launch-worker.mjs +41 -42
- package/dist/src/platform/windows/project-locations.mjs +16 -0
- package/dist/src/project-create.mjs +119 -0
- package/dist/src/project-management.mjs +69 -0
- package/dist/src/self-relay/account-connections.mjs +52 -0
- package/dist/src/self-relay/admin-page.mjs +80 -80
- package/dist/src/self-relay/admin-style.mjs +14 -9
- package/dist/src/self-relay/connector.mjs +22 -4
- package/dist/src/self-relay/file-wire.mjs +13 -0
- package/dist/src/self-relay/p2p-probe.mjs +152 -123
- package/dist/src/self-relay/rtc-file-bridge.mjs +41 -0
- package/dist/src/self-relay/server.mjs +894 -849
- package/dist/src/server.mjs +471 -199
- package/dist/src/service-doctor.mjs +72 -61
- package/dist/src/service-lifecycle.mjs +51 -43
- package/dist/src/shared-app-server.mjs +166 -107
- package/dist/src/shared-catalog.mjs +132 -40
- package/dist/src/shared-thread-stream.mjs +103 -25
- package/dist/src/usage-reset.mjs +17 -0
- package/dist/src/workstation-downloads.mjs +120 -0
- package/package.json +1 -1
|
@@ -1,156 +1,173 @@
|
|
|
1
|
-
(function (root, factory) {
|
|
2
|
-
if (typeof module === 'object' && module.exports) module.exports = factory();
|
|
3
|
-
else root.CodexTimelineSync = factory();
|
|
4
|
-
})(globalThis, function () {
|
|
5
|
-
const equal = (left, right) => left === right || JSON.stringify(left) === JSON.stringify(right);
|
|
6
|
-
function changedFields(previous, current, ignored) {
|
|
7
|
-
const set = {}, append = {}, unset = [];
|
|
8
|
-
for (const key of new Set([...Object.keys(previous || {}), ...Object.keys(current || {})])) {
|
|
9
|
-
if (ignored.has(key) || equal(previous?.[key], current?.[key])) continue;
|
|
10
|
-
if (!Object.hasOwn(current, key)) { unset.push(key); continue; }
|
|
11
|
-
const before = previous?.[key], after = current[key];
|
|
12
|
-
if (typeof before === 'string' && typeof after === 'string' && after.startsWith(before)) append[key] = after.slice(before.length);
|
|
13
|
-
else set[key] = after;
|
|
14
|
-
}
|
|
15
|
-
return { set, append, unset };
|
|
16
|
-
}
|
|
17
|
-
const nonempty = changes => Object.keys(changes.set).length || Object.keys(changes.append).length || changes.unset.length;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
})
|
|
1
|
+
(function (root, factory) {
|
|
2
|
+
if (typeof module === 'object' && module.exports) module.exports = factory();
|
|
3
|
+
else root.CodexTimelineSync = factory();
|
|
4
|
+
})(globalThis, function () {
|
|
5
|
+
const equal = (left, right) => left === right || JSON.stringify(left) === JSON.stringify(right);
|
|
6
|
+
function changedFields(previous, current, ignored) {
|
|
7
|
+
const set = {}, append = {}, unset = [];
|
|
8
|
+
for (const key of new Set([...Object.keys(previous || {}), ...Object.keys(current || {})])) {
|
|
9
|
+
if (ignored.has(key) || equal(previous?.[key], current?.[key])) continue;
|
|
10
|
+
if (!Object.hasOwn(current, key)) { unset.push(key); continue; }
|
|
11
|
+
const before = previous?.[key], after = current[key];
|
|
12
|
+
if (typeof before === 'string' && typeof after === 'string' && after.startsWith(before)) append[key] = after.slice(before.length);
|
|
13
|
+
else set[key] = after;
|
|
14
|
+
}
|
|
15
|
+
return { set, append, unset };
|
|
16
|
+
}
|
|
17
|
+
const nonempty = changes => Object.keys(changes.set).length || Object.keys(changes.append).length || changes.unset.length;
|
|
18
|
+
// An ordered history range owns the order of its shared IDs. Keep entries
|
|
19
|
+
// outside that range at their existing shared anchors, never by timestamps.
|
|
20
|
+
function mergeOrderedEntries(existing, incoming, merge = (_previous, next) => next, incomingIsOlder = false) {
|
|
21
|
+
const incomingIds = new Set(incoming.map(entry => entry.id));
|
|
22
|
+
const existingById = new Map(existing.map(entry => [entry.id, entry]));
|
|
23
|
+
const before = new Map();
|
|
24
|
+
let pending = [], lastShared;
|
|
25
|
+
for (const entry of existing) {
|
|
26
|
+
if (incomingIds.has(entry.id)) {
|
|
27
|
+
before.set(entry.id, pending); pending = []; lastShared = entry.id;
|
|
28
|
+
} else pending.push(entry);
|
|
29
|
+
}
|
|
30
|
+
if (lastShared === undefined) return incomingIsOlder ? [...incoming, ...existing] : [...existing, ...incoming];
|
|
31
|
+
return incoming.flatMap(entry => [ ...(before.get(entry.id) || []),
|
|
32
|
+
existingById.has(entry.id) ? merge(existingById.get(entry.id), entry) : entry,
|
|
33
|
+
...(entry.id === lastShared ? pending : []) ]);
|
|
34
|
+
}
|
|
35
|
+
function diffTurn(previous, current) {
|
|
36
|
+
if (!previous || !current || previous.id !== current.id) return null;
|
|
37
|
+
const before = Array.isArray(previous.entries) ? previous.entries : [];
|
|
38
|
+
const after = Array.isArray(current.entries) ? current.entries : [];
|
|
39
|
+
if (after.length < before.length) return null;
|
|
40
|
+
const entries = [];
|
|
41
|
+
for (let index = 0; index < before.length; index++) {
|
|
42
|
+
if (before[index]?.id !== after[index]?.id || before[index]?.type !== after[index]?.type ||
|
|
43
|
+
before[index]?.role !== after[index]?.role || before[index]?.kind !== after[index]?.kind) return null;
|
|
44
|
+
const changes = changedFields(before[index], after[index], new Set(['id', 'type', 'role', 'kind']));
|
|
45
|
+
if (nonempty(changes)) entries.push({ id: after[index].id, index, ...changes });
|
|
46
|
+
}
|
|
47
|
+
const turn = changedFields(previous, current, new Set(['id', 'entries']));
|
|
48
|
+
const appended = after.slice(before.length);
|
|
49
|
+
return { id: current.id, ...turn, entries, appended };
|
|
50
|
+
}
|
|
51
|
+
function applyChanges(target, changes) {
|
|
52
|
+
const result = { ...target, ...(changes.set || {}) };
|
|
53
|
+
for (const key of changes.unset || []) delete result[key];
|
|
54
|
+
for (const [key, suffix] of Object.entries(changes.append || {})) {
|
|
55
|
+
if (typeof result[key] !== 'string' || typeof suffix !== 'string') throw new Error('增量字段类型不匹配');
|
|
56
|
+
result[key] += suffix;
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
function applyTurnPatch(turn, patch) {
|
|
61
|
+
if (!turn || turn.id !== patch?.id) throw new Error('增量轮次不存在');
|
|
62
|
+
const next = applyChanges(turn, patch), entries = [...(turn.entries || [])];
|
|
63
|
+
for (const entryPatch of patch.entries || []) {
|
|
64
|
+
const index = entries.findIndex(entry => entry.id === entryPatch.id);
|
|
65
|
+
const entry = entries[index];
|
|
66
|
+
if (!entry || entry.id !== entryPatch.id) throw new Error('增量条目边界不匹配');
|
|
67
|
+
entries[index] = applyChanges(entry, entryPatch);
|
|
68
|
+
}
|
|
69
|
+
for (const entry of patch.appended || []) {
|
|
70
|
+
if (entries.some(existing => existing.id === entry.id)) throw new Error('增量条目重复');
|
|
71
|
+
entries.push(entry);
|
|
72
|
+
}
|
|
73
|
+
if (turn.itemPage && next.itemPage) next.itemPage = { ...next.itemPage,
|
|
74
|
+
olderCursor: turn.itemPage.olderCursor, hasLoadedOldest: turn.itemPage.hasLoadedOldest,
|
|
75
|
+
oldestItemId: turn.itemPage.oldestItemId };
|
|
76
|
+
next.entries = entries; return next;
|
|
77
|
+
}
|
|
78
|
+
function acceptSyncUpdate(current, incoming) {
|
|
79
|
+
if (incoming.reset) return { reset: true, next: incoming };
|
|
80
|
+
const valid = current && incoming.id === current.id && incoming.epoch === current.epoch &&
|
|
81
|
+
Number.isSafeInteger(incoming.fromSeq) && incoming.fromSeq >= 1 &&
|
|
82
|
+
Number.isSafeInteger(incoming.toSeq) && incoming.toSeq >= incoming.fromSeq;
|
|
83
|
+
if (!valid || incoming.fromSeq > current.seq + 1) throw new Error('实时更新序号不连续');
|
|
84
|
+
if (incoming.toSeq <= current.seq) return { duplicate: true, next: current };
|
|
85
|
+
return { next: { ...current, seq: incoming.toSeq } };
|
|
86
|
+
}
|
|
87
|
+
// A batch is acknowledged after consumption. Only an individual record is
|
|
88
|
+
// reassembled; the timeline itself is never serialized into one large string.
|
|
89
|
+
function* timelineBatches(timeline) {
|
|
90
|
+
function* entryRecords(value, kind, textField = '') {
|
|
91
|
+
const text = textField ? value[textField]?.text : value.text;
|
|
92
|
+
if (typeof text !== 'string' || text.length <= 4096) { yield { kind, value }; return; }
|
|
93
|
+
const metadata = textField ? { ...value, [textField]: { ...value[textField], text: '' } } : { ...value, text: '' };
|
|
94
|
+
yield { kind: 'entry-start', value: metadata, target: kind, textField };
|
|
95
|
+
for (let offset = 0; offset < text.length; offset += 4096) yield { kind: 'entry-text', value: text.slice(offset, offset + 4096) };
|
|
96
|
+
yield { kind: 'entry-end' };
|
|
97
|
+
}
|
|
98
|
+
function* records() {
|
|
99
|
+
const { turns, turnPatches, ...metadata } = timeline;
|
|
100
|
+
yield { kind: 'timeline', value: metadata };
|
|
101
|
+
for (const turn of turns) {
|
|
102
|
+
const { entries, ...metadata } = turn;
|
|
103
|
+
yield { kind: 'turn', value: metadata };
|
|
104
|
+
for (const entry of entries || []) yield* entryRecords(entry, 'entry');
|
|
105
|
+
}
|
|
106
|
+
for (const patch of turnPatches || []) {
|
|
107
|
+
const { entries, appended, ...metadata } = patch;
|
|
108
|
+
yield { kind: 'patch', value: metadata };
|
|
109
|
+
for (const entry of entries || []) yield* entryRecords(entry, 'patch-entry', entry.append?.text ? 'append' : 'set');
|
|
110
|
+
for (const entry of appended || []) yield* entryRecords(entry, 'patch-appended');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const encoder = new TextEncoder();
|
|
114
|
+
let batch = [], bytes = 0;
|
|
115
|
+
for (const record of records()) {
|
|
116
|
+
const json = JSON.stringify(record);
|
|
117
|
+
for (let offset = 0; offset < json.length; offset += 4096) {
|
|
118
|
+
const piece = { text: json.slice(offset, offset + 4096), end: offset + 4096 >= json.length };
|
|
119
|
+
const size = encoder.encode(JSON.stringify(piece)).length;
|
|
120
|
+
if (batch.length && bytes + size > 48 * 1024) { yield batch; batch = []; bytes = 0; }
|
|
121
|
+
batch.push(piece); bytes += size;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (batch.length) yield batch;
|
|
125
|
+
}
|
|
126
|
+
function createTimelineReceiver() {
|
|
127
|
+
let timeline, turn, patch, entry, entryTarget, textField, textLength = 0, textParts = [], sequence = 0, parts = [], length = 0;
|
|
128
|
+
return {
|
|
129
|
+
accept(batch, pieces) {
|
|
130
|
+
if (batch !== ++sequence || !Array.isArray(pieces)) throw new Error('历史条目批次不连续');
|
|
131
|
+
const added = [];
|
|
132
|
+
const commitEntry = (target, value) => {
|
|
133
|
+
if (target === 'entry' && turn) { turn.entries.push(value); added.push({ turn, entry: value }); }
|
|
134
|
+
else if (target === 'patch-entry' && patch) patch.entries.push(value);
|
|
135
|
+
else if (target === 'patch-appended' && patch) patch.appended.push(value);
|
|
136
|
+
else throw new Error('历史条目目标无效');
|
|
137
|
+
};
|
|
138
|
+
for (const piece of pieces) {
|
|
139
|
+
if (typeof piece.text !== 'string' || typeof piece.end !== 'boolean') throw new Error('历史条目格式无效');
|
|
140
|
+
parts.push(piece.text); length += piece.text.length;
|
|
141
|
+
if (length > 32 * 1024 * 1024) throw new Error('单条历史内容超过接收限制');
|
|
142
|
+
if (!piece.end) continue;
|
|
143
|
+
const record = JSON.parse(parts.join('')); parts = []; length = 0;
|
|
144
|
+
if (entry && !['entry-text', 'entry-end'].includes(record.kind)) throw new Error('历史正文传输未完成');
|
|
145
|
+
if (record.kind === 'timeline' && !timeline) timeline = { ...record.value, turns: [] };
|
|
146
|
+
else if (record.kind === 'turn' && timeline) {
|
|
147
|
+
turn = { ...record.value, entries: [] }; timeline.turns.push(turn);
|
|
148
|
+
} else if (['entry', 'patch-entry', 'patch-appended'].includes(record.kind)) {
|
|
149
|
+
commitEntry(record.kind, record.value);
|
|
150
|
+
} else if (record.kind === 'entry-start' && !entry) {
|
|
151
|
+
entry = record.value; entryTarget = record.target; textField = record.textField; textParts = []; textLength = 0;
|
|
152
|
+
} else if (record.kind === 'entry-text' && entry && typeof record.value === 'string') {
|
|
153
|
+
textLength += record.value.length;
|
|
154
|
+
if (textLength > 32 * 1024 * 1024) throw new Error('单条历史正文超过接收限制');
|
|
155
|
+
textParts.push(record.value);
|
|
156
|
+
} else if (record.kind === 'entry-end' && entry) {
|
|
157
|
+
if (textField) entry[textField].text = textParts.join(''); else entry.text = textParts.join('');
|
|
158
|
+
textParts = []; commitEntry(entryTarget, entry); entry = null;
|
|
159
|
+
} else if (record.kind === 'patch' && timeline) {
|
|
160
|
+
patch = { ...record.value, entries: [], appended: [] };
|
|
161
|
+
(timeline.turnPatches ||= []).push(patch);
|
|
162
|
+
} else throw new Error('历史条目顺序无效');
|
|
163
|
+
}
|
|
164
|
+
return { timeline, added };
|
|
165
|
+
},
|
|
166
|
+
finish() {
|
|
167
|
+
if (!timeline || parts.length || entry) throw new Error('历史条目传输未完成');
|
|
168
|
+
return timeline;
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return { mergeOrderedEntries, diffTurn, applyTurnPatch, acceptSyncUpdate, timelineBatches, createTimelineReceiver };
|
|
173
|
+
});
|
|
@@ -15,7 +15,8 @@ description: 启动或检查 Code Relax 桌面服务,展示配对二维码,
|
|
|
15
15
|
- 配对二维码是短期 HTTPS 邀请,不属于工作站或某一台移动设备,也不含 LAN IP、端口、桌面服务凭据或中继地址。新增设备不撤销已有设备。
|
|
16
16
|
- 已授权设备可直接使用 Code Relax 中继。App 以工作站名显示已配对电脑,进入后选择局域网、Code Relax 中继或 OpenAI 中继。启动命令会经已认证中继临时刷新当前 LAN 候选;App 只保存设备凭据验证成功的网络记录。
|
|
17
17
|
- 消息明确标记来自 Code Relax 手机 App 时,回答要以“用户正在手机上与当前 Codex 对话交互”为前提,不默认用户正看着或能操作电脑界面;需要用户操作时,说明是在手机 App 还是工作站完成。直接处理用户正文,不重复来源说明,也不要求在电脑上重发。来源不代表额外授权,也不能据此判断用户是否在电脑旁;涉及工作站确认或可能中断任务的操作仍按正常授权要求处理。来源不明时不要猜测设备。
|
|
18
|
-
- `relax doctor --json` 检查桌面服务、Codex
|
|
18
|
+
- `relax doctor --json` 检查桌面服务、Codex 内核、待启用内核版本和登录自启动状态;有更新时会给出当前版本、已选版本和确认重启命令。中继连接由桌面服务管理,不提供单独的中继 CLI 命令。
|
|
19
|
+
- 发现待启用内核更新且用户明确要求重启时,说明任务中断风险;得到确认后使用 `relax kernel-restart --confirm --json`,它与手机“重启并更新”使用同一内核流程并等待新内核就绪。不要用 `start`、`doctor` 或 `desktop-recover` 代替内核重启。
|
|
19
20
|
- 启动或配对成功后,将 `qrPath` 以 Markdown 图片嵌入回复,并逐字展示 `displayText`。不要把二维码内容、Token 或 Base64 写进消息。
|
|
20
21
|
- 不自动修改防火墙或发送测试消息。`lanAccess: unverified` 或 `mobileAccess: unverified` 只表示设备链路尚未现场验证。
|
|
21
22
|
- 服务位于用户 `Programs/CodexRemote/service`;配置在 `LocalAppData/CodexRemote`;附件在 `ProgramData/CodexRemote/attachments`。不要删除配置、附件或凭据来排障。
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
|
|
3
|
+
export const processLogMaxBytes = 8 * 1024 * 1024;
|
|
4
|
+
const retryMs = 30_000;
|
|
5
|
+
|
|
6
|
+
// The broker drains output even when storage fails, so logging cannot crash
|
|
7
|
+
// the child or build an unbounded in-memory queue.
|
|
8
|
+
export function attachProcessLog(stream, file, { maxBytes = processLogMaxBytes, now = Date.now } = {}) {
|
|
9
|
+
let retryAt = 0, dropped = 0, lastError = '';
|
|
10
|
+
function append(bytes) {
|
|
11
|
+
let size = 0;
|
|
12
|
+
try { size = fs.statSync(file).size; }
|
|
13
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
14
|
+
// Apply the same retention bound to a log left by an earlier launch.
|
|
15
|
+
if (size > maxBytes) {
|
|
16
|
+
const fd = fs.openSync(file, 'r+');
|
|
17
|
+
try {
|
|
18
|
+
const tail = Buffer.alloc(maxBytes);
|
|
19
|
+
const count = fs.readSync(fd, tail, 0, maxBytes, size - maxBytes);
|
|
20
|
+
fs.ftruncateSync(fd, 0);
|
|
21
|
+
writeAll(fd, tail.subarray(0, count));
|
|
22
|
+
size = count;
|
|
23
|
+
} finally { fs.closeSync(fd); }
|
|
24
|
+
}
|
|
25
|
+
for (let offset = 0; offset < bytes.length;) {
|
|
26
|
+
if (size >= maxBytes) {
|
|
27
|
+
fs.rmSync(`${file}.2`, { force: true });
|
|
28
|
+
try { fs.renameSync(`${file}.1`, `${file}.2`); }
|
|
29
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
30
|
+
fs.renameSync(file, `${file}.1`);
|
|
31
|
+
size = 0;
|
|
32
|
+
}
|
|
33
|
+
const part = bytes.subarray(offset, offset + maxBytes - size);
|
|
34
|
+
const fd = fs.openSync(file, 'a');
|
|
35
|
+
try { writeAll(fd, part); } finally { fs.closeSync(fd); }
|
|
36
|
+
offset += part.length;
|
|
37
|
+
size += part.length;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
stream.on('data', chunk => {
|
|
41
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
42
|
+
if (now() < retryAt) { dropped += bytes.length; return; }
|
|
43
|
+
try {
|
|
44
|
+
if (dropped) append(Buffer.from(`\n[log recovered ${new Date(now()).toISOString()}; previous error: ${lastError}; discarded output bytes: ${dropped}]\n`));
|
|
45
|
+
append(bytes);
|
|
46
|
+
dropped = 0;
|
|
47
|
+
retryAt = 0;
|
|
48
|
+
} catch (error) {
|
|
49
|
+
dropped += bytes.length;
|
|
50
|
+
lastError = error.code || error.message;
|
|
51
|
+
retryAt = now() + retryMs;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function writeAll(fd, bytes) {
|
|
57
|
+
let offset = 0;
|
|
58
|
+
while (offset < bytes.length) {
|
|
59
|
+
const count = fs.writeSync(fd, bytes, offset, bytes.length - offset);
|
|
60
|
+
if (!count) throw new Error('Log write made no progress.');
|
|
61
|
+
offset += count;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import WebSocket from 'ws';
|
|
3
|
+
|
|
4
|
+
// Shared Celsius framing and acknowledged topic lifecycle; callers own identity and HTTP.
|
|
5
|
+
export class CelsiusClient extends EventEmitter {
|
|
6
|
+
constructor({ getConnection, topics = [] }) {
|
|
7
|
+
super(); this.getConnection = getConnection; this.initialTopics = topics;
|
|
8
|
+
this.generation = 0; this.topics = new Map(); this.commands = new Map();
|
|
9
|
+
this.nextId = 0; this.topicLoads = new Map();
|
|
10
|
+
}
|
|
11
|
+
async connect() {
|
|
12
|
+
if (this.ready) return;
|
|
13
|
+
if (this.connecting) return this.connecting;
|
|
14
|
+
const generation = this.generation;
|
|
15
|
+
const work = (async () => {
|
|
16
|
+
const value = await this.getConnection();
|
|
17
|
+
if (generation !== this.generation) throw new Error('消息连接已变化。');
|
|
18
|
+
const url = new URL(value.websocket_url);
|
|
19
|
+
if (url.protocol !== 'wss:' || url.username || url.password) throw new Error('消息推送地址无效。');
|
|
20
|
+
await new Promise((resolve, reject) => {
|
|
21
|
+
const socket = new WebSocket(url, { handshakeTimeout: 15000, maxPayload: 8 * 1024 * 1024 });
|
|
22
|
+
this.socket = socket;
|
|
23
|
+
const timer = setTimeout(() => { reject(new Error('消息推送订阅超时。')); socket.terminate(); }, 30000);
|
|
24
|
+
const current = () => this.socket === socket && generation === this.generation;
|
|
25
|
+
socket.on('error', () => {}); // Do not expose the signed URL in library errors.
|
|
26
|
+
socket.on('open', async () => {
|
|
27
|
+
try {
|
|
28
|
+
await this.command({ type: 'connect', presence: { type: 'presence', state: 'background' } });
|
|
29
|
+
for (const topic of this.initialTopics) await this.subscribe(topic);
|
|
30
|
+
if (!current()) throw new Error('消息连接已变化。');
|
|
31
|
+
this.ready = true; clearTimeout(timer); resolve(); this.emit('connected');
|
|
32
|
+
} catch (error) { clearTimeout(timer); reject(error); socket.terminate(); }
|
|
33
|
+
});
|
|
34
|
+
socket.on('message', data => {
|
|
35
|
+
if (!current()) return;
|
|
36
|
+
let frames; try { frames = JSON.parse(data.toString()); } catch { return; }
|
|
37
|
+
if (!Array.isArray(frames)) return;
|
|
38
|
+
for (const frame of frames) {
|
|
39
|
+
if (frame.type === 'message') this.receive(frame);
|
|
40
|
+
else if (frame.id != null) this.commands.get(frame.id)?.resolve(frame.reply);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
socket.on('close', code => {
|
|
44
|
+
clearTimeout(timer); reject(new Error('消息推送连接已断开。'));
|
|
45
|
+
if (!current()) return;
|
|
46
|
+
this.ready = false; this.socket = null;
|
|
47
|
+
for (const entry of this.commands.values()) entry.reject(new Error('消息推送连接已断开。'));
|
|
48
|
+
this.commands.clear(); this.topics.clear(); this.topicLoads.clear();
|
|
49
|
+
this.emit('disconnected', { source: 'celsius', code });
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
})();
|
|
53
|
+
this.connecting = work;
|
|
54
|
+
try { await work; } finally { if (this.connecting === work) this.connecting = null; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
command(command) {
|
|
58
|
+
if (this.socket?.readyState !== WebSocket.OPEN) return Promise.reject(new Error('消息推送尚未连接。'));
|
|
59
|
+
const id = ++this.nextId;
|
|
60
|
+
const socket = this.socket;
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const finish = (error, value) => {
|
|
63
|
+
if (!this.commands.has(id)) return;
|
|
64
|
+
clearTimeout(timer); this.commands.delete(id);
|
|
65
|
+
if (error) reject(error); else resolve(value);
|
|
66
|
+
};
|
|
67
|
+
const timer = setTimeout(() => { finish(new Error('消息推送请求超时。')); socket.terminate(); }, 30000);
|
|
68
|
+
this.commands.set(id, { resolve: value => finish(null, value), reject: error => finish(error) });
|
|
69
|
+
socket.send(JSON.stringify([{ id, command }]), error => { if (error) finish(new Error('消息推送连接已断开。')); });
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async subscribe(name, { offset, requireRecovery = false } = {}) {
|
|
74
|
+
if (this.topics.has(name)) return;
|
|
75
|
+
if (this.topicLoads.has(name)) return this.topicLoads.get(name);
|
|
76
|
+
const generation = this.generation, socket = this.socket;
|
|
77
|
+
// Every transport reconnection invalidates chat snapshots and reads a new
|
|
78
|
+
// latest window. An old offset never certifies the new snapshot by itself.
|
|
79
|
+
const work = this.command({ type: 'subscribe', topic_id: name, ...(offset == null ? {} : { offset }) }).then(reply => {
|
|
80
|
+
if (socket !== this.socket || generation !== this.generation) throw new Error('消息连接已变化。');
|
|
81
|
+
if (reply?.type !== 'subscribe' || reply.topic_id !== name) throw new Error('消息推送订阅未确认。');
|
|
82
|
+
if (requireRecovery && !reply.recovered) throw new Error('ChatGPT 输出历史未完整接续,请核对原消息。');
|
|
83
|
+
this.topics.set(name, reply.last_offset || null);
|
|
84
|
+
if (reply.recovered) for (const message of reply.catchups || []) this.receive(message);
|
|
85
|
+
});
|
|
86
|
+
this.topicLoads.set(name, work);
|
|
87
|
+
try { await work; } finally { if (this.topicLoads.get(name) === work) this.topicLoads.delete(name); }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async unsubscribe(name) {
|
|
91
|
+
if (!this.topics.delete(name) || this.socket?.readyState !== WebSocket.OPEN) return;
|
|
92
|
+
await this.command({ type: 'unsubscribe', topic_id: name });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
receive(frame) {
|
|
96
|
+
if (frame.offset) this.topics.set(frame.topic_id, frame.offset);
|
|
97
|
+
this.emit('message', frame.topic_id, frame.payload);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
close() {
|
|
102
|
+
this.generation++; this.ready = false; this.connecting = null;
|
|
103
|
+
const socket = this.socket; this.socket = null; socket?.terminate();
|
|
104
|
+
for (const entry of this.commands.values()) entry.reject(new Error('消息推送连接已结束。'));
|
|
105
|
+
this.commands.clear(); this.topics.clear(); this.topicLoads.clear();
|
|
106
|
+
}
|
|
107
|
+
}
|