From 6881dce10703d7a77bc79e01a065fa3a67ee0c81 Mon Sep 17 00:00:00 2001 From: Mikhail Kotelnikov Date: Sun, 2 Aug 2026 23:45:12 +0200 Subject: [PATCH 1/6] refactor(workspace.core): repoint dataflow imports to @statewalker/webrun-dataflow Rewrite the 3 builder import sites (project-builder, transaction-store, updates-store) from @statewalker/shared-dataflow to @statewalker/webrun-dataflow, and drop the @statewalker/shared-dataflow catalog entry from pnpm-workspace.yaml. The workspace.core/wiki.core package.json deps are dropped separately (Task 4/5). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../workspace.core/src/public/builders/project-builder.ts | 4 ++-- .../workspace.core/src/public/builders/transaction-store.ts | 2 +- packages/workspace.core/src/public/builders/updates-store.ts | 2 +- pnpm-workspace.yaml | 1 - 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/workspace.core/src/public/builders/project-builder.ts b/packages/workspace.core/src/public/builders/project-builder.ts index b134851d..8aba296f 100644 --- a/packages/workspace.core/src/public/builders/project-builder.ts +++ b/packages/workspace.core/src/public/builders/project-builder.ts @@ -1,4 +1,4 @@ -import { type CellDefinition, DataflowGraph, readCellUpdates } from "@statewalker/shared-dataflow"; +import { type CellDefinition, DataflowGraph, readCellUpdates } from "@statewalker/webrun-dataflow"; import { type FilesApi, joinPath, tryReadText } from "@statewalker/webrun-files"; import { type Logger, loggerOf } from "../types/logger.js"; import { DEFAULT_SYSTEM_FOLDER, type Project } from "../types/project.js"; @@ -66,7 +66,7 @@ const DEFAULT_YIELD_CONFIG: YieldConfig = { /** * The generic build engine, a project-level adapter resolved via * `project.requireAdapter(ProjectBuilder)`. Schedules signal-driven builders over - * `@statewalker/shared-dataflow`, drives centralized update / transaction stores + * `@statewalker/webrun-dataflow`, drives centralized update / transaction stores * (persisted under the project system folder), and provides generic source * change-detection. Knows nothing wiki-specific; a project's "nature" contributes * builders via `registerBuilder` / a `BuilderProvider`. diff --git a/packages/workspace.core/src/public/builders/transaction-store.ts b/packages/workspace.core/src/public/builders/transaction-store.ts index 9d7623a6..6710078e 100644 --- a/packages/workspace.core/src/public/builders/transaction-store.ts +++ b/packages/workspace.core/src/public/builders/transaction-store.ts @@ -1,4 +1,4 @@ -import type { CellId, TransactionStore } from "@statewalker/shared-dataflow"; +import type { CellId, TransactionStore } from "@statewalker/webrun-dataflow"; import type { FilesApi } from "@statewalker/webrun-files"; import { tryReadJson, writeJsonAtomic } from "./json-io.js"; diff --git a/packages/workspace.core/src/public/builders/updates-store.ts b/packages/workspace.core/src/public/builders/updates-store.ts index cf0ea4dc..401d7348 100644 --- a/packages/workspace.core/src/public/builders/updates-store.ts +++ b/packages/workspace.core/src/public/builders/updates-store.ts @@ -5,7 +5,7 @@ import { type SerializedUpdatesStore, type UpdateEntry, type UpdatesStore, -} from "@statewalker/shared-dataflow"; +} from "@statewalker/webrun-dataflow"; import type { FilesApi } from "@statewalker/webrun-files"; import { tryReadJson, writeJsonAtomic } from "./json-io.js"; diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5820e68e..67974b0a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -26,7 +26,6 @@ catalog: "@spectrum-icons/workflow": ^4.3.0 "@statewalker/shared-adapters": ^0.1.0 "@statewalker/shared-baseclass": ^0.1.0 - "@statewalker/shared-dataflow": ^0.1.0 "@statewalker/shared-logger": ^0.1.0 "@statewalker/shared-registry": ^0.1.0 "@statewalker/webrun-files": ^0.7.0 From cc1968d92e5f96ec3bc07ee107b31289dcb1cfb2 Mon Sep 17 00:00:00 2001 From: Mikhail Kotelnikov Date: Sun, 2 Aug 2026 23:50:09 +0200 Subject: [PATCH 2/6] =?UTF-8?q?chore(workbench):=20swap=20shared-dataflow?= =?UTF-8?q?=20dep=20=E2=86=92=20webrun-dataflow=20(workspace.core)=20+=20d?= =?UTF-8?q?rop=20unused=20(wiki.core)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folds the Task 4/5 dep-drop into Task 1 to keep the umbrella installable between tasks (removing the catalog entry orphans consumers otherwise). Baseline green: webrun-dataflow 131, workspace.core 81, wiki.core 170. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/wiki.core/package.json | 1 - packages/workspace.core/package.json | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/wiki.core/package.json b/packages/wiki.core/package.json index d55be813..919c990e 100644 --- a/packages/wiki.core/package.json +++ b/packages/wiki.core/package.json @@ -36,7 +36,6 @@ "@statewalker/indexer-fulltext": "catalog:", "@statewalker/indexer-mem-flexsearch": "catalog:", "@statewalker/indexer-vector": "catalog:", - "@statewalker/shared-dataflow": "catalog:", "@statewalker/shared-logger": "catalog:", "@statewalker/webrun-files": "catalog:", "@uwdata/flechette": "catalog:", diff --git a/packages/workspace.core/package.json b/packages/workspace.core/package.json index ad2bdb98..247535a5 100644 --- a/packages/workspace.core/package.json +++ b/packages/workspace.core/package.json @@ -33,8 +33,8 @@ "@statewalker/shared-adapters": "catalog:", "@statewalker/shared-baseclass": "catalog:", "@statewalker/shared-commands": "catalog:", - "@statewalker/shared-dataflow": "catalog:", "@statewalker/shared-logger": "catalog:", + "@statewalker/webrun-dataflow": "workspace:*", "@statewalker/webrun-files": "catalog:", "@statewalker/webrun-files-composite": "catalog:" }, From e82c2305eb3f09670205941120f8cec02b955415 Mon Sep 17 00:00:00 2001 From: Mikhail Kotelnikov Date: Mon, 3 Aug 2026 00:30:11 +0200 Subject: [PATCH 3/6] refactor(workspace.core): cut over to @statewalker/webrun-builder; ProjectBuilder = BuildEngine adapter Delete the 6 extracted builder files (now in webrun-builder); ProjectBuilder becomes a thin BuildEngine adapter (host=project) so requireAdapter + all wiki/webapp handlers stay unchanged. builders/index.ts uses explicit named re-exports (RegisteredBuilder/BuilderProvider/ BuilderHandler from ./types.js; Logger/NULL_LOGGER from ../types/logger.js) to shadow star-export collisions. workspace.core 79 (81-2 moved json-io), wiki.core 170 byte-identical. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/workspace.core/package.json | 1 + .../src/public/builders/index.ts | 16 +- .../src/public/builders/json-io.test.ts | 31 - .../src/public/builders/json-io.ts | 38 -- .../src/public/builders/project-builder.ts | 582 +----------------- .../src/public/builders/project-ignore.ts | 93 --- .../src/public/builders/transaction-store.ts | 75 --- .../src/public/builders/types.ts | 77 +-- .../src/public/builders/updates-store.ts | 95 --- 9 files changed, 45 insertions(+), 963 deletions(-) delete mode 100644 packages/workspace.core/src/public/builders/json-io.test.ts delete mode 100644 packages/workspace.core/src/public/builders/json-io.ts delete mode 100644 packages/workspace.core/src/public/builders/project-ignore.ts delete mode 100644 packages/workspace.core/src/public/builders/transaction-store.ts delete mode 100644 packages/workspace.core/src/public/builders/updates-store.ts diff --git a/packages/workspace.core/package.json b/packages/workspace.core/package.json index 247535a5..dedf6357 100644 --- a/packages/workspace.core/package.json +++ b/packages/workspace.core/package.json @@ -34,6 +34,7 @@ "@statewalker/shared-baseclass": "catalog:", "@statewalker/shared-commands": "catalog:", "@statewalker/shared-logger": "catalog:", + "@statewalker/webrun-builder": "workspace:*", "@statewalker/webrun-dataflow": "workspace:*", "@statewalker/webrun-files": "catalog:", "@statewalker/webrun-files-composite": "catalog:" diff --git a/packages/workspace.core/src/public/builders/index.ts b/packages/workspace.core/src/public/builders/index.ts index 225ad9ef..e2c9cbcf 100644 --- a/packages/workspace.core/src/public/builders/index.ts +++ b/packages/workspace.core/src/public/builders/index.ts @@ -1,7 +1,15 @@ +// Re-export the generic engine surface (values + host-agnostic types)… +export * from "@statewalker/webrun-builder"; +// …then explicitly re-export the Project-bound builder-type aliases. Explicit named +// exports shadow the star-exported generic names, so consumers keep their +// unparameterized `RegisteredBuilder` / `BuilderProvider` / `BuilderHandler` usage +// bound to `Project`. +export type { BuilderHandler, BuilderProvider, RegisteredBuilder } from "./types.js"; +// The engine's structural `Logger` / `NULL_LOGGER` collide with workspace.core's own +// (from `@statewalker/shared-logger`). Explicitly re-export the canonical ones so the +// package root keeps a single, unambiguous `Logger` — the engine only needs a logger +// injected, it never asks consumers to import its structural interface. +export { type Logger, NULL_LOGGER } from "../types/logger.js"; export * from "./nature.js"; export * from "./project-builder.js"; -export * from "./project-ignore.js"; export * from "./project-watcher.js"; -export * from "./transaction-store.js"; -export * from "./types.js"; -export * from "./updates-store.js"; diff --git a/packages/workspace.core/src/public/builders/json-io.test.ts b/packages/workspace.core/src/public/builders/json-io.test.ts deleted file mode 100644 index 4bd04119..00000000 --- a/packages/workspace.core/src/public/builders/json-io.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { MemFilesApi } from "@statewalker/webrun-files-mem"; -import { describe, expect, it } from "vitest"; -import { tryReadJson, writeJsonAtomic } from "./json-io.js"; - -describe("writeJsonAtomic", () => { - it("round-trips JSON and creates parent directories", async () => { - const files = new MemFilesApi(); - await writeJsonAtomic(files, "a/b/c.json", { n: 1 }); - expect(await tryReadJson(files, "a/b/c.json")).toEqual({ n: 1 }); - }); - - it("uses a distinct temp path per write so concurrent writers can't clobber it", async () => { - // A shared `${path}.tmp` lets two concurrent writers (tabs / CLI on the same folder) - // overwrite each other's temp file and race the rename, corrupting the target. Each - // write must stage through its own temp path; the final rename is last-writer-wins. - const files = new MemFilesApi(); - const tmpWrites: string[] = []; - const origWrite = files.write.bind(files); - files.write = ((path: string, content: never) => { - if (path.includes("scanner.json") && path !== "state/scanner.json") tmpWrites.push(path); - return origWrite(path, content); - }) as typeof files.write; - - await writeJsonAtomic(files, "state/scanner.json", { writer: 0 }); - await writeJsonAtomic(files, "state/scanner.json", { writer: 1 }); - - expect(tmpWrites).toHaveLength(2); - expect(new Set(tmpWrites).size).toBe(2); - expect(await tryReadJson(files, "state/scanner.json")).toEqual({ writer: 1 }); - }); -}); diff --git a/packages/workspace.core/src/public/builders/json-io.ts b/packages/workspace.core/src/public/builders/json-io.ts deleted file mode 100644 index 8830af50..00000000 --- a/packages/workspace.core/src/public/builders/json-io.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { dirname, type FilesApi, tryReadText, writeText } from "@statewalker/webrun-files"; - -/** Read a JSON file. Returns `undefined` if missing or unparseable. */ -export async function tryReadJson(files: FilesApi, path: string): Promise { - const text = await tryReadText(files, path); - if (text === undefined) return undefined; - try { - return JSON.parse(text) as T; - } catch { - return undefined; - } -} - -let tmpSeq = 0; -/** A per-write temp path. Concurrent writers (e.g. multiple tabs or the CLI on the same - * folder) must NOT share one `${path}.tmp` — they would clobber each other's temp file - * and race the rename, corrupting the target. The final rename is last-writer-wins. */ -function tempPath(path: string): string { - tmpSeq = (tmpSeq + 1) % Number.MAX_SAFE_INTEGER; - return `${path}.${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}-${tmpSeq}.tmp`; -} - -/** Atomic JSON write via temp + rename. Creates parent directories if missing. */ -export async function writeJsonAtomic( - files: FilesApi, - path: string, - value: unknown, -): Promise { - const parent = dirname(path); - if (parent && parent !== "/") await files.mkdir(parent); - const tmp = tempPath(path); - await writeText(files, tmp, JSON.stringify(value, null, 2)); - const moved = await files.move(tmp, path); - if (!moved) { - await files.remove(tmp).catch(() => {}); - throw new Error(`writeJsonAtomic: failed to rename ${tmp} to ${path}`); - } -} diff --git a/packages/workspace.core/src/public/builders/project-builder.ts b/packages/workspace.core/src/public/builders/project-builder.ts index 8aba296f..95471fcf 100644 --- a/packages/workspace.core/src/public/builders/project-builder.ts +++ b/packages/workspace.core/src/public/builders/project-builder.ts @@ -1,573 +1,23 @@ -import { type CellDefinition, DataflowGraph, readCellUpdates } from "@statewalker/webrun-dataflow"; -import { type FilesApi, joinPath, tryReadText } from "@statewalker/webrun-files"; -import { type Logger, loggerOf } from "../types/logger.js"; +import { BuildEngine } from "@statewalker/webrun-builder"; +import { loggerOf } from "../types/logger.js"; import { DEFAULT_SYSTEM_FOLDER, type Project } from "../types/project.js"; -import { tryReadJson, writeJsonAtomic } from "./json-io.js"; -import { makeProjectIgnore } from "./project-ignore.js"; -import { FileBackedTransactionStore } from "./transaction-store.js"; -import type { - BuilderUpdate, - BuildProgress, - BuildStatus, - RegisteredBuilder, - SignalName, -} from "./types.js"; -import { FileBackedUpdatesStore } from "./updates-store.js"; - -/** Reserved cell id of the built-in generic source scanner. */ -export const SCAN_CELL = "SourceScanner"; -/** Base signals emitted by the scanner (kebab-case). */ -export const SOURCES_SIGNAL: SignalName = "sources"; -export const SOURCES_REMOVED_SIGNAL: SignalName = "sources-removed"; - -interface Stores { - updates: FileBackedUpdatesStore; - transactions: FileBackedTransactionStore; - scannerState: Map; - scannerPath: string; -} - -/** - * Cooperative-yield throttle config. Defaults keep a long build from starving the - * event loop: a short pause every `pauseEvery` yields, and a re-run-requesting - * interrupt every `interruptEvery` yields. - */ -export interface YieldConfig { - /** Pause (await `pauseMs`) once every this many `yieldControl` calls. */ - pauseEvery: number; - /** Pause duration in ms. */ - pauseMs: number; - /** Return `false` (request interrupt + re-run) once every this many calls. */ - interruptEvery: number; - /** - * Abort `run()` after this many *consecutive* no-progress passes. A healthy build - * always makes progress (a stage drains, handles ≥1 update, or a scan surfaces - * work), so the counter only climbs when a builder is genuinely stuck (never - * draining) or repeatedly throws before handling anything — kept small to fail fast. - */ - maxStalledPasses: number; - /** - * Max changed sources the scanner emits per scan pass (`0` = unlimited). A - * positive value makes each batch its own transaction, so it traverses the whole - * pipeline — and the terminal stages dump their top-level artifacts — before the - * next batch is scanned. Trades more scan passes for incremental availability. - */ - scanBatchSize: number; -} - -const DEFAULT_YIELD_CONFIG: YieldConfig = { - pauseEvery: 10, - pauseMs: 10, - interruptEvery: 10, - maxStalledPasses: 8, - scanBatchSize: 0, -}; /** - * The generic build engine, a project-level adapter resolved via - * `project.requireAdapter(ProjectBuilder)`. Schedules signal-driven builders over - * `@statewalker/webrun-dataflow`, drives centralized update / transaction stores - * (persisted under the project system folder), and provides generic source - * change-detection. Knows nothing wiki-specific; a project's "nature" contributes - * builders via `registerBuilder` / a `BuilderProvider`. + * The project-level build engine, resolved via `project.requireAdapter(ProjectBuilder)`. + * A thin adapter that binds the generic `BuildEngine` to a `Project`: files, + * root path, system folder, and logger are read off the project, and the project + * itself is the `host` passed to every builder handler (`handler(project)`), so a + * project's "nature" keeps contributing builders via `registerBuilder` / a + * `BuilderProvider` exactly as before. */ -export class ProjectBuilder { - private readonly builders = new Map(); - private graph?: DataflowGraph; - private stores?: Stores; - private yieldCounter = 0; - private yieldCfg: YieldConfig = DEFAULT_YIELD_CONFIG; - private sourceIgnore?: () => Promise<(uri: string) => boolean>; - - constructor(readonly project: Project) {} - - /** Override the cooperative-yield throttle. */ - configureYield(partial: Partial): this { - this.yieldCfg = { ...this.yieldCfg, ...partial }; - return this; - } - - /** - * Inject an additional source-exclusion predicate, composed (logical OR) with the - * project's `.projectignore` during scanning. The provider is re-invoked at the - * start of every scan, so the underlying rules can be re-read each run; a uri the - * predicate excludes is treated exactly like a `.projectignore` match (kept out of - * the source set, and pruned via `sources-removed` if it was previously indexed). - * A project's "nature" uses this to contribute its own ignore policy (e.g. the - * wiki's nested `.indexignore`) without the generic engine knowing about it. - */ - configureSourceIgnore(provider: () => Promise<(uri: string) => boolean>): this { - this.sourceIgnore = provider; - return this; - } - - private get yieldConfig(): YieldConfig { - return this.yieldCfg; - } - - private get filesApi(): FilesApi { - return this.project.workspace.files; - } - - private get systemFolder(): string { - return DEFAULT_SYSTEM_FOLDER; - } - - private stateDir(): string { - return joinPath(this.project.path, this.systemFolder, "state"); - } - - /** Register a builder; returns an unregister function. */ - registerBuilder(builder: RegisteredBuilder): () => void { - if (builder.id === SCAN_CELL) { - throw new Error(`Builder id "${SCAN_CELL}" is reserved for the source scanner`); - } - this.builders.set(builder.id, builder); - this.graph = undefined; // topology changed - return () => { - this.builders.delete(builder.id); - this.graph = undefined; - }; - } - - private getGraph(): DataflowGraph { - if (!this.graph) { - const defs: CellDefinition[] = [ - { - id: SCAN_CELL, - inputs: [], - outputs: [SOURCES_SIGNAL, SOURCES_REMOVED_SIGNAL], - }, - ...[...this.builders.values()].map((b) => ({ - id: b.id, - inputs: [...b.inputs], - outputs: [...b.outputs], - })), - ]; - this.graph = new DataflowGraph(defs); - } - return this.graph; - } - - private async ensureStores(): Promise { - if (this.stores) return this.stores; - const dir = this.stateDir(); - const files = this.filesApi; - const updates = await FileBackedUpdatesStore.open(files, joinPath(dir, "updates.json")); - const transactions = await FileBackedTransactionStore.open( - files, - joinPath(dir, "transactions.json"), - ); - const scannerPath = joinPath(dir, "scanner.json"); - const saved = (await tryReadJson>(files, scannerPath)) ?? {}; - this.stores = { - updates, - transactions, - scannerState: new Map(Object.entries(saved)), - scannerPath, - }; - return this.stores; - } - - /** - * Read the un-handled updates on `signal` for builder `cell`, in URI order. - * Each yielded `BuilderUpdate.handled()` marks it consumed so it does not - * reappear on the next run. - */ - async *readUpdates(opts: { signal: SignalName; cell: string }): AsyncIterable { - const { updates } = await this.ensureStores(); - const { signal, cell } = opts; - for await (const e of updates.readUpdates({ - signal, - cell, - orderBy: "uri", - })) { - yield { - signal: e.signal, - uri: e.uri, - stamp: e.stamp, - handled: async () => { - await updates.handleUpdate({ - signal: e.signal, - uri: e.uri, - cell, - stamp: e.stamp, - }); - }, - }; - } - } - - /** - * Cooperative yield point for long-running builders. Builders MUST call this once - * per processed item. It pauses briefly every `pauseEvery` calls to release the - * event loop, and returns `false` every `interruptEvery` calls to request the - * builder interrupt (return `false`) so `run()` can re-seed it on the next pass. - */ - async yieldControl(): Promise { - const cfg = this.yieldConfig; - this.yieldCounter += 1; - if (cfg.pauseEvery > 0 && this.yieldCounter % cfg.pauseEvery === 0) { - await new Promise((r) => setTimeout(r, cfg.pauseMs)); - } - if (cfg.interruptEvery > 0 && this.yieldCounter % cfg.interruptEvery === 0) { - // Checkpoint at the cooperative interrupt: work done so far is made durable, - // so a build killed here resumes from this point instead of re-running. - if (this.stores) await this.flush(this.stores); - return false; - } - return true; - } - - /** - * Run the pipeline: the built-in scanner (mtime detection → `sources`) plus the - * registered builders, in signal-dependency order. Yields per-stage progress. - * - * Convergence drives every stage to the latest transaction — the **frontier**. - * Each advance flushes state immediately, so a build killed mid-run resumes where - * it stopped. When a scan surfaces no change, the pipeline has converged. - */ - async *run(opts?: { builders?: string[] }): AsyncGenerator { - const stores = await this.ensureStores(); - const graph = this.getGraph(); - const errors: unknown[] = []; - - // Drive a registered builder's generator handler, persisting each emitted - // update. Returns the generator's final value (`false` = interrupted). - const runBuilder = async (id: string): Promise => { - const b = this.builders.get(id); - if (!b) return true; - const gen = b.handler(this.project); - let res = await gen.next(); - while (!res.done) { - const u = res.value; - await stores.updates.setUpdate({ signal: u.signal, uri: u.uri, stamp: u.stamp }); - res = await gen.next(); - } - return res.value !== false; - }; - - const stages = this.executionOrder(graph).filter((c) => c !== SCAN_CELL); - const only = opts?.builders ? new Set(opts.builders) : undefined; - const active = only ? stages.filter((c) => only.has(c)) : stages; - const log = loggerOf(this.project, "ProjectBuilder"); - log.info("build run started", { stages: active }); - - try { - // Safety backstop: abort if scheduling makes no progress for this many - // consecutive passes. A pass is progress when a stage drains, handles at least - // one update, or a scan surfaces work — so this scales to any corpus size - // (including the batched scanner's many small transactions) while still - // catching a genuinely stuck builder (never draining, or throwing before it - // handles anything). A non-convergent build would otherwise loop forever. - const maxStalledPasses = this.yieldConfig.maxStalledPasses; - let stalled = 0; - let converged = false; - for (;;) { - const frontier = await this.frontier(stores, graph); - // Most-downstream stage behind the frontier whose producers are all at the - // frontier — safe to bring up to date (its input is fully produced). - const target = await this.nextStage(stores, graph, active, frontier); - if (target) { - const progressed = yield* this.advanceStage( - stores, - graph, - runBuilder, - target, - frontier, - errors, - log, - ); - stalled = progressed ? 0 : stalled + 1; - if (stalled >= maxStalledPasses) { - // No progress for `maxStalledPasses` passes: the most-downstream stages - // (e.g. index reorganize / search) may never have run, leaving their - // top-level artifacts unwritten. Surface it loudly, not as a clean build. - log.error("build run stalled — no scheduling progress", { - maxStalledPasses, - stage: target, - }); - errors.push( - new Error( - `ProjectBuilder.run: no scheduling progress for ${maxStalledPasses} ` + - `consecutive passes (stuck at "${target}"); a builder is not draining ` + - "its input or repeatedly throws", - ), - ); - break; - } - continue; - } - // Every stage is at the frontier. In explicit-builders mode we are done; - // otherwise scan — advancing the frontier iff the scan surfaced new work. - if (only) { - converged = true; - break; - } - if (!(yield* this.scanStage(stores, errors, log))) { - converged = true; - break; - } - stalled = 0; // a scan that surfaced work is progress - } - if (converged) log.info("build run converged"); - } finally { - await this.flush(stores); - } - - if (errors.length > 0) throw errors[0]; - } - - /** Full topological execution order, the prober(s) first. */ - private executionOrder(graph: DataflowGraph): string[] { - const probers = graph.getAllCells().filter((c) => graph.getCellInputs(c).length === 0); - const proberOutputs = new Set(); - for (const p of probers) for (const out of graph.getCellOutputs(p)) proberOutputs.add(out); - return [...probers, ...graph.getExecutionOrder(proberOutputs)]; - } - - /** Count of unhandled updates across `cell`'s input signals. */ - private async pendingCount(stores: Stores, graph: DataflowGraph, cell: string): Promise { - let n = 0; - for await (const _ of readCellUpdates(stores.updates, graph, cell)) n++; - return n; - } - - /** The latest transaction across all cells — the frontier convergence target. */ - private async frontier(stores: Stores, graph: DataflowGraph): Promise { - let max = 0; - for (const cell of graph.getAllCells()) { - const tx = await stores.transactions.getCellTransaction(cell); - if (tx > max) max = tx; - } - return max; - } - - /** Cells producing any of `cell`'s input signals. */ - private producers(graph: DataflowGraph, cell: string): string[] { - const inputs = new Set(graph.getCellInputs(cell)); - return graph.getAllCells().filter((c) => graph.getCellOutputs(c).some((s) => inputs.has(s))); - } - - /** - * The most-downstream stage that is behind the frontier and whose producers have - * all reached it — so running it to completion safely brings it up to date. - */ - private async nextStage( - stores: Stores, - graph: DataflowGraph, - stages: string[], - frontier: number, - ): Promise { - let target: string | undefined; - for (const cell of stages) { - if ((await stores.transactions.getCellTransaction(cell)) >= frontier) continue; - let ready = true; - for (const p of this.producers(graph, cell)) { - if ((await stores.transactions.getCellTransaction(p)) < frontier) { - ready = false; - break; - } - } - if (ready) target = cell; - } - return target; - } - - /** - * Run one stage toward the frontier. A handler that interrupts (`false`) leaves - * work pending and keeps its old transaction, to be re-selected next pass; once - * it has drained its input it is recorded at the frontier — even if it handled - * nothing — keeping transaction ids monotonic. State is flushed on each advance. - * Returns whether the stage made progress (drained, or handled ≥1 update) — the - * scheduler's stall backstop relies on this to tell a working build from a stuck one. - */ - private async *advanceStage( - stores: Stores, - graph: DataflowGraph, - runBuilder: (id: string) => Promise, - cellId: string, - frontier: number, - errors: unknown[], - log: Logger, - ): AsyncGenerator { - yield { type: "begin", transactionId: frontier }; - log.debug("advancing stage", { stage: cellId, frontier }); - const before = await this.pendingCount(stores, graph, cellId); - let finished = false; - try { - finished = await runBuilder(cellId); - } catch (error) { - log.error("stage failed", { stage: cellId, error }); - errors.push(error); - } - const after = await this.pendingCount(stores, graph, cellId); - const drained = after === 0; - if (drained) await stores.transactions.setCellTransaction(cellId, frontier); - await this.flush(stores); - const result = finished || drained; - log.info("stage", { stage: cellId, result: result ? "ok" : "interrupted", tx: frontier }); - yield { type: "call", transactionId: frontier, builderId: cellId, result }; - yield { type: "end", transactionId: frontier }; - // No producer runs during this stage, so `after <= before`; a strict decrease - // means it handled at least one update this pass (partial progress). - return drained || after < before; - } - - /** - * Run the scanner under a fresh transaction. Advances the frontier (records the - * scanner at the new transaction) only if the scan emitted at least one update. - */ - private async *scanStage( - stores: Stores, - errors: unknown[], - log: Logger, - ): AsyncGenerator { - const transactionId = await stores.transactions.newTransactionId(); - yield { type: "begin", transactionId }; - let emitted = false; - try { - emitted = await this.scan(stores, transactionId, log); - } catch (error) { - log.error("scan failed", { error }); - errors.push(error); - } - if (emitted) await stores.transactions.setCellTransaction(SCAN_CELL, transactionId); - await this.flush(stores); - log.info(emitted ? "scan detected changes" : "scan: no changes", { tx: transactionId }); - yield { type: "end", transactionId }; - return emitted; - } - - /** Per-builder pending counts and last-run transaction ids. */ - async status(): Promise { - const stores = await this.ensureStores(); - const graph = this.getGraph(); - const builders = []; - for (const b of this.builders.values()) { - let pending = 0; - for await (const _ of readCellUpdates(stores.updates, graph, b.id)) pending++; - builders.push({ - id: b.id, - pending, - lastTransaction: await stores.transactions.getCellTransaction(b.id), - }); - } - return { - nextTransactionId: stores.transactions.peekNextTransactionId(), - builders, - }; - } - - /** - * Reset `builderId` and every builder downstream of it (by signal dependency): - * clear their handled watermarks and transaction watermarks so the next `run()` - * re-derives them. Upstream builders are untouched. - */ - async restartFrom(builderId: string): Promise { - const stores = await this.ensureStores(); - const graph = this.getGraph(); - const affected = new Set(graph.getExecutionOrderFromCells([builderId])); - affected.add(builderId); - for (const cell of affected) { - for (const signal of graph.getCellInputs(cell)) { - await stores.updates.clearHandled({ signal, cell }); - } - await stores.transactions.removeCellTransactions(cell); - } - await this.flush(stores); - } - - // ---- internals ---------------------------------------------------------- - - /** - * Generic mtime change-detection: emit `sources` / `sources-removed`. Returns - * whether it emitted any update (i.e. whether the source set changed). - * - * When `scanBatchSize > 0` only that many changed sources are emitted per scan - * (in uri order); the rest keep their stale mtime in `scannerState` and are - * re-detected on the next scan, so each batch flows through the full pipeline - * before the next is picked up. The full tree is always walked so removals are - * detected regardless of the batch limit. - */ - private async scan(stores: Stores, transactionId: number, log: Logger): Promise { - const { updates, scannerState } = stores; - let emitted = false; - const base = this.project.path.replace(/^\/+|\/+$/g, ""); - // `.projectignore` (gitignore-style) at the project root: excluded paths are - // skipped here, so they never enter `seen` and any previously-indexed ones are - // emitted as `sources-removed` below — adding a rule prunes their artifacts. - const projectIgnore = makeProjectIgnore( - await tryReadText(this.filesApi, joinPath(this.project.path, ".projectignore")), - ); - // Compose `.projectignore` with the nature-supplied ignore (re-read each scan). - const natureIgnore = this.sourceIgnore ? await this.sourceIgnore() : undefined; - const isIgnored = (uri: string) => projectIgnore(uri) || (natureIgnore?.(uri) ?? false); - const seen = new Set(); - const changed: { uri: string; mtime: number }[] = []; - for await (const info of this.filesApi.list(this.project.path, { - recursive: true, - })) { - if (info.kind !== "file") continue; - const uri = this.toProjectUri(info.path, base); - if (uri === undefined) continue; - // Skip dot-segments (system folder `.project/`, manifests, `.git`, …). - if (uri.split("/").some((seg) => seg.startsWith("."))) continue; - if (isIgnored(uri)) continue; - seen.add(uri); - const mtime = (info as { lastModified?: number }).lastModified ?? 0; - const prev = scannerState.get(uri); - if (prev !== undefined && prev === mtime) continue; - changed.push({ uri, mtime }); - } - // Emit at most `scanBatchSize` changed sources (0 = unlimited), in uri order so - // the batch boundary is deterministic; un-emitted ones are re-detected next scan. - const limit = this.yieldConfig.scanBatchSize; - changed.sort((a, b) => a.uri.localeCompare(b.uri)); - const batch = limit > 0 ? changed.slice(0, limit) : changed; - for (const { uri, mtime } of batch) { - await updates.setUpdate({ signal: SOURCES_SIGNAL, uri, stamp: transactionId }); - scannerState.set(uri, mtime); - emitted = true; - } - let removed = 0; - for (const uri of [...scannerState.keys()]) { - if (seen.has(uri)) continue; - await updates.setUpdate({ - signal: SOURCES_REMOVED_SIGNAL, - uri, - stamp: transactionId, - }); - scannerState.delete(uri); - removed++; - emitted = true; - } - log.debug("scanned sources", { - seen: seen.size, - changed: batch.length, - deferred: changed.length - batch.length, - removed, - tx: transactionId, +export class ProjectBuilder extends BuildEngine { + constructor(project: Project) { + super({ + files: project.workspace.files, + rootPath: project.path, + systemFolder: DEFAULT_SYSTEM_FOLDER, + logger: loggerOf(project, "ProjectBuilder"), + host: project, }); - return emitted; - } - - /** Map a filesystem path under the project to a project-relative bare URI. */ - private toProjectUri(fsPath: string, base: string): string | undefined { - const p = fsPath.replace(/^\/+/, ""); - if (base === "") return p; - if (p === base) return undefined; - if (!p.startsWith(`${base}/`)) return undefined; - return p.slice(base.length + 1); - } - - private async flush(stores: Stores): Promise { - await stores.updates.flush(); - await stores.transactions.flush(); - await writeJsonAtomic( - this.filesApi, - stores.scannerPath, - Object.fromEntries(stores.scannerState), - ); } } diff --git a/packages/workspace.core/src/public/builders/project-ignore.ts b/packages/workspace.core/src/public/builders/project-ignore.ts deleted file mode 100644 index 1b6771a9..00000000 --- a/packages/workspace.core/src/public/builders/project-ignore.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * `.projectignore` — a `.gitignore`-style exclusion list at a project's root that - * keeps matching sources out of scanning / indexing. Parsed once per scan into a - * predicate over project-relative file URIs. - * - * Supported subset (pragmatic, not full git semantics): - * - `#` comments and blank lines are ignored. - * - `!pattern` re-includes (negation); last matching rule wins. - * - `*` matches within a path segment, `**` across segments, `?` one non-slash char. - * - A pattern containing a `/` (including a leading one) is anchored to the project - * root; otherwise it matches a name at any depth. - * - A trailing `/` (directory marker) is accepted; directories match their subtree. - * - Matching a directory excludes everything beneath it. - */ - -export interface IgnoreRule { - negate: boolean; - regex: RegExp; -} - -/** Translate a glob fragment into a regex body (segment-aware `*` / `**` / `?`). */ -function globToRegex(glob: string): string { - let out = ""; - for (let i = 0; i < glob.length; i++) { - const c = glob[i]; - if (c === undefined) continue; - if (c === "*") { - if (glob[i + 1] === "*") { - out += ".*"; - i++; - } else { - out += "[^/]*"; - } - } else if (c === "?") { - out += "[^/]"; - } else if ("\\^$.|+()[]{}".includes(c)) { - out += `\\${c}`; - } else { - out += c; - } - } - return out; -} - -/** Compile `.projectignore` text into an ordered rule list. */ -export function compileIgnoreRules(text: string): IgnoreRule[] { - const rules: IgnoreRule[] = []; - for (const raw of text.split(/\r?\n/)) { - let line = raw.trim(); - if (!line || line.startsWith("#")) continue; - let negate = false; - if (line.startsWith("!")) { - negate = true; - line = line.slice(1).trim(); - } - line = line.replace(/\/+$/, ""); // directory marker — subtree matched anyway - const anchored = line.includes("/"); // a slash (incl. leading) anchors to root - line = line.replace(/^\/+/, ""); - if (!line) continue; - const body = globToRegex(line); - rules.push({ - negate, - regex: anchored ? new RegExp(`^${body}$`) : new RegExp(`(?:^|/)${body}$`), - }); - } - return rules; -} - -/** Ancestor-or-self path prefixes of a uri: `a/b/c` → [`a`, `a/b`, `a/b/c`]. */ -function selfAndAncestors(uri: string): string[] { - const segments = uri.split("/"); - const paths: string[] = []; - for (let i = 1; i <= segments.length; i++) paths.push(segments.slice(0, i).join("/")); - return paths; -} - -/** - * Build a predicate `(uri) => boolean` from `.projectignore` text. A file is - * excluded when its last matching rule (over the file and every ancestor - * directory) is a non-negated pattern. Empty / missing text excludes nothing. - */ -export function makeProjectIgnore(text: string | undefined): (uri: string) => boolean { - const rules = compileIgnoreRules(text ?? ""); - if (rules.length === 0) return () => false; - return (uri: string) => { - const candidates = selfAndAncestors(uri); - let ignored = false; - for (const rule of rules) { - if (candidates.some((c) => rule.regex.test(c))) ignored = !rule.negate; - } - return ignored; - }; -} diff --git a/packages/workspace.core/src/public/builders/transaction-store.ts b/packages/workspace.core/src/public/builders/transaction-store.ts deleted file mode 100644 index 6710078e..00000000 --- a/packages/workspace.core/src/public/builders/transaction-store.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { CellId, TransactionStore } from "@statewalker/webrun-dataflow"; -import type { FilesApi } from "@statewalker/webrun-files"; -import { tryReadJson, writeJsonAtomic } from "./json-io.js"; - -interface Snapshot { - nextTransactionId: number; - cellTransactions: { [cellId: string]: number }; -} - -const EMPTY: Snapshot = { nextTransactionId: 1, cellTransactions: {} }; - -/** - * Generic file-backed `TransactionStore`. Persists the next-tx counter and - * per-cell last-committed tx to one JSON file. - */ -export class FileBackedTransactionStore implements TransactionStore { - private snap: Snapshot; - private dirty = false; - - private constructor( - private readonly files: FilesApi, - private readonly path: string, - snapshot: Snapshot, - ) { - this.snap = { - nextTransactionId: snapshot.nextTransactionId, - cellTransactions: { ...snapshot.cellTransactions }, - }; - } - - static async open(files: FilesApi, path: string): Promise { - const found = (await tryReadJson(files, path)) ?? EMPTY; - return new FileBackedTransactionStore(files, path, found); - } - - async newTransactionId(): Promise { - const id = this.snap.nextTransactionId; - this.snap.nextTransactionId += 1; - this.dirty = true; - return id; - } - - peekNextTransactionId(): number { - return this.snap.nextTransactionId; - } - - async getCellTransaction(cellId: CellId): Promise { - return this.snap.cellTransactions[cellId] ?? 0; - } - - async setCellTransaction(cellId: CellId, transactionId: number): Promise { - this.snap.cellTransactions[cellId] = transactionId; - this.dirty = true; - } - - async *getCellsTransactions(sinceTransactionId?: number): AsyncGenerator<[CellId, number]> { - const since = sinceTransactionId ?? -1; - for (const [cellId, tx] of Object.entries(this.snap.cellTransactions)) { - if (tx > since) yield [cellId, tx]; - } - } - - async removeCellTransactions(cellId: CellId): Promise { - if (cellId in this.snap.cellTransactions) { - delete this.snap.cellTransactions[cellId]; - this.dirty = true; - } - } - - async flush(): Promise { - if (!this.dirty) return; - await writeJsonAtomic(this.files, this.path, this.snap); - this.dirty = false; - } -} diff --git a/packages/workspace.core/src/public/builders/types.ts b/packages/workspace.core/src/public/builders/types.ts index a5ff69c9..d3022282 100644 --- a/packages/workspace.core/src/public/builders/types.ts +++ b/packages/workspace.core/src/public/builders/types.ts @@ -1,65 +1,20 @@ import type { Project } from "../types/project.js"; -/** A dataflow signal name (e.g. `"sources"`, `"content"`, `"summarized"`). */ -export type SignalName = string; - -/** An update a builder emits: "URI `uri` changed on `signal` at stamp `stamp`". */ -export interface EmittedUpdate { - signal: SignalName; - uri: string; - stamp: number; -} - -/** - * An un-handled update on one of a builder's input signals, read via - * `ProjectBuilder.readUpdates`. Call `handled()` once the update has been - * processed so it does not reappear on the next run. - */ -export interface BuilderUpdate { - readonly signal: SignalName; - readonly uri: string; - readonly stamp: number; - handled(): Promise; -} - /** - * A builder handler: reads its input deltas (via `project.requireAdapter(ProjectBuilder)`), - * processes them, yields output updates for downstream builders, and returns `true` when - * all upstream updates were handled (no re-run needed) or `false` to request a re-run. + * Project-bound aliases of the generic builder types from `@statewalker/webrun-builder`. + * Binding `THost = Project` here lets every existing consumer keep its unparameterized + * `RegisteredBuilder` / `BuilderProvider` / `BuilderHandler` usage while the engine + * itself stays project-agnostic. The host-agnostic types pass through unchanged. */ -export type BuilderHandler = ( - project: Project, -) => AsyncGenerator; - -/** A builder registered on a `ProjectBuilder`. */ -export interface RegisteredBuilder { - id: string; - inputs: readonly SignalName[]; - outputs: readonly SignalName[]; - handler: BuilderHandler; -} - -/** An adapter that contributes builders to a project's pipeline (its "nature"). */ -export interface BuilderProvider { - builders(): readonly RegisteredBuilder[]; -} - -/** Progress event yielded by `ProjectBuilder.run`. */ -export type BuildProgress = - | { type: "begin"; transactionId: number } - | { type: "call"; transactionId: number; builderId: string; result: boolean } - | { type: "end"; transactionId: number }; - -/** Per-builder status snapshot. */ -export interface BuilderStatus { - id: string; - /** Count of un-handled updates across this builder's input signals. */ - pending: number; - /** The builder's last successful transaction id (`0` if never run). */ - lastTransaction: number; -} - -export interface BuildStatus { - nextTransactionId: number; - builders: BuilderStatus[]; -} +export type { + BuildProgress, + BuildStatus, + BuilderUpdate, + EmittedUpdate, + SignalName, + YieldConfig, +} from "@statewalker/webrun-builder"; + +export type RegisteredBuilder = import("@statewalker/webrun-builder").RegisteredBuilder; +export type BuilderProvider = import("@statewalker/webrun-builder").BuilderProvider; +export type BuilderHandler = import("@statewalker/webrun-builder").BuilderHandler; diff --git a/packages/workspace.core/src/public/builders/updates-store.ts b/packages/workspace.core/src/public/builders/updates-store.ts deleted file mode 100644 index 401d7348..00000000 --- a/packages/workspace.core/src/public/builders/updates-store.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { - type HandledEntry, - InMemoryUpdatesStore, - type ReadOrderBy, - type SerializedUpdatesStore, - type UpdateEntry, - type UpdatesStore, -} from "@statewalker/webrun-dataflow"; -import type { FilesApi } from "@statewalker/webrun-files"; -import { tryReadJson, writeJsonAtomic } from "./json-io.js"; - -/** - * Generic file-backed `UpdatesStore`. Wraps `InMemoryUpdatesStore` and persists - * the full relation (signal updates + per-cell handled state) to one JSON file. - * `flush()` snapshots and rewrites atomically. - */ -export class FileBackedUpdatesStore implements UpdatesStore { - private dirty = false; - - private constructor( - private readonly files: FilesApi, - private readonly path: string, - private readonly inner: InMemoryUpdatesStore, - ) {} - - static async open(files: FilesApi, path: string): Promise { - const found = await tryReadJson(files, path); - return new FileBackedUpdatesStore(files, path, new InMemoryUpdatesStore(found ?? undefined)); - } - - readEntries(opts: { - signal: string; - since: number; - uriPrefix?: string; - orderBy?: ReadOrderBy; - }): AsyncIterable { - return this.inner.readEntries(opts); - } - - readUpdates(opts: { - signal: string; - cell: string; - uriPrefix?: string; - orderBy?: ReadOrderBy; - }): AsyncIterable { - return this.inner.readUpdates(opts); - } - - async setUpdate(entry: UpdateEntry): Promise { - await this.inner.setUpdate(entry); - this.dirty = true; - } - - async setUpdates(entries: ReadonlyArray): Promise { - await this.inner.setUpdates(entries); - if (entries.length > 0) this.dirty = true; - } - - async handleUpdate(entry: HandledEntry): Promise { - await this.inner.handleUpdate(entry); - this.dirty = true; - } - - async handleUpdates(entries: ReadonlyArray): Promise { - await this.inner.handleUpdates(entries); - if (entries.length > 0) this.dirty = true; - } - - async clearHandled(key: { signal: string; cell: string }): Promise { - const removed = await this.inner.clearHandled(key); - if (removed > 0) this.dirty = true; - return removed; - } - - async removeUpdate(key: { signal: string; uri: string }): Promise { - await this.inner.removeUpdate(key); - this.dirty = true; - } - - async removeUpdates(keys: ReadonlyArray<{ signal: string; uri: string }>): Promise { - await this.inner.removeUpdates(keys); - if (keys.length > 0) this.dirty = true; - } - - async flush(): Promise { - if (!this.dirty) return; - await writeJsonAtomic(this.files, this.path, this.inner.snapshot()); - this.dirty = false; - } - - /** Snapshot for testing / debugging. */ - snapshot(): SerializedUpdatesStore { - return this.inner.snapshot(); - } -} From 8ea6a1208974bc7ad3b9b878b66bafc38892e3ab Mon Sep 17 00:00:00 2001 From: Mikhail Kotelnikov Date: Mon, 3 Aug 2026 00:47:54 +0200 Subject: [PATCH 4/6] fix(tests): update webapp.core ~deps assertion to proxy form; void-wrap workspace.test onLoad/onUnload callbacks - webapp.core module-server test asserted the pre-~deps-proxy output; updated to the current same-origin ~deps proxy path (pinned-version check lives in webrun-modules' own suite). - workspace.test onLoad/onUnload callbacks returned push()'s number where void is expected (tsc). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/webapp.core/tests/web-app-module-server.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/webapp.core/tests/web-app-module-server.test.ts b/packages/webapp.core/tests/web-app-module-server.test.ts index 935d9744..68b83929 100644 --- a/packages/webapp.core/tests/web-app-module-server.test.ts +++ b/packages/webapp.core/tests/web-app-module-server.test.ts @@ -64,9 +64,10 @@ describe("newWebAppModuleServer", () => { expect(response.status).toBe(200); const body = await response.text(); - // The bare `tiny` import was rewritten to a same-origin URL under `/deps/`, pinned - // to the locked version — no CDN, no bare specifier. - expect(body).toContain("deps/tiny@1.0.0/index.js"); + // The bare `tiny` import was rewritten to a same-origin `~deps` proxy module (the ~deps + // proxy layer) — no CDN, no bare specifier. The proxy pins the locked `deps/tiny@1.0.0` + // version (verified directly in @statewalker/webrun-modules' own suite). + expect(body).toContain("~deps/main.ts/deps.tiny.js"); expect(body).not.toContain("http"); expect(body).not.toContain('from "tiny"'); }); From 9bec7c189e6c5d13e173110b389ef290ef1d1e81 Mon Sep 17 00:00:00 2001 From: Mikhail Kotelnikov Date: Mon, 3 Aug 2026 00:58:52 +0200 Subject: [PATCH 5/6] =?UTF-8?q?fix(workspace.core):=20Phase=20F=20hardenin?= =?UTF-8?q?g=20=E2=80=94=20ProjectBuilder=20logger=20invariant=20doc=20+?= =?UTF-8?q?=20drop=20dead=20type=20re-exports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - document that ProjectBuilder resolves the logger once at construction, so the host must register its LoggerAdapter before resolving it - remove 6 dead passthrough re-exports from builders/types.js (they reach the package root via index.ts's export *); keep the 3 Project-bound aliases Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/public/builders/project-builder.ts | 4 ++++ packages/workspace.core/src/public/builders/types.ts | 12 ++---------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/workspace.core/src/public/builders/project-builder.ts b/packages/workspace.core/src/public/builders/project-builder.ts index 95471fcf..83044c04 100644 --- a/packages/workspace.core/src/public/builders/project-builder.ts +++ b/packages/workspace.core/src/public/builders/project-builder.ts @@ -9,6 +9,10 @@ import { DEFAULT_SYSTEM_FOLDER, type Project } from "../types/project.js"; * itself is the `host` passed to every builder handler (`handler(project)`), so a * project's "nature" keeps contributing builders via `registerBuilder` / a * `BuilderProvider` exactly as before. + * + * Invariant: the logger is resolved once here at construction (`loggerOf`), so the + * host must register its `LoggerAdapter` BEFORE resolving `ProjectBuilder` — a later + * registration is not picked up. Workspace bootstrap satisfies this ordering. */ export class ProjectBuilder extends BuildEngine { constructor(project: Project) { diff --git a/packages/workspace.core/src/public/builders/types.ts b/packages/workspace.core/src/public/builders/types.ts index d3022282..e52f877e 100644 --- a/packages/workspace.core/src/public/builders/types.ts +++ b/packages/workspace.core/src/public/builders/types.ts @@ -4,17 +4,9 @@ import type { Project } from "../types/project.js"; * Project-bound aliases of the generic builder types from `@statewalker/webrun-builder`. * Binding `THost = Project` here lets every existing consumer keep its unparameterized * `RegisteredBuilder` / `BuilderProvider` / `BuilderHandler` usage while the engine - * itself stays project-agnostic. The host-agnostic types pass through unchanged. + * itself stays project-agnostic. The host-agnostic types pass through unchanged + * via the package root's `export *`, so this shim only re-binds the Project ones. */ -export type { - BuildProgress, - BuildStatus, - BuilderUpdate, - EmittedUpdate, - SignalName, - YieldConfig, -} from "@statewalker/webrun-builder"; - export type RegisteredBuilder = import("@statewalker/webrun-builder").RegisteredBuilder; export type BuilderProvider = import("@statewalker/webrun-builder").BuilderProvider; export type BuilderHandler = import("@statewalker/webrun-builder").BuilderHandler; From 3f2f64a446f6a744851ef34fe8c42182323c6f6a Mon Sep 17 00:00:00 2001 From: Mikhail Kotelnikov Date: Mon, 3 Aug 2026 08:08:35 +0200 Subject: [PATCH 6/6] docs: reflect build-engine move to @statewalker/webrun-builder workspace.core migrated onto @statewalker/webrun-builder: the signal-driven build engine (BuildEngine) plus its stores/scanner/ignore moved out; workspace.core now keeps only the thin ProjectBuilder Project-bound adapter. Update the two READMEs that attributed the build engine itself to workspace.core. Public builder API (ProjectBuilder, registerBuilder, BuilderProvider, ignore) is unchanged, so public-API-only docs are left as-is. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/wiki.core/README.md | 2 +- packages/workspace.core/README.md | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/wiki.core/README.md b/packages/wiki.core/README.md index bf3406c8..a844b4f9 100644 --- a/packages/wiki.core/README.md +++ b/packages/wiki.core/README.md @@ -211,7 +211,7 @@ Flags: `--format ` (data channel; default `json`), `--log-level ### Dependencies -- `@statewalker/workspace.core` — the `Workspace`/`Project`/`Resource` model, `ResourceAdapter`/`ProjectAdapter`, and the `ProjectBuilder` signal-driven build engine the whole pipeline rides on. +- `@statewalker/workspace.core` — the `Workspace`/`Project`/`Resource` model, `ResourceAdapter`/`ProjectAdapter`, and the `ProjectBuilder` build adapter (a `BuildEngine` from `@statewalker/webrun-builder`) the whole pipeline rides on. - `@statewalker/content-extractors` — mime-aware text extraction registry (`createDefaultRegistry`) behind `ContentAdapter`. - `@statewalker/indexer-api` / `-fulltext` / `-vector` / `-mem-flexsearch` — the FTS + vector index abstractions and the in-memory FlexSearch backend behind `SearchAdapter`. - `@statewalker/fsm` (+ `-validator` in dev) — the query retrieval state machine. diff --git a/packages/workspace.core/README.md b/packages/workspace.core/README.md index 55d68f2a..84436b66 100644 --- a/packages/workspace.core/README.md +++ b/packages/workspace.core/README.md @@ -24,8 +24,11 @@ hierarchy (the consolidation of `@statewalker/resources-workspace`): > **Transitional:** `@statewalker/resources-workspace` and > `@statewalker/resources-wiki` still exist and build unchanged; the wiki > migration onto this model and the deletion of `resources-workspace` are a -> follow-up step. `ProjectBuilder` (the signal-driven build engine + `BuilderProvider` -> "nature") is being ported here next. +> follow-up step. The signal-driven build engine now lives in +> `@statewalker/webrun-builder` as `BuildEngine`; this package provides the +> `ProjectBuilder` Project-bound adapter (`extends BuildEngine`) plus the +> `BuilderProvider` "nature", re-exported from `./builders` and resolved via +> `project.requireAdapter(ProjectBuilder)`. ## What it exports