From d7533ed8653919801a1e5c7be3572faab46256cd Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Thu, 8 Oct 2026 14:09:12 +0700 Subject: [PATCH 1/2] fix(widget-dev): read a newer node's session view without its unknown top-level fields, and say what was left out An older desktop app connected to a newer node on another machine refused the whole widget dev session view when the node added an optional field such as stopCode. The client now reads the view with readNodeView: unknown top-level fields are dropped and never passed on, the status line and the develop card say the node is newer, every known field keeps its bounds, and nested objects carrying activation, approval and reach state stay strict. Requests and the node's own schema stay strict. Fixes #673 --- docs/open-interfaces.md | 19 +++-- docs/open-interfaces.vi.md | 20 +++-- packages/contracts/src/index.ts | 1 + packages/contracts/src/node-view-read.ts | 38 +++++++++ packages/contracts/src/widget-dev-session.ts | 6 +- .../contracts/test/node-view-read.spec.ts | 77 +++++++++++++++++++ packages/conversation-client/src/api.ts | 30 ++++++-- .../src/i18n/messages-shell.ts | 2 + .../src/use-block-actions.ts | 7 +- .../src/widget-dev-status.tsx | 12 ++- .../test/widget-dev-session-read.spec.ts | 54 +++++++++++++ 11 files changed, 241 insertions(+), 25 deletions(-) create mode 100644 packages/contracts/src/node-view-read.ts create mode 100644 packages/contracts/test/node-view-read.spec.ts create mode 100644 packages/conversation-client/test/widget-dev-session-read.spec.ts diff --git a/docs/open-interfaces.md b/docs/open-interfaces.md index bfda15f5..9856a484 100644 --- a/docs/open-interfaces.md +++ b/docs/open-interfaces.md @@ -897,6 +897,14 @@ and shown in the conversation in the production widget frame. Shapes are `widget | `POST /widget-dev/sessions/:id/place` `{ "conversationId", "widgetId"? }` | Place the running widget in a conversation. | | `POST /widget-dev/chosen-folders/forget` `{ "root" }` | Take back the person's choice of a folder (`widgetDevFolderForgetSchema`): it no longer lets Clark start sessions in it, or in the folders inside it. Answers `{ root, forgotten, stillCoveredBy? }`; `forgotten: false` when the folder was not chosen, so pressing twice is harmless. `stillCoveredBy` names a folder Clark may still develop in that holds this one (another chosen folder, or a `workspace.roots` value the person recorded), so Clark keeps access there until that one goes too. Sessions and what they run stay as they are. Person-only. | +**Reading a session view.** A desktop app and a node on another machine are updated separately, so the app reads a +session view (`readNodeView` in `packages/contracts/src/node-view-read.ts`) tolerantly: a top-level field it does not +know, sent by a node newer than the app, is left out and never passed on, and the status line beside the widget and +the `develop` card say the node is newer and that some of what it said is not shown. Every field the app knows keeps +its bounds. A field it does not know inside `activation`, `latest`, `running`, `lastBuild` or `placed` still refuses +the view, because those objects carry activation, approval and reach state the app must not act on in part. Requests +to the node, and the sessions the node writes and stores, stay strict. + Refusals: `400 ROOT_NOT_ABSOLUTE`, `400 ROOT_NOT_A_FOLDER`, `404 ROOT_NOT_FOUND`, `403 ROOT_UNREADABLE`, `404 CONVERSATION_NOT_FOUND`, `404 SESSION_NOT_FOUND`, `409 TOO_MANY_SESSIONS`, `409 NOT_ACTIVE`, `400 NO_SUCH_WIDGET`, `409 NOT_PLACED` and `503 WIDGET_DEV_UNAVAILABLE`: @@ -1031,11 +1039,12 @@ reading as live: folder and develops it from there. `stopCode` is optional. A session stopped before nodes kept it has none, and a code the client does not know gets - the reason alone, with advice that holds for every case. The client parses the session view with a strict schema, so - it refuses a view with a field it does not know, such as `stopCode` sent to a client older than the field. Keep a - desktop app on the same build as the node it connects to, including a node on another machine; letting the client - read such views while leaving unknown fields out is tracked in issue #673. The code is what the check found at that - restart and is not checked again while the session stays stopped; a start checks the folder as it is then. + the reason alone, with advice that holds for every case. A top-level field newer than the app is left out and the + app says the node is newer (see **Reading a session view** above). An app built before that tolerant read, which + includes every app older than `stopCode`, refuses the whole view instead, and any app refuses a view with a newer + field inside `activation`, `latest`, `running`, `lastBuild` or `placed`; only for those cases, keep the desktop app + on the same build as the node it connects to, including a node on another machine. The code is what the check found at that restart and is not + checked again while the session stays stopped; a start checks the folder as it is then. **Downgrade.** `sessions.json` is read with a strict schema too. A build older than a field the store holds, such as `stopCode` or `chosenFolderId`, finds the whole file does not match, moves it aside as diff --git a/docs/open-interfaces.vi.md b/docs/open-interfaces.vi.md index 5eac4a29..8366854b 100644 --- a/docs/open-interfaces.vi.md +++ b/docs/open-interfaces.vi.md @@ -902,6 +902,14 @@ cuộc hội thoại bằng frame widget dùng trong bản chính thức. Dạng | `POST /widget-dev/sessions/:id/place` `{ "conversationId", "widgetId"? }` | Đặt widget đang chạy vào một cuộc hội thoại. | | `POST /widget-dev/chosen-folders/forget` `{ "root" }` | Thu hồi lựa chọn một thư mục của người dùng (`widgetDevFolderForgetSchema`): lựa chọn đó không còn cho Clark bắt đầu phiên trong thư mục đó, hay trong các thư mục bên trong nó, nữa. Trả `{ root, forgotten, stillCoveredBy? }`; `forgotten: false` khi thư mục chưa được chọn, nên bấm hai lần cũng không sao. `stillCoveredBy` nêu một thư mục Clark vẫn được phát triển và chứa thư mục này (một thư mục khác đã chọn, hoặc một giá trị `workspace.roots` người dùng đã ghi), nên Clark vẫn có quyền ở đó cho tới khi thư mục đó cũng bị thu hồi. Các phiên và những gì chúng chạy vẫn giữ nguyên. Chỉ người dùng được gọi. | +**Đọc view của phiên.** Ứng dụng desktop và một node trên máy khác được cập nhật riêng, nên ứng dụng đọc view của phiên +(`readNodeView` trong `packages/contracts/src/node-view-read.ts`) một cách khoan dung: một trường ở cấp trên cùng mà +nó không biết, do một node mới hơn ứng dụng gửi, bị bỏ qua và không bao giờ được chuyển tiếp, còn dòng trạng thái cạnh +widget và thẻ `develop` nói rằng node mới hơn và một số điều node gửi không được hiển thị. Mọi trường ứng dụng biết vẫn +giữ nguyên các giới hạn của nó. Một trường lạ nằm trong `activation`, `latest`, `running`, `lastBuild` hoặc `placed` +vẫn làm view bị từ chối, vì các đối tượng đó mang trạng thái kích hoạt, phê duyệt và phạm vi truy cập mà ứng dụng +không được hành động dựa trên một phần của chúng. Các yêu cầu gửi tới node, và các phiên node ghi và lưu, vẫn chặt. + Các lần từ chối: `400 ROOT_NOT_ABSOLUTE`, `400 ROOT_NOT_A_FOLDER`, `404 ROOT_NOT_FOUND`, `403 ROOT_UNREADABLE`, `404 CONVERSATION_NOT_FOUND`, `404 SESSION_NOT_FOUND`, `409 TOO_MANY_SESSIONS`, `409 NOT_ACTIVE`, `400 NO_SUCH_WIDGET`, `409 NOT_PLACED` và `503 WIDGET_DEV_UNAVAILABLE`: @@ -1040,11 +1048,13 @@ vẫn hiện là đang chạy: dự án khác trên máy rồi phát triển từ đó. `stopCode` không bắt buộc. Một phiên dừng từ trước khi node giữ trường này thì không có nó, và một mã client không - biết chỉ được nói bằng lý do, kèm lời khuyên đúng cho mọi trường hợp. Client đọc view của phiên bằng một schema chặt, - nên nó từ chối một view có trường nó không biết, chẳng hạn `stopCode` gửi tới một client cũ hơn trường đó. Hãy giữ - ứng dụng desktop ở cùng bản dựng với node mà nó kết nối tới, kể cả một node trên máy khác; việc cho client đọc các - view như vậy mà bỏ qua trường lạ được theo dõi ở issue #673. Mã này là điều bước kiểm tìm thấy ở lần khởi động lại đó - và không được kiểm lại khi phiên vẫn dừng; một lần bắt đầu kiểm thư mục như nó đang có lúc ấy. + biết chỉ được nói bằng lý do, kèm lời khuyên đúng cho mọi trường hợp. Một trường ở cấp trên cùng mới hơn ứng dụng thì + bị bỏ qua và ứng dụng nói rằng node mới hơn (xem **Đọc view của phiên** ở trên). Một ứng dụng dựng trước cách đọc + khoan dung đó, bao gồm mọi ứng dụng cũ hơn `stopCode`, thì từ chối cả view, và mọi ứng dụng đều từ chối một view có + trường mới hơn nằm trong `activation`, `latest`, `running`, `lastBuild` hoặc `placed`; chỉ trong những trường hợp đó, + hãy giữ ứng dụng desktop ở cùng bản dựng với node mà nó kết nối tới, kể cả một node trên máy khác. Mã này là điều + bước kiểm tìm thấy ở lần khởi động lại đó và không được kiểm lại khi phiên vẫn dừng; một lần bắt đầu kiểm thư mục như + nó đang có lúc ấy. **Hạ phiên bản.** `sessions.json` cũng được đọc bằng một schema chặt. Một bản dựng cũ hơn một trường mà kho đang giữ, chẳng hạn `stopCode` hay `chosenFolderId`, thấy cả tệp không khớp, chuyển nó sang diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 7bb230df..cf1914e3 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -26,6 +26,7 @@ export * from "./tasks.ts"; export * from "./effects.ts"; export * from "./install.ts"; export * from "./directory.ts"; +export * from "./node-view-read.ts"; export * from "./widgets.ts"; export * from "./widget-state.ts"; export * from "./widget-perform.ts"; diff --git a/packages/contracts/src/node-view-read.ts b/packages/contracts/src/node-view-read.ts new file mode 100644 index 00000000..3d905589 --- /dev/null +++ b/packages/contracts/src/node-view-read.ts @@ -0,0 +1,38 @@ +import type { z } from "zod"; + +import { UNREAD_FIELD_PATH_PATTERN, unreadListingFields, type UnreadEntryFields, type UnreadListingFields } from "./directory.ts"; + +/** + * Read a view a node answered with the way a client reads it: tolerant of top-level fields it does not know, strict + * about every field it does. + * + * A desktop app may talk to a node on another machine, and the two are updated separately. A view gains optional fields + * over time (the widget dev session view gained `stopCode`), and refusing the whole answer for a field this client has + * never heard of made an older client lose the view altogether, not just the field. So a top-level field outside the + * schema is dropped and reported in `unreadFields`; it is never passed on, so nothing downstream carries a value nobody + * validated. Every known field is still checked against the schema with all its bounds, and every object inside a known + * field stays as strict as the schema says: those carry approval, reach and activation state, which a client must not act + * on with part of it left out. A newer field inside one of them still refuses the view. + * + * `unreadFields` exists so what was dropped is said rather than hidden, the same rule a directory entry follows + * (`readDirectoryEntry`). Requests a client sends, and what a node writes and stores, keep their strict schemas. + */ +export function readNodeView( + schema: Schema, + candidate: unknown, +): { success: true; data: z.infer; unreadFields: UnreadListingFields | undefined } | { success: false; error: z.ZodError } { + if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) { + const result = schema.safeParse(candidate); + return result.success ? { success: true, data: result.data, unreadFields: undefined } : { success: false, error: result.error }; + } + const known: Record = {}; + const unread: UnreadEntryFields = { names: [], unnamed: 0 }; + for (const [key, field] of Object.entries(candidate)) { + if (Object.hasOwn(schema.shape, key)) known[key] = field; + // A key is the node's text; one that is not a plain identifier is counted without being named. + else if (!key.includes(".") && UNREAD_FIELD_PATH_PATTERN.test(key)) unread.names.push(key); + else unread.unnamed += 1; + } + const result = schema.safeParse(known); + return result.success ? { success: true, data: result.data, unreadFields: unreadListingFields(unread) } : { success: false, error: result.error }; +} diff --git a/packages/contracts/src/widget-dev-session.ts b/packages/contracts/src/widget-dev-session.ts index 2d14ead1..4213d2c0 100644 --- a/packages/contracts/src/widget-dev-session.ts +++ b/packages/contracts/src/widget-dev-session.ts @@ -236,7 +236,11 @@ export function widgetDevRootRefusedCode(view: { stopReason?: WidgetDevStopReaso return view.stopReason === "root-refused" ? WIDGET_DEV_ROOT_REFUSED_CODES.find((code) => code === view.stopCode) : undefined; } -/** A session as the node reports it. */ +/** + * A session as the node reports it. The node writes it with this strict schema; a client reads it with `readNodeView`, + * which drops a top-level field this schema does not know (a newer node's) and says so, and keeps every nested object + * strict. + */ export const widgetDevSessionViewSchema = z.strictObject({ sessionId: z.string().min(1).max(200), /** `live` exactly while the node watches the folder; a watcher that failed reads as `stopped` with its reason. */ diff --git a/packages/contracts/test/node-view-read.spec.ts b/packages/contracts/test/node-view-read.spec.ts new file mode 100644 index 00000000..c28453f9 --- /dev/null +++ b/packages/contracts/test/node-view-read.spec.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import { readNodeView, widgetDevSessionCreateSchema, widgetDevSessionViewSchema } from "../src/index.ts"; + +/** + * A client reads a node's view tolerantly: an older desktop app talking to a newer node on another machine keeps the + * session when the view gained a top-level field, says what it left out, and still refuses anything new inside the + * objects that carry approval, reach or activation state. Requests and what the node writes stay strict. + */ + +const AT = "2026-10-08T10:00:00.000Z"; +const empty = { added: [], removed: [] }; +const generation = { + generation: 1, + packageId: "com.example.timer", + version: "0.1.0", + digest: `sha256:${"0".repeat(64)}`, + builtAt: AT, + trigger: "start", + widgetIds: ["com.example.timer.main@1"], + delta: { verdict: "initial", capabilities: empty, frameOrigins: empty, permissions: empty, facets: empty }, + warnings: [], +}; +const view = { + sessionId: "wdev_1", + status: "live", + root: "/home/me/timer", + startedAt: AT, + latest: generation, + running: generation, + activation: { state: "active", generation: 1, generationId: "gen_1" }, + showingLastKnownGood: false, +}; + +describe("reading a node's widget dev session view", () => { + it("keeps the session when a newer node adds a top-level field, and says what was left out", () => { + const read = readNodeView(widgetDevSessionViewSchema, { ...view, fooCode: "FOO_NEW" }); + expect(read.success).toBe(true); + if (!read.success) return; + expect(read.data).toEqual(view); + // Never passed on: nothing downstream carries a value nobody validated. + expect(read.data).not.toHaveProperty("fooCode"); + expect(read.unreadFields).toEqual({ count: 1, names: ["fooCode"] }); + }); + + it("says nothing was left out when the view has only known fields", () => { + const read = readNodeView(widgetDevSessionViewSchema, view); + expect(read).toEqual({ success: true, data: view, unreadFields: undefined }); + }); + + it("counts a field name that is not a plain identifier without naming it", () => { + const read = readNodeView(widgetDevSessionViewSchema, { ...view, "a b‮": 1, "x.y": 2, later: true }); + expect(read.success && read.unreadFields).toEqual({ count: 3, names: ["later"] }); + }); + + it("keeps every known field's bounds", () => { + expect(readNodeView(widgetDevSessionViewSchema, { ...view, status: "paused", fooCode: "x" }).success).toBe(false); + expect(readNodeView(widgetDevSessionViewSchema, { ...view, stopCode: "", fooCode: "x" }).success).toBe(false); + const { root: _root, ...rootless } = view; + expect(readNodeView(widgetDevSessionViewSchema, rootless).success).toBe(false); + expect(readNodeView(widgetDevSessionViewSchema, "not a view").success).toBe(false); + }); + + it("still refuses an unknown field inside an object that carries activation, approval or reach state", () => { + const activation = { ...view.activation, grantedBy: "someone" }; + expect(readNodeView(widgetDevSessionViewSchema, { ...view, activation }).success).toBe(false); + const awaiting = { state: "awaiting-approval", generation: 1, approvalId: "appr_1", autoApprove: true }; + expect(readNodeView(widgetDevSessionViewSchema, { ...view, activation: awaiting }).success).toBe(false); + const running = { ...generation, delta: { ...generation.delta, devices: empty } }; + expect(readNodeView(widgetDevSessionViewSchema, { ...view, running }).success).toBe(false); + }); + + it("leaves what the node writes, and a request to it, strict", () => { + expect(widgetDevSessionViewSchema.safeParse({ ...view, fooCode: "FOO_NEW" }).success).toBe(false); + expect(widgetDevSessionCreateSchema.safeParse({ root: "/home/me/timer", fooCode: "FOO_NEW" }).success).toBe(false); + }); +}); diff --git a/packages/conversation-client/src/api.ts b/packages/conversation-client/src/api.ts index e729b307..bc9ab7b6 100644 --- a/packages/conversation-client/src/api.ts +++ b/packages/conversation-client/src/api.ts @@ -82,7 +82,9 @@ import { type VoiceCapabilities, type WidgetDevFolderForgetResult, type WidgetDevSessionCreate, + type UnreadListingFields, type WidgetDevSessionView, + readNodeView, widgetDevFolderForgetResultSchema, widgetDevSessionViewSchema, } from "@clarkcant/contracts"; @@ -104,6 +106,18 @@ import { terminalSocketUrl, } from "./terminal-socket.ts"; +/** + * A widget dev session as this client read it. `unreadFields` is set when the node sent top-level fields this client does + * not know (a newer node): they were left out, and the surface says so rather than showing the session as complete. + */ +export type WidgetDevSessionRead = WidgetDevSessionView & { unreadFields?: UnreadListingFields }; + +function readDevSession(body: unknown): WidgetDevSessionRead { + const read = readNodeView(widgetDevSessionViewSchema, body); + if (!read.success) throw read.error; + return read.unreadFields === undefined ? read.data : { ...read.data, unreadFields: read.unreadFields }; +} + /** Where the key in effect for one credential comes from: the key saved in the node, its environment, or neither. */ export type CredentialSource = "vault" | "environment" | "none"; @@ -2063,13 +2077,13 @@ export class GatewayClient { } /** A live widget authoring session: its newest build, what runs, and whether that is the last good build. */ - async widgetDevSession(sessionId: string): Promise { - return widgetDevSessionViewSchema.parse(await this.#call("GET", `/widget-dev/sessions/${encodeURIComponent(sessionId)}`)); + async widgetDevSession(sessionId: string): Promise { + return readDevSession(await this.#call("GET", `/widget-dev/sessions/${encodeURIComponent(sessionId)}`)); } /** Start developing the package in a folder on the node, placed in `conversationId` once a build of it runs. */ - async startWidgetDevSession(input: WidgetDevSessionCreate): Promise { - return widgetDevSessionViewSchema.parse(await this.#call("POST", "/widget-dev/sessions", input)); + async startWidgetDevSession(input: WidgetDevSessionCreate): Promise { + return readDevSession(await this.#call("POST", "/widget-dev/sessions", input)); } /** Take back the person's choice of a folder: Clark may no longer start sessions in it. Person-only on the node. */ @@ -2078,13 +2092,13 @@ export class GatewayClient { } /** Build the session's folder now, rather than on its next save. */ - async rebuildWidgetDevSession(sessionId: string): Promise { - return widgetDevSessionViewSchema.parse(await this.#call("POST", `/widget-dev/sessions/${encodeURIComponent(sessionId)}/rebuild`)); + async rebuildWidgetDevSession(sessionId: string): Promise { + return readDevSession(await this.#call("POST", `/widget-dev/sessions/${encodeURIComponent(sessionId)}/rebuild`)); } /** Stop watching the folder. What runs keeps running where it was placed. */ - async stopWidgetDevSession(sessionId: string): Promise { - return widgetDevSessionViewSchema.parse(await this.#call("DELETE", `/widget-dev/sessions/${encodeURIComponent(sessionId)}`)); + async stopWidgetDevSession(sessionId: string): Promise { + return readDevSession(await this.#call("DELETE", `/widget-dev/sessions/${encodeURIComponent(sessionId)}`)); } claimLiveOwner( diff --git a/packages/conversation-client/src/i18n/messages-shell.ts b/packages/conversation-client/src/i18n/messages-shell.ts index a7f80b9b..2c12179b 100644 --- a/packages/conversation-client/src/i18n/messages-shell.ts +++ b/packages/conversation-client/src/i18n/messages-shell.ts @@ -40,6 +40,7 @@ export const MESSAGES_SHELL_VI = { "shell.dev.awaiting": "Bản dựng {latest} đang chờ bạn duyệt trong hộp thư — đang hiện bản dựng {generation}.", "shell.dev.refused": "Bản dựng {latest} không được chạy: {reason} Đang hiện bản dựng {generation}.", "shell.dev.wider": "Bản dựng này được truy cập nhiều hơn bản trước.", + "shell.dev.nodeNewer": "Node này mới hơn ứng dụng này, nên một số thông tin node gửi về phiên này không được hiển thị. Hãy cập nhật ứng dụng để thấy đầy đủ.", "shell.dev.stopped": "Đã dừng theo dõi thư mục · bản dựng {generation} vẫn chạy.", "shell.dev.problems": "Lỗi ({count})", "shell.dev.more": "và {count} lỗi khác", @@ -377,6 +378,7 @@ export const MESSAGES_SHELL_EN = { "shell.dev.awaiting": "Build {latest} is waiting for your approval in the inbox — showing build {generation}.", "shell.dev.refused": "Build {latest} was not run: {reason} Showing build {generation}.", "shell.dev.wider": "This build reaches more than the one before it.", + "shell.dev.nodeNewer": "This node is newer than this app, so some of what it says about this session is not shown. Update the app to see everything.", "shell.dev.stopped": "No longer watching the folder · build {generation} keeps running.", "shell.dev.problems": "Problems ({count})", "shell.dev.more": "and {count} more", diff --git a/packages/conversation-client/src/use-block-actions.ts b/packages/conversation-client/src/use-block-actions.ts index 1873bb56..198310c2 100644 --- a/packages/conversation-client/src/use-block-actions.ts +++ b/packages/conversation-client/src/use-block-actions.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { CommandCardAction, FeedbackPublishIntent, FeedbackRequestInput, ProviderSignInView, WidgetDevFolderForgetResult, WidgetDevSessionView } from "@clarkcant/contracts"; -import { type GatewayClient, GatewayError, type Timeline } from "./api.ts"; +import { type GatewayClient, GatewayError, type Timeline, type WidgetDevSessionRead } from "./api.ts"; import { canPickFolder, pickFolderOnDesktop } from "./desktop-compact.ts"; import { fillMessage } from "./i18n/fill-message.ts"; import type { MessageKey } from "./i18n/messages.ts"; @@ -81,8 +81,9 @@ export function feedbackReportToReuse(previous: FeedbackCardState | undefined, r * use: the node keeps a choice only for the folder itself (`pressed` was its own path, not a link to it) and never for a * whole drive or the home folder (`chosenByPerson`). */ -export function developOutcomeMessage(view: WidgetDevSessionView, t: (key: MessageKey) => string, pressed?: string): string { - const outcome = sessionOutcome(view, t); +export function developOutcomeMessage(view: WidgetDevSessionRead, t: (key: MessageKey) => string, pressed?: string): string { + // A newer node sent more than this app reads: said, so the outcome is never taken for all the node answered. + const outcome = view.unreadFields === undefined ? sessionOutcome(view, t) : `${sessionOutcome(view, t)} ${t("shell.dev.nodeNewer")}`; if (pressed === undefined || view.chosenByPerson === true) return outcome; const leadsElsewhere = pressed.trim().replace(/[\\/]+$/u, "").toLowerCase() !== view.root.replace(/[\\/]+$/u, "").toLowerCase(); const kept = fillMessage(t(leadsElsewhere ? "commandCard.develop.notKeptLink" : "commandCard.develop.notKeptBroad"), { folder: view.root }); diff --git a/packages/conversation-client/src/widget-dev-status.tsx b/packages/conversation-client/src/widget-dev-status.tsx index 527a43fe..68c076b0 100644 --- a/packages/conversation-client/src/widget-dev-status.tsx +++ b/packages/conversation-client/src/widget-dev-status.tsx @@ -2,7 +2,7 @@ import { type ReactElement, useEffect, useRef, useState } from "react"; import { WIDGET_DEV_DIAGNOSTIC_CODES, widgetDevRootRefusedCode, type WidgetDevSessionView } from "@clarkcant/contracts"; -import type { GatewayClient } from "./api.ts"; +import type { GatewayClient, WidgetDevSessionRead } from "./api.ts"; import { useT } from "./i18n/locale-context.tsx"; import type { MessageKey } from "./i18n/messages.ts"; @@ -95,7 +95,7 @@ export interface WidgetDevStatusProps { export function WidgetDevStatus({ client, sessionId, onRunningChange }: WidgetDevStatusProps): ReactElement | null { const t = useT(); - const [view, setView] = useState(undefined); + const [view, setView] = useState(undefined); const [unreachable, setUnreachable] = useState(false); const seenRunning = useRef(undefined); const changed = useRef(onRunningChange); @@ -148,9 +148,15 @@ export function WidgetDevStatus({ client, sessionId, onRunningChange }: WidgetDe data-widget-dev-status={view.status} data-widget-dev-generation={view.running?.generation ?? ""} data-showing-last-good={view.showingLastKnownGood ? "true" : "false"} - data-live-notice={line.notice ? "true" : undefined} + data-live-notice={line.notice || view.unreadFields !== undefined ? "true" : undefined} >

