bothouse-dsh 0.1.57 → 0.1.59
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.en.md +3 -2
- package/README.md +3 -2
- package/collab/attachments.mjs +115 -1
- package/collab/cli-member.mjs +69 -11
- package/collab/cloudweb.mjs +4 -1
- package/collab/devices.html +94 -12
- package/collab/dsh-member.mjs +114 -12
- package/collab/instance-lock.mjs +135 -2
- package/collab/local-host.mjs +106 -0
- package/collab/mentions.mjs +626 -12
- package/collab/plugin.mjs +74 -20
- package/collab/roster.mjs +6 -1
- package/collab/store.mjs +9 -1
- package/collab/team-mode-rules.md +1 -1
- package/node_modules/bothouse-dsh-channels/client.js +1213 -169
- package/node_modules/bothouse-dsh-devices/client.js +70 -5
- package/package.json +4 -7
package/collab/dsh-member.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
10
10
|
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
|
11
11
|
import { basename, isAbsolute, relative, sep } from 'node:path';
|
|
12
12
|
import { exitTarget } from './team-exit.mjs';
|
|
13
|
-
import { DEFAULT_DISPATCH_MODE, MAX_TEXT } from './store.mjs';
|
|
13
|
+
import { DEFAULT_DISPATCH_MODE, MAX_TEXT, messageAttachments } from './store.mjs';
|
|
14
14
|
import { ChannelApprovals, DEFAULT_ACTIONS } from './approvals.mjs';
|
|
15
15
|
import { DEFAULT_PERMISSION_PRESET, presetDecision } from './cli-member.mjs';
|
|
16
16
|
import { GateContext } from './gate-context.mjs';
|
|
@@ -61,7 +61,7 @@ export function memberStatus(hub, tasks = hub?.tasks, roomId) {
|
|
|
61
61
|
import { mentions, nameCalled, split } from './mentions.mjs';
|
|
62
62
|
import { quotePrefix } from './store.mjs';
|
|
63
63
|
import { progressCardOf } from './cloudweb.mjs';
|
|
64
|
-
import { attachmentBlocks, namedAttachmentBlocks, nativeAttachmentBlocks, undeliveredReasonZh } from './attachments.mjs';
|
|
64
|
+
import { attachmentBlocks, namedAttachmentBlocks, nativeAttachmentBlocks, oversizedNote, undeliveredReasonZh } from './attachments.mjs';
|
|
65
65
|
|
|
66
66
|
const CONTEXT_LIMIT = 50;
|
|
67
67
|
const CLOSE_TIMEOUT_MS = 5_000;
|
|
@@ -360,10 +360,31 @@ export class TurnProgress {
|
|
|
360
360
|
}
|
|
361
361
|
}
|
|
362
362
|
|
|
363
|
+
/** Text with every @-mention taken out, trimmed: what a command word is matched against. */
|
|
364
|
+
function commandOf(text, members) {
|
|
365
|
+
return split(text, members).filter(([, id]) => id === null).map(([seg]) => seg).join('').trim();
|
|
366
|
+
}
|
|
367
|
+
|
|
363
368
|
/** Whether text, with @-mentions removed, is the /new command (bridge core cmdNew). */
|
|
364
369
|
export function isNewCommand(text, members) {
|
|
365
|
-
|
|
366
|
-
|
|
370
|
+
return /^\/new(\s|$)/.test(commandOf(text, members));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* The stop command a person typed in a channel: 'one' for `/stop`, 'all' for
|
|
375
|
+
* `/stop all`, undefined for anything else. The @ that addressed it is already
|
|
376
|
+
* gone by the time the message reaches this planner (the bridge forwards a
|
|
377
|
+
* channel command stripped, mentions.mjs forMember), so the members it reaches
|
|
378
|
+
* are the planner's own targets — the same rule /new follows.
|
|
379
|
+
*
|
|
380
|
+
* Not the bridge's /stop all (bridge/core/stop_all.go, every bot in the channel
|
|
381
|
+
* stops its own bridge session): this one only ends DSH members' turns here.
|
|
382
|
+
*/
|
|
383
|
+
export function stopCommand(text, members) {
|
|
384
|
+
const rest = commandOf(text, members);
|
|
385
|
+
if (/^\/stop\s+all$/i.test(rest)) return 'all';
|
|
386
|
+
if (/^\/stop$/i.test(rest)) return 'one';
|
|
387
|
+
return undefined;
|
|
367
388
|
}
|
|
368
389
|
|
|
369
390
|
export class DshMembers {
|
|
@@ -387,8 +408,10 @@ export class DshMembers {
|
|
|
387
408
|
this.openTurns = new Map();
|
|
388
409
|
/** Agents whose request hook was installed after they were loaded by someone else. */
|
|
389
410
|
this.hooked = new WeakSet();
|
|
390
|
-
/** JSON([memberId, cwd, roomId]) -> { memberId, cli, agent } live local CLI agents
|
|
411
|
+
/** JSON([memberId, cwd, roomId]) -> { memberId, roomId, cli, agent } live local CLI agents. */
|
|
391
412
|
this.cliAgents = new Map();
|
|
413
|
+
// Releasing a workspace retains its bindings for rework, but needs no resident CLI.
|
|
414
|
+
this.store.onWorkspaceRelease(roomId => this.dropRoomCli(roomId));
|
|
392
415
|
/** #557: permission cards of channel-started turns, shared with cloud-web bots. */
|
|
393
416
|
this.approvals = ChannelApprovals.of(hub);
|
|
394
417
|
/** Session ids whose owner chose 允许所有: later asks in that Session are allowed without a card (CLI members: `cli:<ACP session id>`). */
|
|
@@ -410,6 +433,14 @@ export class DshMembers {
|
|
|
410
433
|
if (this.config.get(m.id)?.cli !== m.cli) this.dropCli(m.id);
|
|
411
434
|
this.config.set(m.id, m);
|
|
412
435
|
this.publish(m);
|
|
436
|
+
// Hub owns scope rules, including workspace inheritance and the member's private chat.
|
|
437
|
+
const removed = (id, roomId) => id === m.id && !this.hub.membersInRoom(roomId).some(member => member.id === m.id);
|
|
438
|
+
this.cancelCliStarts(removed);
|
|
439
|
+
for (const [key, entry] of this.cliAgents) {
|
|
440
|
+
if (!removed(entry.memberId, entry.roomId)) continue;
|
|
441
|
+
entry.agent.close();
|
|
442
|
+
this.cliAgents.delete(key);
|
|
443
|
+
}
|
|
413
444
|
}
|
|
414
445
|
|
|
415
446
|
/** Stop routing to a member. Pending jobs for it fail when they run; history keeps its name. */
|
|
@@ -461,10 +492,22 @@ export class DshMembers {
|
|
|
461
492
|
* @returns { stopped, running } member ids whose turn was cancelled / left running.
|
|
462
493
|
*/
|
|
463
494
|
recall(triggerId) {
|
|
495
|
+
return this.cancelJobs(job => job.trigger_id === triggerId);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* #148 / #299: end every open job `match` accepts. A queued one is closed, one
|
|
500
|
+
* still waiting in the member's inbox is taken back, and a turn already running
|
|
501
|
+
* is cancelled the way DSH's stop button does (keepInbox, so the member's later
|
|
502
|
+
* inputs still run). Jobs it cannot reach (a CLI member's ask) keep running.
|
|
503
|
+
* The one cancel path behind both "the owner withdrew the message" and "/stop".
|
|
504
|
+
* @returns { stopped, running } member ids whose turn was cancelled / left running.
|
|
505
|
+
*/
|
|
506
|
+
cancelJobs(match) {
|
|
464
507
|
const stopped = [];
|
|
465
508
|
const running = [];
|
|
466
509
|
for (const job of this.store.openJobs()) {
|
|
467
|
-
if (job
|
|
510
|
+
if (!match(job)) continue;
|
|
468
511
|
if (job.state === 'pending') { this.store.finishJob(job.id, 'interrupted'); this.syncQueue(job.member_id, job.room_id); continue; }
|
|
469
512
|
const w = this.watching.get(job.session_id);
|
|
470
513
|
const agent = job.input_id ? this.rt.ctx.agents.get(job.session_id) : undefined;
|
|
@@ -481,6 +524,29 @@ export class DshMembers {
|
|
|
481
524
|
return { stopped, running };
|
|
482
525
|
}
|
|
483
526
|
|
|
527
|
+
/**
|
|
528
|
+
* #299: `/stop` / `/stop all` typed in the channel. Cancels member turns in this
|
|
529
|
+
* room on the sender's own thread and posts one receipt saying who stopped and
|
|
530
|
+
* who is still running; it never queues, so it cannot arrive after the turn it
|
|
531
|
+
* is meant to end.
|
|
532
|
+
* @returns { stopped, still_running } member names, as the DELETE route's shape.
|
|
533
|
+
*/
|
|
534
|
+
stopHere(msg, memberIds) {
|
|
535
|
+
const { stopped, running } = this.cancelJobs(job => job.room_id === msg.room_id && memberIds.includes(job.member_id));
|
|
536
|
+
const name = id => this.config.get(id)?.name ?? id;
|
|
537
|
+
const still_running = [...new Set(running.map(name))];
|
|
538
|
+
const stoppedNames = [...new Set(stopped.map(name))];
|
|
539
|
+
const text = stoppedNames.length === 0
|
|
540
|
+
? `⏹ 没有在跑的活。`
|
|
541
|
+
: `⏹ 已停下 ${stoppedNames.join('、')} 这一轮。` + (still_running.length ? `\n仍在跑:${still_running.join('、')}` : '');
|
|
542
|
+
this.hub.post(msg.room_id, {
|
|
543
|
+
authorKind: 'system', author: 'system', name: 'system',
|
|
544
|
+
clientKey: `dsh-stop-${msg.id}-${stoppedNames.length ? 'stopped' : 'idle'}`,
|
|
545
|
+
text,
|
|
546
|
+
});
|
|
547
|
+
return { stopped: stoppedNames, still_running };
|
|
548
|
+
}
|
|
549
|
+
|
|
484
550
|
/**
|
|
485
551
|
* #557: root approval/request answerer (installed prepended by the plugin).
|
|
486
552
|
* The Session is held out of the DSH conversation list, so its approval
|
|
@@ -536,6 +602,20 @@ export class DshMembers {
|
|
|
536
602
|
const visible = new Set(this.hub.routableIn(msg.room_id).map(m => m.id));
|
|
537
603
|
const targets = [...this.config.keys()].filter(id => !this.hub.quota?.cooling(id) && visible.has(id) && id !== msg.author && !answered.has(id) && (!target || target === id)
|
|
538
604
|
&& (only !== null ? only.has(id) : hit.has(id) || (leadTarget && id === leadID) || (msg.author_kind === 'role' && nameCalled(msg.text, this.config.get(id).name, [...this.config.keys()].filter(o => o !== id).map(o => this.config.get(o).name)))));
|
|
605
|
+
// #299: /stop and /stop all end the member turns running here, now. Both used to fall through to
|
|
606
|
+
// addJob+enqueue below, which serializes per (member, room) — so the stop only ran once the turn it
|
|
607
|
+
// was meant to end had already finished. They take the same out-of-band cancel path as recall, with
|
|
608
|
+
// no placeholder job and no queue slot, so the receipt cannot arrive late.
|
|
609
|
+
// Not the bridge's /stop all (stop_all.go): that stops every bot's bridge session in the channel.
|
|
610
|
+
const stop = msg.author_kind === 'user' ? stopCommand(msg.text, this.hub.routableIn(msg.room_id)) : undefined;
|
|
611
|
+
// `/stop all` is typed bare, so its targets are none; it stops every DSH member visible here.
|
|
612
|
+
// A single `/stop` only stops the members the message reaches, the same targets /new uses.
|
|
613
|
+
if (stop !== undefined) {
|
|
614
|
+
const ids = stop === 'all' ? [...this.config.keys()].filter(id => visible.has(id)) : targets;
|
|
615
|
+
// Nobody is here to stop: keep quiet, like any other message that reaches no member.
|
|
616
|
+
if (ids.length === 0) return undefined;
|
|
617
|
+
return () => { this.stopHere(msg, ids); };
|
|
618
|
+
}
|
|
539
619
|
if (targets.length === 0) return undefined;
|
|
540
620
|
// /new goes to the members the message reaches, as on Discord where only the addressed bot handles it.
|
|
541
621
|
if (msg.author_kind === 'user' && isNewCommand(msg.text, this.hub.routableIn(msg.room_id))) {
|
|
@@ -635,9 +715,11 @@ export class DshMembers {
|
|
|
635
715
|
// never advances the cursor, so the same image would block the room.
|
|
636
716
|
const { parts: media, dropped: refused } = await nativeAttachmentBlocks(this.rt.ctx.attachments, namedAttachmentBlocks(this.rt.files, unseen));
|
|
637
717
|
const dropped = refused.map(d => `\n\n[${d.name}: 附件未送达(${undeliveredReasonZh(d)})]`).join('');
|
|
718
|
+
// #197: a file over the bot connection cap is not inlined; the member reads it from disk.
|
|
719
|
+
const oversized = oversizedNote(this.rt.files?.oversized(unseen.flatMap(messageAttachments)));
|
|
638
720
|
const wiki = await this.turnContext(member, room, agent.session.id, unseen);
|
|
639
721
|
if (this.closed) return;
|
|
640
|
-
const input = this.rt.createUserMessage({ content: [{ type: 'text', text: promptText(room, member, unseen, withBlock(memberStatus(this.hub, undefined, room.id), wiki.block), this.store.dispatchMode(room.id), this.store) + dropped + (this.rt.later?.instructions(member.id, room.id) ?? '') + workspaceHint(this.hub, this.rt.later, member.id, room.id) }, ...media], source: { kind: 'user' } });
|
|
722
|
+
const input = this.rt.createUserMessage({ content: [{ type: 'text', text: promptText(room, member, unseen, withBlock(memberStatus(this.hub, undefined, room.id), wiki.block), this.store.dispatchMode(room.id), this.store) + dropped + (oversized ? `\n${oversized}` : '') + (this.rt.later?.instructions(member.id, room.id) ?? '') + workspaceHint(this.hub, this.rt.later, member.id, room.id) }, ...media], source: { kind: 'user' } });
|
|
641
723
|
const seqBefore = agent.session.seq;
|
|
642
724
|
if (!this.store.markSent(jobId, agent.session.id, seqBefore, input.id)) return;
|
|
643
725
|
wiki.commit();
|
|
@@ -678,7 +760,7 @@ export class DshMembers {
|
|
|
678
760
|
let entry = this.cliAgents.get(cliKey);
|
|
679
761
|
if (entry === undefined) {
|
|
680
762
|
// Persona goes through promptText every turn, so an edit applies without restarting the CLI.
|
|
681
|
-
entry = { memberId: member.id, cli: member.cli, agent: this.rt.createCliMember({ cli: member.cli, cwd, persona: '',
|
|
763
|
+
entry = { memberId: member.id, roomId: room.id, cli: member.cli, agent: this.rt.createCliMember({ cli: member.cli, cwd, persona: '',
|
|
682
764
|
// #1020: memvote.py records citations under these (memory-sources.mjs), named as the gate key (gate-context.mjs).
|
|
683
765
|
env: { CC_PROJECT: `dsh:${member.id}`, CC_SESSION_KEY: `dsh:${member.id}:${room.id}` } }) };
|
|
684
766
|
this.cliAgents.set(cliKey, entry);
|
|
@@ -757,7 +839,12 @@ export class DshMembers {
|
|
|
757
839
|
}
|
|
758
840
|
};
|
|
759
841
|
const prompt = promptText(room, member, unseen, withBlock(memberStatus(this.hub, undefined, room.id), wiki.block), this.store.dispatchMode(room.id), this.store) + (this.rt.later?.instructions(member.id, room.id) ?? '') + workspaceHint(this.hub, this.rt.later, member.id, room.id);
|
|
760
|
-
|
|
842
|
+
// #197 合 develop: 这里不能再提前 wiki.commit()。develop 的 af2d1afe 把「记忆已送达」
|
|
843
|
+
// 挪进 onSession 回调,因为 cli-member.mjs 先建会话、再应用 CLI 配置,才触发 onSession;
|
|
844
|
+
// 这两步失败时提前标记会让重试跳过 MEMORY.md。记忆目录是否真的送达由 onSession 里的调用决定。
|
|
845
|
+
// #197: a file over the bot connection cap is not inlined; the CLI reads it from disk.
|
|
846
|
+
const bigNote = oversizedNote(this.rt.files?.oversized(unseen.flatMap(messageAttachments)));
|
|
847
|
+
const text = (await entry.agent.ask(`${prompt}${bigNote ? `\n${bigNote}` : ''}`, String(room.id), onText, onTool, { resumeId, onSession, onPermission, cliConfig: member.cliConfig, cliConfigManaged: member.cliConfigManaged, attachments: attachmentBlocks(this.rt.files, unseen) }))?.trim();
|
|
761
848
|
if (this.closed) return;
|
|
762
849
|
this.hub.quota?.observe(member.id, room.id, { error: text });
|
|
763
850
|
if (!text) { this.fail(job.id, `${member.name} 这一轮没有给出回复(${member.cli} 没有输出文字)`); return; }
|
|
@@ -803,13 +890,20 @@ export class DshMembers {
|
|
|
803
890
|
resetRoom(roomId) {
|
|
804
891
|
this.gateContext.resetRoom(roomId);
|
|
805
892
|
this.memoryIndex.resetRoom(roomId);
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
893
|
+
this.dropRoomCli(roomId);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
dropRoomCli(roomId) {
|
|
897
|
+
this.cancelCliStarts((_memberId, id) => id === roomId);
|
|
898
|
+
for (const [key, entry] of this.cliAgents) {
|
|
899
|
+
if (entry.roomId !== roomId) continue;
|
|
900
|
+
entry.agent.close();
|
|
901
|
+
this.cliAgents.delete(key);
|
|
809
902
|
}
|
|
810
903
|
}
|
|
811
904
|
|
|
812
905
|
dropCli(id) {
|
|
906
|
+
this.cancelCliStarts(memberId => memberId === id);
|
|
813
907
|
for (const [key, entry] of this.cliAgents) {
|
|
814
908
|
if (entry.memberId !== id) continue;
|
|
815
909
|
entry.agent.close();
|
|
@@ -817,6 +911,14 @@ export class DshMembers {
|
|
|
817
911
|
}
|
|
818
912
|
}
|
|
819
913
|
|
|
914
|
+
/** Close queued CLI jobs too: run/markSent must not revive a process after cleanup. */
|
|
915
|
+
cancelCliStarts(matches) {
|
|
916
|
+
for (const job of this.store.openJobs()) {
|
|
917
|
+
if (job.state !== 'pending' || this.config.get(job.member_id)?.cli === undefined || !matches(job.member_id, job.room_id)) continue;
|
|
918
|
+
this.store.finishJob(job.id, 'interrupted');
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
820
922
|
/** Watch for the consuming turn, admit the input, and post the outcome. */
|
|
821
923
|
async admitAndSettle(job, member, agent, input, seqBefore) {
|
|
822
924
|
const sessionId = agent.session.id;
|
package/collab/instance-lock.mjs
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { mkdirSync } from 'node:fs';
|
|
2
|
-
import { dirname } from 'node:path';
|
|
1
|
+
import { accessSync, chmodSync, constants, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
3
|
import { DatabaseSync } from 'node:sqlite';
|
|
4
4
|
|
|
5
5
|
export const LOCK_FILE = 'collab.lock';
|
|
6
|
+
// #216: the holder's identity, so the DSH that is refused can say who holds the lock
|
|
7
|
+
// instead of calling a busy service "not configured".
|
|
8
|
+
export const OWNER_FILE = 'collab.owner.json';
|
|
6
9
|
|
|
7
10
|
/**
|
|
8
11
|
* #1019: one DSH process per collab data directory. Two hosts on the same collab.sqlite3 both run
|
|
@@ -44,3 +47,133 @@ export function lockBusyMessage(path) {
|
|
|
44
47
|
return `conest-collab: 频道插件没有启动——这台电脑上已经有另一个 DSH 在用同一份频道数据(${path})。`
|
|
45
48
|
+ '两个同时跑会让提醒、派活和消息各处理两遍。请用已经打开的那个 DSH;要换就先关掉它再打开。';
|
|
46
49
|
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* #216: the instance that holds the lock records who it is next to it, so the DSH that is
|
|
53
|
+
* refused can name the holder instead of telling the user to go and configure collab.yml.
|
|
54
|
+
* Written after the lock is held (so the record always describes the current holder) and
|
|
55
|
+
* removed on release; a record left behind by a crash is harmless because the reader also
|
|
56
|
+
* checks the lock itself.
|
|
57
|
+
* @returns the record written.
|
|
58
|
+
*/
|
|
59
|
+
export function writeLockOwner(path, info) {
|
|
60
|
+
const file = join(dirname(path), OWNER_FILE);
|
|
61
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
62
|
+
const record = { ...info, lock: path, writtenAt: info.writtenAt ?? Date.now() };
|
|
63
|
+
// 来源:沿用 pairing.mjs saveJSON 的私有凭据权限;收紧旧诊断文件后再写连接口令。
|
|
64
|
+
try { chmodSync(file, 0o600); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
65
|
+
writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
|
|
66
|
+
return record;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* #216: the holder record for the data directory of `path`.
|
|
71
|
+
* @returns the record, or undefined when no holder wrote one (an older install, or a
|
|
72
|
+
* crashed holder whose lock the kernel already dropped).
|
|
73
|
+
*/
|
|
74
|
+
export function readLockOwner(path) {
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(readFileSync(join(dirname(path), OWNER_FILE), 'utf8'));
|
|
77
|
+
return parsed !== null && typeof parsed === 'object' ? parsed : undefined;
|
|
78
|
+
} catch {
|
|
79
|
+
// Missing or half-written: the caller falls back to the plain refusal.
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** #216: remove `pid`'s record; another live holder's record is left alone. */
|
|
85
|
+
export function clearLockOwner(path, pid = process.pid) {
|
|
86
|
+
const record = readLockOwner(path);
|
|
87
|
+
if (record !== undefined && record.pid !== undefined && record.pid !== pid) return;
|
|
88
|
+
try { unlinkSync(join(dirname(path), OWNER_FILE)); } catch { /* already gone */ }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* #216/#227: take the lock and publish who holds it, as one step.
|
|
93
|
+
*
|
|
94
|
+
* Writing the record can fail (a directory where the file should be, a full or read-only data
|
|
95
|
+
* directory). The lock must not survive that failure: the plugin throws, the DSH keeps running
|
|
96
|
+
* with the error logged, and a lock nobody will ever release welds the data directory shut for
|
|
97
|
+
* every later DSH — the user sees "another DSH is using this" with no DSH to quit. So a failed
|
|
98
|
+
* write releases the lock before the error travels on.
|
|
99
|
+
*
|
|
100
|
+
* @returns the held lock with its record written.
|
|
101
|
+
* @throws the write error, after releasing the lock.
|
|
102
|
+
*/
|
|
103
|
+
export function acquireLockWithOwner(path, info) {
|
|
104
|
+
const lock = InstanceLock.acquire(path);
|
|
105
|
+
if (lock === undefined) return undefined;
|
|
106
|
+
try {
|
|
107
|
+
writeLockOwner(path, info);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
lock.release();
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
return lock;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** #216: is `pid` a live process on this machine? EPERM means it exists but is not ours. */
|
|
116
|
+
function alive(pid) {
|
|
117
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
118
|
+
try { process.kill(pid, 0); return true; } catch (error) { return error?.code === 'EPERM'; }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* #227: does anyone hold the lock at `path`? Answered by reading it, never by taking it.
|
|
123
|
+
*
|
|
124
|
+
* The probe used to be `InstanceLock.acquire` + release, so it held the exclusive lock for the
|
|
125
|
+
* instant between the two: a page poll landing in that instant made a DSH starting at the same
|
|
126
|
+
* moment refuse to start (实测约 95 us/次,另一进程 120 次启动被拒 0 次但这是运气), and on a free
|
|
127
|
+
* directory it created the lock file. A read-only connection takes no lock and creates no file:
|
|
128
|
+
* a holder's `BEGIN EXCLUSIVE` makes the read fail at once with SQLITE_BUSY — the same signal
|
|
129
|
+
* without the contention (实测约 32 us/次).
|
|
130
|
+
*
|
|
131
|
+
* @returns 'held' when someone holds the lock, 'free' when nobody does, 'unknown' when the file
|
|
132
|
+
* cannot be read for a reason other than "locked" (permissions, I/O).
|
|
133
|
+
*/
|
|
134
|
+
function probeLock(path) {
|
|
135
|
+
let db;
|
|
136
|
+
try {
|
|
137
|
+
db = new DatabaseSync(path, { readOnly: true });
|
|
138
|
+
// 来源:沿用 InstanceLock.acquire 的 0 —— 探测不能等,等就等于页面轮询把状态请求拖住。
|
|
139
|
+
db.exec('PRAGMA busy_timeout = 0;');
|
|
140
|
+
db.prepare('SELECT count(*) FROM sqlite_master').get();
|
|
141
|
+
return 'free';
|
|
142
|
+
} catch (error) {
|
|
143
|
+
// 来源:SQLite 结果码——5 BUSY / 6 LOCKED 是别人的排他事务。
|
|
144
|
+
if (error?.errcode === 5 || error?.errcode === 6 || /\blocked\b|\bbusy\b/i.test(String(error?.message))) return 'held';
|
|
145
|
+
// 14 CANTOPEN 有歧义:文件不在(没人持锁)和数据目录不可读(读不出状态)都会报它。
|
|
146
|
+
// 来源:实测——目录 000 时 existsSync 也回 false,只有 access 的 EACCES/ENOENT 能分开。
|
|
147
|
+
try { accessSync(path, constants.R_OK); } catch (denied) { return denied?.code === 'ENOENT' ? 'free' : 'unknown'; }
|
|
148
|
+
return 'unknown';
|
|
149
|
+
} finally {
|
|
150
|
+
try { db?.close(); } catch { /* nothing to close */ }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* #216: what a DSH that lost the lock tells its own page about that lock.
|
|
156
|
+
* - the lock is free (the holder quit): `free`, so the page asks for a restart instead of
|
|
157
|
+
* repeating a holder that already let go;
|
|
158
|
+
* - the lock is held and a live holder recorded itself: who holds it;
|
|
159
|
+
* - the lock is held, or cannot be read, without a trustworthy record: busy, holder unknown —
|
|
160
|
+
* the page must still not call it "not configured".
|
|
161
|
+
*
|
|
162
|
+
* The record alone never decides. Only `alive(pid)` plus a held lock makes it the holder: a dead
|
|
163
|
+
* pid's record outlives its holder, and a crash between `release` and `clearLockOwner` leaves a
|
|
164
|
+
* record naming a process that holds nothing (#227 2026-11-06 19:40,notes/utils/227-lockprobe/e.mjs).
|
|
165
|
+
*/
|
|
166
|
+
export function lockStatus(path) {
|
|
167
|
+
const record = readLockOwner(path);
|
|
168
|
+
// #216: the data directory and the lock path travel with every answer, so a page told
|
|
169
|
+
// "busy, holder unknown" can still say which directory to look at and which file to check
|
|
170
|
+
// (`lsof <lock>`) — and does not have to rebuild a path itself, which breaks on Windows.
|
|
171
|
+
const where = { dataDir: dirname(path), lock: path };
|
|
172
|
+
if (probeLock(path) === 'free') return { busy: false, free: true, ...where };
|
|
173
|
+
// #227: still held (or unreadable) — name the holder only when the record and the lock agree.
|
|
174
|
+
if (record !== undefined && alive(record.pid)) {
|
|
175
|
+
const { local, ...owner } = record;
|
|
176
|
+
return { busy: true, owner, ...where };
|
|
177
|
+
}
|
|
178
|
+
return { busy: true, owner: null, ...where };
|
|
179
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// #216: later DSH windows use the existing channel host; only that host opens the store.
|
|
2
|
+
import { request as httpRequest } from 'node:http';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { readLockOwner, lockStatus } from './instance-lock.mjs';
|
|
5
|
+
import { isLoopback } from './pairing.mjs';
|
|
6
|
+
|
|
7
|
+
/** Private discovery material, kept beside the lock and omitted from the diagnostic API. */
|
|
8
|
+
export function localConnection(webServer) {
|
|
9
|
+
const url = new URL(webServer.getOrigin());
|
|
10
|
+
if (url.protocol !== 'http:' || !isLoopback(url.hostname.replace(/^\[|\]$/g, ''))) return undefined;
|
|
11
|
+
return { origin: url.origin, token: randomUUID() };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A forwarder proves it can read the holder's private discovery file. */
|
|
15
|
+
export function isLocalOwner(req, connection) {
|
|
16
|
+
return connection !== undefined && isLoopback(req.socket.remoteAddress)
|
|
17
|
+
&& req.headers['x-collab-local'] === connection.token;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Same-origin HTTP/SSE bridge, using pairing.mjs remoteProxyRoute's streaming lifecycle. */
|
|
21
|
+
export function forwardLocal(req, res, lockPath, reject) {
|
|
22
|
+
const denied = reject(req);
|
|
23
|
+
if (denied !== undefined) return reply(res, denied, { error: '请先登录本页 DSH' });
|
|
24
|
+
const status = lockStatus(lockPath);
|
|
25
|
+
const record = readLockOwner(lockPath);
|
|
26
|
+
const connection = status.busy && status.owner?.pid !== undefined && status.owner.pid === record?.pid ? record?.local : undefined;
|
|
27
|
+
if (connection === undefined) return reply(res, 404, { error: '频道宿主无法自动连接,请查看占用提示' });
|
|
28
|
+
let url;
|
|
29
|
+
try {
|
|
30
|
+
url = new URL(connection.origin);
|
|
31
|
+
if (url.protocol !== 'http:' || !isLoopback(url.hostname.replace(/^\[|\]$/g, '')) || typeof connection.token !== 'string') throw Error('invalid local host');
|
|
32
|
+
} catch {
|
|
33
|
+
return reply(res, 404, { error: '频道宿主没有留下可用的本机连接信息' });
|
|
34
|
+
}
|
|
35
|
+
const headers = { 'x-collab-local': connection.token };
|
|
36
|
+
// 来源:沿用 pairing.mjs PASS_REQUEST; neither host's browser credentials cross this bridge.
|
|
37
|
+
for (const h of ['accept', 'content-type', 'content-length', 'last-event-id', 'range', 'if-none-match', 'if-modified-since']) {
|
|
38
|
+
if (req.headers[h] !== undefined) headers[h] = req.headers[h];
|
|
39
|
+
}
|
|
40
|
+
return new Promise(resolve => {
|
|
41
|
+
const up = httpRequest(url, { path: req.url, method: req.method, headers }, incoming => {
|
|
42
|
+
const out = {};
|
|
43
|
+
// 来源:沿用 pairing.mjs PASS_RESPONSE; no upstream cookie reaches the browser.
|
|
44
|
+
for (const h of ['content-type', 'cache-control', 'etag', 'last-modified', 'content-disposition', 'content-range', 'accept-ranges', 'location']) {
|
|
45
|
+
if (incoming.headers[h] !== undefined) out[h] = incoming.headers[h];
|
|
46
|
+
}
|
|
47
|
+
res.writeHead(incoming.statusCode, out);
|
|
48
|
+
res.flushHeaders?.();
|
|
49
|
+
incoming.pipe(res);
|
|
50
|
+
incoming.on('end', resolve);
|
|
51
|
+
incoming.on('error', () => { res.destroy(); resolve(); });
|
|
52
|
+
});
|
|
53
|
+
up.on('error', () => {
|
|
54
|
+
if (!res.headersSent) reply(res, 404, { error: '频道宿主已断开,请查看占用提示' });
|
|
55
|
+
else res.destroy();
|
|
56
|
+
resolve();
|
|
57
|
+
});
|
|
58
|
+
req.on('aborted', () => up.destroy());
|
|
59
|
+
req.on('error', () => up.destroy());
|
|
60
|
+
res.on('close', () => { if (!res.writableEnded) up.destroy(); });
|
|
61
|
+
req.pipe(up);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function reply(res, status, value) {
|
|
66
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
67
|
+
res.end(JSON.stringify(value));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** #227: where a later DSH reports which process holds the channel data. */
|
|
71
|
+
export const BUSY_PATH = '/collab/api/lock';
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* #597 G26: a later ticket only opens workspaces (and later). Sent anywhere else, say where to go
|
|
75
|
+
* instead of a bare 401, so a member does not give up; the route's own auth is unchanged.
|
|
76
|
+
*/
|
|
77
|
+
export const TICKET_WRONG_ROUTE = '这个接口不认 X-Later-Ticket 票据。建工作区请用 POST /collab/api/rooms/{id}/workspaces(同一个票据头,JSON: title, owner, reason),任务和十分钟看门会自动开,不用再调 /tasks。';
|
|
78
|
+
|
|
79
|
+
/** The 401/403 split every scoped route uses when the caller is not the owner. */
|
|
80
|
+
export function refuse(req, res) {
|
|
81
|
+
if (req.headers?.['x-later-ticket'] !== undefined) return reply(res, 403, { error: TICKET_WRONG_ROUTE });
|
|
82
|
+
return reply(res, 401, { error: 'unauthorized' });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* #227: the two routes a second DSH serves when another process holds the channel data.
|
|
87
|
+
* It is a client of that host, never a second writer, so it publishes the holder's status and
|
|
88
|
+
* forwards the channel API instead of opening the store.
|
|
89
|
+
*
|
|
90
|
+
* Extracted from `apply` so this branch can be tested without loading the plugin (审稿 F2:
|
|
91
|
+
* `plugin.mjs` needs the DSH runtime to import, and this branch had no unit test at all).
|
|
92
|
+
*
|
|
93
|
+
* @param ctx - the plugin context: `webServer.register`, `effect`, `connection.requestRejection`.
|
|
94
|
+
* @param lockPath - the lock file the holder took; both routes answer from it.
|
|
95
|
+
*/
|
|
96
|
+
export function mountSharedHost(ctx, lockPath) {
|
|
97
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: BUSY_PATH,
|
|
98
|
+
handler: (req, res) => {
|
|
99
|
+
if (ctx.connection.requestRejection(req) !== undefined) return refuse(req, res);
|
|
100
|
+
return reply(res, 200, lockStatus(lockPath));
|
|
101
|
+
},
|
|
102
|
+
}), 'conest-collab: holder status');
|
|
103
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/collab',
|
|
104
|
+
handler: (req, res) => forwardLocal(req, res, lockPath, req => ctx.connection.requestRejection(req)),
|
|
105
|
+
}), 'conest-collab: shared local host');
|
|
106
|
+
}
|