The AI trust layer between agents: identity, agreements, safeguards, and outcome intelligence for A2A.

Viewing files at commit 479efea741d5Commit detailsView latest

packages/persistence/src/hosted.ts 2763 lines · 107.1 KB · typescript
1
import { neon, type NeonQueryFunction } from '@neondatabase/serverless';
2
import { createHash, randomBytes } from 'node:crypto';
3
import { buildCounterpartyBrief, buildInteractionConclusion } from '../../core/src/index.js';
4
import {
5
  AgentIdentitySchema,
6
  DEFAULT_EXTENSION_URI,
7
  CounterpartyBriefSchema,
8
  FederatedInteractionSchema,
9
  FeedbackDimensionSchema,
10
  FeedbackRequestSchema,
11
  LiveSessionAcceptanceSchema,
12
  LiveSessionActivationSchema,
13
  LiveSessionEventSchema,
14
  LiveSessionOfferSchema,
15
  HostedMessageSchema,
16
  HostedThreadSchema,
17
  InteractionCompletionReportSchema,
18
  InteractionConclusionSchema,
19
  InteractionFeedbackSchema,
20
  PublicAgentCardSchema,
21
  ReceiptSchema,
22
  canonicalHash,
23
  verifyObject,
24
  type FederatedInteraction,
25
  type AgentPresence,
26
  type CounterpartyBrief,
27
  type LiveSessionActivation,
28
  type LiveSessionEvent,
29
  type LiveSessionInsight,
30
  type HostedMessage,
31
  type HostedThread,
32
  type InteractionCompletionReport,
33
  type InteractionFeedback,
34
  type PublicAgentCard,
35
} from '../../protocol/src/index.js';
36
import type { AgentProfile } from './onboarding.js';
37
import type { AgentInstallation } from './onboarding.js';
38
import {
39
  agentAccessTokenClientId,
40
  agentAccessTokenId,
41
  createAgentAccessToken,
42
  matchesAgentAccessToken,
43
  type AgentAccessTokenMetadata,
44
} from './access-token.js';
45
import {
46
  decryptGatewayPayload,
47
  encryptGatewayPayload,
48
  attestSessionRecord,
49
  getSessionKeyId,
50
  getSessionVerificationKey,
51
  issueSessionGrant,
52
  signSessionControl,
53
  verifySessionGrant,
54
} from './relay.js';
55
import { postRuntimeJson, resolvePublicRuntimeEndpoint } from './runtime.js';
56
57
export type HostedRecordKind =
58
  | 'agent'
59
  | 'delegation'
60
  | 'contract'
61
  | 'interaction'
62
  | 'event'
63
  | 'receipt'
64
  | 'feedback'
65
  | 'counterparty_brief'
66
  | 'completion_report'
67
  | 'feedback_request'
68
  | 'interaction_feedback'
69
  | 'interaction_conclusion'
70
  | 'learning_eligibility'
71
  | 'profile_delta'
72
  | 'conflict'
73
  | 'profile'
74
  | 'consent'
75
  | 'revocation'
76
  | 'project'
77
  | 'agent_profile'
78
  | 'installation'
79
  | 'setup_request'
80
  | 'publication'
81
  | 'presence';
82
83
export type { PublicAgentCard, FederatedInteraction } from '../../protocol/src/index.js';
84
85
type ContextualProfile = {
86
  recordId: string;
87
  agentVersion: string;
88
  sampleSize: number;
89
  updatedAt: string;
90
  completion: number;
91
  acceptance: number;
92
  specification: number;
93
  deadline: number;
94
  communication: number;
95
  evidence: number;
96
  scope: number;
97
  disputes: number;
98
};
99
100
const FEEDBACK_DIMENSIONS = FeedbackDimensionSchema.options;
101
102
function deterministicUuid(value: string): string {
103
  const bytes = createHash('sha256').update(value).digest().subarray(0, 16);
104
  bytes[6] = (bytes[6]! & 0x0f) | 0x50;
105
  bytes[8] = (bytes[8]! & 0x3f) | 0x80;
106
  const hex = bytes.toString('hex');
107
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
108
}
109
110
function normalizePublicAgentCard(value: unknown): PublicAgentCard {
111
  const current = PublicAgentCardSchema.safeParse(value);
112
  if (current.success) return current.data;
113
  const legacy = value as Record<string, unknown>;
114
  const agentId = String(legacy.agentId ?? '');
115
  const baseUrl = (process.env.OPENCLASP_PUBLIC_URL ?? 'https://openclasp.vercel.app').replace(
116
    /\/$/,
117
    '',
118
  );
119
  return PublicAgentCardSchema.parse({
120
    protocolVersion: '0.1',
121
    agentId,
122
    name: legacy.name,
123
    description: '',
124
    framework: legacy.framework,
125
    agentVersion: '1.0.0',
126
    capabilities: legacy.capabilities,
127
    limitations: legacy.limitations,
128
    assurance: legacy.assurance,
129
    transports:
130
      typeof legacy.a2aEndpoint === 'string'
131
        ? [
132
            {
133
              protocol: 'A2A/1.0',
134
              protocolBinding: 'JSONRPC',
135
              endpoint: legacy.a2aEndpoint,
136
              managedBy: 'agent',
137
            },
138
          ]
139
        : [],
140
    cardUrl: `${baseUrl}/agents/${encodeURIComponent(agentId)}/card.json`,
141
    a2aAgentCardUrl: `${baseUrl}/agents/${encodeURIComponent(agentId)}/a2a-agent-card.json`,
142
    extensionUri: DEFAULT_EXTENSION_URI,
143
    publishedAt: legacy.publishedAt,
144
    updatedAt: legacy.updatedAt,
145
  });
146
}
147
148
export function buildPublicAgentCard(
149
  agent: AgentProfile,
150
  baseUrl: string,
151
  previous?: PublicAgentCard,
152
): PublicAgentCard {
153
  const now = new Date().toISOString();
154
  const root = baseUrl.replace(/\/$/, '');
155
  return PublicAgentCardSchema.parse({
156
    protocolVersion: '0.1',
157
    agentId: agent.agentId,
158
    name: agent.name,
159
    description: agent.description ?? '',
160
    framework: agent.framework,
161
    agentVersion: agent.agentVersion ?? '1.0.0',
162
    capabilities: agent.capabilities,
163
    limitations: agent.limitations,
164
    assurance: 'oauth_authenticated',
165
    agentMode: agent.agentMode ?? (agent.a2aEndpoint ? 'persistent_runtime' : 'temporary_chat'),
166
    transports:
167
      (agent.agentMode ?? (agent.a2aEndpoint ? 'persistent_runtime' : 'temporary_chat')) ===
168
      'temporary_chat'
169
        ? [
170
            {
171
              protocol: 'A2A/1.0',
172
              protocolBinding: 'JSONRPC',
173
              endpoint: `${root}/a2a/temporary/${encodeURIComponent(agent.agentId)}`,
174
              managedBy: 'openclasp',
175
            },
176
          ]
177
        : agent.a2aEndpoint
178
          ? [
179
              {
180
                protocol: 'A2A/1.0',
181
                protocolBinding: 'JSONRPC',
182
                endpoint: agent.a2aEndpoint,
183
                managedBy: 'agent',
184
              },
185
            ]
186
          : [],
187
    cardUrl: `${root}/agents/${encodeURIComponent(agent.agentId)}/card.json`,
188
    a2aAgentCardUrl: `${root}/agents/${encodeURIComponent(agent.agentId)}/a2a-agent-card.json`,
189
    extensionUri: DEFAULT_EXTENSION_URI,
190
    publishedAt: previous?.publishedAt ?? now,
191
    updatedAt: now,
192
  });
193
}
194
195
const normalized = (value: string) =>
196
  value
197
    .trim()
198
    .toLowerCase()
199
    .replace(/[^a-z0-9]+/g, '_');
