@north-light/crouter 0.3.255 → 0.3.256

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.
Files changed (47) hide show
  1. package/dist/api/client.d.ts +13 -1
  2. package/dist/api/client.js +17 -0
  3. package/dist/api/dto/worktree.d.ts +17 -0
  4. package/dist/api/routes.d.ts +1 -0
  5. package/dist/api/routes.js +1 -0
  6. package/dist/clients/attach/viewer.js +936 -797
  7. package/dist/commands/api-client.js +4 -0
  8. package/dist/commands/sys/worktrees.d.ts +1 -0
  9. package/dist/commands/sys/worktrees.js +53 -0
  10. package/dist/commands/sys.js +2 -1
  11. package/dist/core/__tests__/integration/worktree-land.test.js +71 -1
  12. package/dist/core/__tests__/integration/worktree-reap.test.js +101 -0
  13. package/dist/core/__tests__/worktree-landing.test.d.ts +1 -0
  14. package/dist/core/__tests__/worktree-landing.test.js +11 -0
  15. package/dist/core/canvas/canvas.js +24 -8
  16. package/dist/core/canvas/pid.d.ts +6 -0
  17. package/dist/core/canvas/pid.js +48 -3
  18. package/dist/core/canvas/types.d.ts +41 -1
  19. package/dist/core/exclusive-lock.d.ts +6 -0
  20. package/dist/core/exclusive-lock.js +12 -0
  21. package/dist/core/git.d.ts +1 -1
  22. package/dist/core/git.js +5 -1
  23. package/dist/core/runtime/fleet.d.ts +6 -0
  24. package/dist/core/worktree-landing.d.ts +44 -0
  25. package/dist/core/worktree-landing.js +56 -0
  26. package/dist/core/worktree-quarantine.d.ts +19 -0
  27. package/dist/core/worktree-quarantine.js +54 -0
  28. package/dist/core/worktree-sweep.d.ts +60 -0
  29. package/dist/core/worktree-sweep.js +493 -0
  30. package/dist/core/worktree.d.ts +37 -0
  31. package/dist/core/worktree.js +149 -23
  32. package/dist/daemon/__tests__/startup-block-marker.test.d.ts +1 -0
  33. package/dist/daemon/__tests__/startup-block-marker.test.js +86 -0
  34. package/dist/daemon/api/handlers/worktree.js +5 -0
  35. package/dist/daemon/crtrd-cli.js +12 -7
  36. package/dist/daemon/crtrd.js +6 -1
  37. package/dist/daemon/fleet.d.ts +1 -0
  38. package/dist/daemon/fleet.js +3 -0
  39. package/dist/daemon/manage.js +22 -1
  40. package/dist/daemon/reconcilers/managed-worktree-sweep.d.ts +24 -0
  41. package/dist/daemon/reconcilers/managed-worktree-sweep.js +190 -0
  42. package/dist/daemon/reconcilers/storage-maintenance.d.ts +11 -2
  43. package/dist/daemon/reconcilers/storage-maintenance.js +7 -2
  44. package/dist/daemon/startup-block-marker.d.ts +25 -0
  45. package/dist/daemon/startup-block-marker.js +99 -0
  46. package/package.json +1 -1
  47. 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>;