Projects and workspaces¶
octomate.managers.project ¶
The project registry: the code locations declared in the projects: block.
ProjectManager ¶
Bases: Manager
The project registry: every code location Octomate knows by name.
A project gets here one way: the operator declares it in the projects: block,
which reconcile upserts at startup. Declaring is a trust act — a declared
project's AGENTS.md reaches an agent as instructions — so a session running
where nothing is declared files its work under no project rather than minting one.
Every enabled project resolves. Projects are expected to remain a small registry and are all cached.
Note the name collision: projects: here is the operator's registry of code
locations, and is unrelated to ~/.claude/projects/, which is where Claude
Code stores transcripts.
Source code in octomate/managers/project.py
15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 | |
get ¶
The project called name, or None if none is registered as it. Disabled
ones answer too — a thread filed under one still has to say where it was.
Reads the cache the registry keeps, so it does no IO and is safe from a sync caller.
Source code in octomate/managers/project.py
of
async
¶
The project this thread's work runs in, or None when it runs in none.
The thread is where a project is bound, so its attribution is the question; whether that attribution still names somewhere an agent may run is this registry's answer, and the two are not the same. A thread naming a project the block no longer declares is in none, and so is one whose project is disabled — the row is retained either way, because it still has to say where the work was filed, but neither has a root left to offer.
Read through the registry rather than off the row, which is exactly why this
is not just thread.project.
Source code in octomate/managers/project.py
list ¶
resolve ¶
The name of the project cwd is in, or None if none holds it yet.
Resolved before comparing, because is_relative_to is lexical: /tmp against
a registered /private/tmp, or a symlinked home, would otherwise miss silently.
Source code in octomate/managers/project.py
load
async
¶
Read every stored project into the cache and the resolution index.
One pass at startup, because a project resolves by existing: nothing narrows the registry to a subset that some other source still vouches for.
Source code in octomate/managers/project.py
reconcile
async
¶
Upsert the declared block into the registry, then index what disk still has.
A declaration is matched to its row by root, not by name: the root is the
unique column and the identity, so declaring a directory that already has a
row — left by an earlier block, or by the era when sessions registered
projects — adopts that row under the declared name rather than colliding with
it on the way to a second row for one directory.
A row the block no longer declares is left exactly as it is. The threads and runs recorded under it still name it, so dropping a declaration is not a request to forget the history filed there.
What does get cleared is enabled, for every row whose root is no longer a
directory on disk — a deleted checkout, an unmounted drive. The row stays, so
the threads and runs that name it still read back, and it re-enables by itself
the moment the directory is there again.
Both conflict checks run over the reconciled registry rather than over the declared block, because a declaration collides with a row an earlier block left behind as readily as with another declaration, and the operator reading the error needs to be told which two.
Source code in octomate/managers/project.py
index ¶
Rebuild the by-name map and the resolution index from projects.
Every project is kept by name, disabled ones included, so a thread filed under one still reads back and its name stays taken. Only enabled projects get roots: a directory that is gone resolves nothing, and a run must not be sent there.
Roots are sorted deepest first, so the first hit is the longest prefix and a monorepo plus a package inside it can both be projects.
Source code in octomate/managers/project.py
octomate.managers.workspaces.base ¶
Thread workspaces: the forks of a project mirror a run happens in, and their lifecycle from open to save to release.
CopyError ¶
Workspace
dataclass
¶
Bases: ABC
The tree one run happens in, and what becomes of it when the run ends.
Handed out by WorkspaceManager.open and entered by the run that was given it.
Knowing where it is costs nothing — the path is the thread's — so a caller has
it before anything is forked, which is what lets a runtime be configured with
its own working directory before the process that will run there exists.
Two variants, and they differ in exactly three things: where the tree lives, which mirror it is forked from, and whether leaving it keeps or throws away what the run did. Everything else — the fork itself, the branch it lands on, the lock that stops two turns forking it twice — is one mechanism on the manager, and both go through it.
Source code in octomate/managers/workspaces/base.py
path
abstractmethod
property
¶
Where this run happens. Absolute, because it becomes the working directory of a process Octomate did not start in its own, and known without touching the disk.
ProjectWorkspace
dataclass
¶
Bases: Workspace
This thread's own checkout of its project, at workspaces_dir/<thread_id>.
A thread runs here rather than in project.root, so two threads on one project
cannot walk over each other's uncommitted work — and the root stays the
person's, untouched by anything an agent does.
Source code in octomate/managers/workspaces/base.py
prepare
async
¶
This thread's checkout, resumed as it stands or forked when it is gone.
Only a thread's first turn does any of that. A workspace that exists is
resumed into as it stands, and its mirror is not synced for it either: a
workspace is not re-made from the mirror once it is there, so a fetch would
buy the turn nothing and would couple continuing a thread to the upstream
being reachable. materialize answers an existing workspace anyway; the
check is here to keep the sync in front of it from running — and to stamp
the workspace, which is what tells the sweep this thread is still running.
Source code in octomate/managers/workspaces/base.py
ChatWorkspace
dataclass
¶
Bases: Workspace
The tree a thread in no project runs in: a fork of the empty repository, at
workspaces_dir/chat/<thread_id>, gone when the run ends.
A workspace rather than a bare directory, so a run in no project is a run like
any other — it may write, unpack, and script inside its own tree, bounded by
the same directory every run is bounded by. Forking nothing is what makes that
affordable: an empty repository costs a git init's worth of disk, and binding
to a project stops meaning "you may write now" and starts meaning "your work is
kept now".
Under chat/ rather than among the project workspaces, because up there the
directory's existence is the answer to "is this thread's workspace already
forked": a chat fork left at path(thread_id) would be resumed into by the same
thread's first project turn, which would then run in an empty repository instead
of the project's code.
The path is the thread's and does not change between turns, even though what is
in it does. Both runtimes key a resumable session by the directory it ran in —
Claude's --resume answers "no conversation found" from anywhere else — so a
tree named after the run rather than the thread would be a run that cannot
continue the conversation it belongs to.
Source code in octomate/managers/workspaces/base.py
prepare
async
¶
An empty tree of this run's own, forked from the blank mirror.
Claimed first, which is what throws away whatever a killed run left at this path: nothing resumes a chat workspace, so anything there when nobody is in it belongs to a run that is over.
Nothing here is ever saved. The blank mirror would otherwise grow a ref per conversation for work nobody intends to keep, which is the opposite of what a mirror is for.
Source code in octomate/managers/workspaces/base.py
WorkspaceManager ¶
Every project-bound thread's workspace: a fork of the project's mirror at
workspaces_dir/<thread_id>, checked out on the thread's own branch and
released when the disk is wanted back.
A fork is a complete, independent repository — its own .git, its own object
store — made by cloning the mirror's extents where the filesystem does
copy-on-write and by git clone where it does not. Never a git worktree,
whose .git is a file pointing back at the mirror: commits in one write
outside the workspace, which an agent's write sandbox refuses.
A workspace is a cache rather than the only copy: each turn's tree is snapshotted and pushed to the mirror as the thread's own ref, so releasing one is a disk decision and resuming lays that ref back over a fresh fork. What the agent did with git in there is the agent's — nothing here commits.
A thread in no project gets one too, at workspaces_dir/chat/<thread_id>,
forked from the blank mirror and thrown away when the run that made it ends.
Same mechanism, opposite ending: there is no project to keep it for, so binding
to one is what turns a run's tree into work that outlives the run.
The whole lifecycle is here. A turn asks open for the workspace its thread
runs in and enters it, which is what forks the tree and what ends it; the turn
itself owes one more thing, save, which the graph does after the run — or
dismiss, once the agent has said the thread's work is done. Those
need the registry to say which project a thread is in and the mirrors to say
where that project's is, which is why this holds both — which fork a thread gets
is exactly what the registry says about it, so knowing what binds one is not a
dependency it is reaching for. The layer below stays free of it: every method
that touches git takes a mirror path and answers for a directory.
Source code in octomate/managers/workspaces/base.py
247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 | |
identity
property
¶
Who the machine is when it writes a snapshot — the mirrors: block's,
read rather than copied, so a turn's work and a folder sync into a mirror
carry one name and cannot drift into two.
path ¶
Where this thread's workspace lives. Absolute, because it becomes the working directory of a process Octomate did not start in its own.
Source code in octomate/managers/workspaces/base.py
existing ¶
This thread's workspace if it is already forked, else None — what saves a resumed turn the fork and the sync in front of it.
Finding one stamps it as used, so the sweep measures idleness from the turn that just asked rather than from the last one that wrote something.
Source code in octomate/managers/workspaces/base.py
open ¶
The workspace a run in this thread happens in, ready to be entered.
Which one it is, is the whole of what a project decides about a run: a thread in one gets a checkout of it that outlives the turn, and a thread in none gets an empty tree that does not. Nothing else about the two runs differs — both may write where they are, and both are bounded by it.
project is passed rather than looked up because the caller has already
asked, and the answer decides more than the directory. No disk is touched
here: the path is settled, so a runtime can be told where it will run before
anything is forked, and the fork happens when the run enters it.
A thread in no project used to run in the agent's configured cwd, which
defaults to ".": on a server, Octomate's own install directory.
Source code in octomate/managers/workspaces/base.py
claim
async
¶
Count this run into workspace, and clear whatever the last one left.
Counted, because a mid-run follow-up starts its turn before the turn it supersedes has finished losing its own: both runs are in this tree, and the first to leave must not take the floor out from under the second. They share it while they overlap, which is what two runs of one conversation are entitled to.
A tree nobody is in belongs to a run that is over — one that was killed before it could discard — so it goes, and this run forks its own. That is also why a chat workspace needs no staging directory to be forked through: nothing ever resumes into one, so a fork that died half-made is thrown away here rather than mistaken for a tree.
Source code in octomate/managers/workspaces/base.py
discard
async
¶
Give this tree back to the disk, once no run is still in it.
Source code in octomate/managers/workspaces/base.py
detect
async
¶
The cp flag that forks without copying bytes on this host, or None
where nothing does and workspaces are cloned instead.
Probed once, by actually cloning a file where workspaces land: the answer belongs to that filesystem rather than to the platform, and it cannot change under a running host. A host with no copy-on-write filesystem is not a failure — it pays a full tree per workspace and works.
Source code in octomate/managers/workspaces/base.py
materialize
async
¶
Fork mirror into workspace, and answer the tree.
The one place a workspace is made, whichever kind it is: a project's
checkout and a chat thread's empty tree are the same fork of the same
shape, differing only in what they were forked from. Both land on the
thread's own branch, and neither carries the mirror's origin unless the
mirror has a real one to inherit.
A workspace that already exists is answered as it stands — a project thread's live tree, uncommitted work included, where forking again would throw that work away, and for a chat thread the tree an overlapping run of the same conversation is already in. Serialized per thread, so two turns arriving together materialize once.
The fork is made beside the workspace and moved into place, so a workspace only ever exists whole: a rename within one directory is atomic, where a copy the machine dies in the middle of would leave half a tree that every later turn reads as this thread's own. That is what makes "the directory is there" mean "the workspace is there", here and for a caller that asks the same question. A fork that merely fails is removed the same way, before the error travels on.
It is installed before it is moved into place, so a workspace is ready to run the moment it exists. A copy inherits the mirror's installed trees and that costs nothing; a clone carries no untracked file, so an ext4 host builds its own out of the store the mirror already warmed.
Source code in octomate/managers/workspaces/base.py
444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 | |
inherit_remotes
async
¶
Give the fork the mirror's own origin, or no remote where the mirror
has none.
Otherwise where a workspace points depends on how it was forked: a copy
inherits the mirror's remotes, and a clone gets an origin pointing at the
mirror itself. An agent asked to push its branch would find a different
answer on a copy-on-write host than on one that clones, which is not
something the host's filesystem gets to decide.
The mirror's origin is the project's real upstream, and is what an agent
means by pushing — a branch it named for the job, on the repository people
will read it in. Nothing Octomate does travels that way: a turn is saved by
pushing to the mirror's path, never through a remote name.
A folder project's mirror has no upstream to inherit, and neither does its
fork. There is nowhere to push a directory, and saying so by having no
remote is clearer to an agent than an origin that fails when it is used.
Source code in octomate/managers/workspaces/base.py
discard_octomate_refs
async
¶
Take Octomate's own refs out of a fresh fork, however it was made.
git clone asks for refs/heads/* and tags, so a cloned fork never had
them. cp does not know what git is: it brings .git across whole, and
with it every other thread's refs/octomate/threads/* — their snapshots
sitting in a workspace that was never told about them, out of git branch
but not out of git log --all. Which of the two a host does is detect's
answer about its filesystem, and what one thread can read of another is not
something the filesystem gets to decide.
The whole namespace rather than the foreign entries: a mirror that somehow
carried a refs/octomate/saved would hand a fresh fork a workspace that
reads as already saved, which the sweep answers by reclaiming work that was
never pushed. Dropping this thread's own along with the rest costs nothing —
the mirror is where it lives, and checkout fetches it back next.
Source code in octomate/managers/workspaces/base.py
checkout
async
¶
Put a freshly forked workspace back the way this thread's last turn left
it, when mirror is keeping a snapshot of it.
A thread whose workspace was pruned resumes here, and resumes into the state it was interrupted in rather than a tidied version of it: the branch goes back to where the agent's HEAD was, which is the snapshot's parent, and the tree it could see is laid down over that from the snapshot itself. The index then drops back to HEAD, so work the agent had not committed reads as uncommitted rather than as staged.
A thread forking for the first time starts on its own branch, where the
mirror's branch is — or where ref names, when the bind asked for one.
The default branch is the wrong answer often enough: continuing someone's
feature branch, reproducing against a tag, working from a PR head.
ref is only consulted by a first fork. A thread whose workspace was
pruned resumes into its own snapshot, which is where its work is, and no
starting point competes with that.
Source code in octomate/managers/workspaces/base.py
snapshot
async
¶
A commit holding this workspace exactly as it stands — staged, unstaged and untracked alike — made without disturbing it.
Built rather than committed, so restoring a workspace is all this costs the repository inside it: HEAD stays where the agent left it, its branch carries the agent's commits and no others, and whatever the agent was in the middle of is not finished on its behalf. Resolving that history is the agent's job; putting the tree back is this one.
The parent is HEAD, so the branch's own history comes along, and the
snapshot belongs to no branch — reachable only from the thread's ref, the
way a stash is reachable only from refs/stash. Its message carries the
branch, which is the one thing a tree and a parent cannot say.
The index it is written through is a copy of the workspace's, which keeps the real one untouched and carries git's stat cache with it, so staging the tree compares timestamps instead of rehashing every file.
Source code in octomate/managers/workspaces/base.py
save
async
¶
Leave this thread's finished turn where losing its workspace cannot cost it: a snapshot of the whole tree, pushed to the project's mirror as the thread's own ref.
Snapshot and push. A fork owns its objects outright — nothing in a workspace survives its removal — so without the push the workspace is the only copy of what the turn did, and reclaiming it would be losing work rather than evicting a cache. That is the whole of what makes a workspace disposable, and why the sweep is a disk decision.
A thread with no workspace — in no project, or one whose turn never forked one — has nothing to leave. Nothing the agent asks for or is told about either: it knows the directory it was given, not that the directory is a fork, and not that the sweep will take it back.
Forced, because the ref records what the workspace holds rather than a history to protect: an amend or a reset inside the workspace moves the ref with it. The mirror is the only place this ever pushes; nothing here reaches the upstream, which sees a thread's work when a person asks for a pull request and not before.
A failure is logged rather than raised. The turn has already happened and already answered; failing it now would report a delivered answer as an error, and the next turn saves the same work again.
Source code in octomate/managers/workspaces/base.py
saved
async
¶
Whether the mirror already holds everything in this workspace: the same tree, on the same commit, as the last push carried.
The question the sweep has to answer before reclaiming anything, and one
git status cannot answer any more — nothing here commits, so a saved
workspace is a dirty workspace and being dirty says nothing. Snapshotting
again and comparing does say it. A push that failed leaves a workspace
that looks saved from the outside, which is why what it is compared
against is a ref the push itself writes.
Source code in octomate/managers/workspaces/base.py
prune
async
¶
Release every workspace nothing has used for idle seconds, and answer
with the threads that lost theirs.
Eviction, not a decision about whether a thread is over: abandonment is unobservable — a thread parked on a question and one silently dropped look identical — so rather than guessing, being wrong here costs the next turn a fork instead of costing anyone their work.
Which is only true of a workspace the mirror already has, so one holding anything unsaved is kept and said out loud. That covers a turn still running, too: it has changed something since its last turn was saved, almost by definition.
Source code in octomate/managers/workspaces/base.py
sweep
async
¶
Prune on the interval, for as long as the host runs.
A sweep that fails is logged and the next one still happens: reclaiming disk is maintenance, and a host that stops serving because it could not tidy up has the priority backwards.
Source code in octomate/managers/workspaces/base.py
carry
async
¶
Give thread, landed in source's project, a workspace holding the work
as source left it — what a conversation moving to another thread takes of
its work — and fork it.
Saved first: the source's last turn may not have been, and what it left
uncommitted has to travel as uncommitted. The snapshot is filed under
thread too, so its first fork resumes into it the way a pruned workspace
does; on a branch of its own when the source was on its own, since two
threads pushing one branch would overwrite each other. A source that never
forked a workspace has no snapshot, and thread forks the project fresh.
Ignored files are copied across, an .env among them, since a snapshot
never holds them. Ignored directories are not: they are dependencies and
build output, which the fork's install and the next build make again.
Source code in octomate/managers/workspaces/base.py
dismiss
async
¶
Release this thread's workspace on its agent's word that the work in it is done — the one release that is asked for rather than swept up — and answer whether it went.
Saved first, and released only when the save took: the sweep's rule, that a workspace holding work the mirror has not seen is never thrown away, does not bend for being asked. So what this costs is what a prune costs, a fork on the thread's next turn with the ref laid back over it.
Source code in octomate/managers/workspaces/base.py
release
async
¶
Give this thread's workspace back to the disk, or nothing when it is already gone — pruning and releasing a thread that never had one both arrive here, and a workspace is a cache either way.
The mirror is untouched: a copied fork owns its objects outright, and a cloned one holds hard links, where unlinking a link leaves the file. In a thread, off the loop: a released tree is as big as the project.
Source code in octomate/managers/workspaces/base.py
thread_ref ¶
Where a thread's work is kept in its project's mirror.
Outside refs/heads/, so the mirror does not grow a branch per thread and
git branch in a fresh workspace shows the project's branches rather than
everyone's threads. That a fork holds none of them at all is
discard_octomate_refs' doing and not this namespace's — a copied fork brings
across whatever the mirror has.
Source code in octomate/managers/workspaces/base.py
thread_branch ¶
copy
async
¶
Copy source to target with flag, raising CopyError with the stderr
when cp exits nonzero.
-a because a fork has to be the tree it was made from: a symlink stays a
symlink, and preserved timestamps keep the copied git index valid, so the
workspace's first git status compares stat data rather than rehashing
every file in the tree.
Source code in octomate/managers/workspaces/base.py
octomate.managers.workspaces.mirrors ¶
Project mirrors: the pristine checkouts workspaces fork from, and the git plumbing that syncs them.
BATCH_GIT_ENV
module-attribute
¶
GitCommandError ¶
MirrorManager ¶
Every project's mirror: a pristine git checkout at mirrors_dir/<project>,
made from the project's upstream and kept current by syncing from it.
A remote upstream is cloned and freshened by fetching; a directory upstream is
git init'd and freshened by staging the folder's state and committing when
anything changed — the same job with a directory in place of a URL, so the
default branch becomes a history of the folder. The folder stays authoritative:
carrying reviewed work back out to it is a deliberate step, not part of syncing.
A mirror stays on its default branch and no run ever writes to it; workspaces fork from it and are disposable.
Source code in octomate/managers/workspaces/mirrors.py
58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 | |
path ¶
Where project's mirror lives — or, with no project, where the blank one
does: the mirror of nothing, which every thread in no project forks.
Absolute, because directory sync points GIT_DIR here while running in the
upstream folder, where a relative path would resolve against the wrong tree.
Source code in octomate/managers/workspaces/mirrors.py
name ¶
What this mirror is filed under, which is also what its lock and its freshness stamp are keyed by. The blank mirror is dot-prefixed, to keep it out of the way of the mirrors it sits among — those are named after projects, and so after directories people actually have.
Source code in octomate/managers/workspaces/mirrors.py
sync
async
¶
The mirror, current enough to fork from: created from the upstream when missing, freshened from it when the freshness window has lapsed.
Serialized per mirror, so a burst of materializations costs one sync: the
making of it waits inside create, and the freshening of it waits here, on
the same lock. Which is why create is called before this takes it —
asyncio.Lock is not reentrant, and both halves are the same mirror's turn.
A freshen that fails degrades to the stale mirror with a warning rather than failing the caller — work continues on what the machine already has, and the stale stamp is not renewed, so the next sync tries again. A mirror that cannot be created is a hard failure: there is nothing to fall back to.
A mirror whose tree just changed is installed as well, which is what makes forking it cheap: the machine's package store is warm and the installed trees themselves are copied. Not on the window's short path, and not on the stale one — nothing moved there, so there is nothing to install.
Source code in octomate/managers/workspaces/mirrors.py
create
async
¶
The mirror, made where there is none, and where it is.
Three shapes, and a project's upstream picks between the first two: a remote
is cloned onto its default branch, and a directory is git init'd with the
folder's contents committed. With no project at all it is the blank mirror,
which is a git init and one empty commit — that commit being what makes it
forkable rather than special, since a repository with no commit clones onto
an unborn branch, where git refuses the ordinary things a run does with one.
Nothing binds to it and it takes no registry row: a thread that forks it is
exactly a thread that is in no project.
Serialized per mirror and idempotent, which is what the blank one needs —
two threads in no project starting at once both ask for it, and a mirror
half-made is a mirror something would fork. sync holds the same lock for
the freshening half, and takes it after this rather than around it.
A partial mirror is worse than none — it would read as existing and turn every later sync into a stale-mirror warning — so a creation that fails or is cancelled removes what it left before the error travels on.
Source code in octomate/managers/workspaces/mirrors.py
freshen
async
¶
Bring an existing mirror up to its upstream: fetch and move the default branch for a remote, stage-and-commit the folder for a directory.
--prune follows upstream branch deletions; thread refs live outside the
fetched refspec, so pruning never touches them. The hard reset is what a
mirror allows that a working checkout would not: nothing else writes
here, so the default branch simply follows upstream, force-pushes included.
Source code in octomate/managers/workspaces/mirrors.py
commit_folder
async
¶
One sync of a directory upstream: stage exactly the folder's state into
the mirror at path, and commit only when anything changed.
Git's own index does the comparison. With GIT_WORK_TREE pointing at the
folder, add -A stages additions, edits, and deletions in one pass while
honoring the folder's own .gitignore — which is what keeps a served
checkout's .octomate/ out of its mirror — and no .git is ever created
in the folder itself. The final reset materializes the commit into the
mirror's tree; it never git cleans, so untracked state the mirror keeps
(a warm node_modules, say) survives every sync.
Source code in octomate/managers/workspaces/mirrors.py
reconcile
async
¶
Sync every enabled project's mirror, as startup runs it over the registry: registering a project is what grants it a mirror, so the mirrors directory agrees with the registry before the first materialization asks.
One unreachable upstream must not stop the rest — each failure is logged and the sweep continues, the same isolation tentacle startup gets.
Source code in octomate/managers/workspaces/mirrors.py
run_git
async
¶
Run one git command and answer its stdout, raising GitCommandError with
the command and its stderr when it exits nonzero.
Source code in octomate/managers/workspaces/mirrors.py
octomate.managers.workspaces.dependencies ¶
A tree's dependencies, installed once and inherited by every fork of it.
uv, pnpm and npm each keep one content-addressed store per machine and
hardlink out of it, so the second install of a package fetches nothing and costs
almost no disk. Nothing here isolates a tree from those stores and nothing
should: leaving them where the tools put them is the whole of why a fresh
workspace installs offline, and it is the constraint on anything that ever puts a
run behind a filesystem boundary.
The rest is one install per ecosystem, run where a tree just changed — in a mirror, so a copied fork arrives ready to run, and in a fork, because a cloned one carries no untracked file and so arrives with nothing installed at all.
Which ecosystem a tree belongs to is its lockfile's answer, and each manager below owns the commands that follow from it. They differ in more than the binary's name: uv needs two commands and a flag to produce an environment that survives being copied, where the node managers need one and would be broken by the flag their own documentation recommends.
PackageManager ¶
Bases: Manager, ABC
One ecosystem's answer to "put this tree's dependencies in place".
A manager is stateless and claims a tree by its lockfile, which is also what
says whether the tree is still current. Everything a manager decides is in its
install; the machinery around it — whether to install at all, and what to
record afterwards — belongs to the module and is the same for all of them.
Source code in octomate/managers/workspaces/dependencies.py
install
abstractmethod
async
¶
Install tree's dependencies, answering whether they are now there.
The answer is what the stamp turns on, so a manager that reports success is promising the tree can be forked and used as it stands.
Source code in octomate/managers/workspaces/dependencies.py
run
async
¶
One command of an install, in tree, answering whether it worked.
A failure is logged rather than raised. The tree is a working checkout
either way and whatever runs there installs for itself, the way every run
did before any of this existed — refusing someone a workspace because the
host has no pnpm would turn a slow first turn into no turn at all.
Source code in octomate/managers/workspaces/dependencies.py
UV ¶
Bases: PackageManager
Python, through uv.
Source code in octomate/managers/workspaces/dependencies.py
install
async
¶
Create a relocatable environment once, then sync its dependencies.
The venv is otherwise the one thing a fork cannot inherit: uv writes an
absolute interpreter path into every console script, so a copied
.venv/bin/<tool> keeps running the mirror's python against the mirror's
site-packages. Nothing errors — the workspace simply is not the
environment in use — and re-syncing does not repair it, since the copy
already satisfies the lockfile and uv has nothing to do.
Made relocatable, the scripts resolve the interpreter beside themselves
and the copy is correct. uv records the choice in pyvenv.cfg and later
syncs keep it. Reuse that environment when the lockfile changes. An
existing environment without the marker is left untouched and reported:
rewriting its configuration would not repair installed console scripts.
Source code in octomate/managers/workspaces/dependencies.py
Pnpm ¶
Bases: PackageManager
Node, through pnpm.
Source code in octomate/managers/workspaces/dependencies.py
install
async
¶
node_modules is a tree of links into the store and copies cleanly, so
there is nothing here but the install.
Npm ¶
Bases: PackageManager
Node, through npm.
Source code in octomate/managers/workspaces/dependencies.py
install
async
¶
install rather than the ci a lockfile would normally call for: ci
begins by deleting node_modules, which is exactly the tree a warm mirror
exists to keep.
Source code in octomate/managers/workspaces/dependencies.py
manager ¶
Whose tree this is, or None where nobody claims it — a plain folder of documents, and the empty tree a thread in no project forks.
Source code in octomate/managers/workspaces/dependencies.py
install
async
¶
Install tree's dependencies, unless its lockfile is still the one they
were last installed from.
That stamp is what makes one call right for both trees this is asked about. A mirror installs when its lockfile moves, which is the only time it needs to. A copied fork inherits the stamp along with the installed tree and does nothing; a cloned fork has neither and installs from the store the mirror warmed; and a fork checked out on a ref whose lockfile differs installs whichever way it was made.
An install that failed leaves no stamp, so the next tree forked from this one tries again rather than inheriting the claim that it is done.