Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .claude/skills/env-reference/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,15 @@ See `apps/api/.env.example` for the full list. Key variables:
- `ACP_ACTIVITY_BINDING_CACHE_TTL_MS` — Short-lived authorized ACP session binding cache used to avoid ProjectData reads during callback storms (default: `30000`)
- `ACP_ACTIVITY_BINDING_CACHE_MAX_ENTRIES` — Maximum cached ACP activity bindings retained by one Worker isolate (default: `2048`)

- `ACP_INTERACTIONS_ENABLED` — Dormant durable ACP interaction foundation kill switch. Slice A defaults this to `false`; later slices must intentionally enable producers/consumers (default: `false`)
- `ACP_INTERACTION_PERMISSION_CONVERSATION_DEADLINE_MS` / `ACP_INTERACTION_PERMISSION_TASK_DEADLINE_MS` — Default permission deadlines for conversation and task contexts (defaults: `7200000` / `1800000`)
- `ACP_INTERACTION_MAX_DEADLINE_MS` / `ACP_INTERACTION_DEADLINE_MARGIN_MS` — Absolute deadline ceiling and prompt/runtime cap safety margin (defaults: `14400000` / `60000`)
- `ACP_INTERACTION_MAX_PENDING_PER_SESSION` — Maximum pending durable ACP interactions per chat (default: `8`)
- `ACP_INTERACTION_REQUEST_MAX_BYTES`, `ACP_INTERACTION_OPTIONS_MAX_COUNT`, `ACP_INTERACTION_OPTION_NAME_MAX_CHARS`, `ACP_INTERACTION_FORM_SCHEMA_MAX_BYTES`, `ACP_INTERACTION_FORM_SCHEMA_MAX_PROPERTIES`, `ACP_INTERACTION_FORM_SCHEMA_MAX_ENUM` — Request/detail and schema bounds for encrypted ACP interaction payloads (defaults: `32768`, `16`, `200`, `16384`, `20`, `50`)
- `ACP_INTERACTION_ANSWER_MAX_BYTES` / `ACP_INTERACTION_ANSWER_STRING_MAX_BYTES` — Answer decision and individual answer string bounds before encrypted storage (defaults: `16384` / `4096`)
- `ACP_INTERACTION_RETRY_DELAYS_MS` / `ACP_INTERACTION_RETRY_STEADY_MS` / `ACP_INTERACTION_DELIVERY_WINDOW_MS` — Durable answer outbox retry sequence, steady retry delay, and max post-answer retry window (defaults: `1000,5000,30000,120000,300000`, `300000`, `900000`)
- `ACP_INTERACTION_SENSITIVE_PURGE_MS`, `ACP_INTERACTION_SUMMARY_RETENTION_MS`, `ACP_INTERACTION_SUMMARY_LAST_SETTLED`, `ACP_INTERACTION_SNAPSHOT_LAST_SETTLED` — Sensitive encrypted payload purge, settled summary retention, and bounded snapshot controls (defaults: `3600000`, `2592000000`, `100`, `20`)

Activity coalescing and binding caches are per Worker isolate. Delayed flushes carry their original observed event time, and ProjectData rejects stale writes so a delayed intermediate report cannot overwrite a newer idle/error state from another isolate.

