@walkhi/code-relax 0.1.0-beta.39 → 0.1.0-beta.40

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.
@@ -1 +1 @@
1
- {"layout":"npm-platform-packages","version":"0.1.0-beta.39"}
1
+ {"layout":"npm-platform-packages","version":"0.1.0-beta.40"}
@@ -26,10 +26,36 @@
26
26
  return entry ? { state: entry.state, ...(entry.result ? { result: entry.result } : {}),
27
27
  ...(entry.error ? { error: entry.error.message } : {}) } : { state: 'unknown' };
28
28
  }
29
+ async reconcile(scope, clientId, inspect) {
30
+ const key = this.key(scope, clientId);
31
+ let entry = this.entries.get(key);
32
+ if (entry?.state === 'accepted' || entry?.state === 'not_sent') return this.status(scope, clientId);
33
+ if (entry?.checking) return entry.checking;
34
+ if (!entry) {
35
+ // Fence a request which never arrived before checking history. A delayed
36
+ // transport frame with this admission must not execute after a replacement.
37
+ entry = { state: 'unknown', fenced: true };
38
+ this.entries.set(key, entry);
39
+ }
40
+ const target = entry;
41
+ target.checking = (async () => {
42
+ if (target.work) await target.work.catch(() => {});
43
+ if (target.lateWork) await target.lateWork;
44
+ if (target.state === 'accepted' || target.state === 'not_sent') return this.status(scope, clientId);
45
+ const result = await inspect();
46
+ target.state = result ? 'accepted' : 'not_sent';
47
+ target.fenced = true;
48
+ if (result) target.result = result;
49
+ delete target.error;
50
+ return this.status(scope, clientId);
51
+ })().finally(() => { delete target.checking; });
52
+ return target.checking;
53
+ }
29
54
  submit(scope, clientId, operation) {
30
55
  if (!/^[a-f0-9]{32}$/i.test(clientId || '')) return Promise.reject(Object.assign(new Error('消息确认标识无效'), { deliveryUnknown: false }));
31
56
  const key = this.key(scope, clientId), previous = this.entries.get(key);
32
57
  if (previous?.state === 'accepted') return Promise.resolve(previous.result);
58
+ if (previous?.fenced) return Promise.reject(Object.assign(new Error('原发送请求已结束,请使用新的发送标识'), { deliveryUnknown: false }));
33
59
  if (previous?.state === 'sending') return previous.work;
34
60
  if (previous?.state === 'unknown') return Promise.reject(Object.assign(new Error(previous.error?.message || '原发送结果仍未确认'), { deliveryUnknown: true }));
35
61
  const entry = { state: 'sending' };
@@ -39,7 +65,7 @@
39
65
  }, error => {
40
66
  entry.state = error.deliveryUnknown === false || error.details?.deliveryUnknown === false ? 'not_sent' : 'unknown';
41
67
  error.deliveryUnknown = entry.state !== 'not_sent';
42
- if (error.deliveryResult) error.deliveryResult.then(result => {
68
+ if (error.deliveryResult) entry.lateWork = error.deliveryResult.then(result => {
43
69
  entry.state = 'accepted'; entry.result = result; delete entry.error;
44
70
  }, lateError => {
45
71
  entry.state = rejectedBeforeSubmission(lateError) || lateError.deliveryUnknown === false ? 'not_sent' : 'unknown';
@@ -50,5 +76,25 @@
50
76
  return entry.work;
51
77
  }
52
78
  }
53
- return Object.freeze({ Store, rejectedBeforeSubmission, retainLateResult });
79
+ const normalizeId = value => String(value || '').replace(/-/g, '').toLowerCase();
80
+ async function findMessage(request, threadId, clientId, anchorItemId = '') {
81
+ let cursor;
82
+ const seen = new Set();
83
+ do {
84
+ const page = await request('thread/items/list', { threadId, limit: 100, sortDirection: 'desc', ...(cursor ? { cursor } : {}) });
85
+ if (!Array.isArray(page?.data)) throw new Error('发送核对失败:未取得权威历史');
86
+ for (const entry of page.data) {
87
+ const item = entry.item;
88
+ if (item?.type === 'userMessage' && normalizeId(item.clientId) === normalizeId(clientId)) {
89
+ return { data: { messageId: item.id, turnId: entry.turnId } };
90
+ }
91
+ if (anchorItemId && item?.id === anchorItemId) return null;
92
+ }
93
+ cursor = page.nextCursor;
94
+ if (cursor && seen.has(cursor)) throw new Error('发送核对失败:历史游标未推进');
95
+ if (cursor) seen.add(cursor);
96
+ } while (cursor);
97
+ return null;
98
+ }
99
+ return Object.freeze({ Store, rejectedBeforeSubmission, retainLateResult, findMessage, normalizeId });
54
100
  });
@@ -32,17 +32,23 @@
32
32
  const frames = binary ? [JSON.stringify({ type: 'rtc-file-bytes', length: size })] : [];
33
33
  if (binary) for (let i = 0; i < size; i += PART) frames.push(data.slice(i, i + PART));
34
34
  else frames.push(data);
35
- this.outbound.push({ frames, size, index: 0 }); this.queued += size; this.pump();
35
+ const sizes = frames.map(frame => typeof frame === 'string' ? new TextEncoder().encode(frame).length : frame.byteLength);
36
+ const framedSize = sizes.reduce((total, length) => total + length, 0);
37
+ if (this.bufferedAmount + framedSize > LIMIT) throw new Error('文件直连队列超限');
38
+ this.outbound.push({ frames, sizes, index: 0 }); this.queued += framedSize; this.pump();
36
39
  }
