@ours.network/cowork 1.0.4 → 1.0.6
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 +1 -0
- package/dist/cli.js +7 -0
- package/dist/daemon.js +190 -29
- package/dist/web/assets/app.js +8 -8
- package/docs/05-room-workflow.md +4 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -41,3 +41,4 @@ ours-cowork docs web
|
|
|
41
41
|
```
|
|
42
42
|
|
|
43
43
|
Before production use, read the limitations topic. In particular, backups require a stopped daemon and restore uses the complete state directory.
|
|
44
|
+
Lost room identity leases are recovered automatically with a non-force bind and exact persisted-CID proof. Operators can invoke the same safe path explicitly with `ours-cowork room rebind <room-id>`; it never recreates an established identity or steals a live lease.
|
package/dist/cli.js
CHANGED
|
@@ -5053,6 +5053,7 @@ var NATIVE_MUTATION_METHODS = /* @__PURE__ */ new Set([
|
|
|
5053
5053
|
"room.close",
|
|
5054
5054
|
"room.recover",
|
|
5055
5055
|
"room.recover.confirm",
|
|
5056
|
+
"room.rebind",
|
|
5056
5057
|
"room.participant.remove",
|
|
5057
5058
|
"room.participant.replace"
|
|
5058
5059
|
]);
|
|
@@ -5105,6 +5106,7 @@ Room commands:
|
|
|
5105
5106
|
close <room-id>
|
|
5106
5107
|
delete <room-id> --yes
|
|
5107
5108
|
recover <room-id> [--confirm <old-invite-id> <new-invite-id>]
|
|
5109
|
+
rebind <room-id>
|
|
5108
5110
|
|
|
5109
5111
|
Run \u2018ours-cowork docs\u2019 for the offline documentation index.`;
|
|
5110
5112
|
}
|
|
@@ -5349,6 +5351,11 @@ async function roomRequest(command, args) {
|
|
|
5349
5351
|
exactPositionals(parsed, 1, "room recover");
|
|
5350
5352
|
return { method: "room.recover", params: { room_id: roomId } };
|
|
5351
5353
|
}
|
|
5354
|
+
case "rebind": {
|
|
5355
|
+
const parsed = parseOptions(args, []);
|
|
5356
|
+
const [roomId] = exactPositionals(parsed, 1, "room rebind");
|
|
5357
|
+
return { method: "room.rebind", params: { room_id: roomId } };
|
|
5358
|
+
}
|
|
5352
5359
|
default:
|
|
5353
5360
|
usageError(`unknown room command: ${command}`);
|
|
5354
5361
|
}
|
package/dist/daemon.js
CHANGED
|
@@ -5267,6 +5267,14 @@ import { createHash as createHash2 } from "node:crypto";
|
|
|
5267
5267
|
import * as fs from "node:fs";
|
|
5268
5268
|
import { join as join2 } from "node:path";
|
|
5269
5269
|
import { brotliCompressSync, brotliDecompressSync, constants as zlibConstants } from "node:zlib";
|
|
5270
|
+
function sdkErrorCode(error) {
|
|
5271
|
+
if (error === null || typeof error !== "object" || !("code" in error)) return void 0;
|
|
5272
|
+
return typeof error.code === "string" ? error.code : void 0;
|
|
5273
|
+
}
|
|
5274
|
+
function isTransientTransportError(error) {
|
|
5275
|
+
if (error === null || typeof error !== "object" || !("code" in error)) return false;
|
|
5276
|
+
return (/* @__PURE__ */ new Set(["ECONNRESET", "ECONNREFUSED", "EPIPE", "ETIMEDOUT", "UND_ERR_CONNECT_TIMEOUT"])).has(String(error.code));
|
|
5277
|
+
}
|
|
5270
5278
|
function sendResult(result) {
|
|
5271
5279
|
if (result.kind === "refused" || result.kind === "migrating") {
|
|
5272
5280
|
return { status: "send_failed" };
|
|
@@ -5386,7 +5394,7 @@ function unpackInvite(encoded, maximumBytes) {
|
|
|
5386
5394
|
if (compressed.length === 0) throw new Error("the invite blob is empty or invalid base64url");
|
|
5387
5395
|
return Buffer.from(maximumBytes === void 0 ? brotliDecompressSync(compressed) : brotliDecompressSync(compressed, { maxOutputLength: maximumBytes }));
|
|
5388
5396
|
}
|
|
5389
|
-
var LegacyCoworkStateError, PacketRegistry, SdkRoomPacket;
|
|
5397
|
+
var LegacyCoworkStateError, RoomIdentityMismatchError, PacketRegistry, SdkRoomPacket;
|
|
5390
5398
|
var init_packets = __esm({
|
|
5391
5399
|
"src/packets.ts"() {
|
|
5392
5400
|
"use strict";
|
|
@@ -5399,6 +5407,12 @@ var init_packets = __esm({
|
|
|
5399
5407
|
this.name = "LegacyCoworkStateError";
|
|
5400
5408
|
}
|
|
5401
5409
|
};
|
|
5410
|
+
RoomIdentityMismatchError = class extends Error {
|
|
5411
|
+
constructor(expected, found) {
|
|
5412
|
+
super(`room identity CID mismatch during rebind: expected "${expected}", found "${found}"`);
|
|
5413
|
+
this.name = "RoomIdentityMismatchError";
|
|
5414
|
+
}
|
|
5415
|
+
};
|
|
5402
5416
|
PacketRegistry = class {
|
|
5403
5417
|
packets = /* @__PURE__ */ new Map();
|
|
5404
5418
|
trackers = /* @__PURE__ */ new Map();
|
|
@@ -5407,6 +5421,8 @@ var init_packets = __esm({
|
|
|
5407
5421
|
fs;
|
|
5408
5422
|
log;
|
|
5409
5423
|
onNotify;
|
|
5424
|
+
rebindSleep;
|
|
5425
|
+
rebindRandom;
|
|
5410
5426
|
unsubscribe;
|
|
5411
5427
|
constructor(host, stateDir, options = {}) {
|
|
5412
5428
|
this.host = host;
|
|
@@ -5416,6 +5432,8 @@ var init_packets = __esm({
|
|
|
5416
5432
|
});
|
|
5417
5433
|
this.onNotify = options.onNotify ?? (() => {
|
|
5418
5434
|
});
|
|
5435
|
+
this.rebindSleep = options.rebindSleep ?? ((ms) => new Promise((resolve3) => setTimeout(resolve3, ms)));
|
|
5436
|
+
this.rebindRandom = options.rebindRandom ?? Math.random;
|
|
5419
5437
|
this.unsubscribe = host.onIdentityNotify((name) => {
|
|
5420
5438
|
const found = [...this.packets.entries()].find(([, packet2]) => packet2.name === name);
|
|
5421
5439
|
if (!found) return;
|
|
@@ -5453,7 +5471,12 @@ var init_packets = __esm({
|
|
|
5453
5471
|
localAutoAccept: true
|
|
5454
5472
|
});
|
|
5455
5473
|
const cid = created.info.cid;
|
|
5456
|
-
const packet = new SdkRoomPacket(identityName, cid, client
|
|
5474
|
+
const packet = new SdkRoomPacket(identityName, cid, client, {
|
|
5475
|
+
roomId,
|
|
5476
|
+
log: this.log,
|
|
5477
|
+
sleep: this.rebindSleep,
|
|
5478
|
+
random: this.rebindRandom
|
|
5479
|
+
});
|
|
5457
5480
|
await packet.refresh();
|
|
5458
5481
|
this.packets.set(roomId, packet);
|
|
5459
5482
|
this.track(roomId, identityName);
|
|
@@ -5482,7 +5505,12 @@ var init_packets = __esm({
|
|
|
5482
5505
|
if (expectedCid !== void 0 && bound.cid !== expectedCid) {
|
|
5483
5506
|
throw new Error(`restored room identity CID mismatch: expected "${expectedCid}", found "${bound.cid}"`);
|
|
5484
5507
|
}
|
|
5485
|
-
const packet = new SdkRoomPacket(identityName, bound.cid, client
|
|
5508
|
+
const packet = new SdkRoomPacket(identityName, bound.cid, client, {
|
|
5509
|
+
roomId,
|
|
5510
|
+
log: this.log,
|
|
5511
|
+
sleep: this.rebindSleep,
|
|
5512
|
+
random: this.rebindRandom
|
|
5513
|
+
});
|
|
5486
5514
|
await packet.refresh();
|
|
5487
5515
|
this.packets.set(roomId, packet);
|
|
5488
5516
|
this.track(roomId, identityName);
|
|
@@ -5493,6 +5521,21 @@ var init_packets = __esm({
|
|
|
5493
5521
|
throw error;
|
|
5494
5522
|
}
|
|
5495
5523
|
}
|
|
5524
|
+
async rebind(roomId) {
|
|
5525
|
+
validateRoomId(roomId);
|
|
5526
|
+
const packet = this.packets.get(roomId);
|
|
5527
|
+
if (!packet) throw new Error(`room packet "${roomId}" is not hosted`);
|
|
5528
|
+
return packet.rebind();
|
|
5529
|
+
}
|
|
5530
|
+
/** Release one local SDK lease without deleting the persisted daemon identity. */
|
|
5531
|
+
async unhost(roomId) {
|
|
5532
|
+
validateRoomId(roomId);
|
|
5533
|
+
const packet = this.packets.get(roomId);
|
|
5534
|
+
if (!packet) return;
|
|
5535
|
+
this.untrack(roomId);
|
|
5536
|
+
this.packets.delete(roomId);
|
|
5537
|
+
await packet.close();
|
|
5538
|
+
}
|
|
5496
5539
|
async destroy(roomId) {
|
|
5497
5540
|
validateRoomId(roomId);
|
|
5498
5541
|
const packet = this.packets.get(roomId);
|
|
@@ -5549,28 +5592,87 @@ var init_packets = __esm({
|
|
|
5549
5592
|
name;
|
|
5550
5593
|
cid;
|
|
5551
5594
|
client;
|
|
5595
|
+
roomId;
|
|
5596
|
+
log;
|
|
5597
|
+
rebindSleep;
|
|
5598
|
+
rebindRandom;
|
|
5599
|
+
rebindWork;
|
|
5552
5600
|
contacts = [];
|
|
5553
5601
|
hasInviteProvenance = false;
|
|
5554
5602
|
invites = [];
|
|
5555
5603
|
refreshWork;
|
|
5556
5604
|
contactRefreshWork;
|
|
5557
|
-
constructor(name, cid, client) {
|
|
5605
|
+
constructor(name, cid, client, recovery = {}) {
|
|
5606
|
+
this.roomId = recovery.roomId ?? name;
|
|
5558
5607
|
this.name = name;
|
|
5559
5608
|
this.cid = cid;
|
|
5560
5609
|
this.client = client;
|
|
5610
|
+
this.log = recovery.log ?? (() => {
|
|
5611
|
+
});
|
|
5612
|
+
this.rebindSleep = recovery.sleep ?? ((ms) => new Promise((resolve3) => setTimeout(resolve3, ms)));
|
|
5613
|
+
this.rebindRandom = recovery.random ?? Math.random;
|
|
5614
|
+
}
|
|
5615
|
+
rebind() {
|
|
5616
|
+
this.rebindWork ??= this.rebindUnlocked().finally(() => {
|
|
5617
|
+
this.rebindWork = void 0;
|
|
5618
|
+
});
|
|
5619
|
+
return this.rebindWork;
|
|
5620
|
+
}
|
|
5621
|
+
async rebindUnlocked() {
|
|
5622
|
+
this.observe("identity_rebind_detected");
|
|
5623
|
+
let lastError;
|
|
5624
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
5625
|
+
this.observe("identity_rebind_attempt", { attempt });
|
|
5626
|
+
try {
|
|
5627
|
+
const bound = await this.client.chooseIdentity({ name: this.name, force: false });
|
|
5628
|
+
if (bound.cid !== this.cid) {
|
|
5629
|
+
throw new RoomIdentityMismatchError(this.cid, bound.cid);
|
|
5630
|
+
}
|
|
5631
|
+
await this.refreshUnlocked();
|
|
5632
|
+
this.observe("identity_rebind_succeeded", { attempt });
|
|
5633
|
+
return { name: this.name, cid: this.cid, status: "rebound" };
|
|
5634
|
+
} catch (error) {
|
|
5635
|
+
lastError = error;
|
|
5636
|
+
const code = sdkErrorCode(error);
|
|
5637
|
+
if (error instanceof RoomIdentityMismatchError || !isTransientTransportError(error) || attempt === 3) {
|
|
5638
|
+
this.observe("identity_rebind_failed", {
|
|
5639
|
+
attempt,
|
|
5640
|
+
code: code ?? (error instanceof RoomIdentityMismatchError ? "CID_MISMATCH" : "RECOVERY_FAILED")
|
|
5641
|
+
});
|
|
5642
|
+
throw error;
|
|
5643
|
+
}
|
|
5644
|
+
const delay_ms = Math.round(100 * 2 ** (attempt - 1) * (0.75 + this.rebindRandom() * 0.5));
|
|
5645
|
+
this.observe("identity_rebind_retry", { attempt, delay_ms });
|
|
5646
|
+
await this.rebindSleep(delay_ms);
|
|
5647
|
+
}
|
|
5648
|
+
}
|
|
5649
|
+
throw lastError;
|
|
5650
|
+
}
|
|
5651
|
+
async runBound(operation) {
|
|
5652
|
+
try {
|
|
5653
|
+
return await operation();
|
|
5654
|
+
} catch (error) {
|
|
5655
|
+
const code = sdkErrorCode(error);
|
|
5656
|
+
if (code !== "NOT_BOUND" && code !== "BINDING_REASSIGNED") throw error;
|
|
5657
|
+
await this.rebind();
|
|
5658
|
+
return operation();
|
|
5659
|
+
}
|
|
5660
|
+
}
|
|
5661
|
+
observe(event, detail = {}) {
|
|
5662
|
+
this.log(JSON.stringify({ event, room_id: this.roomId, identity_name: this.name, identity_cid: this.cid, ...detail }));
|
|
5561
5663
|
}
|
|
5562
5664
|
refresh() {
|
|
5563
|
-
this.refreshWork ??= this.refreshUnlocked().finally(() => {
|
|
5665
|
+
this.refreshWork ??= this.runBound(() => this.refreshUnlocked()).finally(() => {
|
|
5564
5666
|
this.refreshWork = void 0;
|
|
5565
5667
|
});
|
|
5566
5668
|
return this.refreshWork;
|
|
5567
5669
|
}
|
|
5568
5670
|
async refreshUnlocked() {
|
|
5569
|
-
await this.
|
|
5671
|
+
await this.refreshContactsUnlocked();
|
|
5570
5672
|
this.invites = (await this.client.listInvites()).flatMap((invite) => invite.mode === "one_time" || invite.mode === "public" ? [{ invite_id: invite.invite_id, mode: invite.mode }] : []);
|
|
5571
5673
|
}
|
|
5572
5674
|
refreshContacts() {
|
|
5573
|
-
this.contactRefreshWork ??= this.refreshContactsUnlocked().finally(() => {
|
|
5675
|
+
this.contactRefreshWork ??= this.runBound(() => this.refreshContactsUnlocked()).finally(() => {
|
|
5574
5676
|
this.contactRefreshWork = void 0;
|
|
5575
5677
|
});
|
|
5576
5678
|
return this.contactRefreshWork;
|
|
@@ -5584,13 +5686,13 @@ var init_packets = __esm({
|
|
|
5584
5686
|
}));
|
|
5585
5687
|
}
|
|
5586
5688
|
async mintInvite(mode) {
|
|
5587
|
-
const result = await this.client.generateInvite({ mode });
|
|
5689
|
+
const result = await this.runBound(() => this.client.generateInvite({ mode }));
|
|
5588
5690
|
await this.refresh();
|
|
5589
5691
|
return { blob: result.blob, invite_id: result.inviteId, reusable: mode === "public" };
|
|
5590
5692
|
}
|
|
5591
5693
|
async addContact(invite) {
|
|
5592
5694
|
const decoded = unpackInvite(invite, MAX_EXTERNAL_INVITE_BYTES);
|
|
5593
|
-
const result = await this.client.addContact({ invite });
|
|
5695
|
+
const result = await this.runBound(() => this.client.addContact({ invite }));
|
|
5594
5696
|
await this.refresh();
|
|
5595
5697
|
return {
|
|
5596
5698
|
invite_id: createHash2("sha256").update(decoded).digest("hex"),
|
|
@@ -5600,7 +5702,7 @@ var init_packets = __esm({
|
|
|
5600
5702
|
};
|
|
5601
5703
|
}
|
|
5602
5704
|
async revokeInvite(inviteId) {
|
|
5603
|
-
const result = await this.client.revokeInvite({ invite_id: inviteId });
|
|
5705
|
+
const result = await this.runBound(() => this.client.revokeInvite({ invite_id: inviteId }));
|
|
5604
5706
|
await this.refresh();
|
|
5605
5707
|
return { revoked: result.revoked };
|
|
5606
5708
|
}
|
|
@@ -5615,9 +5717,9 @@ var init_packets = __esm({
|
|
|
5615
5717
|
}
|
|
5616
5718
|
async listUnreadMessages(limit) {
|
|
5617
5719
|
validateBatchLimit(limit);
|
|
5618
|
-
const metadata = (await this.client.listIncomingMessages()).filter((message) => message.status === "unread").sort((left, right) => left.seq - right.seq).slice(0, limit);
|
|
5720
|
+
const metadata = (await this.runBound(() => this.client.listIncomingMessages())).filter((message) => message.status === "unread").sort((left, right) => left.seq - right.seq).slice(0, limit);
|
|
5619
5721
|
return Promise.all(metadata.map(async (listed) => {
|
|
5620
|
-
const history = await this.client.getHistoryItem({ wire_id: listed.wire_id });
|
|
5722
|
+
const history = await this.runBound(() => this.client.getHistoryItem({ wire_id: listed.wire_id }));
|
|
5621
5723
|
if (history === null) throw new Error(`SDK history is missing unread message ${listed.wire_id}`);
|
|
5622
5724
|
assertListedMessage(listed, history);
|
|
5623
5725
|
return messageItem(history);
|
|
@@ -5625,7 +5727,7 @@ var init_packets = __esm({
|
|
|
5625
5727
|
}
|
|
5626
5728
|
async acknowledgeMessage(expected, onUnexpected) {
|
|
5627
5729
|
for (; ; ) {
|
|
5628
|
-
const pulled = await this.client.getMessages({ limit: 1 });
|
|
5730
|
+
const pulled = await this.runBound(() => this.client.getMessages({ limit: 1 }));
|
|
5629
5731
|
if (pulled.messages.length > 1) throw new Error("SDK returned more than one message for limit 1");
|
|
5630
5732
|
const [history] = pulled.messages;
|
|
5631
5733
|
if (history === void 0) return;
|
|
@@ -5639,31 +5741,31 @@ var init_packets = __esm({
|
|
|
5639
5741
|
}
|
|
5640
5742
|
async listUnreadFiles(limit) {
|
|
5641
5743
|
validateBatchLimit(limit);
|
|
5642
|
-
const unread = (await this.client.listIncomingFiles()).filter((file) => file.status === "unread").sort((left, right) => left.seq - right.seq).slice(0, limit);
|
|
5643
|
-
return Promise.all(unread.map(async (file) => fileItem(file, await this.client.fetchFile(file.wire_id))));
|
|
5744
|
+
const unread = (await this.runBound(() => this.client.listIncomingFiles())).filter((file) => file.status === "unread").sort((left, right) => left.seq - right.seq).slice(0, limit);
|
|
5745
|
+
return Promise.all(unread.map(async (file) => fileItem(file, await this.runBound(() => this.client.fetchFile(file.wire_id)))));
|
|
5644
5746
|
}
|
|
5645
5747
|
async acknowledgeFile(expected) {
|
|
5646
|
-
const pulled = await this.client.getFiles({ wire_ids: [expected.wire_id] });
|
|
5748
|
+
const pulled = await this.runBound(() => this.client.getFiles({ wire_ids: [expected.wire_id] }));
|
|
5647
5749
|
if (pulled.files.length !== 1) {
|
|
5648
5750
|
throw new Error(`SDK did not acknowledge selected file ${expected.wire_id}`);
|
|
5649
5751
|
}
|
|
5650
5752
|
assertReceivedFile(expected, pulled.files[0]);
|
|
5651
5753
|
}
|
|
5652
5754
|
async send(contactCid, body, replyTo) {
|
|
5653
|
-
return sendResult(await this.client.sendMessage({
|
|
5755
|
+
return sendResult(await this.runBound(() => this.client.sendMessage({
|
|
5654
5756
|
contact: contactCid,
|
|
5655
5757
|
text: body,
|
|
5656
5758
|
...replyTo === void 0 ? {} : {
|
|
5657
5759
|
reply_to_wire_id: replyTo.wire_id,
|
|
5658
5760
|
...replyTo.sentence === void 0 ? {} : { reply_to_sentence: replyTo.sentence }
|
|
5659
5761
|
}
|
|
5660
|
-
}));
|
|
5762
|
+
})));
|
|
5661
5763
|
}
|
|
5662
5764
|
async sendFile(contactCid, filename, mime, data, replyTo) {
|
|
5663
5765
|
const validName = FileNameSchema.parse(filename);
|
|
5664
5766
|
const validMime = FileMimeSchema.parse(mime);
|
|
5665
5767
|
if (data.length > MAX_FILE_BYTES) throw new RangeError(`room files must be at most ${MAX_FILE_BYTES} bytes (2 MiB)`);
|
|
5666
|
-
return sendResult(await this.client.sendFile({
|
|
5768
|
+
return sendResult(await this.runBound(() => this.client.sendFile({
|
|
5667
5769
|
contact: contactCid,
|
|
5668
5770
|
data_base64: data.toString("base64"),
|
|
5669
5771
|
filename: validName,
|
|
@@ -5672,10 +5774,10 @@ var init_packets = __esm({
|
|
|
5672
5774
|
reply_to_wire_id: replyTo.wire_id,
|
|
5673
5775
|
...replyTo.sentence === void 0 ? {} : { reply_to_sentence: replyTo.sentence }
|
|
5674
5776
|
}
|
|
5675
|
-
}));
|
|
5777
|
+
})));
|
|
5676
5778
|
}
|
|
5677
5779
|
async removeContact(contactCid) {
|
|
5678
|
-
const result = await this.client.removeContact({ contact: contactCid });
|
|
5780
|
+
const result = await this.runBound(() => this.client.removeContact({ contact: contactCid }));
|
|
5679
5781
|
const notified = result.notified === true;
|
|
5680
5782
|
this.contacts = this.contacts.filter((contact) => contact.container_id !== contactCid);
|
|
5681
5783
|
return { status: notified ? "queued" : "send_failed", notified, key_material_retained: true };
|
|
@@ -6392,6 +6494,29 @@ var init_service = __esm({
|
|
|
6392
6494
|
const id = LowerCrockfordUlidSchema.parse(roomId);
|
|
6393
6495
|
return this.lock(id, async () => this.recoverPacketUnlocked(id, await this.store.load(id)));
|
|
6394
6496
|
}
|
|
6497
|
+
/** Canonical operator recovery for an established room identity lease. */
|
|
6498
|
+
async rebindIdentity(roomId) {
|
|
6499
|
+
const id = LowerCrockfordUlidSchema.parse(roomId);
|
|
6500
|
+
let room = await this.store.load(id);
|
|
6501
|
+
if (room.state === "closed" || room.state === "closing") {
|
|
6502
|
+
throw new RoomServiceError(`cannot rebind room "${id}" while it is ${room.state}`);
|
|
6503
|
+
}
|
|
6504
|
+
if (this.isPacketPending(room) || !this.packets.get(id)) {
|
|
6505
|
+
room = await this.recoverPacket(id);
|
|
6506
|
+
} else {
|
|
6507
|
+
if (!this.packets.rebind) throw new RoomServiceError("room packet registry does not support explicit rebind");
|
|
6508
|
+
await this.packets.rebind(id);
|
|
6509
|
+
}
|
|
6510
|
+
await this.reconcileRoom(id);
|
|
6511
|
+
await this.resumePending(id);
|
|
6512
|
+
return {
|
|
6513
|
+
room_id: id,
|
|
6514
|
+
identity_name: room.identity_name,
|
|
6515
|
+
identity_cid: room.identity_cid,
|
|
6516
|
+
status: "rebound",
|
|
6517
|
+
fanout: "resumed"
|
|
6518
|
+
};
|
|
6519
|
+
}
|
|
6395
6520
|
async recoverPacketUnlocked(id, initial) {
|
|
6396
6521
|
let room = initial;
|
|
6397
6522
|
if (room.state === "closed") return room;
|
|
@@ -7074,6 +7199,11 @@ var init_service = __esm({
|
|
|
7074
7199
|
if (room.state === "closed") {
|
|
7075
7200
|
return this.store.save(room);
|
|
7076
7201
|
}
|
|
7202
|
+
if (this.isPacketPending(room)) {
|
|
7203
|
+
throw new RoomServiceError(
|
|
7204
|
+
`cannot close room "${id}" while its identity CID is unproven; run room rebind first, or verify and remove a colliding orphan identity before retrying recovery`
|
|
7205
|
+
);
|
|
7206
|
+
}
|
|
7077
7207
|
if (room.state === "closing") {
|
|
7078
7208
|
room = await this.store.save(room);
|
|
7079
7209
|
} else {
|
|
@@ -8765,6 +8895,14 @@ var init_openapi = __esm({
|
|
|
8765
8895
|
result: "The updated room record with the confirmed recovery.",
|
|
8766
8896
|
example: { room_id: EXAMPLE_ROOM_ID, recovery_of: "inv-01", invite_id: "inv-02" }
|
|
8767
8897
|
},
|
|
8898
|
+
{
|
|
8899
|
+
method: "room.rebind",
|
|
8900
|
+
summary: "Rebind an established room identity",
|
|
8901
|
+
description: "Safely restores the exact persisted room identity with a non-force lease claim and CID proof, then resumes durable room fanout. It never creates or renames an identity.",
|
|
8902
|
+
params: params({ room_id: roomIdProperty }, ["room_id"]),
|
|
8903
|
+
result: "A structured rebind receipt after identity proof, reconciliation, and pending fanout resumption.",
|
|
8904
|
+
example: { room_id: EXAMPLE_ROOM_ID }
|
|
8905
|
+
},
|
|
8768
8906
|
{
|
|
8769
8907
|
method: "room.list",
|
|
8770
8908
|
summary: "List rooms",
|
|
@@ -9298,6 +9436,7 @@ function createServiceRoutes(service) {
|
|
|
9298
9436
|
const value = RecoverConfirmParams.parse(params2);
|
|
9299
9437
|
return service.confirmRecoveredInvite(value.room_id, value.recovery_of, value.invite_id);
|
|
9300
9438
|
} },
|
|
9439
|
+
"room.rebind": { auth: true, run: (params2) => service.rebindIdentity(RoomIdParams.parse(params2).room_id) },
|
|
9301
9440
|
"room.list": { auth: true, run: (params2) => {
|
|
9302
9441
|
external_exports.object({}).strict().parse(params2);
|
|
9303
9442
|
return service.listRooms();
|
|
@@ -10330,21 +10469,43 @@ var init_daemon_runtime = __esm({
|
|
|
10330
10469
|
const rooms = await this.store.list();
|
|
10331
10470
|
this.checkpoint();
|
|
10332
10471
|
const recoverable = rooms.filter((room) => room.state !== "closed");
|
|
10472
|
+
const healthy = new Set(recoverable.map((room) => room.room_id));
|
|
10473
|
+
const recoverPhase = async (roomId, phase, work) => {
|
|
10474
|
+
if (!healthy.has(roomId)) return;
|
|
10475
|
+
try {
|
|
10476
|
+
await work();
|
|
10477
|
+
this.checkpoint();
|
|
10478
|
+
} catch (error) {
|
|
10479
|
+
healthy.delete(roomId);
|
|
10480
|
+
const unhost = this.registry?.unhost;
|
|
10481
|
+
if (unhost) {
|
|
10482
|
+
await unhost.call(this.registry, roomId).catch((unhostError) => {
|
|
10483
|
+
this.options.log?.(JSON.stringify({
|
|
10484
|
+
event: "startup_room_unhost_failed",
|
|
10485
|
+
room_id: roomId,
|
|
10486
|
+
error: unhostError instanceof Error ? unhostError.message : String(unhostError)
|
|
10487
|
+
}));
|
|
10488
|
+
});
|
|
10489
|
+
}
|
|
10490
|
+
this.options.log?.(JSON.stringify({
|
|
10491
|
+
event: "startup_room_recovery_failed",
|
|
10492
|
+
room_id: roomId,
|
|
10493
|
+
phase,
|
|
10494
|
+
error: error instanceof Error ? error.message : String(error)
|
|
10495
|
+
}));
|
|
10496
|
+
}
|
|
10497
|
+
};
|
|
10333
10498
|
for (const room of recoverable) {
|
|
10334
|
-
await this.service.recoverPacket(room.room_id);
|
|
10335
|
-
this.checkpoint();
|
|
10499
|
+
await recoverPhase(room.room_id, "restore", () => this.service.recoverPacket(room.room_id));
|
|
10336
10500
|
}
|
|
10337
10501
|
for (const room of recoverable.filter((candidate) => candidate.state !== "closing")) {
|
|
10338
|
-
await this.service.reconcileRoom(room.room_id);
|
|
10339
|
-
this.checkpoint();
|
|
10502
|
+
await recoverPhase(room.room_id, "reconcile", () => this.service.reconcileRoom(room.room_id));
|
|
10340
10503
|
}
|
|
10341
10504
|
for (const room of recoverable.filter((candidate) => candidate.state === "closing")) {
|
|
10342
|
-
await this.service.closeRoom(room.room_id);
|
|
10343
|
-
this.checkpoint();
|
|
10505
|
+
await recoverPhase(room.room_id, "close", () => this.service.closeRoom(room.room_id));
|
|
10344
10506
|
}
|
|
10345
10507
|
for (const room of recoverable.filter((candidate) => candidate.state !== "closing")) {
|
|
10346
|
-
await this.service.resumePending(room.room_id);
|
|
10347
|
-
this.checkpoint();
|
|
10508
|
+
await recoverPhase(room.room_id, "fanout", () => this.service.resumePending(room.room_id));
|
|
10348
10509
|
}
|
|
10349
10510
|
const realService = this.service;
|
|
10350
10511
|
const serviceRoutes = createServiceRoutes(realService);
|