@rynx-ai/daemon 0.1.11-beta.30 → 0.1.11-beta.32

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.
@@ -3,10 +3,13 @@ import { realpath, stat } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { AgentRuntimeError, agentSpecSchema, resolvedExecutionSnapshotSchema, sessionWorkspaceSnapshotSchema, } from "@rynx-ai/core";
5
5
  import { PLUGIN_HOST_MAX_ACTIVE_RUNS, } from "@rynx-ai/plugin-sdk";
6
+ import { parseRuntimeBrowserRequestHeadersGetParams, parseRuntimeBrowserRequestHeadersSetParams, } from "@rynx-ai/protocol/runtime-browser";
7
+ import { SessionBrowserServiceError, } from "@rynx-ai/server";
6
8
  import { PluginRunnerError } from "@rynx-ai/plugin-runner";
7
9
  import { ensurePluginSessionLaunch, replayPluginSessionLaunch, SessionLaunchOperationConflictError, SessionLaunchOperationTargetDeletedError, } from "./session-launch-operations.js";
8
10
  import { createControlLink } from "./control-link-store.js";
9
- import { issueSessionPortalTicket } from "./session-portal-grant-store.js";
11
+ import { issueSessionPortalTicket, revokeSessionPortalGrant, } from "./session-portal-grant-store.js";
12
+ import { claimSessionTimelineProjection, completeSessionTimelineProjection, } from "./session-timeline-projection-store.js";
10
13
  const MAX_MESSAGE_LENGTH = 1_000_000;
11
14
  /** Leaves ample room for the RPC envelope below the runner's 1 MiB line cap. */
12
15
  export const MAX_PLUGIN_HOST_EVENT_BYTES = 512 * 1024;