37
40
  pump() {
38
41
  if (this.readyState !== 1) return;
39
42
  try {
40
43
  while (this.outbound.length && this.io.buffered() < MAX) {
41
44
  const item = this.outbound[0];
42
- if (this.io.send(item.frames[item.index]) === false) throw new Error('文件直连发送失败');
43
- if (++item.index === item.frames.length) { this.outbound.shift(); this.queued -= item.size; }
45
+ // libdatachannel returns false when it accepted the frame into its buffer.
46
+ // Only an exception rejects a send; replaying a buffered frame duplicates bytes.
47
+ this.io.send(item.frames[item.index]);
48
+ this.queued -= item.sizes[item.index];
49
+ if (++item.index === item.frames.length) this.outbound.shift();
44
50
  }
45
- } catch { this.close(1011, 'send-failed'); }
51
+ } catch (error) { this.close(1011, `send-failed:${String(error?.name || 'Error').slice(0, 40)}`); }
46
52
  }
47
53
  waitForPartial() {
48
54
  clearTimeout(this.timer);
@@ -25,7 +25,7 @@
25
25
  if (!value) return '';
26
26
  const digits = Number.isInteger(value.minorUnitExponent) && value.minorUnitExponent >= 0 && value.minorUnitExponent <= 4
27
27
  ? value.minorUnitExponent : undefined;
28
- return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: value.currency, currencyDisplay: 'narrowSymbol',
28
+ return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: value.currency, currencyDisplay: 'code',
29
29
  minimumFractionDigits: 0, maximumFractionDigits: digits }).format(value.amount);
30
30
  }
31
31
  return { read, format };
@@ -9,6 +9,7 @@ import { dotsRoomTurns } from './dots-room-projection.mjs';
9
9
  import { dotsChatRevision, dotsChatMessageRevision, projectDotsChatItem } from './dots-chat-projection.mjs';
10
10
  import timelineSync from '../shared/timeline-sync.cjs';
11
11
  import serverRequests from '../shared/server-requests.cjs';
12
+ import messageDelivery from '../shared/message-delivery.cjs';
12
13
 
13
14
  const pageSize = 20;
