cyberlegion 0.3.2 → 0.4.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/dist/index.d.mts CHANGED
@@ -55,8 +55,15 @@ interface AgentRecord {
55
55
  brief?: string;
56
56
  spawnedBy?: string;
57
57
  /** Absent ⇒ session (backward compat, no migration). 'standing' = a session-independent,
58
- * prune-exempt owner inbox minted by `unit register --standing`. */
59
- kind?: 'session' | 'standing';
58
+ * prune-exempt owner inbox minted by `unit register --standing`. 'service' = a project service's
59
+ * endpoint (`service.ts`): session-independent and prune-exempt like a standing record, but never
60
+ * surfaced as owner mail — its mailbox belongs to whichever unit currently owns the service. */
61
+ kind?: 'session' | 'standing' | 'service';
62
+ /** Only meaningful on a `kind: 'service'` record — which project service this endpoint is. */
63
+ service?: {
64
+ project: string;
65
+ name: string;
66
+ };
60
67
  /** Only meaningful on a `kind: 'standing'` record — the id of the unit currently standing in for
61
68
  * it (`unit claim`), a per-record singleton pointer that moves as the principal moves between
62
69
  * units (last claim wins). Resolved live, never trusted: a presence unit that has exited reads as
@@ -64,6 +71,42 @@ interface AgentRecord {
64
71
  * stays inert until re-claimed. Independent of the hub's `mainPane` (the human's read-pane). */
65
72
  presence?: string;
66
73
  }
