diff --git a/apps/runtime/src/application/widget-dev-card.ts b/apps/runtime/src/application/widget-dev-card.ts index 6b65dd44..a2b0b106 100644 --- a/apps/runtime/src/application/widget-dev-card.ts +++ b/apps/runtime/src/application/widget-dev-card.ts @@ -1,4 +1,4 @@ -import { type CommandCard, type Instant, type WidgetDevSessionView } from "@clarkcant/contracts"; +import { type CommandCard, type Instant, type WidgetDevSessionView, widgetDevRootRefusedCode } from "@clarkcant/contracts"; /** * The card a person chooses a folder to develop a widget from: `/develop`, Clark's answer when it was asked to develop @@ -142,15 +142,35 @@ export function developFolderCard(input: { .slice(0, SESSIONS_LISTED); for (const session of listed) { const named = session.packageId === undefined ? undefined : session.version === undefined ? session.packageId : `${session.packageId}@${session.version}`; - // Stopped when the node started again because Clark may no longer watch the folder on its own: for example a chosen - // folder deleted and made again, which is another folder, or a folder the person forgot. Only the person's own start chooses it. + // Stopped when the node started again because the folder failed the start check. Which check decides what helps: a + // folder Clark is no longer allowed to watch on its own (a chosen folder deleted and made again, which is another + // folder, or one the person forgot) is chosen again by the person's own start; a folder that now resolves to a network + // share or into the data folder is refused for anyone, so the row offers no button that could only fail. The code is + // what that restart found: a press of Another folder, or of a row that has one, checks the folder again as it is now. + const refusedCode = widgetDevRootRefusedCode(session); + const nowhere = refusedCode === "ROOT_NOT_LOCAL" || refusedCode === "ROOT_IN_DATA_FOLDER"; const refused = - session.status === "stopped" && session.stopReason === "root-refused" - ? say( - "Clark không còn được tự theo dõi thư mục này (chẳng hạn vì nó đã bị xoá rồi tạo lại sau khi bạn chọn); những gì nó đã dựng vẫn chạy. Bấm Phát triển lại để chọn lại thư mục.", - "Clark may no longer watch this folder on its own (for example, it was deleted and made again after you chose it); what it built keeps running. Press Develop again to choose the folder again.", - ) - : undefined; + session.status !== "stopped" || session.stopReason !== "root-refused" + ? undefined + : refusedCode === "ROOT_NOT_LOCAL" + ? say( + "Thư mục này giờ dẫn tới một thư mục chia sẻ qua mạng hoặc một đường dẫn thiết bị, nơi Clark không phát triển widget được, nên chọn lại nó cũng bị từ chối; những gì nó đã dựng vẫn chạy. Hãy chép dự án vào không gian widget của Clark hoặc vào một thư mục trên ổ đĩa của máy này, rồi chọn thư mục đó.", + "This folder now leads to a network share or device path, where Clark can't develop widgets, so choosing it again is refused too; what it built keeps running. Copy the project into Clark's widget workspace or into a folder on this machine's own drives, then choose that folder.", + ) + : refusedCode === "ROOT_IN_DATA_FOLDER" + ? say( + "Thư mục này giờ chứa hoặc nằm trong thư mục dữ liệu của Clark, nơi Clark không phát triển widget được, nên chọn lại nó cũng bị từ chối; những gì nó đã dựng vẫn chạy. Hãy chép dự án vào không gian widget của Clark hoặc vào một thư mục dự án khác, rồi chọn thư mục đó.", + "This folder now holds or lies inside Clark's data folder, where Clark can't develop widgets, so choosing it again is refused too; what it built keeps running. Copy the project into Clark's widget workspace or into another project folder, then choose that folder.", + ) + : refusedCode === "ROOT_NOT_OWNED" + ? say( + "Clark không còn được phép tự theo dõi thư mục này (chẳng hạn vì nó đã bị xoá rồi tạo lại sau khi bạn chọn); những gì nó đã dựng vẫn chạy. Bấm Phát triển lại để chọn lại thư mục.", + "Clark is no longer allowed to watch this folder on its own (for example, it was deleted and made again after you chose it); what it built keeps running. Press Develop again to choose the folder again.", + ) + : say( + "Khi Clark khởi động lại, nó không thể theo dõi thư mục này nữa; những gì nó đã dựng vẫn chạy. Bấm Phát triển lại để thử lại; nếu bị từ chối, hãy chép dự án vào không gian widget của Clark.", + "When Clark restarted, it could no longer watch this folder; what it built keeps running. Press Develop again to try again; if that is refused, copy the project into Clark's widget workspace.", + ); const note = [named, refused].filter((part): part is string => part !== undefined).join(" · "); rows.push({ rowId: `session:${session.sessionId}`, @@ -162,7 +182,7 @@ export function developFolderCard(input: { : { text: say("đã dừng", "stopped"), tone: "neutral" }, // A live session is already watching; a stopped one is picked up again, with what it runs and was granted. actions: - session.status === "live" + session.status === "live" || nowhere ? [] : [{ actionId: "develop", label: say("Phát triển lại", "Develop again"), action: { kind: "develop-folder", root: session.root } }], }); diff --git a/apps/runtime/src/application/widget-dev-sessions.ts b/apps/runtime/src/application/widget-dev-sessions.ts index 518b6b41..804268c9 100644 --- a/apps/runtime/src/application/widget-dev-sessions.ts +++ b/apps/runtime/src/application/widget-dev-sessions.ts @@ -458,6 +458,7 @@ export function createWidgetDevSessions( sessionId: stored.sessionId, status: stored.status, ...(stored.status === "stopped" && stored.stopReason !== undefined ? { stopReason: stored.stopReason } : {}), + ...(stored.status === "stopped" && stored.stopReason === "root-refused" && stored.stopCode !== undefined ? { stopCode: stored.stopCode } : {}), root: stored.root, ...(latest === undefined ? {} : { packageId: latest.packageId, version: latest.version, latest }), startedAt: stored.startedAt, @@ -825,8 +826,12 @@ export function createWidgetDevSessions( } }; - /** Stop watching, and say why: the person, a watcher that failed, a folder that is gone, or the node's capacity. */ - const markStopped = (sessionId: string, reason: WidgetDevStopReason): StoredDevSession | undefined => { + /** + * Stop watching, and say why: the person, a watcher that failed, a folder that is gone, the node's capacity, or a folder + * refused when the node started again. A `root-refused` stop keeps the start check's `code`, which decides what the + * person can do next: choose the folder again, or copy the project elsewhere. + */ + const markStopped = (sessionId: string, reason: WidgetDevStopReason, code?: string): StoredDevSession | undefined => { const session = live.get(sessionId); if (session !== undefined) end(session); live.delete(sessionId); @@ -834,7 +839,7 @@ export function createWidgetDevSessions( * The running generation stays installed and keeps rendering where it was placed; only the folder stops being * watched. A build that was waiting on a question is dropped with its listing, so the question leaves the inbox. */ - return update(sessionId, ({ pending: _dropped, ...rest }) => ({ ...rest, status: "stopped", stopReason: reason })); + return update(sessionId, ({ pending: _dropped, stopCode: _old, ...rest }) => ({ ...rest, status: "stopped", stopReason: reason, ...(reason === "root-refused" && code !== undefined ? { stopCode: code } : {}) })); }; /** @@ -977,7 +982,7 @@ export function createWidgetDevSessions( `this node keeps ${String(WIDGET_DEV_STORE_MAX)} widget dev sessions and each still runs what it built; uninstall some of those packages first`, ); } - const { stopReason: _old, placed: oldPlaced, ...carried } = reopened ?? { sessionId: services().conductor.newId("wdev"), root }; + const { stopReason: _old, stopCode: _oldCode, placed: oldPlaced, ...carried } = reopened ?? { sessionId: services().conductor.newId("wdev"), root }; // A conversation named now replaces where the reopened session placed its widget before. const placed = oldPlaced !== undefined && input.conversationId !== undefined && oldPlaced.conversationId !== input.conversationId ? undefined : oldPlaced; const stored: StoredDevSession = { @@ -1136,7 +1141,7 @@ export function createWidgetDevSessions( if (!checked.ok) { const gone = checked.code === "ROOT_NOT_FOUND" || checked.code === "ROOT_NOT_A_FOLDER"; // A folder that is there but cannot be read is not gone, nor refused: watching it failed. - markStopped(stored.sessionId, gone ? "folder-gone" : checked.code === "ROOT_UNREADABLE" ? "watch-failed" : "root-refused"); + markStopped(stored.sessionId, gone ? "folder-gone" : checked.code === "ROOT_UNREADABLE" ? "watch-failed" : "root-refused", checked.code); process.stderr.write(`widget dev: ${stored.root} could not be watched again (${checked.code}: ${checked.message}), so its session was stopped; what it ran keeps running\n`); continue; } diff --git a/apps/runtime/src/application/widget-dev-store.ts b/apps/runtime/src/application/widget-dev-store.ts index ed5f407d..1c44c45d 100644 --- a/apps/runtime/src/application/widget-dev-store.ts +++ b/apps/runtime/src/application/widget-dev-store.ts @@ -49,6 +49,8 @@ export const storedDevSessionSchema = z.strictObject({ root: z.string().min(1).max(1000), status: z.enum(["live", "stopped"]), stopReason: widgetDevStopReasonSchema.optional(), + /** With `root-refused`, the code the start check refused the folder with when the node started again. */ + stopCode: z.string().min(1).max(80).optional(), /** * Who started the session, which is whose intent its installs carry out: the person on their own surface, or Clark * during a turn (with the turn's origin), whose installs the policy decides as Clark's own proposal. diff --git a/apps/runtime/src/develop-widget-tool.ts b/apps/runtime/src/develop-widget-tool.ts index 870344d8..3426c91d 100644 --- a/apps/runtime/src/develop-widget-tool.ts +++ b/apps/runtime/src/develop-widget-tool.ts @@ -1,4 +1,4 @@ -import { type TurnOrigin, type WidgetDevSessionView } from "@clarkcant/contracts"; +import { type TurnOrigin, type WidgetDevSessionView, widgetDevRootRefusedCode } from "@clarkcant/contracts"; import type { ToolDefinition } from "@clarkcant/pi-adapter"; import type { WidgetDevSessions } from "./application/widget-dev-sessions.ts"; @@ -51,12 +51,17 @@ export function describeDevSession(view: WidgetDevSessionView): string { "watch-failed": "watching its folder failed (the platform stopped reporting changes, or the folder could not be read for 30 s; the node's log names the error)", "folder-gone": "its folder is gone", capacity: "the node already watches as many folders as it can", - "root-refused": "its folder is no longer one it may watch", + "root-refused": "the node could no longer watch its folder when it started again", }[view.stopReason]; + const refusedCode = widgetDevRootRefusedCode(view); const next = - view.stopReason === "root-refused" - ? "ask the person to choose its folder themselves (they can type /develop), or develop it in the widget workspace" - : "start it again to resume"; + refusedCode === "ROOT_NOT_LOCAL" || refusedCode === "ROOT_IN_DATA_FOLDER" + ? `the folder now ${refusedCode === "ROOT_NOT_LOCAL" ? "resolves to a network share or device path" : "holds or lies inside the node's data folder"}, which no start may watch, so choosing it again is refused too; ask the person to copy the project into the widget workspace or another local project folder` + : refusedCode === "ROOT_NOT_OWNED" + ? "ask the person to choose its folder themselves (they can type /develop), or develop it in the widget workspace" + : view.stopReason === "root-refused" + ? "ask the person to develop it again with /develop; if that is refused too, ask them to copy the project into the widget workspace" + : "start it again to resume"; lines.push(`It stopped watching because ${why}; ${next}.`); } if (view.latest?.delta.verdict === "wider") lines.push("The newest build asks to reach more than the one before it."); diff --git a/apps/runtime/test/widget-dev-root-refused.spec.ts b/apps/runtime/test/widget-dev-root-refused.spec.ts new file mode 100644 index 00000000..9d5361e2 --- /dev/null +++ b/apps/runtime/test/widget-dev-root-refused.spec.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; + +import type { Instant, WidgetDevSessionView } from "@clarkcant/contracts"; + +import { developFolderCard } from "../src/application/widget-dev-card.ts"; +import { describeDevSession } from "../src/develop-widget-tool.ts"; + +/** + * A session the node stopped at a restart because its folder failed the start check is listed on the `develop` card, and + * described to Clark, with what helps, which depends on the check that refused it: choosing the folder again, or copying + * the project elsewhere. + */ + +const AT = "2026-10-08T10:00:00.000Z" as Instant; + +function refusedSession(stopCode: string | undefined): WidgetDevSessionView { + return { + sessionId: "wdev_1", + status: "stopped", + stopReason: "root-refused", + ...(stopCode === undefined ? {} : { stopCode }), + root: "/home/me/timer", + startedAt: AT, + activation: { state: "none" }, + showingLastKnownGood: false, + }; +} + +function refusedRow(stopCode: string | undefined, locale: "en" | "vi") { + const card = developFolderCard({ cardId: "card_1", at: AT, locale, chosen: [], sessions: [refusedSession(stopCode)] }); + const row = card.rows.find((candidate) => candidate.rowId === "session:wdev_1"); + expect(row).toBeDefined(); + return { note: row?.note ?? "", actions: row?.actions ?? [] }; +} + +describe("the develop card's row for a session refused at a restart", () => { + it("offers to choose the folder again when Clark is no longer allowed to watch it on its own", () => { + const en = refusedRow("ROOT_NOT_OWNED", "en"); + expect(en.note).toContain("Clark is no longer allowed to watch this folder on its own"); + expect(en.note).toContain("what it built keeps running"); + expect(en.note).toContain("Press Develop again to choose the folder again"); + expect(en.note).not.toContain("may no longer"); + expect(en.actions).toMatchObject([{ action: { kind: "develop-folder", root: "/home/me/timer" } }]); + expect(refusedRow("ROOT_NOT_OWNED", "vi").note).toContain("Bấm Phát triển lại để chọn lại thư mục"); + }); + + it("says to copy the project, with no button, when the folder now leads to a network share or into the data folder", () => { + for (const [code, where, whereVi] of [ + ["ROOT_NOT_LOCAL", "network share or device path", "thư mục chia sẻ qua mạng"], + ["ROOT_IN_DATA_FOLDER", "Clark's data folder", "thư mục dữ liệu của Clark"], + ] as const) { + const en = refusedRow(code, "en"); + expect(en.note, code).toContain(where); + expect(en.note, code).toContain("choosing it again is refused too"); + expect(en.note, code).toContain("what it built keeps running"); + expect(en.note, code).toContain("Copy the project into Clark's widget workspace"); + expect(en.note, code).not.toContain("Press Develop again"); + // Choosing it again would only be refused, so nothing is offered to press. + expect(en.actions, code).toEqual([]); + const vi = refusedRow(code, "vi"); + expect(vi.note, code).toContain(whereVi); + expect(vi.note, code).toContain("chọn lại nó cũng bị từ chối"); + expect(vi.actions, code).toEqual([]); + } + }); + + it("says what holds for every case when the stop names no code it knows", () => { + for (const code of [undefined, "ROOT_SOMETHING_NEW"]) { + const en = refusedRow(code, "en"); + expect(en.note).toContain("it could no longer watch this folder"); + expect(en.note).toContain("if that is refused, copy the project into Clark's widget workspace"); + expect(en.actions).toMatchObject([{ action: { kind: "develop-folder" } }]); + expect(refusedRow(code, "vi").note).toContain("không thể theo dõi thư mục này nữa"); + } + }); +}); + +describe("what Clark is told about a session refused at a restart", () => { + it("asks the person to choose the folder again only when that helps", () => { + expect(describeDevSession(refusedSession("ROOT_NOT_OWNED"))).toContain("ask the person to choose its folder themselves"); + for (const code of ["ROOT_NOT_LOCAL", "ROOT_IN_DATA_FOLDER"]) { + const said = describeDevSession(refusedSession(code)); + expect(said, code).toContain("choosing it again is refused too"); + expect(said, code).toContain("copy the project into the widget workspace"); + expect(said, code).not.toContain("choose its folder themselves"); + } + expect(describeDevSession(refusedSession("ROOT_NOT_LOCAL"))).toContain("network share"); + expect(describeDevSession(refusedSession("ROOT_IN_DATA_FOLDER"))).toContain("data folder"); + }); + + it("says what holds for every case when the stop names no code it knows", () => { + for (const code of [undefined, "ROOT_SOMETHING_NEW"]) { + const said = describeDevSession(refusedSession(code)); + expect(said, code).toContain("the node could no longer watch its folder"); + expect(said, code).toContain("ask the person to develop it again with /develop; if that is refused too, ask them to copy the project into the widget workspace"); + expect(said, code).not.toContain("choose its folder themselves"); + } + }); +}); \ No newline at end of file diff --git a/apps/runtime/test/widget-dev-sessions.spec.ts b/apps/runtime/test/widget-dev-sessions.spec.ts index faaa7a2d..dfdf778b 100644 --- a/apps/runtime/test/widget-dev-sessions.spec.ts +++ b/apps/runtime/test/widget-dev-sessions.spec.ts @@ -861,7 +861,7 @@ describe("a widget dev session", () => { await services.widgetDev.resume(); const views = ((await call("GET", "/widget-dev/sessions")).body as { sessions: WidgetDevSessionView[] }).sessions; - expect(views.find((view) => view.sessionId === chosen.sessionId)).toMatchObject({ status: "stopped", stopReason: "root-refused" }); + expect(views.find((view) => view.sessionId === chosen.sessionId)).toMatchObject({ status: "stopped", stopReason: "root-refused", stopCode: "ROOT_NOT_OWNED" }); const card = services.widgetDev.folderCard({ locale: "en" }); const row = card.rows.find((candidate) => candidate.rowId === `session:${chosen.sessionId}`); expect(row?.note).toContain("Press Develop again to choose the folder again"); @@ -870,8 +870,14 @@ describe("a widget dev session", () => { "Bấm Phát triển lại để chọn lại thư mục", ); - // That press, the person's own start, chooses the folder now at the path. - session(await call("POST", "/widget-dev/sessions", { root: elsewhere })); + // That press, the person's own start, chooses the folder now at the path, and the stop and its code are gone. + const again = session(await call("POST", "/widget-dev/sessions", { root: elsewhere })); + expect(again).toMatchObject({ sessionId: chosen.sessionId, status: "live" }); + expect(again.stopReason).toBeUndefined(); + expect(again.stopCode).toBeUndefined(); + const restarted = readDevSessions(join(dir, "node")).find((stored) => stored.sessionId === chosen.sessionId); + expect(restarted?.stopReason).toBeUndefined(); + expect(restarted?.stopCode).toBeUndefined(); expect(services.widgetDev.chosen()).toEqual([elsewhere]); }); @@ -1338,9 +1344,15 @@ describe("a widget dev session", () => { await services.widgetDev.resume(); const views = ((await call("GET", "/widget-dev/sessions")).body as { sessions: WidgetDevSessionView[] }).sessions; - expect(views.find((view) => view.sessionId === "wdev_clark")).toMatchObject({ status: "stopped", stopReason: "root-refused" }); - expect(views.find((view) => view.sessionId === "wdev_data")).toMatchObject({ status: "stopped", stopReason: "root-refused" }); + expect(views.find((view) => view.sessionId === "wdev_clark")).toMatchObject({ status: "stopped", stopReason: "root-refused", stopCode: "ROOT_NOT_OWNED" }); + expect(views.find((view) => view.sessionId === "wdev_data")).toMatchObject({ status: "stopped", stopReason: "root-refused", stopCode: "ROOT_IN_DATA_FOLDER" }); expect(views.find((view) => view.sessionId === "wdev_person")).toMatchObject({ status: "live" }); + // Choosing the data folder again is refused too, so its row says to copy the project and offers no button that could only fail. + const rows = services.widgetDev.folderCard({ locale: "en" }).rows; + const dataRow = rows.find((row) => row.rowId === "session:wdev_data"); + expect(dataRow?.note).toContain("choosing it again is refused too"); + expect(dataRow?.actions).toEqual([]); + expect(rows.find((row) => row.rowId === "session:wdev_clark")?.note).toContain("Press Develop again to choose the folder again"); }); it("stops as folder-gone, rather than building again, when the folder is deleted before a rebuild", async () => { diff --git a/docs/manifest.json b/docs/manifest.json index ab4b3725..418c260d 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -140,13 +140,13 @@ }, { "path": "widget-development.md", - "bytes": 312173, - "sha256": "cca5d34ef381ed8f6182f616ede20bb87a3e84f2e24325a076f96bbf4f9888bc" + "bytes": 312730, + "sha256": "fbe6959f48d4a18874146df72463646cc8cc1474f001e906c3fbdda5a0670a74" }, { "path": "widget-development.vi.md", - "bytes": 377252, - "sha256": "c48473e1aba0d4005c32cd0de1e098203c2cf6b52e7204bef7a3ae5e9f75cae6" + "bytes": 378079, + "sha256": "0dc42d5116e02a88ba35bc3c3601e41b05e3e37e1b69cf94887113540bcb9a4d" }, { "path": "widgets-and-extensions.md", diff --git a/docs/open-interfaces.md b/docs/open-interfaces.md index 33138827..bccbc0ed 100644 --- a/docs/open-interfaces.md +++ b/docs/open-interfaces.md @@ -891,7 +891,7 @@ and shown in the conversation in the production widget frame. Shapes are `widget |---|---| | `POST /widget-dev/sessions` `{ "root", "conversationId"?, "widgetId"? }` | Start watching `root` (an absolute path on the node). Answers `201` with the session once its first build ran and was activated as far as the policy allows. With `conversationId`, the widget is placed there (pinned open) once a generation runs. Starting a folder that has a stopped session picks that session up again. | | `GET /widget-dev/sessions` | `{ sessions: [...] }`. | -| `GET /widget-dev/sessions/:id` | One session: `latest` (newest good build), `running` (the generation the node runs), `activation` (`active`, `awaiting-approval` with `approvalId`, `refused` with `code` and `message`, or `none`), `lastBuild` (with `diagnostics` when it failed), `showingLastKnownGood`, `placed`, and for a stopped session `stopReason` (`requested`, `watch-failed`, `folder-gone`, `capacity` or `root-refused`). Reading changes nothing: it neither builds, installs nor follows an answer. The session follows an answer from the inbox on its own within about two seconds. | +| `GET /widget-dev/sessions/:id` | One session: `latest` (newest good build), `running` (the generation the node runs), `activation` (`active`, `awaiting-approval` with `approvalId`, `refused` with `code` and `message`, or `none`), `lastBuild` (with `diagnostics` when it failed), `showingLastKnownGood`, `placed`, and for a stopped session `stopReason` (`requested`, `watch-failed`, `folder-gone`, `capacity` or `root-refused`, which may carry `stopCode`; see below). Reading changes nothing: it neither builds, installs nor follows an answer. The session follows an answer from the inbox on its own within about two seconds. | | `DELETE /widget-dev/sessions/:id` | Stop watching (`stopReason: "requested"`). The running generation stays installed and keeps rendering where it was placed. | | `POST /widget-dev/sessions/:id/rebuild` | Build the folder now. `409 SESSION_STOPPED` for a stopped session. | | `POST /widget-dev/sessions/:id/place` `{ "conversationId", "widgetId"? }` | Place the running widget in a conversation. | @@ -926,19 +926,24 @@ Refusals: `400 ROOT_NOT_ABSOLUTE`, `400 ROOT_NOT_A_FOLDER`, `404 ROOT_NOT_FOUND` file id (`chosenFolderId` in the session store). If that path later leads somewhere else (the folder was replaced by a link or junction, moved or removed), or holds another folder (one made there after the chosen one went), the choice no longer counts, so neither a swapped link nor a folder made in its place can widen it (within the limit of the - folder id, below). It is still listed, as not - found, so the person can forget it; if that same folder comes back at that path (moved back), the choice counts - again. A choice stored before folder ids were kept has none: it takes the id of the folder found at its path the - first time one is, and from then on is held to that folder. A new folder at the path is chosen when the person starts - it themselves, which records its id (also for a session still live there). + folder id, below). It is still listed, as not found, so the person can forget it; if that same folder comes back at + that path (moved back), the choice counts again. A choice stored before folder ids were kept has none: it takes the + id of the folder found at its path the first time one is, and from then on is held to that folder. A new folder at + the path is chosen when the person starts it themselves, which records its id (also for a session still live there). - **Limit of the folder id.** The id tells folders apart only while the filesystem does not give a later folder the - same one. NTFS (Windows) gives a folder made again a new id. Linux filesystems such as ext4 can give a folder deleted - (not moved) and made again at once at the same path the id the deleted one had, and then it counts as the chosen - folder. The node holds a watched folder open on Linux and macOS, so this cannot happen while a session watches it; - it can between sessions or while the node is stopped. No second signal is added: a folder's birth time is no finer - than the filesystem's clock tick (on ext4 a folder deleted and made again within one tick has the same id and birth - time), and where it cannot be read Node reports the change time, which moves with every file added or removed. Forget - a folder before deleting it if another folder made there must not inherit the choice. + same one. NTFS (Windows) gives a folder made again a new id. Some filesystems can give a folder deleted (not moved) + and made again at the same path the id the deleted one had, and then it counts as the chosen folder: + - Linux filesystems such as ext4 can hand a freed file id back at any later time, not only at once: for example to a + fresh `git clone` into the same path the next day. The node holds a watched folder open on Linux and macOS, so this + cannot happen while a session watches it; it can between sessions or while the node is stopped. + - On Windows, FAT32 and exFAT drives (such as USB sticks) keep no lasting file id, so a folder made again at the same + path can get the same id. Nothing holds a folder open on Windows, so this can happen even while a session watches + it. + + No second signal is added: a folder's birth time is no finer than the filesystem's clock tick (on ext4 a folder + deleted and made again within one tick has the same id and birth time), and where it cannot be read Node reports the + change time, which moves with every file added or removed. Forget a folder before deleting it if another folder made + there must not inherit the choice. - The person takes a choice back with `POST /widget-dev/chosen-folders/forget`, the **Forget** button on the card `/develop forget` answers with, or the same card Clark shows when asked in words (`develop_widget` action `folders`). Forgetting does not stop a running session. A folder inside another chosen folder, or inside a @@ -1012,11 +1017,30 @@ reading as live: for up to 30 seconds of continuous failures; after that the session stops as `watch-failed`. The same reason applies when the folder is missing after a restart. - `capacity`: the node is already watching eight folders as it resumes. -- `root-refused`: after a restart, the folder fails the same check a start makes. For example, a session Clark started - whose folder is outside the widget workspace, or in a chosen folder that was deleted (or moved away) and made again - at the same path while the node was stopped: the folder there is not the one chosen, so Clark may not watch it on its - own. What the session ran keeps running. The status line and the session's row on the `develop` card tell the person - to choose the folder again; their own start (**Develop again**) chooses the folder now at the path. +- `root-refused`: after a restart, the folder fails the same check a start makes. What the session ran keeps running. + The session view's `stopCode` names the check, and the status line, the session's row on the `develop` card and what + Clark is told say what helps for each: + - `ROOT_NOT_OWNED`: a session Clark started whose folder is outside the widget workspace and every folder the person + chose, for example a chosen folder deleted (or moved away) and made again at the same path while the node was + stopped. The folder there is not the one chosen, so Clark is not allowed to watch it on its own. The person's own + start (**Develop again**) chooses the folder now at the path; copying the project into the widget workspace also + works. + - `ROOT_NOT_LOCAL` or `ROOT_IN_DATA_FOLDER`: the folder now resolves to a network share or device path, or holds or + lies inside the node's data folder. Nobody's start may watch it, so choosing it again is refused too and the row + offers no **Develop again**; the person copies the project into the widget workspace or another local project + 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 session view is parsed with a strict schema, so the + client and the node ship as one build rather than tolerating each other's unknown fields. 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 + `sessions.json.unreadable-