Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions docs/open-interfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,18 @@ 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, 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`,
`409 NOT_PLACED` and `503 WIDGET_DEV_UNAVAILABLE`:
Expand Down Expand Up @@ -1031,11 +1043,13 @@ 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. 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
Expand Down
26 changes: 21 additions & 5 deletions docs/open-interfaces.vi.md
Original file line number Diff line number Diff line change
Expand Up @@ -902,6 +902,19 @@ 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ó, 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`,
`409 NOT_PLACED` và `503 WIDGET_DEV_UNAVAILABLE`:
Expand Down Expand Up @@ -1040,11 +1053,14 @@ 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. 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
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
40 changes: 40 additions & 0 deletions packages/contracts/src/node-view-read.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
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 (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.
*/
export function readNodeView<Schema extends z.ZodObject>(
schema: Schema,
candidate: unknown,
): { success: true; data: z.infer<Schema>; 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<string, unknown> = {};
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 };
}
6 changes: 5 additions & 1 deletion packages/contracts/src/widget-dev-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
77 changes: 77 additions & 0 deletions packages/contracts/test/node-view-read.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading