diff --git a/specs/resource-instances.md b/specs/resource-instances.md new file mode 100644 index 0000000..d3e58f6 --- /dev/null +++ b/specs/resource-instances.md @@ -0,0 +1,471 @@ +# Proposal: Named resource instances and a Resources section + +> **Status: PLAN, agreed 2026-10-08.** Nothing here is implemented yet. §L's proposed answers were +> accepted, and the inline `resource` variant is removed outright rather than kept (§A.1). +> It builds on `specs/execution-inputs.md`, `specs/resource-arguments.md` (whose §Q.2 anticipated "named +> instances") and `specs/evals.md` §B.2. + +## Context + +Resources work, and arguments made them parameterisable. One case still can't be expressed: +**side effects that don't feed any slot, assembled from modular pieces that need each other's +outputs.** + +The motivating example is a task hierarchy for odin. `list_tasks` reads it back through a tool, so +no prompt slot ever takes the child tasks. The prompt takes `taskId` (the root) and nothing else: + +``` +root = seededTask({ title: "Todo app" }) → taskId slot +child1 = seededTask({ title: "Add drag reorder", parentId: root.taskId }) (no slot) +child2 = seededTask({ title: "Persist to localStorage", parentId: root.taskId }) (no slot) +``` + +Three things stop this today. The first two are why the user reported it. The third came up while +reading the registry. + +1. **Nothing runs unless a slot reads it.** Resources are acquired lazily by + `resolveExecutionInputs` as slots resolve. An unselected resource is never created, so + `child1` cannot exist. +2. **A resource has at most one instance per run, as far as the panel is concerned.** The wire + allows two bindings of one URI with different `args`, but the panel holds arguments per URI + (`ResourceArgs`, `resource-arguments.md` §I). Even on the wire, an instance's identity *is* its + recipe (`canonicalArgumentKey`), so two siblings with identical arguments collapse into one, + and editing a text field changes which instance a reference means. +3. **The registry rejects the hierarchy outright.** Cycle detection runs on the resource object, + ignoring the argument key (`resource-registry.ts`, `instantiate`): + `chain.some(c => c.resource === target)`. A child whose `parentId` argument is + `seededTask.taskId` puts `seededTask` in its own chain, so it fails with + `Resource dependency cycle`. `resource-arguments.md` §D chose this deliberately, on the grounds + that "a resource that recursively seeds itself with different arguments … does not exist". This + is that case. + +**The ask:** a **Resources** section at the top of the playground's Execute panel, the eval's +Inputs panel, and a dataset row's details, sharing one implementation. It lists: + +- every resource selected for a slot, +- their code-wired dependencies (`db`), where any arguments they take can be configured, +- resources added with no slot binding at all, which run for their side effects, +- several instances of one resource, each with a name, so a slot (or another instance's + argument) can pick a specific one. + +--- + +## A. The model: a run declares named instances; slots reference them + +Today a run is a set of **slot bindings**, and resources appear *inside* them, inline, identified +by recipe. The proposal adds a second, sibling part to every run: a map of **named instances**. +Slots and arguments then refer to instances by name. + +```ts +/** One resource instance a run creates, whether or not anything reads it. */ +export interface ResourceInstanceInput { + /** The resource's root `ResourceInfo.uri`. Never an output URI. */ + uri: string; + /** Values for its declared arguments, by parameter name. */ + args?: Record; + /** + * Code-wired dependency key → instance name, only where the run has more than one + * instance of that dependency's resource (§D). Usually absent. + */ + deps?: Record; + /** Recorded on every run, sent back only by a replay. Same contract as today's (§F). */ + receipt?: unknown; +} + +export type ExecutionInput = + | … // value, object, dataset, input: unchanged + // `{ kind: "resource", uri, args, receipt }` is removed (§A.1) + /** An instance declared in the run's `resources`, or one of its outputs. */ + | { kind: "instance"; name: string; output?: string }; +``` + +The hierarchy, as a request: + +```jsonc +{ + "resources": { + "root": { "uri": "tasks.ts#seededTask", "args": { "title": { "kind": "value", … } } }, + "child1": { "uri": "tasks.ts#seededTask", "args": { + "title": { "kind": "value", … }, + "parentId": { "kind": "instance", "name": "root", "output": "taskId" } } }, + "child2": { "uri": "tasks.ts#seededTask", "args": { … same parentId … } } + }, + "functionInputs": [ { "kind": "instance", "name": "root", "output": "taskId" } ] +} +``` + +What the change buys: + +- **Identity is the name, not the recipe.** `child1` and `child2` stay two tasks even with + identical arguments. Editing an argument changes what an instance *does*, not which instance a + reference means. That is the same split `resource-arguments.md` §C made between URI and `args`, + carried one level further. +- **Unbound instances are ordinary entries.** Every instance in `resources` is created when the + run starts, whether or not anything references it. "Side effect only" isn't a separate concept: + it's an instance nobody reads. +- **Referencing another instance's output is one small node**, not a copy of its recipe. Today + `parentId` would have to repeat `root`'s whole `{ uri, args }` inline and rely on the memo key + matching. That's the drift problem `evals.md` §B.2 invented `input` to avoid for slots. +- **Arguments are stored once per instance**, not stamped onto every reference. The panel's + `ResourceArgs`/claims machinery, which works out which chip "owns" the form, is no longer needed + (§H). +- **`instance` is the same kind of node as `input`.** Both say "the value something else in this + run produced", and both resolve through the lease, so the same instance comes back. + +### A.1 The inline `resource` variant is removed + +`{ kind: "resource", uri, args, receipt }` goes away entirely. Nobody depends on stored traces, +rows or evals yet, so there's nothing to migrate and no compatibility path to carry. Anything in +`localStorage`, a local dataset or a saved eval that still holds the old node is treated like any +other unparseable stored input: a slot holding it restores empty, and the server rejects it with a +400 naming the slot. + +Dropping it, rather than keeping it as an anonymous instance keyed by recipe, removes a lot: + +- **One resolution path.** No anonymous instances, so the lease memoizes only by instance name. + `canonicalArgumentKey`, `ResourceBinding.key` and the `uri@key` receipt keys all go. +- **One cycle rule.** Cycles are checked on instance names alone. The resource-object rule, and the + special case for keeping it on inline references, both go. +- **No hoisting.** The client never has to turn inline references into instances on load, so + `hoistInlineResources` and its tests never get written. +- **Receipts live in one place**, on the instance. `stampReceipts` no longer walks slot trees + looking for resource nodes. +- **`args` exist in exactly one place.** The client's `resourceArgsFor` stamping, and the + last-writer-wins merge in `fromExecutionInput`, go. + +### A.2 Names + +- **Default name is the export name** (`seededTask`), suffixed `2`, `3`… on collision. Users + rename to something meaningful (`root`, `child1`). +- **Grammar:** `[A-Za-z_][A-Za-z0-9_-]*`. No dots, because the UI shows outputs as `root.taskId`. +- **Renaming rewrites every reference in the same scope** (slots, other instances' args, check + args, a row's cells). This is a pure function over the editor state (§H). +- **Why names rather than opaque ids with a display label:** names are what makes a recorded + trace, a dataset row and an eval binding readable (`taskId ← root.taskId`). They also let an + eval refer to an instance each *row* declares (§E). The cost is that a rename has to rewrite + references. Within one scope that's mechanical. Across scopes (an eval naming a row's instance) + it can't be done, and §E says what happens instead. + +## B. Server: instances are acquired eagerly, keyed by name + +`resolveExecutionInputs` gains the run's instances in its `ResolutionContext`: + +```ts +interface ResolutionContext { + row?: Pick; + bindings?: InputBindings; + /** The run's named instances: eval-level merged with row-level (§E), or the panel's. */ + instances?: Record; +} +``` + +And the lease gains one entry point beside `acquire`: + +```ts +/** Creates (or reuses) the named instance, resolving its args under the same context. */ +acquireInstance(name: string, spec: ResourceInstanceInput, binding: ResourceBinding): Promise; +``` + +Rules: + +1. **Eager.** Before resolving slots, the resolver acquires *every* instance in + `context.instances`, in parallel. References between instances order them naturally: `child1` + awaits `root` because its `parentId` argument resolves through it. Slot resolution then hits + the memo. +2. **Memo key is the instance name** for run-scoped resources. A run-scoped instance map becomes + `Map`. Server-scoped resources keep their single per-process instance, and reset + locks and disposal work as they do now. +3. **Cycle detection runs on instance names, not on the resource object.** `child1 → root` is two + instances of one resource, so it is allowed. `root.parentId ← child1.taskId` together with + `child1.parentId ← root.taskId` is a cycle and fails, naming the instances. Code-wired + dependencies (`inputs: { db }`) keep today's resource-object check, since an undeclared + dependency has no name. +4. **Validated up front, as a 400**, together with the existing `input` checks: an `instance` node + naming a missing instance, an invalid name, a cycle among instances, or two instances of a + `server`-scoped resource. A server-scoped resource has exactly one instance per process, so two + names would alias one object, which is never what someone meant. The panel doesn't offer it. + Output keys are still checked at acquire time by the existing + `no output at '…'` error. +5. **Receipts are recorded per instance**, keyed by name: `lease.receipts()` returns + `Record`, and the route writes each one onto its entry in the recorded + `resources` map (§F). Code-wired dependency instances that were never declared (the anonymous + `db` fallback in §D) record under their URI, as now. + +Nothing about `create`, `ResourceInstance`, arguments, validation, `reset` or scope changes for +resource authors. **This is invisible in playground modules.** + +## C. Where `resources` lives on every surface + +| Surface | Today | Adds | +| --- | --- | --- | +| `ExecuteRequest` | `functionInputs`, `executeInputs` | `resources?: Record` | +| Playground `StoredInputs` (`localStorage`) | same two | `resources?` | +| `EvalInputs` (JSON column `evals.inputs`) | same two | `resources?`. No migration, it's JSON | +| Check bindings (`EvalCheck.args`) | `ExecutionInput`s | may contain `instance` nodes | +| `DatasetRow` | `cells` | `resources?`. New nullable `dataset_rows.resources` blob, plus the local-directory provider's file shape | +| `DatasetRowUpdate` | `cells` | `resources?: Record` | +| `eval_row_results.row_cells` snapshot | cells | the row's `resources` too, so a result shows what it ran with | +| `PromptID` / recorded trace inputs | `functionInputs`, `executeInputs`, defs | `resources?`, with receipts stamped | +| `NamedInputs` flows (`named-inputs.ts`) | slot inputs | carry `resources` alongside (panel ↔ row ↔ trace) | + +The `dataset_rows` column is the only real schema migration. Every other row in the table is a +new optional field on an existing JSON value. + +## D. Dependencies + +A code-wired dependency (`inputs: { db }`) is resolved by the registry today with no binding and +key `""`. The section shows dependencies, and the user wants to be able to configure their +arguments. So the rule becomes: + +> When resolving a code-wired dependency of a named instance, the registry uses the instance of +> that resource **declared in the run**, if there is one. If there are several, it uses the one +> `deps` names. If there are none, it falls back to today's anonymous key `""`. + +- **The panel shows dependencies derived from what's selected**, transitively, using a new + `ResourceInfo.dependencies?: Record` (input key → URI). The provider already + partitions `inputs` into dependencies and parameters, so this costs nothing to compute. A derived + dependency isn't stored. It's a read-only row ("◆ db · shared · used by root, child1, + child2"), with a preview of its value where `ResourceInfo.value` already provides one. +- **A dependency with arguments gets a form**, and editing it materialises a stored instance named + after its export. Its arguments have to be stored somewhere, and an instance is that place. + Under the rule above, every dependent then uses it. (Server-scoped resources still can't take + arguments, so in practice this means run-scoped dependencies like a per-run workspace.) +- **Several instances of one dependency are a later step (§K.5).** Then each dependent shows a + small picker for that input (`db: [db ▾]`), stored in `deps`. This is the "rebind a + resource-valued input" that `resource-arguments.md` §M left out, restricted to instances *of + the same resource*. That keeps it type-safe by construction, which was §M's reason for + deferring it. + +## E. Evals and dataset rows: two layers, one namespace + +An eval run assembles one run per row. Its instances are the eval's `resources` merged with the +row's `resources`, in a single namespace, so either layer can reference the other's instances by +name: + +- **Eval-level** instances apply to every row: "seed this fixture workspace for every case". +- **Row-level** instances belong to that case: this row's hierarchy. A row cell, or the eval's + `taskId` binding, can say `instance root.taskId`, and each row supplies its own `root`. +- **On a name collision the row wins.** That lets an eval define a default `root` that particular + rows override. It also means a typo can shadow something silently, so the run's recorded + `resources` (§F) always shows the merged result, and eval validation reports a reference that + names an instance **no** row and not the eval declares. (The alternative, treating any collision + as an error, is open question §L.1.) +- **Renames don't cross layers.** Renaming `root` in one row's details rewrites that row's cells + only. An eval binding that named `root` then fails for that row with + `Row 12: no resource instance 'root'`. It's an `error`, not a `fail`, as `evals.md` §B.3 already + treats bad bindings. + +Copying between surfaces carries instances along. "Add to dataset" from the panel writes the +panel's `resources` into the new row, so the instance references in its cells still resolve. +"Fill from row" loads the row's instances into the panel's section. A playground trace → +row/panel round trip carries `resources` with its receipts stripped, as cells already are. + +## F. Traces and replay + +The recorded inputs gain `resources`, with each instance's receipt stamped on, next to +`functionInputs`/`executeInputs`. Replay is still "POST what was recorded". The hierarchy now +reproduces properly: `root`'s receipt brings back its id, and `child1.parentId` is a *reference* +to `root.taskId`, not a copied string, so it follows. If the hierarchy had been written as copied +ids, the replay's children would point at the original run's parent. + +## G. UI: the Resources section + +``` +Resources [ + Add resource ] +┌────────────────────────────────────────────────────────────────────────┐ +│ ◆ db Local D1 (.wrangler state) · shared dependency │ +│ used by root, child1, child2 │ +├────────────────────────────────────────────────────────────────────────┤ +│ ◆ root ✎ Seeded task · per run [⋯] │ +│ → taskId, toolsContext.*.rootTaskId │ +│ title [ Todo app ] │ +│ status [ triaged ] │ +│ parentId [ ] ⋯ │ +├────────────────────────────────────────────────────────────────────────┤ +│ ◆ child1 ✎ Seeded task · per run [⋯] │ +│ not bound to a slot: runs for its side effects │ +│ title [ Add drag reorder ] │ +│ parentId [ ◆ root.taskId ] ⋯ │ +└────────────────────────────────────────────────────────────────────────┘ +taskId * [ ◆ root.taskId ⋯ ] +``` + +- **One card per instance**, then derived dependencies. A card shows the editable name, the + resource label, scope, what references it ("→ taskId") or that nothing does, and the argument + form. Removing an instance asks first if anything references it, then clears those references. + The `[⋯]` menu holds Rename, Duplicate (the quickest way to make `child2`) and Remove. +- **Argument forms are `ExecutionInputEditor`s, as today**, so an argument can be a typed value, an + output of another instance, another slot (`input`), or a column (in an eval). Instances are + excluded from their own forms and from any form that would close a cycle, the same way + `resource-arguments.md` §H already excludes self-dependency. +- **The slot picker gets a "Resources in this run" group first**, listing instances with their + outputs as a submenu (`root ▸ Task ID`). Below it, **"New ▸"** holds the existing catalog tree. + Picking from the catalog adds an instance with the default name *and* binds the slot. That + keeps today's one-click flow: picking `seededTask.taskId` for `taskId` and then `root ▸ Title` + for `taskInfo` still gives one task. +- **A chip in a slot is only a reference** (`◆ root.taskId`), and clicking it scrolls to the card. + Forms never appear under chips any more, so "same instance as `taskId` above" goes away. +- **Unbinding a slot doesn't remove the instance**, because instances outliving bindings is the + point. The card switches to "not bound to a slot", which is visible at the top of the panel, so a + forgotten instance doesn't quietly keep seeding rows. (§L.2 has the alternative of auto-pruning + instances the picker created.) +- **"+ Add resource"** opens the same catalog `SourcePicker` uses, filtered to root resources. + Server-scoped and static resources that already have an instance are disabled, with the reason + as a tooltip. +- **Placement:** above the slots in the playground panel and the eval Inputs panel. In row details + it goes above the fields, with the same commit-on-blur behaviour `RowFields` already uses for + typed edits, and immediate commits for add, remove and rename. +- **Empty state:** when no instances or dependencies exist, the section collapses to the header and + its Add button, so prompts without resources look exactly as they do now, apart from one row. + +## H. Sharing the code + +All three surfaces currently hold the same state in three slightly different ways +(`PlaygroundExecution` lines 215–370, `EvalView` 120–150 and 420–440, `DatasetRowDetails` +130–260). Each one re-derives `ResourceArgs`, runs `computeClaims`, and builds its own +`ResourceArgsContext`. The plan replaces that with: + +- **`run-resources-state.ts`** (pure, unit-tested; the client counterpart of §A): + - `InstanceSelections = Record }>`, + which replaces `ResourceArgs`; + - `toWireResources` / `fromWireResources`; + - `addInstance`, `renameInstance` (rewrites every reference in the given selections), + `removeInstance`, `duplicateInstance`, `referencesTo`, `derivedDependencies`, + `excludedFromArgs` (cycle-safe sources for a card's form). +- **`ResourcesSection.tsx`**: a controlled component, + `{ instances, onChange, resources, resourceSlots, describePseudo, referencedBy }`. Persistence + stays with each surface: `localStorage`, eval save, row commit. +- **`pseudo-sources.ts` gains `instanceUri(name, output?)`** and maps it to and from + `{ kind: "instance" }` in `pseudoInput`/`pseudoUriOf`, the same way columns and `input` slots + already go through the picker. The picker group comes from the instances plus `functionSlots` + matches (an instance of `seededTask` fits wherever `seededTask.taskId`'s URI fits), so **no new + server-side matching** is needed. + +Removed: `computeClaims`, `ClaimOwner`, `ResourceArgsContext.claimed`/`resourceArgs`/`depth`, +`ResourceArgumentsForm`, `MAX_RESOURCE_ARG_DEPTH`, `resourceArgsFor`'s recursion, and the +"same instance as" note. `ResourceArgsContext` shrinks to what an editor needs for chips +(`instances`, `describePseudo`). That's a net deletion of several hundred lines across +`resource-args-context.ts`, `execution-input-state.ts`, `SourceRow.tsx` and the three surfaces. + +## I. Alternatives considered + +**Keep instances inline, and add `instance?: string` to the `resource` node as a memo-key +discriminator.** It's the smallest server change. But unbound side effects still have nowhere to +live, arguments are still duplicated on every reference, `parentId` still repeats `root`'s whole +recipe, and the panel keeps the claims machinery. Rejected. + +**Make the section a UI over today's wire**: instances are the distinct `(uri, args)` pairs, +plus a `sideEffects: ExecutionInput[]` list. Identical siblings still collapse, a rename has +nothing to attach to, and a child still trips the cycle rule. Rejected. + +**One resource per hierarchy** (`seededTaskTree` taking `tasks: { key, title, parentKey? }[]`). +This **works today with no platform change**, and for a hierarchy that is pure data it's a good +pattern to document. It doesn't cover the general case: it can't mix different resources (a task, +a user, a message thread); outputs are declared statically, so an inner node's id can't be offered +to a slot; and every new shape needs a new tree resource. Recommended as a pattern, not as the +answer. + +**A `setup()` playground export** run before every prompt. It duplicates the resource lifecycle +(scope, reset, dispose, receipts) for no gain, and it isn't per-run configurable. Rejected. + +**Opaque instance ids with display labels.** Renames become free, but every recorded trace, +dataset row and eval binding then reads `r3.taskId`, and an eval can't refer to a per-row instance +by meaning. Rejected (§A.2). Open question §L.3 keeps it under review. + +## J. Non-goals + +- Ordering side effects that have no data dependency between them. Instances run in parallel, and + a reference is the only ordering. Nothing needs an `after:` field yet. +- Showing row resources in the dataset *grid*. A count badge at most. The details pane is where + they're edited. +- Rebinding a dependency to a *different* resource. §D only rebinds between instances of the same + resource. +- Server-scoped resources taking arguments. That's still refused (`resource-arguments.md` §Q.1). + +## K. Phasing + +1. **Server.** `ResourceInstanceInput`, the `instance` variant, `ExecuteRequest.resources`, eager + acquisition, name-keyed memo, instance-identity cycle rule, 400 validation, the dependency rule + (§D, without `deps`), per-instance receipts and recording. `ResourceInfo.dependencies`. Testable + end to end with a hand-written request seeding odin's hierarchy. +2. **Shared client state and `ResourcesSection`**, adopted by the playground panel: picker group, + deleting the claims machinery. +3. **Evals.** `EvalInputs.resources`, the section in the Inputs panel, instance references in check + args, and the eval runner passing instances into `ResolutionContext`. +4. **Datasets.** `DatasetRow.resources` (Turso migration plus the local-directory provider), + `updateRows`, the section in row details, `named-inputs.ts` flows, eval/row merge (§E), and + the `eval_row_results` snapshot. +5. *(when wanted)* `deps`: several instances of a dependency, with the per-input picker. + +Steps 1–2 are enough for the odin hierarchy in the playground. Step 4 is what makes it a dataset +case. + +## L. Questions (decided 2026-10-08) + +1. **Row/eval name collision: row wins, or an error?** Decided: row wins (§E), so an eval can + carry defaults. The cost is silent shadowing. +2. **Should instances the picker created be pruned when their last reference goes?** Decided: no. + Instances stay, labelled "not bound to a slot". Auto-pruning matches today's feel (choosing + another source for a slot makes the old resource disappear), but it needs a stored `auto` flag + and makes "why did my instance vanish" possible. +3. **Names or ids** (§A.2, §I). Decided: names. +4. ~~**Should the inline `resource` variant be deprecated from the wire eventually?**~~ **Decided: + removed now** (§A.1). Nothing stored needs it. +5. **Does `input` (slot → slot) still earn its place?** Decided: yes. `toolsContext.*.rootTaskId ← + input taskId` says "whatever `taskId` is", which stays correct when `taskId` is bound to a + column instead of an instance. The two overlap but do different jobs. + +## M. Files + +- `src/shared/types.ts`: `ResourceInstanceInput`, the `instance` variant, + `ExecuteRequest.resources`, `ResourceInfo.dependencies`. +- `src/prompt/execution-inputs.ts`: `ResolutionContext.instances`, eager acquisition, the + `instance` case, validation, `stampReceipts` for instances. +- `src/shared/input-references.ts`: instance references in cycle checks and `inputReferenceProblems`. +- `src/prompt/playground/resource-registry.ts`: `acquireInstance`, the `@name` key, the + instance-identity cycle rule, the dependency-resolution rule, per-instance receipts. +- `src/prompt/file/file-prompt-provider.ts`: `ResourceInfo.dependencies`, and passing + `resources` through `resolveInputs`. +- `src/server/api-routes.ts`: accepting and recording `resources`. +- `src/trace/trace-types.ts`, `src/trace/prompt-tracer.ts`, `src/sdk/vercel-ai-sdk/telemetry.ts`, + `src/trace/otel-trace-ingestor.ts`: `resources` on recorded inputs. +- `src/eval/eval-types.ts`, `src/eval/eval-runner.ts`, `src/shared/eval-problems.ts`: + `EvalInputs.resources`, the eval/row merge, validation. +- `src/dataset/dataset-types.ts`, `turso-dataset-provider.ts` plus a migration under + `src/dataset/db/migrations/`, `local-directory-dataset-provider.ts`, + `dataset-provider-contract.ts`: `DatasetRow.resources`, `updateRows`. +- `src/client/components/run-resources-state.ts` *(new)* and `ResourcesSection.tsx` *(new)*. +- `src/client/components/pseudo-sources.ts`, `SourcePicker.tsx`, `source-tree.ts`: instance URIs + and the "Resources in this run" group. +- `src/client/components/PlaygroundExecution.tsx`, `EvalView.tsx`, `DatasetRowDetails.tsx`, + `dataset-row-editing.ts`, `named-inputs.ts`: adopting the section. +- `src/client/components/SourceRow.tsx`, `ExecutionInputEditor.tsx`, `resource-args-context.ts`, + `execution-input-state.ts`: removing per-chip argument forms and claims. +- `docs/`: the resource authoring guide gains "several instances" and "side-effect-only + resources", and the tree-resource pattern from §I. + +## N. Verification + +- **Registry (`vitest`, `MemoryFileProvider`):** two named instances of one resource with + identical args run `create` twice. An instance whose argument references another instance of + the *same* resource resolves, which is the hierarchy regression for problem 3 in the Context. A + two-instance reference cycle fails with both names. An instance nobody references is still + created and disposed. Slots referencing an instance hit the memo, so `create` runs once. A + dependency resolves to the run's declared instance of that resource and gets its args. A + server-scoped resource declared twice is a 400, and so is an old inline `resource` node. Existing + registry tests that use inline references are rewritten to declare instances. +- **Receipts and replay:** recorded `resources` carry per-instance receipts. POSTing them back + reproduces `root`'s id, and `child1.parentId` matches it. +- **Client state:** `renameInstance` rewrites slot, argument and check references, and nothing + else. `removeInstance` clears references. A stored selection holding an old inline node restores + as an empty slot rather than throwing. +- **Evals and datasets:** row instances shadow eval instances. An eval binding to a name a row + lacks gives that row an `error` naming it. "Add to dataset" carries instances, and "Fill from + row" restores them. +- **Playwright (`*.pw.tsx`):** adding a resource with no slot shows the card as unbound. Picking + from "New ▸" adds an instance and binds it. A second slot picking `root ▸ Title` reuses it. + Duplicate, then rename, updates every chip. The same `ResourcesSection` renders in the panel, + the eval Inputs panel and row details (one harness per host). +- **End to end:** in odin's playground, add `root`, `child1` and `child2` with `parentId` set to + `root.taskId`, bind only `taskId`, run, and see `list_tasks` return the three-node tree in the + trace. diff --git a/src/client/App.tsx b/src/client/App.tsx index 97658d3..fa8e592 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -18,6 +18,7 @@ import type { NormalizedPrompt, PromptID, PromptRef, + RunResources, SSEData, } from "../shared/types"; import { getAgentCommand, getAgents, renamePrompt } from "./api"; @@ -182,7 +183,7 @@ function flashPane(paneId: string) { block: "nearest", inline: "nearest", }); - // Restart-safe, as in `SourceRow.tsx`'s `scrollToRow`: removing the class + // Restart-safe, as in `ResourcesSection.tsx`'s `scrollToInstance`: removing the class // and forcing a reflow before adding it back makes a repeat click flash // again instead of no-op-ing into an animation already in flight. el.classList.remove("pane-flash"); @@ -783,12 +784,17 @@ function App() { const current = findPrompt(recorded); const fill = current && hasRecordedInputs(recorded) - ? panelFill(fromTrace(recorded, current), current, { - type: "trace", - description: `trace ${traceId.slice(0, 8)}…`, - providerId: traceProviderId, - traceId, - }) + ? panelFill( + fromTrace(recorded, current), + current, + { + type: "trace", + description: `trace ${traceId.slice(0, 8)}…`, + providerId: traceProviderId, + traceId, + }, + recorded.resources as RunResources | undefined, + ) : undefined; // At the variation the run applied, else the version it ran against — // the server opens head when that version is what's on disk now. diff --git a/src/client/api.ts b/src/client/api.ts index adf385f..6d7de3f 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -665,7 +665,7 @@ export async function deleteDataset( export async function addDatasetRows( providerId: string, datasetId: string, - rows: Pick[], + rows: Pick[], ): Promise { const res = await fetch(datasetUrl(providerId, datasetId, "/rows"), { method: "POST", diff --git a/src/client/components/AddToDatasetMenu.tsx b/src/client/components/AddToDatasetMenu.tsx index 9642901..7cfbc93 100644 --- a/src/client/components/AddToDatasetMenu.tsx +++ b/src/client/components/AddToDatasetMenu.tsx @@ -15,6 +15,7 @@ import type { DatasetRowSource, DatasetSummary, PromptID, + RunResources, } from "../../shared/types"; import { addDatasetRows, @@ -25,6 +26,7 @@ import { import { countMatches, type NamedInputs, + rowResources, type SkippedInput, toCells, } from "./named-inputs"; @@ -34,6 +36,8 @@ import { useAnchoredPopover } from "./use-anchored-popover"; interface Props { /** What would be added — one row's worth. */ inputs: NamedInputs; + /** The resource instances `inputs` may reference, kept on the row. */ + resources?: RunResources; /** The schema "New dataset…" creates. */ newDatasetFields: Omit[]; /** @@ -86,6 +90,7 @@ function describeSkipped(skipped: SkippedInput[]): string { */ export function AddToDatasetMenu({ inputs, + resources, newDatasetFields, prompt, source, @@ -150,7 +155,10 @@ export function AddToDatasetMenu({ fields: readonly DatasetField[], ) => { const { cells, skipped } = toCells(inputs, fields); - await addDatasetRows(target.providerId, target.id, [{ cells, source }]); + const kept = rowResources(resources); + await addDatasetRows(target.providerId, target.id, [ + { cells, source, ...(kept && { resources: kept }) }, + ]); setOutcome({ ok: true, message: `Added to ${target.name}${describeSkipped(skipped)}`, diff --git a/src/client/components/CombinedInputEditor.tsx b/src/client/components/CombinedInputEditor.tsx index 3b37f78..db27a84 100644 --- a/src/client/components/CombinedInputEditor.tsx +++ b/src/client/components/CombinedInputEditor.tsx @@ -7,7 +7,7 @@ import type { CombinedSelection, FieldGroup } from "./combined-inputs"; import { memberCount } from "./combined-inputs"; import { ExecutionInputEditor } from "./ExecutionInputEditor"; import type { SlotSelection } from "./execution-input-state"; -import { nestedField, type ResourceArgsContext } from "./resource-args-context"; +import type { SourceContext } from "./source-context"; interface Props { /** The fan-out slot itself (e.g. `toolsContext`). */ @@ -28,8 +28,8 @@ interface Props { * edit to that row. */ overwritten?: Record; - /** Forwarded to each row's {@link ExecutionInputEditor} unchanged — a chip with an argument form under it is still a chip. */ - argsContext?: ResourceArgsContext; + /** Forwarded to each row's {@link ExecutionInputEditor} unchanged. */ + context?: SourceContext; } /** @@ -46,7 +46,7 @@ export function CombinedInputEditor({ resources, slots, overwritten, - argsContext, + context, }: Props) { const totalMembers = memberCount(groups); @@ -98,16 +98,7 @@ export function CombinedInputEditor({ } resources={resources} slots={rowSlots(slots, propDef.name, group)} - // The row's identity for claiming an argument form (§I) has to - // agree with `computeClaims`, which only ever sees the - // *expanded* selection — so this uses the same representative - // member `rowSlots`/`droppedCandidates` already privilege, - // rather than the group's own synthetic name, which no - // expanded path actually has. - argsContext={ - argsContext && - nestedField(argsContext, `${group.members[0]}.${group.name}`) - } + context={context} /> ); diff --git a/src/client/components/DatasetRowDetails.tsx b/src/client/components/DatasetRowDetails.tsx index 10c2891..345ebfc 100644 --- a/src/client/components/DatasetRowDetails.tsx +++ b/src/client/components/DatasetRowDetails.tsx @@ -14,21 +14,35 @@ import type { Dataset, DatasetField, DatasetRow, + DatasetRowUpdate, ExecutionInput, + PromptInputSources, PropValue, ResourceInfo, } from "../../shared/types"; import { DetailRow, type Fact, FactGroup, FactsGrid } from "./DetailsPane"; import { previewCell, propValueToJson, resourceName } from "./dataset-preview"; import { + changesAnything, chosenResources, type DatasetInputSources, type RowEditorState, - rowCellChanges, + rowChanges, rowEditorState, } from "./dataset-row-editing"; import { ExecutionInputEditor } from "./ExecutionInputEditor"; -import { computeClaims } from "./resource-args-context"; +import { withInstanceResources, withInstanceSlots } from "./pseudo-sources"; +import { + type InstanceEdit, + instanceSourceContext, + ResourcesSection, +} from "./ResourcesSection"; +import { + adoptCatalogPick, + type InstanceSelections, + referencesTo, + retargetSelections, +} from "./run-resources-state"; import { formatTimestamp } from "./trace/format.ts"; import { CalendarIcon, SpansIcon } from "./trace/icons.tsx"; import { JsonView } from "./trace/JsonView.tsx"; @@ -59,7 +73,7 @@ function ValueView({ value }: { value: PropValue }) { ); } -/** Named inputs — an object's properties or a resource's arguments. */ +/** Named inputs — an object's properties or a resource instance's arguments. */ function KeyedInputs({ inputs }: { inputs: Record }) { return (
@@ -75,7 +89,7 @@ function KeyedInputs({ inputs }: { inputs: Record }) { /** * A dataset cell in full: the one-line previews the table has room for, - * expanded — long text to read, and a resource's arguments beneath it. + * expanded — long text to read, and objects key by key. */ export function InputView({ input }: { input: ExecutionInput }) { switch (input.kind) { @@ -83,237 +97,348 @@ export function InputView({ input }: { input: ExecutionInput }) { return ; case "object": return ; - case "resource": + case "instance": return ( -
- - - ◆ - - {resourceName(input.uri)} + + + ◆ - {input.args && Object.keys(input.args).length > 0 && ( - - )} -
+ {previewCell(input)} + ); case "dataset": case "input": return ( {previewCell(input)} ); + default: + return ( + {previewCell(input)} + ); } } /** A stable stand-in for "no resources", so memos keyed on it hold. */ const NO_RESOURCES: ResourceInfo[] = []; +/** A row's resource instances, read-only: each with its resource and arguments. */ +function RowResources({ row }: { row: DatasetRow }) { + const entries = Object.entries(row.resources ?? {}); + if (entries.length === 0) return null; + return ( + Resources}> +
+ {entries.map(([name, spec]) => ( +
+ ◆ {name} +
+ + {resourceName(spec.uri)} + + {spec.args && Object.keys(spec.args).length > 0 && ( + + )} +
+
+ ))} +
+
+ ); +} + /** - * A row's fields as the execute panel edits its slots: each in - * `ExecutionInputEditor` against the field's `def`, offering whatever - * resources `sources` maps onto it, with their arguments. + * A row's resource instances and fields, as the execute panel edits its own: + * the Resources section first, then each field in `ExecutionInputEditor` + * against the field's `def`, offering the row's instances and whatever + * resources `sources` maps onto it. * * Typed edits stay a local draft and are committed on blur, on Enter * (Shift+Enter still types a newline), or when the editor goes away, rather * than on every keystroke — each commit is a save. Choosing or dropping a - * resource commits at once, as there's nothing more to type. A commit sends - * every field whose cell changed, since a resource's arguments are shared by - * every field that picked it. + * source, and adding, removing or renaming an instance, commits at once, as + * there's nothing more to type. A commit sends every field and instance that + * changed. */ function RowFields({ fields, row, sources, - onChangeCells, + onChangeRow, }: { fields: readonly DatasetField[]; row: DatasetRow; sources: DatasetInputSources | undefined; - onChangeCells?: (cells: Record) => void; + onChangeRow?: (update: Omit) => void; }) { - const resources = sources?.resources ?? NO_RESOURCES; - const resourcesByUri = useMemo( - () => new Map(resources.map(r => [r.uri, r])), - [resources], - ); - const [state, setState] = useState(() => - rowEditorState(row.cells, resourcesByUri), + const catalog = sources?.resources ?? NO_RESOURCES; + const catalogByUri = useMemo( + () => new Map(catalog.map(r => [r.uri, r])), + [catalog], ); + const [state, setState] = useState(() => rowEditorState(row)); + // Updates apply to the latest state synchronously, not to the one this + // render saw: a catalog pick adds an instance and binds it in one gesture. + const stateRef = useRef(state); const [dirty, setDirty] = useState(false); - // New stored cells — the refetch after a save — replace a clean draft. - const [stored, setStored] = useState(row.cells); - if (stored !== row.cells) { - setStored(row.cells); - if (!dirty) setState(rowEditorState(row.cells, resourcesByUri)); + // A new stored row — the refetch after a save — replaces a clean draft. + const [stored, setStored] = useState(row); + if (stored !== row) { + setStored(row); + if (!dirty) { + const fresh = rowEditorState(row); + stateRef.current = fresh; + setState(fresh); + } } - // A resource or `object` cell is only editable with resources to offer: + // An instance or `object` cell is only editable with resources to offer: // without them it would read as a resource that's gone. const isEditable = (field: DatasetField) => { const input = row.cells[field.id]; - return !!onChangeCells && (!!sources || !input || input.kind === "value"); + return !!onChangeRow && (!!sources || !input || input.kind === "value"); }; const editableIds = fields.filter(isEditable).map(f => f.id); const commit = (next: RowEditorState) => { setDirty(false); - const changes = rowCellChanges( + const changes = rowChanges( editableIds, - next, - row.cells, - resourcesByUri, + sources ? next : { ...next, instances: rowEditorState(row).instances }, + row, ); - if (Object.keys(changes).length > 0) onChangeCells?.(changes); + if (changesAnything(changes)) onChangeRow?.(changes); }; - const update = (next: RowEditorState) => { - setState(next); - if (chosenResources(next) !== chosenResources(state)) commit(next); + const update = ( + next: RowEditorState | ((latest: RowEditorState) => RowEditorState), + ) => { + const prev = stateRef.current; + const value = typeof next === "function" ? next(prev) : next; + stateRef.current = value; + setState(value); + if (chosenResources(value) !== chosenResources(prev)) commit(value); else setDirty(true); }; // Selecting another row in the grid swaps this editor out (it's keyed by // row) without a blur: the focused textarea is simply removed. So a draft // still pending then is committed on the way out, to the row it was made - // on — `onChangeCells` is bound to that row. + // on — `onChangeRow` is bound to that row. const commitPending = () => { - if (dirty) commit(state); + if (dirty) commit(stateRef.current); + }; + const commitOnEnter = (e: KeyboardEvent) => { + // An editor that used Enter itself — to pick a suggestion, say — has + // already claimed it. + if ( + e.key !== "Enter" || + e.shiftKey || + e.defaultPrevented || + e.nativeEvent.isComposing + ) { + return; + } + e.preventDefault(); + commitPending(); }; const commitRef = useRef(commitPending); commitRef.current = commitPending; useEffect(() => () => commitRef.current(), []); - const claimed = useMemo( - () => - computeClaims( - fields.map(f => ({ - path: fieldPath(f), - label: f.def.name, - selection: state.selections[f.id], - })), - state.resourceArgs, - resourcesByUri, + const { instances } = state; + const sectionSources = useMemo( + () => ({ + resources: withInstanceResources(catalog, instances), + functionSlots: {}, + executeSlots: {}, + resourceSlots: Object.fromEntries( + Object.entries(sources?.resourceSlots ?? {}).map(([uri, slots]) => [ + uri, + withInstanceSlots(slots, catalog, instances), + ]), ), - [fields, state, resourcesByUri], + }), + [catalog, instances, sources], ); - return fields.map(field => { - const input = row.cells[field.id]; - return ( - - {field.def.name} - - {shortSyntax(field.def.type.syntax, 32)} - - - } - > -
- {isEditable(field) ? ( -
) => { - if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { - commitPending(); - } - }} - onKeyDown={(e: KeyboardEvent) => { - // An editor that used Enter itself — to pick a suggestion, - // say — has already claimed it. - if ( - e.key !== "Enter" || - e.shiftKey || - e.defaultPrevented || - e.nativeEvent.isComposing - ) { - return; - } - e.preventDefault(); - commitPending(); - }} - > - - update({ - ...state, - selections: { ...state.selections, [field.id]: selection }, - }) - } - resources={resources} - slots={sources?.fieldSlots[field.id] ?? {}} - argsContext={ - sources && { - resourceArgs: state.resourceArgs, - onResourceArgsChange: (uri, next) => - update({ - ...state, - resourceArgs: { ...state.resourceArgs, [uri]: next }, - }), - resourceSlots: sources.resourceSlots, - claimed, - path: fieldPath(field), - depth: 0, - } - } - /> -
- ) : input ? ( - - ) : ( - — - )} - {onChangeCells && input && ( - - )} -
-
- ); + const changeInstances = ( + next: + | InstanceSelections + | ((latest: InstanceSelections) => InstanceSelections), + edit?: InstanceEdit, + ) => + update(latest => { + const value = typeof next === "function" ? next(latest.instances) : next; + if (!edit) return { ...latest, instances: value }; + const [from, to] = + edit.kind === "rename" ? [edit.from, edit.to] : [edit.name, null]; + return { + instances: value, + selections: retargetSelections(latest.selections, from, to), + }; + }); + + const context = instanceSourceContext({ + instances, + catalogByUri, + adopt: uri => { + const picked = adoptCatalogPick( + stateRef.current.instances, + uri, + catalogByUri, + ); + // Left a draft: the pick that follows binds the new instance, and + // saves both at once. + const latest = stateRef.current; + const next = { ...latest, instances: picked.instances }; + stateRef.current = next; + setState(next); + setDirty(true); + return picked.uri; + }, }); -} -/** A field's row identity, for "same instance as" links between fields. */ -function fieldPath(field: DatasetField): string { - return `field.${field.id}`; + // Every field is one `DetailRow`; the section sits above them as one more. + const editableRows = !!onChangeRow && !!sources; + return ( + <> + {fields.map(field => { + const input = row.cells[field.id]; + return ( + + {field.def.name} + + {shortSyntax(field.def.type.syntax, 32)} + + + } + > +
+ {isEditable(field) ? ( +
) => { + if ( + !e.currentTarget.contains(e.relatedTarget as Node | null) + ) { + commitPending(); + } + }} + onKeyDown={commitOnEnter} + > + + update(latest => ({ + ...latest, + selections: { + ...latest.selections, + [field.id]: selection, + }, + })) + } + resources={sectionSources.resources} + slots={withInstanceSlots( + sources?.fieldSlots[field.id], + catalog, + instances, + )} + context={sources && context} + /> +
+ ) : input ? ( + + ) : ( + — + )} + {onChangeRow && input && ( + + )} +
+
+ ); + })} + {editableRows ? ( + }> +
) => { + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { + commitPending(); + } + }} + onKeyDown={commitOnEnter} + > + + referencesTo(name, instances, [ + { + selections: Object.fromEntries( + fields.flatMap(f => + state.selections[f.id] + ? [[f.def.name, state.selections[f.id]!] as const] + : [], + ), + ), + }, + ]) + } + context={context} + /> +
+
+ ) : ( + + )} + + ); } /** * One dataset row in the details pane: when it was added and where from, * then every field — the same facts-over-blocks layout as a span's details. * - * With `onChangeCells`, every field is edited in place as the execute panel - * edits a parameter, and any cell can be cleared. Resources are offered from - * `sources` — the linked prompt's, since a dataset has none of its own. - * Without them, a resource or `object` cell stays read-only. See + * With `onChangeRow`, every field is edited in place as the execute panel + * edits a parameter, and any cell can be cleared. The row's resource + * instances are edited above the fields, from the catalog in `sources` — the + * linked prompt's, since a dataset has none of its own. Without it, the + * instances and any instance or `object` cell stay read-only. See * `specs/datasets.md` §P.2. */ export function DatasetRowDetails({ dataset, row, onOpenTrace, - onChangeCells, + onChangeRow, sources, }: { dataset: Dataset; row: DatasetRow; onOpenTrace: (providerId: string, traceId: string) => void; /** - * Sets fields' cells, by field id, or clears them with `null`. Read-only - * without. + * Sets fields' cells, by field id, or clears them with `null`, and sets or + * removes the row's resource instances by name. Read-only without. */ - onChangeCells?: (cells: Record) => void; + onChangeRow?: (update: Omit) => void; /** The resources the fields can be filled from, if any. */ sources?: DatasetInputSources; }) { @@ -357,7 +482,7 @@ export function DatasetRowDetails({ fields={dataset.fields} row={row} sources={sources} - onChangeCells={onChangeCells} + onChangeRow={onChangeRow} />
diff --git a/src/client/components/DatasetView.tsx b/src/client/components/DatasetView.tsx index b4dddd2..c5a2b70 100644 --- a/src/client/components/DatasetView.tsx +++ b/src/client/components/DatasetView.tsx @@ -43,7 +43,7 @@ import type { DatasetField, DatasetRow, DatasetRowsOverview, - ExecutionInput, + DatasetRowUpdate, NormalizedPrompt, PromptID, } from "../../shared/types"; @@ -596,13 +596,18 @@ function DatasetView({ if (!dataset || !linkedPrompt || !row) return; onOpenInPlayground( linkedPrompt, - panelFill(fromRow(dataset, row), linkedPrompt, { - type: "dataset", - description: `${dataset.name}, row ${index + 1}`, - providerId, - datasetId, - name: dataset.name, - }), + panelFill( + fromRow(dataset, row), + linkedPrompt, + { + type: "dataset", + description: `${dataset.name}, row ${index + 1}`, + providerId, + datasetId, + name: dataset.name, + }, + row.resources, + ), ); }; @@ -675,9 +680,8 @@ function DatasetView({ * reloads the visible rows from the server, dropping whatever optimistic * edits are on them — any still saving refetch again when they land. */ - const saveEdits = useCallback( - (edits: CellEdit[]) => { - const updates = groupEdits(edits); + const saveUpdates = useCallback( + (updates: DatasetRowUpdate[]) => { if (updates.length === 0) return; pager.patch(updates); setLoaded(n => n + 1); @@ -690,18 +694,9 @@ function DatasetView({ }, [pager, providerId, datasetId, overview.rowCount], ); - - /** Sets or clears cells of one row, by field id — the details pane's edits. */ - const saveCells = useCallback( - (rowId: string, cells: Record) => - saveEdits( - Object.entries(cells).map(([fieldId, cell]) => ({ - rowId, - fieldId, - cell, - })), - ), - [saveEdits], + const saveEdits = useCallback( + (edits: CellEdit[]) => saveUpdates(groupEdits(edits)), + [saveUpdates], ); /** A single edit, a paste over a range, or a fill — one batch either way. */ @@ -968,7 +963,9 @@ function DatasetView({ dataset={dataset} row={selectedRow} onOpenTrace={onOpenTrace} - onChangeCells={cells => saveCells(selectedRow.id, cells)} + onChangeRow={update => + saveUpdates([{ rowId: selectedRow.id, ...update }]) + } sources={rowSources} /> diff --git a/src/client/components/EvalView.tsx b/src/client/components/EvalView.tsx index c34808b..c644385 100644 --- a/src/client/components/EvalView.tsx +++ b/src/client/components/EvalView.tsx @@ -56,9 +56,7 @@ import { evalProblems, prefillBindings } from "./eval-bindings"; import { deleteRunQuestion, formatRate, passRate } from "./eval-summary"; import { fromExecutionInput, - type ResourceArgs, readStoredInputs, - resourceArgsFor, type Selections, type SlotSelection, toExecutionInput, @@ -69,12 +67,22 @@ import { columnUri, describePseudoSource, NEW_COLUMN_URI, + withInstanceSources, withPseudoSources, } from "./pseudo-sources"; import { - computeClaims, - type ResourceArgsContext, -} from "./resource-args-context"; + type InstanceEdit, + instanceSourceContext, + ResourcesSection, +} from "./ResourcesSection"; +import { + adoptCatalogPick, + fromWireResources, + type InstanceSelections, + referencesTo, + retargetSelections, + toWireResources, +} from "./run-resources-state"; import { formatTimestampCompact } from "./trace/format.ts"; import { PromptLinkIcon, @@ -124,32 +132,44 @@ interface EditorState { exec: Selections; /** Check id → its parameters' selections. */ checks: Record; - /** Shared by every slot and check parameter, as in the panel. */ - args: ResourceArgs; + /** + * The eval's own resource instances, which every row gets — a row's own + * instance of the same name wins (`specs/resource-instances.md` §E). + */ + instances: InstanceSelections; } /** Editor state recovered from saved bindings. */ function editorStateOf( def: Pick, - resourcesByUri: ReadonlyMap, ): EditorState { - const args: ResourceArgs = {}; const restore = (inputs: Record) => Object.fromEntries( - Object.entries(inputs).map(([name, input]) => { - const recovered = fromExecutionInput(input, resourcesByUri); - Object.assign(args, recovered.resourceArgs); - return [name, recovered.selection]; - }), + Object.entries(inputs).map(([name, input]) => [ + name, + fromExecutionInput(input), + ]), ); return { fn: restore(def.inputs.functionInputs), exec: restore(def.inputs.executeInputs), checks: Object.fromEntries(def.checks.map(c => [c.id, restore(c.args)])), - args, + instances: fromWireResources(def.inputs.resources), }; } +/** `selections` as inputs by name, empty ones left out. */ +function foldSelections( + selections: Selections, +): Record { + return Object.fromEntries( + Object.entries(selections).flatMap(([k, s]) => { + const input = toExecutionInput(s); + return input ? [[k, input] as const] : []; + }), + ); +} + /** A new check's id: short, and unique within the eval. */ function mintCheckId(existing: readonly EvalCheck[]): string { let n = existing.length + 1; @@ -194,7 +214,7 @@ function EvalView({ fn: {}, exec: {}, checks: {}, - args: {}, + instances: {}, }); /** Bindings made for the user and not yet touched — see `Prefilled.matched`. */ const [matched, setMatched] = useState>(new Set()); @@ -232,7 +252,9 @@ function EvalView({ if (loadedId.current !== evalId) { loadedId.current = evalId; setName(loaded.name); - setState(editorStateOf(loaded, resourcesByUri)); + const loadedState = editorStateOf(loaded); + stateRef.current = loadedState; + setState(loadedState); } }) .catch(err => !cancelled && setLoadError(err.message)); @@ -306,35 +328,19 @@ function EvalView({ /** The bindings `next` folds to. */ const foldInputs = (next: EditorState): EvalInputs => { - const resolve = (uri: string) => - resourceArgsFor(uri, next.args, resourcesByUri); - const fold = (sel: Selections) => - Object.fromEntries( - Object.entries(sel).flatMap(([k, s]) => { - const input = toExecutionInput(s, resolve); - return input ? [[k, input] as const] : []; - }), - ); - return { functionInputs: fold(next.fn), executeInputs: fold(next.exec) }; - }; - const foldChecks = (next: EditorState, checks: EvalCheck[]): EvalCheck[] => { - const resolve = (uri: string) => - resourceArgsFor(uri, next.args, resourcesByUri); - return checks.map(c => ({ - ...c, - args: Object.fromEntries( - Object.entries(next.checks[c.id] ?? {}).flatMap(([k, s]) => { - const input = toExecutionInput(s, resolve); - return input ? [[k, input] as const] : []; - }), - ), - })); + const resources = toWireResources(next.instances); + return { + functionInputs: foldSelections(next.fn), + executeInputs: foldSelections(next.exec), + ...(resources && { resources }), + }; }; + const foldChecks = (next: EditorState, checks: EvalCheck[]): EvalCheck[] => + checks.map(c => ({ ...c, args: foldSelections(next.checks[c.id] ?? {}) })); // The latest state and definition, for a change that lands after an // `await` (a new column), when the render's own `state` may be stale. const stateRef = useRef(state); - stateRef.current = state; const defRef = useRef(def); defRef.current = def; @@ -363,11 +369,12 @@ function EvalView({ stored: readStoredInputs(prompt), }); if (proposed.matched.length === 0) return; - const next = editorStateOf( - { inputs: proposed.inputs, checks: proposed.checks }, - resourcesByUri, - ); + const next = editorStateOf({ + inputs: proposed.inputs, + checks: proposed.checks, + }); setMatched(prev => new Set([...prev, ...proposed.matched])); + stateRef.current = next; setState(next); save({ inputs: proposed.inputs, checks: proposed.checks }); }, [def?.id, prompt?.id, fields, checkInfos, checkIdsKey]); @@ -396,14 +403,17 @@ function EvalView({ // Stable while only values change, so typing doesn't remount editors. const sources = useStructurallyStable( - withPseudoSources(prompt?.inputSources, { - functionParameters, - executeParameters, - bindings: inputs, - fields: fields ?? [], - offerMismatches: true, - newColumn: true, - }), + withPseudoSources( + withInstanceSources(prompt?.inputSources, state.instances), + { + functionParameters, + executeParameters, + bindings: inputs, + fields: fields ?? [], + offerMismatches: true, + newColumn: true, + }, + ), ); const pseudoOptions = { functionParameters, executeParameters, fields }; const checkSources = useStructurallyStable( @@ -418,31 +428,43 @@ function EvalView({ ), ); - const claimed = computeClaims( - [ - ...functionParameters.map(p => ({ - path: `fn.${p.name}`, - label: p.name, - selection: state.fn[p.name], - })), - ...executeParameters.map(p => ({ - path: `exec.${p.name}`, - label: p.name, - selection: state.exec[p.name], - })), - ], - state.args, - resourcesByUri, - ); - const argsContextFor = (path: string): ResourceArgsContext => ({ - resourceArgs: state.args, - onResourceArgsChange: (uri, next) => - commit({ ...state, args: { ...state.args, [uri]: next } }), - resourceSlots: sources.resourceSlots ?? {}, - claimed, - path, - depth: 0, - describePseudo: (uri, type) => + const changeInstances = ( + next: + | InstanceSelections + | ((latest: InstanceSelections) => InstanceSelections), + edit?: InstanceEdit, + ) => { + const prev = stateRef.current; + const instances = typeof next === "function" ? next(prev.instances) : next; + if (!edit) return commit({ ...prev, instances }); + const [from, to] = + edit.kind === "rename" ? [edit.from, edit.to] : [edit.name, null]; + commit({ + fn: retargetSelections(prev.fn, from, to), + exec: retargetSelections(prev.exec, from, to), + checks: Object.fromEntries( + Object.entries(prev.checks).map(([id, sel]) => [ + id, + retargetSelections(sel, from, to), + ]), + ), + instances, + }); + }; + const context = instanceSourceContext({ + instances: state.instances, + catalogByUri: resourcesByUri, + rowsMayDeclare: true, + adopt: uri => { + const picked = adoptCatalogPick( + stateRef.current.instances, + uri, + resourcesByUri, + ); + changeInstances(picked.instances); + return picked.uri; + }, + describeOther: (uri, type) => describePseudoSource(uri, type, pseudoOptions), }); @@ -498,7 +520,8 @@ function EvalView({ }); }; if (relPath === undefined) { - commit({ ...state, [which]: { ...state[which], [name]: selection } }); + const prev = stateRef.current; + commit({ ...prev, [which]: { ...prev[which], [name]: selection } }); return; } void addColumn( @@ -562,6 +585,7 @@ function EvalView({ if (!def) return; const { [id]: _, ...rest } = state.checks; const nextState = { ...state, checks: rest }; + stateRef.current = nextState; setState(nextState); save( { @@ -622,7 +646,6 @@ function EvalView({ onChange, sources: from, slots, - path, }: { key: string; label: string; @@ -631,7 +654,6 @@ function EvalView({ onChange: (selection: SlotSelection) => void; sources: PromptInputSources; slots: PromptInputSources["functionSlots"]; - path: string; }, ) => (
@@ -658,7 +680,7 @@ function EvalView({ onChange={onChange} resources={from.resources} slots={slots} - argsContext={argsContextFor(path)} + context={context} />
); @@ -673,7 +695,6 @@ function EvalView({ onChange: selection => changeSlot(which)(param.name, selection), sources, slots: which === "fn" ? sources.functionSlots : sources.executeSlots, - path: `${which}.${param.name}`, }), ); @@ -872,7 +893,6 @@ function EvalView({ changeCheckParam(check)(param.name, selection), sources: checkSources[check.id] ?? EMPTY_SOURCES, slots: checkSources[check.id]?.functionSlots ?? {}, - path: `check.${check.id}.${param.name}`, }), )} @@ -950,6 +970,26 @@ function EvalView({

Choose a prompt.

)}
+ {((prompt?.inputSources?.resources.length ?? 0) > 0 || + Object.keys(state.instances).length > 0) && ( + + referencesTo(instance, state.instances, [ + { selections: state.fn }, + { selections: state.exec }, + ...Object.values(state.checks).map(selections => ({ + selections, + })), + ]) + } + context={context} + /> + )} +
); diff --git a/src/client/components/ExecutionInputEditor.tsx b/src/client/components/ExecutionInputEditor.tsx index c85b5a8..363f3ca 100644 --- a/src/client/components/ExecutionInputEditor.tsx +++ b/src/client/components/ExecutionInputEditor.tsx @@ -12,8 +12,9 @@ import type { ResourceInfo, } from "../../shared/types"; import { SELF, type SlotSelection } from "./execution-input-state"; -import { nestedField, type ResourceArgsContext } from "./resource-args-context"; +import { isPseudoUri } from "./pseudo-sources"; import { SourceRow } from "./SourceRow"; +import type { SourceContext } from "./source-context"; interface Props { /** The slot being edited. */ @@ -29,14 +30,8 @@ interface Props { * name, as computed provider-side. */ slots: Record; - /** - * Argument-editor state and how to change it, threaded down so a chosen - * resource with declared `parameters` can render its own argument form - * beneath the chip — see `SourceRow` and `specs/resource-arguments.md` §I. - * Absent (e.g. in a host that doesn't wire it up) simply means no resource - * here ever shows an argument form, whether or not it has parameters. - */ - argsContext?: ResourceArgsContext; + /** How chips read, and how a catalog pick becomes an instance — see {@link SourceContext}. */ + context?: SourceContext; } /** @@ -60,7 +55,7 @@ export function ExecutionInputEditor({ onChange, resources, slots, - argsContext, + context, }: Props) { const byUri = useMemo( () => new Map(resources.map(r => [r.uri, r])), @@ -73,20 +68,23 @@ export function ExecutionInputEditor({ // dropping focus after every character. `selection` changes on every // keystroke (it carries the typed value), so neither `chooseResource` nor // the plugin below may depend on it directly; a ref keeps both reading the - // latest `selection` anyway, just not as a dependency. `argsContext` is the - // same story and then some — its `resourceArgs` changes on every keystroke - // of *any* argument form anywhere in the panel, not just this slot's. + // latest `selection` anyway, just not as a dependency. `context` is the + // same story: hosts build it inline, so it's new on every render. const selectionRef = useRef(selection); selectionRef.current = selection; const onChangeRef = useRef(onChange); onChangeRef.current = onChange; - const argsContextRef = useRef(argsContext); - argsContextRef.current = argsContext; + const contextRef = useRef(context); + contextRef.current = context; const chooseResource = useCallback((path: string, uri: string | null) => { const current = selectionRef.current; const next = { ...(current.resources ?? {}) }; - if (uri) next[path] = uri; + // A pick from the catalog adds an instance to the run; the slot holds a + // reference to it, like any other pick from "Resources in this run". + const adopt = contextRef.current?.adopt; + const chosen = uri && !isPseudoUri(uri) && adopt ? adopt(uri) : uri; + if (chosen) next[path] = chosen; else delete next[path]; onChangeRef.current({ ...current, resources: next }); }, []); @@ -112,16 +110,6 @@ export function ExecutionInputEditor({ match: matches, component: (props: ItemEditorProps) => { const path = relativePath(props.path); - const current = argsContextRef.current; - // `path` is this field's own position relative to the top-level - // slot (e.g. "list_tasks.db") — folded into the row's identity so - // it can't collide with an unrelated row that happens to share a - // name (see `ResourceArgsContext.path`). Empty only if the plugin - // ever matched the slot's own root item rather than a nested - // field, which isn't a case ts-proppy produces today; guarded - // rather than assumed. - const nestedContext = - current && path ? nestedField(current, path) : current; // A matched node that is itself an object (not opaque — an opaque // leaf never reaches this recursive call at all, since `SourceRow` // only renders `children` for an editable, i.e. non-opaque, type) @@ -143,7 +131,7 @@ export function ExecutionInputEditor({ matching={matchingFor(slots, propDef.name, props.path, byUri)} chosen={selectionRef.current.resources?.[path]} onChoose={uri => chooseResource(path, uri)} - argsContext={nestedContext} + context={contextRef.current} > chooseResource(SELF, uri)} - argsContext={argsContext} + context={context} > { + const seq = ++updateSeq.current; const basePrompt = shownRef.current; const atRef = refRef.current; const optimisticPrompt = applyOptimisticUpdates(basePrompt, updates); @@ -352,6 +355,10 @@ function PlaygroundContent({ atRef, ); if (!versioned) { + // A later edit was sent on top of this one: its echo carries this + // one too, while this stale echo would overwrite (and re-render + // mid-typing) whatever was typed since. + if (seq !== updateSeq.current) return; if (promptKey(updated) !== promptKey(shownRef.current)) { shownRef.current = updated; onUpdate(updated); diff --git a/src/client/components/PlaygroundExecution.tsx b/src/client/components/PlaygroundExecution.tsx index b98380b..a18d93e 100644 --- a/src/client/components/PlaygroundExecution.tsx +++ b/src/client/components/PlaygroundExecution.tsx @@ -31,8 +31,6 @@ import { brokenResources, ExecutionInputEditor } from "./ExecutionInputEditor"; import { fromExecutionInput, paramStorageKey, - type ResourceArgs, - resourceArgsFor, type Selections, type SlotSelection, type StoredInputs, @@ -45,11 +43,24 @@ import { type PartialExecuteRequest, type SkippedInput, } from "./named-inputs"; -import { describePseudoSource, withPseudoSources } from "./pseudo-sources"; import { - computeClaims, - type ResourceArgsContext, -} from "./resource-args-context"; + describePseudoSource, + withInstanceSources, + withPseudoSources, +} from "./pseudo-sources"; +import { + type InstanceEdit, + instanceSourceContext, + ResourcesSection, +} from "./ResourcesSection"; +import { + fromWireResources, + type InstanceSelections, + referencesTo, + retargetSelections, + toWireResources, +} from "./run-resources-state"; +import { useRunInstances } from "./use-run-instances"; interface Props { prompt: NormalizedPrompt; @@ -109,20 +120,35 @@ type OverwrittenNotes = { exec: Record>; }; -/** - * A top-level slot's own unique position — the root every nested - * {@link ResourceArgsContext.path} within it is built from. Namespaced by - * `which` so a function parameter and an execute parameter of the same name - * (a real case — `PromptInputSources.functionSlots` / - * `executeSlots` are already a parallel split for exactly this reason) don't - * collide. - */ -function slotPath(which: "fn" | "exec", name: string): string { - return `${which}.${name}`; -} - /** A stable stand-in for a prompt with no execute parameters, so memos keyed on them hold. */ const NO_PARAMETERS: PropDefinition[] = []; +/** The same, for a prompt with no resources in scope. */ +const NO_RESOURCES: ResourceInfo[] = []; + +/** `selections` as inputs by slot name, empty slots left out. */ +function namedInputs( + defs: readonly PropDefinition[], + selections: Selections, +): Record { + return Object.fromEntries( + defs.flatMap(d => { + const input = toExecutionInput(selections[d.name]); + return input ? [[d.name, input] as const] : []; + }), + ); +} + +/** Editor state for a set of stored or recorded inputs, by slot name. */ +function restoreSelections( + inputs: Record = {}, +): Selections { + return Object.fromEntries( + Object.entries(inputs).map(([name, input]) => [ + name, + fromExecutionInput(input), + ]), + ); +} /** * Restore the panel from what was saved last time. @@ -130,50 +156,34 @@ const NO_PARAMETERS: PropDefinition[] = []; * An {@link ExecutionInput} is what gets stored, rather than a bare value: a * resource choice has no value to save, and storing the recipe is what lets a * template come back as a template rather than as the string it flattened to. - * - * Arguments ride inside those same stored inputs (a resource node's `args`) - * rather than in a second key — `resourceArgs` here is *derived*, by pulling - * every `args` a stored reference carries back out into - * `specs/resource-arguments.md` §J's shared-by-uri shape, from wherever in - * the tree it turns up. + * The run's resource instances are stored beside the slots, as the request + * sends them. */ function loadStored(prompt: NormalizedPrompt): { fn: Selections; exec: Selections; layout: LayoutChoices; - resourceArgs: ResourceArgs; + instances: InstanceSelections; } { const empty = { fn: {}, exec: {}, layout: { fn: {}, exec: {} }, - resourceArgs: {}, + instances: {}, }; try { const raw = localStorage.getItem(paramStorageKey(prompt)); if (!raw) return empty; const parsed = JSON.parse(raw) as StoredInputs; if (!parsed || typeof parsed !== "object") return empty; - const resourcesByUri = new Map( - (prompt.inputSources?.resources ?? []).map(r => [r.uri, r]), - ); - const resourceArgs: ResourceArgs = {}; - const restore = (inputs: Record = {}) => - Object.fromEntries( - Object.entries(inputs).map(([name, input]) => { - const recovered = fromExecutionInput(input, resourcesByUri); - Object.assign(resourceArgs, recovered.resourceArgs); - return [name, recovered.selection]; - }), - ); return { - fn: restore(parsed.functionInputs), - exec: restore(parsed.executeInputs), + fn: restoreSelections(parsed.functionInputs), + exec: restoreSelections(parsed.executeInputs), layout: { fn: parsed.layout?.functionSlots ?? {}, exec: parsed.layout?.executeSlots ?? {}, }, - resourceArgs, + instances: fromWireResources(parsed.resources), }; } catch { /* ignore (private browsing, quota, corrupt entry, etc.) */ @@ -212,9 +222,6 @@ function PlaygroundExecution({ const [layoutChoices, setLayoutChoices] = useState( stored.layout, ); - const [resourceArgs, setResourceArgs] = useState( - stored.resourceArgs, - ); const [overwrittenNotes, setOverwrittenNotes] = useState({ fn: {}, exec: {}, @@ -226,108 +233,64 @@ function PlaygroundExecution({ const executeParameters = prompt.executeParameters ?? NO_PARAMETERS; const broken = brokenResources(prompt.inputSources); + const catalog = prompt.inputSources?.resources ?? NO_RESOURCES; const resourcesByUri = useMemo( - () => - new Map( - (prompt.inputSources?.resources ?? []).map(r => [r.uri, r]), - ), - [prompt.inputSources], + () => new Map(catalog.map(r => [r.uri, r])), + [catalog], + ); + const { instances, setInstances, adopt } = useRunInstances( + stored.instances, + resourcesByUri, ); // The other slots, as sources for `input` references (`specs/evals.md` // §B.2.1): offered where their type fits and no cycle would close. const bindings = useMemo(() => { - const resolve = (uri: string) => - resourceArgsFor(uri, resourceArgs, resourcesByUri); - const named = (defs: readonly PropDefinition[], sel: Selections) => - Object.fromEntries( - defs.flatMap(d => { - const input = toExecutionInput(sel[d.name], resolve); - return input ? [[d.name, input] as const] : []; - }), - ); + const resources = toWireResources(instances); return { - functionInputs: named(prompt.functionParameters, functionSelections), - executeInputs: named(executeParameters, executeSelections), + functionInputs: namedInputs( + prompt.functionParameters, + functionSelections, + ), + executeInputs: namedInputs(executeParameters, executeSelections), + ...(resources && { resources }), }; }, [ prompt.functionParameters, executeParameters, functionSelections, executeSelections, - resourceArgs, - resourcesByUri, + instances, ]); // Stable while only values change, so typing doesn't remount editors. const sources = useStructurallyStable( - withPseudoSources(prompt.inputSources, { + withPseudoSources(withInstanceSources(prompt.inputSources, instances), { functionParameters: prompt.functionParameters, executeParameters, bindings, }), ); - // Panel order — the first slot to reach a given resource `uri` owns its - // argument form; every later selection of it just points back (§I). - // Recomputed purely from the current selections on every render (rather - // than mutated as rows render) so it behaves identically under React - // StrictMode's double-invoked renders. - const claimed = useMemo( - () => - computeClaims( - [ - ...prompt.functionParameters.map(p => ({ - path: slotPath("fn", p.name), - label: p.name, - selection: functionSelections[p.name], - })), - ...executeParameters.map(p => ({ - path: slotPath("exec", p.name), - label: p.name, - selection: executeSelections[p.name], - })), - ], - resourceArgs, - resourcesByUri, - ), - [ - prompt.functionParameters, - executeParameters, - functionSelections, - executeSelections, - resourceArgs, - resourcesByUri, - ], - ); - - const persist = ( - fn: Selections, - exec: Selections, - layout: LayoutChoices, - args: ResourceArgs, - ) => { + // Every edit lands in storage, whichever piece of state it changed. + // biome-ignore lint/correctness/useExhaustiveDependencies: `bindings` is derived from exactly the state that matters. + useEffect(() => { try { - const resolveArgs = (uri: string) => - resourceArgsFor(uri, args, resourcesByUri); - const collect = (defs: readonly PropDefinition[], sel: Selections) => - Object.fromEntries( - defs.flatMap(d => { - const input = toExecutionInput(sel[d.name], resolveArgs); - return input ? [[d.name, input] as const] : []; - }), - ); localStorage.setItem( paramStorageKey(prompt), JSON.stringify({ - functionInputs: collect(prompt.functionParameters, fn), - executeInputs: collect(executeParameters, exec), - layout: { functionSlots: layout.fn, executeSlots: layout.exec }, + functionInputs: bindings.functionInputs, + executeInputs: bindings.executeInputs, + ...(bindings.resources && { resources: bindings.resources }), + layout: { + functionSlots: layoutChoices.fn, + executeSlots: layoutChoices.exec, + }, } satisfies StoredInputs), ); } catch { /* ignore (private browsing, quota, etc.) */ } - }; + }, [bindings, layoutChoices]); const change = (which: "fn" | "exec") => (name: string, selection: SlotSelection) => { @@ -341,34 +304,30 @@ function PlaygroundExecution({ : executeSelections; setFunctionSelections(fn); setExecuteSelections(exec); - persist(fn, exec, layoutChoices, resourceArgs); }; - const onResourceArgsChange = (uri: string, next: Selections) => { - const args = { ...resourceArgs, [uri]: next }; - setResourceArgs(args); - persist(functionSelections, executeSelections, layoutChoices, args); + const changeInstances = ( + next: Parameters[0], + edit?: InstanceEdit, + ) => { + setInstances(next); + if (!edit) return; + const [from, to] = + edit.kind === "rename" ? [edit.from, edit.to] : [edit.name, null]; + setFunctionSelections(s => retargetSelections(s, from, to)); + setExecuteSelections(s => retargetSelections(s, from, to)); }; - // Not itself a valid row's context — `path` is meaningless at this level — - // but every field *other* than `path` is shared, so each slot spreads this - // and sets its own `path` (see `renderSlots`) rather than repeating the rest. - const argsContextBase: Omit = { - resourceArgs, - onResourceArgsChange, - resourceSlots: sources.resourceSlots ?? {}, - claimed, - depth: 0, - describePseudo: (uri, type) => + const context = instanceSourceContext({ + instances, + catalogByUri: resourcesByUri, + adopt, + describeOther: (uri, type) => describePseudoSource(uri, type, { functionParameters: prompt.functionParameters, executeParameters, }), - }; - - /** Builds the `args` a chosen resource should carry, from the current {@link resourceArgs} state. */ - const resolveArgs = (uri: string) => - resourceArgsFor(uri, resourceArgs, resourcesByUri); + }); /** * A slot's effective layout (§B precedence): the user's own stored choice, @@ -388,7 +347,7 @@ function PlaygroundExecution({ )?.[def.name]; return resolveLayout( groups, - toExecutionInput(selections[def.name], resolveArgs), + toExecutionInput(selections[def.name]), layoutChoices[which][def.name], hint, ); @@ -410,13 +369,9 @@ function PlaygroundExecution({ if (next === "combined") { const selections = which === "fn" ? fn : exec; - const storedInput = toExecutionInput(selections[def.name], resolveArgs); + const storedInput = toExecutionInput(selections[def.name]); const { selection, overwritten } = collapseLossy(groups, storedInput); - // Any `args` the round trip carries came from `resourceArgs` itself (via - // `resolveArgs`), so only the plain selection is needed back out. - const merged = fromExecutionInput( - expand(groups, selection, resolveArgs), - ).selection; + const merged = fromExecutionInput(expand(groups, selection)); if (which === "fn") fn = { ...fn, [def.name]: merged }; else exec = { ...exec, [def.name]: merged }; setFunctionSelections(fn); @@ -428,9 +383,7 @@ function PlaygroundExecution({ setOverwrittenNotes(prev => withEntry(prev, which, def.name, undefined)); } - const layout = withEntry(layoutChoices, which, def.name, next); - setLayoutChoices(layout); - persist(fn, exec, layout, resourceArgs); + setLayoutChoices(withEntry(layoutChoices, which, def.name, next)); }; /** @@ -457,10 +410,7 @@ function PlaygroundExecution({ }): PartialExecuteRequest | null => { const functionInputs: Record = {}; for (const param of prompt.functionParameters) { - const input = toExecutionInput( - functionSelections[param.name], - resolveArgs, - ); + const input = toExecutionInput(functionSelections[param.name]); if (input) functionInputs[param.name] = input; else if ( requireAll && @@ -474,10 +424,7 @@ function PlaygroundExecution({ const executeInputs: Record = {}; for (const param of executeParameters) { - const input = toExecutionInput( - executeSelections[param.name], - resolveArgs, - ); + const input = toExecutionInput(executeSelections[param.name]); if (input) executeInputs[param.name] = input; else if (requireAll && !param.optional) { setError(missingInputMessage(param, !!prompt.inputSources)); @@ -485,7 +432,11 @@ function PlaygroundExecution({ } } - return { functionInputs, executeInputs }; + return { + functionInputs, + executeInputs, + ...(bindings.resources && { resources: bindings.resources }), + }; }; /** The request to run: every slot, positionally, defaults filled in. */ @@ -499,7 +450,11 @@ function PlaygroundExecution({ ? { kind: "value", value: param.defaultValue } : { kind: "value", value: { kind: "primitive", value: undefined } }), ); - return { functionInputs, executeInputs: collected.executeInputs }; + return { + functionInputs, + executeInputs: collected.executeInputs, + ...(collected.resources && { resources: collected.resources }), + }; }; // Applies a fill once: matched slots overwritten, every other slot left as @@ -509,39 +464,37 @@ function PlaygroundExecution({ if (!fill || appliedFills.has(fill.nonce)) return; appliedFills.add(fill.nonce); - const recoveredArgs: ResourceArgs = {}; - const restore = (inputs: Record) => - Object.fromEntries( - Object.entries(inputs).map(([name, input]) => { - const recovered = fromExecutionInput(input, resourcesByUri); - Object.assign(recoveredArgs, recovered.resourceArgs); - return [name, recovered.selection]; - }), - ); const kept = [ ...prompt.functionParameters .filter( p => !(p.name in fill.functionInputs) && - toExecutionInput(functionSelections[p.name], resolveArgs), + toExecutionInput(functionSelections[p.name]), ) .map(p => p.name), ...executeParameters .filter( p => !(p.name in fill.executeInputs) && - toExecutionInput(executeSelections[p.name], resolveArgs), + toExecutionInput(executeSelections[p.name]), ) .map(p => p.name), ]; - const fn = { ...functionSelections, ...restore(fill.functionInputs) }; - const exec = { ...executeSelections, ...restore(fill.executeInputs) }; - const args = { ...resourceArgs, ...recoveredArgs }; - setFunctionSelections(fn); - setExecuteSelections(exec); - setResourceArgs(args); - persist(fn, exec, layoutChoices, args); + setFunctionSelections({ + ...functionSelections, + ...restoreSelections(fill.functionInputs), + }); + setExecuteSelections({ + ...executeSelections, + ...restoreSelections(fill.executeInputs), + }); + // The fill's instances replace any of the same name; the rest stay, as + // unfilled slots do. + if (fill.resources) { + const filled = fromWireResources(fill.resources); + setInstances(latest => ({ ...latest, ...filled })); + } setFillNotice({ from: fill.from, kept, skipped: fill.skipped }); }, [fill?.nonce]); @@ -579,10 +532,6 @@ function PlaygroundExecution({ const groups = fanOutGroups(def); const combinable = isCombinable(groups); const layout = combinable ? layoutFor(which, def, groups) : "expanded"; - const slotArgsContext: ResourceArgsContext = { - ...argsContextBase, - path: slotPath(which, def.name), - }; return (
@@ -630,24 +579,19 @@ function PlaygroundExecution({ propDef={def} groups={groups} selection={ - collapseLossy( - groups, - toExecutionInput(selections[def.name], resolveArgs), - ).selection + collapseLossy(groups, toExecutionInput(selections[def.name])) + .selection } onChange={combined => - // As in `toggleLayout`: `args` in the round trip are already - // in `resourceArgs`, edited directly by each row's own form. change(which)( def.name, - fromExecutionInput(expand(groups, combined, resolveArgs)) - .selection, + fromExecutionInput(expand(groups, combined)), ) } resources={sources?.resources ?? []} slots={slots} overwritten={overwrittenNotes[which][def.name]} - argsContext={slotArgsContext} + context={context} /> ) : ( change(which)(def.name, selection)} resources={sources?.resources ?? []} slots={slots} - argsContext={slotArgsContext} + context={context} /> )}
@@ -670,6 +614,7 @@ function PlaygroundExecution({ prompt.providerId && ( )} + {(catalog.length > 0 || Object.keys(instances).length > 0) && ( + + referencesTo(name, instances, [ + { selections: functionSelections }, + { selections: executeSelections }, + ]) + } + context={context} + /> + )} + {broken.map(r => (
Playground module {r.uri} failed to load: {r.error} diff --git a/src/client/components/PromptList.tsx b/src/client/components/PromptList.tsx index a1fb7a1..01e4267 100644 --- a/src/client/components/PromptList.tsx +++ b/src/client/components/PromptList.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { NormalizedPrompt } from "../../shared/types"; import { requireProviderId } from "../utils"; +import { PlusIcon } from "./trace/icons.tsx"; interface PromptListProps { prompts: NormalizedPrompt[]; @@ -193,23 +194,6 @@ function SearchIcon() { ); } -function PlusIcon() { - return ( - - - - - ); -} - // --- Inline rename input --- function RenameInput({ diff --git a/src/client/components/ResourcesSection.tsx b/src/client/components/ResourcesSection.tsx new file mode 100644 index 0000000..09950bb --- /dev/null +++ b/src/client/components/ResourcesSection.tsx @@ -0,0 +1,454 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 Alexander Corrado + +import { useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import type { PromptInputSources, ResourceInfo } from "../../shared/types"; +import { ExecutionInputEditor } from "./ExecutionInputEditor"; +import { describeInstanceSource, parseInstanceUri } from "./pseudo-sources"; +import { + addInstance, + derivedDependencies, + duplicateInstance, + excludedFromArgs, + type InstanceSelections, + instanceNameProblem, + removeInstance, + renameInstance, +} from "./run-resources-state"; +import SourcePicker from "./SourcePicker"; +import type { SourceContext } from "./source-context"; +import { PlusIcon } from "./trace/icons"; +import { useAnchoredPopover } from "./use-anchored-popover"; + +/** + * A change to the run's instances that the host has to carry into its own + * slots too: references to a renamed instance follow it, and references to a + * removed one are cleared (see `retargetSelections`). + */ +export type InstanceEdit = + | { kind: "rename"; from: string; to: string } + | { kind: "remove"; name: string }; + +/** How long a card stays flagged `.pg-row-highlight` after {@link scrollToInstance} lands on it. */ +const HIGHLIGHT_MS = 1200; + +/** + * Scrolls instance `name`'s card into view and briefly flashes it — what a + * chip naming the instance does when clicked. + */ +export function scrollToInstance(name: string) { + const el = document.querySelector( + `[data-instance-card="${CSS.escape(name)}"]`, + ); + if (!el) return; + el.scrollIntoView({ behavior: "smooth", block: "center" }); + // Removing the class and forcing a reflow restarts the animation, so a + // repeat click flashes again. + el.classList.remove("pg-row-highlight"); + void el.offsetWidth; + el.classList.add("pg-row-highlight"); + window.setTimeout( + () => el.classList.remove("pg-row-highlight"), + HIGHLIGHT_MS, + ); +} + +interface Props { + /** The run's instances, by name, in display order. */ + instances: InstanceSelections; + /** + * Called with the next instances (or how to get them from the latest — + * an argument pick from the catalog adds an instance through + * `context.adopt` just before), and with the edit when slots must follow + * it. + */ + onChange: ( + next: + | InstanceSelections + | ((latest: InstanceSelections) => InstanceSelections), + edit?: InstanceEdit, + ) => void; + /** + * Every source the host's slots are offered — instances, columns and + * prompt slots included — whose `resourceSlots` say what fits each + * argument. + */ + sources: PromptInputSources; + /** The resources an instance can be created from: the prompt's own `inputSources.resources`. */ + catalog: readonly ResourceInfo[]; + /** What references instance `name` (`taskId`, `child1.parentId`) — see `referencesTo`. */ + referencedBy: (name: string) => string[]; + /** Forwarded to each argument's editor. */ + context?: SourceContext; +} + +/** + * The run's named resource instances, one card each, above the slots: its + * name, its resource, what references it, and its argument form. Then the + * dependencies the run doesn't declare, which are created with no arguments + * and shared. Controlled: the host persists `instances` however it persists + * the rest of its inputs. See `specs/resource-instances.md` §G. + */ +export function ResourcesSection({ + instances, + onChange, + sources, + catalog, + referencedBy, + context, +}: Props) { + const catalogByUri = useMemo( + () => new Map(catalog.map(r => [r.uri, r])), + [catalog], + ); + const named = Object.entries(instances); + const dependencies = derivedDependencies(instances, catalogByUri); + + // Only resources themselves, not their outputs; a server-scoped one that + // already has an instance can't have a second. + const addable = catalog.filter( + r => + !r.parent && + !r.error && + !( + (r.scope === "server" || r.value !== undefined) && + named.some(([, i]) => i.uri === r.uri) + ), + ); + + const add = (uri: string | null) => { + if (!uri) return; + onChange(addInstance(instances, uri).instances); + }; + + return ( +
+
+ Resources + {addable.length > 0 && ( + + )} +
+ {named.map(([name, instance]) => ( + + ))} + {dependencies.map(dep => { + const info = catalogByUri.get(dep.uri); + return ( +
+
+ + ◆ {info?.label ?? dep.uri} + + + {scopeLabel(info)} · dependency + + {!dep.ambiguous && ( + + )} +
+
+ {dep.ambiguous + ? `⚠ ${dep.usedBy.join(", ")} can't tell which to use: ${dep.ambiguous.join(", ")}` + : `implicit instance for ${dep.usedBy.join(", ")}`} +
+
+ ); + })} +
+ ); +} + +/** "shared" for a resource created once per server, "per run" otherwise. */ +function scopeLabel(info: ResourceInfo | undefined): string { + return info?.scope === "server" ? "shared" : "per run"; +} + +/** A gear — the instance's actions menu. */ +function GearIcon() { + return ( + + ); +} + +function InstanceCard({ + name, + instances, + resource, + onChange, + sources, + referencedBy, + context, +}: { + name: string; + instances: InstanceSelections; + resource: ResourceInfo | undefined; + onChange: Props["onChange"]; + sources: PromptInputSources; + referencedBy: string[]; + context?: SourceContext; +}) { + const instance = instances[name]; + const [draftName, setDraftName] = useState(null); + const [menuOpen, setMenuOpen] = useState(false); + const closeMenu = () => setMenuOpen(false); + const { triggerRef, popoverRef, style } = + useAnchoredPopover({ + open: menuOpen, + onClose: closeMenu, + matchTriggerWidth: false, + }); + const problem = + draftName === null + ? undefined + : instanceNameProblem(draftName, instances, name); + + const commitRename = () => { + if (draftName === null) return; + if (!problem && draftName !== name) { + onChange(renameInstance(instances, name, draftName), { + kind: "rename", + from: name, + to: draftName, + }); + } + setDraftName(null); + }; + + const remove = () => { + if ( + referencedBy.length > 0 && + !window.confirm( + `Remove '${name}'? ${referencedBy.join(", ")} will be cleared.`, + ) + ) { + return; + } + onChange(removeInstance(instances, name), { kind: "remove", name }); + }; + + // An argument may name any other instance whose creation doesn't need + // this one first. + const excluded = excludedFromArgs(instances, name); + const argSlots = Object.fromEntries( + Object.entries(sources.resourceSlots?.[instance.uri] ?? {}).map( + ([path, uris]) => [ + path, + uris.filter(uri => { + const ref = parseInstanceUri(uri); + return !ref || !excluded.has(ref.name); + }), + ], + ), + ); + const params = resource?.parameters ?? []; + + return ( +
+
+ {draftName === null ? ( + + ) : ( + setDraftName(e.target.value)} + onBlur={commitRename} + onKeyDown={e => { + if (e.key === "Enter") commitRename(); + if (e.key === "Escape") setDraftName(null); + }} + /> + )} + + {resource ? resource.label : "Resource no longer available"} + {resource && ` · ${scopeLabel(resource)}`} + + + + {menuOpen && + createPortal( +
+ {resource?.scope !== "server" && ( + + )} + +
, + document.body, + )} +
+
+ {problem &&
{problem}
} +
+ {referencedBy.length > 0 + ? `→ ${referencedBy.join(", ")}` + : "not bound to a slot: runs for its side effects"} +
+ {params.length > 0 && ( +
+ {params.map(param => ( +
+ {param.name} + + onChange(latest => { + const current = latest[name]; + if (!current) return latest; + return { + ...latest, + [name]: { + ...current, + args: { ...current.args, [param.name]: next }, + }, + }; + }) + } + resources={sources.resources} + slots={argSlots} + context={context} + /> +
+ ))} +
+ )} +
+ ); +} + +/** + * The {@link SourceContext} a host hands its slots and the section's own + * argument forms: instance chips name the instance and open its card, a pick + * from the catalog adds an instance, and every other pseudo-source is + * described by `describeOther` (columns, prompt slots). + */ +export function instanceSourceContext({ + instances, + catalogByUri, + adopt, + describeOther, + rowsMayDeclare, +}: { + instances: InstanceSelections; + catalogByUri: ReadonlyMap; + adopt: (uri: string) => string; + describeOther?: SourceContext["describePseudo"]; + /** An eval's: a name it doesn't declare is one each row declares. See `describeInstanceSource`. */ + rowsMayDeclare?: boolean; +}): SourceContext { + return { + adopt, + describePseudo: (uri, slotType) => { + const instance = describeInstanceSource(uri, instances, catalogByUri, { + rowsMayDeclare, + }); + if (!instance) return describeOther?.(uri, slotType); + const ref = parseInstanceUri(uri)!; + // Only an instance with a card here has anywhere to scroll to. + const hasCard = Object.hasOwn(instances, ref.name); + return { + ...instance, + ...(hasCard && { onOpen: () => scrollToInstance(ref.name) }), + }; + }, + }; +} diff --git a/src/client/components/SourcePicker.tsx b/src/client/components/SourcePicker.tsx index 0d192b6..2104281 100644 --- a/src/client/components/SourcePicker.tsx +++ b/src/client/components/SourcePicker.tsx @@ -37,6 +37,7 @@ export default function SourcePicker({ editable, label, onChoose, + triggerLabel, }: { /** Every source offered to the prompt, unfiltered — for the tree's group/value context. */ resources: readonly ResourceInfo[]; @@ -52,6 +53,12 @@ export default function SourcePicker({ label: string; /** Called with the chosen uri, or `null` for "Custom" / no resource. */ onChoose: (uri: string | null) => void; + /** + * Text for a labelled trigger button ("+ Add resource") in place of the + * slot's `⋯` icon — a picker that adds something rather than filling a + * slot, so it has no "Custom" or placeholder row either. + */ + triggerLabel?: string; }) { const [open, setOpen] = useState(false); const [filter, setFilter] = useState(""); @@ -94,23 +101,31 @@ export default function SourcePicker({ ) : undefined; - const headerKind = editable ? "custom" : !chosen ? "placeholder" : undefined; + const headerKind = triggerLabel + ? undefined + : editable + ? "custom" + : !chosen + ? "placeholder" + : undefined; return (
{open && diff --git a/src/client/components/SourceRow.tsx b/src/client/components/SourceRow.tsx index b48f92b..de4a0cf 100644 --- a/src/client/components/SourceRow.tsx +++ b/src/client/components/SourceRow.tsx @@ -5,15 +5,9 @@ import { useMemo } from "react"; import { ItemEditor } from "ts-proppy/react"; import { jsonToPropValue } from "../../shared/json-prop-value"; import type { PropDefinition, ResourceInfo } from "../../shared/types"; -import { ExecutionInputEditor } from "./ExecutionInputEditor"; -import { rootResourceUri, type Selections } from "./execution-input-state"; import { isPseudoUri } from "./pseudo-sources"; -import { - MAX_RESOURCE_ARG_DEPTH, - nested, - type ResourceArgsContext, -} from "./resource-args-context"; import SourcePicker from "./SourcePicker"; +import type { SourceContext } from "./source-context"; import { combinedLabel } from "./source-tree"; /** @@ -36,46 +30,15 @@ function chipLabel( /** `ItemEditor` needs an `onChange` even for the read-only preview — `disabled` already keeps it from ever firing. */ function noop() {} -/** How long a row stays flagged `.pg-row-highlight` after `scrollToRow` lands on it. */ -const HIGHLIGHT_MS = 1200; - -/** - * Scrolls the row identified by `path` (the same string `SourceRow` tags its - * own `.pg-slot` with, via `data-row-path`) into view and briefly flashes it - * — what the chip's "same instance as ▲ X" link does, so picking the same - * resource twice reads as "here's the other one", not just a name. - */ -function scrollToRow(path: string) { - const el = document.querySelector( - `[data-row-path="${CSS.escape(path)}"]`, - ); - if (!el) return; - el.scrollIntoView({ behavior: "smooth", block: "center" }); - // Restarts the animation even if this row was just highlighted and hasn't - // finished fading — removing the class and forcing a reflow before adding - // it back is what makes a repeat click flash again instead of no-op-ing. - el.classList.remove("pg-row-highlight"); - void el.offsetWidth; - el.classList.add("pg-row-highlight"); - window.setTimeout( - () => el.classList.remove("pg-row-highlight"), - HIGHLIGHT_MS, - ); -} - /** * A slot's source dropdown and the control beneath it. * - * With a resource chosen the editor is replaced — by a read-only preview of - * the resource's value where the server already has one to show (see - * `ResourceInfo.value`), or otherwise by a chip naming the resource. Most - * resources' values don't exist until the run creates them, which is when a - * chip is all there is to show. (Seeding an editor from a stored value is - * what a dataset row is for.) - * - * When the chosen resource declares `parameters` (`specs/resource-arguments.md` - * §B), its argument form renders as a block beneath the chip — see - * {@link ResourceArgumentsForm}. + * With a source chosen the editor is replaced — by a read-only preview of a + * catalog resource's value where the server already has one to show (see + * `ResourceInfo.value`), or otherwise by a chip naming it: one of the run's + * resource instances (`◆ root.taskId`), a column, or another slot. A chip is + * only a reference: an instance's arguments live on its card in the + * Resources section, which the chip opens. * * Shared by {@link ExecutionInputEditor} (a slot's own row and every nested * one) and {@link CombinedInputEditor} (one row per deduped group) so both @@ -87,7 +50,7 @@ export function SourceRow({ matching, chosen, onChoose, - argsContext, + context, children, }: { propDef: PropDefinition; @@ -96,57 +59,74 @@ export function SourceRow({ matching: ResourceInfo[]; chosen: string | undefined; onChoose: (uri: string | null) => void; - /** See {@link ResourceArgsContext}. Absent means no argument form is ever rendered. */ - argsContext?: ResourceArgsContext; + /** See {@link SourceContext}. */ + context?: SourceContext; children: React.ReactNode; }) { const editable = propDef.type.kind !== "opaque"; const selected = chosen ? matching.find(r => r.uri === chosen) : undefined; - // A stored choice whose resource is gone — renamed, deleted, or in a module + const pseudo = + chosen && isPseudoUri(chosen) + ? (context?.describePseudo?.(chosen, propDef.type) ?? { note: "" }) + : undefined; + // A stored choice whose source is gone — renamed, deleted, or in a module // that now fails to load. It stays listed, and selected, because it is still // what a run would send: dropping it silently would show an editor while - // submitting the resource behind it. - const stale = chosen && !selected ? chosen : undefined; + // submitting the source behind it. + const stale = chosen && !selected && !pseudo?.label ? chosen : undefined; - // A resource already selected by an earlier row is the *same instance* - // here too (§I), whether or not it takes arguments — the chip says so - // instead of repeating "created once per server"/"created for each run", - // which would otherwise wrongly imply a second one gets made. const resourcesByUri = useMemo( () => new Map(resources.map(r => [r.uri, r])), [resources], ); - const pseudo = - selected && isPseudoUri(selected.uri) - ? (argsContext?.describePseudo?.(selected.uri, propDef.type) ?? { - note: "", - }) - : undefined; - const sharedWith = - selected && !pseudo && argsContext - ? argsContext.claimed.get(rootResourceUri(selected.uri, resourcesByUri)) - : undefined; - const sameInstanceAs = - sharedWith && sharedWith.path !== argsContext?.path - ? sharedWith - : undefined; - if (matching.length === 0 && !stale && editable) return <>{children}; + if (matching.length === 0 && !stale && !pseudo && editable) { + return <>{children}; + } + + const pseudoLabel = pseudo + ? (pseudo.label ?? + (selected ? chipLabel(selected, resourcesByUri) : undefined)) + : undefined; - const row = ( -
+ return ( +
- {selected && pseudo ? ( + {editable && selected?.value !== undefined ? ( + // The real value beats naming it: only a live handle or a run-only + // resource (no `ResourceInfo.value` — see `ResourceRegistry.describe`) + // falls back to a chip. An instance of a static resource is that + // value, so it's previewed too. +
+ +
+ ) : pseudo && pseudoLabel !== undefined ? ( - {selected.label} + {pseudo.onOpen ? ( + + ) : ( + pseudoLabel + )} {(pseudo.warning || pseudo.note) && ( {pseudo.warning ? `⚠ ${pseudo.warning}` : pseudo.note} @@ -154,43 +134,14 @@ export function SourceRow({ )} ) : selected ? ( - // The real value beats naming it: only a live handle or a run-only - // resource (no `ResourceInfo.value` — see `ResourceRegistry.describe`) - // falls back to the chip. - editable && selected.value !== undefined ? ( -
- -
- ) : ( - - {chipLabel(selected, resourcesByUri)} - - {sameInstanceAs ? ( - <> - same instance as{" "} - - - ) : selected.scope === "server" ? ( - "created once per server" - ) : ( - "created for each run" - )} - - - ) + + {chipLabel(selected, resourcesByUri)} + + {selected.scope === "server" + ? "created once per server" + : "created for each run"} + + ) : stale ? (
Resource no longer available @@ -218,7 +169,7 @@ export function SourceRow({ {/* An opaque slot has no editor to fall back to, so its picker offers no "Custom" — picking a source is the only way to fill it. */} - {(matching.length > 0 || stale) && ( + {(matching.length > 0 || stale || pseudo) && ( ); - - const argsForm = - selected && !pseudo && argsContext ? ( - - ) : null; - - if (!argsForm) return row; - return ( -
- {row} - {argsForm} -
- ); -} - -/** - * The block beneath a chosen resource's chip: its own argument editors, only - * for the row that owns its first selection (`specs/resource-arguments.md` - * §I's "one binding per resource per prompt" rule) — every other selection - * of the same resource already says so on its chip (see `sameInstanceAs` in - * {@link SourceRow}), so it takes no space here at all. Ownership itself is - * decided elsewhere, purely, by `computeClaims` — this only reads the answer - * (by comparing full paths, not bare names — two rows can easily share a - * name, like a prompt's own `taskId` and some other resource's own `taskId` - * argument), which is what keeps it safe to call twice (React StrictMode) - * without the two calls disagreeing. - */ -function ResourceArgumentsForm({ - selected, - resources, - resourcesByUri, - argsContext, -}: { - selected: ResourceInfo; - resources: readonly ResourceInfo[]; - /** `resources`, by `uri`. */ - resourcesByUri: ReadonlyMap; - argsContext: ResourceArgsContext; -}) { - // `selected` may be one of the resource's own output values - // (`seededTask.taskId`) rather than the resource itself — only the root - // carries `parameters`, and the args form (and its state) belongs to the - // root regardless of which value was actually picked for this slot, so - // that `seededTask.taskId` here and `seededTask.title` on another slot - // share one set of arguments (§I). - const root = - resourcesByUri.get(rootResourceUri(selected.uri, resourcesByUri)) ?? - selected; - - const params = root.parameters; - if (!params || params.length === 0) return null; - if (argsContext.depth >= MAX_RESOURCE_ARG_DEPTH) return null; - - const claimedBy = argsContext.claimed.get(root.uri); - // No owner found is defensive only (computeClaims should always have - // visited whatever's actually selected) — default to rendering rather than - // hiding, so a gap in that computation never silently loses editability. - if (claimedBy && claimedBy.path !== argsContext.path) { - return null; - } - - const slots = argsContext.resourceSlots[root.uri] ?? {}; - const selections = argsContext.resourceArgs[root.uri] ?? {}; - const setSelections = (next: Selections) => - argsContext.onResourceArgsChange(root.uri, next); - - return ( -
- {params.map(param => ( -
- {param.name} - - setSelections({ ...selections, [param.name]: next }) - } - resources={resources} - slots={slots} - argsContext={nested(argsContext, `args.${param.name}`)} - /> -
- ))} -
- ); } diff --git a/src/client/components/TraceView.tsx b/src/client/components/TraceView.tsx index 28bbf98..e592d2a 100644 --- a/src/client/components/TraceView.tsx +++ b/src/client/components/TraceView.tsx @@ -7,6 +7,7 @@ import type { AgentInfo } from "../../shared/agent"; import type { NormalizedPrompt, PromptID, + RunResources, Span, Trace, TraceLiveEvent, @@ -270,6 +271,7 @@ function TraceView({ traceInputs.length > 0 && ({ inputs: traceInputs, + resources: rootPrompt.resources as RunResources | undefined, newDatasetFields: fieldsForTrace(rootPrompt, currentPrompt, traceInputs), prompt: currentPrompt?.providerId ? { diff --git a/src/client/components/__fixtures__/ResourcesSectionHarness.tsx b/src/client/components/__fixtures__/ResourcesSectionHarness.tsx new file mode 100644 index 0000000..7f32005 --- /dev/null +++ b/src/client/components/__fixtures__/ResourcesSectionHarness.tsx @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 Alexander Corrado + +import { useState } from "react"; +import type { PromptInputSources, ResourceInfo } from "../../../shared/types"; +import { ResourcesSection } from "../ResourcesSection"; +import type { InstanceSelections } from "../run-resources-state"; +import "../../styles.css"; + +const CATALOG: ResourceInfo[] = [ + { uri: "db.ts#db", label: "db", scope: "run" }, + { uri: "cache.ts#cache", label: "cache", scope: "server" }, +]; + +const SOURCES: PromptInputSources = { + resources: CATALOG, + functionSlots: {}, + executeSlots: {}, +}; + +const INITIAL: InstanceSelections = { + db: { uri: "db.ts#db", args: {} }, + cache: { uri: "cache.ts#cache", args: {} }, +}; + +/** + * Mounts {@link ResourcesSection} with a `db` run resource and a `cache` + * server resource. Only `db` is referenced, so removing it asks first. The + * current instance names are in `[data-testid="names"]`. + */ +export function ResourcesSectionHarness() { + const [instances, setInstances] = useState(INITIAL); + return ( +
+ + setInstances(latest => + typeof next === "function" ? next(latest) : next, + ) + } + sources={SOURCES} + catalog={CATALOG} + referencedBy={name => (name === "db" ? ["taskId"] : [])} + /> + {Object.keys(instances).join(",")} +
+ ); +} diff --git a/src/client/components/__fixtures__/datasetView.pw.tsx b/src/client/components/__fixtures__/datasetView.pw.tsx index 45767af..8f87419 100644 --- a/src/client/components/__fixtures__/datasetView.pw.tsx +++ b/src/client/components/__fixtures__/datasetView.pw.tsx @@ -88,7 +88,7 @@ async function mockDataset( page: Page, dataset: Dataset, rows: DatasetRow[], - fields: Record = {}, + fields: Record = {}, ) { const pages: [number, number][] = []; const base = `**/api/datasets/local/${dataset.id}`; @@ -130,8 +130,10 @@ const SUPPORT_ROWS: DatasetRow[] = [ id: "r1", cells: { "0": text("My order never arrived"), - "1": { - kind: "resource", + "1": { kind: "instance", name: "seededTask" }, + }, + resources: { + seededTask: { uri: ".evalution/playground/tasks.ts#seededTask", args: { title: text("Buy milk") }, }, @@ -143,15 +145,16 @@ const SUPPORT_ROWS: DatasetRow[] = [ id: "r2", cells: { "0": text("Refund please"), - // Takes no `title`: that column is n/a for this row. - "1": { kind: "resource", uri: ".evalution/playground/tasks.ts#blank" }, + "1": { kind: "instance", name: "blank" }, }, + resources: { blank: { uri: ".evalution/playground/tasks.ts#blank" } }, createdAt: 2, }, { id: "r3", cells: {}, createdAt: 3 }, ]; -const SUPPORT_SHAPE = { "1": { keys: ["title"], resource: true } }; +/** No field has keys: a cell naming an instance doesn't expand. */ +const SUPPORT_SHAPE = {}; /** * Serves `POST …/:id/rows` — what the trailing row sends — appending to @@ -203,9 +206,7 @@ test("DatasetView draws value and resource cells, sparse cells as dashes", async await expect( inGrid(component, "gridcell", "My order never arrived"), ).toBeAttached(); - await expect( - inGrid(component, "gridcell", '◆ seededTask(title: "Buy milk")'), - ).toBeAttached(); + await expect(inGrid(component, "gridcell", "◆ seededTask")).toBeAttached(); await expect(inGrid(component, "gridcell", "trace ↗")).toBeAttached(); // Row 3 has nothing in `task`, nor a source; row 2 has no source. (Its // empty `ticket` is an editor's, which reads as empty.) @@ -223,13 +224,12 @@ test("the table ends after its last row, with nothing ruled below it", async ({ await expect(component.getByTestId("data-grid-canvas")).toBeVisible(); // Glide rules its whole canvas, so what's below the last row is covered: - // group header + header + 3 rows and the trailing "New row", one past the - // closing rule. + // header + 3 rows and the trailing "New row", one past the closing rule. const cover = component.locator(".dataset-grid-fill"); const box = await cover.boundingBox(); const grid = await component.locator(".dataset-grid").boundingBox(); if (!box || !grid) throw new Error("grid not laid out"); - expect(box.y - grid.y).toBe(GRID.group + GRID.header + 4 * GRID.row + 1); + expect(box.y - grid.y).toBe(GRID.header + 4 * GRID.row + 1); // It reaches the bottom, so no ruled lines survive below it. expect(Math.round(box.y + box.height)).toBe(Math.round(grid.y + grid.height)); @@ -261,35 +261,11 @@ test("the table ends after its last row, with nothing ruled below it", async ({ expect(stacking.scroller.zIndex).toBeGreaterThan(stacking.cover.zIndex); }); -test("clicking a field's group header splits it into a column per argument", async ({ - mount, - page, -}) => { - await mockDataset(page, SUPPORT, SUPPORT_ROWS, SUPPORT_SHAPE); - const component = await mount( - , - ); - await expect(inGrid(component, "columnheader", "task")).toBeAttached(); - await expect(inGrid(component, "columnheader", "title")).toHaveCount(0); - - // `task` is the second column: 240px past `ticket`. - await clickGrid(page, GRID.marker + 240 + 60, GRID.group / 2); - - await expect(inGrid(component, "columnheader", "title")).toBeAttached(); - // The head names the resource; the argument has its own column. - await expect(inGrid(component, "gridcell", "◆ seededTask")).toBeAttached(); - await expect(inGrid(component, "gridcell", '"Buy milk"')).toBeAttached(); - await expect(inGrid(component, "gridcell", "n/a")).toHaveCount(1); - - // And back. - await clickGrid(page, GRID.marker + 240 + 60, GRID.group / 2); - await expect(inGrid(component, "columnheader", "title")).toHaveCount(0); -}); - -test("a field of typed-in objects expands into its properties, with no column of its own", async ({ - mount, - page, -}) => { +/** + * Serves `tickets` with a `taskInfo` field of typed-in objects, whose cells + * expand into their `title` and `description`. + */ +function mockTaskInfo(page: Page) { const info: ExecutionInput = { kind: "value", value: { @@ -300,7 +276,7 @@ test("a field of typed-in objects expands into its properties, with no column of }, }, }; - await mockDataset( + return mockDataset( page, { ...SUPPORT, @@ -319,6 +295,13 @@ test("a field of typed-in objects expands into its properties, with no column of [{ id: "r1", cells: { "0": text("Hi"), "1": info }, createdAt: 1 }], { "1": { keys: ["title", "description"] } }, ); +} + +test("a field of typed-in objects expands into its properties, with no column of its own", async ({ + mount, + page, +}) => { + await mockTaskInfo(page); const component = await mount( , ); @@ -346,10 +329,10 @@ test("expanding a field and resizing a column are remembered per dataset", async page, }) => { await page.evaluate(() => localStorage.clear()); - await mockDataset(page, SUPPORT, SUPPORT_ROWS, SUPPORT_SHAPE); + await mockTaskInfo(page); const view = ; const component = await mount(view); - // Columns start at their defaults: marker + ticket + task + source. + // Columns start at their defaults: marker + ticket + taskInfo + source. await expect(gridWidth(component)).toHaveAttribute("style", /width: 608px/); await clickGrid(page, GRID.marker + 240 + 60, GRID.group / 2); @@ -367,9 +350,9 @@ test("expanding a field and resizing a column are remembered per dataset", async await component.unmount(); const reopened = await mount(view); - // Expanded, and `ticket` still 360 wide: 32 + 360 + 140 + 160 + 96. + // Expanded, and `ticket` still 360 wide: 32 + 360 + 160 + 160 + 96. await expect(inGrid(reopened, "columnheader", "title")).toBeAttached(); - await expect(gridWidth(reopened)).toHaveAttribute("style", /width: 788px/); + await expect(gridWidth(reopened)).toHaveAttribute("style", /width: 808px/); // Deleting the dataset takes its layout with it. await page.route("**/api/datasets/local/tickets", route => @@ -404,14 +387,19 @@ test("selecting a row shows it in full in the details pane", async ({ ); await expect(component.getByTestId("data-grid-canvas")).toBeVisible(); - await clickCell(page, 40, 0, true); + await clickCell(page, 40, 0, false); const pane = component.getByRole("region", { name: "Row details" }); await expect(pane).toContainText("Row 1"); await expect(pane).toContainText("r1"); await expect(pane.getByText("My order never arrived")).toBeVisible(); - await expect(pane.getByText("seededTask")).toBeVisible(); - await expect(pane.getByText("title")).toBeVisible(); + // The row's resources come first, each with its arguments; the field + // names the instance it's bound to. + await expect(pane.getByText("◆ seededTask", { exact: true })).toBeVisible(); + await expect(pane.getByText("title", { exact: true })).toBeVisible(); await expect(pane.getByText("Buy milk")).toBeVisible(); + await expect( + pane.locator(".dataset-detail-cell").getByText("seededTask"), + ).toBeVisible(); // Beside the grid when there's room. await expect(pane).toHaveClass(/trace-details-pane/); @@ -440,7 +428,7 @@ test("double-clicking a cell to edit it in place doesn't open the details pane", // pause between its clicks is long enough for a pane to open in. const at = { x: box.x + GRID.marker + 40, - y: box.y + GRID.group + GRID.header + GRID.row / 2, + y: box.y + GRID.header + GRID.row / 2, }; await page.mouse.click(at.x, at.y); await page.waitForTimeout(100); @@ -452,10 +440,10 @@ test("double-clicking a cell to edit it in place doesn't open the details pane", // A click that doesn't go on to edit still opens it, once it has paused... await page.keyboard.press("Escape"); - await clickCell(page, 240 + 40, 0, true); + await clickCell(page, 240 + 40, 0, false); await expect(pane).toContainText("Row 1"); // ...and, open, it follows the selection at once. - await clickCell(page, 40, 1, true); + await clickCell(page, 40, 1, false); await expect(pane).toContainText("Row 2"); }); @@ -487,7 +475,7 @@ test("the details pane opens a row in the playground and deletes it", async ({ ); await expect(component.getByTestId("data-grid-canvas")).toBeVisible(); - await clickCell(page, 40, 1, true); + await clickCell(page, 40, 1, false); const pane = component.getByRole("region", { name: "Row details" }); await expect(pane).toContainText("Row 2"); await pane.getByRole("button", { name: "Open row in playground" }).click(); @@ -599,7 +587,7 @@ test("typing into a string cell saves it, showing it at once", async ({ await untilRowsShown(component); // Row 3's `ticket` is empty. - await clickCell(page, 40, 2, true); + await clickCell(page, 40, 2, false); await typeIntoCell(page, "Late delivery"); await expect @@ -610,11 +598,11 @@ test("typing into a string cell saves it, showing it at once", async ({ await expect(inGrid(component, "gridcell", "Late delivery")).toBeAttached(); // A resource cell is read-only in the grid: no editor opens there. - await clickCell(page, 240 + 40, 0, true); + await clickCell(page, 240 + 40, 0, false); await pressInGrid(page, "Enter"); await expect(page.locator(".dataset-grid-portal textarea")).toHaveCount(0); // Delete clears an editable cell to `null`, and skips a read-only one. - await clickCell(page, 40, 1, true); + await clickCell(page, 40, 1, false); await pressInGrid(page, "Delete"); await expect.poll(() => sent).toHaveLength(2); expect(sent[1]).toEqual({ @@ -638,10 +626,10 @@ test("a failed save reloads the row from the server and says why under the heade ); await untilRowsShown(component); - await clickCell(page, 40, 1, true); + await clickCell(page, 40, 1, false); await typeIntoCell(page, "Changed"); await expect(inGrid(component, "gridcell", "Changed")).toBeAttached(); - await clickCell(page, 40, 1, true); + await clickCell(page, 40, 1, false); await typeIntoCell(page, "Changed again"); await expect(inGrid(component, "gridcell", "Changed again")).toBeAttached(); @@ -732,7 +720,7 @@ test("the details pane edits a value cell, committing on Enter, and clears any c ); await untilRowsShown(component); - await clickCell(page, 40, 1, true); + await clickCell(page, 40, 1, false); const pane = component.getByRole("region", { name: "Row details" }); const editor = pane.locator(".dataset-detail-editor textarea"); await expect(editor).toHaveValue("Refund please"); @@ -749,7 +737,7 @@ test("the details pane edits a value cell, committing on Enter, and clears any c await expect(inGrid(component, "gridcell", "Refund now")).toBeAttached(); // The resource is read-only here too, but can be cleared. - await expect(pane.getByText("blank")).toBeVisible(); + await expect(pane.getByText("◆ blank", { exact: true })).toBeVisible(); await pane.getByRole("button", { name: "Clear task" }).click(); await expect.poll(() => sent).toHaveLength(2); expect(sent[1]).toEqual({ @@ -773,9 +761,9 @@ test("a details-pane edit is saved when the grid is clicked, on the same row or const editor = pane.locator(".dataset-detail-editor textarea"); // Another cell of the same row: the pane stays, and the edit is saved. - await clickCell(page, 40, 1, true); + await clickCell(page, 40, 1, false); await editor.fill("Refund now"); - await clickCell(page, 300, 1, true); + await clickCell(page, 300, 1, false); await expect .poll(() => sent) .toEqual([ @@ -784,9 +772,9 @@ test("a details-pane edit is saved when the grid is clicked, on the same row or // Another row: the pane moves on, and the edit is saved to the row it // was made on. - await clickCell(page, 40, 1, true); + await clickCell(page, 40, 1, false); await editor.fill("Refund today"); - await clickCell(page, 40, 0, true); + await clickCell(page, 40, 0, false); await expect(editor).toHaveValue("My order never arrived"); await expect.poll(() => sent).toHaveLength(2); expect(sent[1]).toEqual({ @@ -838,47 +826,51 @@ test("the details pane offers the linked prompt's resources, with their argument ); await untilRowsShown(component); - await clickCell(page, 40, 1, true); + await clickCell(page, 40, 1, false); const pane = component.getByRole("region", { name: "Row details" }); - await expect(pane.getByText("Blank task")).toBeVisible(); + const resources = pane.getByRole("region", { name: "Resources" }); + await expect(resources.locator("[data-instance-card='blank']")).toBeVisible(); - // Choosing a resource is saved at once. + // Choosing a resource from New adds an instance of it to the row and + // binds the field to it, saved at once. await pane.getByRole("button", { name: "Source for task" }).click(); + await page.getByRole("menuitem", { name: "New" }).click(); await page .getByRole("menuitem", { name: "Seeded task", exact: true }) .click(); + await expect( + resources.locator("[data-instance-card='seededTask']"), + ).toBeVisible(); await expect - .poll(() => sent) - .toEqual([ - { - updates: [ - { - rowId: "r2", - cells: { "1": { kind: "resource", uri: seeded.uri } }, - }, - ], - }, - ]); + .poll(() => sent.at(-1)) + .toEqual({ + updates: [ + { + rowId: "r2", + cells: { "1": { kind: "instance", name: "seededTask" } }, + // Only what changed: `blank` stays as it is. + resources: { seededTask: { uri: seeded.uri } }, + }, + ], + }); + // One save, not one for the instance and another for the binding. + expect(sent).toHaveLength(1); - // Its argument is typed in like any value: a draft until Enter. - const title = pane - .locator(".dataset-detail-editor") - .nth(1) + // Its argument is typed into its card like any value: a draft until Enter. + const title = resources + .locator("[data-instance-card='seededTask']") .locator("textarea"); await title.fill("Buy eggs"); expect(sent).toHaveLength(1); await title.press("Enter"); await expect.poll(() => sent).toHaveLength(2); - expect(sent[1]).toEqual({ + expect(sent.at(-1)).toEqual({ updates: [ { rowId: "r2", - cells: { - "1": { - kind: "resource", - uri: seeded.uri, - args: { title: text("Buy eggs") }, - }, + cells: {}, + resources: { + seededTask: { uri: seeded.uri, args: { title: text("Buy eggs") } }, }, }, ], @@ -1017,7 +1009,7 @@ test("clicking the trailing row appends an empty row and selects it", async ({ ); await expect(component.getByTestId("data-grid-canvas")).toBeVisible(); - await clickCell(page, 40, 3, true); + await clickCell(page, 40, 3, false); await expect(component.getByText("4 rows")).toBeVisible(); await expect.poll(() => bodies).toEqual([{ rows: [{ cells: {} }] }]); @@ -1104,14 +1096,14 @@ test("two rows appended at once both stay while the first one's refetch lands", ); await expect(component.getByTestId("data-grid-canvas")).toBeVisible(); - await clickCell(page, 40, 3, true); + await clickCell(page, 40, 3, false); await expect(component.getByText("4 rows")).toBeVisible(); const refetched = page.waitForResponse( r => r.request().method() === "GET" && r.url().endsWith("/api/datasets/local/tickets"), ); - await clickCell(page, 40, 4, true); + await clickCell(page, 40, 4, false); await expect(component.getByText("5 rows")).toBeVisible(); await expect.poll(() => bodies).toHaveLength(2); @@ -1134,7 +1126,7 @@ test("a row that fails to append is taken back, with the error shown", async ({ ); await expect(component.getByTestId("data-grid-canvas")).toBeVisible(); - await clickCell(page, 40, 3, true); + await clickCell(page, 40, 3, false); await expect(component.getByText("4 rows")).toBeVisible(); await expect(component.getByText("disk full")).toBeVisible(); await expect(component.getByText("3 rows")).toBeVisible(); diff --git a/src/client/components/__fixtures__/evalView.pw.tsx b/src/client/components/__fixtures__/evalView.pw.tsx index 75224ce..3395afa 100644 --- a/src/client/components/__fixtures__/evalView.pw.tsx +++ b/src/client/components/__fixtures__/evalView.pw.tsx @@ -142,6 +142,60 @@ test("binds a same-named column to the prompt's input, marked as matched, and sa await expect(component.getByRole("button", { name: /Run/ })).toBeEnabled(); }); +test("the inputs panel has the run's Resources above the slots, saved with the eval", async ({ + mount, + page, +}) => { + const seeded = { + uri: ".evalution/playground/tasks.ts#seededTask", + label: "Seeded task", + scope: "run" as const, + parameters: [string("title")], + }; + const withResources = { + ...TRIAGE, + inputSources: { + resources: [seeded], + functionSlots: {}, + executeSlots: {}, + }, + } as NormalizedPrompt; + const patches = await mockEval(page, EVAL); + const component = await mount( + , + ); + + const resources = component.getByRole("region", { name: "Resources" }); + await resources.getByRole("button", { name: "+ Add resource" }).click(); + await page + .getByRole("menuitem", { name: "Seeded task", exact: true }) + .click(); + const card = resources.locator("[data-instance-card='seededTask']"); + await expect(card).toContainText("not bound to a slot"); + + // Its argument can take a column, as any slot in an eval can. + await card.locator(".pg-slot-source-wrap").click(); + await page + .getByRole("menuitem", { name: /ticket/ }) + .first() + .click(); + await expect + .poll(() => patches.at(-1)?.inputs) + .toMatchObject({ + resources: { + seededTask: { + uri: seeded.uri, + args: { title: { kind: "dataset", field: "0" } }, + }, + }, + }); +}); + test("lists an unbound check parameter as a problem, and won't run until it's bound", async ({ mount, page, @@ -309,7 +363,8 @@ test("what to run against is always showing, in the scrolling body above Run", a await expect(options.getByLabel("Working tree")).toBeChecked(); await expect(options.getByLabel("Parallel workers")).toHaveValue("4"); // Set off from the inputs above by a rule, as the execute inputs are. - const rule = component.locator(".pg-exec-body > .pg-exec-section"); + // (the last divider; others separate the inputs and the resources) + const rule = component.locator(".pg-exec-body > .pg-exec-section").last(); expect( await rule.evaluate((el: Element) => getComputedStyle(el).borderTopWidth), ).toBe("1px"); diff --git a/src/client/components/__fixtures__/executionFixtures.ts b/src/client/components/__fixtures__/executionFixtures.ts index 900fb50..163ec50 100644 --- a/src/client/components/__fixtures__/executionFixtures.ts +++ b/src/client/components/__fixtures__/executionFixtures.ts @@ -174,26 +174,6 @@ export const TITLE_GENERATOR: ResourceInfo = { scope: "run", }; -/** - * A second parameterized resource whose own argument happens to be named - * `taskId` — the exact same name as a top-level prompt parameter a test - * might also be filling from {@link PARAMETERIZED_SEEDED_TASK}. Regression - * fixture for the name-collision bug a bare-label "who owns this form" check - * fell into: two rows named `taskId` at different depths in the tree are not - * the same row. - */ -export const SEEDED_RUN_TASK_ID_PARAM: PropDefinition = { - name: "taskId", - type: { kind: "primitive", syntax: "TaskId", base: "string" }, - optional: false, -}; -export const SEEDED_RUN: ResourceInfo = { - uri: "seeded-task.playground.ts#seededRun", - label: "Seeded run", - scope: "run", - parameters: [SEEDED_RUN_TASK_ID_PARAM], -}; - /** * A static `value` resource the server already knows, so the panel can * preview it — unlike {@link SEEDED_TASK}, which only exists once a run @@ -281,3 +261,22 @@ export function sourcesFor( ...(resourceSlots ? { resourceSlots } : {}), }; } + +/** + * A task that can be seeded under another — `specs/resource-instances.md`'s + * hierarchy case: its `parentId` argument takes the id of another instance + * of the very same resource. + */ +export const TREE_TASK: ResourceInfo = { + uri: "tree.playground.ts#task", + label: "Seeded task", + scope: "run", + parameters: [ + SEEDED_TASK_PARAM, + { + name: "parentId", + type: { kind: "primitive", syntax: "TaskId", base: "string" }, + optional: true, + }, + ], +}; diff --git a/src/client/components/__fixtures__/playgroundExecution.pw.tsx b/src/client/components/__fixtures__/playgroundExecution.pw.tsx index 5ecdd1f..82f7096 100644 --- a/src/client/components/__fixtures__/playgroundExecution.pw.tsx +++ b/src/client/components/__fixtures__/playgroundExecution.pw.tsx @@ -9,7 +9,6 @@ import { OPAQUE_DB, odinDbSlots, PARAMETERIZED_SEEDED_TASK, - SEEDED_RUN, SEEDED_TASK, sourcesFor, TASK_A, @@ -21,6 +20,7 @@ import { TASKS_LIBRARY, TITLE_GENERATOR, TOOLS_CONTEXT_WITH_NESTED_DB, + TREE_TASK, WORKSPACE_RESOURCE, } from "./executionFixtures"; import { PlaygroundExecutionHarness } from "./PlaygroundExecutionHarness"; @@ -38,6 +38,31 @@ async function chooseSource(page: Page, trigger: Locator, label: string) { await page.getByRole("menuitem", { name: label, exact: true }).click(); } +/** + * Like {@link chooseSource}, for a catalog entry filed under **New**: where + * the menu offers more than the catalog (the run's instances, another + * slot), the catalog sits one submenu down. + */ +async function chooseNew(page: Page, trigger: Locator, label: string) { + await trigger.click(); + await page.getByRole("menuitem", { name: "New" }).click(); + await page.getByRole("menuitem", { name: label, exact: true }).click(); +} + +/** + * Opens instance `instance`'s actions menu (the "⋯" on its card) and picks + * `item` from it. The menu is portal-rendered, so it's found via `page`. + */ +async function chooseInstanceAction( + page: Page, + scope: Locator, + instance: string, + item: string, +) { + await scope.getByRole("button", { name: `Actions for ${instance}` }).click(); + await page.getByRole("menu").getByRole("menuitem", { name: item }).click(); +} + async function mockExecute(page: Page) { await page.route("**/api/**", route => route.fulfill({ @@ -328,11 +353,15 @@ test("picking a resource replaces the editor with a labelled chip, and it surviv ); // A resource's value doesn't exist until the run creates it, so there is - // nothing to seed an editor with — the chip says what will happen instead. + // nothing to seed an editor with. The pick added an instance of it to the + // run, and the chip names that instance and what it is. const chip = component.locator(".pg-slot-chip"); + await expect(chip).toContainText("◆ seededRootTask"); await expect(chip).toContainText(SEEDED_TASK.label); - await expect(chip).toContainText("created for each run"); await expect(component.locator("textarea, input")).toHaveCount(0); + await expect( + component.locator("[data-instance-card='seededRootTask']"), + ).toContainText("per run"); await component.unmount(); const remounted = await mount(); @@ -341,120 +370,6 @@ test("picking a resource replaces the editor with a labelled chip, and it surviv ); }); -test("choosing the same resource for a second slot says 'same instance as' on its chip, even with no arguments to share", async ({ - mount, - page, -}) => { - // Regression: the "one binding per resource" note used to only exist for - // a resource with declared arguments — a plain resource (no `parameters`) - // picked twice showed two identical, unrelated-looking chips with no hint - // that a run creates it once and reuses it, not twice. - const component = await mount( - , - ); - - const rows = component.locator(".pg-exec-param"); - await chooseSource( - page, - rows.nth(0).locator(".pg-slot-source-wrap"), - SEEDED_TASK.label, - ); - await chooseSource( - page, - rows.nth(1).locator(".pg-slot-source-wrap"), - SEEDED_TASK.label, - ); - - // The first (owning) selection keeps its normal scope note. - await expect(rows.nth(0).locator(".pg-slot-chip-note")).toContainText( - "created for each run", - ); - // The second names the row it's the same instance as, instead — as a link - // (a chevron pointing up at it, not the word "above") rather than plain text. - const note = rows.nth(1).locator(".pg-slot-chip-note"); - await expect(note).toContainText("same instance as"); - await expect(note).not.toContainText("above"); - const link = note.getByRole("button", { name: /taskId/ }); - await expect(link).toContainText("▲"); - await expect(link).toContainText("taskId"); - // Neither row wastes space on an args form — this resource has none. - await expect(component.locator(".pg-args-form")).toHaveCount(0); -}); - -test("clicking a 'same instance as' link scrolls to and briefly highlights the row it points at", async ({ - mount, - page, -}) => { - const component = await mount( - , - ); - - const rows = component.locator(".pg-exec-param"); - await chooseSource( - page, - rows.nth(0).locator(".pg-slot-source-wrap"), - SEEDED_TASK.label, - ); - await chooseSource( - page, - rows.nth(1).locator(".pg-slot-source-wrap"), - SEEDED_TASK.label, - ); - - const ownerSlot = rows.nth(0).locator(".pg-slot"); - await expect(ownerSlot).not.toHaveClass(/pg-row-highlight/); - - await rows - .nth(1) - .locator(".pg-slot-chip-note") - .getByRole("button", { name: /taskId/ }) - .click(); - - // The owning row (not the one clicked) is the one that flashes. - await expect(ownerSlot).toHaveClass(/pg-row-highlight/); - await expect(rows.nth(1).locator(".pg-slot")).not.toHaveClass( - /pg-row-highlight/, - ); - // And the flash is temporary, not a lasting state change. - await expect(ownerSlot).not.toHaveClass(/pg-row-highlight/, { - timeout: 2000, - }); -}); - test("a resource the server already has a value for previews it, read-only, through the slot's own editor", async ({ mount, page, @@ -541,8 +456,9 @@ test("an opaque slot with a matching resource makes the picker its only control" await expect(page.getByRole("menuitem")).toHaveText([DB_RESOURCE.label]); await page.getByRole("menuitem", { name: DB_RESOURCE.label }).click(); - await expect(component.locator(".pg-slot-chip")).toContainText( - "created once per server", + await expect(component.locator(".pg-slot-chip")).toContainText("◆ db"); + await expect(component.locator("[data-instance-card='db']")).toContainText( + "shared", ); }); @@ -954,20 +870,16 @@ test.describe("SourcePicker (specs/resource-hierarchy.md §E)", () => { .getByRole("menuitem", { name: TASK_A_ID.label, exact: true }) .click(); - // Task A has three matching outputs, so the dropdown row that chose this - // one was itself labelled `"${TASK_A.label} → ${TASK_A_ID.label}"` — the - // chip repeats that same composite, arrow and all, now that the tree - // (and its breadcrumb) is no longer on screen to say which task this is. + // The pick added an instance of Task A, and the chip names its output + // as `instance.output`, with the resource it's an instance of. const chip = component.locator(".pg-slot-chip"); - await expect(chip).toHaveText( - `${TASK_A.label} → ${TASK_A_ID.label}created for each run`, - ); + await expect(chip).toHaveText(`◆ taskA.id${TASK_A.label}`); await expect(component.locator("textarea, input")).toHaveCount(0); await component.unmount(); const remounted = await mount(taskRefHarness("task-picker-prompt")); - await expect(remounted.locator(".pg-slot-chip")).toContainText( - `${TASK_A.label} → ${TASK_A_ID.label}`, + await expect(remounted.locator(".pg-slot-chip")).toHaveText( + `◆ taskA.id${TASK_A.label}`, ); }); @@ -982,7 +894,7 @@ test.describe("SourcePicker (specs/resource-hierarchy.md §E)", () => { // The row itself is labelled "Resource → Value" (its one declared value // collapsed the submenu away, same as Task A's narrowed-down one did) — // and it's still that value being chosen underneath, so the chip - // afterward names the same pair. + // afterward names that output of the new instance. const taskBRow = page.getByRole("menuitem", { name: `${TASK_B.label} → ${TASK_B_ID.label}`, exact: true, @@ -991,7 +903,7 @@ test.describe("SourcePicker (specs/resource-hierarchy.md §E)", () => { await taskBRow.click(); await expect(component.locator(".pg-slot-chip")).toHaveText( - `${TASK_B.label} → ${TASK_B_ID.label}created for each run`, + `◆ taskB.id${TASK_B.label}`, ); }); @@ -1169,7 +1081,7 @@ test.describe("resource arguments (specs/resource-arguments.md §I, §J)", () => await expect(remountedForm.locator("select")).toHaveValue("open"); }); - test("choosing the same resource for a second slot marks its chip 'same instance as' instead of repeating the form", async ({ + test("the nested title argument offers Title generator as a source, and picking it shows a chip", async ({ mount, page, }) => { @@ -1181,157 +1093,300 @@ test.describe("resource arguments (specs/resource-arguments.md §I, §J)", () => type: { kind: "primitive", syntax: "TaskId", base: "string" }, optional: false, }, - { - name: "taskTitle", - type: { kind: "primitive", syntax: "string", base: "string" }, - optional: false, - }, ]} inputSources={sourcesFor( - { - taskId: [PARAMETERIZED_SEEDED_TASK.uri], - taskTitle: [PARAMETERIZED_SEEDED_TASK.uri], - }, + { taskId: [PARAMETERIZED_SEEDED_TASK.uri] }, [PARAMETERIZED_SEEDED_TASK, TITLE_GENERATOR], + "functionSlots", + { [PARAMETERIZED_SEEDED_TASK.uri]: { title: [TITLE_GENERATOR.uri] } }, )} />, ); - - const rows = component.locator(".pg-exec-param"); await chooseSource( page, - rows.nth(0).locator(".pg-slot-source-wrap"), + component.locator(".pg-slot-source-wrap"), PARAMETERIZED_SEEDED_TASK.label, ); - const firstForm = rows.nth(0).locator(".pg-args-form").first(); - await firstForm.locator("textarea, input").first().fill("Shared title"); - await chooseSource( - page, - rows.nth(1).locator(".pg-slot-source-wrap"), - PARAMETERIZED_SEEDED_TASK.label, + const form = component.locator(".pg-args-form").first(); + const titleTrigger = form.locator(".pg-slot-source-wrap").first(); + await expect(titleTrigger).toBeVisible(); + // Beside the prompt's own `taskId` slot, which fits a string too. + await chooseNew(page, titleTrigger, TITLE_GENERATOR.label); + await expect(form.locator(".pg-slot-chip").first()).toContainText( + TITLE_GENERATOR.label, ); + }); +}); + +test.describe("resource instances (specs/resource-instances.md §G)", () => { + const TASK_ID = { + name: "taskId", + type: { + kind: "primitive" as const, + syntax: "TaskId", + base: "string" as const, + }, + optional: false, + }; + const PARENT_TASK_ID = { ...TASK_ID, name: "parentTaskId" }; + + /** The section's card for instance `name`. */ + const card = (scope: Locator, name: string) => + scope.locator(`[data-instance-card="${name}"]`); - // The second selection doesn't get its own editable form — its chip - // says it's the same instance as the row that owns it, and that's it: - // no form, no extra vertical space for one. - const note = rows.nth(1).locator(".pg-slot-chip-note"); - await expect(note).toContainText("same instance as"); - await expect(note.getByRole("button", { name: /taskId/ })).toBeVisible(); - await expect(rows.nth(1).locator(".pg-args-form")).toHaveCount(0); - // The first row's own value is untouched by the second selection. - await expect(firstForm.locator("textarea, input").first()).toHaveValue( - "Shared title", + /** Adds an instance of the resource labelled `label` from the section's own picker. */ + async function addResource(page: Page, scope: Locator, label: string) { + await scope.getByRole("button", { name: "+ Add resource" }).click(); + await page.getByRole("menuitem", { name: label, exact: true }).click(); + } + + test("a resource added with no slot shows as unbound, and is sent with the run", async ({ + mount, + page, + }) => { + const bodies: unknown[] = []; + await page.route("**/api/**", route => { + if (route.request().method() === "POST") { + bodies.push(route.request().postDataJSON()); + } + return route.fulfill({ + json: { traceId: "t1", tracerProviderId: "p1", rootSpanId: "s1" }, + }); + }); + const component = await mount( + , + ); + const resources = component.getByRole("region", { name: "Resources" }); + await addResource(page, resources, SEEDED_TASK.label); + + await expect(card(resources, "seededRootTask")).toContainText( + "not bound to a slot: runs for its side effects", ); + + await component.getByRole("button", { name: "▶ Run" }).click(); + await expect + .poll(() => bodies.at(-1)) + .toMatchObject({ + resources: { seededRootTask: { uri: SEEDED_TASK.uri } }, + }); }); - test("the nested title argument offers Title generator as a source, and picking it shows a chip", async ({ + test("picking from New adds an instance and binds it; a second slot can reuse it", async ({ mount, page, }) => { const component = await mount( , ); + const rows = component.locator(".pg-exec-param"); + const resources = component.getByRole("region", { name: "Resources" }); + + // With no instances yet, the catalog is all there is: no New to open. await chooseSource( page, - component.locator(".pg-slot-source-wrap"), - PARAMETERIZED_SEEDED_TASK.label, + rows.nth(0).locator(".pg-slot-source-wrap"), + SEEDED_TASK.label, + ); + await expect(card(resources, "seededRootTask")).toContainText("→ taskId"); + + // Now the run's instance comes first, and the catalog sits under New. + await rows.nth(1).locator(".pg-slot-source-wrap").click(); + await expect(page.getByRole("menuitem")).toHaveText([ + "✓Custom", + "seededRootTask", + "New▸", + "= taskId", + ]); + await page.getByRole("menuitem", { name: "seededRootTask" }).click(); + + await expect(rows.nth(1).locator(".pg-slot-chip")).toContainText( + "◆ seededRootTask", + ); + // Still one instance, which both slots name. + await expect(resources.locator("[data-instance-card]")).toHaveCount(1); + await expect(card(resources, "seededRootTask")).toContainText( + "→ taskId, parentTaskId", ); - const form = component.locator(".pg-args-form").first(); - const titleTrigger = form.locator(".pg-slot-source-wrap").first(); - await expect(titleTrigger).toBeVisible(); - await chooseSource(page, titleTrigger, TITLE_GENERATOR.label); - await expect(form.locator(".pg-slot-chip").first()).toContainText( - TITLE_GENERATOR.label, + // Picking from New again is a second, separate instance. + await rows.nth(1).locator(".pg-slot-source-wrap").click(); + await page.getByRole("menuitem", { name: "New" }).click(); + await page + .getByRole("menuitem", { name: SEEDED_TASK.label, exact: true }) + .click(); + await expect(rows.nth(1).locator(".pg-slot-chip")).toContainText( + "◆ seededRootTask2", ); + await expect(card(resources, "seededRootTask")).toContainText("→ taskId"); }); - test("a prompt's own taskId slot and a different resource's own taskId argument don't collide just because they share a name", async ({ + test("clicking an instance's chip scrolls to and briefly highlights its card", async ({ mount, page, }) => { - // Regression: `SEEDED_RUN`'s own argument happens to be named `taskId`, - // the same as the prompt's own top-level parameter — the bare-name - // ownership check this replaced treated those as the same row, showing - // the form under *both* instead of one owning it and the other pointing - // back. See `ResourceArgsContext.path`. const component = await mount( , ); - - const rows = component.locator(".pg-exec-param"); - // Select seededTask for the prompt's own taskId slot first. await chooseSource( page, - rows.nth(0).locator(".pg-slot-source-wrap"), - PARAMETERIZED_SEEDED_TASK.label, + component.locator(".pg-slot-source-wrap"), + SEEDED_TASK.label, ); - // Then select seededRun for `run`, and seededTask again for *its* own - // `taskId` argument. + const target = card(component, "seededRootTask"); + await expect(target).not.toHaveClass(/pg-row-highlight/); + + await component + .locator(".pg-slot-chip") + .getByRole("button", { name: /seededRootTask/ }) + .click(); + await expect(target).toHaveClass(/pg-row-highlight/); + // The flash is temporary, not a lasting state change. + await expect(target).not.toHaveClass(/pg-row-highlight/, { + timeout: 2000, + }); + }); + + test("duplicating and then renaming an instance updates every chip that names it", async ({ + mount, + page, + }) => { + const props = { + functionParameters: [TASK_ID], + inputSources: sourcesFor({ taskId: [PARAMETERIZED_SEEDED_TASK.uri] }, [ + PARAMETERIZED_SEEDED_TASK, + ]), + promptId: "instances-rename-prompt", + }; + const component = await mount(); + const resources = component.getByRole("region", { name: "Resources" }); await chooseSource( page, - rows.nth(1).locator(".pg-slot-source-wrap"), - SEEDED_RUN.label, + component.locator(".pg-slot-source-wrap"), + PARAMETERIZED_SEEDED_TASK.label, + ); + await card(resources, "seededTask") + .locator("textarea, input") + .first() + .fill("Todo app"); + + // A duplicate copies the arguments, under the next free name. + await chooseInstanceAction(page, resources, "seededTask", "Duplicate"); + await expect( + card(resources, "seededTask2").locator("textarea, input").first(), + ).toHaveValue("Todo app"); + await expect(card(resources, "seededTask2")).toContainText( + "not bound to a slot", + ); + + // Renaming carries the slot's reference along with it. + await card(resources, "seededTask") + .getByRole("button", { name: /seededTask/ }) + .first() + .click(); + const nameInput = resources.getByLabel("Name for seededTask"); + await nameInput.fill("seededTask2"); + // A name that's taken is refused. + await expect(nameInput).toHaveAttribute("aria-invalid", "true"); + await nameInput.fill("root"); + await nameInput.press("Enter"); + await expect(component.locator(".pg-slot-chip")).toContainText("◆ root"); + await expect(card(resources, "root")).toContainText("→ taskId"); + + // And it all survives a remount. + await component.unmount(); + const remounted = await mount(); + await expect(remounted.locator(".pg-slot-chip")).toContainText("◆ root"); + await expect( + card(remounted, "seededTask2").locator("textarea, input").first(), + ).toHaveValue("Todo app"); + }); + + test("removing a referenced instance asks first, then clears the slot", async ({ + mount, + page, + }) => { + const component = await mount( + , ); - const runForm = rows.nth(1).locator(".pg-args-form").first(); await chooseSource( page, - runForm.locator(".pg-slot-source-wrap").first(), - PARAMETERIZED_SEEDED_TASK.label, + component.locator(".pg-slot-source-wrap"), + SEEDED_TASK.label, ); + const dialogs: string[] = []; + page.once("dialog", dialog => { + dialogs.push(dialog.message()); + return dialog.dismiss(); + }); + const remove = () => + chooseInstanceAction(page, component, "seededRootTask", "Remove"); + await remove(); + expect(dialogs).toEqual([ + "Remove 'seededRootTask'? taskId will be cleared.", + ]); + await expect(card(component, "seededRootTask")).toHaveCount(1); + + page.once("dialog", dialog => dialog.accept()); + await remove(); + await expect(card(component, "seededRootTask")).toHaveCount(0); + await expect(component.locator(".pg-slot-chip")).toHaveCount(0); + }); - // Exactly one editable seededTask form exists (title + status) — the - // prompt's own taskId slot owns it. - await expect(rows.nth(0).locator(".pg-args-form .pg-args-name")).toHaveText( - ["title", "status"], + test("a task seeded under another names it in its argument, and a cycle isn't offered", async ({ + mount, + page, + }) => { + const component = await mount( + , ); - // seededRun's own row shows only its own argument ("taskId"), whose chip - // says it's the same instance as the prompt's own taskId row — not a - // second title/status form nested inside it. - await expect(rows.nth(1).locator(".pg-args-name")).toHaveText(["taskId"]); - const nestedNote = runForm.locator(".pg-slot-chip-note"); - await expect(nestedNote).toContainText("same instance as"); + const resources = component.getByRole("region", { name: "Resources" }); + await addResource(page, resources, TREE_TASK.label); + await addResource(page, resources, TREE_TASK.label); + await expect(resources.locator("[data-instance-card]")).toHaveCount(2); + + // `task2`'s parent is `task`: another instance of the same resource. + const parentOf = (name: string) => + card(resources, name) + .locator(".pg-args-row") + .filter({ hasText: "parentId" }) + .locator(".pg-slot-source-wrap"); + await parentOf("task2").click(); + await page.getByRole("menuitem", { name: "task", exact: true }).click(); await expect( - nestedNote.getByRole("button", { name: /taskId/ }), - ).toBeVisible(); + card(resources, "task2").locator(".pg-slot-chip"), + ).toContainText("◆ task"); + await expect(card(resources, "task")).toContainText("→ task2.parentId"); + + // So `task` can't take `task2` as its own parent, nor itself: only a + // new instance is offered. + await parentOf("task").click(); + await expect(page.getByRole("menuitem")).not.toContainText(["task2"]); + await expect( + page.getByRole("menuitem", { name: "task", exact: true }), + ).toHaveCount(0); }); }); diff --git a/src/client/components/__fixtures__/playgroundRoundTrip-firefox.pw.tsx b/src/client/components/__fixtures__/playgroundRoundTrip-firefox.pw.tsx index 3e95f68..41d253c 100644 --- a/src/client/components/__fixtures__/playgroundRoundTrip-firefox.pw.tsx +++ b/src/client/components/__fixtures__/playgroundRoundTrip-firefox.pw.tsx @@ -12,12 +12,20 @@ import { PlaygroundRoundTripHarness } from "./PlaygroundRoundTripHarness"; // caret-position regressions only reproduce here, so the test is scoped to it. test.use({ browserName: "firefox" }); -async function mockApi(page: Page, updateLatencyMs = 80) { +async function mockApi( + page: Page, + updateLatencyMs: number | ((call: number) => number) = 80, +) { + let updateCalls = 0; await page.route("**/api/**", async route => { const url = route.request().url(); if (url.includes("/update")) { const updates = route.request().postDataJSON(); - await new Promise(r => setTimeout(r, updateLatencyMs)); + const latency = + typeof updateLatencyMs === "number" + ? updateLatencyMs + : updateLatencyMs(updateCalls++); + await new Promise(r => setTimeout(r, latency)); const messages = (updates.messages ?? []).map((m: any) => ({ role: m.role, content: { kind: m.content.kind, value: m.content.value }, @@ -76,6 +84,32 @@ test("caret stays at the end while typing into a freshly added message (firefox) expect(await getCursorOffset(editor)).toBe(2); }); +test("a stale echo landing between keystrokes doesn't drop typed text (firefox)", async ({ + mount, + page, +}) => { + // Pins the race the timing-based test above only hits under load: the + // "Add message" echo (empty) lands after "H" is typed, while the "H" echo is + // still in flight when "i" is typed. Applying that stale echo would wipe "H". + await mockApi(page, call => (call === 0 ? 250 : 1000)); + + const component = await mount(); + + await component.getByText("Add message").click(); + + const editor = component.locator(".token-editor").last(); + await editor.click(); + await editor.press("H"); + await page.waitForTimeout(400); + await editor.press("i"); + + await expect(editor).toHaveText("Hi"); + // Outlast the slow echoes, so none of them can still overwrite the text. + await page.waitForTimeout(1200); + await expect(editor).toHaveText("Hi"); + expect(await getCursorOffset(editor)).toBe(2); +}); + test("caret stays put when the round-trip lands after both chars (firefox)", async ({ mount, page, diff --git a/src/client/components/__fixtures__/resourcesSection.pw.tsx b/src/client/components/__fixtures__/resourcesSection.pw.tsx new file mode 100644 index 0000000..30574ad --- /dev/null +++ b/src/client/components/__fixtures__/resourcesSection.pw.tsx @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 Alexander Corrado + +import { expect, test } from "@playwright/experimental-ct-react"; +import { ResourcesSectionHarness } from "./ResourcesSectionHarness"; + +test("an instance's actions sit behind a menu, not inline", async ({ + mount, + page, +}) => { + const component = await mount(); + const trigger = component.getByRole("button", { name: "Actions for db" }); + await expect(trigger).toHaveAttribute("aria-haspopup", "menu"); + await expect(trigger).toHaveAttribute("aria-expanded", "false"); + await expect( + component.getByRole("button", { name: "Duplicate" }), + ).toHaveCount(0); + await expect( + component.getByRole("button", { name: "Remove db" }), + ).toHaveCount(0); + + await trigger.click(); + await expect(trigger).toHaveAttribute("aria-expanded", "true"); + // The menu is portaled to the body, outside the mounted component. + const menu = page.getByRole("menu"); + await expect(menu.getByRole("menuitem")).toHaveText(["Duplicate", "Remove"]); +}); + +test("a server-scoped instance can be removed but not duplicated", async ({ + mount, + page, +}) => { + const component = await mount(); + await component.getByRole("button", { name: "Actions for cache" }).click(); + const menu = page.getByRole("menu"); + await expect(menu.getByRole("menuitem")).toHaveText(["Remove"]); +}); + +test("Duplicate adds a copy and closes the menu", async ({ mount, page }) => { + const component = await mount(); + await component.getByRole("button", { name: "Actions for db" }).click(); + await page + .getByRole("menu") + .getByRole("menuitem", { name: "Duplicate" }) + .click(); + + await expect(component.getByTestId("names")).toHaveText("db,db2,cache"); + await expect(page.getByRole("menu")).toHaveCount(0); +}); + +test("Remove clears the instance once confirmed", async ({ mount, page }) => { + const component = await mount(); + await component.getByRole("button", { name: "Actions for db" }).click(); + page.once("dialog", dialog => dialog.accept()); + await page + .getByRole("menu") + .getByRole("menuitem", { name: "Remove db" }) + .click(); + + await expect(component.getByTestId("names")).toHaveText("cache"); + await expect(page.getByRole("menu")).toHaveCount(0); +}); + +test("cancelling Remove keeps the instance", async ({ mount, page }) => { + const component = await mount(); + await component.getByRole("button", { name: "Actions for db" }).click(); + page.once("dialog", dialog => dialog.dismiss()); + await page + .getByRole("menu") + .getByRole("menuitem", { name: "Remove db" }) + .click(); + + await expect(component.getByTestId("names")).toHaveText("db,cache"); + await expect(page.getByRole("menu")).toHaveCount(0); +}); + +test("a click outside the open menu closes it", async ({ mount, page }) => { + const component = await mount(); + await component.getByRole("button", { name: "Actions for db" }).click(); + await expect(page.getByRole("menu")).toBeVisible(); + + await page.mouse.click(1, 1); + await expect(page.getByRole("menu")).toHaveCount(0); +}); diff --git a/src/client/components/combined-inputs.test.ts b/src/client/components/combined-inputs.test.ts index db6de90..5dce832 100644 --- a/src/client/components/combined-inputs.test.ts +++ b/src/client/components/combined-inputs.test.ts @@ -253,7 +253,7 @@ function odinInput(): ExecutionInput { ): ExecutionInput => ({ kind: "object", properties: { - db: { kind: "resource", uri: ".evalution/playground/db.ts#db" }, + db: { kind: "instance", name: "db" }, workspaceId: { kind: "value", value: { kind: "primitive", value: "ws_internal_default" }, diff --git a/src/client/components/combined-inputs.ts b/src/client/components/combined-inputs.ts index b65b40e..2a271dc 100644 Binary files a/src/client/components/combined-inputs.ts and b/src/client/components/combined-inputs.ts differ diff --git a/src/client/components/dataset-grid.test.ts b/src/client/components/dataset-grid.test.ts index 9e01b82..9e12be3 100644 --- a/src/client/components/dataset-grid.test.ts +++ b/src/client/components/dataset-grid.test.ts @@ -46,7 +46,7 @@ const FIELDS = [ field("2", "info", "Info"), ]; const SHAPE = { - "1": { keys: ["title", "owner"], resource: true }, + "1": { keys: ["title", "owner"] }, "2": { keys: ["title"] }, }; @@ -59,12 +59,16 @@ const info: ExecutionInput = { }, }; +/** An object grafting a resource instance in beside a typed-in value. */ const seeded: ExecutionInput = { - kind: "resource", - uri: "tasks.ts#seededTask", - args: { title: text("Milk"), owner: { kind: "resource", uri: "db.ts#db" } }, + kind: "object", + properties: { + title: text("Milk"), + owner: { kind: "instance", name: "db" }, + }, }; -const blank: ExecutionInput = { kind: "resource", uri: "tasks.ts#blank" }; +const blank: ExecutionInput = { kind: "object", properties: {} }; +const task: ExecutionInput = { kind: "instance", name: "root", output: "id" }; const row = ( cells: DatasetRow["cells"], @@ -98,10 +102,9 @@ describe("buildColumns", () => { expect(cols[1].type).toBe("Task"); }); - it("splits an expanded resource field into its head and one column per key", () => { + it("splits an expanded field into one column per key", () => { expect(columns(["1"]).map(c => [c.id, c.title, c.role, c.group])).toEqual([ ["0", "ticket", "whole", undefined], - ["1/", "task", "head", "task"], ["1/title", "title", "key", "task"], ["1/owner", "owner", "key", "task"], ["2", "info", "whole", "info"], @@ -148,9 +151,7 @@ describe("menuFieldId", () => { it("names the field for a field's own columns, and nothing for the rest", () => { const cols = columns(["1"]); expect(menuFieldId(byId(cols, "0"))).toBe("0"); - // An expanded resource field's head stands for the field... - expect(menuFieldId(byId(cols, "1/"))).toBe("1"); - // ...but the keys inside it, and the source, aren't fields. + // The keys inside an expanded field, and the source, aren't fields. expect(menuFieldId(byId(cols, "1/title"))).toBeUndefined(); expect(menuFieldId(byId(cols, "source"))).toBeUndefined(); expect(menuFieldId(undefined)).toBeUndefined(); @@ -164,17 +165,10 @@ describe("readPath", () => { expect(readPath(row({}), { fieldId: "1", key: "title" })).toBe("absent"); }); - it("reads a resource's argument and an object's property by key", () => { + it("reads an object's property by key", () => { expect( readPath(row({ "1": seeded }), { fieldId: "1", key: "title" }), ).toEqual(text("Milk")); - const object: ExecutionInput = { - kind: "object", - properties: { title: text("Eggs") }, - }; - expect( - readPath(row({ "1": object }), { fieldId: "1", key: "title" }), - ).toEqual(text("Eggs")); }); it("reads a typed-in object's property, wrapped as the value cell it would be", () => { @@ -186,7 +180,7 @@ describe("readPath", () => { ); }); - it("is n/a when the row's resource takes no such argument", () => { + it("is n/a when the row's cell has no such key", () => { expect(readPath(row({ "1": blank }), { fieldId: "1", key: "title" })).toBe( "n/a", ); @@ -201,9 +195,9 @@ describe("cellView", () => { expect(cellView(undefined, columns()[0])).toEqual({ kind: "loading" }); }); - it("previews a value, an empty cell, and a resource chip with its arguments", () => { + it("previews a value, an empty cell, and a resource instance as a chip", () => { const cols = columns(); - const r = row({ "0": text("Hi"), "1": seeded }); + const r = row({ "0": text("Hi"), "1": task }); // `ticket: string` is typed into in place, so it's an editor's view. expect(cellView(r, byId(cols, "0"))).toEqual({ kind: "edit", @@ -220,17 +214,13 @@ describe("cellView", () => { expect(cellView(row({}), byId(cols, "1"))).toEqual({ kind: "empty" }); expect(cellView(r, byId(cols, "1"))).toEqual({ kind: "chip", - text: '◆ seededTask(title: "Milk", owner: db)', + text: "◆ root.id", }); }); - it("names just the resource in an expanded head, with arguments in their own columns", () => { + it("splits an expanded object's keys into their own columns", () => { const cols = columns(["1"]); const r = row({ "1": seeded }); - expect(cellView(r, byId(cols, "1/"))).toEqual({ - kind: "chip", - text: "◆ seededTask", - }); expect(cellView(r, byId(cols, "1/title"))).toEqual({ kind: "text", text: '"Milk"', @@ -260,7 +250,7 @@ describe("cellView", () => { const object: ExecutionInput = { kind: "object", properties: { - db: { kind: "resource", uri: "db.ts#db" }, + db: { kind: "instance", name: "db" }, userId: text("u1"), }, }; @@ -270,6 +260,27 @@ describe("cellView", () => { }); }); + it("draws a stored inline resource node as unsupported text, not undefined", () => { + // Rows saved before named instances hold `{ kind: "resource" }` cells, + // whole and nested; Glide crashes on a text cell with no text. + const legacy = { + kind: "resource", + uri: "db.ts#db", + } as unknown as ExecutionInput; + const object: ExecutionInput = { + kind: "object", + properties: { db: legacy }, + }; + expect(cellView(row({ "1": legacy }), byId(columns(), "1"))).toEqual({ + kind: "text", + text: "(unsupported)", + }); + expect(cellView(row({ "1": object }), byId(columns(), "1"))).toEqual({ + kind: "text", + text: "{ db: (unsupported) }", + }); + }); + it("links a trace source and dims a playground one", () => { const source = byId(columns(), "source"); expect( diff --git a/src/client/components/dataset-grid.ts b/src/client/components/dataset-grid.ts index f60d0b3..7f67838 100644 --- a/src/client/components/dataset-grid.ts +++ b/src/client/components/dataset-grid.ts @@ -6,12 +6,11 @@ * it has, what each cell shows, and the paged cache rows are read through. * Pure and grid-agnostic — `DatasetView` maps it onto Glide Data Grid. * - * A column is a *path* into a row: a whole field, or one key inside it — a - * resource's argument, an object's property, or a property of a typed-in - * object value. The keys come from {@link DatasetRowsOverview}, found in the - * rows rather than the schema, and merged by name, so one `title` column - * spans every resource that takes a `title`. A path is also what sorting and - * filtering will name when they arrive. + * A column is a *path* into a row: a whole field, or one key inside it — an + * object's property, or a property of a typed-in object value. The keys come + * from {@link DatasetRowsOverview}, found in the rows rather than the schema, + * and merged by name. A path is also what sorting and filtering will name + * when they arrive. * * Some cells are typed into in place — see {@link editableCell}. An edit is * reported per cell and sent per row ({@link groupEdits}), and the page it @@ -32,13 +31,13 @@ import type { ExecutionInput, PropValue, } from "../../shared/types"; -import { previewCell, previewPropValue, resourceName } from "./dataset-preview"; +import { previewCell, previewPropValue } from "./dataset-preview"; import { DISCLOSURE_COLLAPSED, DISCLOSURE_EXPANDED } from "./grid-sprites"; /** Where a column reads from: a field, or one key one level inside it. */ export interface CellPath { fieldId: string; - /** A resource argument's or object property's name. */ + /** An object property's name. */ key?: string; } @@ -62,13 +61,8 @@ export type DatasetColumn = { base?: EditableBase; } & ( | { - /** - * `whole`: a field in one column. `head`: the column naming which - * resource fills an expanded field, beside one `key` column per - * argument. A field of objects has no head: its own cell would only - * ever read `{…}` next to the columns holding what's in it. - */ - role: "whole" | "head"; + /** A field in one column. */ + role: "whole"; path: CellPath; } | { role: "key"; path: Required } @@ -76,7 +70,7 @@ export type DatasetColumn = { | { role: "source" } ); -const WIDTHS = { whole: 240, head: 140, key: 160, source: 96 }; +const WIDTHS = { whole: 240, key: 160, source: 96 }; /** * A field's group header, which is its name: only fields with keys have one, @@ -131,17 +125,6 @@ export function buildColumns({ columns.push({ ...whole, group }); continue; } - if (fieldShape?.resource) { - columns.push({ - id: `${field.id}/`, - title: field.def.name, - type, - group, - role: "head", - path, - width: WIDTHS.head, - }); - } for (const key of fieldKeys) { columns.push({ id: `${field.id}/${key}`, @@ -170,9 +153,7 @@ export function buildColumns({ export function menuFieldId( column: DatasetColumn | undefined, ): string | undefined { - return column?.role === "whole" || column?.role === "head" - ? column.path.fieldId - : undefined; + return column?.role === "whole" ? column.path.fieldId : undefined; } /** @@ -213,8 +194,7 @@ export function groupHeader( /** * What a path reads in a row: the input there, `"absent"` when the row has - * nothing there, or `"n/a"` when the field's cell has no such key — a - * different resource, taking other arguments, say. + * nothing there, or `"n/a"` when the field's cell has no such key. * * A typed-in object's property is a `PropValue`, not an `ExecutionInput`; it * comes back wrapped as the value cell it would have been on its own. @@ -233,12 +213,7 @@ export function readPath( ? { kind: "value", value: value.properties[key] } : "n/a"; } - const inner = - cell.kind === "resource" - ? cell.args - : cell.kind === "object" - ? cell.properties - : undefined; + const inner = cell.kind === "object" ? cell.properties : undefined; return inner && Object.hasOwn(inner, key) ? inner[key] : "n/a"; } @@ -391,7 +366,10 @@ export function groupEdits(edits: readonly CellEdit[]): DatasetRowUpdate[] { return [...byRow.values()]; } -/** `row` with `update`'s cells set or cleared, as the server will store it. */ +/** + * `row` with `update`'s cells and resource instances set or cleared, as the + * server will store it. + */ export function applyUpdate( row: DatasetRow, update: DatasetRowUpdate, @@ -401,7 +379,18 @@ export function applyUpdate( if (cell === null) delete cells[fieldId]; else cells[fieldId] = cell; } - return { ...row, cells }; + if (!update.resources) return { ...row, cells }; + const resources = { ...row.resources }; + for (const [name, spec] of Object.entries(update.resources)) { + if (spec === null) delete resources[name]; + else resources[name] = spec; + } + const { resources: _, ...rest } = row; + return { + ...rest, + cells, + ...(Object.keys(resources).length > 0 && { resources }), + }; } /** How the grid should draw one cell. */ @@ -409,10 +398,10 @@ export type CellView = | { kind: "loading" } /** Nothing here: a sparse row. Drawn as a dim dash. */ | { kind: "empty" } - /** Not applicable: this row's resource doesn't take this argument. */ + /** Not applicable: this row's cell has no such key. */ | { kind: "n/a" } | { kind: "text"; text: string; tone?: "dim" | "link" } - /** A resource, drawn as a chip. */ + /** A resource instance, drawn as a chip. */ | { kind: "chip"; text: string } /** * A cell typed into in place (see {@link editableCell}): `value` is what @@ -434,7 +423,7 @@ function previewObject( return `{ ${entries .map( ([key, child]) => - `${key}: ${child.kind === "resource" ? `◆ ${previewCell(child)}` : previewCell(child)}`, + `${key}: ${child.kind === "instance" ? `◆ ${previewCell(child)}` : previewCell(child)}`, ) .join(", ")} }`; } @@ -451,22 +440,12 @@ function previewValueObject( } /** How an input is drawn in a column of `role`. */ -function inputView( - input: ExecutionInput, - role: "whole" | "head" | "key", -): CellView { +function inputView(input: ExecutionInput): CellView { switch (input.kind) { - case "resource": - return { - kind: "chip", - // An expanded field's head names the resource; the arguments have - // their own columns beside it. - text: `◆ ${role === "head" ? resourceName(input.uri) : previewCell(input)}`, - }; + case "instance": + return { kind: "chip", text: `◆ ${previewCell(input)}` }; case "object": - return role === "head" - ? { kind: "text", text: "{…}", tone: "dim" } - : { kind: "text", text: previewObject(input) }; + return { kind: "text", text: previewObject(input) }; default: return { kind: "text", @@ -504,7 +483,7 @@ export function cellView( } if (found === "absent") return { kind: "empty" }; if (found === "n/a") return { kind: "n/a" }; - return inputView(found, column.role); + return inputView(found); } } } diff --git a/src/client/components/dataset-preview.test.ts b/src/client/components/dataset-preview.test.ts index eb0f075..a12f3d3 100644 --- a/src/client/components/dataset-preview.test.ts +++ b/src/client/components/dataset-preview.test.ts @@ -2,11 +2,13 @@ // Copyright (c) 2026 Alexander Corrado import { describe, expect, it } from "vitest"; +import type { ExecutionInput } from "../../shared/types"; import { previewCell, previewPropValue, propValueToJson, resourceName, + UNSUPPORTED_PREVIEW, } from "./dataset-preview"; describe("previewPropValue", () => { @@ -27,17 +29,23 @@ describe("previewPropValue", () => { }); describe("previewCell", () => { - it("names a resource by its export, with an arguments summary", () => { + it("names a resource instance, and the output read from it", () => { + expect(previewCell({ kind: "instance", name: "db" })).toBe("db"); expect( - previewCell({ - kind: "resource", - uri: ".evalution/playground/tasks.ts#seededTask", - args: { - title: { kind: "value", value: { kind: "primitive", value: "Milk" } }, - owner: { kind: "resource", uri: "db.ts#db" }, - }, - }), - ).toBe('seededTask(title: "Milk", owner: db)'); + previewCell({ kind: "instance", name: "root", output: "taskId" }), + ).toBe("root.taskId"); + }); + + it("shows a stored node of a removed kind as unsupported", () => { + const legacy = { + kind: "resource", + uri: "db.ts#seededTask", + args: {}, + } as unknown as ExecutionInput; + expect(previewCell(legacy)).toBe(UNSUPPORTED_PREVIEW); + expect(previewCell({ kind: "object", properties: { db: legacy } })).toBe( + "{…}", + ); }); it("truncates a long value", () => { @@ -53,7 +61,7 @@ describe("previewCell", () => { const cell = { kind: "object" as const, properties: { - db: { kind: "resource" as const, uri: "db.ts#db" }, + db: { kind: "instance" as const, name: "db" }, userId: { kind: "value" as const, value: { kind: "primitive" as const, value: "u1" }, diff --git a/src/client/components/dataset-preview.ts b/src/client/components/dataset-preview.ts index 071969b..acd8161 100644 --- a/src/client/components/dataset-preview.ts +++ b/src/client/components/dataset-preview.ts @@ -58,17 +58,12 @@ export function resourceName(uri: string): string { return hash >= 0 ? uri.slice(hash + 1) : uri; } -/** A one-line summary of a resource's arguments: `title: "Milk", owner: db`. */ -export function previewArgs( - args: Record | undefined, -): string { - if (!args) return ""; - return truncate( - Object.entries(args) - .map(([name, input]) => `${name}: ${previewCell(input)}`) - .join(", "), - ); -} +/** + * How a cell of a kind this client doesn't know is shown — say, an inline + * `resource` node stored before named instances replaced it + * (`specs/resource-instances.md` §A.1). + */ +export const UNSUPPORTED_PREVIEW = "(unsupported)"; /** A one-line preview of any cell. */ export function previewCell(input: ExecutionInput): string { @@ -77,16 +72,16 @@ export function previewCell(input: ExecutionInput): string { return truncate(previewPropValue(input.value)); case "object": return "{…}"; - case "resource": { - const args = previewArgs(input.args); - return args - ? `${resourceName(input.uri)}(${args})` - : resourceName(input.uri); - } + case "instance": + return input.output === undefined + ? input.name + : `${input.name}.${input.output}`; case "dataset": return `= column ${input.field}`; case "input": return `= ${input.half === "execute" ? "execute." : ""}${input.path}`; + default: + return UNSUPPORTED_PREVIEW; } } diff --git a/src/client/components/dataset-row-editing.test.ts b/src/client/components/dataset-row-editing.test.ts index 6713819..812956d 100644 --- a/src/client/components/dataset-row-editing.test.ts +++ b/src/client/components/dataset-row-editing.test.ts @@ -10,11 +10,13 @@ import type { ResourceInfo, } from "../../shared/types"; import { + changesAnything, chosenResources, datasetInputSources, - rowCellChanges, + rowChanges, rowEditorState, } from "./dataset-row-editing"; +import { instanceUri } from "./pseudo-sources"; const def = (name: string, syntax: string): PropDefinition => ({ name, @@ -36,7 +38,6 @@ const SEEDED_ID: ResourceInfo = { scope: "run", parent: SEEDED.uri, }; -const BY_URI = new Map([SEEDED, SEEDED_ID].map(r => [r.uri, r])); const prompt = (overrides: Partial = {}): NormalizedPrompt => ({ @@ -85,62 +86,85 @@ describe("datasetInputSources", () => { }); }); -describe("rowCellChanges", () => { - const seededCell: ExecutionInput = { - kind: "resource", - uri: SEEDED_ID.uri, - args: { title: text("Buy milk") }, +describe("rowChanges", () => { + const taskCell: ExecutionInput = { + kind: "instance", + name: "task", + output: "id", }; + const task = { uri: SEEDED.uri, args: { title: text("Buy milk") } }; - it("commits nothing for an untouched row, however its cells were built", () => { + it("commits nothing for an untouched row, however it was built", () => { // Keys in another order than the editor would write them. - const cells: Record = { - "0": { - args: { title: text("Buy milk") }, - uri: SEEDED_ID.uri, - kind: "resource", - } as ExecutionInput, - "1": text("hello"), + const row = { + cells: { + "0": { output: "id", name: "task", kind: "instance" } as ExecutionInput, + "1": text("hello"), + }, + resources: { task: { args: task.args, uri: task.uri, receipt: "r" } }, }; - const state = rowEditorState(cells, BY_URI); - expect(rowCellChanges(["0", "1", "2"], state, cells, BY_URI)).toEqual({}); + const changes = rowChanges(["0", "1", "2"], rowEditorState(row), row); + expect(changes).toEqual({ cells: {} }); + expect(changesAnything(changes)).toBe(false); }); - it("folds a chosen resource and its arguments back into a cell", () => { - const cells = { "1": text("hello") }; - const state = rowEditorState(cells, BY_URI); - state.selections["0"] = { resources: { "": SEEDED_ID.uri } }; - // Arguments live under the root resource, as in the panel. - state.resourceArgs[SEEDED.uri] = { - title: { value: { kind: "primitive", value: "Buy milk" } }, + it("folds a chosen instance into a cell, and a new instance into resources", () => { + const row = { cells: { "1": text("hello") } }; + const state = rowEditorState(row); + state.selections["0"] = { resources: { "": instanceUri("task", "id") } }; + state.instances.task = { + uri: SEEDED.uri, + args: { title: { value: { kind: "primitive", value: "Buy milk" } } }, }; - expect(rowCellChanges(["0", "1"], state, cells, BY_URI)).toEqual({ - "0": seededCell, + expect(rowChanges(["0", "1"], state, row)).toEqual({ + cells: { "0": taskCell }, + resources: { task }, }); }); - it("clears a field emptied in its editor, and leaves other fields alone", () => { - const cells = { "0": seededCell, "1": text("hello") }; - const state = rowEditorState(cells, BY_URI); + it("clears a field emptied in its editor and removes a dropped instance, leaving the rest", () => { + const row = { + cells: { "0": taskCell, "1": text("hello") }, + resources: { task, other: { uri: SEEDED.uri } }, + }; + const state = rowEditorState(row); state.selections["1"] = { value: { kind: "primitive", value: "" } }; state.selections["0"] = {}; - expect(rowCellChanges(["1"], state, cells, BY_URI)).toEqual({ "1": null }); + delete state.instances.other; + expect(rowChanges(["1"], state, row)).toEqual({ + cells: { "1": null }, + resources: { other: null }, + }); }); }); describe("chosenResources", () => { - it("changes when a resource is chosen, not when a value is typed", () => { - const state = rowEditorState({ "0": text("a") }, BY_URI); + it("changes when a source is chosen or an instance renamed, not when a value is typed", () => { + const state = rowEditorState({ + cells: { "0": text("a") }, + resources: { task: { uri: SEEDED.uri } }, + }); const before = chosenResources(state); const typed = { ...state, selections: { "0": { value: { kind: "primitive", value: "ab" } } }, + instances: { + task: { + uri: SEEDED.uri, + args: { title: { value: { kind: "primitive", value: "x" } } }, + }, + }, } as typeof state; expect(chosenResources(typed)).toBe(before); const chosen = { ...state, - selections: { "0": { resources: { "": SEEDED.uri } } }, + selections: { "0": { resources: { "": instanceUri("task") } } }, }; expect(chosenResources(chosen)).not.toBe(before); + const renamed = { + ...state, + instances: { renamed: { uri: SEEDED.uri, args: {} } }, + }; + expect(chosenResources(renamed)).not.toBe(before); }); }); diff --git a/src/client/components/dataset-row-editing.ts b/src/client/components/dataset-row-editing.ts index f42b6bc..293402d 100644 --- a/src/client/components/dataset-row-editing.ts +++ b/src/client/components/dataset-row-editing.ts @@ -3,26 +3,33 @@ /** * Editing a dataset row in the details pane the way the execute panel edits - * its slots: a field's cell becomes a {@link SlotSelection}, resources and - * their arguments included, and folds back into a cell on commit. Pure. See - * `specs/datasets.md` §P.2. + * its slots: a field's cell becomes a {@link SlotSelection}, the row's + * resource instances become the Resources section's state, and both fold back + * into a row update on commit. Pure. See `specs/datasets.md` §P.2 and + * `specs/resource-instances.md` §G. */ import { committedCell } from "../../shared/dataset-cells"; import { matchKey } from "../../shared/dataset-fields"; import type { DatasetField, + DatasetRow, + DatasetRowUpdate, ExecutionInput, NormalizedPrompt, ResourceInfo, } from "../../shared/types"; import { fromExecutionInput, - type ResourceArgs, - resourceArgsFor, type Selections, + type SlotSelection, toExecutionInput, } from "./execution-input-state"; +import { + fromWireResources, + type InstanceSelections, + toWireResources, +} from "./run-resources-state"; /** The resources a dataset's fields can be filled from, borrowed from a prompt. */ export interface DatasetInputSources { @@ -76,66 +83,92 @@ export function datasetInputSources( }; } -/** A row's editor state: one selection per field id, and the shared arguments. */ +/** A row's editor state: one selection per field id, and the row's resource instances. */ export interface RowEditorState { selections: Selections; - resourceArgs: ResourceArgs; + instances: InstanceSelections; } -/** The editor state for a row's `cells`, as the panel restores its own. */ +/** The editor state for `row`, as the panel restores its own. */ export function rowEditorState( - cells: Record, - resourcesByUri: ReadonlyMap, + row: Pick, ): RowEditorState { const selections: Selections = {}; - const resourceArgs: ResourceArgs = {}; - for (const [fieldId, cell] of Object.entries(cells)) { - const recovered = fromExecutionInput(cell, resourcesByUri); - selections[fieldId] = recovered.selection; - Object.assign(resourceArgs, recovered.resourceArgs); + for (const [fieldId, cell] of Object.entries(row.cells)) { + selections[fieldId] = fromExecutionInput(cell); } - return { selections, resourceArgs }; + return { selections, instances: fromWireResources(row.resources) }; } /** - * The cells `state` would change, of the fields in `fieldIds`: field id → - * the new cell, or `null` to clear it. A field whose cell is unchanged has - * no entry, so an untouched row commits nothing. + * What `state` would change about `row`, of the fields in `fieldIds` and its + * resource instances: field id → the new cell, or `null` to clear it, and + * instance name → its new spec, or `null` to remove it. Anything unchanged + * has no entry, so an untouched row commits nothing. */ -export function rowCellChanges( +export function rowChanges( fieldIds: readonly string[], state: RowEditorState, - cells: Record, - resourcesByUri: ReadonlyMap, -): Record { - const resolveArgs = (uri: string) => - resourceArgsFor(uri, state.resourceArgs, resourcesByUri); - const changes: Record = {}; + row: Pick, +): Omit { + const cells: Record = {}; for (const fieldId of fieldIds) { - const input = toExecutionInput(state.selections[fieldId], resolveArgs); + const input = toExecutionInput(state.selections[fieldId]); const next = input?.kind === "value" ? committedCell(input.value) : (input ?? null); - if (canonical(next) !== canonical(cells[fieldId] ?? null)) { - changes[fieldId] = next; + if (canonical(next) !== canonical(row.cells[fieldId] ?? null)) { + cells[fieldId] = next; + } + } + + const before = row.resources ?? {}; + const after = toWireResources(state.instances) ?? {}; + const resources: NonNullable = {}; + for (const name of new Set([...Object.keys(before), ...Object.keys(after)])) { + const next = after[name] ?? null; + const { receipt: _, ...prev } = before[name] ?? { uri: "" }; + if (canonical(next) !== canonical(before[name] ? prev : null)) { + resources[name] = next; } } - return changes; + return { + cells, + ...(Object.keys(resources).length > 0 && { resources }), + }; } -/** Every resource chosen anywhere in `state`, as one comparable string. */ +/** Whether `changes` changes anything at all. */ +export function changesAnything( + changes: Omit, +): boolean { + return ( + Object.keys(changes.cells).length > 0 || + Object.keys(changes.resources ?? {}).length > 0 + ); +} + +/** + * Everything in `state` that isn't typed — every source chosen in a slot or + * an argument, and which instances exist under which names — as one + * comparable string. A change to it commits at once, since there's nothing + * more to type; anything else is a draft until blur. + */ export function chosenResources(state: RowEditorState): string { const resourcesOf = (selections: Selections) => Object.fromEntries( - Object.entries(selections).map(([k, s]) => [k, s.resources ?? {}]), + Object.entries(selections).flatMap(([k, s]: [string, SlotSelection]) => + s.resources && Object.keys(s.resources).length > 0 + ? [[k, s.resources]] + : [], + ), ); return canonical([ resourcesOf(state.selections), - Object.fromEntries( - Object.entries(state.resourceArgs).map(([uri, args]) => [ - uri, - resourcesOf(args), - ]), - ), + Object.entries(state.instances).map(([name, instance]) => [ + name, + instance.uri, + resourcesOf(instance.args), + ]), ]); } diff --git a/src/client/components/eval-bindings.test.ts b/src/client/components/eval-bindings.test.ts index 5a20e02..645ac88 100644 --- a/src/client/components/eval-bindings.test.ts +++ b/src/client/components/eval-bindings.test.ts @@ -17,7 +17,7 @@ const obj = (name: string, properties: PropDefinition[]): PropDefinition => ({ type: { kind: "object", syntax: `{…}`, properties }, }); -const db: ExecutionInput = { kind: "resource", uri: "db.ts#db" }; +const db: ExecutionInput = { kind: "instance", name: "db" }; const text = (value: string): ExecutionInput => ({ kind: "value", value: { kind: "primitive", value }, @@ -56,11 +56,17 @@ describe("prefillBindings", () => { stored: { functionInputs: { title: text("hi") }, executeInputs: { db }, + resources: { + db: { uri: "db.ts#db", receipt: "r" }, + unused: { uri: "db.ts#other" }, + }, }, }); + // The instance a taken binding names comes along; the rest don't. expect(inputs).toEqual({ functionInputs: { title: text("hi") }, executeInputs: { db }, + resources: { db: { uri: "db.ts#db" } }, }); }); diff --git a/src/client/components/execution-input-state.test.ts b/src/client/components/execution-input-state.test.ts index 5e2ff7c..c8af473 100644 --- a/src/client/components/execution-input-state.test.ts +++ b/src/client/components/execution-input-state.test.ts @@ -2,223 +2,95 @@ // Copyright (c) 2026 Alexander Corrado import { describe, expect, it } from "vitest"; -import type { ExecutionInput, ResourceInfo } from "../../shared/types"; +import type { ExecutionInput } from "../../shared/types"; import { fromExecutionInput, - type ResourceArgs, - resourceArgsFor, SELF, type SlotSelection, toExecutionInput, } from "./execution-input-state"; +import { columnUri, instanceUri } from "./pseudo-sources"; const strVal = (value: string): ExecutionInput => ({ kind: "value", value: { kind: "primitive", value }, }); -describe("toExecutionInput (specs/resource-arguments.md §C, §J)", () => { - it("emits an input byte-identical to before arguments existed for a resource with no arguments", () => { - const selection: SlotSelection = { resources: { [SELF]: "pg.ts#db" } }; - expect(toExecutionInput(selection)).toEqual({ - kind: "resource", - uri: "pg.ts#db", - }); - // Same with a `resolveArgs` present but reporting nothing for this uri. - expect(toExecutionInput(selection, () => undefined)).toEqual({ - kind: "resource", - uri: "pg.ts#db", - }); - }); - - it("attaches args built by resolveArgs onto the resource node", () => { - const selection: SlotSelection = { resources: { [SELF]: "pg.ts#seeded" } }; - const args = { title: strVal("Todo app") }; - expect(toExecutionInput(selection, () => args)).toEqual({ - kind: "resource", - uri: "pg.ts#seeded", - args, - }); +describe("toExecutionInput", () => { + it("references an instance, or one of its outputs, chosen for the slot", () => { + expect( + toExecutionInput({ resources: { [SELF]: instanceUri("db") } }), + ).toEqual({ kind: "instance", name: "db" }); + expect( + toExecutionInput({ + resources: { [SELF]: instanceUri("root", "taskId") }, + }), + ).toEqual({ kind: "instance", name: "root", output: "taskId" }); }); - it("attaches args to a resource nested inside an object", () => { + it("grafts an instance into one field of a typed-in object", () => { const selection: SlotSelection = { - resources: { db: "pg.ts#seeded" }, + value: { + kind: "object", + properties: { workspaceId: { kind: "primitive", value: "ws_1" } }, + }, + resources: { db: instanceUri("db") }, }; - const args = { title: strVal("x") }; - const result = toExecutionInput(selection, uri => - uri === "pg.ts#seeded" ? args : undefined, - ); - expect(result).toEqual({ + expect(toExecutionInput(selection)).toEqual({ kind: "object", - properties: { db: { kind: "resource", uri: "pg.ts#seeded", args } }, - }); - }); -}); - -describe("resourceArgsFor (§J)", () => { - const resourcesByUri = new Map([ - [ - "pg.ts#seeded", - { - uri: "pg.ts#seeded", - label: "Seeded task", - scope: "run", - parameters: [ - { - name: "title", - type: { kind: "primitive", syntax: "string", base: "string" }, - optional: false, - }, - { - name: "status", - type: { kind: "primitive", syntax: "string", base: "string" }, - optional: false, - }, - ], - }, - ], - [ - "pg.ts#titleGen", - { uri: "pg.ts#titleGen", label: "Title generator", scope: "run" }, - ], - ]); - - it("builds one ExecutionInput per declared parameter, in declaration order", () => { - const resourceArgs: ResourceArgs = { - "pg.ts#seeded": { - title: { value: { kind: "primitive", value: "Todo app" } }, - status: { value: { kind: "primitive", value: "triaged" } }, + properties: { + workspaceId: strVal("ws_1"), + db: { kind: "instance", name: "db" }, }, - }; - const args = resourceArgsFor("pg.ts#seeded", resourceArgs, resourcesByUri); - expect(Object.keys(args!)).toEqual(["title", "status"]); - expect(args!.title).toEqual(strVal("Todo app")); + }); }); - it("returns undefined for a resource with no declared parameters", () => { + it("leaves out a catalog URI, which only stands for something once adopted", () => { expect( - resourceArgsFor("pg.ts#titleGen", {}, resourcesByUri), + toExecutionInput({ resources: { [SELF]: "pg.ts#db" } }), ).toBeUndefined(); - }); - - it("returns undefined when no argument has anything typed in or chosen", () => { - expect(resourceArgsFor("pg.ts#seeded", {}, resourcesByUri)).toBeUndefined(); - }); - - it("resolves an argument that is itself a resource, recursively", () => { - const resourceArgs: ResourceArgs = { - "pg.ts#seeded": { - title: { resources: { [SELF]: "pg.ts#titleGen" } }, - }, - }; - const args = resourceArgsFor("pg.ts#seeded", resourceArgs, resourcesByUri); - expect(args!.title).toEqual({ kind: "resource", uri: "pg.ts#titleGen" }); + expect( + toExecutionInput({ + value: { kind: "primitive", value: "x" }, + resources: { db: "pg.ts#db" }, + }), + ).toEqual(strVal("x")); }); }); -describe("fromExecutionInput (§I, §J)", () => { - it("restores a stored selection made before arguments existed, unchanged", () => { - const input: ExecutionInput = { kind: "resource", uri: "pg.ts#db" }; - const recovered = fromExecutionInput(input); - expect(recovered.selection).toEqual({ resources: { [SELF]: "pg.ts#db" } }); - expect(recovered.resourceArgs).toEqual({}); - }); - - it("recovers a resource's args into the ResourceArgs map, keyed by its uri", () => { - const input: ExecutionInput = { - kind: "resource", - uri: "pg.ts#seeded", - args: { title: strVal("Todo app") }, - }; - const recovered = fromExecutionInput(input); - expect(recovered.selection).toEqual({ - resources: { [SELF]: "pg.ts#seeded" }, - }); - expect(recovered.resourceArgs["pg.ts#seeded"]).toEqual({ - title: { value: { kind: "primitive", value: "Todo app" } }, - }); - }); - - it("recovers args from a resource nested arbitrarily deep in an object", () => { +describe("fromExecutionInput", () => { + it("restores instance, column and value inputs as the panel chose them", () => { const input: ExecutionInput = { kind: "object", properties: { - list_tasks: { - kind: "object", - properties: { - db: { - kind: "resource", - uri: "pg.ts#seeded", - args: { title: strVal("x") }, - }, - }, - }, + db: { kind: "instance", name: "db" }, + title: { kind: "dataset", field: "0" }, + workspaceId: strVal("ws_1"), }, }; - const recovered = fromExecutionInput(input); - expect(recovered.selection.resources).toEqual({ - "list_tasks.db": "pg.ts#seeded", - }); - expect(recovered.resourceArgs["pg.ts#seeded"]).toEqual({ - title: { value: { kind: "primitive", value: "x" } }, - }); - }); - - it("recovers a resource argument that is itself a resource with its own args", () => { - const input: ExecutionInput = { - kind: "resource", - uri: "pg.ts#seeded", - args: { - owner: { - kind: "resource", - uri: "pg.ts#owner", - args: { name: strVal("Ada") }, - }, + const selection = fromExecutionInput(input); + expect(selection).toEqual({ + value: { + kind: "object", + properties: { workspaceId: { kind: "primitive", value: "ws_1" } }, }, - }; - const recovered = fromExecutionInput(input); - expect(recovered.resourceArgs["pg.ts#seeded"].owner).toEqual({ - resources: { [SELF]: "pg.ts#owner" }, + resources: { db: instanceUri("db"), title: columnUri("0") }, }); - expect(recovered.resourceArgs["pg.ts#owner"]).toEqual({ - name: { value: { kind: "primitive", value: "Ada" } }, + expect(toExecutionInput(selection)).toEqual({ + kind: "object", + properties: { + workspaceId: strVal("ws_1"), + db: { kind: "instance", name: "db" }, + title: { kind: "dataset", field: "0" }, + }, }); }); -}); - -describe("round trip (toExecutionInput ∘ resourceArgsFor, then fromExecutionInput)", () => { - it("restores a shared-by-uri binding losslessly", () => { - const resourcesByUri = new Map([ - [ - "pg.ts#seeded", - { - uri: "pg.ts#seeded", - label: "Seeded task", - scope: "run", - parameters: [ - { - name: "title", - type: { kind: "primitive", syntax: "string", base: "string" }, - optional: false, - }, - ], - }, - ], - ]); - const resourceArgs: ResourceArgs = { - "pg.ts#seeded": { - title: { value: { kind: "primitive", value: "Todo app" } }, - }, - }; - const selection: SlotSelection = { resources: { [SELF]: "pg.ts#seeded" } }; - const wire = toExecutionInput(selection, uri => - resourceArgsFor(uri, resourceArgs, resourcesByUri), - )!; - const recovered = fromExecutionInput(wire); - - expect(recovered.selection).toEqual(selection); - expect(recovered.resourceArgs).toEqual(resourceArgs); + it("restores an inline resource stored before instances existed as an empty slot", () => { + const legacy = { + kind: "resource", + uri: "pg.ts#db", + } as unknown as ExecutionInput; + expect(fromExecutionInput(legacy)).toEqual({}); }); }); diff --git a/src/client/components/execution-input-state.ts b/src/client/components/execution-input-state.ts index e46d3f9..a2e8f15 100644 --- a/src/client/components/execution-input-state.ts +++ b/src/client/components/execution-input-state.ts @@ -6,7 +6,7 @@ import type { InputLayout, NormalizedPrompt, PropValue, - ResourceInfo, + RunResources, } from "../../shared/types"; import { pseudoInput, pseudoUriOf } from "./pseudo-sources"; @@ -17,6 +17,8 @@ import { pseudoInput, pseudoUriOf } from "./pseudo-sources"; export interface StoredInputs { functionInputs?: Record; executeInputs?: Record; + /** The run's named resource instances. See `specs/resource-instances.md`. */ + resources?: RunResources; /** The user's explicit layout choice, by slot path. Absent until they touch the toggle. */ layout?: { functionSlots?: Record; @@ -51,15 +53,17 @@ export function readStoredInputs( * * Kept apart from {@link ExecutionInput} because editing has a state the wire * format has no reason to carry: a hand-edited value *and* a set of nested - * slots that have been handed to a resource instead. `toExecutionInput` folds + * slots that have been handed to a source instead. `toExecutionInput` folds * the two together at submit time. */ export interface SlotSelection { /** The value typed into the editor, if any. */ value?: PropValue; /** - * Resource chosen for a slot, keyed by the slot's dotted path relative to - * this top-level slot (`''` for the slot itself, `'db'` for a nested field). + * Source chosen for a slot — a pseudo-source URI naming one of the run's + * resource instances, a column, or another slot (see `pseudo-sources.ts`) + * — keyed by the slot's dotted path relative to this top-level slot (`''` + * for the slot itself, `'db'` for a nested field). * * Nested entries are what let a resource fill one field of an otherwise * hand-edited object — `toolsContext`'s `db` beside its typed-in ids. @@ -70,47 +74,35 @@ export interface SlotSelection { /** Editor state for one set of slots, keyed by slot (or argument) name. */ export type Selections = Record; -/** - * Argument editor state, held once per resource `uri` and shared by every - * selection of it — the panel-level counterpart of `ExecutionInput.args` - * (`specs/resource-arguments.md` §I, §J). - * - * Kept as a sibling map rather than per slot because arguments are a property - * of the *resource*, the same reason `ResourceInfo.parameters` is: picking - * `seededTask.taskId` for one slot and `seededTask.title` for another edits - * one set of arguments, not two. - */ -export type ResourceArgs = Record; - /** The path key used for the top-level slot itself. */ export const SELF = ""; /** * Fold a slot's editor state into the input to send. * - * A resource chosen for the slot itself wins outright. Otherwise nested + * A source chosen for the slot itself wins outright. Otherwise nested * choices are grafted onto the typed-in value, producing the `object` variant - * only where one is actually needed — a slot with no nested resources stays a + * only where one is actually needed — a slot with no nested sources stays a * plain `value`, which is also what makes it restore exactly on replay. * - * @param selection - This slot's own editor state. - * @param resolveArgs - Given a chosen resource's `uri`, returns the `args` it - * should carry (built from {@link ResourceArgs} — see - * {@link resourceArgsFor}), or `undefined` if it takes none. Recursive: an - * argument that is itself a resource gets its own `args` resolved the same - * way, which is how `resourceArgsFor` calls back into this function. + * A chosen URI that isn't a pseudo-source (a catalog resource, which a host + * turns into an instance as it's picked) stands for nothing on the wire and + * is left out. */ export function toExecutionInput( selection: SlotSelection | undefined, - resolveArgs?: (uri: string) => Record | undefined, ): ExecutionInput | undefined { if (!selection) return undefined; const resources = selection.resources ?? {}; const own = resources[SELF]; - if (own) return resourceInput(own, resolveArgs); + if (own) return pseudoInput(own); - const nested = Object.entries(resources).filter(([path]) => path !== SELF); + const nested = Object.entries(resources).flatMap(([path, uri]) => { + if (path === SELF) return []; + const input = pseudoInput(uri); + return input ? [[path, input] as const] : []; + }); if (nested.length === 0) { return selection.value ? { kind: "value", value: selection.value } @@ -119,81 +111,15 @@ export function toExecutionInput( // Build the object tree from the value, then overlay each nested choice at // its path. - const root: ExecutionInput = selection.value + let out: ExecutionInput = selection.value ? { kind: "value", value: selection.value } : { kind: "object", properties: {} }; - - let out: ExecutionInput = root; - for (const [path, uri] of nested) { - out = overlay(out, path.split("."), resourceInput(uri, resolveArgs)); + for (const [path, input] of nested) { + out = overlay(out, path.split("."), input); } return out; } -/** - * A `{ kind: "resource" }` node, with `args` attached when `resolveArgs` has - * any — or, for a column or prompt slot chosen as a source, the `dataset` or - * `input` node it stands for. - */ -function resourceInput( - uri: string, - resolveArgs?: (uri: string) => Record | undefined, -): ExecutionInput { - const pseudo = pseudoInput(uri); - if (pseudo) return pseudo; - const args = resolveArgs?.(uri); - return args ? { kind: "resource", uri, args } : { kind: "resource", uri }; -} - -/** - * Builds the `args` a reference to `uri` should carry: one entry per its - * `ResourceInfo.parameters`, taken from `resourceArgs[uri]` and folded - * through {@link toExecutionInput} the same as any other slot — recursively, - * so an argument that is itself a resource gets its own `args` the same way. - * - * Meant to be passed (partially applied over `resourceArgs` and - * `resourcesByUri`) as {@link toExecutionInput}'s `resolveArgs`. - * - * @param uri - The resource whose arguments to build. - * @param resourceArgs - The panel's whole argument-editor state. - * @param resourcesByUri - Every resource in scope, for parameter lookup. - */ -export function resourceArgsFor( - uri: string, - resourceArgs: ResourceArgs, - resourcesByUri: ReadonlyMap, -): Record | undefined { - // `uri` may name an output value (`seededTask.taskId`) rather than the - // resource itself — only the root carries `parameters`, and §I's "one - // binding per resource" means the args live (and are looked up) under the - // *root's* uri regardless of which of its values was actually picked. - const root = rootResourceUri(uri, resourcesByUri); - const params = resourcesByUri.get(root)?.parameters; - if (!params || params.length === 0) return undefined; - - const selections = resourceArgs[root] ?? {}; - const entries = params.flatMap(param => { - const input = toExecutionInput(selections[param.name], childUri => - resourceArgsFor(childUri, resourceArgs, resourcesByUri), - ); - return input ? [[param.name, input] as const] : []; - }); - return entries.length > 0 ? Object.fromEntries(entries) : undefined; -} - -/** - * `uri` itself, or the resource it's an output value of (`ResourceInfo.parent`) - * — the key {@link ResourceArgs} and `ResourceArgsContext.resourceSlots` / - * `.claimed` are all addressed by, so that choosing `seededTask.taskId` for - * one slot and `seededTask.title` for another shares one set of arguments. - */ -export function rootResourceUri( - uri: string, - resourcesByUri: ReadonlyMap, -): string { - return resourcesByUri.get(uri)?.parent ?? uri; -} - /** * Replace whatever sits at `path` inside `input` with `replacement`, * converting the `value` variant into the nestable `object` variant only along @@ -232,73 +158,26 @@ function propertiesOf(input: ExecutionInput): Record { return {}; } -/** What {@link fromExecutionInput} recovers from a stored or recorded input. */ -export interface RecoveredInput { - /** This slot's own editor state — the direct inverse of the old `fromExecutionInput` result. */ - selection: SlotSelection; - /** - * Argument editor state for every resource (at any depth) the input named - * with `args` — merge into the panel's {@link ResourceArgs}. Empty when - * nothing in the input had arguments. - */ - resourceArgs: ResourceArgs; -} - /** * Recover editor state from a stored or recorded input. * * The inverse of {@link toExecutionInput}, and lossless for what the panel * actually shows: a `value` comes back in its original `PropValue` form (a * template stays a template rather than the flattened string a materialized - * value would have left behind), a resource comes back as a chip, and a - * resource's `args` come back as {@link ResourceArgs} entries the caller - * merges into the panel's own map — restoring the shared-by-uri binding - * `specs/resource-arguments.md` §I describes, not a copy per reference. - * - * @param resourcesByUri - When given, a recovered resource's `args` are - * keyed by its *root* uri ({@link rootResourceUri}) rather than by - * whichever of its values the reference actually named — the same - * resolution {@link resourceArgsFor} does, and necessary for the same - * reason: `seededTask.taskId`'s recorded `args` belong to `seededTask`, - * not to a key nothing else will ever look up. Omit only when no resource - * in the tree could possibly be a value reference (e.g. recovering a - * resource's own already-rooted argument, as this function does for - * itself below). + * value would have left behind), and an instance, column or slot reference + * comes back as the chip it was chosen as. Anything else — an inline + * `resource` node stored before instances existed — comes back empty rather + * than failing. */ export function fromExecutionInput( input: ExecutionInput | undefined, - resourcesByUri?: ReadonlyMap, -): RecoveredInput { - if (!input) return { selection: {}, resourceArgs: {} }; +): SlotSelection { + if (!input) return {}; const resources: Record = {}; let value: PropValue | undefined; - const resourceArgs: ResourceArgs = {}; - - const captureArgs = ( - uri: string, - args: Record | undefined, - ) => { - if (!args) return; - const selections: Selections = {}; - for (const [paramName, argInput] of Object.entries(args)) { - const recovered = fromExecutionInput(argInput, resourcesByUri); - selections[paramName] = recovered.selection; - Object.assign(resourceArgs, recovered.resourceArgs); - } - const root = resourcesByUri ? rootResourceUri(uri, resourcesByUri) : uri; - // Last-writer-wins where two references to `root` disagree — harmless in - // practice (see the doc comment above) and never happens for a binding - // this panel itself produced, since §I holds one per uri. - resourceArgs[root] = selections; - }; - const walk = (node: ExecutionInput, prefix: string) => { switch (node.kind) { - case "resource": - resources[prefix] = node.uri; - captureArgs(node.uri, node.args); - break; case "value": if (prefix === SELF) value = node.value; else setAtPath(prefix, node.value); @@ -310,9 +189,13 @@ export function fromExecutionInput( break; case "dataset": case "input": - // A column or another slot comes back as the chip it was chosen as. + case "instance": + // A column, another slot or an instance comes back as the chip it + // was chosen as. resources[prefix] = pseudoUriOf(node)!; break; + default: + break; } }; @@ -337,10 +220,7 @@ export function fromExecutionInput( walk(input, SELF); return { - selection: { - ...(value ? { value } : {}), - ...(Object.keys(resources).length > 0 ? { resources } : {}), - }, - resourceArgs, + ...(value ? { value } : {}), + ...(Object.keys(resources).length > 0 ? { resources } : {}), }; } diff --git a/src/client/components/named-inputs.test.ts b/src/client/components/named-inputs.test.ts index 56319b2..db1bb03 100644 --- a/src/client/components/named-inputs.test.ts +++ b/src/client/components/named-inputs.test.ts @@ -88,30 +88,6 @@ describe("toCells", () => { expect(skipped).toEqual([{ name: "verbose", reason: "no-match" }]); }); - it("strips receipts, at any depth", () => { - const { cells } = toCells( - [ - { - def: def("taskId"), - input: { - kind: "resource", - uri: TASK_URI, - receipt: { id: "tsk_abc123" }, - args: { - owner: { kind: "resource", uri: DB_URI, receipt: "r2" }, - }, - }, - }, - ], - fields(def("taskId")), - ); - expect(cells["0"]).toEqual({ - kind: "resource", - uri: TASK_URI, - args: { owner: { kind: "resource", uri: DB_URI } }, - }); - }); - it("treats a second input for an already-filled field as the same value", () => { const { cells, matched, skipped } = toCells( [ @@ -149,7 +125,7 @@ describe("fromTrace", () => { it("types execute inputs from the recorded execute definitions", () => { const ctx: ExecutionInput = { kind: "object", - properties: { db: { kind: "resource", uri: DB_URI }, userId: text("u") }, + properties: { db: { kind: "instance", name: "db" }, userId: text("u") }, }; const inputs = fromTrace({ id: "x", @@ -218,19 +194,50 @@ describe("fromTrace", () => { }); describe("toPanel", () => { - it("skips a resource cell whose uri isn't in the prompt's scope", () => { + it("drops an instance whose resource isn't in the prompt's scope, and skips what references it", () => { const p = prompt(); - const { functionInputs, skipped } = toPanel( + const { functionInputs, resources, skipped } = toPanel( [ { def: def("ticket"), input: text("hi") }, { def: def("db", "Db", "opaque"), - input: { kind: "resource", uri: "elsewhere.playground.ts#db" }, + input: { kind: "instance", name: "far" }, }, ], p, + { + far: { uri: "elsewhere.playground.ts#db" }, + near: { uri: DB_URI, receipt: "stripped" }, + }, ); expect(functionInputs).toEqual({ ticket: text("hi") }); + expect(resources).toEqual({ near: { uri: DB_URI } }); + expect(skipped).toEqual([{ name: "db", reason: "resource-out-of-scope" }]); + }); + + it("also drops an in-scope instance whose arguments name a dropped one, and what names it", () => { + const { resources, skipped } = toPanel( + [ + { + def: def("db", "Db", "opaque"), + input: { kind: "instance", name: "grandchild" }, + }, + ], + prompt(), + { + root: { uri: "elsewhere.playground.ts#db" }, + child: { + uri: DB_URI, + args: { parent: { kind: "instance", name: "root", output: "id" } }, + }, + grandchild: { + uri: DB_URI, + args: { parent: { kind: "instance", name: "child" } }, + }, + near: { uri: DB_URI }, + }, + ); + expect(resources).toEqual({ near: { uri: DB_URI } }); expect(skipped).toEqual([{ name: "db", reason: "resource-out-of-scope" }]); }); @@ -257,17 +264,13 @@ describe("round trip", () => { kind: "value", value: { kind: "template", value: ["Order ", { expr: "id" }] }, } as ExecutionInput, - db: { - kind: "resource", - uri: TASK_URI, - args: { title: text("Buy milk") }, - } as ExecutionInput, + db: { kind: "instance", name: "task" } as ExecutionInput, }, executeInputs: { toolsContext: { kind: "object", properties: { - db: { kind: "resource", uri: DB_URI }, + db: { kind: "instance", name: "db" }, userId: text("u1"), }, } as ExecutionInput, @@ -278,12 +281,16 @@ describe("round trip", () => { id: i.toString(36), ...f, })); + const resources = { + task: { uri: TASK_URI, args: { title: text("Buy milk") } }, + db: { uri: DB_URI }, + }; const { cells, skipped } = toCells(fromPanel(p, request), schema); expect(skipped).toEqual([]); - const row = { id: "r", cells, createdAt: 0 }; - const back = toPanel(fromRow(dataset(schema), row), p); - expect(back).toEqual({ ...request, skipped: [] }); + const row = { id: "r", cells, resources, createdAt: 0 }; + const back = toPanel(fromRow(dataset(schema), row), p, row.resources); + expect(back).toEqual({ ...request, resources, skipped: [] }); }); }); diff --git a/src/client/components/named-inputs.ts b/src/client/components/named-inputs.ts index 2917be8..ee3e38f 100644 --- a/src/client/components/named-inputs.ts +++ b/src/client/components/named-inputs.ts @@ -15,6 +15,7 @@ import { fieldsForPrompt, matchKey, } from "../../shared/dataset-fields"; +import { instanceNames } from "../../shared/input-references"; import { jsonToPropValue } from "../../shared/json-prop-value"; import type { Dataset, @@ -24,6 +25,7 @@ import type { NormalizedPrompt, PromptID, PropDefinition, + RunResources, } from "../../shared/types"; /** An input together with the definition that says what it is. */ @@ -55,6 +57,8 @@ export interface SkippedInput { export interface PartialExecuteRequest { functionInputs: Record; executeInputs: Record; + /** The run's named resource instances, which slots may reference. */ + resources?: RunResources; } /** @@ -167,7 +171,9 @@ export function hasRecordedInputs(recorded: PromptID | undefined): boolean { /** * Cells for a row of a dataset with `fields`: each input lands in the field - * that matches it, receipts stripped. An input matching no field is skipped; + * that matches it. A cell references the row's resource instances by name; + * the instances themselves travel beside the cells, as the row's + * `resources` (receipts stripped — see {@link rowResources}). An input matching no field is skipped; * a second input matching an already-filled field is the same value arriving * twice (a function and an execute parameter of one name and type) and is * neither matched again nor skipped. @@ -191,41 +197,61 @@ export function toCells( continue; } if (field.id in cells) continue; - cells[field.id] = stripReceipts(input); + cells[field.id] = input; matched++; } return { cells, matched, skipped }; } -/** Every resource `uri` an input names, at any depth (arguments included). */ -function resourceUris(input: ExecutionInput): string[] { - switch (input.kind) { - case "resource": - return [ - input.uri, - ...Object.values(input.args ?? {}).flatMap(resourceUris), - ]; - case "object": - return Object.values(input.properties).flatMap(resourceUris); - default: - return []; - } +/** + * The resource instances a dataset row keeps: `resources` without receipts, + * or `undefined` when there are none. A receipt makes a run reconstruct a + * *past* instance; a row is a recipe for new runs. + */ +export function rowResources( + resources: RunResources | undefined, +): RunResources | undefined { + return resources && Object.keys(resources).length > 0 + ? stripReceipts(resources) + : undefined; } /** * Panel inputs for `prompt`: each input fills every slot it matches — a * function and an execute parameter of one name and type are one value, so - * both get it. An input naming a resource outside this prompt's - * `inputSources` is skipped rather than filled: the chip couldn't resolve, - * and would only fail at Run. + * both get it. `resources` come along, receipts stripped, except an instance + * of a resource outside this prompt's `inputSources`: it couldn't be created + * here, so it's dropped — with every instance whose arguments name it — and + * an input referencing a dropped one is skipped rather than filled; it would + * only fail at Run. */ export function toPanel( inputs: NamedInputs, prompt: NormalizedPrompt, + resources?: RunResources, ): PartialExecuteRequest & { skipped: SkippedInput[] } { const inScope = new Set( (prompt.inputSources?.resources ?? []).map(r => r.uri), ); + const kept: RunResources = {}; + const dropped = new Set(); + for (const [name, spec] of Object.entries(rowResources(resources) ?? {})) { + if (inScope.has(spec.uri)) kept[name] = spec; + else dropped.add(name); + } + // An instance whose arguments name a dropped one couldn't be created + // either: drop it too, and whatever names it in turn. + for (let changed = dropped.size > 0; changed; ) { + changed = false; + for (const [name, spec] of Object.entries(kept)) { + const args = Object.values(spec.args ?? {}); + if (args.some(arg => instanceNames(arg).some(n => dropped.has(n)))) { + delete kept[name]; + dropped.add(name); + changed = true; + } + } + } const functionInputs: Record = {}; const executeInputs: Record = {}; const skipped: SkippedInput[] = []; @@ -238,15 +264,19 @@ export function toPanel( skipped.push({ name: def.name, reason: "no-match" }); continue; } - if (resourceUris(input).some(uri => !inScope.has(uri))) { + if (instanceNames(input).some(name => dropped.has(name))) { skipped.push({ name: def.name, reason: "resource-out-of-scope" }); continue; } - const clean = stripReceipts(input); - if (fnDef) functionInputs[fnDef.name] = clean; - if (execDef) executeInputs[execDef.name] = clean; + if (fnDef) functionInputs[fnDef.name] = input; + if (execDef) executeInputs[execDef.name] = input; } - return { functionInputs, executeInputs, skipped }; + return { + functionInputs, + executeInputs, + ...(Object.keys(kept).length > 0 && { resources: kept }), + skipped, + }; } /** A schema for "New dataset" from a set of inputs: one field per distinct input. */ @@ -323,6 +353,8 @@ export type PanelFillSource = { description: string } & ( export interface PanelFill { functionInputs: Record; executeInputs: Record; + /** Resource instances the inputs reference, receipts stripped. */ + resources?: RunResources; /** Shown in the notice: "trace 3f2a1b2c…" / "Support tickets, row 4". */ from: PanelFillSource; /** What didn't fit, named in the notice. */ @@ -333,18 +365,27 @@ export interface PanelFill { let fillSeq = 0; -/** A {@link PanelFill} for `prompt` from `inputs`, with a fresh nonce. */ +/** + * A {@link PanelFill} for `prompt` from `inputs` and the resource instances + * they came with — a row's, or a trace's recorded ones — with a fresh nonce. + */ export function panelFill( inputs: NamedInputs, prompt: NormalizedPrompt, from: PanelFillSource, + resources?: RunResources, ): PanelFill { - const { functionInputs, executeInputs, skipped } = toPanel(inputs, prompt); + const { functionInputs, executeInputs, skipped, ...rest } = toPanel( + inputs, + prompt, + resources, + ); // Time-based as well as sequential, so a nonce minted after a reload never // collides with one an earlier page load already applied. return { functionInputs, executeInputs, + ...(rest.resources && { resources: rest.resources }), from, skipped, nonce: Date.now() * 1000 + (fillSeq++ % 1000), diff --git a/src/client/components/pseudo-sources.test.ts b/src/client/components/pseudo-sources.test.ts index 22844f7..9ecf5c2 100644 --- a/src/client/components/pseudo-sources.test.ts +++ b/src/client/components/pseudo-sources.test.ts @@ -7,13 +7,19 @@ import { fromExecutionInput, toExecutionInput } from "./execution-input-state"; import { checkParameterSources, columnUri, + describeInstanceSource, describePseudoSource, + INSTANCES_GROUP, inputUri, + instanceUri, NEW_COLUMN_URI, + NEW_GROUP, + parseInstanceUri, pseudoInput, + withInstanceSources, withPseudoSources, } from "./pseudo-sources"; -import { computeClaims } from "./resource-args-context"; +import { buildSourceTree } from "./source-tree"; const def = (name: string, syntax: string, base = syntax): PropDefinition => ({ name, @@ -35,8 +41,10 @@ describe("pseudo-source URIs", () => { for (const input of [ { kind: "dataset", field: "3" }, { kind: "input", half: "execute", path: "ctx.db" }, + { kind: "instance", name: "root" }, + { kind: "instance", name: "root", output: "taskId" }, ] as const) { - const { selection } = fromExecutionInput(input); + const selection = fromExecutionInput(input); expect(toExecutionInput(selection)).toEqual(input); } expect(pseudoInput(columnUri("a"))).toEqual({ @@ -47,24 +55,83 @@ describe("pseudo-source URIs", () => { expect(pseudoInput(NEW_COLUMN_URI)).toBeUndefined(); }); - it("are never claimed as a shared instance", () => { - const claims = computeClaims( - [ - { - path: "fn.a", - label: "a", - selection: { resources: { "": columnUri("0") } }, - }, - { - path: "fn.b", - label: "b", - selection: { resources: { "": columnUri("0") } }, - }, - ], - {}, - new Map(), + it("name an instance and an output of it", () => { + expect(parseInstanceUri(instanceUri("root"))).toEqual({ name: "root" }); + expect(parseInstanceUri(instanceUri("root", "taskId"))).toEqual({ + name: "root", + output: "taskId", + }); + expect(parseInstanceUri(columnUri("0"))).toBeUndefined(); + }); +}); + +describe("withInstanceSources", () => { + const catalog: ResourceInfo[] = [ + { uri: "t.ts#task", label: "Seeded task", scope: "run" }, + { + uri: "t.ts#task.id", + label: "Task ID", + scope: "run", + parent: "t.ts#task", + }, + { uri: "t.ts#other", label: "Other", scope: "run", group: ["Misc"] }, + ]; + const sources = { + resources: catalog, + functionSlots: { taskId: ["t.ts#task.id"], other: ["t.ts#other"] }, + executeSlots: {}, + }; + const instances = { root: { uri: "t.ts#task" }, child: { uri: "t.ts#task" } }; + + it("offers each instance first wherever its resource or output fits", () => { + const withInstances = withInstanceSources(sources, instances); + expect(withInstances.functionSlots.taskId).toEqual([ + instanceUri("root", "id"), + instanceUri("child", "id"), + "t.ts#task.id", + ]); + expect(withInstances.functionSlots.other).toEqual(["t.ts#other"]); + expect( + withInstances.resources.filter(r => r.group?.[0] === INSTANCES_GROUP), + ).toHaveLength(4); + }); + + it("lists instances first and the catalog under New, opened in place where no instance fits", () => { + const { resources, functionSlots } = withInstanceSources( + sources, + instances, ); - expect(claims.size).toBe(0); + const pick = (uris: string[]) => + buildSourceTree(resources, uris).map(n => n.label); + expect(pick(functionSlots.taskId!)).toEqual([INSTANCES_GROUP, NEW_GROUP]); + expect(pick(functionSlots.other!)).toEqual(["Other"]); + }); + + it("leaves sources alone for a run with no instances", () => { + expect(withInstanceSources(sources, {}).resources).toEqual(catalog); + }); + + it("describes a chip by the instance's resource, or says it's gone", () => { + const byUri = new Map(catalog.map(r => [r.uri, r])); + expect( + describeInstanceSource(instanceUri("root", "id"), instances, byUri), + ).toEqual({ label: "root.id", note: "Seeded task" }); + expect( + describeInstanceSource(instanceUri("gone"), instances, byUri), + ).toEqual({ + label: "gone", + note: "resource no longer in this run", + missing: true, + }); + // In an eval, an undeclared name is one each row declares. + expect( + describeInstanceSource(instanceUri("task"), instances, byUri, { + rowsMayDeclare: true, + }), + ).toEqual({ + label: "task", + note: "not declared here: each row must declare it", + }); }); }); diff --git a/src/client/components/pseudo-sources.ts b/src/client/components/pseudo-sources.ts index c21c580..5bece4f 100644 --- a/src/client/components/pseudo-sources.ts +++ b/src/client/components/pseudo-sources.ts @@ -2,11 +2,13 @@ // Copyright (c) 2026 Alexander Corrado /** - * Columns and prompt slots as sources: `ResourceInfo` entries with URIs the - * server never sees, grouped as **Columns** and **Prompt inputs**, so the + * Columns, prompt slots and the run's resource instances as sources: + * `ResourceInfo` entries with URIs the server never sees, grouped as + * **Columns**, **Prompt inputs** and **Resources in this run**, so the * existing picker, chips and slot matching offer them without knowing they - * aren't resources. `toExecutionInput` turns a chosen one into the `dataset` - * or `input` variant it stands for. See `specs/evals.md` §B.2 and §F. + * aren't resources. `toExecutionInput` turns a chosen one into the + * `dataset`, `input` or `instance` variant it stands for. See + * `specs/evals.md` §B.2 and §F, and `specs/resource-instances.md` §G. */ import type { DatasetField } from "../../dataset/dataset-types"; @@ -26,6 +28,7 @@ import type { const COLUMN_PREFIX = "@@column/"; const INPUT_PREFIX = "@@input/"; +const INSTANCE_PREFIX = "@@instance/"; /** The picker entry that adds a dataset column from the slot it's chosen for. */ export const NEW_COLUMN_URI = "@@new-column"; @@ -34,6 +37,10 @@ export const NEW_COLUMN_URI = "@@new-column"; export const COLUMNS_GROUP = "Dataset columns"; /** The group other prompt slots are listed under. */ export const PROMPT_INPUTS_GROUP = "Prompt inputs"; +/** The group the run's own resource instances are listed under, first. */ +export const INSTANCES_GROUP = "Resources in this run"; +/** The group the resource catalog moves under once the run has instances to list first. */ +export const NEW_GROUP = "New"; /** The pseudo-source URI for dataset field `field`. */ export function columnUri(field: string): string { @@ -45,6 +52,25 @@ export function inputUri(half: "function" | "execute", path: string): string { return `${INPUT_PREFIX}${half}/${path}`; } +/** The pseudo-source URI for the run's instance `name`, or its `output`. */ +export function instanceUri(name: string, output?: string): string { + return output === undefined + ? `${INSTANCE_PREFIX}${name}` + : `${INSTANCE_PREFIX}${name}/${output}`; +} + +/** The instance (and output) an {@link instanceUri} names, or `undefined` for any other URI. */ +export function parseInstanceUri( + uri: string, +): { name: string; output?: string } | undefined { + if (!uri.startsWith(INSTANCE_PREFIX)) return undefined; + const rest = uri.slice(INSTANCE_PREFIX.length); + const slash = rest.indexOf("/"); + return slash < 0 + ? { name: rest } + : { name: rest.slice(0, slash), output: rest.slice(slash + 1) }; +} + /** Whether `uri` names a pseudo-source rather than a resource. */ export function isPseudoUri(uri: string): boolean { return uri.startsWith("@@"); @@ -65,6 +91,8 @@ export function pseudoInput(uri: string): ExecutionInput | undefined { if (half !== "function" && half !== "execute") return undefined; return { kind: "input", half, path: rest.slice(slash + 1) }; } + const instance = parseInstanceUri(uri); + if (instance) return { kind: "instance", ...instance }; return undefined; } @@ -72,6 +100,7 @@ export function pseudoInput(uri: string): ExecutionInput | undefined { export function pseudoUriOf(input: ExecutionInput): string | undefined { if (input.kind === "dataset") return columnUri(input.field); if (input.kind === "input") return inputUri(input.half, input.path); + if (input.kind === "instance") return instanceUri(input.name, input.output); return undefined; } @@ -351,3 +380,150 @@ export function describePseudoSource( ? { note, warning: `${type.syntax} into ${slotType.syntax}` } : { note }; } + +/** A run's instances, as the picker needs them: by name, in listing order. */ +type InstanceUris = Readonly>; + +/** + * `resources` with the run's instances (and their outputs) listed first, as + * **Resources in this run**, and the catalog moved under **New** once there + * is any instance to list ahead of it. See {@link withInstanceSources}. + */ +export function withInstanceResources( + resources: readonly ResourceInfo[], + instances: InstanceUris, +): ResourceInfo[] { + const named = Object.entries(instances); + if (named.length === 0) return [...resources]; + const byUri = new Map(resources.map(r => [r.uri, r])); + const outputsOf = new Map(); + for (const r of resources) { + if (r.parent) { + outputsOf.set(r.parent, [...(outputsOf.get(r.parent) ?? []), r]); + } + } + const instanceEntries: ResourceInfo[] = named.flatMap(([name, { uri }]) => { + const root = byUri.get(uri); + const scope = root?.scope ?? "run"; + return [ + { + uri: instanceUri(name), + label: name, + scope, + group: [INSTANCES_GROUP], + // A static resource's instance is its value: the slot previews it. + ...(root?.value !== undefined && { value: root.value }), + }, + ...(outputsOf.get(uri) ?? []).map( + (output): ResourceInfo => ({ + uri: instanceUri(name, output.uri.slice(uri.length + 1)), + label: output.label, + scope, + group: [INSTANCES_GROUP], + parent: instanceUri(name), + ...(output.value !== undefined && { value: output.value }), + }), + ), + ]; + }); + return [ + ...instanceEntries, + ...resources.map(r => + isPseudoUri(r.uri) ? r : { ...r, group: [NEW_GROUP, ...(r.group ?? [])] }, + ), + ]; +} + +/** + * `slots` (slot path → offered URIs) with each run instance offered ahead of + * the catalog entries it stands for: an instance of `seededTask` wherever + * `seededTask` is offered, and its `taskId` wherever `seededTask.taskId` is. + * + * @param resources - The catalog, to tell an output's resource. + */ +export function withInstanceSlots( + slots: Readonly> | undefined, + resources: readonly ResourceInfo[], + instances: InstanceUris, +): Record { + const named = Object.entries(instances); + if (named.length === 0) return { ...slots }; + const parentOf = new Map( + resources.flatMap(r => (r.parent ? [[r.uri, r.parent] as const] : [])), + ); + return Object.fromEntries( + Object.entries(slots ?? {}).map(([path, offered]) => { + const fitting: string[] = []; + for (const [name, { uri }] of named) { + for (const candidate of offered) { + if (candidate === uri) fitting.push(instanceUri(name)); + else if (parentOf.get(candidate) === uri) { + fitting.push(instanceUri(name, candidate.slice(uri.length + 1))); + } + } + } + return [path, [...fitting, ...offered]]; + }), + ); +} + +/** + * `sources`, with the run's own instances offered first wherever their + * resource (or one of its outputs) fits — the picker's **Resources in this + * run** group — and the catalog moved under **New** once there is any + * instance to list ahead of it. Picking from **New** adds an instance (see + * `adoptCatalogPick`); picking from the instances group only references + * one. No new matching is needed: an instance of `seededTask` fits wherever + * `seededTask` does, and its `taskId` wherever `seededTask.taskId` does. + * + * @param instances - The run's instances, by name, in the order to list them. + */ +export function withInstanceSources( + sources: PromptInputSources | undefined, + instances: InstanceUris, +): PromptInputSources { + const resources = sources?.resources ?? []; + const extend = (slots: Record | undefined) => + withInstanceSlots(slots, resources, instances); + return { + resources: withInstanceResources(resources, instances), + functionSlots: extend(sources?.functionSlots), + executeSlots: extend(sources?.executeSlots), + ...(sources?.resourceSlots && { + resourceSlots: Object.fromEntries( + Object.entries(sources.resourceSlots).map(([uri, slots]) => [ + uri, + extend(slots), + ]), + ), + }), + }; +} + +/** + * What a chip for instance reference `uri` says: what it is, or that the run + * no longer has it. + * + * @param options.rowsMayDeclare - In an eval, a name the eval doesn't + * declare may be one each dataset row declares (`specs/resource-instances.md` + * §E), so it reads as that requirement rather than as missing. + */ +export function describeInstanceSource( + uri: string, + instances: InstanceUris, + resourcesByUri: ReadonlyMap, + options: { rowsMayDeclare?: boolean } = {}, +): { label: string; note: string; missing?: boolean } | undefined { + const ref = parseInstanceUri(uri); + if (!ref) return undefined; + const instance = instances[ref.name]; + const label = + ref.output === undefined ? ref.name : `${ref.name}.${ref.output}`; + if (!instance) { + return options.rowsMayDeclare + ? { label, note: "not declared here: each row must declare it" } + : { label, note: "resource no longer in this run", missing: true }; + } + const resource = resourcesByUri.get(instance.uri); + return { label, note: resource?.label ?? instance.uri }; +} diff --git a/src/client/components/resource-args-context.ts b/src/client/components/resource-args-context.ts deleted file mode 100644 index e39a2d6..0000000 --- a/src/client/components/resource-args-context.ts +++ /dev/null @@ -1,193 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -// Copyright (c) 2026 Alexander Corrado - -import type { PropType, ResourceInfo } from "../../shared/types"; -import { - type ResourceArgs, - rootResourceUri, - type Selections, - type SlotSelection, -} from "./execution-input-state"; -import { isPseudoUri } from "./pseudo-sources"; - -/** - * Nesting cap on a resource's own argument form — a resource whose argument - * is a resource whose argument is a resource. `ResourceRegistry`'s cycle - * check (`specs/resource-arguments.md` §D) is the backstop for anything past - * this; this is purely about the panel not growing an unbounded column of - * nested forms. - */ -export const MAX_RESOURCE_ARG_DEPTH = 3; - -/** - * Who owns a resource's first selection: `path` is its identity (compared - * for equality — see {@link ResourceArgsContext.path}), `label` is what a - * later chip's "same instance as X above" note displays for it. - * - * The two are deliberately different things. Two rows can easily share a - * *label* — a prompt's own `taskId` parameter and some other resource's own - * `taskId` argument are both just named `"taskId"` — so identity has to come - * from each row's full position in the tree, not from what it's called. - */ -export interface ClaimOwner { - /** Unique path identifying the owning row — see {@link ResourceArgsContext.path}. */ - path: string; - /** Display name for the "same instance as X above" note. */ - label: string; -} - -/** - * Everything `SourceRow` needs to render a chosen resource's argument form — - * bundled into one object so it threads through `ExecutionInputEditor` and - * `CombinedInputEditor` as a single prop rather than five. See - * `specs/resource-arguments.md` §I, §J. - */ -export interface ResourceArgsContext { - /** The panel's whole argument-editor state, keyed by resource `uri`. */ - resourceArgs: ResourceArgs; - /** Replaces one resource's argument selections. */ - onResourceArgsChange: (uri: string, next: Selections) => void; - /** - * Resource `uri` → (argument slot path → URIs of sources that can fill - * it) — `PromptInputSources.resourceSlots`, unpacked. - */ - resourceSlots: Record>; - /** - * Resource `uri` → the row that owns its *first* selection — every other - * selection of that `uri` is the same instance, not a fresh one, and - * `SourceRow` says so on the chip instead of repeating an argument form - * (§I's "one binding per resource" rule) it wouldn't own anyway. Computed - * once per render by {@link computeClaims}, from the same selections that - * will render, rather than mutated as rows render: `SourceRow` only ever - * reads this, which is what keeps it safe under React StrictMode's - * double-invoked renders. - */ - claimed: ReadonlyMap; - /** - * This row's own unique position in the whole tree — `"fn.taskId"` for a - * top-level function parameter, `"fn.toolsContext.list_tasks.db"` for a - * nested field within one, `"fn.taskId.args.title"` for an argument of - * whatever `fn.taskId` resolved to. Compared against - * {@link ClaimOwner.path} to decide "is this row the owner?" — never - * against a bare name, which is what let two same-named rows (a prompt's - * own `taskId` and some resource's own `taskId` argument) collide. - */ - path: string; - /** How many resource-argument forms deep this row already is. */ - depth: number; - /** - * What a chip for a column or prompt-slot source says, and whether its - * type doesn't fit the slot's — see `pseudo-sources.ts`. Absent where no - * such source is offered. - */ - describePseudo?: ( - uri: string, - slotType: PropType, - ) => { note: string; warning?: string } | undefined; -} - -/** - * `context`, moved to the child at `pathSuffix` (appended with `.`) and one - * level deeper — for a nested field within the current slot, or an argument - * of the resource the current slot resolved to. - */ -export function nested( - context: ResourceArgsContext, - pathSuffix: string, -): ResourceArgsContext { - return { - ...context, - path: `${context.path}.${pathSuffix}`, - depth: context.depth + 1, - }; -} - -/** - * `context`, moved to a nested field *within the current slot* — a `db` - * field buried inside `toolsContext`, say. Only the path grows; `depth` - * doesn't, because this isn't argument nesting at all, just a different - * field of the same top-level selection. (Crossing into a *resource's own* - * arguments is what {@link nested} is for, and that's the one the depth cap - * applies to.) - */ -export function nestedField( - context: ResourceArgsContext, - pathSuffix: string, -): ResourceArgsContext { - return { ...context, path: `${context.path}.${pathSuffix}` }; -} - -/** - * Precomputes, for every resource selected anywhere in the panel, which row - * "owns" its first selection — the first one reached walking `entries` in - * order, and within a slot's own nested resource choices, in whatever order - * they appear there. Recurses into an owning resource's own argument - * selections (up to {@link MAX_RESOURCE_ARG_DEPTH}), so a resource chosen as - * *another* resource's argument gets claimed too — whether or not either one - * takes arguments itself: a resource with none still gets the same instance - * both times it's picked, and `SourceRow`'s chip says so. - * - * Pure — no rendering, no mutation of anything the caller didn't just - * allocate — so it can be recomputed on every render without StrictMode's - * double-invocation ever observing a half-claimed map. - * - * @param entries - Top-level slots in panel order, each with the unique - * `path` and display `label` `SourceRow` would render it with (a function - * or execute parameter's own path, e.g. `"fn.taskId"`). - */ -export function computeClaims( - entries: readonly { - path: string; - label: string; - selection: SlotSelection | undefined; - }[], - resourceArgs: ResourceArgs, - resourcesByUri: ReadonlyMap, -): ReadonlyMap { - const claimed = new Map(); - - const visit = ( - path: string, - label: string, - selection: SlotSelection | undefined, - depth: number, - ) => { - if (!selection?.resources || depth > MAX_RESOURCE_ARG_DEPTH) return; - for (const [relPath, uri] of Object.entries(selection.resources)) { - // A column or another slot isn't an instance anyone shares. - if (isPseudoUri(uri)) continue; - const root = rootResourceUri(uri, resourcesByUri); - if (claimed.has(root)) continue; - - // A nested field's own row is identified by its full path, and - // labelled by its own (leaf) name — the same as the `propDef.name` - // `SourceRow` renders it with, matching `ExecutionInputEditor`'s - // nested-slot plugin. - const ownPath = relPath === "" ? path : `${path}.${relPath}`; - const segments = relPath.split("."); - const ownLabel = relPath === "" ? label : segments[segments.length - 1]; - claimed.set(root, { path: ownPath, label: ownLabel }); - - // Only a resource that actually takes arguments has selections of its - // own worth descending into — one that doesn't is still claimed above, - // just with nothing beneath it to recurse into. - const info = resourcesByUri.get(root); - if (!info?.parameters || info.parameters.length === 0) continue; - const argSelections = resourceArgs[root] ?? {}; - for (const param of info.parameters) { - visit( - `${ownPath}.args.${param.name}`, - param.name, - argSelections[param.name], - depth + 1, - ); - } - } - }; - - for (const { path, label, selection } of entries) { - visit(path, label, selection, 0); - } - - return claimed; -} diff --git a/src/client/components/run-resources-state.test.ts b/src/client/components/run-resources-state.test.ts new file mode 100644 index 0000000..a3dbcce --- /dev/null +++ b/src/client/components/run-resources-state.test.ts @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 Alexander Corrado + +import { describe, expect, it } from "vitest"; +import type { ResourceInfo } from "../../shared/types"; +import { SELF, type Selections } from "./execution-input-state"; +import { instanceUri } from "./pseudo-sources"; +import { + adoptCatalogPick, + defaultInstanceName, + derivedDependencies, + duplicateInstance, + excludedFromArgs, + fromWireResources, + type InstanceSelections, + instanceNameProblem, + referencesTo, + removeInstance, + renameInstance, + retargetSelections, + toWireResources, +} from "./run-resources-state"; + +const catalog: ResourceInfo[] = [ + { uri: "pg.ts#db", label: "DB", scope: "server" }, + { + uri: "pg.ts#task", + label: "Task", + scope: "run", + dependencies: { db: "pg.ts#db" }, + }, + { + uri: "pg.ts#task.id", + label: "Task ID", + scope: "run", + parent: "pg.ts#task", + }, +]; +const byUri = new Map(catalog.map(r => [r.uri, r])); + +const ref = (name: string, output?: string) => ({ + resources: { [SELF]: instanceUri(name, output) }, +}); + +/** A root task, a child taking its id, and a slot bound to the child. */ +function hierarchy(): InstanceSelections { + return { + root: { + uri: "pg.ts#task", + args: { title: { value: { kind: "primitive", value: "Root" } } }, + }, + child: { uri: "pg.ts#task", args: { parentId: ref("root", "id") } }, + }; +} + +describe("wire round trip", () => { + it("folds arguments into inputs and back, dropping receipts and empty arguments", () => { + const instances: InstanceSelections = { + ...hierarchy(), + bare: { uri: "pg.ts#db", args: { unused: {} } }, + }; + const wire = toWireResources(instances)!; + expect(wire).toEqual({ + root: { + uri: "pg.ts#task", + args: { + title: { kind: "value", value: { kind: "primitive", value: "Root" } }, + }, + }, + child: { + uri: "pg.ts#task", + args: { parentId: { kind: "instance", name: "root", output: "id" } }, + }, + bare: { uri: "pg.ts#db" }, + }); + expect( + fromWireResources({ ...wire, root: { ...wire.root!, receipt: "r" } }), + ).toEqual({ ...hierarchy(), bare: { uri: "pg.ts#db", args: {} } }); + expect(toWireResources({})).toBeUndefined(); + }); +}); + +describe("names", () => { + it("defaults to the export name, numbered when taken", () => { + expect(defaultInstanceName("pg.ts#task", [])).toBe("task"); + expect(defaultInstanceName("pg.ts#task", ["task", "task2"])).toBe("task3"); + expect(defaultInstanceName("pg.ts#1st", [])).toBe("r_1st"); + }); + + it("says why a name can't be used", () => { + expect(instanceNameProblem("root", hierarchy())).toMatch(/already/); + expect(instanceNameProblem("root", hierarchy(), "root")).toBeUndefined(); + expect(instanceNameProblem("-x", {})).toMatch(/letters, digits/); + }); +}); + +describe("adoptCatalogPick", () => { + it("adds an instance for a pick, and references the output picked", () => { + const picked = adoptCatalogPick(hierarchy(), "pg.ts#task.id", byUri); + expect(picked.uri).toBe(instanceUri("task", "id")); + expect(picked.instances.task).toEqual({ uri: "pg.ts#task", args: {} }); + }); + + it("reuses a server-scoped resource's one instance", () => { + const once = adoptCatalogPick({}, "pg.ts#db", byUri); + const twice = adoptCatalogPick(once.instances, "pg.ts#db", byUri); + expect(twice.instances).toBe(once.instances); + expect(twice.uri).toBe(instanceUri("db")); + }); +}); + +describe("rename, remove, duplicate", () => { + it("renames in place, following references in arguments and slots, and nothing else", () => { + const renamed = renameInstance(hierarchy(), "root", "parent"); + expect(Object.keys(renamed)).toEqual(["parent", "child"]); + expect(renamed.child.args.parentId).toEqual(ref("parent", "id")); + + const slots: Selections = { + taskId: ref("root", "id"), + other: ref("child"), + typed: { value: { kind: "primitive", value: "x" } }, + }; + const retargeted = retargetSelections(slots, "root", "parent"); + expect(retargeted.taskId).toEqual(ref("parent", "id")); + expect(retargeted.other).toBe(slots.other); + expect(retargeted.typed).toBe(slots.typed); + expect(retargetSelections(slots, "nope", "x")).toBe(slots); + }); + + it("refuses a rename to a taken name", () => { + expect(() => renameInstance(hierarchy(), "child", "root")).toThrow( + /already/, + ); + }); + + it("clears every reference to a removed instance", () => { + const removed = removeInstance(hierarchy(), "root"); + expect(Object.keys(removed)).toEqual(["child"]); + expect(removed.child.args.parentId).toEqual({}); + expect( + retargetSelections({ taskId: ref("root", "id") }, "root", null), + ).toEqual({ taskId: {} }); + }); + + it("duplicates next to the original, with its arguments copied", () => { + const { instances, name } = duplicateInstance(hierarchy(), "child"); + expect(name).toBe("child2"); + expect(Object.keys(instances)).toEqual(["root", "child", "child2"]); + expect(instances.child2).toEqual(instances.child); + expect(instances.child2.args).not.toBe(instances.child.args); + }); +}); + +describe("references and cycles", () => { + it("lists the slots and arguments that reference an instance", () => { + expect( + referencesTo("root", hierarchy(), [ + { + selections: { + taskId: ref("root", "id"), + toolsContext: { + resources: { "list_tasks.rootTaskId": instanceUri("root", "id") }, + }, + }, + }, + ]), + ).toEqual([ + "taskId", + "toolsContext.list_tasks.rootTaskId", + "child.parentId", + ]); + expect(referencesTo("child", hierarchy(), [])).toEqual([]); + }); + + it("excludes the instance itself and everything that reaches it from its arguments", () => { + const instances: InstanceSelections = { + ...hierarchy(), + grandchild: { uri: "pg.ts#task", args: { parentId: ref("child", "id") } }, + }; + expect([...excludedFromArgs(instances, "root")].sort()).toEqual([ + "child", + "grandchild", + "root", + ]); + expect([...excludedFromArgs(instances, "grandchild")]).toEqual([ + "grandchild", + ]); + }); +}); + +describe("derivedDependencies", () => { + it("lists a dependency the run doesn't declare, with what uses it", () => { + expect(derivedDependencies(hierarchy(), byUri)).toEqual([ + { uri: "pg.ts#db", usedBy: ["root", "child"] }, + ]); + }); + + it("leaves out a dependency declared once, and flags one declared twice", () => { + expect( + derivedDependencies( + { ...hierarchy(), main: { uri: "pg.ts#db", args: {} } }, + byUri, + ), + ).toEqual([]); + expect( + derivedDependencies( + { + ...hierarchy(), + a: { uri: "pg.ts#db", args: {} }, + b: { uri: "pg.ts#db", args: {} }, + }, + byUri, + ), + ).toEqual([ + { uri: "pg.ts#db", usedBy: ["root", "child"], ambiguous: ["a", "b"] }, + ]); + }); +}); diff --git a/src/client/components/run-resources-state.ts b/src/client/components/run-resources-state.ts new file mode 100644 index 0000000..4aee276 --- /dev/null +++ b/src/client/components/run-resources-state.ts @@ -0,0 +1,388 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 Alexander Corrado + +/** + * A run's named resource instances, as the Resources section edits them — + * the client counterpart of `RunResources`, shared by the execute panel, the + * eval Inputs panel and dataset row details. Pure; no React. See + * `specs/resource-instances.md` §G, §H. + */ + +import { isValidInstanceName } from "../../shared/instance-names"; +import type { ResourceInfo, RunResources } from "../../shared/types"; +import { + fromExecutionInput, + type Selections, + type SlotSelection, + toExecutionInput, +} from "./execution-input-state"; +import { instanceUri, parseInstanceUri } from "./pseudo-sources"; + +/** One instance being edited: the resource it creates, and its argument editors by parameter name. */ +export interface InstanceState { + /** The resource's root `uri`. */ + uri: string; + /** Argument editor state, by parameter name. */ + args: Selections; +} + +/** + * The run's instances, by name, in the order the section lists them — + * insertion order, which a plain object keeps for names like these (none is + * an integer-like key, since a name can't start with a digit). + */ +export type InstanceSelections = Record; + +/** The wire form of `instances`, or `undefined` when there are none. */ +export function toWireResources( + instances: InstanceSelections, +): RunResources | undefined { + const entries = Object.entries(instances); + if (entries.length === 0) return undefined; + return Object.fromEntries( + entries.map(([name, { uri, args }]) => { + const wireArgs = Object.fromEntries( + Object.entries(args).flatMap(([param, selection]) => { + const input = toExecutionInput(selection); + return input ? [[param, input] as const] : []; + }), + ); + return [ + name, + Object.keys(wireArgs).length > 0 ? { uri, args: wireArgs } : { uri }, + ]; + }), + ); +} + +/** Editor state for a stored or recorded `resources`, receipts dropped. */ +export function fromWireResources( + resources: RunResources | undefined, +): InstanceSelections { + if (!resources || typeof resources !== "object") return {}; + const out: InstanceSelections = {}; + for (const [name, spec] of Object.entries(resources)) { + if (!spec || typeof spec.uri !== "string") continue; + out[name] = { + uri: spec.uri, + args: Object.fromEntries( + Object.entries(spec.args ?? {}).map(([param, input]) => [ + param, + fromExecutionInput(input), + ]), + ), + }; + } + return out; +} + +/** + * A free name for a new instance of `uri`: its export name (`seededTask`), + * or that with a number (`seededTask2`) when it's taken. + */ +export function defaultInstanceName( + uri: string, + taken: Iterable, +): string { + const used = new Set(taken); + const exported = uri.slice(uri.lastIndexOf("#") + 1).split(".")[0] ?? ""; + let base = exported.replace(/[^A-Za-z0-9_-]/g, "_"); + if (!isValidInstanceName(base)) base = `r_${base}`; + if (!used.has(base)) return base; + for (let n = 2; ; n++) { + if (!used.has(`${base}${n}`)) return `${base}${n}`; + } +} + +/** `instances` with a new, argument-less instance of `uri` added at the end. */ +export function addInstance( + instances: InstanceSelections, + uri: string, +): { instances: InstanceSelections; name: string } { + const name = defaultInstanceName(uri, Object.keys(instances)); + return { instances: { ...instances, [name]: { uri, args: {} } }, name }; +} + +/** + * The instance reference a catalog pick stands for: a server-scoped resource + * already in the run is reused — it has one instance however it's named — + * and anything else becomes a new instance, named by default. An output pick + * (`seededTask.taskId`) references that output of the new instance. + * + * @returns The instances to keep, and the pseudo-source URI to bind. + */ +export function adoptCatalogPick( + instances: InstanceSelections, + pickedUri: string, + resourcesByUri: ReadonlyMap, +): { instances: InstanceSelections; uri: string } { + const picked = resourcesByUri.get(pickedUri); + const rootUri = picked?.parent ?? pickedUri; + const output = picked?.parent + ? pickedUri.slice(picked.parent.length + 1) + : undefined; + if (resourcesByUri.get(rootUri)?.scope === "server") { + const existing = Object.entries(instances).find( + ([, i]) => i.uri === rootUri, + ); + if (existing) return { instances, uri: instanceUri(existing[0], output) }; + } + const added = addInstance(instances, rootUri); + return { instances: added.instances, uri: instanceUri(added.name, output) }; +} + +/** `selection`, with every reference to instance `from` pointed at `to` instead — or cleared, for `to: null`. */ +function retargetSelection( + selection: SlotSelection, + from: string, + to: string | null, +): SlotSelection { + if (!selection.resources) return selection; + let changed = false; + const resources: Record = {}; + for (const [path, uri] of Object.entries(selection.resources)) { + const ref = parseInstanceUri(uri); + if (ref?.name !== from) { + resources[path] = uri; + continue; + } + changed = true; + if (to !== null) resources[path] = instanceUri(to, ref.output); + } + if (!changed) return selection; + return Object.keys(resources).length > 0 + ? { ...selection, resources } + : (({ resources: _, ...rest }) => rest)(selection); +} + +/** `selections`, with every reference to instance `from` pointed at `to`, or cleared for `to: null`. Unchanged entries keep their identity. */ +export function retargetSelections( + selections: Selections, + from: string, + to: string | null, +): Selections { + let changed = false; + const out: Selections = {}; + for (const [key, selection] of Object.entries(selections)) { + const next = retargetSelection(selection, from, to); + if (next !== selection) changed = true; + out[key] = next; + } + return changed ? out : selections; +} + +/** + * `instances` with `from` renamed to `to` in place (keeping its position), + * and every argument referencing it pointed at the new name. The host + * applies {@link retargetSelections} to its own slots the same way. + * + * @throws When `to` is invalid or already taken. + */ +export function renameInstance( + instances: InstanceSelections, + from: string, + to: string, +): InstanceSelections { + if (from === to) return instances; + const problem = instanceNameProblem(to, instances, from); + if (problem) throw new Error(problem); + return Object.fromEntries( + Object.entries(instances).map(([name, state]) => [ + name === from ? to : name, + { ...state, args: retargetSelections(state.args, from, to) }, + ]), + ); +} + +/** Why `name` can't be given to an instance (`self` being the one renamed), or `undefined` when it can. */ +export function instanceNameProblem( + name: string, + instances: InstanceSelections, + self?: string, +): string | undefined { + if (!isValidInstanceName(name)) { + return "Use letters, digits, '_' and '-', not starting with a digit or '-'."; + } + if (name !== self && Object.hasOwn(instances, name)) { + return `There's already a resource named '${name}'.`; + } + return undefined; +} + +/** `instances` without `name`, and with every argument that referenced it cleared. */ +export function removeInstance( + instances: InstanceSelections, + name: string, +): InstanceSelections { + return Object.fromEntries( + Object.entries(instances) + .filter(([n]) => n !== name) + .map(([n, state]) => [ + n, + { ...state, args: retargetSelections(state.args, name, null) }, + ]), + ); +} + +/** `instances` with a copy of `name` — same resource, same arguments — right after it. */ +export function duplicateInstance( + instances: InstanceSelections, + name: string, +): { instances: InstanceSelections; name: string } { + const source = instances[name]; + if (!source) return { instances, name }; + const copy = defaultInstanceName( + name.replace(/\d+$/, "") || name, + Object.keys(instances), + ); + const out: InstanceSelections = {}; + for (const [n, state] of Object.entries(instances)) { + out[n] = state; + if (n === name) { + out[copy] = { uri: source.uri, args: structuredClone(source.args) }; + } + } + return { instances: out, name: copy }; +} + +/** Every place in `selection` that references instance `name`, as dotted paths below `prefix`. */ +function referencesIn( + selection: SlotSelection | undefined, + name: string, + prefix: string, +): string[] { + return Object.entries(selection?.resources ?? {}).flatMap(([path, uri]) => + parseInstanceUri(uri)?.name === name + ? [path === "" ? prefix : `${prefix}.${path}`] + : [], + ); +} + +/** + * What references instance `name` — each slot (`taskId`, + * `toolsContext.list_tasks.db`) and each other instance's argument + * (`child1.parentId`) — for the card's "→ taskId" line. + * + * @param slots - The host's slot selections, each labelled by how a + * reference into it should read (`""` for slots named bare). + */ +export function referencesTo( + name: string, + instances: InstanceSelections, + slots: readonly { prefix?: string; selections: Selections }[], +): string[] { + const refs: string[] = []; + for (const { prefix, selections } of slots) { + for (const [slot, selection] of Object.entries(selections)) { + refs.push( + ...referencesIn(selection, name, prefix ? `${prefix}.${slot}` : slot), + ); + } + } + for (const [other, state] of Object.entries(instances)) { + if (other === name) continue; + for (const [param, selection] of Object.entries(state.args)) { + refs.push(...referencesIn(selection, name, `${other}.${param}`)); + } + } + return refs; +} + +/** The instance names `state`'s arguments reference directly. */ +function argumentTargets(state: InstanceState): Set { + const names = new Set(); + for (const selection of Object.values(state.args)) { + for (const uri of Object.values(selection.resources ?? {})) { + const ref = parseInstanceUri(uri); + if (ref) names.add(ref.name); + } + } + return names; +} + +/** + * The instances `name`'s arguments may not reference, because creating them + * means creating `name` first: itself, and every instance whose arguments + * reach it. Offering those would only close a cycle. + */ +export function excludedFromArgs( + instances: InstanceSelections, + name: string, +): Set { + const excluded = new Set([name]); + let grew = true; + while (grew) { + grew = false; + for (const [other, state] of Object.entries(instances)) { + if (excluded.has(other)) continue; + if ([...argumentTargets(state)].some(t => excluded.has(t))) { + excluded.add(other); + grew = true; + } + } + } + return excluded; +} + +/** A dependency the run doesn't declare an instance of, shown below the instances. */ +export interface DerivedDependency { + /** The dependency's resource uri. */ + uri: string; + /** The instances (or other dependencies, by uri) whose resource needs it. */ + usedBy: string[]; + /** + * The instances the run declares of it when there are several — a + * dependency can't tell which to use, so the run would fail. Absent when + * there are none. + */ + ambiguous?: string[]; +} + +/** + * The code-wired dependencies of the run's instances that the run doesn't + * declare itself (`specs/resource-instances.md` §D): each is created with no + * arguments, once, and shared by everything that needs it. Followed + * transitively. A dependency the run declares exactly one instance of + * resolves to that instance and isn't listed; one declared more than once is + * listed with {@link DerivedDependency.ambiguous}. + */ +export function derivedDependencies( + instances: InstanceSelections, + resourcesByUri: ReadonlyMap, +): DerivedDependency[] { + const declaredBy = new Map(); + for (const [name, state] of Object.entries(instances)) { + declaredBy.set(state.uri, [...(declaredBy.get(state.uri) ?? []), name]); + } + + const found = new Map(); + const visit = (uri: string, user: string, seen: Set) => { + for (const dep of Object.values( + resourcesByUri.get(uri)?.dependencies ?? {}, + )) { + const declared = declaredBy.get(dep) ?? []; + if (declared.length === 1) continue; + let entry = found.get(dep); + if (!entry) { + entry = { + uri: dep, + usedBy: [], + ...(declared.length > 1 && { ambiguous: declared }), + }; + found.set(dep, entry); + } + if (!entry.usedBy.includes(user)) entry.usedBy.push(user); + if (declared.length === 0 && !seen.has(dep)) { + visit( + dep, + resourcesByUri.get(dep)?.label ?? dep, + new Set([...seen, dep]), + ); + } + } + }; + for (const [name, state] of Object.entries(instances)) { + visit(state.uri, name, new Set([state.uri])); + } + return [...found.values()]; +} diff --git a/src/client/components/source-context.ts b/src/client/components/source-context.ts new file mode 100644 index 0000000..d13d395 --- /dev/null +++ b/src/client/components/source-context.ts @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 Alexander Corrado + +import type { PropType } from "../../shared/types"; + +/** What a chip for a pseudo-source says — see {@link SourceContext.describePseudo}. */ +export interface PseudoDescription { + /** The chip's own label, when it should differ from the picker entry's. */ + label?: string; + /** The note beneath the label. */ + note: string; + /** Set when the source's type doesn't fit the slot's: `"string into number"`. */ + warning?: string; + /** Set when the source no longer exists — a removed instance, say. */ + missing?: boolean; + /** Brings what the chip names into view: an instance's card. */ + onOpen?: () => void; +} + +/** + * What a slot's editor needs from its host beyond its own selection: how to + * describe a chip for a column, another slot or a resource instance, and how + * to turn a pick from the resource catalog into an instance of the run. + * Threaded unchanged through `ExecutionInputEditor` and `CombinedInputEditor` + * as one prop. See `specs/resource-instances.md` §G, §H. + */ +export interface SourceContext { + /** + * What a chip for pseudo-source `uri` says in a slot of `slotType`. + * Absent where no pseudo-source is offered. + */ + describePseudo?: ( + uri: string, + slotType: PropType, + ) => PseudoDescription | undefined; + /** + * Turns a pick from the resource catalog into a reference to one of the + * run's instances — adding one, usually — and returns that reference's + * pseudo-source URI, which is what the slot then holds. Absent where the + * host offers no catalog. + */ + adopt?: (uri: string) => string; +} diff --git a/src/client/components/source-tree.ts b/src/client/components/source-tree.ts index 21acc1c..30b3617 100644 --- a/src/client/components/source-tree.ts +++ b/src/client/components/source-tree.ts @@ -2,6 +2,7 @@ // Copyright (c) 2026 Alexander Corrado import type { ResourceInfo } from "../../shared/types"; +import { NEW_GROUP } from "./pseudo-sources"; /** * One entry in the nested source menu {@link SourcePicker} renders — a @@ -99,7 +100,15 @@ export function buildSourceTree( if (node) items.push({ group: root.group ?? [], node }); } - return groupTree(items); + const tree = groupTree(items); + // The catalog sits under **New** only to make room for the run's own + // instances above it; for a slot none of them fits, it's all there is, so + // it's opened in place rather than behind one more click. + const [only] = tree; + if (tree.length === 1 && only.kind === "group" && only.label === NEW_GROUP) { + return only.children ?? []; + } + return tree; } /** `["Tasks", "Regressions"]` + `"Task A"` → `"Tasks / Regressions / Task A"`. */ @@ -236,7 +245,9 @@ function finalize( } const children = finalize(entry.children); if (children.length === 0) continue; // every member pruned away - if (children.length === 1) { + // **New** stays a group however little is in it: a pick from it adds an + // instance, which must not read like the run's own instances beside it. + if (children.length === 1 && entry.label !== NEW_GROUP) { resolved.push(children[0]); // nothing left to disambiguate } else { resolved.push({ diff --git a/src/client/components/use-run-instances.ts b/src/client/components/use-run-instances.ts new file mode 100644 index 0000000..d75dbed --- /dev/null +++ b/src/client/components/use-run-instances.ts @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 Alexander Corrado + +import { useCallback, useRef, useState } from "react"; +import type { ResourceInfo } from "../../shared/types"; +import { + adoptCatalogPick, + type InstanceSelections, +} from "./run-resources-state"; + +/** What {@link useRunInstances} returns. */ +export interface RunInstances { + /** The run's instances, as of this render. */ + instances: InstanceSelections; + /** Replaces the instances, or derives them from the latest. */ + setInstances: ( + next: + | InstanceSelections + | ((latest: InstanceSelections) => InstanceSelections), + ) => void; + /** + * `SourceContext.adopt`: adds an instance for a catalog pick (or reuses a + * server-scoped one) and returns the reference to bind. + */ + adopt: (uri: string) => string; +} + +/** + * The run's instances as host state. Updates apply to the latest value + * synchronously rather than to the one a render saw, so a catalog pick — + * which adds an instance and binds a slot (or another instance's argument) + * in one gesture — never has one half overwrite the other. + * + * @param resourcesByUri - The catalog, for {@link adoptCatalogPick}. + * @param onSet - Called with every new value, for the host to persist. + */ +export function useRunInstances( + initial: InstanceSelections | (() => InstanceSelections), + resourcesByUri: ReadonlyMap, + onSet?: (next: InstanceSelections) => void, +): RunInstances { + const [instances, setState] = useState(initial); + const latest = useRef(instances); + const byUri = useRef(resourcesByUri); + byUri.current = resourcesByUri; + const onSetRef = useRef(onSet); + onSetRef.current = onSet; + + const setInstances = useCallback(next => { + const value = typeof next === "function" ? next(latest.current) : next; + if (value === latest.current) return; + latest.current = value; + setState(value); + onSetRef.current?.(value); + }, []); + + const adopt = useCallback( + (uri: string) => { + const picked = adoptCatalogPick(latest.current, uri, byUri.current); + setInstances(picked.instances); + return picked.uri; + }, + [setInstances], + ); + + return { instances, setInstances, adopt }; +} diff --git a/src/client/styles.css b/src/client/styles.css index c03cbbe..9c1e293 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -1860,8 +1860,8 @@ pre { color: var(--panel-text-secondary); } -/* The "same instance as ▲ X" link inside a chip note — reset to read as - text, not a button, until hovered/focused. +/* An instance chip's name (`◆ root.taskId`), which opens the instance's + card — reset to read as text, not a button, until hovered/focused. The underline is a `border-bottom`, not `text-decoration`: a `