feat: standalone coordinator and repository installer
This commit is contained in:
commit
9c47ef5fde
11 changed files with 4794 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Legacy pre-SQLite runtime residue. Current state lives in the private,
|
||||
# host-local SQLite database outside the repository; never version either form.
|
||||
state/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
237
DESIGN.md
Normal file
237
DESIGN.md
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
# Agent coordination design
|
||||
|
||||
Status: implemented. Packaging and installation: [`README.md`](README.md).
|
||||
|
||||
## Boundary that actually isolates work
|
||||
|
||||
The shared Jujutsu working copy is also `@`. Every `jj` command snapshots all peer filesystem
|
||||
edits into whichever change currently owns that checkout. File leases cannot prevent this because
|
||||
the absorbing session did not write those files. This caused duplicate work, foreign features
|
||||
inside unrelated commits, and one temporary loss/recovery incident.
|
||||
|
||||
Writable main agents therefore use independent colocated `jj git clone` instances. They have
|
||||
independent operation logs, working-copy commits and Git backends, and ordinary Git-aware Nix
|
||||
flake discovery works in them. The canonical checkout is for read-only inspection and one
|
||||
explicit integration owner.
|
||||
|
||||
Native `jj workspace add` remains available behind `--native-workspace`. It is useful when sharing
|
||||
one repository is intentional, but it shares the operation log/object store and is not the
|
||||
default isolation boundary. In this repository its secondary directory also lacks an ordinary
|
||||
`.git`, so `nix flake metadata .` does not discover the colocated Git flake normally.
|
||||
|
||||
Managed clones live under `/var/tmp/fleet-audit`, the large non-snapshotted scratch dataset.
|
||||
`/tmp` and `/home` are on the nearly-full snapshotted dataset and must not receive agent clones.
|
||||
|
||||
## Identity
|
||||
|
||||
- A conversation ID is durable across resume.
|
||||
- A runtime ID is a fresh UUID for one live attachment to that conversation.
|
||||
- Human/LLM-facing runtime and workspace identities are exact five-word handles derived from the
|
||||
full opaque ID with a domain-separated SHA-256 mapping over 128 common words (35 display bits).
|
||||
Exact handle matches carry authority; prefixes never do, and an unlikely collision is refused
|
||||
rather than guessed.
|
||||
- Full UUIDs remain canonical in SQLite, tmux metadata, launcher environments and deterministic
|
||||
audit exports. Handles are aliases, not secrets and not replacements for internal entropy.
|
||||
- Managed launches export the exact workspace record as `COORD_WORK_ID`. It may default only
|
||||
low-blast-radius workspace-local operations after proving that the live runtime owns the record
|
||||
and `cwd` is inside its registered path. Resource scopes and destructive targets stay explicit.
|
||||
- The launcher stores full IDs in tmux user options. Window names are presentation only:
|
||||
`<short-task>-<sid4>`.
|
||||
|
||||
The distinction prevents a stale pre-resume process from renewing or releasing the resumed
|
||||
process's resources.
|
||||
|
||||
## Transactional state
|
||||
|
||||
Runtime state is a private SQLite WAL database at
|
||||
`$XDG_STATE_HOME/fleet-agent-coord/<project-id>.sqlite3` (override with `COORD_DB`). It is outside
|
||||
every agent-writable clone, directory mode 0700 and database/WAL mode 0600.
|
||||
|
||||
This database is deliberately host-local. It coordinates Claude and Codex processes sharing one
|
||||
machine; it does not serialize work performed from different fleet nodes. Cross-host deployment
|
||||
still needs an explicit human/integration owner (or a future authenticated service).
|
||||
|
||||
All conflict-check-plus-claim operations use `BEGIN IMMEDIATE`. Resource names are exact,
|
||||
repository-relative hierarchical names. Equal names and ancestor/descendant names overlap.
|
||||
The compatibility matrix is:
|
||||
|
||||
| Existing | Requested append | Requested exclusive |
|
||||
|---|---:|---:|
|
||||
| append | allowed | refused |
|
||||
| exclusive | refused | refused |
|
||||
|
||||
Leases cover files and global operations alike, for example:
|
||||
|
||||
- `tests/checks.nix`
|
||||
- `integration/master`
|
||||
- `push/origin/master`
|
||||
- `activate/home/tpp15s`
|
||||
- `deploy/system/nix-control`
|
||||
- `external/fleet-dotfiles`
|
||||
|
||||
Heartbeats renew both runtime presence and held leases. Expired/dead runtime leases are reaped
|
||||
transactionally. Partial release removes only the named resources.
|
||||
|
||||
Messages have monotonic integer IDs. A broadcast snapshots its recipient set at send time.
|
||||
Delivery transitions `queued → delivered → acked`. Delivered context repeats at hook boundaries
|
||||
until the receiving agent explicitly runs `coord ack <message-id>`. The first delivery carries the
|
||||
body; later boundaries carry the message ID, sender handle, kind and body digest plus an explicit
|
||||
`coord inbox --peek` recovery command. A rejected/lost harness response therefore cannot silently
|
||||
consume it, while a long body is not reinjected on every tool call.
|
||||
|
||||
Audit events remain append-like in SQLite and can be exported deterministically as JSONL with
|
||||
`coord audit`.
|
||||
|
||||
## Action composition
|
||||
|
||||
`coord batch ACTION : ACTION...` reduces agent/tool round trips without inventing a second command
|
||||
language: every action is parsed by the ordinary CLI parser and executed by its ordinary handler.
|
||||
It runs sequentially, stops on the first nonzero result, and can return compact human output or
|
||||
one JSON envelope. The separator is a standalone token, so quoted message punctuation is not
|
||||
special.
|
||||
|
||||
A batch is intentionally not atomic and performs no rollback. Each state-changing action retains
|
||||
its own transaction, validation and audit event. The allowlist excludes clone creation/removal,
|
||||
tmux process replacement and integration queueing. Generic composition must not become a way to
|
||||
hide destructive scope or ambiguous external side effects.
|
||||
|
||||
Every nested action inherits the outer batch's already-resolved exact runtime. Identity flags are
|
||||
rejected inside action token streams and the resolved nested runtime is asserted equal before
|
||||
dispatch, preventing one batch from mixing or impersonating audit/lease authority.
|
||||
|
||||
Remote publication, integration, activation and deployment need purpose-built workflows with
|
||||
explicit resources and expected object IDs. In particular, a future publish workflow must acquire
|
||||
the exact push lease, authoritatively read the network remote, refuse an unexpected base, avoid
|
||||
force, reread the new remote head, and retain the lease with recovery instructions after an
|
||||
ambiguous mutation result.
|
||||
|
||||
## Enforcement and degradation
|
||||
|
||||
The shared hook recognizes Claude `Edit`/`Write` paths and every Codex `apply_patch` source and
|
||||
`Move to:` destination. It canonicalizes against the hook payload's working directory and blocks
|
||||
a peer's exclusive overlapping lease.
|
||||
|
||||
This is scoped enforcement, not a shell parser. Shell writers, formatters, generators and VCS
|
||||
operations remain advisory. Isolation is what makes that honest limitation safe. A private clone
|
||||
can continue working if the coordinator is unavailable.
|
||||
|
||||
The hook and lifecycle bridge fail open on load, parse or database failure. A positively
|
||||
recognized conflicting write fails closed. Denial and `additionalContext` are separate branches
|
||||
because Codex rejects a response containing both.
|
||||
|
||||
Claude subagents receive their own deterministic runtime UUID, derived from the full parent
|
||||
conversation ID and hook `agent_id`. They therefore conflict, message, and lease independently
|
||||
instead of inheriting the parent's authority. Writable subagents should still use their own
|
||||
managed clone; the distinct identity makes accidental writes in the integration checkout fail
|
||||
closed unless that subagent explicitly owns the resource.
|
||||
|
||||
## Workspace lifecycle and cleanup
|
||||
|
||||
Each record contains the full task/conversation/runtime identity, source and actual path, exact
|
||||
base/current Jujutsu IDs, dependencies, planned/actual resources, gate/integration/lifecycle
|
||||
states, immutable tmux IDs and an ownership token.
|
||||
|
||||
The default CLI view deliberately omits those machine fields. It prints handles, task/state, and
|
||||
only the path needed to enter a newly created clone. `--verbose`, `--json`, and audit export are
|
||||
explicit diagnostic/machine surfaces. Cleanup tokens remain private by default; normal removal
|
||||
requires repeating the exact work handle and then compares the registry token with the on-disk
|
||||
marker.
|
||||
|
||||
Lifecycle states support active, parked, resumed, archived, queued/integrating/lock-released/
|
||||
integrated and removed work. Lock release is not integration evidence: a separate proof verifies
|
||||
that the exact source commit is an ancestor of the exact target commit. Cleanup never guesses:
|
||||
|
||||
The current “queue” is an integration lock plus an auditable state machine, not a fair scheduler:
|
||||
enqueue order and recorded dependencies are visible but do not automatically grant the next turn.
|
||||
The integration owner adjudicates readiness and stale bases.
|
||||
|
||||
- path must remain below the recorded scratch root;
|
||||
- private registry token and on-disk `.jj` marker must agree;
|
||||
- working copy must be clean;
|
||||
- an advanced change must be released/integrated;
|
||||
- automatic cleanup requires a passed or explicitly waived gate.
|
||||
|
||||
Missing, dirty, ungated or identity-mismatched directories remain recovery candidates.
|
||||
|
||||
Known independent clones can be recovered after database loss with explicit `work adopt`. Adoption
|
||||
requires both `.jj/` and `.git/`, a path beneath the configured scratch root, and a source distinct
|
||||
from the candidate. It validates exact Jujutsu IDs before rotating the ownership marker. There is
|
||||
deliberately no directory-name scan or automatic adoption.
|
||||
|
||||
If older shared-checkout work was lost, recover read-only with
|
||||
`jj --at-op=<op> --ignore-working-copy file show <path>`, then merge the recovered content into a
|
||||
fresh isolated clone. Never restore a whole stale file over newer work.
|
||||
|
||||
## Tmux rules
|
||||
|
||||
Managed sessions discover project ownership through tmux user options and mutate objects only by
|
||||
immutable `$session`, `@window` and `%pane` IDs returned by tmux. Names are never lookup targets.
|
||||
Automatic window renaming is disabled. Codex resumes with `codex-direct resume <conversation>`;
|
||||
Claude resumes with `claude-direct --resume <conversation>`.
|
||||
|
||||
`work close` revalidates those IDs and kills the exact window. It refuses when called from any
|
||||
pane in the target window, because killing that window would terminate the coordinator before its
|
||||
registry update. If the operator is attached elsewhere in the target session's last window, it
|
||||
first switches to another managed session and otherwise refuses, preventing the surprising drop
|
||||
to a plain outer shell.
|
||||
|
||||
The ordinary `codex`/`claude` helper also persists interactive resume invocations and forwards
|
||||
their arguments to the session runner. Automation (`codex exec`, `claude -p`, pipes and non-TTY
|
||||
calls) remains direct and status-faithful.
|
||||
|
||||
## Proved failure modes
|
||||
|
||||
Behavioral checks cover:
|
||||
|
||||
- eight-way exclusive race: exactly one winner;
|
||||
- six simultaneous messages: no loss and monotonic IDs;
|
||||
- exclusive/append matrix, hierarchical conflicts, renewal/expiry and partial release;
|
||||
- truncated identity refusal;
|
||||
- `apply_patch` move-destination blocking;
|
||||
- repeated delivery until explicit acknowledgement;
|
||||
- independent clone metadata and explicit native-workspace limitations;
|
||||
- dirty, ungated, advanced and ownership-mismatched cleanup refusal;
|
||||
- tmux name collision handling and immutable-ID-only mutations;
|
||||
- distinct Claude subagent authority, explicit orphan adoption and exact-window close;
|
||||
- real disposable `jj git clone --colocate` create/inspect/remove.
|
||||
|
||||
## Implementation language and versioning
|
||||
|
||||
Python plus the standard-library SQLite driver remains the current best fit: hooks can run before
|
||||
a Nix activation or build bootstrap, deployment has no compiled-artifact handoff, and the
|
||||
transaction boundary lives in SQLite rather than process memory. A Go rewrite would improve
|
||||
single-binary distribution, startup predictability and static typing, but would not improve the
|
||||
isolation or locking model by itself. Those benefits do not currently justify a second
|
||||
implementation and migration surface.
|
||||
|
||||
## Interface and context budget
|
||||
|
||||
Coordinator output crosses an unusually expensive boundary: lifecycle context and command output
|
||||
are repeatedly injected into model context. The interface therefore follows these rules:
|
||||
|
||||
- managed environments infer caller identity; ordinary commands do not repeat it;
|
||||
- required operands are positional (`claim integration/master`, `work create cache-review`);
|
||||
- readable long options remain for genuinely optional semantics;
|
||||
- default listings use exact word handles and suppress full IDs, conversations, paths and
|
||||
workspace rows not requested by the caller;
|
||||
- JSON is compact and explicit, and ownership secrets are redacted unless a private recovery
|
||||
export opts in;
|
||||
- full Jujutsu/Git IDs are stored as evidence but presentation output uses a sufficient display
|
||||
prefix only where no later command consumes it as authority.
|
||||
|
||||
This is a larger saving than removing the two dash characters from every option. Cryptic flags
|
||||
can increase correction turns and negate their tiny lexical saving.
|
||||
|
||||
The executable hook source is cooperative infrastructure, not a security boundary: a writable
|
||||
clone contains a writable copy, and hook trust is hash-pinned by each harness. Before deploying
|
||||
incompatible database schemas, add explicit versioned migrations with backup/rollback and package
|
||||
one immutable coordinator runtime through Home Manager. That packaging can use Python or Go; it
|
||||
does not require a language rewrite.
|
||||
|
||||
## Third-party adjudication
|
||||
|
||||
The current system is intentionally small and local. MCP Agent Mail may later add a searchable
|
||||
mail UI, and `tmux-agent-status` may add an operator sidebar, but neither is authoritative for
|
||||
leases. Beads adds a useful task DAG at the cost of Dolt; Gas Town and Agent of Empires are
|
||||
Git-worktree-oriented and too invasive as the coordination substrate. Any pilot must compose with,
|
||||
not replace, the transactional resource and isolated-clone boundary.
|
||||
25
README.md
Normal file
25
README.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# coord
|
||||
|
||||
Harness-neutral coordination for Claude Code, Codex, and other repository agents.
|
||||
|
||||
The standalone project is the source of truth. Install a vendored copy into another repository:
|
||||
|
||||
```sh
|
||||
./install /path/to/repository --project-id my-logical-project
|
||||
```
|
||||
|
||||
An installation provides:
|
||||
|
||||
- `.coord/` — canonical CLI, hooks, runtime modules, installer, design, and immutable project ID.
|
||||
- `.claude/coord/` — compatibility shims for existing sessions and old instructions.
|
||||
- merged `.claude/settings.json` and `.codex/hooks.json` entries pointing at `.coord/`.
|
||||
|
||||
Upgrade an existing installation with the same command. Existing project IDs are preserved and a
|
||||
conflicting requested ID is refused. Check drift without writing:
|
||||
|
||||
```sh
|
||||
./install /path/to/repository --check
|
||||
```
|
||||
|
||||
Runtime state remains outside repositories in the private host-local SQLite database described in
|
||||
[`DESIGN.md`](DESIGN.md). Installing or upgrading never copies, deletes, or rewrites that state.
|
||||
203
hook.py
Executable file
203
hook.py
Executable file
|
|
@ -0,0 +1,203 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Fail-open recognized-write guard and transactional peer-message delivery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import importlib.machinery
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
CLI_SPEC = importlib.util.spec_from_loader(
|
||||
"coord_cli",
|
||||
importlib.machinery.SourceFileLoader("coord_cli", str(HERE / "coord")),
|
||||
)
|
||||
try:
|
||||
cli = importlib.util.module_from_spec(CLI_SPEC)
|
||||
assert CLI_SPEC.loader is not None
|
||||
CLI_SPEC.loader.exec_module(cli)
|
||||
except Exception as exc: # the hook boundary must fail open, including import
|
||||
print(f"coord-hook: cannot load coordinator; failing open: {exc}", file=sys.stderr)
|
||||
raise SystemExit(0)
|
||||
|
||||
PATCH_PATH = re.compile(
|
||||
r"^\*\*\* (?:Add|Update|Delete) File: (.+)$|^\*\*\* Move to: (.+)$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
MAX_MESSAGES = 5
|
||||
|
||||
|
||||
def allow() -> None:
|
||||
raise SystemExit(0)
|
||||
|
||||
|
||||
def runtime_for(db: object, event: dict) -> str:
|
||||
agent_id = str(event.get("agent_id") or "")
|
||||
conversation = str(event.get("session_id") or "")
|
||||
if agent_id and conversation:
|
||||
sub_conversation = f"{conversation}:agent:{agent_id}"
|
||||
exact = str(
|
||||
uuid.uuid5(
|
||||
uuid.NAMESPACE_URL, f"fleet-agent-coord:{sub_conversation}"
|
||||
)
|
||||
)
|
||||
try:
|
||||
return db.resolve_runtime(exact)
|
||||
except KeyError:
|
||||
return db.register_instance(
|
||||
sub_conversation,
|
||||
runtime_id=exact,
|
||||
harness="claude-code-subagent",
|
||||
host=socket.gethostname(),
|
||||
cwd=str(project_root()),
|
||||
label=f"subagent:{agent_id}",
|
||||
)
|
||||
exact = os.environ.get("COORD_INSTANCE_ID", "")
|
||||
return db.resolve_runtime(exact or event.get("session_id", ""))
|
||||
|
||||
|
||||
def project_root() -> pathlib.Path:
|
||||
"""Return the checkout root; the override exists for packaged tests/tools."""
|
||||
override = os.environ.get("COORD_PROJECT_ROOT", "")
|
||||
return pathlib.Path(override).resolve() if override else cli.ROOT
|
||||
|
||||
|
||||
def relative_path(raw: str, cwd: pathlib.Path, root: pathlib.Path) -> str | None:
|
||||
candidate = pathlib.Path(raw)
|
||||
if not candidate.is_absolute():
|
||||
candidate = cwd / candidate
|
||||
try:
|
||||
return candidate.resolve(strict=False).relative_to(root).as_posix()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def paths_from(event: dict) -> list[str]:
|
||||
tool_input = event.get("tool_input") or {}
|
||||
cwd = pathlib.Path(event.get("cwd") or os.getcwd()).resolve()
|
||||
root = project_root()
|
||||
raw_paths: list[str] = []
|
||||
direct = tool_input.get("file_path") or tool_input.get("path")
|
||||
if isinstance(direct, str):
|
||||
raw_paths.append(direct)
|
||||
body = tool_input.get("command") or tool_input.get("input") or ""
|
||||
if isinstance(body, str):
|
||||
for match in PATCH_PATH.finditer(body):
|
||||
raw_paths.append(match.group(1) or match.group(2))
|
||||
normalized = (relative_path(path.strip(), cwd, root) for path in raw_paths)
|
||||
return list(dict.fromkeys(path for path in normalized if path))
|
||||
|
||||
|
||||
def output_context(messages: list[object]) -> None:
|
||||
lines = []
|
||||
for item in messages:
|
||||
sender = cli.runtime_handle(item.sender_runtime_id)
|
||||
if item.state == "delivered":
|
||||
digest = hashlib.sha256(item.body.encode("utf-8")).hexdigest()[:10]
|
||||
lines.append(
|
||||
f"[{item.message_id}] {sender} pending again "
|
||||
f"({item.kind}, sha256:{digest}); read with `coord inbox --peek`"
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
f"[{item.message_id}] {sender} ({item.kind}): {item.body}"
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"additionalContext": (
|
||||
"Messages from peer agent runtimes:\n"
|
||||
+ "\n".join(lines)
|
||||
+ "\nDelivery repeats until explicitly acknowledged with "
|
||||
"`coord ack <message-id>`."
|
||||
),
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
event = json.loads(sys.stdin.read())
|
||||
except Exception:
|
||||
allow()
|
||||
db = cli.store()
|
||||
owner = runtime_for(db, event)
|
||||
db.heartbeat(
|
||||
owner,
|
||||
ttl_seconds=cli.storelib.DEFAULT_INSTANCE_TTL,
|
||||
renew_lease_ttl_seconds=cli.storelib.DEFAULT_LEASE_TTL,
|
||||
)
|
||||
|
||||
managed_workspace = db.is_managed_workspace(project_root())
|
||||
leases = db.active_leases()
|
||||
for path in paths_from(event):
|
||||
owner_has_lease = False
|
||||
for lease in leases:
|
||||
if (
|
||||
lease["runtime_id"] == owner
|
||||
and lease["mode"] == "exclusive"
|
||||
and db.resources_overlap(path, lease["path"])
|
||||
):
|
||||
owner_has_lease = True
|
||||
if lease["runtime_id"] == owner or lease["mode"] != "exclusive":
|
||||
continue
|
||||
if not db.resources_overlap(path, lease["path"]):
|
||||
continue
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": (
|
||||
f"{path} is leased exclusively by "
|
||||
f"{cli.runtime_handle(lease['runtime_id'])} for "
|
||||
f"{lease['purpose'] or 'unstated'}. Coordinate before editing."
|
||||
),
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
return
|
||||
if not managed_workspace and not owner_has_lease:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": (
|
||||
f"{path} is in the canonical integration checkout. "
|
||||
"Writable work must use a managed isolated clone, or "
|
||||
"the integration owner must claim this exact resource."
|
||||
),
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
pending = db.inbox(owner, limit=MAX_MESSAGES, mark_delivered=True)
|
||||
if pending:
|
||||
output_context(pending)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as exc: # fail open is load-bearing
|
||||
print(f"coord-hook: failing open: {exc}", file=sys.stderr)
|
||||
raise SystemExit(0)
|
||||
303
install
Executable file
303
install
Executable file
|
|
@ -0,0 +1,303 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Install or verify a vendored, harness-neutral coordinator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import stat
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
CORE_FILES = (
|
||||
".gitignore",
|
||||
"DESIGN.md",
|
||||
"README.md",
|
||||
"coord",
|
||||
"hook.py",
|
||||
"install",
|
||||
"session.py",
|
||||
"store.py",
|
||||
"workspace.py",
|
||||
)
|
||||
EXECUTABLES = {"coord", "install"}
|
||||
PROJECT_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
|
||||
|
||||
CLAUDE_COMMANDS = {
|
||||
"SessionStart": ('python3 "$CLAUDE_PROJECT_DIR/.coord/session.py"', 10),
|
||||
"SessionEnd": ('python3 "$CLAUDE_PROJECT_DIR/.coord/session.py"', 5),
|
||||
"PreToolUse": ('python3 "$CLAUDE_PROJECT_DIR/.coord/hook.py"', 10),
|
||||
}
|
||||
CODEX_DISCOVERY = (
|
||||
'root="$PWD"; while [ "$root" != / ] && [ ! -f '
|
||||
'"$root/.coord/{name}.py" ]; do root="${{root%/*}}"; '
|
||||
'[ -n "$root" ] || root=/; done; '
|
||||
'[ -f "$root/.coord/{name}.py" ] || exit 0; '
|
||||
'python3 "$root/.coord/{name}.py"'
|
||||
)
|
||||
|
||||
|
||||
def legacy_wrapper(name: str) -> str:
|
||||
if name == "coord":
|
||||
return """#!/usr/bin/env python3
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
target = Path(__file__).resolve().parents[2] / ".coord" / "coord"
|
||||
os.execv(str(target), [str(target), *sys.argv[1:]])
|
||||
"""
|
||||
if name in {"hook.py", "session.py"}:
|
||||
return f"""#!/usr/bin/env python3
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
|
||||
target = Path(__file__).resolve().parents[2] / ".coord" / "{name}"
|
||||
runpy.run_path(str(target), run_name="__main__")
|
||||
"""
|
||||
if name in {"store.py", "workspace.py"}:
|
||||
module = f"_coord_{name.removesuffix('.py')}_compat"
|
||||
return f"""from importlib.util import module_from_spec, spec_from_file_location
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
target = Path(__file__).resolve().parents[2] / ".coord" / "{name}"
|
||||
spec = spec_from_file_location("{module}", target)
|
||||
assert spec is not None and spec.loader is not None
|
||||
implementation = module_from_spec(spec)
|
||||
sys.modules[spec.name] = implementation
|
||||
spec.loader.exec_module(implementation)
|
||||
globals().update({{
|
||||
key: value for key, value in vars(implementation).items()
|
||||
if key not in {{"__name__", "__loader__", "__package__", "__spec__"}}
|
||||
}})
|
||||
"""
|
||||
raise ValueError(f"unsupported compatibility wrapper: {name}")
|
||||
|
||||
|
||||
def read_json(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ValueError(f"refusing invalid JSON at {path}: {exc}") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"refusing non-object JSON at {path}")
|
||||
return value
|
||||
|
||||
|
||||
def is_coord_hook(item: object) -> bool:
|
||||
return isinstance(item, dict) and isinstance(item.get("command"), str) and (
|
||||
".coord/" in item["command"] or ".claude/coord/" in item["command"]
|
||||
)
|
||||
|
||||
|
||||
def merged_hooks(
|
||||
current: dict[str, Any], desired: dict[str, tuple[str, int]]
|
||||
) -> dict[str, Any]:
|
||||
result = json.loads(json.dumps(current))
|
||||
hooks = result.setdefault("hooks", {})
|
||||
if not isinstance(hooks, dict):
|
||||
raise ValueError("refusing hook configuration whose 'hooks' value is not an object")
|
||||
for event, (command, timeout) in desired.items():
|
||||
existing = hooks.get(event, [])
|
||||
if not isinstance(existing, list):
|
||||
raise ValueError(f"refusing hook event {event!r} whose value is not a list")
|
||||
retained = []
|
||||
for group in existing:
|
||||
if not isinstance(group, dict):
|
||||
retained.append(group)
|
||||
continue
|
||||
entries = group.get("hooks")
|
||||
if not isinstance(entries, list):
|
||||
retained.append(group)
|
||||
continue
|
||||
kept_entries = [entry for entry in entries if not is_coord_hook(entry)]
|
||||
if kept_entries:
|
||||
kept_group = dict(group)
|
||||
kept_group["hooks"] = kept_entries
|
||||
retained.append(kept_group)
|
||||
installed: dict[str, Any] = {
|
||||
"hooks": [{"type": "command", "command": command, "timeout": timeout}]
|
||||
}
|
||||
if event == "PreToolUse":
|
||||
installed["matcher"] = ".*"
|
||||
retained.append(installed)
|
||||
hooks[event] = retained
|
||||
return result
|
||||
|
||||
|
||||
def codex_hooks(current: dict[str, Any]) -> dict[str, Any]:
|
||||
desired = {
|
||||
event: (
|
||||
CODEX_DISCOVERY.format(
|
||||
name="hook" if event == "PreToolUse" else "session"
|
||||
),
|
||||
timeout,
|
||||
)
|
||||
for event, (_command, timeout) in CLAUDE_COMMANDS.items()
|
||||
}
|
||||
return merged_hooks(current, desired)
|
||||
|
||||
|
||||
def write_if_changed(
|
||||
path: Path,
|
||||
content: bytes,
|
||||
*,
|
||||
executable: bool = False,
|
||||
check: bool,
|
||||
drift: list[str],
|
||||
) -> None:
|
||||
observed = path.read_bytes() if path.is_file() else None
|
||||
expected_mode = 0o755 if executable else 0o644
|
||||
observed_executable = path.is_file() and bool(
|
||||
stat.S_IMODE(path.stat().st_mode) & stat.S_IXUSR
|
||||
)
|
||||
mode_ok = path.is_file() and observed_executable == executable
|
||||
if observed == content and mode_ok:
|
||||
return
|
||||
drift.append(str(path))
|
||||
if check:
|
||||
return
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(f".{path.name}.coord-new-{secrets.token_hex(6)}")
|
||||
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, expected_mode)
|
||||
try:
|
||||
os.write(fd, content)
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
os.replace(temporary, path)
|
||||
os.chmod(path, expected_mode)
|
||||
|
||||
|
||||
def project_id(target: Path, requested: str, *, check: bool) -> str:
|
||||
canonical = target / ".coord" / "project-id"
|
||||
legacy = target / ".claude" / "coord" / "project-id"
|
||||
existing = []
|
||||
for path in (canonical, legacy):
|
||||
if path.is_file():
|
||||
existing.append((path, path.read_text(encoding="utf-8").strip()))
|
||||
values = {value for _path, value in existing}
|
||||
if len(values) > 1:
|
||||
detail = ", ".join(f"{path}={value!r}" for path, value in existing)
|
||||
raise ValueError(f"refusing ambiguous existing project IDs: {detail}")
|
||||
selected = next(iter(values), "")
|
||||
if requested and selected and requested != selected:
|
||||
raise ValueError(
|
||||
f"refusing project ID change from {selected!r} to {requested!r}"
|
||||
)
|
||||
selected = selected or requested
|
||||
if not selected:
|
||||
if check:
|
||||
raise ValueError("installation has no project ID")
|
||||
raise ValueError("new installation requires --project-id")
|
||||
if not PROJECT_ID_RE.fullmatch(selected) or ".." in selected:
|
||||
raise ValueError("project ID must be one safe immutable slug")
|
||||
return selected
|
||||
|
||||
|
||||
def install(target: Path, *, requested_id: str, check: bool) -> list[str]:
|
||||
target = target.expanduser().resolve()
|
||||
if not target.is_dir():
|
||||
raise ValueError(f"target is not a directory: {target}")
|
||||
selected_id = project_id(target, requested_id, check=check)
|
||||
drift: list[str] = []
|
||||
destination = target / ".coord"
|
||||
for name in CORE_FILES:
|
||||
write_if_changed(
|
||||
destination / name,
|
||||
(HERE / name).read_bytes(),
|
||||
executable=name in EXECUTABLES,
|
||||
check=check,
|
||||
drift=drift,
|
||||
)
|
||||
write_if_changed(
|
||||
destination / "project-id",
|
||||
f"{selected_id}\n".encode(),
|
||||
check=check,
|
||||
drift=drift,
|
||||
)
|
||||
|
||||
legacy = target / ".claude" / "coord"
|
||||
for name in ("coord", "hook.py", "session.py", "store.py", "workspace.py"):
|
||||
write_if_changed(
|
||||
legacy / name,
|
||||
legacy_wrapper(name).encode(),
|
||||
executable=name == "coord",
|
||||
check=check,
|
||||
drift=drift,
|
||||
)
|
||||
write_if_changed(
|
||||
legacy / "project-id",
|
||||
f"{selected_id}\n".encode(),
|
||||
check=check,
|
||||
drift=drift,
|
||||
)
|
||||
write_if_changed(
|
||||
legacy / "DESIGN.md",
|
||||
b"Canonical design: [`../../.coord/DESIGN.md`](../../.coord/DESIGN.md).\\n",
|
||||
check=check,
|
||||
drift=drift,
|
||||
)
|
||||
write_if_changed(
|
||||
legacy / ".gitignore",
|
||||
b"state/\\n__pycache__/\\n",
|
||||
check=check,
|
||||
drift=drift,
|
||||
)
|
||||
|
||||
settings = target / ".claude" / "settings.json"
|
||||
expected_claude = (
|
||||
json.dumps(
|
||||
merged_hooks(read_json(settings), CLAUDE_COMMANDS),
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n"
|
||||
).encode()
|
||||
write_if_changed(settings, expected_claude, check=check, drift=drift)
|
||||
|
||||
codex = target / ".codex" / "hooks.json"
|
||||
expected_codex = (
|
||||
json.dumps(codex_hooks(read_json(codex)), indent=2, sort_keys=True) + "\n"
|
||||
).encode()
|
||||
write_if_changed(codex, expected_codex, check=check, drift=drift)
|
||||
return drift
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(prog="coord-install")
|
||||
parser.add_argument("target", nargs="?", default=".")
|
||||
parser.add_argument("--project-id", default="")
|
||||
parser.add_argument("--check", action="store_true")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
drift = install(
|
||||
Path(args.target), requested_id=args.project_id, check=args.check
|
||||
)
|
||||
except ValueError as exc:
|
||||
print(f"coord-install: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
if args.check:
|
||||
if drift:
|
||||
print(f"coord-install: drift ({len(drift)} files)")
|
||||
for path in drift:
|
||||
print(f" {path}")
|
||||
return 1
|
||||
print("coord-install: installation matches")
|
||||
return 0
|
||||
print(f"coord-install: {'updated' if drift else 'already current'}")
|
||||
print(f"target {Path(args.target).expanduser().resolve()}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
1
project-id
Normal file
1
project-id
Normal file
|
|
@ -0,0 +1 @@
|
|||
coord-tooling
|
||||
172
session.py
Executable file
172
session.py
Executable file
|
|
@ -0,0 +1,172 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Best-effort session lifecycle bridge for Claude Code and Codex hooks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import importlib.machinery
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import socket
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
SPEC = importlib.util.spec_from_loader(
|
||||
"coord_cli",
|
||||
importlib.machinery.SourceFileLoader("coord_cli", str(HERE / "coord")),
|
||||
)
|
||||
cli = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC.loader is not None
|
||||
SPEC.loader.exec_module(cli)
|
||||
|
||||
|
||||
def annotate_tmux(runtime_id: str, conversation_id: str) -> None:
|
||||
pane = os.environ.get("TMUX_PANE")
|
||||
if not pane:
|
||||
return
|
||||
for key, value in (
|
||||
("@coord_instance", runtime_id),
|
||||
("@coord_conversation", conversation_id),
|
||||
("@coord_task", os.environ.get("FLEET_AI_TASK", "")),
|
||||
):
|
||||
subprocess.run(
|
||||
["tmux", "set-option", "-p", "-q", "-t", pane, key, value],
|
||||
check=False,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
def subagent_identity(conversation_id: str, agent_id: str) -> tuple[str, str]:
|
||||
"""Derive the stable conversation/runtime pair shared by all hook processes."""
|
||||
conversation = f"{conversation_id}:agent:{agent_id}"
|
||||
runtime = str(uuid.uuid5(uuid.NAMESPACE_URL, f"fleet-agent-coord:{conversation}"))
|
||||
return conversation, runtime
|
||||
|
||||
|
||||
def main() -> None:
|
||||
event = json.loads(sys.stdin.read())
|
||||
conversation = event.get("session_id") or ""
|
||||
if not conversation:
|
||||
return
|
||||
agent_id = str(event.get("agent_id") or "")
|
||||
db = cli.store()
|
||||
name = event.get("hook_event_name") or ""
|
||||
requested = os.environ.get("COORD_INSTANCE_ID", "")
|
||||
if agent_id:
|
||||
conversation, requested = subagent_identity(conversation, agent_id)
|
||||
harness = (
|
||||
"codex"
|
||||
if os.environ.get("CODEX_THREAD_ID")
|
||||
else "claude-code"
|
||||
)
|
||||
|
||||
if name == "SessionEnd":
|
||||
# Without a launcher-provided runtime UUID, conversation identity is
|
||||
# insufficient: a stale process could otherwise end a newer resume.
|
||||
if not requested:
|
||||
return
|
||||
try:
|
||||
db.end_instance(db.resolve_runtime(requested), reason="clean")
|
||||
except KeyError:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
# A direct, unwrapped harness cannot communicate a newly generated UUID
|
||||
# to later hook processes. Reuse its one live conversation attachment;
|
||||
# managed launchers always provide a fresh exact runtime.
|
||||
if not requested:
|
||||
runtime_id = db.resolve_runtime(conversation)
|
||||
db.heartbeat(
|
||||
runtime_id,
|
||||
renew_lease_ttl_seconds=cli.storelib.DEFAULT_LEASE_TTL,
|
||||
)
|
||||
else:
|
||||
runtime_id = db.register_instance(
|
||||
conversation,
|
||||
runtime_id=requested,
|
||||
harness=harness,
|
||||
host=socket.gethostname(),
|
||||
cwd=str(pathlib.Path(event.get("cwd") or os.getcwd()).resolve()),
|
||||
label=os.environ.get("FLEET_AI_TASK", ""),
|
||||
)
|
||||
except (KeyError, sqlite3.IntegrityError):
|
||||
if not requested:
|
||||
runtime_id = db.register_instance(
|
||||
conversation,
|
||||
harness=harness,
|
||||
host=socket.gethostname(),
|
||||
cwd=str(pathlib.Path(event.get("cwd") or os.getcwd()).resolve()),
|
||||
label=os.environ.get("FLEET_AI_TASK", ""),
|
||||
)
|
||||
else:
|
||||
runtime_id = db.resolve_runtime(requested)
|
||||
db.heartbeat(
|
||||
runtime_id,
|
||||
renew_lease_ttl_seconds=cli.storelib.DEFAULT_LEASE_TTL,
|
||||
)
|
||||
annotate_tmux(runtime_id, conversation)
|
||||
|
||||
peers = [item for item in db.live_instances() if item["runtime_id"] != runtime_id]
|
||||
leases = [item for item in db.active_leases() if item["runtime_id"] != runtime_id]
|
||||
pending = db.inbox(runtime_id, limit=5, mark_delivered=True)
|
||||
if not peers and not pending:
|
||||
return
|
||||
lines: list[str] = []
|
||||
if peers:
|
||||
lines.append(f"Live peer agents: {len(peers)}")
|
||||
for peer in peers:
|
||||
lines.append(
|
||||
f" · {cli.runtime_handle(peer['runtime_id'])} "
|
||||
f"{peer['harness']} task={cli.peer_label(peer)}"
|
||||
)
|
||||
if leases:
|
||||
lines.append("Active peer resource leases:")
|
||||
for lease in leases:
|
||||
lines.append(
|
||||
f" · {lease['path']} [{lease['mode']}] by "
|
||||
f"{cli.runtime_handle(lease['runtime_id'])} — {lease['purpose']}"
|
||||
)
|
||||
if pending:
|
||||
lines.append("Pending durable peer messages:")
|
||||
for message in pending:
|
||||
sender = cli.runtime_handle(message.sender_runtime_id)
|
||||
if message.state == "delivered":
|
||||
digest = hashlib.sha256(message.body.encode("utf-8")).hexdigest()[:10]
|
||||
lines.append(
|
||||
f" · [{message.message_id}] {sender} pending again "
|
||||
f"({message.kind}, sha256:{digest}); `coord inbox --peek`"
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
f" · [{message.message_id}] {sender} "
|
||||
f"({message.kind}): {message.body}"
|
||||
)
|
||||
lines.append(
|
||||
"Use an isolated managed clone for writable work; claim shared integration, "
|
||||
"push, activation, and deployment resources before those operations."
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "SessionStart",
|
||||
"additionalContext": "\n".join(lines),
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as exc: # lifecycle hooks must fail open
|
||||
print(f"coord-session: failing open: {exc}", file=sys.stderr)
|
||||
raise SystemExit(0)
|
||||
249
tests/test_install.py
Executable file
249
tests/test_install.py
Executable file
|
|
@ -0,0 +1,249 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Behavioral tests for fresh install and legacy in-place migration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
INSTALL = ROOT / "install"
|
||||
|
||||
|
||||
class InstallBehavior(unittest.TestCase):
|
||||
def run_install(
|
||||
self, target: Path, *args: str
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(INSTALL), str(target), *args],
|
||||
cwd=ROOT,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
def test_fresh_install_requires_explicit_immutable_project_id(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
target = Path(temporary)
|
||||
missing = self.run_install(target)
|
||||
self.assertEqual(missing.returncode, 2)
|
||||
self.assertIn("--project-id", missing.stderr)
|
||||
installed = self.run_install(target, "--project-id", "fresh-project")
|
||||
self.assertEqual(installed.returncode, 0, installed.stderr)
|
||||
self.assertEqual(
|
||||
(target / ".coord/project-id").read_text().strip(),
|
||||
"fresh-project",
|
||||
)
|
||||
checked = self.run_install(target, "--check")
|
||||
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
|
||||
|
||||
def test_legacy_upgrade_preserves_hooks_identity_and_both_entrypoints(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
target = Path(temporary)
|
||||
legacy = target / ".claude/coord"
|
||||
legacy.mkdir(parents=True)
|
||||
(legacy / "project-id").write_text("legacy-project\n")
|
||||
(target / ".claude/settings.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"effortLevel": "high",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "printf unrelated",
|
||||
"timeout": 2,
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 .claude/coord/hook.py",
|
||||
"timeout": 10,
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
(target / ".codex").mkdir()
|
||||
(target / ".codex/hooks.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 .claude/coord/session.py",
|
||||
"timeout": 10,
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
installed = self.run_install(target)
|
||||
self.assertEqual(installed.returncode, 0, installed.stderr)
|
||||
self.assertEqual(
|
||||
(target / ".coord/project-id").read_text().strip(),
|
||||
"legacy-project",
|
||||
)
|
||||
self.assertEqual(
|
||||
(target / ".claude/coord/project-id").read_text().strip(),
|
||||
"legacy-project",
|
||||
)
|
||||
settings = json.loads((target / ".claude/settings.json").read_text())
|
||||
self.assertEqual(settings["effortLevel"], "high")
|
||||
commands = [
|
||||
hook["command"]
|
||||
for group in settings["hooks"]["PreToolUse"]
|
||||
for hook in group["hooks"]
|
||||
]
|
||||
self.assertIn("printf unrelated", commands)
|
||||
self.assertEqual(
|
||||
sum("/.coord/hook.py" in command for command in commands), 1
|
||||
)
|
||||
self.assertFalse(any(".claude/coord/" in command for command in commands))
|
||||
|
||||
canonical = subprocess.run(
|
||||
[str(target / ".coord/coord"), "--help"],
|
||||
cwd="/",
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
compatibility = subprocess.run(
|
||||
[str(target / ".claude/coord/coord"), "--help"],
|
||||
cwd="/",
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
self.assertEqual(canonical.returncode, 0, canonical.stderr)
|
||||
self.assertEqual(compatibility.returncode, canonical.returncode)
|
||||
self.assertEqual(compatibility.stdout, canonical.stdout)
|
||||
|
||||
store_path = target / ".claude/coord/store.py"
|
||||
spec = importlib.util.spec_from_file_location("legacy_store_test", store_path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
self.assertEqual(
|
||||
module.friendly_handle("identity", "runtime").count("-"), 4
|
||||
)
|
||||
|
||||
conflicting = self.run_install(
|
||||
target, "--project-id", "different-project"
|
||||
)
|
||||
self.assertEqual(conflicting.returncode, 2)
|
||||
self.assertIn("refusing project ID change", conflicting.stderr)
|
||||
|
||||
(target / ".coord/coord").write_text("# drift\n")
|
||||
drift = self.run_install(target, "--check")
|
||||
self.assertEqual(drift.returncode, 1)
|
||||
self.assertIn("drift", drift.stdout)
|
||||
repaired = self.run_install(target)
|
||||
self.assertEqual(repaired.returncode, 0, repaired.stderr)
|
||||
final = self.run_install(target, "--check")
|
||||
self.assertEqual(final.returncode, 0, final.stdout + final.stderr)
|
||||
|
||||
def test_installed_cli_smokes_hello_claim_and_work_create(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
target = root / "project"
|
||||
subprocess.run(
|
||||
["jj", "git", "init", "--colocate", str(target)],
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["jj", "-R", str(target), "bookmark", "create", "master", "-r", "@"],
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=True,
|
||||
)
|
||||
installed = self.run_install(
|
||||
target, "--project-id", "smoke-project"
|
||||
)
|
||||
self.assertEqual(installed.returncode, 0, installed.stderr)
|
||||
cli = target / ".coord/coord"
|
||||
env = {
|
||||
**os.environ,
|
||||
"COORD_DB": str(root / "state/coord.sqlite3"),
|
||||
"COORD_PROJECT_ID": "smoke-project",
|
||||
}
|
||||
hello = subprocess.run(
|
||||
[
|
||||
str(cli),
|
||||
"hello",
|
||||
"--conversation",
|
||||
"smoke-conversation",
|
||||
"--harness",
|
||||
"test",
|
||||
"--cwd",
|
||||
str(target),
|
||||
],
|
||||
cwd=target,
|
||||
env=env,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
self.assertEqual(hello.returncode, 0, hello.stderr)
|
||||
runtime = hello.stdout.strip()
|
||||
claimed = subprocess.run(
|
||||
[str(cli), "-i", runtime, "claim", "smoke/resource"],
|
||||
cwd="/",
|
||||
env=env,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
self.assertEqual(claimed.returncode, 0, claimed.stderr)
|
||||
created = subprocess.run(
|
||||
[
|
||||
str(target / ".claude/coord/coord"),
|
||||
"-i",
|
||||
runtime,
|
||||
"work",
|
||||
"create",
|
||||
"smoke-work",
|
||||
"--source",
|
||||
str(target),
|
||||
"--scratch-root",
|
||||
str(root / "scratch"),
|
||||
"--json",
|
||||
],
|
||||
cwd="/",
|
||||
env=env,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
self.assertEqual(created.returncode, 0, created.stderr)
|
||||
record = json.loads(created.stdout)
|
||||
self.assertEqual(record["task"], "smoke-work")
|
||||
self.assertTrue(Path(record["actual_path"]).is_dir())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
1186
workspace.py
Executable file
1186
workspace.py
Executable file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue