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

Viewing files at commit 5b7999fcbb0cCommit detailsView latest

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