my-durable 0.0.30 → 0.0.32
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/apps/dpi-backend/src/faux.ts +6 -0
- package/apps/dpi-backend/src/git.ts +58 -2
- package/apps/dpi-backend/src/routes.ts +13 -1
- package/apps/dpi-backend/src/store.ts +91 -1
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-B2OcBmds.js +81 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CL5-3stf.css +1 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +2 -2
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/GitPanel.vue +242 -5
- package/apps/dpi-sdk/src/client.ts +18 -1
- package/apps/dpi-sdk/src/protocol/rest-routes.ts +1 -0
- package/package.json +1 -1
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-38s6IL1Z.js +0 -79
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-DsIHMahK.css +0 -1
|
@@ -67,6 +67,12 @@ export function installFaux(models: MutableModels) {
|
|
|
67
67
|
const ref = prompt.slice(6).trim();
|
|
68
68
|
return call("check_durable_job", /^\d+$/.test(ref) ? { task_id: Number(ref) } : ref ? { job: ref } : {});
|
|
69
69
|
}
|
|
70
|
+
if (prompt.startsWith("write:")) {
|
|
71
|
+
// "write:<path>|<content>" — exercises the real write tool so git-panel
|
|
72
|
+
// features (session changelist, per-turn summary) are testable.
|
|
73
|
+
const [path, ...rest] = prompt.slice(6).split("|");
|
|
74
|
+
return call("write", { path: (path ?? "").trim(), content: rest.join("|") });
|
|
75
|
+
}
|
|
70
76
|
if (prompt.startsWith("search:")) return call("search_history", { query: prompt.slice(7).trim() });
|
|
71
77
|
if (prompt.startsWith("page:")) return call("ping_on_call_engineer", { summary: prompt.slice(5).trim() });
|
|
72
78
|
if (prompt.startsWith("Release check:"))
|
|
@@ -41,6 +41,19 @@ export function git(cwd: string, args: string[], timeout = GIT_TIMEOUT): Promise
|
|
|
41
41
|
});
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** One GitHub CLI call — PRs, checks and comments are forge territory and gh
|
|
45
|
+
* is the least-token-ceremony way to reach them from a device. Same no-shell
|
|
46
|
+
* rule: argv only, plain text back. */
|
|
47
|
+
export function gh(cwd: string, args: string[], timeout = GIT_TIMEOUT): Promise<string> {
|
|
48
|
+
return new Promise((ok, fail) => {
|
|
49
|
+
// gh resolves the repo from cwd (a --repo flag wants a slug, not a path).
|
|
50
|
+
execFile("gh", args, { cwd, timeout, maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
51
|
+
if (error) fail(Error(String(stderr || stdout || error.message).trim().slice(0, 400)));
|
|
52
|
+
else ok(String(stdout));
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
44
57
|
const count = (s: string, re: RegExp) => (s.match(re) ?? []).length;
|
|
45
58
|
|
|
46
59
|
export interface GitStatus {
|
|
@@ -230,7 +243,13 @@ export type GitOp =
|
|
|
230
243
|
| "ignore"
|
|
231
244
|
| "checkpoint"
|
|
232
245
|
| "checkpoints"
|
|
233
|
-
| "rewind"
|
|
246
|
+
| "rewind"
|
|
247
|
+
| "submodule-update"
|
|
248
|
+
| "pr-create"
|
|
249
|
+
| "pr-list"
|
|
250
|
+
| "pr-merge"
|
|
251
|
+
| "pr-checks"
|
|
252
|
+
| "pr-comment";
|
|
234
253
|
|
|
235
254
|
/** Plain string an arg may never contain — we build argv ourselves anyway. */
|
|
236
255
|
const safeRef = (s: string) => /^[\w./@+-]+$/.test(s) && !s.includes("..");
|
|
@@ -255,6 +274,10 @@ export async function gitOp(
|
|
|
255
274
|
name?: string;
|
|
256
275
|
/** ignore: pattern lines to append to .gitignore. */
|
|
257
276
|
lines?: string[];
|
|
277
|
+
/** pr: merge method + comment body + PR number. */
|
|
278
|
+
method?: "squash" | "merge";
|
|
279
|
+
body?: string;
|
|
280
|
+
n?: number;
|
|
258
281
|
} = {},
|
|
259
282
|
): Promise<{
|
|
260
283
|
text: string;
|
|
@@ -263,8 +286,9 @@ export async function gitOp(
|
|
|
263
286
|
behind?: number;
|
|
264
287
|
changed?: { status: string; path: string }[];
|
|
265
288
|
checkpoints?: { name: string; hash: string; date: string; subject: string }[];
|
|
289
|
+
prs?: { n: number; title: string; state: string; draft: boolean; branch: string; url: string }[];
|
|
266
290
|
}> {
|
|
267
|
-
const { ref, message, target, branch, files, create, staged, rebase, setUpstream, name, lines } = opts;
|
|
291
|
+
const { ref, message, target, branch, files, create, staged, rebase, setUpstream, name, lines, method, body, n } = opts;
|
|
268
292
|
switch (op) {
|
|
269
293
|
case "diff":
|
|
270
294
|
return { text: await git(path, ["--no-pager", "diff", ...(ref && safeRef(ref) ? [ref] : [])]) };
|
|
@@ -412,6 +436,38 @@ export async function gitOp(
|
|
|
412
436
|
const sha = (await git(path, ["rev-parse", "--verify", refname])).trim();
|
|
413
437
|
return { text: (await git(path, ["reset", "--hard", sha])).trim().split("\n")[0] ?? `rewound to ${name}` };
|
|
414
438
|
}
|
|
439
|
+
case "submodule-update":
|
|
440
|
+
return { text: (await git(path, ["submodule", "update", "--init", "--recursive"], GIT_SLOW_TIMEOUT)).trim() || "submodules up to date" };
|
|
441
|
+
case "pr-create": {
|
|
442
|
+
// gh pr create — title from message, head = current branch. gh owns auth.
|
|
443
|
+
const title = (message ?? "").trim();
|
|
444
|
+
if (!title) throw Error("pr-create needs a title (message)");
|
|
445
|
+
const args = ["pr", "create", "--title", title.slice(0, 200), "--body", (body ?? title).slice(0, 4000)];
|
|
446
|
+
if (ref && safeRef(ref)) args.push("--base", ref);
|
|
447
|
+
return { text: (await gh(path, args, GIT_SLOW_TIMEOUT)).trim() };
|
|
448
|
+
}
|
|
449
|
+
case "pr-list": {
|
|
450
|
+
const raw = await gh(path, ["pr", "list", "--limit", "20", "--json", "number,title,state,isDraft,headRefName,url"]);
|
|
451
|
+
const list = JSON.parse(raw || "[]") as { number: number; title: string; state: string; isDraft: boolean; headRefName: string; url: string }[];
|
|
452
|
+
return {
|
|
453
|
+
text: "",
|
|
454
|
+
prs: list.map((r) => ({ n: r.number, title: r.title, state: r.state, draft: r.isDraft, branch: r.headRefName, url: r.url })),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
case "pr-merge": {
|
|
458
|
+
if (!n || !Number.isInteger(n)) throw Error("pr-merge needs a PR number");
|
|
459
|
+
return { text: (await gh(path, ["pr", "merge", String(n), method === "merge" ? "--merge" : "--squash"], GIT_SLOW_TIMEOUT)).trim() || `PR #${n} merged` };
|
|
460
|
+
}
|
|
461
|
+
case "pr-checks": {
|
|
462
|
+
if (!n || !Number.isInteger(n)) throw Error("pr-checks needs a PR number");
|
|
463
|
+
return { text: (await gh(path, ["pr", "checks", String(n)], GIT_SLOW_TIMEOUT)).trim() || "no checks reported" };
|
|
464
|
+
}
|
|
465
|
+
case "pr-comment": {
|
|
466
|
+
if (!n || !Number.isInteger(n)) throw Error("pr-comment needs a PR number");
|
|
467
|
+
const text = (body ?? "").trim();
|
|
468
|
+
if (!text) throw Error("pr-comment needs a body");
|
|
469
|
+
return { text: (await gh(path, ["pr", "comment", String(n), "--body", text.slice(0, 4000)])).trim() };
|
|
470
|
+
}
|
|
415
471
|
case "commit": {
|
|
416
472
|
const msg = (message ?? "").trim();
|
|
417
473
|
if (!msg) throw Error("commit needs a message");
|
|
@@ -11,7 +11,7 @@ import type { DevicePairing } from "./pairing.js";
|
|
|
11
11
|
type Async = (req: Request, res: Response) => Promise<unknown>;
|
|
12
12
|
const route = (fn: Async) => (req: Request, res: Response, next: NextFunction) => fn(req, res).catch(next);
|
|
13
13
|
import { gitOp, gitStatus, gitStatusBatch, type GitOp } from "./git.ts";
|
|
14
|
-
const GIT_OPS: GitOp[] = ["diff", "log", "log-list", "fetch", "pull", "push", "switch", "branch", "commit", "stage", "unstage", "discard", "diff-file", "stash-push", "stash-pop", "compare", "merge-abort", "show-files", "ignore", "checkpoint", "checkpoints", "rewind", "worktree-list", "worktree-add", "worktree-remove"];
|
|
14
|
+
const GIT_OPS: GitOp[] = ["diff", "log", "log-list", "fetch", "pull", "push", "switch", "branch", "commit", "stage", "unstage", "discard", "diff-file", "stash-push", "stash-pop", "compare", "merge-abort", "show-files", "ignore", "checkpoint", "checkpoints", "rewind", "submodule-update", "pr-create", "pr-list", "pr-merge", "pr-checks", "pr-comment", "worktree-list", "worktree-add", "worktree-remove"];
|
|
15
15
|
|
|
16
16
|
interface Locals {
|
|
17
17
|
row: SessionRow;
|
|
@@ -112,6 +112,13 @@ export function v1(
|
|
|
112
112
|
|
|
113
113
|
// ---------------------------------------------------------- device
|
|
114
114
|
r.get("/device", (_req, res) => res.json(store.deviceInfo()));
|
|
115
|
+
// Model health, probed live: one provider, its first catalog model, one
|
|
116
|
+
// throwaway session. Local control plane only — deliberately NOT tunnelled.
|
|
117
|
+
r.post("/device/probe-model", route(async (req, res) => {
|
|
118
|
+
const provider = String(req.body?.provider ?? "");
|
|
119
|
+
const modelId = String(req.body?.modelId ?? "");
|
|
120
|
+
res.json(await store.probeModel(provider, modelId));
|
|
121
|
+
}));
|
|
115
122
|
r.get("/tree", route(async (_req, res) => res.json(await store.tree())));
|
|
116
123
|
r.get("/events", (req, res, next) => {
|
|
117
124
|
try {
|
|
@@ -145,6 +152,9 @@ export function v1(
|
|
|
145
152
|
setUpstream: body.setUpstream === true,
|
|
146
153
|
name: typeof body.name === "string" ? body.name : undefined,
|
|
147
154
|
lines: Array.isArray(body.lines) ? body.lines.map(String).slice(0, 50) : undefined,
|
|
155
|
+
method: body.method === "merge" ? "merge" : "squash",
|
|
156
|
+
body: typeof body.body === "string" ? body.body : undefined,
|
|
157
|
+
n: Number.isInteger(body.n) ? Number(body.n) : undefined,
|
|
148
158
|
});
|
|
149
159
|
res.json(out);
|
|
150
160
|
}));
|
|
@@ -169,6 +179,8 @@ export function v1(
|
|
|
169
179
|
// The zcode flagship, adapted: which files has THIS session's agent touched?
|
|
170
180
|
// Powers "commit session changes" in the git panel.
|
|
171
181
|
r.get("/sessions/:s/git-files", route(async (_req, res) => res.json({ files: await store.sessionTouchedFiles(L(res).row) })));
|
|
182
|
+
// Per-turn breakdown of the same data (zcode taskChangeSummary shape).
|
|
183
|
+
r.get("/sessions/:s/git-turns", route(async (_req, res) => res.json({ turns: await store.sessionGitTurns(L(res).row) })));
|
|
172
184
|
r.get(
|
|
173
185
|
"/sessions/:s/tasks",
|
|
174
186
|
route(async (req, res) => {
|
|
@@ -301,7 +301,15 @@ export class Store {
|
|
|
301
301
|
}
|
|
302
302
|
|
|
303
303
|
private defaultModel() {
|
|
304
|
-
|
|
304
|
+
if (this.options.provider === "faux") return fauxModels[0].id;
|
|
305
|
+
// The curated constant can drift out of the LIVE catalog (seen: default
|
|
306
|
+
// anthropic/claude-sonnet-5.5 while the credential store held 41 other
|
|
307
|
+
// models) — never point the default outside the catalog we serve.
|
|
308
|
+
// (this.modelList, NOT deviceInfo() — that would recurse.)
|
|
309
|
+
const catalog = this.modelList;
|
|
310
|
+
return catalog.some((m) => `${m.provider}/${m.id}` === defaultModelId)
|
|
311
|
+
? defaultModelId
|
|
312
|
+
: (catalog[0] ? `${catalog[0].provider}/${catalog[0].id}` : defaultModelId);
|
|
305
313
|
}
|
|
306
314
|
|
|
307
315
|
/** True when the device has no provider key: the catalog shown is the built-in
|
|
@@ -761,6 +769,88 @@ export class Store {
|
|
|
761
769
|
return [...paths];
|
|
762
770
|
}
|
|
763
771
|
|
|
772
|
+
/**
|
|
773
|
+
* Per-turn file summary (zcode taskChangeSummary): every user turn of the
|
|
774
|
+
* session, with the files its agents wrote or edited during that turn.
|
|
775
|
+
* Line stats come from the UI against the live status — here we only know
|
|
776
|
+
* WHAT was touched and how many times.
|
|
777
|
+
*/
|
|
778
|
+
async sessionGitTurns(row: SessionRow): Promise<{ when: string; files: { path: string; writes: number }[] }[]> {
|
|
779
|
+
const h = await this.openAgent(row);
|
|
780
|
+
const scanned: { when: string; files: Map<string, number> }[] = [];
|
|
781
|
+
let current: { when: string; files: Map<string, number> } | null = null;
|
|
782
|
+
const fileOf = (b: { type?: string; name?: string; arguments?: Record<string, unknown> }): string[] => {
|
|
783
|
+
if (b?.type !== "toolCall" || (b.name !== "write" && b.name !== "edit")) return [];
|
|
784
|
+
const args = b.arguments ?? {};
|
|
785
|
+
const out: string[] = [];
|
|
786
|
+
for (const f of [args.file_path, args.path]) if (typeof f === "string" && f.length > 0 && f.length < 4096) out.push(f);
|
|
787
|
+
const edits = Array.isArray(args.edits) ? (args.edits as { path?: unknown }[]) : [];
|
|
788
|
+
for (const ed of edits) if (typeof ed.path === "string" && ed.path.length > 0) out.push(ed.path);
|
|
789
|
+
return out;
|
|
790
|
+
};
|
|
791
|
+
await h.commit(async (tx) => {
|
|
792
|
+
let convs = await tx.scanConversations({}, 1000);
|
|
793
|
+
const records = [...convs.items];
|
|
794
|
+
while (convs.next && records.length < 100) {
|
|
795
|
+
convs = await tx.scanConversations({}, 1000, convs.next);
|
|
796
|
+
records.push(...convs.items);
|
|
797
|
+
}
|
|
798
|
+
for (const c of records) {
|
|
799
|
+
// ascending: a turn's tool calls FOLLOW its user entry (default is
|
|
800
|
+
// descending, which attributes every call to the NEXT turn — caught
|
|
801
|
+
// by the git-session-files test).
|
|
802
|
+
let page = await tx.scanEntries({ conversationId: c.id, order: "ascending" }, 500);
|
|
803
|
+
let count = 0;
|
|
804
|
+
for (;;) {
|
|
805
|
+
for (const e of page.items as unknown as { kind?: string; model?: readonly { content?: unknown }[] }[]) {
|
|
806
|
+
if (e.kind === "pi.user") {
|
|
807
|
+
current = { when: new Date().toISOString(), files: new Map() };
|
|
808
|
+
scanned.push(current);
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
if (!current) continue;
|
|
812
|
+
for (const m of e.model ?? []) {
|
|
813
|
+
const blocks = Array.isArray(m.content) ? (m.content as unknown[]) : [];
|
|
814
|
+
for (const b of blocks as { type?: string; name?: string; arguments?: Record<string, unknown> }[]) {
|
|
815
|
+
for (const f of fileOf(b)) current.files.set(f, (current.files.get(f) ?? 0) + 1);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
count += page.items.length;
|
|
820
|
+
if (!page.next || count >= 4000) break;
|
|
821
|
+
page = await tx.scanEntries({ conversationId: c.id }, 500, page.next);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}, ctx);
|
|
825
|
+
return scanned
|
|
826
|
+
.map((t) => ({ when: t.when, files: [...t.files.entries()].map(([path, writes]) => ({ path, writes })).slice(0, 50) }))
|
|
827
|
+
.slice(-20);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/** One provider, one model, one throwaway session — is it alive? */
|
|
831
|
+
async probeModel(provider: string, modelId: string): Promise<{ ok: boolean; detail: string }> {
|
|
832
|
+
if (!provider || !modelId) throw invalid("provider and modelId are required");
|
|
833
|
+
const created: SessionRow[] = [];
|
|
834
|
+
try {
|
|
835
|
+
const summary = await this.create({ name: `probe-${provider}`, cwd: this.startDirs[0] ?? homedir(), model: { provider, modelId } });
|
|
836
|
+
const row = { ...summary, createdAt: Number(summary.createdAt) } as unknown as SessionRow;
|
|
837
|
+
created.push(row);
|
|
838
|
+
const h = await this.openAgent(row);
|
|
839
|
+
const c = (await h.conversation(1 as ConversationId, ctx))!;
|
|
840
|
+
const submission = await this.send(c, { content: "Reply with exactly: ok", whenBusy: "followUp", requestId: `probe-${Date.now()}` });
|
|
841
|
+
const result = await this.settled(h, submission.id as number, 45_000);
|
|
842
|
+
const text = result.status === "done" ? result.answer.text.trim() : "";
|
|
843
|
+
return {
|
|
844
|
+
ok: result.status === "done" && !!text,
|
|
845
|
+
detail: result.status === "done" ? (text || "empty answer").slice(0, 120) : ((result as { reason?: string }).reason ?? "no answer").slice(0, 120),
|
|
846
|
+
};
|
|
847
|
+
} catch (e) {
|
|
848
|
+
return { ok: false, detail: String((e as Error).message).slice(0, 160) };
|
|
849
|
+
} finally {
|
|
850
|
+
for (const r of created) void this.archive(r).catch(() => {});
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
764
854
|
// ------------------------------------------------------------ agents
|
|
765
855
|
|
|
766
856
|
private names(sessionId: string) {
|