@rynx-ai/server 0.1.11-beta.1 → 0.1.11-beta.3

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.
@@ -42,12 +42,14 @@ export class SessionBrowserService {
42
42
  observers = new Set();
43
43
  pageIdFactory;
44
44
  bootGenerationSeed;
45
+ admissionReserve;
45
46
  shuttingDown = false;
46
47
  closeAllPromise;
47
48
  constructor(ports, options = {}) {
48
49
  this.ports = ports;
49
50
  this.pageIdFactory = options.pageIdFactory ?? (() => `page_${randomUUID()}`);
50
51
  this.bootGenerationSeed = options.bootGenerationSeed ?? randomBootGenerationSeed();
52
+ this.admissionReserve = options.admissionReserve;
51
53
  if (!Number.isSafeInteger(this.bootGenerationSeed) ||
52
54
  this.bootGenerationSeed < 0 ||
53
55
  this.bootGenerationSeed >= Number.MAX_SAFE_INTEGER) {
@@ -224,29 +226,35 @@ export class SessionBrowserService {
224
226
  });
225
227
  }
226
228
  async open(input) {
227
- const params = parseInput(() => parseRuntimeBrowserOpenParams(input), "Browser open request is invalid");
228
- return this.exclusive(params.sessionId, async () => {
229
- this.ensureRunning();
230
- await this.ensureSessionExists(params.sessionId);
231
- const existing = this.records.get(params.sessionId);
232
- if (existing?.status === "opening") {
233
- return this.project(existing);
234
- }
235
- if (existing?.status === "ready") {
236
- try {
237
- await this.reconcileForRead(existing);
229
+ const admission = this.reserveOpenAdmission();
230
+ try {
231
+ const params = parseInput(() => parseRuntimeBrowserOpenParams(input), "Browser open request is invalid");
232
+ return await this.exclusive(params.sessionId, async () => {
233
+ this.ensureRunning();
234
+ await this.ensureSessionExists(params.sessionId);
235
+ const existing = this.records.get(params.sessionId);
236
+ if (existing?.status === "opening") {
238
237
  return this.project(existing);
239
238
  }
240
- catch {
241
- // An App Host may have gone away immediately before this open. The
242
- // failed reconciliation marks the old generation unavailable, so an
243
- // idempotent open can recreate it on the current preferred Host.
239
+ if (existing?.status === "ready") {
240
+ try {
241
+ await this.reconcileForRead(existing);
242
+ return this.project(existing);
243
+ }
244
+ catch {
245
+ // An App Host may have gone away immediately before this open. The
246
+ // failed reconciliation marks the old generation unavailable, so an
247
+ // idempotent open can recreate it on the current preferred Host.
248
+ }
244
249
  }
245
- }
246
- if (existing)
247
- await this.closeUnavailableForRecreate(existing);
248
- return this.createBrowser(params);
249
- });
250
+ if (existing)
251
+ await this.closeUnavailableForRecreate(existing);
252
+ return this.createBrowser(params);
253
+ });
254
+ }
255
+ finally {
256
+ admission?.release();
257
+ }
250
258
  }
251
259
  async close(input) {
252
260
  const params = parseInput(() => parseRuntimeBrowserCloseParams(input), "Browser close request is invalid");
@@ -438,6 +446,15 @@ export class SessionBrowserService {
438
446
  throw new SessionBrowserServiceError("host_failure", "Browser Host state could not be initialized", { cause: error });
439
447
  }
440
448
  }
449
+ reserveOpenAdmission() {
450
+ if (!this.admissionReserve)
451
+ return undefined;
452
+ const admission = this.admissionReserve();
453
+ if (!admission) {
454
+ throw new SessionBrowserServiceError("shutting_down", "Browser service is shutting down");
455
+ }
456
+ return admission;
457
+ }
441
458
  async closeUnavailableForRecreate(record) {
442
459
  record.status = "closing";
443
460
  try {
@@ -13,6 +13,12 @@ export interface SessionRuntimeActivitySnapshot {
13
13
  */
14
14
  export declare class SessionRuntimeIndex {
15
15
  private readonly sessions;
16
+ /**
17
+ * Make an accepted Turn visible before the request admission reservation is
18
+ * released. The returned callback only abandons the pre-created handoff; once
19
+ * response.created arrives, the native response remains authoritative.
20
+ */
21
+ beginResponseHandoff(sessionId: string, responseId: string): () => void;
16
22
  observe(sessionId: string, event: SessionEvent): void;
17
23
  snapshot(sessionId: string): SessionRuntimeSnapshot;
18
24
  /** Aggregate only process-live work; durable Session history is not activity. */
@@ -20,4 +26,5 @@ export declare class SessionRuntimeIndex {
20
26
  remove(sessionId: string): void;
21
27
  private state;
22
28
  private removeResponseInteractions;
29
+ private hasRunningTurn;
23
30
  }
@@ -6,47 +6,76 @@
6
6
  */
7
7
  export class SessionRuntimeIndex {
8
8
  sessions = new Map();
9
+ /**
10
+ * Make an accepted Turn visible before the request admission reservation is
11
+ * released. The returned callback only abandons the pre-created handoff; once
12
+ * response.created arrives, the native response remains authoritative.
13
+ */
14
+ beginResponseHandoff(sessionId, responseId) {
15
+ const state = this.state(sessionId);
16
+ state.pendingResponseIds.add(responseId);
17
+ state.status = "running";
18
+ state.statusKind = "startup";
19
+ let pending = true;
20
+ return () => {
21
+ if (!pending)
22
+ return;
23
+ pending = false;
24
+ state.pendingResponseIds.delete(responseId);
25
+ };
26
+ }
9
27
  observe(sessionId, event) {
10
28
  const state = this.state(sessionId);
11
29
  switch (event.type) {
12
30
  case "response.created":
31
+ state.pendingResponseIds.delete(event.responseId);
13
32
  state.activeResponseIds.add(event.responseId);
14
33
  state.status = "running";
34
+ state.statusKind = undefined;
15
35
  return;
16
36
  case "response.completed":
37
+ state.pendingResponseIds.delete(event.responseId);
17
38
  state.activeResponseIds.delete(event.responseId);
18
39
  this.removeResponseInteractions(state, event.responseId);
19
- state.status = state.activeResponseIds.size > 0 ? "running" : "idle";
40
+ state.status = this.hasRunningTurn(state) ? "running" : "idle";
41
+ state.statusKind = undefined;
20
42
  return;
21
43
  case "response.failed":
44
+ state.pendingResponseIds.delete(event.responseId);
22
45
  state.activeResponseIds.delete(event.responseId);
23
46
  this.removeResponseInteractions(state, event.responseId);
24
47
  // Response failure is Turn-local. It must not poison a reusable Session.
25
- state.status = state.activeResponseIds.size > 0 ? "running" : "idle";
48
+ state.status = this.hasRunningTurn(state) ? "running" : "idle";
49
+ state.statusKind = undefined;
26
50
  return;
27
51
  case "session.status":
28
52
  state.status =
29
- state.pendingInteractions.size > 0 || state.activeResponseIds.size > 0
53
+ state.pendingInteractions.size > 0 || this.hasRunningTurn(state)
30
54
  ? "running"
31
55
  : event.status;
56
+ state.statusKind = event.statusKind;
32
57
  return;
33
58
  case "session.interaction.requested":
59
+ state.pendingResponseIds.delete(event.responseId);
34
60
  state.pendingInteractions.set(event.interaction.interactionId, {
35
61
  responseId: event.responseId,
36
62
  interaction: event.interaction,
37
63
  });
38
64
  state.activeResponseIds.add(event.responseId);
39
65
  state.status = "running";
66
+ state.statusKind = undefined;
40
67
  return;
41
68
  case "session.interaction.resolved":
42
69
  case "session.interaction.cancelled":
43
70
  state.pendingInteractions.delete(event.interactionId);
44
- state.status = state.activeResponseIds.size > 0 ? "running" : state.status;
71
+ state.status = this.hasRunningTurn(state) ? "running" : state.status;
45
72
  return;
46
73
  case "session.rotated":
74
+ state.pendingResponseIds.clear();
47
75
  state.activeResponseIds.clear();
48
76
  state.pendingInteractions.clear();
49
77
  state.status = "idle";
78
+ state.statusKind = undefined;
50
79
  return;
51
80
  default:
52
81
  return;
@@ -57,7 +86,10 @@ export class SessionRuntimeIndex {
57
86
  if (!state)
58
87
  return { status: "idle", activeResponseIds: [], pendingInteractions: [] };
59
88
  return {
60
- status: state.pendingInteractions.size > 0 ? "running" : state.status,
89
+ status: state.pendingInteractions.size > 0 || this.hasRunningTurn(state)
90
+ ? "running"
91
+ : state.status,
92
+ ...(state.statusKind === undefined ? {} : { statusKind: state.statusKind }),
61
93
  activeResponseIds: [...state.activeResponseIds],
62
94
  pendingInteractions: [...state.pendingInteractions.values()],
63
95
  };
@@ -67,7 +99,7 @@ export class SessionRuntimeIndex {
67
99
  let runningTurns = 0;
68
100
  let pendingInteractions = 0;
69
101
  for (const state of this.sessions.values()) {
70
- runningTurns += state.activeResponseIds.size;
102
+ runningTurns += state.pendingResponseIds.size + state.activeResponseIds.size;
71
103
  pendingInteractions += state.pendingInteractions.size;
72
104
  }
73
105
  return { runningTurns, pendingInteractions };
@@ -80,6 +112,7 @@ export class SessionRuntimeIndex {
80
112
  if (!state) {
81
113
  state = {
82
114
  status: "idle",
115
+ pendingResponseIds: new Set(),
83
116
  activeResponseIds: new Set(),
84
117
  pendingInteractions: new Map(),
85
118
  };
@@ -93,4 +126,7 @@ export class SessionRuntimeIndex {
93
126
  state.pendingInteractions.delete(interactionId);
94
127
  }
95
128
  }
129
+ hasRunningTurn(state) {
130
+ return state.pendingResponseIds.size > 0 || state.activeResponseIds.size > 0;
131
+ }
96
132
  }
@@ -1,3 +1,4 @@
1
+ import type { AdmissionReservation } from "@rynx-ai/core";
1
2
  import { type RunnerManager } from "@rynx-ai/runtime";
2
3
  export type SessionTerminalRole = "owner" | "read-only";
3
4
  export interface SessionTerminalOpenOptions {
@@ -46,6 +47,11 @@ export interface RunnerSessionTerminalHostOptions {
46
47
  } | undefined | Promise<{
47
48
  cwd: string;
48
49
  } | undefined>;
50
+ /**
51
+ * Synchronous daemon admission fence. A successful reservation is held until
52
+ * the attachment is registered and the runner confirms that it is ready.
53
+ */
54
+ admissionReserve?: () => AdmissionReservation | undefined;
49
55
  }
50
56
  /** Adapt the runner-child terminal protocol without exposing cwd or commands to clients. */
51
57
  export declare function createRunnerSessionTerminalHost(options: RunnerSessionTerminalHostOptions): RunnerSessionTerminalHost;
@@ -24,56 +24,71 @@ export function createRunnerSessionTerminalHost(options) {
24
24
  return {
25
25
  activeTerminalCount: () => attachmentCounts.size,
26
26
  async open(sessionId, openOptions) {
27
- validateOpen(sessionId, openOptions);
28
- throwIfAborted(openOptions.signal);
29
- const session = await options.resolveLiveSession(sessionId);
30
- if (!session || typeof session.cwd !== "string" || session.cwd.trim().length === 0) {
31
- throw new SessionTerminalOpenError("not_live", "Session terminal is not live");
32
- }
33
- throwIfAborted(openOptions.signal);
34
- let parent;
27
+ const admission = reserveTerminalAdmission(options.admissionReserve);
35
28
  try {
36
- parent = options.runnerManager.openLiveTerminal(sessionId, {
37
- role: openOptions.role,
38
- cwd: session.cwd,
39
- cols: openOptions.cols,
40
- rows: openOptions.rows,
41
- });
42
- }
43
- catch (error) {
44
- if (error instanceof TerminalOpenError && error.code === "terminal_not_live") {
45
- throw new SessionTerminalOpenError("not_live", "Session terminal is not live", {
46
- cause: error,
29
+ validateOpen(sessionId, openOptions);
30
+ throwIfAborted(openOptions.signal);
31
+ const session = await options.resolveLiveSession(sessionId);
32
+ if (!session || typeof session.cwd !== "string" || session.cwd.trim().length === 0) {
33
+ throw new SessionTerminalOpenError("not_live", "Session terminal is not live");
34
+ }
35
+ throwIfAborted(openOptions.signal);
36
+ let parent;
37
+ try {
38
+ parent = options.runnerManager.openLiveTerminal(sessionId, {
39
+ role: openOptions.role,
40
+ cwd: session.cwd,
41
+ cols: openOptions.cols,
42
+ rows: openOptions.rows,
47
43
  });
48
44
  }
49
- throw new SessionTerminalOpenError("internal", "Session terminal could not be opened", { cause: error });
50
- }
51
- const attachment = new RunnerSessionTerminalAttachment(sessionId, parent, openOptions.signal);
52
- const releaseActivity = trackTerminalAttachment(attachmentCounts, sessionId);
53
- void attachment.closed.then(releaseActivity, releaseActivity);
54
- try {
55
- const opened = await waitFor(parent.ready, openOptions.signal);
56
- attachment.opened(opened.role);
57
- attachment.throwIfOpeningFailed();
58
- return attachment;
59
- }
60
- catch (error) {
61
- await attachment.close().catch(() => undefined);
62
- if (isAbort(error)) {
63
- throw new SessionTerminalOpenError("invalid_request", "Session terminal open was cancelled", { cause: error });
45
+ catch (error) {
46
+ if (error instanceof TerminalOpenError && error.code === "terminal_not_live") {
47
+ throw new SessionTerminalOpenError("not_live", "Session terminal is not live", {
48
+ cause: error,
49
+ });
50
+ }
51
+ throw new SessionTerminalOpenError("internal", "Session terminal could not be opened", { cause: error });
64
52
  }
65
- if (error instanceof TerminalOpenError && error.code === "terminal_not_live") {
66
- throw new SessionTerminalOpenError("not_live", "Session terminal is not live", {
67
- cause: error,
68
- });
53
+ const attachment = new RunnerSessionTerminalAttachment(sessionId, parent, openOptions.signal);
54
+ const releaseActivity = trackTerminalAttachment(attachmentCounts, sessionId);
55
+ void attachment.closed.then(releaseActivity, releaseActivity);
56
+ try {
57
+ const opened = await waitFor(parent.ready, openOptions.signal);
58
+ attachment.opened(opened.role);
59
+ attachment.throwIfOpeningFailed();
60
+ return attachment;
61
+ }
62
+ catch (error) {
63
+ await attachment.close().catch(() => undefined);
64
+ if (isAbort(error)) {
65
+ throw new SessionTerminalOpenError("invalid_request", "Session terminal open was cancelled", { cause: error });
66
+ }
67
+ if (error instanceof TerminalOpenError && error.code === "terminal_not_live") {
68
+ throw new SessionTerminalOpenError("not_live", "Session terminal is not live", {
69
+ cause: error,
70
+ });
71
+ }
72
+ if (error instanceof SessionTerminalOpenError)
73
+ throw error;
74
+ throw new SessionTerminalOpenError("internal", "Session terminal could not be opened", { cause: error });
69
75
  }
70
- if (error instanceof SessionTerminalOpenError)
71
- throw error;
72
- throw new SessionTerminalOpenError("internal", "Session terminal could not be opened", { cause: error });
76
+ }
77
+ finally {
78
+ admission?.release();
73
79
  }
74
80
  },
75
81
  };
76
82
  }
83
+ function reserveTerminalAdmission(reserve) {
84
+ if (!reserve)
85
+ return undefined;
86
+ const admission = reserve();
87
+ if (!admission) {
88
+ throw new SessionTerminalOpenError("not_live", "Session terminal is unavailable during daemon maintenance");
89
+ }
90
+ return admission;
91
+ }
77
92
  function trackTerminalAttachment(attachmentCounts, sessionId) {
78
93
  attachmentCounts.set(sessionId, (attachmentCounts.get(sessionId) ?? 0) + 1);
79
94
  let released = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rynx-ai/server",
3
- "version": "0.1.11-beta.1",
3
+ "version": "0.1.11-beta.3",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/rynx-ai/rynx.git",
@@ -27,15 +27,15 @@
27
27
  "@koa/router": "^15.4.0",
28
28
  "koa": "^3.2.0",
29
29
  "ws": "^8.21.0",
30
- "@rynx-ai/core": "0.1.11-beta.1",
31
- "@rynx-ai/plugin-sdk": "0.1.11-beta.1",
32
- "@rynx-ai/protocol": "0.1.11-beta.1",
33
- "@rynx-ai/remote-runtime-e2ee": "0.1.11-beta.1",
34
- "@rynx-ai/runtime": "0.1.11-beta.1"
30
+ "@rynx-ai/plugin-sdk": "0.1.11-beta.3",
31
+ "@rynx-ai/protocol": "0.1.11-beta.3",
32
+ "@rynx-ai/core": "0.1.11-beta.3",
33
+ "@rynx-ai/remote-runtime-e2ee": "0.1.11-beta.3",
34
+ "@rynx-ai/runtime": "0.1.11-beta.3"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/ws": "^8.18.1",
38
- "@rynx-ai/control-web": "0.1.11-beta.1"
38
+ "@rynx-ai/control-web": "0.1.11-beta.3"
39
39
  },
40
40
  "scripts": {
41
41
  "build": "rm -rf dist && tsc -p tsconfig.json && mkdir -p dist/control-web && cp -R ../control-web/dist/. dist/control-web/",
@@ -1,60 +0,0 @@
1
- /**
2
- * Runtime manager for mounted channel instances in the single-process control
3
- * plane. Holds live channel handles keyed by instance id and can start / stop /
4
- * reload a single instance without restarting the daemon — the seam the web
5
- * control page drives.
6
- *
7
- * Shared pieces (host/conversationRuntime/codex session store) are injected once
8
- * and reused across instances; only the per-instance {@link ChannelContext}
9
- * fields (instanceId / options / defaultAgent) differ, so two Lark bots get
10
- * distinct credentials + state while sharing one agent runtime.
11
- */
12
- import type { AgentCapabilities, AgentSessionStore, AppConfig, ChannelInstanceDescriptor, ConversationRuntime, SessionBus, SessionLogStore, SessionRegistry } from "@rynx-ai/core";
13
- export interface ChannelInstanceStatus {
14
- instanceId: string;
15
- type: string;
16
- running: boolean;
17
- agent?: string;
18
- /** Connection state from the channel, or null when it exposes no status. */
19
- connected: boolean | null;
20
- lastError?: string | null;
21
- lastEventAt?: string | null;
22
- }
23
- export interface ChannelManagerOptions {
24
- config: AppConfig;
25
- conversationRuntime: ConversationRuntime;
26
- capabilities: AgentCapabilities;
27
- sessionStore: AgentSessionStore;
28
- /** Durable canonical session log, shared across instances (absent ⇒ no
29
- * persistence). */
30
- sessionLog?: SessionLogStore;
31
- /** Shared canonical session event bus (one instance ⇒ cross-channel fan-out). */
32
- sessionBus?: SessionBus;
33
- /** Unified machine-session registry, shared across instances; channels register
34
- * their sessions' identity rows here so the control plane lists them. */
35
- sessionRegistry?: SessionRegistry;
36
- /** Source of truth for current instance descriptors (daemon reads config). */
37
- loadInstances: () => Promise<ChannelInstanceDescriptor[]>;
38
- log?: (line: string) => void;
39
- }
40
- export declare class ChannelManager {
41
- private readonly opts;
42
- private readonly mounted;
43
- constructor(opts: ChannelManagerOptions);
44
- /** Mount + start every enabled instance from `loadInstances`. */
45
- startAll(): Promise<void>;
46
- /** Stop every mounted instance (on server close). */
47
- stopAll(): Promise<void>;
48
- /** Live status of currently-mounted instances. */
49
- list(): ChannelInstanceStatus[];
50
- isRunning(instanceId: string): boolean;
51
- /** Start one instance by id (re-reads descriptors). No-op if already running. */
52
- startInstance(instanceId: string): Promise<void>;
53
- /** Stop + unmount one instance. No-op if not running. */
54
- stopInstance(instanceId: string): Promise<void>;
55
- /** Stop then start, picking up edited options/agent. */
56
- reloadInstance(instanceId: string): Promise<void>;
57
- private mountAndStart;
58
- private buildContext;
59
- private log;
60
- }
@@ -1,106 +0,0 @@
1
- export class ChannelManager {
2
- opts;
3
- mounted = new Map();
4
- constructor(opts) {
5
- this.opts = opts;
6
- }
7
- /** Mount + start every enabled instance from `loadInstances`. */
8
- async startAll() {
9
- const instances = await this.opts.loadInstances();
10
- for (const descriptor of instances) {
11
- await this.mountAndStart(descriptor);
12
- }
13
- }
14
- /** Stop every mounted instance (on server close). */
15
- async stopAll() {
16
- const ids = [...this.mounted.keys()];
17
- await Promise.all(ids.map((id) => this.stopInstance(id)));
18
- }
19
- /** Live status of currently-mounted instances. */
20
- list() {
21
- return [...this.mounted.values()].map(({ descriptor, channel }) => {
22
- const status = channel.getStatus?.();
23
- return {
24
- instanceId: descriptor.instanceId,
25
- type: descriptor.type,
26
- running: true,
27
- agent: descriptor.agent,
28
- connected: status ? status.connected : null,
29
- lastError: status?.lastError ?? null,
30
- lastEventAt: status?.lastEventAt ?? null,
31
- };
32
- });
33
- }
34
- isRunning(instanceId) {
35
- return this.mounted.has(instanceId);
36
- }
37
- /** Start one instance by id (re-reads descriptors). No-op if already running. */
38
- async startInstance(instanceId) {
39
- if (this.mounted.has(instanceId))
40
- return;
41
- const descriptor = (await this.opts.loadInstances()).find((instance) => instance.instanceId === instanceId);
42
- if (!descriptor) {
43
- throw new Error(`channel instance "${instanceId}" is not enabled/configured`);
44
- }
45
- await this.mountAndStart(descriptor);
46
- }
47
- /** Stop + unmount one instance. No-op if not running. */
48
- async stopInstance(instanceId) {
49
- const entry = this.mounted.get(instanceId);
50
- if (!entry)
51
- return;
52
- this.mounted.delete(instanceId);
53
- try {
54
- await entry.channel.stop();
55
- }
56
- catch (error) {
57
- this.log(`instance "${instanceId}" stop failed: ${messageOf(error)}`);
58
- }
59
- }
60
- /** Stop then start, picking up edited options/agent. */
61
- async reloadInstance(instanceId) {
62
- await this.stopInstance(instanceId);
63
- // Only restart if it's still enabled/configured (disable ⇒ stays stopped).
64
- const descriptor = (await this.opts.loadInstances()).find((instance) => instance.instanceId === instanceId);
65
- if (descriptor) {
66
- await this.mountAndStart(descriptor);
67
- }
68
- }
69
- async mountAndStart(descriptor) {
70
- let channel;
71
- try {
72
- channel = descriptor.factory(this.buildContext(descriptor));
73
- }
74
- catch (error) {
75
- this.log(`instance "${descriptor.instanceId}" construct failed: ${messageOf(error)}`);
76
- return;
77
- }
78
- this.mounted.set(descriptor.instanceId, { descriptor, channel });
79
- try {
80
- await channel.start();
81
- }
82
- catch (error) {
83
- this.log(`instance "${descriptor.instanceId}" start failed: ${messageOf(error)}`);
84
- }
85
- }
86
- buildContext(descriptor) {
87
- return {
88
- config: this.opts.config,
89
- conversationRuntime: this.opts.conversationRuntime,
90
- capabilities: this.opts.capabilities,
91
- sessionStore: this.opts.sessionStore,
92
- sessionLog: this.opts.sessionLog,
93
- sessionBus: this.opts.sessionBus,
94
- sessionRegistry: this.opts.sessionRegistry,
95
- instanceId: descriptor.instanceId,
96
- options: descriptor.options,
97
- defaultAgent: descriptor.agent,
98
- };
99
- }
100
- log(line) {
101
- (this.opts.log ?? ((message) => console.error(message)))(line);
102
- }
103
- }
104
- function messageOf(error) {
105
- return error instanceof Error ? error.message : String(error);
106
- }