Files
paperclip-harness/bin/office

489 lines
20 KiB
Python
Executable File

#!/usr/bin/env python3
"""Open a folder as a project: adopt it, remember it, build its pages.
bin/office <folder> [--project <id|urlKey|name>] [--session NAME] [--dry]
bin/office . # the folder you are in
bin/office <folder> --new "Name" # create the Paperclip project too
bin/office --remote <ssh-host> <folder> --project "Name" # the folder is on another machine
Adopting writes <folder>/.harness/ (the project file, check.sh, NOTES.md) and
nothing else. An existing file is never overwritten. With --new, Paperclip gets
a project for the folder, with the per-task worktree policy set; without it,
--project adopts a project that already exists.
A session is created on the daemon of the machine that runs this command, and
the panes run on that machine. A program cannot move your client to the new
session: TUIOS refuses that from a pane. Switch with TUIOS's session switcher,
or `tuios attach --host <machine> <session>`.
The machine is remembered, so the flag is stated once. Running the same command
on a machine that is not the recorded one is refused rather than silently
producing a second copy of the same project.
"""
import json
import os
import shlex
import socket
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "lib"))
import harness as h # noqa: E402
import project as pj # noqa: E402
import tuios # noqa: E402
def parse(argv):
"""Read the flags, then take what is left as the folder.
A flag's value is not a folder. The first version of this treated the value
of --remote as the folder, which is exactly the kind of bug that only shows
up when someone uses the flag.
"""
opts = {"folder": ".", "folder_given": False, "project": "", "session": "",
"dry": False, "host": "", "no_switch": False, "new": "", "remote": ""}
positional = []
i = 0
while i < len(argv):
a = argv[i]
if a in ("--project", "--session", "--host", "--new", "--remote"):
if i + 1 < len(argv):
opts[a[2:]] = argv[i + 1]
i += 2
continue
i += 1
continue
if a == "--dry":
opts["dry"] = True
elif a == "--no-switch":
opts["no_switch"] = True
elif not a.startswith("-"):
positional.append(a)
i += 1
if positional:
opts["folder"] = positional[0]
opts["folder_given"] = True
return opts
def create_project(name, folder, dry):
"""Create a Paperclip project for a folder, with the worktree policy set.
Adopting a folder that Paperclip has never heard of needed two steps before:
make the project in the browser, then run this command. One command is enough.
"""
# A folder that is a git repository gets a branch per task in its own
# worktree. A folder that is not cannot: asking for a worktree would create
# a project whose every run fails. Those keep the folder in place instead.
is_repo = subprocess.run(["git", "-C", folder, "rev-parse", "--is-inside-work-tree"],
capture_output=True, text=True, timeout=30).stdout.strip() == "true"
body = {
"name": name,
"description": "Adopted from %s by bin/office." % folder,
"status": "backlog",
"workspace": {
"name": name,
"sourceType": "git_repo" if is_repo else "local_path",
"cwd": folder,
"isPrimary": True,
},
}
if is_repo:
body["executionWorkspacePolicy"] = {
"enabled": True,
"defaultMode": "isolated_workspace",
"allowIssueOverride": True,
"workspaceStrategy": {"type": "git_worktree", "baseRef": "main",
"branchTemplate": "{{issue.identifier}}-{{slug}}"},
}
else:
h.say(" not a git repository: the project keeps the folder in place,")
h.say(" so a task changes these files directly. No branch, no worktree.")
if dry:
h.say("would create Paperclip project %r for %s" % (name, folder))
return "(dry run)"
try:
created = h.api("POST", "/api/companies/%s/projects" % h.COMPANY, body)
except h.Refused as exc:
# A project with that name may already exist; adopting it is the right
# thing to do then, not a failure.
try:
for p in h.api("GET", "/api/companies/%s/projects" % h.COMPANY):
if p.get("name") == name:
h.say("project: %s (already in Paperclip)" % name)
return p["id"]
except h.Refused:
pass
raise exc
h.say("project: %s created (%s)" % (name, created.get("id")))
return created.get("id")
def ensure_session(session, folder):
"""Make the session exist. It cannot be shown from here, and that is fine.
Moving a person's TUIOS client to another session is refused when the call
comes from a pane — the daemon will not let a program change what the person
is looking at. Creating the session and building its panes works, so the
only step left for the person is switching to it, which TUIOS has a key for.
"""
if session in tuios.sessions():
h.say("session: %s (already there)" % session)
return
params = {"name": session, "cwd": folder, "window": False}
out = subprocess.run(["tuios", "call", "new-session", json.dumps(params)],
capture_output=True, text=True, timeout=60)
if out.returncode != 0 and "session_exists" not in (out.stderr or out.stdout):
raise h.Refused(3, "could not create session %r: %s" % (session, (out.stderr or out.stdout).strip()[:300]))
h.say("session: %s (created; switch to it with TUIOS's session switcher)" % session)
def scaffold(folder, name):
"""Write the files this project keeps, and only the ones that are missing.
Anything already there is left exactly as it is: adopting a folder must never
overwrite a person's own notes or scripts.
"""
written = []
harness = os.path.join(folder, ".harness")
os.makedirs(harness, exist_ok=True)
check = os.path.join(harness, "check.sh")
if not os.path.exists(check):
with open(check, "w") as f:
f.write("""#!/bin/sh
# This project's own check, run by bin/merge before and after it puts a task's
# work on the base branch. Exit 0 means "the project still passes".
#
# It does not decide that a task is finished: an agent sets a task's status, and
# the review decides whether the change is good. This is the regression check,
# the same thing a CI pipeline would run.
#
# Leave it as it is while the project has no tests. bin/merge will say there is
# no check and let the review decide. Replace the body when tests exist:
#
# exec go test ./...
# exec make test
echo "this project has no check yet"
exit 0
""")
os.chmod(check, 0o755)
written.append(check)
notes = os.path.join(harness, "NOTES.md")
if not os.path.exists(notes):
with open(notes, "w") as f:
f.write("# %s\n\nWhat this project is, what finished looks like, and anything a person\n"
"picking it up next week needs to know. Notes only: tasks live in Paperclip.\n" % name)
written.append(notes)
return written
# Where the commands live on a machine that is driven over ssh.
REMOTE_BIN = "~/.local/share/paperclip-harness/bin"
HOSTS = {
"nixos-desktop": "192.168.20.13", ".13": "192.168.20.13", "13": "192.168.20.13",
"sam-4screen-desktop": "192.168.20.27", "sam-4screen-desktop-1": "192.168.20.27",
".27": "192.168.20.27", "27": "192.168.20.27",
"sam-thinkpad": "192.168.20.51", ".51": "192.168.20.51", "51": "192.168.20.51",
}
def local_addresses():
"""The addresses and names that mean this machine."""
names = {socket.gethostname(), socket.gethostname().split(".")[0]}
try:
out = subprocess.run(["ip", "-4", "-o", "addr", "show", "scope", "global"],
capture_output=True, text=True, timeout=10).stdout
for line in out.splitlines():
parts = line.split()
if len(parts) >= 4:
names.add(parts[3].split("/")[0])
except Exception:
pass
return names
def resolve_host(name):
"""A host name, or the address ssh can actually reach for it."""
if not name:
return ""
return HOSTS.get(name, name)
def is_this_machine(name):
addr = resolve_host(name)
return addr in local_addresses()
def install_wrapper(addr, remote_bin):
"""Leave a short `office` on the other machine's PATH, pointing at the copy."""
script = ("#!/bin/sh\n"
"# Written by office on another machine. Runs the copy it keeps here.\n"
'exec "$HOME/.local/share/paperclip-harness/bin/office" "$@"\n')
subprocess.run(["ssh", "-o", "BatchMode=yes", addr,
"mkdir -p \"$HOME/.local/bin\" && cat > \"$HOME/.local/bin/office\" && chmod +x \"$HOME/.local/bin/office\""],
input=script, text=True, capture_output=True, timeout=60)
def run_on_remote(host, folder, opts):
"""Adopt a folder that lives on another machine, from where you are sitting.
The session has to be made by the daemon that will hold the panes, and the
panes have to run on the machine that owns the folder. So the work is done
over ssh on that machine, and only the last step — showing the session — is
the client's own business.
"""
addr = resolve_host(host)
probe = subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", addr, "true"],
capture_output=True, text=True, timeout=30)
if probe.returncode != 0:
raise h.Refused(3, "cannot log in to %s (%s). Set up a key first: ssh-copy-id %s"
% (host, addr, addr))
# The other machine needs the commands. They are copied to one fixed place
# there rather than assumed to be in the same folder: a machine that has
# never seen this repository still gets a working `office`. Copying every
# time is what keeps the two in step, and it is one small folder.
here = os.path.dirname(os.path.realpath(__file__))
remote_bin = REMOTE_BIN
# rsync will not make two levels of directory, so make the parent first.
subprocess.run(["ssh", "-o", "BatchMode=yes", addr,
"mkdir -p \"$HOME/.local/share/paperclip-harness\""],
capture_output=True, text=True, timeout=60)
copy = subprocess.run(
["rsync", "-a", "--delete", "--exclude", "__pycache__", "--exclude", "*.pyc",
here + "/", "%s:%s/" % (addr, remote_bin)],
capture_output=True, text=True, timeout=300)
if copy.returncode != 0:
raise h.Refused(3, "could not copy the commands to %s: %s"
% (host, (copy.stderr or copy.stdout).strip()[:300]))
install_wrapper(addr, remote_bin)
inner = [folder, "--no-switch"]
for flag, value in (("--project", opts["project"]), ("--session", opts["session"]), ("--new", opts["new"])):
if value:
inner += [flag, value]
if opts["dry"]:
inner.append("--dry")
# "$HOME" must stay unquoted so the other machine's shell expands it; every
# argument after it is quoted, so a folder with a space still arrives whole.
cmd = 'exec "$HOME/.local/share/paperclip-harness/bin/office" ' + \
" ".join(shlex.quote(a) for a in inner)
h.say("on %s: %s" % (host, cmd))
out = subprocess.run(["ssh", "-o", "StrictHostKeyChecking=accept-new", addr, cmd],
capture_output=True, text=True, timeout=600)
sys.stdout.write(out.stdout)
if out.returncode != 0:
raise h.Refused(3, "the other machine refused: %s" % (out.stderr or out.stdout).strip()[:300])
h.say("")
h.say("switch to it: tuios attach --host %s %s" % (host, opts["session"] or os.path.basename(folder)))
return 0
USAGE = """office — open a folder as a project, and say what else can be run
THE RULE
The folder and the session live on the same machine. If you are typing inside
the session, on the machine that owns the folder, it is just: office <folder>
Use --host only when the folder is on a different machine from your shell.
WHAT IT DOES
1. reads the folder
2. if a machine is named, checks whether that is this machine, and runs locally if so
3. if it really is another machine, checks the login and runs the same command there
4. adopts the Paperclip project, or creates it with --new
5. writes what is missing in <folder>/.harness/ and never overwrites
6. records the project in ~/.config/paperclip-harness/projects.toml
7. creates the session on that machine's daemon, then builds eight panes
COMMANDS
office <folder> --project "<Name>" adopt a project Paperclip already has
office <folder> --new "<Name>" create the Paperclip project as well
office . adopt the folder you are standing in
office <folder> --session <name> name the session instead of the folder
office <folder> --project "<Name>" --host <machine> the folder is on that machine
office <folder> --new "<Name>" --dry say what would happen, write nothing
board create any missing pane in this project's session
board --folder <folder> the same, for another project
board --replace close this project's panes, then build all eight again
board --reset close every pane in the session, then build
project-reset <folder> forget the session and start this project again
merge <task> rebase, check, review and merge one task (asks first)
review <task> [--post] run ocr over one task's diff, and record the verdict
triage <task> [--ask next|failure] ask Mouse-1 one question about a task
MACHINES (name, or the address ssh reaches)
nixos-desktop .13 192.168.20.13 hosts Paperclip, the daemon, the main session
sam-4screen-desktop .27 192.168.20.27 your working desktop
sam-thinkpad .51 192.168.20.51 the laptop
EVERY MACHINE, FROM THE TERMINAL
On .13, a folder that is on .13:
office ~/paseo/projects/test-alpha --project "test-alpha"
On .27, adopting a folder that lives on .13:
office /home/sam/paseo/projects/email_agent --project "Email Agent" --host nixos-desktop
On .27, a folder that is on .27 (stays local, no --host):
office ~/src/thing --new "thing"
On .51, the same folder on .51:
office ~/src/thing --project "thing" --host sam-thinkpad
Then attach. Nothing may move your client from inside a pane, so the last step is yours:
tuios attach <session> on this machine
tuios attach --host nixos-desktop <session> to a session on another machine
ON ANOTHER MACHINE
--host copies this folder of commands to the other machine, at
~/.local/share/paperclip-harness/bin/
and leaves a short ~/.local/bin/office there, so it can be run from that
machine too. Nothing has to be installed by hand. On .27 and .51 ~/.local/bin
is not on PATH yet, so there you call it by its full path:
~/.local/share/paperclip-harness/bin/office <folder>
AFTER A REBOOT
TUIOS restores a session with a fresh shell in every pane and keeps the pane
names. Run board once for that project: it checks what each pane is actually
running and replaces any pane that is only a prompt.
KEYS INSIDE THE PANES
( ) change tab tab change panel enter open / choose
r re-read Paperclip R rebuild all panes ? list the keys
h j k l z n and digits belong to TUIOS and never reach the program.
MORE
docs/STARTUP.md these commands, the rule, and the three rebuild levels
docs/WORKSPACES.md what each of the eight panes is for
docs/CLEAN-TESTS.md the two end-to-end tests and what they measured
"""
def main():
argv = sys.argv[1:]
if not argv or "-h" in argv or "--help" in argv:
h.say(USAGE.strip())
return 0
opts = parse(argv)
if not opts["folder_given"]:
# "." is a real folder: `office . --project <name>` adopts the folder you
# are standing in. Only a missing argument is an error.
raise h.Refused(2, "give the folder: office <folder> --project \"<Name>\" (office --help)")
folder = os.path.abspath(os.path.expanduser(opts["folder"]))
# The folder is only checked here when it is this machine's folder. A folder
# on another machine is checked there: asking this machine about it says "no
# folder", which is true and useless.
wanted = opts["host"] or opts["remote"]
if not (wanted and not is_this_machine(wanted)) and not os.path.isdir(folder):
raise h.Refused(2, "no folder at %s" % folder)
host = opts["host"] or pj.this_machine()
if opts["host"] and not is_this_machine(opts["host"]):
return run_on_remote(opts["host"], folder, opts)
if opts["host"]:
h.say("host: %s (this machine)" % opts["host"])
# An adopted project already knows its session. Only a new one needs a
# default, and a default that names a session nobody made is worse than an
# error, so the session is checked before anything is written.
existing = {}
if pj.exists(folder):
existing = pj.load(folder)
session = (
opts["session"]
or existing.get("session")
or os.path.basename(folder)
)
# The session is created if it does not exist and the client is moved to it,
# so one command takes a person from a folder to a working layout. TUIOS has
# the verb for exactly this; asking the person to run `tuios new` first was
# the step everyone forgot.
if opts["remote"]:
if is_this_machine(opts["remote"]):
h.say("host: %s (this machine)" % opts["remote"])
opts["remote"] = ""
else:
return run_on_remote(opts["remote"], folder, opts)
if opts["dry"]:
if opts["new"]:
h.say("would create a Paperclip project %r for %s" % (opts["new"], folder))
else:
h.say("would adopt the Paperclip project %r for %s" % (opts["project"] or "<from .harness/harness.toml>", folder))
h.say("would write %s" % os.path.join(folder, ".harness", "harness.toml"))
h.say("would create or reuse session %r and build eight panes there" % session)
h.say("dry run: nothing was written")
return 0
if opts["host"] and opts["host"] not in (pj.this_machine(), pj.this_machine().split(".")[0]):
# Only a note, because the panes are built by the machine that owns them.
h.say("host: %s (build the pages there)" % host)
if opts["new"]:
ref = create_project(opts["new"], folder, opts["dry"])
if not ref:
return 1
opts["project"] = ref
ref = opts["project"]
if not ref and pj.exists(folder):
ref = pj.load(folder)["paperclip"]["project"]
if not ref:
raise h.Refused(
2,
"which Paperclip project is %s? pass --project <id|urlKey|name>" % folder,
)
proj = pj.resolve_project(ref)
h.say("project: %s (%s)" % (proj["name"], proj["id"]))
h.say("folder: %s" % folder)
h.say("session: %s" % session)
h.say("machine: %s" % host)
tasks = pj.active_tasks(proj["id"])
watching = next((t["identifier"] for t in tasks if t["status"] not in ("done", "cancelled")),
tasks[0]["identifier"] if tasks else "")
cfg = {
"session": session,
"paperclip": {"company": h.COMPANY, "project": proj["id"]},
"machine": {"host": host, "folder": folder},
"watching": watching,
}
if opts["dry"]:
h.say("would write %s" % pj.path(folder))
h.say("would build pages: shell, tree, reply, log, new")
return 0
pj.save(folder, cfg)
h.say("wrote %s" % pj.path(folder))
if not opts["no_switch"]:
ensure_session(session, folder)
pj.register(cfg, proj["name"])
h.say("recorded in %s" % pj.REGISTRY)
written = scaffold(folder, proj["name"])
for path in written:
h.say("wrote %s" % path)
# The pages are built by this machine, from this folder, in this session.
code = os.spawnv(
os.P_WAIT,
sys.executable,
[sys.executable, os.path.join(HERE, "board"), "--folder", folder],
)
return code
if __name__ == "__main__":
h.main_guard(main)