- `SESSION_SNAPSHOT_RECOVERY_CLAIM_LEASE_MS` — Reclaim timeout for an interrupted replacement-runtime wake claim (default: `600000`)
Expand Down
22 changes: 22 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,28 @@ INFOMANIAK_IP_POLL_INTERVAL_MS=3000
# ACP_ACTIVITY_COALESCE_MAX_PENDING=512 # Max pending coalesced activity reports per Worker isolate
# ACP_ACTIVITY_BINDING_CACHE_TTL_MS=30000 # Short-lived authorized ACP session binding cache
# ACP_ACTIVITY_BINDING_CACHE_MAX_ENTRIES=2048 # Max cached ACP activity bindings per Worker isolate
# Dormant durable ACP interaction foundation. Slice A keeps this disabled; later slices wire runtime/UI producers and consumers.
# ACP_INTERACTIONS_ENABLED=false # Fail-closed feature flag for durable ACP interaction creation
# ACP_INTERACTION_PERMISSION_CONVERSATION_DEADLINE_MS=7200000 # Default conversation permission deadline (2h)
# ACP_INTERACTION_PERMISSION_TASK_DEADLINE_MS=1800000 # Default task permission deadline (30m)
# ACP_INTERACTION_MAX_DEADLINE_MS=14400000 # Absolute max request deadline (4h)
# ACP_INTERACTION_DEADLINE_MARGIN_MS=60000 # Safety margin before prompt/runtime cap
# ACP_INTERACTION_MAX_PENDING_PER_SESSION=8 # Pending durable interactions allowed per chat
# ACP_INTERACTION_REQUEST_MAX_BYTES=32768 # Encrypted request detail cap
# ACP_INTERACTION_OPTIONS_MAX_COUNT=16 # Permission option count cap
# ACP_INTERACTION_OPTION_NAME_MAX_CHARS=200 # Permission option display cap
# ACP_INTERACTION_FORM_SCHEMA_MAX_BYTES=16384 # Encrypted form schema cap
# ACP_INTERACTION_FORM_SCHEMA_MAX_PROPERTIES=20 # Form schema property cap
# ACP_INTERACTION_FORM_SCHEMA_MAX_ENUM=50 # Form enum option cap
# ACP_INTERACTION_ANSWER_MAX_BYTES=16384 # Encrypted answer/decision cap
# ACP_INTERACTION_ANSWER_STRING_MAX_BYTES=4096 # Individual answer string cap
# ACP_INTERACTION_RETRY_DELAYS_MS=1000,5000,30000,120000,300000 # Durable answer outbox retry sequence
# ACP_INTERACTION_RETRY_STEADY_MS=300000 # Steady retry delay after the sequence
# ACP_INTERACTION_DELIVERY_WINDOW_MS=900000 # Max answer delivery retry window after accepted decision
# ACP_INTERACTION_SENSITIVE_PURGE_MS=3600000 # Purge encrypted details/answers after terminal state
# ACP_INTERACTION_SUMMARY_RETENTION_MS=2592000000 # Retain bounded settled summaries for 30d
# ACP_INTERACTION_SUMMARY_LAST_SETTLED=100 # Min settled summaries preserved during retention compaction
# ACP_INTERACTION_SNAPSHOT_LAST_SETTLED=20 # Settled summaries returned per snapshot page
# CREDENTIAL_LIMIT_WARNING_PERCENT=75 # Advisory provider credential quota warning threshold
# CREDENTIAL_LIMIT_CRITICAL_PERCENT=90 # Advisory provider credential quota critical threshold

Expand Down
198 changes: 198 additions & 0 deletions apps/api/src/durable-objects/interaction-store-model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import {
type AcpInteractionAnswerDecision,
type AcpInteractionRuntimeCreate,
type AcpInteractionRuntimeSettle,
type AcpInteractionSafeSummary,
} from '@simple-agent-manager/shared';

import { canonicalJson } from '../lib/canonical-json';
import type { AcpInteractionConfig } from '../services/acp-interaction-config';

export type InteractionRow = {
interaction_id: string;
project_id: string;
chat_session_id: string;
agent_session_id: string;
kind: string;
state: string;
generation: string;
runtime_identity: string;
payload_hash: string;
encrypted_detail: string | null;
detail_iv: string | null;
detail_purged_at: number | null;
safe_summary_json: string;
upstream_request_id: string | null;
created_at: number;
updated_at: number;
deadline_at: number;
answered_at: number | null;
answer_key: string | null;
answer_body_hash: string | null;
decision_kind: string | null;
decision_hash: string | null;
encrypted_answer: string | null;
answer_iv: string | null;
encrypted_decision: string | null;
decision_iv: string | null;
delivery_state: string | null;
delivery_attempts: number;
delivery_deadline_at: number | null;
last_delivery_error: string | null;
attention_marker_id: string | null;
attention_projection_state: string | null;
terminal_at: number | null;
purge_at: number | null;
};

export interface InteractionStoreCreateInput extends AcpInteractionRuntimeCreate {
projectId: string;
chatSessionId: string;
}

export interface InteractionStoreAnswerInput {
projectId: string;
chatSessionId: string;
interactionId: string;
answerKey: string;
answerBodyHash: string;
decision: AcpInteractionAnswerDecision;
}

