@openmarket/rooms-client 0.1.0
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/LICENSE +201 -0
- package/README.md +25 -0
- package/dist/agent/armed-mode.d.ts +187 -0
- package/dist/agent/armed-mode.js +306 -0
- package/dist/agent/clients/common.d.ts +70 -0
- package/dist/agent/clients/common.js +46 -0
- package/dist/agent/lane-draft.d.ts +15 -0
- package/dist/agent/lane-draft.js +43 -0
- package/dist/agent/lane-prompts.d.ts +102 -0
- package/dist/agent/lane-prompts.js +120 -0
- package/dist/agent/library-context.d.ts +38 -0
- package/dist/agent/library-context.js +57 -0
- package/dist/agent/library-model.d.ts +41 -0
- package/dist/agent/library-model.js +173 -0
- package/dist/agent/room-context.d.ts +151 -0
- package/dist/agent/room-context.js +251 -0
- package/dist/agent/sidebar-model.d.ts +86 -0
- package/dist/agent/sidebar-model.js +162 -0
- package/dist/agent/topic-inbox.d.ts +119 -0
- package/dist/agent/topic-inbox.js +266 -0
- package/dist/agent/topic-view-batcher.d.ts +54 -0
- package/dist/agent/topic-view-batcher.js +115 -0
- package/dist/client.d.ts +421 -0
- package/dist/client.js +1428 -0
- package/dist/doc-reconcile.d.ts +100 -0
- package/dist/doc-reconcile.js +110 -0
- package/dist/doc-uri.d.ts +29 -0
- package/dist/doc-uri.js +55 -0
- package/dist/endpoints.d.ts +9 -0
- package/dist/endpoints.js +13 -0
- package/dist/jwt.d.ts +10 -0
- package/dist/jwt.js +51 -0
- package/dist/library-publisher.d.ts +52 -0
- package/dist/library-publisher.js +49 -0
- package/dist/merge3.d.ts +50 -0
- package/dist/merge3.js +280 -0
- package/dist/names.d.ts +6 -0
- package/dist/names.js +35 -0
- package/dist/shared/emoji-data.d.ts +22 -0
- package/dist/shared/emoji-data.js +8834 -0
- package/dist/shared/mentions.d.ts +6 -0
- package/dist/shared/mentions.js +32 -0
- package/dist/shared/rooms-protocol.d.ts +1183 -0
- package/dist/shared/rooms-protocol.js +160 -0
- package/dist/social-client.d.ts +361 -0
- package/dist/social-client.js +686 -0
- package/dist/social-types.d.ts +338 -0
- package/dist/social-types.js +1 -0
- package/dist/suggestion-attribution.d.ts +10 -0
- package/dist/suggestion-attribution.js +56 -0
- package/dist/topic-uri.d.ts +26 -0
- package/dist/topic-uri.js +40 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.js +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/dist/ws-client.d.ts +115 -0
- package/dist/ws-client.js +491 -0
- package/package.json +180 -0
- package/src/agent/armed-mode.ts +368 -0
- package/src/agent/clients/common.ts +91 -0
- package/src/agent/lane-draft.ts +47 -0
- package/src/agent/lane-prompts.ts +267 -0
- package/src/agent/library-context.ts +97 -0
- package/src/agent/library-model.ts +210 -0
- package/src/agent/room-context.ts +351 -0
- package/src/agent/sidebar-model.ts +235 -0
- package/src/agent/topic-inbox.ts +297 -0
- package/src/agent/topic-view-batcher.ts +134 -0
- package/src/client.ts +2331 -0
- package/src/doc-reconcile.ts +160 -0
- package/src/doc-uri.ts +83 -0
- package/src/endpoints.ts +14 -0
- package/src/jwt.ts +59 -0
- package/src/library-publisher.ts +93 -0
- package/src/merge3.ts +326 -0
- package/src/names.ts +44 -0
- package/src/shared/emoji-data.ts +8868 -0
- package/src/shared/mentions.ts +32 -0
- package/src/shared/rooms-protocol.ts +1339 -0
- package/src/social-client.ts +1287 -0
- package/src/social-types.ts +376 -0
- package/src/suggestion-attribution.ts +83 -0
- package/src/topic-uri.ts +64 -0
- package/src/types.ts +83 -0
- package/src/version.ts +2 -0
- package/src/ws-client.ts +611 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE client reconcile model (phase 4 P4-RECONCILE). Every pending
|
|
3
|
+
* reconcile, whatever produced it, is the same shape: three full contents
|
|
4
|
+
* (base / mine / theirs), the merge3 regions where they genuinely diverge,
|
|
5
|
+
* and the server head rev the merge was computed against. Three sources
|
|
6
|
+
* feed it:
|
|
7
|
+
*
|
|
8
|
+
* - "publish-conflict": two working copies published; the second writer's
|
|
9
|
+
* publishDoc came back {status:"conflict"}. mine = the draft, theirs =
|
|
10
|
+
* the server head, base = the published content at the draft's baseRev.
|
|
11
|
+
* - "disk-edit": the operator's on-disk mirror copy collided with a landed
|
|
12
|
+
* head (the library conflict path re-pointed at this model). mine = the
|
|
13
|
+
* local file, theirs = the server head, base = the receipt revision.
|
|
14
|
+
* - "run-revert": undoing an agent run diverged from later edits. mine =
|
|
15
|
+
* the CURRENT head, theirs = the pre-run content the revert restores,
|
|
16
|
+
* base = the run's final content (tharamine returns all three).
|
|
17
|
+
*
|
|
18
|
+
* Resolution always rides an expected-head fence, never a blind write:
|
|
19
|
+
* publish-conflict and run-revert resolutions publish through the one
|
|
20
|
+
* checkpoint primitive CAS'd on `headRev`; disk-edit resolutions ride the
|
|
21
|
+
* existing applyResolution path with `expectedHeadRev: headRev`. Either
|
|
22
|
+
* way a head that moved after mediation returns conflict/stale DATA and
|
|
23
|
+
* the model refreshes via `refreshDocReconcile`; nothing is overwritten
|
|
24
|
+
* and no draft is lost.
|
|
25
|
+
*
|
|
26
|
+
* Pure module (merge3 + types only), browser-safe by construction: the
|
|
27
|
+
* chat GUI builds and resolves models client-side.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { type Merge3Region, type MergeSkeletonPart, merge3 } from "./merge3.js";
|
|
31
|
+
|
|
32
|
+
/** What produced a pending reconcile (labels and application route differ). */
|
|
33
|
+
export type DocReconcileKind = "publish-conflict" | "disk-edit" | "run-revert";
|
|
34
|
+
|
|
35
|
+
/** A per-region resolution: keep my side or take theirs. */
|
|
36
|
+
export type DocRegionPick = "mine" | "theirs";
|
|
37
|
+
|
|
38
|
+
export interface DocReconcileInput {
|
|
39
|
+
kind: DocReconcileKind;
|
|
40
|
+
docId: string;
|
|
41
|
+
path: string;
|
|
42
|
+
/**
|
|
43
|
+
* The shared ancestor of the two sides: the draft's baseRev content
|
|
44
|
+
* (publish-conflict), the mirror receipt revision (disk-edit), or the
|
|
45
|
+
* run's final content (run-revert). "" when unavailable: merge3 then
|
|
46
|
+
* degrades to one whole-document region, the side-by-side floor.
|
|
47
|
+
*/
|
|
48
|
+
base: string;
|
|
49
|
+
/** My side: the working copy / local file, or the current head (run-revert). */
|
|
50
|
+
mine: string;
|
|
51
|
+
/** Their side: the server head, or the pre-run content (run-revert). */
|
|
52
|
+
theirs: string;
|
|
53
|
+
/** The server head rev the merge was computed against: the resolution fence. */
|
|
54
|
+
headRev: number;
|
|
55
|
+
/** Head author/note for the screen's explainer line (when known). */
|
|
56
|
+
theirsAuthor?: string | undefined;
|
|
57
|
+
theirsNote?: string | undefined;
|
|
58
|
+
/** run-revert: the run being undone. */
|
|
59
|
+
runId?: string | undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface DocReconcileModel extends DocReconcileInput {
|
|
63
|
+
/** Diverging chunks (empty exactly when the merge is clean). */
|
|
64
|
+
regions: Merge3Region[];
|
|
65
|
+
/** Splice skeleton; null when the merge is clean (nothing to splice). */
|
|
66
|
+
skeleton: MergeSkeletonPart[] | null;
|
|
67
|
+
/** True when the two sides merge without mediation. */
|
|
68
|
+
clean: boolean;
|
|
69
|
+
/** The clean merge result ("" while regions exist). */
|
|
70
|
+
merged: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Which side of the model tracks the SERVER HEAD (the side that goes stale
|
|
75
|
+
* when the fence fails). For a run revert the head is "mine": the revert
|
|
76
|
+
* merges the pre-run content onto whatever the doc says now.
|
|
77
|
+
*/
|
|
78
|
+
export function docReconcileHeadSide(kind: DocReconcileKind): DocRegionPick {
|
|
79
|
+
return kind === "run-revert" ? "mine" : "theirs";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Build the model: one merge3 pass over the three contents. */
|
|
83
|
+
export function buildDocReconcile(input: DocReconcileInput): DocReconcileModel {
|
|
84
|
+
const result = merge3(input.base, input.mine, input.theirs);
|
|
85
|
+
return {
|
|
86
|
+
...input,
|
|
87
|
+
regions: result.regions,
|
|
88
|
+
skeleton: result.skeleton ?? null,
|
|
89
|
+
clean: result.clean,
|
|
90
|
+
merged: result.merged,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Turn per-region picks into the full resolved document. A clean model
|
|
96
|
+
* needs no picks (the merge IS the resolution); a diverging model requires
|
|
97
|
+
* exactly one pick per region, spliced through the merge3 skeleton so
|
|
98
|
+
* untouched text is never regenerated.
|
|
99
|
+
*
|
|
100
|
+
* The splice carries the picked LINE ARRAYS directly, never a joined
|
|
101
|
+
* string: spliceSkeleton's "" resolution means "drop the region" (a
|
|
102
|
+
* contract pinned for its other callers), so a picked side of [""] (one
|
|
103
|
+
* empty line, e.g. a separator blanked in place) would vanish through the
|
|
104
|
+
* string form. Here an empty array contributes nothing and [""] contributes
|
|
105
|
+
* exactly one empty line, so the applied document IS the picked bytes.
|
|
106
|
+
*/
|
|
107
|
+
export function resolveDocReconcile(model: DocReconcileModel, picks: DocRegionPick[]): string {
|
|
108
|
+
if (model.clean) return model.merged;
|
|
109
|
+
if (!model.skeleton) throw new Error("doc-reconcile: diverging model without a skeleton");
|
|
110
|
+
if (picks.length !== model.regions.length) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`doc-reconcile: ${model.regions.length} regions need ${model.regions.length} picks, got ${picks.length}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
for (const [index, pick] of picks.entries()) {
|
|
116
|
+
if (pick !== "mine" && pick !== "theirs") {
|
|
117
|
+
throw new Error(`doc-reconcile: invalid pick for region ${index}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const lines: string[] = [];
|
|
121
|
+
for (const part of model.skeleton) {
|
|
122
|
+
if (part.kind === "lines") {
|
|
123
|
+
lines.push(...part.lines);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const region = model.regions[part.index];
|
|
127
|
+
const pick = picks[part.index];
|
|
128
|
+
if (!region || pick === undefined) {
|
|
129
|
+
throw new Error(`doc-reconcile: missing resolution for region ${part.index}`);
|
|
130
|
+
}
|
|
131
|
+
lines.push(...region[pick]);
|
|
132
|
+
}
|
|
133
|
+
return lines.join("\n");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Fence failure: the server head moved after the model was built. Re-point
|
|
138
|
+
* the head-tracking side at the fresh head and re-merge; every other side
|
|
139
|
+
* (the draft, the local file, the pre-run target) is carried over verbatim,
|
|
140
|
+
* so nothing of mine is lost by a refresh.
|
|
141
|
+
*/
|
|
142
|
+
export function refreshDocReconcile(
|
|
143
|
+
model: DocReconcileModel,
|
|
144
|
+
head: { rev: number; content: string; authorHandle?: string; note?: string },
|
|
145
|
+
): DocReconcileModel {
|
|
146
|
+
const side = docReconcileHeadSide(model.kind);
|
|
147
|
+
return buildDocReconcile({
|
|
148
|
+
kind: model.kind,
|
|
149
|
+
docId: model.docId,
|
|
150
|
+
path: model.path,
|
|
151
|
+
base: model.base,
|
|
152
|
+
mine: side === "mine" ? head.content : model.mine,
|
|
153
|
+
theirs: side === "theirs" ? head.content : model.theirs,
|
|
154
|
+
headRev: head.rev,
|
|
155
|
+
theirsAuthor:
|
|
156
|
+
side === "theirs" ? (head.authorHandle ?? model.theirsAuthor) : model.theirsAuthor,
|
|
157
|
+
theirsNote: side === "theirs" ? (head.note ?? model.theirsNote) : model.theirsNote,
|
|
158
|
+
runId: model.runId,
|
|
159
|
+
});
|
|
160
|
+
}
|
package/src/doc-uri.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
|
|
3
|
+
import type { RoomDoc } from "./social-types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* om://doc URIs — how docs travel through chat.
|
|
7
|
+
*
|
|
8
|
+
* A share is plain text on the wire (`om://doc/<docId>@<rev>`), so it
|
|
9
|
+
* survives every transport (rooms, DMs, copy-paste, other clients) with no
|
|
10
|
+
* protocol changes. Clients that understand the scheme render it as a pill
|
|
11
|
+
* and can annotate liveness ("head moved") from their local library cache;
|
|
12
|
+
* clients that don't still show a usable, resolvable reference.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface DocUriRef {
|
|
16
|
+
docId: string;
|
|
17
|
+
rev?: number | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DOC_URI_RE = /om:\/\/doc\/([A-Za-z0-9-]{8,64})(?:@(\d{1,9}))?/g;
|
|
21
|
+
|
|
22
|
+
export function makeDocUri(docId: string, rev?: number): string {
|
|
23
|
+
return `om://doc/${docId}${rev !== undefined ? `@${rev}` : ""}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function parseDocUris(text: string): DocUriRef[] {
|
|
27
|
+
const refs: DocUriRef[] = [];
|
|
28
|
+
for (const match of text.matchAll(DOC_URI_RE)) {
|
|
29
|
+
const docId = match[1];
|
|
30
|
+
if (!docId) continue;
|
|
31
|
+
const rev = match[2] !== undefined ? Number.parseInt(match[2], 10) : undefined;
|
|
32
|
+
refs.push({ docId, ...(rev !== undefined ? { rev } : {}) });
|
|
33
|
+
}
|
|
34
|
+
return refs;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface DocUriRenderContext {
|
|
38
|
+
/** Resolve a docId to its cached head (path + headRev), when known. */
|
|
39
|
+
lookup?: ((docId: string) => Pick<RoomDoc, "path" | "headRev"> | undefined) | undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Process-wide lookup used by the tape renderers. The chat TUI registers its
|
|
44
|
+
* LibraryModel here once; every renderer (room tape, DMs, the legacy REPL)
|
|
45
|
+
* then annotates pills with live head receipts without threading the cache
|
|
46
|
+
* through a dozen render signatures. Renderers work unregistered too — pills
|
|
47
|
+
* just lose the path/receipt annotation.
|
|
48
|
+
*/
|
|
49
|
+
let activeLookup: DocUriRenderContext["lookup"];
|
|
50
|
+
|
|
51
|
+
export function setDocUriLookup(lookup: DocUriRenderContext["lookup"]): void {
|
|
52
|
+
activeLookup = lookup;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function decorateDocUrisLive(text: string): string {
|
|
56
|
+
if (!text.includes("om://doc/")) return text;
|
|
57
|
+
return decorateDocUris(text, { lookup: activeLookup });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Replace om://doc URIs with a rendered pill. With a library cache lookup the
|
|
62
|
+
* pill shows the doc's path and a head receipt (`@r3` vs `@r3→r5` when the
|
|
63
|
+
* head has moved past the shared revision); without one it stays a compact
|
|
64
|
+
* reference that is still visibly a doc link.
|
|
65
|
+
*/
|
|
66
|
+
export function decorateDocUris(text: string, context: DocUriRenderContext = {}): string {
|
|
67
|
+
return text.replace(DOC_URI_RE, (_raw, docId: string, revRaw: string | undefined) => {
|
|
68
|
+
const rev = revRaw !== undefined ? Number.parseInt(revRaw, 10) : undefined;
|
|
69
|
+
const known = context.lookup?.(docId);
|
|
70
|
+
if (!known) {
|
|
71
|
+
const revLabel = rev !== undefined ? pc.dim(`@r${rev}`) : "";
|
|
72
|
+
return `${pc.cyan(`⧉ doc:${docId.slice(0, 8)}`)}${revLabel}`;
|
|
73
|
+
}
|
|
74
|
+
const moved = rev !== undefined && known.headRev > rev;
|
|
75
|
+
const revLabel =
|
|
76
|
+
rev === undefined
|
|
77
|
+
? pc.dim(`@r${known.headRev}`)
|
|
78
|
+
: moved
|
|
79
|
+
? pc.yellow(`@r${rev}→r${known.headRev}`)
|
|
80
|
+
: pc.dim(`@r${rev}`);
|
|
81
|
+
return `${pc.cyan(`⧉ ${known.path}`)}${revLabel}`;
|
|
82
|
+
});
|
|
83
|
+
}
|
package/src/endpoints.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default production endpoints, in their own module so the browser-importable
|
|
3
|
+
* client graph never touches constants.ts (which reads node:os/path for
|
|
4
|
+
* OM_HOME). Env overrides live in envString(), which no-ops in browsers.
|
|
5
|
+
*/
|
|
6
|
+
export const ROOMS_WS_URL = "wss://chat.kiyotaka.ai/ws/rooms";
|
|
7
|
+
export const ROOM_CHAT_API_URL = "https://chat.kiyotaka.ai/chat";
|
|
8
|
+
|
|
9
|
+
/** process.env reader that is safe in browsers (returns undefined). */
|
|
10
|
+
export function envString(name: string): string | undefined {
|
|
11
|
+
const proc = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process;
|
|
12
|
+
const value = proc?.env?.[name];
|
|
13
|
+
return typeof value === "string" ? value : undefined;
|
|
14
|
+
}
|
package/src/jwt.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Global crypto/atob: works in Bun, Node 18+, and browsers.
|
|
2
|
+
const randomUUID = (): string => globalThis.crypto.randomUUID();
|
|
3
|
+
|
|
4
|
+
import { type RoomAuthSuccessPayload, SUPPORTED_ROOM_PROTOCOL_VERSION } from "./types.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Derive a room auth identity from an OM API key (a JWT) for the HTTP fallback
|
|
8
|
+
* path, where there is no server AUTH_SUCCESS frame. The claims are read
|
|
9
|
+
* best-effort; an unparseable key yields a synthesized placeholder identity
|
|
10
|
+
* rather than throwing.
|
|
11
|
+
*/
|
|
12
|
+
export function authFromApiKey(apiKey: string): RoomAuthSuccessPayload {
|
|
13
|
+
const payload = decodeJwtPayload(apiKey);
|
|
14
|
+
const userId =
|
|
15
|
+
stringClaim(payload, "sub") || stringClaim(payload, "id") || stringClaim(payload, "jti") || "";
|
|
16
|
+
const username =
|
|
17
|
+
stringClaim(payload, "username") ||
|
|
18
|
+
stringClaim(payload, "handle") ||
|
|
19
|
+
(userId ? `om-${userId.slice(-6)}` : "om-user");
|
|
20
|
+
return {
|
|
21
|
+
userId,
|
|
22
|
+
username,
|
|
23
|
+
handle: username,
|
|
24
|
+
connectionId: `http-${randomUUID()}`,
|
|
25
|
+
protocolVersion: SUPPORTED_ROOM_PROTOCOL_VERSION,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function decodeJwtPayload(apiKey: string): Record<string, unknown> {
|
|
30
|
+
const [, payload] = apiKey.split(".");
|
|
31
|
+
if (!payload) return {};
|
|
32
|
+
try {
|
|
33
|
+
const normalized = payload.replace(/-/g, "+").replace(/_/g, "/");
|
|
34
|
+
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
|
35
|
+
const parsed = JSON.parse(base64Utf8(padded)) as unknown;
|
|
36
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
37
|
+
? (parsed as Record<string, unknown>)
|
|
38
|
+
: {};
|
|
39
|
+
} catch {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function stringClaim(payload: Record<string, unknown>, key: string): string | undefined {
|
|
45
|
+
const value = payload[key];
|
|
46
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function base64Utf8(value: string): string {
|
|
50
|
+
const nodeBuffer = (
|
|
51
|
+
globalThis as {
|
|
52
|
+
Buffer?: { from(data: string, encoding: string): { toString(encoding: string): string } };
|
|
53
|
+
}
|
|
54
|
+
).Buffer;
|
|
55
|
+
if (nodeBuffer) return nodeBuffer.from(value, "base64").toString("utf8");
|
|
56
|
+
const binary = atob(value);
|
|
57
|
+
const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
|
|
58
|
+
return new TextDecoder().decode(bytes);
|
|
59
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The checkpoint publish primitive (phase 4 spine). Every surface that turns
|
|
3
|
+
* a private working copy into a shared revision (GUI editor, agent proposals,
|
|
4
|
+
* run undo, and eventually the disk mirror) publishes through `publishDoc`
|
|
5
|
+
* and nothing else.
|
|
6
|
+
*
|
|
7
|
+
* Contract (binding for the later work packages):
|
|
8
|
+
* - One write path. `publishDoc` routes through the existing `updateRoomDoc`
|
|
9
|
+
* CAS call, which lands in tharamine's `commitContentChange` transaction.
|
|
10
|
+
* There is no second writer here and none may be added: new publish
|
|
11
|
+
* behavior extends this function, not the wire.
|
|
12
|
+
* - Publishing from a stale base never overwrites. A moved head comes back
|
|
13
|
+
* as `{status: "conflict"}` DATA carrying the server's current head
|
|
14
|
+
* (rev + content when available) so the reconcile surface can mediate;
|
|
15
|
+
* it is never a thrown string. Non-conflict transport/HTTP failures still
|
|
16
|
+
* throw (`RoomSocialError`), because they are errors, not outcomes.
|
|
17
|
+
* - Every publish is attributed. `source` defaults to "gui" (the working-copy
|
|
18
|
+
* editor is the primary caller); agent, mirror, and CLI callers pass their
|
|
19
|
+
* own source plus `credentialId`/`runId` when they have them. Absent rider
|
|
20
|
+
* fields stay absent on the revision.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { RoomDocConflictError, updateRoomDoc } from "./social-client.js";
|
|
24
|
+
import type {
|
|
25
|
+
RoomDoc,
|
|
26
|
+
RoomDocAttribution,
|
|
27
|
+
RoomDocConflictHead,
|
|
28
|
+
RoomDocRevisionSource,
|
|
29
|
+
RoomSocialConfig,
|
|
30
|
+
} from "./social-types.js";
|
|
31
|
+
|
|
32
|
+
export interface PublishDocInput extends RoomDocAttribution {
|
|
33
|
+
docId: string;
|
|
34
|
+
/** The published revision the working copy started from (the CAS base). */
|
|
35
|
+
baseRev: number;
|
|
36
|
+
/** The full document to land as the checkpoint. */
|
|
37
|
+
content: string;
|
|
38
|
+
/** Optional human change note (absent = server auto-derives "+A -R lines"). */
|
|
39
|
+
note?: string | undefined;
|
|
40
|
+
/** Display flag: the write was authored by an agent acting as the operator. */
|
|
41
|
+
asAgent?: boolean | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type PublishDocResult =
|
|
45
|
+
| {
|
|
46
|
+
status: "published";
|
|
47
|
+
doc: RoomDoc;
|
|
48
|
+
/** The revision the checkpoint landed as. */
|
|
49
|
+
rev: number;
|
|
50
|
+
/** True when the content matched the head byte-for-byte (no revision). */
|
|
51
|
+
unchanged: boolean;
|
|
52
|
+
}
|
|
53
|
+
| {
|
|
54
|
+
status: "conflict";
|
|
55
|
+
/** The stale base the caller published from. */
|
|
56
|
+
baseRev: number;
|
|
57
|
+
/**
|
|
58
|
+
* The server's current head (content included) for the mediation
|
|
59
|
+
* surface. Null only when even the fallback head re-fetch failed.
|
|
60
|
+
*/
|
|
61
|
+
head: RoomDocConflictHead | null;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const DEFAULT_SOURCE: RoomDocRevisionSource = "gui";
|
|
65
|
+
|
|
66
|
+
/** Publish a working copy as one attributed revision, CAS'd on `baseRev`. */
|
|
67
|
+
export async function publishDoc(
|
|
68
|
+
config: RoomSocialConfig,
|
|
69
|
+
input: PublishDocInput,
|
|
70
|
+
): Promise<PublishDocResult> {
|
|
71
|
+
try {
|
|
72
|
+
const result = await updateRoomDoc(config, input.docId, {
|
|
73
|
+
baseRev: input.baseRev,
|
|
74
|
+
content: input.content,
|
|
75
|
+
source: input.source ?? DEFAULT_SOURCE,
|
|
76
|
+
...(input.note !== undefined ? { note: input.note } : {}),
|
|
77
|
+
...(input.asAgent !== undefined ? { asAgent: input.asAgent } : {}),
|
|
78
|
+
...(input.credentialId !== undefined ? { credentialId: input.credentialId } : {}),
|
|
79
|
+
...(input.runId !== undefined ? { runId: input.runId } : {}),
|
|
80
|
+
});
|
|
81
|
+
return {
|
|
82
|
+
status: "published",
|
|
83
|
+
doc: result.doc,
|
|
84
|
+
rev: result.rev,
|
|
85
|
+
unchanged: result.unchanged,
|
|
86
|
+
};
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (error instanceof RoomDocConflictError) {
|
|
89
|
+
return { status: "conflict", baseRev: input.baseRev, head: error.head };
|
|
90
|
+
}
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|