@edspencer/paddock 0.70.0 → 0.70.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/server/dist/background-live.d.ts +16 -8
- package/packages/server/dist/background-live.js +41 -1
- package/packages/server/dist/git.d.ts +63 -8
- package/packages/server/dist/git.js +100 -20
- package/packages/server/dist/herdctl-jobs.d.ts +30 -4
- package/packages/server/dist/herdctl-jobs.js +35 -8
- package/packages/server/dist/project-files.d.ts +18 -7
- package/packages/server/dist/project-files.js +38 -34
- package/packages/server/dist/projects.d.ts +1 -1
- package/packages/server/dist/projects.js +14 -7
- package/packages/server/dist/routes/git.d.ts +2 -2
- package/packages/server/dist/routes/git.js +133 -1
- package/packages/server/dist/ws-protocol.d.ts +11 -1
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/{Chip-C8a-t439.js → Chip-P4n88XYj.js} +1 -1
- package/packages/web/dist/assets/DiscoverPage-B7ap1I1Y.js +2 -0
- package/packages/web/dist/assets/{DiscoverView-DS_Z9xKH.js → DiscoverView-DxC3mmGp.js} +1 -1
- package/packages/web/dist/assets/{InstanceConfigPage-B1ei1FDo.js → InstanceConfigPage-DO87_86Z.js} +1 -1
- package/packages/web/dist/assets/{MarkdownRenderer-DCj_ZwVG.js → MarkdownRenderer-DatKu1Pl.js} +1 -1
- package/packages/web/dist/assets/{ProjectMenu-B7dGZSFB.js → ProjectMenu-DJB7uCGt.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-bstvMvM9.js → ProjectRedirect-D5Uxki7M.js} +1 -1
- package/packages/web/dist/assets/ProjectView-D8TqrBW4.js +13 -0
- package/packages/web/dist/assets/{ProjectsGrid-DKvySDUQ.js → ProjectsGrid-CqQTeqdE.js} +1 -1
- package/packages/web/dist/assets/{RootHome-CD_37qb-.js → RootHome-B0si7ijq.js} +1 -1
- package/packages/web/dist/assets/{Surfaces-1dBayrsj.js → Surfaces-t3XF_my5.js} +1 -1
- package/packages/web/dist/assets/{arc-BJPb3ViJ.js → arc-Bvnrokeb.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-PkLOC5wV.js → architectureDiagram-3BPJPVTR-Dc5OXCmK.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-IPduhNH-.js → blockDiagram-GPEHLZMM-CFZeUqSH.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-dKTYV1Vq.js → c4Diagram-AAUBKEIU-BPI5UmpX.js} +1 -1
- package/packages/web/dist/assets/channel-BGIf4GoQ.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-B5C6geyg.js → chunk-2J33WTMH-CVpthvPT.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-1BRhFRhF.js → chunk-4BX2VUAB-cTaBnmzz.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-DNv-sTiI.js → chunk-55IACEB6-dkgF9sAS.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-CWezuVkx.js → chunk-727SXJPM-UiFZch2W.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-Bt4yEQnu.js → chunk-AQP2D5EJ-IKsoQNZy.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-lb_8q4JY.js → chunk-FMBD7UC4-Tzt_HckZ.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-DOwgGlQ_.js → chunk-ND2GUHAM-dbNwUFdT.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-BanKpY4q.js → chunk-QZHKN3VN-eUsG1OkP.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-6kluxBAR.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-6kluxBAR.js +2 -0
- package/packages/web/dist/assets/{core-CAsyoNAa.js → core-Duun4AtS.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-sVaVkwMI.js → cose-bilkent-S5V4N54A-KF_gNdsd.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-C9pKHSMo.js → dagre-BM42HDAG-CXFDGeP7.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-CHdrmCfX.js → diagram-2AECGRRQ-BiefFjo3.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-4UTa2Dfa.js → diagram-5GNKFQAL-B1IpANC8.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-NidpfVwW.js → diagram-KO2AKTUF-ChdOA3EK.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-ictQj03T.js → diagram-LMA3HP47-BTbLDsXf.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-B7dTLMHC.js → diagram-OG6HWLK6-Bqiqt27L.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-C2QYYzJ8.js → erDiagram-TEJ5UH35-KA9WLkV0.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-D2rcjSZ9.js → flowDiagram-I6XJVG4X-CZKvoeTz.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-BQcXTYzg.js → ganttDiagram-6RSMTGT7-o1QDzlgt.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-DwB2Vqxa.js → gitGraphDiagram-PVQCEYII-D5GdOppF.js} +1 -1
- package/packages/web/dist/assets/index-4ugpmAz4.css +1 -0
- package/packages/web/dist/assets/{index-BeuEs7dL.js → index-B-J2K1w2.js} +13 -13
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-BoK4Mawg.js → infoDiagram-5YYISTIA-21Z9hMO-.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DPP9svhg.js → ishikawaDiagram-YF4QCWOH-TmbPK40i.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-TrggodYk.js → journeyDiagram-JHISSGLW-DYAWKu-K.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-Bh69hRSR.js → kanban-definition-UN3LZRKU-ZW_ZQ0DQ.js} +1 -1
- package/packages/web/dist/assets/{linear-C1iYMpBz.js → linear-BDQKnwHn.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-BZDABcuq.js → mermaid.core-BHXnmT7N.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-DTJON3dS.js → mindmap-definition-RKZ34NQL-CHVPYLQs.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BxtFV74I.js → pieDiagram-4H26LBE5-paqmu057.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-D2Ar8kmO.js → quadrantDiagram-W4KKPZXB-diM_4CGS.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-DiLSfeSQ.js → requirementDiagram-4Y6WPE33-DCyNbyIz.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-D_CRQc7j.js → sankeyDiagram-5OEKKPKP-DxpEvGOy.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DjtfcRNV.js → sequenceDiagram-3UESZ5HK-BH4-7BdR.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-CzEtmYLV.js → stateDiagram-AJRCARHV-CVPrwckU.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CMmy9TKm.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-DXKspEG3.js → timeline-definition-PNZ67QCA-B6_yfyHl.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-B_tXRMHH.js → vennDiagram-CIIHVFJN-Ic8OogjJ.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-DNaNoaJm.js → wardley-L42UT6IY-CuhgMTiv.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-BuVDpC8s.js → wardleyDiagram-YWT4CUSO-Djeda9cE.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-Cqtew2PA.js → xychartDiagram-2RQKCTM6-D3hl8Mdh.js} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/DiscoverPage-C1khyCvD.js +0 -2
- package/packages/web/dist/assets/ProjectView-iuiUIoIl.js +0 -13
- package/packages/web/dist/assets/channel-BhrgMiqg.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-Ho1IMdjg.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-Ho1IMdjg.js +0 -2
- package/packages/web/dist/assets/index-BNzTD4hA.css +0 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-pM8fIOir.js +0 -2
package/package.json
CHANGED
|
@@ -35,29 +35,37 @@
|
|
|
35
35
|
* a restart is correct, because Paddock stops the fleet with
|
|
36
36
|
* `waitForJobs: false` and the tasks are genuinely dead.
|
|
37
37
|
*/
|
|
38
|
+
/**
|
|
39
|
+
* The role for an SDK task type. Identity for anything unrecognised, which also
|
|
40
|
+
* means a future SDK that starts sending friendly names needs no change here.
|
|
41
|
+
*/
|
|
42
|
+
export declare function roleForTaskType(type: string): string;
|
|
38
43
|
/**
|
|
39
44
|
* One live background task, as broadcast to clients.
|
|
40
45
|
*
|
|
41
|
-
* `type` is the SDK
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
46
|
+
* `type` is the RAW SDK discriminant (`local_bash`, `local_agent`, …), kept
|
|
47
|
+
* verbatim so an unexpected kind is diagnosable from the wire. `role` is the
|
|
48
|
+
* rendering role derived from it by {@link roleForTaskType}. Both are plain
|
|
49
|
+
* `string`s rather than unions on purpose: a new SDK task type should render as
|
|
50
|
+
* a generic row, not be dropped.
|
|
45
51
|
*/
|
|
46
52
|
export interface LiveBackgroundTask {
|
|
47
53
|
id: string;
|
|
48
54
|
type: string;
|
|
55
|
+
/** Rendering role derived from {@link type} — see {@link roleForTaskType}. */
|
|
56
|
+
role: string;
|
|
49
57
|
description: string;
|
|
50
58
|
/** Epoch-ms this task was first observed. Stamped here; the SDK sends no start time. */
|
|
51
59
|
startedAt: number;
|
|
52
60
|
/** Links the task to its launching tool card, when an edge has told us. */
|
|
53
61
|
toolUseId?: string;
|
|
54
|
-
/** `subagent` only. */
|
|
62
|
+
/** `subagent` role only. */
|
|
55
63
|
agentType?: string;
|
|
56
|
-
/** `shell` only. */
|
|
64
|
+
/** `shell` role only. */
|
|
57
65
|
command?: string;
|
|
58
|
-
/** `workflow` only. */
|
|
66
|
+
/** `workflow` role only. */
|
|
59
67
|
workflowName?: string;
|
|
60
|
-
/** `monitor` / MCP-task only. */
|
|
68
|
+
/** `monitor` / MCP-task role only. */
|
|
61
69
|
server?: string;
|
|
62
70
|
tool?: string;
|
|
63
71
|
/** Latest tool the task ran, from `task_progress`. */
|
|
@@ -37,6 +37,44 @@
|
|
|
37
37
|
*/
|
|
38
38
|
/** Terminal task statuses — a task in one of these is no longer live. */
|
|
39
39
|
const TERMINAL_STATUSES = new Set(["completed", "failed", "stopped", "killed"]);
|
|
40
|
+
/**
|
|
41
|
+
* SDK task-type discriminant → the role clients render (issue #846).
|
|
42
|
+
*
|
|
43
|
+
* This module used to document `type` as "the SDK's friendly label (`shell` |
|
|
44
|
+
* `subagent` | `monitor` | `workflow`)" and pass `task_type` straight through.
|
|
45
|
+
* **The CLI does no such mapping.** Its level payload is built by
|
|
46
|
+
*
|
|
47
|
+
* ```js
|
|
48
|
+
* .map((t) => ({ task_id: t.id, task_type: t.type, description: t.description }))
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
51
|
+
* — the INTERNAL discriminant verbatim. None of the four friendly names is in
|
|
52
|
+
* the CLI's own id-prefix table, so before this map every row fell through the
|
|
53
|
+
* client's unknown-kind fallback to a raw `local_bash` and a neutral clock.
|
|
54
|
+
* There was no case in which the friendly-name branches fired.
|
|
55
|
+
*
|
|
56
|
+
* The translation lives HERE, at the registry boundary, so one mapping serves
|
|
57
|
+
* every consumer rather than each of them re-deriving it. An unrecognised kind
|
|
58
|
+
* still falls through to its raw string — that fallback was always right, it
|
|
59
|
+
* just should not have been the only path.
|
|
60
|
+
*/
|
|
61
|
+
const TASK_ROLES = {
|
|
62
|
+
local_bash: "shell",
|
|
63
|
+
local_agent: "subagent",
|
|
64
|
+
remote_agent: "subagent",
|
|
65
|
+
in_process_teammate: "subagent",
|
|
66
|
+
local_workflow: "workflow",
|
|
67
|
+
monitor_mcp: "monitor",
|
|
68
|
+
monitor_ws: "monitor",
|
|
69
|
+
mcp_task: "task",
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* The role for an SDK task type. Identity for anything unrecognised, which also
|
|
73
|
+
* means a future SDK that starts sending friendly names needs no change here.
|
|
74
|
+
*/
|
|
75
|
+
export function roleForTaskType(type) {
|
|
76
|
+
return TASK_ROLES[type] ?? type;
|
|
77
|
+
}
|
|
40
78
|
const str = (v) => (typeof v === "string" && v ? v : undefined);
|
|
41
79
|
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
|
|
42
80
|
/**
|
|
@@ -159,10 +197,12 @@ export class BackgroundRegistry {
|
|
|
159
197
|
if (!id)
|
|
160
198
|
continue;
|
|
161
199
|
const existing = prev?.tasks.get(id);
|
|
200
|
+
const type = str(rt.task_type) ?? existing?.type ?? "task";
|
|
162
201
|
next.set(id, {
|
|
163
202
|
...existing,
|
|
164
203
|
id,
|
|
165
|
-
type
|
|
204
|
+
type,
|
|
205
|
+
role: roleForTaskType(type),
|
|
166
206
|
description: str(rt.description) ?? existing?.description ?? "",
|
|
167
207
|
startedAt: existing?.startedAt ?? this.now(),
|
|
168
208
|
});
|
|
@@ -65,10 +65,11 @@ export declare class GitService {
|
|
|
65
65
|
* successful check (repo-ness doesn't change under a running server); a thrown
|
|
66
66
|
* git / missing-binary error resolves to `false`.
|
|
67
67
|
*
|
|
68
|
-
* Store-wide, so it still gates the store-wide
|
|
69
|
-
* {@link
|
|
70
|
-
* {@link isRepoAt} about the
|
|
71
|
-
*
|
|
68
|
+
* Store-wide, so it still gates the one genuinely store-wide operation,
|
|
69
|
+
* {@link dirtyCounts} — a single `git status` over the whole root. Everything
|
|
70
|
+
* else asks {@link isRepoAt} about the directory it is about to act on: the
|
|
71
|
+
* project's own working directory for status/diff/commit (#597, #206) and for
|
|
72
|
+
* remote/push (#710). See {@link isRepoAt} for why the two had to be separated.
|
|
72
73
|
*/
|
|
73
74
|
isRepo(): Promise<boolean>;
|
|
74
75
|
/**
|
|
@@ -101,6 +102,40 @@ export declare class GitService {
|
|
|
101
102
|
* `projectDir`.
|
|
102
103
|
*/
|
|
103
104
|
projectStatus(projectDir: string): Promise<GitProjectStatus>;
|
|
105
|
+
/**
|
|
106
|
+
* Whether `relPath` is a file git currently reports as UNTRACKED in
|
|
107
|
+
* `projectDir` — the read gate for the Changes tab's new-file view (#710).
|
|
108
|
+
*
|
|
109
|
+
* ## Why git's own answer, and not a path guard
|
|
110
|
+
*
|
|
111
|
+
* The Changes tab renders an untracked file's CONTENT (it has no diff), and
|
|
112
|
+
* until #710 it did so through the metadata-dir file surface — which 404'd for
|
|
113
|
+
* every project whose working directory is somewhere else. Serving the file
|
|
114
|
+
* from the working directory instead needs a guard, and the obvious one to
|
|
115
|
+
* reach for is `project-files.ts`'s: refuse any path descending through a
|
|
116
|
+
* dot-prefixed directory. That guard is right for the metadata dir and wrong
|
|
117
|
+
* here, in both directions:
|
|
118
|
+
*
|
|
119
|
+
* - **It under-blocks on a worktree.** A linked worktree's `.git` is a FILE,
|
|
120
|
+
* not a directory, so it is the LEAF — and the leaf is deliberately allowed
|
|
121
|
+
* there (an untracked `.gitignore` has to render). The file is only a
|
|
122
|
+
* `gitdir:` pointer, but serving it discloses the main checkout's path for
|
|
123
|
+
* no reason.
|
|
124
|
+
* - **It over-blocks on a real repo.** A brand-new untracked
|
|
125
|
+
* `.github/workflows/ci.yml` is an ordinary Changes-tab row, and a
|
|
126
|
+
* dot-directory rule refuses to render exactly the file the UI is listing.
|
|
127
|
+
*
|
|
128
|
+
* Asking git is both tighter and looser in the right places. `git status`
|
|
129
|
+
* never reports `.git` (file or directory), never reports an IGNORED path —
|
|
130
|
+
* so `.chats/` and anything else the repo excludes stays unreadable — and does
|
|
131
|
+
* report `.github/…`. The set the route may serve is then, by construction,
|
|
132
|
+
* exactly the set the pane already displays: no third notion of "allowed" to
|
|
133
|
+
* keep in step with the other two.
|
|
134
|
+
*
|
|
135
|
+
* `:(literal)` disables pathspec magic, so a filename containing `*` or `[` is
|
|
136
|
+
* matched as itself rather than as a pattern. False on any git failure.
|
|
137
|
+
*/
|
|
138
|
+
isUntrackedFile(projectDir: string, relPath: string): Promise<boolean>;
|
|
104
139
|
/**
|
|
105
140
|
* `git diff --numstat` for a project's tracked working-tree changes, keyed by
|
|
106
141
|
* PROJECT-relative path (`--relative` makes git emit paths relative to the
|
|
@@ -116,8 +151,23 @@ export declare class GitService {
|
|
|
116
151
|
projectDiff(projectDir: string, file?: string): Promise<string>;
|
|
117
152
|
/** Commit identity (from config, no global git config needed). */
|
|
118
153
|
private identity;
|
|
119
|
-
/**
|
|
120
|
-
|
|
154
|
+
/**
|
|
155
|
+
* Origin remote info + ahead/behind vs upstream (best-effort) for ONE
|
|
156
|
+
* directory's repository, defaulting to the backing store.
|
|
157
|
+
*
|
|
158
|
+
* The `dir` parameter is issue #710. `remote()` and {@link push} were the last
|
|
159
|
+
* two members of the per-project Changes surface still hard-wired to
|
|
160
|
+
* `projectsRoot` after #597/#709 moved status/diff/commit onto the project's
|
|
161
|
+
* `workingDir` — which made the pane's header self-contradictory: it showed the
|
|
162
|
+
* WORKTREE's branch beside an ahead-count, a remote URL and a Push button that
|
|
163
|
+
* all belonged to Paddock's own notes repo. Passing the working directory makes
|
|
164
|
+
* every field in that header describe one repository.
|
|
165
|
+
*
|
|
166
|
+
* For a notebook project (and for the root workspace) the working directory is
|
|
167
|
+
* inside `projectsRoot`, so this resolves to the same repo, same branch, same
|
|
168
|
+
* upstream as before — the generalisation costs those shapes nothing.
|
|
169
|
+
*/
|
|
170
|
+
remote(dir?: string): Promise<{
|
|
121
171
|
repo: boolean;
|
|
122
172
|
configured: boolean;
|
|
123
173
|
url?: string;
|
|
@@ -163,8 +213,13 @@ export declare class GitService {
|
|
|
163
213
|
* Returns 0 for a directory that isn't a repo, matching "nothing to report".
|
|
164
214
|
*/
|
|
165
215
|
dirtyCountAt(dir: string): Promise<number>;
|
|
166
|
-
/**
|
|
167
|
-
|
|
216
|
+
/**
|
|
217
|
+
* Push one directory's current branch to origin (sets upstream on first push),
|
|
218
|
+
* defaulting to the backing store. See {@link remote} for why `dir` exists —
|
|
219
|
+
* in a linked WORKTREE `--abbrev-ref HEAD` is per-worktree, so this pushes the
|
|
220
|
+
* branch the user is actually looking at rather than the store's.
|
|
221
|
+
*/
|
|
222
|
+
push(dir?: string): Promise<{
|
|
168
223
|
pushed: boolean;
|
|
169
224
|
error?: string;
|
|
170
225
|
}>;
|
|
@@ -77,10 +77,11 @@ export class GitService {
|
|
|
77
77
|
* successful check (repo-ness doesn't change under a running server); a thrown
|
|
78
78
|
* git / missing-binary error resolves to `false`.
|
|
79
79
|
*
|
|
80
|
-
* Store-wide, so it still gates the store-wide
|
|
81
|
-
* {@link
|
|
82
|
-
* {@link isRepoAt} about the
|
|
83
|
-
*
|
|
80
|
+
* Store-wide, so it still gates the one genuinely store-wide operation,
|
|
81
|
+
* {@link dirtyCounts} — a single `git status` over the whole root. Everything
|
|
82
|
+
* else asks {@link isRepoAt} about the directory it is about to act on: the
|
|
83
|
+
* project's own working directory for status/diff/commit (#597, #206) and for
|
|
84
|
+
* remote/push (#710). See {@link isRepoAt} for why the two had to be separated.
|
|
84
85
|
*/
|
|
85
86
|
async isRepo() {
|
|
86
87
|
if (this.repoFlag !== null)
|
|
@@ -198,6 +199,70 @@ export class GitService {
|
|
|
198
199
|
}
|
|
199
200
|
return { repo: true, branch, files, clean: files.length === 0 };
|
|
200
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* Whether `relPath` is a file git currently reports as UNTRACKED in
|
|
204
|
+
* `projectDir` — the read gate for the Changes tab's new-file view (#710).
|
|
205
|
+
*
|
|
206
|
+
* ## Why git's own answer, and not a path guard
|
|
207
|
+
*
|
|
208
|
+
* The Changes tab renders an untracked file's CONTENT (it has no diff), and
|
|
209
|
+
* until #710 it did so through the metadata-dir file surface — which 404'd for
|
|
210
|
+
* every project whose working directory is somewhere else. Serving the file
|
|
211
|
+
* from the working directory instead needs a guard, and the obvious one to
|
|
212
|
+
* reach for is `project-files.ts`'s: refuse any path descending through a
|
|
213
|
+
* dot-prefixed directory. That guard is right for the metadata dir and wrong
|
|
214
|
+
* here, in both directions:
|
|
215
|
+
*
|
|
216
|
+
* - **It under-blocks on a worktree.** A linked worktree's `.git` is a FILE,
|
|
217
|
+
* not a directory, so it is the LEAF — and the leaf is deliberately allowed
|
|
218
|
+
* there (an untracked `.gitignore` has to render). The file is only a
|
|
219
|
+
* `gitdir:` pointer, but serving it discloses the main checkout's path for
|
|
220
|
+
* no reason.
|
|
221
|
+
* - **It over-blocks on a real repo.** A brand-new untracked
|
|
222
|
+
* `.github/workflows/ci.yml` is an ordinary Changes-tab row, and a
|
|
223
|
+
* dot-directory rule refuses to render exactly the file the UI is listing.
|
|
224
|
+
*
|
|
225
|
+
* Asking git is both tighter and looser in the right places. `git status`
|
|
226
|
+
* never reports `.git` (file or directory), never reports an IGNORED path —
|
|
227
|
+
* so `.chats/` and anything else the repo excludes stays unreadable — and does
|
|
228
|
+
* report `.github/…`. The set the route may serve is then, by construction,
|
|
229
|
+
* exactly the set the pane already displays: no third notion of "allowed" to
|
|
230
|
+
* keep in step with the other two.
|
|
231
|
+
*
|
|
232
|
+
* `:(literal)` disables pathspec magic, so a filename containing `*` or `[` is
|
|
233
|
+
* matched as itself rather than as a pattern. False on any git failure.
|
|
234
|
+
*/
|
|
235
|
+
async isUntrackedFile(projectDir, relPath) {
|
|
236
|
+
if (!isSafeRelPath(relPath))
|
|
237
|
+
return false;
|
|
238
|
+
if (!(await this.isRepoAt(projectDir)))
|
|
239
|
+
return false;
|
|
240
|
+
// Porcelain paths are REPO-ROOT relative; the caller's path is PROJECT
|
|
241
|
+
// relative. Rebase with the same `--show-prefix` the status route uses, so
|
|
242
|
+
// a project dir below the repo root compares like for like.
|
|
243
|
+
let prefix = "";
|
|
244
|
+
try {
|
|
245
|
+
prefix = (await this.git(projectDir, ["rev-parse", "--show-prefix"])).trim();
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
/* project dir is the repo root */
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
const out = await this.git(projectDir, [
|
|
252
|
+
"status",
|
|
253
|
+
"--porcelain=v1",
|
|
254
|
+
"-z",
|
|
255
|
+
"--untracked-files=all",
|
|
256
|
+
"--",
|
|
257
|
+
`:(literal)${relPath}`,
|
|
258
|
+
]);
|
|
259
|
+
return parsePorcelainZ(out).some((f) => f.untracked &&
|
|
260
|
+
(prefix && f.path.startsWith(prefix) ? f.path.slice(prefix.length) : f.path) === relPath);
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
201
266
|
/**
|
|
202
267
|
* `git diff --numstat` for a project's tracked working-tree changes, keyed by
|
|
203
268
|
* PROJECT-relative path (`--relative` makes git emit paths relative to the
|
|
@@ -248,13 +313,28 @@ export class GitService {
|
|
|
248
313
|
identity() {
|
|
249
314
|
return this.author;
|
|
250
315
|
}
|
|
251
|
-
/**
|
|
252
|
-
|
|
253
|
-
|
|
316
|
+
/**
|
|
317
|
+
* Origin remote info + ahead/behind vs upstream (best-effort) for ONE
|
|
318
|
+
* directory's repository, defaulting to the backing store.
|
|
319
|
+
*
|
|
320
|
+
* The `dir` parameter is issue #710. `remote()` and {@link push} were the last
|
|
321
|
+
* two members of the per-project Changes surface still hard-wired to
|
|
322
|
+
* `projectsRoot` after #597/#709 moved status/diff/commit onto the project's
|
|
323
|
+
* `workingDir` — which made the pane's header self-contradictory: it showed the
|
|
324
|
+
* WORKTREE's branch beside an ahead-count, a remote URL and a Push button that
|
|
325
|
+
* all belonged to Paddock's own notes repo. Passing the working directory makes
|
|
326
|
+
* every field in that header describe one repository.
|
|
327
|
+
*
|
|
328
|
+
* For a notebook project (and for the root workspace) the working directory is
|
|
329
|
+
* inside `projectsRoot`, so this resolves to the same repo, same branch, same
|
|
330
|
+
* upstream as before — the generalisation costs those shapes nothing.
|
|
331
|
+
*/
|
|
332
|
+
async remote(dir = this.projectsRoot) {
|
|
333
|
+
if (!(await this.isRepoAt(dir)))
|
|
254
334
|
return { repo: false, configured: false };
|
|
255
335
|
let branch;
|
|
256
336
|
try {
|
|
257
|
-
branch = (await this.git(
|
|
337
|
+
branch = (await this.git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
258
338
|
if (branch === "HEAD")
|
|
259
339
|
branch = undefined;
|
|
260
340
|
}
|
|
@@ -263,7 +343,7 @@ export class GitService {
|
|
|
263
343
|
}
|
|
264
344
|
let url;
|
|
265
345
|
try {
|
|
266
|
-
url = (await this.git(
|
|
346
|
+
url = (await this.git(dir, ["remote", "get-url", "origin"])).trim();
|
|
267
347
|
}
|
|
268
348
|
catch {
|
|
269
349
|
return { repo: true, configured: false, branch };
|
|
@@ -271,12 +351,7 @@ export class GitService {
|
|
|
271
351
|
let ahead;
|
|
272
352
|
let behind;
|
|
273
353
|
try {
|
|
274
|
-
const counts = (await this.git(
|
|
275
|
-
"rev-list",
|
|
276
|
-
"--left-right",
|
|
277
|
-
"--count",
|
|
278
|
-
"@{upstream}...HEAD",
|
|
279
|
-
])).trim();
|
|
354
|
+
const counts = (await this.git(dir, ["rev-list", "--left-right", "--count", "@{upstream}...HEAD"])).trim();
|
|
280
355
|
const [b, a] = counts.split(/\s+/).map((n) => Number.parseInt(n, 10));
|
|
281
356
|
behind = Number.isFinite(b) ? b : undefined;
|
|
282
357
|
ahead = Number.isFinite(a) ? a : undefined;
|
|
@@ -393,13 +468,18 @@ export class GitService {
|
|
|
393
468
|
return 0;
|
|
394
469
|
}
|
|
395
470
|
}
|
|
396
|
-
/**
|
|
397
|
-
|
|
398
|
-
|
|
471
|
+
/**
|
|
472
|
+
* Push one directory's current branch to origin (sets upstream on first push),
|
|
473
|
+
* defaulting to the backing store. See {@link remote} for why `dir` exists —
|
|
474
|
+
* in a linked WORKTREE `--abbrev-ref HEAD` is per-worktree, so this pushes the
|
|
475
|
+
* branch the user is actually looking at rather than the store's.
|
|
476
|
+
*/
|
|
477
|
+
async push(dir = this.projectsRoot) {
|
|
478
|
+
if (!(await this.isRepoAt(dir)))
|
|
399
479
|
return { pushed: false, error: "not a repo" };
|
|
400
480
|
try {
|
|
401
|
-
const branch = (await this.git(
|
|
402
|
-
await this.git(
|
|
481
|
+
const branch = (await this.git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
482
|
+
await this.git(dir, ["push", "--set-upstream", "origin", branch]);
|
|
403
483
|
return { pushed: true };
|
|
404
484
|
}
|
|
405
485
|
catch (err) {
|
|
@@ -66,10 +66,36 @@ export declare function listProjectRuns(stateDir: string, project: Project, limi
|
|
|
66
66
|
* the data source for the Triggers tab's per-trigger last-run column. Used to pull
|
|
67
67
|
* one project's keeper AND every scoped `trigger-<slug>-<name>` agent in a single
|
|
68
68
|
* pass so {@link import("./trigger-runtime.js").buildTriggerRuntime} can attribute a
|
|
69
|
-
* scoped trigger's newest run by agent name.
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
69
|
+
* scoped trigger's newest run by agent name. Order (started_at descending) is
|
|
70
|
+
* preserved. Errors swallow to `[]` so the runtime view degrades to config-only
|
|
71
|
+
* rather than failing to render.
|
|
72
|
+
*
|
|
73
|
+
* ## Why the filter is pushed down (#535)
|
|
74
|
+
*
|
|
75
|
+
* This used to call `listJobs(jobsDir)` with NEITHER filter nor limit, then
|
|
76
|
+
* filter and slice in JS — because `ListJobsFilter` had `agent` (exactly one)
|
|
77
|
+
* and no multi-agent form. That defeats core's job index completely: with
|
|
78
|
+
* `filter.limit === undefined` core sets `retain = matches`, so EVERY record in
|
|
79
|
+
* the shared, never-pruned, fleet-wide jobs dir is read, YAML-parsed and
|
|
80
|
+
* Zod-validated, then thrown away. Measured on a 2,016-record dir:
|
|
81
|
+
*
|
|
82
|
+
* | call | warm |
|
|
83
|
+
* |---|---|
|
|
84
|
+
* | `listJobs(dir)` + JS filter + slice | 1,935–2,095 ms |
|
|
85
|
+
* | `listJobs(dir, { agents, limit })` | 125–146 ms |
|
|
86
|
+
*
|
|
87
|
+
* That ~2 s was essentially the whole cost of `GET /triggers/runtime`, which the
|
|
88
|
+
* Triggers tab polls every 10 s.
|
|
89
|
+
*
|
|
90
|
+
* Both `agents` AND `limit` are load-bearing. `agents` alone still leaves
|
|
91
|
+
* `limit` undefined, so `retain = matches` and every match is hydrated anyway —
|
|
92
|
+
* the win comes from the pair. `limit <= 0` keeps its documented "no cap"
|
|
93
|
+
* meaning and is passed as no `limit`, which costs more but is still filtered.
|
|
94
|
+
*
|
|
95
|
+
* Requires `@herdctl/core` >= 5.28.0 (herdctl#418). On an older core `agents` is
|
|
96
|
+
* an unknown key and would be silently ignored, returning the newest `limit`
|
|
97
|
+
* records across ALL agents — hence the version floor in package.json rather
|
|
98
|
+
* than a defensive re-filter here, which would mask the mismatch.
|
|
73
99
|
*/
|
|
74
100
|
export declare function listRunsForAgents(stateDir: string, agents: string[], limit?: number): Promise<JobMetadata[]>;
|
|
75
101
|
/**
|
|
@@ -124,19 +124,46 @@ export async function listProjectRuns(stateDir, project, limit = 100) {
|
|
|
124
124
|
* the data source for the Triggers tab's per-trigger last-run column. Used to pull
|
|
125
125
|
* one project's keeper AND every scoped `trigger-<slug>-<name>` agent in a single
|
|
126
126
|
* pass so {@link import("./trigger-runtime.js").buildTriggerRuntime} can attribute a
|
|
127
|
-
* scoped trigger's newest run by agent name.
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
127
|
+
* scoped trigger's newest run by agent name. Order (started_at descending) is
|
|
128
|
+
* preserved. Errors swallow to `[]` so the runtime view degrades to config-only
|
|
129
|
+
* rather than failing to render.
|
|
130
|
+
*
|
|
131
|
+
* ## Why the filter is pushed down (#535)
|
|
132
|
+
*
|
|
133
|
+
* This used to call `listJobs(jobsDir)` with NEITHER filter nor limit, then
|
|
134
|
+
* filter and slice in JS — because `ListJobsFilter` had `agent` (exactly one)
|
|
135
|
+
* and no multi-agent form. That defeats core's job index completely: with
|
|
136
|
+
* `filter.limit === undefined` core sets `retain = matches`, so EVERY record in
|
|
137
|
+
* the shared, never-pruned, fleet-wide jobs dir is read, YAML-parsed and
|
|
138
|
+
* Zod-validated, then thrown away. Measured on a 2,016-record dir:
|
|
139
|
+
*
|
|
140
|
+
* | call | warm |
|
|
141
|
+
* |---|---|
|
|
142
|
+
* | `listJobs(dir)` + JS filter + slice | 1,935–2,095 ms |
|
|
143
|
+
* | `listJobs(dir, { agents, limit })` | 125–146 ms |
|
|
144
|
+
*
|
|
145
|
+
* That ~2 s was essentially the whole cost of `GET /triggers/runtime`, which the
|
|
146
|
+
* Triggers tab polls every 10 s.
|
|
147
|
+
*
|
|
148
|
+
* Both `agents` AND `limit` are load-bearing. `agents` alone still leaves
|
|
149
|
+
* `limit` undefined, so `retain = matches` and every match is hydrated anyway —
|
|
150
|
+
* the win comes from the pair. `limit <= 0` keeps its documented "no cap"
|
|
151
|
+
* meaning and is passed as no `limit`, which costs more but is still filtered.
|
|
152
|
+
*
|
|
153
|
+
* Requires `@herdctl/core` >= 5.28.0 (herdctl#418). On an older core `agents` is
|
|
154
|
+
* an unknown key and would be silently ignored, returning the newest `limit`
|
|
155
|
+
* records across ALL agents — hence the version floor in package.json rather
|
|
156
|
+
* than a defensive re-filter here, which would mask the mismatch.
|
|
131
157
|
*/
|
|
132
158
|
export async function listRunsForAgents(stateDir, agents, limit = 200) {
|
|
133
159
|
if (agents.length === 0)
|
|
134
160
|
return [];
|
|
135
161
|
const jobsDir = jobsDirOf(stateDir);
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
162
|
+
// Core dedupes `agents` into a Set internally, so the caller's keeper +
|
|
163
|
+
// per-trigger list may repeat the keeper (unscoped schedule triggers run
|
|
164
|
+
// under it) exactly as it did when this deduped locally.
|
|
165
|
+
const { jobs } = await listJobs(jobsDir, limit > 0 ? { agents, limit } : { agents }).catch(() => ({ jobs: [], errors: 0 }));
|
|
166
|
+
return jobs;
|
|
140
167
|
}
|
|
141
168
|
/**
|
|
142
169
|
* Delete every job record matching `match`, plus each one's sibling `.jsonl`
|
|
@@ -33,8 +33,18 @@ import type { FileEntry, FileKind } from "./project-types.js";
|
|
|
33
33
|
* reachable there either. This is worth closing because "hidden in the listing"
|
|
34
34
|
* should not be the only thing standing between an API and a transcript, not
|
|
35
35
|
* because it grants anything new.
|
|
36
|
+
*
|
|
37
|
+
* **`hiddenSegments: "allow"`** turns the dot-directory half off, leaving only
|
|
38
|
+
* containment (issue #710). It exists for exactly one caller: `/git/file`, which
|
|
39
|
+
* serves a file from the project's WORKING directory and has already checked
|
|
40
|
+
* that `git status` reports that path as untracked. A git-verified allowlist is
|
|
41
|
+
* strictly stronger than this heuristic — git never names `.git` and never names
|
|
42
|
+
* an ignored path — and the heuristic is actively wrong out there, because a new
|
|
43
|
+
* `.github/workflows/ci.yml` is an ordinary Changes-tab row that a dot-directory
|
|
44
|
+
* rule refuses to render. Every other caller keeps the default. Pass it only
|
|
45
|
+
* with an independent allowlist in hand.
|
|
36
46
|
*/
|
|
37
|
-
export declare function resolveInProject(
|
|
47
|
+
export declare function resolveInProject(dir: string, name: string, hiddenSegments?: "refuse" | "allow"): string;
|
|
38
48
|
/**
|
|
39
49
|
* List one level of a project directory (issue #259). `subpath` is a
|
|
40
50
|
* project-relative directory ("" = the project root); the returned entries
|
|
@@ -46,16 +56,16 @@ export declare function resolveInProject(root: string, slug: string, name: strin
|
|
|
46
56
|
* doesn't exist and `ProjectError("not_directory")` when `subpath` is a file —
|
|
47
57
|
* the latter lets the caller fall back to rendering that file.
|
|
48
58
|
*/
|
|
49
|
-
export declare function listFiles(
|
|
59
|
+
export declare function listFiles(dir: string, subpath?: string): Promise<FileEntry[]>;
|
|
50
60
|
/** Read a freeform file's contents as UTF-8 text (path-traversal guarded). */
|
|
51
|
-
export declare function readProjectFile(
|
|
61
|
+
export declare function readProjectFile(dir: string, name: string, hiddenSegments?: "refuse" | "allow"): Promise<string>;
|
|
52
62
|
/**
|
|
53
63
|
* Read a file's raw bytes + its MIME type (issue #61), for the binary/image
|
|
54
64
|
* endpoint. Path-traversal guarded; throws ProjectError("not_found") if the
|
|
55
65
|
* file is missing so the route can 404 cleanly. NOT decoded as text, so binary
|
|
56
|
-
* (image) bytes survive intact.
|
|
66
|
+
* (image) bytes survive intact. See {@link resolveInProject} for `hiddenSegments`.
|
|
57
67
|
*/
|
|
58
|
-
export declare function readFileBytes(
|
|
68
|
+
export declare function readFileBytes(dir: string, name: string, hiddenSegments?: "refuse" | "allow"): Promise<{
|
|
59
69
|
bytes: Buffer;
|
|
60
70
|
mime: string;
|
|
61
71
|
}>;
|
|
@@ -67,9 +77,10 @@ export declare function readFileBytes(root: string, slug: string, name: string):
|
|
|
67
77
|
* For an IMAGE the raw bytes are NOT returned here (decoding binary as UTF-8
|
|
68
78
|
* would mangle it): `content` is empty and the client fetches the bytes from
|
|
69
79
|
* the raw endpoint. We still stat the file so a missing image 404s. Path-
|
|
70
|
-
* traversal guarded; throws ProjectError("not_found") when missing.
|
|
80
|
+
* traversal guarded; throws ProjectError("not_found") when missing. See
|
|
81
|
+
* {@link resolveInProject} for `hiddenSegments`.
|
|
71
82
|
*/
|
|
72
|
-
export declare function readFileWithKind(
|
|
83
|
+
export declare function readFileWithKind(dir: string, name: string, hiddenSegments?: "refuse" | "allow"): Promise<{
|
|
73
84
|
name: string;
|
|
74
85
|
kind: FileKind;
|
|
75
86
|
content: string;
|