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

Viewing files at commit 296bb55574a2Commit detailsView latest

packages/persistence/src/hosted.ts 1425 lines · 54.6 KB · typescript
1
import { neon, type NeonQueryFunction } from '@neondatabase/serverless';
2
import { randomBytes } from 'node:crypto';
3
import {
4
  DEFAULT_EXTENSION_URI,
5
  FederatedInteractionSchema,
6
  LiveSessionAcceptanceSchema,
7
  LiveSessionActivationSchema,
8
  LiveSessionEventSchema,
9
  LiveSessionOfferSchema,
10
  PublicAgentCardSchema,
11
  canonicalHash,
12
  type FederatedInteraction,
13
  type AgentPresence,
14
  type LiveSessionActivation,
15
  type LiveSessionEvent,
16
  type LiveSessionInsight,
17
  type PublicAgentCard,
18
} from '../../protocol/src/index.js';
19
import type { AgentProfile } from './onboarding.js';
20
import {
21
  encryptGatewayPayload,
22
  attestSessionRecord,
23
  getSessionKeyId,
24
  getSessionVerificationKey,
25
  issueSessionGrant,
26
  signSessionControl,
27
  verifySessionGrant,
28
} from './relay.js';
29
import { postRuntimeJson, resolvePublicRuntimeEndpoint } from './runtime.js';
30
31
export type HostedRecordKind =
32
  | 'agent'
33
  | 'delegation'
34
  | 'contract'
35
  | 'interaction'
36
  | 'event'
37
  | 'receipt'
38
  | 'feedback'
39
  | 'conflict'
40
  | 'profile'
41
  | 'consent'
42
  | 'revocation'
43
  | 'project'
44
  | 'agent_profile'
45
  | 'installation'
46
  | 'setup_request'
47
  | 'publication'
48
  | 'presence';
49
50
export type { PublicAgentCard, FederatedInteraction } from '../../protocol/src/index.js';
51
52
type ContextualProfile = {
53
  recordId: string;
54
  agentVersion: string;
55
  sampleSize: number;
56
  updatedAt: string;
57
  completion: number;
58
  acceptance: number;
59
  specification: number;
60
  deadline: number;
61
  communication: number;
62
  evidence: number;
63
  scope: number;
64
  disputes: number;
65
};
66
67
function normalizePublicAgentCard(value: unknown): PublicAgentCard {
68
  const current = PublicAgentCardSchema.safeParse(value);
69
  if (current.success) return current.data;
70
  const legacy = value as Record<string, unknown>;
71
  const agentId = String(legacy.agentId ?? '');
72
  const baseUrl = (process.env.OPENCLASP_PUBLIC_URL ?? 'https://openclasp.vercel.app').replace(
73
    /\/$/,
74
    '',
75
  );
76
  return PublicAgentCardSchema.parse({
77
    protocolVersion: '0.1',
78
    agentId,
79
    name: legacy.name,
80
    description: '',
81
    framework: legacy.framework,
82
    agentVersion: '1.0.0',
83
    capabilities: legacy.capabilities,
84
    limitations: legacy.limitations,
85
    assurance: legacy.assurance,
86
    transports:
87
      typeof legacy.a2aEndpoint === 'string'
88
        ? [
89
            {
90
              protocol: 'A2A/1.0',
91
              protocolBinding: 'JSONRPC',
92
              endpoint: legacy.a2aEndpoint,
93
              managedBy: 'agent',
94
            },
95
          ]
96
        : [],
97
    cardUrl: `${baseUrl}/agents/${encodeURIComponent(agentId)}/card.json`,
98
    a2aAgentCardUrl: `${baseUrl}/agents/${encodeURIComponent(agentId)}/a2a-agent-card.json`,
99
    extensionUri: DEFAULT_EXTENSION_URI,
100
    publishedAt: legacy.publishedAt,
101
    updatedAt: legacy.updatedAt,
102
  });
103
}
104
105
export function buildPublicAgentCard(
106
  agent: AgentProfile,
107
  baseUrl: string,
108
  previous?: PublicAgentCard,
109
): PublicAgentCard {
110
  const now = new Date().toISOString();
111
  const root = baseUrl.replace(/\/$/, '');
112
  return PublicAgentCardSchema.parse({
113
    protocolVersion: '0.1',
114
    agentId: agent.agentId,
115
    name: agent.name,
116
    description: agent.description ?? '',
117
    framework: agent.framework,
118
    agentVersion: agent.agentVersion ?? '1.0.0',
119
    capabilities: agent.capabilities,
120
    limitations: agent.limitations,
121
    assurance: 'oauth_authenticated',
122
    transports: agent.a2aEndpoint
123
      ? [
124
          {
125
            protocol: 'A2A/1.0',
126
            protocolBinding: 'JSONRPC',
127
            endpoint: agent.a2aEndpoint,
128
            managedBy: 'agent',
129
          },
130
        ]
131
      : [],
132
    cardUrl: `${root}/agents/${encodeURIComponent(agent.agentId)}/card.json`,
133
    a2aAgentCardUrl: `${root}/agents/${encodeURIComponent(agent.agentId)}/a2a-agent-card.json`,
134
    extensionUri: DEFAULT_EXTENSION_URI,
135
    publishedAt: previous?.publishedAt ?? now,
136
    updatedAt: now,
137
  });
138
}
139
140
const normalized = (value: string) =>
141
  value
142
    .trim()
143
    .toLowerCase()
144
    .replace(/[^a-z0-9]+/g, '_');
145
146
export const AGENT_ONLINE_WINDOW_MS = 2 * 60_000;
147
148
export function resolveAgentPresence(lastSeenAt?: string, now = new Date()): AgentPresence {
149
  const online = Boolean(
150
    lastSeenAt && now.getTime() - Date.parse(lastSeenAt) <= AGENT_ONLINE_WINDOW_MS,
151
  );
152
  return {
153
    status: online ? 'online' : 'offline',
154
    ...(lastSeenAt ? { lastSeenAt } : {}),
155
    checkedAt: now.toISOString(),
156
  };
157
}
158
159
export function canAutoAcceptInteraction(
160
  agent: AgentProfile,
161
  interaction: FederatedInteraction,
162
): boolean {
163
  if (agent.autoAcceptPolicy !== 'safe_matching' || agent.status !== 'active') return false;
164
  const categories = new Set(
165
    [...(agent.autoAcceptTaskCategories ?? []), ...(agent.capabilities ?? [])].map(normalized),
166
  );
167
  const taskCategory = normalized(interaction.contract.taskCategory);
168
  if (!taskCategory || !categories.has(taskCategory)) return false;
169
  if (interaction.contract.allowedData.length > 0) return false;
170
  if (
171
    /\b(password|secret|api[_ -]?key|access[_ -]?token|credential|bank|payment|medical|health|ssn|personal[_ -]?data|customer[_ -]?record)\b/i.test(
172
      `${interaction.contract.purpose} ${interaction.contract.requestedOutcome}`,
173
    )
174
  )
175
    return false;
176
  if (interaction.contract.humanApprovalRequirements.length > 0) return false;
177
  if (interaction.contract.retentionDays > 30) return false;
178
  if (interaction.contract.allowedActions.length > 0) {
179
    const capabilities = new Set((agent.capabilities ?? []).map(normalized));
180
    if (
181
      !interaction.contract.allowedActions.every((action) => capabilities.has(normalized(action)))
182
    )
183
      return false;
184
  }
185
  return true;
186
}
187
188
export type AccountSettings = {
189
  displayName: string;
190
  contributionEnabled: boolean;
191
  retentionDays: number;
192
  evidenceSharing: 'never' | 'ask' | 'contract_only';
193
  rawConversationsStored: false;
194
};
195
196
const defaults: AccountSettings = {
197
  displayName: '',
198
  contributionEnabled: false,
199
  retentionDays: 30,
200
  evidenceSharing: 'ask',
201
  rawConversationsStored: false,
202
};
203
204
export class HostedRepository {
205
  private readonly sql: NeonQueryFunction<false, false>;
206
  private initialized?: Promise<void>;
207
208
  constructor(databaseUrl: string) {
209
    this.sql = neon(databaseUrl);
210
  }
211
212
  private gatewaySecret() {
213
    const value = process.env.OPENCLASP_RELAY_ENCRYPTION_KEY;
214
    if (!value || value.length < 32) throw new Error('Gateway encryption is not configured');
215
    return value;
216
  }
217
218
  ensureSchema(): Promise<void> {
219
    return (this.initialized ??= (async () => {
220
      await this.sql`
221
        CREATE TABLE IF NOT EXISTS openclasp_records (
222
          operator_id TEXT NOT NULL,
223
          kind TEXT NOT NULL,
224
          record_id TEXT NOT NULL,
225
          payload JSONB NOT NULL,
226
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
227
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
228
          PRIMARY KEY (operator_id, kind, record_id)
229
        )
230
      `;
231
      await this.sql`
232
        CREATE INDEX IF NOT EXISTS openclasp_records_operator_created
233
        ON openclasp_records(operator_id, created_at DESC)
234
      `;
235
      await this.sql`
236
        CREATE TABLE IF NOT EXISTS openclasp_account_settings (
237
          operator_id TEXT PRIMARY KEY,
238
          display_name TEXT NOT NULL DEFAULT '',
239
          contribution_enabled BOOLEAN NOT NULL DEFAULT FALSE,
240
          retention_days INTEGER NOT NULL DEFAULT 30 CHECK (retention_days BETWEEN 0 AND 3650),
241
          evidence_sharing TEXT NOT NULL DEFAULT 'ask'
242
            CHECK (evidence_sharing IN ('never', 'ask', 'contract_only')),
243
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
244
        )
245
      `;
246
      await this.sql`
247
        CREATE TABLE IF NOT EXISTS openclasp_public_agents (
248
          agent_id TEXT PRIMARY KEY,
249
          operator_id TEXT NOT NULL,
250
          card JSONB NOT NULL,
251
          published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
252
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
253
        )
254
      `;
255
      await this.sql`
256
        CREATE INDEX IF NOT EXISTS openclasp_public_agents_updated
257
        ON openclasp_public_agents(updated_at DESC)
258
      `;
259
      await this.sql`
260
        CREATE TABLE IF NOT EXISTS openclasp_federated_interactions (
261
          interaction_id TEXT PRIMARY KEY,
262
          initiator_operator_id TEXT NOT NULL,
263
          responder_operator_id TEXT NOT NULL,
264
          initiator_agent_id TEXT NOT NULL,
265
          responder_agent_id TEXT NOT NULL,
266
          status TEXT NOT NULL,
267
          payload JSONB NOT NULL,
268
          expires_at TIMESTAMPTZ NOT NULL,
269
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
270
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
271
        )
272
      `;
273
      await this.sql`
274
        CREATE INDEX IF NOT EXISTS openclasp_federated_interactions_participants
275
        ON openclasp_federated_interactions(initiator_operator_id, responder_operator_id, updated_at DESC)
276
      `;
277
      await this.sql`
278
        CREATE TABLE IF NOT EXISTS openclasp_agent_runtimes (
279
          agent_id TEXT PRIMARY KEY,
280
          operator_id TEXT NOT NULL,
281
          endpoint TEXT NOT NULL,
282
          secret_ciphertext TEXT NOT NULL,
283
          secret_iv TEXT NOT NULL,
284
          secret_auth_tag TEXT NOT NULL,
285
          status TEXT NOT NULL CHECK (status IN ('verified', 'disabled')),
286
          verified_at TIMESTAMPTZ NOT NULL,
287
          last_delivery_at TIMESTAMPTZ,
288
          last_error TEXT,
289
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
290
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
291
        )
292
      `;
293
      await this.sql`
294
        ALTER TABLE openclasp_agent_runtimes
295
        ADD COLUMN IF NOT EXISTS a2a_endpoint TEXT,
296
        ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ
297
      `;
298
      await this.sql`
299
        CREATE TABLE IF NOT EXISTS openclasp_live_sessions (
300
          interaction_id TEXT PRIMARY KEY,
301
          initiator_agent_id TEXT NOT NULL,
302
          responder_agent_id TEXT NOT NULL,
303
          initiator_session_id TEXT NOT NULL,
304
          responder_session_id TEXT NOT NULL,
305
          initiator_endpoint TEXT NOT NULL,
306
          responder_endpoint TEXT NOT NULL,
307
          status TEXT NOT NULL CHECK (status IN ('preparing', 'active', 'completed', 'failed')),
308
          expires_at TIMESTAMPTZ NOT NULL,
309
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
310
          activated_at TIMESTAMPTZ,
311
          completed_at TIMESTAMPTZ,
312
          last_error TEXT
313
        )
314
      `;
315
      await this.sql`
316
        CREATE TABLE IF NOT EXISTS openclasp_live_session_events (
317
          interaction_id TEXT NOT NULL,
318
          event_id TEXT NOT NULL,
319
          agent_id TEXT NOT NULL,
320
          sequence INTEGER NOT NULL,
321
          event JSONB NOT NULL,
322
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
323
          PRIMARY KEY (interaction_id, event_id),
324
          UNIQUE (interaction_id, agent_id, sequence)
325
        )
326
      `;
327
    })());
328
  }
329
330
  getRuntimeVerificationKey() {
331
    return {
332
      algorithm: 'Ed25519' as const,
333
      keyId: getSessionKeyId(this.gatewaySecret()),
334
      publicKey: getSessionVerificationKey(this.gatewaySecret()),
335
    };
336
  }
337
338
  async upsert(
339
    operatorId: string,
340
    kind: HostedRecordKind,
341
    recordId: string,
342
    payload: unknown,
343
  ): Promise<void> {
344
    await this.ensureSchema();
345
    const encoded = JSON.stringify(payload);
346
    await this.sql`
347
      INSERT INTO openclasp_records(operator_id, kind, record_id, payload)
348
      VALUES (${operatorId}, ${kind}, ${recordId}, ${encoded}::jsonb)
349
      ON CONFLICT (operator_id, kind, record_id)
350
      DO UPDATE SET payload = EXCLUDED.payload, updated_at = NOW()
351
    `;
352
  }
353
354
  async list(
355
    operatorId: string,
356
  ): Promise<{ kind: HostedRecordKind; recordId: string; payload: any }[]> {
357
    await this.ensureSchema();
358
    const rows = await this.sql`
359
      SELECT kind, record_id, payload
360
      FROM openclasp_records
361
      WHERE operator_id = ${operatorId}
362
      ORDER BY created_at ASC
363
    `;
364
    return rows.map((row) => ({
365
      kind: row.kind as HostedRecordKind,
366
      recordId: String(row.record_id),
367
      payload: row.payload,
368
    }));
369
  }
370
371
  async dashboard(operatorId: string) {
372
    const rows = await this.list(operatorId);
373
    const [federatedInteractions, runtimes, liveSessionRows, liveEventRows] = await Promise.all([
374
      this.listFederatedInteractions(operatorId),
375
      this.listAgentRuntimes(operatorId),
376
      this.sql`
377
        SELECT session.interaction_id, session.initiator_agent_id, session.responder_agent_id,
378
          session.status, session.created_at, session.activated_at, session.completed_at,
379
          session.expires_at, session.last_error
380
        FROM openclasp_live_sessions session
381
        INNER JOIN openclasp_federated_interactions interaction
382
          ON interaction.interaction_id = session.interaction_id
383
        WHERE interaction.initiator_operator_id = ${operatorId}
384
           OR interaction.responder_operator_id = ${operatorId}
385
        ORDER BY session.created_at DESC
386
      `,
387
      this.sql`
388
        SELECT events.event
389
        FROM openclasp_live_session_events events
390
        INNER JOIN openclasp_federated_interactions interaction
391
          ON interaction.interaction_id = events.interaction_id
392
        WHERE interaction.initiator_operator_id = ${operatorId}
393
           OR interaction.responder_operator_id = ${operatorId}
394
        ORDER BY events.created_at ASC
395
      `,
396
    ]);
397
    const ofKind = (kind: HostedRecordKind) =>
398
      rows.filter((row) => row.kind === kind).map((row) => row.payload);
399
    const presence = new Map(
400
      rows
401
        .filter((row) => row.kind === 'presence')
402
        .map((row) => [row.recordId, String(row.payload.lastSeenAt)]),
403
    );
404
    return {
405
      agents: [...ofKind('agent_profile'), ...ofKind('agent')].map((agent) => ({
406
        ...agent,
407
        presence: resolveAgentPresence(presence.get(String(agent.agentId))),
408
      })),
409
      projects: ofKind('project'),
410
      installations: ofKind('installation'),
411
      setupRequests: ofKind('setup_request'),
412
      publications: ofKind('publication'),
413
      interactions: ofKind('interaction'),
414
      events: [...ofKind('event'), ...liveEventRows.map((row) => row.event)],
415
      conflicts: ofKind('conflict'),
416
      receipts: ofKind('receipt'),
417
      profiles: ofKind('profile'),
418
      federatedInteractions,
419
      liveSessions: liveSessionRows.map((row) => ({
420
        interactionId: String(row.interaction_id),
421
        initiatorAgentId: String(row.initiator_agent_id),
422
        responderAgentId: String(row.responder_agent_id),
423
        status: String(row.status),
424
        createdAt: new Date(String(row.created_at)).toISOString(),
425
        ...(row.activated_at
426
          ? { activatedAt: new Date(String(row.activated_at)).toISOString() }
427
          : {}),
428
        ...(row.completed_at
429
          ? { completedAt: new Date(String(row.completed_at)).toISOString() }
430
          : {}),
431
        expiresAt: new Date(String(row.expires_at)).toISOString(),
432
        ...(row.last_error ? { lastError: String(row.last_error) } : {}),
433
      })),
434
      runtimes,
435
    };
436
  }
437
438
  async publishAgent(operatorId: string, card: PublicAgentCard): Promise<PublicAgentCard> {
439
    await this.ensureSchema();
440
    card = PublicAgentCardSchema.parse(card);
441
    const runtimes = await this.sql`
442
      SELECT a2a_endpoint, endpoint FROM openclasp_agent_runtimes
443
      WHERE operator_id = ${operatorId} AND agent_id = ${card.agentId} AND status = 'verified'
444
    `;
445
    if (runtimes[0]) {
446
      card = PublicAgentCardSchema.parse({
447
        ...card,
448
        transports: [
449
          {
450
            protocol: 'A2A/1.0',
451
            protocolBinding: 'JSONRPC',
452
            endpoint: String(runtimes[0].a2a_endpoint ?? runtimes[0].endpoint),
453
            managedBy: 'agent',
454
          },
455
        ],
456
      });
457
    }
458
    const encoded = JSON.stringify(card);
459
    const rows = await this.sql`
460
      INSERT INTO openclasp_public_agents(agent_id, operator_id, card, published_at, updated_at)
461
      VALUES (${card.agentId}, ${operatorId}, ${encoded}::jsonb, NOW(), NOW())
462
      ON CONFLICT (agent_id) DO UPDATE SET
463
        card = EXCLUDED.card,
464
        updated_at = NOW()
465
      WHERE openclasp_public_agents.operator_id = ${operatorId}
466
      RETURNING card
467
    `;
468
    const published = rows[0]?.card as PublicAgentCard | undefined;
469
    if (!published) throw new Error('Agent ID is already owned by another operator');
470
    return published;
471
  }
472
473
  async unpublishAgent(operatorId: string, agentId: string): Promise<boolean> {
474
    await this.ensureSchema();
475
    const rows = await this.sql`
476
      DELETE FROM openclasp_public_agents
477
      WHERE agent_id = ${agentId} AND operator_id = ${operatorId}
478
      RETURNING agent_id
479
    `;
480
    return rows.length > 0;
481
  }
482
483
  async getPublishedAgent(agentId: string): Promise<PublicAgentCard | undefined> {
484
    await this.ensureSchema();
485
    const rows = await this.sql`
486
      SELECT agents.card, runtime.a2a_endpoint, runtime.endpoint
487
      FROM openclasp_public_agents agents
488
      LEFT JOIN openclasp_agent_runtimes runtime
489
        ON runtime.agent_id = agents.agent_id AND runtime.status = 'verified'
490
      WHERE agents.agent_id = ${agentId}
491
    `;
492
    if (!rows[0]?.card) return undefined;
493
    let card = normalizePublicAgentCard(rows[0].card);
494
    if (rows[0].a2a_endpoint || rows[0].endpoint)
495
      card = PublicAgentCardSchema.parse({
496
        ...card,
497
        transports: [
498
          {
499
            protocol: 'A2A/1.0',
500
            protocolBinding: 'JSONRPC',
501
            endpoint: String(rows[0].a2a_endpoint ?? rows[0].endpoint),
502
            managedBy: 'agent',
503
          },
504
        ],
505
      });
506
    return { ...card, presence: await this.getAgentPresence(agentId) };
507
  }
508
509
  async searchPublishedAgents(input: {
510
    query?: string | undefined;
511
    capability?: string | undefined;
512
    limit?: number | undefined;
513
  }): Promise<PublicAgentCard[]> {
514
    await this.ensureSchema();
515
    const rows = await this.sql`
516
      SELECT agents.card, runtime.a2a_endpoint, runtime.endpoint
517
      FROM openclasp_public_agents agents
518
      LEFT JOIN openclasp_agent_runtimes runtime
519
        ON runtime.agent_id = agents.agent_id AND runtime.status = 'verified'
520
      ORDER BY agents.updated_at DESC LIMIT 100
521
    `;
522
    const query = input.query?.trim().toLowerCase();
523
    const capability = input.capability?.trim().toLowerCase();
524
    const presenceRows = await this.sql`
525
      SELECT records.record_id, records.payload
526
      FROM openclasp_records records
527
      INNER JOIN openclasp_public_agents agents ON agents.agent_id = records.record_id
528
      WHERE records.kind = 'presence'
529
    `;
530
    const presence = new Map(
531
      presenceRows.map((row) => [String(row.record_id), String(row.payload.lastSeenAt)]),
532
    );
533
    return rows
534
      .map((row) => {
535
        let card = normalizePublicAgentCard(row.card);
536
        if (row.a2a_endpoint || row.endpoint)
537
          card = PublicAgentCardSchema.parse({
538
            ...card,
539
            transports: [
540
              {
541
                protocol: 'A2A/1.0',
542
                protocolBinding: 'JSONRPC',
543
                endpoint: String(row.a2a_endpoint ?? row.endpoint),
544
                managedBy: 'agent',
545
              },
546
            ],
547
          });
548
        return { ...card, presence: resolveAgentPresence(presence.get(card.agentId)) };
549
      })
550
      .filter(
551
        (card) =>
552
          (!query ||
553
            card.name.toLowerCase().includes(query) ||
554
            card.framework.toLowerCase().includes(query)) &&
555
          (!capability ||
556
            card.capabilities.some((value) => value.toLowerCase().includes(capability))),
557
      )
558
      .slice(0, Math.min(Math.max(input.limit ?? 20, 1), 50));
559
  }
560
561
  async touchAgentPresence(operatorId: string, agentId: string): Promise<AgentPresence> {
562
    await this.ensureSchema();
563
    const owned = await this.sql`
564
      SELECT 1 FROM openclasp_records
565
      WHERE operator_id = ${operatorId} AND kind = 'agent_profile' AND record_id = ${agentId}
566
    `;
567
    if (!owned.length) throw new Error('Agent is not owned by this account');
568
    const lastSeenAt = new Date().toISOString();
569
    await this.upsert(operatorId, 'presence', agentId, { lastSeenAt });
570
    await this.sql`
571
      UPDATE openclasp_agent_runtimes SET last_seen_at = ${lastSeenAt}, updated_at = NOW()
572
      WHERE operator_id = ${operatorId} AND agent_id = ${agentId} AND status = 'verified'
573
    `;
574
    return resolveAgentPresence(lastSeenAt);
575
  }
576
577
  async getAgentPresence(agentId: string): Promise<AgentPresence> {
578
    await this.ensureSchema();
579
    const rows = await this.sql`
580
      SELECT records.payload
581
      FROM openclasp_public_agents agents
582
      LEFT JOIN openclasp_records records
583
        ON records.operator_id = agents.operator_id
584
        AND records.kind = 'presence'
585
        AND records.record_id = agents.agent_id
586
      WHERE agents.agent_id = ${agentId}
587
      LIMIT 1
588
    `;
589
    if (!rows.length) throw new Error('Published agent not found');
590
    const lastSeenAt = rows[0]?.payload?.lastSeenAt;
591
    return resolveAgentPresence(typeof lastSeenAt === 'string' ? lastSeenAt : undefined);
592
  }
593
594
  async listAgentRuntimes(operatorId: string) {
595
    await this.ensureSchema();
596
    const rows = await this.sql`
597
      SELECT agent_id, endpoint, a2a_endpoint, status, verified_at, last_seen_at, last_error, updated_at
598
      FROM openclasp_agent_runtimes
599
      WHERE operator_id = ${operatorId}
600
      ORDER BY updated_at DESC
601
    `;
602
    return rows.map((row) => ({
603
      agentId: String(row.agent_id),
604
      endpoint: String(row.endpoint),
605
      a2aEndpoint: String(row.a2a_endpoint ?? row.endpoint),
606
      status: row.status as 'verified' | 'disabled',
607
      verifiedAt: new Date(String(row.verified_at)).toISOString(),
608
      ...(row.last_seen_at ? { lastSeenAt: new Date(String(row.last_seen_at)).toISOString() } : {}),
609
      ...(row.last_error ? { lastError: String(row.last_error) } : {}),
610
      updatedAt: new Date(String(row.updated_at)).toISOString(),
611
    }));
612
  }
613
614
  async registerAgentRuntime(operatorId: string, agentId: string, endpoint: string) {
615
    await this.ensureSchema();
616
    const owned = await this.sql`
617
      SELECT 1 FROM openclasp_records
618
      WHERE operator_id = ${operatorId} AND kind = 'agent_profile' AND record_id = ${agentId}
619
    `;
620
    if (!owned.length) throw new Error('Agent is not owned by this account');
621
    const challenge = crypto.randomUUID();
622
    const verification = await postRuntimeJson(endpoint, {
623
      type: 'openclasp.runtime.verify',
624
      version: '1',
625
      agentId,
626
      challenge,
627
    });
628
    if (
629
      verification.status < 200 ||
630
      verification.status >= 300 ||
631
      !verification.body ||
632
      typeof verification.body !== 'object' ||
633
      (verification.body as { type?: unknown }).type !== 'openclasp.runtime.verified' ||
634
      (verification.body as { version?: unknown }).version !== '1' ||
635
      (verification.body as { agentId?: unknown }).agentId !== agentId ||
636
      (verification.body as { challenge?: unknown }).challenge !== challenge
637
    )
638
      throw new Error('Runtime endpoint did not return the verification challenge');
639
    const a2aEndpointValue = (verification.body as { a2aEndpoint?: unknown }).a2aEndpoint;
640
    const a2aEndpoint =
641
      typeof a2aEndpointValue === 'string' && a2aEndpointValue ? a2aEndpointValue : endpoint;
642
    await resolvePublicRuntimeEndpoint(a2aEndpoint);
643
    const runtimeSecret = randomBytes(32).toString('base64url');
644
    const encrypted = encryptGatewayPayload(this.gatewaySecret(), runtimeSecret);
645
    const verifiedAt = new Date().toISOString();
646
    const saved = await this.sql`
647
      INSERT INTO openclasp_agent_runtimes(
648
        agent_id, operator_id, endpoint, a2a_endpoint, secret_ciphertext, secret_iv, secret_auth_tag,
649
        status, verified_at, last_seen_at, updated_at
650
      ) VALUES (
651
        ${agentId}, ${operatorId}, ${endpoint}, ${a2aEndpoint}, ${encrypted.ciphertext},
652
        ${encrypted.iv}, ${encrypted.authTag}, 'verified', ${verifiedAt}, ${verifiedAt}, NOW()
653
      )
654
      ON CONFLICT (agent_id) DO UPDATE SET
655
        operator_id = EXCLUDED.operator_id,
656
        endpoint = EXCLUDED.endpoint,
657
        a2a_endpoint = EXCLUDED.a2a_endpoint,
658
        secret_ciphertext = EXCLUDED.secret_ciphertext,
659
        secret_iv = EXCLUDED.secret_iv,
660
        secret_auth_tag = EXCLUDED.secret_auth_tag,
661
        status = 'verified',
662
        verified_at = EXCLUDED.verified_at,
663
        last_seen_at = EXCLUDED.last_seen_at,
664
        last_error = NULL,
665
        updated_at = NOW()
666
      WHERE openclasp_agent_runtimes.operator_id = ${operatorId}
667
      RETURNING agent_id
668
    `;
669
    if (!saved.length) throw new Error('Agent runtime is owned by another account');
670
    await this.sql`
671
      UPDATE openclasp_public_agents
672
      SET card = jsonb_set(
673
        card,
674
        '{transports}',
675
        ${JSON.stringify([
676
          {
677
            protocol: 'A2A/1.0',
678
            protocolBinding: 'JSONRPC',
679
            endpoint: a2aEndpoint,
680
            managedBy: 'agent',
681
          },
682
        ])}::jsonb
683
      ), updated_at = NOW()
684
      WHERE agent_id = ${agentId} AND operator_id = ${operatorId}
685
    `;
686
    await this.sql`
687
      UPDATE openclasp_records
688
      SET payload = jsonb_set(payload, '{a2aEndpoint}', to_jsonb(${a2aEndpoint}::text)),
689
        updated_at = NOW()
690
      WHERE operator_id = ${operatorId} AND kind = 'agent_profile' AND record_id = ${agentId}
691
    `;
692
    await this.touchAgentPresence(operatorId, agentId);
693
    return {
694
      agentId,
695
      endpoint,
696
      a2aEndpoint,
697
      status: 'verified' as const,
698
      verifiedAt,
699
      verificationKey: getSessionVerificationKey(this.gatewaySecret()),
700
    };
701
  }
702
703
  async disableAgentRuntime(operatorId: string, agentId: string) {
704
    await this.ensureSchema();
705
    const rows = await this.sql`
706
      UPDATE openclasp_agent_runtimes
707
      SET status = 'disabled', updated_at = NOW()
708
      WHERE operator_id = ${operatorId} AND agent_id = ${agentId}
709
      RETURNING agent_id
710
    `;
711
    if (!rows.length) throw new Error('Agent runtime not found');
712
    return { agentId, status: 'disabled' as const };
713
  }
714
715
  async deleteAgent(operatorId: string, agentId: string) {
716
    await this.ensureSchema();
717
    const owned = await this.sql`
718
      SELECT 1 FROM openclasp_records
719
      WHERE operator_id = ${operatorId}
720
        AND kind = 'agent_profile'
721
        AND record_id = ${agentId}
722
      LIMIT 1
723
    `;
724
    if (!owned.length) throw new Error('Owned agent not found');
725
    const openInteractions = await this.sql`
726
      SELECT interaction_id FROM openclasp_federated_interactions
727
      WHERE (initiator_agent_id = ${agentId} OR responder_agent_id = ${agentId})
728
        AND status IN ('pending', 'active')
729
      LIMIT 1
730
    `;
731
    if (openInteractions.length) {
732
      const error = new Error('Finish or reject the agent’s open interactions before deleting it');
733
      Object.assign(error, { statusCode: 409 });
734
      throw error;
735
    }
736
    await this.sql`
737
      DELETE FROM openclasp_public_agents
738
      WHERE operator_id = ${operatorId} AND agent_id = ${agentId}
739
    `;
740
    await this.sql`
741
      DELETE FROM openclasp_agent_runtimes
742
      WHERE operator_id = ${operatorId} AND agent_id = ${agentId}
743
    `;
744
    await this.sql`
745
      DELETE FROM openclasp_records
746
      WHERE operator_id = ${operatorId}
747
        AND (
748
          (record_id = ${agentId} AND kind IN ('agent', 'agent_profile', 'publication', 'presence'))
749
          OR (kind = 'installation' AND payload->>'agentId' = ${agentId})
750
          OR (kind = 'setup_request' AND payload->>'existingAgentId' = ${agentId})
751
        )
752
    `;
753
    return {
754
      agentId,
755
      deleted: true as const,
756
      historyRetained: true as const,
757
    };
758
  }
759
760
  private async liveRuntime(agentId: string) {
761
    const rows = await this.sql`
762
      SELECT runtime.*, agents.card
763
      FROM openclasp_agent_runtimes runtime
764
      INNER JOIN openclasp_public_agents agents ON agents.agent_id = runtime.agent_id
765
      WHERE runtime.agent_id = ${agentId} AND runtime.status = 'verified'
766
      LIMIT 1
767
    `;
768
    const row = rows[0];
769
    if (!row) throw new Error(`Agent ${agentId} does not have a verified live runtime`);
770
    return {
771
      agentId,
772
      operatorId: String(row.operator_id),
773
      callbackEndpoint: String(row.endpoint),
774
      a2aEndpoint: String(row.a2a_endpoint ?? row.endpoint),
775
      card: normalizePublicAgentCard(row.card),
776
    };
777
  }
778
779
  private async signedRuntimeRequest(
780
    runtime: Awaited<ReturnType<HostedRepository['liveRuntime']>>,
781
    requestId: string,
782
    value: unknown,
783
  ) {
784
    const body = JSON.stringify(value);
785
    const timestamp = new Date().toISOString();
786
    return postRuntimeJson(runtime.callbackEndpoint, value, {
787
      'openclasp-request-id': requestId,
788
      'openclasp-timestamp': timestamp,
789
      'openclasp-signature': `v1=${signSessionControl(this.gatewaySecret(), requestId, timestamp, body)}`,
790
    });
791
  }
792
793
  private sessionGrant(
794
    interaction: FederatedInteraction,
795
    senderAgentId: string,
796
    recipientAgentId: string,
797
  ) {
798
    return issueSessionGrant(this.gatewaySecret(), {
799
      interactionId: interaction.interactionId,
800
      senderAgentId,
801
      recipientAgentId,
802
      expiresAt: Math.min(Date.parse(interaction.expiresAt), Date.now() + 60 * 60_000),
803
    });
804
  }
805
806
  private async privateCounterpartyInsights(
807
    viewerOperatorId: string,
808
    counterparty: Awaited<ReturnType<HostedRepository['liveRuntime']>>,
809
    taskCategory: string,
810
  ): Promise<LiveSessionInsight[]> {
811
    const rows = await this.sql`
812
      SELECT record_id, payload
813
      FROM openclasp_records
814
      WHERE operator_id = ${viewerOperatorId}
815
        AND kind = 'profile'
816
        AND payload->>'agentId' = ${counterparty.agentId}
817
        AND payload->>'taskCategory' = ${taskCategory}
818
      ORDER BY (payload->>'sampleSize')::integer DESC, updated_at DESC
819
    `;
820
    const profiles: ContextualProfile[] = rows
821
      .map<Record<string, unknown>>((row) => ({
822
        recordId: String(row.record_id),
823
        ...(row.payload as Record<string, unknown>),
824
      }))
825
      .filter(
826
        (profile) =>
827
          typeof profile.agentVersion === 'string' &&
828
          typeof profile.sampleSize === 'number' &&
829
          typeof profile.updatedAt === 'string' &&
830
          typeof profile.completion === 'number' &&
831
          typeof profile.acceptance === 'number' &&
832
          typeof profile.specification === 'number' &&
833
          typeof profile.deadline === 'number' &&
834
          typeof profile.communication === 'number' &&
835
          typeof profile.evidence === 'number' &&
836
          typeof profile.scope === 'number' &&
837
          typeof profile.disputes === 'number',
838
      ) as ContextualProfile[];
839
    const exact = profiles.find(
840
      (profile) => profile.agentVersion === counterparty.card.agentVersion,
841
    );
842
    const current = exact ?? profiles[0];
843
    const insights: LiveSessionInsight[] = [];
844
    if (!current) {
845
      insights.push({
846
        code: 'limited_verified_history',
847
        severity: 'caution',
848
        message: `No eligible ${taskCategory} history is available for this agent version. Ask for task-relevant evidence.`,
849
        evidenceReferences: [],
850
      });
851
    } else {
852
      const ageDays = Math.max(
853
        0,
854
        (Date.now() - Date.parse(String(current.updatedAt))) / 86_400_000,
855
      );
856
      const freshness = Math.exp(-ageDays / 180);
857
      const sampleSize = Number(current.sampleSize);
858
      const confidence = Math.min(0.95, (sampleSize / (sampleSize + 5)) * freshness);
859
      const versionChanged = current.agentVersion !== counterparty.card.agentVersion;
860
      const reference = `openclasp:profile:${current.recordId}`;
861
      insights.push({
862
        code: versionChanged ? 'version_history_only' : 'contextual_history',
863
        severity: versionChanged || confidence < 0.25 ? 'caution' : 'info',
864
        message: versionChanged
865
          ? `Only prior-version ${taskCategory} history is available (${sampleSize} eligible interaction${sampleSize === 1 ? '' : 's'}); confidence is reduced for version ${counterparty.card.agentVersion}.`
866
          : `Based on ${sampleSize} eligible ${taskCategory} interaction${sampleSize === 1 ? '' : 's'}; confidence ${(confidence * 100).toFixed(0)}%.`,
867
        evidenceReferences: [reference],
868
      });
869
      const weakDimensions = [
870
        ['completion', Number(current.completion)],
871
        ['output acceptance', Number(current.acceptance)],
872
        ['specification adherence', Number(current.specification)],
873
        ['deadline reliability', Number(current.deadline)],
874
        ['communication quality', Number(current.communication)],
875
        ['evidence quality', Number(current.evidence)],
876
        ['scope adherence', Number(current.scope)],
877
      ].filter(([, score]) => Number(score) < 0.7);
878
      if (weakDimensions.length)
879
        insights.push({
880
          code: 'historical_weaknesses',
881
          severity: weakDimensions.some(([, score]) => Number(score) < 0.5) ? 'high' : 'caution',
882
          message: `Observed weaker areas: ${weakDimensions
883
            .map(([name, score]) => `${String(name)} ${(Number(score) * 100).toFixed(0)}%`)
884
            .join(', ')}.`,
885
          evidenceReferences: [reference],
886
        });
887
      if (Number(current.disputes) > 0.25)
888
        insights.push({
889
          code: 'elevated_dispute_history',
890
          severity: Number(current.disputes) >= 0.5 ? 'high' : 'caution',
891
          message: `Eligible feedback shows a ${(Number(current.disputes) * 100).toFixed(0)}% dispute rate in this task category.`,
892
          evidenceReferences: [reference],
893
        });
894
    }
895
    if (counterparty.card.limitations.length)
896
      insights.push({
897
        code: 'declared_limitations',
898
        severity: 'info',
899
        message: `Counterparty declares: ${counterparty.card.limitations.join('; ')}`,
900
        evidenceReferences: [counterparty.card.cardUrl],
901
      });
902
    return insights;
903
  }
904
905
  async brokerLiveSession(interaction: FederatedInteraction) {
906
    await this.ensureSchema();
907
    const existing = await this.sql`
908
      SELECT status FROM openclasp_live_sessions
909
      WHERE interaction_id = ${interaction.interactionId}
910
    `;
911
    if (existing[0]?.status === 'active') return;
912
    const [initiator, responder] = await Promise.all([
913
      this.liveRuntime(interaction.initiatorAgentId),
914
      this.liveRuntime(interaction.responderAgentId),
915
    ]);
916
    const [initiatorInsights, responderInsights] = await Promise.all([
917
      this.privateCounterpartyInsights(
918
        initiator.operatorId,
919
        responder,
920
        interaction.contract.taskCategory,
921
      ),
922
      this.privateCounterpartyInsights(
923
        responder.operatorId,
924
        initiator,
925
        interaction.contract.taskCategory,
926
      ),
927
    ]);
928
    const issuedAt = new Date().toISOString();
929
    const makeOffer = (
930
      runtime: typeof initiator,
931
      counterparty: typeof responder,
932
      role: 'initiator' | 'responder',
933
      privateInsights: LiveSessionInsight[],
934
    ) =>
935
      LiveSessionOfferSchema.parse({
936
        type: 'openclasp.session.offer',
937
        version: '1',
938
        offerId: crypto.randomUUID(),
939
        interactionId: interaction.interactionId,
940
        agentId: runtime.agentId,
941
        role,
942
        counterparty: {
943
          agentId: counterparty.agentId,
944
          name: counterparty.card.name,
945
          agentVersion: counterparty.card.agentVersion,
946
          capabilities: counterparty.card.capabilities,
947
        },
948
        contract: interaction.contract,
949
        contractHash: interaction.termsHash,
950
        privateInsights,
951
        issuedAt,
952
        expiresAt: interaction.expiresAt,
953
      });
954
    const initiatorOffer = makeOffer(initiator, responder, 'initiator', initiatorInsights);
955
    const responderOffer = makeOffer(responder, initiator, 'responder', responderInsights);
956
    const [initiatorResponse, responderResponse] = await Promise.all([
957
      this.signedRuntimeRequest(initiator, initiatorOffer.offerId, initiatorOffer),
958
      this.signedRuntimeRequest(responder, responderOffer.offerId, responderOffer),
959
    ]);
960
    if (initiatorResponse.status < 200 || initiatorResponse.status >= 300)
961
      throw new Error(`Initiator runtime is not live (HTTP ${initiatorResponse.status})`);
962
    if (responderResponse.status < 200 || responderResponse.status >= 300)
963
      throw new Error(`Responder runtime is not live (HTTP ${responderResponse.status})`);
964
    const initiatorAcceptance = LiveSessionAcceptanceSchema.parse(initiatorResponse.body);
965
    const responderAcceptance = LiveSessionAcceptanceSchema.parse(responderResponse.body);
966
    if (
967
      initiatorAcceptance.offerId !== initiatorOffer.offerId ||
968
      initiatorAcceptance.agentId !== initiator.agentId ||
969
      responderAcceptance.offerId !== responderOffer.offerId ||
970
      responderAcceptance.agentId !== responder.agentId
971
    )
972
      throw new Error('Runtime returned a mismatched live-session acceptance');
973
    await Promise.all([
974
      resolvePublicRuntimeEndpoint(initiatorAcceptance.a2aEndpoint),
975
      resolvePublicRuntimeEndpoint(responderAcceptance.a2aEndpoint),
976
    ]);
977
    await this.sql`
978
      INSERT INTO openclasp_live_sessions(
979
        interaction_id, initiator_agent_id, responder_agent_id,
980
        initiator_session_id, responder_session_id,
981
        initiator_endpoint, responder_endpoint, status, expires_at
982
      ) VALUES (
983
        ${interaction.interactionId}, ${interaction.initiatorAgentId}, ${interaction.responderAgentId},
984
        ${initiatorAcceptance.sessionId}, ${responderAcceptance.sessionId},
985
        ${initiatorAcceptance.a2aEndpoint}, ${responderAcceptance.a2aEndpoint},
986
        'preparing', ${interaction.expiresAt}
987
      )
988
      ON CONFLICT (interaction_id) DO UPDATE SET
989
        initiator_session_id = EXCLUDED.initiator_session_id,
990
        responder_session_id = EXCLUDED.responder_session_id,
991
        initiator_endpoint = EXCLUDED.initiator_endpoint,
992
        responder_endpoint = EXCLUDED.responder_endpoint,
993
        status = 'preparing', expires_at = EXCLUDED.expires_at, last_error = NULL
994
    `;
995
    const baseUrl = (process.env.OPENCLASP_PUBLIC_URL ?? 'https://openclasp.vercel.app').replace(
996
      /\/$/,
997
      '',
998
    );
999
    const activatedAt = new Date().toISOString();
1000
    const verificationKey = getSessionVerificationKey(this.gatewaySecret());
1001
    const makeActivation = (
1002
      agentId: string,
1003
      sessionId: string,
1004
      role: 'initiator' | 'responder',
1005
      peerAgentId: string,
1006
      peerSessionId: string,
1007
      peerEndpoint: string,
1008
    ): LiveSessionActivation => {
1009
      const bearerToken = this.sessionGrant(interaction, agentId, peerAgentId);
1010
      return LiveSessionActivationSchema.parse({
1011
        type: 'openclasp.session.activation',
1012
        version: '1',
1013
        activationId: crypto.randomUUID(),
1014
        interactionId: interaction.interactionId,
1015
        agentId,
1016
        sessionId,
1017
        role,
1018
        peer: {
1019
          agentId: peerAgentId,
1020
          sessionId: peerSessionId,
1021
          endpoint: peerEndpoint,
1022
          bearerToken,
1023
          verificationKey,
1024
        },
1025
        reporting: {
1026
          endpoint: `${baseUrl}/sessions/${encodeURIComponent(interaction.interactionId)}/events`,
1027
          bearerToken,
1028
        },
1029
        contractHash: interaction.termsHash,
1030
        activatedAt,
1031
        expiresAt: interaction.expiresAt,
1032
      });
1033
    };
1034
    const initiatorActivation = makeActivation(
1035
      initiator.agentId,
1036
      initiatorAcceptance.sessionId,
1037
      'initiator',
1038
      responder.agentId,
1039
      responderAcceptance.sessionId,
1040
      responderAcceptance.a2aEndpoint,
1041
    );
1042
    const responderActivation = makeActivation(
1043
      responder.agentId,
1044
      responderAcceptance.sessionId,
1045
      'responder',
1046
      initiator.agentId,
1047
      initiatorAcceptance.sessionId,
1048
      initiatorAcceptance.a2aEndpoint,
1049
    );
1050
    try {
1051
      const responderActivated = await this.signedRuntimeRequest(
1052
        responder,
1053
        responderActivation.activationId,
1054
        responderActivation,
1055
      );
1056
      if (responderActivated.status < 200 || responderActivated.status >= 300)
1057
        throw new Error(`Responder activation failed with HTTP ${responderActivated.status}`);
1058
      const initiatorActivated = await this.signedRuntimeRequest(
1059
        initiator,
1060
        initiatorActivation.activationId,
1061
        initiatorActivation,
1062
      );
1063
      if (initiatorActivated.status < 200 || initiatorActivated.status >= 300)
1064
        throw new Error(`Initiator activation failed with HTTP ${initiatorActivated.status}`);
1065
      await this.sql`
1066
        UPDATE openclasp_live_sessions
1067
        SET status = 'active', activated_at = NOW(), last_error = NULL
1068
        WHERE interaction_id = ${interaction.interactionId}
1069
      `;
1070
      await this.sql`
1071
        UPDATE openclasp_agent_runtimes
1072
        SET last_seen_at = NOW(), last_error = NULL, updated_at = NOW()
1073
        WHERE agent_id IN (${initiator.agentId}, ${responder.agentId})
1074
      `;
1075
      await Promise.all([
1076
        this.touchAgentPresence(initiator.operatorId, initiator.agentId),
1077
        this.touchAgentPresence(responder.operatorId, responder.agentId),
1078
      ]);
1079
    } catch (error) {
1080
      const reason =
1081
        error instanceof Error ? error.message.slice(0, 500) : 'Session activation failed';
1082
      await this.sql`
1083
        UPDATE openclasp_live_sessions SET status = 'failed', last_error = ${reason}
1084
        WHERE interaction_id = ${interaction.interactionId}
1085
      `;
1086
      throw error;
1087
    }
1088
  }
1089
1090
  async getLiveSession(operatorId: string, interactionId: string, agentId: string) {
1091
    await this.ensureSchema();
1092
    const rows = await this.sql`
1093
      SELECT session.*, interaction.payload,
1094
        initiator.operator_id AS initiator_operator_id,
1095
        responder.operator_id AS responder_operator_id
1096
      FROM openclasp_live_sessions session
1097
      INNER JOIN openclasp_federated_interactions interaction
1098
        ON interaction.interaction_id = session.interaction_id
1099
      INNER JOIN openclasp_agent_runtimes initiator
1100
        ON initiator.agent_id = session.initiator_agent_id
1101
      INNER JOIN openclasp_agent_runtimes responder
1102
        ON responder.agent_id = session.responder_agent_id
1103
      WHERE session.interaction_id = ${interactionId} AND session.status = 'active'
1104
      LIMIT 1
1105
    `;
1106
    const row = rows[0];
1107
    if (!row) throw new Error('Active live session not found');
1108
    const isInitiator = row.initiator_agent_id === agentId;
1109
    const isResponder = row.responder_agent_id === agentId;
1110
    if (!isInitiator && !isResponder) throw new Error('Agent is not a session participant');
1111
    const owner = isInitiator ? row.initiator_operator_id : row.responder_operator_id;
1112
    if (owner !== operatorId) throw new Error('Agent is not owned by this account');
1113
    const interaction = FederatedInteractionSchema.parse(row.payload);
1114
    const peerAgentId = String(isInitiator ? row.responder_agent_id : row.initiator_agent_id);
1115
    const bearerToken = this.sessionGrant(interaction, agentId, peerAgentId);
1116
    return {
1117
      type: 'openclasp.session.activation' as const,
1118
      version: '1' as const,
1119
      activationId: crypto.randomUUID(),
1120
      interactionId,
1121
      agentId,
1122
      sessionId: String(isInitiator ? row.initiator_session_id : row.responder_session_id),
1123
      role: isInitiator ? ('initiator' as const) : ('responder' as const),
1124
      peer: {
1125
        agentId: peerAgentId,
1126
        sessionId: String(isInitiator ? row.responder_session_id : row.initiator_session_id),
1127
        endpoint: String(isInitiator ? row.responder_endpoint : row.initiator_endpoint),
1128
        bearerToken,
1129
        verificationKey: getSessionVerificationKey(this.gatewaySecret()),
1130
      },
1131
      reporting: {
1132
        endpoint: `${(process.env.OPENCLASP_PUBLIC_URL ?? 'https://openclasp.vercel.app').replace(/\/$/, '')}/sessions/${encodeURIComponent(interactionId)}/events`,
1133
        bearerToken,
1134
      },
1135
      contractHash: interaction.termsHash,
1136
      activatedAt: row.activated_at
1137
        ? new Date(String(row.activated_at)).toISOString()
1138
        : new Date().toISOString(),
1139
      expiresAt: new Date(String(row.expires_at)).toISOString(),
1140
    };
1141
  }
1142
1143
  async recordLiveSessionEvent(token: string, value: LiveSessionEvent) {
1144
    await this.ensureSchema();
1145
    const event = LiveSessionEventSchema.parse(value);
1146
    const grant = verifySessionGrant(this.gatewaySecret(), token);
1147
    if (grant.interactionId !== event.interactionId || grant.senderAgentId !== event.agentId)
1148
      throw new Error('Session credential does not match the event');
1149
    const sessions = await this.sql`
1150
      SELECT 1 FROM openclasp_live_sessions
1151
      WHERE interaction_id = ${event.interactionId}
1152
        AND status = 'active'
1153
        AND ${event.agentId} IN (initiator_agent_id, responder_agent_id)
1154
    `;
1155
    if (!sessions.length) throw new Error('Active live session not found');
1156
    const attestation = attestSessionRecord(this.gatewaySecret(), event);
1157
    const encoded = JSON.stringify({ ...event, attestation });
1158
    const rows = await this.sql`
1159
      INSERT INTO openclasp_live_session_events(
1160
        interaction_id, event_id, agent_id, sequence, event
1161
      ) SELECT ${event.interactionId}, ${event.eventId}, ${event.agentId}, ${event.sequence}, ${encoded}::jsonb
1162
      WHERE (
1163
        SELECT COUNT(*) FROM openclasp_live_session_events
1164
        WHERE interaction_id = ${event.interactionId}
1165
      ) < 1000
1166
      ON CONFLICT DO NOTHING
1167
      RETURNING event_id
1168
    `;
1169
    if (event.type === 'session_completed' || event.type === 'session_failed') {
1170
      const terminal = await this.sql`
1171
        SELECT COUNT(DISTINCT agent_id) AS count
1172
        FROM openclasp_live_session_events
1173
        WHERE interaction_id = ${event.interactionId}
1174
          AND event->>'type' IN ('session_completed', 'session_failed')
1175
      `;
1176
      if (Number(terminal[0]?.count ?? 0) >= 2) {
1177
        await this.sql`
1178
          UPDATE openclasp_live_sessions SET status = 'completed', completed_at = NOW()
1179
          WHERE interaction_id = ${event.interactionId} AND status = 'active'
1180
        `;
1181
        const completedAt = new Date().toISOString();
1182
        await this.sql`
1183
          UPDATE openclasp_federated_interactions
1184
          SET status = 'completed',
1185
            payload = jsonb_set(
1186
              jsonb_set(payload, '{status}', '"completed"'::jsonb),
1187
              '{updatedAt}', to_jsonb(${completedAt}::text)
1188
            ),
1189
            updated_at = NOW()
1190
          WHERE interaction_id = ${event.interactionId} AND status = 'active'
1191
        `;
1192
      }
1193
    }
1194
    return {
1195
      recorded: rows.length > 0,
1196
      deduplicated: rows.length === 0,
1197
      eventId: event.eventId,
1198
      attestation,
1199
    };
1200
  }
1201
1202
  async createFederatedInteraction(
1203
    operatorId: string,
1204
    value: FederatedInteraction,
1205
  ): Promise<FederatedInteraction> {
1206
    await this.ensureSchema();
1207
    const interaction = FederatedInteractionSchema.parse(value);
1208
    if (interaction.status !== 'pending') throw new Error('New interactions must be pending');
1209
    if (interaction.contract.interactionId !== interaction.interactionId)
1210
      throw new Error('Contract interaction ID does not match');
1211
    if (canonicalHash(interaction.contract) !== interaction.termsHash)
1212
      throw new Error('Contract hash does not match the immutable terms');
1213
    if (
1214
      interaction.contract.parties.length !== 2 ||
1215
      interaction.contract.parties[0] !== interaction.initiatorAgentId ||
1216
      interaction.contract.parties[1] !== interaction.responderAgentId
1217
    )
1218
      throw new Error('Contract parties do not match the interaction participants');
1219
    const acceptanceEntries = Object.entries(interaction.acceptances);
1220
    if (
1221
      acceptanceEntries.length !== 1 ||
1222
      acceptanceEntries[0]?.[0] !== interaction.initiatorAgentId ||
1223
      acceptanceEntries[0]?.[1].agentId !== interaction.initiatorAgentId ||
1224
      acceptanceEntries[0]?.[1].termsHash !== interaction.termsHash ||
1225
      !['oauth_installation', 'oauth_account'].includes(acceptanceEntries[0]?.[1].method ?? '')
1226
    )
1227
      throw new Error('A pending interaction requires only the initiator acceptance');
1228
    if (Date.parse(interaction.expiresAt) <= Date.now())
1229
      throw new Error('Interaction expiry must be in the future');
1230
    const owners = await this.sql`
1231
      SELECT agent_id, operator_id, card FROM openclasp_public_agents
1232
      WHERE agent_id IN (${interaction.initiatorAgentId}, ${interaction.responderAgentId})
1233
    `;
1234
    const initiator = owners.find((row) => row.agent_id === interaction.initiatorAgentId);
1235
    const responder = owners.find((row) => row.agent_id === interaction.responderAgentId);
1236
    if (!initiator || initiator.operator_id !== operatorId)
1237
      throw new Error('The initiating agent must be published and owned by this account');
1238
    if (!responder) throw new Error('Responder is not a published OpenClasp agent');
1239
    if (responder.operator_id === operatorId)
1240
      throw new Error('Federated interactions require agents from different accounts');
1241
    const responderCard = normalizePublicAgentCard(responder.card);
1242
    if (
1243
      !responderCard.transports.some(
1244
        (transport) =>
1245
          transport.endpoint === interaction.responderTransport.endpoint &&
1246
          transport.protocolBinding === interaction.responderTransport.protocolBinding,
1247
      )
1248
    )
1249
      throw new Error('Responder transport does not match its published Agent Card');
1250
    const encoded = JSON.stringify(interaction);
1251
    const rows = await this.sql`
1252
      INSERT INTO openclasp_federated_interactions(
1253
        interaction_id, initiator_operator_id, responder_operator_id,
1254
        initiator_agent_id, responder_agent_id, status, payload, expires_at
1255
      ) VALUES (
1256
        ${interaction.interactionId}, ${operatorId}, ${responder.operator_id},
1257
        ${interaction.initiatorAgentId}, ${interaction.responderAgentId},
1258
        ${interaction.status}, ${encoded}::jsonb, ${interaction.expiresAt}
1259
      )
1260
      RETURNING payload
1261
    `;
1262
    const stored = FederatedInteractionSchema.parse(rows[0]?.payload);
1263
    const profiles = await this.sql`
1264
      SELECT payload FROM openclasp_records
1265
      WHERE operator_id = ${responder.operator_id}
1266
        AND kind = 'agent_profile'
1267
        AND record_id = ${interaction.responderAgentId}
1268
      LIMIT 1
1269
    `;
1270
    const responderProfile = profiles[0]?.payload as AgentProfile | undefined;
1271
    if (responderProfile && canAutoAcceptInteraction(responderProfile, stored))
1272
      return this.respondToFederatedInteraction(
1273
        String(responder.operator_id),
1274
        stored.interactionId,
1275
        stored.responderAgentId,
1276
        'accept',
1277
        'policy_auto_accept',
1278
      );
1279
    return stored;
1280
  }
1281
1282
  async listFederatedInteractions(operatorId: string): Promise<FederatedInteraction[]> {
1283
    await this.ensureSchema();
1284
    await this.sql`
1285
      UPDATE openclasp_federated_interactions
1286
      SET status = 'expired', payload = jsonb_set(payload, '{status}', '"expired"'::jsonb), updated_at = NOW()
1287
      WHERE status = 'pending' AND expires_at <= NOW()
1288
    `;
1289
    const rows = await this.sql`
1290
      SELECT payload FROM openclasp_federated_interactions
1291
      WHERE initiator_operator_id = ${operatorId} OR responder_operator_id = ${operatorId}
1292
      ORDER BY updated_at DESC
1293
    `;
1294
    return rows.map((row) => FederatedInteractionSchema.parse(row.payload));
1295
  }
1296
1297
  async getFederatedInteraction(
1298
    operatorId: string,
1299
    interactionId: string,
1300
  ): Promise<FederatedInteraction | undefined> {
1301
    const values = await this.listFederatedInteractions(operatorId);
1302
    return values.find((value) => value.interactionId === interactionId);
1303
  }
1304
1305
  async respondToFederatedInteraction(
1306
    operatorId: string,
1307
    interactionId: string,
1308
    agentId: string,
1309
    decision: 'accept' | 'reject',
1310
    method: 'oauth_installation' | 'oauth_account' | 'policy_auto_accept' = 'oauth_account',
1311
  ): Promise<FederatedInteraction> {
1312
    await this.ensureSchema();
1313
    const rows = await this.sql`
1314
      SELECT payload, responder_operator_id, responder_agent_id
1315
      FROM openclasp_federated_interactions WHERE interaction_id = ${interactionId}
1316
    `;
1317
    const row = rows[0];
1318
    if (!row) throw new Error('Interaction invitation not found');
1319
    if (row.responder_operator_id !== operatorId || row.responder_agent_id !== agentId)
1320
      throw new Error('Only the invited agent may respond');
1321
    const current = FederatedInteractionSchema.parse(row.payload);
1322
    if (current.status !== 'pending') throw new Error('Invitation is no longer pending');
1323
    const now = new Date().toISOString();
1324
    if (Date.parse(current.expiresAt) <= Date.now()) {
1325
      const expired = { ...current, status: 'expired' as const, updatedAt: now };
1326
      await this.updateFederatedInteraction(interactionId, current.status, expired);
1327
      throw new Error('Invitation has expired');
1328
    }
1329
    const next: FederatedInteraction =
1330
      decision === 'reject'
1331
        ? { ...current, status: 'rejected', updatedAt: now }
1332
        : {
1333
            ...current,
1334
            status: 'active',
1335
            updatedAt: now,
1336
            acceptances: {
1337
              ...current.acceptances,
1338
              [agentId]: {
1339
                agentId,
1340
                method,
1341
                termsHash: current.termsHash,
1342
                acceptedAt: now,
1343
              },
1344
            },
1345
          };
1346
    if (decision === 'accept') {
1347
      try {
1348
        await this.brokerLiveSession(next);
1349
      } catch (error) {
1350
        const reason = error instanceof Error ? error.message.slice(0, 500) : 'Live session failed';
1351
        await this.sql`
1352
          UPDATE openclasp_agent_runtimes SET last_error = ${reason}, updated_at = NOW()
1353
          WHERE agent_id IN (${current.initiatorAgentId}, ${current.responderAgentId})
1354
        `;
1355
        throw error;
1356
      }
1357
    }
1358
    const updated = await this.updateFederatedInteraction(interactionId, current.status, next);
1359
    if (!updated) throw new Error('Invitation was already handled');
1360
    return next;
1361
  }
1362
1363
  private async updateFederatedInteraction(
1364
    interactionId: string,
1365
    expectedStatus: string,
1366
    value: FederatedInteraction,
1367
  ): Promise<boolean> {
1368
    const encoded = JSON.stringify(FederatedInteractionSchema.parse(value));
1369
    const rows = await this.sql`
1370
      UPDATE openclasp_federated_interactions
1371
      SET status = ${value.status}, payload = ${encoded}::jsonb, updated_at = NOW()
1372
      WHERE interaction_id = ${interactionId} AND status = ${expectedStatus}
1373
      RETURNING interaction_id
1374
    `;
1375
    return rows.length > 0;
1376
  }
1377
1378
  async getSettings(operatorId: string): Promise<AccountSettings> {
1379
    await this.ensureSchema();
1380
    const rows = await this.sql`
1381
      SELECT display_name, contribution_enabled, retention_days, evidence_sharing
1382
      FROM openclasp_account_settings
1383
      WHERE operator_id = ${operatorId}
1384
    `;
1385
    const row = rows[0] as
1386
      | {
1387
          display_name: string;
1388
          contribution_enabled: boolean;
1389
          retention_days: number;
1390
          evidence_sharing: AccountSettings['evidenceSharing'];
1391
        }
1392
      | undefined;
1393
    return row
1394
      ? {
1395
          displayName: row.display_name,
1396
          contributionEnabled: row.contribution_enabled,
1397
          retentionDays: row.retention_days,
1398
          evidenceSharing: row.evidence_sharing,
1399
          rawConversationsStored: false,
1400
        }
1401
      : defaults;
1402
  }
1403
1404
  async saveSettings(
1405
    operatorId: string,
1406
    settings: Omit<AccountSettings, 'rawConversationsStored'>,
1407
  ): Promise<AccountSettings> {
1408
    await this.ensureSchema();
1409
    await this.sql`
1410
      INSERT INTO openclasp_account_settings(
1411
        operator_id, display_name, contribution_enabled, retention_days, evidence_sharing
1412
      ) VALUES (
1413
        ${operatorId}, ${settings.displayName}, ${settings.contributionEnabled},
1414
        ${settings.retentionDays}, ${settings.evidenceSharing}
1415
      )
1416
      ON CONFLICT (operator_id) DO UPDATE SET
1417
        display_name = EXCLUDED.display_name,
1418
        contribution_enabled = EXCLUDED.contribution_enabled,
1419
        retention_days = EXCLUDED.retention_days,
1420
        evidence_sharing = EXCLUDED.evidence_sharing,
1421
        updated_at = NOW()
1422
    `;
1423
    return { ...settings, rawConversationsStored: false };
1424
  }
1425
}