@marktrue/dsh-mobile-remote 3.2.3-rc.1 → 3.2.4-alpha.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/CHANGELOG.md +92 -44
- package/docs/03-api.md +227 -3
- package/docs/10-release.md +177 -39
- package/lib/endpoint-registry.js +2 -0
- package/lib/git-read-bundled-provider.js +248 -53
- package/lib/git-read-service.js +293 -14
- package/lib/index.js +25 -12
- package/package.json +2 -2
- package/docs/spec-app-auto-update.md +0 -143
package/lib/git-read-service.js
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { basename, isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
|
-
import { realpath } from "node:fs/promises";
|
|
3
|
+
import { lstat, realpath } from "node:fs/promises";
|
|
4
4
|
import { createBundledGitReadProvider } from "./git-read-bundled-provider.js";
|
|
5
5
|
|
|
6
6
|
const MAX_GRAPH_TIPS = 5;
|
|
7
|
+
const MAX_TRACKED_FILES = 200;
|
|
8
|
+
const FILE_HISTORY_DEFAULT_LIMIT = 20;
|
|
9
|
+
const FILE_HISTORY_MAX_LIMIT = 100;
|
|
10
|
+
const FILE_HISTORY_MAX_COMMITS = 5000;
|
|
11
|
+
const FILE_HISTORY_TIMEOUT_MS = 20_000;
|
|
12
|
+
const MAX_FILE_HISTORY_SNAPSHOTS = 8;
|
|
13
|
+
const REF_KIND_ORDER = { head: 0, localBranch: 1, remoteBranch: 2, other: 3 };
|
|
14
|
+
const FILE_HISTORY_REASONS = new Set(["not-tracked", "no-history", "path-invalid"]);
|
|
7
15
|
|
|
8
16
|
function failure(code, status, message = code) {
|
|
9
17
|
const error = new Error(message);
|
|
@@ -12,16 +20,144 @@ function failure(code, status, message = code) {
|
|
|
12
20
|
return error;
|
|
13
21
|
}
|
|
14
22
|
|
|
15
|
-
|
|
23
|
+
// The provider contract is additive: v1.1.0 providers expose the new methods,
|
|
24
|
+
// while legacy v1.0.0 providers stay compatible and their raw ref strings are
|
|
25
|
+
// projected into the structured DTO by this facade.
|
|
26
|
+
const providerCompatible = (provider) => /^1\.\d+\.\d+$/.test(String(provider?.contractVersion ?? ""))
|
|
16
27
|
&& ["repositoryRoot", "branches", "resolveTip", "graph", "commit"].every((method) => typeof provider[method] === "function");
|
|
17
28
|
|
|
29
|
+
const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
|
|
30
|
+
const refShortName = (name) => name === "HEAD" ? "HEAD" : String(name).replace(/^refs\/(?:heads|remotes|tags)\//, "");
|
|
31
|
+
const trimSubject = (value) => [...String(value)].slice(0, 200).join("");
|
|
32
|
+
const emptyRefDetails = () => ({ refsDetail: [], tagsDetail: [] });
|
|
33
|
+
const sortRefDetails = (rows) => rows.sort((left, right) => (REF_KIND_ORDER[left.kind] ?? 9) - (REF_KIND_ORDER[right.kind] ?? 9) || compareText(left.name, right.name));
|
|
34
|
+
const sortTagDetails = (rows) => rows.sort((left, right) => compareText(left.name, right.name));
|
|
35
|
+
|
|
36
|
+
/** Project provider-owned ref records onto the commit they resolve to. */
|
|
37
|
+
function refDetailsFromRecords(records, commitOid, { fullTags = false } = {}) {
|
|
38
|
+
const target = String(commitOid ?? "").toLowerCase();
|
|
39
|
+
const refs = [], tags = [], seen = new Set();
|
|
40
|
+
for (const record of Array.isArray(records) ? records : []) {
|
|
41
|
+
if (!record || String(record.commitOid ?? "").toLowerCase() !== target) continue;
|
|
42
|
+
const name = typeof record.name === "string" ? record.name : "";
|
|
43
|
+
if (!name) continue;
|
|
44
|
+
const shortName = typeof record.shortName === "string" && record.shortName ? record.shortName : refShortName(name);
|
|
45
|
+
if (record.kind === "tag") {
|
|
46
|
+
const key = `tag\0${shortName}`;
|
|
47
|
+
if (seen.has(key)) continue;
|
|
48
|
+
seen.add(key);
|
|
49
|
+
const tag = { name, shortName, oid: target, annotated: record.annotated === true };
|
|
50
|
+
if (fullTags) {
|
|
51
|
+
if (typeof record.tagger === "string" && record.tagger) tag.tagger = record.tagger;
|
|
52
|
+
const date = Number(record.date);
|
|
53
|
+
if (Number.isFinite(date) && date > 0) tag.date = date;
|
|
54
|
+
if (typeof record.subject === "string" && record.subject) tag.subject = trimSubject(record.subject);
|
|
55
|
+
if (typeof record.hint === "string" && record.hint) tag.hint = record.hint;
|
|
56
|
+
}
|
|
57
|
+
tags.push(tag);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const kind = ["head", "localBranch", "remoteBranch"].includes(record.kind) ? record.kind : "other";
|
|
61
|
+
const key = `${kind}\0${shortName}`;
|
|
62
|
+
if (seen.has(key)) continue;
|
|
63
|
+
seen.add(key);
|
|
64
|
+
const detail = { kind, name, shortName, oid: target };
|
|
65
|
+
if (record.current === true) detail.current = true;
|
|
66
|
+
refs.push(detail);
|
|
67
|
+
}
|
|
68
|
+
return { refsDetail: sortRefDetails(refs), tagsDetail: sortTagDetails(tags) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* String fallback for legacy providers: parse raw decorations such as
|
|
73
|
+
* `HEAD -> refs/heads/main`, `refs/remotes/origin/main` and
|
|
74
|
+
* `tag: refs/tags/v1.0.0`. Annotated metadata is unavailable here, so tags
|
|
75
|
+
* are advertised as lightweight name-level entries.
|
|
76
|
+
*/
|
|
77
|
+
function refDetailsFromLegacyRefs(rawRefs, commitOid, { tags = [] } = {}) {
|
|
78
|
+
const target = String(commitOid ?? "");
|
|
79
|
+
const refs = [], tagRows = [], seen = new Set();
|
|
80
|
+
let currentBranch = null;
|
|
81
|
+
const addRef = (kind, name, shortName, extra = {}) => {
|
|
82
|
+
const key = `${kind}\0${shortName}`;
|
|
83
|
+
if (seen.has(key)) return;
|
|
84
|
+
seen.add(key);
|
|
85
|
+
refs.push({ kind, name, shortName, oid: target, ...extra });
|
|
86
|
+
};
|
|
87
|
+
const addTag = (value) => {
|
|
88
|
+
let name = String(value ?? "").trim();
|
|
89
|
+
if (name.startsWith("tag: ")) name = name.slice(5).trim();
|
|
90
|
+
if (!name) return;
|
|
91
|
+
if (!name.startsWith("refs/tags/")) name = `refs/tags/${name}`;
|
|
92
|
+
const shortName = name.slice(10);
|
|
93
|
+
if (!shortName) return;
|
|
94
|
+
const key = `tag\0${shortName}`;
|
|
95
|
+
if (seen.has(key)) return;
|
|
96
|
+
seen.add(key);
|
|
97
|
+
tagRows.push({ name, shortName, oid: target, annotated: false });
|
|
98
|
+
};
|
|
99
|
+
const addRefValue = (value) => {
|
|
100
|
+
const raw = String(value ?? "").trim();
|
|
101
|
+
if (!raw) return;
|
|
102
|
+
if (raw === "HEAD") return addRef("head", "HEAD", "HEAD", { current: true });
|
|
103
|
+
if (raw.startsWith("HEAD -> ")) {
|
|
104
|
+
addRef("head", "HEAD", "HEAD", { current: true });
|
|
105
|
+
const next = raw.slice(8).trim();
|
|
106
|
+
if (!next) return undefined;
|
|
107
|
+
// Providers may decorate with shortened branch names (`HEAD -> main`)
|
|
108
|
+
// instead of full ref names; normalize the branch form here. The target
|
|
109
|
+
// of HEAD is by definition the current branch, marked after parsing.
|
|
110
|
+
if (next.startsWith("refs/heads/")) { currentBranch = next.slice(11); return addRef("localBranch", next, currentBranch); }
|
|
111
|
+
if (next.startsWith("refs/")) return addRefValue(next);
|
|
112
|
+
currentBranch = next;
|
|
113
|
+
return addRef("localBranch", `refs/heads/${next}`, next);
|
|
114
|
+
}
|
|
115
|
+
if (raw.startsWith("tag: ")) return addTag(raw.slice(5));
|
|
116
|
+
if (raw === "refs/stash") return;
|
|
117
|
+
if (raw.startsWith("refs/heads/")) return addRef("localBranch", raw, raw.slice(11));
|
|
118
|
+
if (raw.startsWith("refs/remotes/")) return raw.endsWith("/HEAD") ? undefined : addRef("remoteBranch", raw, raw.slice(13));
|
|
119
|
+
if (raw.startsWith("refs/tags/")) return addTag(raw);
|
|
120
|
+
return addRef("other", raw, raw);
|
|
121
|
+
};
|
|
122
|
+
for (const raw of Array.isArray(rawRefs) ? rawRefs : []) addRefValue(raw);
|
|
123
|
+
for (const raw of Array.isArray(tags) ? tags : []) addTag(raw);
|
|
124
|
+
// `current` is set once HEAD has been seen, so decoration order does not matter.
|
|
125
|
+
if (currentBranch !== null) {
|
|
126
|
+
const row = refs.find((entry) => entry.kind === "localBranch" && entry.shortName === currentBranch);
|
|
127
|
+
if (row) row.current = true;
|
|
128
|
+
}
|
|
129
|
+
return { refsDetail: sortRefDetails(refs), tagsDetail: sortTagDetails(tagRows) };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const mergeRefDetails = (primary, secondary) => {
|
|
133
|
+
const refs = new Map(), tags = new Map();
|
|
134
|
+
for (const row of [...primary.refsDetail, ...secondary.refsDetail]) {
|
|
135
|
+
const key = `${row.kind}\0${row.shortName}`;
|
|
136
|
+
if (!refs.has(key)) refs.set(key, row);
|
|
137
|
+
}
|
|
138
|
+
for (const row of [...primary.tagsDetail, ...secondary.tagsDetail]) {
|
|
139
|
+
if (!tags.has(row.shortName)) tags.set(row.shortName, row);
|
|
140
|
+
}
|
|
141
|
+
return { refsDetail: sortRefDetails([...refs.values()]), tagsDetail: sortTagDetails([...tags.values()]) };
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const HISTORY_STATUS_WORDS = { added: "A", modified: "M", deleted: "D", renamed: "R", copied: "C", "type-changed": "T" };
|
|
145
|
+
const historyStatus = (value) => {
|
|
146
|
+
const text = String(value ?? "").trim();
|
|
147
|
+
const word = HISTORY_STATUS_WORDS[text.toLowerCase()];
|
|
148
|
+
if (word) return word;
|
|
149
|
+
const upper = text.toUpperCase();
|
|
150
|
+
return ["A", "M", "D", "R", "C", "T"].includes(upper[0]) ? upper[0] : "M";
|
|
151
|
+
};
|
|
152
|
+
|
|
18
153
|
/** Session-authorized read facade over an optional external or bundled provider. */
|
|
19
154
|
export function createGitReadService(ctx, { bundledProvider, now = () => Date.now(), snapshotTtlMs = 5 * 60_000, pollIntervalMs = 30_000, onChanged } = {}) {
|
|
20
155
|
const get = (name) => { try { return ctx.get?.(name); } catch { return undefined; } };
|
|
21
156
|
const external = get("gitReadProvider");
|
|
22
157
|
let externalAvailable = false;
|
|
23
158
|
try { externalAvailable = providerCompatible(external) && external.capabilities?.().available === true; } catch {}
|
|
24
|
-
const
|
|
159
|
+
const bundled = bundledProvider ?? createBundledGitReadProvider(ctx);
|
|
160
|
+
const provider = externalAvailable ? external : bundled;
|
|
25
161
|
const bindings = new Map();
|
|
26
162
|
const repositoryIdFor = (sessionId, root) => `repo_${createHash("sha256").update(`${sessionId}\0${root}`).digest("base64url").slice(0, 32)}`;
|
|
27
163
|
const inside = (child, parent) => {
|
|
@@ -60,6 +196,8 @@ export function createGitReadService(ctx, { bundledProvider, now = () => Date.no
|
|
|
60
196
|
const worktreeSnapshots = new Map();
|
|
61
197
|
const snapshots = new Map();
|
|
62
198
|
const fileCursors = new Map();
|
|
199
|
+
const historySnapshots = new Map();
|
|
200
|
+
const historyCursors = new Map();
|
|
63
201
|
const refSignatures = new Map();
|
|
64
202
|
const keepBounded = (map, maximum) => {
|
|
65
203
|
while (map.size > maximum) map.delete(map.keys().next().value);
|
|
@@ -67,6 +205,8 @@ export function createGitReadService(ctx, { bundledProvider, now = () => Date.no
|
|
|
67
205
|
const prune = () => {
|
|
68
206
|
for (const [id, item] of snapshots) if (item.expiresAt <= now()) snapshots.delete(id);
|
|
69
207
|
for (const [id, item] of fileCursors) if (item.expiresAt <= now()) fileCursors.delete(id);
|
|
208
|
+
for (const [id, item] of historySnapshots) if (item.expiresAt <= now()) historySnapshots.delete(id);
|
|
209
|
+
for (const [id, item] of historyCursors) if (item.expiresAt <= now()) historyCursors.delete(id);
|
|
70
210
|
};
|
|
71
211
|
let pollTimer = null;
|
|
72
212
|
let unsubscribeProvider = null;
|
|
@@ -74,15 +214,68 @@ export function createGitReadService(ctx, { bundledProvider, now = () => Date.no
|
|
|
74
214
|
const safeFilePath = (value) => typeof value === "string" && value !== "" && !isAbsolute(value) && !/^(?:[A-Za-z]:[\\/]|\\\\|[a-z][a-z\d+.-]*:\/\/)/i.test(value) && !value.split(/[\\/]/).includes("..") && !value.includes("\0");
|
|
75
215
|
const validRef = (value) => value === "HEAD" || (typeof value === "string" && /^refs\/(?:heads|remotes)\/[A-Za-z0-9._/-]+$/.test(value) && !value.includes("..") && !value.includes("//") && !value.includes("@{") && !/(^|\/)\.|\/$|\.lock(?:\/|$)|[/.]$/.test(value));
|
|
76
216
|
const stale = () => failure("graph-stale", 409);
|
|
217
|
+
const hasNewFileMethods = (candidate) => typeof candidate?.files === "function" && typeof candidate?.fileHistory === "function";
|
|
218
|
+
const bundledProviderReady = () => {
|
|
219
|
+
try { return bundled.capabilities?.().available === true; } catch { return false; }
|
|
220
|
+
};
|
|
221
|
+
// Truthful capability: only the ACTIVE provider may serve the new endpoints.
|
|
222
|
+
// A legacy external provider must not be mixed with bundled data (its graph
|
|
223
|
+
// and commit facts come from a different source); a bundled provider also
|
|
224
|
+
// needs its Git subprocess to be usable.
|
|
225
|
+
const fileHistoryFeature = () => {
|
|
226
|
+
if (!hasNewFileMethods(provider)) return false;
|
|
227
|
+
if (provider !== bundled) return true;
|
|
228
|
+
return bundledProviderReady();
|
|
229
|
+
};
|
|
230
|
+
const fileReader = () => {
|
|
231
|
+
if (!hasNewFileMethods(provider)) throw failure("provider-incompatible", 503);
|
|
232
|
+
return provider;
|
|
233
|
+
};
|
|
234
|
+
const readRefRecords = async (root) => {
|
|
235
|
+
if (typeof provider.refs !== "function") return null;
|
|
236
|
+
try {
|
|
237
|
+
const records = await provider.refs(root);
|
|
238
|
+
return Array.isArray(records) ? records : null;
|
|
239
|
+
} catch { return null; }
|
|
240
|
+
};
|
|
77
241
|
const capabilities = () => {
|
|
78
242
|
const base = provider.capabilities?.() ?? { available: true, reason: null };
|
|
79
243
|
return {
|
|
80
|
-
contractVersion: "1.
|
|
244
|
+
contractVersion: "1.1.0", available: base.available !== false,
|
|
81
245
|
reason: ["git-provider-unavailable", "workspace-registry-unavailable"].includes(base.reason) ? base.reason : null,
|
|
82
246
|
readOnly: true, provider: provider === external ? "external" : "bundled",
|
|
83
|
-
features: {
|
|
247
|
+
features: {
|
|
248
|
+
repository: true, branches: true, graph: true, commit: true, worktree: true, preview: true,
|
|
249
|
+
// Both flags are independent and additive: refsDetail can be
|
|
250
|
+
// synthesized from legacy strings; fileHistory is true only when the
|
|
251
|
+
// active provider implements the new methods (see fileHistoryFeature).
|
|
252
|
+
refsDetail: true, fileHistory: fileHistoryFeature(),
|
|
253
|
+
},
|
|
84
254
|
};
|
|
85
255
|
};
|
|
256
|
+
/**
|
|
257
|
+
* Normalize a client-supplied path to a repository-relative POSIX path.
|
|
258
|
+
* Returns null for paths that escape the repository (reported as
|
|
259
|
+
* `path-invalid`), and throws 400 for directories.
|
|
260
|
+
*/
|
|
261
|
+
const normalizeHistoryPath = async (root, input) => {
|
|
262
|
+
if (typeof input !== "string" || input === "" || input.includes("\0")) return null;
|
|
263
|
+
// Windows-style absolute paths cannot be mapped on a POSIX host; report
|
|
264
|
+
// them as invalid instead of treating them as a relative filename.
|
|
265
|
+
if (/^(?:[A-Za-z]:[\\/]|\\\\)/.test(input) && !isAbsolute(input)) return null;
|
|
266
|
+
const candidate = isAbsolute(input) ? resolve(input) : resolve(root, input);
|
|
267
|
+
const rel = relative(root, candidate);
|
|
268
|
+
if (rel === "") throw failure("path-invalid", 400);
|
|
269
|
+
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null;
|
|
270
|
+
const normalized = sep === "/" ? rel : rel.split(sep).join("/");
|
|
271
|
+
try {
|
|
272
|
+
const info = await lstat(candidate);
|
|
273
|
+
if (info.isDirectory()) throw failure("path-invalid", 400);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (error?.code === "path-invalid") throw error;
|
|
276
|
+
}
|
|
277
|
+
return normalized;
|
|
278
|
+
};
|
|
86
279
|
const service = {
|
|
87
280
|
capabilities,
|
|
88
281
|
async capabilitiesForSession(sessionId) {
|
|
@@ -115,10 +308,10 @@ export function createGitReadService(ctx, { bundledProvider, now = () => Date.no
|
|
|
115
308
|
},
|
|
116
309
|
async worktree(sessionId, repositoryId) {
|
|
117
310
|
const authorized = await binding(sessionId, repositoryId);
|
|
118
|
-
let reader = typeof provider.worktree === "function" ? provider :
|
|
311
|
+
let reader = typeof provider.worktree === "function" ? provider : bundled;
|
|
119
312
|
let raw = await reader.worktree(authorized.root);
|
|
120
313
|
if (typeof raw?.signature !== "string" || raw.signature === "" || !Array.isArray(raw.entries)) {
|
|
121
|
-
reader =
|
|
314
|
+
reader = bundled;
|
|
122
315
|
raw = await reader.worktree(authorized.root);
|
|
123
316
|
}
|
|
124
317
|
const entries = (raw.entries ?? []).filter((entry) => safeFilePath(entry.path)).slice(0, 2000);
|
|
@@ -130,7 +323,7 @@ export function createGitReadService(ctx, { bundledProvider, now = () => Date.no
|
|
|
130
323
|
async preview(sessionId, repositoryId, { kind, snapshotId, path, oid } = {}) {
|
|
131
324
|
const authorized = await binding(sessionId, repositoryId);
|
|
132
325
|
if (!safeFilePath(path) || !["staged", "unstaged", "untracked", "commit"].includes(kind)) throw failure("preview-invalid", 400);
|
|
133
|
-
const reader =
|
|
326
|
+
const reader = bundled;
|
|
134
327
|
let raw;
|
|
135
328
|
if (kind === "commit") {
|
|
136
329
|
if (!validOid(oid)) throw failure("invalid-oid", 400);
|
|
@@ -205,7 +398,20 @@ export function createGitReadService(ctx, { bundledProvider, now = () => Date.no
|
|
|
205
398
|
keepBounded(fileCursors, 1024);
|
|
206
399
|
}
|
|
207
400
|
const stats = allFiles.reduce((sum, file) => ({ additions: sum.additions + (file.additions ?? 0), deletions: sum.deletions + (file.deletions ?? 0), files: sum.files + 1 }), { additions: 0, deletions: 0, files: 0 });
|
|
208
|
-
|
|
401
|
+
const commitOid = String(value.oid ?? oid);
|
|
402
|
+
const legacyRefs = Array.isArray(value.refs) ? value.refs.map(String) : [];
|
|
403
|
+
const legacyTags = Array.isArray(value.tags) ? value.tags.map(String) : [];
|
|
404
|
+
const records = await readRefRecords(authorized.root);
|
|
405
|
+
const refDetails = mergeRefDetails(
|
|
406
|
+
records ? refDetailsFromRecords(records, commitOid, { fullTags: true }) : emptyRefDetails(),
|
|
407
|
+
refDetailsFromLegacyRefs(legacyRefs, commitOid, { tags: legacyTags }),
|
|
408
|
+
);
|
|
409
|
+
return {
|
|
410
|
+
repositoryId, oid: commitOid, parents: Array.isArray(value.parents) ? value.parents.map(String) : [],
|
|
411
|
+
author: String(value.author ?? ""), timestamp: Number(value.timestamp) || 0, message: String(value.message ?? ""),
|
|
412
|
+
refs: legacyRefs, tags: legacyTags, refsDetail: refDetails.refsDetail, tagsDetail: refDetails.tagsDetail,
|
|
413
|
+
stats, files: { items, total: allFiles.length, nextCursor },
|
|
414
|
+
};
|
|
209
415
|
},
|
|
210
416
|
async graph(sessionId, repositoryId, { tips, snapshotId, cursor, limit = 100 } = {}) {
|
|
211
417
|
const authorized = await binding(sessionId, repositoryId);
|
|
@@ -260,11 +466,22 @@ export function createGitReadService(ctx, { bundledProvider, now = () => Date.no
|
|
|
260
466
|
try { current = await provider.resolveTip(authorized.root, tip.name); } catch { snapshots.delete(snapshot.id); throw stale(); }
|
|
261
467
|
if (current.toLowerCase() !== tip.tipOid.toLowerCase()) { snapshots.delete(snapshot.id); throw stale(); }
|
|
262
468
|
}
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
469
|
+
const records = await readRefRecords(authorized.root);
|
|
470
|
+
const commits = (Array.isArray(rawCommits) ? rawCommits : []).map((row) => {
|
|
471
|
+
const oid = String(row.oid ?? "");
|
|
472
|
+
const legacy = Array.isArray(row.refs) ? row.refs.map(String) : [];
|
|
473
|
+
// Graph rows only guarantee name-level refs plus `annotated`; full
|
|
474
|
+
// tag metadata is reserved for commit details.
|
|
475
|
+
const refDetails = mergeRefDetails(
|
|
476
|
+
records ? refDetailsFromRecords(records, oid) : emptyRefDetails(),
|
|
477
|
+
refDetailsFromLegacyRefs(legacy, oid),
|
|
478
|
+
);
|
|
479
|
+
return {
|
|
480
|
+
oid, parents: Array.isArray(row.parents) ? row.parents.map(String) : [],
|
|
481
|
+
author: String(row.author ?? ""), timestamp: Number(row.timestamp) || 0, subject: String(row.subject ?? ""),
|
|
482
|
+
refs: legacy, refsDetail: refDetails.refsDetail, tagsDetail: refDetails.tagsDetail,
|
|
483
|
+
};
|
|
484
|
+
});
|
|
268
485
|
let nextCursor = null;
|
|
269
486
|
if (commits.length === pageSize) {
|
|
270
487
|
nextCursor = randomUUID();
|
|
@@ -273,6 +490,68 @@ export function createGitReadService(ctx, { bundledProvider, now = () => Date.no
|
|
|
273
490
|
}
|
|
274
491
|
return { repositoryId, snapshotId: snapshot.id, tips: snapshot.tips, commits, nextCursor };
|
|
275
492
|
},
|
|
493
|
+
async files(sessionId, repositoryId, { q, limit } = {}) {
|
|
494
|
+
const authorized = await binding(sessionId, repositoryId);
|
|
495
|
+
const pageSize = Math.max(1, Math.min(MAX_TRACKED_FILES, Number(limit) || MAX_TRACKED_FILES));
|
|
496
|
+
const query = typeof q === "string" ? q : "";
|
|
497
|
+
const raw = await fileReader().files(authorized.root, { q: query, limit: pageSize });
|
|
498
|
+
const files = [...new Set((Array.isArray(raw?.files) ? raw.files : [])
|
|
499
|
+
.map((path) => String(path ?? ""))
|
|
500
|
+
.filter((path) => safeFilePath(path)))].sort(compareText).slice(0, pageSize);
|
|
501
|
+
return { repositoryId, files, truncated: raw?.truncated === true };
|
|
502
|
+
},
|
|
503
|
+
async fileHistory(sessionId, repositoryId, { path, cursor, limit } = {}) {
|
|
504
|
+
const authorized = await binding(sessionId, repositoryId);
|
|
505
|
+
const pageSize = Math.max(1, Math.min(FILE_HISTORY_MAX_LIMIT, Number(limit) || FILE_HISTORY_DEFAULT_LIMIT));
|
|
506
|
+
prune();
|
|
507
|
+
const normalized = await normalizeHistoryPath(authorized.root, path);
|
|
508
|
+
if (normalized === null) {
|
|
509
|
+
return { repositoryId, path: String(path ?? ""), entries: [], nextCursor: null, truncated: false, reason: "path-invalid" };
|
|
510
|
+
}
|
|
511
|
+
let snapshot = null, offset = 0;
|
|
512
|
+
if (cursor !== undefined && cursor !== null && cursor !== "") {
|
|
513
|
+
const saved = historyCursors.get(String(cursor));
|
|
514
|
+
if (!saved || saved.expiresAt <= now() || saved.sessionId !== sessionId || saved.repositoryId !== repositoryId || saved.path !== normalized) throw failure("invalid-history-cursor", 400);
|
|
515
|
+
snapshot = historySnapshots.get(saved.snapshotId);
|
|
516
|
+
if (!snapshot || snapshot.expiresAt <= now() || snapshot.root !== authorized.root) throw failure("invalid-history-cursor", 400);
|
|
517
|
+
offset = saved.offset;
|
|
518
|
+
}
|
|
519
|
+
if (!snapshot) {
|
|
520
|
+
const raw = await fileReader().fileHistory(authorized.root, normalized, {
|
|
521
|
+
maxCommits: FILE_HISTORY_MAX_COMMITS,
|
|
522
|
+
timeoutMs: FILE_HISTORY_TIMEOUT_MS,
|
|
523
|
+
});
|
|
524
|
+
const reason = FILE_HISTORY_REASONS.has(raw?.reason) ? raw.reason : null;
|
|
525
|
+
const entries = (Array.isArray(raw?.entries) ? raw.entries : []).map((entry) => ({
|
|
526
|
+
oid: String(entry?.oid ?? ""),
|
|
527
|
+
shortOid: String(entry?.shortOid ?? String(entry?.oid ?? "").slice(0, 7)),
|
|
528
|
+
author: String(entry?.author ?? ""),
|
|
529
|
+
timestamp: Number(entry?.timestamp) || 0,
|
|
530
|
+
subject: String(entry?.subject ?? ""),
|
|
531
|
+
status: historyStatus(entry?.status),
|
|
532
|
+
})).filter((entry) => validOid(entry.oid));
|
|
533
|
+
if (reason && entries.length === 0) {
|
|
534
|
+
return { repositoryId, path: normalized, entries: [], nextCursor: null, truncated: false, reason };
|
|
535
|
+
}
|
|
536
|
+
snapshot = {
|
|
537
|
+
id: randomUUID(), sessionId, repositoryId, root: authorized.root, path: normalized,
|
|
538
|
+
entries, truncated: raw?.truncated === true, expiresAt: now() + snapshotTtlMs,
|
|
539
|
+
};
|
|
540
|
+
historySnapshots.set(snapshot.id, snapshot);
|
|
541
|
+
keepBounded(historySnapshots, MAX_FILE_HISTORY_SNAPSHOTS);
|
|
542
|
+
}
|
|
543
|
+
const items = snapshot.entries.slice(offset, offset + pageSize);
|
|
544
|
+
let nextCursor = null;
|
|
545
|
+
if (offset + items.length < snapshot.entries.length) {
|
|
546
|
+
nextCursor = randomUUID();
|
|
547
|
+
historyCursors.set(nextCursor, {
|
|
548
|
+
sessionId, repositoryId, path: normalized, snapshotId: snapshot.id,
|
|
549
|
+
offset: offset + items.length, expiresAt: now() + snapshotTtlMs,
|
|
550
|
+
});
|
|
551
|
+
keepBounded(historyCursors, 1024);
|
|
552
|
+
}
|
|
553
|
+
return { repositoryId, path: normalized, entries: items, nextCursor, truncated: snapshot.truncated };
|
|
554
|
+
},
|
|
276
555
|
async _pollOnce() {
|
|
277
556
|
for (const [repositoryId, authorized] of bindings) {
|
|
278
557
|
try {
|
package/lib/index.js
CHANGED
|
@@ -2738,6 +2738,14 @@ export function apply(ctx, config, runtime = {}) {
|
|
|
2738
2738
|
backgroundOnBudget: false,
|
|
2739
2739
|
runDeadlineMs: budgetMs,
|
|
2740
2740
|
});
|
|
2741
|
+
/**
|
|
2742
|
+
* 审批/问询在手机在线时的保持时长默认值:1 小时。
|
|
2743
|
+
*
|
|
2744
|
+
* 原本是 120 秒——人工处理一个需要看代码的审批(尤其手机上)经常来不及,
|
|
2745
|
+
* 超时后按 unavailable 结算,用户看到的是"莫名其妙失败了"。
|
|
2746
|
+
* 可用 config.pendingTimeoutMs 覆盖。
|
|
2747
|
+
*/
|
|
2748
|
+
const DEFAULT_PENDING_TIMEOUT_MS = 60 * 60_000;
|
|
2741
2749
|
const NOTIF_MAX = 100;
|
|
2742
2750
|
let catalogCache = null; // { at, body } 15 秒 TTL
|
|
2743
2751
|
let balanceCache = null; // { at, body } 余额缓存 60 秒 TTL(官方 API 抖动时兜底)
|
|
@@ -4818,7 +4826,7 @@ export function apply(ctx, config, runtime = {}) {
|
|
|
4818
4826
|
"connection": "keep-alive",
|
|
4819
4827
|
});
|
|
4820
4828
|
res.write(": connected\n\n");
|
|
4821
|
-
res.write(`data: ${JSON.stringify({ type: "hello", serverTime: Date.now(), capabilities: { eventTimeline: EVENT_TIMELINE_CAPABILITIES, turnOutline: turnOutlineCapabilityOf(ctx), fileRange: FILE_RANGE_CAPABILITIES, hold: { version: 1, onlineMs: Math.max(0, Number(config.pendingTimeoutMs ??
|
|
4829
|
+
res.write(`data: ${JSON.stringify({ type: "hello", serverTime: Date.now(), capabilities: { eventTimeline: EVENT_TIMELINE_CAPABILITIES, turnOutline: turnOutlineCapabilityOf(ctx), fileRange: FILE_RANGE_CAPABILITIES, hold: { version: 1, onlineMs: Math.max(0, Number(config.pendingTimeoutMs ?? DEFAULT_PENDING_TIMEOUT_MS) || 0), offlineMs: Math.max(0, Number(config.offlineHoldMs ?? 300_000) || 0) } } })}\n\n`);
|
|
4822
4830
|
connections.add(res);
|
|
4823
4831
|
// 补发断线期间挂起的问询/审批(与 PC 端 GUI 连接时的 pending 回放一致;超 TTL 的僵尸帧先清掉)
|
|
4824
4832
|
for (const [key, f] of pendingFrames) {
|
|
@@ -5563,7 +5571,7 @@ export function apply(ctx, config, runtime = {}) {
|
|
|
5563
5571
|
// v3.2.4(issue #55):手机端待办等待上限(仅供显示剩余时间,结算仍以服务端为准)。
|
|
5564
5572
|
hold: {
|
|
5565
5573
|
version: 1,
|
|
5566
|
-
onlineMs: Math.max(0, Number(config.pendingTimeoutMs ??
|
|
5574
|
+
onlineMs: Math.max(0, Number(config.pendingTimeoutMs ?? DEFAULT_PENDING_TIMEOUT_MS) || 0),
|
|
5567
5575
|
offlineMs: Math.max(0, Number(config.offlineHoldMs ?? 300_000) || 0),
|
|
5568
5576
|
},
|
|
5569
5577
|
},
|
|
@@ -5710,7 +5718,7 @@ export function apply(ctx, config, runtime = {}) {
|
|
|
5710
5718
|
return sendJson(res, result.status, result.body);
|
|
5711
5719
|
}
|
|
5712
5720
|
|
|
5713
|
-
if (new Set(["/git/capabilities", "/git/repository", "/git/branches", "/git/graph", "/git/commit", "/git/worktree", "/git/preview"]).has(rest)) {
|
|
5721
|
+
if (new Set(["/git/capabilities", "/git/repository", "/git/branches", "/git/graph", "/git/commit", "/git/worktree", "/git/preview", "/git/files", "/git/file-history"]).has(rest)) {
|
|
5714
5722
|
if (requireGet(method, res)) return;
|
|
5715
5723
|
const sessionId = url.searchParams.get("sessionId") ?? "";
|
|
5716
5724
|
if (!sessionId) return error(res, 400, "bad-request", "sessionId is required");
|
|
@@ -5724,7 +5732,12 @@ export function apply(ctx, config, runtime = {}) {
|
|
|
5724
5732
|
if (rest === "/git/branches") value = await gitRead.branches(sessionId, repositoryId);
|
|
5725
5733
|
else if (rest === "/git/worktree") value = await gitRead.worktree(sessionId, repositoryId);
|
|
5726
5734
|
else if (rest === "/git/preview") value = await gitRead.preview(sessionId, repositoryId, { kind: url.searchParams.get("kind"), snapshotId: url.searchParams.get("snapshotId"), path: url.searchParams.get("path"), oid: url.searchParams.get("oid") });
|
|
5727
|
-
else if (rest === "/git/
|
|
5735
|
+
else if (rest === "/git/files") value = await gitRead.files(sessionId, repositoryId, { q: url.searchParams.get("q") ?? undefined, limit: url.searchParams.get("limit") ?? undefined });
|
|
5736
|
+
else if (rest === "/git/file-history") {
|
|
5737
|
+
const path = url.searchParams.get("path");
|
|
5738
|
+
if (path === null || path === "") return error(res, 400, "bad-request", "path is required");
|
|
5739
|
+
value = await gitRead.fileHistory(sessionId, repositoryId, { path, cursor: url.searchParams.get("cursor") ?? undefined, limit: url.searchParams.get("limit") ?? undefined });
|
|
5740
|
+
} else if (rest === "/git/graph") {
|
|
5728
5741
|
let tips;
|
|
5729
5742
|
const rawTips = url.searchParams.get("tips");
|
|
5730
5743
|
if (rawTips !== null) {
|
|
@@ -5738,7 +5751,7 @@ export function apply(ctx, config, runtime = {}) {
|
|
|
5738
5751
|
} catch (err) {
|
|
5739
5752
|
// External providers may include host paths or credentials in messages:
|
|
5740
5753
|
// only expose stable mobile error codes, never raw provider details.
|
|
5741
|
-
const known = new Set(["session-not-found", "workspace-not-allowed", "not-git-repository", "git-provider-unavailable", "repository-not-authorized", "graph-stale", "graph-tip-invalid", "graph-too-many-tips", "invalid-oid", "invalid-files-cursor", "git-command-failed", "git-output-too-large", "provider-incompatible", "preview-invalid", "preview-file-not-found"]);
|
|
5754
|
+
const known = new Set(["session-not-found", "workspace-not-allowed", "not-git-repository", "git-provider-unavailable", "repository-not-authorized", "graph-stale", "graph-tip-invalid", "graph-too-many-tips", "invalid-oid", "invalid-files-cursor", "invalid-history-cursor", "path-invalid", "git-command-failed", "git-output-too-large", "provider-incompatible", "preview-invalid", "preview-file-not-found"]);
|
|
5742
5755
|
const code = known.has(err?.code) ? err.code : "git-provider-unavailable";
|
|
5743
5756
|
const status = Number(err?.status);
|
|
5744
5757
|
ctx.logger.warn?.(`mobile-remote: Git read failed (${code}): ${err?.message ?? err}`);
|
|
@@ -8437,7 +8450,7 @@ export function apply(ctx, config, runtime = {}) {
|
|
|
8437
8450
|
// desktop —— 一律 next() 交桌面 GUI(手机不弹审批/问询卡),适合常驻电脑前。
|
|
8438
8451
|
const approvalMode = config.approvalMode ?? "both";
|
|
8439
8452
|
/** 手机待办超时 fail-close(默认沿用 120s;v3.2.4 / issue #55 起可配)。 */
|
|
8440
|
-
const PENDING_TIMEOUT_MS = Math.max(0, Number(config.pendingTimeoutMs ??
|
|
8453
|
+
const PENDING_TIMEOUT_MS = Math.max(0, Number(config.pendingTimeoutMs ?? DEFAULT_PENDING_TIMEOUT_MS) || 0);
|
|
8441
8454
|
/** 手机离线时仍接管审批/问询的等待上限(修复:离线交桌面导致内核悬挂)。 */
|
|
8442
8455
|
const OFFLINE_HOLD_MS = Math.max(0, Number(config.offlineHoldMs ?? 300_000) || 0);
|
|
8443
8456
|
/** 审批/问询推送的宽限期(0 = 立即推)。 */
|
|
@@ -8451,7 +8464,7 @@ export function apply(ctx, config, runtime = {}) {
|
|
|
8451
8464
|
* 无手机在线时,条目 + 回放帧只是"等手机回来再答"的记录:到期**成对清理**且**不结算**,
|
|
8452
8465
|
* 既不给桌面端流程添乱,也不会留下"点了没反应"的死卡(条目没了帧还在)。
|
|
8453
8466
|
*/
|
|
8454
|
-
const OFFLINE_PENDING_TTL_MS =
|
|
8467
|
+
const OFFLINE_PENDING_TTL_MS = 70 * 60_000; // 必须 > 在线保持时长(1h),否则回放帧先于结算消失
|
|
8455
8468
|
/**
|
|
8456
8469
|
* 待答帧的回放存储读写(v3.1.4,issue #14 Bug2)。
|
|
8457
8470
|
* 键以 phoneId 为主(与 /respond 结算同源),并兼容 rpcId 形态的旧键;
|
|
@@ -8610,9 +8623,9 @@ export function apply(ctx, config, runtime = {}) {
|
|
|
8610
8623
|
const entry = { phoneId: phoneKey, sessionId, rpcId: phoneKey, timer: null, holdMs, settle: resolveHeld };
|
|
8611
8624
|
pendingApprovals.set(phoneKey, entry);
|
|
8612
8625
|
armTimeout(entry, () => {
|
|
8613
|
-
//
|
|
8614
|
-
ctx.logger.warn?.(`mobile-remote: approval/request 手机超时未应答 →
|
|
8615
|
-
finishApproval(entry, "
|
|
8626
|
+
// 手机超时未应答:按「超时自动拒绝」结算(rejected),而非 unavailable——前者用户看得到「被拒绝」,后者只显示「没有答复」
|
|
8627
|
+
ctx.logger.warn?.(`mobile-remote: approval/request 手机超时未应答 → rejected(超时自动拒绝)(${phoneKey})`);
|
|
8628
|
+
finishApproval(entry, "rejected");
|
|
8616
8629
|
});
|
|
8617
8630
|
const requested = {
|
|
8618
8631
|
type: "approval/requested", rpcId: entry.rpcId, sessionId, approvalId: phoneKey,
|
|
@@ -8700,8 +8713,8 @@ export function apply(ctx, config, runtime = {}) {
|
|
|
8700
8713
|
armTimeout(entry, () => {
|
|
8701
8714
|
// 手机在场但两端均未应答:fail-close(与 mobile 接管语义一致;另一端
|
|
8702
8715
|
// 超时前先答则本端已由 cancel 帧收卡,此回调因条目删除而失效)
|
|
8703
|
-
ctx.logger.warn?.(`mobile-remote: approval/request 双端待办超时 →
|
|
8704
|
-
finishApproval(entry, "
|
|
8716
|
+
ctx.logger.warn?.(`mobile-remote: approval/request 双端待办超时 → rejected(超时自动拒绝)(${phoneKey})`);
|
|
8717
|
+
finishApproval(entry, "rejected");
|
|
8705
8718
|
});
|
|
8706
8719
|
} else {
|
|
8707
8720
|
armOfflineReap(entry, "a");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marktrue/dsh-mobile-remote",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.4-alpha.1",
|
|
4
4
|
"description": "DSH Mobile:把 DeepSeek Harness 变成随身控制台——手机远程发消息派活、看进度、收通知、审批决策、管理会话;配套 Flutter App(当前 Android 端,iOS 未开发)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"qrcode"
|
|
53
53
|
],
|
|
54
54
|
"optionalDependencies": {
|
|
55
|
-
"@marktrue/dsh-mobile-remote-app": "npm:@marktrue/dsh-mobile-remote@3.2.
|
|
55
|
+
"@marktrue/dsh-mobile-remote-app": "npm:@marktrue/dsh-mobile-remote@3.2.4-alpha.1-app"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
58
|
"@deepseek-ai/dsh-credentials": ">=0.1.0-rc.6 <0.3.0",
|