dsh-report-ledger 0.1.0 → 0.2.0
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 +387 -29
- package/lib/client.js +2528 -331
- package/lib/client.js.map +1 -1
- package/lib/index.js +1802 -74
- package/package.json +4 -3
package/lib/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
3
|
-
import {
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { appendFile, mkdir, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises";
|
|
4
5
|
import { homedir } from "node:os";
|
|
5
|
-
import { join } from "node:path";
|
|
6
|
+
import { basename, dirname, join, normalize, resolve } from "node:path";
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
6
8
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
7
9
|
//#region \0rolldown/runtime.js
|
|
8
10
|
var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
|
|
@@ -149,10 +151,16 @@ const PROTOCOL = [
|
|
|
149
151
|
"- report_list —— 只列缩略;用 session 过滤看某位同伴相关的全部汇报,用 task 把同一次协作跨会话拉到一起(部分匹配、不区分大小写)。",
|
|
150
152
|
"- report_ack —— 回执:确认已收到并处理,让发送方看到闭环(事情仍在进行时用它)。",
|
|
151
153
|
"- report_amend —— 修正:改错的主题/任务标签/产物,或更正正文。**只有发起者或共写者**能改;正文默认是**追加**一段更正,明确要求(replace_body)才会整段替换,两种都会在传递路径里留下记录。",
|
|
152
|
-
"- report_close ——
|
|
154
|
+
"- report_close —— 结案:事情做完了才用它,且**只有发起者或共写者**能关;之后任何贡献/主送/抄送/转呈都会自动重开并记录。结案同时**释放**它持有的工作区(见下)。",
|
|
155
|
+
"- report_release —— 交还工作区:工作做完了但对话没结束、想让出检出时用它。检出原样留在磁盘上,结束的只是\"占用\"。",
|
|
156
|
+
"- report_lease —— 认领一个**已存在**的检出(人手建的、上一份汇报留下的、或你要接手的):与 report_release 互为反向。分支从检出本身读出,所以只有想强制校验时才传 branch。",
|
|
153
157
|
"- peer_list —— 列出你能对话的代理(上级、下属、兄弟、你开启的同伴、账本里有往来的联系人),并标注谁此刻在线。",
|
|
154
158
|
"- peer_start —— 开启一个**独立同伴会话**(不是下属):它有自己的生命周期、比你活得更久;任务会作为一份汇报交给它。",
|
|
155
159
|
"",
|
|
160
|
+
"工作区(若本部署启用):peer_start / report_author 加 worktree:true,会为该汇报分配一个**独立的 git 检出**,分支名形如 dsh/R-0007-<主题>。",
|
|
161
|
+
"独立的只有**工作目录与索引**;config、hooks、对象库、以及机器上一切检出之外的东西都是**共享**的——装一个 hook 或改一项配置,会影响所有检出里每一次提交。",
|
|
162
|
+
"一个分支同一时刻只能被一个检出占用,所以它同时也是一把锁:认领已有检出用 report_lease,交还用 report_release,结案会自动交还。被占用时会明确告诉你占用者是谁。",
|
|
163
|
+
"",
|
|
156
164
|
"五条行为约定:",
|
|
157
165
|
"1. 收件方不在线不是错误。投递由账本持有,等该会话重新驻留时自动送达;不要因为对方此刻不在就放弃交接、或改为自己扛下。",
|
|
158
166
|
"2. 传递路径(含每一份抄送与副本)由账本自动记录,不需要你手工维护——但每一步都必须经工具完成,绕过工具的口信不会进入路径。",
|
|
@@ -6788,7 +6796,7 @@ var require_public_api = /* @__PURE__ */ __commonJSMin(((exports) => {
|
|
|
6788
6796
|
exports.stringify = stringify;
|
|
6789
6797
|
}));
|
|
6790
6798
|
//#endregion
|
|
6791
|
-
//#region src/report/
|
|
6799
|
+
//#region src/report/types.ts
|
|
6792
6800
|
var import_dist = (/* @__PURE__ */ __commonJSMin(((exports) => {
|
|
6793
6801
|
var composer = require_composer();
|
|
6794
6802
|
var Document = require_Document();
|
|
@@ -6835,14 +6843,58 @@ var import_dist = (/* @__PURE__ */ __commonJSMin(((exports) => {
|
|
|
6835
6843
|
exports.visit = visit.visit;
|
|
6836
6844
|
exports.visitAsync = visit.visitAsync;
|
|
6837
6845
|
})))();
|
|
6838
|
-
/**
|
|
6839
|
-
|
|
6840
|
-
|
|
6846
|
+
/**
|
|
6847
|
+
* The report model.
|
|
6848
|
+
*
|
|
6849
|
+
* A report is the plugin's first-class interaction primitive between agents. It
|
|
6850
|
+
* is deliberately two-layered:
|
|
6851
|
+
*
|
|
6852
|
+
* - the FRONT MATTER is the digest — a bounded flat mapping that can enter a
|
|
6853
|
+
* model context, an index, or a timeline card without dragging the body in;
|
|
6854
|
+
* - the BODY is the detail, stored out of context and read on demand.
|
|
6855
|
+
*
|
|
6856
|
+
* The transfer path is a third, append-only stream (see {@link RouteHop}): every
|
|
6857
|
+
* hop — authoring, contribution, send, delivery, CC, forwarding, copying, read,
|
|
6858
|
+
* acknowledgement — is recorded with its actor and time, so the complete route
|
|
6859
|
+
* including every copy survives independently of the report text.
|
|
6860
|
+
*
|
|
6861
|
+
* @module dsh-report-ledger/report/types
|
|
6862
|
+
*/
|
|
6863
|
+
/**
|
|
6864
|
+
* Every report status, as both the runtime list and the source of
|
|
6865
|
+
* {@link ReportStatus}.
|
|
6866
|
+
*
|
|
6867
|
+
* A union type and a validator array are two spellings of one fact, and two
|
|
6868
|
+
* spellings drift. The parser falls back to `open` for anything it does not
|
|
6869
|
+
* recognize, so a status that existed in the type but not in the array would
|
|
6870
|
+
* quietly rewrite itself on the next read.
|
|
6871
|
+
*/
|
|
6872
|
+
const REPORT_STATUSES = [
|
|
6841
6873
|
"open",
|
|
6842
6874
|
"acked",
|
|
6843
6875
|
"closed"
|
|
6844
6876
|
];
|
|
6845
|
-
|
|
6877
|
+
/**
|
|
6878
|
+
* One recorded hop in a report's transfer path.
|
|
6879
|
+
*
|
|
6880
|
+
* `authored` and `contributed` establish co-authorship; `sent`/`delivered` are
|
|
6881
|
+
* the addressed hand-off and its arrival; `cc` is a quiet copy; `forwarded` and
|
|
6882
|
+
* `copied` are onward transfer and duplication; `read`/`acked` close the loop for
|
|
6883
|
+
* one reader; `leased`/`released` bracket a claim on a working tree, and
|
|
6884
|
+
* `reclaimed` records that the ledger cleared a checkout away — whether to stay
|
|
6885
|
+
* within its retained budget or because the owner asked for it at closing time;
|
|
6886
|
+
* `amended` records a correction to the digest or the body; `closed` concludes
|
|
6887
|
+
* the matter, and `reopened` records that fresh activity brought it back to life.
|
|
6888
|
+
*
|
|
6889
|
+
* Declared as a value and derived into a type on purpose. The parser validates
|
|
6890
|
+
* each record against this list and **drops** one it does not recognize, so a
|
|
6891
|
+
* union that named an action this array omitted would lose those hops from the
|
|
6892
|
+
* audit trail without a word — the single failure a ledger must not have.
|
|
6893
|
+
* Deriving the type from the value makes that drift impossible rather than
|
|
6894
|
+
* merely unlikely; it happened once already, when `leased` and `released`
|
|
6895
|
+
* reached the union before the array.
|
|
6896
|
+
*/
|
|
6897
|
+
const HOP_ACTIONS = [
|
|
6846
6898
|
"authored",
|
|
6847
6899
|
"contributed",
|
|
6848
6900
|
"sent",
|
|
@@ -6852,10 +6904,31 @@ const ACTIONS = [
|
|
|
6852
6904
|
"copied",
|
|
6853
6905
|
"read",
|
|
6854
6906
|
"acked",
|
|
6907
|
+
"leased",
|
|
6908
|
+
"released",
|
|
6909
|
+
"reclaimed",
|
|
6855
6910
|
"amended",
|
|
6856
6911
|
"closed",
|
|
6857
6912
|
"reopened"
|
|
6858
6913
|
];
|
|
6914
|
+
//#endregion
|
|
6915
|
+
//#region src/report/frontmatter.ts
|
|
6916
|
+
/**
|
|
6917
|
+
* The report file codec: YAML front matter plus a Markdown body.
|
|
6918
|
+
*
|
|
6919
|
+
* The format deliberately mirrors what this harness already uses for its own
|
|
6920
|
+
* documents (skills, package READMEs): an opening `---` line, a flat YAML
|
|
6921
|
+
* mapping, a closing `---` line, then free text. The parser below is a
|
|
6922
|
+
* reimplementation of the shipped skills parser
|
|
6923
|
+
* (`@deepseek-ai/dsh-skill-filesystem`, whose `parseFrontmatter` is module
|
|
6924
|
+
* private and not re-exported), tuned for the report schema: it tolerates a
|
|
6925
|
+
* hand-edited ledger by normalizing whatever it finds instead of throwing, so a
|
|
6926
|
+
* human can correct a report file without breaking the ledger.
|
|
6927
|
+
*
|
|
6928
|
+
* @module dsh-report-ledger/report/frontmatter
|
|
6929
|
+
*/
|
|
6930
|
+
/** The front-matter fence. */
|
|
6931
|
+
const FENCE = "---";
|
|
6859
6932
|
/**
|
|
6860
6933
|
* Split a document into its YAML front matter and body.
|
|
6861
6934
|
*
|
|
@@ -6921,6 +6994,33 @@ function time(value, fallback) {
|
|
|
6921
6994
|
return parsed === void 0 || parsed < 0 ? fallback : parsed;
|
|
6922
6995
|
}
|
|
6923
6996
|
/**
|
|
6997
|
+
* Read a working-tree binding, tolerating the hand-edited short form.
|
|
6998
|
+
*
|
|
6999
|
+
* A human who wants to point a report at a checkout should be able to write
|
|
7000
|
+
* `worktree: /path/to/tree` and have it mean what it looks like, so a bare
|
|
7001
|
+
* scalar is normalized to a path-only binding. A mapping with no usable path is
|
|
7002
|
+
* dropped whole rather than half-read: a binding with a branch but no path would
|
|
7003
|
+
* claim a branch the ledger cannot locate, which is worse than no binding.
|
|
7004
|
+
* @param value - the raw front-matter value.
|
|
7005
|
+
* @returns the binding, or `undefined` when there is no usable path.
|
|
7006
|
+
*/
|
|
7007
|
+
function toWorktree(value) {
|
|
7008
|
+
if (value === void 0 || value === null) return void 0;
|
|
7009
|
+
const record = typeof value === "object" && !Array.isArray(value) ? value : { path: value };
|
|
7010
|
+
const path = str$2(record.path);
|
|
7011
|
+
if (path === void 0) return void 0;
|
|
7012
|
+
const branch = str$2(record.branch);
|
|
7013
|
+
const head = str$2(record.head);
|
|
7014
|
+
const repo = str$2(record.repo);
|
|
7015
|
+
return {
|
|
7016
|
+
path,
|
|
7017
|
+
...branch === void 0 ? {} : { branch },
|
|
7018
|
+
...head === void 0 ? {} : { head },
|
|
7019
|
+
...repo === void 0 ? {} : { repo },
|
|
7020
|
+
...typeof record.allocated === "boolean" ? { allocated: record.allocated } : {}
|
|
7021
|
+
};
|
|
7022
|
+
}
|
|
7023
|
+
/**
|
|
6924
7024
|
* Normalize a raw front-matter mapping into the report schema.
|
|
6925
7025
|
*
|
|
6926
7026
|
* Every field is optional on the way in: a missing recipient list becomes empty,
|
|
@@ -6933,15 +7033,16 @@ function toFrontMatter(data) {
|
|
|
6933
7033
|
const report = str$2(data.report) ?? str$2(data.id);
|
|
6934
7034
|
if (report === void 0) return void 0;
|
|
6935
7035
|
const statusText = str$2(data.status);
|
|
6936
|
-
const status =
|
|
7036
|
+
const status = REPORT_STATUSES.find((candidate) => candidate === statusText) ?? "open";
|
|
6937
7037
|
const created = time(data.created, 0);
|
|
6938
7038
|
const updated = time(data.updated, created);
|
|
7039
|
+
const worktree = toWorktree(data.worktree);
|
|
6939
7040
|
const lastRaw = data.last;
|
|
6940
7041
|
let last;
|
|
6941
7042
|
if (lastRaw !== null && typeof lastRaw === "object" && !Array.isArray(lastRaw)) {
|
|
6942
7043
|
const record = lastRaw;
|
|
6943
7044
|
const actionText = str$2(record.action);
|
|
6944
|
-
const action =
|
|
7045
|
+
const action = HOP_ACTIONS.find((candidate) => candidate === actionText);
|
|
6945
7046
|
const actor = str$2(record.actor);
|
|
6946
7047
|
if (action !== void 0 && actor !== void 0) last = {
|
|
6947
7048
|
at: time(record.at, updated),
|
|
@@ -6963,6 +7064,7 @@ function toFrontMatter(data) {
|
|
|
6963
7064
|
...str$2(data.parent) === void 0 ? {} : { parent: str$2(data.parent) },
|
|
6964
7065
|
children: strList(data.children),
|
|
6965
7066
|
...str$2(data.task) === void 0 ? {} : { task: str$2(data.task) },
|
|
7067
|
+
...worktree === void 0 ? {} : { worktree },
|
|
6966
7068
|
artifacts: strList(data.artifacts),
|
|
6967
7069
|
hops: num(data.hops) ?? 0,
|
|
6968
7070
|
...last === void 0 ? {} : { last },
|
|
@@ -7008,6 +7110,13 @@ function frontMatterYaml(front) {
|
|
|
7008
7110
|
if (front.parent !== void 0) ordered.parent = front.parent;
|
|
7009
7111
|
ordered.children = [...front.children];
|
|
7010
7112
|
if (front.task !== void 0) ordered.task = front.task;
|
|
7113
|
+
if (front.worktree !== void 0) ordered.worktree = {
|
|
7114
|
+
path: front.worktree.path,
|
|
7115
|
+
...front.worktree.branch === void 0 ? {} : { branch: front.worktree.branch },
|
|
7116
|
+
...front.worktree.head === void 0 ? {} : { head: front.worktree.head },
|
|
7117
|
+
...front.worktree.repo === void 0 ? {} : { repo: front.worktree.repo },
|
|
7118
|
+
...front.worktree.allocated === void 0 ? {} : { allocated: front.worktree.allocated }
|
|
7119
|
+
};
|
|
7011
7120
|
ordered.artifacts = [...front.artifacts];
|
|
7012
7121
|
ordered.hops = front.hops;
|
|
7013
7122
|
if (front.last !== void 0) ordered.last = {
|
|
@@ -7051,7 +7160,7 @@ function parseHop(raw) {
|
|
|
7051
7160
|
if (value === null || typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
7052
7161
|
const record = value;
|
|
7053
7162
|
const actionText = str$2(record.action);
|
|
7054
|
-
const action =
|
|
7163
|
+
const action = HOP_ACTIONS.find((candidate) => candidate === actionText);
|
|
7055
7164
|
const actor = str$2(record.actor);
|
|
7056
7165
|
if (action === void 0 || actor === void 0) return void 0;
|
|
7057
7166
|
const from = str$2(record.from);
|
|
@@ -7280,6 +7389,793 @@ async function listReports() {
|
|
|
7280
7389
|
digests.sort((left, right) => right.updated - left.updated || right.report.localeCompare(left.report));
|
|
7281
7390
|
return digests;
|
|
7282
7391
|
}
|
|
7392
|
+
/**
|
|
7393
|
+
* Whether a report document exists on disk.
|
|
7394
|
+
* @param id - report identifier.
|
|
7395
|
+
* @returns true when the document exists.
|
|
7396
|
+
*/
|
|
7397
|
+
function reportExists(id) {
|
|
7398
|
+
return isValidReportId(id) && existsSync(reportPath(id));
|
|
7399
|
+
}
|
|
7400
|
+
/**
|
|
7401
|
+
* Give back an id that was claimed but never became a report.
|
|
7402
|
+
*
|
|
7403
|
+
* {@link allocateId} claims a name by creating an empty document, which is what
|
|
7404
|
+
* makes a concurrent claim lose cleanly. When the work that needed the id then
|
|
7405
|
+
* fails — a working tree that could not be created, a peer that never started —
|
|
7406
|
+
* the claim would otherwise leave an empty file behind forever, burning the
|
|
7407
|
+
* number and showing up in no listing.
|
|
7408
|
+
*
|
|
7409
|
+
* Only an **empty** document is removed. A report with any content is kept, so a
|
|
7410
|
+
* misuse of this function can cost at most a placeholder, never a record.
|
|
7411
|
+
* @param id - the claimed identifier.
|
|
7412
|
+
* @returns true when an empty claim was removed.
|
|
7413
|
+
*/
|
|
7414
|
+
async function discardUnwrittenId(id) {
|
|
7415
|
+
if (!isValidReportId(id)) return false;
|
|
7416
|
+
const path = reportPath(id);
|
|
7417
|
+
try {
|
|
7418
|
+
if (await readFile(path, "utf8") !== "") return false;
|
|
7419
|
+
} catch {
|
|
7420
|
+
return false;
|
|
7421
|
+
}
|
|
7422
|
+
try {
|
|
7423
|
+
await unlink(path);
|
|
7424
|
+
return true;
|
|
7425
|
+
} catch {
|
|
7426
|
+
return false;
|
|
7427
|
+
}
|
|
7428
|
+
}
|
|
7429
|
+
//#endregion
|
|
7430
|
+
//#region src/report/worktree.ts
|
|
7431
|
+
/**
|
|
7432
|
+
* Git worktree adapter.
|
|
7433
|
+
*
|
|
7434
|
+
* The ledger records *intent*; git records *reality*. This module is the whole
|
|
7435
|
+
* of the second half: it shells out to `git`, reads `git worktree list`, and
|
|
7436
|
+
* decides nothing. Every policy question — which report may hold a branch, when
|
|
7437
|
+
* a lease may be reclaimed, what to do when the two layers disagree — belongs to
|
|
7438
|
+
* the service layer, because answering it needs the ledger. This module must
|
|
7439
|
+
* stay usable and testable without one.
|
|
7440
|
+
*
|
|
7441
|
+
* Four deliberate choices:
|
|
7442
|
+
*
|
|
7443
|
+
* - **Shell out, never reimplement.** Git's occupancy check, its refusal to
|
|
7444
|
+
* remove an unclean tree, and its `repair` are the authority. A weaker
|
|
7445
|
+
* reimplementation in TypeScript would be a second source of truth about the
|
|
7446
|
+
* same bytes, which is the exact failure this plugin exists to avoid.
|
|
7447
|
+
* - **No `--force`, anywhere.** Neither {@link addWorktree} nor
|
|
7448
|
+
* {@link removeWorktree} exposes a force flag, so no caller can acquire the
|
|
7449
|
+
* ability to discard uncommitted work or to claim a branch git says is taken.
|
|
7450
|
+
* A safeguard can only be overridden by a human running git, deliberately.
|
|
7451
|
+
* - **`--porcelain -z` for parsing.** The non-`-z` form escapes unusual
|
|
7452
|
+
* characters and quotes paths containing them, so a parser for it must
|
|
7453
|
+
* reimplement git's C-style quoting. The `-z` form emits raw NUL-separated
|
|
7454
|
+
* fields, which makes parsing a split rather than an unescape.
|
|
7455
|
+
* - **Paths stay native, comparison normalizes.** Git prints forward slashes
|
|
7456
|
+
* even on Windows while the harness hands us backslashes. Rather than pick a
|
|
7457
|
+
* dialect everywhere, stored paths keep the form the OS uses and
|
|
7458
|
+
* {@link compareKey} is the single place that decides when two spellings name
|
|
7459
|
+
* one file.
|
|
7460
|
+
*
|
|
7461
|
+
* @module dsh-report-ledger/report/worktree
|
|
7462
|
+
*/
|
|
7463
|
+
/** Directory name, under a repository's parent, that holds ledger-allocated worktrees. */
|
|
7464
|
+
const WORKTREE_DIR = ".dsh-worktrees";
|
|
7465
|
+
/** Default timeout for one git invocation. */
|
|
7466
|
+
const GIT_TIMEOUT_MS = 3e4;
|
|
7467
|
+
/** Exit status used for a failure that never reached git (a missing binary). */
|
|
7468
|
+
const NOT_EXECUTED = -1;
|
|
7469
|
+
/**
|
|
7470
|
+
* Recognized refusals, most specific first.
|
|
7471
|
+
*
|
|
7472
|
+
* Every pattern is copied from a message observed from git 2.49 on Windows.
|
|
7473
|
+
* Matching on prose is brittle by nature, so the classifier exists only to let
|
|
7474
|
+
* the caller choose a *remedy*; the raw stderr is always passed through
|
|
7475
|
+
* unchanged, and `unknown` is a correct answer rather than a failure.
|
|
7476
|
+
*
|
|
7477
|
+
* Order carries meaning. `a branch named 'x' already exists` and `'<path>'
|
|
7478
|
+
* already exists` are different problems with different remedies — reuse or
|
|
7479
|
+
* rename the branch, versus choose another directory — and git words both with
|
|
7480
|
+
* "already exists", so the branch form has to be recognized before the generic
|
|
7481
|
+
* one or every name collision looks like a full directory.
|
|
7482
|
+
*/
|
|
7483
|
+
const REFUSALS = [
|
|
7484
|
+
["branch-in-use", /is already used by worktree at/i],
|
|
7485
|
+
["branch-exists", /a branch named .* already exists/i],
|
|
7486
|
+
["missing-but-registered", /is a missing but already registered worktree/i],
|
|
7487
|
+
["main-working-tree", /is a main working tree|cannot be locked or unlocked/i],
|
|
7488
|
+
["locked", /cannot remove a locked working tree/i],
|
|
7489
|
+
["unclean", /contains modified or untracked files/i],
|
|
7490
|
+
["path-exists", /already exists/i]
|
|
7491
|
+
];
|
|
7492
|
+
/**
|
|
7493
|
+
* Run one git command and capture its result without throwing.
|
|
7494
|
+
*
|
|
7495
|
+
* The ledger treats a git refusal as information, not as an exception: "this
|
|
7496
|
+
* branch is taken" is a normal answer that has to reach a model as a sentence.
|
|
7497
|
+
* So this returns the exit status instead of rejecting, and a binary that cannot
|
|
7498
|
+
* be run at all is reported the same way rather than as a crash.
|
|
7499
|
+
* @param cwd - directory to run in.
|
|
7500
|
+
* @param args - arguments after the program name.
|
|
7501
|
+
* @returns the exit status and both streams.
|
|
7502
|
+
*/
|
|
7503
|
+
async function runGit(cwd, args) {
|
|
7504
|
+
return await new Promise((settle) => {
|
|
7505
|
+
execFile("git", [...args], {
|
|
7506
|
+
cwd,
|
|
7507
|
+
encoding: "utf8",
|
|
7508
|
+
timeout: GIT_TIMEOUT_MS,
|
|
7509
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
7510
|
+
windowsHide: true
|
|
7511
|
+
}, (error, stdout, stderr) => {
|
|
7512
|
+
const raw = error?.code;
|
|
7513
|
+
const out = typeof stdout === "string" ? stdout : "";
|
|
7514
|
+
const err = typeof stderr === "string" ? stderr : "";
|
|
7515
|
+
settle({
|
|
7516
|
+
ok: error === null,
|
|
7517
|
+
code: error === null ? 0 : typeof raw === "number" ? raw : NOT_EXECUTED,
|
|
7518
|
+
stdout: out,
|
|
7519
|
+
stderr: err === "" ? error?.message ?? "" : err
|
|
7520
|
+
});
|
|
7521
|
+
});
|
|
7522
|
+
});
|
|
7523
|
+
}
|
|
7524
|
+
/**
|
|
7525
|
+
* Locate the repository a directory belongs to.
|
|
7526
|
+
*
|
|
7527
|
+
* Answers `undefined` rather than throwing when the directory is not in a
|
|
7528
|
+
* repository, because "not a repository" is the ordinary answer for the many
|
|
7529
|
+
* sessions that never touch one, and the caller must be able to ask cheaply.
|
|
7530
|
+
* @param cwd - a directory inside the repository (or the repository itself).
|
|
7531
|
+
* @returns the location, or `undefined` when this is not a git repository.
|
|
7532
|
+
*/
|
|
7533
|
+
async function resolveRepo(cwd) {
|
|
7534
|
+
if (cwd.trim() === "") return void 0;
|
|
7535
|
+
const common = await runGit(cwd, ["rev-parse", "--git-common-dir"]);
|
|
7536
|
+
if (!common.ok) return void 0;
|
|
7537
|
+
const reported = common.stdout.trim();
|
|
7538
|
+
if (reported === "") return void 0;
|
|
7539
|
+
const commonDir = normalize(resolve(cwd, reported));
|
|
7540
|
+
const bare = (await runGit(cwd, ["rev-parse", "--is-bare-repository"])).stdout.trim() === "true";
|
|
7541
|
+
const repoRoot = bare ? commonDir : dirname(commonDir);
|
|
7542
|
+
const repoParent = dirname(repoRoot);
|
|
7543
|
+
const repoName = basename(repoRoot).replace(/\.git$/i, "") || "repo";
|
|
7544
|
+
return {
|
|
7545
|
+
commonDir,
|
|
7546
|
+
repoRoot,
|
|
7547
|
+
repoParent,
|
|
7548
|
+
repoName,
|
|
7549
|
+
bare,
|
|
7550
|
+
worktreeRoot: join(repoParent, WORKTREE_DIR, repoName)
|
|
7551
|
+
};
|
|
7552
|
+
}
|
|
7553
|
+
/** Strip `refs/heads/` so a branch is named the way every other layer names it. */
|
|
7554
|
+
function shortBranch(ref) {
|
|
7555
|
+
return ref.startsWith("refs/heads/") ? ref.slice(11) : ref;
|
|
7556
|
+
}
|
|
7557
|
+
/**
|
|
7558
|
+
* Parse `git worktree list --porcelain -z` output.
|
|
7559
|
+
*
|
|
7560
|
+
* In the `-z` form every field is NUL-terminated and a record ends at an empty
|
|
7561
|
+
* field, so a record boundary is a split on `\0` that yields `''`. Fields are
|
|
7562
|
+
* `key value`, or a bare `key` for a boolean one (`detached`, `bare`) and for a
|
|
7563
|
+
* flag whose reason is optional (`locked`, `prunable`).
|
|
7564
|
+
* @param raw - the command's standard output.
|
|
7565
|
+
* @returns one entry per record that carried a path.
|
|
7566
|
+
*/
|
|
7567
|
+
function parseWorktreeList(raw) {
|
|
7568
|
+
const entries = [];
|
|
7569
|
+
let fields = /* @__PURE__ */ new Map();
|
|
7570
|
+
const flush = () => {
|
|
7571
|
+
const path = fields.get("worktree");
|
|
7572
|
+
if (path !== void 0 && path !== "") {
|
|
7573
|
+
const head = fields.get("HEAD");
|
|
7574
|
+
const branchRef = fields.get("branch");
|
|
7575
|
+
const lockReason = fields.get("locked");
|
|
7576
|
+
const prunableReason = fields.get("prunable");
|
|
7577
|
+
entries.push({
|
|
7578
|
+
path: normalize(path),
|
|
7579
|
+
detached: fields.has("detached"),
|
|
7580
|
+
bare: fields.has("bare"),
|
|
7581
|
+
locked: fields.has("locked"),
|
|
7582
|
+
prunable: fields.has("prunable"),
|
|
7583
|
+
...head === void 0 || head === "" ? {} : { head },
|
|
7584
|
+
...branchRef === void 0 || branchRef === "" ? {} : { branch: shortBranch(branchRef) },
|
|
7585
|
+
...lockReason === void 0 || lockReason === "" ? {} : { lockReason },
|
|
7586
|
+
...prunableReason === void 0 || prunableReason === "" ? {} : { prunableReason }
|
|
7587
|
+
});
|
|
7588
|
+
}
|
|
7589
|
+
fields = /* @__PURE__ */ new Map();
|
|
7590
|
+
};
|
|
7591
|
+
for (const field of raw.split("\0")) {
|
|
7592
|
+
if (field === "") {
|
|
7593
|
+
flush();
|
|
7594
|
+
continue;
|
|
7595
|
+
}
|
|
7596
|
+
const space = field.indexOf(" ");
|
|
7597
|
+
if (space < 0) fields.set(field, "");
|
|
7598
|
+
else fields.set(field.slice(0, space), field.slice(space + 1));
|
|
7599
|
+
}
|
|
7600
|
+
flush();
|
|
7601
|
+
return entries;
|
|
7602
|
+
}
|
|
7603
|
+
/**
|
|
7604
|
+
* List every working tree of a repository.
|
|
7605
|
+
* @param repo - the repository.
|
|
7606
|
+
* @returns one entry per working tree, main worktree first.
|
|
7607
|
+
* @throws when git itself cannot be asked.
|
|
7608
|
+
*/
|
|
7609
|
+
async function listWorktrees(repo) {
|
|
7610
|
+
const result = await runGit(repo.commonDir, [
|
|
7611
|
+
"worktree",
|
|
7612
|
+
"list",
|
|
7613
|
+
"--porcelain",
|
|
7614
|
+
"-z"
|
|
7615
|
+
]);
|
|
7616
|
+
if (!result.ok) throw new Error(`report-ledger: git worktree list failed: ${result.stderr.trim() || `exit ${result.code}`}`);
|
|
7617
|
+
return parseWorktreeList(result.stdout);
|
|
7618
|
+
}
|
|
7619
|
+
/**
|
|
7620
|
+
* Map each branch to the entry currently holding it.
|
|
7621
|
+
*
|
|
7622
|
+
* Prunable entries are skipped deliberately. Their working tree is gone, so they
|
|
7623
|
+
* do not actually hold anything — which is precisely why a stale entry must not
|
|
7624
|
+
* be allowed to block reuse forever, and why the same list yields two different
|
|
7625
|
+
* answers depending on the question being asked.
|
|
7626
|
+
* @param entries - entries from {@link listWorktrees}.
|
|
7627
|
+
* @returns branch name to holding entry.
|
|
7628
|
+
*/
|
|
7629
|
+
function branchHolders(entries) {
|
|
7630
|
+
const holders = /* @__PURE__ */ new Map();
|
|
7631
|
+
for (const entry of entries) {
|
|
7632
|
+
if (entry.branch === void 0 || entry.prunable) continue;
|
|
7633
|
+
if (!holders.has(entry.branch)) holders.set(entry.branch, entry);
|
|
7634
|
+
}
|
|
7635
|
+
return holders;
|
|
7636
|
+
}
|
|
7637
|
+
/**
|
|
7638
|
+
* Whether a working tree has no modification, no staged change and no untracked
|
|
7639
|
+
* file.
|
|
7640
|
+
*
|
|
7641
|
+
* Untracked files count as dirty on purpose: they are exactly what `git worktree
|
|
7642
|
+
* remove` refuses to delete, so a check that ignored them would authorize a
|
|
7643
|
+
* removal git then rejects.
|
|
7644
|
+
* @param worktreePath - the working tree to inspect.
|
|
7645
|
+
* @returns true only when git confirms a clean tree; a failure to ask answers
|
|
7646
|
+
* false, because an unreadable tree must never be treated as safe to delete.
|
|
7647
|
+
*/
|
|
7648
|
+
async function isWorktreeClean(worktreePath) {
|
|
7649
|
+
const result = await runGit(worktreePath, ["status", "--porcelain"]);
|
|
7650
|
+
if (!result.ok) return false;
|
|
7651
|
+
return result.stdout.trim() === "";
|
|
7652
|
+
}
|
|
7653
|
+
/**
|
|
7654
|
+
* Read the commit a working tree is on.
|
|
7655
|
+
* @param worktreePath - the working tree to inspect.
|
|
7656
|
+
* @returns the object id, or `undefined` for an unborn branch or a failure.
|
|
7657
|
+
*/
|
|
7658
|
+
async function headOf(worktreePath) {
|
|
7659
|
+
const result = await runGit(worktreePath, ["rev-parse", "HEAD"]);
|
|
7660
|
+
if (!result.ok) return void 0;
|
|
7661
|
+
const sha = result.stdout.trim();
|
|
7662
|
+
return /^[0-9a-f]{40,64}$/.test(sha) ? sha : void 0;
|
|
7663
|
+
}
|
|
7664
|
+
/**
|
|
7665
|
+
* Whether a repository uses submodules.
|
|
7666
|
+
*
|
|
7667
|
+
* Git's own manual is explicit that multiple checkouts are experimental and that
|
|
7668
|
+
* support for submodules is incomplete — a linked worktree containing submodules
|
|
7669
|
+
* cannot even be moved. So this is a gate the caller is expected to consult
|
|
7670
|
+
* before allocating, not a warning to print afterwards.
|
|
7671
|
+
*
|
|
7672
|
+
* A bare repository answers false: the answer is derived from the working tree's
|
|
7673
|
+
* `.gitmodules`, which a bare repository does not have.
|
|
7674
|
+
* @param repo - the repository.
|
|
7675
|
+
* @returns true when `.gitmodules` exists in the main working tree.
|
|
7676
|
+
*/
|
|
7677
|
+
function hasSubmodules(repo) {
|
|
7678
|
+
if (repo.bare) return false;
|
|
7679
|
+
return existsSync(join(repo.repoRoot, ".gitmodules"));
|
|
7680
|
+
}
|
|
7681
|
+
/**
|
|
7682
|
+
* Attach a linked working tree.
|
|
7683
|
+
*
|
|
7684
|
+
* There is no force flag: git's occupancy check and its refusal of an unclean
|
|
7685
|
+
* path are returned to the caller as results, never overridden. A refusal is
|
|
7686
|
+
* data the ledger turns into a sentence naming a remedy.
|
|
7687
|
+
* @param input - what to create or check out.
|
|
7688
|
+
* @returns the git result; the caller decides whether a refusal is fatal.
|
|
7689
|
+
*/
|
|
7690
|
+
async function addWorktree(input) {
|
|
7691
|
+
const args = ["worktree", "add"];
|
|
7692
|
+
if (input.relativePaths === true) args.push("--relative-paths");
|
|
7693
|
+
if (input.checkoutExisting === true) args.push(input.path, input.branch);
|
|
7694
|
+
else args.push("-b", input.branch, input.path, input.base);
|
|
7695
|
+
return await runGit(input.repo.commonDir, args);
|
|
7696
|
+
}
|
|
7697
|
+
/**
|
|
7698
|
+
* Remove a linked working tree.
|
|
7699
|
+
*
|
|
7700
|
+
* Deliberately without a force option. Git refuses when the tree is unclean or
|
|
7701
|
+
* locked, and that refusal is the last line of defence for work no one has
|
|
7702
|
+
* committed; exposing `--force` here would turn a policy mistake in the ledger
|
|
7703
|
+
* into silent data loss. A human who truly wants the tree gone can run the
|
|
7704
|
+
* command themselves, which is the correct amount of friction.
|
|
7705
|
+
* @param repo - the repository the tree belongs to.
|
|
7706
|
+
* @param worktreePath - the working tree to remove.
|
|
7707
|
+
* @returns the git result; a refusal is data, not an exception.
|
|
7708
|
+
*/
|
|
7709
|
+
async function removeWorktree(repo, worktreePath) {
|
|
7710
|
+
return await runGit(repo.commonDir, [
|
|
7711
|
+
"worktree",
|
|
7712
|
+
"remove",
|
|
7713
|
+
worktreePath
|
|
7714
|
+
]);
|
|
7715
|
+
}
|
|
7716
|
+
/**
|
|
7717
|
+
* Classify a git refusal so the caller can name a remedy.
|
|
7718
|
+
* @param stderr - the command's standard error.
|
|
7719
|
+
* @returns the recognized kind, or `'unknown'`.
|
|
7720
|
+
*/
|
|
7721
|
+
function classifyGitRefusal(stderr) {
|
|
7722
|
+
for (const [kind, pattern] of REFUSALS) if (pattern.test(stderr)) return kind;
|
|
7723
|
+
return "unknown";
|
|
7724
|
+
}
|
|
7725
|
+
/** Strip a trailing separator without ever eating a root. */
|
|
7726
|
+
function stripTrailingSeparators(path) {
|
|
7727
|
+
if (/^[a-zA-Z]:[\\/]$/.test(path)) return path;
|
|
7728
|
+
if (/^[\\/]{1,2}$/.test(path)) return path;
|
|
7729
|
+
return path.replace(/[\\/]+$/, "");
|
|
7730
|
+
}
|
|
7731
|
+
/**
|
|
7732
|
+
* Reduce a path to the string two spellings are compared by.
|
|
7733
|
+
*
|
|
7734
|
+
* The single place that decides when two paths name one file. Windows compares
|
|
7735
|
+
* case-insensitively and treats either separator as a separator; a POSIX host
|
|
7736
|
+
* does neither, so the rule is the host's rather than a lowest common
|
|
7737
|
+
* denominator — comparing case-insensitively on Linux would merge two directories
|
|
7738
|
+
* git considers distinct.
|
|
7739
|
+
* @param path - a path, absolute or relative to the current directory.
|
|
7740
|
+
* @returns the comparison key.
|
|
7741
|
+
*/
|
|
7742
|
+
function compareKey(path) {
|
|
7743
|
+
const absolute = stripTrailingSeparators(normalize(resolve(path)));
|
|
7744
|
+
return process.platform === "win32" ? absolute.toLowerCase() : absolute;
|
|
7745
|
+
}
|
|
7746
|
+
/**
|
|
7747
|
+
* Whether two paths name the same file.
|
|
7748
|
+
* @param left - one path.
|
|
7749
|
+
* @param right - another path.
|
|
7750
|
+
* @returns true when they share a comparison key.
|
|
7751
|
+
*/
|
|
7752
|
+
function samePath(left, right) {
|
|
7753
|
+
return compareKey(left) === compareKey(right);
|
|
7754
|
+
}
|
|
7755
|
+
//#endregion
|
|
7756
|
+
//#region src/report/lease.ts
|
|
7757
|
+
/**
|
|
7758
|
+
* The working-tree lease referee.
|
|
7759
|
+
*
|
|
7760
|
+
* A lease answers one question: may *this* report work in *that* checkout? The
|
|
7761
|
+
* answer needs two authorities, and this module consults both.
|
|
7762
|
+
*
|
|
7763
|
+
* - **The ledger knows intent.** It can say which report claims a branch, and
|
|
7764
|
+
* therefore name the agent to talk to. Git cannot: it reports a path, and a
|
|
7765
|
+
* path is not a colleague.
|
|
7766
|
+
* - **Git knows reality.** It can say which working tree actually has a branch
|
|
7767
|
+
* checked out. The ledger cannot: a checkout a human made by hand never
|
|
7768
|
+
* appears in it.
|
|
7769
|
+
*
|
|
7770
|
+
* Neither is sufficient, and when they disagree the disagreement is reported
|
|
7771
|
+
* rather than resolved — a ledger that silently overrode git would be lying
|
|
7772
|
+
* about the filesystem, and one that silently deferred to git would lose the
|
|
7773
|
+
* only party who can be asked to let go.
|
|
7774
|
+
*
|
|
7775
|
+
* **Lease liveness is derived from the hop stream, never stored as a flag.** The
|
|
7776
|
+
* digest is a cache of current state, and `leased`/`released` hops are the
|
|
7777
|
+
* authority for whether a claim is still held — the same shape as
|
|
7778
|
+
* `pendingTargets`, which derives the durable mailbox from hops rather than
|
|
7779
|
+
* recording it. The binding itself *does* persist in the digest after release,
|
|
7780
|
+
* because a working tree kept on disk must still be traceable back to the report
|
|
7781
|
+
* that made it.
|
|
7782
|
+
*
|
|
7783
|
+
* @module dsh-report-ledger/report/lease
|
|
7784
|
+
*/
|
|
7785
|
+
/**
|
|
7786
|
+
* Derive lease liveness from the authoritative hop stream.
|
|
7787
|
+
* @param hops - a report's hops, oldest first.
|
|
7788
|
+
* @returns the current lease state.
|
|
7789
|
+
*/
|
|
7790
|
+
function leaseState(hops) {
|
|
7791
|
+
let state = "none";
|
|
7792
|
+
for (const hop of hops) if (hop.action === "leased") state = "held";
|
|
7793
|
+
else if (hop.action === "released") state = "released";
|
|
7794
|
+
else if (hop.action === "reclaimed") state = "reclaimed";
|
|
7795
|
+
return state;
|
|
7796
|
+
}
|
|
7797
|
+
/**
|
|
7798
|
+
* Whether two bindings name the same claim.
|
|
7799
|
+
*
|
|
7800
|
+
* A branch, when both sides name one, is the claim's identity: two reports bound
|
|
7801
|
+
* to the same branch are claiming one thing however differently they spell the
|
|
7802
|
+
* path. With no branch on one side there is nothing but the path to compare,
|
|
7803
|
+
* which is what a detached tree has instead of a name.
|
|
7804
|
+
* @param left - one binding.
|
|
7805
|
+
* @param right - another binding.
|
|
7806
|
+
* @returns true when they are the same claim.
|
|
7807
|
+
*/
|
|
7808
|
+
function sameClaim(left, right) {
|
|
7809
|
+
if (left.branch !== void 0 && right.branch !== void 0) return left.branch === right.branch;
|
|
7810
|
+
return samePath(left.path, right.path);
|
|
7811
|
+
}
|
|
7812
|
+
/** The claimed name in prose: the branch when there is one, else the path. */
|
|
7813
|
+
function claimName(binding) {
|
|
7814
|
+
return binding.branch ?? `${binding.path} (detached)`;
|
|
7815
|
+
}
|
|
7816
|
+
/**
|
|
7817
|
+
* Decide whether a claim may be taken.
|
|
7818
|
+
*
|
|
7819
|
+
* Git is consulted before the ledger on purpose. Accusing a colleague of holding
|
|
7820
|
+
* something that turns out not to exist is a worse answer than "there is no such
|
|
7821
|
+
* checkout", and the ledger layer is only meaningful once the thing being
|
|
7822
|
+
* claimed is real.
|
|
7823
|
+
* @param query - the claim and the authorities to check it against.
|
|
7824
|
+
* @returns the refusal, or `undefined` when the claim may be taken.
|
|
7825
|
+
* @throws when git cannot be asked at all.
|
|
7826
|
+
*/
|
|
7827
|
+
async function evaluateLease(query) {
|
|
7828
|
+
const { binding, repo } = query;
|
|
7829
|
+
const refuse = (refusal) => ({
|
|
7830
|
+
kind: "refused",
|
|
7831
|
+
refusal
|
|
7832
|
+
});
|
|
7833
|
+
if (repo === void 0) return refuse({
|
|
7834
|
+
kind: "no-repository",
|
|
7835
|
+
message: `${binding.path} is not inside a git repository, so there is no working tree to claim. Bind a checkout that exists, or let the ledger allocate one.`
|
|
7836
|
+
});
|
|
7837
|
+
const entries = await listWorktrees(repo);
|
|
7838
|
+
const entry = entries.find((candidate) => samePath(candidate.path, binding.path));
|
|
7839
|
+
if (entry === void 0) return refuse({
|
|
7840
|
+
kind: "not-a-worktree",
|
|
7841
|
+
message: `${binding.path} is not a working tree of ${repo.commonDir}. An existing checkout can be listed with git worktree list.`
|
|
7842
|
+
});
|
|
7843
|
+
if (entry.prunable) return refuse({
|
|
7844
|
+
kind: "not-a-worktree",
|
|
7845
|
+
message: `${binding.path} is registered but its working tree is gone, so git reports it prunable${entry.prunableReason === void 0 ? "" : ` (${entry.prunableReason})`}. If it was moved by hand, run git worktree repair from the new location; otherwise remove the stale entry first.`
|
|
7846
|
+
});
|
|
7847
|
+
if (binding.branch !== void 0) {
|
|
7848
|
+
const actual = entry.branch;
|
|
7849
|
+
if (actual !== binding.branch) return refuse({
|
|
7850
|
+
kind: "branch-mismatch",
|
|
7851
|
+
message: `${binding.path} is on ${actual === void 0 ? "a detached HEAD" : `'${actual}'`}, not '${binding.branch}', so the binding no longer describes the checkout.`
|
|
7852
|
+
});
|
|
7853
|
+
const holder = branchHolders(entries).get(binding.branch);
|
|
7854
|
+
if (holder !== void 0 && !samePath(holder.path, binding.path)) return refuse({
|
|
7855
|
+
kind: "checked-out",
|
|
7856
|
+
heldAt: holder.path,
|
|
7857
|
+
message: `'${binding.branch}' is checked out at ${holder.path}, and a branch is held by exactly one working tree. Claim ${holder.path} instead, or use a different branch.`
|
|
7858
|
+
});
|
|
7859
|
+
}
|
|
7860
|
+
for (const front of query.digests) {
|
|
7861
|
+
if (front.report === query.report) continue;
|
|
7862
|
+
if (front.worktree === void 0) continue;
|
|
7863
|
+
if (!sameClaim(front.worktree, binding)) continue;
|
|
7864
|
+
if (front.status === "closed") continue;
|
|
7865
|
+
if (leaseState(await query.hopsOf(front.report)) !== "held") continue;
|
|
7866
|
+
const holders = [.../* @__PURE__ */ new Set([front.from, ...front.authors])];
|
|
7867
|
+
return refuse({
|
|
7868
|
+
kind: "held-by-report",
|
|
7869
|
+
heldBy: front.report,
|
|
7870
|
+
heldByActor: front.from,
|
|
7871
|
+
message: `${front.report} (opened by ${front.from}) already holds ${claimName(binding)}. A claim is held by one report at a time. Ask it to release with report_release, or to close — closing releases the claim. Its authors are ${holders.join(", ")}.`
|
|
7872
|
+
});
|
|
7873
|
+
}
|
|
7874
|
+
return {
|
|
7875
|
+
kind: "granted",
|
|
7876
|
+
binding: {
|
|
7877
|
+
path: binding.path,
|
|
7878
|
+
...entry.branch === void 0 ? {} : { branch: entry.branch },
|
|
7879
|
+
...entry.head === void 0 ? {} : { head: entry.head },
|
|
7880
|
+
repo: repo.commonDir,
|
|
7881
|
+
...binding.allocated === void 0 ? {} : { allocated: binding.allocated }
|
|
7882
|
+
}
|
|
7883
|
+
};
|
|
7884
|
+
}
|
|
7885
|
+
/** Longest slug kept in a branch and directory name. */
|
|
7886
|
+
const SLUG_LIMIT = 40;
|
|
7887
|
+
/**
|
|
7888
|
+
* Reduce a subject to a ref-safe slug.
|
|
7889
|
+
*
|
|
7890
|
+
* The output alphabet is `[a-z0-9-]`, a subset of what git accepts in a ref
|
|
7891
|
+
* name, so nothing needs escaping afterwards. That narrowness is the point: git
|
|
7892
|
+
* forbids `~^:?*[`, `..`, `@{`, spaces, control characters, and a leading or
|
|
7893
|
+
* trailing `.`, and a slug that can only hold lower-case letters, digits and
|
|
7894
|
+
* interior hyphens cannot spell any of them. A subject in a non-Latin script
|
|
7895
|
+
* reduces to nothing, which is why a name without a slug exists too.
|
|
7896
|
+
* @param subject - the report subject.
|
|
7897
|
+
* @returns the slug, possibly empty.
|
|
7898
|
+
*/
|
|
7899
|
+
function slugify(subject) {
|
|
7900
|
+
return subject.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, SLUG_LIMIT).replace(/-+$/g, "");
|
|
7901
|
+
}
|
|
7902
|
+
/**
|
|
7903
|
+
* The name a report's checkout goes by, in both its branch and its directory.
|
|
7904
|
+
*
|
|
7905
|
+
* A report id is always part of it, so a checkout traces back to its report from
|
|
7906
|
+
* `git branch` alone, and the report id is recoverable from a directory listing
|
|
7907
|
+
* without consulting the ledger.
|
|
7908
|
+
* @param report - report identifier.
|
|
7909
|
+
* @param subject - the report subject, for the readable suffix.
|
|
7910
|
+
* @returns the name.
|
|
7911
|
+
*/
|
|
7912
|
+
function worktreeName(report, subject) {
|
|
7913
|
+
const slug = slugify(subject);
|
|
7914
|
+
return slug === "" ? report : `${report}-${slug}`;
|
|
7915
|
+
}
|
|
7916
|
+
/**
|
|
7917
|
+
* The branch a report's checkout is on.
|
|
7918
|
+
* @param report - report identifier.
|
|
7919
|
+
* @param subject - the report subject.
|
|
7920
|
+
* @returns the branch name.
|
|
7921
|
+
*/
|
|
7922
|
+
function worktreeBranch(report, subject) {
|
|
7923
|
+
return `dsh/${worktreeName(report, subject)}`;
|
|
7924
|
+
}
|
|
7925
|
+
/**
|
|
7926
|
+
* Where a report's checkout is created.
|
|
7927
|
+
*
|
|
7928
|
+
* The repository name is always a path component, so two repositories sharing a
|
|
7929
|
+
* parent — the ordinary case for anyone with several projects — cannot allocate
|
|
7930
|
+
* into each other's directory.
|
|
7931
|
+
* @param repo - the repository.
|
|
7932
|
+
* @param report - report identifier.
|
|
7933
|
+
* @param subject - the report subject.
|
|
7934
|
+
* @param base - base directory override, when configured.
|
|
7935
|
+
* @returns the absolute path.
|
|
7936
|
+
*/
|
|
7937
|
+
function worktreePath(repo, report, subject, base) {
|
|
7938
|
+
const name = worktreeName(report, subject);
|
|
7939
|
+
return base === void 0 ? join(repo.worktreeRoot, name) : join(base, repo.repoName, name);
|
|
7940
|
+
}
|
|
7941
|
+
/**
|
|
7942
|
+
* Fill in the defaults for working-tree support.
|
|
7943
|
+
* @param config - the plugin configuration, when one was supplied.
|
|
7944
|
+
* @returns the resolved runtime.
|
|
7945
|
+
*/
|
|
7946
|
+
function resolveWorktree(config) {
|
|
7947
|
+
return {
|
|
7948
|
+
enabled: config?.enabled === true,
|
|
7949
|
+
settings: {
|
|
7950
|
+
maxActive: config?.maxActive ?? 4,
|
|
7951
|
+
maxRetained: config?.maxRetained ?? 4,
|
|
7952
|
+
reclaim: config?.reclaim ?? "never",
|
|
7953
|
+
relativePaths: config?.relativePaths === true,
|
|
7954
|
+
...config?.root === void 0 ? {} : { base: config.root }
|
|
7955
|
+
},
|
|
7956
|
+
onClose: config?.onClose ?? "keep"
|
|
7957
|
+
};
|
|
7958
|
+
}
|
|
7959
|
+
/** When a claim ended, from the hop that ended it. */
|
|
7960
|
+
function lastReleaseAt(hops) {
|
|
7961
|
+
let at = 0;
|
|
7962
|
+
for (const hop of hops) if (hop.action === "released") at = hop.at;
|
|
7963
|
+
return at;
|
|
7964
|
+
}
|
|
7965
|
+
/**
|
|
7966
|
+
* The message shown when the active budget is full.
|
|
7967
|
+
*
|
|
7968
|
+
* "Wait" is useless advice unless it says for whom, and the ledger already knows
|
|
7969
|
+
* both the holders and whether each is around — so the refusal names them, and
|
|
7970
|
+
* names the two ways out that do not involve waiting.
|
|
7971
|
+
* @param held - the reports currently holding a checkout.
|
|
7972
|
+
* @param limit - the configured cap.
|
|
7973
|
+
* @returns the refusal message.
|
|
7974
|
+
*/
|
|
7975
|
+
function budgetMessage(held, limit) {
|
|
7976
|
+
const holders = held.map((holder) => `${holder.report} -> ${holder.actor} (${holder.live ? "live" : "absent"})`).join(", ");
|
|
7977
|
+
return `the working-tree budget is full (${held.length} of ${limit} held). Held by: ${holders}. Wait for one of these to finish, hand one back with report_release, or close its report — closing releases the claim.`;
|
|
7978
|
+
}
|
|
7979
|
+
/**
|
|
7980
|
+
* Decide what to allocate, or refuse.
|
|
7981
|
+
*
|
|
7982
|
+
* One pass over the ledger yields both answers: which checkouts are held now,
|
|
7983
|
+
* and which ended claims left one behind. The budget is then checked against the
|
|
7984
|
+
* held set alone and refuses without an override; reclamation runs afterwards,
|
|
7985
|
+
* for the disk limit, and can only reach checkouts whose claim already ended.
|
|
7986
|
+
* @param query - the allocation and the ledger it must fit into.
|
|
7987
|
+
* @param settings - the resolved settings.
|
|
7988
|
+
* @returns the plan, or the refusal.
|
|
7989
|
+
*/
|
|
7990
|
+
async function planAllocation(query, settings) {
|
|
7991
|
+
if (hasSubmodules(query.repo)) return {
|
|
7992
|
+
kind: "submodules",
|
|
7993
|
+
message: `${query.repo.repoRoot} uses submodules (it has a .gitmodules file). Git does not recommend multiple checkouts of a superproject, and a linked worktree containing submodules cannot be moved, so the ledger will not allocate one here.`
|
|
7994
|
+
};
|
|
7995
|
+
const held = [];
|
|
7996
|
+
const reclaimable = [];
|
|
7997
|
+
const stale = [];
|
|
7998
|
+
for (const front of query.digests) {
|
|
7999
|
+
if (front.report === query.report) continue;
|
|
8000
|
+
const binding = front.worktree;
|
|
8001
|
+
if (binding === void 0) continue;
|
|
8002
|
+
const hops = await query.hopsOf(front.report);
|
|
8003
|
+
const state = leaseState(hops);
|
|
8004
|
+
if (state === "held") {
|
|
8005
|
+
held.push({
|
|
8006
|
+
report: front.report,
|
|
8007
|
+
actor: front.from,
|
|
8008
|
+
live: query.isLive(front.from)
|
|
8009
|
+
});
|
|
8010
|
+
continue;
|
|
8011
|
+
}
|
|
8012
|
+
if (state !== "released" || front.status !== "closed" || binding.allocated !== true) continue;
|
|
8013
|
+
if (!existsSync(binding.path)) {
|
|
8014
|
+
stale.push({
|
|
8015
|
+
report: front.report,
|
|
8016
|
+
path: binding.path
|
|
8017
|
+
});
|
|
8018
|
+
continue;
|
|
8019
|
+
}
|
|
8020
|
+
reclaimable.push({
|
|
8021
|
+
report: front.report,
|
|
8022
|
+
path: binding.path,
|
|
8023
|
+
...binding.branch === void 0 ? {} : { branch: binding.branch },
|
|
8024
|
+
releasedAt: lastReleaseAt(hops)
|
|
8025
|
+
});
|
|
8026
|
+
}
|
|
8027
|
+
if (held.length >= settings.maxActive) return {
|
|
8028
|
+
kind: "budget",
|
|
8029
|
+
message: budgetMessage(held, settings.maxActive)
|
|
8030
|
+
};
|
|
8031
|
+
reclaimable.sort((left, right) => left.releasedAt - right.releasedAt || left.report.localeCompare(right.report));
|
|
8032
|
+
let reclaimed;
|
|
8033
|
+
const reclaimSkipped = [];
|
|
8034
|
+
if (settings.reclaim === "oldest-closed" && reclaimable.length > settings.maxRetained) for (const candidate of reclaimable) {
|
|
8035
|
+
if (!await isWorktreeClean(candidate.path)) {
|
|
8036
|
+
reclaimSkipped.push(`${candidate.report} (uncommitted changes at ${candidate.path})`);
|
|
8037
|
+
continue;
|
|
8038
|
+
}
|
|
8039
|
+
reclaimed = {
|
|
8040
|
+
report: candidate.report,
|
|
8041
|
+
path: candidate.path,
|
|
8042
|
+
...candidate.branch === void 0 ? {} : { branch: candidate.branch }
|
|
8043
|
+
};
|
|
8044
|
+
break;
|
|
8045
|
+
}
|
|
8046
|
+
return {
|
|
8047
|
+
binding: {
|
|
8048
|
+
path: worktreePath(query.repo, query.report, query.subject, settings.base),
|
|
8049
|
+
branch: worktreeBranch(query.report, query.subject),
|
|
8050
|
+
repo: query.repo.commonDir,
|
|
8051
|
+
allocated: true
|
|
8052
|
+
},
|
|
8053
|
+
retained: reclaimable.length - (reclaimed === void 0 ? 0 : 1),
|
|
8054
|
+
...reclaimed === void 0 ? {} : { reclaimed },
|
|
8055
|
+
reclaimSkipped,
|
|
8056
|
+
stale
|
|
8057
|
+
};
|
|
8058
|
+
}
|
|
8059
|
+
//#endregion
|
|
8060
|
+
//#region src/report/maintenance.ts
|
|
8061
|
+
/**
|
|
8062
|
+
* The maintenance view: what the ledger thinks it owns, and what is true of it.
|
|
8063
|
+
*
|
|
8064
|
+
* This exists because of an asymmetry the design accepts deliberately. A working
|
|
8065
|
+
* tree is not a first-class object in the ledger — it is a field on a report plus
|
|
8066
|
+
* a directory on disk — so there is no index to consult when an operator asks
|
|
8067
|
+
* "what checkouts are out there, and is any of it broken?". The answer has to be
|
|
8068
|
+
* assembled from the reports and from git, every time, and that is what this
|
|
8069
|
+
* module does.
|
|
8070
|
+
*
|
|
8071
|
+
* It **reports and never repairs**. Nothing here removes, prunes or reclaims: a
|
|
8072
|
+
* checkout moved by hand still works from its own side while git calls it
|
|
8073
|
+
* dangling, so an automatic prune destroys live work, and the whole point of a
|
|
8074
|
+
* maintenance view is to let a human decide with the facts in front of them.
|
|
8075
|
+
*
|
|
8076
|
+
* @module dsh-report-ledger/report/maintenance
|
|
8077
|
+
*/
|
|
8078
|
+
/**
|
|
8079
|
+
* Assemble the maintenance view.
|
|
8080
|
+
*
|
|
8081
|
+
* Two passes, because they cost different things: the first asks the ledger what
|
|
8082
|
+
* it believes (cheap), the second asks git what is true (one `worktree list` per
|
|
8083
|
+
* repository, plus one `status` per checkout that is actually on disk).
|
|
8084
|
+
* @param deps - the ledger readers.
|
|
8085
|
+
* @param settings - the configured limits.
|
|
8086
|
+
* @param enabled - whether allocation is on in this deployment.
|
|
8087
|
+
* @returns the view.
|
|
8088
|
+
*/
|
|
8089
|
+
async function buildWorktreeReport(deps, settings, enabled) {
|
|
8090
|
+
const digests = await deps.listReports();
|
|
8091
|
+
const records = [];
|
|
8092
|
+
for (const front of digests) {
|
|
8093
|
+
const binding = front.worktree;
|
|
8094
|
+
if (binding === void 0) continue;
|
|
8095
|
+
const onDisk = existsSync(binding.path);
|
|
8096
|
+
records.push({
|
|
8097
|
+
report: front.report,
|
|
8098
|
+
subject: front.subject,
|
|
8099
|
+
status: front.status,
|
|
8100
|
+
path: binding.path,
|
|
8101
|
+
...binding.branch === void 0 ? {} : { branch: binding.branch },
|
|
8102
|
+
...binding.head === void 0 ? {} : { head: binding.head },
|
|
8103
|
+
...binding.repo === void 0 ? {} : { repo: binding.repo },
|
|
8104
|
+
allocated: binding.allocated === true,
|
|
8105
|
+
state: leaseState(await deps.readHops(front.report)),
|
|
8106
|
+
onDisk,
|
|
8107
|
+
...onDisk ? { clean: await isWorktreeClean(binding.path) } : {},
|
|
8108
|
+
prunable: false
|
|
8109
|
+
});
|
|
8110
|
+
}
|
|
8111
|
+
const byRepo = /* @__PURE__ */ new Map();
|
|
8112
|
+
for (const record of records) {
|
|
8113
|
+
const key = record.repo ?? record.path;
|
|
8114
|
+
const bucket = byRepo.get(key);
|
|
8115
|
+
if (bucket === void 0) byRepo.set(key, [record]);
|
|
8116
|
+
else bucket.push(record);
|
|
8117
|
+
}
|
|
8118
|
+
const warnings = [];
|
|
8119
|
+
const unmanaged = [];
|
|
8120
|
+
const dangling = [];
|
|
8121
|
+
const prunable = /* @__PURE__ */ new Set();
|
|
8122
|
+
for (const bucket of byRepo.values()) {
|
|
8123
|
+
const first = bucket[0];
|
|
8124
|
+
if (first === void 0) continue;
|
|
8125
|
+
const repo = await resolveRepo(first.path) ?? (first.repo === void 0 ? void 0 : await resolveRepo(first.repo));
|
|
8126
|
+
if (repo === void 0) {
|
|
8127
|
+
warnings.push(`the repository holding ${bucket.map((record) => record.report).join(", ")} could not be resolved, so those checkouts were not inspected`);
|
|
8128
|
+
continue;
|
|
8129
|
+
}
|
|
8130
|
+
let entries;
|
|
8131
|
+
try {
|
|
8132
|
+
entries = await listWorktrees(repo);
|
|
8133
|
+
} catch (error) {
|
|
8134
|
+
warnings.push(`git could not list the working trees of ${repo.commonDir}: ${error instanceof Error ? error.message : String(error)}`);
|
|
8135
|
+
continue;
|
|
8136
|
+
}
|
|
8137
|
+
for (const entry of entries) if (entry.prunable) {
|
|
8138
|
+
prunable.add(entry.path);
|
|
8139
|
+
dangling.push(entry.path);
|
|
8140
|
+
}
|
|
8141
|
+
for (const entry of entries) {
|
|
8142
|
+
if (entry.bare || entry.prunable || samePath(entry.path, repo.repoRoot)) continue;
|
|
8143
|
+
if (records.some((record) => samePath(record.path, entry.path))) continue;
|
|
8144
|
+
unmanaged.push(entry.path);
|
|
8145
|
+
}
|
|
8146
|
+
}
|
|
8147
|
+
const resolved = records.map((record) => prunable.has(record.path) ? {
|
|
8148
|
+
...record,
|
|
8149
|
+
prunable: true
|
|
8150
|
+
} : record);
|
|
8151
|
+
const active = resolved.filter((record) => record.state === "held").length;
|
|
8152
|
+
const retained = resolved.filter((record) => record.state === "released" && record.onDisk).length;
|
|
8153
|
+
for (const record of resolved) {
|
|
8154
|
+
if (record.state === "held" && !record.onDisk) warnings.push(`${record.report} holds a claim on ${record.path}, and that checkout is no longer on disk. Release the claim, or close the report — closing releases it.`);
|
|
8155
|
+
if (record.state === "released" && !record.onDisk) warnings.push(`${record.report}'s bound checkout at ${record.path} is gone, and the ledger still records the claim as released rather than reclaimed. The next allocation will note that it is gone.`);
|
|
8156
|
+
if (record.clean === false && record.state !== "held") warnings.push(`the kept checkout at ${record.path} (${record.report}) has uncommitted work, so it will never be reclaimed automatically.`);
|
|
8157
|
+
}
|
|
8158
|
+
if (retained > settings.maxRetained && settings.reclaim === "never") warnings.push(`${retained} checkouts are kept on disk past the retained limit of ${settings.maxRetained}, and automatic reclamation is off, so they will accumulate. Close reports with worktree:"remove-if-clean", or set worktree.reclaim to "oldest-closed".`);
|
|
8159
|
+
const uniqueDangling = [...new Set(dangling)].sort();
|
|
8160
|
+
for (const path of uniqueDangling) warnings.push(`git reports the worktree entry at ${path} as dangling. If it was moved by hand, run git worktree repair from the new location; otherwise prune the stale entry.`);
|
|
8161
|
+
for (const path of unmanaged) warnings.push(`git has a working tree no report is bound to: ${path}. A report can adopt it with report_lease.`);
|
|
8162
|
+
resolved.sort((left, right) => left.report.localeCompare(right.report));
|
|
8163
|
+
return {
|
|
8164
|
+
generatedAt: Date.now(),
|
|
8165
|
+
enabled,
|
|
8166
|
+
limits: {
|
|
8167
|
+
maxActive: settings.maxActive,
|
|
8168
|
+
maxRetained: settings.maxRetained,
|
|
8169
|
+
reclaim: settings.reclaim
|
|
8170
|
+
},
|
|
8171
|
+
active,
|
|
8172
|
+
retained,
|
|
8173
|
+
records: resolved,
|
|
8174
|
+
unmanaged: [...new Set(unmanaged)].sort(),
|
|
8175
|
+
dangling: uniqueDangling,
|
|
8176
|
+
warnings
|
|
8177
|
+
};
|
|
8178
|
+
}
|
|
7283
8179
|
//#endregion
|
|
7284
8180
|
//#region src/report/roster.ts
|
|
7285
8181
|
/**
|
|
@@ -7529,6 +8425,8 @@ function holdsChannelTo(input, target) {
|
|
|
7529
8425
|
if (byId.get(target)?.header.parentSession === input.self) return true;
|
|
7530
8426
|
return input.peers.some((peer) => peer.startedBy === input.self && peer.sessionId === target);
|
|
7531
8427
|
}
|
|
8428
|
+
/** How long a caller waits for evidence about a peer's first turn, by default. */
|
|
8429
|
+
const DEFAULT_RECEIPT_MS = 1500;
|
|
7532
8430
|
/** The peer service. */
|
|
7533
8431
|
var PeerService = class {
|
|
7534
8432
|
#host;
|
|
@@ -7602,6 +8500,19 @@ var PeerService = class {
|
|
|
7602
8500
|
return holdsChannelTo(await this.#inputs(self), target);
|
|
7603
8501
|
}
|
|
7604
8502
|
/**
|
|
8503
|
+
* Observe what became of a peer's first turn.
|
|
8504
|
+
*
|
|
8505
|
+
* Not a status check: it is the honest answer to "did the thing I just handed
|
|
8506
|
+
* this peer actually start working?" — the question a bare `delivered` cannot
|
|
8507
|
+
* answer. Call it after the assignment report has been delivered.
|
|
8508
|
+
* @param sessionId - the peer session to observe.
|
|
8509
|
+
* @param timeoutMs - how long a still-running turn is awaited before being reported as running.
|
|
8510
|
+
* @returns the observation, or `undefined` when the peer is not resident.
|
|
8511
|
+
*/
|
|
8512
|
+
async receipt(sessionId, timeoutMs = DEFAULT_RECEIPT_MS) {
|
|
8513
|
+
return await this.#host.confirmPeer(sessionId, timeoutMs);
|
|
8514
|
+
}
|
|
8515
|
+
/**
|
|
7605
8516
|
* Open one independent peer session.
|
|
7606
8517
|
*
|
|
7607
8518
|
* The peer is recorded before it is returned, so the relationship survives even
|
|
@@ -7619,10 +8530,11 @@ var PeerService = class {
|
|
|
7619
8530
|
if (name !== void 0 && name.length > 60) throw new Error("report-ledger: peer_start name must be 60 characters or fewer");
|
|
7620
8531
|
const already = await this.startedCount(self);
|
|
7621
8532
|
if (already >= this.#maxPeers) throw new Error(`report-ledger: this session already started ${already} peers (limit ${this.#maxPeers}); reuse an existing peer, or report to one you already hold a channel to instead of opening another`);
|
|
7622
|
-
const cwd = this.#host.selfCwd(self);
|
|
8533
|
+
const cwd = input.cwd ?? this.#host.selfCwd(self);
|
|
7623
8534
|
const sessionId = `session-${randomUUID()}`;
|
|
7624
8535
|
const created = await this.#host.createPeer({
|
|
7625
8536
|
sessionId,
|
|
8537
|
+
startedBy: self,
|
|
7626
8538
|
...cwd === void 0 ? {} : { cwd }
|
|
7627
8539
|
});
|
|
7628
8540
|
await appendPeer({
|
|
@@ -7646,6 +8558,14 @@ var PeerService = class {
|
|
|
7646
8558
|
const TIMELINE_PATH = "/api/report-ledger/timeline";
|
|
7647
8559
|
/** Path of one report's detail. */
|
|
7648
8560
|
const REPORT_PATH = "/api/report-ledger/report";
|
|
8561
|
+
/**
|
|
8562
|
+
* Path of the working-tree maintenance view.
|
|
8563
|
+
*
|
|
8564
|
+
* Registered whatever the feature's state, so an operator asking "is anything
|
|
8565
|
+
* out there?" gets an answer even when the answer is "nothing, and allocation is
|
|
8566
|
+
* off". A 404 would be indistinguishable from a typo.
|
|
8567
|
+
*/
|
|
8568
|
+
const WORKTREES_PATH = "/api/report-ledger/worktrees";
|
|
7649
8569
|
/** Longest accepted root session id. */
|
|
7650
8570
|
const MAX_ID_LENGTH = 200;
|
|
7651
8571
|
/** Conservative session-id shape: no separators that could escape a query. */
|
|
@@ -7773,6 +8693,23 @@ function createTimelineHandler(deps) {
|
|
|
7773
8693
|
});
|
|
7774
8694
|
}
|
|
7775
8695
|
/**
|
|
8696
|
+
* Build the working-tree maintenance handler.
|
|
8697
|
+
*
|
|
8698
|
+
* Read-only and unparameterised: it answers one question — what checkouts does
|
|
8699
|
+
* the ledger know about, and what is true of them — with no way to change any of
|
|
8700
|
+
* it. Repair stays a human decision made from these facts.
|
|
8701
|
+
* @param deps - data sources.
|
|
8702
|
+
* @returns the route handler.
|
|
8703
|
+
*/
|
|
8704
|
+
function createWorktreesHandler(deps) {
|
|
8705
|
+
return guarded(async (_url, response) => {
|
|
8706
|
+
writeJson(response, 200, {
|
|
8707
|
+
ok: true,
|
|
8708
|
+
value: await deps.worktrees()
|
|
8709
|
+
});
|
|
8710
|
+
});
|
|
8711
|
+
}
|
|
8712
|
+
/**
|
|
7776
8713
|
* Build the single-report handler.
|
|
7777
8714
|
* @param deps - data sources.
|
|
7778
8715
|
* @returns the route handler.
|
|
@@ -7820,8 +8757,21 @@ function createReportHandler(deps) {
|
|
|
7820
8757
|
const BODY_INLINE_LIMIT = 8e3;
|
|
7821
8758
|
/** How many characters of a body are shown when it exceeds the inline limit. */
|
|
7822
8759
|
const BODY_HEAD_LIMIT = 1200;
|
|
7823
|
-
/**
|
|
7824
|
-
|
|
8760
|
+
/**
|
|
8761
|
+
* One token naming a bound working tree.
|
|
8762
|
+
*
|
|
8763
|
+
* The branch is the lease's identity, so it is what a listing shows; a detached
|
|
8764
|
+
* tree has none, and there the directory name is the only stable handle. The
|
|
8765
|
+
* full path stays in the digest for anyone who needs to act on it.
|
|
8766
|
+
* @param worktree - the binding.
|
|
8767
|
+
* @returns the label.
|
|
8768
|
+
*/
|
|
8769
|
+
function worktreeLabel(worktree) {
|
|
8770
|
+
if (worktree.branch !== void 0) return worktree.branch;
|
|
8771
|
+
return basename(worktree.path) || worktree.path;
|
|
8772
|
+
}
|
|
8773
|
+
/** Serialize one report for model-facing output. */
|
|
8774
|
+
function renderFrontLine(front) {
|
|
7825
8775
|
const parts = [
|
|
7826
8776
|
`${front.report} [${front.status}] ${front.subject}`,
|
|
7827
8777
|
`from=${front.from}`,
|
|
@@ -7832,6 +8782,7 @@ function renderFrontLine(front) {
|
|
|
7832
8782
|
`updated=${new Date(front.updated).toISOString()}`
|
|
7833
8783
|
];
|
|
7834
8784
|
if (front.task !== void 0) parts.push(`task=${front.task}`);
|
|
8785
|
+
if (front.worktree !== void 0) parts.push(`worktree=${worktreeLabel(front.worktree)}@${front.worktree.path}`);
|
|
7835
8786
|
if (front.parent !== void 0) parts.push(`parent=${front.parent}`);
|
|
7836
8787
|
if (front.children.length > 0) parts.push(`children=${front.children.join(",")}`);
|
|
7837
8788
|
if (front.last !== void 0) parts.push(`last=${front.last.action}@${front.last.actor}`);
|
|
@@ -7871,31 +8822,55 @@ var ReportService = class ReportService {
|
|
|
7871
8822
|
#host;
|
|
7872
8823
|
#locks = /* @__PURE__ */ new Map();
|
|
7873
8824
|
/**
|
|
8825
|
+
* Mutexes keyed by shared git directory, for decisions that span reports.
|
|
8826
|
+
*
|
|
8827
|
+
* A per-report mutex cannot serialize two *different* reports racing for one
|
|
8828
|
+
* branch, and claiming a working tree is exactly that decision. The key is the
|
|
8829
|
+
* repository rather than a checkout, because a branch is held by a repository
|
|
8830
|
+
* — keying by path would leave two checkouts of one repository mutually
|
|
8831
|
+
* invisible, which is the race the lock exists to stop. Their order is fixed;
|
|
8832
|
+
* see {@link ReportService.lease}.
|
|
8833
|
+
*/
|
|
8834
|
+
#repoLocks = /* @__PURE__ */ new Map();
|
|
8835
|
+
/**
|
|
7874
8836
|
* @param host - host capabilities (agent lookup, display names).
|
|
7875
8837
|
*/
|
|
7876
8838
|
constructor(host) {
|
|
7877
8839
|
this.#host = host;
|
|
7878
8840
|
}
|
|
8841
|
+
/** Run one task under a key's mutex. */
|
|
8842
|
+
async #serialize(map, key, work) {
|
|
8843
|
+
const next = (map.get(key) ?? Promise.resolve()).then(work, work);
|
|
8844
|
+
map.set(key, next.then(() => void 0, () => void 0));
|
|
8845
|
+
return next;
|
|
8846
|
+
}
|
|
7879
8847
|
/** Run one mutation under the report's mutex. */
|
|
7880
8848
|
async #withLock(report, work) {
|
|
7881
|
-
|
|
7882
|
-
|
|
7883
|
-
|
|
8849
|
+
return await this.#serialize(this.#locks, report, work);
|
|
8850
|
+
}
|
|
8851
|
+
/** Run one decision under the repository's mutex. */
|
|
8852
|
+
async #withRepoLock(key, work) {
|
|
8853
|
+
return await this.#serialize(this.#repoLocks, key, work);
|
|
7884
8854
|
}
|
|
7885
8855
|
/** Append one hop, then refresh the derived digest fields. */
|
|
7886
|
-
async #record(report, hop) {
|
|
8856
|
+
async #record(report, hop, options = {}) {
|
|
7887
8857
|
await appendHop(report, hop);
|
|
7888
|
-
return this.#sync(report);
|
|
8858
|
+
return await this.#sync(report, options);
|
|
7889
8859
|
}
|
|
7890
|
-
/**
|
|
7891
|
-
|
|
8860
|
+
/**
|
|
8861
|
+
* Recompute the derived front-matter fields from the hop stream.
|
|
8862
|
+
* @param report - report identifier.
|
|
8863
|
+
* @param options - `touch: false` keeps the digest's `updated` stamp, for an
|
|
8864
|
+
* administrative hop that must not make a concluded report look active again.
|
|
8865
|
+
*/
|
|
8866
|
+
async #sync(report, options = {}) {
|
|
7892
8867
|
const current = await readReport(report);
|
|
7893
8868
|
if (current === void 0) throw new Error(`report-ledger: report ${report} does not exist`);
|
|
7894
8869
|
const projected = projectRoute(await readHops(report));
|
|
7895
8870
|
const front = {
|
|
7896
8871
|
...current.front,
|
|
7897
8872
|
...projected,
|
|
7898
|
-
updated: Date.now()
|
|
8873
|
+
updated: options.touch === false ? current.front.updated : Date.now()
|
|
7899
8874
|
};
|
|
7900
8875
|
await writeReport({
|
|
7901
8876
|
front,
|
|
@@ -7933,7 +8908,8 @@ var ReportService = class ReportService {
|
|
|
7933
8908
|
async author(input) {
|
|
7934
8909
|
const subject = input.subject.trim();
|
|
7935
8910
|
if (subject === "") throw new Error("report-ledger: report_author requires a non-empty subject");
|
|
7936
|
-
const report = await allocateId();
|
|
8911
|
+
const report = input.reportId ?? await allocateId();
|
|
8912
|
+
if (!isValidReportId(report)) throw new Error(`report-ledger: ${JSON.stringify(report)} is not a usable report id (expected the form R-0001)`);
|
|
7937
8913
|
const now = Date.now();
|
|
7938
8914
|
const fromName = await this.#host.describe(input.actor);
|
|
7939
8915
|
await writeReport({
|
|
@@ -7952,7 +8928,8 @@ var ReportService = class ReportService {
|
|
|
7952
8928
|
artifacts: [...input.artifacts ?? []],
|
|
7953
8929
|
hops: 0,
|
|
7954
8930
|
...input.parent === void 0 ? {} : { parent: input.parent },
|
|
7955
|
-
...input.task === void 0 ? {} : { task: input.task }
|
|
8931
|
+
...input.task === void 0 ? {} : { task: input.task },
|
|
8932
|
+
...input.worktree === void 0 ? {} : { worktree: input.worktree }
|
|
7956
8933
|
},
|
|
7957
8934
|
body: input.body
|
|
7958
8935
|
});
|
|
@@ -7962,6 +8939,13 @@ var ReportService = class ReportService {
|
|
|
7962
8939
|
action: "authored",
|
|
7963
8940
|
to: []
|
|
7964
8941
|
});
|
|
8942
|
+
if (input.worktree !== void 0) await this.#record(report, {
|
|
8943
|
+
at: Date.now(),
|
|
8944
|
+
actor: input.actor,
|
|
8945
|
+
action: "leased",
|
|
8946
|
+
to: [],
|
|
8947
|
+
note: `${input.worktree.branch ?? "(detached HEAD)"} @ ${input.worktree.path}`
|
|
8948
|
+
});
|
|
7965
8949
|
if (input.parent !== void 0) {
|
|
7966
8950
|
if (await readReport(input.parent) !== void 0) await this.#withLock(input.parent, async () => {
|
|
7967
8951
|
const latest = await this.#require(input.parent);
|
|
@@ -8015,6 +8999,27 @@ var ReportService = class ReportService {
|
|
|
8015
8999
|
return actor === front.from || front.authors.includes(actor);
|
|
8016
9000
|
}
|
|
8017
9001
|
/**
|
|
9002
|
+
* Throw unless the actor owns the report.
|
|
9003
|
+
*
|
|
9004
|
+
* Correcting, concluding and claiming a checkout are all the owner's acts, and
|
|
9005
|
+
* they are refused the same way — by naming who may do it instead. A refusal
|
|
9006
|
+
* that only says "no" makes the model guess; naming the authors turns it into
|
|
9007
|
+
* a hand-off.
|
|
9008
|
+
*
|
|
9009
|
+
* The gerund is passed rather than derived, because the derivation is wrong:
|
|
9010
|
+
* `close` + `ing` is not a word, and a refusal that reads like a bug invites a
|
|
9011
|
+
* model to route around it.
|
|
9012
|
+
* @param front - the report's digest.
|
|
9013
|
+
* @param actor - the session attempting the act.
|
|
9014
|
+
* @param verb - the act, in the imperative, for the sentence.
|
|
9015
|
+
* @param gerund - the same act as a noun phrase.
|
|
9016
|
+
*/
|
|
9017
|
+
#assertOwner(front, actor, verb, gerund) {
|
|
9018
|
+
if (ReportService.owns(front, actor)) return;
|
|
9019
|
+
const owners = [.../* @__PURE__ */ new Set([front.from, ...front.authors])];
|
|
9020
|
+
throw new Error(`report-ledger: ${actor} may not ${verb} ${front.report} — ${gerund} is the owner's act, not a reader's. Ask one of its authors to do it: ${owners.join(", ")}`);
|
|
9021
|
+
}
|
|
9022
|
+
/**
|
|
8018
9023
|
* Return a closed report to life because fresh activity arrived.
|
|
8019
9024
|
*
|
|
8020
9025
|
* `closed` is a state, not a lock. If someone contributes to, sends, copies, or
|
|
@@ -8181,8 +9186,11 @@ var ReportService = class ReportService {
|
|
|
8181
9186
|
return ` ${new Date(hop.at).toISOString()} ${hop.action.padEnd(11)} by ${hop.actor}${targets}${note}`;
|
|
8182
9187
|
}).join("\n");
|
|
8183
9188
|
const pending = pendingTargets(hops);
|
|
9189
|
+
const worktree = found.front.worktree;
|
|
9190
|
+
const lease = worktree === void 0 ? [] : [`lease=${leaseState(hops)} ${worktree.branch ?? "(detached HEAD)"} @ ${worktree.path} (${worktree.allocated === true ? "allocated by the ledger" : "adopted"})`];
|
|
8184
9191
|
return [
|
|
8185
9192
|
renderFrontLine(found.front),
|
|
9193
|
+
...lease,
|
|
8186
9194
|
`body=${path}`,
|
|
8187
9195
|
"",
|
|
8188
9196
|
"## transfer path",
|
|
@@ -8202,9 +9210,14 @@ var ReportService = class ReportService {
|
|
|
8202
9210
|
const all = await listReports();
|
|
8203
9211
|
const session = filter.session;
|
|
8204
9212
|
const task = filter.task?.trim().toLowerCase();
|
|
9213
|
+
const worktree = filter.worktree?.trim().toLowerCase();
|
|
8205
9214
|
const matching = all.filter((front) => {
|
|
8206
9215
|
if (filter.status !== void 0 && front.status !== filter.status) return false;
|
|
8207
9216
|
if (task !== void 0 && task !== "" && !(front.task ?? "").toLowerCase().includes(task)) return false;
|
|
9217
|
+
if (worktree !== void 0 && worktree !== "") {
|
|
9218
|
+
if (front.worktree === void 0) return false;
|
|
9219
|
+
if (!`${front.worktree.branch ?? ""} ${front.worktree.path}`.toLowerCase().includes(worktree)) return false;
|
|
9220
|
+
}
|
|
8208
9221
|
if (session === void 0) return true;
|
|
8209
9222
|
return front.from === session || front.to.includes(session) || front.cc.includes(session) || front.authors.includes(session);
|
|
8210
9223
|
});
|
|
@@ -8218,6 +9231,7 @@ var ReportService = class ReportService {
|
|
|
8218
9231
|
cc: front.cc,
|
|
8219
9232
|
hops: front.hops,
|
|
8220
9233
|
...front.task === void 0 ? {} : { task: front.task },
|
|
9234
|
+
...front.worktree === void 0 ? {} : { worktree: worktreeLabel(front.worktree) },
|
|
8221
9235
|
...front.last === void 0 ? {} : { last: `${front.last.action}@${front.last.actor}` },
|
|
8222
9236
|
updated: front.updated
|
|
8223
9237
|
}));
|
|
@@ -8263,22 +9277,53 @@ var ReportService = class ReportService {
|
|
|
8263
9277
|
* @param report - report identifier.
|
|
8264
9278
|
* @param actor - the session concluding it.
|
|
8265
9279
|
* @param note - optional note.
|
|
8266
|
-
* @
|
|
9280
|
+
* @param options - what to do with a bound working tree on disk.
|
|
9281
|
+
* @returns the refreshed digest, and what happened to the working tree.
|
|
8267
9282
|
* @throws when the actor is neither the originator nor a co-author.
|
|
8268
9283
|
*/
|
|
8269
|
-
async close(report, actor, note) {
|
|
9284
|
+
async close(report, actor, note, options = {}) {
|
|
8270
9285
|
return this.#withLock(report, async () => {
|
|
8271
9286
|
const current = await this.#require(report);
|
|
8272
|
-
|
|
8273
|
-
|
|
8274
|
-
|
|
9287
|
+
this.#assertOwner(current.front, actor, "close", "closing");
|
|
9288
|
+
const binding = current.front.worktree;
|
|
9289
|
+
if (binding !== void 0 && leaseState(await readHops(report)) === "held") await this.#record(report, {
|
|
9290
|
+
at: Date.now(),
|
|
9291
|
+
actor,
|
|
9292
|
+
action: "released",
|
|
9293
|
+
to: [],
|
|
9294
|
+
note: "released by closing the report"
|
|
9295
|
+
});
|
|
9296
|
+
let outcome;
|
|
9297
|
+
if (binding !== void 0) if (options.worktree !== "remove-if-clean") outcome = { action: "kept" };
|
|
9298
|
+
else {
|
|
9299
|
+
const repo = await resolveRepo(binding.path);
|
|
9300
|
+
if (repo === void 0) outcome = {
|
|
9301
|
+
action: "refused",
|
|
9302
|
+
detail: `${binding.path} is no longer inside a git repository`
|
|
9303
|
+
};
|
|
9304
|
+
else {
|
|
9305
|
+
const removed = await removeWorktree(repo, binding.path);
|
|
9306
|
+
outcome = removed.ok ? { action: "removed" } : {
|
|
9307
|
+
action: "refused",
|
|
9308
|
+
detail: removed.stderr.trim()
|
|
9309
|
+
};
|
|
9310
|
+
}
|
|
8275
9311
|
}
|
|
9312
|
+
const disposition = outcome === void 0 ? void 0 : outcome.action === "removed" ? "working tree removed" : outcome.action === "kept" ? "working tree kept" : `working tree kept: ${outcome.detail ?? "removal refused"}`;
|
|
9313
|
+
if (outcome?.action === "removed") await this.#record(report, {
|
|
9314
|
+
at: Date.now(),
|
|
9315
|
+
actor,
|
|
9316
|
+
action: "reclaimed",
|
|
9317
|
+
to: [],
|
|
9318
|
+
note: `checkout ${binding?.path ?? ""} removed at the owner's request`
|
|
9319
|
+
});
|
|
9320
|
+
const closedNote = [note, disposition].filter((part) => part !== void 0 && part !== "").join("; ");
|
|
8276
9321
|
await this.#record(report, {
|
|
8277
9322
|
at: Date.now(),
|
|
8278
9323
|
actor,
|
|
8279
9324
|
action: "closed",
|
|
8280
9325
|
to: [],
|
|
8281
|
-
...
|
|
9326
|
+
...closedNote === "" ? {} : { note: closedNote }
|
|
8282
9327
|
});
|
|
8283
9328
|
const fresh = await this.#require(report);
|
|
8284
9329
|
await writeReport({
|
|
@@ -8291,11 +9336,305 @@ var ReportService = class ReportService {
|
|
|
8291
9336
|
});
|
|
8292
9337
|
return {
|
|
8293
9338
|
front: (await this.#require(report)).front,
|
|
9339
|
+
outcomes: [],
|
|
9340
|
+
...outcome === void 0 ? {} : { worktree: outcome }
|
|
9341
|
+
};
|
|
9342
|
+
});
|
|
9343
|
+
}
|
|
9344
|
+
/**
|
|
9345
|
+
* Claim a working tree for a report.
|
|
9346
|
+
*
|
|
9347
|
+
* The decision runs under two mutexes, outer first: the working tree's,
|
|
9348
|
+
* because two reports racing for one branch is exactly what a per-report mutex
|
|
9349
|
+
* cannot see, and then the report's, because writing the digest still races
|
|
9350
|
+
* other mutations of the same report. Only this method nests them —
|
|
9351
|
+
* {@link ReportService.release} takes the report lock alone, since giving a
|
|
9352
|
+
* claim up needs no atomicity against the filesystem — so the order is fixed
|
|
9353
|
+
* and cannot form the cycle that would deadlock.
|
|
9354
|
+
*
|
|
9355
|
+
* Re-claiming is allowed and is how a binding is refreshed (a new head, a
|
|
9356
|
+
* corrected path): the report already holding it is skipped by the referee, so
|
|
9357
|
+
* refreshing needs no release first. It is still recorded, because the audit
|
|
9358
|
+
* is the product.
|
|
9359
|
+
* @param report - report identifier.
|
|
9360
|
+
* @param actor - the claiming session; must own the report.
|
|
9361
|
+
* @param binding - the working tree to claim.
|
|
9362
|
+
* @returns the refreshed digest.
|
|
9363
|
+
* @throws when git or the ledger refuses the claim.
|
|
9364
|
+
*/
|
|
9365
|
+
async lease(report, actor, binding) {
|
|
9366
|
+
const repo = await resolveRepo(binding.path) ?? (binding.repo === void 0 ? void 0 : await resolveRepo(binding.repo));
|
|
9367
|
+
const key = compareKey(repo?.commonDir ?? binding.path);
|
|
9368
|
+
return await this.#withRepoLock(key, async () => await this.#withLock(report, async () => {
|
|
9369
|
+
const current = await this.#require(report);
|
|
9370
|
+
this.#assertOwner(current.front, actor, "lease", "leasing");
|
|
9371
|
+
const verdict = await evaluateLease({
|
|
9372
|
+
binding,
|
|
9373
|
+
repo,
|
|
9374
|
+
report,
|
|
9375
|
+
digests: await listReports(),
|
|
9376
|
+
hopsOf: async (id) => await readHops(id)
|
|
9377
|
+
});
|
|
9378
|
+
if (verdict.kind === "refused") throw new Error(`report-ledger: ${verdict.refusal.message}`);
|
|
9379
|
+
const claim = verdict.binding;
|
|
9380
|
+
await writeReport({
|
|
9381
|
+
front: {
|
|
9382
|
+
...current.front,
|
|
9383
|
+
worktree: claim,
|
|
9384
|
+
updated: Date.now()
|
|
9385
|
+
},
|
|
9386
|
+
body: current.body
|
|
9387
|
+
});
|
|
9388
|
+
return {
|
|
9389
|
+
front: await this.#record(report, {
|
|
9390
|
+
at: Date.now(),
|
|
9391
|
+
actor,
|
|
9392
|
+
action: "leased",
|
|
9393
|
+
to: [],
|
|
9394
|
+
note: `${claim.branch ?? "(detached HEAD)"} @ ${claim.path}`
|
|
9395
|
+
}),
|
|
8294
9396
|
outcomes: []
|
|
8295
9397
|
};
|
|
9398
|
+
}));
|
|
9399
|
+
}
|
|
9400
|
+
/**
|
|
9401
|
+
* Give up a report's claim on its working tree, without touching the disk.
|
|
9402
|
+
*
|
|
9403
|
+
* The binding stays in the digest on purpose. A checkout kept on disk must
|
|
9404
|
+
* remain traceable to the report that made it — otherwise the ledger forgets
|
|
9405
|
+
* which working trees it is responsible for, and a later cleanup could not
|
|
9406
|
+
* tell its own from a stranger's. What ended is the claim, and the hop stream
|
|
9407
|
+
* is where that is recorded.
|
|
9408
|
+
* @param report - report identifier.
|
|
9409
|
+
* @param actor - the releasing session; must own the report.
|
|
9410
|
+
* @param note - optional note.
|
|
9411
|
+
* @returns the refreshed digest.
|
|
9412
|
+
* @throws when the report holds nothing, or the actor is not an owner.
|
|
9413
|
+
*/
|
|
9414
|
+
async release(report, actor, note) {
|
|
9415
|
+
return await this.#withLock(report, async () => {
|
|
9416
|
+
const current = await this.#require(report);
|
|
9417
|
+
this.#assertOwner(current.front, actor, "release", "releasing");
|
|
9418
|
+
if (current.front.worktree === void 0) throw new Error(`report-ledger: ${report} is not bound to a working tree, so there is nothing to release`);
|
|
9419
|
+
if (leaseState(await readHops(report)) !== "held") throw new Error(`report-ledger: ${report} does not currently hold its working tree, so there is nothing to release (a claim is released once; closing a report releases it too)`);
|
|
9420
|
+
return {
|
|
9421
|
+
front: await this.#record(report, {
|
|
9422
|
+
at: Date.now(),
|
|
9423
|
+
actor,
|
|
9424
|
+
action: "released",
|
|
9425
|
+
to: [],
|
|
9426
|
+
...note === void 0 ? {} : { note }
|
|
9427
|
+
}),
|
|
9428
|
+
outcomes: []
|
|
9429
|
+
};
|
|
9430
|
+
});
|
|
9431
|
+
}
|
|
9432
|
+
/**
|
|
9433
|
+
* Create the working tree a report is about to be given, and clear room for it.
|
|
9434
|
+
*
|
|
9435
|
+
* Separate from {@link ReportService.lease} because it runs *before* the
|
|
9436
|
+
* report exists: a working tree's name is derived from the report id, and the
|
|
9437
|
+
* caller needs the checkout's path to hand a peer its working directory. So
|
|
9438
|
+
* the order is claim the id, provision the checkout, then author — and this is
|
|
9439
|
+
* the middle step.
|
|
9440
|
+
*
|
|
9441
|
+
* Runs under the repository mutex, because the budget it consults and the
|
|
9442
|
+
* branch it takes are both repository-wide facts that two concurrent
|
|
9443
|
+
* allocations would otherwise read at the same moment.
|
|
9444
|
+
* @param input - what to provision, and for whom.
|
|
9445
|
+
* @returns the binding to record, plus what housekeeping happened.
|
|
9446
|
+
* @throws when a gate refuses the allocation or git cannot create the tree.
|
|
9447
|
+
*/
|
|
9448
|
+
async prepareWorktree(input) {
|
|
9449
|
+
return await this.#withRepoLock(compareKey(input.repo.commonDir), async () => {
|
|
9450
|
+
const plan = await planAllocation({
|
|
9451
|
+
report: input.report,
|
|
9452
|
+
subject: input.subject,
|
|
9453
|
+
repo: input.repo,
|
|
9454
|
+
digests: await listReports(),
|
|
9455
|
+
hopsOf: async (id) => await readHops(id),
|
|
9456
|
+
isLive: (sessionId) => this.#host.getAgent(sessionId) !== void 0
|
|
9457
|
+
}, input.settings);
|
|
9458
|
+
if ("kind" in plan) throw new Error(`report-ledger: ${plan.message}`);
|
|
9459
|
+
for (const gone of plan.stale) await this.#recordVanished(input.actor, gone);
|
|
9460
|
+
if (plan.reclaimed !== void 0) await this.#reclaim(input.actor, plan.reclaimed);
|
|
9461
|
+
const created = await this.#createWorktree(input.repo, plan.binding, input.base, input.settings.relativePaths);
|
|
9462
|
+
if (!created.ok) throw new Error(`report-ledger: ${created.reason}`);
|
|
9463
|
+
const head = await headOf(plan.binding.path);
|
|
9464
|
+
return {
|
|
9465
|
+
binding: {
|
|
9466
|
+
...plan.binding,
|
|
9467
|
+
...head === void 0 ? {} : { head }
|
|
9468
|
+
},
|
|
9469
|
+
retained: plan.retained,
|
|
9470
|
+
reclaimSkipped: plan.reclaimSkipped,
|
|
9471
|
+
stale: plan.stale,
|
|
9472
|
+
...plan.reclaimed === void 0 ? {} : { reclaimed: plan.reclaimed }
|
|
9473
|
+
};
|
|
9474
|
+
});
|
|
9475
|
+
}
|
|
9476
|
+
/**
|
|
9477
|
+
* Attach the linked working tree, recovering from a branch left by a crash.
|
|
9478
|
+
*
|
|
9479
|
+
* A previous attempt can have created the branch and then failed before the
|
|
9480
|
+
* checkout existed, which leaves `-b` permanently refused on that name. Falling
|
|
9481
|
+
* back to checking the existing branch out is what makes the retry work rather
|
|
9482
|
+
* than dead-end, and it cannot take a live claim: git still refuses a branch
|
|
9483
|
+
* another working tree holds.
|
|
9484
|
+
*/
|
|
9485
|
+
async #createWorktree(repo, binding, base, relativePaths) {
|
|
9486
|
+
const first = await addWorktree({
|
|
9487
|
+
repo,
|
|
9488
|
+
path: binding.path,
|
|
9489
|
+
branch: binding.branch,
|
|
9490
|
+
base,
|
|
9491
|
+
relativePaths
|
|
9492
|
+
});
|
|
9493
|
+
if (first.ok) return { ok: true };
|
|
9494
|
+
if (classifyGitRefusal(first.stderr) !== "branch-exists") return {
|
|
9495
|
+
ok: false,
|
|
9496
|
+
reason: `could not create a working tree at ${binding.path}: ${first.stderr.trim()}`
|
|
9497
|
+
};
|
|
9498
|
+
const retry = await addWorktree({
|
|
9499
|
+
repo,
|
|
9500
|
+
path: binding.path,
|
|
9501
|
+
branch: binding.branch,
|
|
9502
|
+
checkoutExisting: true,
|
|
9503
|
+
relativePaths
|
|
9504
|
+
});
|
|
9505
|
+
if (retry.ok) return { ok: true };
|
|
9506
|
+
return {
|
|
9507
|
+
ok: false,
|
|
9508
|
+
reason: `the branch ${binding.branch} already existed and could not be checked out at ${binding.path}: ${retry.stderr.trim()}`
|
|
9509
|
+
};
|
|
9510
|
+
}
|
|
9511
|
+
/**
|
|
9512
|
+
* Record that a checkout the ledger believed it owned has already vanished.
|
|
9513
|
+
*
|
|
9514
|
+
* Nothing is removed — there is nothing left to remove — so this exists purely
|
|
9515
|
+
* because leaving the belief in place is not free: the binding would be
|
|
9516
|
+
* re-examined as a reclamation candidate on every later allocation, and a
|
|
9517
|
+
* candidate whose directory is missing fails the cleanliness check, so it
|
|
9518
|
+
* would be reported as having uncommitted changes. That sentence is wrong, and
|
|
9519
|
+
* it is the kind of wrong that sends a reader looking for work nobody left.
|
|
9520
|
+
*
|
|
9521
|
+
* The hop is administrative, recorded with `touch: false` for the same reason
|
|
9522
|
+
* a reclamation is: a vanished checkout is not a concluded report becoming
|
|
9523
|
+
* active again.
|
|
9524
|
+
*/
|
|
9525
|
+
async #recordVanished(actor, gone) {
|
|
9526
|
+
await this.#withLock(gone.report, async () => {
|
|
9527
|
+
if (!reportExists(gone.report)) return;
|
|
9528
|
+
await this.#record(gone.report, {
|
|
9529
|
+
at: Date.now(),
|
|
9530
|
+
actor,
|
|
9531
|
+
action: "reclaimed",
|
|
9532
|
+
to: [],
|
|
9533
|
+
note: `checkout ${gone.path} was already gone; nothing to remove`
|
|
9534
|
+
}, { touch: false });
|
|
9535
|
+
});
|
|
9536
|
+
}
|
|
9537
|
+
/**
|
|
9538
|
+
* Clear a checkout whose claim already ended, and record that it happened.
|
|
9539
|
+
*
|
|
9540
|
+
* Removal goes through git without a force flag, so an unclean tree refuses
|
|
9541
|
+
* here exactly as it would for a human — and that refusal aborts the
|
|
9542
|
+
* allocation rather than being stepped over, because taking something away is
|
|
9543
|
+
* never the safe default.
|
|
9544
|
+
*
|
|
9545
|
+
* The hop is recorded with `touch: false`: clearing litter must not make a
|
|
9546
|
+
* concluded report look like it just became active and float it to the top of
|
|
9547
|
+
* every listing. It is still recorded, because taking something away from
|
|
9548
|
+
* another report is precisely the act an audit exists for.
|
|
9549
|
+
*/
|
|
9550
|
+
async #reclaim(actor, target) {
|
|
9551
|
+
const repo = await resolveRepo(target.path);
|
|
9552
|
+
if (repo !== void 0) {
|
|
9553
|
+
const removed = await removeWorktree(repo, target.path);
|
|
9554
|
+
if (!removed.ok) throw new Error(`report-ledger: refusing to reclaim ${target.path} from ${target.report} — ${removed.stderr.trim()}`);
|
|
9555
|
+
}
|
|
9556
|
+
await this.#withLock(target.report, async () => {
|
|
9557
|
+
if (!reportExists(target.report)) return;
|
|
9558
|
+
await this.#record(target.report, {
|
|
9559
|
+
at: Date.now(),
|
|
9560
|
+
actor,
|
|
9561
|
+
action: "reclaimed",
|
|
9562
|
+
to: [],
|
|
9563
|
+
note: `checkout ${target.path} cleared to stay within the retained budget`
|
|
9564
|
+
}, { touch: false });
|
|
8296
9565
|
});
|
|
8297
9566
|
}
|
|
8298
9567
|
/**
|
|
9568
|
+
* Undo a provisioning that a later step could not use.
|
|
9569
|
+
*
|
|
9570
|
+
* Deliberately without a force flag: a checkout that something has already
|
|
9571
|
+
* modified refuses to go, and it should — the failure that triggered the
|
|
9572
|
+
* rollback is not a licence to discard work. The caller is told whether the
|
|
9573
|
+
* cleanup happened rather than being left to assume it did.
|
|
9574
|
+
* @param binding - the binding whose checkout should go.
|
|
9575
|
+
* @returns true when the checkout was removed.
|
|
9576
|
+
*/
|
|
9577
|
+
async discardWorktree(binding) {
|
|
9578
|
+
const repo = await resolveRepo(binding.path);
|
|
9579
|
+
if (repo === void 0) return false;
|
|
9580
|
+
return (await removeWorktree(repo, binding.path)).ok;
|
|
9581
|
+
}
|
|
9582
|
+
/**
|
|
9583
|
+
* Claim an id and provision a working tree for a report that does not exist yet.
|
|
9584
|
+
*
|
|
9585
|
+
* The two halves of a checkout's identity are circular: its branch and
|
|
9586
|
+
* directory are named after the report, and the caller needs its path *before*
|
|
9587
|
+
* the report is authored — a peer has to be created in it. This breaks the
|
|
9588
|
+
* circle by claiming the id first, and gives the claim back if provisioning
|
|
9589
|
+
* fails, so a failure does not burn a number.
|
|
9590
|
+
* @param input - what to provision, and for whom.
|
|
9591
|
+
* @returns the claimed id and the provisioned checkout.
|
|
9592
|
+
* @throws when the session has no directory, is not in a repository, is in one
|
|
9593
|
+
* with no commit, or a gate refuses the allocation.
|
|
9594
|
+
*/
|
|
9595
|
+
async provision(input) {
|
|
9596
|
+
const cwd = this.#host.selfCwd?.(input.actor);
|
|
9597
|
+
if (cwd === void 0) throw new Error(`report-ledger: cannot allocate a working tree for ${input.actor} — the session has no working directory, so there is no repository to allocate one in`);
|
|
9598
|
+
const repo = await resolveRepo(cwd);
|
|
9599
|
+
if (repo === void 0) throw new Error(`report-ledger: ${cwd} is not inside a git repository, so there is no repository to allocate a working tree in`);
|
|
9600
|
+
const base = await headOf(cwd);
|
|
9601
|
+
if (base === void 0) throw new Error(`report-ledger: ${cwd} has no commit yet, so there is nothing to branch a working tree from`);
|
|
9602
|
+
const report = await allocateId();
|
|
9603
|
+
try {
|
|
9604
|
+
return {
|
|
9605
|
+
report,
|
|
9606
|
+
prepared: await this.prepareWorktree({
|
|
9607
|
+
report,
|
|
9608
|
+
subject: input.subject,
|
|
9609
|
+
actor: input.actor,
|
|
9610
|
+
repo,
|
|
9611
|
+
base,
|
|
9612
|
+
settings: input.settings
|
|
9613
|
+
})
|
|
9614
|
+
};
|
|
9615
|
+
} catch (error) {
|
|
9616
|
+
await discardUnwrittenId(report);
|
|
9617
|
+
throw error;
|
|
9618
|
+
}
|
|
9619
|
+
}
|
|
9620
|
+
/**
|
|
9621
|
+
* Undo a provisioning whose report never got authored.
|
|
9622
|
+
*
|
|
9623
|
+
* A step after provisioning can fail — a peer that cannot be created, an
|
|
9624
|
+
* authoring error — and without this the caller would leave a checkout and a
|
|
9625
|
+
* burned number behind. Both halves are best-effort and reported rather than
|
|
9626
|
+
* assumed: a checkout refuses to go once anything has touched it, and that
|
|
9627
|
+
* refusal is the correct outcome, not an error to swallow.
|
|
9628
|
+
* @param provisioned - what {@link ReportService.provision} returned.
|
|
9629
|
+
* @returns what was actually undone.
|
|
9630
|
+
*/
|
|
9631
|
+
async rollbackProvision(provisioned) {
|
|
9632
|
+
return {
|
|
9633
|
+
worktree: await this.discardWorktree(provisioned.prepared.binding),
|
|
9634
|
+
id: await discardUnwrittenId(provisioned.report)
|
|
9635
|
+
};
|
|
9636
|
+
}
|
|
9637
|
+
/**
|
|
8299
9638
|
* Correct a report: the digest fields and/or the body, as the owner's act.
|
|
8300
9639
|
*
|
|
8301
9640
|
* The tension this resolves is real. A ledger's value comes from history being
|
|
@@ -8419,11 +9758,49 @@ var ReportService = class ReportService {
|
|
|
8419
9758
|
function renderRow(row) {
|
|
8420
9759
|
const last = row.last === void 0 ? "-" : row.last;
|
|
8421
9760
|
const task = row.task === void 0 ? "" : ` task=${row.task}`;
|
|
8422
|
-
|
|
9761
|
+
const worktree = row.worktree === void 0 ? "" : ` wt=${row.worktree}`;
|
|
9762
|
+
return `${row.report} [${row.status}] ${row.subject}${task}${worktree} | from=${row.from} to=${row.to.length === 0 ? "-" : row.to.join(",")} cc=${row.cc.length === 0 ? "-" : row.cc.join(",")} hops=${row.hops} last=${last}`;
|
|
8423
9763
|
}
|
|
8424
9764
|
//#endregion
|
|
8425
9765
|
//#region src/report/timeline.ts
|
|
8426
9766
|
/**
|
|
9767
|
+
* Timeline assembly: the recursive session subtree plus the reports that touch it.
|
|
9768
|
+
*
|
|
9769
|
+
* Kept free of any harness object so the whole thing is unit-testable: the
|
|
9770
|
+
* caller adapts `ctx.sessionQuery` and the ledger into the small structural
|
|
9771
|
+
* shapes below, and this module turns them into the JSON the tab renders.
|
|
9772
|
+
*
|
|
9773
|
+
* Ordering is DFS pre-order with siblings sorted by creation time, which is both
|
|
9774
|
+
* hierarchical and chronological — a child is always created after its parent,
|
|
9775
|
+
* so "top to bottom" reads as the collaboration actually unfolded.
|
|
9776
|
+
*
|
|
9777
|
+
* @module dsh-report-ledger/report/timeline
|
|
9778
|
+
*/
|
|
9779
|
+
/**
|
|
9780
|
+
* Collect the checkouts a set of reports is bound to.
|
|
9781
|
+
*
|
|
9782
|
+
* Only a report that carries a binding costs a hop read, so a ledger with no
|
|
9783
|
+
* working trees in play pays one pass over the digests and nothing more.
|
|
9784
|
+
* @param reports - the report digests in play.
|
|
9785
|
+
* @param readHops - read one report's hops.
|
|
9786
|
+
* @returns one lane per bound report.
|
|
9787
|
+
*/
|
|
9788
|
+
async function worktreeLanes(reports, readHops) {
|
|
9789
|
+
const lanes = [];
|
|
9790
|
+
for (const front of reports) {
|
|
9791
|
+
const binding = front.worktree;
|
|
9792
|
+
if (binding === void 0) continue;
|
|
9793
|
+
lanes.push({
|
|
9794
|
+
report: front.report,
|
|
9795
|
+
path: binding.path,
|
|
9796
|
+
...binding.branch === void 0 ? {} : { branch: binding.branch },
|
|
9797
|
+
allocated: binding.allocated === true,
|
|
9798
|
+
state: leaseState(await readHops(front.report))
|
|
9799
|
+
});
|
|
9800
|
+
}
|
|
9801
|
+
return lanes.sort((left, right) => (left.branch ?? left.path).localeCompare(right.branch ?? right.path) || left.report.localeCompare(right.report));
|
|
9802
|
+
}
|
|
9803
|
+
/**
|
|
8427
9804
|
* Build the subtree rooted at one session.
|
|
8428
9805
|
*
|
|
8429
9806
|
* A record is included only when its parent chain reaches the root, so a session
|
|
@@ -8520,11 +9897,13 @@ async function buildTimeline(deps, root) {
|
|
|
8520
9897
|
const bare = buildSubtree(await deps.listSessionRecords(), root);
|
|
8521
9898
|
const sessions = withTitles(bare, bare.length === 0 ? /* @__PURE__ */ new Map() : await deps.readTitles(bare.map((node) => node.id)));
|
|
8522
9899
|
const reports = reportsForSubtree(await deps.listReports(), sessions);
|
|
9900
|
+
const worktrees = await worktreeLanes(reports, deps.readHops);
|
|
8523
9901
|
return {
|
|
8524
9902
|
root,
|
|
8525
9903
|
generatedAt: Date.now(),
|
|
8526
9904
|
sessions,
|
|
8527
|
-
reports
|
|
9905
|
+
reports,
|
|
9906
|
+
worktrees
|
|
8528
9907
|
};
|
|
8529
9908
|
}
|
|
8530
9909
|
//#endregion
|
|
@@ -8539,6 +9918,57 @@ async function buildTimeline(deps, root) {
|
|
|
8539
9918
|
*
|
|
8540
9919
|
* @module dsh-report-ledger/report/tools
|
|
8541
9920
|
*/
|
|
9921
|
+
/**
|
|
9922
|
+
* The `worktree` parameter a tool advertises, or nothing at all.
|
|
9923
|
+
*
|
|
9924
|
+
* With the feature off the parameter does not exist, so a model cannot ask for
|
|
9925
|
+
* something the deployment will not do. That is a better answer than a parameter
|
|
9926
|
+
* that is always refused, and it keeps the promise that a deployment which does
|
|
9927
|
+
* not want working trees pays nothing for them.
|
|
9928
|
+
* @param runtime - the resolved feature state.
|
|
9929
|
+
* @param description - what the parameter does, for this tool.
|
|
9930
|
+
* @returns the parameter map to spread into a tool's parameters.
|
|
9931
|
+
*/
|
|
9932
|
+
function worktreeParam(runtime, description) {
|
|
9933
|
+
return runtime.enabled ? { worktree: {
|
|
9934
|
+
type: "boolean",
|
|
9935
|
+
description
|
|
9936
|
+
} } : {};
|
|
9937
|
+
}
|
|
9938
|
+
/** Provision a checkout, or explain that this deployment does not offer one. */
|
|
9939
|
+
async function requireWorktree(runtime, service, input) {
|
|
9940
|
+
if (!runtime.enabled) throw new Error("report-ledger: this deployment has not enabled git worktree support, so no checkout can be allocated (the composition row sets worktree.enabled)");
|
|
9941
|
+
return await service.provision({
|
|
9942
|
+
subject: input.subject,
|
|
9943
|
+
actor: input.actor,
|
|
9944
|
+
settings: runtime.settings
|
|
9945
|
+
});
|
|
9946
|
+
}
|
|
9947
|
+
/**
|
|
9948
|
+
* The lines describing a checkout that was just provisioned.
|
|
9949
|
+
*
|
|
9950
|
+
* The sharing sentence is not boilerplate. "Its own checkout" reads naturally as
|
|
9951
|
+
* "its own environment", and the difference becomes real the moment an agent
|
|
9952
|
+
* installs a hook or changes a setting — both of which reach every other
|
|
9953
|
+
* checkout, because only the working directory and index are private.
|
|
9954
|
+
* @param prepared - what provisioning produced.
|
|
9955
|
+
* @returns the lines to append to the tool output.
|
|
9956
|
+
*/
|
|
9957
|
+
function renderProvision(prepared) {
|
|
9958
|
+
const lines = [`worktree=${prepared.binding.path} branch=${prepared.binding.branch}`];
|
|
9959
|
+
if (prepared.reclaimed !== void 0) lines.push(`reclaimed ${prepared.reclaimed.report}'s checkout at ${prepared.reclaimed.path} to stay within the retained budget; that report remains closed`);
|
|
9960
|
+
for (const skipped of prepared.reclaimSkipped) lines.push(`kept a checkout the budget would otherwise have cleared: ${skipped}`);
|
|
9961
|
+
for (const gone of prepared.stale) lines.push(`recorded that ${gone.report}'s checkout at ${gone.path} was already gone — nothing to remove`);
|
|
9962
|
+
lines.push("private: working directory and index. shared: config, hooks, the object store, and everything outside the checkouts.");
|
|
9963
|
+
return lines;
|
|
9964
|
+
}
|
|
9965
|
+
/** The line describing what a mutation did with a checkout on disk. */
|
|
9966
|
+
function renderWorktreeOutcome(outcome, canRemove) {
|
|
9967
|
+
if (outcome === void 0) return void 0;
|
|
9968
|
+
if (outcome.action === "removed") return "working tree removed";
|
|
9969
|
+
if (outcome.action === "kept") return canRemove ? "working tree kept (close with worktree:\"remove-if-clean\" to remove it)" : "working tree kept";
|
|
9970
|
+
return `working tree NOT removed — git refused: ${outcome.detail ?? "unknown reason"}`;
|
|
9971
|
+
}
|
|
8542
9972
|
/** Every tool's output is one compact text rendering of its canonical string value. */
|
|
8543
9973
|
const stringOutput = {
|
|
8544
9974
|
schema: { type: "string" },
|
|
@@ -8561,7 +9991,7 @@ function actorOf(exec) {
|
|
|
8561
9991
|
return agent.id;
|
|
8562
9992
|
}
|
|
8563
9993
|
/** Render a mutation result as a compact model-facing report. */
|
|
8564
|
-
function renderMutation(front, outcomes) {
|
|
9994
|
+
function renderMutation(front, outcomes, prepared) {
|
|
8565
9995
|
const lines = [`${front.report} [${front.status}] ${front.subject}`, `authors=${front.authors.join(",")} to=${front.to.length === 0 ? "-" : front.to.join(",")} cc=${front.cc.length === 0 ? "-" : front.cc.join(",")} hops=${front.hops}`];
|
|
8566
9996
|
if (outcomes.length > 0) {
|
|
8567
9997
|
const delivered = outcomes.filter((outcome) => outcome.status === "delivered").map((outcome) => outcome.targetId);
|
|
@@ -8569,6 +9999,7 @@ function renderMutation(front, outcomes) {
|
|
|
8569
9999
|
if (delivered.length > 0) lines.push(`delivered to: ${delivered.join(", ")}`);
|
|
8570
10000
|
if (queued.length > 0) lines.push(`held for absent recipient (delivered automatically when it becomes resident): ${queued.join(", ")}`);
|
|
8571
10001
|
}
|
|
10002
|
+
if (prepared !== void 0) lines.push(...renderProvision(prepared));
|
|
8572
10003
|
return lines.join("\n");
|
|
8573
10004
|
}
|
|
8574
10005
|
/** Derive a concise report subject from a task description. */
|
|
@@ -8578,13 +10009,34 @@ function subjectFrom(task, name) {
|
|
|
8578
10009
|
return name === void 0 ? trimmed : `${name}: ${trimmed}`;
|
|
8579
10010
|
}
|
|
8580
10011
|
/**
|
|
10012
|
+
* Render what was observed about a peer's first turn.
|
|
10013
|
+
*
|
|
10014
|
+
* A delivery certifies one thing only: the digest reached a live agent. It says
|
|
10015
|
+
* nothing about whether a turn opened, and a turn that dies at step 1 — an
|
|
10016
|
+
* unresolvable prompt variable, no model route, no composition — leaves exactly
|
|
10017
|
+
* the same `delivered` hop and the same `live` roster row as a peer that is hard
|
|
10018
|
+
* at work. This line is the difference between those two, and it exists because
|
|
10019
|
+
* the difference cost a real caller a long, silent wait.
|
|
10020
|
+
* @param receipt - the observation, or `undefined` when the host could not make one.
|
|
10021
|
+
* @param windowMs - the observation window the receipt was bounded by.
|
|
10022
|
+
* @returns one line for the tool output, or `undefined` when there is nothing to say.
|
|
10023
|
+
*/
|
|
10024
|
+
function describeReceipt(receipt, windowMs) {
|
|
10025
|
+
if (receipt === void 0) return void 0;
|
|
10026
|
+
if (receipt.kind === "running") return `the peer's first turn is running (observed for ${windowMs} ms after the assignment landed)`;
|
|
10027
|
+
if (receipt.kind === "failed") return `⚠ the peer's first turn FAILED before doing any work: ${receipt.error ?? receipt.reason ?? "unknown error"}`;
|
|
10028
|
+
if (receipt.kind === "unstarted") return "⚠ the assignment is in the peer's inbox but no turn opened — nothing is working on it";
|
|
10029
|
+
if (receipt.reason === "completed") return `the peer's first turn completed within ${windowMs} ms — read the report for its answer`;
|
|
10030
|
+
return `the peer's first turn ended within ${windowMs} ms (${receipt.reason ?? "settled"}) — nothing is working on it now`;
|
|
10031
|
+
}
|
|
10032
|
+
/**
|
|
8581
10033
|
* Register the report and peer tools on the tools registry.
|
|
8582
10034
|
* @param ctx - the plugin context (`tools` injected).
|
|
8583
10035
|
* @param service - the report service backing the tools.
|
|
8584
10036
|
* @param peers - the peer service backing discovery and creation.
|
|
8585
10037
|
* @returns a disposer removing every registration.
|
|
8586
10038
|
*/
|
|
8587
|
-
function registerReportTools(ctx, service, peers) {
|
|
10039
|
+
function registerReportTools(ctx, service, peers, worktree) {
|
|
8588
10040
|
const disposers = [];
|
|
8589
10041
|
disposers.push(ctx.tools.register(defineTool({
|
|
8590
10042
|
name: "report_author",
|
|
@@ -8622,22 +10074,37 @@ function registerReportTools(ctx, service, peers) {
|
|
|
8622
10074
|
parent: {
|
|
8623
10075
|
type: "string",
|
|
8624
10076
|
description: "Report id this one answers, linking it into the thread."
|
|
8625
|
-
}
|
|
10077
|
+
},
|
|
10078
|
+
...worktreeParam(worktree, "Allocate this report its own git checkout, on a branch named after the report. Only the working directory and index are private; config, hooks and the object store are shared with every other checkout.")
|
|
8626
10079
|
},
|
|
8627
10080
|
output: stringOutput,
|
|
8628
10081
|
execute: async (args, exec) => {
|
|
8629
10082
|
const input = args;
|
|
8630
|
-
const
|
|
10083
|
+
const actor = actorOf(exec);
|
|
10084
|
+
const provisioned = input.worktree === true ? await requireWorktree(worktree, service, {
|
|
8631
10085
|
subject: input.subject,
|
|
8632
|
-
|
|
8633
|
-
|
|
8634
|
-
|
|
8635
|
-
|
|
8636
|
-
|
|
8637
|
-
|
|
8638
|
-
|
|
8639
|
-
|
|
8640
|
-
|
|
10086
|
+
actor
|
|
10087
|
+
}) : void 0;
|
|
10088
|
+
try {
|
|
10089
|
+
const result = await service.author({
|
|
10090
|
+
subject: input.subject,
|
|
10091
|
+
body: input.body,
|
|
10092
|
+
actor,
|
|
10093
|
+
...input.to === void 0 ? {} : { to: input.to },
|
|
10094
|
+
...input.cc === void 0 ? {} : { cc: input.cc },
|
|
10095
|
+
...input.task === void 0 ? {} : { task: input.task },
|
|
10096
|
+
...input.artifacts === void 0 ? {} : { artifacts: input.artifacts },
|
|
10097
|
+
...input.parent === void 0 ? {} : { parent: input.parent },
|
|
10098
|
+
...provisioned === void 0 ? {} : {
|
|
10099
|
+
reportId: provisioned.report,
|
|
10100
|
+
worktree: provisioned.prepared.binding
|
|
10101
|
+
}
|
|
10102
|
+
});
|
|
10103
|
+
return renderMutation(result.front, result.outcomes, provisioned?.prepared);
|
|
10104
|
+
} catch (error) {
|
|
10105
|
+
if (provisioned !== void 0) await service.rollbackProvision(provisioned);
|
|
10106
|
+
throw error;
|
|
10107
|
+
}
|
|
8641
10108
|
}
|
|
8642
10109
|
})));
|
|
8643
10110
|
disposers.push(ctx.tools.register(defineTool({
|
|
@@ -8769,7 +10236,7 @@ function registerReportTools(ctx, service, peers) {
|
|
|
8769
10236
|
})));
|
|
8770
10237
|
disposers.push(ctx.tools.register(defineTool({
|
|
8771
10238
|
name: "report_list",
|
|
8772
|
-
description: "List report digests from the ledger — no bodies, so this is the cheap way to survey a long-running collaboration. Filter by `session` to see every report that touches one agent (authored, addressed, copied, or contributed to), by `status`, or by `task` to pull up one collaboration across every session it touched. The task label is matched case-insensitively and partially, so \"payments\" finds \"payments-rework\". Use it to find the report id you need before reading it, or to check what a teammate is still waiting on.",
|
|
10239
|
+
description: "List report digests from the ledger — no bodies, so this is the cheap way to survey a long-running collaboration. Filter by `session` to see every report that touches one agent (authored, addressed, copied, or contributed to), by `status`, or by `task` to pull up one collaboration across every session it touched. The task label is matched case-insensitively and partially, so \"payments\" finds \"payments-rework\". `worktree` matches a bound working tree by branch or path, which is the way to find out who is working in which checkout. Use it to find the report id you need before reading it, or to check what a teammate is still waiting on.",
|
|
8773
10240
|
parameters: {
|
|
8774
10241
|
session: {
|
|
8775
10242
|
type: "string",
|
|
@@ -8788,6 +10255,10 @@ function registerReportTools(ctx, service, peers) {
|
|
|
8788
10255
|
type: "string",
|
|
8789
10256
|
description: "Only reports carrying this collaboration label (partial match, case-insensitive)."
|
|
8790
10257
|
},
|
|
10258
|
+
worktree: {
|
|
10259
|
+
type: "string",
|
|
10260
|
+
description: "Only reports bound to a working tree whose branch or path contains this text (partial match, case-insensitive)."
|
|
10261
|
+
},
|
|
8791
10262
|
limit: {
|
|
8792
10263
|
type: "integer",
|
|
8793
10264
|
description: "Maximum rows (default 50, capped at 200)."
|
|
@@ -8800,9 +10271,13 @@ function registerReportTools(ctx, service, peers) {
|
|
|
8800
10271
|
...input.session === void 0 ? {} : { session: input.session },
|
|
8801
10272
|
...input.status === void 0 ? {} : { status: input.status },
|
|
8802
10273
|
...input.task === void 0 ? {} : { task: input.task },
|
|
10274
|
+
...input.worktree === void 0 ? {} : { worktree: input.worktree },
|
|
8803
10275
|
...input.limit === void 0 ? {} : { limit: input.limit }
|
|
8804
10276
|
});
|
|
8805
|
-
if (rows.length === 0)
|
|
10277
|
+
if (rows.length === 0) {
|
|
10278
|
+
if (input.worktree !== void 0 && input.worktree.trim() !== "") return `no reports are bound to a working tree matching ${JSON.stringify(input.worktree)}`;
|
|
10279
|
+
return input.task === void 0 ? "no reports match" : `no reports carry a task matching ${JSON.stringify(input.task)}`;
|
|
10280
|
+
}
|
|
8806
10281
|
const lines = [`${rows.length} report(s):`, ...rows.map(renderRow)];
|
|
8807
10282
|
const taskFilter = input.task?.trim();
|
|
8808
10283
|
if (taskFilter !== void 0 && taskFilter !== "") {
|
|
@@ -8850,13 +10325,74 @@ function registerReportTools(ctx, service, peers) {
|
|
|
8850
10325
|
note: {
|
|
8851
10326
|
type: "string",
|
|
8852
10327
|
description: "Why it is finished, or what concluded it. Recorded on the hop."
|
|
10328
|
+
},
|
|
10329
|
+
...worktree.enabled ? { worktree: {
|
|
10330
|
+
type: "string",
|
|
10331
|
+
enum: ["keep", "remove-if-clean"],
|
|
10332
|
+
description: "What to do with a bound git checkout. keep (the default) leaves it exactly as it is; remove-if-clean deletes it only if it has no uncommitted work, and reports git's refusal otherwise."
|
|
10333
|
+
} } : {}
|
|
10334
|
+
},
|
|
10335
|
+
output: stringOutput,
|
|
10336
|
+
execute: async (args, exec) => {
|
|
10337
|
+
const input = args;
|
|
10338
|
+
const result = await service.close(input.report, actorOf(exec), input.note, { worktree: input.worktree ?? worktree.onClose });
|
|
10339
|
+
return [
|
|
10340
|
+
renderMutation(result.front, result.outcomes),
|
|
10341
|
+
renderWorktreeOutcome(result.worktree, worktree.enabled),
|
|
10342
|
+
"closed — later activity on this report reopens it automatically"
|
|
10343
|
+
].filter((line) => line !== void 0).join("\n");
|
|
10344
|
+
}
|
|
10345
|
+
})));
|
|
10346
|
+
disposers.push(ctx.tools.register(defineTool({
|
|
10347
|
+
name: "report_release",
|
|
10348
|
+
description: "Hand back a report's claim on its git checkout without concluding the report. Use it when the work in that checkout is done but the conversation is not: the checkout stays on disk exactly as you left it, and the claim — which is what stops another agent from taking the same branch — ends. A claim is released once; closing a report releases it too, so this is only needed while the report stays open.",
|
|
10349
|
+
parameters: {
|
|
10350
|
+
report: {
|
|
10351
|
+
type: "string",
|
|
10352
|
+
required: true,
|
|
10353
|
+
description: "Report id whose checkout claim should end."
|
|
10354
|
+
},
|
|
10355
|
+
note: {
|
|
10356
|
+
type: "string",
|
|
10357
|
+
description: "Why the claim is being handed back. Recorded on the hop."
|
|
8853
10358
|
}
|
|
8854
10359
|
},
|
|
8855
10360
|
output: stringOutput,
|
|
8856
10361
|
execute: async (args, exec) => {
|
|
8857
10362
|
const input = args;
|
|
8858
|
-
const result = await service.
|
|
8859
|
-
|
|
10363
|
+
const result = await service.release(input.report, actorOf(exec), input.note);
|
|
10364
|
+
const binding = result.front.worktree;
|
|
10365
|
+
return [renderMutation(result.front, result.outcomes), binding === void 0 ? "claim released" : `claim released; the checkout at ${binding.path} is untouched and stays traceable to ${result.front.report}`].join("\n");
|
|
10366
|
+
}
|
|
10367
|
+
})));
|
|
10368
|
+
if (worktree.enabled) disposers.push(ctx.tools.register(defineTool({
|
|
10369
|
+
name: "report_lease",
|
|
10370
|
+
description: "Claim an EXISTING git checkout for a report — the counterpart to report_release. Use it to pick up a checkout that already exists: one a human made, one a previous report left behind, or one a report released earlier. A branch is held by exactly one live report at a time, so a competing claim is refused and the refusal names the holder and its authors. The branch is read from the checkout itself, so pass one only to require it.",
|
|
10371
|
+
parameters: {
|
|
10372
|
+
report: {
|
|
10373
|
+
type: "string",
|
|
10374
|
+
required: true,
|
|
10375
|
+
description: "Report id that will hold the checkout. Only its owners may claim on its behalf."
|
|
10376
|
+
},
|
|
10377
|
+
path: {
|
|
10378
|
+
type: "string",
|
|
10379
|
+
required: true,
|
|
10380
|
+
description: "Absolute path of the checkout to claim."
|
|
10381
|
+
},
|
|
10382
|
+
branch: {
|
|
10383
|
+
type: "string",
|
|
10384
|
+
description: "The branch the checkout must be on. Omit to take whichever branch it is really on."
|
|
10385
|
+
}
|
|
10386
|
+
},
|
|
10387
|
+
output: stringOutput,
|
|
10388
|
+
execute: async (args, exec) => {
|
|
10389
|
+
const input = args;
|
|
10390
|
+
const result = await service.lease(input.report, actorOf(exec), {
|
|
10391
|
+
path: input.path,
|
|
10392
|
+
...input.branch === void 0 ? {} : { branch: input.branch }
|
|
10393
|
+
});
|
|
10394
|
+
const binding = result.front.worktree;
|
|
10395
|
+
return [renderMutation(result.front, result.outcomes), binding === void 0 ? "claimed" : `claimed ${binding.branch ?? "(detached HEAD)"} at ${binding.path}${binding.head === void 0 ? "" : ` @ ${binding.head.slice(0, 12)}`}`].join("\n");
|
|
8860
10396
|
}
|
|
8861
10397
|
})));
|
|
8862
10398
|
disposers.push(ctx.tools.register(defineTool({
|
|
@@ -8950,7 +10486,7 @@ function registerReportTools(ctx, service, peers) {
|
|
|
8950
10486
|
})));
|
|
8951
10487
|
disposers.push(ctx.tools.register(defineTool({
|
|
8952
10488
|
name: "peer_start",
|
|
8953
|
-
description: "Open a new independent agent session — a peer, not a subordinate. A peer is a root session with its own lifecycle that outlives this turn, so it is the right move when the work is long-running, when you want a partner that keeps its own context across days, or when a task should not be nested under you. This creates the session, records that you started it (which is what later lets you address it directly), and hands it the task as a REPORT — so the assignment is durable and traceable, and the peer can answer by contributing to that same report. Do not use
|
|
10489
|
+
description: "Open a new independent agent session — a peer, not a subordinate. A peer is a root session with its own lifecycle that outlives this turn, so it is the right move when the work is long-running, when you want a partner that keeps its own context across days, or when a task should not be nested under you. This creates the session, records that you started it (which is what later lets you address it directly), and hands it the task as a REPORT — so the assignment is durable and traceable, and the peer can answer by contributing to that same report. The output also reports what became of the peer's first turn (running, already ended, or failed): a delivered digest proves only that the peer is resident, never that work started. Do not use this as a cheaper subagent: for a bounded subtask, delegation is the right tool.",
|
|
8954
10490
|
parameters: {
|
|
8955
10491
|
task: {
|
|
8956
10492
|
type: "string",
|
|
@@ -8960,36 +10496,62 @@ function registerReportTools(ctx, service, peers) {
|
|
|
8960
10496
|
name: {
|
|
8961
10497
|
type: "string",
|
|
8962
10498
|
description: "Optional short display name for the peer (60 characters or fewer)."
|
|
8963
|
-
}
|
|
10499
|
+
},
|
|
10500
|
+
...worktreeParam(worktree, "Give the peer its own git checkout, on a branch named after the assignment report, and start it there instead of in your directory. Only the working directory and index are private; config, hooks and the object store are shared with every other checkout.")
|
|
8964
10501
|
},
|
|
8965
10502
|
output: stringOutput,
|
|
8966
10503
|
execute: async (args, exec) => {
|
|
8967
10504
|
const input = args;
|
|
8968
10505
|
const actor = actorOf(exec);
|
|
8969
|
-
const
|
|
8970
|
-
|
|
8971
|
-
|
|
8972
|
-
|
|
8973
|
-
|
|
8974
|
-
|
|
8975
|
-
|
|
8976
|
-
|
|
8977
|
-
|
|
8978
|
-
|
|
8979
|
-
|
|
8980
|
-
|
|
8981
|
-
|
|
8982
|
-
|
|
8983
|
-
|
|
8984
|
-
|
|
8985
|
-
|
|
8986
|
-
|
|
10506
|
+
const subject = subjectFrom(input.task, input.name);
|
|
10507
|
+
const provisioned = input.worktree === true ? await requireWorktree(worktree, service, {
|
|
10508
|
+
subject,
|
|
10509
|
+
actor
|
|
10510
|
+
}) : void 0;
|
|
10511
|
+
let started;
|
|
10512
|
+
let result;
|
|
10513
|
+
try {
|
|
10514
|
+
started = await peers.start(actor, {
|
|
10515
|
+
task: input.task,
|
|
10516
|
+
...input.name === void 0 ? {} : { name: input.name },
|
|
10517
|
+
...provisioned === void 0 ? {} : { cwd: provisioned.prepared.binding.path }
|
|
10518
|
+
});
|
|
10519
|
+
result = await service.author({
|
|
10520
|
+
subject,
|
|
10521
|
+
body: [
|
|
10522
|
+
`本会话由 ${actor} 开启,你是它的**同伴**而非下属:你有自己的生命周期,会比这一轮活得更久。`,
|
|
10523
|
+
...provisioned === void 0 ? [] : [
|
|
10524
|
+
"",
|
|
10525
|
+
`你的工作目录是一个**独立的 git 检出**:${provisioned.prepared.binding.path}(分支 ${provisioned.prepared.binding.branch})。`,
|
|
10526
|
+
"独立的是工作目录与索引;config、hooks、对象库与其它检出共享,所以装 hook 或改配置会影响所有检出。"
|
|
10527
|
+
],
|
|
10528
|
+
"",
|
|
10529
|
+
`要回复或继续这个话题,用 report_contribute 追加到本汇报——它会成为我们之间的线程;需要直接交代新事情时用 report_author 并 to=[${actor}]。`,
|
|
10530
|
+
"",
|
|
10531
|
+
"## 任务",
|
|
10532
|
+
input.task
|
|
10533
|
+
].join("\n"),
|
|
10534
|
+
actor,
|
|
10535
|
+
to: [started.sessionId],
|
|
10536
|
+
task: started.name,
|
|
10537
|
+
...provisioned === void 0 ? {} : {
|
|
10538
|
+
reportId: provisioned.report,
|
|
10539
|
+
worktree: provisioned.prepared.binding
|
|
10540
|
+
}
|
|
10541
|
+
});
|
|
10542
|
+
} catch (error) {
|
|
10543
|
+
if (provisioned !== void 0) await service.rollbackProvision(provisioned);
|
|
10544
|
+
throw error;
|
|
10545
|
+
}
|
|
10546
|
+
const receipt = describeReceipt(await peers.receipt(started.sessionId, DEFAULT_RECEIPT_MS), DEFAULT_RECEIPT_MS);
|
|
8987
10547
|
return [
|
|
8988
10548
|
`opened peer ${started.sessionId}${started.name === void 0 ? "" : ` (name=${started.name})`}`,
|
|
8989
10549
|
started.cwd === void 0 ? "" : `cwd=${started.cwd}`,
|
|
8990
10550
|
`peers you have started: ${started.started}`,
|
|
8991
10551
|
`assignment report: ${result.front.report}`,
|
|
10552
|
+
...provisioned === void 0 ? [] : renderProvision(provisioned.prepared),
|
|
8992
10553
|
renderMutation(result.front, result.outcomes),
|
|
10554
|
+
receipt === void 0 ? "" : receipt,
|
|
8993
10555
|
`Address this peer later with report_send / report_cc to=${started.sessionId}, or see everyone with peer_list.`
|
|
8994
10556
|
].filter((line) => line !== "").join("\n");
|
|
8995
10557
|
}
|
|
@@ -9048,6 +10610,107 @@ function toSessionRecord(entry) {
|
|
|
9048
10610
|
};
|
|
9049
10611
|
}
|
|
9050
10612
|
/**
|
|
10613
|
+
* Resolve the model route a peer is created with.
|
|
10614
|
+
*
|
|
10615
|
+
* This is the difference between a peer that works and a peer that dies silently:
|
|
10616
|
+
* an agent with no route cannot resolve `{{model}}` while its prompt is
|
|
10617
|
+
* assembled, so its first turn ends at step 1 with an `error` reason — before a
|
|
10618
|
+
* single tool call, which is why the ledger's `delivered` and the roster's `live`
|
|
10619
|
+
* both stay perfectly green while nothing whatsoever happens.
|
|
10620
|
+
*
|
|
10621
|
+
* The starter's live route is the right source, in the same order the harness's
|
|
10622
|
+
* own delegation uses: the session's request header records the route actually in
|
|
10623
|
+
* force after request-time selection, creation options are the fallback before
|
|
10624
|
+
* the first request, and only a starter carrying neither falls through to the
|
|
10625
|
+
* deployment default. When even that is absent the peer is refused rather than
|
|
10626
|
+
* created doomed.
|
|
10627
|
+
* @param caller - the live starter agent, when it is resident.
|
|
10628
|
+
* @param fallback - the deployment default model service, when it is mounted.
|
|
10629
|
+
* @returns the route fields to pass as creation options.
|
|
10630
|
+
* @throws when no source can name a provider and model.
|
|
10631
|
+
*/
|
|
10632
|
+
function peerRoute(caller, fallback) {
|
|
10633
|
+
const fromHeader = caller?.session.requestHeader()?.config;
|
|
10634
|
+
const created = caller?.options;
|
|
10635
|
+
const selection = fallback?.currentSelection();
|
|
10636
|
+
const provider = fromHeader?.provider ?? created?.provider ?? selection?.provider;
|
|
10637
|
+
const model = fromHeader?.model ?? created?.model ?? selection?.model;
|
|
10638
|
+
if (provider === void 0 || model === void 0) throw new Error("report-ledger: cannot start a peer — no model route could be resolved for it. The starting session carries no provider/model and this deployment exposes no default model selection, so the peer would be created unable to assemble a prompt (its first turn would fail).");
|
|
10639
|
+
const reasoningEffort = fromHeader?.reasoningEffort ?? created?.reasoningEffort ?? selection?.reasoningEffort;
|
|
10640
|
+
return {
|
|
10641
|
+
provider,
|
|
10642
|
+
model,
|
|
10643
|
+
...reasoningEffort === void 0 ? {} : { reasoningEffort },
|
|
10644
|
+
...created?.maxTokens === void 0 ? {} : { maxTokens: created.maxTokens }
|
|
10645
|
+
};
|
|
10646
|
+
}
|
|
10647
|
+
/**
|
|
10648
|
+
* Whether a session's log already records an opened turn.
|
|
10649
|
+
* @param session - the live session.
|
|
10650
|
+
* @returns true when at least one `turn/start` was appended.
|
|
10651
|
+
*/
|
|
10652
|
+
function hasOpenedTurn(session) {
|
|
10653
|
+
return session.snapshotEvents().some((event) => event !== null && typeof event === "object" && event.type === "turn/start");
|
|
10654
|
+
}
|
|
10655
|
+
/**
|
|
10656
|
+
* Read the last turn's stop reason out of a session log.
|
|
10657
|
+
* @param session - the live session.
|
|
10658
|
+
* @returns the reason kind and, for an error, its message.
|
|
10659
|
+
*/
|
|
10660
|
+
function lastTurnReason(session) {
|
|
10661
|
+
const events = session.snapshotEvents();
|
|
10662
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
10663
|
+
const event = events[index];
|
|
10664
|
+
if (event === null || typeof event !== "object") continue;
|
|
10665
|
+
const record = event;
|
|
10666
|
+
if (record.type !== "turn/end") continue;
|
|
10667
|
+
const data = record.data;
|
|
10668
|
+
if (data === null || typeof data !== "object") return {};
|
|
10669
|
+
const reason = data.reason;
|
|
10670
|
+
if (reason === null || typeof reason !== "object") return {};
|
|
10671
|
+
const fields = reason;
|
|
10672
|
+
const error = fields.error;
|
|
10673
|
+
const message = error === null || typeof error !== "object" ? void 0 : str(error, "message");
|
|
10674
|
+
return {
|
|
10675
|
+
...typeof fields.kind === "string" ? { kind: fields.kind } : {},
|
|
10676
|
+
...message === void 0 ? {} : { message }
|
|
10677
|
+
};
|
|
10678
|
+
}
|
|
10679
|
+
return {};
|
|
10680
|
+
}
|
|
10681
|
+
/**
|
|
10682
|
+
* Wait for one agent to reach quiescence, bounded.
|
|
10683
|
+
*
|
|
10684
|
+
* The bound is what makes the answer usable: a peer that opened a turn and is
|
|
10685
|
+
* still working after it elapses is reported as running, while a turn that dies
|
|
10686
|
+
* (the silent failure mode this exists to expose) settles in milliseconds. The
|
|
10687
|
+
* timer is an observation window only — it is unref'd, so it can never be a
|
|
10688
|
+
* reason for the host to stay alive.
|
|
10689
|
+
* @param agent - the agent to observe.
|
|
10690
|
+
* @param timeoutMs - the window length.
|
|
10691
|
+
* @returns true when quiescence came first, false when the window closed first.
|
|
10692
|
+
*/
|
|
10693
|
+
function settledWithin(agent, timeoutMs) {
|
|
10694
|
+
return new Promise((resolve) => {
|
|
10695
|
+
let finished = false;
|
|
10696
|
+
const finish = (settled) => {
|
|
10697
|
+
if (finished) return;
|
|
10698
|
+
finished = true;
|
|
10699
|
+
clearTimeout(timer);
|
|
10700
|
+
resolve(settled);
|
|
10701
|
+
};
|
|
10702
|
+
const timer = setTimeout(() => {
|
|
10703
|
+
finish(false);
|
|
10704
|
+
}, Math.max(timeoutMs, 0));
|
|
10705
|
+
timer.unref?.();
|
|
10706
|
+
agent.whenIdle().then(() => {
|
|
10707
|
+
finish(true);
|
|
10708
|
+
}, () => {
|
|
10709
|
+
finish(true);
|
|
10710
|
+
});
|
|
10711
|
+
});
|
|
10712
|
+
}
|
|
10713
|
+
/**
|
|
9051
10714
|
* Build every host capability this plugin consumes.
|
|
9052
10715
|
*
|
|
9053
10716
|
* Agent lookup goes through `ctx.agents`, and delivery uses the `Agent`'s own
|
|
@@ -9093,6 +10756,10 @@ function buildHost(ctx) {
|
|
|
9093
10756
|
async describe(sessionId) {
|
|
9094
10757
|
return (await readTitles([sessionId])).get(sessionId);
|
|
9095
10758
|
},
|
|
10759
|
+
selfCwd(sessionId) {
|
|
10760
|
+
const cwd = (ctx.agents.get(sessionId)?.session?.header)?.cwd;
|
|
10761
|
+
return typeof cwd === "string" && cwd !== "" ? cwd : void 0;
|
|
10762
|
+
},
|
|
9096
10763
|
readTitles,
|
|
9097
10764
|
async listSessionRecords() {
|
|
9098
10765
|
const query = sessionQuery();
|
|
@@ -9128,11 +10795,32 @@ async function readDetail(id) {
|
|
|
9128
10795
|
* gate, {@link Config.maxPeersPerAgent} bounds the blast radius, the peer
|
|
9129
10796
|
* inherits the caller's own working directory (so it cannot be pointed at an
|
|
9130
10797
|
* unrelated tree), and every creation is attributed in the roster log.
|
|
10798
|
+
*
|
|
10799
|
+
* It is also the only place that must supply what a fresh agent cannot supply
|
|
10800
|
+
* itself. A root session is born with no composition and no model route:
|
|
10801
|
+
*
|
|
10802
|
+
* - **Composition.** The peer joins the starter's standing preset
|
|
10803
|
+
* (`composeFrom`), which is a bind to the SAME generation the starter runs —
|
|
10804
|
+
* not a re-resolution of the preset by id, so a preset edited or deleted since
|
|
10805
|
+
* cannot hand the peer a different world than its starter's. A deployment with
|
|
10806
|
+
* no roster yields no join and no error, exactly like a delegated child.
|
|
10807
|
+
* - **Route.** The peer inherits the starter's provider/model/reasoning effort.
|
|
10808
|
+
* Without it, prompt assembly cannot resolve `{{model}}` and every turn of the
|
|
10809
|
+
* peer dies at step 1 before its first tool call.
|
|
10810
|
+
* - **Preset identity.** The joined preset is recorded in the durable session
|
|
10811
|
+
* header, so a cold read of the peer's log rebuilds the composition it
|
|
10812
|
+
* actually ran under instead of the deployment default.
|
|
10813
|
+
*
|
|
10814
|
+
* `confirmPeer` closes the other half of the same problem: a peer that turns out
|
|
10815
|
+
* to be unable to run must be visible to the agent that just started it, rather
|
|
10816
|
+
* than reported as a successful hand-off.
|
|
9131
10817
|
* @param ctx - the plugin context.
|
|
9132
10818
|
* @param host - the session-reading bundle.
|
|
9133
10819
|
* @returns the peer host capability.
|
|
9134
10820
|
*/
|
|
9135
10821
|
function buildPeerHost(ctx, host) {
|
|
10822
|
+
const presets = () => ctx.get("agentPresets");
|
|
10823
|
+
const defaultModel = () => ctx.get("agentDefaultModel");
|
|
9136
10824
|
return {
|
|
9137
10825
|
selfCwd(sessionId) {
|
|
9138
10826
|
const cwd = ((ctx.agents.get(sessionId)?.session)?.header)?.cwd;
|
|
@@ -9147,9 +10835,21 @@ function buildPeerHost(ctx, host) {
|
|
|
9147
10835
|
readTitles: (ids) => host.readTitles(ids),
|
|
9148
10836
|
listReports: () => listReports(),
|
|
9149
10837
|
async createPeer(input) {
|
|
10838
|
+
const caller = ctx.agents.get(input.startedBy);
|
|
10839
|
+
if (caller === void 0) throw new Error(`report-ledger: cannot start a peer from ${input.startedBy} — the starting session is not resident, so the peer has no composition and no model route to inherit`);
|
|
10840
|
+
const agentOptions = peerRoute(caller, defaultModel());
|
|
10841
|
+
const agentPreset = presets()?.composedPreset(caller.ctx);
|
|
10842
|
+
const meta = {
|
|
10843
|
+
...input.cwd === void 0 ? {} : { cwd: input.cwd },
|
|
10844
|
+
...agentPreset === void 0 ? {} : { agentPreset }
|
|
10845
|
+
};
|
|
9150
10846
|
const handle = await ctx.agents.create({
|
|
9151
10847
|
sessionId: input.sessionId,
|
|
9152
|
-
...
|
|
10848
|
+
...Object.keys(meta).length === 0 ? {} : { meta },
|
|
10849
|
+
agentOptions,
|
|
10850
|
+
setup: (agentCtx) => {
|
|
10851
|
+
presets()?.composeFrom(agentCtx, caller.ctx);
|
|
10852
|
+
}
|
|
9153
10853
|
});
|
|
9154
10854
|
return {
|
|
9155
10855
|
id: String(handle.agent.id),
|
|
@@ -9157,6 +10857,22 @@ function buildPeerHost(ctx, host) {
|
|
|
9157
10857
|
handle.agent.steer(message);
|
|
9158
10858
|
}
|
|
9159
10859
|
};
|
|
10860
|
+
},
|
|
10861
|
+
async confirmPeer(sessionId, timeoutMs) {
|
|
10862
|
+
const agent = ctx.agents.get(sessionId);
|
|
10863
|
+
if (agent === void 0) return void 0;
|
|
10864
|
+
if (!hasOpenedTurn(agent.session)) return { kind: "unstarted" };
|
|
10865
|
+
if (!await settledWithin(agent, timeoutMs)) return { kind: "running" };
|
|
10866
|
+
const reason = lastTurnReason(agent.session);
|
|
10867
|
+
if (reason.kind === "error") return {
|
|
10868
|
+
kind: "failed",
|
|
10869
|
+
reason: "error",
|
|
10870
|
+
...reason.message === void 0 ? {} : { error: reason.message }
|
|
10871
|
+
};
|
|
10872
|
+
return {
|
|
10873
|
+
kind: "settled",
|
|
10874
|
+
...reason.kind === void 0 ? {} : { reason: reason.kind }
|
|
10875
|
+
};
|
|
9160
10876
|
}
|
|
9161
10877
|
};
|
|
9162
10878
|
}
|
|
@@ -9169,10 +10885,11 @@ function apply(ctx, config) {
|
|
|
9169
10885
|
const host = buildHost(ctx);
|
|
9170
10886
|
const service = new ReportService(host);
|
|
9171
10887
|
const peers = new PeerService(buildPeerHost(ctx, host), config?.maxPeersPerAgent ?? 8);
|
|
9172
|
-
|
|
10888
|
+
const worktree = resolveWorktree(config?.worktree);
|
|
10889
|
+
ctx.effect(() => registerReportTools(ctx, service, peers, worktree), "report-ledger: model tools");
|
|
9173
10890
|
ctx.on("agent/created", (payload) => {
|
|
9174
10891
|
const sessionId = String(payload?.agent?.id ?? "");
|
|
9175
|
-
if (sessionId === "") return;
|
|
10892
|
+
if (sessionId === "") return void 0;
|
|
9176
10893
|
service.flushForAgent(sessionId).catch((error) => {
|
|
9177
10894
|
ctx.logger?.warn?.(`report-ledger: deferred delivery to ${sessionId} failed: ${String(error)}`);
|
|
9178
10895
|
});
|
|
@@ -9184,9 +10901,14 @@ function apply(ctx, config) {
|
|
|
9184
10901
|
timeline: async (root) => buildTimeline({
|
|
9185
10902
|
listSessionRecords: () => host.listSessionRecords(),
|
|
9186
10903
|
readTitles: (ids) => host.readTitles(ids),
|
|
9187
|
-
listReports: () => listReports()
|
|
10904
|
+
listReports: () => listReports(),
|
|
10905
|
+
readHops: (report) => readHops(report)
|
|
9188
10906
|
}, root),
|
|
9189
|
-
report: readDetail
|
|
10907
|
+
report: readDetail,
|
|
10908
|
+
worktrees: async () => buildWorktreeReport({
|
|
10909
|
+
listReports: () => listReports(),
|
|
10910
|
+
readHops: (report) => readHops(report)
|
|
10911
|
+
}, worktree.settings, worktree.enabled)
|
|
9190
10912
|
};
|
|
9191
10913
|
scoped.effect(() => {
|
|
9192
10914
|
const disposeReport = webServer.register({
|
|
@@ -9199,7 +10921,13 @@ function apply(ctx, config) {
|
|
|
9199
10921
|
path: TIMELINE_PATH,
|
|
9200
10922
|
handler: createTimelineHandler(deps)
|
|
9201
10923
|
});
|
|
10924
|
+
const disposeWorktrees = webServer.register({
|
|
10925
|
+
kind: "exact",
|
|
10926
|
+
path: WORKTREES_PATH,
|
|
10927
|
+
handler: createWorktreesHandler(deps)
|
|
10928
|
+
});
|
|
9202
10929
|
return () => {
|
|
10930
|
+
disposeWorktrees();
|
|
9203
10931
|
disposeTimeline();
|
|
9204
10932
|
disposeReport();
|
|
9205
10933
|
};
|