Workspaces, revisions and recovery¶
A Studio workspace is an append-only list of revisions. Saving adds one; it
never rewrites one. Schema studio.workspace.v1.
Four failures this replaces, each reproduced against the store as it stood before the replacement was written:
| Failure | What happened | What happens now |
|---|---|---|
| Lost update | Two editors loaded the same workspace and both saved. The second save replaced the first, with nothing to notice it by | A save states the revision it was made from; a save from a stale revision is refused with HTTP 409 |
| Unrecoverable delete | delete_project removed the file |
Deletion moves the workspace to a trash it can be restored from |
| Torn write | A save truncated the live file and wrote into it. Under a real RLIMIT_FSIZE refusal a 156-byte workspace became 65,536 bytes of unterminated JSON, and loading it raised |
A revision is written to a sibling temporary file, fsynced and os.replaced into place; a failure leaves the previous revision untouched |
| Lost update through the check | Two savers read the same head between them, both computed revision 1, both were acknowledged with different digests, and one payload was overwritten | Reading the head and writing the revision happen while the workspace is held against every other writer, in this process and in others |
On disk¶
<projects root>/
<name>/
head.json which revision is current
revisions/1.json immutable
revisions/2.json immutable
.trash/
<name>.<ms>-<random>/ a deleted workspace, restorable
.locks/
<name>.lock.sqlite3 holds the workspace while one writer works on it
<name>.legacy-retired prevents re-adoption of a deleted legacy workspace
os.replace is atomic on POSIX: a reader sees the old bytes or the new ones,
never half of either. The directory is fsynced after the rename, so the
rename itself survives a power loss.
Trash tokens are opaque; the random suffix keeps two deletions under an equal
clock reading separate. Older <name>.<ms> tokens remain readable and restorable.
Deletion of an adopted workspace preserves the original flat JSON file and first
writes a durable retirement marker in .locks/. Reads cannot resurrect that
older copy after deletion or restart. Restore uses the saved revision directory,
not the flat migration source. Include .locks/ in workspace backups. Rolling
back to a version without retirement-marker support can re-adopt deleted legacy
files; restore or archive those migration sources before such a rollback.
A revision document is also a valid project payload — it carries name,
saved_at, version and state — so the evidence bundle reads a revision
directly rather than a second copy that could disagree with it.
One writer at a time¶
Refusing a stale save is only a refusal if reading the head and writing the next revision happen together. Every operation that writes a workspace — save, fork, import, delete, restore, and the adoption of a pre-revision file — holds that workspace for as long as it needs it, and so does every read a writer depends on.
The exclusion is a SQLite BEGIN IMMEDIATE on a small database of its own, one
per workspace, which is the primitive the job ledger already relies on for
single-host serialisation:
- it is enforced by the operating system, so two server processes over one project root exclude each other, not only two threads of one process;
- it is released when the connection closes and when the process dies, so a crashed or killed worker cannot leave a workspace permanently locked;
- the wait is bounded. A writer that cannot take the workspace within the
wait is answered
503witherror: workspace_busyand writes nothing, so the same request can simply be retried; a request thread is never blocked indefinitely by another writer.
The lock databases live in .locks/ beside the workspaces rather than inside
one, because deleting a workspace moves its directory into the trash: a lock
kept inside would vanish underneath the writer holding it.
A revision number is never reused. A writer that died between writing its
revision file and writing the head leaves a revision the head does not point
at; the next save numbers itself above the highest revision on disk rather than
above the head, so that stored state is never written over. It stays readable
through GET /api/project/load/{name}?revision=N and appears in the history.
Operator limitation, stated rather than implied. This serialises writers that share a filesystem implementing SQLite's locking — one host, as for the job ledger. Two hosts over one network share are outside what it can promise. Deploy one host per project root.
Saving against a revision¶
POST /api/project/save takes expected_revision: the revision the caller
loaded and edited.
- A number — save on top of that revision. If the workspace has moved on, the save is refused.
- Omitted or
null— a claim that the workspace is new. Over an existing workspace this is also a conflict, because the caller did not know it was there.
A refusal is HTTP 409 with the revision that is actually current:
{
"detail": {
"actual_revision": 2,
"error": "workspace_conflict",
"expected_revision": 1,
"reason": "the workspace moved to revision 2 while you were editing revision 1; reload and reapply your change."
}
}
The conflict is translated once, in the Studio API error boundary, because
WorkspaceConflict is a ValueError: a route that forgot it would report
"invalid input" for a save that was perfectly valid and merely arrived second.
The Studio frontend carries the revision it loaded or last wrote and sends it with every save. It does not adopt the revision a conflict reports — saving again with the state it still holds is exactly the lost update the conflict prevents. Reload, reapply, save.
Keeping the edit that was refused¶
Refusing the save protects the other editor's work, and on its own it leaves the refused edit in one browser and nowhere else: "reapply your change" means retype it, and a closed tab loses it. That is a second way to lose an update, slower than the first.
POST /api/project/{name}/branch-refused-edit stores the refused state as the
first revision of its own workspace, named for what it diverged from —
column (from revision 1). Both edits then exist as revisions and either can
be loaded, exported or compared; nothing merges them automatically, because a
merge of two Studio states is a scientific judgement and not a textual one.
It refuses a base_revision that does not exist — an edit that diverged from
nothing is not a divergence — and it never overwrites an existing workspace, so
a second conflict cannot bury the first branch.
A 503 is a different answer and the editor says so differently: nothing was
written, no other editor's work is at stake, and the same save can simply be
sent again. Treating it like a conflict would push a user into reloading and
reapplying work that never conflicted with anything.
{
"detail": {
"error": "workspace_busy",
"name": "shared",
"reason": "another writer held workspace 'shared' for longer than 10.0 seconds; nothing was written, so the same save can be retried.",
"timeout_seconds": 10.0
}
}
History, forks and transfer¶
| Route | What it does |
|---|---|
GET /api/project/{name}/revisions |
Every revision, oldest first, with its parent and state digest |
GET /api/project/load/{name}?revision=N |
One revision as it was written; no later save can have altered it |
POST /api/project/{name}/fork (new_name, optional revision) |
Copies one revision into a new workspace at revision 1, leaving the source alone |
GET /api/project/{name}/export |
A self-contained transfer document |
POST /api/project/import (name, document) |
Creates a workspace from one |
Forking or importing onto a name that is already in use is refused, not merged.
Deleting and restoring¶
DELETE /api/project/{name} moves the workspace, with its whole history, into
the trash and returns recoverable: true. GET /api/project/deleted lists
what is waiting, newest first, each with a token; POST /api/project/restore
takes that token and brings the workspace back under its original name with
every revision it had.
Restoring onto a name that is live again is refused. Overwriting a live workspace is the loss this store exists to prevent, and a refusal leaves both the live workspace and the trashed one intact.
The Studio projects panel shows the trash under Deleted (restorable) with a restore action per entry, so recovery does not require an API call.
Nothing leaves the trash on its own. Retention is an operator decision: remove a trashed directory to purge it, and it is gone.
Reading a stored workspace¶
A workspace written by a newer schema is refused rather than downgraded — dropping fields a future build added would lose a user's work quietly. Upgrade the package instead.
Workspaces saved before revisions existed¶
The previous store kept one flat <name>.json per workspace in the project
root. Those files are adopted on first access: the document becomes revision 1
(marked adopted_from: studio.project-save.v0) and the flat file is left on
disk untouched — an adoption that writes is reversible by deleting the
workspace directory, one that deleted would not be. Adoption is idempotent and
happens on read, list, save and history alike, so nothing an existing
installation saved goes missing.
The first save after an adoption states revision 1, like any other save. Saving without stating it is refused, as it is for any existing workspace.
A file in the project root that is not a readable workspace — a stray
notes.txt, a truncated JSON file — is adopted by nobody, left exactly where it
is, and never allowed to take down the listing.
A revision file that cannot be read is omitted from the history listing rather than reported as an empty workspace, and every error message about a stored revision is path-free.