Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: Quality

on:
pull_request:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read

concurrency:
group: quality-${{ github.ref }}
cancel-in-progress: true

jobs:
go:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: go.mod
check-latest: true
- run: go build ./...
- run: go vet ./...
- run: go test -race -cover ./...
- run: make test-fuzz-smoke
env:
GOMAXPROCS: "2"

studio:
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: studio
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
with:
version: 10.11.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "24"
cache: pnpm
cache-dependency-path: studio/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test
- run: pnpm --filter @rendis/statepro-studio-react exec stryker run --dryRunOnly
- run: pnpm build
- run: pnpm audit --audit-level=low
102 changes: 102 additions & 0 deletions docs/reviews/2026-10-03-follow-up.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Snapshot correctness and Studio quality follow-up

Base: `ea00b2b776c99c0d7e788826c508336f21360480` (main after PR #22).
This change completes the dependency, snapshot, performance and quality-gate follow-ups from
the [initial review](2026-10-03-security-quality.md), including R12, R15 and R16.

## Runtime changes

Snapshot capture used a JSON round-trip that converted every untyped number to `float64` and
discarded serialization errors. For example, metadata containing integer `9007199254740993`
could be persisted as `9007199254740992`. Invalid metadata could produce incomplete snapshots.

- Capture, snapshot JSON decoding and restoration now retain `json.Number` when conversion to
`float64` would change the numeric value after re-encoding. Values that round-trip exactly
retain their existing `float64` representation.
- Precision is preserved in nested metadata and accumulated event data, including unsigned
integers and long decimal fractions. Decimal comparison does not expand exponent magnitudes.
- The optional `instrumentation.SnapshotProvider` capability and `GetSnapshotWithError` helper
return a complete snapshot or a contextual serialization error. Existing machine and executor
interfaces remain compatible. Synchronous actions can use the helper with their arguments
without reacquiring the owning machine's lock.
- Legacy `GetSnapshot()` logs capture errors and returns `nil`. Consumers must check that result.
Metadata consumers must handle `json.Number` as well as `float64`.

See [runtime guidance](../runtime.md#snapshots) for usage and compatibility details.

## Dependency and quality changes

- Upgrade Vitest to 4.1.11 and migrate the mutation runner configuration. Stryker 10 supports
this Vitest version; its initial instrumented test run was verified.
- Migrate the app from Tailwind 3 to Tailwind 4's Vite plugin, removing the old watcher/glob
dependency chain that introduced the unpatched `braces` advisory. Regenerate the lockfile.
- The full workspace dependency audit reports **zero advisories**, including development
dependencies, at the time of verification. This does not guarantee future advisories are absent.
- Replace placeholder lint commands with ESLint and enforce React hook ordering. Fix early
returns in PropertiesModal and JsonIOModal that could change hook order across renders.
- Add a GitHub Actions workflow for Go build, vet, race tests and fuzz smoke; Studio frozen
installation, lint, type checking, tests, Stryker dry-run, build and dependency audit.
Actions use immutable commit pins and read-only repository permissions. Go setup selects
the latest patch for the declared toolchain; Studio CI uses Node 24 and pnpm 10.11.0.

The Studio development workspace now requires Node 22 or later. Tailwind 4 requires modern
browsers; legacy browser support was not validated. The standalone app owns the Tailwind setup;
the published editor's React peer dependency remains unchanged.

## Performance changes and measurements

- Compare unchanged history sections by identity and changed sections by deep equality instead
of serializing the entire graph twice per comparison. Undo checkpoints still clone their data.
- Group ordered transitions by source once during serialization, eliminating a full transition
scan for each reality while preserving ordering.
- Load ELK asynchronously on the first layout request. Concurrent requests share its instance;
a failed load can be retried. Existing automatic layout behavior is preserved, so apps requesting
layout on mount still fetch the chunk then.

Production app build, compared with PR #22:

| Asset | Before | After |
| --- | ---: | ---: |
| Main JavaScript, minified | 2,283.01 kB | 802.13 kB |
| Main JavaScript, gzip | 676.52 kB | 225.09 kB |
| Separate ELK JavaScript, minified | Included above | 1,458.21 kB |
| CSS, minified | 45.52 kB | 68.40 kB |

The main JavaScript entry is about 65% smaller. ELK remains a large dependency; total downloaded
JavaScript is similar when layout is requested. Tailwind 4 increased the generated CSS size.

A local reducer benchmark used generated graphs with 256-character metadata per reality,
20 coalesced machine-ID edits, and the median of three runs with Node 24.19.0. Both versions
used their built CommonJS package on the same machine:

| Realities | Before | After |
| ---: | ---: | ---: |
| 100 | 6.41 ms | 0.40 ms |
| 1,000 | 49.40 ms | 2.96 ms |
| 5,000 | 250.12 ms | 21.39 ms |

These are measurements of that scenario, not a general latency guarantee. Checkpoint cloning
and other graph operations still have costs proportional to graph size.

## Validation

All checks below passed locally:

- Go build and vet; race-enabled tests with coverage across all 11 packages.
- Three native fuzz smoke targets, five seconds each, with two workers.
- Snapshot regressions for precise nested metadata, accumulated events, JSON persistence and
restoration, detached data, serialization errors and callback capture without lock reentry.
- Frozen pnpm installation; workspace lint and type checking; **255 tests in 46 files**;
all three package builds; dependency audit with zero advisories.
- Stryker dry-run: 16 tests passed with instrumented source. No full mutation campaign or
mutation score is claimed.
- Chromium browser checks at 1440×900 and 390×844: rendered canvas, JSON export parsing,
library opening/closing and auto layout. Production preview additionally fetched the
separate ELK chunk successfully. No console or page errors occurred in these flows.
- Desktop baseline comparison preserved header geometry. Mobile rendering remains cramped
in the existing header; this is not a responsive-layout redesign. Screenshots and command
logs were retained in the review workspace.

The remaining callback ownership and asynchronous invoke lifecycle contracts described in the
initial review still apply. No production deployment or compatibility test against every
embedding application was performed.
25 changes: 25 additions & 0 deletions docs/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,31 @@ Zero changes to the JSON definition. The existing `on.create-form` transition wi
Use `qm.LoadSnapshot(snapshot, machineContext)` to restore a machine. Snapshots capture the latest
machine context metadata but you must provide any external context objects when reloading.

For persistence, use the optional checked capture capability:

```go
snapshot, err := instrumentation.GetSnapshotWithError(qm)
if err != nil {
return err
}
encoded, err := snapshot.ToJson()
```

The experimental runtime returns a complete snapshot or an error. Unsupported JSON values in
metadata or event data (such as channels, functions, cycles, or non-finite floats) fail capture.
The legacy `GetSnapshot()` method logs the failure and returns `nil`; check that result before use.
Existing third-party implementations do not need to add a method to the machine interface. The
checked helper returns an unsupported-capability error when a provider lacks `SnapshotProvider`.

Snapshot capture, `json.Unmarshal` into `MachineSnapshot`, and `LoadSnapshot` preserve numeric
values through JSON round-trips. Ordinary values retain the previous `float64` representation;
integers and decimals whose value would change through that conversion retain `json.Number`.
Consumers of untyped metadata must support both representations. This includes integers above
the exact float64 range and long decimal fractions. Captured maps remain detached from live data.

Inside a synchronous action, call `instrumentation.GetSnapshotWithError(args)` using the callback
arguments. Calling the owning machine directly would try to reacquire its execution lock.

`qm.ReplayOnEntry(ctx)` re-executes entry actions and invokes for every active universe without
changing reality assignments. This is useful when you need to re-run side effects after downtime.

Expand Down
31 changes: 20 additions & 11 deletions experimental/laws.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package experimental

import (
"fmt"
"log/slog"
"sync"

Expand Down Expand Up @@ -131,17 +132,18 @@ func (o *observerExecutorArgs) UpdateUniverseMetadata(md map[string]any) {
// --------- ActionExecutorArgs ---------//

type actionExecutorArgs struct {
context any
realityName string
universeCanonicalName string
universeID string
universeMetadata map[string]any
metadataMu *sync.Mutex
event instrumentation.Event
action theoretical.ActionModel
actionType instrumentation.ActionType
getSnapshotFn func() *instrumentation.MachineSnapshot
emittedEvents *[]instrumentation.EmittedEvent
context any
realityName string
universeCanonicalName string
universeID string
universeMetadata map[string]any
metadataMu *sync.Mutex
event instrumentation.Event
action theoretical.ActionModel
actionType instrumentation.ActionType
getSnapshotFn func() *instrumentation.MachineSnapshot
getSnapshotWithErrorFn func() (*instrumentation.MachineSnapshot, error)
emittedEvents *[]instrumentation.EmittedEvent
}

func (a *actionExecutorArgs) GetContext() any {
Expand Down Expand Up @@ -176,6 +178,13 @@ func (a *actionExecutorArgs) GetSnapshot() *instrumentation.MachineSnapshot {
return a.getSnapshotFn()
}

func (a *actionExecutorArgs) GetSnapshotWithError() (*instrumentation.MachineSnapshot, error) {
if a.getSnapshotWithErrorFn == nil {
return nil, fmt.Errorf("snapshot provider does not support capture errors")
}
return a.getSnapshotWithErrorFn()
}

func (a *actionExecutorArgs) GetUniverseMetadata() map[string]any {
return metaGet(a.metadataMu, a.universeMetadata)
}
Expand Down
53 changes: 39 additions & 14 deletions experimental/machine.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ func NewExQuantumMachine(qmm *theoretical.QuantumMachineModel, universes []*ExUn

u.constantsLawsExecutor = qm
u.getSnapshotFn = qm.snapshotUnlocked
u.getSnapshotWithErrorFn = qm.snapshotUnlockedWithError
qm.universes[u.model.ID] = u
}

Expand Down Expand Up @@ -169,16 +170,39 @@ func (qm *ExQuantumMachine) LoadSnapshot(snapshot *instrumentation.MachineSnapsh
}

func (qm *ExQuantumMachine) GetSnapshot() *instrumentation.MachineSnapshot {
snapshot, err := qm.GetSnapshotWithError()
if err != nil {
slog.Error("snapshot capture failed", "error", err)
}
return snapshot
}

// GetSnapshotWithError returns a complete snapshot or an error, never a partial snapshot.
func (qm *ExQuantumMachine) GetSnapshotWithError() (*instrumentation.MachineSnapshot, error) {
if qm == nil {
return nil, fmt.Errorf("quantum machine must not be nil")
}
qm.quantumMachineMtx.Lock()
defer qm.quantumMachineMtx.Unlock()
return qm.snapshotUnlocked()
return qm.snapshotUnlockedWithError()
}

func (qm *ExQuantumMachine) snapshotUnlocked() *instrumentation.MachineSnapshot {
snapshot, err := qm.snapshotUnlockedWithError()
if err != nil {
slog.Error("snapshot capture failed", "error", err)
}
return snapshot
}

func (qm *ExQuantumMachine) snapshotUnlockedWithError() (*instrumentation.MachineSnapshot, error) {
var machineSnapshot = &instrumentation.MachineSnapshot{}

for _, u := range qm.universes {
universeSnapshot := u.getSnapshot()
universeSnapshot, err := u.getSnapshotWithError()
if err != nil {
return nil, fmt.Errorf("capture snapshot for universe '%s': %w", u.model.ID, err)
}

// add snapshot
machineSnapshot.AddUniverseSnapshot(u.model.ID, universeSnapshot)
Expand All @@ -205,7 +229,7 @@ func (qm *ExQuantumMachine) snapshotUnlocked() *instrumentation.MachineSnapshot
machineSnapshot.AddTracking(u.model.ID, cloneStringSlice(u.tracking))
}

return machineSnapshot
return machineSnapshot, nil
}

func (qm *ExQuantumMachine) ReplayOnEntry(ctx context.Context) error {
Expand Down Expand Up @@ -531,17 +555,18 @@ func (qm *ExQuantumMachine) executeAction(ctx context.Context, model *theoretica
}

a := &actionExecutorArgs{
context: args.Context,
realityName: args.RealityName,
universeCanonicalName: args.UniverseCanonicalName,
universeID: args.UniverseID,
universeMetadata: u.metadata,
metadataMu: &u.metadataMu,
event: args.Event,
action: *model,
actionType: actionType,
getSnapshotFn: qm.snapshotUnlocked,
emittedEvents: args.EmittedEvents,
context: args.Context,
realityName: args.RealityName,
universeCanonicalName: args.UniverseCanonicalName,
universeID: args.UniverseID,
universeMetadata: u.metadata,
metadataMu: &u.metadataMu,
event: args.Event,
action: *model,
actionType: actionType,
getSnapshotFn: qm.snapshotUnlocked,
getSnapshotWithErrorFn: qm.snapshotUnlockedWithError,
emittedEvents: args.EmittedEvents,
}

if fn := builtin.GetAction(model.Src); fn != nil {
Expand Down
Loading
Loading