{line.text}

+ {/* A newer node sent more than this app reads: said, so the line is never taken for the whole session. */} + {view.unreadFields !== undefined && ( +

+ {t("shell.dev.nodeNewer")} +

+ )} {wider &&

{t("shell.dev.wider")}

} {problems.length > 0 && (
diff --git a/packages/conversation-client/test/widget-dev-session-read.spec.ts b/packages/conversation-client/test/widget-dev-session-read.spec.ts new file mode 100644 index 00000000..b97874fd --- /dev/null +++ b/packages/conversation-client/test/widget-dev-session-read.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import { GatewayClient } from "../src/api.ts"; +import { CATALOGS, type MessageKey } from "../src/i18n/messages.ts"; +import { developOutcomeMessage } from "../src/use-block-actions.ts"; + +/** + * A desktop app older than the node it talks to (on another machine, updated separately) still reads a widget dev session + * view that gained a field, and says the node sent more than it shows rather than losing the session or hiding the drop. + */ + +const en = (key: MessageKey): string => CATALOGS.en[key]; +const vi = (key: MessageKey): string => CATALOGS.vi[key]; +const AT = "2026-10-08T10:00:00.000Z"; +const view = { + sessionId: "wdev_1", + status: "live", + root: "/home/me/timer", + startedAt: AT, + activation: { state: "none" }, + showingLastKnownGood: false, +}; + +function nodeAnswering(body: unknown): GatewayClient { + const fetchImpl = (async () => Response.json(body)) as typeof fetch; + return new GatewayClient({ baseUrl: "http://127.0.0.1:8765", token: "tok", fetchImpl }); +} + +describe("a widget dev session view from a newer node", () => { + it("is read without the field this app does not know, and the drop is reported", async () => { + const read = await nodeAnswering({ ...view, fooCode: "FOO_NEW" }).widgetDevSession("wdev_1"); + expect(read).toEqual({ ...view, unreadFields: { count: 1, names: ["fooCode"] } }); + const started = await nodeAnswering({ ...view, fooCode: "FOO_NEW" }).startWidgetDevSession({ root: view.root }); + expect(started.unreadFields?.count).toBe(1); + }); + + it("reports nothing when the node sent only what this app knows", async () => { + const read = await nodeAnswering(view).widgetDevSession("wdev_1"); + expect(read).toEqual(view); + expect(read).not.toHaveProperty("unreadFields"); + }); + + it("is still refused when the new field is inside the activation state", async () => { + await expect(nodeAnswering({ ...view, activation: { state: "none", grantedBy: "x" } }).widgetDevSession("wdev_1")).rejects.toThrow(); + }); + + it("is said to be incomplete on the develop card", async () => { + const read = await nodeAnswering({ ...view, fooCode: "FOO_NEW" }).widgetDevSession("wdev_1"); + expect(developOutcomeMessage(read, en)).toBe(`Watching /home/me/timer. ${en("shell.dev.nodeNewer")}`); + expect(developOutcomeMessage(read, vi)).toContain("Hãy cập nhật ứng dụng"); + const complete = await nodeAnswering(view).widgetDevSession("wdev_1"); + expect(developOutcomeMessage(complete, en)).toBe("Watching /home/me/timer."); + }); +}); From e33eeae6687b3599d55c5522a76b8bcce76a57ad Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Thu, 8 Oct 2026 14:33:34 +0700 Subject: [PATCH 2/2] docs(widget-dev): name every case where a session view is still refused, and test the status line's newer-node sentence A new value in a field the app knows (a new stopReason, status or activation state) still refuses the view, so the same-build advice covers it too. lastBuild and placed carry build and placement state, not approval or reach. Approval or reach state is never added as a new top-level field, which an older app would drop. The status report is drawn by its own component so the sentence can be rendered in a test. --- docs/open-interfaces.md | 19 ++++++++++------ docs/open-interfaces.vi.md | 22 ++++++++++++------- packages/contracts/src/node-view-read.ts | 8 ++++--- .../src/widget-dev-status.tsx | 6 +++++ .../test/widget-dev-status.spec.ts | 21 +++++++++++++++++- 5 files changed, 57 insertions(+), 19 deletions(-) diff --git a/docs/open-interfaces.md b/docs/open-interfaces.md index 9856a484..d28cf210 100644 --- a/docs/open-interfaces.md +++ b/docs/open-interfaces.md @@ -901,9 +901,13 @@ and shown in the conversation in the production widget frame. Shapes are `widget session view (`readNodeView` in `packages/contracts/src/node-view-read.ts`) tolerantly: a top-level field it does not know, sent by a node newer than the app, is left out and never passed on, and the status line beside the widget and the `develop` card say the node is newer and that some of what it said is not shown. Every field the app knows keeps -its bounds. A field it does not know inside `activation`, `latest`, `running`, `lastBuild` or `placed` still refuses -the view, because those objects carry activation, approval and reach state the app must not act on in part. Requests -to the node, and the sessions the node writes and stores, stay strict. +its bounds, so a value it does not know in such a field (a new `stopReason`, `status`, activation `state`, `verdict` +or `trigger`) still refuses the view. A field it does not know inside `activation`, `latest`, `running`, `lastBuild` +or `placed` still refuses the view too: `activation`, `latest` and `running` carry activation, approval and reach +state the app must not act on in part, and `lastBuild` and `placed` carry build and placement state. Requests to the +node, and the sessions the node writes and stores, stay strict. Because an older app drops a new top-level field with +only the generic note, approval or reach state is never added to the view as a new top-level field; it goes inside a +strict nested object, or the view's shape changes in a way older apps refuse. Refusals: `400 ROOT_NOT_ABSOLUTE`, `400 ROOT_NOT_A_FOLDER`, `404 ROOT_NOT_FOUND`, `403 ROOT_UNREADABLE`, `404 CONVERSATION_NOT_FOUND`, `404 SESSION_NOT_FOUND`, `409 TOO_MANY_SESSIONS`, `409 NOT_ACTIVE`, `400 NO_SUCH_WIDGET`, @@ -1041,10 +1045,11 @@ reading as live: `stopCode` is optional. A session stopped before nodes kept it has none, and a code the client does not know gets the reason alone, with advice that holds for every case. A top-level field newer than the app is left out and the app says the node is newer (see **Reading a session view** above). An app built before that tolerant read, which - includes every app older than `stopCode`, refuses the whole view instead, and any app refuses a view with a newer - field inside `activation`, `latest`, `running`, `lastBuild` or `placed`; only for those cases, keep the desktop app - on the same build as the node it connects to, including a node on another machine. The code is what the check found at that restart and is not - checked again while the session stays stopped; a start checks the folder as it is then. + includes every app older than `stopCode`, refuses the whole view instead. Any app also refuses a view with a newer + field inside `activation`, `latest`, `running`, `lastBuild` or `placed`, or with a value it does not know in a field + it knows, such as a new `stopReason`, `status` or activation state. For those cases, keep the desktop app on the + same build as the node it connects to, including a node on another machine. The code is what the check found at + that restart and is not checked again while the session stays stopped; a start checks the folder as it is then. **Downgrade.** `sessions.json` is read with a strict schema too. A build older than a field the store holds, such as `stopCode` or `chosenFolderId`, finds the whole file does not match, moves it aside as diff --git a/docs/open-interfaces.vi.md b/docs/open-interfaces.vi.md index 8366854b..e7584e88 100644 --- a/docs/open-interfaces.vi.md +++ b/docs/open-interfaces.vi.md @@ -906,9 +906,14 @@ cuộc hội thoại bằng frame widget dùng trong bản chính thức. Dạng (`readNodeView` trong `packages/contracts/src/node-view-read.ts`) một cách khoan dung: một trường ở cấp trên cùng mà nó không biết, do một node mới hơn ứng dụng gửi, bị bỏ qua và không bao giờ được chuyển tiếp, còn dòng trạng thái cạnh widget và thẻ `develop` nói rằng node mới hơn và một số điều node gửi không được hiển thị. Mọi trường ứng dụng biết vẫn -giữ nguyên các giới hạn của nó. Một trường lạ nằm trong `activation`, `latest`, `running`, `lastBuild` hoặc `placed` -vẫn làm view bị từ chối, vì các đối tượng đó mang trạng thái kích hoạt, phê duyệt và phạm vi truy cập mà ứng dụng -không được hành động dựa trên một phần của chúng. Các yêu cầu gửi tới node, và các phiên node ghi và lưu, vẫn chặt. +giữ nguyên các giới hạn của nó, nên một giá trị ứng dụng không biết trong một trường như vậy (một `stopReason`, +`status`, `state` của activation, `verdict` hay `trigger` mới) vẫn làm view bị từ chối. Một trường lạ nằm trong +`activation`, `latest`, `running`, `lastBuild` hoặc `placed` cũng vẫn làm view bị từ chối: `activation`, `latest` và +`running` mang trạng thái kích hoạt, phê duyệt và phạm vi truy cập mà ứng dụng không được hành động dựa trên một phần +của chúng, còn `lastBuild` và `placed` mang trạng thái bản dựng và vị trí đặt widget. Các yêu cầu gửi tới node, và các +phiên node ghi và lưu, vẫn chặt. Vì một ứng dụng cũ hơn bỏ qua một trường mới ở cấp trên cùng và chỉ kèm lời báo +chung, trạng thái phê duyệt hay phạm vi truy cập không bao giờ được thêm vào view thành một trường mới ở cấp trên cùng; +nó nằm trong một đối tượng lồng nhau đọc chặt, hoặc dạng của view đổi theo cách mà ứng dụng cũ hơn từ chối. Các lần từ chối: `400 ROOT_NOT_ABSOLUTE`, `400 ROOT_NOT_A_FOLDER`, `404 ROOT_NOT_FOUND`, `403 ROOT_UNREADABLE`, `404 CONVERSATION_NOT_FOUND`, `404 SESSION_NOT_FOUND`, `409 TOO_MANY_SESSIONS`, `409 NOT_ACTIVE`, `400 NO_SUCH_WIDGET`, @@ -1050,11 +1055,12 @@ vẫn hiện là đang chạy: `stopCode` không bắt buộc. Một phiên dừng từ trước khi node giữ trường này thì không có nó, và một mã client không biết chỉ được nói bằng lý do, kèm lời khuyên đúng cho mọi trường hợp. Một trường ở cấp trên cùng mới hơn ứng dụng thì bị bỏ qua và ứng dụng nói rằng node mới hơn (xem **Đọc view của phiên** ở trên). Một ứng dụng dựng trước cách đọc - khoan dung đó, bao gồm mọi ứng dụng cũ hơn `stopCode`, thì từ chối cả view, và mọi ứng dụng đều từ chối một view có - trường mới hơn nằm trong `activation`, `latest`, `running`, `lastBuild` hoặc `placed`; chỉ trong những trường hợp đó, - hãy giữ ứng dụng desktop ở cùng bản dựng với node mà nó kết nối tới, kể cả một node trên máy khác. Mã này là điều - bước kiểm tìm thấy ở lần khởi động lại đó và không được kiểm lại khi phiên vẫn dừng; một lần bắt đầu kiểm thư mục như - nó đang có lúc ấy. + khoan dung đó, bao gồm mọi ứng dụng cũ hơn `stopCode`, thì từ chối cả view. Mọi ứng dụng cũng từ chối một view có + trường mới hơn nằm trong `activation`, `latest`, `running`, `lastBuild` hoặc `placed`, hoặc có một giá trị ứng dụng + không biết trong một trường nó biết, chẳng hạn một `stopReason`, `status` hay trạng thái activation mới. Trong những + trường hợp đó, hãy giữ ứng dụng desktop ở cùng bản dựng với node mà nó kết nối tới, kể cả một node trên máy khác. + Mã này là điều bước kiểm tìm thấy ở lần khởi động lại đó và không được kiểm lại khi phiên vẫn dừng; một lần bắt đầu + kiểm thư mục như nó đang có lúc ấy. **Hạ phiên bản.** `sessions.json` cũng được đọc bằng một schema chặt. Một bản dựng cũ hơn một trường mà kho đang giữ, chẳng hạn `stopCode` hay `chosenFolderId`, thấy cả tệp không khớp, chuyển nó sang diff --git a/packages/contracts/src/node-view-read.ts b/packages/contracts/src/node-view-read.ts index 3d905589..a52ad9b9 100644 --- a/packages/contracts/src/node-view-read.ts +++ b/packages/contracts/src/node-view-read.ts @@ -10,9 +10,11 @@ import { UNREAD_FIELD_PATH_PATTERN, unreadListingFields, type UnreadEntryFields, * over time (the widget dev session view gained `stopCode`), and refusing the whole answer for a field this client has * never heard of made an older client lose the view altogether, not just the field. So a top-level field outside the * schema is dropped and reported in `unreadFields`; it is never passed on, so nothing downstream carries a value nobody - * validated. Every known field is still checked against the schema with all its bounds, and every object inside a known - * field stays as strict as the schema says: those carry approval, reach and activation state, which a client must not act - * on with part of it left out. A newer field inside one of them still refuses the view. + * validated. Every known field is still checked against the schema with all its bounds (a value it does not know, such as + * a new enum member, still refuses the view), and every object inside a known field stays as strict as the schema says: + * such objects may carry approval, reach and activation state, which a client must not act on with part of it left out. + * A newer field inside one of them still refuses the view. So binding state is never added as a new top-level field, + * which an older client would drop with only the generic note. * * `unreadFields` exists so what was dropped is said rather than hidden, the same rule a directory entry follows * (`readDirectoryEntry`). Requests a client sends, and what a node writes and stores, keep their strict schemas. diff --git a/packages/conversation-client/src/widget-dev-status.tsx b/packages/conversation-client/src/widget-dev-status.tsx index 68c076b0..e3356912 100644 --- a/packages/conversation-client/src/widget-dev-status.tsx +++ b/packages/conversation-client/src/widget-dev-status.tsx @@ -136,6 +136,12 @@ export function WidgetDevStatus({ client, sessionId, onRunningChange }: WidgetDe ); } if (view === undefined) return null; + return ; +} + +/** What the status says for one read of the session: drawn from the view alone, so it can be checked without a node. */ +export function WidgetDevStatusReport({ view }: { view: WidgetDevSessionRead }): ReactElement { + const t = useT(); const line = widgetDevStatusLine(view, t); const problems = view.lastBuild?.ok === false ? view.lastBuild.diagnostics : []; const more = view.lastBuild?.ok === false ? (view.lastBuild.diagnosticsMore ?? 0) : 0; diff --git a/packages/conversation-client/test/widget-dev-status.spec.ts b/packages/conversation-client/test/widget-dev-status.spec.ts index 2f20081c..b12dcaae 100644 --- a/packages/conversation-client/test/widget-dev-status.spec.ts +++ b/packages/conversation-client/test/widget-dev-status.spec.ts @@ -1,9 +1,12 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import type { WidgetDevGeneration, WidgetDevSessionView } from "@clarkcant/contracts"; +import { readStoredLocale } from "../src/i18n/locale.ts"; import { CATALOGS, type MessageKey } from "../src/i18n/messages.ts"; -import { widgetDevDiagnosticText, widgetDevRefusalReason, widgetDevStatusLine } from "../src/widget-dev-status.tsx"; +import { WidgetDevStatusReport, widgetDevDiagnosticText, widgetDevRefusalReason, widgetDevStatusLine } from "../src/widget-dev-status.tsx"; /** * What the host says beside a widget dev session's frame. "Last successful build" is said exactly when the node says the @@ -170,4 +173,20 @@ describe("the widget dev status line", () => { expect(widgetDevDiagnosticText({ code: "FILES_LINK_REFUSED", message: "x" }, vi)).toContain("hãy xoá liên kết"); expect(widgetDevDiagnosticText({ message: "widget.json: not JSON" }, vi)).toBe("widget.json: not JSON"); }); + it("says beside the frame when a newer node sent fields this app left out, and only then", () => { + const report = (unreadFields?: { count: number; names: string[] }) => + renderToStaticMarkup(createElement(WidgetDevStatusReport, { view: { ...view({}), ...(unreadFields === undefined ? {} : { unreadFields }) } })); + // Rendered outside a locale provider, so in the stored (default) locale. + const said = CATALOGS[readStoredLocale()]["shell.dev.nodeNewer"]; + const partial = report({ count: 1, names: ["fooCode"] }); + expect(partial).toContain('data-widget-dev-node-newer="1"'); + expect(partial).toContain(said); + expect(partial).toContain('data-live-notice="true"'); + // Names are the node's text and stay out of the sentence. + expect(partial).not.toContain("fooCode"); + const complete = report(); + expect(complete).not.toContain("data-widget-dev-node-newer"); + expect(complete).not.toContain(said); + expect(complete).not.toContain("data-live-notice"); + }); });