@ours.network/cowork 1.3.0 → 1.3.1
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/dist/cli.js +3 -2
- package/dist/daemon.js +55 -14
- package/docs/05-room-workflow.md +14 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -4562,6 +4562,7 @@ var SeatSchema = external_exports.object({
|
|
|
4562
4562
|
participant_id: LowerCrockfordUlidSchema,
|
|
4563
4563
|
state: SeatStateSchema,
|
|
4564
4564
|
alias: NonEmptyStringSchema.optional(),
|
|
4565
|
+
removal_reason: external_exports.literal("contact_absent").optional(),
|
|
4565
4566
|
removed_at: Rfc3339Schema.optional(),
|
|
4566
4567
|
removed_epoch: external_exports.number().int().nonnegative().safe().optional(),
|
|
4567
4568
|
// Read and discard prerelease successor lineage. New rooms expose only
|
|
@@ -4575,7 +4576,7 @@ var SeatSchema = external_exports.object({
|
|
|
4575
4576
|
context.addIssue({ code: external_exports.ZodIssueCode.custom, path: [field], message: `pending seats require ${field}` });
|
|
4576
4577
|
}
|
|
4577
4578
|
}
|
|
4578
|
-
for (const field of ["accepted_at", "removed_at", "removed_epoch", "bounced_at"]) {
|
|
4579
|
+
for (const field of ["accepted_at", "removed_at", "removed_epoch", "bounced_at", "removal_reason"]) {
|
|
4579
4580
|
if (seat[field] !== void 0) {
|
|
4580
4581
|
context.addIssue({ code: external_exports.ZodIssueCode.custom, path: [field], message: `${field} is forbidden on pending seats` });
|
|
4581
4582
|
}
|
|
@@ -4606,7 +4607,7 @@ var SeatSchema = external_exports.object({
|
|
|
4606
4607
|
if (seat.accepted_at === void 0) {
|
|
4607
4608
|
context.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["accepted_at"], message: "active seats require accepted_at" });
|
|
4608
4609
|
}
|
|
4609
|
-
for (const field of ["removed_at", "removed_epoch", "bounced_at"]) {
|
|
4610
|
+
for (const field of ["removed_at", "removed_epoch", "bounced_at", "removal_reason"]) {
|
|
4610
4611
|
if (seat[field] !== void 0) {
|
|
4611
4612
|
context.addIssue({
|
|
4612
4613
|
code: external_exports.ZodIssueCode.custom,
|
package/dist/daemon.js
CHANGED
|
@@ -11594,7 +11594,7 @@ var init_ours_runtime = __esm({
|
|
|
11594
11594
|
}
|
|
11595
11595
|
for (let settled = await step; !settled.done; settled = await step) {
|
|
11596
11596
|
backoffMs = WATCH_RETRY_MIN_MS;
|
|
11597
|
-
this.announce(identityName);
|
|
11597
|
+
this.announce(identityName, settled.value);
|
|
11598
11598
|
step = stream.next();
|
|
11599
11599
|
}
|
|
11600
11600
|
if (!signal.aborted) {
|
|
@@ -11611,10 +11611,10 @@ var init_ours_runtime = __esm({
|
|
|
11611
11611
|
backoffMs = Math.min(backoffMs * 2, WATCH_RETRY_MAX_MS);
|
|
11612
11612
|
}
|
|
11613
11613
|
}
|
|
11614
|
-
announce(identityName) {
|
|
11614
|
+
announce(identityName, event) {
|
|
11615
11615
|
for (const listener of this.listeners) {
|
|
11616
11616
|
try {
|
|
11617
|
-
listener(identityName);
|
|
11617
|
+
listener(identityName, event);
|
|
11618
11618
|
} catch (error) {
|
|
11619
11619
|
this.log(`cowork SDK notification listener failed for ${identityName}:`, error);
|
|
11620
11620
|
}
|
|
@@ -12168,6 +12168,7 @@ var init_contracts = __esm({
|
|
|
12168
12168
|
participant_id: LowerCrockfordUlidSchema,
|
|
12169
12169
|
state: SeatStateSchema,
|
|
12170
12170
|
alias: NonEmptyStringSchema.optional(),
|
|
12171
|
+
removal_reason: external_exports.literal("contact_absent").optional(),
|
|
12171
12172
|
removed_at: Rfc3339Schema.optional(),
|
|
12172
12173
|
removed_epoch: external_exports.number().int().nonnegative().safe().optional(),
|
|
12173
12174
|
// Read and discard prerelease successor lineage. New rooms expose only
|
|
@@ -12181,7 +12182,7 @@ var init_contracts = __esm({
|
|
|
12181
12182
|
context.addIssue({ code: external_exports.ZodIssueCode.custom, path: [field], message: `pending seats require ${field}` });
|
|
12182
12183
|
}
|
|
12183
12184
|
}
|
|
12184
|
-
for (const field of ["accepted_at", "removed_at", "removed_epoch", "bounced_at"]) {
|
|
12185
|
+
for (const field of ["accepted_at", "removed_at", "removed_epoch", "bounced_at", "removal_reason"]) {
|
|
12185
12186
|
if (seat[field] !== void 0) {
|
|
12186
12187
|
context.addIssue({ code: external_exports.ZodIssueCode.custom, path: [field], message: `${field} is forbidden on pending seats` });
|
|
12187
12188
|
}
|
|
@@ -12212,7 +12213,7 @@ var init_contracts = __esm({
|
|
|
12212
12213
|
if (seat.accepted_at === void 0) {
|
|
12213
12214
|
context.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["accepted_at"], message: "active seats require accepted_at" });
|
|
12214
12215
|
}
|
|
12215
|
-
for (const field of ["removed_at", "removed_epoch", "bounced_at"]) {
|
|
12216
|
+
for (const field of ["removed_at", "removed_epoch", "bounced_at", "removal_reason"]) {
|
|
12216
12217
|
if (seat[field] !== void 0) {
|
|
12217
12218
|
context.addIssue({
|
|
12218
12219
|
code: external_exports.ZodIssueCode.custom,
|
|
@@ -13790,12 +13791,13 @@ var init_packets = __esm({
|
|
|
13790
13791
|
});
|
|
13791
13792
|
this.rebindSleep = options.rebindSleep ?? ((ms) => new Promise((resolve3) => setTimeout(resolve3, ms)));
|
|
13792
13793
|
this.rebindRandom = options.rebindRandom ?? Math.random;
|
|
13793
|
-
this.unsubscribe = host.onIdentityNotify((name) => {
|
|
13794
|
+
this.unsubscribe = host.onIdentityNotify((name, event) => {
|
|
13794
13795
|
const found = [...this.packets.entries()].find(([, packet2]) => packet2.name === name);
|
|
13795
13796
|
if (!found) return;
|
|
13796
13797
|
const [roomId, packet] = found;
|
|
13798
|
+
if (event?.event === "contact_removed" && event.identity_cid !== packet.cid) return;
|
|
13797
13799
|
void packet.refresh().then(
|
|
13798
|
-
() => this.onNotify(roomId, "message_received"),
|
|
13800
|
+
() => this.onNotify(roomId, event?.event === "contact_removed" ? "contact_removed" : "message_received"),
|
|
13799
13801
|
(error) => this.log(`[${name}] failed to refresh SDK state after notification:`, error)
|
|
13800
13802
|
);
|
|
13801
13803
|
});
|
|
@@ -15043,6 +15045,17 @@ var init_intake = __esm({
|
|
|
15043
15045
|
const room = await this.store.load(roomId);
|
|
15044
15046
|
const activeCids = new Set(room.seats.filter((seat) => seat.state === "active").map((seat) => seat.identity));
|
|
15045
15047
|
const removedCids = new Set(room.seats.filter((seat) => seat.state === "removed").map((seat) => seat.identity));
|
|
15048
|
+
let firstDeliveryError;
|
|
15049
|
+
let deliveryFailed = false;
|
|
15050
|
+
const attempt = async (send) => {
|
|
15051
|
+
try {
|
|
15052
|
+
return await send();
|
|
15053
|
+
} catch (error) {
|
|
15054
|
+
if (!deliveryFailed) firstDeliveryError = error;
|
|
15055
|
+
deliveryFailed = true;
|
|
15056
|
+
return void 0;
|
|
15057
|
+
}
|
|
15058
|
+
};
|
|
15046
15059
|
let after = 0;
|
|
15047
15060
|
for (; ; ) {
|
|
15048
15061
|
const pending = await queryStore(this.store, roomId, {
|
|
@@ -15051,7 +15064,10 @@ var init_intake = __esm({
|
|
|
15051
15064
|
after,
|
|
15052
15065
|
limit: JOURNAL_WORK_BATCH_SIZE
|
|
15053
15066
|
});
|
|
15054
|
-
if (pending.length === 0)
|
|
15067
|
+
if (pending.length === 0) {
|
|
15068
|
+
if (deliveryFailed) throw firstDeliveryError;
|
|
15069
|
+
return;
|
|
15070
|
+
}
|
|
15055
15071
|
for (const intent of pending) {
|
|
15056
15072
|
after = intent.seq;
|
|
15057
15073
|
const [message] = intent.message_id === void 0 ? [] : await queryStore(this.store, roomId, { kind: "message", messageId: intent.message_id, limit: 1 });
|
|
@@ -15098,7 +15114,7 @@ var init_intake = __esm({
|
|
|
15098
15114
|
const replyTo = decision.replyTo;
|
|
15099
15115
|
if (file !== void 0) {
|
|
15100
15116
|
const uploader = file.author_alias?.alias ?? file.author.display_name;
|
|
15101
|
-
const notice = await sendRoomBody(packet, intent.recipient_identity, {
|
|
15117
|
+
const notice = await attempt(() => sendRoomBody(packet, intent.recipient_identity, {
|
|
15102
15118
|
version: 1,
|
|
15103
15119
|
kind: "room_msg",
|
|
15104
15120
|
room_id: roomId,
|
|
@@ -15111,7 +15127,8 @@ var init_intake = __esm({
|
|
|
15111
15127
|
},
|
|
15112
15128
|
text: `${uploader} sent a file`,
|
|
15113
15129
|
at: file.at
|
|
15114
|
-
}, replyTo);
|
|
15130
|
+
}, replyTo));
|
|
15131
|
+
if (notice === void 0) continue;
|
|
15115
15132
|
if (notice.status === "send_failed") {
|
|
15116
15133
|
const failed = await this.store.append(roomId, {
|
|
15117
15134
|
version: 1,
|
|
@@ -15126,13 +15143,14 @@ var init_intake = __esm({
|
|
|
15126
15143
|
if (failed.kind !== "relay_result") throw new Error("storage returned the wrong relay result kind");
|
|
15127
15144
|
continue;
|
|
15128
15145
|
}
|
|
15129
|
-
const outcome2 = await packet.sendFile(
|
|
15146
|
+
const outcome2 = await attempt(() => packet.sendFile(
|
|
15130
15147
|
intent.recipient_identity,
|
|
15131
15148
|
file.filename,
|
|
15132
15149
|
file.mime,
|
|
15133
15150
|
Buffer.from(file.data_base64, "base64"),
|
|
15134
15151
|
replyTo
|
|
15135
|
-
);
|
|
15152
|
+
));
|
|
15153
|
+
if (outcome2 === void 0) continue;
|
|
15136
15154
|
const appended2 = await this.store.append(roomId, {
|
|
15137
15155
|
version: 1,
|
|
15138
15156
|
kind: "relay_result",
|
|
@@ -15167,7 +15185,8 @@ var init_intake = __esm({
|
|
|
15167
15185
|
...message.briefing_version === void 0 ? {} : { briefing_version: message.briefing_version },
|
|
15168
15186
|
...message.membership === void 0 ? {} : { membership: message.membership }
|
|
15169
15187
|
};
|
|
15170
|
-
const outcome = await sendRoomBody(packet, intent.recipient_identity, unsigned, replyTo);
|
|
15188
|
+
const outcome = await attempt(() => sendRoomBody(packet, intent.recipient_identity, unsigned, replyTo));
|
|
15189
|
+
if (outcome === void 0) continue;
|
|
15171
15190
|
const appended = await this.store.append(roomId, {
|
|
15172
15191
|
version: 1,
|
|
15173
15192
|
kind: "relay_result",
|
|
@@ -16992,6 +17011,7 @@ var init_service = __esm({
|
|
|
16992
17011
|
}
|
|
16993
17012
|
async reconcileUnlocked(room, packet) {
|
|
16994
17013
|
if (room.state === "closed" || room.state === "closing") return room;
|
|
17014
|
+
await packet.refreshContacts();
|
|
16995
17015
|
const contactsByCid = /* @__PURE__ */ new Map();
|
|
16996
17016
|
for (const contact of packet.listContacts()) {
|
|
16997
17017
|
if (contactsByCid.has(contact.container_id)) continue;
|
|
@@ -17001,6 +17021,27 @@ var init_service = __esm({
|
|
|
17001
17021
|
...authenticatedInvite === void 0 ? {} : { inviteId: authenticatedInvite }
|
|
17002
17022
|
});
|
|
17003
17023
|
}
|
|
17024
|
+
let epoch = room.membership_epoch;
|
|
17025
|
+
const departed = /* @__PURE__ */ new Set();
|
|
17026
|
+
const reconciledSeats = room.seats.map((seat) => {
|
|
17027
|
+
if (seat.state !== "active" || contactsByCid.has(seat.identity)) return seat;
|
|
17028
|
+
departed.add(seat.identity);
|
|
17029
|
+
return {
|
|
17030
|
+
...seat,
|
|
17031
|
+
state: "removed",
|
|
17032
|
+
removed_at: this.now(),
|
|
17033
|
+
removed_epoch: ++epoch,
|
|
17034
|
+
removal_reason: "contact_absent"
|
|
17035
|
+
};
|
|
17036
|
+
});
|
|
17037
|
+
if (departed.size > 0) {
|
|
17038
|
+
room = await this.store.save(RoomSchema.parse({
|
|
17039
|
+
...room,
|
|
17040
|
+
seats: reconciledSeats,
|
|
17041
|
+
membership_epoch: epoch,
|
|
17042
|
+
command_grants: room.command_grants.filter((grant) => !departed.has(grant.caller_cid))
|
|
17043
|
+
}));
|
|
17044
|
+
}
|
|
17004
17045
|
const inviteById = new Map(room.invites.map((invite) => [invite.invite_id, invite]));
|
|
17005
17046
|
const existingCids = new Set(room.seats.filter((seat) => seat.state === "active" || seat.state === "pending").map((seat) => seat.identity));
|
|
17006
17047
|
const lastRemovedAt = /* @__PURE__ */ new Map();
|
|
@@ -18994,7 +19035,7 @@ import * as nodeFs5 from "node:fs";
|
|
|
18994
19035
|
import { join as join7 } from "node:path";
|
|
18995
19036
|
import { fileURLToPath } from "node:url";
|
|
18996
19037
|
function isIntakeNotification(event) {
|
|
18997
|
-
return event === "message_received" || event === "file_received" || event === "contact_accepted" || event === "contact_added";
|
|
19038
|
+
return event === "message_received" || event === "file_received" || event === "contact_accepted" || event === "contact_added" || event === "contact_removed";
|
|
18998
19039
|
}
|
|
18999
19040
|
function createDaemonControlRoutes(control) {
|
|
19000
19041
|
if (!/^[0-9a-f]{32}$/.test(control.session)) throw new TypeError("invalid daemon control session");
|
package/docs/05-room-workflow.md
CHANGED
|
@@ -115,3 +115,17 @@ Use the RPC arguments without `room_id`; the receiving room fixes the target. Fo
|
|
|
115
115
|
Host lifecycle and global room creation/listing are excluded. `room.accept` accepts invitation input through its separate grant and remains unavailable through REST. Ours close/delete return a durable accepted receipt before closing the reply channel; verify completion through management. Close retains archive/files; delete requires `confirm:true`, closes first, and erases local room data. Pending lifecycle requests resume after restart; failed requests remain visible in room metadata for explicit management retry. Missing replies do not prove a mutation failed.
|
|
116
116
|
|
|
117
117
|
The SDK owns command reply delivery and its size limits. Large history pages or a single large file record may exceed that transport's capacity; use CLI/REST for those results. No additional result-omission contract is introduced.
|
|
118
|
+
|
|
119
|
+
## Contact deletion and room membership
|
|
120
|
+
|
|
121
|
+
Cowork subscribes to SDK notifications for each hosted room identity. A typed `contact_removed` with a different local identity CID is ignored, even when the identity name matches. The room packet refreshes state and forwards the wake. Under the room mutex, reconciliation refreshes contacts again and removes only active seats whose exact peer CID is absent. The same path runs on startup, watch reconnection and the existing periodic resync, covering missed notifications and a crash between core deletion and room persistence.
|
|
122
|
+
|
|
123
|
+
A departed seat stays in durable room metadata with its participant ID, CID, role, accepted_at, invite provenance, removed_at, removed_epoch and `removal_reason: 'contact_absent'`. Each transition increments membership_epoch once and revokes that CID's explicit command grants. Accepted invite history remains accepted: a departure is not an invitation/admission failure. Pending asynchronous admissions without contacts stay pending. Removal reason deliberately does not claim peer intent: contact absence can also follow local removal or crash recovery. This persisted lifecycle record remains available even in quiet rooms and when nobody remains to receive a membership message.
|
|
124
|
+
|
|
125
|
+
Duplicate wakes and restarts do not change an already-removed seat's epoch or timestamp. An old event with a currently present/re-added contact cannot remove it. No attempt is made to infer an unobserved remove-and-readd cycle from a surviving contact snapshot. Each room identity reconciles its own contact book; deleting a contact in one room does not authorize removal in another where that contact still exists.
|
|
126
|
+
|
|
127
|
+
Future broadcasts use active membership; pending relays already addressed to departed participants use the existing `skipped_removed` outcome. If a contact disappears during delivery, independent recipients are still attempted (text, file metadata and file bytes, including later journal batches). A thrown send remains result-less and is retried under existing recovery semantics; the first error is reported after other recipients have been attempted. Storage errors stop immediately. No durable append receipt or relay outcome schema was changed. This boundary was coordinated with backlog task `0mtzfmkqra63687cd`, which remains owner of durable receipts and per-recipient outcome design.
|
|
128
|
+
|
|
129
|
+
### Rollout
|
|
130
|
+
|
|
131
|
+
No core protocol or room-version migration is required. Existing version-2 rooms load with optional `removal_reason` absent; reconciliation fills it only on newly observed contact departures. Older Cowork versions strip this optional field on parse/save and do not perform automatic reconciliation; retain the upgraded version to preserve reason metadata. Admission and removal fields use the existing room lifecycle schema. An older SDK daemon supplies a legacy/sync wake; authoritative periodic reconciliation still works. The enriched typed event requires the accompanying SDK daemon change. No dependency bump to an unpublished package is made by this patch; release the SDK enhancement before or alongside Cowork.
|