@mattstack/rt-client 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts CHANGED
@@ -5,7 +5,20 @@
5
5
  */
6
6
  import { rtCommand } from "./transport.ts";
7
7
  import type { RtResponse, RtClientOptions } from "./transport.ts";
8
- import type { DemandDecl, ProjectMRsData, DiscussionsData, MrByBranchData, ForgeSlug, ForgeTokenData, RunSummary, RunDetail } from "./commands.ts";
8
+ import type {
9
+ DemandDecl,
10
+ ProjectMRsData,
11
+ DiscussionsData,
12
+ MrByBranchData,
13
+ ForgeSlug,
14
+ ForgeTokenData,
15
+ RunSummary,
16
+ RunDetail,
17
+ WakeMode,
18
+ ChatMember,
19
+ ChatMessage,
20
+ RoomSummary,
21
+ } from "./commands.ts";
9
22
 
10
23
  /**
11
24
  * One repo's project open-MR store. A cold repo forces a full paginated sync
@@ -90,3 +103,130 @@ export function getRun(
90
103
  if (repo !== undefined) payload.repo = repo;
91
104
  return rtCommand<RunDetail>("runs:get", payload, { sockPath: opts.sockPath, timeoutMs: 10_000 });
92
105
  }
106
+
107
+ /**
108
+ * SKILLS-54. rt's only write path into run state, so a wedged run can be
109
+ * resolved by a person instead of lying in the data forever. The write happens
110
+ * in the daemon and is attributed there; consumers never touch the run DB.
111
+ */
112
+ export function abandonRun(
113
+ runId: string,
114
+ repo?: string,
115
+ reason?: string,
116
+ opts: RtClientOptions = {},
117
+ ): Promise<RtResponse<{ ok: boolean }>> {
118
+ const payload: Record<string, unknown> = { runId };
119
+ if (repo !== undefined) payload.repo = repo;
120
+ if (reason !== undefined) payload.reason = reason;
121
+ return rtCommand<{ ok: boolean }>("runs:abandon", payload, { sockPath: opts.sockPath, timeoutMs: 10_000 });
122
+ }
123
+
124
+ // ─── Chat (RT-48 Task 6) ──────────────────────────────────────────────────
125
+ // The web viewer's (plan 2's) entire dependency: it reaches the daemon
126
+ // through these wrappers over the unix socket, so no /api/chat/* REST rows
127
+ // ship and needsToken() stays untouched.
128
+
129
+ export function chatJoin(
130
+ a: { room: string; handle: string; wakeOn?: WakeMode; cwd?: string; pane?: string },
131
+ o: RtClientOptions = {},
132
+ ): Promise<RtResponse<{ handle: string; memberCount: number; unread: number }>> {
133
+ const payload: Record<string, unknown> = { room: a.room, handle: a.handle };
134
+ if (a.wakeOn !== undefined) payload.wakeOn = a.wakeOn;
135
+ if (a.cwd !== undefined) payload.cwd = a.cwd;
136
+ if (a.pane !== undefined) payload.pane = a.pane;
137
+ return rtCommand<{ handle: string; memberCount: number; unread: number }>("chat:join", payload, { sockPath: o.sockPath, timeoutMs: 10_000 });
138
+ }
139
+
140
+ export function chatLeave(
141
+ a: { room: string; handle: string },
142
+ o: RtClientOptions = {},
143
+ ): Promise<RtResponse<Record<string, never>>> {
144
+ return rtCommand<Record<string, never>>("chat:leave", { room: a.room, handle: a.handle }, { sockPath: o.sockPath, timeoutMs: 10_000 });
145
+ }
146
+
147
+ export function chatPost(
148
+ a: { room: string; handle: string; body: string },
149
+ o: RtClientOptions = {},
150
+ ): Promise<RtResponse<{ id: number; recipients: string[] }>> {
151
+ return rtCommand<{ id: number; recipients: string[] }>("chat:post", { room: a.room, handle: a.handle, body: a.body }, { sockPath: o.sockPath, timeoutMs: 10_000 });
152
+ }
153
+
154
+ export function chatRead(
155
+ a: { handle: string; room?: string; limit?: number; sinceMs?: number },
156
+ o: RtClientOptions = {},
157
+ ): Promise<RtResponse<{ rooms: { room: string; messages: ChatMessage[] }[] }>> {
158
+ const payload: Record<string, unknown> = { handle: a.handle };
159
+ if (a.room !== undefined) payload.room = a.room;
160
+ if (a.limit !== undefined) payload.limit = a.limit;
161
+ if (a.sinceMs !== undefined) payload.sinceMs = a.sinceMs;
162
+ return rtCommand<{ rooms: { room: string; messages: ChatMessage[] }[] }>("chat:read", payload, { sockPath: o.sockPath, timeoutMs: 10_000 });
163
+ }
164
+
165
+ export function chatRooms(
166
+ a: { handle: string },
167
+ o: RtClientOptions = {},
168
+ ): Promise<RtResponse<{ rooms: RoomSummary[] }>> {
169
+ return rtCommand<{ rooms: RoomSummary[] }>("chat:rooms", { handle: a.handle }, { sockPath: o.sockPath, timeoutMs: 10_000 });
170
+ }
171
+
172
+ export function chatWho(
173
+ a: { room: string },
174
+ o: RtClientOptions = {},
175
+ ): Promise<RtResponse<{ members: ChatMember[] }>> {
176
+ return rtCommand<{ members: ChatMember[] }>("chat:who", { room: a.room }, { sockPath: o.sockPath, timeoutMs: 10_000 });
177
+ }
178
+
179
+ export function chatMark(
180
+ a: { handle: string; room?: string },
181
+ o: RtClientOptions = {},
182
+ ): Promise<RtResponse<Record<string, never>>> {
183
+ const payload: Record<string, unknown> = { handle: a.handle };
184
+ if (a.room !== undefined) payload.room = a.room;
185
+ return rtCommand<Record<string, never>>("chat:mark", payload, { sockPath: o.sockPath, timeoutMs: 10_000 });
186
+ }
187
+
188
+ export function chatMessages(
189
+ a: { room: string; before?: number; limit?: number },
190
+ o: RtClientOptions = {},
191
+ ): Promise<RtResponse<{ messages: ChatMessage[] }>> {
192
+ const payload: Record<string, unknown> = { room: a.room };
193
+ if (a.before !== undefined) payload.before = a.before;
194
+ if (a.limit !== undefined) payload.limit = a.limit;
195
+ return rtCommand<{ messages: ChatMessage[] }>("chat:messages", payload, { sockPath: o.sockPath, timeoutMs: 10_000 });
196
+ }
197
+
198
+ export function chatArm(
199
+ a: { handle: string; room?: string },
200
+ o: RtClientOptions = {},
201
+ ): Promise<RtResponse<Record<string, never>>> {
202
+ const payload: Record<string, unknown> = { handle: a.handle };
203
+ if (a.room !== undefined) payload.room = a.room;
204
+ return rtCommand<Record<string, never>>("chat:arm", payload, { sockPath: o.sockPath, timeoutMs: 10_000 });
205
+ }
206
+
207
+ export function chatTouch(
208
+ a: { handle: string },
209
+ o: RtClientOptions = {},
210
+ ): Promise<RtResponse<Record<string, never>>> {
211
+ return rtCommand<Record<string, never>>("chat:touch", { handle: a.handle }, { sockPath: o.sockPath, timeoutMs: 10_000 });
212
+ }
213
+
214
+ export function chatDisarm(
215
+ a: { handle: string },
216
+ o: RtClientOptions = {},
217
+ ): Promise<RtResponse<Record<string, never>>> {
218
+ return rtCommand<Record<string, never>>("chat:disarm", { handle: a.handle }, { sockPath: o.sockPath, timeoutMs: 10_000 });
219
+ }
220
+
221
+ export function chatUnreadWaking(
222
+ a: { handle: string; room?: string },
223
+ o: RtClientOptions = {},
224
+ ): Promise<RtResponse<{ rooms: { room: string; count: number; mentions: number; maxId: number }[] }>> {
225
+ const payload: Record<string, unknown> = { handle: a.handle };
226
+ if (a.room !== undefined) payload.room = a.room;
227
+ return rtCommand<{ rooms: { room: string; count: number; mentions: number; maxId: number }[] }>("chat:unread-waking", payload, { sockPath: o.sockPath, timeoutMs: 10_000 });
228
+ }
229
+
230
+ export function eventsHead(o: RtClientOptions = {}): Promise<RtResponse<{ cursor: number }>> {
231
+ return rtCommand<{ cursor: number }>("events:head", {}, { sockPath: o.sockPath, timeoutMs: 10_000 });
232
+ }
package/src/commands.ts CHANGED
@@ -58,12 +58,74 @@ export interface ForgeTokenData {
58
58
  */
59
59
  export interface EventsBusEvent { id: number; topic: string; payload: unknown; emittedAt: number }
60
60
 
61
+ /**
62
+ * Duplicated shape on purpose, same reasoning as EventsBusEvent above:
63
+ * these mirror lib/state/chat-store.ts's types, which rt-client cannot
64
+ * import (it's outside lib/state/ and outside this package entirely).
65
+ */
66
+ export type WakeMode = "mention" | "all" | "none";
67
+
68
+ export interface ChatMember {
69
+ room: string;
70
+ handle: string;
71
+ joinedAt: number;
72
+ lastReadId: number;
73
+ wakeOn: WakeMode;
74
+ lastSeenAt?: number;
75
+ armedAt?: number;
76
+ cwd?: string;
77
+ pane?: string;
78
+ }
79
+
80
+ export interface ChatMessage {
81
+ id: number;
82
+ room: string;
83
+ handle: string;
84
+ body: string;
85
+ mentions: string[];
86
+ replyTo?: number;
87
+ postedAt: number;
88
+ }
89
+
90
+ export interface RoomSummary {
91
+ room: string;
92
+ memberCount: number;
93
+ unread: number;
94
+ mentions: number;
95
+ lastPostedAt?: number;
96
+ }
97
+
98
+ // SKILLS-53: one judgment, computed once in rt, so the console and the tray
99
+ // never derive two verdicts that can disagree.
100
+ export type Attention = {
101
+ needs: boolean;
102
+ reason: "failed" | "stale" | "stranded" | null;
103
+ evidence: string;
104
+ };
105
+
61
106
  export interface RunSummary {
62
107
  id: string; repo: string; work_type: string; pipeline: string;
63
108
  status: string; current_stage: string | null; spawned_by: string | null;
64
109
  started_at: number; ended_at: number | null;
110
+ // v2. Null on runs written before schema v2; pack_dirty means the pack tree
111
+ // had uncommitted changes, so the as-run text may exist in no commit.
112
+ pack_commits: string | null; pack_dirty: number;
113
+ attention: Attention;
114
+ /** Max over stage, field, and decision timestamps; falls back to
115
+ `started_at` when the run has produced no events yet. The board orders
116
+ by silence, so this — not `started_at` — is its sort key. */
117
+ last_event_at: number;
118
+ /** Denormalized from the run's `ticket` / `branch` fields so the LIST view
119
+ can render and search them without a detail fetch per row. Null when
120
+ the run has not produced that field yet. */
121
+ ticket: string | null;
122
+ branch: string | null;
123
+ }
124
+ export interface RunStageRow {
125
+ name: string; status: string; attempt: number;
126
+ started_at: number | null; ended_at: number | null;
127
+ reason: string | null; detail_path: string | null;
65
128
  }
66
- export interface RunStageRow { name: string; status: string; attempt: number; started_at: number | null; ended_at: number | null; }
67
129
  export interface RunFieldRow { key: string; value: string; produced_by: string; at: number; }
68
130
  export interface RunDecisionRow { contract: string; scope: string; selection: string; decided_by: string; decided_at: number; }
69
131
  export interface RunDetail { run: RunSummary; stages: RunStageRow[]; fields: RunFieldRow[]; decisions: RunDecisionRow[]; schemaAhead: boolean; }
@@ -120,8 +182,22 @@ export interface Commands {
120
182
  "events:emit": { payload: { topic: string; payload?: unknown }; data: { id: number } };
121
183
  "events:wait": { payload: { pattern: string; after?: number; waitMs?: number }; data: { events: EventsBusEvent[]; cursor: number } };
122
184
  "events:list": { payload: { pattern: string; after?: number; limit?: number }; data: { events: EventsBusEvent[]; cursor: number } };
185
+ "events:head": { payload: Record<string, never>; data: { cursor: number } };
123
186
  "runs:list": { payload: { repo?: string }; data: { runs: RunSummary[] } };
124
187
  "runs:get": { payload: { runId: string; repo?: string }; data: RunDetail };
188
+ "runs:abandon": { payload: { runId: string; repo?: string; reason?: string }; data: { ok: boolean } };
189
+ "chat:join": { payload: { room: string; handle: string; wakeOn?: WakeMode; cwd?: string; pane?: string }; data: { handle: string; memberCount: number; unread: number } };
190
+ "chat:leave": { payload: { room: string; handle: string }; data: Record<string, never> };
191
+ "chat:post": { payload: { room: string; handle: string; body: string }; data: { id: number; recipients: string[] } };
192
+ "chat:read": { payload: { handle: string; room?: string; limit?: number; sinceMs?: number }; data: { rooms: { room: string; messages: ChatMessage[] }[] } };
193
+ "chat:rooms": { payload: { handle: string }; data: { rooms: RoomSummary[] } };
194
+ "chat:who": { payload: { room: string }; data: { members: ChatMember[] } };
195
+ "chat:mark": { payload: { handle: string; room?: string }; data: Record<string, never> };
196
+ "chat:messages": { payload: { room: string; before?: number; limit?: number }; data: { messages: ChatMessage[] } };
197
+ "chat:arm": { payload: { handle: string; room?: string }; data: Record<string, never> };
198
+ "chat:touch": { payload: { handle: string }; data: Record<string, never> };
199
+ "chat:disarm": { payload: { handle: string }; data: Record<string, never> };
200
+ "chat:unread-waking": { payload: { handle: string; room?: string }; data: { rooms: { room: string; count: number; mentions: number; maxId: number }[] } };
125
201
  }
126
202
 
127
203
  export type CommandName = keyof Commands;
@@ -135,6 +211,20 @@ export const COMMAND_NAMES: readonly CommandName[] = [
135
211
  "events:emit",
136
212
  "events:wait",
137
213
  "events:list",
214
+ "events:head",
138
215
  "runs:list",
139
216
  "runs:get",
217
+ "runs:abandon",
218
+ "chat:join",
219
+ "chat:leave",
220
+ "chat:post",
221
+ "chat:read",
222
+ "chat:rooms",
223
+ "chat:who",
224
+ "chat:mark",
225
+ "chat:messages",
226
+ "chat:arm",
227
+ "chat:touch",
228
+ "chat:disarm",
229
+ "chat:unread-waking",
140
230
  ];
package/src/index.ts CHANGED
@@ -1,7 +1,28 @@
1
1
  export { rtCommand, DEFAULT_SOCK } from "./transport.ts";
2
2
  export type { RtResponse, RtClientOptions } from "./transport.ts";
3
3
 
4
- export { readProjectMRs, readDiscussions, readMrsByBranch, resolveForgeToken } from "./client.ts";
4
+ export {
5
+ readProjectMRs,
6
+ readDiscussions,
7
+ readMrsByBranch,
8
+ resolveForgeToken,
9
+ listRuns,
10
+ getRun,
11
+ abandonRun,
12
+ chatJoin,
13
+ chatLeave,
14
+ chatPost,
15
+ chatRead,
16
+ chatRooms,
17
+ chatWho,
18
+ chatMark,
19
+ chatMessages,
20
+ chatArm,
21
+ chatTouch,
22
+ chatDisarm,
23
+ chatUnreadWaking,
24
+ eventsHead,
25
+ } from "./client.ts";
5
26
 
6
27
  export { COMMAND_NAMES } from "./commands.ts";
7
28
  export type {
@@ -16,6 +37,16 @@ export type {
16
37
  CommandName,
17
38
  ForgeSlug,
18
39
  ForgeTokenData,
40
+ Attention,
41
+ RunSummary,
42
+ RunStageRow,
43
+ RunFieldRow,
44
+ RunDecisionRow,
45
+ RunDetail,
46
+ WakeMode,
47
+ ChatMember,
48
+ ChatMessage,
49
+ RoomSummary,
19
50
  } from "./commands.ts";
20
51
 
21
52
  export { subscribe, DEFAULT_WS_URL } from "./relay.ts";
@@ -37,7 +68,7 @@ export type {
37
68
  ExpandCtx,
38
69
  } from "./settings/resolve.ts";
39
70
 
40
- export { setSetting } from "./settings/write.ts";
71
+ export { setSetting, unsetSetting } from "./settings/write.ts";
41
72
  export type { SetSettingOpts } from "./settings/write.ts";
42
73
 
43
74
  export { getDef, allDefs, validateValue, isMigrated } from "./settings/registry-machinery.ts";
@@ -47,4 +78,8 @@ export { REGISTRY } from "./settings/registry-defs.ts";
47
78
  export { readStore, listTeams } from "./settings/stores.ts";
48
79
  export type { StoreFile } from "./settings/stores.ts";
49
80
 
50
- export { normalizeRemote, identityFromRemote, deriveRepoIdentity, clearIdentityMemo } from "./settings/identity.ts";
81
+ export {
82
+ normalizeRemote, identityFromRemote, deriveRepoIdentity, clearIdentityMemo,
83
+ serializeIdentity, parseIdentity, resolveNameToIdentity,
84
+ type RepoIdentity,
85
+ } from "./settings/identity.ts";
package/src/repos.ts CHANGED
@@ -1,13 +1,16 @@
1
1
  /**
2
- * Repo-name resolution against rt's global index (~/.mattstack/rt/repos.json),
3
- * a flat
4
- * `{ "<repoName>": "<absolute path>" }` map. Lets a client resolve "what
5
- * directory am I in" to "what does the daemon call this repo" without
6
- * maintaining its own copy of the mapping.
2
+ * Repo-name resolution against rt's global index. rt itself resolves this
3
+ * through state.db (~/.mattstack/rt/state.db); this module runs OUT OF
4
+ * PROCESS (gitq, mr-board, deck), so it has no handle on that db and reads
5
+ * a derived snapshot instead state.db's kv `repo-index` namespace when
6
+ * reachable, falling back to the legacy `repos.json` mirror
7
+ * (~/.mattstack/rt/repos.json, a flat `{ "<repoName>": "<absolute path>" }`
8
+ * map) rt keeps in sync for exactly this purpose. See rt's
9
+ * lib/repo-index.ts `repoIndexCompatPath` for the write side.
7
10
  */
8
11
  import { existsSync, readFileSync } from "fs";
9
12
  import { homedir } from "os";
10
- import { join } from "path";
13
+ import { dirname, join } from "path";
11
14
 
12
15
  function defaultReposJsonPath(): string {
13
16
  // Duplicates the ~/.mattstack/rt layout: rt-client has no dependency on rt's
@@ -16,17 +19,65 @@ function defaultReposJsonPath(): string {
16
19
  return join(homedir(), ".mattstack", "rt", "repos.json");
17
20
  }
18
21
 
22
+ interface RepoIndexRow {
23
+ k: string;
24
+ v: string;
25
+ }
26
+
27
+ interface BunSqliteDatabase {
28
+ query(sql: string): { all(...params: unknown[]): unknown[] };
29
+ close(): void;
30
+ }
31
+
32
+ type BunSqliteDatabaseCtor = new (path: string, opts?: { readonly?: boolean }) => BunSqliteDatabase;
33
+
19
34
  /**
20
- * Exact-match lookup: returns the repo name whose recorded path equals
21
- * `repoPath`, or null if the file is missing, corrupt, or has no match.
22
- * Never throws -- a resolution failure just means the caller falls back to
23
- * whatever it had before (an unqualified path, a prompt, etc).
35
+ * Loads bun:sqlite defensively: only Bun's runtime provides this built-in.
36
+ * A non-Bun consumer throws resolving the specifier, caught here so the
37
+ * caller degrades to the repos.json path instead of throwing.
24
38
  */
25
- export function repoNameForPath(repoPath: string, reposJsonPath?: string): string | null {
26
- const path = reposJsonPath ?? defaultReposJsonPath();
39
+ function loadBunSqliteDatabase(): BunSqliteDatabaseCtor | null {
40
+ try {
41
+ return (require("bun:sqlite") as { Database: BunSqliteDatabaseCtor }).Database;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Exact-match lookup against state.db's `repo-index` kv namespace (ns=k=v
49
+ * rows written by rt's setKvValue, so `v` is a JSON-encoded string). Returns
50
+ * null (never throws) on any failure — missing db, unreadable, wrong
51
+ * schema, no match — so the caller always has repos.json as a fallback.
52
+ */
53
+ function repoNameFromStateDb(repoPath: string, dbPath: string): string | null {
54
+ if (!existsSync(dbPath)) return null;
55
+ const DatabaseCtor = loadBunSqliteDatabase();
56
+ if (!DatabaseCtor) return null;
57
+ try {
58
+ const db = new DatabaseCtor(dbPath, { readonly: true });
59
+ try {
60
+ const rows = db.query("SELECT k, v FROM kv WHERE ns = 'repo-index';").all() as RepoIndexRow[];
61
+ for (const row of rows) {
62
+ try {
63
+ if (JSON.parse(row.v) === repoPath) return row.k;
64
+ } catch {
65
+ continue;
66
+ }
67
+ }
68
+ return null;
69
+ } finally {
70
+ db.close();
71
+ }
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ function repoNameFromJson(repoPath: string, reposJsonPath: string): string | null {
27
78
  try {
28
- if (!existsSync(path)) return null;
29
- const raw = readFileSync(path, "utf8");
79
+ if (!existsSync(reposJsonPath)) return null;
80
+ const raw = readFileSync(reposJsonPath, "utf8");
30
81
  const index = JSON.parse(raw) as Record<string, unknown>;
31
82
  for (const [repoName, value] of Object.entries(index)) {
32
83
  if (value === repoPath) return repoName;
@@ -36,3 +87,23 @@ export function repoNameForPath(repoPath: string, reposJsonPath?: string): strin
36
87
  return null;
37
88
  }
38
89
  }
90
+
91
+ /**
92
+ * Exact-match lookup: returns the repo name whose recorded path equals
93
+ * `repoPath`, or null if no source has a match. Never throws -- a
94
+ * resolution failure just means the caller falls back to whatever it had
95
+ * before (an unqualified path, a prompt, etc).
96
+ *
97
+ * Prefers state.db (authoritative, kept live by every rt process); falls
98
+ * back to the repos.json compat mirror when state.db is unreachable — a
99
+ * pre-upgrade rt install, a non-Bun consumer, or a state.db this process
100
+ * can't open. `reposJsonPath`, when passed, also relocates the state.db
101
+ * lookup: both files live side by side under the same rt data directory.
102
+ */
103
+ export function repoNameForPath(repoPath: string, reposJsonPath?: string): string | null {
104
+ const jsonPath = reposJsonPath ?? defaultReposJsonPath();
105
+ const dbPath = join(dirname(jsonPath), "state.db");
106
+ const fromDb = repoNameFromStateDb(repoPath, dbPath);
107
+ if (fromDb !== null) return fromDb;
108
+ return repoNameFromJson(repoPath, jsonPath);
109
+ }
@@ -1,14 +1,15 @@
1
1
  /**
2
- * Repo identity: the normalized-remote string that keys `repos.<identity>`
3
- * sections in every settings store (RT-47 spec, "Repo identity").
2
+ * Repo identity: the tagged value that keys `repos.<identity>` sections in
3
+ * every settings store.
4
4
  *
5
- * Identity is `host/path` (lowercase host, path case preserved), derived from
6
- * `remote.origin.url` — never a filesystem path, so it is checkout-location
7
- * independent: every worktree of a repo shares the same remote and therefore
8
- * the same identity. A remote that doesn't match a recognized host form
9
- * (bare local paths are the main case repos.json has two) normalizes to
10
- * null, meaning repo-scoped sections are unreachable for it and only global
11
- * scopes apply. That's an honest degrade, not a crash.
5
+ * A `remote`-kind identity is `host/path` (lowercase host, path case
6
+ * preserved), derived from `remote.origin.url` — checkout-location
7
+ * independent, since every worktree of a repo shares the same remote. When
8
+ * no usable remote exists, identity falls back to `path`-kind: the realpath
9
+ * of the *main* worktree, which is still shared across that repo's linked
10
+ * worktrees (see `deriveRepoIdentity`) even though it is filesystem-bound.
11
+ * `deriveRepoIdentity` therefore never returns null every repo has at
12
+ * least a path-kind identity.
12
13
  *
13
14
  * Three entry points:
14
15
  * - `normalizeRemote` is the pure string transform, no I/O.
@@ -25,6 +26,8 @@
25
26
  * one process don't re-spawn git.
26
27
  */
27
28
 
29
+ import { existsSync, readFileSync, realpathSync } from "fs";
30
+ import { join } from "path";
28
31
  import { runCapture } from "./exec.ts";
29
32
  import { machineSettingsPath } from "./paths.ts";
30
33
  import { readStore } from "./stores.ts";
@@ -37,6 +40,40 @@ const URL_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/(?:[^@/]+@)?([^/]+)\/(.+)$/;
37
40
  // so a Windows-drive-letter-free local remote never falsely matches.
38
41
  const SCP_RE = /^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/;
39
42
 
43
+ export type RepoIdentity =
44
+ | { kind: "remote"; id: string }
45
+ | { kind: "path"; id: string };
46
+
47
+ /**
48
+ * The wire form crosses the daemon socket, sits in board config, and lands in
49
+ * console's `/runs/:repo/...` URL — all of which need one slash-free segment.
50
+ * `encodeURIComponent` guarantees that and is exactly reversible.
51
+ */
52
+ export function serializeIdentity(id: RepoIdentity): string {
53
+ return `${id.kind}:${encodeURIComponent(id.id)}`;
54
+ }
55
+
56
+ export function parseIdentity(wire: string): RepoIdentity | null {
57
+ const colon = wire.indexOf(":");
58
+ if (colon === -1) return null;
59
+ const kind = wire.slice(0, colon);
60
+ if (kind !== "remote" && kind !== "path") return null;
61
+ const encoded = wire.slice(colon + 1);
62
+ let id: string;
63
+ try {
64
+ id = decodeURIComponent(encoded);
65
+ } catch {
66
+ return null;
67
+ }
68
+ // Canonical wires only: the id segment must be byte-for-byte what
69
+ // serializeIdentity emits. Guard sites validate with parseIdentity and then
70
+ // use the WIRE as a single path component (repoDataDir et al.) — a
71
+ // hand-built wire with a literal "/" ("path:../..") would otherwise parse
72
+ // and escape the state directory.
73
+ if (encodeURIComponent(id) !== encoded) return null;
74
+ return { kind, id };
75
+ }
76
+
40
77
  /**
41
78
  * Pure normalization: `remote` → `host/path` (lowercase host, `.git` and
42
79
  * embedded credentials stripped) or null when the remote doesn't match a
@@ -75,47 +112,71 @@ export function normalizeRemote(remote: string): string | null {
75
112
  * Reads the machine store fresh each call (files are small; store reads are
76
113
  * not memoized anywhere in the resolver design).
77
114
  */
78
- export function identityFromRemote(remote: string): string | null {
115
+ export function identityFromRemote(remote: string): RepoIdentity | null {
79
116
  const store = readStore(machineSettingsPath());
80
117
  const overrides = store.global["rt.repoIdentityOverrides"];
81
118
  if (overrides !== null && typeof overrides === "object" && !Array.isArray(overrides)) {
82
119
  const hit = (overrides as Record<string, unknown>)[remote];
83
- if (typeof hit === "string") return hit;
120
+ if (typeof hit === "string") return { kind: "remote", id: hit };
84
121
  }
85
- return normalizeRemote(remote);
122
+ const normalized = normalizeRemote(remote);
123
+ return normalized === null ? null : { kind: "remote", id: normalized };
86
124
  }
87
125
 
88
126
  // Per-process, per-repo-path memoization. Promise-valued so concurrent
89
127
  // callers for the same path share one spawn rather than racing.
90
- const memo = new Map<string, Promise<string | null>>();
128
+ const memo = new Map<string, Promise<RepoIdentity>>();
91
129
 
92
130
  /**
93
131
  * Async derivation from a repo path: `git -C <repoPath> config --get
94
132
  * remote.origin.url`, then identityFromRemote (so overrides apply to
95
133
  * derivation too). Never a sync spawn — safe to call from daemon contexts.
134
+ * Never returns null: no usable remote falls back to a path-kind identity
135
+ * (the main worktree's realpath, via `git worktree list`, so every linked
136
+ * worktree of one repo still shares the same identity).
96
137
  *
97
- * Only a SUCCESSFUL derivation (non-null identity) is memoized, for the life
98
- * of the process; a remote change after that first success is NOT picked up
99
- * until clearIdentityMemo() — documented behavior, not a bug (see spec:
100
- * derivation is a one-time capture per process, not a live poll). A FAILED
101
- * derivation (no remote yet, git not initialized yet, etc.) is never cached
102
- * and is retried on every subsequent call — a caller racing repo
103
- * provisioning (mid-clone, daemon-startup) must not permanently lose
104
- * identity for a path just because it asked too early.
138
+ * Only a `remote`-kind result is memoized, for the life of the process; a
139
+ * remote change after that first success is NOT picked up until
140
+ * clearIdentityMemo() — documented behavior, not a bug (see spec: derivation
141
+ * is a one-time capture per process, not a live poll). A `path`-kind result
142
+ * is never cached and is retried on every subsequent call cheap to
143
+ * recompute, and a caller racing repo provisioning (mid-clone,
144
+ * daemon-startup, a remote added after the fact) must not permanently lose
145
+ * the chance to pick up a real remote just because it asked too early.
105
146
  */
106
- export async function deriveRepoIdentity(repoPath: string): Promise<string | null> {
147
+ export async function deriveRepoIdentity(repoPath: string): Promise<RepoIdentity> {
107
148
  const cached = memo.get(repoPath);
108
149
  if (cached) return cached;
109
150
 
110
- const result = await (async (): Promise<string | null> => {
151
+ const result = await (async (): Promise<RepoIdentity> => {
111
152
  const spawned = await runCapture(["git", "-C", repoPath, "config", "--get", "remote.origin.url"]);
112
- if (spawned.exitCode !== 0) return null;
113
- const remote = spawned.stdout.trim();
114
- if (!remote) return null;
115
- return identityFromRemote(remote);
153
+ if (spawned.exitCode === 0) {
154
+ const remote = spawned.stdout.trim();
155
+ const fromRemote = remote ? identityFromRemote(remote) : null;
156
+ if (fromRemote) return fromRemote;
157
+ }
158
+ // Main worktree via `git worktree list` (main is always listed first) —
159
+ // NOT `--git-common-dir/..`, which points outside the tree under
160
+ // `--separate-git-dir` and would derive one shared identity for every
161
+ // repo whose metadata lives in the same parent directory. In that layout
162
+ // git lists the git DIR as the main entry, so the listed path is resolved
163
+ // through its own `--show-toplevel`, degrading to this worktree's
164
+ // toplevel when the entry isn't a work tree at all.
165
+ const listed = await runCapture(["git", "-C", repoPath, "worktree", "list", "--porcelain"]);
166
+ const first = listed.exitCode === 0 ? /^worktree (.+)$/m.exec(listed.stdout)?.[1]?.trim() : undefined;
167
+ let base: string | undefined;
168
+ if (first) {
169
+ const top = await runCapture(["git", "-C", first, "rev-parse", "--show-toplevel"]);
170
+ if (top.exitCode === 0 && top.stdout.trim()) base = top.stdout.trim();
171
+ }
172
+ if (!base) {
173
+ const own = await runCapture(["git", "-C", repoPath, "rev-parse", "--show-toplevel"]);
174
+ base = own.exitCode === 0 && own.stdout.trim() ? own.stdout.trim() : repoPath;
175
+ }
176
+ return { kind: "path", id: safeRealpath(base) };
116
177
  })();
117
178
 
118
- if (result !== null) memo.set(repoPath, Promise.resolve(result));
179
+ if (result.kind === "remote") memo.set(repoPath, Promise.resolve(result));
119
180
  return result;
120
181
  }
121
182
 
@@ -123,3 +184,35 @@ export async function deriveRepoIdentity(repoPath: string): Promise<string | nul
123
184
  export function clearIdentityMemo(): void {
124
185
  memo.clear();
125
186
  }
187
+
188
+ // realpath of a path that no longer exists throws ENOENT. A path-kind identity
189
+ // may be derived for a worktree whose directory is already gone (dispose flows
190
+ // call this on the tree being removed), and derivation must degrade to the
191
+ // literal path there, never throw past its callers.
192
+ function safeRealpath(p: string): string {
193
+ try {
194
+ return realpathSync(p);
195
+ } catch {
196
+ return p;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * One-shot helper for rewriting board's existing name-valued config to
202
+ * host/path identities. NOT a runtime path — the daemon never calls it.
203
+ * Resolves a repo name to the identity of the path it points at in repos.json.
204
+ */
205
+ export async function resolveNameToIdentity(
206
+ name: string,
207
+ reposJsonPath: string,
208
+ ): Promise<RepoIdentity | null> {
209
+ if (!existsSync(reposJsonPath)) return null;
210
+ try {
211
+ const index = JSON.parse(readFileSync(reposJsonPath, "utf8")) as Record<string, unknown>;
212
+ const path = index[name];
213
+ if (typeof path !== "string") return null;
214
+ return await deriveRepoIdentity(path);
215
+ } catch {
216
+ return null;
217
+ }
218
+ }