Documentation
¶
Overview ¶
Package devdraft implements the `harbor dev` draft-save scaffolding surface — Phase 66 / D-100.
What this package owns ¶
A project-local `.harbor/drafts/<tenant>/<user>/<session>/<draft_id>/` scratchpad where an operator iterates on an agent without committing scaffold output. The Store materialises the same file tree `harbor scaffold` emits (agent.go, agent_test.go, harbor.yaml, README.md, go.mod) and exposes a small HTTP surface mounted by `harbor dev` under `/v1/dev/drafts/`. A `save` promotes a draft to a `harbor scaffold`-emitted layout under an operator-supplied output dir; before promotion the rendered `harbor.yaml` is run through `internal/config.Load + Validate` so an invalid draft cannot leak into the operator's working tree.
Identity scoping (CLAUDE.md §6) ¶
The on-disk path is identity-scoped — the tuple `(tenant, user, session)` is the prefix. Concurrent operators (multiple `harbor dev` clients hitting the same `.harbor/drafts/` root) cannot collide. The `draft_id` is an opaque ULID; the Store's APIs always take an `identity.Identity` from `ctx` and reject any request whose identity is missing or incomplete (§6 rule 9: identity is mandatory; fail closed). Cross-identity reads are impossible at the filesystem layer because the path is composed from the identity before any file open.
Path-traversal safety (CLAUDE.md §7 rule 5) ¶
Every operator-supplied path component (the `{path}` in `PATCH /v1/dev/drafts/{id}/files/{path}` and the operator-supplied output dir on `save`) is filtered through the local `resolveSafe` helper which mirrors `internal/skills/importer/path_safety.go`: `filepath.Clean` + lexical-prefix verification + a symlink-eval pass. Escape attempts fail loud with ErrUnsafePath.
Bus events ¶
Five lifecycle events land on the canonical event bus so the Console (and integration tests) observe the round-trip:
- `dev.draft.created`
- `dev.draft.updated`
- `dev.draft.previewed`
- `dev.draft.saved`
- `dev.draft.discarded`
All five are SafePayload by construction — the payload carries the draft ID and a short marker (file path, output dir abs path) and never the file contents.
Concurrent reuse (D-025) ¶
The Store is a compiled artifact: every field is set at construction and immutable afterwards; per-request state lives in `ctx`. The concurrent-reuse test exercises N≥100 invocations against a single shared Store under `-race`.
Index ¶
- Constants
- Variables
- type CreateOptions
- type Draft
- type DraftCreatedPayload
- type DraftDiscardedPayload
- type DraftFile
- type DraftPreviewedPayload
- type DraftSavedPayload
- type DraftUpdatedPayload
- type Handler
- type Options
- type PreviewResult
- type SaveOptions
- type SaveResult
- type Store
- func (s *Store) Close(_ context.Context) error
- func (s *Store) Create(ctx context.Context, opts CreateOptions) (*Draft, error)
- func (s *Store) Discard(ctx context.Context, draftID string) error
- func (s *Store) Get(ctx context.Context, draftID string) (*Draft, error)
- func (s *Store) Preview(ctx context.Context, draftID string) (*PreviewResult, error)
- func (s *Store) Root() string
- func (s *Store) Save(ctx context.Context, draftID string, opts SaveOptions) (*SaveResult, error)
- func (s *Store) WriteFile(ctx context.Context, draftID, relPath string, content []byte) error
Constants ¶
const ( // EventTypeDraftCreated — emitted by Store.Create after the // draft tree has been materialised under the identity-scoped // path. Payload is DraftCreatedPayload (SafePayload). EventTypeDraftCreated events.EventType = "dev.draft.created" // EventTypeDraftUpdated — emitted by Store.WriteFile after a // successful PATCH. Payload is DraftUpdatedPayload (SafePayload). EventTypeDraftUpdated events.EventType = "dev.draft.updated" // EventTypeDraftPreviewed — emitted by Store.Preview after a // preview run terminates. Payload is DraftPreviewedPayload // (SafePayload). EventTypeDraftPreviewed events.EventType = "dev.draft.previewed" // EventTypeDraftSaved — emitted by Store.Save after a successful // promotion to the scaffold output dir. Payload is // DraftSavedPayload (SafePayload). EventTypeDraftSaved events.EventType = "dev.draft.saved" // EventTypeDraftDiscarded — emitted by Store.Discard after the // draft tree has been removed. Payload is DraftDiscardedPayload // (SafePayload). EventTypeDraftDiscarded events.EventType = "dev.draft.discarded" )
Draft lifecycle event types. Each is registered with the events package's exhaustive registry via init() so Publish accepts them without ErrUnknownEventType. Identity lives on the Event itself, intentionally not duplicated on the payload.
const ( // CodeIdentityRequired — the request reached the handler without // a verified identity in ctx. Maps to 401. CodeIdentityRequired = "identity_required" // CodeInvalidRequest — the request was structurally malformed // (bad JSON, missing required field, unknown method). CodeInvalidRequest = "invalid_request" // CodeNotFound — the draft does not exist for the caller. CodeNotFound = "not_found" // CodeUnsafePath — a path component escaped its allowed root // (CLAUDE.md §7 rule 5). CodeUnsafePath = "unsafe_path" // CodeUnknownTemplate — the create body named a template not in // scaffold.Templates(). CodeUnknownTemplate = "unknown_template" // CodeOutputDirExists — save's output dir already exists. CodeOutputDirExists = "output_dir_exists" // CodeValidationFailed — save's pre-promotion validation pass // failed (the draft's harbor.yaml does not pass config.Validate). CodeValidationFailed = "validation_failed" // CodeInternal — catch-all server-side error. Maps to 500. CodeInternal = "internal_error" )
Stable wire error codes. The Console + scripted clients branch on these strings; new codes ADD entries here. Codes mirror the Protocol error-code naming convention even though this surface is not (yet) part of the Single Source CanonicalWireTypes (D-093).
const RoutePrefix = "/v1/dev/drafts"
RoutePrefix is the canonical path prefix the draft handler is mounted under by `harbor dev`. The Phase 60 transport mux owns `/v1/control/...` + `/v1/events`; the draft surface is `/v1/dev/ drafts/...` so a future Console can discover it via a stable well-known root.
Variables ¶
var ( // ErrIdentityMissing — a Store method was invoked without a // complete (tenant, user, session) triple in ctx. Fails closed // per CLAUDE.md §6 rule 9. ErrIdentityMissing = errors.New("devdraft: identity required") // ErrNotFound — the requested draft does not exist under the // caller's identity-scoped path. ErrNotFound = errors.New("devdraft: draft not found") // ErrUnsafePath — a path component (PATCH file path or Save // output dir) escaped its allowed root per CLAUDE.md §7 rule 5. ErrUnsafePath = errors.New("devdraft: unsafe path") // ErrUnknownTemplate — Create was invoked with a template name // not registered in the embedded scaffold template set. ErrUnknownTemplate = errors.New("devdraft: unknown template") // ErrInvalidName — Create or Save was invoked with a project // name that fails the scaffold engine's validation. ErrInvalidName = errors.New("devdraft: invalid project name") // ErrOutputDirExists — Save was invoked with an OutputDir that // already exists. Mirrors `scaffold.ErrOutputDirExists`'s no- // overwrite posture. ErrOutputDirExists = errors.New("devdraft: output directory already exists") // ErrValidationFailed — Save's pre-promotion validation pass // (config.Load + Validate against the rendered harbor.yaml) // failed. The wrapped error names the offending field. ErrValidationFailed = errors.New("devdraft: rendered config failed validation") // ErrIO — a filesystem operation failed during a Store method. // The wrapped error carries the offending path + operation. ErrIO = errors.New("devdraft: filesystem operation failed") )
Sentinel errors. Callers compare via errors.Is.
Functions ¶
This section is empty.
Types ¶
type CreateOptions ¶
CreateOptions configures Store.Create. Name is the seed-time project name (used by the scaffold engine as `harbor.yaml`'s service name + the Go package name). Template selects the embedded scaffold template; empty defaults to `scaffold.DefaultTemplate`.
type Draft ¶
type Draft struct {
// ID is the opaque ULID Store.Create minted.
ID string `json:"id"`
// Template is the template the draft was seeded from. Empty
// when read via Get (V1 does not persist the template name —
// see the Get godoc).
Template string `json:"template,omitempty"`
// CreatedAt is the wall-clock time the draft was materialised
// (the draft root's ModTime).
CreatedAt time.Time `json:"created_at"`
// UpdatedAt is the most-recent on-disk mtime across the draft
// tree. Useful for the Console's "last edit" column.
UpdatedAt time.Time `json:"updated_at"`
// Files is the deterministic-order list of rel paths under the
// draft root, lexicographically sorted.
Files []DraftFile `json:"files"`
}
Draft is the in-memory snapshot Store.Get returns to a caller. It is a value type — the caller owns the returned slice references.
type DraftCreatedPayload ¶
type DraftCreatedPayload struct {
events.SafeSealed
DraftID string
Template string
FileCount int
}
DraftCreatedPayload reports a successful Store.Create. DraftID is the opaque ULID minted by the Store; FileCount is the number of files seeded from the chosen template. SafePayload by construction.
type DraftDiscardedPayload ¶
type DraftDiscardedPayload struct {
events.SafeSealed
DraftID string
}
DraftDiscardedPayload reports a successful Store.Discard. The draft tree was removed from the operator's working dir. SafePayload by construction.
type DraftFile ¶
type DraftFile struct {
Path string `json:"path"`
Size int `json:"size"`
Content []byte `json:"content"`
}
DraftFile is the (rel-path, content, size) tuple Store.Get returns per file. Content is the on-disk byte stream; callers that want metadata-only can read Size and skip Content.
type DraftPreviewedPayload ¶
type DraftPreviewedPayload struct {
events.SafeSealed
DraftID string
OK bool
}
DraftPreviewedPayload reports a Store.Preview call. The payload is deliberately small — Phase 66's preview path is a stub that validates the draft tree (specifically the rendered harbor.yaml) and reports whether it would boot. Concrete dry-run execution lands in a later phase; the event shape is stable across that upgrade. SafePayload by construction.
type DraftSavedPayload ¶
type DraftSavedPayload struct {
events.SafeSealed
DraftID string
OutputDir string
FileCount int
}
DraftSavedPayload reports a successful Store.Save promotion to the scaffold output dir. OutputDir is the absolute path Save wrote to; FileCount is the number of files materialised. SafePayload by construction — OutputDir is the operator's own working-dir, not secret-shaped.
type DraftUpdatedPayload ¶
type DraftUpdatedPayload struct {
events.SafeSealed
DraftID string
Path string
Size int
}
DraftUpdatedPayload reports a successful Store.WriteFile. Path is the slash-separated rel-path the operator submitted (already path-traversal-checked at the boundary). Size is the number of bytes written. SafePayload by construction — neither field carries secret-shaped material; file contents themselves NEVER reach the bus.
type Handler ¶
type Handler struct {
// contains filtered or unexported fields
}
Handler wraps a Store in an http.Handler that routes the five draft endpoints under RoutePrefix. The returned handler is a compiled artifact (D-025) and safe to share across N concurrent requests; per-request state lives in the request ctx.
The handler does NOT perform auth — `harbor dev`'s boot wraps it in `auth.Middleware`, which injects the verified identity into ctx. Standalone tests that need to skip auth wrap the handler in their own ctx-injecting middleware.
func NewHandler ¶
NewHandler builds a Handler. The store argument is mandatory; a nil store is an immediate construction error (CLAUDE.md §13 fail-loud on operator-facing seam).
type Options ¶
type Options struct {
// Root is the directory the Store materialises draft trees
// under. The Store appends `<tenant>/<user>/<session>/<draft_id>`
// to this root. Typical operator value: `<cwd>/.harbor/drafts`.
Root string
// Bus is the events.EventBus the Store publishes lifecycle
// events onto. Mandatory — a Store with no bus would silently
// drop the observability surface, which violates CLAUDE.md §13
// "fail loudly" + the Wave 11.5 §17.6 F1 lesson (test fixture
// vs production divergence on bus wiring).
Bus events.EventBus
// Logger is the slog.Logger the Store writes lifecycle lines to.
// Optional — nil falls back to slog.Default.
Logger *slog.Logger
// MaxFileBytes overrides the per-file size cap PATCH enforces.
// Zero falls back to defaultDraftFileMaxBytes (1 MiB).
MaxFileBytes int
}
Options configures NewStore.
type PreviewResult ¶
PreviewResult reports the outcome of Store.Preview. OK is true when the rendered `harbor.yaml` parses + validates via the in-process `internal/config` loader; the Errors slice carries the human- readable reasons when OK is false. The Phase 66 preview surface is a config-validation pass; a future phase upgrades this to a real dry-run that boots the draft against a sandboxed runtime.
type SaveOptions ¶
SaveOptions configures Store.Save. Name is the project name to label the promoted output (mirrors `harbor scaffold --name`). OutputDir is the operator-supplied output dir — the engine refuses to overwrite an existing dir.
type SaveResult ¶
type SaveResult struct {
Name string `json:"name"`
OutputDir string `json:"output_dir"`
Files []string `json:"files"`
}
SaveResult reports the promoted scaffold output.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store is the per-binary, per-operator-root draft scratchpad. Construction binds the on-disk Root + the bus + the logger; the returned Store is a compiled artifact (D-025) and safe to share across N concurrent goroutines.
func NewStore ¶
NewStore builds a Store. Both `Options.Root` and `Options.Bus` are mandatory; missing either is an immediate error (CLAUDE.md §13 fail-loud on operator-facing seam).
func (*Store) Close ¶
Close releases the Store. Phase 83m (Item 3, D-156): the V1 Store owns no long-lived goroutines and no persistent file handles — the per-call methods open + close their own descriptors — so Close is a no-op today. It exists so cmd/harbor's bootDevStack and harbortest/devstack can append it to their closer chain alongside every other subsystem's Close, and so a future on-disk / SQLite- backed Draft store (one that DOES own goroutines / handles) does not need to be retro-fitted into every caller. Safe to call concurrently and idempotent.
The ctx is accepted for closer-signature compatibility (closers take `func(context.Context) error`); the Store's drain has no blocking shape to bound.
func (*Store) Create ¶
Create seeds a fresh draft under the caller's identity-scoped path using the chosen template. Returns the seeded Draft (including the minted DraftID + the list of seeded files).
Identity is mandatory — a ctx missing the triple is rejected with ErrIdentityMissing (CLAUDE.md §6 rule 9). The on-disk path is `<root>/<tenant>/<user>/<session>/<draft_id>/` so concurrent operators (multiple `harbor dev` clients against the same `.harbor/drafts/` root) cannot collide.
On success a `dev.draft.created` event lands on the bus carrying (DraftID, Template, FileCount).
func (*Store) Discard ¶
Discard removes the named draft from disk. Idempotent — discarding a draft that does not exist still emits the terminal event so observers see a clean lifecycle. On success a `dev.draft.discarded` event lands on the bus.
func (*Store) Get ¶
Get returns a Draft snapshot for the named draft ID under the caller's identity. Returns ErrNotFound when the draft does not exist for the caller. Cross-identity reads are impossible — the on-disk path is composed from the identity before any file open.
V1 does not persist the template name; the returned Draft.Template is empty. A future phase that adds a `.harbor/drafts/<id>/.meta` sidecar will flip this to the recorded template.
func (*Store) Preview ¶
Preview runs a validation-only dry-run against the named draft. V1: parse + Validate the rendered `harbor.yaml` via `internal/ config.Load`. The seed harbor.yaml passes validation by construction; a draft that mutates the yaml to an invalid shape is caught here BEFORE the operator tries Save. On every preview a `dev.draft.previewed` event lands on the bus.
func (*Store) Root ¶
Root returns the absolute on-disk root the Store materialises drafts under. Exposed for tests + the devstack helper's debug- only inspection; production code should NOT path-join into this.
func (*Store) Save ¶
func (s *Store) Save(ctx context.Context, draftID string, opts SaveOptions) (*SaveResult, error)
Save promotes the named draft to a `harbor scaffold`-emitted layout under opts.OutputDir.
Promotion order (every step fails loud; no partial writes):
- Resolve identity from ctx.
- Validate the project name + output dir shape.
- Stat the draft root (ErrNotFound when absent).
- Pre-promotion validation: `internal/config.Load + Validate` against the draft's `harbor.yaml`. Refuses promotion with ErrValidationFailed on any error — closes the seam at the boundary, not at the operator's next `harbor validate`.
- Canonicalise the operator-supplied output dir, assert it does not exist (ErrOutputDirExists).
- Copy the draft tree byte-for-byte to OutputDir. The draft IS the rendered output — the seed shape came from the scaffold engine and mutations the operator made via PATCH are load- bearing. On any copy error the partial output is removed.
On success a `dev.draft.saved` event lands on the bus.