@north-light/crouter 0.3.255 → 0.3.257
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/dist/api/client.d.ts +13 -1
- package/dist/api/client.js +17 -0
- package/dist/api/dto/worktree.d.ts +17 -0
- package/dist/api/routes.d.ts +1 -0
- package/dist/api/routes.js +1 -0
- package/dist/clients/attach/viewer.js +936 -797
- package/dist/commands/api-client.js +4 -0
- package/dist/commands/sys/worktrees.d.ts +1 -0
- package/dist/commands/sys/worktrees.js +53 -0
- package/dist/commands/sys.js +2 -1
- package/dist/core/__tests__/integration/worktree-land.test.js +71 -1
- package/dist/core/__tests__/integration/worktree-reap.test.js +101 -0
- package/dist/core/__tests__/worktree-landing.test.d.ts +1 -0
- package/dist/core/__tests__/worktree-landing.test.js +11 -0
- package/dist/core/canvas/canvas.js +24 -8
- package/dist/core/canvas/pid.d.ts +6 -0
- package/dist/core/canvas/pid.js +48 -3
- package/dist/core/canvas/types.d.ts +41 -1
- package/dist/core/exclusive-lock.d.ts +6 -0
- package/dist/core/exclusive-lock.js +12 -0
- package/dist/core/git.d.ts +1 -1
- package/dist/core/git.js +5 -1
- package/dist/core/runtime/fleet.d.ts +6 -0
- package/dist/core/worktree-landing.d.ts +44 -0
- package/dist/core/worktree-landing.js +56 -0
- package/dist/core/worktree-quarantine.d.ts +19 -0
- package/dist/core/worktree-quarantine.js +54 -0
- package/dist/core/worktree-sweep.d.ts +60 -0
- package/dist/core/worktree-sweep.js +493 -0
- package/dist/core/worktree.d.ts +37 -0
- package/dist/core/worktree.js +149 -23
- package/dist/daemon/__tests__/startup-block-marker.test.d.ts +1 -0
- package/dist/daemon/__tests__/startup-block-marker.test.js +86 -0
- package/dist/daemon/api/handlers/worktree.js +5 -0
- package/dist/daemon/crtrd-cli.js +12 -7
- package/dist/daemon/crtrd.js +6 -1
- package/dist/daemon/fleet.d.ts +1 -0
- package/dist/daemon/fleet.js +3 -0
- package/dist/daemon/manage.js +22 -1
- package/dist/daemon/reconcilers/managed-worktree-sweep.d.ts +24 -0
- package/dist/daemon/reconcilers/managed-worktree-sweep.js +190 -0
- package/dist/daemon/reconcilers/storage-maintenance.d.ts +11 -2
- package/dist/daemon/reconcilers/storage-maintenance.js +7 -2
- package/dist/daemon/startup-block-marker.d.ts +25 -0
- package/dist/daemon/startup-block-marker.js +99 -0
- package/dist/shared/generated-context.js +1 -1
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Tracked-but-modified and untracked paths from `git status --porcelain`. */
|
|
2
|
+
export interface CheckoutStatus {
|
|
3
|
+
/** Paths with modifications to a tracked file (staged, unstaged, or both). */
|
|
4
|
+
tracked: string[];
|
|
5
|
+
/** Paths Git does not track at all. */
|
|
6
|
+
untracked: string[];
|
|
7
|
+
}
|
|
8
|
+
/** Split `git status --porcelain` (v1 format) into tracked modifications and
|
|
9
|
+
* untracked paths. A rename entry (`R old -> new`) reports the destination:
|
|
10
|
+
* that is the path an incoming commit would collide with. */
|
|
11
|
+
export declare function parseStatusPorcelain(porcelain: string): CheckoutStatus;
|
|
12
|
+
export type LandPlan =
|
|
13
|
+
/** Nothing local overlaps the incoming commits — fast-forward directly. */
|
|
14
|
+
{
|
|
15
|
+
kind: 'direct';
|
|
16
|
+
}
|
|
17
|
+
/** Local modifications touch incoming files; land underneath them. */
|
|
18
|
+
| {
|
|
19
|
+
kind: 'stash';
|
|
20
|
+
overlapping: string[];
|
|
21
|
+
}
|
|
22
|
+
/** An incoming commit adds a path that already exists here, untracked. */
|
|
23
|
+
| {
|
|
24
|
+
kind: 'refuse';
|
|
25
|
+
collisions: string[];
|
|
26
|
+
};
|
|
27
|
+
/** How to fast-forward a base checkout that may hold local changes.
|
|
28
|
+
*
|
|
29
|
+
* Git is less fragile than it looks here: a fast-forward refuses only when an
|
|
30
|
+
* incoming commit touches a file with local modifications, so DISJOINT local
|
|
31
|
+
* changes are carried along fine and need no stash at all. Overlap is the
|
|
32
|
+
* blocker, not dirt.
|
|
33
|
+
*
|
|
34
|
+
* Untracked files are different. They also do not block a fast-forward unless
|
|
35
|
+
* an incoming commit adds the same path — and that collision is a refusal
|
|
36
|
+
* rather than a stash, because sweeping a person's untracked file into a stash
|
|
37
|
+
* to make room for a commit is not a trade this code may make on their behalf. */
|
|
38
|
+
export declare function planLandIntoCheckout(status: CheckoutStatus, incoming: readonly string[]): LandPlan;
|
|
39
|
+
/** The `git update-ref --stdin` body that deletes a managed branch ONLY while
|
|
40
|
+
* the ref that proved its content already landed is still at the exact SHA the
|
|
41
|
+
* proof inspected. Compare-and-delete is the whole ownership guard: a branch
|
|
42
|
+
* or base someone else moved between the proof and this transaction aborts it
|
|
43
|
+
* instead of losing the work. */
|
|
44
|
+
export declare function refDeleteTransactionStdin(branch: string, branchSha: string, containingRef: string, containingSha: string): string;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// The judgment shared by every path that lands a managed worktree onto its
|
|
2
|
+
// base branch. These functions run no subprocesses and touch no filesystem:
|
|
3
|
+
// they decide from Git output that the caller already has in hand.
|
|
4
|
+
//
|
|
5
|
+
// Two callers execute the same decision in two forms — `worktree.ts`'s
|
|
6
|
+
// synchronous `close` (an API handler, already blocking) and
|
|
7
|
+
// `worktree-sweep.ts`'s asynchronous daemon sweep (which must never block the
|
|
8
|
+
// event loop). Only the process plumbing differs; the rules that decide whether
|
|
9
|
+
// local work is at risk live here once, so the two can never drift.
|
|
10
|
+
/** Split `git status --porcelain` (v1 format) into tracked modifications and
|
|
11
|
+
* untracked paths. A rename entry (`R old -> new`) reports the destination:
|
|
12
|
+
* that is the path an incoming commit would collide with. */
|
|
13
|
+
export function parseStatusPorcelain(porcelain) {
|
|
14
|
+
const tracked = [];
|
|
15
|
+
const untracked = [];
|
|
16
|
+
for (const line of porcelain.split('\n')) {
|
|
17
|
+
if (line.length < 4)
|
|
18
|
+
continue;
|
|
19
|
+
const code = line.slice(0, 2);
|
|
20
|
+
const rest = line.slice(3);
|
|
21
|
+
const path = rest.includes(' -> ') ? rest.slice(rest.indexOf(' -> ') + 4) : rest;
|
|
22
|
+
const unquoted = path.startsWith('"') && path.endsWith('"') ? path.slice(1, -1) : path;
|
|
23
|
+
if (code === '??')
|
|
24
|
+
untracked.push(unquoted);
|
|
25
|
+
else if (code !== '!!')
|
|
26
|
+
tracked.push(unquoted);
|
|
27
|
+
}
|
|
28
|
+
return { tracked, untracked };
|
|
29
|
+
}
|
|
30
|
+
/** How to fast-forward a base checkout that may hold local changes.
|
|
31
|
+
*
|
|
32
|
+
* Git is less fragile than it looks here: a fast-forward refuses only when an
|
|
33
|
+
* incoming commit touches a file with local modifications, so DISJOINT local
|
|
34
|
+
* changes are carried along fine and need no stash at all. Overlap is the
|
|
35
|
+
* blocker, not dirt.
|
|
36
|
+
*
|
|
37
|
+
* Untracked files are different. They also do not block a fast-forward unless
|
|
38
|
+
* an incoming commit adds the same path — and that collision is a refusal
|
|
39
|
+
* rather than a stash, because sweeping a person's untracked file into a stash
|
|
40
|
+
* to make room for a commit is not a trade this code may make on their behalf. */
|
|
41
|
+
export function planLandIntoCheckout(status, incoming) {
|
|
42
|
+
const arriving = new Set(incoming);
|
|
43
|
+
const collisions = status.untracked.filter((path) => arriving.has(path));
|
|
44
|
+
if (collisions.length > 0)
|
|
45
|
+
return { kind: 'refuse', collisions };
|
|
46
|
+
const overlapping = status.tracked.filter((path) => arriving.has(path));
|
|
47
|
+
return overlapping.length > 0 ? { kind: 'stash', overlapping } : { kind: 'direct' };
|
|
48
|
+
}
|
|
49
|
+
/** The `git update-ref --stdin` body that deletes a managed branch ONLY while
|
|
50
|
+
* the ref that proved its content already landed is still at the exact SHA the
|
|
51
|
+
* proof inspected. Compare-and-delete is the whole ownership guard: a branch
|
|
52
|
+
* or base someone else moved between the proof and this transaction aborts it
|
|
53
|
+
* instead of losing the work. */
|
|
54
|
+
export function refDeleteTransactionStdin(branch, branchSha, containingRef, containingSha) {
|
|
55
|
+
return `verify ${containingRef} ${containingSha}\ndelete refs/heads/${branch} ${branchSha}\n`;
|
|
56
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface QuarantinedWorktree {
|
|
2
|
+
node_id: string;
|
|
3
|
+
path: string;
|
|
4
|
+
branch: string;
|
|
5
|
+
repo_root: string;
|
|
6
|
+
/** Structured code for the refusal that quarantined this record. */
|
|
7
|
+
reason: string;
|
|
8
|
+
detail?: string;
|
|
9
|
+
/** Consecutive sweep passes that examined it without completing cleanup. */
|
|
10
|
+
attempts: number;
|
|
11
|
+
/** ISO timestamp of the most recent examination. */
|
|
12
|
+
last_attempt: string;
|
|
13
|
+
/** Bytes the checkout occupies, or null when the path is gone or unreadable. */
|
|
14
|
+
size_bytes: number | null;
|
|
15
|
+
}
|
|
16
|
+
/** Every managed worktree the sweep has flagged as needing attention, oldest
|
|
17
|
+
* refusal first. Reads node records only — a checkout crtr did not create has
|
|
18
|
+
* no node record and is never reported. */
|
|
19
|
+
export declare function listQuarantinedManagedWorktrees(): Promise<QuarantinedWorktree[]>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Read-only inventory of managed worktrees the daemon sweep could not
|
|
2
|
+
// reconcile. Its whole purpose is turning an invisible accumulation into a
|
|
3
|
+
// short, explained list a human can act on, so it reports the recorded refusal
|
|
4
|
+
// reason alongside the resources still held: the checkout path, its branch, and
|
|
5
|
+
// what that checkout costs on disk.
|
|
6
|
+
//
|
|
7
|
+
// Nothing here mutates. Disposal belongs to the sweep
|
|
8
|
+
// (`worktree-sweep.ts`), which is the one authority for removing a checkout.
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { existsSync } from 'node:fs';
|
|
11
|
+
import { getNode, listNodes } from './canvas/index.js';
|
|
12
|
+
/** Disk usage of one directory in bytes, or null when it cannot be measured.
|
|
13
|
+
* `du` is spawned asynchronously: a managed checkout can be tens of gigabytes,
|
|
14
|
+
* and this runs inside the daemon, which must never block its event loop. */
|
|
15
|
+
async function directorySizeBytes(path) {
|
|
16
|
+
if (!existsSync(path))
|
|
17
|
+
return null;
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
const child = spawn('du', ['-sk', path]);
|
|
20
|
+
let stdout = '';
|
|
21
|
+
child.stdout.on('data', (d) => (stdout += d.toString()));
|
|
22
|
+
child.stderr.on('data', () => { });
|
|
23
|
+
child.on('error', () => resolve(null));
|
|
24
|
+
child.on('close', (status) => {
|
|
25
|
+
if (status !== 0)
|
|
26
|
+
return resolve(null);
|
|
27
|
+
const kb = Number(stdout.trim().split(/\s+/)[0]);
|
|
28
|
+
resolve(Number.isSafeInteger(kb) ? kb * 1024 : null);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
/** Every managed worktree the sweep has flagged as needing attention, oldest
|
|
33
|
+
* refusal first. Reads node records only — a checkout crtr did not create has
|
|
34
|
+
* no node record and is never reported. */
|
|
35
|
+
export async function listQuarantinedManagedWorktrees() {
|
|
36
|
+
const found = [];
|
|
37
|
+
for (const row of listNodes()) {
|
|
38
|
+
const wt = getNode(row.node_id)?.managed_worktree;
|
|
39
|
+
if (wt?.sweep?.quarantined !== true)
|
|
40
|
+
continue;
|
|
41
|
+
found.push({
|
|
42
|
+
node_id: row.node_id,
|
|
43
|
+
path: wt.path,
|
|
44
|
+
branch: wt.branch,
|
|
45
|
+
repo_root: wt.repo_root,
|
|
46
|
+
reason: wt.sweep.reason,
|
|
47
|
+
...(wt.sweep.detail === undefined ? {} : { detail: wt.sweep.detail }),
|
|
48
|
+
attempts: wt.sweep.attempts,
|
|
49
|
+
last_attempt: wt.sweep.last_attempt,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
found.sort((a, b) => a.last_attempt.localeCompare(b.last_attempt));
|
|
53
|
+
return Promise.all(found.map(async (entry) => ({ ...entry, size_bytes: await directorySizeBytes(entry.path) })));
|
|
54
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type ManagedWorktree } from './canvas/index.js';
|
|
2
|
+
export type SweepOutcome =
|
|
3
|
+
/** The checkout and branch are gone and the record is marked complete. */
|
|
4
|
+
{
|
|
5
|
+
status: 'complete';
|
|
6
|
+
stash?: string;
|
|
7
|
+
}
|
|
8
|
+
/** Nothing to do: no record, already reconciled, or the node came back. */
|
|
9
|
+
| {
|
|
10
|
+
status: 'skipped';
|
|
11
|
+
reason: string;
|
|
12
|
+
}
|
|
13
|
+
/** A safety rule says this record must survive. Visible, retried, never fatal. */
|
|
14
|
+
| {
|
|
15
|
+
status: 'refused';
|
|
16
|
+
reason: string;
|
|
17
|
+
detail?: string;
|
|
18
|
+
unchanged?: true;
|
|
19
|
+
}
|
|
20
|
+
/** Git or the repository could not answer. Transient; retried. */
|
|
21
|
+
| {
|
|
22
|
+
status: 'failed';
|
|
23
|
+
reason: string;
|
|
24
|
+
detail?: string;
|
|
25
|
+
};
|
|
26
|
+
/** A record this sweep still owns: open, or closed without proven cleanup. An
|
|
27
|
+
* abandoned record is deliberately out of scope — abandon already removed the
|
|
28
|
+
* checkout and retains its branch for a person on purpose. */
|
|
29
|
+
export declare function isReconcilable(wt: ManagedWorktree | null | undefined): boolean;
|
|
30
|
+
/** Whether the sweep should examine this record on a pass starting at `now`. A
|
|
31
|
+
* record with no recorded refusal is always due. */
|
|
32
|
+
export declare function isDueForSweep(wt: ManagedWorktree, now: number): boolean;
|
|
33
|
+
export interface ReconcileGuard {
|
|
34
|
+
/** Is the owning node STILL gone? Consulted synchronously immediately before
|
|
35
|
+
* each Git mutation and before any metadata write — not once per pass.
|
|
36
|
+
*
|
|
37
|
+
* The repository lock serializes worktree operations, NOT node lifecycle: a
|
|
38
|
+
* revive can land in any of the awaits between the proof and the removal,
|
|
39
|
+
* and it would then be running in the very checkout about to be deleted. So
|
|
40
|
+
* every destructive step re-asks, and a false answer stops the pass where it
|
|
41
|
+
* stands, leaving the record incomplete for a later pass to resume. The
|
|
42
|
+
* implementation must not spawn a subprocess: this runs inside crtrd. */
|
|
43
|
+
stillEligible: () => boolean;
|
|
44
|
+
}
|
|
45
|
+
/** Dispose of one node's managed worktree, or explain why it must survive.
|
|
46
|
+
*
|
|
47
|
+
* Ownership is proved from crouter's own node record, never from
|
|
48
|
+
* `git worktree list`: a checkout the user or another tool created has no node
|
|
49
|
+
* record and is never reachable from here. Under the repository lock the
|
|
50
|
+
* recorded path is confirmed REGISTERED to the recorded branch — a path match
|
|
51
|
+
* alone is not ownership — and removal is `git worktree remove <recorded
|
|
52
|
+
* path>` without `--force`. `git worktree prune` is never run: it is
|
|
53
|
+
* repo-wide and would touch administrative entries for worktrees crouter does
|
|
54
|
+
* not own.
|
|
55
|
+
*
|
|
56
|
+
* Effects are ordered checkout → branch → metadata so every interruption is
|
|
57
|
+
* resumable: a crash before removal retries from pending; after removal but
|
|
58
|
+
* before the branch delete, the branch is still an intact ref the next pass
|
|
59
|
+
* finds; after both, the next pass observes both absent and marks complete. */
|
|
60
|
+
export declare function reconcileManagedWorktree(nodeId: string, guard: ReconcileGuard, now?: number): Promise<SweepOutcome>;
|