Novolis Workspaces / Snapshots / Timeline
Policies that keep the org coherent
name: Workspace Snapshot Timeline
overview: Greenfield a single multi-package repo (novolis-workspaces) implementing three logically separate libraries—Novolis.Snapshots, Novolis.Timeline, and Novolis.Workspaces—with adapter packages composing them. Phased delivery from abstractions through zip-based workspace save points and timeline UI projections.
todos:
- id: scaffold-repo
content: "Create novolis-workspaces from novolis-template-dotnet: solution, 15 projects, CI, docs/design.md with boundary rules" status: completed
- id: snapshots-abstractions
content: Implement Novolis.Snapshots.Abstractions + Memory + Json + unit tests status: completed
- id: snapshots-io
content: Implement Snapshots.FileSystem and Snapshots.Zip with MockFileSystem tests status: completed
- id: timeline-core
content: Implement Timeline.Abstractions + Memory + FileSystem persistence under .novolis/timeline/ status: completed
- id: workspaces-core
content: Implement Workspaces.Abstractions + FileSystem (manifests, layout, open/create) status: completed
- id: adapter-snapshots
content: Implement Workspaces.Snapshots (policy, zip store, restore + safety checkpoint) status: completed
- id: adapter-timeline
content: Implement Workspaces.Timeline, Projects.Timeline, Timeline.Presentation projector status: completed
- id: governance-dogfood
content: Add governance boundary doc, minimal sample, verify-nuget-only, GPR publish status: completed isProject: false
Decision summary
| Topic | Choice |
|---|---|
| GitHub repo | `novolis-workspaces` (one multi-package repo; shared 2026.1.* versioning and CI) |
| Logical split | Three libraries via package boundaries and project references, not folders alone |
| Filesystem | `System.IO.Abstractions` (IFileSystem, IDirectoryInfo, IFileInfo) in all disk facets |
| Existing platform | Do not merge with `Novolis.IO.Workspace` or `Novolis.Storage` event snapshots—document boundaries; optional bridge later |
| Simulation replay | Separate—`SimulationTimeline<TState>` is tick replay, not editor save points |
Hard boundaries (enforce in docs/design.md + analyzer-friendly project refs)
flowchart TB
subgraph workspaces [Novolis.Workspaces]
WA[Abstractions]
WF[FileSystem]
WS[Snapshots adapter]
WT[Timeline adapter]
WPT[Projects.Timeline adapter]
end
subgraph snapshots [Novolis.Snapshots]
SA[Abstractions]
SM[Memory]
SF[FileSystem]
SZ[Zip]
SJ[Json]
end
subgraph timeline [Novolis.Timeline]
TA[Abstractions]
TM[Memory]
TF[FileSystem]
TP[Presentation]
end
WA --> WF
WS --> WA
WS --> SA
WS --> SZ
WT --> WS
WT --> TA
WPT --> WT
WF --> SA
TM --> TA
TF --> TA
SM --> SA
SF --> SA
SZ --> SA
SJ --> SA
TP --> TARules (from your spec):
Novolis.Snapshots.*— no timeline types, no workspace manifestsNovolis.Timeline.*— no file I/O, no zip/json serializers; onlyTSnapshotRefopaque refsNovolis.Workspaces.*— no branching graph logic in core; branching only in Timeline adapter usage- Adapters (
Workspaces.Snapshots,Workspaces.Timeline,Workspaces.Projects.Timeline) are the only place that wires all three
Naming collisions to document
| Existing | New library | User-facing term |
|---|---|---|
ISnapshotCapableEventStore (stream compaction) | ISnapshotStore<TState,TRef> | internal “snapshot”; UI: Save Point |
IFileWorkspace (storage root + IFileProvider) | IWorkspace (editor container) | Workspace |
SimulationTimeline<TState> | ITimeline<TSnapshotRef> | Timeline / Branch |
Repo bootstrap
Create from `novolis-template-dotnet` → GitHub repo `novolis-workspaces`.
Standard layout per frank-naming-and-structure.md:
novolis-workspaces/
src/ # 15 packable projects (below)
tests/Novolis.Workspaces.Unit/
Novolis.Workspaces.slnx
Directory.Build.props # NovolisGitHubRepository = novolis-workspaces
Directory.Packages.props # System.IO.Abstractions + Novolis.* 2026.1.*
build/version.json
docs/design.md # boundaries + on-disk layout
docs/getting-started.mdThin CI: copy `novolis-audio/.github/workflows` pattern → novolis-workflows reusable workflows.
Register packages in .novolis/packages.json and novolis-registry after first GPR publish.
Third-party (central versions): System.IO.Abstractions, System.IO.Abstractions.TestingHelpers, System.Text.Json (snapshots metadata only).
Package inventory and dependency order
Implement in this order so each phase is shippable to GPR:
| # | Package | Depends on |
|---|---|---|
| 1 | Novolis.Snapshots.Abstractions | — |
| 2 | Novolis.Snapshots.Memory | Abstractions |
| 3 | Novolis.Snapshots.Json | Abstractions |
| 4 | Novolis.Timeline.Abstractions | — |
| 5 | Novolis.Timeline.Memory | Timeline.Abstractions |
| 6 | Novolis.Workspaces.Abstractions | — |
| 7 | Novolis.Snapshots.FileSystem | Abstractions + IO.Abstractions |
| 8 | Novolis.Timeline.FileSystem | Timeline.Abstractions + IO.Abstractions |
| 9 | Novolis.Workspaces.FileSystem | Workspaces.Abstractions + IO.Abstractions |
| 10 | Novolis.Snapshots.Zip | Abstractions + IO.Abstractions |
| 11 | Novolis.Timeline.Presentation | Timeline.Abstractions |
| 12 | Novolis.Workspaces.Snapshots | Workspaces.* + Snapshots.Zip |
| 13 | Novolis.Workspaces.Timeline | Workspaces.Snapshots + Timeline.FileSystem |
| 14 | Novolis.Workspaces.Projects.Timeline | Workspaces.Timeline (project-scoped policy) |
Novolis.Snapshots.Json = serializer helpers for manifest/timeline sidecars, not full workspace state.
Core API surface (Phase 0 contracts)
Match your spec verbatim in Abstractions projects; keep interfaces small and implementation-agnostic.
Snapshots (`Novolis.Snapshots.Abstractions`)
public interface ISnapshotStore<TState, TSnapshotRef>
{
ValueTask<TSnapshotRef> SaveAsync(TState state, SnapshotRequest request, CancellationToken ct = default);
ValueTask RestoreAsync(TState target, TSnapshotRef snapshot, CancellationToken ct = default);
}Supporting types: SnapshotRequest (label, kind, properties), MemorySnapshotRef, ZipSnapshotRef, optional FileSnapshotRef for FileSystem backend.
Serializer seam (below store):
public interface IStateSerializer<TState>
{
ValueTask WriteAsync(TState state, Stream destination, CancellationToken ct = default);
ValueTask ReadAsync(TState target, Stream source, CancellationToken ct = default);
}Timeline (`Novolis.Timeline.Abstractions`)
ITimeline<TSnapshotRef>—AddAsync,BranchAsync,MoveHeadAsync, plus queries:GetNodesAsync,GetBranchesAsync,GetHeadAsync- Records:
TimelineNode<TSnapshotRef>,TimelineMetadata,Branch,BranchName,TimelineHead - Strong IDs:
TimelineNodeId,BranchId(wrapGuidorUlid)
Explicit non-goals in XML docs: no merge, rebase, remotes, conflict resolution.
Workspaces (`Novolis.Workspaces.Abstractions`)
IWorkspace,IProject,IDocument(optional v1), manifests:WorkspaceManifest,ProjectManifest,ProjectReference- IDs:
WorkspaceId,ProjectId(value types) ProjectKindenum (extensible; start withGeneric,VoicePack,Scenario,GameSave)- Roots expose
IDirectoryInfofrom injectedIFileSystem
On-disk layout (FileSystem facet)
my-workspace/
.novolis/
workspace.json
settings.json
timeline/ # written by Timeline.FileSystem adapter only
projects/
{project-id}/
project.json
documents/
assets/
outputs/
cache/
temp/WorkspaceFileSystemService (name TBD): OpenAsync(path), CreateAsync(path, name), enumerate projects from manifest, validate schema version.
Implementation phases
Phase 1 — Snapshots foundation (shippable alone)
Memory: MemorySnapshotStore<TState> — deep clone via serializer round-trip or optional ICloneable constraint documented per consumer.
Json: JsonStateSerializer<TState> using System.Text.Json with source-gen friendly options.
FileSystem: blob store under a caller-provided directory; refs = relative path + content hash/id.
Zip: ZipSnapshotStore<TState> — one entry state.dat (+ optional manifest.json); ZipSnapshotRef(ObjectId, RelativePath).
Unit tests (TUnit + MockFileSystem): save/restore round-trip, overwrite policy, missing ref errors.
Phase 2 — Timeline foundation
Memory: InMemoryTimeline<TSnapshotRef> — adjacency list + branch head map; thread-safe if documented for single-writer UI.
FileSystem: persist under .novolis/timeline/:
timeline/
branches.json
nodes/
{node-id}.json # metadata + snapshot ref only
head.jsonQueries needed by presentation: ordered children per parent, branch membership, head per branch.
Phase 3 — Workspaces on disk
- Read/write
workspace.json/project.json(schema version1) PhysicalWorkspace/PhysicalProjectbacked byIFileSystem- Create default folder skeleton on
CreateAsync - No snapshot/timeline code in these projects
Phase 4 — Workspace snapshots adapter
`Novolis.Workspaces.Snapshots`:
IWorkspaceSnapshotPolicy+DefaultWorkspaceSnapshotPolicy(your include/exclude lists)ZipWorkspaceSnapshotStoreimplementingISnapshotStore<IWorkspace, ZipSnapshotRef>:- Walk workspace root via
IFileSystem - Zip included files preserving relative paths
- Exclude
.novolis/timeline/,cache/,temp/,outputs/, build artifacts - Restore behavior:
- Safety save point (“before restore”) via injected
WorkspaceTimelineor direct snapshot call - Restore working tree from zip
- Never delete
.novolis/timeline/(timeline refs may point at restored snapshot ids—document that orphaned refs are possible if user deletes nodes manually)
SnapshotRequest kinds: Manual, Autosave, Safety, Quick, ExportCheckpoint (string constants in SnapshotKinds).
Phase 5 — Timeline adapters + presentation
`Novolis.Workspaces.Timeline`:
public sealed class WorkspaceTimeline(
ITimeline<ZipSnapshotRef> timeline,
ISnapshotStore<IWorkspace, ZipSnapshotRef> snapshots) { ... }SavePointAsync— save zip +timeline.AddAsyncRestorePointAsync— safety checkpoint + restore +MoveHeadAsyncoptionalBranchFromAsync—BranchAsyncfrom selected node
`Novolis.Workspaces.Projects.Timeline`: same API scoped with IProjectSnapshotPolicy (only projects/{id}/ subtree).
`Novolis.Timeline.Presentation`:
ITimelineProjector<TSnapshotRef>+ defaultTimelineTreeProjectorTimelineTreeView,TimelineTreeNode,TimelineTreeRow(flat list for Avalonia/Blazor)- Map
TimelineMetadata.Label/Kind→ presentation; never exposeZipSnapshotRefpaths in UI models
Phase 6 — Dogfood + governance
- Add
samples/MinimalWorkspaceTimeline/console or tiny Avalonia sample in-repo (not packable): create workspace → 3 save points → branch → restore - Add boundary section to library-boundaries.md or new
docs/architectural-ideals/workspace-snapshot-timeline.mdlinked from README - Run
pwsh -File novolis-governance/scripts/verify-nuget-only.ps1before merge - GPR publish on merge; document consumer
PackageReferencepattern for future Voice Studio app
UX mapping (for future apps, not in library UI)
| User action | Library behavior |
|---|---|
| Autosave | App writes working files; optional lightweight SnapshotRequest kind Autosave without timeline node |
| Ctrl+S | WorkspaceTimeline.SavePointAsync with kind Manual |
| Before export / migration | App calls safety/manual save point |
| Restore | Safety checkpoint → RestoreAsync → optional head move |
| Branch | BranchAsync from selected node |
Testing strategy
Single test project tests/Novolis.Workspaces.Unit/ with folders per facet:
- Snapshots: memory + zip round-trip on
MockFileSystem - Timeline: fork/head move/branch isolation
- Workspaces: manifest IO, policy include/exclude
- Adapters: restore preserves
timeline/dir; safety checkpoint creates new node
Use System.IO.Abstractions.TestingHelpers.MockFileSystem consistently (aligns with novolis-machinelearning pattern, not InMemoryFileWorkspace).
Future (out of initial scope)
- Bridge package
Novolis.Workspaces.StorageexposingIFileWorkspacefor a project sub-root (JSON entity repos) - Voice Studio Avalonia app in
novolis-dogfoodingconsumingTimelineTreeRow - Incremental/workspace-delta snapshots (v1 is full zip of policy-filtered tree)
Novolis.Snapshots.*generic host DI extensions (AddNovolisSnapshots())
Risk notes
- Large workspaces: zip snapshots are full-tree; document size expectations; Voice packs should scope to
Projects.Timelinewhere possible - Orphan snapshot refs: if timeline node references a deleted zip object,
RestorePointAsyncfails clearly with structured error - Concurrent writers: v1 assumes single-writer process (studio app); no file locking spec yet