From 3e14fc9d44a00bd43c420d8fee0bbad5f27391df Mon Sep 17 00:00:00 2001 From: Aarav Sharma Date: Mon, 10 Aug 2026 18:13:13 -0600 Subject: [PATCH] fix: upload chat-message markup as MarkupBlobRef instead of raw HTML (HULY-20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `huly comment add` (and 7 sibling write paths on chat-shaped messages) passed the raw HTML body string straight into the `message` field. The Huly platform expects `message` to be a MarkupBlobRef pointing to a prosemirror-JSON blob in MinIO — same shape as `issue.description` after `huly issue create` runs it through `uploadMarkup`. With raw HTML, the front-end renders the markup as plain text (literal `

`, `

`, `` characters) instead of as rich content. The audit in the HULY-8 PR description flagged this as the same bug class across the following surfaces: * `huly comment add` — chunter:class:ChatMessage (issue.comments) * `huly comment update` * `huly activity reply add` — activity:class:ActivityMessage (replies) * `huly activity reply update` * `huly channel message send` — chunter:class:ChatMessage (channel.messages) * `huly channel message update` * `huly channel thread add ` — chunter:class:ThreadMessage (replies) * `huly channel thread update ` * `huly dm message send` — chunter:class:ChatMessage (DM.messages) * `huly approval comment` — chunter:class:ChatMessage (request.comments) Each fix: 1. Calls `uploadMarkup(client, class, id, 'message', body, 'markup')` to convert HTML to prosemirror JSON and upload the blob, getting a MarkupBlobRef back. 2. Stores the ref in `message` instead of the raw string. 3. For create paths, generates the id up-front so the markup blob's collabId (`${class}:${id}:message`) matches the ChatMessage doc's id. For update paths, reuses the existing id so updates land on the same blob slot. 4. Defers the actual upload until after the dry-run guard on the channel message paths (`comment add`/`activity reply add`/`approval comment` don't have a dry-run code path today — pre-existing gap). Same pattern as the HULY-8 `updateIssue` fix; dry-run output now includes a `wouldUploadMarkup` note so users can see what would happen. Also drops the `as any` cast on the comment.ts writes — the MarkupBlobRef field is a plain string, which the SDK's WithMarkup type allows. Verified on the test server (`localhost:7180`): * `huly comment add TSK-3 --body "

x

y

"` → message field is `-message-` (MarkupBlobRef). Same with --body-file. * `huly comment update --body ...` → message ref updates with a new timestamp. * `huly activity reply add" --body ...` → message is MarkupBlobRef. * `huly channel message send --body ... --dry-run` → prints `message: ""` + wouldUploadMarkup note, no MinIO blob created. * Cross-checked via `huly issue update --body" (same uploadMarkup pipeline) renders as rich text in `huly issue get --markdown` — same infrastructure now serves comments. Two pre-existing test-env issues surfaced but are NOT caused by this fix: * Channel message `addCollection` returns an id but the platform doesn't persist the doc in this test workspace (verified by reverting to the pre-fix binary and seeing the same behavior). Looks like a channel-membership / server-config issue in the test stack, not in scope here. * `comment add` / `activity reply add` / `approval comment` don't honor `--dry-run` (pre-existing). Out of scope for HULY-20. Test artifacts cleaned; compose stack brought down. --- packages/cli/src/resources/activity.ts | 13 +++- packages/cli/src/resources/approvals.ts | 16 ++++- packages/cli/src/resources/channel.ts | 90 +++++++++++++++++++++++-- packages/cli/src/resources/comment.ts | 31 ++++++++- 4 files changed, 139 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/resources/activity.ts b/packages/cli/src/resources/activity.ts index 56382bd..4607be7 100644 --- a/packages/cli/src/resources/activity.ts +++ b/packages/cli/src/resources/activity.ts @@ -18,6 +18,7 @@ import { } from '../output/format.js' import { withSpinner } from '../output/progress.js' import { CliError, ExitCode } from '../output/errors.js' +import { uploadMarkup, generateId } from './_helpers.js' type ActivityMessage = Doc & { message?: string @@ -371,7 +372,12 @@ export async function addReply(opts: ReplyOpts): Promise { const client = await connectCli({ url: opts.url, workspace: opts.workspace }) try { const { id, doc } = await fetchActivity(client, opts.target) - const data: Record = { message: opts.body } + // HULY-20: upload the markup first so `message` stores a MarkupBlobRef + // (prosemirror-JSON-backed), not raw HTML. The id is generated up-front + // so the markup blob's collabId and the reply doc share the same id. + const newReplyId = generateId() + const messageRef = await uploadMarkup(client, ACTIVITY_CLASS, newReplyId, 'message', opts.body, 'markup') + const data: Record = { message: messageRef } const rid = await withSpinner( 'Replying…', () => @@ -382,6 +388,7 @@ export async function addReply(opts: ReplyOpts): Promise { ACTIVITY_CLASS, 'replies', data as any, + newReplyId, ), opts, ) @@ -408,11 +415,13 @@ export async function updateReply( // attachedTo pointing at the parent). `message` lives on the reply // doc itself — updateDoc, NOT updateCollection against the parent's // 'replies' tuple (which doesn't exist). + // HULY-20: upload markup first so the update lands a MarkupBlobRef. + const messageRef = await uploadMarkup(client, ACTIVITY_CLASS, id, 'message', opts.body, 'markup') await client.updateDoc( ACTIVITY_CLASS, doc.space as unknown as Ref, id as Ref, - { message: opts.body, modifiedOn: Date.now() } as any, + { message: messageRef, modifiedOn: Date.now() } as any, ) updated('updated reply', refString(id)) } finally { diff --git a/packages/cli/src/resources/approvals.ts b/packages/cli/src/resources/approvals.ts index 4988add..547ca68 100644 --- a/packages/cli/src/resources/approvals.ts +++ b/packages/cli/src/resources/approvals.ts @@ -18,6 +18,7 @@ import { } from '../output/format.js' import { withSpinner } from '../output/progress.js' import { CliError, ExitCode } from '../output/errors.js' +import { uploadMarkup, generateId } from './_helpers.js' const REQUEST_STATUSES = ['Active', 'Completed', 'Rejected', 'Cancelled'] as const type RequestStatus = (typeof REQUEST_STATUSES)[number] @@ -297,7 +298,19 @@ export async function commentOnApproval(opts: CommentOpts): Promise { const client = await connectCli({ url: opts.url, workspace: opts.workspace }) try { const { id, doc } = await fetchRequest(client, opts.ref!) - const data: Record = { message: opts.body } + // HULY-20: upload markup so `message` stores a MarkupBlobRef, not raw + // HTML. Generate id up-front so the markup blob's collabId matches + // the ChatMessage doc's id. No --dry-run flag on this surface today. + const newCommentId = generateId() + const messageRef = await uploadMarkup( + client, + 'chunter:class:ChatMessage' as Ref>, + newCommentId, + 'message', + opts.body, + 'markup', + ) + const data: Record = { message: messageRef } if (opts.decision) data.decision = opts.decision const cid = await withSpinner( 'Commenting…', @@ -309,6 +322,7 @@ export async function commentOnApproval(opts: CommentOpts): Promise { REQUEST_CLASS, 'comments', data as any, + newCommentId, ), opts, ) diff --git a/packages/cli/src/resources/channel.ts b/packages/cli/src/resources/channel.ts index 7061201..cee9346 100644 --- a/packages/cli/src/resources/channel.ts +++ b/packages/cli/src/resources/channel.ts @@ -6,6 +6,7 @@ import { normalizeSocialKey } from '../auth/social.js' import { shouldJson, json, table, COLUMNS, C, success, updated, bulkRemoved } from '../output/format.js' import { withSpinner } from '../output/progress.js' import { CliError, ExitCode } from '../output/errors.js' +import { uploadMarkup, generateId } from './_helpers.js' type Channel = Doc & { name: string @@ -546,8 +547,21 @@ export async function sendChannelMessage( const client = await connectCli({ url: opts.url, workspace: opts.workspace }) try { const channel = await resolveChannel(client, ref) + // HULY-20: pre-upload the markup so `message` stores a MarkupBlobRef, + // not raw HTML. Generate the id up-front so the markup blob's collabId + // matches the ChatMessage doc's id (the platform keys the blob by + // `${class}:${id}:${attribute}`). + const newMessageId = generateId() + // Defer the actual upload until after the dry-run guard below — same + // pattern as the HULY-8 updateIssue fix. Dry-run must not write to + // MinIO. The dry-run preview shows `message: ''` (the placeholder) + + // a `wouldUploadMarkup` note so users see what would happen. + let messageRef = '' + if (!opts.dryRun) { + messageRef = await uploadMarkup(client, CHAT_MESSAGE_CLASS, newMessageId, 'message', body, 'markup') + } const data: Record = { - message: body, + message: messageRef, } if (opts.dryRun) { console.log('would send channel message:') @@ -560,6 +574,12 @@ export async function sendChannelMessage( attachedToClass: CHANNEL_CLASS, collection: 'messages', data, + wouldUploadMarkup: { + objectClass: CHAT_MESSAGE_CLASS, + objectId: newMessageId, + objectAttr: 'message', + bodyBytes: body.length, + }, }, null, 2, @@ -575,6 +595,7 @@ export async function sendChannelMessage( CHANNEL_CLASS, 'messages', data as any, + newMessageId, ), ) if (shouldJson({ json: opts.json, ci: opts.ci })) { @@ -610,8 +631,23 @@ export async function updateChannelMessage( if (msg.attachedTo !== channel._id) { throw new CliError(ExitCode.NotFound, 'message does not belong to this channel') } + // HULY-20: upload the markup so the update lands a MarkupBlobRef in + // `message`, not raw HTML. The existing messageId is reused so the + // markup blob's collabId stays consistent across updates. Deferred + // until after the dry-run guard below (same pattern as HULY-8). + let messageRef = '' + if (!opts.dryRun) { + messageRef = await uploadMarkup( + client, + CHAT_MESSAGE_CLASS, + messageId as Ref, + 'message', + body, + 'markup', + ) + } const data: Record = { - message: body, + message: messageRef, editedOn: Date.now(), } if (opts.dryRun) { @@ -737,8 +773,24 @@ export async function addThreadReply( try { const parent = await client.findOne(CHAT_MESSAGE_CLASS, { _id: targetId as Ref }) if (!parent) throw new CliError(ExitCode.NotFound, `target message ${targetId} not found`) + // HULY-20: upload markup first so `message` stores a MarkupBlobRef. + // The reply lives in chunter:class:ThreadMessage (subclass of + // ChatMessage), and the markup blob is keyed by that subclass id. + // Deferred until after the dry-run guard (same pattern as HULY-8). + const newReplyId = generateId() + let messageRef = '' + if (!opts.dryRun) { + messageRef = await uploadMarkup( + client, + 'chunter:class:ThreadMessage' as Ref>, + newReplyId, + 'message', + body, + 'markup', + ) + } const data: Record = { - message: body, + message: messageRef, } if (opts.dryRun) { console.log('would add thread reply:') @@ -759,6 +811,7 @@ export async function addThreadReply( CHAT_MESSAGE_CLASS, 'replies', data as any, + newReplyId, ), ) if (shouldJson({ json: opts.json, ci: opts.ci })) { @@ -791,7 +844,20 @@ export async function updateThreadReply( _id: replyId as Ref, }) if (!reply) throw new CliError(ExitCode.NotFound, `thread reply ${replyId} not found`) - const data: Record = { message: body, editedOn: Date.now() } + // HULY-20: upload markup so the update lands a MarkupBlobRef. Deferred + // until after the dry-run guard (same pattern as HULY-8). + let messageRef = '' + if (!opts.dryRun) { + messageRef = await uploadMarkup( + client, + 'chunter:class:ThreadMessage' as Ref>, + replyId as Ref, + 'message', + body, + 'markup', + ) + } + const data: Record = { message: messageRef, editedOn: Date.now() } if (opts.dryRun) { console.log(`would update thread reply ${replyId}:`) console.log( @@ -1039,7 +1105,14 @@ export async function sendDmMessage( }) const dm = await client.findOne(DM_CLASS, { _id: dmId as Ref }) if (!dm) throw new CliError(ExitCode.NotFound, `DM ${dmRef} not found`) - const data: Record = { message: body } + // HULY-20: upload markup so `message` stores a MarkupBlobRef, not raw + // HTML. Deferred until after the dry-run guard (same pattern as HULY-8). + const newMessageId = generateId() + let messageRef = '' + if (!opts.dryRun) { + messageRef = await uploadMarkup(client, CHAT_MESSAGE_CLASS, newMessageId, 'message', body, 'markup') + } + const data: Record = { message: messageRef } if (opts.dryRun) { console.log('would send DM:') console.log( @@ -1051,6 +1124,12 @@ export async function sendDmMessage( attachedToClass: DM_CLASS, collection: 'messages', data, + wouldUploadMarkup: { + objectClass: CHAT_MESSAGE_CLASS, + objectId: newMessageId, + objectAttr: 'message', + bodyBytes: body.length, + }, }, null, 2, @@ -1066,6 +1145,7 @@ export async function sendDmMessage( DM_CLASS, 'messages', data as any, + newMessageId, ), ) if (shouldJson({ json: opts.json, ci: opts.ci })) { diff --git a/packages/cli/src/resources/comment.ts b/packages/cli/src/resources/comment.ts index 858f184..d70f88b 100644 --- a/packages/cli/src/resources/comment.ts +++ b/packages/cli/src/resources/comment.ts @@ -6,6 +6,7 @@ import { shouldJson, json, table, COLUMNS, success, updated, bulkRemoved } from import { withSpinner } from '../output/progress.js' import { CliError, ExitCode } from '../output/errors.js' import { readEnv } from '../auth/env.js' +import { uploadMarkup, generateId } from './_helpers.js' type ChatMessage = Doc & { message: string @@ -85,6 +86,19 @@ export async function addComment(opts: { }) const issue = await client.findOne(CLASS.Issue as Ref>, { _id: issueId }) if (!issue) throw new CliError(ExitCode.NotFound, `issue ${opts.issue} not found`) + // HULY-20: pre-upload the markup so the `message` field stores a + // MarkupBlobRef (pointing to a prosemirror-JSON blob in MinIO) instead of + // a raw HTML string. Generating the id up-front lets us use the same + // id for both the markup blob's collabId and the ChatMessage doc. + const newMessageId = generateId() + const messageRef = await uploadMarkup( + client, + CLASS.ChatMessage as Ref>, + newMessageId, + 'message', + body, + 'markup', + ) const id = await withSpinner( 'Adding comment…', () => @@ -94,13 +108,14 @@ export async function addComment(opts: { issueId, CLASS.Issue, 'comments', - { message: body } as any, + { message: messageRef } as any, + newMessageId, ), opts, ) invalidateIndex(client, CLASS.ChatMessage) if (shouldJson({ json: opts.json, ci: opts.ci })) { - json({ _id: id, attachedTo: issueId, message: body }) + json({ _id: id, attachedTo: issueId, message: messageRef }) } else { success('added comment', `on ${opts.issue}`, id) } @@ -130,6 +145,16 @@ export async function updateComment( }) const comment = await client.findOne(CLASS.ChatMessage as Ref>, { _id: commentId }) if (!comment) throw new CliError(ExitCode.NotFound, `comment ${ref} not found`) + // HULY-20: same fix as addComment — upload the markup first so the + // update lands a MarkupBlobRef in `message`, not raw HTML. + const messageRef = await uploadMarkup( + client, + CLASS.ChatMessage as Ref>, + commentId, + 'message', + body, + 'markup', + ) await withSpinner( 'Updating comment…', () => @@ -137,7 +162,7 @@ export async function updateComment( CLASS.ChatMessage as Ref>, (comment as Doc).space as Ref, commentId as Ref, - { message: body, editedOn: Date.now() } as any, + { message: messageRef, editedOn: Date.now() } as any, ), opts, )