MILAB-6648: Project template - #1767
Conversation
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
🦋 Changeset detectedLatest commit: 2677325 The changes in this PR will be included in the next version bump. This PR includes changesets to release 29 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #1767 +/- ##
==========================================
- Coverage 53.76% 53.13% -0.64%
==========================================
Files 366 411 +45
Lines 19679 21280 +1601
Branches 4341 4737 +396
==========================================
+ Hits 10581 11307 +726
- Misses 7795 8617 +822
- Partials 1303 1356 +53 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
9e79ae2 to
866d82b
Compare
Mechanical, and a prerequisite rather than part of the feature: a kind is a mandatory component of a V3 block, so the workspace cannot hold V2 example blocks once the following commits land. The `-v3` suffixed duplicates that existed for migration testing are removed and their non-suffixed counterparts take their place, so every reference to `enter-numbers-v3` / `sum-numbers-v3` in tests moves with them. `ml-legacy.test.ts` and `v2.test.ts` covered the V2 path only and are dropped; the two cases from `ml-legacy.test.ts` still worth keeping are carried into `ml-v3.test.ts`. Each block's model also projects its params, which only compiles once the params contract lands — see the sdk/model commit later in this branch.
`BlockKindReference` is the `{name}@{version}` string a published block advertises,
and `formatKindRef` is the one place it is built from a compiled kind. The reference
sits on the config container beside `code`, not inside a render envelope: which kind
a block implements is orthogonal to how it renders, and the container is what a
reader already has in hand.
The read side stays optional. Blocks published before kinds existed carry no
reference, and the middle layer must keep reading them.
A PColumn id carries the block id of the block that produced it, sometimes several levels down: under wrappers, in every hop of a discovered path, and in the keys of a qualifications map. Template export and apply both have to rewrite those ids, so the walk lives beside the id codec rather than in either caller. Rewriting is by value: an id that does not change comes back as the very same string rather than a re-serialized equivalent, so a caller can tell "nothing to do" from "rewritten to the same thing".
The schema a template file is parsed against, and the codec that converts the
references inside an entry's params between the two forms they take: a live `PlRef`
naming a block, and the two-key mapping a file uses to name another entry.
Two decisions the schema makes, both load-bearing:
- `kind` is required on every entry, because the kind carries the params contract —
an entry without one describes params nothing can check.
- a kind selector is its own three-tier grammar (exact, patch-float, minor-float)
rather than an npm range, so that below 1.0.0 the tiers stay distinguishable,
where npm's `~` and `^` collapse into the same thing.
An entry may pin a version or pin a place, never both: a pinned place is what makes a
template resolvable without a registry, and a pinned version is what makes it
portable, so a file that claims both has said nothing definite.
A kind is a block's identity plus its init-params contract, declared in its own tiny package so that two blocks can implement the same kind and a template can name one without naming an implementation. The params type is recovered from the declaration (`InferBlockParams`), and a kind with wider params is deliberately not assignable to one with narrower params — a block must not widen the contract it claims to implement. `ts-builder` gets the rolldown and tsconfig pair a kind package builds with; a kind ships as its own artifact because a block bundles its dependencies once and the kind has to be readable without unpacking the block.
The structurer gains `kind/` as a fourth component beside model, workflow and ui, so `block-tools` scaffolds it, validates its package.json, and refuses a block without one. New blocks get a params parser stub that deliberately does not build: a kind whose contract was never written should fail loudly at build time, not silently accept anything. A kind is built twice — once as its own publishable artifact, once bundled into the block — because the registry must be able to read a kind's contract without unpacking any implementation, while the block still needs it at runtime. `checkKindVersionMatch` is the gate publishing runs before anything is written: the kind a block claims and the kind that was built have to agree on name, org and version, with an incidental npm-scope difference normalized away.
Publishing is kind-first: the version-match gate, then the kind, then the block. A kind version's content folder is immutable — republishing identical content is an idempotent no-op, republishing the same version with different content is a hard failure, because a template that pinned that version would otherwise silently mean something else. Reconcile derives a per-kind overview (which kind versions exist, which blocks implement each, in which channels) in the same pass that already reconciles packages, rather than in a second reconciler. The overview is a derived view, so a channel change after the fact re-derives it. `resolveKind` maps a selector tier to an explicit semver range and picks the newest matching kind version with a stable implementer, falling back to the any channel only when the caller allows unstable. The block-repo tests carry this on the same axes block publishing is already covered on, and across two kind versions — with a single version on the registry every selector picks the same block whether or not the ranges are right.
Three things arrive together because the type parameter that carries the kind's params
threads through all of them:
- `create()` now takes a kind, and the authoring API can no longer produce a
kind-less block. The kind handed to the data model and the one handed to `create()`
are cross-checked, so there is one source rather than a precedence order.
- `templateParams()` is required. It is the inverse of the data model's `init`:
`init` builds data from params, this recovers the params that would rebuild the
current data. A block whose state carries nothing worth restoring returns `{}` and
says so, rather than exporting an entry that silently applies as a default block.
- `parseTemplateParams` is read off the compiled kind, never declared per block.
The contract belongs to the kind, so two blocks implementing it cannot disagree
about what a valid params object is.
There is no unchecked path: what flows on to `init` is the parser's output, not its
input, so params the kind rejects never reach a block.
A known hole, left explicit in the tests: an extra field the kind does not declare is
not rejected, because a kind's params are a TypeScript type with no runtime schema of
their own beyond what the parser chooses to check.
Generated, and uniform by construction: one `kind/` package per example block, four config files and a params declaration each. Nothing here is a design decision — the contracts these kinds declare are whatever the corresponding block model already projects in its `templateParams()`. Worth skimming rather than reading, with one exception: the params type in each `kind/src/index.ts` is the contract a template file is checked against, so it is the one line per block that carries meaning.
Generated. `pnpm-workspace.yaml` picks up the `kind/` glob and the lockfile follows.
A dependency-order walk over the project structure, then a serializer that renders the result as `template-v1` YAML. Structure order is already instantiation order, so the walk sorts nothing. All or nothing: no partial YAML is ever produced. The walk collects every problem and the serializer adds its own, and they are reported together in one pass, because a half-written template is worse than none. The central check is that no live block id escapes into the file. A `PlRef` the codec failed to rewrite is caught, and so is one hidden inside a string (the `EnrichmentRef` case) or double-stringified. A column id naming a block the template describes is a wire and is fine; one naming a block it does not describe is a fault. A block cannot name itself, since its own id is only published once it has been written. `mutator/project.ts` gains the kind reference on `BlockInfo` — `extractConfig` normalizes the render envelope one level below where the kind sits, so it has to be read off the container during the load that is already happening. The `initialStorage` seam added here is what the apply path later fills in.
The three steps before anything is created: parse the YAML, check the document on its own, then resolve each entry to a concrete block pack. Parse errors are located the way the file is written, and every schema problem is listed rather than just the first, because a hand-edited template usually has more than one thing wrong with it. Validation runs before creation, not during: forward references, self-references, references to ids the file does not define, and — the case that only shows up with a file from another machine — params still carrying a live block id from the project they were exported from, including one hidden inside a string. Resolution has two routes. An entry that names a place is read from that place and consults no registry at all, which is the whole point of naming one; its declared kind is then verified against what was actually found there. An entry that names a kind goes to the configured registries in order, first hit winning, and when they disagree the failure that got furthest is the one reported — a registry that has never heard of the kind is less informative than one that has it but only unstable. Every entry is attempted before anything is reported, so a document that half resolves says so about both halves.
Creation, in file order, with an id map recording which block each entry became so the
next entry's references can point at blocks that already exist. A forward reference is
impossible by construction here rather than merely rejected.
Every entry goes through the params path, including one with no params, which is
applied as `{}`. There is no second, unchecked way in — that is what makes "the kind
checked these params" true of every block a template creates.
Params are turned into storage in the model VM before the transaction is touched, so a
kind rejecting them is a value the caller can report rather than a half-applied
project. Apply stops at the first entry it cannot add and reports what already landed:
partial is visible, never silent. A rejected entry does not become a reference target.
`MiddleLayer` gains both public entry points. They take a project id rather than an
open project, because exporting and applying are properties of the stored project, not
of a session with it — the export command lives on a project card where the project is
usually closed, and the import flow has just created the project and has no session yet.
Export and apply are inverses. The two halves only a live project can exercise are deriving every block's params in the model VM off stored state, and resolving, installing and placing blocks against a real backend; everything between them is unit tested in the middle layer, so what is proven here is that the two ends compose. Applying goes through the YAML text rather than the in-memory document, so what a user would actually save is what the importer reads. Equivalence is stated up to renaming, since applying creates new blocks with new ids. The fidelity boundary is asserted rather than avoided: a field the kind declares survives, a field it does not is lost and the test says so. Transport is held constant — both blocks are installed from a folder on disk, with `resolveKind` stubbed to throw, so a located entry reaching a registry would fail the test rather than pass quietly.
The working documents this branch was built from: the kind and lifecycle design, the export and import designs, and the resolved-question log each carries. Kept because the decisions behind several non-obvious choices — the selector grammar below 1.0.0, kind built twice, params checked by the kind rather than the block — are recorded here and nowhere else. Separated into its own commit so the code can be reviewed without it, and so it can be dropped or relocated without touching anything else.
866d82b to
2677325
Compare
| * so a parser must not treat a specific id as meaningful. | ||
| */ | ||
| readonly parseTemplateParams: (value: unknown) => BlockParams; | ||
| readonly __PHANTOM_BLOCK_PARAMS__?: (p: BlockParams) => void; |
There was a problem hiding this comment.
Why do you need that, exactly? Not clear from the class docs.
| export type { CompiledBlockKind, InferBlockParams } from "./descriptor"; | ||
|
|
||
| /** A kind's identity, as declared in its own `package.json`, plus optional behaviour. */ | ||
| export interface BlockKindMeta<BlockParams = unknown> { |
There was a problem hiding this comment.
Why do we need this one if we have CompiledBlockKindV1 ?
| /** Reference to the block kind this data model belongs to, if declared. */ | ||
| kindRef?: BlockKindReference; | ||
| /** The kind's runtime params check, threaded with {@link kindRef}. */ | ||
| parseTemplateParams?: (value: unknown) => unknown; |
| * Runtime check for params that did not come from a typed caller. Required — see | ||
| * {@link CompiledBlockKind}'s `parseTemplateParams` for what it must do and why it exists. | ||
| */ | ||
| parseTemplateParams: (value: unknown) => BlockParams; |
There was a problem hiding this comment.
parseInitializationParams, template is one way how we can initialize the block, another one will be tests, another MCP/AI.
Another thing here, let's not allow this function to alter parameters (like zod normalization), this logic should be on the block side. In other words if we do so, there will be a temptation to for example to fill in defaults of a certain parameters with a specific values here, and this will create an incorrect logic placement, any logic belongs to the block model code, and should change with block.
|
|
||
| return { | ||
| addBlock: (request): AddBlockOutcome => { | ||
| const prepared = entries.get(request.id); |
There was a problem hiding this comment.
This is a bit "cheating", that is the whole point of isolating APIs, this function should receive all the information from the caller.
| // A kind ships a runtime params check, so it is not a types-only package. zod is | ||
| // the default the scaffolded parser is written against; an author who validates by | ||
| // hand can drop this, which is why it is seeded rather than asserted on refresh. | ||
| zod: "catalog:", |
| * cannot drift from the contract by being left behind. zod is the default; any function | ||
| * from `unknown` to `BlockParams` satisfies the slot, including a hand-written one. | ||
| */ | ||
| const Params = z.object({}).strict(); |
| * existing content is discarded rather than filtered. | ||
| */ | ||
| type KindTouchAccumulator = { | ||
| touched: Set<string> | null; |
There was a problem hiding this comment.
touchedImplemeters/touchedBlocks?
| // Seed every existing kind overview empty so kinds orphaned by | ||
| // migration/removal are rewritten (or deleted) this pass. The block scan | ||
| // above already re-enumerates every live kind ref (refs live inside block | ||
| // manifests); this LIST exists solely to reset orphans. | ||
| const kindPaths = await this.storage.listFiles(KindsPrefix); | ||
| for (const rel of kindPaths) { | ||
| if (!KindOverviewPathPattern.test(rel)) continue; | ||
| touchedKinds.set(KindsPrefix + rel, { touched: null, add: [] }); | ||
| } |
There was a problem hiding this comment.
This is an expensive operation that is O(N) from the number of kinds == number of blocks, let's do it only for mode == "force" only. Please check that I am not missing something, if my understanding is correct it should work okay, even for deletion, if it is not due to real deletion of the block, which anyway is processed only for the force mode.
There was a problem hiding this comment.
If we have to read all kinds each time we do this sync, let's rethink how we approach it.
Greptile Summary
This PR introduces block kinds and project templates across the SDK, middle layer, registry, and block-authoring toolchain.
template-v1parsing, validation, serialization, export, resolution, and application.defineBlockKind; it now carries the kind identity, schema version, parameter type, and template-parameter parser.{package-name}@{version}identity stored in block models and manifests; this PR adds formatting and parsing helpers.template-v1document describing blocks, initialization parameters, and references; this PR adds its schema and import/export lifecycle.Confidence Score: 5/5
The PR appears safe to merge.
The previously reported stale kind association, pre-1.0 selector range, and missing initialization-parameter paths are addressed in the current code, and no blocking failure remains.
Important Files Changed
template-v1document contract used by template parsing, validation, import, and export.Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart LR K[Block kind package] --> KB[Kind build and manifest] KB --> PUB[Kind-first publication] PUB --> REG[Registry kind overview] T[template-v1 document] --> PARSE[Parse and validate] PARSE --> RESOLVE[Resolve kind selectors] REG --> RESOLVE RESOLVE --> APPLY[Create project blocks] APPLY --> INIT[Initialize storage from params] INIT --> P[Project] P --> EXPORT[Export template params] EXPORT --> TReviews (2): Last reviewed commit: "refactor: enhance kind overview handling..." | Re-trigger Greptile
Context used: