| 1 | #!/usr/bin/env python3 |
| 2 | """Generate rollback fixtures with the tagged Desktop storage implementations. |
| 3 | |
| 4 | Run from any directory. Historical source is transient; generated files contain |
| 5 | only synthetic identities and content. No user home or application is opened. |
| 6 | """ |
| 7 | import pathlib |
| 8 | import shutil |
| 9 | import subprocess |
| 10 | import tempfile |
| 11 | |
| 12 | ROOT = pathlib.Path(__file__).resolve().parents[1] |
| 13 | DESKTOP = ROOT / "desktop" |
| 14 | OUTPUT = DESKTOP / "testdata" / "manual-session-upgrade" |
| 15 | |
| 16 | |
| 17 | def git(*args): |
| 18 | return subprocess.check_output(["git", *args], cwd=ROOT) |
| 19 | |
| 20 | |
| 21 | for version, tag in [(v, "desktop-v" + v) for v in ("1.38.9", "1.38.10", "1.38.11")] + [ |
| 22 | ("main-v2-pr10469", "e8762acc9"), ("main-v2-pr10572", "54826e46a")]: |
| 23 | output = OUTPUT / version |
| 24 | output.mkdir(parents=True, exist_ok=True) |
| 25 | temporary = pathlib.Path(tempfile.mkdtemp(prefix="rollbackfixture", dir=DESKTOP / "internal")) |
| 26 | try: |
| 27 | imports = ['"context"', '"os"', '"path/filepath"', 'currentstate "reasonix/desktop/internal/workspacestate"'] |
| 28 | prefix = "reasonix/desktop/internal/" + temporary.name |
| 29 | for package in ("workspacestate", "draftstate"): |
| 30 | source = "desktop/internal/" + package |
| 31 | paths = git("ls-tree", "-r", "--name-only", tag, source).decode().splitlines() |
| 32 | paths = [p for p in paths if str(pathlib.PurePosixPath(p).parent) == source |
| 33 | and p.endswith(".go") and not p.endswith("_test.go")] |
| 34 | if not paths: |
| 35 | continue |
| 36 | destination = temporary / package |
| 37 | destination.mkdir() |
| 38 | for path in paths: |
| 39 | (destination / pathlib.Path(path).name).write_bytes(git("show", tag + ":" + path)) |
| 40 | imports.append(f'{package} "{prefix}/{package}"') |
| 41 | body = ''' |
| 42 | ctx:=context.Background(); output:=os.Args[1] |
| 43 | registry:=workspacestate.NewStore(filepath.Join(output,"workspace-state.json")) |
| 44 | must(registry.EnsureWorkspace(ctx,workspacestate.Workspace{ID:"global",Root:"/fixture/workspace",Title:"Synthetic upgrade fixture",Visible:true})) |
| 45 | must(registry.AttachSession(ctx,"","global","existing-session","")) |
| 46 | must(registry.BeginCreate(ctx,workspacestate.PendingCreate{OperationID:"interrupted-create",WorkspaceID:"global",SessionID:"reserved-session"})) |
| 47 | // Upgrade a separate copy, then exercise the real tagged reader and writer. |
| 48 | roundtrip,err:=os.MkdirTemp("","reasonix-registry-roundtrip-");must(err);defer os.RemoveAll(roundtrip) |
| 49 | source,err:=os.ReadFile(filepath.Join(output,"workspace-state.json"));must(err) |
| 50 | checkPath:=filepath.Join(roundtrip,"workspace-state.json");must(os.WriteFile(checkPath,source,0600)) |
| 51 | current:=currentstate.NewStore(checkPath);must(current.AttachSession(ctx,"","global","new-version-session","")) |
| 52 | old:=workspacestate.NewStore(checkPath);_,oldReadError:=old.Load(ctx) |
| 53 | ''' |
| 54 | if version == "1.38.9": |
| 55 | body += 'if oldReadError==nil {panic("1.38.9 unexpectedly accepted registry v3")}\n' |
| 56 | else: |
| 57 | body += '''must(oldReadError) |
| 58 | must(old.AttachSession(ctx,"","global","continued-in-old-version","")) |
| 59 | reupgraded,err:=currentstate.NewStore(checkPath).Load(ctx);must(err) |
| 60 | found:=false;for _,id:=range reupgraded.Workspaces["global"].SessionIDs {if id=="continued-in-old-version" {found=true}} |
| 61 | if !found {panic("old-version mutation lost on reupgrade")} |
| 62 | ''' |
| 63 | if (temporary / "draftstate").exists(): |
| 64 | imports.append('"fmt"') |
| 65 | body += ''' |
| 66 | drafts:=draftstate.New(filepath.Join(output,"drafts.sqlite")); defer drafts.Close() |
| 67 | for _,phase:=range []string{"editable","settings-only","converted","reserved","starting","dispatching","dispatching_shell","dispatch_unknown","accepted","cancel_requested","resume_required","failed","cancelled"} { |
| 68 | draft,_,err:=drafts.Open(ctx,"workspace-"+phase,"project","/fixture/"+phase,"draft-"+phase,`{"model":"fixture/model","modelSource":"explicit","mode":"normal","toolApprovalMode":"ask","disabledMcp":{},"mcpOrder":[]}`);must(err) |
| 69 | if phase=="settings-only" {continue} |
| 70 | revision:=draft.Revision |
| 71 | draft,err=drafts.Save(ctx,draft.ID,revision,`{"text":"unsent upgrade fixture","attachments":[{"path":"missing-historical-file"}],"future":{"preserve":true}}`,draft.SettingsJSON,false);must(err) |
| 72 | if phase=="editable" {_,err=drafts.Save(ctx,draft.ID,revision,`{"text":"losing historical writer"}`,draft.SettingsJSON,false);if err==nil {panic("expected conflict")};continue} |
| 73 | frozen:=fmt.Sprintf(`{"snapshotVersion":%d,"draftId":%q,"expectedRevision":%d,"content":{"text":"frozen historical request"},"settings":{"model":"fixture/frozen","modelSource":"explicit","toolApprovalMode":"read-only"},"futureSnapshot":{"preserve":true}}`,draftstate.SnapshotVersion,draft.ID,draft.Revision) |
| 74 | op,_,err:=drafts.BeginOperation(ctx,draftstate.Operation{ID:"operation-"+phase,DraftID:draft.ID,WorkspaceID:draft.WorkspaceID,DraftRevision:draft.Revision,SessionID:"session-"+phase,TopicID:"topic-"+phase,SubmissionID:"submit-"+phase,Fingerprint:"synthetic",RequestJSON:frozen});must(err) |
| 75 | if phase=="converted" {_,err=drafts.SetOperationPhase(ctx,op.ID,"accepted","");must(err);must(drafts.Convert(ctx,draft.ID,op.ID))} else if phase!="reserved" {_,err=drafts.SetOperationPhase(ctx,op.ID,phase,"");must(err)} |
| 76 | } |
| 77 | ''' |
| 78 | (temporary / "main.go").write_text("package main\nimport (\n" + "\n".join(imports) + "\n)\nfunc must(err error){if err!=nil{panic(err)}}\nfunc main(){" + body + "}\n") |
| 79 | # A fresh output avoids idempotently reopening an earlier fixture. |
| 80 | for name in ("workspace-state.json", "drafts.sqlite", "drafts.sqlite-wal", "drafts.sqlite-shm"): |
| 81 | (output / name).unlink(missing_ok=True) |
| 82 | subprocess.run(["go", "run", "./internal/" + temporary.name, str(output)], cwd=DESKTOP, check=True) |
| 83 | print(f"PASS {tag}: fixture generation and registry downgrade/reupgrade boundary", flush=True) |
| 84 | (output / "SOURCE").write_text(tag + "\n" + git("rev-parse", tag + "^{commit}").decode()) |
| 85 | for lock in output.glob("*.lock"): |
| 86 | lock.unlink() |
| 87 | finally: |
| 88 | shutil.rmtree(temporary) |
| 89 |