Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
185 commits
Select commit Hold shift + click to select a range
bafe1b3
agent-herdr: resolve herdr via PATH probe instead of hardcoded ~/.loc…
m4ttheweric Aug 28, 2026
fa5f64b
notifier: retry the post-push queue removal so a busy-swallowed delet…
m4ttheweric Aug 28, 2026
ce9c8d6
cron: pass the daemon's resolved process.env to spawn instead of the …
m4ttheweric Aug 28, 2026
52be3d9
agent handler: return ok:false when herdr dedups the tab label instea…
m4ttheweric Aug 28, 2026
94d6218
chat handlers: guard emit/notify after the message commit so a throw …
m4ttheweric Aug 28, 2026
7b061da
discussions:diffs: bound the GitLab fetch with a timeout+abort signal…
m4ttheweric Aug 28, 2026
cc924b5
plan: Phase 0 honest-supervision implementation plan
m4ttheweric Aug 28, 2026
71ca8a9
hooks-guard: handle fs.watch error events and reconcile stale watcher…
m4ttheweric Aug 28, 2026
b944a2d
plan: Phase 3 trust-boundary implementation plan (S005/S006/S010/S040…
m4ttheweric Aug 28, 2026
2246ec9
deps links: never auto-unlink a DEFAULT_EXPOSED tool (rt/fast-browser…
m4ttheweric Aug 28, 2026
def840f
age-key: give the keychain spawn a bounded, distinguishable timeout (…
m4ttheweric Aug 28, 2026
c0c1ff0
presence-store: honor the tail heartbeat in the offline rule and prun…
m4ttheweric Aug 28, 2026
fd626d8
chat:sign-in: reject a missing/empty sessionId instead of storing a N…
m4ttheweric Aug 28, 2026
5651d9a
worktree trash: exclude .worktrees/ from the repo's git status on eve…
m4ttheweric Aug 28, 2026
b64dac7
docs: sketch daemon supervision verdicts + exit-code semantics
m4ttheweric Aug 28, 2026
f0ffdbb
docs: retire stale daemon-runner-health.md, point at the current audi…
m4ttheweric Aug 28, 2026
a15ed3e
daemon: cap request body size at 1 MiB on both servers (S092)
m4ttheweric Aug 28, 2026
cd20722
worktree trash reap: require a plausible rt-written epoch, and refuse…
m4ttheweric Aug 28, 2026
32fd197
daemon: one shared api-token cache for api-server and secrets handler…
m4ttheweric Aug 28, 2026
421d67b
daemon: boot failure is fatal (exit 1), gated by boot-phase flag; rt.…
m4ttheweric Aug 28, 2026
8be75f0
pane:spawn: check the caller's abort signal between steps and stop ea…
m4ttheweric Aug 28, 2026
eea6451
mr:by-branch: apply the same demand-scope gate to the forge write-bac…
m4ttheweric Aug 28, 2026
3bd511b
plan: Phase 1 event-loop implementation plan (RT-78, items 1.1-1.5)
m4ttheweric Aug 28, 2026
b7694dd
daemon: drop startDaemon's dead-code catch and its now-stale JSDoc
m4ttheweric Aug 28, 2026
b24bb2b
worktree create: serialize createTree per repoPath so provision and r…
m4ttheweric Aug 28, 2026
8da0e04
daemon: rt.trustedBrowserOrigins allowlist + needsToken invert-defaul…
m4ttheweric Aug 28, 2026
40e1e35
herdr client: accumulate raw socket bytes and decode once instead of …
m4ttheweric Aug 28, 2026
d34dfbf
daemon: install stderr redirect + crash handlers before every module-…
m4ttheweric Aug 28, 2026
43c5486
port-scanner: canonicalize repo/worktree paths once per scan so a sym…
m4ttheweric Aug 28, 2026
ff751ee
rt-paths: only migrate a legacy ~/.rt that carries an actual rt signa…
m4ttheweric Aug 28, 2026
8e689b0
daemon: fix needsToken to never gate OPTIONS preflight (review fix)
m4ttheweric Aug 28, 2026
e8d0a7c
daemon-client: return timed-out/refused attribution per query instead…
m4ttheweric Aug 28, 2026
4448554
daemon: default-deny CORS and gate /ws on origin/token (S005/S006)
m4ttheweric Aug 28, 2026
6db63b8
runCapture: race reads against the deadline so a pipe-holding grandch…
m4ttheweric Aug 28, 2026
59fc77a
daemon-client: bounded-poll rt.sock after a restart instead of one 30…
m4ttheweric Aug 28, 2026
0d816c0
runs prune: reap expired run dirs with a detached rm -rf, never a syn…
m4ttheweric Aug 28, 2026
cbba6b4
daemon: broadcast() drops dead/backpressured ws clients instead of si…
m4ttheweric Aug 28, 2026
659ecee
worktree reconciler: queue a follow-up pass for a kick arriving after…
m4ttheweric Aug 28, 2026
ba5e7fc
events.db: corruption quarantine + busy_timeout/synchronous pragmas; …
m4ttheweric Aug 28, 2026
b86d4c2
runCapture: stop clearing the SIGKILL timer before it fires, strength…
m4ttheweric Aug 28, 2026
456b8b8
chat:post/chat:dm: validate room/handle/body shape and refuse an unkn…
m4ttheweric Aug 28, 2026
5657d27
daemon: pathParam() helper -- malformed %-encoding is a 400, not a lo…
m4ttheweric Aug 28, 2026
e508951
validate chat:join wakeOn and agent:start/resume surface against thei…
m4ttheweric Aug 28, 2026
4b9afad
source-guards: update the boot-failure-exits guard for runDaemon owni…
m4ttheweric Aug 28, 2026
fd935d7
git-async: 5-min timeout for checkout/merge/stash/status so a large-r…
m4ttheweric Aug 28, 2026
2291068
chat:read/chat:messages: clamp limit into [1,500] instead of reaching…
m4ttheweric Aug 28, 2026
0bdb7e8
daemon: coerce REST GET query params to number/boolean at the seam (S…
m4ttheweric Aug 28, 2026
d96051b
git-async: bump the 3 inline runGit(status/stash pop) call sites to M…
m4ttheweric Aug 28, 2026
746515d
discussions-diffs test: fix fetch cast to satisfy tsc
m4ttheweric Aug 28, 2026
2bd75c6
daemon: log the EADDRINUSE port holder and throw a typed ApiPortInUse…
m4ttheweric Aug 28, 2026
150486f
cache-refresh: async git + grant-gated doppler loop; add listWorktree…
m4ttheweric Aug 28, 2026
f292058
daemon: bind API before socket; register rt.apiPort setting + resolve…
m4ttheweric Aug 28, 2026
a458351
daemon: standalone git-ref validator for option-injection guard (work…
m4ttheweric Aug 28, 2026
977c03d
daemon: standalone credential-redaction utility (freshness.ts wiring …
m4ttheweric Aug 28, 2026
7d1a2fa
freshness: async, cached getRemoteUrl via runCapture (R032)
m4ttheweric Aug 28, 2026
cf52596
worktree-process-kill: async lsof/ps via runCapture (S015, S016)
m4ttheweric Aug 28, 2026
6d950e3
home-snapshot: lazy daemon-flavored state.db; getStateDb re-applies a…
m4ttheweric Aug 28, 2026
baaffc9
docs: the :9401 trust boundary model and the S010/S050/S043 sibling w…
m4ttheweric Aug 28, 2026
fed0ce3
repo-index: async observed-main-path on the endpoint:claim resolve pa…
m4ttheweric Aug 28, 2026
a7bf256
fix rt-paths lint and registry test regressions on job/p3-trust-boundary
m4ttheweric Aug 28, 2026
3037b27
state: isBusyError matches SQLITE_BUSY_*; read-then-write daemon txns…
m4ttheweric Aug 28, 2026
f76d994
repo-index: fix write-back regression in resolveIndexPathForIdentity
m4ttheweric Aug 28, 2026
63e5ec5
gate: fail on sync-exec anywhere in the daemon import graph (1.3)
m4ttheweric Aug 28, 2026
56e7f14
state.db: isolate each legacy importer in a SAVEPOINT so one bad file…
m4ttheweric Aug 28, 2026
9783b1c
daemon: add supervision-state (boot attempts, failures, last-exit) kv…
m4ttheweric Aug 28, 2026
673cfcf
refresh: whole-cycle deadline clears the coalesce latch; cap RepoWatc…
m4ttheweric Aug 28, 2026
fcf8220
daemon: wire supervision-state breadcrumbs + kv records into boot/shu…
m4ttheweric Aug 28, 2026
fa6523f
fix(refresh): clear the deadline timer on every settle path, not just…
m4ttheweric Aug 28, 2026
e9cdbd8
freshness: rebuild the provider cache when gitlabToken rotates (S048,…
m4ttheweric Aug 28, 2026
1af0f28
daemon: lazy-resolve trusted-origin allowlist, drop finding-ID comments
m4ttheweric Aug 28, 2026
fbf7ba8
docs: name the S084 GET-route known-gap ruling in the trust-boundary …
m4ttheweric Aug 28, 2026
88da406
freshness: close the two remaining stale-token paths (S048/S049 revie…
m4ttheweric Aug 28, 2026
58ab7e4
system-process-scanner: a failed lsof preserves runaway windows (S061)
m4ttheweric Aug 28, 2026
3c090d3
runs: mtime-memoize finished-run summaries; back off herdr probe (S10…
m4ttheweric Aug 28, 2026
e9313e0
daemon status: alive-not-serving / parked / boot-failed / crash-loopi…
m4ttheweric Aug 28, 2026
063dd29
runs: scope summary-cache key by runsRoot; poller: test the backoff r…
m4ttheweric Aug 28, 2026
78803f5
pollers: gate the 10s/30s scans on recent consumer demand (S058, S093)
m4ttheweric Aug 28, 2026
983020b
daemon status: exclude the calling process from probePidAlive's lsof …
m4ttheweric Aug 28, 2026
fce330f
demand-tracker: document command-only demand-stamp contract; subproce…
m4ttheweric Aug 28, 2026
f2dfbf0
logs: rotate daemon-stderr.log on open; hide stale crash block by mtime
m4ttheweric Aug 28, 2026
3366ff8
daemon: bare-signal exit is non-zero (launchd respawns); shutdown ver…
m4ttheweric Aug 28, 2026
33a8359
daemon: ownership-aware socket/pid unlink; eviction waits for pid dea…
m4ttheweric Aug 28, 2026
e84f667
daemon CLI: uninstall guards on liveness; start escalates to kickstar…
m4ttheweric Aug 28, 2026
919100e
daemon: redirect stderr after legacy-dir migration; set shuttingDownV…
m4ttheweric Aug 28, 2026
4e0af1e
daemon: drop unused clearBreadcrumb
m4ttheweric Aug 28, 2026
2fa055c
rt-client: soften the rt.apiPort description to match reality
m4ttheweric Aug 28, 2026
0badca0
docs: scrub em/en dashes, fix daemon-supervision-design phase-order d…
m4ttheweric Aug 28, 2026
05a8f18
chore: scrub em/en dashes from branch-added comments and strings (own…
m4ttheweric Aug 28, 2026
93a98a4
Merge branch 'job/bounded-batch' into job/integration
m4ttheweric Aug 28, 2026
2e03f36
Merge branch 'job/p3-trust-boundary' into job/integration
m4ttheweric Aug 28, 2026
5b77738
Merge branch 'job/p1-event-loop' into job/integration
m4ttheweric Aug 28, 2026
026b4cb
Merge branch 'job/p0-supervision' into job/integration
m4ttheweric Aug 28, 2026
2852e9d
I5: wire the seams the four lanes documented but could not cross
m4ttheweric Aug 28, 2026
f395575
e2e: rewrite the 3 API-port-squat daemon tests for I5(a)'s park-retry
m4ttheweric Aug 28, 2026
ecd989f
Merge branch 'job/integration' into job/p2-health
m4ttheweric Aug 28, 2026
17cd7fd
Merge branch 'job/integration' into job/p6-portability
m4ttheweric Aug 28, 2026
1792720
spec: p2-health daemon health model design
m4ttheweric Aug 28, 2026
e130062
spec: p6-portability design (Phase 6 portability)
m4ttheweric Aug 28, 2026
4ed21e0
spec: fix degraded-vs-alive-not-serving branch, pin thresholds, scope…
m4ttheweric Aug 28, 2026
90ce759
spec: apply reviewer edits (S069 read contract + 3 sites, bounded-pre…
m4ttheweric Aug 28, 2026
52f450e
plan: p2-health implementation plan (16 TDD tasks)
m4ttheweric Aug 28, 2026
803c960
plan: p6-portability implementation plan (10 tasks)
m4ttheweric Aug 28, 2026
dcca3ff
plan: apply reviewer fixes (loop-monitor test/alloc, CacheRefresherDe…
m4ttheweric Aug 28, 2026
3dcbfe7
add lib/daemon/health.ts: pure computeHealth + thresholds
m4ttheweric Aug 28, 2026
b8f1572
add lib/daemon/heartbeat-file.ts: atomic-rename heartbeat
m4ttheweric Aug 28, 2026
e9b22f2
plan: apply reviewer fixes (bounded read in links.ts, widen disabledR…
m4ttheweric Aug 28, 2026
25c73c5
add lib/daemon/loop-monitor.ts: drift monitor + heartbeat cadence
m4ttheweric Aug 28, 2026
cc5374b
rt-client: register rt.daemonPath machine setting (6.1)
m4ttheweric Aug 28, 2026
d2b3e21
daemon-status: heartbeat-stale 'stalled' detail + degraded eventLoop
m4ttheweric Aug 28, 2026
38ffaa8
daemon status: render health/stall lines; add non-restarting pingDaemon
m4ttheweric Aug 28, 2026
319849a
user-path: async fish-aware killable PATH probe + rt.daemonPath overr…
m4ttheweric Aug 28, 2026
258973b
daemon-logger: stream error listener + loggerDegraded + crash-handler…
m4ttheweric Aug 28, 2026
4b2e199
daemon-logger: drop em dashes from new comments (style)
m4ttheweric Aug 28, 2026
b8a9948
user-path: reword overlay comment to drop process-artifact reference
m4ttheweric Aug 28, 2026
36ebab3
daemon: await async resolveUserPath; drop user-path sync-exec allowli…
m4ttheweric Aug 28, 2026
494bb52
daemon-logger: rt.logLevel resolution, stderr->warn demotion, 50m siz…
m4ttheweric Aug 28, 2026
65ca166
home: stable machine-key at init, data-preserving freeze of existing …
m4ttheweric Aug 28, 2026
40aeb13
log-janitor: onError callback; daemon logs prune failures at warn
m4ttheweric Aug 28, 2026
7e83441
plan: Task 14 imports setSettingsWarnSink from local ./settings/resol…
m4ttheweric Aug 28, 2026
4c725d1
rt-client: rt.logLevel registry row + injectable deduped settings war…
m4ttheweric Aug 28, 2026
3b7daf6
dev-mode: marker-based wrapper detection, bounded read, legacy fallba…
m4ttheweric Aug 28, 2026
384b8a1
handleCommand: reqId + caller tag + per-(cmd,error) suppression + slo…
m4ttheweric Aug 28, 2026
8ca4d9b
setup: arm64/unsupported-arch row at setup (R051)
m4ttheweric Aug 28, 2026
e877114
unknown-command envelope (code+version); transports send X-RT-Client …
m4ttheweric Aug 28, 2026
e75d566
servers: thread X-RT-Client into payload._client; advertise it in CORS
m4ttheweric Aug 28, 2026
46ac573
plan: split HandlerContext type additions into the tasks that provide…
m4ttheweric Aug 28, 2026
a410818
ctx: extend refreshStatusRef with cycle outcome; export apiWsClientCount
m4ttheweric Aug 28, 2026
af36aec
cache-refresh: remove em dash from applyRefreshOutcome docstring
m4ttheweric Aug 28, 2026
dd3353b
dev-mode: fix rt-health/steps-a consumer tests for the new wrapper de…
m4ttheweric Aug 28, 2026
8f3b92a
home-snapshot: diagnose not-provisioned and missing git identity (S09…
m4ttheweric Aug 28, 2026
0400416
daemon: wire loop monitor + heartbeat + health sampler; surface healt…
m4ttheweric Aug 28, 2026
38cf561
add rt daemon log-level: live level set/show via daemon:log-level verb
m4ttheweric Aug 28, 2026
22a2f52
fix: remove em dashes from log-level comments
m4ttheweric Aug 28, 2026
16de2ba
home-snapshot: gate janitor-zone commit on git identity; drop em dash…
m4ttheweric Aug 28, 2026
2753782
secrets: timeout + SecretsTimeoutError on the sops spawn (S070 sops h…
m4ttheweric Aug 28, 2026
81928fd
e2e: assert additive health/metrics/eventLoop + heartbeat file
m4ttheweric Aug 28, 2026
16d071d
branch-cache: add composeKey/branchOf/identityOf + get/getByBranch (S…
m4ttheweric Aug 28, 2026
1186b79
registry.test: add rt.logLevel to migrated-key fixtures (24 keys)
m4ttheweric Aug 28, 2026
23a6d3d
branch-cache: make get/getByBranch free functions; restore daemon.ts …
m4ttheweric Aug 28, 2026
d8de94f
fix: windowed maxLag recovery + named loop-lag threshold + review fix…
m4ttheweric Aug 28, 2026
3ac78f2
fix: drop em dashes in arch-row comment and test describe (no-em-dashes)
m4ttheweric Aug 29, 2026
e966d03
branch-cache: flip to composite ${identity}:${branch} key; scope all …
m4ttheweric Aug 29, 2026
12f8967
test: adapt dev-mode + home-init consumer fixtures to stricter detect…
m4ttheweric Aug 29, 2026
1e79eb3
docs: regenerate command reference for wave-1 daemon flags and verbs
m4ttheweric Aug 29, 2026
730d008
enrich: repo-scope daemon cache:read; SecretsTimeoutError.name (final…
m4ttheweric Aug 29, 2026
b7ee957
merge job/integration (wave 1) into job/integration-2
m4ttheweric Aug 29, 2026
553fd6a
merge job/p2-health (phase 2: daemon health) into job/integration-2
m4ttheweric Aug 29, 2026
00bb2b1
merge job/p6-portability (phase 6: cross-machine portability) into jo…
m4ttheweric Aug 29, 2026
e8d8417
ci: retrigger checks after docs regeneration
m4ttheweric Aug 29, 2026
afe0f90
docs: regenerate command reference after wave-2 merge (J5)
m4ttheweric Aug 29, 2026
92f165b
merge main into job/integration
m4ttheweric Aug 29, 2026
68c2618
chat-handlers.test: drop test for chat:unread-waking, a verb main's d…
m4ttheweric Aug 29, 2026
34d4a10
merge job/integration (main absorbed) into job/integration-2
m4ttheweric Aug 29, 2026
0d962e6
spec: p4 destructive engine design (RT-81 phase 4)
m4ttheweric Aug 29, 2026
8b1a068
spec: apply review edits (S068 unconditional column add, sync root, n…
m4ttheweric Aug 29, 2026
a5bfde4
plan: p4 destructive engine implementation plan (13 tasks)
m4ttheweric Aug 29, 2026
67b6260
plan: fold in five reviewer execution advisories
m4ttheweric Aug 29, 2026
fd5c6a5
RT-52: default worktree pool root to ~/.mattstack/rt/worktrees/<ident…
m4ttheweric Aug 29, 2026
bc51232
Task 1: drop em dashes from new rt-paths comments
m4ttheweric Aug 29, 2026
3bc1872
docs: drop em dashes from p4 plan (repo no-em-dash rule)
m4ttheweric Aug 29, 2026
fa07a9c
RT-52: retention store follows the tree's pool root; reaper sweeps ev…
m4ttheweric Aug 29, 2026
12ac826
S025: route registry and claim writes through runCriticalWrite; destr…
m4ttheweric Aug 29, 2026
8f3c4b0
S017/S018: kill scoped to the exact tree (realpath, nested-tree exclu…
m4ttheweric Aug 29, 2026
7429a09
S019: freshen never falls back to a positional stash ref; a failed pu…
m4ttheweric Aug 29, 2026
9e67ba5
S064: idle-main freshen is opt-in and aborts on live edits; failed po…
m4ttheweric Aug 29, 2026
9afc2bf
R040: disposeTree re-reads the registry record under the lock and ref…
m4ttheweric Aug 29, 2026
7695f3d
S063: reconcile holds a transiently-missing path for 3 passes instead…
m4ttheweric Aug 29, 2026
9b79253
S056: adopt leaves foreign worktrees unmanaged; --claim opts a tree i…
m4ttheweric Aug 29, 2026
8561ccb
S077: on-deck pool is opt-in on unowned machines, capped, disk-gated,…
m4ttheweric Aug 29, 2026
146adee
Task 10 fix: disk precheck targets cfg.root; correct stale APP_CONFIG…
m4ttheweric Aug 29, 2026
184d63f
S068: endpoint liveness compares process start-time (recycled pid rea…
m4ttheweric Aug 29, 2026
9183da8
RT-51: durable disposal manifest and rt worktree restore
m4ttheweric Aug 29, 2026
d68a03a
Task 12 fix: test the keptUntil reaper branch; drop dead restore erro…
m4ttheweric Aug 29, 2026
a1b6572
fix: endpoint release keeps the worktree arg when --role is omitted
m4ttheweric Aug 29, 2026
3c6c973
fix: never let an exclude equal to the kill target zero out the kill set
m4ttheweric Aug 29, 2026
09fd0f4
merge main into job/p4-destructive-engine
m4ttheweric Aug 29, 2026
8dfd0f6
fix: repo-index singleWorktree fast path resolves symlinks like git w…
m4ttheweric Aug 29, 2026
084ca58
fix: headBranch regex-group indexed access under noUncheckedIndexedAc…
m4ttheweric Aug 29, 2026
f3db4b6
fix: worktree restore rolls back created worktree/branch on copy or r…
m4ttheweric Aug 29, 2026
b728b04
fix: reject unsafe treeName (path separators, . and ..) in worktree r…
m4ttheweric Aug 29, 2026
f0fd18d
fix: reject --role with a missing or option-like value in endpoint re…
m4ttheweric Aug 29, 2026
8f09556
fix: preserve verified startTime on renewal when pidStartTime is unav…
m4ttheweric Aug 29, 2026
764a7a7
fix: tolerate concurrent duplicate-column race on endpoint_claims.sta…
m4ttheweric Aug 29, 2026
e4922a3
fix: mark worktree restore's Tree arg optional, regenerate usage docs
m4ttheweric Aug 29, 2026
bac9fd6
fix: type the query spy in db.test.ts to satisfy tsc --noEmit
m4ttheweric Aug 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions commands/__tests__/endpoint.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test";
import { parseEndpointReleaseArgs } from "../endpoint.ts";

describe("parseEndpointReleaseArgs", () => {
test("worktree first, no role", () => {
expect(parseEndpointReleaseArgs(["/path/wt"])).toEqual({ worktree: "/path/wt", role: undefined, roleInvalid: false });
});

test("--json before the worktree, no role", () => {
expect(parseEndpointReleaseArgs(["--json", "/path/wt"])).toEqual({ worktree: "/path/wt", role: undefined, roleInvalid: false });
});

test("worktree then --role", () => {
expect(parseEndpointReleaseArgs(["/path/wt", "--role", "web"])).toEqual({ worktree: "/path/wt", role: "web", roleInvalid: false });
});

test("--role before the worktree", () => {
expect(parseEndpointReleaseArgs(["--role", "web", "/path/wt"])).toEqual({ worktree: "/path/wt", role: "web", roleInvalid: false });
});

test("--role with no following value is flagged invalid, not treated as omitted", () => {
expect(parseEndpointReleaseArgs(["/path/wt", "--role"])).toEqual({
worktree: "/path/wt",
role: undefined,
roleInvalid: true,
});
});

test("--role followed by --json (option-like) is flagged invalid, not treated as a role value", () => {
expect(parseEndpointReleaseArgs(["/path/wt", "--role", "--json"])).toEqual({
worktree: "/path/wt",
role: undefined,
roleInvalid: true,
});
});
});
6 changes: 6 additions & 0 deletions commands/daemon.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -456,6 +456,12 @@ export async function showStatus(args: string[] = []): Promise<void> {
if (identity) {
printFlavorInfo({ flavor: identity.flavor, version: identity.version, sourceRev: identity.sourceRev, pid: verdict.data.pid ?? null });
}
// S077: a declared pool this machine has never opted into (unowned default
// is now disabled) would otherwise build nothing with no visible reason.
const worktreePool = verdict.data.worktreePool as { dormant: boolean; message?: string } | undefined;
if (worktreePool?.dormant) {
console.log(` ${dim}${worktreePool.message}${reset}`);
}
} else if (verdict.state === "degraded") {
// `status` timed out or errored, but the daemon proved it's alive — the
// flavor cross-check matters most right here, so it earns its own ping.
Expand Down
101 changes: 99 additions & 2 deletions commands/endpoint.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
#!/usr/bin/env bun

/**
* rt endpoint — read-only surface over dev-endpoint claims (RT-28 Task 7).
* rt endpoint: surface over dev-endpoint claims (RT-28 Task 7; `release`
* added in S068 as the manual escape hatch for a claim liveness can't clear
* on its own).
*
* rt endpoint lookup <role> [--json] does this worktree hold a claim?
* rt endpoint lookup <role> [--json] does this worktree hold a claim?
* rt endpoint release <worktree> [--role] free a claim by hand
*
* Repo identification mirrors the pattern already used by `rt worktree each`
* (commands/worktree.ts): derive the repo identity from the cwd's git
Expand DownExpand Up@@ -124,3 +127,97 @@ export async function endpointLookup(args: string[]): Promise<void> {
const statusColor = data.running ? green : yellow;
console.log(`\n ${statusColor}${data.url}${reset} ${dim}(${data.running ? "running" : "claimed, not running"})${reset}\n`);
}

interface ReleaseData {
released: number;
}

/** Lists the worktrees this repo currently has claims for, over the daemon's own `endpoint:status` (a picker source, not a state read). */
async function pickWorktree(identity: string): Promise<string | null> {
const res = await daemonQuery("endpoint:status", { repo: identity }, 10_000);
if (!res?.ok) return null;
const data = res.data as { repos: Record<string, Array<{ worktree: string }>> };
const worktrees = [...new Set((data.repos[identity] ?? []).map((c) => c.worktree))];
if (worktrees.length === 0) fail("no claims to release");
const { filterableSelect } = await import("../lib/rt-render.tsx");
return filterableSelect({
message: "worktree to release",
options: worktrees.map((w) => ({ value: w, label: w })),
});
}

export interface ParsedEndpointReleaseArgs {
worktree: string | undefined;
role: string | undefined;
/** `--role` was present but its value was missing or itself another flag (e.g. `--role --json`): a real role must never be silently treated as omitted. */
roleInvalid: boolean;
}

/**
* Pure arg parse for `rt endpoint release`, split out so the --role-absent
* case (roleFlagIdx === -1) is unit-testable without a git repo or daemon.
* roleFlagIdx + 1 must only be excluded from worktreeArgs when --role is
* actually present, or index 0 (the worktree itself) gets filtered out.
*/
export function parseEndpointReleaseArgs(args: string[]): ParsedEndpointReleaseArgs {
const roleFlagIdx = args.indexOf("--role");
const roleValueIdx = roleFlagIdx === -1 ? -1 : roleFlagIdx + 1;
const roleValue = roleFlagIdx !== -1 ? args[roleValueIdx] : undefined;
const roleInvalid = roleFlagIdx !== -1 && (roleValue === undefined || roleValue.startsWith("--"));
const role = roleInvalid ? undefined : roleValue;
const worktreeArgs = args.filter((a, i) => i !== roleFlagIdx && i !== roleValueIdx && !a.startsWith("--"));
return { worktree: worktreeArgs[0], role, roleInvalid };
}

/**
* Manual escape hatch (S068): frees a worktree's endpoint claim(s) directly,
* for the case a recycled pid or a wedged process leaves a claim liveness
* can't clear on its own. Repo identity is always the cwd's; `<worktree>` is
* the claim to drop and need not be the cwd itself (that is the whole point
* of a manual release).
*/
export async function endpointRelease(args: string[]): Promise<void> {
const json = args.includes("--json");
const parsed = parseEndpointReleaseArgs(args);
if (parsed.roleInvalid) fail("--role needs a value (usage: rt endpoint release <worktree> --role <role>)");
const role = parsed.role;
let worktree = parsed.worktree;

const cwd = process.cwd();
const toplevel = await gitToplevel(cwd);
if (!toplevel) fail("not in a git repo");

const remote = await gitRemote(toplevel);
const repoName = remote ? deriveRepoName(remote) : basename(toplevel);
const identity = serializeIdentity(await deriveRepoIdentity(toplevel));

if ((await resolveIndexPathForIdentity(identity)) === null) {
fail(`repo "${repoName}" is not registered... visit it with rt first (repos.json is a derived mirror, not the source of truth)`);
}

if (!worktree) {
if (process.stdin.isTTY && !json && !process.env.RT_BATCH) {
worktree = (await pickWorktree(identity)) ?? undefined;
if (!worktree) process.exit(0);
} else {
fail("usage: rt endpoint release <worktree> [--role <role>] [--json]");
}
}

const res = await daemonQuery("endpoint:release", { repo: identity, worktree, role }, 10_000);
if (!res) fail("daemon unavailable... rt endpoint release needs the rt daemon (rt daemon start)");
if (!res.ok) fail(res.error ?? "release failed");

const data = res.data as ReleaseData;

if (json) {
console.log(JSON.stringify({ ok: true, ...data }));
return;
}

if (data.released === 0) {
console.log(`\n ${dim}no claim(s) to release for ${worktree}${role ? ` (role "${role}")` : ""} in ${repoName}${reset}\n`);
return;
}
console.log(`\n ${green}released ${data.released} claim${data.released === 1 ? "" : "s"}${reset} ${dim}for ${worktree} in ${repoName}${reset}\n`);
}
131 changes: 126 additions & 5 deletions commands/worktree.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,6 +44,9 @@ const PROVISION_TIMEOUT_MS = 6 * 60_000;
const DISPOSE_TIMEOUT_MS = 2 * 60_000;
const FRESHEN_TIMEOUT_MS = 10 * 60_000;
const ADOPT_TIMEOUT_MS = 2 * 60_000;
// Restore recreates the worktree AND re-runs ready steps (an install), so it
// gets provision's budget rather than dispose's.
const RESTORE_TIMEOUT_MS = 6 * 60_000;

// ─── Arg parsing (pure — unit tested in lib/__tests__/worktree-cli-args.test.ts) ──

Expand DownExpand Up@@ -128,6 +131,22 @@ export function parseDisposeArgs(args: string[]): DisposeArgs {
return { tree, owner: owner.value, repoName: repo.value, force: force.present, json: json.present };
}

export interface RestoreArgs {
tree?: string;
repoName?: string;
list: boolean;
json: boolean;
}

export function parseRestoreArgs(args: string[]): RestoreArgs {
let rest = args;
const json = takeBoolFlag(rest, "--json"); rest = json.rest;
const list = takeBoolFlag(rest, "--list"); rest = list.rest;
const repo = takeFlag(rest, "--repo"); rest = repo.rest;
const tree = rest.find((a) => !a.startsWith("--"));
return { tree, repoName: repo.value, list: list.present, json: json.present };
}

export interface ListArgs {
repoName?: string;
json: boolean;
Expand DownExpand Up@@ -157,13 +176,15 @@ export function parseFreshenArgs(args: string[]): FreshenArgs {
export interface AdoptArgs {
repoName?: string;
json: boolean;
claim: boolean;
}

export function parseAdoptArgs(args: string[]): AdoptArgs {
let rest = args;
const json = takeBoolFlag(rest, "--json"); rest = json.rest;
const claim = takeBoolFlag(rest, "--claim"); rest = claim.rest;
const repo = takeFlag(rest, "--repo"); rest = repo.rest;
return { repoName: repo.value, json: json.present };
return { repoName: repo.value, json: json.present, claim: claim.present };
}

// ─── Shared IO helpers ───────────────────────────────────────────────────────
Expand DownExpand Up@@ -202,6 +223,22 @@ function explainError(error: string): string {
}
if (error.startsWith("checkout-failed:")) return `checkout failed: ${error.slice("checkout-failed:".length)}`;
if (error.startsWith("create-failed:")) return `worktree creation failed at step "${error.slice("create-failed:".length)}"`;
if (error === "not-found") return "no retained trash entry with that name";
if (error === "no-manifest") return "that entry has no disposal manifest (disposed before RT-51, or the write failed) and cannot be restored";
if (error === "branch-elsewhere") return "that branch already exists again... restore refuses to clobber it";
if (error === "no-head-sha") return "the disposal manifest has no recorded commit to restore from";
if (error === "path-exists") return "the pool root already has a tree at that name";
if (error === "worktree-add-failed") return "git worktree add failed while restoring";
if (error === "copy-failed") {
return "the worktree was recreated but copying the retained tree's gitignored content back failed... " +
"the checkout and the retained trash entry are both left in place; a plain retry will refuse " +
"with \"path-exists\", so recover by hand (copy the entry's content over yourself, or `rt worktree dispose --force` the half-restored tree and retry)";
}
if (error === "register-failed") {
return "the worktree was recreated but the registry write did not land, so rt does not know about it yet... " +
"the checkout and the retained trash entry are both left in place; a plain retry will refuse " +
"with \"branch-elsewhere\"/\"path-exists\", so recover by hand (`rt worktree adopt --repo <name>` picks up the checkout, or dispose it and retry)";
}
return error;
}

Expand DownExpand Up@@ -386,7 +423,7 @@ export async function worktreeDispose(args: string[], _ctx: unknown): Promise<vo
repoName = picked.repoName;
}

const payload: Record<string, unknown> = { force: parsed.force };
const payload: Record<string, unknown> = { force: parsed.force, callerPid: process.pid };
if (repoName) payload.repoName = repoName;
if (parsed.owner) payload.owner = parsed.owner;
if (treeName) payload.tree = treeName;
Expand DownExpand Up@@ -418,6 +455,79 @@ export async function worktreeDispose(args: string[], _ctx: unknown): Promise<vo
console.log("");
}

// ─── restore ─────────────────────────────────────────────────────────────────

/** Restorable trash entries for `repoName`, straight from disk (read-only, so like `each` this skips the daemon round trip). */
async function fetchRestorableEntries(repoName: string, repoPath: string) {
const { listRestorableEntries } = await import("../lib/worktree/restore.ts");
return listRestorableEntries(repoName, repoPath);
}

function printRestorableEntries(entries: Awaited<ReturnType<typeof fetchRestorableEntries>>): void {
if (entries.length === 0) { console.log(`\n ${dim}nothing recoverable${reset}\n`); return; }
console.log("");
for (const e of entries) {
console.log(
` ${bold}${e.name}${reset} ${cyan}${e.branch ?? "(detached)"}${reset} ${dim}disposed ${e.disposedAt.slice(0, 10)} (${e.reason}), kept until ${e.keptUntil.slice(0, 10)}${reset}`,
);
}
console.log("");
}

async function pickRestorableEntry(entries: Awaited<ReturnType<typeof fetchRestorableEntries>>): Promise<string | null> {
if (entries.length === 0) return null;
const { filterableSelect } = await import("../lib/rt-render.tsx");
const nameWidth = Math.max(...entries.map((e) => e.name.length));
const options = entries.map((e) => ({
value: e.name,
label: e.name.padEnd(nameWidth),
hint: `${e.branch ?? "(detached)"} disposed ${e.disposedAt.slice(0, 10)} (${e.reason}), kept until ${e.keptUntil.slice(0, 10)}`,
}));
return filterableSelect({ message: "Restore which worktree?", options, stderr: true });
}

export async function worktreeRestore(args: string[], _ctx: unknown): Promise<void> {
const parsed = parseRestoreArgs(args);
const repoName = parsed.repoName ? await resolveRepoArg(parsed.repoName, (m) => failText(parsed.json, m)) : currentRepoIdentity();
if (!repoName) failText(parsed.json, "no repo... pass --repo <name> or run from inside a registered repo");

const repoIndex = loadRepoIndex();
const repoPath = repoIndex[repoName];
if (!repoPath) failText(parsed.json, `repo "${repoName}" not registered in ~/.mattstack/rt/repos.json`);

if (parsed.list) {
const entries = await fetchRestorableEntries(repoName, repoPath);
if (parsed.json) { console.log(JSON.stringify({ entries }, null, 2)); return; }
printRestorableEntries(entries);
return;
}

let treeName = parsed.tree;
if (!treeName) {
if (process.stdin.isTTY && !parsed.json && !process.env.RT_BATCH) {
const entries = await fetchRestorableEntries(repoName, repoPath);
const picked = await pickRestorableEntry(entries);
if (!picked) { console.log(`\n ${dim}nothing selected${reset}\n`); return; }
treeName = picked;
} else {
failText(parsed.json, "no target... pass a tree name (no TTY for the picker)");
}
}

const res = await daemonQuery("worktree:restore", { repoName, tree: treeName }, RESTORE_TIMEOUT_MS);
const ok = requireQueryResult(parsed.json, res);

if (parsed.json) { console.log(JSON.stringify(ok.data, null, 2)); return; }

const d = ok.data as { restored: boolean; path: string; tree: string; readyFailed?: boolean; failedStep?: string };
console.log("");
console.log(` ${green}✓${reset} ${bold}${d.tree}${reset} restored ${dim}${d.path}${reset}`);
if (d.readyFailed) {
console.log(` ${yellow}⚠${reset} ready step "${d.failedStep}" failed... tree is usable but dependencies may be stale`);
}
console.log("");
}

// ─── list ────────────────────────────────────────────────────────────────────

export async function worktreeList(args: string[], _ctx: unknown): Promise<void> {
Expand DownExpand Up@@ -493,14 +603,25 @@ export async function worktreeAdopt(args: string[], _ctx: unknown): Promise<void
if (!parsed.repoName) failText(parsed.json, "--repo <name> is required for adopt");
const repoName = await resolveRepoArg(parsed.repoName, (m) => failText(parsed.json, m));

const res = await daemonQuery("worktree:adopt", { repoName }, ADOPT_TIMEOUT_MS);
const res = await daemonQuery("worktree:adopt", { repoName, claim: parsed.claim }, ADOPT_TIMEOUT_MS);
const ok = requireQueryResult(parsed.json, res);

if (parsed.json) { console.log(JSON.stringify(ok.data, null, 2)); return; }

const d = ok.data as { main: string; claimed: string[]; disposed: string[]; refused: Array<{ tree: string; reason: string }> };
const d = ok.data as {
main: string;
claimed: string[];
unmanaged: string[];
disposed: string[];
refused: Array<{ tree: string; reason: string }>;
};
console.log("");
console.log(` ${green}✓${reset} adopted ${d.main ? `main=${d.main}, ` : ""}${d.claimed.length} claimed, ${d.disposed.length} disposed`);
console.log(
` ${green}✓${reset} adopted ${d.main ? `main=${d.main}, ` : ""}${d.claimed.length} claimed, ` +
`${d.unmanaged.length} unmanaged, ${d.disposed.length} disposed`,
);
for (const name of d.claimed) console.log(` ${green}✓${reset} ${name} ${dim}(kind: ephemeral, claimed)${reset}`);
for (const name of d.unmanaged) console.log(` ${dim}·${reset} ${name} ${dim}(kind: unmanaged, untouched)${reset}`);
for (const r of d.refused) console.log(` ${yellow}⚠${reset} ${r.tree} not disposed: ${r.reason}`);
console.log("");
}
Expand Down
Loading
Loading