@edspencer/paddock 0.70.0 → 0.71.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/package.json +2 -2
- package/packages/server/dist/background-live.d.ts +40 -8
- package/packages/server/dist/background-live.js +79 -1
- package/packages/server/dist/cli/args.d.ts +11 -3
- package/packages/server/dist/cli/args.js +33 -7
- package/packages/server/dist/cli/service/index.js +12 -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/herdctl.d.ts +49 -0
- package/packages/server/dist/herdctl.js +42 -0
- 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/chats.js +7 -7
- package/packages/server/dist/routes/discover.js +1 -1
- 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 +89 -3
- package/packages/server/dist/ws-protocol.js +9 -0
- package/packages/server/dist/ws.js +38 -0
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/{Chip-C8a-t439.js → Chip-5S5MAayn.js} +1 -1
- package/packages/web/dist/assets/DiscoverPage-CEzcRO7X.js +2 -0
- package/packages/web/dist/assets/DiscoverView-CArICIRO.js +2 -0
- package/packages/web/dist/assets/InstanceConfigPage-CKjJsaGZ.js +2 -0
- package/packages/web/dist/assets/{MarkdownRenderer-DCj_ZwVG.js → MarkdownRenderer-BOrDawoY.js} +1 -1
- package/packages/web/dist/assets/{ProjectMenu-B7dGZSFB.js → ProjectMenu-B_8NYWZr.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-bstvMvM9.js → ProjectRedirect-xFWTlADS.js} +1 -1
- package/packages/web/dist/assets/ProjectView-polknqZw.js +13 -0
- package/packages/web/dist/assets/{ProjectsGrid-DKvySDUQ.js → ProjectsGrid-BF65wTqN.js} +1 -1
- package/packages/web/dist/assets/{RootHome-CD_37qb-.js → RootHome-32k1iDh4.js} +1 -1
- package/packages/web/dist/assets/{Surfaces-1dBayrsj.js → Surfaces-DCm19ZAD.js} +1 -1
- package/packages/web/dist/assets/{arc-BJPb3ViJ.js → arc-7GXE70CZ.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-PkLOC5wV.js → architectureDiagram-3BPJPVTR-CAEj_lAw.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-IPduhNH-.js → blockDiagram-GPEHLZMM-wNLONvdQ.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-dKTYV1Vq.js → c4Diagram-AAUBKEIU-Cf7MUqJz.js} +1 -1
- package/packages/web/dist/assets/channel-U0NZSTs2.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-B5C6geyg.js → chunk-2J33WTMH-W18BVHtE.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-1BRhFRhF.js → chunk-4BX2VUAB-DDJQi9KC.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-DNv-sTiI.js → chunk-55IACEB6-L5afa49k.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-CWezuVkx.js → chunk-727SXJPM-Bb0Sw8oK.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-Bt4yEQnu.js → chunk-AQP2D5EJ-DRrKTv1K.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-lb_8q4JY.js → chunk-FMBD7UC4-CIsdDBaT.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-DOwgGlQ_.js → chunk-ND2GUHAM-DN5ua8KT.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-BanKpY4q.js → chunk-QZHKN3VN-DE1WNShj.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-D1QjNKTc.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-D1QjNKTc.js +2 -0
- package/packages/web/dist/assets/{core-CAsyoNAa.js → core-DadwNpE4.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-sVaVkwMI.js → cose-bilkent-S5V4N54A-2FTPTDA6.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-C9pKHSMo.js → dagre-BM42HDAG-eJ7MdaEX.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-CHdrmCfX.js → diagram-2AECGRRQ-D8MZTSkB.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-4UTa2Dfa.js → diagram-5GNKFQAL-DVXF3Z-H.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-NidpfVwW.js → diagram-KO2AKTUF-DoXfbQ0i.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-ictQj03T.js → diagram-LMA3HP47-CRW0fiqv.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-B7dTLMHC.js → diagram-OG6HWLK6-DfhYXDqr.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-C2QYYzJ8.js → erDiagram-TEJ5UH35-HG0QTHgA.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-D2rcjSZ9.js → flowDiagram-I6XJVG4X-ChVekwUy.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-BQcXTYzg.js → ganttDiagram-6RSMTGT7-CeA-6Z-T.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-DwB2Vqxa.js → gitGraphDiagram-PVQCEYII-ew2oouNN.js} +1 -1
- package/packages/web/dist/assets/{index-BeuEs7dL.js → index-CbqaEg2j.js} +14 -14
- package/packages/web/dist/assets/index-tvJIh4A9.css +1 -0
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-BoK4Mawg.js → infoDiagram-5YYISTIA-BY9NYdld.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DPP9svhg.js → ishikawaDiagram-YF4QCWOH-CgbdrGBm.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-TrggodYk.js → journeyDiagram-JHISSGLW-BepLFtbU.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-Bh69hRSR.js → kanban-definition-UN3LZRKU-JHMJYGJu.js} +1 -1
- package/packages/web/dist/assets/{linear-C1iYMpBz.js → linear-Ccw8eVgC.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-BZDABcuq.js → mermaid.core-lHjASk7x.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-DTJON3dS.js → mindmap-definition-RKZ34NQL-Bi5Iq2M4.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BxtFV74I.js → pieDiagram-4H26LBE5-rZuB_P1J.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-D2Ar8kmO.js → quadrantDiagram-W4KKPZXB-BuStI37i.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-DiLSfeSQ.js → requirementDiagram-4Y6WPE33-BFElQKEX.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-D_CRQc7j.js → sankeyDiagram-5OEKKPKP-tzdQCr91.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DjtfcRNV.js → sequenceDiagram-3UESZ5HK-j1koqb6Q.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-CzEtmYLV.js → stateDiagram-AJRCARHV-B-dUkvgq.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-b1ksIBrs.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-DXKspEG3.js → timeline-definition-PNZ67QCA-BWyq6u64.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-B_tXRMHH.js → vennDiagram-CIIHVFJN-B0CRl3xN.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-DNaNoaJm.js → wardley-L42UT6IY-C45bBEPS.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-BuVDpC8s.js → wardleyDiagram-YWT4CUSO-DT50TWrW.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-Cqtew2PA.js → xychartDiagram-2RQKCTM6-C8iBuFGD.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/DiscoverView-DS_Z9xKH.js +0 -2
- package/packages/web/dist/assets/InstanceConfigPage-B1ei1FDo.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
|
@@ -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`
|
|
@@ -79,6 +79,26 @@ export interface ChatDeletion {
|
|
|
79
79
|
/** The transcript was deliberately left on disk because it is not paddock's. */
|
|
80
80
|
retained: boolean;
|
|
81
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* What happened to a request to stop one background task (#848).
|
|
84
|
+
*
|
|
85
|
+
* Three cases, kept apart on purpose — see
|
|
86
|
+
* {@link HerdctlService.stopBackgroundTask} for why only `error` is a failure.
|
|
87
|
+
*/
|
|
88
|
+
export type StopTaskOutcome =
|
|
89
|
+
/** Accepted. The SDK's own terminal notification is what removes the row. */
|
|
90
|
+
{
|
|
91
|
+
outcome: "stopping";
|
|
92
|
+
}
|
|
93
|
+
/** No live session — the work died with it. The caller drops the row. */
|
|
94
|
+
| {
|
|
95
|
+
outcome: "gone";
|
|
96
|
+
}
|
|
97
|
+
/** The stop did not happen and the task is still running. */
|
|
98
|
+
| {
|
|
99
|
+
outcome: "error";
|
|
100
|
+
message: string;
|
|
101
|
+
};
|
|
82
102
|
/** Options passed through to a streamed trigger. */
|
|
83
103
|
export interface ChatTurnOptions {
|
|
84
104
|
prompt: string;
|
|
@@ -768,6 +788,35 @@ export declare class HerdctlService {
|
|
|
768
788
|
* failure, so a broken Stop looked like a no-op).
|
|
769
789
|
*/
|
|
770
790
|
cancel(jobId: string): Promise<boolean>;
|
|
791
|
+
/**
|
|
792
|
+
* Stop ONE background task, by session id and SDK task id (issue #848).
|
|
793
|
+
*
|
|
794
|
+
* Deliberately keyed on the SESSION, not on {@link liveSessions}. That map is
|
|
795
|
+
* emptied in a `finally` the moment the primary turn's result lands, while the
|
|
796
|
+
* background tasks it launched keep running — so a `liveSessions`-keyed stop
|
|
797
|
+
* would go inert during exactly the phase the running-work bar exists for.
|
|
798
|
+
* `stopTaskInSession` is herdctl's answer to that gap: the reaper holds the
|
|
799
|
+
* session across it.
|
|
800
|
+
*
|
|
801
|
+
* There is deliberately NO liveness pre-check. A reap landing between render
|
|
802
|
+
* and click is a routine race, and `false` already describes it precisely.
|
|
803
|
+
*
|
|
804
|
+
* The three outcomes are kept DISTINCT all the way to the UI, because they
|
|
805
|
+
* mean genuinely different things and only one of them is a failure:
|
|
806
|
+
*
|
|
807
|
+
* - `stopping` — the request was accepted. Nothing is evicted here: the SDK
|
|
808
|
+
* emits the terminal `task_notification{status:"stopped"}` on the session's
|
|
809
|
+
* own stream and {@link BackgroundRegistry} already evicts on it. This is
|
|
810
|
+
* also the idempotent case — the CLI answers `not_found` / `not_running`
|
|
811
|
+
* with a success, so a click racing a natural completion lands here.
|
|
812
|
+
* - `gone` — no live session with that id. NOT a failure: the tasks died with
|
|
813
|
+
* the session, so the user's intent is already satisfied. The caller drops
|
|
814
|
+
* the row itself, because there is no stream left to notify on.
|
|
815
|
+
* - `error` — the stop did NOT happen and the task is still running. Notably
|
|
816
|
+
* a `monitor_mcp` task, which the CLI has no kill strategy for and rejects
|
|
817
|
+
* with `unsupported_type`. Surfaced rather than reported as success.
|
|
818
|
+
*/
|
|
819
|
+
stopBackgroundTask(sessionId: string, taskId: string): Promise<StopTaskOutcome>;
|
|
771
820
|
/**
|
|
772
821
|
* End a managed chat SESSION outright, by session id (issue #731). Where
|
|
773
822
|
* {@link cancel} ends the model turn — and on the session runtime deliberately
|
|
@@ -1397,6 +1397,48 @@ export class HerdctlService {
|
|
|
1397
1397
|
return false;
|
|
1398
1398
|
}
|
|
1399
1399
|
}
|
|
1400
|
+
/**
|
|
1401
|
+
* Stop ONE background task, by session id and SDK task id (issue #848).
|
|
1402
|
+
*
|
|
1403
|
+
* Deliberately keyed on the SESSION, not on {@link liveSessions}. That map is
|
|
1404
|
+
* emptied in a `finally` the moment the primary turn's result lands, while the
|
|
1405
|
+
* background tasks it launched keep running — so a `liveSessions`-keyed stop
|
|
1406
|
+
* would go inert during exactly the phase the running-work bar exists for.
|
|
1407
|
+
* `stopTaskInSession` is herdctl's answer to that gap: the reaper holds the
|
|
1408
|
+
* session across it.
|
|
1409
|
+
*
|
|
1410
|
+
* There is deliberately NO liveness pre-check. A reap landing between render
|
|
1411
|
+
* and click is a routine race, and `false` already describes it precisely.
|
|
1412
|
+
*
|
|
1413
|
+
* The three outcomes are kept DISTINCT all the way to the UI, because they
|
|
1414
|
+
* mean genuinely different things and only one of them is a failure:
|
|
1415
|
+
*
|
|
1416
|
+
* - `stopping` — the request was accepted. Nothing is evicted here: the SDK
|
|
1417
|
+
* emits the terminal `task_notification{status:"stopped"}` on the session's
|
|
1418
|
+
* own stream and {@link BackgroundRegistry} already evicts on it. This is
|
|
1419
|
+
* also the idempotent case — the CLI answers `not_found` / `not_running`
|
|
1420
|
+
* with a success, so a click racing a natural completion lands here.
|
|
1421
|
+
* - `gone` — no live session with that id. NOT a failure: the tasks died with
|
|
1422
|
+
* the session, so the user's intent is already satisfied. The caller drops
|
|
1423
|
+
* the row itself, because there is no stream left to notify on.
|
|
1424
|
+
* - `error` — the stop did NOT happen and the task is still running. Notably
|
|
1425
|
+
* a `monitor_mcp` task, which the CLI has no kill strategy for and rejects
|
|
1426
|
+
* with `unsupported_type`. Surfaced rather than reported as success.
|
|
1427
|
+
*/
|
|
1428
|
+
async stopBackgroundTask(sessionId, taskId) {
|
|
1429
|
+
const fleet = this.fleet;
|
|
1430
|
+
if (!fleet)
|
|
1431
|
+
return { outcome: "gone" };
|
|
1432
|
+
try {
|
|
1433
|
+
const stopped = await fleet.stopTaskInSession(sessionId, taskId);
|
|
1434
|
+
return stopped ? { outcome: "stopping" } : { outcome: "gone" };
|
|
1435
|
+
}
|
|
1436
|
+
catch (err) {
|
|
1437
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1438
|
+
console.warn(`[herdctl] stopTaskInSession failed for ${sessionId}/${taskId}:`, err);
|
|
1439
|
+
return { outcome: "error", message };
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1400
1442
|
/**
|
|
1401
1443
|
* End a managed chat SESSION outright, by session id (issue #731). Where
|
|
1402
1444
|
* {@link cancel} ends the model turn — and on the session runtime deliberately
|
|
@@ -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;
|
|
@@ -3,31 +3,23 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Extracted from projects.ts (issue #403): the path-traversal guard plus the
|
|
5
5
|
* directory listing + file readers (text / raw bytes / kind-hinted). Pure free
|
|
6
|
-
* functions taking `(
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* functions taking `(dir, …)` — no `ProjectStore` state at all — so
|
|
7
|
+
* `ProjectStore` keeps thin delegate methods over these and the public API is
|
|
8
|
+
* unchanged. `ProjectError` codes are preserved exactly.
|
|
9
|
+
*
|
|
10
|
+
* **`dir` is the project's CONTENT dir, not `projectsRoot/<slug>`** (issue #710).
|
|
11
|
+
* These used to take `(root, slug)` and re-derive the directory by joining, which
|
|
12
|
+
* was right only while a project's content lived under the projects root. A
|
|
13
|
+
* MANAGED project with a `path:` (issue #206) keeps its curated trio out at that
|
|
14
|
+
* path, so the join addressed a directory holding nothing but `project.yaml` and
|
|
15
|
+
* the whole Files tab came back empty. Taking the directory the caller already
|
|
16
|
+
* resolved removes the second copy of that resolution rather than teaching it a
|
|
17
|
+
* new case — see {@link import("./project-paths.js").contentDirFor}.
|
|
9
18
|
*/
|
|
10
19
|
import { promises as fs } from "node:fs";
|
|
11
20
|
import path from "node:path";
|
|
12
21
|
import { ProjectError } from "./project-paths.js";
|
|
13
22
|
import { fileKind, contentTypeFor } from "./project-mime.js";
|
|
14
|
-
/**
|
|
15
|
-
* The directory backing a workspace key — a second copy of
|
|
16
|
-
* `ProjectStore.dirFor`'s resolution, kept in step by being the same one-liner.
|
|
17
|
-
*
|
|
18
|
-
* A key is a path relative to the projects root and the root workspace's key is
|
|
19
|
-
* `""`, so `path.join` handles both with no branch. That matters historically:
|
|
20
|
-
* the previous design DID need a branch here for a reserved slug, this copy was
|
|
21
|
-
* missed, and every root file route 404'd until it was found in live QA. There
|
|
22
|
-
* is no longer a branch to miss.
|
|
23
|
-
*
|
|
24
|
-
* The traversal guard below is unaffected: it compares against whatever this
|
|
25
|
-
* returns, so at the root "inside the workspace dir" means "inside the projects
|
|
26
|
-
* root" — exactly the scope of a keeper whose cwd is that directory.
|
|
27
|
-
*/
|
|
28
|
-
function dirFor(root, key) {
|
|
29
|
-
return path.join(root, key);
|
|
30
|
-
}
|
|
31
23
|
/**
|
|
32
24
|
* Resolve a freeform file name to an absolute path inside the project dir,
|
|
33
25
|
* rejecting path traversal AND traversal *through* a hidden (dot-prefixed)
|
|
@@ -62,13 +54,24 @@ function dirFor(root, key) {
|
|
|
62
54
|
* reachable there either. This is worth closing because "hidden in the listing"
|
|
63
55
|
* should not be the only thing standing between an API and a transcript, not
|
|
64
56
|
* because it grants anything new.
|
|
57
|
+
*
|
|
58
|
+
* **`hiddenSegments: "allow"`** turns the dot-directory half off, leaving only
|
|
59
|
+
* containment (issue #710). It exists for exactly one caller: `/git/file`, which
|
|
60
|
+
* serves a file from the project's WORKING directory and has already checked
|
|
61
|
+
* that `git status` reports that path as untracked. A git-verified allowlist is
|
|
62
|
+
* strictly stronger than this heuristic — git never names `.git` and never names
|
|
63
|
+
* an ignored path — and the heuristic is actively wrong out there, because a new
|
|
64
|
+
* `.github/workflows/ci.yml` is an ordinary Changes-tab row that a dot-directory
|
|
65
|
+
* rule refuses to render. Every other caller keeps the default. Pass it only
|
|
66
|
+
* with an independent allowlist in hand.
|
|
65
67
|
*/
|
|
66
|
-
export function resolveInProject(
|
|
67
|
-
const dir = dirFor(root, slug);
|
|
68
|
+
export function resolveInProject(dir, name, hiddenSegments = "refuse") {
|
|
68
69
|
const resolved = path.resolve(dir, name);
|
|
69
70
|
if (resolved !== dir && !resolved.startsWith(dir + path.sep)) {
|
|
70
71
|
throw new ProjectError("Invalid file path", "invalid");
|
|
71
72
|
}
|
|
73
|
+
if (hiddenSegments === "allow")
|
|
74
|
+
return resolved;
|
|
72
75
|
// Check the RESOLVED path's segments relative to the project dir, not the
|
|
73
76
|
// caller's raw string: `a/./.git/config` and `a/../.git/config` both normalise
|
|
74
77
|
// to a hidden directory segment the raw string doesn't literally contain. The
|
|
@@ -100,11 +103,11 @@ function isHidden(segment) {
|
|
|
100
103
|
* doesn't exist and `ProjectError("not_directory")` when `subpath` is a file —
|
|
101
104
|
* the latter lets the caller fall back to rendering that file.
|
|
102
105
|
*/
|
|
103
|
-
export async function listFiles(
|
|
104
|
-
const target = resolveInProject(
|
|
106
|
+
export async function listFiles(dir, subpath = "") {
|
|
107
|
+
const target = resolveInProject(dir, subpath);
|
|
105
108
|
// A LISTING target is a directory, so unlike a read the leaf gets no pass:
|
|
106
109
|
// `?path=.chats` is exactly how every transcript filename was enumerable.
|
|
107
|
-
const leaf = relSegments(
|
|
110
|
+
const leaf = relSegments(dir, target).at(-1);
|
|
108
111
|
if (leaf && isHidden(leaf)) {
|
|
109
112
|
throw new ProjectError("Invalid file path", "invalid");
|
|
110
113
|
}
|
|
@@ -126,17 +129,17 @@ export async function listFiles(root, slug, subpath = "") {
|
|
|
126
129
|
.sort((a, b) => a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === "dir" ? -1 : 1);
|
|
127
130
|
}
|
|
128
131
|
/** Read a freeform file's contents as UTF-8 text (path-traversal guarded). */
|
|
129
|
-
export async function readProjectFile(
|
|
130
|
-
return fs.readFile(resolveInProject(
|
|
132
|
+
export async function readProjectFile(dir, name, hiddenSegments = "refuse") {
|
|
133
|
+
return fs.readFile(resolveInProject(dir, name, hiddenSegments), "utf8");
|
|
131
134
|
}
|
|
132
135
|
/**
|
|
133
136
|
* Read a file's raw bytes + its MIME type (issue #61), for the binary/image
|
|
134
137
|
* endpoint. Path-traversal guarded; throws ProjectError("not_found") if the
|
|
135
138
|
* file is missing so the route can 404 cleanly. NOT decoded as text, so binary
|
|
136
|
-
* (image) bytes survive intact.
|
|
139
|
+
* (image) bytes survive intact. See {@link resolveInProject} for `hiddenSegments`.
|
|
137
140
|
*/
|
|
138
|
-
export async function readFileBytes(
|
|
139
|
-
const resolved = resolveInProject(
|
|
141
|
+
export async function readFileBytes(dir, name, hiddenSegments = "refuse") {
|
|
142
|
+
const resolved = resolveInProject(dir, name, hiddenSegments);
|
|
140
143
|
try {
|
|
141
144
|
const bytes = await fs.readFile(resolved);
|
|
142
145
|
return { bytes, mime: contentTypeFor(name) };
|
|
@@ -156,14 +159,15 @@ export async function readFileBytes(root, slug, name) {
|
|
|
156
159
|
* For an IMAGE the raw bytes are NOT returned here (decoding binary as UTF-8
|
|
157
160
|
* would mangle it): `content` is empty and the client fetches the bytes from
|
|
158
161
|
* the raw endpoint. We still stat the file so a missing image 404s. Path-
|
|
159
|
-
* traversal guarded; throws ProjectError("not_found") when missing.
|
|
162
|
+
* traversal guarded; throws ProjectError("not_found") when missing. See
|
|
163
|
+
* {@link resolveInProject} for `hiddenSegments`.
|
|
160
164
|
*/
|
|
161
|
-
export async function readFileWithKind(
|
|
165
|
+
export async function readFileWithKind(dir, name, hiddenSegments = "refuse") {
|
|
162
166
|
const kind = fileKind(name);
|
|
163
167
|
if (kind === "image") {
|
|
164
168
|
// Existence check only — the bytes go over the raw endpoint.
|
|
165
169
|
try {
|
|
166
|
-
await fs.stat(resolveInProject(
|
|
170
|
+
await fs.stat(resolveInProject(dir, name, hiddenSegments));
|
|
167
171
|
}
|
|
168
172
|
catch (err) {
|
|
169
173
|
if (err instanceof ProjectError)
|
|
@@ -177,7 +181,7 @@ export async function readFileWithKind(root, slug, name) {
|
|
|
177
181
|
}
|
|
178
182
|
let content;
|
|
179
183
|
try {
|
|
180
|
-
content = await readProjectFile(
|
|
184
|
+
content = await readProjectFile(dir, name, hiddenSegments);
|
|
181
185
|
}
|
|
182
186
|
catch (err) {
|
|
183
187
|
if (err instanceof ProjectError)
|
|
@@ -311,7 +311,7 @@ export declare class ProjectStore {
|
|
|
311
311
|
*/
|
|
312
312
|
writeChangelog(slug: string, body: string): Promise<void>;
|
|
313
313
|
/**
|
|
314
|
-
* List one level of a project directory (issue #259). See
|
|
314
|
+
* List one level of a project's content directory (issue #259). See
|
|
315
315
|
* {@link import("./project-files.js").listFiles}.
|
|
316
316
|
*/
|
|
317
317
|
listFiles(slug: string, subpath?: string): Promise<FileEntry[]>;
|
|
@@ -1066,32 +1066,39 @@ export class ProjectStore {
|
|
|
1066
1066
|
await fs.writeFile(file, `${title}\n\n${withoutTitle.trim()}\n`, "utf8");
|
|
1067
1067
|
}
|
|
1068
1068
|
// --- freeform file serving (delegated to project-files.ts, issue #403) ---
|
|
1069
|
-
// Thin wrappers over the pure `(
|
|
1070
|
-
//
|
|
1069
|
+
// Thin wrappers over the pure `(dir, …)` helpers so `ProjectStore`'s public
|
|
1070
|
+
// file-read API (and its ProjectError codes) is unchanged.
|
|
1071
|
+
//
|
|
1072
|
+
// Every one of them browses the project's CONTENT dir (issue #710). That is
|
|
1073
|
+
// the metadata dir for a notebook and for any UNMANAGED project — unchanged —
|
|
1074
|
+
// but for a MANAGED project with a `path:` (issue #206) the curated trio lives
|
|
1075
|
+
// out at that path, and joining `root + slug` addressed a directory holding
|
|
1076
|
+
// only `project.yaml`, so the Files tab listed nothing and every note 404'd.
|
|
1077
|
+
// `contentDirOf` is the single resolution both this and the sweeper use.
|
|
1071
1078
|
/**
|
|
1072
|
-
* List one level of a project directory (issue #259). See
|
|
1079
|
+
* List one level of a project's content directory (issue #259). See
|
|
1073
1080
|
* {@link import("./project-files.js").listFiles}.
|
|
1074
1081
|
*/
|
|
1075
1082
|
async listFiles(slug, subpath = "") {
|
|
1076
|
-
return projectFiles.listFiles(this.
|
|
1083
|
+
return projectFiles.listFiles(await this.contentDirOf(slug), subpath);
|
|
1077
1084
|
}
|
|
1078
1085
|
/** Read a freeform file's contents as UTF-8 text (path-traversal guarded). */
|
|
1079
1086
|
async readFile(slug, name) {
|
|
1080
|
-
return projectFiles.readProjectFile(this.
|
|
1087
|
+
return projectFiles.readProjectFile(await this.contentDirOf(slug), name);
|
|
1081
1088
|
}
|
|
1082
1089
|
/**
|
|
1083
1090
|
* Read a file's raw bytes + its MIME type (issue #61), for the binary/image
|
|
1084
1091
|
* endpoint. See {@link import("./project-files.js").readFileBytes}.
|
|
1085
1092
|
*/
|
|
1086
1093
|
async readFileBytes(slug, name) {
|
|
1087
|
-
return projectFiles.readFileBytes(this.
|
|
1094
|
+
return projectFiles.readFileBytes(await this.contentDirOf(slug), name);
|
|
1088
1095
|
}
|
|
1089
1096
|
/**
|
|
1090
1097
|
* Read a file plus a render-kind hint derived from its extension (issues #3 /
|
|
1091
1098
|
* #61). See {@link import("./project-files.js").readFileWithKind}.
|
|
1092
1099
|
*/
|
|
1093
1100
|
async readFileWithKind(slug, name) {
|
|
1094
|
-
return projectFiles.readFileWithKind(this.
|
|
1101
|
+
return projectFiles.readFileWithKind(await this.contentDirOf(slug), name);
|
|
1095
1102
|
}
|
|
1096
1103
|
// --- internals ---------------------------------------------------------
|
|
1097
1104
|
/**
|
|
@@ -1436,8 +1436,8 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
1436
1436
|
app.get("/adoptable-chats", {
|
|
1437
1437
|
schema: {
|
|
1438
1438
|
tags: ["Chats"],
|
|
1439
|
-
summary: "Count the native Claude Code chats this project could
|
|
1440
|
-
description: "Returns the terminal `claude` sessions that could be
|
|
1439
|
+
summary: "Count the native Claude Code chats this project could adopt",
|
|
1440
|
+
description: "Returns the terminal `claude` sessions that could be adopted into this project but are not yet visible in it: the project's own working directory, plus any Claude transcript folder whose recorded working directory matches the project (by checkout name when the project has a `repo:` key, by exact path otherwise). Sidechains, already-adopted sessions and sessions belonging to a real Paddock run are excluded by the engine; empty transcripts, slash-command-only transcripts and Paddock's own sweeper curation runs (`too-small` / `slash-command-only` / `sweeper-run`) are additionally withheld as noise and reported under `filtered`, so a count lower than the raw total always has an explanation. Response: `{ count, sources: [{ sourceCwd, sessionIds }], filtered: [{ sessionId, sourceCwd, reason }] }`. The count is LIVE — re-read it after an adoption rather than remembering a dismissal.",
|
|
1441
1441
|
params: {
|
|
1442
1442
|
type: "object",
|
|
1443
1443
|
properties: {
|
|
@@ -1474,8 +1474,8 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
1474
1474
|
app.post("/adopt-chats", {
|
|
1475
1475
|
schema: {
|
|
1476
1476
|
tags: ["Chats"],
|
|
1477
|
-
summary: "
|
|
1478
|
-
description: "
|
|
1477
|
+
summary: "Adopt native Claude Code chats into this project",
|
|
1478
|
+
description: "Adopts the terminal `claude` sessions reported by GET …/adoptable-chats. With no body (or no `sourceCwd`) every detected source is adopted; with `sourceCwd` only that source is, and it must be one this project actually offers — an unrecognised path is a 400 rather than an invitation to scan arbitrary directories. `sessionIds` narrows the adoption to a chosen SUBSET of what is on offer (what the confirmation dialog sends); ids that are not on offer are simply not imported, and anything the engine adopts as a side effect of taking a whole source is released again. Transcripts are COPIED, never moved: the user's own `~/.claude` history is left intact and the copies keep their original mtimes, so imported chats sort by when they really happened. Afterwards the session, run-record and detection caches are dropped, so the chats appear on the very next list request with no restart. Response: `{ adopted, skipped }` — `skipped` carries the engine's reason per session (`sidechain`, `already-adopted`, `destination-exists`, `attributed-to-run`, `unreadable`, `placement-failed`, `record-failed`).",
|
|
1479
1479
|
params: {
|
|
1480
1480
|
type: "object",
|
|
1481
1481
|
properties: {
|
|
@@ -1500,7 +1500,7 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
1500
1500
|
},
|
|
1501
1501
|
response: {
|
|
1502
1502
|
200: {
|
|
1503
|
-
description: "Object `{ adopted, skipped }` describing what was
|
|
1503
|
+
description: "Object `{ adopted, skipped }` describing what was adopted.",
|
|
1504
1504
|
type: "object",
|
|
1505
1505
|
additionalProperties: true,
|
|
1506
1506
|
},
|
|
@@ -1549,8 +1549,8 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
1549
1549
|
app.post("/unadopt-chats", {
|
|
1550
1550
|
schema: {
|
|
1551
1551
|
tags: ["Chats"],
|
|
1552
|
-
summary: "Undo the most recent native-chat
|
|
1553
|
-
description: "Reverses the
|
|
1552
|
+
summary: "Undo the most recent native-chat adoption into this project",
|
|
1553
|
+
description: "Reverses the adoption this server most recently performed into the workspace: each adoption is released and the copies that adoption placed are deleted, leaving the user's own `~/.claude` history untouched. `sessionIds` undoes part of that adoption; omit it to undo all of it. Deliberately scoped to the LAST adoption and remembered in memory — undo may delete files, so the paths it can touch come from what this process actually did rather than from the request body, and a restart forgets the offer rather than acting on a stale one. Response: `{ released }`, the session ids actually released; an empty array means there was nothing to undo (nothing imported, already undone, or the server restarted since).",
|
|
1554
1554
|
params: {
|
|
1555
1555
|
type: "object",
|
|
1556
1556
|
properties: {
|
|
@@ -73,7 +73,7 @@ export function registerDiscoverRoutes(app, ctx) {
|
|
|
73
73
|
schema: {
|
|
74
74
|
tags: ["System"],
|
|
75
75
|
summary: "Directories on this machine that could become projects",
|
|
76
|
-
description: "Scans `<claudeHome>/projects/*` for directories with existing Claude Code history and returns the ones worth importing, ranked by how many non-noise sessions each holds. Never walks the filesystem: the only directories it can name are ones a transcript already records, and each is put through the same path floor a linked project must clear (no system directories, no temp roots, nothing inside Paddock's own data dir, nothing overlapping an existing project). Two rules are relaxable: `includeNonGit=1` offers directories that are not git checkouts, `includeOutsideHome=1` offers directories outside `$HOME`. `excluded` reports how many
|
|
76
|
+
description: "Scans `<claudeHome>/projects/*` for directories with existing Claude Code history and returns the ones worth importing, ranked by how many non-noise sessions each holds. Never walks the filesystem: the only directories it can name are ones a transcript already records, and each is put through the same path floor a linked project must clear (no system directories, no temp roots, nothing inside Paddock's own data dir, nothing overlapping an existing project). Two rules are relaxable: `includeNonGit=1` offers directories that are not git checkouts, `includeOutsideHome=1` offers directories outside `$HOME`. Note the two counts are in DIFFERENT units and do not reconcile by subtraction: `scanned` counts transcript FOLDERS, while `excluded` reports how many DIRECTORIES each rule ate — one directory routinely has two transcript folders (the user's own and Paddock's copy), so a rule that ate one directory may account for two scanned folders. The single exception is `no-recorded-cwd`, which is necessarily per-folder: a folder with no recorded cwd has no directory to be counted under. A container whose Claude home is empty reports `scanned: 0`, which is what distinguishes 'no history' from 'all of it was filtered'. Response: `{ claudeHome, homeDir, scanned, candidates, excluded }`.",
|
|
77
77
|
querystring: {
|
|
78
78
|
type: "object",
|
|
79
79
|
properties: {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Git routes: the fleet-level remote + GitHub device-flow auth, plus the
|
|
3
|
-
* per-project working-tree status / diff / commit
|
|
4
|
-
* separate explicit action from a project commit.
|
|
3
|
+
* per-project working-tree status / diff / commit / remote / push surface
|
|
4
|
+
* (#258, #710). Push is a separate explicit action from a project commit.
|
|
5
5
|
*/
|
|
6
6
|
import type { FastifyInstance } from "fastify";
|
|
7
7
|
import type { RouteCtx } from "../route-context.js";
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { sendProjectError } from "../route-errors.js";
|
|
2
|
+
import { ProjectError } from "../project-paths.js";
|
|
3
|
+
import { readFileWithKind, readFileBytes } from "../project-files.js";
|
|
2
4
|
export function registerGitRoutes(app, ctx) {
|
|
3
5
|
const { git, githubAuth } = ctx;
|
|
4
6
|
// --- git (backing store): fleet-level remote + connection state --------
|
|
@@ -118,7 +120,7 @@ export function registerGitRoutes(app, ctx) {
|
|
|
118
120
|
* and per project) — see `workspace-mount.ts`. Paths are relative to the mount.
|
|
119
121
|
*/
|
|
120
122
|
export function registerGitWorkspaceRoutes(app, ctx) {
|
|
121
|
-
const { git, projects } = ctx;
|
|
123
|
+
const { git, projects, githubAuth } = ctx;
|
|
122
124
|
// --- git (backing-store capability, phase 1: read surface) -------------
|
|
123
125
|
// Uncommitted changes confined to this project's subtree. Returns
|
|
124
126
|
// `{ repo: false }` when the projects dir isn't a git working tree, so the
|
|
@@ -236,4 +238,134 @@ export function registerGitWorkspaceRoutes(app, ctx) {
|
|
|
236
238
|
return sendProjectError(reply, err);
|
|
237
239
|
}
|
|
238
240
|
});
|
|
241
|
+
// This project's own remote + ahead/behind, with the (genuinely fleet-level)
|
|
242
|
+
// GitHub connection state folded in so the Changes header is one fetch (#710).
|
|
243
|
+
// Same payload shape as `GET /api/git`, which now differs only in WHICH repo it
|
|
244
|
+
// describes: that one always speaks for the backing store, this one for the
|
|
245
|
+
// directory the project actually works in.
|
|
246
|
+
app.get("/git/remote", {
|
|
247
|
+
schema: {
|
|
248
|
+
tags: ["Git"],
|
|
249
|
+
summary: "Get a project's git remote and GitHub connection state",
|
|
250
|
+
description: "Returns the remote details for the project's OWN working directory (a linked checkout or worktree has its own origin, branch and ahead/behind — issue #710) merged with a `github` object describing the fleet-level GitHub device-flow connection status.",
|
|
251
|
+
params: {
|
|
252
|
+
type: "object",
|
|
253
|
+
properties: {
|
|
254
|
+
slug: { type: "string", description: "The project slug." },
|
|
255
|
+
},
|
|
256
|
+
required: ["slug"],
|
|
257
|
+
},
|
|
258
|
+
response: {
|
|
259
|
+
200: {
|
|
260
|
+
description: "Object containing the remote configuration fields plus a nested `github` object with connection state.",
|
|
261
|
+
type: "object",
|
|
262
|
+
additionalProperties: true,
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
}, async (req, reply) => {
|
|
267
|
+
try {
|
|
268
|
+
const project = await projects.get(req.params.slug);
|
|
269
|
+
const [remote, github] = await Promise.all([
|
|
270
|
+
git.remote(project.workingDir),
|
|
271
|
+
githubAuth.status(),
|
|
272
|
+
]);
|
|
273
|
+
return { ...remote, github };
|
|
274
|
+
}
|
|
275
|
+
catch (err) {
|
|
276
|
+
return sendProjectError(reply, err);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
// Push THIS project's working directory to its origin (#710). The fleet-level
|
|
280
|
+
// `POST /api/git/push` still pushes the backing store; for a notebook project
|
|
281
|
+
// the two are the same repo, and for a linked one they are emphatically not —
|
|
282
|
+
// the Changes header's Push button used to push Paddock's notes repo while
|
|
283
|
+
// showing the user's worktree branch beside it.
|
|
284
|
+
app.post("/git/push", {
|
|
285
|
+
schema: {
|
|
286
|
+
tags: ["Git"],
|
|
287
|
+
summary: "Push a project's working directory to its remote",
|
|
288
|
+
description: "Pushes the current branch of the project's own working directory to its `origin`, setting upstream on first push. Returns an object describing the push result.",
|
|
289
|
+
params: {
|
|
290
|
+
type: "object",
|
|
291
|
+
properties: {
|
|
292
|
+
slug: { type: "string", description: "The project slug." },
|
|
293
|
+
},
|
|
294
|
+
required: ["slug"],
|
|
295
|
+
},
|
|
296
|
+
response: {
|
|
297
|
+
200: {
|
|
298
|
+
description: "Object describing the outcome of the push operation.",
|
|
299
|
+
type: "object",
|
|
300
|
+
additionalProperties: true,
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
}, async (req, reply) => {
|
|
305
|
+
try {
|
|
306
|
+
const project = await projects.get(req.params.slug);
|
|
307
|
+
return await git.push(project.workingDir);
|
|
308
|
+
}
|
|
309
|
+
catch (err) {
|
|
310
|
+
return sendProjectError(reply, err);
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
// An UNTRACKED file's content, read from the project's WORKING directory
|
|
314
|
+
// (#710). The Changes tab shows a new file's content in place of a diff, and
|
|
315
|
+
// used to fetch it from `/files/:name` — the metadata-dir surface, which for a
|
|
316
|
+
// repo-backed or linked project is a different directory entirely, so every
|
|
317
|
+
// new file in the pane rendered "File not found".
|
|
318
|
+
//
|
|
319
|
+
// The gate is `isUntrackedFile`, i.e. git's own answer, NOT the dot-segment
|
|
320
|
+
// guard `/files/:name` uses — see {@link GitService.isUntrackedFile} for why a
|
|
321
|
+
// path-shape rule is wrong in both directions once the target is a work tree.
|
|
322
|
+
// A path git does not report as untracked 404s, so the servable set is exactly
|
|
323
|
+
// the set the pane lists.
|
|
324
|
+
app.get("/git/file", {
|
|
325
|
+
schema: {
|
|
326
|
+
tags: ["Git"],
|
|
327
|
+
summary: "Get an untracked file's content from the project's working directory",
|
|
328
|
+
description: "Returns the content of a file git reports as UNTRACKED in the project's working directory — what the Changes tab renders in place of a diff for a brand-new file. `?path=` is the project-relative path; anything git does not currently report as untracked 404s. With `?raw=1` it streams raw bytes with the correct Content-Type (sandbox CSP + nosniff + inline disposition), how the pane loads a new image.",
|
|
329
|
+
params: {
|
|
330
|
+
type: "object",
|
|
331
|
+
properties: {
|
|
332
|
+
slug: { type: "string", description: "The project slug." },
|
|
333
|
+
},
|
|
334
|
+
required: ["slug"],
|
|
335
|
+
},
|
|
336
|
+
querystring: {
|
|
337
|
+
type: "object",
|
|
338
|
+
additionalProperties: true,
|
|
339
|
+
properties: {
|
|
340
|
+
path: { type: "string", description: "Project-relative path of the untracked file." },
|
|
341
|
+
raw: {
|
|
342
|
+
type: "string",
|
|
343
|
+
description: "Set to a truthy value (e.g. `1`) to stream raw bytes instead of JSON.",
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
}, async (req, reply) => {
|
|
349
|
+
try {
|
|
350
|
+
const project = await projects.get(req.params.slug);
|
|
351
|
+
const name = req.query.path ?? "";
|
|
352
|
+
if (!(await git.isUntrackedFile(project.workingDir, name))) {
|
|
353
|
+
throw new ProjectError(`File not found: ${name}`, "not_found");
|
|
354
|
+
}
|
|
355
|
+
if (req.query.raw !== undefined && req.query.raw !== "0" && req.query.raw !== "false") {
|
|
356
|
+
const { bytes, mime } = await readFileBytes(project.workingDir, name, "allow");
|
|
357
|
+
return reply
|
|
358
|
+
.header("content-type", mime)
|
|
359
|
+
.header("content-disposition", "inline")
|
|
360
|
+
.header("x-content-type-options", "nosniff")
|
|
361
|
+
.header("content-security-policy", "sandbox; default-src 'none'")
|
|
362
|
+
.header("cache-control", "private, max-age=60")
|
|
363
|
+
.send(bytes);
|
|
364
|
+
}
|
|
365
|
+
return await readFileWithKind(project.workingDir, name, "allow");
|
|
366
|
+
}
|
|
367
|
+
catch (err) {
|
|
368
|
+
return sendProjectError(reply, err);
|
|
369
|
+
}
|
|
370
|
+
});
|
|
239
371
|
}
|