cyberlegion 0.3.1 → 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
@@ -1,6 +1,5 @@
1
1
  import { Exec, Exec as Exec$1, MuxAdapter, MuxPlacement, MuxProbe, NudgeOptions, probeMultiplexer } from "cyber-mux";
2
2
  import { assertDistinctFromPrimary, gitWorktreeAdapter, resolvePrimaryRoot } from "cyber-mux/worktree";
3
-
4
3
  //#region src/store/store.d.ts
5
4
  interface Message {
6
5
  id: string;
@@ -56,8 +55,15 @@ interface AgentRecord {
56
55
  brief?: string;
57
56
  spawnedBy?: string;
58
57
  /** Absent ⇒ session (backward compat, no migration). 'standing' = a session-independent,
59
- * prune-exempt owner inbox minted by `unit register --standing`. */
60
- 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
+ };
61
67
  /** Only meaningful on a `kind: 'standing'` record — the id of the unit currently standing in for
62
68
  * it (`unit claim`), a per-record singleton pointer that moves as the principal moves between
63
69
  * units (last claim wins). Resolved live, never trusted: a presence unit that has exited reads as
@@ -65,6 +71,42 @@ interface AgentRecord {
65
71
  * stays inert until re-claimed. Independent of the hub's `mainPane` (the human's read-pane). */
66
72
  presence?: string;
67
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
+ }
68
110
  interface InboxSnapshot {
69
111
  unread: Message[];
70
112
  read: Message[];
@@ -90,6 +132,14 @@ interface Store {
90
132
  listAgents(): AgentRecord[];
91
133
  removeAgent(id: string): void;
92
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;
93
143
  putPaneIndex(pane: string, agentId: string): void;
94
144
  resolvePaneId(pane: string): string | undefined;
95
145
  /** Reverse-lookup: an agent's pane from the index, when the agent record itself carries none
@@ -103,6 +153,15 @@ interface Store {
103
153
  setMainPane(pane: string | null): void;
104
154
  /** The hub's currently bound main pane, or undefined when none is bound. */
105
155
  getMainPane(): string | undefined;
156
+ /** Run `fn` while holding an exclusive, named advisory lock scoped to this store's root —
157
+ * mutual exclusion for a genuine read-modify-write (read current state, decide, write back),
158
+ * which an atomic single-file write alone can't make safe against a concurrent racer. Blocks
159
+ * until acquired or throws `LockTimeoutError` (`store/lock.ts`) after a bounded wait; never
160
+ * steals a lock a live holder still holds — only one abandoned by a confirmed-dead process.
161
+ * `setMainPane` uses this internally; callers doing their own load-mutate-save against this
162
+ * store (e.g. a standing record's presence rebind) should hold the SAME named lock around that
163
+ * whole sequence, not just around the final write. */
164
+ withLock<T>(name: string, fn: () => T): T;
106
165
  }
107
166
  //#endregion
108
167
  //#region src/identity.d.ts
@@ -202,18 +261,33 @@ interface DecommissionInput {
202
261
  id: string;
203
262
  /** Discard uncommitted changes in the worktree. Never overrides refusing the primary checkout. */
204
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;
205
270
  }
206
271
  interface DecommissionResult {
207
272
  agent: AgentRecord;
208
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;
209
280
  pane?: string;
210
281
  }
211
282
  /**
212
283
  * Tear a unit down and reap its registry record — the deterministic inverse of `spawn`. Refuses
213
- * the primary checkout (absolute — `--force` never overrides it) and a dirty worktree unless
214
- * `--force`. Teardown always precedes reap: an already-gone worktree or pane is tolerated, but a
215
- * genuine worktree-removal failure aborts and leaves the record intact so the operation is
216
- * 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`.
217
291
  */
218
292
  declare function decommission(ctx: IdContext, input: DecommissionInput): DecommissionResult;
219
293
  //#endregion
@@ -364,7 +438,177 @@ declare const paths: {
364
438
  dataDir: (root: string, id: string) => string;
365
439
  briefFile: (root: string, id: string) => string;
366
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;
444
+ /** A message's file path within `toId`'s unread/read inbox dir, keyed by its own collision-free
445
+ * id — validated the same as an agent id (it's the same class of risk: a peer- or CLI-controlled
446
+ * string becoming a filename). */
447
+ messageFile: (root: string, toId: string, msgId: string) => string;
448
+ messageReadFile: (root: string, toId: string, msgId: string) => string;
449
+ };
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;
367
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>>;
368
612
  //#endregion
369
613
  //#region src/session.d.ts
370
614
  interface SpawnInput {
@@ -445,6 +689,11 @@ declare class FileStore implements Store {
445
689
  listAgents(): AgentRecord[];
446
690
  removeAgent(id: string): void;
447
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;
448
697
  putPaneIndex(pane: string, agentId: string): void;
449
698
  resolvePaneId(pane: string): string | undefined;
450
699
  findPaneByAgentId(agentId: string): string | undefined;
@@ -453,6 +702,7 @@ declare class FileStore implements Store {
453
702
  readBrief(agentId: string): string | undefined;
454
703
  setMainPane(pane: string | null): void;
455
704
  getMainPane(): string | undefined;
705
+ withLock<T>(name: string, fn: () => T): T;
456
706
  }
457
707
  //#endregion
458
708
  //#region src/wake/await.d.ts
@@ -507,4 +757,4 @@ interface WatchOptions {
507
757
  */
508
758
  declare function watchMail(ctx: MsgContext, input: WatchInput, onMessage: (msg: InboxItem) => void, opts?: WatchOptions): Promise<void>;
509
759
  //#endregion
510
- 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 };