@mycadet/assistant 0.13.0-dev.202611142107.f5e00a2 → 0.13.0-dev.202611142321.f0c8539

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/Dockerfile CHANGED
@@ -189,6 +189,7 @@ COPY packages/egress-proxy /app/packages/egress-proxy
189
189
  COPY packages/environments /app/packages/environments
190
190
  COPY packages/gateway-client /app/packages/gateway-client
191
191
  COPY packages/ipc-server-utils /app/packages/ipc-server-utils
192
+ COPY packages/shared /app/packages/shared
192
193
  COPY packages/slack-text /app/packages/slack-text
193
194
  COPY packages/twilio-client /app/packages/twilio-client
194
195
 
@@ -1,3 +1,5 @@
1
+ import { z } from "zod";
2
+
1
3
  /**
2
4
  * Wire contracts for the remote-web pairing flow — the RFC 8628-style
3
5
  * device-code exchange that connects a browser (or the iOS app) to a
@@ -59,6 +61,16 @@ export interface RemoteWebPairingChallengeResponse {
59
61
  intervalSeconds: number;
60
62
  }
61
63
 
64
+ export const RemoteWebPairingChallengeResponseSchema = z.object({
65
+ deviceCode: z.string(),
66
+ userCode: z.string(),
67
+ verificationUri: z.string(),
68
+ publicBaseUrl: z.string(),
69
+ expiresAt: z.string(),
70
+ expiresInSeconds: z.number(),
71
+ intervalSeconds: z.number(),
72
+ }) satisfies z.ZodType<RemoteWebPairingChallengeResponse>;
73
+
62
74
  /** `POST /v1/remote-web/pairing-verification` request body. */
