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
40 changes: 30 additions & 10 deletions apps/runtime/src/application/widget-dev-card.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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}`,
Expand All @@ -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 } }],
});
Expand Down
15 changes: 10 additions & 5 deletions apps/runtime/src/application/widget-dev-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -825,16 +826,20 @@ 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);
/*
* 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 } : {}) }));
};

/**
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions apps/runtime/src/application/widget-dev-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 10 additions & 5 deletions apps/runtime/src/develop-widget-tool.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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.");
Expand Down
99 changes: 99 additions & 0 deletions apps/runtime/test/widget-dev-root-refused.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
}
});
});
Loading
Loading