74
+ /** A registered project: one git repository, shared by its default checkout and every linked
75
+ * worktree of it. Keyed by its git common dir, never by a pane or a display name. */
76
+ interface ProjectRecord {
77
+ /** Stable reference derived from the canonical git common dir (`project.ts`). */
78
+ id: string;
79
+ /** Display name — the default checkout's directory basename. Not unique; never a key. */
80
+ name: string;
81
+ /** The default checkout's root. */
82
+ root: string;
83
+ /** The canonical (realpath) git common dir the id is derived from. */
84
+ commonDir: string;
85
+ registeredAt: string;
86
+ }
87
+ /** A project service's ownership lease (`service.ts`) — who authoritatively owns the service now,
88
+ * persisted independently of any runtime so ownership survives the runtime that held it. */
89
+ interface ServiceLease {
90
+ project: string;
91
+ service: string;
92
+ /** The service endpoint's record id — the durable mailbox and the stable reference. */
93
+ endpoint: string;
94
+ /** Fencing generation: bumped on every change of authority (a new reservation or a handoff), so a
95
+ * runtime holding an older generation is provably stale. */
96
+ generation: number;
97
+ /** `vacant` — no owner, no start in progress. `reserved` — a start is in progress (or failed and
98
+ * not yet expired). `active` — `holder` owns the service at `generation`. */
99
+ state: 'vacant' | 'reserved' | 'active';
100
+ holder?: string;
101
+ reservation?: {
102
+ token: string;
103
+ by?: string;
104
+ at: string;
105
+ expiresAt: string;
106
+ forced?: boolean;
107
+ };
108
+ updatedAt: string;
109
+ }
67
110
  interface InboxSnapshot {
68
111
  unread: Message[];
69
112
  read: Message[];
@@ -89,6 +132,14 @@ interface Store {
89
132
  listAgents(): AgentRecord[];
90
133
  removeAgent(id: string): void;
91
134
  removeAgentData(id: string): void;
135
+ /** Upsert a project record (keyed by `rec.id`). */
136
+ putProject(rec: ProjectRecord): void;
137
+ getProject(id: string): ProjectRecord | undefined;
138
+ listProjects(): ProjectRecord[];
139
+ /** Upsert a service lease (keyed by `lease.project` + `lease.service`). Callers mutate a lease only
140
+ * while holding that service's named lock (`service.ts`). */
141
+ putServiceLease(lease: ServiceLease): void;
142
+ getServiceLease(project: string, service: string): ServiceLease | undefined;
92
143
  putPaneIndex(pane: string, agentId: string): void;
93
144
  resolvePaneId(pane: string): string | undefined;
94
145
  /** Reverse-lookup: an agent's pane from the index, when the agent record itself carries none
@@ -210,18 +261,33 @@ interface DecommissionInput {
210
261
  id: string;
211
262
  /** Discard uncommitted changes in the worktree. Never overrides refusing the primary checkout. */
212
263
  force?: boolean;
264
+ /**
265
+ * Reap the unit but leave its worktree on disk, so a pool can detach it and hand it to the next
266
+ * unit — far cheaper than a fresh checkout per unit. Never overrides refusing the primary
267
+ * checkout: that guard is about identity, not destructiveness.
268
+ */
269
+ keepWorktree?: boolean;
213
270
  }
214
271
  interface DecommissionResult {
215
272
  agent: AgentRecord;
216
273
  worktreeRoot?: string;
274
+ /**
275
+ * The worktree left on disk by `keepWorktree` — set only when a worktree was actually there to
276
+ * keep, so a pool manager can treat its presence as "this path is reusable" rather than
277
+ * re-checking `worktreeRoot` against disk.
278
+ */
279
+ retainedWorktree?: string;
217
280
  pane?: string;
218
281
  }
219
282
  /**
220
283
  * Tear a unit down and reap its registry record — the deterministic inverse of `spawn`. Refuses
221
- * the primary checkout (absolute — `--force` never overrides it) and a dirty worktree unless
222
- * `--force`. Teardown always precedes reap: an already-gone worktree or pane is tolerated, but a
223
- * genuine worktree-removal failure aborts and leaves the record intact so the operation is
224
- * retryable.
284
+ * the primary checkout (absolute — neither `--force` nor `--keep-worktree` overrides it) and a
285
+ * dirty worktree unless `--force`. Teardown always precedes reap: an already-gone worktree or pane
286
+ * is tolerated, but a genuine worktree-removal failure aborts and leaves the record intact so the
287
+ * operation is retryable.
288
+ *
289
+ * `keepWorktree` reaps everything else — pane, record, pane pointer, brief — and leaves the
290
+ * checkout on disk, reporting it as `retainedWorktree`.
225
291
  */
226
292
  declare function decommission(ctx: IdContext, input: DecommissionInput): DecommissionResult;
227
293
  //#endregion
@@ -372,6 +438,9 @@ declare const paths: {
372
438
  dataDir: (root: string, id: string) => string;
373
439
  briefFile: (root: string, id: string) => string;
374
440
  mainPaneFile: (root: string) => string;
441
+ projectsDir: (root: string) => string;
442
+ serviceLeaseFile: (root: string, project: string, service: string) => string;
443
+ projectFile: (root: string, id: string) => string;
375
444
  /** A message's file path within `toId`'s unread/read inbox dir, keyed by its own collision-free
376
445
  * id — validated the same as an agent id (it's the same class of risk: a peer- or CLI-controlled
377
446
  * string becoming a filename). */
@@ -379,6 +448,168 @@ declare const paths: {
379
448
  messageReadFile: (root: string, toId: string, msgId: string) => string;
380
449
  };
381
450
  //#endregion
451
+ //#region src/project.d.ts
452
+ interface ProjectContext {
453
+ store: Store;
454
+ exec?: Exec;
455
+ now?: () => number;
456
+ }
457
+ /**
458
+ * Register (or idempotently refresh) the project containing `dir` — any checkout of it, default or
459
+ * linked. Keeps the first `registeredAt`, so re-registering from a worktree is a no-op in effect.
460
+ */
461
+ declare function registerProject(ctx: ProjectContext, input?: {
462
+ dir?: string;
463
+ }): ProjectRecord;
464
+ declare function listProjects(store: Store): ProjectRecord[];
465
+ /**
466
+ * Resolve a project from anywhere — by id, by a path inside any of its checkouts, or by name when
467
+ * exactly one registered project carries it. A path registers its project on first use: git has
468
+ * already confirmed it is a repository, and registering is idempotent, so there is nothing a typo
469
+ * could mint. A name never registers — nothing can be found by a name no checkout has reported —
470
+ * and an ambiguous name names its candidates rather than picking one.
471
+ */
472
+ declare function resolveProject(ctx: ProjectContext, ref: string): ProjectRecord;
473
+ //#endregion
474
+ //#region src/service.d.ts
475
+ interface ServiceContext extends IdContext {
476
+ /** Whether a unit's session is still there. Defaults to asking its multiplexer (`sessionLive`). */
477
+ isLive?: (unit: AgentRecord) => boolean;
478
+ }
479
+ type ServiceHealth = 'vacant' | 'starting' | 'expired' | 'healthy' | 'unhealthy';
480
+ interface ServiceView {
481
+ project: ProjectRecord;
482
+ endpoint: AgentRecord;
483
+ lease: ServiceLease;
484
+ /** The owning unit's record, when the lease is active and the record still exists. */
485
+ owner?: AgentRecord;
486
+ health: ServiceHealth;
487
+ /** Whether the owner's session can be controlled (focused, nudged, read) — `pane` when it has a
488
+ * multiplexer pane, `none` otherwise. Resolving the address never implies control. */
489
+ control: 'pane' | 'none';
490
+ /** A human-readable qualification of `health`/`control`, when there is one. */
491
+ note?: string;
492
+ }
493
+ type AcquireResult = {
494
+ outcome: 'resolved';
495
+ lease: ServiceLease;
496
+ view: ServiceView;
497
+ } | {
498
+ outcome: 'starting';
499
+ lease: ServiceLease;
500
+ view: ServiceView;
501
+ } | {
502
+ outcome: 'reserved';
503
+ lease: ServiceLease;
504
+ view: ServiceView;
505
+ token: string;
506
+ };
507
+ type OwnershipErrorCode = 'stale' | 'unit-not-live' | 'unknown-unit';
508
+ /** A refused ownership transition. `stale` means the caller's generation, token, or holder claim is
509
+ * not the current one — the caller is not (or no longer) the authority it claims to be. */
510
+ declare class ServiceOwnershipError extends Error {
511
+ readonly code: OwnershipErrorCode;
512
+ constructor(code: OwnershipErrorCode, message: string);
513
+ }
514
+ /** How long a reservation holds before another caller may take over a start that never bound. */
515
+ declare const DEFAULT_RESERVATION_TTL_MS: number;
516
+ declare function serviceEndpointId(projectId: string, name: string): string;
517
+ /** Read a service's ownership without changing anything. Throws for a service never started. */
518
+ declare function resolveService(ctx: ServiceContext, projectRef: string, name: string): ServiceView;
519
+ interface AcquireInput {
520
+ /** Who is starting the service — recorded on the reservation for diagnosis. */
521
+ by?: string;
522
+ ttlMs?: number;
523
+ /** Replace even a healthy owner or an unexpired reservation. Must name the current generation, so
524
+ * a force decided on a stale read fails instead of displacing an owner the caller never saw. */
525
+ force?: {
526
+ generation: number;
527
+ };
528
+ }
529
+ /**
530
+ * Contact-or-start: resolve the service's healthy owner, or reserve the right to start one. Exactly
531
+ * one of any number of concurrent callers gets `reserved`; the rest see `starting` (a start is in
532
+ * progress) or `resolved`. A vacant service, an unhealthy owner, and an expired reservation are all
533
+ * reservable, each under a new generation that fences out whatever held the old one.
534
+ */
535
+ declare function acquireService(ctx: ServiceContext, projectRef: string, name: string, input?: AcquireInput): AcquireResult;
536
+ /**
537
+ * Complete a reservation: make `unit` the owner at the reserved generation. Refused when the
538
+ * reservation is no longer the current one (it expired and someone else re-reserved, or it was
539
+ * released) — the starter then holds a runtime that is not the authority and must stop it. A
540
+ * reservation that expired with nobody taking over is still bindable: nothing replaced it.
541
+ */
542
+ declare function bindService(ctx: ServiceContext, projectRef: string, name: string, input: {
543
+ generation: number;
544
+ token: string;
545
+ unit: string;
546
+ }): ServiceView;
547
+ /**
548
+ * Give the service up: abandon a reservation (a failed start — pass its `token`) or step down as
549
+ * owner (pass the holder `unit`). Either must match the current generation. The generation is kept;
550
+ * the next acquire bumps it.
551
+ */
552
+ declare function releaseService(ctx: ServiceContext, projectRef: string, name: string, input: {
553
+ generation: number;
554
+ token?: string;
555
+ unit?: string;
556
+ }): ServiceView;
557
+ /**
558
+ * Transfer ownership from the current holder to another live unit, under a new generation. Only the
559
+ * holder at the current generation can hand off, and afterwards it is stale.
560
+ */
561
+ declare function handoffService(ctx: ServiceContext, projectRef: string, name: string, input: {
562
+ generation: number;
563
+ from: string;
564
+ to: string;
565
+ }): ServiceView;
566
+ /**
567
+ * The fencing check: succeed only when `unit` owns the service at exactly `generation`. A runtime
568
+ * calls this before acting as the service's authority; a stale runtime — replaced, handed off, or
569
+ * recovered past — is refused.
570
+ */
571
+ declare function verifyOwnership(ctx: ServiceContext, projectRef: string, name: string, input: {
572
+ unit: string;
573
+ generation: number;
574
+ }): ServiceView;
575
+ /**
576
+ * Run `fn` as the verified owner, holding the service lock so no transition can interleave between
577
+ * the check and the act. For short critical sections only — the lock is bounded-wait for everyone
578
+ * else — and never reentrant: `fn` must not call another service transition on the same service.
579
+ */
580
+ declare function withOwnership<T>(ctx: ServiceContext, projectRef: string, name: string, input: {
581
+ unit: string;
582
+ generation: number;
583
+ }, fn: (view: ServiceView) => T): T;
584
+ interface StartInput<T extends {
585
+ unit: string;
586
+ }> extends AcquireInput {
587
+ /** Launch the runtime that will own the service, returning its unit id. Called only by the one
588
+ * caller holding the reservation; `generation` is the generation that runtime will own at. */
589
+ launch: (reservation: {
590
+ generation: number;
591
+ endpoint: string;
592
+ }) => Promise<T>;
593
+ }
594
+ type StartResult<T> = {
595
+ outcome: 'resolved' | 'starting';
596
+ view: ServiceView;
597
+ } | {
598
+ outcome: 'started';
599
+ view: ServiceView;
600
+ launched: T;
601
+ };
602
+ /**
603
+ * Resolve-or-start end to end: acquire, and only when this caller wins the reservation, launch a
604
+ * runtime and bind it. A launch that throws releases the reservation (best-effort — an expiry covers
605
+ * a release that itself fails) so the start is retryable at once. A launch that finishes after its
606
+ * reservation was superseded is refused at bind: the launched unit is not the owner, and the error
607
+ * names it so the caller can stop it.
608
+ */
609
+ declare function startService<T extends {
610
+ unit: string;
611
+ }>(ctx: ServiceContext, projectRef: string, name: string, input: StartInput<T>): Promise<StartResult<T>>;
612
+ //#endregion
382
613
  //#region src/session.d.ts
383
614
  interface SpawnInput {
384
615
  harness?: string;
@@ -458,6 +689,11 @@ declare class FileStore implements Store {
458
689
  listAgents(): AgentRecord[];
459
690
  removeAgent(id: string): void;
460
691
  removeAgentData(id: string): void;
692
+ putProject(rec: ProjectRecord): void;
693
+ getProject(id: string): ProjectRecord | undefined;
694
+ listProjects(): ProjectRecord[];
695
+ putServiceLease(lease: ServiceLease): void;
696
+ getServiceLease(project: string, service: string): ServiceLease | undefined;
461
697
  putPaneIndex(pane: string, agentId: string): void;
462
698
  resolvePaneId(pane: string): string | undefined;
463
699
  findPaneByAgentId(agentId: string): string | undefined;
@@ -521,4 +757,4 @@ interface WatchOptions {
521
757
  */
522
758
  declare function watchMail(ctx: MsgContext, input: WatchInput, onMessage: (msg: InboxItem) => void, opts?: WatchOptions): Promise<void>;
523
759
  //#endregion
524
- export { type AgentDef, type AgentRecord, type AwaitInput, type AwaitOutcome, type DecommissionInput, type DecommissionResult, type Exec, FileStore, type Format, type Harness, type HookEvent, type IdContext, type InboxItem, type InboxQuery, type InboxSnapshot, type Message, type MuxProbe, type RegisterInput, type RootOptions, type SendInput, type SpawnInput, type SpawnResult, type Store, ack, assertDistinctFromPrimary, awaitReply, bumpLastSeen, decommission, deleteMessage, detectHarness, emit, ensureMarker, fail, gitWorktreeAdapter, inbox, injectInbox, install, listAgentDefs, listAgents, loadAgent, nextStep, paths, peek, probeMultiplexer, projectRoot, prune, realExec, realizeLaunch, register, resolveAgent, resolveAgentDef, resolveBody, resolvePrimaryRoot, resolveProjectLocalRoot, resolveRecipient, resolveRoot, resolveSelfId, selectSessionAdapter, send, spawn, spawnAndWake, toonList, toonObject, touch, watchMail };
760
+ export { type AcquireInput, type AcquireResult, type AgentDef, type AgentRecord, type AwaitInput, type AwaitOutcome, DEFAULT_RESERVATION_TTL_MS, type DecommissionInput, type DecommissionResult, type Exec, FileStore, type Format, type Harness, type HookEvent, type IdContext, type InboxItem, type InboxQuery, type InboxSnapshot, type Message, type MuxProbe, type OwnershipErrorCode, type ProjectContext, type ProjectRecord, type RegisterInput, type RootOptions, type SendInput, type ServiceContext, type ServiceHealth, type ServiceLease, ServiceOwnershipError, type ServiceView, type SpawnInput, type SpawnResult, type StartInput, type StartResult, type Store, ack, acquireService, assertDistinctFromPrimary, awaitReply, bindService, bumpLastSeen, decommission, deleteMessage, detectHarness, emit, ensureMarker, fail, gitWorktreeAdapter, handoffService, inbox, injectInbox, install, listAgentDefs, listAgents, listProjects, loadAgent, nextStep, paths, peek, probeMultiplexer, projectRoot, prune, realExec, realizeLaunch, register, registerProject, releaseService, resolveAgent, resolveAgentDef, resolveBody, resolvePrimaryRoot, resolveProject, resolveProjectLocalRoot, resolveRecipient, resolveRoot, resolveSelfId, resolveService, selectSessionAdapter, send, serviceEndpointId, spawn, spawnAndWake, startService, toonList, toonObject, touch, verifyOwnership, watchMail, withOwnership };