63
75
  export interface RemoteWebPairingVerificationRequest {
64
76
  /** The `userCode` from the challenge, entered/scanned on the host. */
@@ -1,3 +1,5 @@
1
+ import { z } from "zod";
2
+
1
3
  /**
2
4
  * Wire contracts for the remote-web pairing flow — the RFC 8628-style
3
5
  * device-code exchange that connects a browser (or the iOS app) to a
@@ -59,6 +61,16 @@ export interface RemoteWebPairingChallengeResponse {
59
61
  intervalSeconds: number;
60
62
  }
61
63
 
64
+ export const RemoteWebPairingChallengeResponseSchema = z.object({
65
+ deviceCode: z.string(),
66
+ userCode: z.string(),
67
+ verificationUri: z.string(),
68
+ publicBaseUrl: z.string(),
69
+ expiresAt: z.string(),
70
+ expiresInSeconds: z.number(),
71
+ intervalSeconds: z.number(),
72
+ }) satisfies z.ZodType<RemoteWebPairingChallengeResponse>;
73
+
62
74
  /** `POST /v1/remote-web/pairing-verification` request body. */
63
75
  export interface RemoteWebPairingVerificationRequest {
64
76
  /** The `userCode` from the challenge, entered/scanned on the host. */
@@ -1,3 +1,5 @@
1
+ import { z } from "zod";
2
+
1
3
  /**
2
4
  * Wire contracts for the remote-web pairing flow — the RFC 8628-style
3
5
  * device-code exchange that connects a browser (or the iOS app) to a
@@ -59,6 +61,16 @@ export interface RemoteWebPairingChallengeResponse {
59
61
  intervalSeconds: number;
60
62
  }
61
63
 
64
+ export const RemoteWebPairingChallengeResponseSchema = z.object({
65
+ deviceCode: z.string(),
66
+ userCode: z.string(),
67
+ verificationUri: z.string(),
68
+ publicBaseUrl: z.string(),
69
+ expiresAt: z.string(),
70
+ expiresInSeconds: z.number(),
71
+ intervalSeconds: z.number(),
72
+ }) satisfies z.ZodType<RemoteWebPairingChallengeResponse>;
73
+
62
74
  /** `POST /v1/remote-web/pairing-verification` request body. */
63
75
  export interface RemoteWebPairingVerificationRequest {
64
76
  /** The `userCode` from the challenge, entered/scanned on the host. */
@@ -207,28 +207,11 @@ function readPersistedDefault(): CadetEnvironment | undefined {
207
207
  }
208
208
 
209
209
  /**
210
- * The environment name every surface shares — the single resolution chain
211
- * local-mode hosts and the mobile config loader both ride:
212
- *
213
- * 1. `CADET_ENVIRONMENT` (explicit override, also inherited by the
214
- * hatch/retire child processes the CLI spawns), then
215
- * `VITE_ENVIRONMENT` (the build-time inlined var bundled surfaces bake)
216
- * 2. the release channel the running copy's version carries —
217
- * `__CADET_OWN_VERSION__`, which the entrypoints that know their
218
- * package root inject (the CLI's bin computes its git/local stamp,
219
- * the Electron builds define it at build time). A released `-dev.`
220
- * install resolves `dev` even on a machine that never wrote a
221
- * persisted default
222
- * 3. the persisted default written by pre-version-tied CLIs (old files
223
- * are still honored)
224
- * 4. `production`
225
- *
226
- * The version step is what keeps every released install from silently
227
- * reading production's state: the channel is decided by the release
228
- * pipeline, not by whatever default an older CLI happened to leave behind.
229
- * Names outside `CadetEnvironment` are ignored rather than returned: an
230
- * unrecognized value falls through to the next arm instead of leaking an
231
- * unkeyable string into `SEEDS` lookups.
210
+ * The environment name every surface shares: `CADET_ENVIRONMENT`, then
211
+ * `VITE_ENVIRONMENT`, then the release channel the copy's version carries
212
+ * (`__CADET_OWN_VERSION__`, defined at build time by hosts that bundle
213
+ * local-mode), then the persisted default written by pre-version-tied CLIs,
214
+ * then production.
232
215
  */
233
216
  export function resolveEnvironmentName(): CadetEnvironment {
234
217
  const env = processEnv();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mycadet/assistant",
3
- "version": "0.13.0-dev.202611142107.f5e00a2",
3
+ "version": "0.13.0-dev.202611142321.f0c8539",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/vargasjr-dev/mycadet-temp.git"
@@ -8,6 +8,12 @@ import {
8
8
  type SlashContext,
9
9
  } from "../daemon/conversation-slash.js";
10
10
  import { getWorkspaceConfigPath } from "../util/layout.js";
11
+ import { useMemorySecureKeys } from "./helpers/secure-keys.js";
12
+
13
+ // The /models resolution probes configured providers through secure storage;
14
+ // pin the memory backend so the classification-parity tests never route
15
+ // through CES RPC discovery, which has no daemon to reach in a test process.
16
+ useMemorySecureKeys();
11
17
 
12
18
  function makeSlashContext(overrides: Partial<SlashContext> = {}): SlashContext {
13
19
  return {
@@ -0,0 +1,39 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { describe, expect, test } from "bun:test";
4
+
5
+ /**
6
+ * The assistant image copies workspace package sources after `bun install`,
7
+ * and the container smoke step resolves each declared `workspace:*`
8
+ * dependency. A declared dependency that is not COPYed fails the image
9
+ * build with `Cannot find module '@mycadet/<name>'` — the DR134 red.
10
+ */
11
+ describe("assistant Dockerfile workspace packages", () => {
12
+ test("copies every workspace dependency into the image", () => {
13
+ const repoRoot = join(import.meta.dir, "../../..");
14
+ const pkg = JSON.parse(
15
+ readFileSync(join(repoRoot, "assistant/package.json"), "utf8"),
16
+ ) as { dependencies?: Record<string, string> };
17
+ const dockerfile = readFileSync(
18
+ join(repoRoot, "assistant/Dockerfile"),
19
+ "utf8",
20
+ );
21
+
22
+ const missing: string[] = [];
23
+ for (const [name, version] of Object.entries(pkg.dependencies ?? {})) {
24
+ if (version !== "workspace:*") {
25
+ continue;
26
+ }
27
+ if (!name.startsWith("@mycadet/")) {
28
+ missing.push(`${name} (unexpected workspace package)`);
29
+ continue;
30
+ }
31
+ const dir = `packages/${name.slice("@mycadet/".length)}`;
32
+ if (!dockerfile.includes(`COPY ${dir} `)) {
33
+ missing.push(dir);
34
+ }
35
+ }
36
+
37
+ expect(missing).toEqual([]);
38
+ });
39
+ });
@@ -2,21 +2,36 @@
2
2
  * Tests for the shared CES RPC session factory used by assistant boot and
3
3
  * child-process credential resolution.
4
4
  */
5
- import { describe, expect, test } from "bun:test";
5
+ import { mkdtempSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { afterAll, describe, expect, test } from "bun:test";
6
9
 
7
10
  import { createCesClient } from "./client.js";
8
11
 
12
+ // Socket discovery is the process manager's concern, and it reads
13
+ // `CES_SOCKET_DIR` — an empty socket dir makes the real discovery exhaust
14
+ // its poll budget and come back unavailable, no module mocking needed.
15
+ const socketDir = mkdtempSync(join(tmpdir(), "ces-no-socket-"));
16
+ const prevSocketDir = process.env["CES_SOCKET_DIR"];
17
+ process.env["CES_SOCKET_DIR"] = socketDir;
18
+
19
+ afterAll(() => {
20
+ if (prevSocketDir === undefined) {
21
+ delete process.env["CES_SOCKET_DIR"];
22
+ } else {
23
+ process.env["CES_SOCKET_DIR"] = prevSocketDir;
24
+ }
25
+ });
26
+
9
27
  describe("createCesClient", () => {
10
- test("returns undefined when discovery fails without polling", async () => {
28
+ test("returns undefined when discovery finds no socket", async () => {
11
29
  const start = Date.now();
12
- const client = await createCesClient({
13
- discover: async () => ({
14
- mode: "unavailable",
15
- reason: "missing test socket",
16
- }),
17
- });
30
+ const client = await createCesClient();
18
31
 
19
32
  expect(client).toBeUndefined();
20
- expect(Date.now() - start).toBeLessThan(1_000);
33
+ // The discovery poll budget (3s) is the dominant cost — the point is
34
+ // that the factory settles on its own rather than hanging.
35
+ expect(Date.now() - start).toBeLessThan(10_000);
21
36
  });
22
37
  });
@@ -59,8 +59,6 @@ export interface CesSessionClient extends CesRpcClient, CesClientLifecycle {}
59
59
  export interface CesSessionClientOptions {
60
60
  /** Handshake material reused verbatim across reconnections. */
61
61
  handshake?: CesRpcHandshakeOptions;
62
- /** Socket discovery override. Tests inject a stub. */
63
- discover?: Parameters<typeof createCesProcessManager>[0]["discover"];
64
62
  /** RPC/handshake timeouts forwarded to the shared client. */
65
63
  config?: CesRpcClientConfig;
66
64
  }
@@ -79,7 +77,9 @@ export interface CesSessionClientOptions {
79
77
  export async function createCesClient(
80
78
  options: CesSessionClientOptions = {},
81
79
  ): Promise<CesSessionClient | undefined> {
82
- const pm = createCesProcessManager({ discover: options.discover });
80
+ // Socket discovery is the process manager's own concern — this factory
81
+ // only supplies the session-level handshake and timeouts.
82
+ const pm = createCesProcessManager();
83
83
  const handshake = options.handshake;
84
84
 
85
85
  let inner: CesRpcClient | undefined;
@@ -5,7 +5,10 @@ import { join } from "node:path";
5
5
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
6
6
 
7
7
  import { waitFor } from "../__tests__/helpers/wait-for.js";
8
- import { createCesProcessManager } from "./process-manager.js";
8
+ import {
9
+ type CesProcessManagerConfig,
10
+ createCesProcessManager,
11
+ } from "./process-manager.js";
9
12
 
10
13
  const untilClosed = (predicate: () => boolean) =>
11
14
  waitFor(predicate, {
@@ -124,10 +127,11 @@ describe("CesProcessManager.onTransportClose", () => {
124
127
  // ---------------------------------------------------------------------------
125
128
 
126
129
  type LogCall = { level: string; msg: string };
130
+ type PmLogger = NonNullable<CesProcessManagerConfig["logger"]>;
127
131
 
128
132
  function makeRecordingLogger(): {
129
133
  calls: LogCall[];
130
- logger: NonNullable<Parameters<typeof createCesProcessManager>[0]["logger"]>;
134
+ logger: PmLogger;
131
135
  } {
132
136
  const calls: LogCall[] = [];
133
137
  const rec =
@@ -141,9 +145,7 @@ function makeRecordingLogger(): {
141
145
  warn: rec("warn"),
142
146
  debug: rec("debug"),
143
147
  error: rec("error"),
144
- } as unknown as NonNullable<
145
- Parameters<typeof createCesProcessManager>[0]["logger"]
146
- >;
148
+ } as unknown as PmLogger;
147
149
  return { calls, logger };
148
150
  }
149
151
 
@@ -110,7 +110,7 @@ export interface CesProcessManager {
110
110
  // ---------------------------------------------------------------------------
111
111
 
112
112
  export function createCesProcessManager(
113
- config: CesProcessManagerConfig,
113
+ config: CesProcessManagerConfig = {},
114
114
  ): CesProcessManager {
115
115
  const pmLog = config.logger ?? log;
116
116
  const discover = config.discover ?? discoverCesWithRetry;
@@ -21,30 +21,18 @@ import { getLogger } from "../util/logger.js";
21
21
 
22
22
  const log = getLogger("ces-rpc-record-backend");
23
23
 
24
- /** Minimum interval between CES connection attempts after a failure. */
25
- const LAZY_SESSION_COOLDOWN_MS = 3_000;
26
-
27
- /** How long ensureClient waits on a self-healing session before giving up. */
28
- const RECONNECT_WAIT_MS = 15_000;
29
-
30
- function sleep(ms: number): Promise<void> {
31
- return new Promise((resolve) => setTimeout(resolve, ms));
32
- }
33
-
34
24
  class CesRpcRecordBackend {
35
25
  /**
36
26
  * The live IPC session, opened by this backend's first record operation.
37
27
  * The session owns its transport and reconnection: after a CES restart the
38
- * same reference becomes ready again, so this backend never re-opens or
39
- * swaps clients itself.
28
+ * same reference becomes ready again, so this backend never re-opens,
29
+ * swaps, or even waits on clients — it opens once and every operation
30
+ * either talks to that session or throws.
40
31
  */
41
32
  private client: CesSessionClient | undefined;
42
33
 
43
- /** Memoized in-flight session attempt (single-flight across callers). */
44
- private sessionAttempt: Promise<CesClient> | undefined;
45
-
46
- /** When the last connection attempt failed; gates re-attempts. */
47
- private lastFailedAttemptAt = 0;
34
+ /** Memoized open (single-flight across concurrent first operations). */
35
+ private clientPromise: Promise<CesClient> | undefined;
48
36
 
49
37
  /** Sync snapshot of the live client; `undefined` until connected. */
50
38
  getClient(): CesClient | undefined {
@@ -55,62 +43,32 @@ class CesRpcRecordBackend {
55
43
  * Resolve the client, opening our own CES IPC connection on first use.
56
44
  * Throws {@link CesUnavailableError} when CES is unreachable — callers
57
45
  * surface that rather than degrading, so a lost write is never mistaken
58
- * for a successful one. Failed attempts are not re-attempted within the
59
- * cooldown window: every operation would otherwise re-pay the discovery
60
- * polling while CES is down. A held session whose transport died is NOT
61
- * dropped here — the session reconnects itself; we just wait for it.
46
+ * for a successful one.
62
47
  */
63
48
  async ensureClient(): Promise<CesClient> {
64
- if (this.client?.isReady()) {
65
- return this.client;
66
- }
67
- if (this.client && !this.client.isReady()) {
68
- // The session is self-healing (in-session reconnect loop) — wait for
69
- // it to come back rather than opening a competing session.
70
- for (let waited = 0; waited < RECONNECT_WAIT_MS; waited += 250) {
71
- await sleep(250);
72
- if (this.client.isReady()) {
73
- return this.client;
49
+ this.clientPromise ??= createCesClient()
50
+ .then((client) => {
51
+ if (!client) {
52
+ throw new CesUnavailableError(
53
+ "CES is unreachable (socket discovery failed or handshake rejected)",
54
+ );
74
55
  }
75
- }
76
- throw new CesUnavailableError(
77
- "CES is unreachable (session reconnect did not become ready)",
78
- );
79
- }
80
- if (Date.now() - this.lastFailedAttemptAt < LAZY_SESSION_COOLDOWN_MS) {
81
- throw new CesUnavailableError(
82
- "CES is unreachable (recent connection attempt failed)",
83
- );
84
- }
85
- if (!this.sessionAttempt) {
86
- this.sessionAttempt = createCesClient()
87
- .then((client) => {
88
- if (!client) {
89
- throw new CesUnavailableError(
90
- "CES is unreachable (socket discovery failed or handshake rejected)",
91
- );
92
- }
93
- this.client = client;
94
- return client;
95
- })
96
- .catch((err) => {
97
- this.lastFailedAttemptAt = Date.now();
98
- throw err;
99
- })
100
- .finally(() => {
101
- this.sessionAttempt = undefined;
102
- });
103
- }
104
- return this.sessionAttempt;
56
+ this.client = client;
57
+ return client;
58
+ })
59
+ .catch((err) => {
60
+ // A failed open is not sticky: the next operation opens fresh.
61
+ this.clientPromise = undefined;
62
+ throw err;
63
+ });
64
+ return this.clientPromise;
105
65
  }
106
66
 
107
67
  /** Close the backend's connection (one-shot CLI processes call this on exit). */
108
68
  async close(): Promise<void> {
109
69
  const client = this.client;
110
70
  this.client = undefined;
111
- this.sessionAttempt = undefined;
112
- // A deliberate close is a fresh start: clear any open-failure cooldown.
113
- this.lastFailedAttemptAt = 0;
71
+ this.clientPromise = undefined;
114
72
  await client?.stop().catch(() => {});
115
73
  }
116
74