14
15
  const mergeTurns = (older, newer) => timelineSync.mergeOrderedEntries(older, newer, (old, fresh) => ({ ...fresh,
@@ -347,6 +348,26 @@ export class DotsChatServer extends HostedHistoryAppServer {
347
348
  try { return await this.sendMessage(args); }
348
349
  catch (error) { if (error.deliveryUnknown === undefined) error.deliveryUnknown = false; throw error; }
349
350
  }
351
+ async findSentMessage(threadId, clientId, anchorItemId) {
352
+ await this.connect();
353
+ const room = this.bindings.get(threadId) || await this.room(threadId);
354
+ let before;
355
+ const seen = new Set();
356
+ do {
357
+ const page = await this.rooms.messages(room.id, { before, limit: pageSize });
358
+ if (!Array.isArray(page?.items)) throw new Error('发送核对失败:未取得房间历史');
359
+ for (const message of page.items) {
360
+ if (messageDelivery.normalizeId(message.request_id) === messageDelivery.normalizeId(clientId)) {
361
+ return { data: { messageId: message.id } };
362
+ }
363
+ }
364
+ if (anchorItemId && page.items.some(message => message.id === anchorItemId)) return null;
365
+ before = page.before;
366
+ if (before && seen.has(before)) throw new Error('发送核对失败:房间历史游标未推进');
367
+ if (before) seen.add(before);
368
+ } while (before);
369
+ return null;
370
+ }
350
371
  async sendMessage(args) {
351
372
  await this.connect(); this.requireWritable(args.threadId);
352
373
  const record = this.threads.get(args.threadId), room = this.bindings.get(args.threadId), epoch = this.epoch;
@@ -58,7 +58,8 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
58
58
  ws.on('close', stop);
59
59
  ws.on('message', (bytes, binary) => {
60
60
  if (stopped || ws.readyState !== WebSocket.OPEN) return;
61
- if (++queued > 4) { stop(); ws.close(1013, 'File request queue exceeded'); return; }
61
+ // Three upload chunks may be in flight, each with a header and binary frame.
62
+ if (++queued > 8) { stop(); ws.close(1013, 'File request queue exceeded'); return; }
62
63
  pending = pending.then(async () => {
63
64
  if (stopped || ws.readyState !== WebSocket.OPEN) return;
64
65
  if (binary) {
@@ -1,9 +1,11 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import crypto from 'node:crypto';
3
4
  import sharp from 'sharp';
4
5
  import media from '../shared/code-browser.cjs';
5
6
  import { validateInlineImage } from './message-images.mjs';
6
- import { MAX_FILE_BYTES, MAX_FILE_COUNT, MAX_TOTAL_BYTES } from './file-upload-session.mjs';
7
+ import { createFileUploadSession, FILE_CHUNK_BYTES, MAX_FILE_BYTES, MAX_FILE_COUNT, MAX_TOTAL_BYTES } from './file-upload-session.mjs';
8
+ import { fileRevision } from './file-transfer-range.mjs';
7
9
 
8
10
  const mimeTypes = { pdf: 'application/pdf', txt: 'text/plain', md: 'text/markdown', csv: 'text/csv',
9
11
  json: 'application/json', zip: 'application/zip', doc: 'application/msword',
@@ -11,6 +13,39 @@ const mimeTypes = { pdf: 'application/pdf', txt: 'text/plain', md: 'text/markdow
11
13
  xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
12
14
  ppt: 'application/vnd.ms-powerpoint', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' };
13
15
 
16
+ // Reuse the incoming-file contract without sending workstation bytes to the phone.
17
+ export async function stageWorkstationMessageFile(file, uploadsRoot) {
18
+ const handle = await fs.open(file.path, 'r');
19
+ const session = createFileUploadSession(uploadsRoot);
20
+ try {
21
+ const stat = await handle.stat();
22
+ if (!stat.isFile() || stat.size < 1 || stat.size > MAX_FILE_BYTES) throw new Error('附件为空或超过 20 MiB。');
23
+ const validate = async () => {
24
+ if (fileRevision(await handle.stat()) !== file.revision || fileRevision(await fs.stat(file.path)) !== file.revision) {
25
+ throw new Error('电脑文件已变化,请重新添加。');
26
+ }
27
+ };
28
+ await validate();
29
+ const bytes = Buffer.alloc(stat.size);
30
+ for (let offset = 0; offset < bytes.length;) {
31
+ const { bytesRead } = await handle.read(bytes, offset, bytes.length - offset, offset);
32
+ if (!bytesRead) throw new Error('电脑文件读取不完整,请重新添加。');
33
+ offset += bytesRead;
34
+ }
35
+ await validate();
36
+ const sha256 = crypto.createHash('sha256').update(bytes).digest('hex');
37
+ const started = await session.receive({ type: 'start', name: file.name, size: bytes.length, sha256 });
38
+ for (let offset = 0; offset < bytes.length; offset += FILE_CHUNK_BYTES) {
39
+ await session.receive({ type: 'chunk', id: started.id, offset, data: bytes.subarray(offset, offset + FILE_CHUNK_BYTES) });
40
+ }
41
+ await validate();
42
+ const result = await session.receive({ type: 'finish', id: started.id });
43
+ await session.receive({ type: 'commit' });
44
+ return { ...result, mediaKind: media.mediaKind(file),
45
+ mimeType: media.mediaMime(file) || mimeTypes[path.extname(file.name).slice(1).toLowerCase()] || 'application/octet-stream' };
46
+ } finally { await handle.close(); await session.close(); }
47
+ }
48
+
14
49
  async function localFile(file, directory) {
15
50
  if (typeof file?.path !== 'string' || typeof file.name !== 'string' || !file.name || /[\r\n\0]/.test(file.name)) {
16
51
  throw new Error('附件信息无效,请重新选择。');
@@ -34,6 +34,9 @@ export async function updateWorkstationProject(server, stateRoot, codexDataRoot,
34
34
  operationId: body.idempotencyKey, operation: 'update', confirmed: body.confirmDesktopRestart === true, serverUrl: server.url }, async desktop => {
35
35
  const current = (await rpc('project/read', { projectId })).project;
36
36
  if (current.id !== projectId) fail('项目身份不一致。');
37
+ if (current.roots.length && rootKey(current.roots.slice(0, 1)) !== rootKey(target.roots.slice(0, 1))) {
38
+ fail('编辑项目不能修改或移除主目录。');
39
+ }
37
40
  operation.previousProject ||= current;
38
41
  if (!same(current, operation.previousProject) && !same(current, target)) fail('项目已被其他操作修改,请刷新后重新编辑。');
39
42
  let project = current;
@@ -47,10 +47,10 @@ import { browseProjectFolders, createProjectFolder } from './project-management.
47
47
  import { projectLocations } from './platform/project-locations.mjs';
48
48
  import { createWorkstationProject } from './project-create.mjs';
49
49
  import { removeWorkstationProject } from './project-remove.mjs';
50
- import { projectArchiveCandidates } from './project-archive.mjs';
51
50
  import { archivedThreadsPage } from './archived-threads.mjs';
52
51
  import { updateWorkstationProject } from './project-update.mjs';
53
52
  import { WorkstationDownloads } from './workstation-downloads.mjs';
53
+ import { stageWorkstationMessageFile } from './message-upload-files.mjs';
54
54
  import { consumeUsageReset } from './usage-reset.mjs';
55
55
  import { DesktopHelper } from './platform/windows/desktop-helper.mjs';
56
56
 
@@ -511,6 +511,18 @@ async function handleRequest(request, response) {
511
511
  return;
512
512
  }
513
513
 
514
+ if (request.method === 'POST' && url.pathname === '/api/workstation-file-attachments') {
515
+ await requireExecutionWritable();
516
+ const body = await readJsonBody(request);
517
+ const source = body.source;
518
+ const file = typeof source === 'string' && /^[a-z]:[\\/]/i.test(source) ? describeLocalFile(source)
519
+ : typeof source === 'string' && /^[a-f0-9]{64}$/.test(source) ? fileCache.get(source) : null;
520
+ if (!file) throw new RequestError(404, '电脑文件不可用,请从原文件入口重新添加。');
521
+ if (file.externalIdentity) await dotsImages.authorization(file.externalIdentity);
522
+ sendJson(response, 201, { file: await stageWorkstationMessageFile(file, uploadsRoot) });
523
+ return;
524
+ }
525
+
514
526
  if (request.method === 'GET' && url.pathname === '/api/file-save-locations') {
515
527
  await requireExecutionWritable();
516
528
  const locations = await projectLocations();
@@ -557,7 +569,7 @@ async function handleRequest(request, response) {
557
569
  return;
558
570
  }
559
571
 
560
- const projectActionMatch = /^\/api\/projects\/([^/]+)\/(archive-candidates|remove|move)$/.exec(url.pathname);
572
+ const projectActionMatch = /^\/api\/projects\/([^/]+)\/(remove|move)$/.exec(url.pathname);
561
573
  if (projectActionMatch) {
562
574
  await requireExecutionWritable();
563
575
  const projectId = decodeURIComponent(projectActionMatch[1]);
@@ -570,10 +582,6 @@ async function handleRequest(request, response) {
570
582
  sendJson(response, 200, await sharedCatalog.projects().then(({ projects }) => ({ projects })));
571
583
  return;
572
584
  }
573
- if (request.method === 'GET' && projectActionMatch[2] === 'archive-candidates') {
574
- sendJson(response, 200, await projectArchiveCandidates(sharedServer, dotsServer, projectId));
575
- return;
576
- }
577
585
  if (request.method === 'POST' && projectActionMatch[2] === 'remove') {
578
586
  const result = await removeWorkstationProject(sharedServer, stateRoot, codexDataRoot, projectId, await readJsonBody(request));
579
587
  sendJson(response, 200, result);
@@ -930,7 +938,16 @@ async function handleRequest(request, response) {
930
938
 
931
939
  const messageStatusMatch = /^\/api\/threads\/([^/]+)\/messages\/([a-f0-9]{32})\/status$/.exec(url.pathname);
932
940
  if (request.method === 'GET' && messageStatusMatch) {
933
- sendJson(response, 200, messageReceipts.status([hostId, decodeURIComponent(messageStatusMatch[1])], messageStatusMatch[2]));
941
+ const threadId = decodeURIComponent(messageStatusMatch[1]), clientId = messageStatusMatch[2];
942
+ const result = await messageReceipts.reconcile([hostId, threadId], clientId, async () => {
943
+ if (hostId !== 'durable') {
944
+ await sharedServer.connect();
945
+ const queue = await appServerEvents.readQueue((method, params) => sharedServer.request(method, params), threadId);
946
+ if (queue.items.some(item => messageDelivery.normalizeId(item.clientUserMessageId) === clientId)) return queue;
947
+ }
948
+ return execution.findSentMessage(threadId, clientId, url.searchParams.get('anchorItemId') || '');
949
+ });
950
+ sendJson(response, 200, result);
934
951
  return;
935
952
  }
936
953
  const messageMatch = /^\/api\/threads\/([^/]+)\/messages$/.exec(url.pathname);
@@ -136,7 +136,7 @@ export class SharedAppServer extends EventEmitter {
136
136
  const error = Object.assign(new Error(`${method} 超时;不会自动重发,请先检查对话。`),
137
137
  { code: 'app_server_request_timeout' });
138
138
  this.traceLifecycle('timeout', diagnostic);
139
- if ((method === 'turn/start' || method === 'turn/steer') && this.pending.has(id)) {
139
+ if (['turn/start', 'turn/steer', 'thread/queue/add'].includes(method) && this.pending.has(id)) {
140
140
  messageDelivery.retainLateResult(this.pending.get(id), error);
141
141
  } else { this.pending.delete(id); reject(error); }
142
142
  }, 30000);
@@ -203,6 +203,11 @@ export class SharedAppServer extends EventEmitter {
203
203
  }
204
204
  }
205
205
 
206
+ async findSentMessage(threadId, clientId, anchorItemId) {
207
+ await this.connect();
208
+ return messageDelivery.findMessage((method, params) => this.request(method, params), threadId, clientId, anchorItemId);
209
+ }
210
+
206
211
  respondApproval(threadId, body) {
207
212
  if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) throw new Error('Codex 内核未就绪');
208
213
  this.requireWritable(threadId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@walkhi/code-relax",
3
- "version": "0.1.0-beta.39",
3
+ "version": "0.1.0-beta.40",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -41,8 +41,8 @@
41
41
  "tag": "beta"
42
42
  },
43
43
  "optionalDependencies": {
44
- "@walkhi/code-relax-native-win32-x64": "0.1.0-beta.39",
45
- "@walkhi/code-relax-native-darwin-arm64": "0.1.0-beta.39",
46
- "@walkhi/code-relax-native-darwin-x64": "0.1.0-beta.39"
44
+ "@walkhi/code-relax-native-win32-x64": "0.1.0-beta.40",
45
+ "@walkhi/code-relax-native-darwin-arm64": "0.1.0-beta.40",
46
+ "@walkhi/code-relax-native-darwin-x64": "0.1.0-beta.40"
47
47
  }
48
48
  }
@@ -1,30 +0,0 @@
1
- import { sharedThreadProjectId, dotsThreadProjectId } from './shared-catalog.mjs';
2
-
3
- // Enumerate metadata, not the sidebar's limited window or conversation bodies.
4
- export async function projectArchiveCandidates(server, dotsServer, projectId) {
5
- const projects = await server.projects();
6
- const project = projects.find(value => value.id === projectId);
7
- if (!project) throw Object.assign(new Error('项目不存在,请刷新列表。'), { statusCode: 404 });
8
- const local = [];
9
- let cursor;
10
- do {
11
- const page = await server.request('thread/list', { limit: 100, archived: false,
12
- sortKey: 'recency_at', sortDirection: 'desc', ...(cursor ? { cursor } : {}) });
13
- local.push(...page.data); cursor = page.nextCursor;
14
- } while (cursor);
15
- let hosted = [], catalogError = '';
16
- if (dotsServer) {
17
- try { hosted = await dotsServer.taskCatalog(false, true); }
18
- catch (error) { catalogError = `云端目录读取失败,本次仅包含本机对话:${error.message}`; }
19
- }
20
- const threads = [];
21
- for (const [hostId, values, group] of [['local', local, sharedThreadProjectId], ['durable', hosted, dotsThreadProjectId]]) {
22
- for (const thread of values) {
23
- if (thread.threadSource === 'aeon' || group(thread, projects) !== projectId) continue;
24
- const status = typeof thread.status === 'string' ? thread.status : thread.status?.type || 'unknown';
25
- threads.push({ id: thread.id, hostId, projectId, threadSource: thread.threadSource,
26
- title: thread.name || thread.preview || thread.id, status });
27
- }
28
- }
29
- return { projectId, threads, ...(catalogError ? { catalogError } : {}) };
30
- }