export interface InteractionStoreSettleInput extends AcpInteractionRuntimeSettle {
projectId: string;
chatSessionId: string;
}

export type InteractionStoreCreateResult =
| { status: 'created' | 'existing'; summary: AcpInteractionSafeSummary }
| {
status: 'disabled' | 'conflict' | 'too_many_pending' | 'expired' | 'invalid';
reason: string;
};

export type InteractionStoreAnswerResult =
| {
status: 'answered' | 'already_answered';
summary: AcpInteractionSafeSummary;
delivery: { generation: string; runtimeIdentity: string };
}
| {
status: 'not_found' | 'stale' | 'conflict' | 'answer_key_conflict' | 'payload_too_large';
reason: string;
};

export interface InteractionStoreSnapshot {
pending: AcpInteractionSafeSummary[];
settled: AcpInteractionSafeSummary[];
cursor: string | null;
}

export function nowMs(): number {
return Date.now();
}

export async function sha256(value: string): Promise<string> {
const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
}

export function parseSummary(row: InteractionRow): AcpInteractionSafeSummary {
const safeSummary = JSON.parse(row.safe_summary_json) as unknown;
return {
interactionId: row.interaction_id,
kind: row.kind as AcpInteractionSafeSummary['kind'],
state: row.state as AcpInteractionSafeSummary['state'],
createdAt: row.created_at,
updatedAt: row.updated_at,
deadlineAt: row.deadline_at,
answeredAt: row.answered_at,
deliveryState: row.delivery_state as AcpInteractionSafeSummary['deliveryState'],
attentionMarkerId: row.attention_marker_id,
toolCallId:
typeof safeSummary === 'object' &&
safeSummary !== null &&
'toolCallId' in safeSummary &&
typeof safeSummary.toolCallId === 'string'
? safeSummary.toolCallId
: null,
};
}

export function terminalState(state: string): boolean {
return [
'delivery_confirmed',
'delivery_unconfirmed',
'interrupted',
'expired',
'cancelled',
].includes(state);
}

export function detailBoundsViolation(
detail: unknown,
config: AcpInteractionConfig
): string | null {
return detailRecordBoundsViolation(asRecord(detail), config);
}

function detailRecordBoundsViolation(
record: Record<string, unknown> | null,
config: AcpInteractionConfig
): string | null {
if (!record) return null;
const optionsViolation = optionsBoundsViolation(record, config);
if (optionsViolation) return optionsViolation;
return schemaBoundsViolation(record, config);
}

function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object') return null;
if (Array.isArray(value)) return null;
return value as Record<string, unknown>;
}

function optionsBoundsViolation(
record: Record<string, unknown>,
config: AcpInteractionConfig
): string | null {
if (Array.isArray(record.options) && record.options.length > config.optionsMaxCount) {
return 'request options exceed configured maximum';
}
return null;
}

function schemaBoundsViolation(
record: Record<string, unknown>,
config: AcpInteractionConfig
): string | null {
const schema = record.schema ?? record.formSchema;
if (schema === undefined) return null;
const schemaJson = canonicalJson(schema);
if (new TextEncoder().encode(schemaJson).byteLength > config.formSchemaMaxBytes) {
return 'form schema exceeds configured maximum';
}
return schemaObjectBoundsViolation(schema, config);
}

function schemaObjectBoundsViolation(
schema: unknown,
config: AcpInteractionConfig
): string | null {
const schemaRecord = asRecord(schema);
if (!schemaRecord) return null;
const properties = asRecord(schemaRecord.properties);
if (properties && Object.keys(properties).length > config.formSchemaMaxProperties) {
return 'form schema properties exceed configured maximum';
}
if (hasEnumOverflow(schema, config.formSchemaMaxEnum)) return 'form schema enum exceeds configured maximum';
return null;
}

function hasEnumOverflow(value: unknown, maxEnum: number): boolean {
if (!value || typeof value !== 'object') return false;
if (Array.isArray(value)) return value.some((item) => hasEnumOverflow(item, maxEnum));
const record = value as Record<string, unknown>;
if (Array.isArray(record.enum) && record.enum.length > maxEnum) return true;
return Object.values(record).some((item) => hasEnumOverflow(item, maxEnum));
}
Loading
Loading