200
201
export const AGENT_ONLINE_WINDOW_MS = 2 * 60_000;
202
203
export function resolveAgentPresence(lastSeenAt?: string, now = new Date()): AgentPresence {
204
  const online = Boolean(
205
    lastSeenAt && now.getTime() - Date.parse(lastSeenAt) <= AGENT_ONLINE_WINDOW_MS,
206
  );
207
  return {
208
    status: online ? 'online' : 'offline',
209
    ...(lastSeenAt ? { lastSeenAt } : {}),
210
    checkedAt: now.toISOString(),
211
  };
212
}
213
214
export function canAutoAcceptInteraction(
215
  agent: AgentProfile,
216
  interaction: FederatedInteraction,
217
): boolean {
218
  if (agent.autoAcceptPolicy !== 'safe_matching' || agent.status !== 'active') return false;
219
  const categories = new Set(
220
    [...(agent.autoAcceptTaskCategories ?? []), ...(agent.capabilities ?? [])].map(normalized),
221
  );
222
  const taskCategory = normalized(interaction.contract.taskCategory);
223
  if (!taskCategory || !categories.has(taskCategory)) return false;
224
  if (interaction.contract.allowedData.length > 0) return false;
225
  if (
226
    /\b(password|secret|api[_ -]?key|access[_ -]?token|credential|bank|payment|medical|health|ssn|personal[_ -]?data|customer[_ -]?record)\b/i.test(
227
      `${interaction.contract.purpose} ${interaction.contract.requestedOutcome}`,
228
    )
229
  )
230
    return false;
231
  if (interaction.contract.humanApprovalRequirements.length > 0) return false;
232
  if (interaction.contract.retentionDays > 30) return false;
233
  if (interaction.contract.allowedActions.length > 0) {
234
    const capabilities = new Set((agent.capabilities ?? []).map(normalized));
235
    if (
236
      !interaction.contract.allowedActions.every((action) => capabilities.has(normalized(action)))
237
    )
238
      return false;
239
  }
240
  return true;
241
}
242
243
export type AccountSettings = {
244
  displayName: string;
245
  contributionEnabled: boolean;
246
  retentionDays: number;
247
  evidenceSharing: 'never' | 'ask' | 'contract_only';
248
  rawConversationsStored: false;
249
};
250
251
const defaults: AccountSettings = {
252
  displayName: '',
253
  contributionEnabled: false,
254
  retentionDays: 30,
255
  evidenceSharing: 'ask',
256
  rawConversationsStored: false,
257
};
258
259
export class HostedRepository {
260
  private readonly sql: NeonQueryFunction<false, false>;
261
  private initialized?: Promise<void>;
262
263
  constructor(databaseUrl: string) {
264
    this.sql = neon(databaseUrl);
265
  }
266
267
  private gatewaySecret() {
268
    const value = process.env.OPENCLASP_RELAY_ENCRYPTION_KEY;
269
    if (!value || value.length < 32) throw new Error('Gateway encryption is not configured');
270
    return value;
271
  }
272
273
  ensureSchema(): Promise<void> {
274
    return (this.initialized ??= (async () => {
275
      await this.sql`
276
        CREATE TABLE IF NOT EXISTS openclasp_records (
277
          operator_id TEXT NOT NULL,
278
          kind TEXT NOT NULL,
279
          record_id TEXT NOT NULL,
280
          payload JSONB NOT NULL,
281
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
282
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
283
          PRIMARY KEY (operator_id, kind, record_id)
284
        )
285
      `;
286
      await this.sql`
287
        CREATE INDEX IF NOT EXISTS openclasp_records_operator_created
288
        ON openclasp_records(operator_id, created_at DESC)
289
      `;
290
      await this.sql`
291
        CREATE TABLE IF NOT EXISTS openclasp_account_settings (
292
          operator_id TEXT PRIMARY KEY,
293
          display_name TEXT NOT NULL DEFAULT '',
294
          contribution_enabled BOOLEAN NOT NULL DEFAULT FALSE,
295
          retention_days INTEGER NOT NULL DEFAULT 30 CHECK (retention_days BETWEEN 0 AND 3650),
296
          evidence_sharing TEXT NOT NULL DEFAULT 'ask'
297
            CHECK (evidence_sharing IN ('never', 'ask', 'contract_only')),
298
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
299
        )
300
      `;
301
      await this.sql`
302
        CREATE TABLE IF NOT EXISTS openclasp_public_agents (
303
          agent_id TEXT PRIMARY KEY,
304
          operator_id TEXT NOT NULL,
305
          card JSONB NOT NULL,
306
          published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
307
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
308
        )
309
      `;
310
      await this.sql`
311
        CREATE INDEX IF NOT EXISTS openclasp_public_agents_updated
312
        ON openclasp_public_agents(updated_at DESC)
313
      `;
314
      await this.sql`
315
        CREATE TABLE IF NOT EXISTS openclasp_federated_interactions (
316
          interaction_id TEXT PRIMARY KEY,
317
          initiator_operator_id TEXT NOT NULL,
318
          responder_operator_id TEXT NOT NULL,
319
          initiator_agent_id TEXT NOT NULL,
320
          responder_agent_id TEXT NOT NULL,
321
          status TEXT NOT NULL,
322
          payload JSONB NOT NULL,
323
          expires_at TIMESTAMPTZ NOT NULL,
324
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
325
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
326
        )
327
      `;
328
      await this.sql`
329
        CREATE INDEX IF NOT EXISTS openclasp_federated_interactions_participants
330
        ON openclasp_federated_interactions(initiator_operator_id, responder_operator_id, updated_at DESC)
331
      `;
332
      await this.sql`
333
        CREATE TABLE IF NOT EXISTS openclasp_agent_runtimes (
334
          agent_id TEXT PRIMARY KEY,
335
          operator_id TEXT NOT NULL,
336
          endpoint TEXT NOT NULL,
337
          secret_ciphertext TEXT NOT NULL,
338
          secret_iv TEXT NOT NULL,
339
          secret_auth_tag TEXT NOT NULL,
340
          status TEXT NOT NULL CHECK (status IN ('verified', 'disabled')),
341
          verified_at TIMESTAMPTZ NOT NULL,
342
          last_delivery_at TIMESTAMPTZ,
343
          last_error TEXT,
344
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
345
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
346
        )
347
      `;
348
      await this.sql`
349
        CREATE TABLE IF NOT EXISTS openclasp_agent_access_tokens (
350
          token_id TEXT PRIMARY KEY,
351
          operator_id TEXT NOT NULL,
352
          agent_id TEXT NOT NULL,
353
          name TEXT NOT NULL,
354
          token_hash TEXT NOT NULL,
355
          scopes JSONB NOT NULL,
356
          expires_at TIMESTAMPTZ NOT NULL,
357
          last_used_at TIMESTAMPTZ,
358
          revoked_at TIMESTAMPTZ,
359
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
360
        )
361
      `;
362
      await this.sql`
363
        CREATE INDEX IF NOT EXISTS openclasp_agent_access_tokens_owner_agent
364
        ON openclasp_agent_access_tokens(operator_id, agent_id, created_at DESC)
365
      `;
366
      await this.sql`
367
        ALTER TABLE openclasp_agent_runtimes
368
        ADD COLUMN IF NOT EXISTS a2a_endpoint TEXT,
369
        ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ
370
      `;
371
      await this.sql`
372
        CREATE TABLE IF NOT EXISTS openclasp_live_sessions (
373
          interaction_id TEXT PRIMARY KEY,
374
          initiator_agent_id TEXT NOT NULL,
375
          responder_agent_id TEXT NOT NULL,
376
          initiator_session_id TEXT NOT NULL,
377
          responder_session_id TEXT NOT NULL,
378
          initiator_endpoint TEXT NOT NULL,
379
          responder_endpoint TEXT NOT NULL,
380
          status TEXT NOT NULL CHECK (status IN ('preparing', 'active', 'completed', 'failed')),
381
          expires_at TIMESTAMPTZ NOT NULL,
382
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
383
          activated_at TIMESTAMPTZ,
384
          completed_at TIMESTAMPTZ,
385
          last_error TEXT
386
        )
387
      `;
388
      await this.sql`
389
        CREATE TABLE IF NOT EXISTS openclasp_live_session_events (
390
          interaction_id TEXT NOT NULL,
391
          event_id TEXT NOT NULL,
392
          agent_id TEXT NOT NULL,
393
          sequence INTEGER NOT NULL,
394
          event JSONB NOT NULL,
395
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
396
          PRIMARY KEY (interaction_id, event_id),
397
          UNIQUE (interaction_id, agent_id, sequence)
398
        )
399
      `;
400
      await this.sql`
401
        CREATE TABLE IF NOT EXISTS openclasp_hosted_threads (
402
          thread_id TEXT PRIMARY KEY,
403
          interaction_id TEXT NOT NULL UNIQUE,
404
          participant_a_agent_id TEXT NOT NULL,
405
          participant_b_agent_id TEXT NOT NULL,
406
          status TEXT NOT NULL CHECK (status IN ('open', 'closed')) DEFAULT 'open',
407
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
408
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
409
          expires_at TIMESTAMPTZ NOT NULL
410
        )
411
      `;
412
      await this.sql`
413
        CREATE INDEX IF NOT EXISTS openclasp_hosted_threads_participants
414
        ON openclasp_hosted_threads(participant_a_agent_id, participant_b_agent_id, updated_at DESC)
415
      `;
416
      await this.sql`
417
        CREATE TABLE IF NOT EXISTS openclasp_hosted_messages (
418
          message_id TEXT PRIMARY KEY,
419
          thread_id TEXT NOT NULL REFERENCES openclasp_hosted_threads(thread_id) ON DELETE CASCADE,
420
          interaction_id TEXT NOT NULL,
421
          sender_agent_id TEXT NOT NULL,
422
          recipient_agent_id TEXT NOT NULL,
423
          request_key TEXT NOT NULL,
424
          content_ciphertext TEXT NOT NULL,
425
          content_iv TEXT NOT NULL,
426
          content_auth_tag TEXT NOT NULL,
427
          content_hash TEXT NOT NULL,
428
          delivery TEXT NOT NULL CHECK (delivery IN ('accepted', 'delivered', 'read')),
429
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
430
          read_at TIMESTAMPTZ
431
        )
432
      `;
433
      await this.sql`
434
        CREATE UNIQUE INDEX IF NOT EXISTS openclasp_hosted_messages_dedup
435
        ON openclasp_hosted_messages(thread_id, sender_agent_id, request_key)
436
      `;
437
      await this.sql`
438
        CREATE INDEX IF NOT EXISTS openclasp_hosted_messages_thread_created
439
        ON openclasp_hosted_messages(thread_id, created_at ASC)
440
      `;
441
    })());
442
  }
443
444
  getRuntimeVerificationKey() {
445
    return {
446
      algorithm: 'Ed25519' as const,
447
      keyId: getSessionKeyId(this.gatewaySecret()),
448
      publicKey: getSessionVerificationKey(this.gatewaySecret()),
449
    };
450
  }
451
452
  async upsert(
453
    operatorId: string,
454
    kind: HostedRecordKind,
455
    recordId: string,
456
    payload: unknown,
457
  ): Promise<void> {
458
    await this.ensureSchema();
459
    const encoded = JSON.stringify(payload);
460
    await this.sql`
461
      INSERT INTO openclasp_records(operator_id, kind, record_id, payload)
462
      VALUES (${operatorId}, ${kind}, ${recordId}, ${encoded}::jsonb)
463
      ON CONFLICT (operator_id, kind, record_id)
464
      DO UPDATE SET payload = EXCLUDED.payload, updated_at = NOW()
465
    `;
466
  }
467
468
  async list(
469
    operatorId: string,
470
  ): Promise<{ kind: HostedRecordKind; recordId: string; payload: any }[]> {
471
    await this.ensureSchema();
472
    const rows = await this.sql`
473
      SELECT kind, record_id, payload
474
      FROM openclasp_records
475
      WHERE operator_id = ${operatorId}
476
      ORDER BY created_at ASC
477
    `;
478
    return rows.map((row) => ({
479
      kind: row.kind as HostedRecordKind,
480
      recordId: String(row.record_id),
481
      payload: row.payload,
482
    }));
483
  }
484
485
  async dashboard(operatorId: string) {
486
    const rows = await this.list(operatorId);
487
    const [federatedInteractions, runtimes, accessTokens, liveSessionRows, liveEventRows] =
488
      await Promise.all([
489
        this.listFederatedInteractions(operatorId),
490
        this.listAgentRuntimes(operatorId),
491
        this.listAgentAccessTokens(operatorId),
492
        this.sql`
493
        SELECT session.interaction_id, session.initiator_agent_id, session.responder_agent_id,
494
          session.status, session.created_at, session.activated_at, session.completed_at,
495
          session.expires_at, session.last_error
496
        FROM openclasp_live_sessions session
497
        INNER JOIN openclasp_federated_interactions interaction
498
          ON interaction.interaction_id = session.interaction_id
499
        WHERE interaction.initiator_operator_id = ${operatorId}
500
           OR interaction.responder_operator_id = ${operatorId}
501
        ORDER BY session.created_at DESC
502
      `,
503
        this.sql`
504
        SELECT events.event
505
        FROM openclasp_live_session_events events
506
        INNER JOIN openclasp_federated_interactions interaction
507
          ON interaction.interaction_id = events.interaction_id
508
        WHERE interaction.initiator_operator_id = ${operatorId}
509
           OR interaction.responder_operator_id = ${operatorId}
510
        ORDER BY events.created_at ASC
511
      `,
512
      ]);
513
    const ofKind = (kind: HostedRecordKind) =>
514
      rows.filter((row) => row.kind === kind).map((row) => row.payload);
515
    const agentProfiles = ofKind('agent_profile') as AgentProfile[];
516
    const hostedThreads = (
517
      await Promise.all(
518
        agentProfiles
519
          .filter(
520
            (agent) =>
521
              (agent.agentMode ?? (agent.a2aEndpoint ? 'persistent_runtime' : 'temporary_chat')) ===
522
              'temporary_chat',
523
          )
524
          .map((agent) => this.listHostedThreads(operatorId, agent.agentId)),
525
      )
526
    ).flat();
527
    const presence = new Map(
528
      rows
529
        .filter((row) => row.kind === 'presence')
530
        .map((row) => [row.recordId, String(row.payload.lastSeenAt)]),
531
    );
532
    return {
533
      agents: [...ofKind('agent_profile'), ...ofKind('agent')].map((agent) => ({
534
        ...agent,
535
        presence: resolveAgentPresence(presence.get(String(agent.agentId))),
536
      })),
537
      projects: ofKind('project'),
538
      installations: ofKind('installation'),
539
      setupRequests: ofKind('setup_request'),
540
      publications: ofKind('publication'),
541
      interactions: ofKind('interaction'),
542
      events: [...ofKind('event'), ...liveEventRows.map((row) => row.event)],
543
      conflicts: ofKind('conflict'),
544
      receipts: ofKind('receipt'),
545
      profiles: ofKind('profile'),
546
      counterpartyBriefs: ofKind('counterparty_brief'),
547
      completionReports: ofKind('completion_report'),
548
      feedbackRequests: ofKind('feedback_request'),
549
      interactionFeedback: ofKind('interaction_feedback'),
550
      interactionConclusions: ofKind('interaction_conclusion'),
551
      learningEligibility: ofKind('learning_eligibility'),
552
      profileDeltas: ofKind('profile_delta'),
553
      federatedInteractions,
554
      liveSessions: liveSessionRows.map((row) => ({
555
        interactionId: String(row.interaction_id),
556
        initiatorAgentId: String(row.initiator_agent_id),
557
        responderAgentId: String(row.responder_agent_id),
558
        status: String(row.status),
559
        createdAt: new Date(String(row.created_at)).toISOString(),
560
        ...(row.activated_at
561
          ? { activatedAt: new Date(String(row.activated_at)).toISOString() }
562
          : {}),
563
        ...(row.completed_at
564
          ? { completedAt: new Date(String(row.completed_at)).toISOString() }
565
          : {}),
566
        expiresAt: new Date(String(row.expires_at)).toISOString(),
567
        ...(row.last_error ? { lastError: String(row.last_error) } : {}),
568
      })),
569
      hostedThreads,
570
      runtimes,
571
      accessTokens,
572
    };
573
  }
574
575
  async issueAgentAccessToken(
576
    operatorId: string,
577
    agentId: string,
578
    input: { name: string; expiresInDays: number },
579
  ): Promise<AgentAccessTokenMetadata & { token: string }> {
580
    await this.ensureSchema();
581
    const agents = await this.sql`
582
      SELECT payload FROM openclasp_records
583
      WHERE operator_id = ${operatorId} AND kind = 'agent_profile' AND record_id = ${agentId}
584
      LIMIT 1
585
    `;
586
    const agent = agents[0]?.payload as AgentProfile | undefined;
587
    if (!agent) throw new Error('Owned agent not found');
588
    if (agent.status !== 'active') throw new Error('Revoked agents cannot receive access tokens');
589
    const name = input.name.trim();
590
    if (!name) throw new Error('Token name is required');
591
    const { tokenId, token, tokenHash } = createAgentAccessToken();
592
    const createdAt = new Date();
593
    const expiresAt = new Date(createdAt.getTime() + input.expiresInDays * 86_400_000);
594
    // The credential is bound to one agent. It may use MCP outbound and let that
595
    // same agent register its own inbound runtime; it cannot manage other agents.
596
    const scopes = ['mcp:access', 'runtime:connect'];
597
    await this.sql`
598
      INSERT INTO openclasp_agent_access_tokens(
599
        token_id, operator_id, agent_id, name, token_hash, scopes, expires_at, created_at
600
      ) VALUES (
601
        ${tokenId}, ${operatorId}, ${agentId}, ${name}, ${tokenHash},
602
        ${JSON.stringify(scopes)}::jsonb, ${expiresAt.toISOString()}, ${createdAt.toISOString()}
603
      )
604
    `;
605
    const clientId = agentAccessTokenClientId(tokenId);
606
    await this.upsert(operatorId, 'installation', clientId, {
607
      installationId: `install_${tokenId}`,
608
      clientId,
609
      agentId,
610
      projectId: agent.projectId,
611
      connectedAt: createdAt.toISOString(),
612
      updatedAt: createdAt.toISOString(),
613
    } satisfies AgentInstallation);
614
    return {
615
      tokenId,
616
      token,
617
      agentId,
618
      name,
619
      scopes,
620
      createdAt: createdAt.toISOString(),
621
      expiresAt: expiresAt.toISOString(),
622
    };
623
  }
624
625
  async listAgentAccessTokens(
626
    operatorId: string,
627
    agentId?: string,
628
  ): Promise<AgentAccessTokenMetadata[]> {
629
    await this.ensureSchema();
630
    const rows = agentId
631
      ? await this.sql`
632
          SELECT token_id, agent_id, name, scopes, expires_at, last_used_at, revoked_at, created_at
633
          FROM openclasp_agent_access_tokens
634
          WHERE operator_id = ${operatorId} AND agent_id = ${agentId}
635
          ORDER BY created_at DESC
636
        `
637
      : await this.sql`
638
          SELECT token_id, agent_id, name, scopes, expires_at, last_used_at, revoked_at, created_at
639
          FROM openclasp_agent_access_tokens
640
          WHERE operator_id = ${operatorId}
641
          ORDER BY created_at DESC
642
        `;
643
    return rows.map((row) => ({
644
      tokenId: String(row.token_id),
645
      agentId: String(row.agent_id),
646
      name: String(row.name),
647
      scopes: Array.isArray(row.scopes)
648
        ? row.scopes.filter((scope): scope is string => typeof scope === 'string')
649
        : [],
650
      createdAt: new Date(String(row.created_at)).toISOString(),
651
      expiresAt: new Date(String(row.expires_at)).toISOString(),
652
      ...(row.last_used_at ? { lastUsedAt: new Date(String(row.last_used_at)).toISOString() } : {}),
653
      ...(row.revoked_at ? { revokedAt: new Date(String(row.revoked_at)).toISOString() } : {}),
654
    }));
655
  }
656
657
  async revokeAgentAccessToken(operatorId: string, agentId: string, tokenId: string) {
658
    await this.ensureSchema();
659
    const rows = await this.sql`
660
      UPDATE openclasp_agent_access_tokens
661
      SET revoked_at = COALESCE(revoked_at, NOW())
662
      WHERE token_id = ${tokenId} AND operator_id = ${operatorId} AND agent_id = ${agentId}
663
      RETURNING revoked_at
664
    `;
665
    if (!rows.length) throw new Error('Agent access token not found');
666
    const clientId = agentAccessTokenClientId(tokenId);
667
    await this.sql`
668
      DELETE FROM openclasp_records
669
      WHERE operator_id = ${operatorId} AND kind = 'installation' AND record_id = ${clientId}
670
    `;
671
    return {
672
      tokenId,
673
      agentId,
674
      revokedAt: new Date(String(rows[0]?.revoked_at)).toISOString(),
675
    };
676
  }
677
678
  async verifyAgentAccessToken(token: string) {
679
    await this.ensureSchema();
680
    const tokenId = agentAccessTokenId(token);
681
    if (!tokenId) throw new Error('Invalid agent access token');
682
    const rows = await this.sql`
683
      SELECT tokens.operator_id, tokens.agent_id, tokens.token_hash, tokens.scopes,
684
        tokens.expires_at, profile.payload AS profile
685
      FROM openclasp_agent_access_tokens tokens
686
      INNER JOIN openclasp_records profile
687
        ON profile.operator_id = tokens.operator_id
688
        AND profile.kind = 'agent_profile'
689
        AND profile.record_id = tokens.agent_id
690
      WHERE tokens.token_id = ${tokenId} AND tokens.revoked_at IS NULL
691
      LIMIT 1
692
    `;
693
    const row = rows[0];
694
    if (!row || !matchesAgentAccessToken(token, String(row.token_hash)))
695
      throw new Error('Invalid agent access token');
696
    if (Date.parse(String(row.expires_at)) <= Date.now())
697
      throw new Error('Agent access token has expired');
698
    const profile = row.profile as Partial<AgentProfile>;
699
    if (profile.status !== 'active') throw new Error('Agent access token is disabled');
700
    await this.sql`
701
      UPDATE openclasp_agent_access_tokens
702
      SET last_used_at = NOW()
703
      WHERE token_id = ${tokenId}
704
        AND (last_used_at IS NULL OR last_used_at < NOW() - INTERVAL '5 minutes')
705
    `;
706
    return {
707
      tokenId,
708
      operatorId: String(row.operator_id),
709
      agentId: String(row.agent_id),
710
      clientId: agentAccessTokenClientId(tokenId),
711
      scopes: Array.isArray(row.scopes)
712
        ? row.scopes.filter((scope): scope is string => typeof scope === 'string')
713
        : [],
714
      expiresAt: new Date(String(row.expires_at)).toISOString(),
715
    };
716
  }
717
718
  async publishAgent(operatorId: string, card: PublicAgentCard): Promise<PublicAgentCard> {
719
    await this.ensureSchema();
720
    card = PublicAgentCardSchema.parse(card);
721
    const runtimes = await this.sql`
722
      SELECT a2a_endpoint, endpoint FROM openclasp_agent_runtimes
723
      WHERE operator_id = ${operatorId} AND agent_id = ${card.agentId} AND status = 'verified'
724
    `;
725
    if (runtimes[0]) {
726
      card = PublicAgentCardSchema.parse({
727
        ...card,
728
        agentMode: 'persistent_runtime',
729
        transports: [
730
          {
731
            protocol: 'A2A/1.0',
732
            protocolBinding: 'JSONRPC',
733
            endpoint: String(runtimes[0].a2a_endpoint ?? runtimes[0].endpoint),
734
            managedBy: 'agent',
735
          },
736
        ],
737
      });
738
    }
739
    const encoded = JSON.stringify(card);
740
    const rows = await this.sql`
741
      INSERT INTO openclasp_public_agents(agent_id, operator_id, card, published_at, updated_at)
742
      VALUES (${card.agentId}, ${operatorId}, ${encoded}::jsonb, NOW(), NOW())
743
      ON CONFLICT (agent_id) DO UPDATE SET
744
        card = EXCLUDED.card,
745
        updated_at = NOW()
746
      WHERE openclasp_public_agents.operator_id = ${operatorId}
747
      RETURNING card
748
    `;
749
    const published = rows[0]?.card as PublicAgentCard | undefined;
750
    if (!published) throw new Error('Agent ID is already owned by another operator');
751
    return published;
752
  }
753
754
  async unpublishAgent(operatorId: string, agentId: string): Promise<boolean> {
755
    await this.ensureSchema();
756
    const rows = await this.sql`
757
      DELETE FROM openclasp_public_agents
758
      WHERE agent_id = ${agentId} AND operator_id = ${operatorId}
759
      RETURNING agent_id
760
    `;
761
    return rows.length > 0;
762
  }
763
764
  async getPublishedAgent(agentId: string): Promise<PublicAgentCard | undefined> {
765
    await this.ensureSchema();
766
    const rows = await this.sql`
767
      SELECT agents.card, runtime.a2a_endpoint, runtime.endpoint, profile.payload AS profile
768
      FROM openclasp_public_agents agents
769
      LEFT JOIN openclasp_agent_runtimes runtime
770
        ON runtime.agent_id = agents.agent_id AND runtime.status = 'verified'
771
      LEFT JOIN openclasp_records profile
772
        ON profile.operator_id = agents.operator_id AND profile.kind = 'agent_profile'
773
        AND profile.record_id = agents.agent_id
774
      WHERE agents.agent_id = ${agentId}
775
    `;
776
    if (!rows[0]?.card) return undefined;
777
    let card = normalizePublicAgentCard(rows[0].card);
778
    const profile = rows[0].profile as Partial<AgentProfile> | undefined;
779
    const mode =
780
      profile?.agentMode ??
781
      (rows[0].a2a_endpoint || rows[0].endpoint ? 'persistent_runtime' : 'temporary_chat');
782
    if (mode === 'temporary_chat') {
783
      const root = (process.env.OPENCLASP_PUBLIC_URL ?? 'https://openclasp.vercel.app').replace(
784
        /\/$/,
785
        '',
786
      );
787
      card = PublicAgentCardSchema.parse({
788
        ...card,
789
        agentMode: 'temporary_chat',
790
        transports: [
791
          {
792
            protocol: 'A2A/1.0',
793
            protocolBinding: 'JSONRPC',
794
            endpoint: `${root}/a2a/temporary/${encodeURIComponent(agentId)}`,
795
            managedBy: 'openclasp',
796
          },
797
        ],
798
      });
799
    } else if (rows[0].a2a_endpoint || rows[0].endpoint)
800
      card = PublicAgentCardSchema.parse({
801
        ...card,
802
        agentMode: 'persistent_runtime',
803
        transports: [
804
          {
805
            protocol: 'A2A/1.0',
806
            protocolBinding: 'JSONRPC',
807
            endpoint: String(rows[0].a2a_endpoint ?? rows[0].endpoint),
808
            managedBy: 'agent',
809
          },
810
        ],
811
      });
812
    return { ...card, presence: await this.getAgentPresence(agentId) };
813
  }
814
815
  async searchPublishedAgents(input: {
816
    query?: string | undefined;
817
    capability?: string | undefined;
818
    limit?: number | undefined;
819
  }): Promise<PublicAgentCard[]> {
820
    await this.ensureSchema();
821
    const rows = await this.sql`
822
      SELECT agents.card, runtime.a2a_endpoint, runtime.endpoint, profile.payload AS profile
823
      FROM openclasp_public_agents agents
824
      LEFT JOIN openclasp_agent_runtimes runtime
825
        ON runtime.agent_id = agents.agent_id AND runtime.status = 'verified'
826
      LEFT JOIN openclasp_records profile
827
        ON profile.operator_id = agents.operator_id AND profile.kind = 'agent_profile'
828
        AND profile.record_id = agents.agent_id
829
      ORDER BY agents.updated_at DESC LIMIT 100
830
    `;
831
    const query = input.query?.trim().toLowerCase();
832
    const capability = input.capability?.trim().toLowerCase();
833
    const presenceRows = await this.sql`
834
      SELECT records.record_id, records.payload
835
      FROM openclasp_records records
836
      INNER JOIN openclasp_public_agents agents ON agents.agent_id = records.record_id
837
      WHERE records.kind = 'presence'
838
    `;
839
    const presence = new Map(
840
      presenceRows.map((row) => [String(row.record_id), String(row.payload.lastSeenAt)]),
841
    );
842
    return rows
843
      .map((row) => {
844
        let card = normalizePublicAgentCard(row.card);
845
        const profile = row.profile as Partial<AgentProfile> | undefined;
846
        const mode =
847
          profile?.agentMode ??
848
          (row.a2a_endpoint || row.endpoint ? 'persistent_runtime' : 'temporary_chat');
849
        if (mode === 'temporary_chat') {
850
          const root = (process.env.OPENCLASP_PUBLIC_URL ?? 'https://openclasp.vercel.app').replace(
851
            /\/$/,
852
            '',
853
          );
854
          card = PublicAgentCardSchema.parse({
855
            ...card,
856
            agentMode: 'temporary_chat',
857
            transports: [
858
              {
859
                protocol: 'A2A/1.0',
860
                protocolBinding: 'JSONRPC',
861
                endpoint: `${root}/a2a/temporary/${encodeURIComponent(card.agentId)}`,
862
                managedBy: 'openclasp',
863
              },
864
            ],
865
          });
866
        } else if (row.a2a_endpoint || row.endpoint)
867
          card = PublicAgentCardSchema.parse({
868
            ...card,
869
            agentMode: 'persistent_runtime',
870
            transports: [
871
              {
872
                protocol: 'A2A/1.0',
873
                protocolBinding: 'JSONRPC',
874
                endpoint: String(row.a2a_endpoint ?? row.endpoint),
875
                managedBy: 'agent',
876
              },
877
            ],
878
          });
879
        return { ...card, presence: resolveAgentPresence(presence.get(card.agentId)) };
880
      })
881
      .filter(
882
        (card) =>
883
          (!query ||
884
            card.name.toLowerCase().includes(query) ||
885
            card.framework.toLowerCase().includes(query)) &&
886
          (!capability ||
887
            card.capabilities.some((value) => value.toLowerCase().includes(capability))),
888
      )
889
      .slice(0, Math.min(Math.max(input.limit ?? 20, 1), 50));
890
  }
891
892
  async touchAgentPresence(operatorId: string, agentId: string): Promise<AgentPresence> {
893
    await this.ensureSchema();
894
    const owned = await this.sql`
895
      SELECT 1 FROM openclasp_records
896
      WHERE operator_id = ${operatorId} AND kind = 'agent_profile' AND record_id = ${agentId}
897
    `;
898
    if (!owned.length) throw new Error('Agent is not owned by this account');
899
    const lastSeenAt = new Date().toISOString();
900
    await this.upsert(operatorId, 'presence', agentId, { lastSeenAt });
901
    await this.sql`
902
      UPDATE openclasp_agent_runtimes SET last_seen_at = ${lastSeenAt}, updated_at = NOW()
903
      WHERE operator_id = ${operatorId} AND agent_id = ${agentId} AND status = 'verified'
904
    `;
905
    return resolveAgentPresence(lastSeenAt);
906
  }
907
908
  async getAgentPresence(agentId: string): Promise<AgentPresence> {
909
    await this.ensureSchema();
910
    const rows = await this.sql`
911
      SELECT records.payload
912
      FROM openclasp_public_agents agents
913
      LEFT JOIN openclasp_records records
914
        ON records.operator_id = agents.operator_id
915
        AND records.kind = 'presence'
916
        AND records.record_id = agents.agent_id
917
      WHERE agents.agent_id = ${agentId}
918
      LIMIT 1
919
    `;
920
    if (!rows.length) throw new Error('Published agent not found');
921
    const lastSeenAt = rows[0]?.payload?.lastSeenAt;
922
    return resolveAgentPresence(typeof lastSeenAt === 'string' ? lastSeenAt : undefined);
923
  }
924
925
  async listAgentRuntimes(operatorId: string) {
926
    await this.ensureSchema();
927
    const rows = await this.sql`
928
      SELECT agent_id, endpoint, a2a_endpoint, status, verified_at, last_seen_at, last_error, updated_at
929
      FROM openclasp_agent_runtimes
930
      WHERE operator_id = ${operatorId}
931
      ORDER BY updated_at DESC
932
    `;
933
    return rows.map((row) => ({
934
      agentId: String(row.agent_id),
935
      endpoint: String(row.endpoint),
936
      a2aEndpoint: String(row.a2a_endpoint ?? row.endpoint),
937
      status: row.status as 'verified' | 'disabled',
938
      verifiedAt: new Date(String(row.verified_at)).toISOString(),
939
      ...(row.last_seen_at ? { lastSeenAt: new Date(String(row.last_seen_at)).toISOString() } : {}),
940
      ...(row.last_error ? { lastError: String(row.last_error) } : {}),
941
      updatedAt: new Date(String(row.updated_at)).toISOString(),
942
    }));
943
  }
944
945
  async registerAgentRuntime(operatorId: string, agentId: string, endpoint: string) {
946
    await this.ensureSchema();
947
    const owned = await this.sql`
948
      SELECT 1 FROM openclasp_records
949
      WHERE operator_id = ${operatorId} AND kind = 'agent_profile' AND record_id = ${agentId}
950
    `;
951
    if (!owned.length) throw new Error('Agent is not owned by this account');
952
    const challenge = crypto.randomUUID();
953
    const verification = await postRuntimeJson(endpoint, {
954
      type: 'openclasp.runtime.verify',
955
      version: '1',
956
      agentId,
957
      challenge,
958
    });
959
    if (
960
      verification.status < 200 ||
961
      verification.status >= 300 ||
962
      !verification.body ||
963
      typeof verification.body !== 'object' ||
964
      (verification.body as { type?: unknown }).type !== 'openclasp.runtime.verified' ||
965
      (verification.body as { version?: unknown }).version !== '1' ||
966
      (verification.body as { agentId?: unknown }).agentId !== agentId ||
967
      (verification.body as { challenge?: unknown }).challenge !== challenge
968
    )
969
      throw new Error('Runtime endpoint did not return the verification challenge');
970
    const a2aEndpointValue = (verification.body as { a2aEndpoint?: unknown }).a2aEndpoint;
971
    const a2aEndpoint =
972
      typeof a2aEndpointValue === 'string' && a2aEndpointValue ? a2aEndpointValue : endpoint;
973
    await resolvePublicRuntimeEndpoint(a2aEndpoint);
974
    const runtimeSecret = randomBytes(32).toString('base64url');
975
    const encrypted = encryptGatewayPayload(this.gatewaySecret(), runtimeSecret);
976
    const verifiedAt = new Date().toISOString();
977
    const saved = await this.sql`
978
      INSERT INTO openclasp_agent_runtimes(
979
        agent_id, operator_id, endpoint, a2a_endpoint, secret_ciphertext, secret_iv, secret_auth_tag,
980
        status, verified_at, last_seen_at, updated_at
981
      ) VALUES (
982
        ${agentId}, ${operatorId}, ${endpoint}, ${a2aEndpoint}, ${encrypted.ciphertext},
983
        ${encrypted.iv}, ${encrypted.authTag}, 'verified', ${verifiedAt}, ${verifiedAt}, NOW()
984
      )
985
      ON CONFLICT (agent_id) DO UPDATE SET
986
        operator_id = EXCLUDED.operator_id,
987
        endpoint = EXCLUDED.endpoint,
988
        a2a_endpoint = EXCLUDED.a2a_endpoint,
989
        secret_ciphertext = EXCLUDED.secret_ciphertext,
990
        secret_iv = EXCLUDED.secret_iv,
991
        secret_auth_tag = EXCLUDED.secret_auth_tag,
992
        status = 'verified',
993
        verified_at = EXCLUDED.verified_at,
994
        last_seen_at = EXCLUDED.last_seen_at,
995
        last_error = NULL,
996
        updated_at = NOW()
997
      WHERE openclasp_agent_runtimes.operator_id = ${operatorId}
998
      RETURNING agent_id
999
    `;
1000
    if (!saved.length) throw new Error('Agent runtime is owned by another account');
1001
    await this.sql`
1002
      UPDATE openclasp_public_agents
1003
      SET card = jsonb_set(
1004
          jsonb_set(
1005
            card,
1006
            '{transports}',
1007
            ${JSON.stringify([
1008
              {
1009
                protocol: 'A2A/1.0',
1010
                protocolBinding: 'JSONRPC',
1011
                endpoint: a2aEndpoint,
1012
                managedBy: 'agent',
1013
              },
1014
            ])}::jsonb
1015
          ),
1016
          '{agentMode}', '"persistent_runtime"'::jsonb
1017
        ), updated_at = NOW()
1018
      WHERE agent_id = ${agentId} AND operator_id = ${operatorId}
1019
    `;
1020
    await this.sql`
1021
      UPDATE openclasp_records
1022
      SET payload = jsonb_set(
1023
          jsonb_set(payload, '{a2aEndpoint}', to_jsonb(${a2aEndpoint}::text)),
1024
          '{agentMode}', '"persistent_runtime"'::jsonb
1025
        ),
1026
        updated_at = NOW()
1027
      WHERE operator_id = ${operatorId} AND kind = 'agent_profile' AND record_id = ${agentId}
1028
    `;
1029
    await this.touchAgentPresence(operatorId, agentId);
1030
    return {
1031
      agentId,
1032
      endpoint,
1033
      a2aEndpoint,
1034
      status: 'verified' as const,
1035
      verifiedAt,
1036
      verificationKey: getSessionVerificationKey(this.gatewaySecret()),
1037
    };
1038
  }
1039
1040
  async disableAgentRuntime(operatorId: string, agentId: string) {
1041
    await this.ensureSchema();
1042
    const rows = await this.sql`
1043
      UPDATE openclasp_agent_runtimes
1044
      SET status = 'disabled', updated_at = NOW()
1045
      WHERE operator_id = ${operatorId} AND agent_id = ${agentId}
1046
      RETURNING agent_id
1047
    `;
1048
    if (!rows.length) throw new Error('Agent runtime not found');
1049
    return { agentId, status: 'disabled' as const };
1050
  }
1051
1052
  async deleteAgent(operatorId: string, agentId: string) {
1053
    await this.ensureSchema();
1054
    const owned = await this.sql`
1055
      SELECT 1 FROM openclasp_records
1056
      WHERE operator_id = ${operatorId}
1057
        AND kind = 'agent_profile'
1058
        AND record_id = ${agentId}
1059
      LIMIT 1
1060
    `;
1061
    if (!owned.length) throw new Error('Owned agent not found');
1062
    const openInteractions = await this.sql`
1063
      SELECT interaction_id FROM openclasp_federated_interactions
1064
      WHERE (initiator_agent_id = ${agentId} OR responder_agent_id = ${agentId})
1065
        AND status IN ('pending', 'active')
1066
      LIMIT 1
1067
    `;
1068
    if (openInteractions.length) {
1069
      const error = new Error('Finish or reject the agent’s open interactions before deleting it');
1070
      Object.assign(error, { statusCode: 409 });
1071
      throw error;
1072
    }
1073
    await this.sql`
1074
      DELETE FROM openclasp_hosted_threads
1075
      WHERE participant_a_agent_id = ${agentId} OR participant_b_agent_id = ${agentId}
1076
    `;
1077
    await this.sql`
1078
      DELETE FROM openclasp_public_agents
1079
      WHERE operator_id = ${operatorId} AND agent_id = ${agentId}
1080
    `;
1081
    await this.sql`
1082
      DELETE FROM openclasp_agent_runtimes
1083
      WHERE operator_id = ${operatorId} AND agent_id = ${agentId}
1084
    `;
1085
    await this.sql`
1086
      DELETE FROM openclasp_agent_access_tokens
1087
      WHERE operator_id = ${operatorId} AND agent_id = ${agentId}
1088
    `;
1089
    await this.sql`
1090
      DELETE FROM openclasp_records
1091
      WHERE operator_id = ${operatorId}
1092
        AND (
1093
          (record_id = ${agentId} AND kind IN ('agent', 'agent_profile', 'publication', 'presence'))
1094
          OR (kind = 'installation' AND payload->>'agentId' = ${agentId})
1095
          OR (kind = 'setup_request' AND payload->>'existingAgentId' = ${agentId})
1096
        )
1097
    `;
1098
    return {
1099
      agentId,
1100
      deleted: true as const,
1101
      historyRetained: true as const,
1102
    };
1103
  }
1104
1105
  private async sessionParticipant(agentId: string) {
1106
    const rows = await this.sql`
1107
      SELECT agents.operator_id, agents.card, profile.payload AS profile,
1108
        runtime.endpoint, runtime.a2a_endpoint
1109
      FROM openclasp_public_agents agents
1110
      LEFT JOIN openclasp_records profile
1111
        ON profile.operator_id = agents.operator_id
1112
        AND profile.kind = 'agent_profile'
1113
        AND profile.record_id = agents.agent_id
1114
      LEFT JOIN openclasp_agent_runtimes runtime
1115
        ON runtime.agent_id = agents.agent_id AND runtime.status = 'verified'
1116
      WHERE agents.agent_id = ${agentId}
1117
      LIMIT 1
1118
    `;
1119
    const row = rows[0];
1120
    if (!row) throw new Error(`Published agent ${agentId} was not found`);
1121
    const card = normalizePublicAgentCard(row.card);
1122
    const profile = row.profile as Partial<AgentProfile> | undefined;
1123
    const mode =
1124
      profile?.agentMode ??
1125
      (row.endpoint || row.a2a_endpoint ? 'persistent_runtime' : 'temporary_chat');
1126
    if (mode === 'persistent_runtime' && !row.endpoint)
1127
      throw new Error(`Agent ${agentId} does not have a verified live runtime`);
1128
    const baseUrl = (process.env.OPENCLASP_PUBLIC_URL ?? 'https://openclasp.vercel.app').replace(
1129
      /\/$/,
1130
      '',
1131
    );
1132
    return {
1133
      agentId,
1134
      operatorId: String(row.operator_id),
1135
      mode: mode as 'persistent_runtime' | 'temporary_chat',
1136
      ...(row.endpoint ? { callbackEndpoint: String(row.endpoint) } : {}),
1137
      a2aEndpoint:
1138
        mode === 'temporary_chat'
1139
          ? `${baseUrl}/a2a/temporary/${encodeURIComponent(agentId)}`
1140
          : String(row.a2a_endpoint ?? row.endpoint),
1141
      card: PublicAgentCardSchema.parse({ ...card, agentMode: mode }),
1142
    };
1143
  }
1144
1145
  private async signedRuntimeRequest(
1146
    runtime: { callbackEndpoint: string },
1147
    requestId: string,
1148
    value: unknown,
1149
  ) {
1150
    const body = JSON.stringify(value);
1151
    const timestamp = new Date().toISOString();
1152
    return postRuntimeJson(runtime.callbackEndpoint, value, {
1153
      'openclasp-request-id': requestId,
1154
      'openclasp-timestamp': timestamp,
1155
      'openclasp-signature': `v1=${signSessionControl(this.gatewaySecret(), requestId, timestamp, body)}`,
1156
    });
1157
  }
1158
1159
  private sessionGrant(
1160
    interaction: FederatedInteraction,
1161
    senderAgentId: string,
1162
    recipientAgentId: string,
1163
  ) {
1164
    return issueSessionGrant(this.gatewaySecret(), {
1165
      interactionId: interaction.interactionId,
1166
      senderAgentId,
1167
      recipientAgentId,
1168
      expiresAt: Math.min(Date.parse(interaction.expiresAt), Date.now() + 60 * 60_000),
1169
    });
1170
  }
1171
1172
  private async privateCounterpartyInsights(
1173
    viewerOperatorId: string,
1174
    counterparty: { agentId: string; card: PublicAgentCard },
1175
    taskCategory: string,
1176
  ): Promise<{
1177
    insights: LiveSessionInsight[];
1178
    relevantSampleSize: number;
1179
    historyConfidence: number;
1180
  }> {
1181
    const rows = await this.sql`
1182
      SELECT record_id, payload
1183
      FROM openclasp_records
1184
      WHERE operator_id = ${viewerOperatorId}
1185
        AND kind = 'profile'
1186
        AND payload->>'agentId' = ${counterparty.agentId}
1187
        AND payload->>'taskCategory' = ${taskCategory}
1188
      ORDER BY (payload->>'sampleSize')::integer DESC, updated_at DESC
1189
    `;
1190
    const profiles: ContextualProfile[] = rows
1191
      .map<Record<string, unknown>>((row) => ({
1192
        recordId: String(row.record_id),
1193
        ...(row.payload as Record<string, unknown>),
1194
      }))
1195
      .filter(
1196
        (profile) =>
1197
          typeof profile.agentVersion === 'string' &&
1198
          typeof profile.sampleSize === 'number' &&
1199
          typeof profile.updatedAt === 'string' &&
1200
          typeof profile.completion === 'number' &&
1201
          typeof profile.acceptance === 'number' &&
1202
          typeof profile.specification === 'number' &&
1203
          typeof profile.deadline === 'number' &&
1204
          typeof profile.communication === 'number' &&
1205
          typeof profile.evidence === 'number' &&
1206
          typeof profile.scope === 'number' &&
1207
          typeof profile.disputes === 'number',
1208
      ) as ContextualProfile[];
1209
    const exact = profiles.find(
1210
      (profile) => profile.agentVersion === counterparty.card.agentVersion,
1211
    );
1212
    const current = exact ?? profiles[0];
1213
    const insights: LiveSessionInsight[] = [];
1214
    let relevantSampleSize = 0;
1215
    let historyConfidence = 0;
1216
    if (!current) {
1217
      insights.push({
1218
        code: 'limited_verified_history',
1219
        severity: 'caution',
1220
        message: `No eligible ${taskCategory} history is available for this agent version. Ask for task-relevant evidence.`,
1221
        evidenceReferences: [],
1222
        requirementReferences: [],
1223
      });
1224
    } else {
1225
      const ageDays = Math.max(
1226
        0,
1227
        (Date.now() - Date.parse(String(current.updatedAt))) / 86_400_000,
1228
      );
1229
      const freshness = Math.exp(-ageDays / 180);
1230
      const sampleSize = Number(current.sampleSize);
1231
      const confidence = Math.min(0.95, (sampleSize / (sampleSize + 5)) * freshness);
1232
      relevantSampleSize = sampleSize;
1233
      historyConfidence = confidence;
1234
      const versionChanged = current.agentVersion !== counterparty.card.agentVersion;
1235
      const reference = `openclasp:profile:${current.recordId}`;
1236
      insights.push({
1237
        code: versionChanged ? 'version_history_only' : 'contextual_history',
1238
        severity: versionChanged || confidence < 0.25 ? 'caution' : 'info',
1239
        message: versionChanged
1240
          ? `Only prior-version ${taskCategory} history is available (${sampleSize} eligible interaction${sampleSize === 1 ? '' : 's'}); confidence is reduced for version ${counterparty.card.agentVersion}.`
1241
          : `Based on ${sampleSize} eligible ${taskCategory} interaction${sampleSize === 1 ? '' : 's'}; confidence ${(confidence * 100).toFixed(0)}%.`,
1242
        evidenceReferences: [reference],
1243
        requirementReferences: [],
1244
      });
1245
      const weakDimensions = [
1246
        ['completion', Number(current.completion)],
1247
        ['output acceptance', Number(current.acceptance)],
1248
        ['specification adherence', Number(current.specification)],
1249
        ['deadline reliability', Number(current.deadline)],
1250
        ['communication quality', Number(current.communication)],
1251
        ['evidence quality', Number(current.evidence)],
1252
        ['scope adherence', Number(current.scope)],
1253
      ].filter(([, score]) => Number(score) < 0.7);
1254
      if (weakDimensions.length)
1255
        insights.push({
1256
          code: 'historical_weaknesses',
1257
          severity: weakDimensions.some(([, score]) => Number(score) < 0.5) ? 'high' : 'caution',
1258
          message: `Observed weaker areas: ${weakDimensions
1259
            .map(([name, score]) => `${String(name)} ${(Number(score) * 100).toFixed(0)}%`)
1260
            .join(', ')}.`,
1261
          evidenceReferences: [reference],
1262
          requirementReferences: [],
1263
        });
1264
      if (Number(current.disputes) > 0.25)
1265
        insights.push({
1266
          code: 'elevated_dispute_history',
1267
          severity: Number(current.disputes) >= 0.5 ? 'high' : 'caution',
1268
          message: `Eligible feedback shows a ${(Number(current.disputes) * 100).toFixed(0)}% dispute rate in this task category.`,
1269
          evidenceReferences: [reference],
1270
          requirementReferences: [],
1271
        });
1272
    }
1273
    if (counterparty.card.limitations.length)
1274
      insights.push({
1275
        code: 'declared_limitations',
1276
        severity: 'info',
1277
        message: `Counterparty declares: ${counterparty.card.limitations.join('; ')}`,
1278
        evidenceReferences: [counterparty.card.cardUrl],
1279
        requirementReferences: [],
1280
      });
1281
    return { insights, relevantSampleSize, historyConfidence };
1282
  }
1283
1284
  async brokerLiveSession(interaction: FederatedInteraction) {
1285
    await this.ensureSchema();
1286
    const existing = await this.sql`
1287
      SELECT status FROM openclasp_live_sessions
1288
      WHERE interaction_id = ${interaction.interactionId}
1289
    `;
1290
    if (existing[0]?.status === 'active') return;
1291
    const [initiator, responder] = await Promise.all([
1292
      this.sessionParticipant(interaction.initiatorAgentId),
1293
      this.sessionParticipant(interaction.responderAgentId),
1294
    ]);
1295
    if (initiator.mode === 'temporary_chat' && responder.mode === 'temporary_chat')
1296
      throw new Error('Temporary-to-temporary hosted conversations are not supported in this MVP');
1297
    const [initiatorContext, responderContext] = await Promise.all([
1298
      this.privateCounterpartyInsights(
1299
        initiator.operatorId,
1300
        responder,
1301
        interaction.contract.taskCategory,
1302
      ),
1303
      this.privateCounterpartyInsights(
1304
        responder.operatorId,
1305
        initiator,
1306
        interaction.contract.taskCategory,
1307
      ),
1308
    ]);
1309
    const issuedAt = new Date().toISOString();
1310
    const initiatorBrief = buildCounterpartyBrief({
1311
      interactionId: interaction.interactionId,
1312
      contractHash: interaction.termsHash,
1313
      contract: interaction.contract,
1314
      recipientAgentId: initiator.agentId,
1315
      subject: responder.card,
1316
      historyInsights: initiatorContext.insights,
1317
      relevantSampleSize: initiatorContext.relevantSampleSize,
1318
      historyConfidence: initiatorContext.historyConfidence,
1319
      generatedAt: issuedAt,
1320
      expiresAt: interaction.expiresAt,
1321
    });
1322
    const responderBrief = buildCounterpartyBrief({
1323
      interactionId: interaction.interactionId,
1324
      contractHash: interaction.termsHash,
1325
      contract: interaction.contract,
1326
      recipientAgentId: responder.agentId,
1327
      subject: initiator.card,
1328
      historyInsights: responderContext.insights,
1329
      relevantSampleSize: responderContext.relevantSampleSize,
1330
      historyConfidence: responderContext.historyConfidence,
1331
      generatedAt: issuedAt,
1332
      expiresAt: interaction.expiresAt,
1333
    });
1334
    await Promise.all([
1335
      this.upsert(
1336
        initiator.operatorId,
1337
        'counterparty_brief',
1338
        initiatorBrief.briefId,
1339
        initiatorBrief,
1340
      ),
1341
      this.upsert(
1342
        responder.operatorId,
1343
        'counterparty_brief',
1344
        responderBrief.briefId,
1345
        responderBrief,
1346
      ),
1347
    ]);
1348
    const makeOffer = (
1349
      runtime: typeof initiator,
1350
      counterparty: typeof responder,
1351
      role: 'initiator' | 'responder',
1352
      privateInsights: LiveSessionInsight[],
1353
      counterpartyBrief: CounterpartyBrief,
1354
    ) =>
1355
      LiveSessionOfferSchema.parse({
1356
        type: 'openclasp.session.offer',
1357
        version: '1',
1358
        offerId: crypto.randomUUID(),
1359
        interactionId: interaction.interactionId,
1360
        agentId: runtime.agentId,
1361
        role,
1362
        counterparty: {
1363
          agentId: counterparty.agentId,
1364
          name: counterparty.card.name,
1365
          agentVersion: counterparty.card.agentVersion,
1366
          capabilities: counterparty.card.capabilities,
1367
        },
1368
        contract: interaction.contract,
1369
        contractHash: interaction.termsHash,
1370
        privateInsights,
1371
        counterpartyBrief,
1372
        issuedAt,
1373
        expiresAt: interaction.expiresAt,
1374
      });
1375
    const initiatorOffer = makeOffer(
1376
      initiator,
1377
      responder,
1378
      'initiator',
1379
      initiatorBrief.insights,
1380
      initiatorBrief,
1381
    );
1382
    const responderOffer = makeOffer(
1383
      responder,
1384
      initiator,
1385
      'responder',
1386
      responderBrief.insights,
1387
      responderBrief,
1388
    );
1389
    const prepare = async (
1390
      participant: typeof initiator,
1391
      offer: typeof initiatorOffer,
1392
      label: 'Initiator' | 'Responder',
1393
    ) => {
1394
      if (participant.mode === 'temporary_chat')
1395
        return LiveSessionAcceptanceSchema.parse({
1396
          type: 'openclasp.session.accepted',
1397
          version: '1',
1398
          offerId: offer.offerId,
1399
          interactionId: interaction.interactionId,
1400
          agentId: participant.agentId,
1401
          sessionId: crypto.randomUUID(),
1402
          a2aEndpoint: participant.a2aEndpoint,
1403
          expiresAt: interaction.expiresAt,
1404
        });
1405
      if (!participant.callbackEndpoint) throw new Error(`${label} runtime is not configured`);
1406
      const response = await this.signedRuntimeRequest(
1407
        { callbackEndpoint: participant.callbackEndpoint },
1408
        offer.offerId,
1409
        offer,
1410
      );
1411
      if (response.status < 200 || response.status >= 300)
1412
        throw new Error(`${label} runtime is not live (HTTP ${response.status})`);
1413
      return LiveSessionAcceptanceSchema.parse(response.body);
1414
    };
1415
    const [initiatorAcceptance, responderAcceptance] = await Promise.all([
1416
      prepare(initiator, initiatorOffer, 'Initiator'),
1417
      prepare(responder, responderOffer, 'Responder'),
1418
    ]);
1419
    if (
1420
      initiatorAcceptance.offerId !== initiatorOffer.offerId ||
1421
      initiatorAcceptance.agentId !== initiator.agentId ||
1422
      responderAcceptance.offerId !== responderOffer.offerId ||
1423
      responderAcceptance.agentId !== responder.agentId
1424
    )
1425
      throw new Error('Runtime returned a mismatched live-session acceptance');
1426
    await Promise.all([
1427
      ...(initiator.mode === 'persistent_runtime'
1428
        ? [resolvePublicRuntimeEndpoint(initiatorAcceptance.a2aEndpoint)]
1429
        : []),
1430
      ...(responder.mode === 'persistent_runtime'
1431
        ? [resolvePublicRuntimeEndpoint(responderAcceptance.a2aEndpoint)]
1432
        : []),
1433
    ]);
1434
    await this.sql`
1435
      INSERT INTO openclasp_live_sessions(
1436
        interaction_id, initiator_agent_id, responder_agent_id,
1437
        initiator_session_id, responder_session_id,
1438
        initiator_endpoint, responder_endpoint, status, expires_at
1439
      ) VALUES (
1440
        ${interaction.interactionId}, ${interaction.initiatorAgentId}, ${interaction.responderAgentId},
1441
        ${initiatorAcceptance.sessionId}, ${responderAcceptance.sessionId},
1442
        ${initiatorAcceptance.a2aEndpoint}, ${responderAcceptance.a2aEndpoint},
1443
        'preparing', ${interaction.expiresAt}
1444
      )
1445
      ON CONFLICT (interaction_id) DO UPDATE SET
1446
        initiator_session_id = EXCLUDED.initiator_session_id,
1447
        responder_session_id = EXCLUDED.responder_session_id,
1448
        initiator_endpoint = EXCLUDED.initiator_endpoint,
1449
        responder_endpoint = EXCLUDED.responder_endpoint,
1450
        status = 'preparing', expires_at = EXCLUDED.expires_at, last_error = NULL
1451
    `;
1452
    const baseUrl = (process.env.OPENCLASP_PUBLIC_URL ?? 'https://openclasp.vercel.app').replace(
1453
      /\/$/,
1454
      '',
1455
    );
1456
    const activatedAt = new Date().toISOString();
1457
    const verificationKey = getSessionVerificationKey(this.gatewaySecret());
1458
    const makeActivation = (
1459
      agentId: string,
1460
      sessionId: string,
1461
      role: 'initiator' | 'responder',
1462
      peerAgentId: string,
1463
      peerSessionId: string,
1464
      peerEndpoint: string,
1465
      privateInsights: LiveSessionInsight[],
1466
      counterpartyBrief: CounterpartyBrief,
1467
    ): LiveSessionActivation => {
1468
      const bearerToken = this.sessionGrant(interaction, agentId, peerAgentId);
1469
      return LiveSessionActivationSchema.parse({
1470
        type: 'openclasp.session.activation',
1471
        version: '1',
1472
        activationId: crypto.randomUUID(),
1473
        interactionId: interaction.interactionId,
1474
        agentId,
1475
        sessionId,
1476
        role,
1477
        peer: {
1478
          agentId: peerAgentId,
1479
          sessionId: peerSessionId,
1480
          endpoint: peerEndpoint,
1481
          bearerToken,
1482
          verificationKey,
1483
        },
1484
        reporting: {
1485
          endpoint: `${baseUrl}/sessions/${encodeURIComponent(interaction.interactionId)}/events`,
1486
          completionEndpoint: `${baseUrl}/sessions/${encodeURIComponent(interaction.interactionId)}/completion-reports`,
1487
          feedbackEndpoint: `${baseUrl}/sessions/${encodeURIComponent(interaction.interactionId)}/feedback`,
1488
          bearerToken,
1489
        },
1490
        privateInsights,
1491
        counterpartyBrief,
1492
        contractHash: interaction.termsHash,
1493
        activatedAt,
1494
        expiresAt: interaction.expiresAt,
1495
      });
1496
    };
1497
    const initiatorActivation = makeActivation(
1498
      initiator.agentId,
1499
      initiatorAcceptance.sessionId,
1500
      'initiator',
1501
      responder.agentId,
1502
      responderAcceptance.sessionId,
1503
      responderAcceptance.a2aEndpoint,
1504
      initiatorBrief.insights,
1505
      initiatorBrief,
1506
    );
1507
    const responderActivation = makeActivation(
1508
      responder.agentId,
1509
      responderAcceptance.sessionId,
1510
      'responder',
1511
      initiator.agentId,
1512
      initiatorAcceptance.sessionId,
1513
      initiatorAcceptance.a2aEndpoint,
1514
      responderBrief.insights,
1515
      responderBrief,
1516
    );
1517
    try {
1518
      const activate = async (
1519
        participant: typeof initiator,
1520
        activation: LiveSessionActivation,
1521
        label: 'Initiator' | 'Responder',
1522
      ) => {
1523
        if (participant.mode === 'temporary_chat') return;
1524
        if (!participant.callbackEndpoint) throw new Error(`${label} runtime is not configured`);
1525
        const response = await this.signedRuntimeRequest(
1526
          { callbackEndpoint: participant.callbackEndpoint },
1527
          activation.activationId,
1528
          activation,
1529
        );
1530
        if (response.status < 200 || response.status >= 300)
1531
          throw new Error(`${label} activation failed with HTTP ${response.status}`);
1532
      };
1533
      await activate(responder, responderActivation, 'Responder');
1534
      await activate(initiator, initiatorActivation, 'Initiator');
1535
      await this.sql`
1536
        UPDATE openclasp_live_sessions
1537
        SET status = 'active', activated_at = NOW(), last_error = NULL
1538
        WHERE interaction_id = ${interaction.interactionId}
1539
      `;
1540
      await this.sql`
1541
        UPDATE openclasp_agent_runtimes
1542
        SET last_seen_at = NOW(), last_error = NULL, updated_at = NOW()
1543
        WHERE agent_id IN (${initiator.agentId}, ${responder.agentId})
1544
      `;
1545
      await Promise.all([
1546
        this.touchAgentPresence(initiator.operatorId, initiator.agentId),
1547
        this.touchAgentPresence(responder.operatorId, responder.agentId),
1548
      ]);
1549
    } catch (error) {
1550
      const reason =
1551
        error instanceof Error ? error.message.slice(0, 500) : 'Session activation failed';
1552
      await this.sql`
1553
        UPDATE openclasp_live_sessions SET status = 'failed', last_error = ${reason}
1554
        WHERE interaction_id = ${interaction.interactionId}
1555
      `;
1556
      throw error;
1557
    }
1558
  }
1559
1560
  async getLiveSession(operatorId: string, interactionId: string, agentId: string) {
1561
    await this.ensureSchema();
1562
    const rows = await this.sql`
1563
      SELECT session.*, interaction.payload,
1564
        initiator.operator_id AS initiator_operator_id,
1565
        responder.operator_id AS responder_operator_id
1566
      FROM openclasp_live_sessions session
1567
      INNER JOIN openclasp_federated_interactions interaction
1568
        ON interaction.interaction_id = session.interaction_id
1569
      INNER JOIN openclasp_public_agents initiator
1570
        ON initiator.agent_id = session.initiator_agent_id
1571
      INNER JOIN openclasp_public_agents responder
1572
        ON responder.agent_id = session.responder_agent_id
1573
      WHERE session.interaction_id = ${interactionId} AND session.status = 'active'
1574
      LIMIT 1
1575
    `;
1576
    const row = rows[0];
1577
    if (!row) throw new Error('Active live session not found');
1578
    const isInitiator = row.initiator_agent_id === agentId;
1579
    const isResponder = row.responder_agent_id === agentId;
1580
    if (!isInitiator && !isResponder) throw new Error('Agent is not a session participant');
1581
    const owner = isInitiator ? row.initiator_operator_id : row.responder_operator_id;
1582
    if (owner !== operatorId) throw new Error('Agent is not owned by this account');
1583
    const interaction = FederatedInteractionSchema.parse(row.payload);
1584
    const peerAgentId = String(isInitiator ? row.responder_agent_id : row.initiator_agent_id);
1585
    const peer = await this.sessionParticipant(peerAgentId);
1586
    const privateContext = await this.privateCounterpartyInsights(
1587
      String(owner),
1588
      peer,
1589
      interaction.contract.taskCategory,
1590
    );
1591
    const briefRows = await this.sql`
1592
      SELECT payload FROM openclasp_records
1593
      WHERE operator_id = ${String(owner)}
1594
        AND kind = 'counterparty_brief'
1595
        AND payload->>'interactionId' = ${interactionId}
1596
        AND payload->>'recipientAgentId' = ${agentId}
1597
      ORDER BY updated_at DESC
1598
      LIMIT 1
1599
    `;
1600
    const storedBrief = CounterpartyBriefSchema.safeParse(briefRows[0]?.payload);
1601
    const counterpartyBrief = storedBrief.success
1602
      ? storedBrief.data
1603
      : buildCounterpartyBrief({
1604
          interactionId,
1605
          contractHash: interaction.termsHash,
1606
          contract: interaction.contract,
1607
          recipientAgentId: agentId,
1608
          subject: peer.card,
1609
          historyInsights: privateContext.insights,
1610
          relevantSampleSize: privateContext.relevantSampleSize,
1611
          historyConfidence: privateContext.historyConfidence,
1612
          expiresAt: new Date(String(row.expires_at)).toISOString(),
1613
        });
1614
    const bearerToken = this.sessionGrant(interaction, agentId, peerAgentId);
1615
    return {
1616
      type: 'openclasp.session.activation' as const,
1617
      version: '1' as const,
1618
      activationId: crypto.randomUUID(),
1619
      interactionId,
1620
      agentId,
1621
      sessionId: String(isInitiator ? row.initiator_session_id : row.responder_session_id),
1622
      role: isInitiator ? ('initiator' as const) : ('responder' as const),
1623
      peer: {
1624
        agentId: peerAgentId,
1625
        sessionId: String(isInitiator ? row.responder_session_id : row.initiator_session_id),
1626
        endpoint: String(isInitiator ? row.responder_endpoint : row.initiator_endpoint),
1627
        bearerToken,
1628
        verificationKey: getSessionVerificationKey(this.gatewaySecret()),
1629
      },
1630
      reporting: {
1631
        endpoint: `${(process.env.OPENCLASP_PUBLIC_URL ?? 'https://openclasp.vercel.app').replace(/\/$/, '')}/sessions/${encodeURIComponent(interactionId)}/events`,
1632
        completionEndpoint: `${(process.env.OPENCLASP_PUBLIC_URL ?? 'https://openclasp.vercel.app').replace(/\/$/, '')}/sessions/${encodeURIComponent(interactionId)}/completion-reports`,
1633
        feedbackEndpoint: `${(process.env.OPENCLASP_PUBLIC_URL ?? 'https://openclasp.vercel.app').replace(/\/$/, '')}/sessions/${encodeURIComponent(interactionId)}/feedback`,
1634
        bearerToken,
1635
      },
1636
      privateInsights: privateContext.insights,
1637
      counterpartyBrief,
1638
      contractHash: interaction.termsHash,
1639
      activatedAt: row.activated_at
1640
        ? new Date(String(row.activated_at)).toISOString()
1641
        : new Date().toISOString(),
1642
      expiresAt: new Date(String(row.expires_at)).toISOString(),
1643
    };
1644
  }
1645
1646
  private async interactionParticipant(operatorId: string, interactionId: string, agentId: string) {
1647
    await this.ensureSchema();
1648
    const rows = await this.sql`
1649
      SELECT payload, initiator_operator_id, responder_operator_id,
1650
        initiator_agent_id, responder_agent_id
1651
      FROM openclasp_federated_interactions
1652
      WHERE interaction_id = ${interactionId}
1653
        AND (
1654
          (initiator_operator_id = ${operatorId} AND initiator_agent_id = ${agentId})
1655
          OR (responder_operator_id = ${operatorId} AND responder_agent_id = ${agentId})
1656
        )
1657
      LIMIT 1
1658
    `;
1659
    const row = rows[0];
1660
    if (!row) throw new Error('Interaction participant not found for this account');
1661
    const interaction = FederatedInteractionSchema.parse(row.payload);
1662
    const isInitiator = interaction.initiatorAgentId === agentId;
1663
    return {
1664
      interaction,
1665
      counterpartyAgentId: isInitiator
1666
        ? interaction.responderAgentId
1667
        : interaction.initiatorAgentId,
1668
      participantOperatorId: String(
1669
        isInitiator ? row.initiator_operator_id : row.responder_operator_id,
1670
      ),
1671
      counterpartyOperatorId: String(
1672
        isInitiator ? row.responder_operator_id : row.initiator_operator_id,
1673
      ),
1674
    };
1675
  }
1676
1677
  async getCounterpartyBrief(operatorId: string, interactionId: string, agentId: string) {
1678
    await this.interactionParticipant(operatorId, interactionId, agentId);
1679
    const rows = await this.sql`
1680
      SELECT payload FROM openclasp_records
1681
      WHERE operator_id = ${operatorId}
1682
        AND kind = 'counterparty_brief'
1683
        AND payload->>'interactionId' = ${interactionId}
1684
        AND payload->>'recipientAgentId' = ${agentId}
1685
      ORDER BY updated_at DESC
1686
      LIMIT 1
1687
    `;
1688
    const parsed = CounterpartyBriefSchema.safeParse(rows[0]?.payload);
1689
    if (!parsed.success) throw new Error('Counterparty brief is not available');
1690
    return parsed.data;
1691
  }
1692
1693
  private async ensureFeedbackRequest(
1694
    operatorId: string,
1695
    interactionId: string,
1696
    reviewerAgentId: string,
1697
    subjectAgentId: string,
1698
  ) {
1699
    const existing = await this.sql`
1700
      SELECT payload FROM openclasp_records
1701
      WHERE operator_id = ${operatorId}
1702
        AND kind = 'feedback_request'
1703
        AND payload->>'interactionId' = ${interactionId}
1704
        AND payload->>'reviewerAgentId' = ${reviewerAgentId}
1705
      ORDER BY updated_at DESC
1706
      LIMIT 1
1707
    `;
1708
    const parsed = FeedbackRequestSchema.safeParse(existing[0]?.payload);
1709
    if (parsed.success) return parsed.data;
1710
    const requestedAt = new Date();
1711
    const base = FeedbackRequestSchema.parse({
1712
      requestId: deterministicUuid(`feedback-request:${interactionId}:${reviewerAgentId}`),
1713
      interactionId,
1714
      reviewerAgentId,
1715
      subjectAgentId,
1716
      status: 'pending',
1717
      requestedDimensions: FEEDBACK_DIMENSIONS,
1718
      requestedAt: requestedAt.toISOString(),
1719
      dueAt: new Date(requestedAt.getTime() + 24 * 60 * 60_000).toISOString(),
1720
    });
1721
    const request = FeedbackRequestSchema.parse({
1722
      ...base,
1723
      platformAttestation: attestSessionRecord(this.gatewaySecret(), base),
1724
    });
1725
    await this.upsert(operatorId, 'feedback_request', request.requestId, request);
1726
    return request;
1727
  }
1728
1729
  async listFeedbackRequests(operatorId: string, agentId: string) {
1730
    const rows = await this.sql`
1731
      SELECT payload FROM openclasp_records
1732
      WHERE operator_id = ${operatorId}
1733
        AND kind = 'feedback_request'
1734
        AND payload->>'reviewerAgentId' = ${agentId}
1735
      ORDER BY updated_at DESC
1736
    `;
1737
    return rows.map((row) => FeedbackRequestSchema.parse(row.payload));
1738
  }
1739
1740
  async submitCompletionReport(
1741
    operatorId: string,
1742
    agentId: string,
1743
    value: InteractionCompletionReport,
1744
    submissionMethod: 'oauth_installation' | 'agent_access_token' | 'runtime_session',
1745
  ) {
1746
    const participant = await this.interactionParticipant(operatorId, value.interactionId, agentId);
1747
    const report = InteractionCompletionReportSchema.parse(value);
1748
    if (report.platformAttestation)
1749
      throw new Error('Platform attestation is assigned by OpenClasp');
1750
    if (report.reportingAgentId !== agentId)
1751
      throw new Error('Completion report identity does not match the authenticated agent');
1752
    if (report.counterpartyAgentId !== participant.counterpartyAgentId)
1753
      throw new Error('Completion report counterparty does not match the interaction');
1754
    if (report.contractHash !== participant.interaction.termsHash)
1755
      throw new Error('Completion report contract hash does not match the interaction');
1756
    if (report.requestedOutcome !== participant.interaction.contract.requestedOutcome)
1757
      throw new Error('Completion report requested outcome does not match the contract');
1758
    if (
1759
      report.criteria.some(
1760
        (criterion) =>
1761
          !participant.interaction.contract.successCriteria.includes(criterion.criterion),
1762
      )
1763
    )
1764
      throw new Error('Completion report contains a criterion outside the contract');
1765
    if (!['active', 'completed'].includes(participant.interaction.status))
1766
      throw new Error('Only active or completed interactions accept completion reports');
1767
    const profiles = await this.sql`
1768
      SELECT payload FROM openclasp_records
1769
      WHERE operator_id = ${operatorId} AND kind = 'agent_profile' AND record_id = ${agentId}
1770
      LIMIT 1
1771
    `;
1772
    const profile = profiles[0]?.payload as Partial<AgentProfile> | undefined;
1773
    if (!profile || profile.agentVersion !== report.agentVersion)
1774
      throw new Error('Completion report agent version does not match the registered agent');
1775
    let verifiedSubmissionMethod: InteractionCompletionReport['submissionMethod'] =
1776
      submissionMethod;
1777
    if (report.signature) {
1778
      if (report.submissionMethod !== 'agent_signature')
1779
        throw new Error('Signed completion reports must declare agent_signature submission');
1780
      const identities = await this.sql`
1781
        SELECT payload FROM openclasp_records
1782
        WHERE operator_id = ${operatorId} AND kind = 'agent' AND record_id = ${agentId}
1783
        LIMIT 1
1784
      `;
1785
      const identity = AgentIdentitySchema.safeParse(identities[0]?.payload);
1786
      if (
1787
        !identity.success ||
1788
        !verifyObject(report as unknown as Record<string, unknown>, identity.data.publicKey)
1789
      )
1790
        throw new Error('Completion report agent signature is invalid or unverifiable');
1791
      verifiedSubmissionMethod = 'agent_signature';
1792
    }
1793
    const unsigned = InteractionCompletionReportSchema.parse({
1794
      ...report,
1795
      submissionMethod: verifiedSubmissionMethod,
1796
    });
1797
    const stored = InteractionCompletionReportSchema.parse({
1798
      ...unsigned,
1799
      platformAttestation: attestSessionRecord(this.gatewaySecret(), unsigned),
1800
    });
1801
    const existing = await this.sql`
1802
      SELECT payload FROM openclasp_records
1803
      WHERE kind = 'completion_report' AND record_id = ${stored.reportId}
1804
        AND payload->>'interactionId' = ${stored.interactionId}
1805
      LIMIT 1
1806
    `;
1807
    if (existing[0] && canonicalHash(existing[0].payload) !== canonicalHash(stored))
1808
      throw new Error('Conflicting completion report ID');
1809
    await Promise.all([
1810
      this.upsert(participant.participantOperatorId, 'completion_report', stored.reportId, stored),
1811
      this.upsert(participant.counterpartyOperatorId, 'completion_report', stored.reportId, stored),
1812
    ]);
1813
    const [feedbackRequest] = await Promise.all([
1814
      this.ensureFeedbackRequest(
1815
        participant.participantOperatorId,
1816
        stored.interactionId,
1817
        stored.reportingAgentId,
1818
        stored.counterpartyAgentId,
1819
      ),
1820
      this.ensureFeedbackRequest(
1821
        participant.counterpartyOperatorId,
1822
        stored.interactionId,
1823
        stored.counterpartyAgentId,
1824
        stored.reportingAgentId,
1825
      ),
1826
    ]);
1827
    const completed = await this.sql`
1828
      SELECT COUNT(DISTINCT payload->>'reportingAgentId') AS count
1829
      FROM openclasp_records
1830
      WHERE kind = 'completion_report'
1831
        AND payload->>'interactionId' = ${stored.interactionId}
1832
    `;
1833
    if (Number(completed[0]?.count ?? 0) >= 2) {
1834
      const completedAt = new Date().toISOString();
1835
      await Promise.all([
1836
        this.sql`
1837
          UPDATE openclasp_live_sessions SET status = 'completed', completed_at = NOW()
1838
          WHERE interaction_id = ${stored.interactionId} AND status = 'active'
1839
        `,
1840
        this.sql`
1841
          UPDATE openclasp_federated_interactions
1842
          SET status = 'completed',
1843
            payload = jsonb_set(
1844
              jsonb_set(payload, '{status}', '"completed"'::jsonb),
1845
              '{updatedAt}', to_jsonb(${completedAt}::text)
1846
            ),
1847
            updated_at = NOW()
1848
          WHERE interaction_id = ${stored.interactionId} AND status = 'active'
1849
        `,
1850
      ]);
1851
    }
1852
    return { report: stored, feedbackRequest };
1853
  }
1854
1855
  async recordSessionCompletionReport(token: string, value: InteractionCompletionReport) {
1856
    const report = InteractionCompletionReportSchema.parse(value);
1857
    const grant = verifySessionGrant(this.gatewaySecret(), token);
1858
    if (
1859
      grant.interactionId !== report.interactionId ||
1860
      grant.senderAgentId !== report.reportingAgentId ||
1861
      grant.recipientAgentId !== report.counterpartyAgentId
1862
    )
1863
      throw new Error('Session credential does not match the completion report');
1864
    const owners = await this.sql`
1865
      SELECT initiator_operator_id, responder_operator_id,
1866
        initiator_agent_id, responder_agent_id
1867
      FROM openclasp_federated_interactions
1868
      WHERE interaction_id = ${report.interactionId}
1869
      LIMIT 1
1870
    `;
1871
    const row = owners[0];
1872
    if (!row) throw new Error('Interaction not found');
1873
    const operatorId = String(
1874
      row.initiator_agent_id === report.reportingAgentId
1875
        ? row.initiator_operator_id
1876
        : row.responder_operator_id,
1877
    );
1878
    return this.submitCompletionReport(
1879
      operatorId,
1880
      report.reportingAgentId,
1881
      report,
1882
      'runtime_session',
1883
    );
1884
  }
1885
1886
  private async finalizeInteractionConclusion(interactionId: string) {
1887
    const [interactionRows, reportRows, feedbackRows, requestRows, existingRows] =
1888
      await Promise.all([
1889
        this.sql`
1890
        SELECT payload, initiator_operator_id, responder_operator_id
1891
        FROM openclasp_federated_interactions
1892
        WHERE interaction_id = ${interactionId}
1893
        LIMIT 1
1894
      `,
1895
        this.sql`
1896
        SELECT record_id, payload FROM openclasp_records
1897
        WHERE kind = 'completion_report' AND payload->>'interactionId' = ${interactionId}
1898
      `,
1899
        this.sql`
1900
        SELECT record_id, payload FROM openclasp_records
1901
        WHERE kind = 'interaction_feedback' AND payload->>'interactionId' = ${interactionId}
1902
      `,
1903
        this.sql`
1904
        SELECT record_id, payload FROM openclasp_records
1905
        WHERE kind = 'feedback_request' AND payload->>'interactionId' = ${interactionId}
1906
      `,
1907
        this.sql`
1908
        SELECT payload FROM openclasp_records
1909
        WHERE kind = 'interaction_conclusion' AND payload->>'interactionId' = ${interactionId}
1910
        LIMIT 1
1911
      `,
1912
      ]);
1913
    const existing = InteractionConclusionSchema.safeParse(existingRows[0]?.payload);
1914
    if (existing.success) return { released: true, conclusion: existing.data };
1915
    const interactionRow = interactionRows[0];
1916
    if (!interactionRow) throw new Error('Interaction not found');
1917
    const interaction = FederatedInteractionSchema.parse(interactionRow.payload);
1918
    const reports = [
1919
      ...new Map(
1920
        reportRows
1921
          .map((row) => InteractionCompletionReportSchema.parse(row.payload))
1922
          .map((report) => [report.reportId, report]),
1923
      ).values(),
1924
    ];
1925
    const feedback = [
1926
      ...new Map(
1927
        feedbackRows
1928
          .map((row) => InteractionFeedbackSchema.parse(row.payload))
1929
          .map((item) => [item.feedbackId, item]),
1930
      ).values(),
1931
    ];
1932
    const requests = [
1933
      ...new Map(
1934
        requestRows
1935
          .map((row) => FeedbackRequestSchema.parse(row.payload))
1936
          .map((request) => [request.requestId, request]),
1937
      ).values(),
1938
    ];
1939
    if (requests.length < 2 || requests.some((request) => request.status === 'pending'))
1940
      return { released: false as const };
1941
    const base = buildInteractionConclusion({
1942
      interaction,
1943
      reports,
1944
      feedback,
1945
      conclusionId: deterministicUuid(`interaction-conclusion:${interactionId}`),
1946
    });
1947
    const conclusion = InteractionConclusionSchema.parse({
1948
      ...base,
1949
      platformAttestation: attestSessionRecord(this.gatewaySecret(), base),
1950
    });
1951
    const startedAt = reports
1952
      .map((report) => report.startedAt)
1953
      .filter((value): value is string => typeof value === 'string')
1954
      .sort()[0];
1955
    const receiptBase = {
1956
      receiptId: deterministicUuid(`interaction-receipt:${interactionId}`),
1957
      interactionId,
1958
      participants: [interaction.initiatorAgentId, interaction.responderAgentId],
1959
      agentVersions: Object.fromEntries(
1960
        reports.map((report) => [report.reportingAgentId, report.agentVersion]),
1961
      ),
1962
      contractHash: interaction.termsHash,
1963
      startedAt: startedAt ?? interaction.createdAt,
1964
      completedAt:
1965
        reports
1966
          .map((report) => report.completedAt)
1967
          .sort()
1968
          .at(-1) ?? conclusion.generatedAt,
1969
      outcome: conclusion.outcome,
1970
      commitmentsFulfilled: conclusion.criteria
1971
        .filter((criterion) => criterion.status === 'met')
1972
        .map((criterion) => criterion.criterion),
1973
      commitmentsMissed: conclusion.criteria
1974
        .filter((criterion) => criterion.status !== 'met')
1975
        .map((criterion) => criterion.criterion),
1976
      evidenceHashes: conclusion.evidenceReferences.map((reference) => canonicalHash(reference)),
1977
      policyWarnings: reports.flatMap((report) => report.blockers),
1978
      policyViolations: [],
1979
      disputeStatus: conclusion.consensus === 'conflicting' ? ('open' as const) : ('none' as const),
1980
      delegationChainHash: canonicalHash(interaction.contract.delegationRules),
1981
      unilateral: reports.length < 2,
1982
      signatures: {},
1983
      completionReportIds: conclusion.reportIds,
1984
      conclusionId: conclusion.conclusionId,
1985
    };
1986
    const receipt = ReceiptSchema.parse({
1987
      ...receiptBase,
1988
      platformAttestation: attestSessionRecord(this.gatewaySecret(), receiptBase),
1989
    });
1990
    await Promise.all([
1991
      this.upsert(
1992
        String(interactionRow.initiator_operator_id),
1993
        'interaction_conclusion',
1994
        conclusion.conclusionId,
1995
        conclusion,
1996
      ),
1997
      this.upsert(
1998
        String(interactionRow.responder_operator_id),
1999
        'interaction_conclusion',
2000
        conclusion.conclusionId,
2001
        conclusion,
2002
      ),
2003
      this.upsert(
2004
        String(interactionRow.initiator_operator_id),
2005
        'receipt',
2006
        receipt.receiptId,
2007
        receipt,
2008
      ),
2009
      this.upsert(
2010
        String(interactionRow.responder_operator_id),
2011
        'receipt',
2012
        receipt.receiptId,
2013
        receipt,
2014
      ),
2015
    ]);
2016
    return { released: true as const, conclusion, receipt };
2017
  }
2018
2019
  async submitInteractionFeedback(
2020
    operatorId: string,
2021
    agentId: string,
2022
    value: InteractionFeedback,
2023
    submissionMethod: 'oauth_installation' | 'agent_access_token' | 'runtime_session',
2024
  ) {
2025
    const feedback = InteractionFeedbackSchema.parse(value);
2026
    if (feedback.platformAttestation)
2027
      throw new Error('Platform attestation is assigned by OpenClasp');
2028
    if (feedback.reviewerAgentId !== agentId)
2029
      throw new Error('Feedback reviewer does not match the authenticated agent');
2030
    const participant = await this.interactionParticipant(
2031
      operatorId,
2032
      feedback.interactionId,
2033
      agentId,
2034
    );
2035
    if (feedback.subjectAgentId !== participant.counterpartyAgentId)
2036
      throw new Error('Feedback subject does not match the interaction counterparty');
2037
    const requestRows = await this.sql`
2038
      SELECT payload FROM openclasp_records
2039
      WHERE operator_id = ${operatorId}
2040
        AND kind = 'feedback_request'
2041
        AND record_id = ${feedback.requestId}
2042
      LIMIT 1
2043
    `;
2044
    const request = FeedbackRequestSchema.parse(requestRows[0]?.payload);
2045
    if (
2046
      request.interactionId !== feedback.interactionId ||
2047
      request.reviewerAgentId !== feedback.reviewerAgentId ||
2048
      request.subjectAgentId !== feedback.subjectAgentId
2049
    )
2050
      throw new Error('Feedback does not match its request');
2051
    if (request.status !== 'pending') throw new Error('Feedback request is no longer pending');
2052
    if (Date.parse(request.dueAt) <= Date.now()) throw new Error('Feedback request has expired');
2053
    if (
2054
      request.requestedDimensions.some(
2055
        (dimension) => typeof feedback.ratings[dimension] !== 'number',
2056
      )
2057
    )
2058
      throw new Error('Feedback must rate every requested dimension');
2059
    const profiles = await this.sql`
2060
      SELECT payload FROM openclasp_records
2061
      WHERE operator_id = ${operatorId} AND kind = 'agent_profile' AND record_id = ${agentId}
2062
      LIMIT 1
2063
    `;
2064
    const profile = profiles[0]?.payload as Partial<AgentProfile> | undefined;
2065
    if (!profile || profile.agentVersion !== feedback.reviewerAgentVersion)
2066
      throw new Error('Feedback reviewer version does not match the registered agent');
2067
    let verifiedSubmissionMethod: InteractionFeedback['submissionMethod'] = submissionMethod;
2068
    if (feedback.signature) {
2069
      if (feedback.submissionMethod !== 'agent_signature')
2070
        throw new Error('Signed feedback must declare agent_signature submission');
2071
      const identities = await this.sql`
2072
        SELECT payload FROM openclasp_records
2073
        WHERE operator_id = ${operatorId} AND kind = 'agent' AND record_id = ${agentId}
2074
        LIMIT 1
2075
      `;
2076
      const identity = AgentIdentitySchema.safeParse(identities[0]?.payload);
2077
      if (
2078
        !identity.success ||
2079
        !verifyObject(feedback as unknown as Record<string, unknown>, identity.data.publicKey)
2080
      )
2081
        throw new Error('Feedback agent signature is invalid or unverifiable');
2082
      verifiedSubmissionMethod = 'agent_signature';
2083
    }
2084
    const unattested = InteractionFeedbackSchema.parse({
2085
      ...feedback,
2086
      submissionMethod: verifiedSubmissionMethod,
2087
    });
2088
    const stored = InteractionFeedbackSchema.parse({
2089
      ...unattested,
2090
      platformAttestation: attestSessionRecord(this.gatewaySecret(), unattested),
2091
    });
2092
    const existing = await this.sql`
2093
      SELECT payload FROM openclasp_records
2094
      WHERE operator_id = ${operatorId}
2095
        AND kind = 'interaction_feedback'
2096
        AND record_id = ${stored.feedbackId}
2097
      LIMIT 1
2098
    `;
2099
    if (existing[0] && canonicalHash(existing[0].payload) !== canonicalHash(stored))
2100
      throw new Error('Conflicting feedback ID');
2101
    await this.upsert(operatorId, 'interaction_feedback', stored.feedbackId, stored);
2102
    const requestBase = { ...request, status: 'submitted' as const };
2103
    delete requestBase.platformAttestation;
2104
    const updatedRequest = FeedbackRequestSchema.parse({
2105
      ...requestBase,
2106
      platformAttestation: attestSessionRecord(this.gatewaySecret(), requestBase),
2107
    });
2108
    await this.upsert(operatorId, 'feedback_request', request.requestId, updatedRequest);
2109
    const release = await this.finalizeInteractionConclusion(feedback.interactionId);
2110
    return {
2111
      feedbackId: stored.feedbackId,
2112
      status: 'submitted' as const,
2113
      revealed: release.released,
2114
      ...(release.released ? { conclusion: release.conclusion } : {}),
2115
    };
2116
  }
2117
2118
  async processDueFeedback(now = new Date()) {
2119
    await this.ensureSchema();
2120
    const rows = await this.sql`
2121
      SELECT operator_id, record_id, payload FROM openclasp_records
2122
      WHERE kind = 'feedback_request'
2123
        AND payload->>'status' = 'pending'
2124
        AND (payload->>'dueAt')::timestamptz <= ${now.toISOString()}
2125
      LIMIT 500
2126
    `;
2127
    const interactions = new Set<string>();
2128
    for (const row of rows) {
2129
      const request = FeedbackRequestSchema.parse(row.payload);
2130
      const base = { ...request, status: 'expired' as const };
2131
      delete base.platformAttestation;
2132
      const expired = FeedbackRequestSchema.parse({
2133
        ...base,
2134
        platformAttestation: attestSessionRecord(this.gatewaySecret(), base),
2135
      });
2136
      await this.upsert(
2137
        String(row.operator_id),
2138
        'feedback_request',
2139
        String(row.record_id),
2140
        expired,
2141
      );
2142
      interactions.add(request.interactionId);
2143
    }
2144
    let released = 0;
2145
    for (const interactionId of interactions) {
2146
      const result = await this.finalizeInteractionConclusion(interactionId);
2147
      if (result.released) released += 1;
2148
    }
2149
    return { expired: rows.length, released };
2150
  }
2151
2152
  async recordSessionFeedback(token: string, value: InteractionFeedback) {
2153
    const feedback = InteractionFeedbackSchema.parse(value);
2154
    const grant = verifySessionGrant(this.gatewaySecret(), token);
2155
    if (
2156
      grant.interactionId !== feedback.interactionId ||
2157
      grant.senderAgentId !== feedback.reviewerAgentId ||
2158
      grant.recipientAgentId !== feedback.subjectAgentId
2159
    )
2160
      throw new Error('Session credential does not match the feedback');
2161
    const owners = await this.sql`
2162
      SELECT initiator_operator_id, responder_operator_id,
2163
        initiator_agent_id, responder_agent_id
2164
      FROM openclasp_federated_interactions
2165
      WHERE interaction_id = ${feedback.interactionId}
2166
      LIMIT 1
2167
    `;
2168
    const row = owners[0];
2169
    if (!row) throw new Error('Interaction not found');
2170
    const operatorId = String(
2171
      row.initiator_agent_id === feedback.reviewerAgentId
2172
        ? row.initiator_operator_id
2173
        : row.responder_operator_id,
2174
    );
2175
    return this.submitInteractionFeedback(
2176
      operatorId,
2177
      feedback.reviewerAgentId,
2178
      feedback,
2179
      'runtime_session',
2180
    );
2181
  }
2182
2183
  private async ownedTemporaryAgent(operatorId: string, agentId: string) {
2184
    const rows = await this.sql`
2185
      SELECT payload FROM openclasp_records
2186
      WHERE operator_id = ${operatorId} AND kind = 'agent_profile' AND record_id = ${agentId}
2187
      LIMIT 1
2188
    `;
2189
    const profile = rows[0]?.payload as Partial<AgentProfile> | undefined;
2190
    if (!profile) throw new Error('Agent is not owned by this account');
2191
    const mode =
2192
      profile.agentMode ?? (profile.a2aEndpoint ? 'persistent_runtime' : 'temporary_chat');
2193
    if (mode !== 'temporary_chat')
2194
      throw new Error('Hosted conversations are available only to temporary chat agents');
2195
    return profile;
2196
  }
2197
2198
  private hostedMessage(row: Record<string, any>): HostedMessage {
2199
    const decrypted = decryptGatewayPayload(this.gatewaySecret(), {
2200
      ciphertext: String(row.content_ciphertext),
2201
      iv: String(row.content_iv),
2202
      authTag: String(row.content_auth_tag),
2203
    }) as { content?: unknown };
2204
    return HostedMessageSchema.parse({
2205
      messageId: String(row.message_id),
2206
      threadId: String(row.thread_id),
2207
      interactionId: String(row.interaction_id),
2208
      senderAgentId: String(row.sender_agent_id),
2209
      recipientAgentId: String(row.recipient_agent_id),
2210
      contentType: 'text/plain',
2211
      content: decrypted.content,
2212
      contentHash: String(row.content_hash),
2213
      delivery: row.delivery,
2214
      createdAt: new Date(String(row.created_at)).toISOString(),
2215
      ...(row.read_at ? { readAt: new Date(String(row.read_at)).toISOString() } : {}),
2216
    });
2217
  }
2218
2219
  private async storeHostedMessage(input: {
2220
    interactionId: string;
2221
    senderAgentId: string;
2222
    recipientAgentId: string;
2223
    requestKey: string;
2224
    content: string;
2225
    delivery: 'accepted' | 'delivered';
2226
  }) {
2227
    const content = input.content.trim();
2228
    if (!content || content.length > 20_000)
2229
      throw new Error('Message must contain 1-20000 characters');
2230
    const sessions = await this.sql`
2231
      SELECT session.status, interaction.status AS interaction_status,
2232
        session.initiator_agent_id, session.responder_agent_id
2233
      FROM openclasp_live_sessions session
2234
      INNER JOIN openclasp_federated_interactions interaction
2235
        ON interaction.interaction_id = session.interaction_id
2236
      WHERE session.interaction_id = ${input.interactionId}
2237
      LIMIT 1
2238
    `;
2239
    const session = sessions[0];
2240
    if (!session || session.status !== 'active' || session.interaction_status !== 'active')
2241
      throw new Error('Active interaction not found');
2242
    const participants = new Set([
2243
      String(session.initiator_agent_id),
2244
      String(session.responder_agent_id),
2245
    ]);
2246
    if (!participants.has(input.senderAgentId) || !participants.has(input.recipientAgentId))
2247
      throw new Error('Message participants do not match the active interaction');
2248
    const now = new Date();
2249
    const threadId = input.interactionId;
2250
    const expiresAt = new Date(now.getTime() + 30 * 86_400_000).toISOString();
2251
    await this.sql`
2252
      INSERT INTO openclasp_hosted_threads(
2253
        thread_id, interaction_id, participant_a_agent_id, participant_b_agent_id,
2254
        status, expires_at
2255
      ) VALUES (
2256
        ${threadId}, ${input.interactionId}, ${String(session.initiator_agent_id)},
2257
        ${String(session.responder_agent_id)}, 'open', ${expiresAt}
2258
      )
2259
      ON CONFLICT (thread_id) DO UPDATE SET updated_at = NOW()
2260
      WHERE openclasp_hosted_threads.status = 'open'
2261
    `;
2262
    const thread = await this.sql`
2263
      SELECT status, expires_at FROM openclasp_hosted_threads WHERE thread_id = ${threadId}
2264
    `;
2265
    if (thread[0]?.status !== 'open') throw new Error('Hosted thread is closed');
2266
    if (Date.parse(String(thread[0].expires_at)) <= Date.now())
2267
      throw new Error('Hosted thread has expired');
2268
    const encrypted = encryptGatewayPayload(this.gatewaySecret(), { content });
2269
    const messageId = crypto.randomUUID();
2270
    const contentHash = canonicalHash(content);
2271
    const inserted = await this.sql`
2272
      INSERT INTO openclasp_hosted_messages(
2273
        message_id, thread_id, interaction_id, sender_agent_id, recipient_agent_id,
2274
        request_key, content_ciphertext, content_iv, content_auth_tag, content_hash, delivery
2275
      ) VALUES (
2276
        ${messageId}, ${threadId}, ${input.interactionId}, ${input.senderAgentId},
2277
        ${input.recipientAgentId}, ${input.requestKey}, ${encrypted.ciphertext}, ${encrypted.iv},
2278
        ${encrypted.authTag}, ${contentHash}, ${input.delivery}
2279
      )
2280
      ON CONFLICT (thread_id, sender_agent_id, request_key) DO NOTHING
2281
      RETURNING *
2282
    `;
2283
    if (!inserted[0]) {
2284
      const existing = await this.sql`
2285
        SELECT * FROM openclasp_hosted_messages
2286
        WHERE thread_id = ${threadId} AND sender_agent_id = ${input.senderAgentId}
2287
          AND request_key = ${input.requestKey}
2288
        LIMIT 1
2289
      `;
2290
      return { message: this.hostedMessage(existing[0]!), deduplicated: true };
2291
    }
2292
    const sequenceRows = await this.sql`
2293
      SELECT COALESCE(MAX(sequence), -1) + 1 AS sequence
2294
      FROM openclasp_live_session_events
2295
      WHERE interaction_id = ${input.interactionId} AND agent_id = ${input.senderAgentId}
2296
    `;
2297
    const event = LiveSessionEventSchema.parse({
2298
      eventId: crypto.randomUUID(),
2299
      interactionId: input.interactionId,
2300
      agentId: input.senderAgentId,
2301
      sequence: Number(sequenceRows[0]?.sequence ?? 0),
2302
      type: 'message_sent',
2303
      occurredAt: now.toISOString(),
2304
      messageHash: contentHash,
2305
      evidenceReferences: [],
2306
      details: { labels: ['hosted_temporary'], metrics: {}, flags: { rawStoredEncrypted: true } },
2307
    });
2308
    const eventWithAttestation = JSON.stringify({
2309
      ...event,
2310
      attestation: attestSessionRecord(this.gatewaySecret(), event),
2311
    });
2312
    await this.sql`
2313
      INSERT INTO openclasp_live_session_events(interaction_id, event_id, agent_id, sequence, event)
2314
      VALUES (${event.interactionId}, ${event.eventId}, ${event.agentId}, ${event.sequence}, ${eventWithAttestation}::jsonb)
2315
      ON CONFLICT DO NOTHING
2316
    `;
2317
    return { message: this.hostedMessage(inserted[0]), deduplicated: false };
2318
  }
2319
2320
  async receiveTemporaryMessage(
2321
    token: string,
2322
    recipientAgentId: string,
2323
    requestKey: string,
2324
    content: string,
2325
  ) {
2326
    await this.ensureSchema();
2327
    const grant = verifySessionGrant(this.gatewaySecret(), token);
2328
    if (grant.recipientAgentId !== recipientAgentId)
2329
      throw new Error('Session credential is not valid for this temporary agent');
2330
    const recipient = await this.sessionParticipant(recipientAgentId);
2331
    if (recipient.mode !== 'temporary_chat')
2332
      throw new Error('Target is not a temporary chat agent');
2333
    const stored = await this.storeHostedMessage({
2334
      interactionId: grant.interactionId,
2335
      senderAgentId: grant.senderAgentId,
2336
      recipientAgentId,
2337
      requestKey,
2338
      content,
2339
      delivery: 'delivered',
2340
    });
2341
    return stored;
2342
  }
2343
2344
  async sendTemporaryMessage(
2345
    operatorId: string,
2346
    senderAgentId: string,
2347
    interactionId: string,
2348
    content: string,
2349
  ) {
2350
    await this.ensureSchema();
2351
    await this.ownedTemporaryAgent(operatorId, senderAgentId);
2352
    const session = await this.getLiveSession(operatorId, interactionId, senderAgentId);
2353
    const peer = await this.sessionParticipant(session.peer.agentId);
2354
    if (peer.mode !== 'persistent_runtime')
2355
      throw new Error('Temporary-to-temporary hosted conversations are not supported');
2356
    const requestKey = crypto.randomUUID();
2357
    const stored = await this.storeHostedMessage({
2358
      interactionId,
2359
      senderAgentId,
2360
      recipientAgentId: session.peer.agentId,
2361
      requestKey,
2362
      content,
2363
      delivery: 'accepted',
2364
    });
2365
    const response = await postRuntimeJson(
2366
      session.peer.endpoint,
2367
      {
2368
        jsonrpc: '2.0',
2369
        id: requestKey,
2370
        method: 'message/send',
2371
        params: {
2372
          message: {
2373
            role: 'user',
2374
            parts: [{ kind: 'text', text: content.trim() }],
2375
            metadata: {
2376
              [DEFAULT_EXTENSION_URI]: {
2377
                interactionId,
2378
                termsHash: session.contractHash,
2379
                initiatorAgentId:
2380
                  session.role === 'initiator' ? senderAgentId : session.peer.agentId,
2381
                responderAgentId:
2382
                  session.role === 'responder' ? senderAgentId : session.peer.agentId,
2383
              },
2384
            },
2385
          },
2386
        },
2387
      },
2388
      {
2389
        authorization: `Bearer ${session.peer.bearerToken}`,
2390
        'A2A-Extensions': DEFAULT_EXTENSION_URI,
2391
      },
2392
    );
2393
    if (response.status < 200 || response.status >= 300)
2394
      throw new Error(`Peer A2A endpoint returned HTTP ${response.status}`);
2395
    await this.sql`
2396
      UPDATE openclasp_hosted_messages SET delivery = 'delivered'
2397
      WHERE message_id = ${stored.message.messageId}
2398
    `;
2399
    return {
2400
      ...stored,
2401
      message: { ...stored.message, delivery: 'delivered' as const },
2402
      peerResponse: response.body,
2403
    };
2404
  }
2405
2406
  async listHostedThreads(operatorId: string, agentId: string): Promise<HostedThread[]> {
2407
    await this.ensureSchema();
2408
    await this.ownedTemporaryAgent(operatorId, agentId);
2409
    await this.sql`DELETE FROM openclasp_hosted_threads WHERE expires_at <= NOW()`;
2410
    const rows = await this.sql`
2411
      SELECT thread.*,
2412
        (SELECT COUNT(*) FROM openclasp_hosted_messages message
2413
          WHERE message.thread_id = thread.thread_id
2414
            AND message.recipient_agent_id = ${agentId} AND message.read_at IS NULL) AS unread_count
2415
      FROM openclasp_hosted_threads thread
2416
      WHERE ${agentId} IN (thread.participant_a_agent_id, thread.participant_b_agent_id)
2417
      ORDER BY thread.updated_at DESC
2418
    `;
2419
    return rows.map((row) =>
2420
      HostedThreadSchema.parse({
2421
        threadId: String(row.thread_id),
2422
        interactionId: String(row.interaction_id),
2423
        participantAgentIds: [
2424
          String(row.participant_a_agent_id),
2425
          String(row.participant_b_agent_id),
2426
        ],
2427
        status: row.status,
2428
        privacyMode: 'openclasp_hosted_temporary',
2429
        unreadCount: Number(row.unread_count ?? 0),
2430
        createdAt: new Date(String(row.created_at)).toISOString(),
2431
        updatedAt: new Date(String(row.updated_at)).toISOString(),
2432
        expiresAt: new Date(String(row.expires_at)).toISOString(),
2433
      }),
2434
    );
2435
  }
2436
2437
  async getHostedThread(operatorId: string, agentId: string, threadId: string) {
2438
    const threads = await this.listHostedThreads(operatorId, agentId);
2439
    const thread = threads.find((value) => value.threadId === threadId);
2440
    if (!thread) throw new Error('Hosted thread not found');
2441
    const rows = await this.sql`
2442
      SELECT * FROM openclasp_hosted_messages
2443
      WHERE thread_id = ${threadId} ORDER BY created_at ASC
2444
    `;
2445
    const peerAgentId = thread.participantAgentIds.find((value) => value !== agentId);
2446
    if (!peerAgentId) throw new Error('Hosted thread has invalid participants');
2447
    const peer = await this.sessionParticipant(peerAgentId);
2448
    const interactions = await this.sql`
2449
      SELECT payload FROM openclasp_federated_interactions
2450
      WHERE interaction_id = ${thread.interactionId} LIMIT 1
2451
    `;
2452
    const interaction = FederatedInteractionSchema.parse(interactions[0]?.payload);
2453
    const insights = await this.privateCounterpartyInsights(
2454
      operatorId,
2455
      peer,
2456
      interaction.contract.taskCategory,
2457
    );
2458
    return { thread, messages: rows.map((row) => this.hostedMessage(row)), insights };
2459
  }
2460
2461
  async markHostedThreadRead(operatorId: string, agentId: string, threadId: string) {
2462
    await this.getHostedThread(operatorId, agentId, threadId);
2463
    const rows = await this.sql`
2464
      UPDATE openclasp_hosted_messages
2465
      SET delivery = 'read', read_at = COALESCE(read_at, NOW())
2466
      WHERE thread_id = ${threadId} AND recipient_agent_id = ${agentId} AND read_at IS NULL
2467
      RETURNING message_id
2468
    `;
2469
    return { threadId, markedRead: rows.length };
2470
  }
2471
2472
  async closeHostedThread(operatorId: string, agentId: string, threadId: string) {
2473
    await this.getHostedThread(operatorId, agentId, threadId);
2474
    await this.sql`
2475
      UPDATE openclasp_hosted_threads SET status = 'closed', updated_at = NOW()
2476
      WHERE thread_id = ${threadId}
2477
    `;
2478
    return { threadId, status: 'closed' as const };
2479
  }
2480
2481
  async recordLiveSessionEvent(token: string, value: LiveSessionEvent) {
2482
    await this.ensureSchema();
2483
    const event = LiveSessionEventSchema.parse(value);
2484
    const grant = verifySessionGrant(this.gatewaySecret(), token);
2485
    if (grant.interactionId !== event.interactionId || grant.senderAgentId !== event.agentId)
2486
      throw new Error('Session credential does not match the event');
2487
    const sessions = await this.sql`
2488
      SELECT 1 FROM openclasp_live_sessions
2489
      WHERE interaction_id = ${event.interactionId}
2490
        AND status = 'active'
2491
        AND ${event.agentId} IN (initiator_agent_id, responder_agent_id)
2492
    `;
2493
    if (!sessions.length) throw new Error('Active live session not found');
2494
    const attestation = attestSessionRecord(this.gatewaySecret(), event);
2495
    const encoded = JSON.stringify({ ...event, attestation });
2496
    const rows = await this.sql`
2497
      INSERT INTO openclasp_live_session_events(
2498
        interaction_id, event_id, agent_id, sequence, event
2499
      ) SELECT ${event.interactionId}, ${event.eventId}, ${event.agentId}, ${event.sequence}, ${encoded}::jsonb
2500
      WHERE (
2501
        SELECT COUNT(*) FROM openclasp_live_session_events
2502
        WHERE interaction_id = ${event.interactionId}
2503
      ) < 1000
2504
      ON CONFLICT DO NOTHING
2505
      RETURNING event_id
2506
    `;
2507
    if (event.type === 'session_completed' || event.type === 'session_failed') {
2508
      const terminal = await this.sql`
2509
        SELECT COUNT(DISTINCT agent_id) AS count
2510
        FROM openclasp_live_session_events
2511
        WHERE interaction_id = ${event.interactionId}
2512
          AND event->>'type' IN ('session_completed', 'session_failed')
2513
      `;
2514
      if (Number(terminal[0]?.count ?? 0) >= 2) {
2515
        await this.sql`
2516
          UPDATE openclasp_live_sessions SET status = 'completed', completed_at = NOW()
2517
          WHERE interaction_id = ${event.interactionId} AND status = 'active'
2518
        `;
2519
        const completedAt = new Date().toISOString();
2520
        await this.sql`
2521
          UPDATE openclasp_federated_interactions
2522
          SET status = 'completed',
2523
            payload = jsonb_set(
2524
              jsonb_set(payload, '{status}', '"completed"'::jsonb),
2525
              '{updatedAt}', to_jsonb(${completedAt}::text)
2526
            ),
2527
            updated_at = NOW()
2528
          WHERE interaction_id = ${event.interactionId} AND status = 'active'
2529
        `;
2530
      }
2531
    }
2532
    return {
2533
      recorded: rows.length > 0,
2534
      deduplicated: rows.length === 0,
2535
      eventId: event.eventId,
2536
      attestation,
2537
    };
2538
  }
2539
2540
  async createFederatedInteraction(
2541
    operatorId: string,
2542
    value: FederatedInteraction,
2543
  ): Promise<FederatedInteraction> {
2544
    await this.ensureSchema();
2545
    const interaction = FederatedInteractionSchema.parse(value);
2546
    if (interaction.status !== 'pending') throw new Error('New interactions must be pending');
2547
    if (interaction.contract.interactionId !== interaction.interactionId)
2548
      throw new Error('Contract interaction ID does not match');
2549
    if (canonicalHash(interaction.contract) !== interaction.termsHash)
2550
      throw new Error('Contract hash does not match the immutable terms');
2551
    if (
2552
      interaction.contract.parties.length !== 2 ||
2553
      interaction.contract.parties[0] !== interaction.initiatorAgentId ||
2554
      interaction.contract.parties[1] !== interaction.responderAgentId
2555
    )
2556
      throw new Error('Contract parties do not match the interaction participants');
2557
    const acceptanceEntries = Object.entries(interaction.acceptances);
2558
    if (
2559
      acceptanceEntries.length !== 1 ||
2560
      acceptanceEntries[0]?.[0] !== interaction.initiatorAgentId ||
2561
      acceptanceEntries[0]?.[1].agentId !== interaction.initiatorAgentId ||
2562
      acceptanceEntries[0]?.[1].termsHash !== interaction.termsHash ||
2563
      !['oauth_installation', 'oauth_account'].includes(acceptanceEntries[0]?.[1].method ?? '')
2564
    )
2565
      throw new Error('A pending interaction requires only the initiator acceptance');
2566
    if (Date.parse(interaction.expiresAt) <= Date.now())
2567
      throw new Error('Interaction expiry must be in the future');
2568
    const owners = await this.sql`
2569
      SELECT agent_id, operator_id, card FROM openclasp_public_agents
2570
      WHERE agent_id IN (${interaction.initiatorAgentId}, ${interaction.responderAgentId})
2571
    `;
2572
    const initiator = owners.find((row) => row.agent_id === interaction.initiatorAgentId);
2573
    const responder = owners.find((row) => row.agent_id === interaction.responderAgentId);
2574
    if (!initiator || initiator.operator_id !== operatorId)
2575
      throw new Error('The initiating agent must be published and owned by this account');
2576
    if (!responder) throw new Error('Responder is not a published OpenClasp agent');
2577
    if (responder.operator_id === operatorId)
2578
      throw new Error('Federated interactions require agents from different accounts');
2579
    const responderCard = normalizePublicAgentCard(responder.card);
2580
    if (
2581
      !responderCard.transports.some(
2582
        (transport) =>
2583
          transport.endpoint === interaction.responderTransport.endpoint &&
2584
          transport.protocolBinding === interaction.responderTransport.protocolBinding,
2585
      )
2586
    )
2587
      throw new Error('Responder transport does not match its published Agent Card');
2588
    const encoded = JSON.stringify(interaction);
2589
    const rows = await this.sql`
2590
      INSERT INTO openclasp_federated_interactions(
2591
        interaction_id, initiator_operator_id, responder_operator_id,
2592
        initiator_agent_id, responder_agent_id, status, payload, expires_at
2593
      ) VALUES (
2594
        ${interaction.interactionId}, ${operatorId}, ${responder.operator_id},
2595
        ${interaction.initiatorAgentId}, ${interaction.responderAgentId},
2596
        ${interaction.status}, ${encoded}::jsonb, ${interaction.expiresAt}
2597
      )
2598
      RETURNING payload
2599
    `;
2600
    const stored = FederatedInteractionSchema.parse(rows[0]?.payload);
2601
    const profiles = await this.sql`
2602
      SELECT payload FROM openclasp_records
2603
      WHERE operator_id = ${responder.operator_id}
2604
        AND kind = 'agent_profile'
2605
        AND record_id = ${interaction.responderAgentId}
2606
      LIMIT 1
2607
    `;
2608
    const responderProfile = profiles[0]?.payload as AgentProfile | undefined;
2609
    if (responderProfile && canAutoAcceptInteraction(responderProfile, stored))
2610
      return this.respondToFederatedInteraction(
2611
        String(responder.operator_id),
2612
        stored.interactionId,
2613
        stored.responderAgentId,
2614
        'accept',
2615
        'policy_auto_accept',
2616
      );
2617
    return stored;
2618
  }
2619
2620
  async listFederatedInteractions(operatorId: string): Promise<FederatedInteraction[]> {
2621
    await this.ensureSchema();
2622
    await this.sql`
2623
      UPDATE openclasp_federated_interactions
2624
      SET status = 'expired', payload = jsonb_set(payload, '{status}', '"expired"'::jsonb), updated_at = NOW()
2625
      WHERE status = 'pending' AND expires_at <= NOW()
2626
    `;
2627
    const rows = await this.sql`
2628
      SELECT payload FROM openclasp_federated_interactions
2629
      WHERE initiator_operator_id = ${operatorId} OR responder_operator_id = ${operatorId}
2630
      ORDER BY updated_at DESC
2631
    `;
2632
    return rows.map((row) => FederatedInteractionSchema.parse(row.payload));
2633
  }
2634
2635
  async getFederatedInteraction(
2636
    operatorId: string,
2637
    interactionId: string,
2638
  ): Promise<FederatedInteraction | undefined> {
2639
    const values = await this.listFederatedInteractions(operatorId);
2640
    return values.find((value) => value.interactionId === interactionId);
2641
  }
2642
2643
  async respondToFederatedInteraction(
2644
    operatorId: string,
2645
    interactionId: string,
2646
    agentId: string,
2647
    decision: 'accept' | 'reject',
2648
    method: 'oauth_installation' | 'oauth_account' | 'policy_auto_accept' = 'oauth_account',
2649
  ): Promise<FederatedInteraction> {
2650
    await this.ensureSchema();
2651
    const rows = await this.sql`
2652
      SELECT payload, responder_operator_id, responder_agent_id
2653
      FROM openclasp_federated_interactions WHERE interaction_id = ${interactionId}
2654
    `;
2655
    const row = rows[0];
2656
    if (!row) throw new Error('Interaction invitation not found');
2657
    if (row.responder_operator_id !== operatorId || row.responder_agent_id !== agentId)
2658
      throw new Error('Only the invited agent may respond');
2659
    const current = FederatedInteractionSchema.parse(row.payload);
2660
    if (current.status !== 'pending') throw new Error('Invitation is no longer pending');
2661
    const now = new Date().toISOString();
2662
    if (Date.parse(current.expiresAt) <= Date.now()) {
2663
      const expired = { ...current, status: 'expired' as const, updatedAt: now };
2664
      await this.updateFederatedInteraction(interactionId, current.status, expired);
2665
      throw new Error('Invitation has expired');
2666
    }
2667
    const next: FederatedInteraction =
2668
      decision === 'reject'
2669
        ? { ...current, status: 'rejected', updatedAt: now }
2670
        : {
2671
            ...current,
2672
            status: 'active',
2673
            updatedAt: now,
2674
            acceptances: {
2675
              ...current.acceptances,
2676
              [agentId]: {
2677
                agentId,
2678
                method,
2679
                termsHash: current.termsHash,
2680
                acceptedAt: now,
2681
              },
2682
            },
2683
          };
2684
    if (decision === 'accept') {
2685
      try {
2686
        await this.brokerLiveSession(next);
2687
      } catch (error) {
2688
        const reason = error instanceof Error ? error.message.slice(0, 500) : 'Live session failed';
2689
        await this.sql`
2690
          UPDATE openclasp_agent_runtimes SET last_error = ${reason}, updated_at = NOW()
2691
          WHERE agent_id IN (${current.initiatorAgentId}, ${current.responderAgentId})
2692
        `;
2693
        throw error;
2694
      }
2695
    }
2696
    const updated = await this.updateFederatedInteraction(interactionId, current.status, next);
2697
    if (!updated) throw new Error('Invitation was already handled');
2698
    return next;
2699
  }
2700
2701
  private async updateFederatedInteraction(
2702
    interactionId: string,
2703
    expectedStatus: string,
2704
    value: FederatedInteraction,
2705
  ): Promise<boolean> {
2706
    const encoded = JSON.stringify(FederatedInteractionSchema.parse(value));
2707
    const rows = await this.sql`
2708
      UPDATE openclasp_federated_interactions
2709
      SET status = ${value.status}, payload = ${encoded}::jsonb, updated_at = NOW()
2710
      WHERE interaction_id = ${interactionId} AND status = ${expectedStatus}
2711
      RETURNING interaction_id
2712
    `;
2713
    return rows.length > 0;
2714
  }
2715
2716
  async getSettings(operatorId: string): Promise<AccountSettings> {
2717
    await this.ensureSchema();
2718
    const rows = await this.sql`
2719
      SELECT display_name, contribution_enabled, retention_days, evidence_sharing
2720
      FROM openclasp_account_settings
2721
      WHERE operator_id = ${operatorId}
2722
    `;
2723
    const row = rows[0] as
2724
      | {
2725
          display_name: string;
2726
          contribution_enabled: boolean;
2727
          retention_days: number;
2728
          evidence_sharing: AccountSettings['evidenceSharing'];
2729
        }
2730
      | undefined;
2731
    return row
2732
      ? {
2733
          displayName: row.display_name,
2734
          contributionEnabled: row.contribution_enabled,
2735
          retentionDays: row.retention_days,
2736
          evidenceSharing: row.evidence_sharing,
2737
          rawConversationsStored: false,
2738
        }
2739
      : defaults;
2740
  }
2741
2742
  async saveSettings(
2743
    operatorId: string,
2744
    settings: Omit<AccountSettings, 'rawConversationsStored'>,
2745
  ): Promise<AccountSettings> {
2746
    await this.ensureSchema();
2747
    await this.sql`
2748
      INSERT INTO openclasp_account_settings(
2749
        operator_id, display_name, contribution_enabled, retention_days, evidence_sharing
2750
      ) VALUES (
2751
        ${operatorId}, ${settings.displayName}, ${settings.contributionEnabled},
2752
        ${settings.retentionDays}, ${settings.evidenceSharing}
2753
      )
2754
      ON CONFLICT (operator_id) DO UPDATE SET
2755
        display_name = EXCLUDED.display_name,
2756
        contribution_enabled = EXCLUDED.contribution_enabled,
2757
        retention_days = EXCLUDED.retention_days,
2758
        evidence_sharing = EXCLUDED.evidence_sharing,
2759
        updated_at = NOW()
2760
    `;
2761
    return { ...settings, rawConversationsStored: false };
2762
  }
2763
}