@maiaos/client-core 0.0.0-stage → 0.1.0-nightly.20261024.5
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/README.md +124 -2
- package/dist/boot/boot.d.ts +63 -0
- package/dist/boot/boot.js +86 -0
- package/dist/boot/index.d.ts +3 -0
- package/dist/boot/index.js +2 -0
- package/dist/cockpit/attention.d.ts +23 -0
- package/dist/cockpit/attention.js +52 -0
- package/dist/cockpit/client.d.ts +88 -0
- package/dist/cockpit/client.js +190 -0
- package/dist/cockpit/cockpit.d.ts +75 -0
- package/dist/cockpit/cockpit.js +241 -0
- package/dist/cockpit/combine.d.ts +29 -0
- package/dist/cockpit/combine.js +89 -0
- package/dist/cockpit/creation.d.ts +128 -0
- package/dist/cockpit/creation.js +277 -0
- package/dist/cockpit/index.d.ts +26 -0
- package/dist/cockpit/index.js +13 -0
- package/dist/cockpit/launcher.d.ts +49 -0
- package/dist/cockpit/launcher.js +131 -0
- package/dist/cockpit/onboarding.d.ts +179 -0
- package/dist/cockpit/onboarding.js +371 -0
- package/dist/cockpit/overview.d.ts +63 -0
- package/dist/cockpit/overview.js +76 -0
- package/dist/cockpit/portfolio-index.d.ts +96 -0
- package/dist/cockpit/portfolio-index.js +236 -0
- package/dist/cockpit/portfolio-list.d.ts +76 -0
- package/dist/cockpit/portfolio-list.js +103 -0
- package/dist/cockpit/routing.d.ts +30 -0
- package/dist/cockpit/routing.js +38 -0
- package/dist/cockpit/triage.d.ts +24 -0
- package/dist/cockpit/triage.js +35 -0
- package/dist/commands/index.d.ts +3 -0
- package/dist/commands/index.js +2 -0
- package/dist/commands/registry.d.ts +80 -0
- package/dist/commands/registry.js +328 -0
- package/dist/dictation/dictation.d.ts +159 -0
- package/dist/dictation/dictation.js +223 -0
- package/dist/dictation/index.d.ts +3 -0
- package/dist/dictation/index.js +2 -0
- package/dist/id-generator/id-generator.d.ts +21 -0
- package/dist/id-generator/id-generator.js +33 -0
- package/dist/id-generator/index.d.ts +3 -0
- package/dist/id-generator/index.js +2 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +5 -0
- package/dist/interaction/index.d.ts +5 -0
- package/dist/interaction/index.js +3 -0
- package/dist/interaction/interaction.d.ts +35 -0
- package/dist/interaction/interaction.js +103 -0
- package/dist/interaction/recovery.d.ts +190 -0
- package/dist/interaction/recovery.js +77 -0
- package/dist/interaction/view.d.ts +36 -0
- package/dist/interaction/view.js +102 -0
- package/dist/navigation/index.d.ts +5 -0
- package/dist/navigation/index.js +3 -0
- package/dist/navigation/pagination.d.ts +41 -0
- package/dist/navigation/pagination.js +43 -0
- package/dist/navigation/routes.d.ts +82 -0
- package/dist/navigation/routes.js +98 -0
- package/dist/node-registry/index.d.ts +3 -0
- package/dist/node-registry/index.js +2 -0
- package/dist/node-registry/node-registry.d.ts +81 -0
- package/dist/node-registry/node-registry.js +94 -0
- package/dist/node-status/index.d.ts +3 -0
- package/dist/node-status/index.js +2 -0
- package/dist/node-status/node-status.d.ts +38 -0
- package/dist/node-status/node-status.js +76 -0
- package/dist/portfolio/index.d.ts +3 -0
- package/dist/portfolio/index.js +2 -0
- package/dist/portfolio/portfolio.d.ts +143 -0
- package/dist/portfolio/portfolio.js +91 -0
- package/dist/presentation/collection-view.d.ts +29 -0
- package/dist/presentation/collection-view.js +134 -0
- package/dist/presentation/index.d.ts +14 -0
- package/dist/presentation/index.js +7 -0
- package/dist/presentation/live.d.ts +150 -0
- package/dist/presentation/live.js +53 -0
- package/dist/presentation/project-view.d.ts +59 -0
- package/dist/presentation/project-view.js +48 -0
- package/dist/presentation/service.d.ts +40 -0
- package/dist/presentation/service.js +151 -0
- package/dist/presentation/session-view.d.ts +134 -0
- package/dist/presentation/session-view.js +47 -0
- package/dist/presentation/view.d.ts +53 -0
- package/dist/presentation/view.js +29 -0
- package/dist/session-attach/index.d.ts +2 -0
- package/dist/session-attach/index.js +2 -0
- package/dist/session-attach/reducer.d.ts +6 -0
- package/dist/session-attach/reducer.js +6 -0
- package/dist/session-view/actions.d.ts +13 -0
- package/dist/session-view/actions.js +15 -0
- package/dist/session-view/conversation.d.ts +52 -0
- package/dist/session-view/conversation.js +119 -0
- package/dist/session-view/index.d.ts +11 -0
- package/dist/session-view/index.js +6 -0
- package/dist/session-view/live.d.ts +56 -0
- package/dist/session-view/live.js +112 -0
- package/dist/session-view/session-view.d.ts +92 -0
- package/dist/session-view/session-view.js +363 -0
- package/dist/session-view/timeline.d.ts +29 -0
- package/dist/session-view/timeline.js +43 -0
- package/dist/single-session/index.d.ts +3 -0
- package/dist/single-session/index.js +2 -0
- package/dist/single-session/single-session.d.ts +96 -0
- package/dist/single-session/single-session.js +228 -0
- package/dist/terminal/index.d.ts +4 -0
- package/dist/terminal/index.js +3 -0
- package/dist/terminal/terminal.d.ts +37 -0
- package/dist/terminal/terminal.js +131 -0
- package/dist/terminal/unavailable.d.ts +3 -0
- package/dist/terminal/unavailable.js +23 -0
- package/dist/workspace-scope/index.d.ts +3 -0
- package/dist/workspace-scope/index.js +2 -0
- package/dist/workspace-scope/workspace-scope.d.ts +45 -0
- package/dist/workspace-scope/workspace-scope.js +230 -0
- package/package.json +110 -3
package/README.md
CHANGED
|
@@ -1,3 +1,125 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @maiaos/client-core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Platform-neutral client use cases for Maia OS: Effect services that sit between
|
|
4
|
+
a client's renderer and
|
|
5
|
+
[`@maiaos/sdk`](https://github.com/vllnt/maia/blob/main/packages/sdk/README.md),
|
|
6
|
+
so every client reads Maia Node state and sends requests the same way.
|
|
7
|
+
|
|
8
|
+
> **Pre-release.** npm carries only `nightly` and `canary` builds, which are
|
|
9
|
+
> not release-qualified. No stable release exists
|
|
10
|
+
> ([D-132](https://github.com/vllnt/maia/blob/main/docs/decisions.md#d-132)).
|
|
11
|
+
> Nothing in this package is a stability promise: entry points, names and
|
|
12
|
+
> shapes can change in any build.
|
|
13
|
+
|
|
14
|
+
Two Maia clients share it. The Expo client,
|
|
15
|
+
[`@maiaos/client`](https://github.com/vllnt/maia/blob/main/apps/client/README.md)
|
|
16
|
+
(Desktop and mobile), uses it today. The `maia` CLI will use it for its
|
|
17
|
+
terminal dashboard
|
|
18
|
+
([D-161](https://github.com/vllnt/maia/blob/main/docs/decisions.md#d-161),
|
|
19
|
+
planned; the CLI does not use it yet).
|
|
20
|
+
|
|
21
|
+
## Requirements
|
|
22
|
+
|
|
23
|
+
Node.js 24.22.1, and a Maia Node to talk to through `@maiaos/sdk`, which
|
|
24
|
+
installs with this package.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
pnpm add @maiaos/client-core@nightly # or @canary, or an exact version
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { makeNodeStatusClientService } from "@maiaos/client-core/node-status";
|
|
36
|
+
import { validateVoiceProposal } from "@maiaos/client-core/single-session";
|
|
37
|
+
import { makeTerminalClientService } from "@maiaos/client-core/terminal";
|
|
38
|
+
import { makeWorkspaceScopeService } from "@maiaos/client-core/workspace-scope";
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Each factory returns an Effect service that the renderer composes with a
|
|
42
|
+
platform transport; React components only render its state.
|
|
43
|
+
|
|
44
|
+
The package owns client-side validation, deadlines, cancellation, recovery of
|
|
45
|
+
uncertain requests and projections of Node state. It owns no transport,
|
|
46
|
+
credentials, native API, Node lifecycle or durable state: the SDK is the
|
|
47
|
+
protocol client and Maia Node is the authority.
|
|
48
|
+
|
|
49
|
+
| Entry point | Responsibility |
|
|
50
|
+
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
51
|
+
| `boot` | The Desktop boot sequence: lifecycle status, one start, then status reads until the host reports a ready Node |
|
|
52
|
+
| `navigation` | Route names, the `?q=&state=&page=` list query, parent routes and the one pagination model |
|
|
53
|
+
| `node-status` | One truthful, point-in-time Node status projection |
|
|
54
|
+
| `workspace-scope` | The one active Workspace scope, loaded from and replaced only by Node |
|
|
55
|
+
| `single-session` | Voice setup, Node-provided orchestrator context and local proposals |
|
|
56
|
+
| `terminal`, `terminal-unavailable` | Live terminal client and its explicit unavailable state |
|
|
57
|
+
| `interaction`, `interaction-view` | Record-only questions and answers, with recovery of unknown outcomes |
|
|
58
|
+
| `presentation` | Bounded Workspace, Project and Session list reads |
|
|
59
|
+
| `cockpit` | Work cockpit per Node and a combined view: one bounded index, one change feed, attention, lists, launcher search, single or bulk Session creation over the SDK batch and first-run onboarding |
|
|
60
|
+
| `session-view` | Focused Session detail: live items over `session.attach`, recent turns, summary, diff and a send, interrupt and wait composer |
|
|
61
|
+
| `commands` | The keyboard command registry: stable IDs, rebindable keymap, conflicts |
|
|
62
|
+
| `node-registry` | The local and registered remote Nodes a client addresses: connecting, ready, stale or unreachable (with ssh's reason, or `auth` when the Node rejects the credential) |
|
|
63
|
+
| `session-attach` | The SDK's Session attach reducer, re-exported unchanged |
|
|
64
|
+
| `dictation` | Composer dictation state: listening, partial text at the caret, cancel, language |
|
|
65
|
+
|
|
66
|
+
- **Deadlines and uncertainty.** Every request gets a unique message ID from
|
|
67
|
+
the package `IdGenerator` (Effect `Random` by default) and a 5.5-second
|
|
68
|
+
client deadline. A timed-out query is retryable; a timed-out
|
|
69
|
+
mutation is `outcome_unknown` and is reconciled with its original command
|
|
70
|
+
ID, never retried as a new one. Each request carries `deadlineAt`, that
|
|
71
|
+
deadline as an instant on `Clock.monotonicTimeNanos` (never wall-clock time),
|
|
72
|
+
for a requester that retries to stop before it. A client that must bound its
|
|
73
|
+
reads in flight provides `ReadAdmission`; a read waits for its permit before
|
|
74
|
+
its deadline starts, and a mutation never waits.
|
|
75
|
+
- **Freshness.** Each Node's cockpit keeps the time of its last successful
|
|
76
|
+
read and one whole-Node load in flight. A failed load retries, after 2 s and
|
|
77
|
+
doubling to 30 s, until the Node answers; a reload a person asks for runs
|
|
78
|
+
once; the change feed resubscribes on the same backoff. A copy is `stale`
|
|
79
|
+
when its latest whole-Node read failed and `partial` until the Node first
|
|
80
|
+
answers or while it caps a read. The combined view stays ready once any Node
|
|
81
|
+
answers.
|
|
82
|
+
- **Routing.** The combined view labels each Project and Session with its
|
|
83
|
+
Node, and `nodeIdOf` is the one lookup an action asks. It returns that Node,
|
|
84
|
+
or a `NodeRouteError` when the combined view holds no such target
|
|
85
|
+
(`unknown`) or two Nodes' indexes hold the same ID (`ambiguous`). A repeated
|
|
86
|
+
Workspace, Project or Session ID keeps the first Node's record and marks the
|
|
87
|
+
view truncated, and no action targets a repeated Project or Session. One
|
|
88
|
+
Node's own view routes every target to that Node, since its bounded index
|
|
89
|
+
can lack a Session the Node holds.
|
|
90
|
+
- **Fail closed.** Token, Workspace, orchestrator and relay services are
|
|
91
|
+
unavailable by default. A proposal or preview is never evidence of delivery;
|
|
92
|
+
only an exact Node preview enables explicit confirmation.
|
|
93
|
+
- **No hidden authority.** Browsing owns no execution focus or mutation
|
|
94
|
+
authority, and errors carry only bounded reason codes, never credentials or
|
|
95
|
+
request envelopes.
|
|
96
|
+
|
|
97
|
+
## Status
|
|
98
|
+
|
|
99
|
+
**Source candidate.** Its terminal, voice, interaction and cockpit services are
|
|
100
|
+
not release-qualified. The cockpit follows today's `observe.events` feed; the
|
|
101
|
+
planned R1 aggregate subscription, view store and reconnect policy will replace
|
|
102
|
+
its index load here; see the
|
|
103
|
+
[client architecture](https://github.com/vllnt/maia/blob/main/docs/clients/architecture.md).
|
|
104
|
+
|
|
105
|
+
- **Bulk creation.** A preview fixes every target, task and command ID and
|
|
106
|
+
carries a digest; one confirmation runs it once, within its expiry. Each
|
|
107
|
+
item reports its receipts or the Node's refusal (for example `saturated`
|
|
108
|
+
when an account has no free slot). A retry replays the same commands and
|
|
109
|
+
request bodies, so the Node answers the original results. The Run ledger
|
|
110
|
+
(S19) joins as a second `CreationDispatch`.
|
|
111
|
+
- **Onboarding.** `makeOnboarding` reads vendor accounts from Connection
|
|
112
|
+
records, scans native sessions as metadata (titles only when a person turns
|
|
113
|
+
them on), registers repositories, and runs adoption as a preview, then one
|
|
114
|
+
confirmation request per item, each opening the Desktop native sheet. A declined or uncertain confirmation stops the batch;
|
|
115
|
+
confirming again resends the same preview IDs. One-click use of signed-in
|
|
116
|
+
CLIs waits for `connection.detect` (`CliDetection` placeholder).
|
|
117
|
+
- **Timeline seam.** `session-view` reads final turn text through a
|
|
118
|
+
`TimelineSource`; the S12b `reduceSessionAttach` source plugs in there.
|
|
119
|
+
|
|
120
|
+
## Links
|
|
121
|
+
|
|
122
|
+
- [Desktop and mobile client architecture](https://github.com/vllnt/maia/blob/main/docs/clients/architecture.md)
|
|
123
|
+
- [`@maiaos/sdk`](https://github.com/vllnt/maia/blob/main/packages/sdk/README.md)
|
|
124
|
+
- [Security policy](https://github.com/vllnt/maia/blob/main/SECURITY.md)
|
|
125
|
+
- [Repository](https://github.com/vllnt/maia)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Duration, Effect, type Scope, type Stream } from "effect";
|
|
2
|
+
/**
|
|
3
|
+
* What the Desktop host reports about its local Node (`node_lifecycle_*`).
|
|
4
|
+
* The renderer never infers it: `ready` comes from the host alone, which
|
|
5
|
+
* checked the Node's descriptor and an authenticated `node.status`.
|
|
6
|
+
*/
|
|
7
|
+
export type NodeLifecycleState = {
|
|
8
|
+
readonly instanceId: string;
|
|
9
|
+
readonly state: "ready";
|
|
10
|
+
} | {
|
|
11
|
+
readonly reason: string;
|
|
12
|
+
readonly state: "failed" | "unavailable";
|
|
13
|
+
} | {
|
|
14
|
+
readonly state: "detecting" | "starting";
|
|
15
|
+
};
|
|
16
|
+
declare const NodeLifecycleError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
|
|
17
|
+
readonly _tag: "NodeLifecycleError";
|
|
18
|
+
} & Readonly<A>;
|
|
19
|
+
export declare class NodeLifecycleError extends NodeLifecycleError_base<{
|
|
20
|
+
readonly message: string;
|
|
21
|
+
}> {
|
|
22
|
+
}
|
|
23
|
+
/** The host's two lifecycle commands, lifted into Effect at the platform edge. */
|
|
24
|
+
export type NodeLifecycle = {
|
|
25
|
+
readonly start: Effect.Effect<NodeLifecycleState, NodeLifecycleError>;
|
|
26
|
+
readonly status: Effect.Effect<NodeLifecycleState, NodeLifecycleError>;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Where the boot sequence is. `waiting` while the host starts or detects a
|
|
30
|
+
* Node; `stopped` when it gave up and a person may start it again; `ready`
|
|
31
|
+
* once the host reports a ready Node; routes read it from then on.
|
|
32
|
+
*/
|
|
33
|
+
export type BootState = {
|
|
34
|
+
readonly instanceId: string;
|
|
35
|
+
readonly phase: "ready";
|
|
36
|
+
} | {
|
|
37
|
+
readonly phase: "checking";
|
|
38
|
+
} | {
|
|
39
|
+
readonly phase: "stopped";
|
|
40
|
+
readonly reason: string;
|
|
41
|
+
} | {
|
|
42
|
+
readonly phase: "waiting";
|
|
43
|
+
};
|
|
44
|
+
export type BootOptions = {
|
|
45
|
+
/** First pause between status reads; doubles up to `maxDelay`. */
|
|
46
|
+
readonly initialDelay?: Duration.Input;
|
|
47
|
+
readonly maxDelay?: Duration.Input;
|
|
48
|
+
/** How long to wait for a starting Node before stopping. */
|
|
49
|
+
readonly timeout?: Duration.Input;
|
|
50
|
+
};
|
|
51
|
+
export type Boot = {
|
|
52
|
+
readonly changes: Stream.Stream<BootState>;
|
|
53
|
+
readonly current: Effect.Effect<BootState>;
|
|
54
|
+
/** Runs the sequence again (the "Start Maia" action); ignored while one runs. */
|
|
55
|
+
readonly restart: Effect.Effect<void>;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Starts the boot sequence in the caller's scope. Routes mount once it
|
|
59
|
+
* publishes `ready`; closing the scope interrupts a sequence in flight.
|
|
60
|
+
*/
|
|
61
|
+
export declare const makeBoot: (lifecycle: NodeLifecycle, options?: BootOptions) => Effect.Effect<Boot, never, Scope.Scope>;
|
|
62
|
+
export {};
|
|
63
|
+
//# sourceMappingURL=boot.d.ts.map
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Data, Duration, Effect, Semaphore, SubscriptionRef, } from "effect";
|
|
2
|
+
export class NodeLifecycleError extends Data.TaggedError("NodeLifecycleError") {
|
|
3
|
+
}
|
|
4
|
+
/** The host starts its Node in up to 60 seconds; leave headroom. */
|
|
5
|
+
const DEFAULTS = {
|
|
6
|
+
initialDelay: Duration.millis(250),
|
|
7
|
+
maxDelay: Duration.seconds(2),
|
|
8
|
+
timeout: Duration.seconds(75),
|
|
9
|
+
};
|
|
10
|
+
const settled = (lifecycle) => {
|
|
11
|
+
if (lifecycle.state === "ready")
|
|
12
|
+
return { instanceId: lifecycle.instanceId, phase: "ready" };
|
|
13
|
+
if (lifecycle.state === "failed" || lifecycle.state === "unavailable")
|
|
14
|
+
return { phase: "stopped", reason: lifecycle.reason };
|
|
15
|
+
return undefined;
|
|
16
|
+
};
|
|
17
|
+
/** A bridge call that fails reads as an unreachable host. */
|
|
18
|
+
const unreachable = {
|
|
19
|
+
reason: "host_unreachable",
|
|
20
|
+
state: "unavailable",
|
|
21
|
+
};
|
|
22
|
+
const read = (effect) => effect.pipe(Effect.catch(() => Effect.succeed(unreachable)));
|
|
23
|
+
/**
|
|
24
|
+
* A status read while the Node starts: a failed call is one missed answer,
|
|
25
|
+
* not a verdict, so the sequence keeps waiting until its deadline.
|
|
26
|
+
*/
|
|
27
|
+
const poll = (effect) => effect.pipe(Effect.catch(() => Effect.succeed({ state: "starting" })));
|
|
28
|
+
/**
|
|
29
|
+
* Status reads with a growing pause until the host reports the Node ready
|
|
30
|
+
* or stopped. The caller bounds the whole wait.
|
|
31
|
+
*/
|
|
32
|
+
const waitForReady = (run, current, delay) => Effect.gen(function* () {
|
|
33
|
+
const outcome = settled(current);
|
|
34
|
+
if (outcome !== undefined)
|
|
35
|
+
return outcome;
|
|
36
|
+
yield* Effect.sleep(delay);
|
|
37
|
+
return yield* waitForReady(run, yield* poll(run.lifecycle.status), Duration.min(Duration.times(delay, 2), run.options.maxDelay));
|
|
38
|
+
});
|
|
39
|
+
/**
|
|
40
|
+
* Status; unless the Node is ready, one start (the host's start is
|
|
41
|
+
* idempotent: it adopts a running Node, keeps a start in flight, or spawns
|
|
42
|
+
* one); then status reads until the host reports ready or stopped. The
|
|
43
|
+
* whole sequence, every host call included, ends at the timeout. The state
|
|
44
|
+
* follows every step, so the screen tells the truth.
|
|
45
|
+
*/
|
|
46
|
+
const sequence = (run) => Effect.gen(function* () {
|
|
47
|
+
yield* SubscriptionRef.set(run.ref, { phase: "checking" });
|
|
48
|
+
const first = yield* read(run.lifecycle.status);
|
|
49
|
+
if (first.state === "ready")
|
|
50
|
+
return yield* SubscriptionRef.set(run.ref, {
|
|
51
|
+
instanceId: first.instanceId,
|
|
52
|
+
phase: "ready",
|
|
53
|
+
});
|
|
54
|
+
yield* SubscriptionRef.set(run.ref, { phase: "waiting" });
|
|
55
|
+
const outcome = yield* waitForReady(run, yield* read(run.lifecycle.start), run.options.initialDelay);
|
|
56
|
+
yield* SubscriptionRef.set(run.ref, outcome);
|
|
57
|
+
}).pipe(Effect.timeoutOrElse({
|
|
58
|
+
duration: run.options.timeout,
|
|
59
|
+
orElse: () => SubscriptionRef.set(run.ref, {
|
|
60
|
+
phase: "stopped",
|
|
61
|
+
reason: "readiness_timeout",
|
|
62
|
+
}),
|
|
63
|
+
}));
|
|
64
|
+
const durations = (options) => ({
|
|
65
|
+
initialDelay: Duration.fromInputUnsafe(options.initialDelay ?? DEFAULTS.initialDelay),
|
|
66
|
+
maxDelay: Duration.fromInputUnsafe(options.maxDelay ?? DEFAULTS.maxDelay),
|
|
67
|
+
timeout: Duration.fromInputUnsafe(options.timeout ?? DEFAULTS.timeout),
|
|
68
|
+
});
|
|
69
|
+
/**
|
|
70
|
+
* Starts the boot sequence in the caller's scope. Routes mount once it
|
|
71
|
+
* publishes `ready`; closing the scope interrupts a sequence in flight.
|
|
72
|
+
*/
|
|
73
|
+
export const makeBoot = (lifecycle, options = {}) => Effect.gen(function* () {
|
|
74
|
+
const ref = yield* SubscriptionRef.make({ phase: "checking" });
|
|
75
|
+
const lock = yield* Semaphore.make(1);
|
|
76
|
+
const scope = yield* Effect.scope;
|
|
77
|
+
const run = { lifecycle, options: durations(options), ref };
|
|
78
|
+
const once = Effect.forkIn(lock.withPermitsIfAvailable(1)(sequence(run)), scope).pipe(Effect.asVoid);
|
|
79
|
+
yield* once;
|
|
80
|
+
return {
|
|
81
|
+
changes: SubscriptionRef.changes(ref),
|
|
82
|
+
current: SubscriptionRef.get(ref),
|
|
83
|
+
restart: once,
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
//# sourceMappingURL=boot.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Session, SessionObservation } from "@maiaos/sdk";
|
|
2
|
+
export type SessionRuntime = NonNullable<SessionObservation["runtime"]>;
|
|
3
|
+
/**
|
|
4
|
+
* Why a Session needs a person, as the Node reports it (`session.list` and
|
|
5
|
+
* `session.observe` carry it). The client derives none of these values.
|
|
6
|
+
*/
|
|
7
|
+
export type AttentionReason = NonNullable<SessionObservation["attention"]>;
|
|
8
|
+
export type AttentionItem = {
|
|
9
|
+
readonly projectId: string;
|
|
10
|
+
readonly reason: AttentionReason;
|
|
11
|
+
readonly sessionId: string;
|
|
12
|
+
readonly updatedAt: string;
|
|
13
|
+
};
|
|
14
|
+
/** Bounds the list a person triages; the portfolio list shows the rest. */
|
|
15
|
+
export declare const MAX_ATTENTION_ITEMS = 500;
|
|
16
|
+
/**
|
|
17
|
+
* The ordered, bounded list of Sessions the Node says need a person now.
|
|
18
|
+
* A Session the Node reports nothing for is absent.
|
|
19
|
+
*/
|
|
20
|
+
export declare const deriveAttention: (sessions: Iterable<Session>, reasons: ReadonlyMap<string, AttentionReason>) => readonly AttentionItem[];
|
|
21
|
+
/** Short labels for people; the Node's value stays the source. */
|
|
22
|
+
export declare const attentionLabel: (reason: AttentionReason) => string;
|
|
23
|
+
//# sourceMappingURL=attention.d.ts.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** Most urgent first: a person blocks the work, then failures, then review. */
|
|
2
|
+
const SEVERITY = [
|
|
3
|
+
"approval_pending",
|
|
4
|
+
"question",
|
|
5
|
+
"failed",
|
|
6
|
+
"turn_failed",
|
|
7
|
+
"stalled",
|
|
8
|
+
"settled_unreviewed",
|
|
9
|
+
];
|
|
10
|
+
/** Bounds the list a person triages; the portfolio list shows the rest. */
|
|
11
|
+
export const MAX_ATTENTION_ITEMS = 500;
|
|
12
|
+
const byUrgency = (left, right) => SEVERITY.indexOf(left.reason) - SEVERITY.indexOf(right.reason) ||
|
|
13
|
+
right.updatedAt.localeCompare(left.updatedAt) ||
|
|
14
|
+
left.sessionId.localeCompare(right.sessionId);
|
|
15
|
+
/**
|
|
16
|
+
* The ordered, bounded list of Sessions the Node says need a person now.
|
|
17
|
+
* A Session the Node reports nothing for is absent.
|
|
18
|
+
*/
|
|
19
|
+
export const deriveAttention = (sessions, reasons) => [...sessions]
|
|
20
|
+
.flatMap((session) => {
|
|
21
|
+
const reason = reasons.get(session.id);
|
|
22
|
+
return reason === undefined
|
|
23
|
+
? []
|
|
24
|
+
: [
|
|
25
|
+
{
|
|
26
|
+
projectId: session.projectId,
|
|
27
|
+
reason,
|
|
28
|
+
sessionId: session.id,
|
|
29
|
+
updatedAt: session.updatedAt,
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
})
|
|
33
|
+
.toSorted(byUrgency)
|
|
34
|
+
.slice(0, MAX_ATTENTION_ITEMS);
|
|
35
|
+
/** Short labels for people; the Node's value stays the source. */
|
|
36
|
+
export const attentionLabel = (reason) => {
|
|
37
|
+
switch (reason) {
|
|
38
|
+
case "approval_pending":
|
|
39
|
+
return "Needs approval";
|
|
40
|
+
case "failed":
|
|
41
|
+
return "Failed";
|
|
42
|
+
case "question":
|
|
43
|
+
return "Question";
|
|
44
|
+
case "settled_unreviewed":
|
|
45
|
+
return "Review";
|
|
46
|
+
case "stalled":
|
|
47
|
+
return "Stalled";
|
|
48
|
+
case "turn_failed":
|
|
49
|
+
return "Turn failed";
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
//# sourceMappingURL=attention.js.map
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { type LocalNodeClient } from "@maiaos/sdk";
|
|
2
|
+
import { Context, Effect } from "effect";
|
|
3
|
+
/** The SDK operations the cockpit, creation, Session detail and onboarding use. */
|
|
4
|
+
export type CockpitClient = Pick<LocalNodeClient, "archiveSession" | "confirmSessionAdopt" | "createSession" | "detectConnections" | "diffSession" | "getReceipt" | "interruptTurn" | "listConnections" | "listContainerIndex" | "listDiscoveredSessions" | "listRemoteNodes" | "listSessionEvents" | "listSessions" | "listWorkspaces" | "observeSessions" | "previewSessionAdopt" | "registerProject" | "respondToApproval" | "resumeSession" | "sendTurn" | "showApproval" | "showSession" | "showTurn" | "startSession" | "stopSession" | "waitTurns">;
|
|
5
|
+
/**
|
|
6
|
+
* Every Node operation the cockpit, creation, Session detail and onboarding
|
|
7
|
+
* send. Each request names one of them, so a test can hold the Desktop
|
|
8
|
+
* allowlist against this list.
|
|
9
|
+
*/
|
|
10
|
+
export declare const COCKPIT_OPERATIONS: readonly ["approval.respond", "approval.show", "connection.detect", "connection.list", "container.index.list", "discovery.sessions.list", "node.remote.list", "project.register", "session.adopt.confirm", "session.adopt.preview", "session.archive", "session.create", "session.diff", "session.events.list", "session.list", "session.observe", "session.resume", "session.show", "session.start", "session.stop", "turn.interrupt", "turn.send", "turn.show", "turn.wait", "workspace.list"];
|
|
11
|
+
export type CockpitOperation = (typeof COCKPIT_OPERATIONS)[number];
|
|
12
|
+
/**
|
|
13
|
+
* Why a request did not succeed. `refused` carries the Node's protocol code
|
|
14
|
+
* (for example `saturated` when an account has no free Session slot);
|
|
15
|
+
* `declined` means a person declined the native confirmation and nothing was
|
|
16
|
+
* sent; `sheet_busy` means another native confirmation was open or none was
|
|
17
|
+
* available, so the Node got nothing and the same request may run again;
|
|
18
|
+
* `outcome_unknown` means a mutation may have committed and must be
|
|
19
|
+
* reconciled with its original command ID.
|
|
20
|
+
*/
|
|
21
|
+
export type CockpitErrorReason = "declined" | "denied" | "invalid" | "offline" | "outcome_unknown" | "refused" | "sheet_busy" | "unavailable";
|
|
22
|
+
declare const CockpitError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
|
|
23
|
+
readonly _tag: "CockpitError";
|
|
24
|
+
} & Readonly<A>;
|
|
25
|
+
export declare class CockpitError extends CockpitError_base<{
|
|
26
|
+
/** The Node's protocol error code when it refused the request. */
|
|
27
|
+
readonly code?: string;
|
|
28
|
+
/**
|
|
29
|
+
* The Node's own explanation of a refusal, which often names the fix, or
|
|
30
|
+
* why a remote Node is unreachable, such as the last line ssh printed.
|
|
31
|
+
*/
|
|
32
|
+
readonly detail?: string;
|
|
33
|
+
readonly message: string;
|
|
34
|
+
readonly operation: string;
|
|
35
|
+
readonly reason: CockpitErrorReason;
|
|
36
|
+
}> {
|
|
37
|
+
}
|
|
38
|
+
/** How long the client waits for an ordinary request. */
|
|
39
|
+
export declare const REQUEST_DEADLINE_MILLISECONDS = 5500;
|
|
40
|
+
/**
|
|
41
|
+
* How long the client waits for a request behind the native confirmation:
|
|
42
|
+
* the sheet declines itself after two minutes (the Desktop's
|
|
43
|
+
* `CONFIRMATION_WINDOW_SECONDS`), then the Node deadline starts. A test
|
|
44
|
+
* holds this above the two together.
|
|
45
|
+
*/
|
|
46
|
+
export declare const CONFIRMATION_DEADLINE_MILLISECONDS = 150000;
|
|
47
|
+
/**
|
|
48
|
+
* The Desktop bridge's code for a request it refused to send, such as an
|
|
49
|
+
* approval answer whose request it no longer remembers, or one to a remote
|
|
50
|
+
* Node whose registration changed.
|
|
51
|
+
*/
|
|
52
|
+
export declare const PLATFORM_DENIED = "request_denied";
|
|
53
|
+
/**
|
|
54
|
+
* Classifies a failure with the SDK. A mutation the Node never answered is
|
|
55
|
+
* `outcome_unknown`, because it may have committed.
|
|
56
|
+
*/
|
|
57
|
+
export declare const toCockpitError: (operation: string, error: unknown, mutation?: boolean) => CockpitError;
|
|
58
|
+
type RequestOptions = {
|
|
59
|
+
/** When the client stops waiting, on `Clock.monotonicTimeNanos`. */
|
|
60
|
+
readonly deadlineAt: bigint;
|
|
61
|
+
readonly messageId: string;
|
|
62
|
+
readonly signal: AbortSignal;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Admits a read before its deadline starts, so a wait for a permit never
|
|
66
|
+
* counts against the deadline. It never admits a mutation. The default admits
|
|
67
|
+
* every read at once, as the Desktop does; a client that must bound reads in
|
|
68
|
+
* flight provides one, such as a `Semaphore`'s `withPermits(1)`.
|
|
69
|
+
*/
|
|
70
|
+
export type ReadAdmissionService = <TValue, TError>(effect: Effect.Effect<TValue, TError>) => Effect.Effect<TValue, TError>;
|
|
71
|
+
declare const ReadAdmission_base: Context.Reference<ReadAdmissionService>;
|
|
72
|
+
export declare class ReadAdmission extends ReadAdmission_base {
|
|
73
|
+
}
|
|
74
|
+
/** A read: a fresh message ID, the client deadline and bounded errors. */
|
|
75
|
+
export declare const read: <T>(operation: CockpitOperation, request: (options: RequestOptions) => Promise<T>) => Effect.Effect<T, CockpitError>;
|
|
76
|
+
/**
|
|
77
|
+
* A mutation: a timeout is `outcome_unknown`, never a failure, because the
|
|
78
|
+
* Node may have committed it; callers reconcile with the same command ID.
|
|
79
|
+
*/
|
|
80
|
+
export declare const mutate: <T>(operation: CockpitOperation, request: (options: RequestOptions) => Promise<T>) => Effect.Effect<T, CockpitError>;
|
|
81
|
+
/**
|
|
82
|
+
* A mutation behind the native confirmation sheet: the client waits as long
|
|
83
|
+
* as the sheet may stay open, and a timeout is `outcome_unknown` as above.
|
|
84
|
+
*/
|
|
85
|
+
export declare const confirmMutation: <T>(operation: CockpitOperation, request: (options: RequestOptions) => Promise<T>) => Effect.Effect<T, CockpitError>;
|
|
86
|
+
export declare const unavailable: (operation: string) => CockpitError;
|
|
87
|
+
export {};
|
|
88
|
+
//# sourceMappingURL=client.d.ts.map
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { classifyClientError, MaiaClientError, } from "@maiaos/sdk";
|
|
2
|
+
import { Clock, Context, Data, Duration, Effect } from "effect";
|
|
3
|
+
import { nextCompactId } from "../id-generator/index.js";
|
|
4
|
+
/**
|
|
5
|
+
* Every Node operation the cockpit, creation, Session detail and onboarding
|
|
6
|
+
* send. Each request names one of them, so a test can hold the Desktop
|
|
7
|
+
* allowlist against this list.
|
|
8
|
+
*/
|
|
9
|
+
export const COCKPIT_OPERATIONS = [
|
|
10
|
+
"approval.respond",
|
|
11
|
+
"approval.show",
|
|
12
|
+
"connection.detect",
|
|
13
|
+
"connection.list",
|
|
14
|
+
"container.index.list",
|
|
15
|
+
"discovery.sessions.list",
|
|
16
|
+
"node.remote.list",
|
|
17
|
+
"project.register",
|
|
18
|
+
"session.adopt.confirm",
|
|
19
|
+
"session.adopt.preview",
|
|
20
|
+
"session.archive",
|
|
21
|
+
"session.create",
|
|
22
|
+
"session.diff",
|
|
23
|
+
"session.events.list",
|
|
24
|
+
"session.list",
|
|
25
|
+
"session.observe",
|
|
26
|
+
"session.resume",
|
|
27
|
+
"session.show",
|
|
28
|
+
"session.start",
|
|
29
|
+
"session.stop",
|
|
30
|
+
"turn.interrupt",
|
|
31
|
+
"turn.send",
|
|
32
|
+
"turn.show",
|
|
33
|
+
"turn.wait",
|
|
34
|
+
"workspace.list",
|
|
35
|
+
];
|
|
36
|
+
export class CockpitError extends Data.TaggedError("CockpitError") {
|
|
37
|
+
}
|
|
38
|
+
/** How long the client waits for an ordinary request. */
|
|
39
|
+
export const REQUEST_DEADLINE_MILLISECONDS = 5500;
|
|
40
|
+
/**
|
|
41
|
+
* How long the client waits for a request behind the native confirmation:
|
|
42
|
+
* the sheet declines itself after two minutes (the Desktop's
|
|
43
|
+
* `CONFIRMATION_WINDOW_SECONDS`), then the Node deadline starts. A test
|
|
44
|
+
* holds this above the two together.
|
|
45
|
+
*/
|
|
46
|
+
export const CONFIRMATION_DEADLINE_MILLISECONDS = 150_000;
|
|
47
|
+
/**
|
|
48
|
+
* The Desktop bridge's code for a request it refused to send, such as an
|
|
49
|
+
* approval answer whose request it no longer remembers, or one to a remote
|
|
50
|
+
* Node whose registration changed.
|
|
51
|
+
*/
|
|
52
|
+
export const PLATFORM_DENIED = "request_denied";
|
|
53
|
+
const nativeField = (error, field) => {
|
|
54
|
+
const cause = error.cause;
|
|
55
|
+
return cause !== null && typeof cause === "object"
|
|
56
|
+
? Reflect.get(cause, field)
|
|
57
|
+
: undefined;
|
|
58
|
+
};
|
|
59
|
+
const nativeReason = (error) => nativeField(error, "reason");
|
|
60
|
+
const MAX_DETAIL_LENGTH = 240;
|
|
61
|
+
/** The native bridge's bounded reason text, never an envelope or credential. */
|
|
62
|
+
const nativeDetail = (error) => {
|
|
63
|
+
const detail = nativeField(error, "detail");
|
|
64
|
+
return typeof detail === "string" && detail !== ""
|
|
65
|
+
? detail.slice(0, MAX_DETAIL_LENGTH)
|
|
66
|
+
: undefined;
|
|
67
|
+
};
|
|
68
|
+
const message = (reason) => {
|
|
69
|
+
switch (reason) {
|
|
70
|
+
case "declined":
|
|
71
|
+
return "Declined in the confirmation dialog; nothing was sent.";
|
|
72
|
+
case "denied":
|
|
73
|
+
return "The Node denied this request.";
|
|
74
|
+
case "invalid":
|
|
75
|
+
return "The Node answer did not match the protocol.";
|
|
76
|
+
case "offline":
|
|
77
|
+
return "The Node did not answer.";
|
|
78
|
+
case "outcome_unknown":
|
|
79
|
+
return "The Node may have applied this change; reconcile it with the same command.";
|
|
80
|
+
case "refused":
|
|
81
|
+
return "The Node refused this request.";
|
|
82
|
+
case "sheet_busy":
|
|
83
|
+
return "Another confirmation is open or none is available; nothing was sent. Try again.";
|
|
84
|
+
case "unavailable":
|
|
85
|
+
return "This operation is not available on this client or Node.";
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const failure = (operation, reason, code) => new CockpitError({
|
|
89
|
+
...(code === undefined ? {} : { code }),
|
|
90
|
+
message: code === PLATFORM_DENIED
|
|
91
|
+
? "The Desktop bridge refused this request; nothing was applied."
|
|
92
|
+
: message(reason),
|
|
93
|
+
operation,
|
|
94
|
+
reason,
|
|
95
|
+
});
|
|
96
|
+
/** A Node refusal keeps the Node's own explanation. */
|
|
97
|
+
const refusedBy = (operation, error) => new CockpitError({
|
|
98
|
+
...(error.remoteError === undefined
|
|
99
|
+
? {}
|
|
100
|
+
: { code: error.remoteError.code, detail: error.remoteError.message }),
|
|
101
|
+
message: message("refused"),
|
|
102
|
+
operation,
|
|
103
|
+
reason: "refused",
|
|
104
|
+
});
|
|
105
|
+
/** A remote Node whose bridge is opening or failed, with ssh's reason. */
|
|
106
|
+
const remoteFailure = (operation, code, error) => {
|
|
107
|
+
const detail = nativeDetail(error);
|
|
108
|
+
return new CockpitError({
|
|
109
|
+
code,
|
|
110
|
+
...(detail === undefined ? {} : { detail }),
|
|
111
|
+
message: message("offline"),
|
|
112
|
+
operation,
|
|
113
|
+
reason: "offline",
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
/** The SDK's classification in the cockpit's words. */
|
|
117
|
+
const reasonFor = (kind) => {
|
|
118
|
+
if (kind === "cancelled")
|
|
119
|
+
return "offline";
|
|
120
|
+
return kind === "conflict" || kind === "not_found" ? "refused" : kind;
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* Classifies a failure with the SDK. A mutation the Node never answered is
|
|
124
|
+
* `outcome_unknown`, because it may have committed.
|
|
125
|
+
*/
|
|
126
|
+
export const toCockpitError = (operation, error, mutation = false) => {
|
|
127
|
+
if (error instanceof CockpitError)
|
|
128
|
+
return error;
|
|
129
|
+
// A remote Node's bridge reports an opening or failed bridge before the
|
|
130
|
+
// request left, with ssh's reason; the SDK has no word for that.
|
|
131
|
+
if (error instanceof MaiaClientError) {
|
|
132
|
+
const native = nativeReason(error);
|
|
133
|
+
if (native === "node_connecting" || native === "node_unreachable")
|
|
134
|
+
return remoteFailure(operation, native, error);
|
|
135
|
+
}
|
|
136
|
+
const { code, kind } = classifyClientError(error, { mutation });
|
|
137
|
+
if (code === "confirmation_unavailable")
|
|
138
|
+
return failure(operation, "sheet_busy", code);
|
|
139
|
+
const reason = reasonFor(kind);
|
|
140
|
+
return reason === "refused" &&
|
|
141
|
+
error instanceof MaiaClientError &&
|
|
142
|
+
error.remoteError !== undefined
|
|
143
|
+
? refusedBy(operation, error)
|
|
144
|
+
: failure(operation, reason, code);
|
|
145
|
+
};
|
|
146
|
+
const admitAtOnce = (effect) => effect;
|
|
147
|
+
export class ReadAdmission extends Context.Reference("@maiaos/client-core/ReadAdmission", { defaultValue: () => admitAtOnce }) {
|
|
148
|
+
}
|
|
149
|
+
const send = (operation, request, { deadline, mutation }) => {
|
|
150
|
+
// One monotonic read sets the cutoff, then the timeout starts: the cutoff is
|
|
151
|
+
// never later than the timeout, and a wall clock that steps back moves neither.
|
|
152
|
+
const attempt = Effect.flatMap(Clock.monotonicTimeNanos, (now) => Effect.flatMap(nextCompactId("cockpit"), (messageId) => Effect.tryPromise({
|
|
153
|
+
catch: (error) => toCockpitError(operation, error, mutation),
|
|
154
|
+
try: (signal) => request({
|
|
155
|
+
deadlineAt: now + Duration.toNanosUnsafe(deadline),
|
|
156
|
+
messageId,
|
|
157
|
+
signal,
|
|
158
|
+
}),
|
|
159
|
+
})).pipe(Effect.timeoutOrElse({
|
|
160
|
+
duration: deadline,
|
|
161
|
+
orElse: () => Effect.fail(failure(operation, "offline", "timed_out")),
|
|
162
|
+
})));
|
|
163
|
+
return mutation
|
|
164
|
+
? attempt
|
|
165
|
+
: Effect.flatMap(Effect.service(ReadAdmission), (admit) => admit(attempt));
|
|
166
|
+
};
|
|
167
|
+
/** A read: a fresh message ID, the client deadline and bounded errors. */
|
|
168
|
+
export const read = (operation, request) => send(operation, request, {
|
|
169
|
+
deadline: REQUEST_DEADLINE_MILLISECONDS,
|
|
170
|
+
mutation: false,
|
|
171
|
+
});
|
|
172
|
+
const unknownOnTimeout = (operation, effect) => effect.pipe(Effect.catchIf((error) => error.code === "timed_out" || error.code === "deadline_exceeded", () => Effect.fail(failure(operation, "outcome_unknown"))));
|
|
173
|
+
/**
|
|
174
|
+
* A mutation: a timeout is `outcome_unknown`, never a failure, because the
|
|
175
|
+
* Node may have committed it; callers reconcile with the same command ID.
|
|
176
|
+
*/
|
|
177
|
+
export const mutate = (operation, request) => unknownOnTimeout(operation, send(operation, request, {
|
|
178
|
+
deadline: REQUEST_DEADLINE_MILLISECONDS,
|
|
179
|
+
mutation: true,
|
|
180
|
+
}));
|
|
181
|
+
/**
|
|
182
|
+
* A mutation behind the native confirmation sheet: the client waits as long
|
|
183
|
+
* as the sheet may stay open, and a timeout is `outcome_unknown` as above.
|
|
184
|
+
*/
|
|
185
|
+
export const confirmMutation = (operation, request) => unknownOnTimeout(operation, send(operation, request, {
|
|
186
|
+
deadline: CONFIRMATION_DEADLINE_MILLISECONDS,
|
|
187
|
+
mutation: true,
|
|
188
|
+
}));
|
|
189
|
+
export const unavailable = (operation) => failure(operation, "unavailable");
|
|
190
|
+
//# sourceMappingURL=client.js.map
|