@@ -119,6 +122,18 @@ export class PluginHostRpc {
119
122
  capabilities: {
120
123
  sessionRunContent: true,
121
124
  sessionRunFiles: true,
125
+ ...(this.services.sessionTimeline
126
+ ? { sessionTimelineProjectText: true }
127
+ : {}),
128
+ ...(this.services.sessionBrowserRequestHeaders
129
+ ? { sessionBrowserRequestHeaders: true }
130
+ : {}),
131
+ ...(this.services.sessionPortal
132
+ ? {
133
+ sessionPortalEmbedOrigin: true,
134
+ sessionPortalGrantRevocation: true,
135
+ }
136
+ : {}),
122
137
  },
123
138
  ...(this.services.sessionPortal
124
139
  ? { sessionPortal: { ...this.services.sessionPortal } }
@@ -176,18 +191,31 @@ export class PluginHostRpc {
176
191
  return this.services.sessionRegistry.list().filter((session) => this.isOwned(session));
177
192
  case "session.meta.setTitle":
178
193
  return this.setSessionTitle(params);
194
+ case "session.browser.requestHeaders.get":
195
+ return await this.getSessionBrowserRequestHeaders(params);
196
+ case "session.browser.requestHeaders.set":
197
+ return await this.setSessionBrowserRequestHeaders(params);
179
198
  case "control.link.issue":
180
199
  return this.issueControlLink(params);
181
200
  case "session.portal.plugin.issue": {
182
- const access = requiredSessionPortalAccess(params);
183
- return this.issueSessionPortalLink("plugin_sessions", access);
201
+ const input = record(params);
202
+ return this.issueSessionPortalLink("plugin_sessions", requiredSessionPortalAccess(input), undefined, optionalSessionPortalEmbedOrigin(input.embedOrigin), optionalString(input.grantHandle, "grantHandle", 128));
184
203
  }
185
204
  case "session.portal.session.issue": {
186
205
  const input = record(params);
187
206
  const sessionId = requiredSessionId(input);
188
207
  this.ownedSession(sessionId);
189
- return this.issueSessionPortalLink("single_session", requiredSessionPortalAccess(input), sessionId);
208
+ return this.issueSessionPortalLink("single_session", requiredSessionPortalAccess(input), sessionId, optionalSessionPortalEmbedOrigin(input.embedOrigin), optionalString(input.grantHandle, "grantHandle", 128));
209
+ }
210
+ case "session.portal.grant.revoke": {
211
+ const grantHandle = requiredString(record(params).grantHandle, "grantHandle", 128);
212
+ return revokeSessionPortalGrant({
213
+ pluginId: this.principalId,
214
+ grantHandle,
215
+ });
190
216
  }
217
+ case "session.timeline.projectText":
218
+ return await this.projectTimelineText(params);
191
219
  default:
192
220
  throw new PluginHostCallError("METHOD_NOT_FOUND", `unknown host method: ${method}`);
193
221
  }
@@ -843,6 +871,36 @@ export class PluginHostRpc {
843
871
  this.services.sessionRegistry.setTitle(sessionId, title);
844
872
  return { updated: true };
845
873
  }
874
+ async getSessionBrowserRequestHeaders(params) {
875
+ let input;
876
+ try {
877
+ input = parseRuntimeBrowserRequestHeadersGetParams(params);
878
+ }
879
+ catch {
880
+ throw new PluginHostCallError("INVALID_PARAMS", "Session Browser request headers parameters are invalid");
881
+ }
882
+ this.ownedSession(input.sessionId);
883
+ const service = this.services.sessionBrowserRequestHeaders;
884
+ if (!service) {
885
+ throw new PluginHostCallError("METHOD_NOT_FOUND", "Session Browser request headers are unsupported by this Host");
886
+ }
887
+ return await service.getRequestHeaders(input);
888
+ }
889
+ async setSessionBrowserRequestHeaders(params) {
890
+ let input;
891
+ try {
892
+ input = parseRuntimeBrowserRequestHeadersSetParams(params);
893
+ }
894
+ catch {
895
+ throw new PluginHostCallError("INVALID_PARAMS", "Session Browser request headers parameters are invalid");
896
+ }
897
+ this.ownedSession(input.sessionId);
898
+ const service = this.services.sessionBrowserRequestHeaders;
899
+ if (!service) {
900
+ throw new PluginHostCallError("METHOD_NOT_FOUND", "Session Browser request headers are unsupported by this Host");
901
+ }
902
+ return await service.setRequestHeaders(input);
903
+ }
846
904
  resolveAgentExecution(params) {
847
905
  const input = record(params);
848
906
  const defaultAgent = optionalAgentId(input.defaultAgent, "defaultAgent");
@@ -862,7 +920,7 @@ export class PluginHostRpc {
862
920
  ...(ttlSeconds === undefined ? {} : { ttlMs: ttlSeconds * 1_000 }),
863
921
  });
864
922
  }
865
- issueSessionPortalLink(scope, access, sessionId) {
923
+ issueSessionPortalLink(scope, access, sessionId, embedOrigin, grantHandle) {
866
924
  if (!this.services.sessionPortal) {
867
925
  throw new PluginHostCallError("HOST_CALL_FAILED", "the Session Portal is not configured");
868
926
  }
@@ -871,8 +929,47 @@ export class PluginHostRpc {
871
929
  scope,
872
930
  access,
873
931
  ...(sessionId ? { sessionId } : {}),
932
+ ...(embedOrigin ? { embedOrigin } : {}),
933
+ ...(grantHandle ? { grantHandle } : {}),
874
934
  });
875
935
  }
936
+ async projectTimelineText(params) {
937
+ const sessionTimeline = this.services.sessionTimeline;
938
+ if (!sessionTimeline) {
939
+ throw new PluginHostCallError("HOST_CALL_FAILED", "Session timeline projection is unavailable");
940
+ }
941
+ const input = record(params);
942
+ const sessionId = requiredSessionId(input);
943
+ this.ownedSession(sessionId);
944
+ const projection = claimSessionTimelineProjection({
945
+ pluginId: this.principalId,
946
+ sessionId,
947
+ projectionId: requiredString(input.projectionId, "projectionId", 256),
948
+ ...(input.responseId === undefined
949
+ ? {}
950
+ : { responseId: requiredString(input.responseId, "responseId", 256) }),
951
+ text: requiredText(input.text, "text", 64 * 1024),
952
+ });
953
+ if (!(await sessionTimeline.hasItem(sessionId, projection.itemId))) {
954
+ await sessionTimeline.projectText({
955
+ sessionId,
956
+ responseId: projection.responseId,
957
+ itemId: projection.itemId,
958
+ text: projection.text,
959
+ });
960
+ }
961
+ completeSessionTimelineProjection({
962
+ pluginId: this.principalId,
963
+ sessionId,
964
+ projectionId: projection.projectionId,
965
+ });
966
+ return {
967
+ projectionId: projection.projectionId,
968
+ responseId: projection.responseId,
969
+ itemId: projection.itemId,
970
+ disposition: projection.disposition,
971
+ };
972
+ }
876
973
  ownedSession(sessionId) {
877
974
  const session = this.services.sessionRegistry.get(sessionId);
878
975
  if (!session || !this.isOwned(session)) {
@@ -933,6 +1030,18 @@ export class PluginHostCallError extends PluginRunnerError {
933
1030
  function toHostCallError(error) {
934
1031
  if (error instanceof PluginHostCallError)
935
1032
  return error;
1033
+ if (error instanceof SessionBrowserServiceError) {
1034
+ if (error.code === "revision_mismatch") {
1035
+ return new PluginHostCallError("REVISION_CONFLICT", "Session Browser request headers changed since they were read");
1036
+ }
1037
+ if (error.code === "invalid_request") {
1038
+ return new PluginHostCallError("INVALID_PARAMS", "Session Browser request headers parameters are invalid");
1039
+ }
1040
+ if (error.code === "not_found") {
1041
+ return new PluginHostCallError("SESSION_NOT_FOUND", "plugin session not found");
1042
+ }
1043
+ return new PluginHostCallError("HOST_CALL_FAILED", "Session Browser request headers operation failed");
1044
+ }
936
1045
  if (error instanceof AgentRuntimeError && error.code === "live_unavailable") {
937
1046
  // ConversationRuntime emits this code only before injectMessage is called:
938
1047
  // the native live Session (including its exact Skill environment) could
@@ -1109,6 +1218,21 @@ function requiredSessionPortalAccess(params) {
1109
1218
  return access;
1110
1219
  throw new PluginHostCallError("INVALID_PARAMS", "access must be read_only or read_write");
1111
1220
  }
1221
+ function optionalSessionPortalEmbedOrigin(value) {
1222
+ if (value === undefined)
1223
+ return undefined;
1224
+ const candidate = requiredString(value, "embedOrigin", 512);
1225
+ try {
1226
+ const parsed = new URL(candidate);
1227
+ if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
1228
+ parsed.origin !== candidate)
1229
+ throw new Error("invalid origin");
1230
+ return parsed.origin;
1231
+ }
1232
+ catch {
1233
+ throw new PluginHostCallError("INVALID_PARAMS", "embedOrigin must be an exact http(s) origin");
1234
+ }
1235
+ }
1112
1236
  function sessionInteractionResolution(value) {
1113
1237
  const input = record(value);
1114
1238
  const keys = Object.keys(input);
@@ -5,6 +5,7 @@ export interface SessionPortalAuthorization {
5
5
  pluginId: string;
6
6
  scope: SessionPortalScope;
7
7
  access: SessionPortalAccess;
8
+ embedOrigin?: string;
8
9
  sessionId?: string;
9
10
  expiresAt: string;
10
11
  }
@@ -16,14 +17,39 @@ export declare function issueSessionPortalTicket(input: {
16
17
  scope: SessionPortalScope;
17
18
  access: SessionPortalAccess;
18
19
  sessionId?: string;
20
+ embedOrigin?: string;
21
+ grantHandle?: string;
19
22
  now?: Date;
20
23
  }): {
21
24
  path: string;
22
25
  expiresAt: string;
26
+ grantHandle: string;
23
27
  };
24
28
  /** Atomically consume a one-use ticket and mint a fixed-lifetime browser grant. */
25
- export declare function redeemSessionPortalTicket(ticket: string, now?: Date): (SessionPortalAuthorization & {
29
+ export declare function redeemSessionPortalTicket(ticket: string, now?: Date, embedOrigin?: string): (SessionPortalAuthorization & {
26
30
  secret: string;
27
31
  }) | undefined;
32
+ /** Read the public, still-active half of a grant without accepting its secret.
33
+ * Used only to bind an embedded SPA bootstrap and transport lifecycle to the
34
+ * exact grant that was issued; it does not authorize an API call. */
35
+ export declare function inspectSessionPortalGrant(portalId: string, now?: Date): SessionPortalAuthorization | undefined;
28
36
  export declare function authorizeSessionPortalGrant(portalId: string, secret: string, now?: Date): SessionPortalAuthorization | undefined;
37
+ /** Revoke one issued Portal link, whether its ticket is still unused or it has
38
+ * already become a browser grant. The opaque handle carries no product meaning. */
39
+ export declare function revokeSessionPortalGrant(input: {
40
+ pluginId: string;
41
+ grantHandle: string;
42
+ now?: Date;
43
+ }): Promise<{
44
+ revoked: boolean;
45
+ }>;
46
+ /** Subscribe to committed grant revocations. The notification is emitted only
47
+ * after SQLite commits, so a listener may synchronously tear down every
48
+ * transport admitted under the revoked portal id. */
49
+ export declare function onSessionPortalGrantRevoked(listener: (portalIds: readonly string[]) => void | Promise<void>): () => void;
50
+ /** Embedded writable grants are process-generation capabilities. A daemon
51
+ * restart has no surviving transport to drain and Plugin recovery may fail, so
52
+ * fail closed before the Portal listener is reopened; ordinary non-embedded
53
+ * Portal links retain their historical fixed-lifetime behavior. */
54
+ export declare function revokeEmbeddedWritableSessionPortalGrants(now?: Date): void;
29
55
  export declare function pruneSessionPortalAuthorization(now?: Date): void;
@@ -3,6 +3,7 @@ import { db } from "./db.js";
3
3
  import { transaction } from "./sqlite.js";
4
4
  const TICKET_TTL_MS = 5 * 60 * 1_000;
5
5
  const GRANT_TTL_MS = 30 * 60 * 1_000;
6
+ const revocationListeners = new Set();
6
7
  /** Issue a browser handoff into the native Session workbench. The Host knows
7
8
  * only the generic Session scope and access level; product/user policy stays in
8
9
  * the plugin that asks for the ticket. */
@@ -13,24 +14,34 @@ export function issueSessionPortalTicket(input) {
13
14
  const now = input.now ?? new Date();
14
15
  const expiresAt = new Date(now.getTime() + TICKET_TTL_MS).toISOString();
15
16
  const ticket = randomToken();
17
+ const grantHandle = input.grantHandle ?? randomUUID();
16
18
  pruneSessionPortalAuthorization(now);
17
19
  db().prepare(`INSERT INTO session_portal_tickets
18
- (ticket_hash, plugin_id, scope, access, session_id, created_at, expires_at, used_at)
19
- VALUES (?, ?, ?, ?, ?, ?, ?, NULL)`).run(hash(ticket), input.pluginId, input.scope, input.access, input.sessionId ?? null, now.toISOString(), expiresAt);
20
+ (ticket_hash, plugin_id, scope, access, session_id, created_at, expires_at, used_at,
21
+ grant_handle, embed_origin)
22
+ VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`).run(hash(ticket), input.pluginId, input.scope, input.access, input.sessionId ?? null, now.toISOString(), expiresAt, grantHandle, input.embedOrigin ?? null);
23
+ const redeem = new URL("/session-portal/redeem", "http://session-portal.invalid");
24
+ if (input.embedOrigin)
25
+ redeem.searchParams.set("embed_origin", input.embedOrigin);
20
26
  return {
21
- path: `/session-portal/redeem#ticket=${encodeURIComponent(ticket)}`,
27
+ path: `${redeem.pathname}${redeem.search}#ticket=${encodeURIComponent(ticket)}`,
22
28
  expiresAt,
29
+ grantHandle,
23
30
  };
24
31
  }
25
32
  /** Atomically consume a one-use ticket and mint a fixed-lifetime browser grant. */
26
- export function redeemSessionPortalTicket(ticket, now = new Date()) {
33
+ export function redeemSessionPortalTicket(ticket, now = new Date(), embedOrigin) {
27
34
  const connection = db();
28
35
  return transaction(connection, () => {
29
36
  const ticketHash = hash(ticket);
30
- const row = connection.prepare(`SELECT plugin_id, scope, access, session_id, expires_at, used_at
37
+ const row = connection.prepare(`SELECT plugin_id, scope, access, session_id, expires_at, used_at,
38
+ grant_handle, embed_origin
31
39
  FROM session_portal_tickets
32
40
  WHERE ticket_hash = ?`).get(ticketHash);
33
- if (!row || row.used_at !== null || row.expires_at <= now.toISOString())
41
+ if (!row ||
42
+ row.used_at !== null ||
43
+ row.expires_at <= now.toISOString() ||
44
+ (row.embed_origin ?? "") !== (embedOrigin ?? ""))
34
45
  return undefined;
35
46
  const consumed = connection.prepare(`UPDATE session_portal_tickets SET used_at = ?
36
47
  WHERE ticket_hash = ? AND used_at IS NULL AND expires_at > ?`).run(now.toISOString(), ticketHash, now.toISOString());
@@ -40,21 +51,36 @@ export function redeemSessionPortalTicket(ticket, now = new Date()) {
40
51
  const secret = randomToken();
41
52
  const expiresAt = new Date(now.getTime() + GRANT_TTL_MS).toISOString();
42
53
  connection.prepare(`INSERT INTO session_portal_grants
43
- (portal_id, secret_hash, plugin_id, scope, access, session_id, created_at, expires_at, revoked_at)
44
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`).run(portalId, hash(secret), row.plugin_id, row.scope, row.access, row.session_id, now.toISOString(), expiresAt);
54
+ (portal_id, secret_hash, plugin_id, scope, access, session_id, created_at, expires_at,
55
+ revoked_at, grant_handle, embed_origin)
56
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`).run(portalId, hash(secret), row.plugin_id, row.scope, row.access, row.session_id, now.toISOString(), expiresAt, row.grant_handle, row.embed_origin);
45
57
  return {
46
58
  portalId,
47
59
  secret,
48
60
  pluginId: row.plugin_id,
49
61
  scope: row.scope,
50
62
  access: row.access,
63
+ ...(row.embed_origin ? { embedOrigin: row.embed_origin } : {}),
51
64
  ...(row.session_id ? { sessionId: row.session_id } : {}),
52
65
  expiresAt,
53
66
  };
54
67
  })();
55
68
  }
69
+ /** Read the public, still-active half of a grant without accepting its secret.
70
+ * Used only to bind an embedded SPA bootstrap and transport lifecycle to the
71
+ * exact grant that was issued; it does not authorize an API call. */
72
+ export function inspectSessionPortalGrant(portalId, now = new Date()) {
73
+ const row = db().prepare(`SELECT portal_id, secret_hash, plugin_id, scope, access, session_id, expires_at, revoked_at,
74
+ grant_handle, embed_origin
75
+ FROM session_portal_grants
76
+ WHERE portal_id = ?`).get(portalId);
77
+ if (!row || row.revoked_at !== null || row.expires_at <= now.toISOString())
78
+ return undefined;
79
+ return publicGrant(row);
80
+ }
56
81
  export function authorizeSessionPortalGrant(portalId, secret, now = new Date()) {
57
- const row = db().prepare(`SELECT portal_id, secret_hash, plugin_id, scope, access, session_id, expires_at, revoked_at
82
+ const row = db().prepare(`SELECT portal_id, secret_hash, plugin_id, scope, access, session_id, expires_at, revoked_at,
83
+ grant_handle, embed_origin
58
84
  FROM session_portal_grants
59
85
  WHERE portal_id = ?`).get(portalId);
60
86
  if (!row || row.revoked_at !== null || row.expires_at <= now.toISOString())
@@ -63,14 +89,52 @@ export function authorizeSessionPortalGrant(portalId, secret, now = new Date())
63
89
  const expected = Buffer.from(row.secret_hash, "hex");
64
90
  if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
65
91
  return undefined;
66
- return {
67
- portalId: row.portal_id,
68
- pluginId: row.plugin_id,
69
- scope: row.scope,
70
- access: row.access,
71
- ...(row.session_id ? { sessionId: row.session_id } : {}),
72
- expiresAt: row.expires_at,
73
- };
92
+ return publicGrant(row);
93
+ }
94
+ /** Revoke one issued Portal link, whether its ticket is still unused or it has
95
+ * already become a browser grant. The opaque handle carries no product meaning. */
96
+ export async function revokeSessionPortalGrant(input) {
97
+ const now = input.now ?? new Date();
98
+ const connection = db();
99
+ const result = transaction(connection, () => {
100
+ const portalIds = connection.prepare(`SELECT portal_id FROM session_portal_grants
101
+ WHERE plugin_id = ? AND grant_handle = ? AND revoked_at IS NULL`).all(input.pluginId, input.grantHandle);
102
+ const tickets = connection.prepare(`UPDATE session_portal_tickets SET used_at = ?
103
+ WHERE plugin_id = ? AND grant_handle = ? AND used_at IS NULL`).run(now.toISOString(), input.pluginId, input.grantHandle);
104
+ const grants = connection.prepare(`UPDATE session_portal_grants SET revoked_at = ?
105
+ WHERE plugin_id = ? AND grant_handle = ? AND revoked_at IS NULL`).run(now.toISOString(), input.pluginId, input.grantHandle);
106
+ return {
107
+ revoked: tickets.changes > 0 || grants.changes > 0,
108
+ portalIds: portalIds.map((row) => row.portal_id),
109
+ };
110
+ })();
111
+ if (result.portalIds.length > 0) {
112
+ // Revocation is a barrier, not merely a database flag. Host RPC returns
113
+ // only after every Portal transport admitted under the committed grant has
114
+ // been aborted and drained.
115
+ await Promise.all([...revocationListeners].map((listener) => listener(result.portalIds)));
116
+ }
117
+ return { revoked: result.revoked };
118
+ }
119
+ /** Subscribe to committed grant revocations. The notification is emitted only
120
+ * after SQLite commits, so a listener may synchronously tear down every
121
+ * transport admitted under the revoked portal id. */
122
+ export function onSessionPortalGrantRevoked(listener) {
123
+ revocationListeners.add(listener);
124
+ return () => revocationListeners.delete(listener);
125
+ }
126
+ /** Embedded writable grants are process-generation capabilities. A daemon
127
+ * restart has no surviving transport to drain and Plugin recovery may fail, so
128
+ * fail closed before the Portal listener is reopened; ordinary non-embedded
129
+ * Portal links retain their historical fixed-lifetime behavior. */
130
+ export function revokeEmbeddedWritableSessionPortalGrants(now = new Date()) {
131
+ const connection = db();
132
+ transaction(connection, () => {
133
+ connection.prepare(`UPDATE session_portal_tickets SET used_at=?
134
+ WHERE access='read_write' AND embed_origin IS NOT NULL AND used_at IS NULL`).run(now.toISOString());
135
+ connection.prepare(`UPDATE session_portal_grants SET revoked_at=?
136
+ WHERE access='read_write' AND embed_origin IS NOT NULL AND revoked_at IS NULL`).run(now.toISOString());
137
+ })();
74
138
  }
75
139
  export function pruneSessionPortalAuthorization(now = new Date()) {
76
140
  const connection = db();
@@ -83,3 +147,14 @@ function randomToken() {
83
147
  function hash(value) {
84
148
  return createHash("sha256").update(value, "utf8").digest("hex");
85
149
  }
150
+ function publicGrant(row) {
151
+ return {
152
+ portalId: row.portal_id,
153
+ pluginId: row.plugin_id,
154
+ scope: row.scope,
155
+ access: row.access,
156
+ ...(row.embed_origin ? { embedOrigin: row.embed_origin } : {}),
157
+ ...(row.session_id ? { sessionId: row.session_id } : {}),
158
+ expiresAt: row.expires_at,
159
+ };
160
+ }
@@ -28,6 +28,10 @@ export declare class SqliteSessionResourceStore implements MachineSessionResourc
28
28
  markMessageInjected(sessionId: string, clientMessageId: string, identity?: {
29
29
  responseId: string;
30
30
  }): void;
31
+ markMessageMirrored(sessionId: string, clientMessageId: string, identity: {
32
+ responseId: string;
33
+ messageItemId: string;
34
+ }): void;
31
35
  markMessageOutcomeUnknown(sessionId: string, clientMessageId: string, reason: string): void;
32
36
  markMessageFailedNotStarted(sessionId: string, clientMessageId: string, reason: string): void;
33
37
  deleteSession(sessionId: string): void;
@@ -347,6 +347,28 @@ export class SqliteSessionResourceStore {
347
347
  response_id = COALESCE(response_id, ?),
348
348
  updated_at = ?
349
349
  WHERE session_id = ? AND client_message_id = ? AND state = 'injecting'`).run(identity?.responseId ?? null, Date.now(), sessionId, clientMessageId);
350
+ if (result.changes !== 1 && current.state !== "mirrored") {
351
+ throw conflict("Message operation state changed");
352
+ }
353
+ }
354
+ markMessageMirrored(sessionId, clientMessageId, identity) {
355
+ const current = db().prepare(`SELECT state, response_id, message_item_id
356
+ FROM session_message_operations
357
+ WHERE session_id = ? AND client_message_id = ?`).get(sessionId, clientMessageId);
358
+ if (!current ||
359
+ (current.response_id && current.response_id !== identity.responseId) ||
360
+ (current.message_item_id && current.message_item_id !== identity.messageItemId)) {
361
+ throw conflict("Message operation identity changed");
362
+ }
363
+ if (current.state === "mirrored")
364
+ return;
365
+ const result = db().prepare(`UPDATE session_message_operations
366
+ SET state = 'mirrored', error = NULL,
367
+ response_id = COALESCE(response_id, ?),
368
+ message_item_id = COALESCE(message_item_id, ?),
369
+ updated_at = ?
370
+ WHERE session_id = ? AND client_message_id = ?
371
+ AND state IN ('injecting', 'injected', 'outcome_unknown')`).run(identity.responseId, identity.messageItemId, Date.now(), sessionId, clientMessageId);
350
372
  if (result.changes !== 1)
351
373
  throw conflict("Message operation state changed");
352
374
  }
@@ -0,0 +1,24 @@
1
+ export interface SessionTimelineProjection {
2
+ pluginId: string;
3
+ sessionId: string;
4
+ projectionId: string;
5
+ responseId: string;
6
+ itemId: string;
7
+ text: string;
8
+ state: "pending" | "completed";
9
+ disposition: "created" | "replayed";
10
+ }
11
+ /** Durable identity for a narrow text projection. The text itself deliberately
12
+ * stays in the canonical Session item; this row only fences retries. */
13
+ export declare function claimSessionTimelineProjection(input: {
14
+ pluginId: string;
15
+ sessionId: string;
16
+ projectionId: string;
17
+ responseId?: string;
18
+ text: string;
19
+ }): SessionTimelineProjection;
20
+ export declare function completeSessionTimelineProjection(input: {
21
+ pluginId: string;
22
+ sessionId: string;
23
+ projectionId: string;
24
+ }): void;
@@ -0,0 +1,53 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { db } from "./db.js";
3
+ import { transaction } from "./sqlite.js";
4
+ /** Durable identity for a narrow text projection. The text itself deliberately
5
+ * stays in the canonical Session item; this row only fences retries. */
6
+ export function claimSessionTimelineProjection(input) {
7
+ const connection = db();
8
+ const requestHash = createHash("sha256")
9
+ .update(JSON.stringify({ responseId: input.responseId ?? null, text: input.text }), "utf8")
10
+ .digest("hex");
11
+ return transaction(connection, () => {
12
+ const existing = connection.prepare(`SELECT plugin_id, session_id, projection_id, request_hash, response_id, item_id, state
13
+ FROM session_timeline_projections
14
+ WHERE plugin_id = ? AND session_id = ? AND projection_id = ?`).get(input.pluginId, input.sessionId, input.projectionId);
15
+ if (existing) {
16
+ if (existing.request_hash !== requestHash) {
17
+ throw new Error("projection id was reused with different text");
18
+ }
19
+ return present(existing, input.text, "replayed");
20
+ }
21
+ const responseId = input.responseId?.trim() || `projection_response_${input.projectionId}`;
22
+ const itemId = `msg_projection_${randomUUID()}`;
23
+ connection.prepare(`INSERT INTO session_timeline_projections
24
+ (plugin_id, session_id, projection_id, request_hash, response_id, item_id, state, created_at)
25
+ VALUES (?, ?, ?, ?, ?, ?, 'pending', ?)`).run(input.pluginId, input.sessionId, input.projectionId, requestHash, responseId, itemId, new Date().toISOString());
26
+ return {
27
+ pluginId: input.pluginId,
28
+ sessionId: input.sessionId,
29
+ projectionId: input.projectionId,
30
+ responseId,
31
+ itemId,
32
+ text: input.text,
33
+ state: "pending",
34
+ disposition: "created",
35
+ };
36
+ })();
37
+ }
38
+ export function completeSessionTimelineProjection(input) {
39
+ db().prepare(`UPDATE session_timeline_projections SET state = 'completed'
40
+ WHERE plugin_id = ? AND session_id = ? AND projection_id = ?`).run(input.pluginId, input.sessionId, input.projectionId);
41
+ }
42
+ function present(row, text, disposition) {
43
+ return {
44
+ pluginId: row.plugin_id,
45
+ sessionId: row.session_id,
46
+ projectionId: row.projection_id,
47
+ responseId: row.response_id,
48
+ itemId: row.item_id,
49
+ text,
50
+ state: row.state,
51
+ disposition,
52
+ };
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rynx-ai/daemon",
3
- "version": "0.1.11-beta.30",
3
+ "version": "0.1.11-beta.32",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/rynx-ai/rynx.git",
@@ -49,18 +49,19 @@
49
49
  "@electron-internal/extract-zip": "1.0.5",
50
50
  "pm2": "^6.0.0",
51
51
  "tar": "^7.5.19",
52
+ "undici": "^7.28.0",
52
53
  "ws": "^8.21.0",
53
- "@rynx-ai/core": "0.1.11-beta.30",
54
- "@rynx-ai/plugin-sdk": "0.1.11-beta.30",
55
- "@rynx-ai/emulator": "0.1.11-beta.30",
56
- "@rynx-ai/protocol": "0.1.11-beta.30",
57
- "@rynx-ai/remote-runtime-client": "0.1.11-beta.30",
58
- "@rynx-ai/plugin-runner": "0.1.11-beta.30",
59
- "@rynx-ai/server": "0.1.11-beta.30"
54
+ "@rynx-ai/core": "0.1.11-beta.32",
55
+ "@rynx-ai/emulator": "0.1.11-beta.32",
56
+ "@rynx-ai/plugin-runner": "0.1.11-beta.32",
57
+ "@rynx-ai/plugin-sdk": "0.1.11-beta.32",
58
+ "@rynx-ai/protocol": "0.1.11-beta.32",
59
+ "@rynx-ai/remote-runtime-client": "0.1.11-beta.32",
60
+ "@rynx-ai/server": "0.1.11-beta.32"
60
61
  },
61
62
  "devDependencies": {
62
63
  "@types/ws": "^8.18.1",
63
- "@rynx-ai/plugin-channel-lark": "0.1.11-beta.30"
64
+ "@rynx-ai/plugin-channel-lark": "0.1.11-beta.32"
64
65
  },
65
66
  "scripts": {
66
67
  "build": "rm -rf dist bundled-plugins && tsc -p tsconfig.json && chmod +x dist/index-daemon.js && node ../../scripts/stage-bundled-plugins.mjs",