@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/README.md +48 -0
- package/dist/client.d.ts +88 -1
- package/dist/commands.d.ts +180 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +338 -21
- package/dist/repos.d.ts +9 -3
- package/dist/settings/identity.d.ts +43 -19
- package/dist/settings/write.d.ts +12 -0
- package/package.json +1 -1
- package/src/client.ts +141 -1
- package/src/commands.ts +91 -1
- package/src/index.ts +38 -3
- package/src/repos.ts +85 -14
- package/src/settings/identity.ts +121 -28
- package/src/settings/registry-defs.ts +43 -6
- package/src/settings/write.ts +91 -0
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 {
|
|
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 {
|
|
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 {
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
|
|
26
|
-
|
|
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(
|
|
29
|
-
const raw = readFileSync(
|
|
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
|
+
}
|
package/src/settings/identity.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Repo identity: the
|
|
3
|
-
*
|
|
2
|
+
* Repo identity: the tagged value that keys `repos.<identity>` sections in
|
|
3
|
+
* every settings store.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* `remote.origin.url` —
|
|
7
|
-
* independent
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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):
|
|
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
|
-
|
|
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<
|
|
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
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* and
|
|
103
|
-
*
|
|
104
|
-
*
|
|
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<
|
|
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<
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
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
|
+
}
|