diff --git a/apps/runtime/src/application/widget-dev-sessions.ts b/apps/runtime/src/application/widget-dev-sessions.ts index 57cd0a97..49775e33 100644 --- a/apps/runtime/src/application/widget-dev-sessions.ts +++ b/apps/runtime/src/application/widget-dev-sessions.ts @@ -35,6 +35,7 @@ import { type NodeServices } from "../services.ts"; import { placeWidget } from "../widget-perform-tool.ts"; import { installPackage, packageInstallDepsOf, type ApprovedInstall } from "./package-install.ts"; import { + WIDGET_DEV_DIRECTORY_SOURCE, WIDGET_DEV_SNAPSHOTS_MAX, WIDGET_DEV_STORE_MAX, readDevSessions, @@ -518,15 +519,17 @@ export function createWidgetDevSessions( const approval = approvalState(consentApproval); return approval !== undefined && approval.decision === "granted" && approval.operationDigest === scope ? consentApproval : undefined; })(); + // The session installs its own listing, so it names the dev source: a listing another source owns is never taken. + const sourceId = WIDGET_DEV_DIRECTORY_SOURCE.id; const approved: ApprovedInstall | undefined = - granted === undefined ? undefined : { approvalId: granted, digest: scope, localDigest: latest.generation.digest }; + granted === undefined ? undefined : { approvalId: granted, digest: scope, localDigest: latest.generation.digest, sourceId }; const intent = intentOf(stored); let outcome: Awaited>; try { outcome = await installPackage( packageInstallDepsOf(services()), - { packageId: latest.listing.packageId, version: latest.listing.version, contentDigest: latest.generation.digest }, + { packageId: latest.listing.packageId, version: latest.listing.version, contentDigest: latest.generation.digest, sourceId }, { consentScope: scope, ...(approved === undefined ? {} : { approved }), ...(intent === undefined ? {} : { intent }) }, ); } catch (cause) { diff --git a/apps/runtime/src/application/widget-dev-store.ts b/apps/runtime/src/application/widget-dev-store.ts index 81244af6..c6a92e9a 100644 --- a/apps/runtime/src/application/widget-dev-store.ts +++ b/apps/runtime/src/application/widget-dev-store.ts @@ -11,7 +11,14 @@ import { widgetDevStopReasonSchema, type DirectoryEntry, } from "@clarkcant/contracts"; -import { directoryIndexPath, readDirectory, readDirectoryIndex, type DirectoryIndexState } from "@clarkcant/core"; +import { + directoryIndexPath, + listingKey, + readDirectory, + readDirectoryIndex, + type DirectoryIndexState, + type DirectoryOrigin, +} from "@clarkcant/core"; /** * Where a node keeps its live widget authoring sessions, and the directory view they add to. @@ -160,18 +167,32 @@ export function devListings(dataDir: string): DirectoryEntry[] { ]); } +/** + * The source every dev listing names. Like any other source, it is recorded on what is installed from it and checked by + * the install path, so an install from a row that named another source (a marketplace card) never takes a session's + * build of the same id and version, and a session's own install names this source to take its build. + */ +export const WIDGET_DEV_DIRECTORY_SOURCE: DirectoryOrigin = { + id: "widget-dev", + kind: "widget-dev", + label: "this node's widget dev sessions", +}; + /** * The directory as this node resolves installed packages: every configured source (`readDirectory`), with the listings - * of its widget dev sessions in front. With no source configured and no sessions, the answer is the directory's own - * `not-configured`. The person's own index file that cannot be read keeps the directory unreadable rather than shrinking - * to the dev listings; remote sources that have nothing to list yet (never fetched, unreachable) do not hide a session's - * generations, since local development must work without any marketplace. + * of its widget dev sessions in front, each owned by `WIDGET_DEV_DIRECTORY_SOURCE`. With no source configured and no + * sessions, the answer is the directory's own `not-configured`. The person's own index file that cannot be read keeps + * the directory unreadable rather than shrinking to the dev listings; remote sources that have nothing to list yet (never + * fetched, unreachable) do not hide a session's generations, since local development must work without any marketplace. */ export function readNodeDirectory(dataDir: string, env: NodeJS.ProcessEnv = process.env): DirectoryIndexState { const index = readDirectory({ env, dataDir }); const dev = devListings(dataDir); if (dev.length === 0) return index; - if (index.kind === "not-configured") return { kind: "configured", directory: widgetDevStoreDir(dataDir), entries: dev }; + const devOrigins = dev.map((entry): [string, DirectoryOrigin] => [listingKey(entry), WIDGET_DEV_DIRECTORY_SOURCE]); + if (index.kind === "not-configured") { + return { kind: "configured", directory: widgetDevStoreDir(dataDir), entries: dev, origins: new Map(devOrigins) }; + } if (index.kind === "unreadable") { const indexPath = directoryIndexPath(env); if (indexPath !== undefined && readDirectoryIndex(indexPath).kind !== "configured") return index; @@ -179,10 +200,12 @@ export function readNodeDirectory(dataDir: string, env: NodeJS.ProcessEnv = proc kind: "configured", directory: index.directory, entries: dev, + origins: new Map(devOrigins), ...(index.sources === undefined ? {} : { sources: index.sources }), }; } - return { ...index, entries: [...dev, ...index.entries] }; + // The dev listings come first, so the same listing key is the session's: the entry found first is the one it names. + return { ...index, entries: [...dev, ...index.entries], origins: new Map([...(index.origins ?? []), ...devOrigins]) }; } /** diff --git a/apps/runtime/test/widget-dev-sessions.spec.ts b/apps/runtime/test/widget-dev-sessions.spec.ts index 368f8f5f..9b162b99 100644 --- a/apps/runtime/test/widget-dev-sessions.spec.ts +++ b/apps/runtime/test/widget-dev-sessions.spec.ts @@ -1,8 +1,9 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { createServer, type Server } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_EXECUTION_POLICY_CONFIG, @@ -12,7 +13,16 @@ import { type WidgetDefinition, type WidgetDevSessionView, } from "@clarkcant/contracts"; -import { EXECUTION_POLICY_PREFERENCE_KEY, setPreference, writeRegisteredPreference } from "@clarkcant/core"; +import { + DIRECTORY_FEED_FORMAT, + EXECUTION_POLICY_PREFERENCE_KEY, + OFFICIAL_MARKETPLACE_FEED_URL, + customFeedId, + listInstalledPackages, + refreshDirectory, + setPreference, + writeRegisteredPreference, +} from "@clarkcant/core"; import { createWidgetDevSessions } from "../src/application/widget-dev-sessions.ts"; import { WIDGET_DEV_STORE_MAX, readDevSessions, writeDevSessions } from "../src/application/widget-dev-store.ts"; @@ -645,3 +655,126 @@ describe("a widget dev session", () => { expect(stopped).toMatchObject({ status: "stopped", stopReason: "folder-gone", activation: { state: "active", generation: 1 } }); }); }); + +describe("the directory a widget dev session lists its builds in", () => { + const restoreEnv: Record = {}; + const servers: Server[] = []; + + /** Set (or, with no value, remove) a directory setting for this test only. */ + function setEnv(name: string, value?: string): void { + if (!(name in restoreEnv)) restoreEnv[name] = process.env[name]; + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + + /** A marketplace feed on loopback that lists this package's id and version under another publisher's archive. */ + async function startFeed(): Promise { + const listing = { + packageId: PACKAGE, + version: VERSION, + displayName: "Timer", + description: "Someone else's timer.", + source: { kind: "npm", name: "timer", version: VERSION }, + publisher: { id: "someone-else", sourceUrl: "https://example.org", license: "MIT" }, + preview: {}, + facets: ["ui"], + isolations: [{ facetKind: "ui", isolation: "isolated-ui" }], + platforms: ["darwin-arm64", "linux-x64", "win32-x64", "web"], + hostApi: { min: 1, max: 1 }, + permissionsSummary: [], + riskTier: "isolated-ui", + sizeBytes: 1024, + digest: `sha256:${"1".repeat(64)}`, + }; + const server = createServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ format: DIRECTORY_FEED_FORMAT, entries: [listing], nextCursor: null })); + }); + servers.push(server); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (address === null || typeof address === "string") throw new Error("the feed did not bind"); + return `http://127.0.0.1:${String(address.port)}/api/v1/directory`; + } + + const installedSource = () => + listInstalledPackages({ + db: services.runtime.db, + nodeId: services.runtime.identity.nodeId, + now: () => new Date().toISOString() as Instant, + newId: (prefix: string) => `${prefix}_x`, + }).find((installed) => installed.packageId === PACKAGE)?.directorySource; + + /** Every address the node fetched that is not this machine's loopback; none of them is reached. */ + let offLoopback: string[]; + + beforeEach(() => { + // The node's defaults: no index file, no feed of the person's own, and the official Marketplace on. + setEnv("CC_DIRECTORY_INDEX"); + setEnv("CC_DIRECTORY_MARKETPLACES"); + setEnv("CC_OFFICIAL_MARKETPLACE"); + // A test run never reaches the live Marketplace: it answers as an unavailable one, and only loopback feeds are real. + offLoopback = []; + const realFetch = globalThis.fetch; + vi.spyOn(globalThis, "fetch").mockImplementation((input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (new URL(url).origin.startsWith("http://127.0.0.1")) return realFetch(input, init); + offLoopback.push(url); + return Promise.resolve(new Response("unavailable in tests", { status: 503 })); + }); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + for (const [name, value] of Object.entries(restoreEnv)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + delete restoreEnv[name]; + } + await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(resolve)))); + }); + + it("activates and serves the folder on a fresh node whose Marketplace was never fetched", async () => { + const conversationId = await conversation(); + const started = session(await call("POST", "/widget-dev/sessions", { root, conversationId })); + expect(started).toMatchObject({ status: "live", activation: { state: "active", generation: 1 } }); + + const frame = await live(conversationId, started.placed?.instanceId ?? ""); + expect(await served(frame.frame.url)).toContain("first"); + }); + + it("refuses to activate while the person's index file cannot be read", async () => { + const indexPath = join(dir, "index.json"); + writeFileSync(indexPath, "[ {broken"); + setEnv("CC_DIRECTORY_INDEX", indexPath); + + const started = session(await call("POST", "/widget-dev/sessions", { root })); + expect(started.activation).toMatchObject({ state: "refused", code: "DIRECTORY_UNREADABLE" }); + expect(started.running).toBeUndefined(); + // The install looked for the listing in the Marketplace too, so the Marketplace was on. + expect(offLoopback.some((url) => url.startsWith(OFFICIAL_MARKETPLACE_FEED_URL))).toBe(true); + }); + + it("names its own source, so an install from a row naming a marketplace never takes the session's build", async () => { + const feedUrl = await startFeed(); + setEnv("CC_DIRECTORY_MARKETPLACES", feedUrl); + + // The feed was never fetched, so nothing tells the session the id and version are listed elsewhere. + const started = session(await call("POST", "/widget-dev/sessions", { root })); + expect(started.activation).toMatchObject({ state: "active", generation: 1 }); + expect(installedSource()).toMatchObject({ id: "widget-dev", kind: "widget-dev" }); + + // Fetched now, the marketplace lists the same id and version; the person presses Install on its card. + await refreshDirectory({ env: process.env, dataDir: join(dir, "node") }); + expect(offLoopback.some((url) => url.startsWith(OFFICIAL_MARKETPLACE_FEED_URL))).toBe(true); + const pressed = await call("POST", "/packages/install", { packageId: PACKAGE, version: VERSION, sourceId: customFeedId(feedUrl) }); + expect(pressed.status).toBe(409); + expect(pressed.body).toMatchObject({ code: "DIRECTORY_SOURCE_CHANGED" }); + expect(installedSource()?.id).toBe("widget-dev"); + + // And the session's next build is refused for the id the directory now lists. + writePackage("

second

\n"); + const rebuilt = session(await call("POST", `/widget-dev/sessions/${started.sessionId}/rebuild`)); + expect(rebuilt.activation).toMatchObject({ code: "PACKAGE_LISTED" }); + }); +}); diff --git a/docs/manifest.json b/docs/manifest.json index 900e28a8..44b9c210 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -140,13 +140,13 @@ }, { "path": "widget-development.md", - "bytes": 300030, - "sha256": "c2f3681dd6d6a83a3001a2421c2bd86cf227c79f27c1e137abf6f686f788424c" + "bytes": 300253, + "sha256": "3ae20bf49fadcf93457363532f9de0addb596a49e049c79f8feaac60014c6c33" }, { "path": "widget-development.vi.md", - "bytes": 361790, - "sha256": "fd65b7be725718d7eefb3fcbbb4a4696c8e0d077c9c361232b7d104a7b408ef1" + "bytes": 362022, + "sha256": "ae79a88ee73c168b5fcf47e3fa22bb26069d8a27daf0ed7a2235c78210787402" }, { "path": "widgets-and-extensions.md", diff --git a/docs/open-interfaces.md b/docs/open-interfaces.md index ffa7a97e..17c1df0a 100644 --- a/docs/open-interfaces.md +++ b/docs/open-interfaces.md @@ -706,7 +706,11 @@ row. Without one, it answers `409 DIRECTORY_SOURCE_UNREAD` when an earlier sourc `409 DIRECTORY_SOURCE_CHANGED` when the package is installed from a different source. With one, it answers `409 DIRECTORY_SOURCE_CHANGED` when another source owns the listing by now. The node records the source on the installed generation (`directorySource`), and update notices come only from that source; a generation installed -before sources were recorded counts as installed from the index file. An install approval holds the source that owned +before sources were recorded counts as installed from the index file. The builds of the node's +[widget dev sessions](#widget-dev-sessions) are listed in front of every other source under their own source, +`widget-dev` (kind `widget-dev`), and only on this node, never in a search: an install whose `sourceId` names another +source answers `409 DIRECTORY_SOURCE_CHANGED` rather than taking a session's build of the same id and version. An +install approval holds the source that owned the listing when the person was asked, and `POST /packages/approvals/{id}/decision` with `granted` answers `409 DIRECTORY_SOURCE_CHANGED` when another source owns it by then. A listing from a marketplace installs through exactly the same checks as one from a file. diff --git a/docs/open-interfaces.vi.md b/docs/open-interfaces.vi.md index 03c7660b..b7fa90b0 100644 --- a/docs/open-interfaces.vi.md +++ b/docs/open-interfaces.vi.md @@ -713,7 +713,10 @@ nguồn mà người dùng đã chọn khi bấm Cài trên một dòng. Không nguồn đứng trước không đọc được, và `409 DIRECTORY_SOURCE_CHANGED` khi gói đang được cài từ một nguồn khác. Có nó, route trả `409 DIRECTORY_SOURCE_CHANGED` khi giờ đây một nguồn khác sở hữu listing đó. Node ghi nguồn lên generation đã cài (`directorySource`), và thông báo cập nhật chỉ đến từ chính nguồn đó; generation được cài trước khi nguồn được -ghi lại được coi là cài từ file index. Một yêu cầu chấp thuận cài đặt giữ nguồn sở hữu listing lúc người dùng được hỏi, +ghi lại được coi là cài từ file index. Các bản build của [phiên phát triển widget](#phiên-phát-triển-widget) trên node được +liệt kê trước mọi nguồn khác dưới nguồn riêng của chúng, `widget-dev` (kind `widget-dev`), và chỉ trên node này, không +bao giờ xuất hiện trong kết quả tìm kiếm: một lần cài có `sourceId` chỉ tới nguồn khác sẽ trả +`409 DIRECTORY_SOURCE_CHANGED` thay vì lấy bản build của phiên có cùng id và version. Một yêu cầu chấp thuận cài đặt giữ nguồn sở hữu listing lúc người dùng được hỏi, và `POST /packages/approvals/{id}/decision` với `granted` trả `409 DIRECTORY_SOURCE_CHANGED` khi lúc đó một nguồn khác đã sở hữu listing. Một listing từ marketplace được cài qua đúng những bước kiểm tra như listing từ file. diff --git a/docs/widget-development.md b/docs/widget-development.md index b2df719f..f991132a 100644 --- a/docs/widget-development.md +++ b/docs/widget-development.md @@ -2892,7 +2892,9 @@ widget"), or call `POST /widget-dev/sessions` as the owner ([open interfaces](open-interfaces.md#widget-dev-sessions)). The node watches the folder with the same build engine the dev host uses: every change that reads as a package becomes an immutable generation named by the digest of its files, is copied into the node's package cache and listed for this node alone, and is installed through the ordinary -install path. Nothing goes through npm, the Marketplace or a directory index, and nothing is published. A root +install path. Those listings name their own source, `widget-dev`, which the installed generation records, so pressing +Install on a marketplace row that lists the same id and version is refused rather than installing the session's files. +Nothing goes through npm, the Marketplace or a directory index, and nothing is published. A root `node_modules` folder is left out of each build; built output such as `dist` is kept. Clark develops widgets in its own widget workspace (`/widget-workspace`), where it scaffolds a new widget. To diff --git a/docs/widget-development.vi.md b/docs/widget-development.vi.md index 0afb1fba..679dcc8f 100644 --- a/docs/widget-development.vi.md +++ b/docs/widget-development.vi.md @@ -2878,7 +2878,9 @@ Dev host mô phỏng một node. Để xem widget trên chính node, hãy nhờ hẹn giờ"), hoặc gọi `POST /widget-dev/sessions` với tư cách chủ máy ([giao diện mở](open-interfaces.vi.md#phiên-phát-triển-widget)). Node theo dõi thư mục bằng cùng build engine mà dev host dùng: mỗi thay đổi đọc được thành một gói trở thành một generation bất biến mang tên theo digest các tệp của nó, được sao -chép vào bộ nhớ đệm gói của node, chỉ được liệt kê cho riêng node này, và được cài qua đường cài thông thường. Không có gì +chép vào bộ nhớ đệm gói của node, chỉ được liệt kê cho riêng node này, và được cài qua đường cài thông thường. Các listing +đó mang nguồn riêng, `widget-dev`, được ghi lên generation đã cài, nên bấm Cài trên một dòng marketplace liệt kê cùng id +và version sẽ bị từ chối thay vì cài các tệp của phiên. Không có gì đi qua npm, Marketplace hay một directory index, và không có gì được phát hành. Thư mục `node_modules` ở gốc bị loại khỏi mỗi lần dựng; đầu ra đã dựng như `dist` vẫn được giữ. diff --git a/packages/contracts/src/install.ts b/packages/contracts/src/install.ts index 051f4c79..a1716e11 100644 --- a/packages/contracts/src/install.ts +++ b/packages/contracts/src/install.ts @@ -750,16 +750,17 @@ export function previousGenerationSurvives(failedAt: InstallState): boolean { /** * The kinds of source a node's directory is composed from: the person's own index file (`CC_DIRECTORY_INDEX`), a - * marketplace or catalog feed they configured, and the official ClarkCant Marketplace. Every one of them is discovery - * data, never authority; the kind says where a listing came from, not how far it is trusted. + * marketplace or catalog feed they configured, and the official ClarkCant Marketplace — plus the node's own widget dev + * sessions, which list the builds of the folders being developed on it. Every one of them is discovery data, never + * authority; the kind says where a listing came from, not how far it is trusted. */ -export const directorySourceKindSchema = z.enum(["local-file", "official-marketplace", "custom-marketplace"]); +export const directorySourceKindSchema = z.enum(["local-file", "official-marketplace", "custom-marketplace", "widget-dev"]); export type DirectorySourceKind = z.infer; /** - * Which directory source a listing came from: its id on the node (`local`, `official`, or `custom-`, so two feeds never share one), its kind, and the name a person recognises (the index file's path, or the - * feed's host and path without its query). + * Which directory source a listing came from: its id on the node (`local`, `official`, `widget-dev`, or `custom-`, so two feeds never share one), its kind, and the name a person recognises (the index file's path, + * or the feed's host and path without its query). */ export const directorySourceRefSchema = z.strictObject({ id: z.string().min(1).max(120), diff --git a/packages/core/src/directory-index.ts b/packages/core/src/directory-index.ts index 34cd81cd..f3e959c8 100644 --- a/packages/core/src/directory-index.ts +++ b/packages/core/src/directory-index.ts @@ -55,7 +55,7 @@ export type DirectoryIndexState = /** Where a listing came from: which configured source, of which kind, under the name a person recognises. */ export interface DirectoryOrigin { - /** Stable id of the source on this node (`local`, `official`, `custom-`). */ + /** Stable id of the source on this node (`local`, `official`, `custom-`, `widget-dev`). */ id: string; kind: DirectorySourceKind; /** The index file's path, or the marketplace's name or address. */