diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d923c8c..303e98e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: run: dart pub get - name: test - run: dart test packages/intentcall_schema packages/intentcall_core packages/intentcall_mcp packages/intentcall_webmcp packages/intentcall_gemma packages/intentcall_apple packages/intentcall_android packages/intentcall_platform packages/intentcall_codegen packages/intentcall_testing tool/intentcall + run: dart test packages/intentcall_schema packages/intentcall_core packages/intentcall_session packages/intentcall_mcp packages/intentcall_webmcp packages/intentcall_gemma packages/intentcall_apple packages/intentcall_android packages/intentcall_platform packages/intentcall_codegen packages/intentcall_testing tool/intentcall - name: analyze run: dart analyze . diff --git a/PUBLISHING.md b/PUBLISHING.md index 128883c..7f5e5f3 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -15,9 +15,10 @@ checks only as historical/diagnostic guidance. 1. `intentcall_schema` 2. `intentcall_core` -3. `intentcall_mcp`, `intentcall_webmcp`, `intentcall_gemma`, `intentcall_apple`, `intentcall_android`, `intentcall_codegen` -4. `intentcall_platform` (Flutter plugin — may need `flutter pub publish`) -5. `intentcall_testing` +3. `intentcall_session` +4. `intentcall_mcp`, `intentcall_webmcp`, `intentcall_gemma`, `intentcall_apple`, `intentcall_android`, `intentcall_codegen` +5. `intentcall_platform` (Flutter plugin — may need `flutter pub publish`) +6. `intentcall_testing` ## Commands diff --git a/README.md b/README.md index 0e32b44..f9eeda4 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ GitHub: [Arenukvern/intentcall](https://github.com/Arenukvern/intentcall) |---------|------| | `intentcall_schema` | Wire types, validation, `AgentResult` | | `intentcall_core` | Registry, runtime, `AgentCallEntry` | +| `intentcall_session` | Runtime session lifecycle, persisted session state, and registry execution inside a session | | `intentcall_mcp` | MCP publish adapter (`dart_mcp`) | | `intentcall_webmcp` | WebMCP hot-sync adapter | | `intentcall_platform` | Native/web emitters, protocol fallback artifacts, and Flutter plugin | diff --git a/docs/DESIGN_FAQ.mdx b/docs/DESIGN_FAQ.mdx index 2c99e36..ee39fa1 100644 --- a/docs/DESIGN_FAQ.mdx +++ b/docs/DESIGN_FAQ.mdx @@ -40,6 +40,29 @@ A: `AgentRegistry`, `AgentCallEntry`, and `RegisteredAgentIntent` are the curren **Q: Why are there separate `intentcall_apple` / `intentcall_android` / `intentcall_gemma` packages instead of one `intentcall_native`?** A: Native surface adapters differ sharply in their platform SDKs and entitlement requirements. A single `intentcall_native` would force all three sets of platform SDKs into every build. Platform-specific packages let Flutter tree-shaker and pubspec `platforms` keys exclude irrelevant targets cleanly. +## Runtime sessions + +**Q: Why does `intentcall_session` exist?** +A: Sessions are runtime context for calling intents: which live app/tool endpoint is active, how it was selected, and when it was last used. That belongs beside IntentCall invocation, not inside a Flutter MCP consumer repo or a facade package. See [ADR 0014](decisions/0014-own-runtime-sessions-in-intentcall.md). + +**Q: What is a session in IntentCall?** +A: A session is a persisted runtime attachment record, not a command catalog and not a transcript store. It keeps identity, endpoint, connection mode, active/sticky selection, and timestamps so CLI, MCP, app, and agent flows can reconnect to the same runtime without importing transport internals. + +**Q: What is the difference between a session manager and a broker?** +A: `IntentSessionManager` owns lifecycle and persistence; `IntentSessionExecutor` resolves a session before invoking an `AgentRegistry`. A broker is a product-level composition of sessions, registry invocation, transport, and domain artifacts, so IntentCall exposes the reusable pieces instead of naming a separate broker facade. + +**Q: Why keep file-backed persistence instead of using only memory?** +A: CLI/MCP debug loops often span multiple process calls, so memory-only state would lose the active endpoint between commands. `StateStore`, `StateLockManager`, and `SafeFileWriter` keep the existing durable behavior while still allowing tests or embedded hosts to provide temporary files. + +**Q: What remains adapter-specific after session extraction?** +A: The `IntentSessionConnector` implementation remains runtime-specific. Flutter MCP keeps VM service discovery, DTD, Flutter extension calls, screenshots, widget inspection, and concrete CLI/MCP wiring; another runtime should provide its own connector and reuse the session manager. + +**Q: Why is dynamic registry not part of `intentcall_session`?** +A: Dynamic registry is registry and adapter responsibility: descriptors, resource/tool snapshots, events, validation, and invocation are IntentCall core/MCP concerns. Sessions answer "which runtime am I attached to?", while registry answers "what can I call there?" + +**Q: Why does `intentcall_session` include `IntentSnapshotStore`?** +A: Snapshot persistence is session-adjacent durable state: save a JSON runtime artifact, list it, load it, and diff it later. The store intentionally does not execute commands or know any transport; concrete hosts such as Flutter MCP build domain snapshots and pass plain JSON payloads into it. + --- ## Transport model diff --git a/docs/DX_FAQ.mdx b/docs/DX_FAQ.mdx index 80fcfdc..8b0d276 100644 --- a/docs/DX_FAQ.mdx +++ b/docs/DX_FAQ.mdx @@ -91,6 +91,109 @@ Wire types (`AgentResult`, validation helpers, resource input schemas) live in ` --- +## 🧭 Runtime sessions + +**Q: How do I add reusable runtime session lifecycle to a CLI or tool?** + +Use `intentcall_session` with a connector that knows how to attach to your runtime. The connector owns target discovery and transport details; `IntentSessionManager` owns persisted session state. + +```dart +import 'package:intentcall_session/intentcall_session.dart'; + +final sessions = IntentSessionManager( + connector: myConnector, + stateStore: StateStore(path: '.intentcall/session_state.json'), +); + +await sessions.load(); +final start = await sessions.startSession( + const IntentSessionStartRequest( + mode: IntentSessionConnectionMode.uri, + uri: 'ws://127.0.0.1:8181/ws', + sessionId: 'debug', + ), +); +``` + +**Q: What does my connector need to implement?** + +Implement `IntentSessionConnector`. Return a stable `activeEndpointDisplay` after `connect`; throw `IntentSessionConnectionException` with `multipleTargets`, `targetNotFound`, `noTargets`, `invalidUri`, or `invalidTargetId` when selection fails. + +```dart +final class MyConnector implements IntentSessionConnector { + @override + String? activeEndpointDisplay; + + @override + Map get lastSelectionDiagnostics => const {}; + + @override + Future> connect({ + IntentSessionConnectionMode mode = IntentSessionConnectionMode.auto, + String? targetId, + String? uri, + String? host, + int? port, + bool forceReconnect = false, + }) async { + activeEndpointDisplay = uri ?? 'runtime://current'; + return {'connected': true, 'reusedConnection': false}; + } + + @override + Future disconnect() async { + activeEndpointDisplay = null; + } +} +``` + +**Q: How do I invoke an intent inside the active session?** + +Use `IntentSessionExecutor`. It attaches first, invokes the registry, and marks the session used after successful calls. + +```dart +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_session/intentcall_session.dart'; + +final executor = IntentSessionExecutor( + sessions: sessions, + registry: registry, +); + +final result = await executor.invoke( + qualifiedName: 'debug_select', + arguments: const {'id': 'node-7'}, + sessionId: 'debug', +); +``` + +**Q: Should I build a broker package on top of this?** + +Usually no. Compose `IntentSessionManager`, `IntentSessionExecutor`, `AgentRegistry`, and your transport adapter directly; introduce a named broker only when it owns real product behavior such as routing policy, artifact storage, or multi-runtime coordination. + +**Q: How do I persist and diff JSON runtime snapshots?** + +Use `IntentSnapshotStore` for storage only. Build the snapshot payload in your host, then let the store handle safe writes, listing, loading, and structural diffs. + +```dart +final snapshots = IntentSnapshotStore( + snapshotsDir: '.intentcall/snapshots', +); + +await snapshots.saveSnapshot( + id: 'before', + snapshot: const { + 'id': 'before', + 'createdAt': '2026-06-22T00:00:00.000Z', + 'result': {'selected': 'node-7'}, + }, +); + +final diff = await snapshots.diffSnapshots(fromId: 'before', toId: 'after'); +``` + +--- + ## 🧭 IntentPack direction **Q: Should I use `IntentPack` today?** @@ -142,6 +245,17 @@ No. You can register `AgentCallEntry` objects manually. Codegen is a convenience Add `intentcall_testing` as a `dev_dependency` and use the provided `AgentCallContractTest` mixin. See the test suite in `packages/intentcall_mcp/test/` for a concrete example. +**Q: How do I test session lifecycle without Flutter or MCP?** + +Use an in-memory fake connector and a temporary `StateStore` path. This proves persistence, lock handling, and session executor behavior without importing adapter internals. + +```dart +final manager = IntentSessionManager( + connector: FakeConnector(endpoint: 'runtime://test'), + stateStore: StateStore(path: '${tempDir.path}/state.json'), +); +``` + **Q: How do I run integration tests against the mcp_flutter sibling repo?** ```bash @@ -158,9 +272,10 @@ make check-intentcall-integration 1. `intentcall_schema` 2. `intentcall_core` -3. `intentcall_mcp`, `intentcall_webmcp`, `intentcall_gemma`, `intentcall_apple`, `intentcall_android`, `intentcall_codegen` (parallel) -4. `intentcall_platform` -5. `intentcall_testing` +3. `intentcall_session` +4. `intentcall_mcp`, `intentcall_webmcp`, `intentcall_gemma`, `intentcall_apple`, `intentcall_android`, `intentcall_codegen` (parallel) +5. `intentcall_platform` +6. `intentcall_testing` **Q: How do I publish?** @@ -202,10 +317,9 @@ dependency_overrides: # … etc ``` -After a GitHub rename to `intentcall`, update the path to -`../intentcall/packages/…` or use `INTENTCALL_ROOT` env var for local source -validation: +If your checkout uses a different local folder name, update the path or use +`INTENTCALL_ROOT` for local source validation: ```bash -INTENTCALL_ROOT=~/mcp/intentcall make check-intentcall-integration +INTENTCALL_ROOT=~/mcp/agentkit make check-intentcall-integration ``` diff --git a/docs/decisions/0014-own-runtime-sessions-in-intentcall.md b/docs/decisions/0014-own-runtime-sessions-in-intentcall.md new file mode 100644 index 0000000..9ae8cab --- /dev/null +++ b/docs/decisions/0014-own-runtime-sessions-in-intentcall.md @@ -0,0 +1,71 @@ +--- +status: accepted +date: 2026-06-22 +decision-makers: IntentCall maintainers +consulted: +informed: +--- + +# Own runtime sessions in IntentCall + +## Context and Problem Statement + +IntentCall already owns the reusable callable surface: registry, descriptors, +invocation, results, artifacts, and registry events. `mcp_flutter` previously +grew CLI session state around Flutter VM connections, then a temporary broker +extraction risked adding another command and dynamic-registry layer beside +IntentCall. + +Downstream tools need the same session mechanics without importing Flutter MCP +server internals: start or attach to a live runtime, persist the selected +endpoint, invoke registered intents, and keep file-backed session state durable. + +## Decision Drivers + +* **Single command model** - IntentCall invocation is the command envelope. +* **No facade packages** - public packages must own behavior, not re-export it. +* **Hard-cut clarity** - pre-release consumers should update imports instead of + carrying stale compatibility exports. +* **Runtime neutrality** - sessions should work for CLI, MCP, apps, and tools + without pulling in Flutter VM or MCP server dependencies. +* **Persistence continuity** - existing file-backed session behavior remains + the default. + +## Considered Options + +* **Keep a broker package in `mcp_flutter`** - rejected because it would sit + beside IntentCall and duplicate registry/invocation ownership. +* **Rename broker to a toolkit session package** - rejected because reusable + session semantics belong with the IntentCall runtime, not a consumer repo. +* **Move sessions into IntentCall** - chosen because sessions are runtime + context for IntentCall invocation. + +## Decision Outcome + +Create `intentcall_session` as the owner of runtime session lifecycle and +persistence. It provides file-backed session state, state locking, safe writes, +session start/attach/end operations, and an `AgentRegistry` session executor. + +The package does not define a dynamic registry, command catalog, artifact model, +transport, Flutter VM connector, MCP server, or visual debugger. Those remain in +`intentcall_core`, `intentcall_schema`, adapter packages, or concrete consumer +repos. + +`mcp_flutter` consumes `intentcall_session` directly. The temporary broker +package and compatibility re-export shims are removed. + +### Consequences + +* Good, because downstreams use the same session behavior without Flutter MCP + server internals. +* Good, because IntentCall remains the single owner of dynamic registry and + invocation semantics. +* Neutral, because Flutter MCP still needs a concrete connector adapter for VM + service targets. +* Bad, because pre-release consumers must update imports; accepted because stale + compatibility layers would obscure the real owner. + +## Links + +* [NORTH_STAR.md](../NORTH_STAR.mdx) +* [DESIGN_FAQ.md](../DESIGN_FAQ.mdx) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 99c2fab..ae2539e 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -3,7 +3,7 @@ Architecture Decision Records (ADRs) for IntentCall. Format: [MADR](https://adr.github.io/madr/) — see any existing ADR for the template. -Next ADR number: **0014** +Next ADR number: **0015** --- @@ -15,6 +15,7 @@ Next ADR number: **0014** | [0011](0011-agent-skills-discoverability-for-intentcall.md) | accepted | Agent Skills Discoverability and Custom Skills for IntentCall | 2026-06-02 | | [0012](0012-adopt-platform-support-tiers.md) | accepted | Adopt platform support tiers for IntentCall | 2026-06-10 | | [0013](0013-delete-implemented-plans-after-durable-extraction.md) | accepted | Delete implemented plans after durable extraction | 2026-06-10 | +| [0014](0014-own-runtime-sessions-in-intentcall.md) | accepted | Own runtime sessions in IntentCall | 2026-06-22 | --- diff --git a/justfile b/justfile index 1e1e82a..20baa6f 100644 --- a/justfile +++ b/justfile @@ -6,7 +6,7 @@ default: # Run tests for all packages in the workspace test: - dart test packages/intentcall_schema packages/intentcall_core packages/intentcall_mcp packages/intentcall_webmcp packages/intentcall_gemma packages/intentcall_apple packages/intentcall_android packages/intentcall_platform packages/intentcall_codegen packages/intentcall_testing tool/intentcall + dart test packages/intentcall_schema packages/intentcall_core packages/intentcall_session packages/intentcall_mcp packages/intentcall_webmcp packages/intentcall_gemma packages/intentcall_apple packages/intentcall_android packages/intentcall_platform packages/intentcall_codegen packages/intentcall_testing tool/intentcall # Analyze the Dart code in the workspace analyze: @@ -33,7 +33,7 @@ publish-preflight-first: publish-execute: dart run tool/intentcall/bin/intentcall.dart publish-all --execute -# Check for path dependencies pointing to intentcall/packages +# Check for local IntentCall path dependencies in publishable packages check-path-deps: dart run tool/intentcall/bin/intentcall.dart check-path-deps diff --git a/packages/intentcall_core/README.md b/packages/intentcall_core/README.md index 8955b65..ed393cb 100644 --- a/packages/intentcall_core/README.md +++ b/packages/intentcall_core/README.md @@ -40,6 +40,8 @@ await runtime.start(); - `AgentResult.envelope` / `resourceEnvelope` (`intentcall_schema`) - `AgentWireArgs` for string-key maps +- Tool/resource registration contracts for hosts that expose capability + surfaces without depending on a concrete transport adapter - `AgentClientInstall.once` in `mcp_toolkit` for lazy registration ## Migration helpers @@ -59,6 +61,8 @@ Use this import for `MigrateAgentEntriesMigrator`, ## Related packages - `intentcall_schema` — results, validation, wire args +- `intentcall_session` — reusable runtime session state, lifecycle, and JSON + snapshot persistence - `intentcall_mcp` — MCP bridge, publish adapter, resource mapper - `intentcall_webmcp` — WebMCP `modelContext` publish adapter - `intentcall_gemma` — on-device Gemma function-calling adapter diff --git a/packages/intentcall_core/lib/intentcall_core.dart b/packages/intentcall_core/lib/intentcall_core.dart index a8bd327..4990f4b 100644 --- a/packages/intentcall_core/lib/intentcall_core.dart +++ b/packages/intentcall_core/lib/intentcall_core.dart @@ -9,6 +9,9 @@ export 'src/intent/registered_agent_intent.dart'; export 'src/module/agent_module.dart'; export 'src/module/agent_module_from_entries.dart'; export 'src/naming/qualified_name.dart'; +export 'src/registration/resource_registration.dart'; +export 'src/registration/resource_template_registration.dart'; +export 'src/registration/tool_registration.dart'; export 'src/registry/agent_registry.dart'; export 'src/registry/agent_registry_errors.dart'; export 'src/registry/in_memory_agent_registry.dart'; diff --git a/packages/intentcall_core/lib/src/authoring/agent_call_entry.dart b/packages/intentcall_core/lib/src/authoring/agent_call_entry.dart index 2dcd22c..cea1b7d 100644 --- a/packages/intentcall_core/lib/src/authoring/agent_call_entry.dart +++ b/packages/intentcall_core/lib/src/authoring/agent_call_entry.dart @@ -24,8 +24,7 @@ typedef _AgentCallEntryValue = ({ extension type const AgentCallEntry._( MapEntry _entry -) - implements MapEntry { +) implements MapEntry { factory AgentCallEntry.tool({ required final String namespace, required final String name, @@ -95,10 +94,7 @@ extension type const AgentCallEntry._( ); registration.validate(coerced); return registration.execute( - AgentInvocation( - descriptor: registration.descriptor, - arguments: coerced, - ), + AgentInvocation(descriptor: registration.descriptor, arguments: coerced), ); } } diff --git a/packages/intentcall_core/lib/src/intent/registered_agent_intent.dart b/packages/intentcall_core/lib/src/intent/registered_agent_intent.dart index 2a84760..8722d6a 100644 --- a/packages/intentcall_core/lib/src/intent/registered_agent_intent.dart +++ b/packages/intentcall_core/lib/src/intent/registered_agent_intent.dart @@ -3,7 +3,8 @@ import 'package:intentcall_schema/intentcall_schema.dart'; import 'agent_intent_descriptor.dart'; import 'agent_invocation.dart'; -typedef AgentExecutor = Future Function(AgentInvocation invocation); +typedef AgentExecutor = + Future Function(AgentInvocation invocation); typedef AgentValidator = void Function(AgentArguments arguments); final class RegisteredAgentIntent { diff --git a/packages/intentcall_core/lib/src/naming/qualified_name.dart b/packages/intentcall_core/lib/src/naming/qualified_name.dart index e51a7b7..d0c4fa1 100644 --- a/packages/intentcall_core/lib/src/naming/qualified_name.dart +++ b/packages/intentcall_core/lib/src/naming/qualified_name.dart @@ -12,14 +12,15 @@ void validateBareName(final String name) { } } -String qualifyName({required final String namespace, required final String name}) { +String qualifyName({ + required final String namespace, + required final String name, +}) { validateNamespace(namespace); validateBareName(name); final prefix = '${namespace}_'; if (name.startsWith(prefix)) { - throw ArgumentError( - 'Bare name must not include namespace prefix: $name', - ); + throw ArgumentError('Bare name must not include namespace prefix: $name'); } return '${namespace}_$name'; } diff --git a/packages/intentcall_core/lib/src/registration/resource_registration.dart b/packages/intentcall_core/lib/src/registration/resource_registration.dart new file mode 100644 index 0000000..adca4c5 --- /dev/null +++ b/packages/intentcall_core/lib/src/registration/resource_registration.dart @@ -0,0 +1,23 @@ +import 'package:intentcall_schema/intentcall_schema.dart'; +import 'package:meta/meta.dart'; + +/// Handler for a capability resource read using transport-agnostic [AgentResult]. +typedef ResourceHandler = Future Function(String uri); + +/// A resource a capability wants a host to expose. +@immutable +final class ResourceRegistration { + const ResourceRegistration({ + required this.uri, + required this.name, + required this.description, + required this.mimeType, + required this.handler, + }); + + final String uri; + final String name; + final String description; + final String mimeType; + final ResourceHandler handler; +} diff --git a/packages/intentcall_core/lib/src/registration/resource_template_registration.dart b/packages/intentcall_core/lib/src/registration/resource_template_registration.dart new file mode 100644 index 0000000..5ba1486 --- /dev/null +++ b/packages/intentcall_core/lib/src/registration/resource_template_registration.dart @@ -0,0 +1,21 @@ +import 'package:meta/meta.dart'; + +import 'resource_registration.dart'; + +/// A parameterized resource template a capability wants a host to expose. +@immutable +final class ResourceTemplateRegistration { + const ResourceTemplateRegistration({ + required this.uriTemplate, + required this.name, + required this.description, + required this.mimeType, + required this.handler, + }); + + final String uriTemplate; + final String name; + final String description; + final String mimeType; + final ResourceHandler handler; +} diff --git a/packages/intentcall_core/lib/src/registration/tool_registration.dart b/packages/intentcall_core/lib/src/registration/tool_registration.dart new file mode 100644 index 0000000..c71a310 --- /dev/null +++ b/packages/intentcall_core/lib/src/registration/tool_registration.dart @@ -0,0 +1,24 @@ +import 'package:intentcall_schema/intentcall_schema.dart'; +import 'package:meta/meta.dart'; + +/// Handler for a capability tool using transport-agnostic [AgentResult]. +typedef ToolHandler = Future Function(AgentArguments arguments); + +/// A tool a capability wants a host to expose. +/// +/// [name] is the bare tool name. Hosts can apply their own namespace or +/// transport naming policy when publishing the tool. +@immutable +final class ToolRegistration { + const ToolRegistration({ + required this.name, + required this.description, + required this.inputSchema, + required this.handler, + }); + + final String name; + final String description; + final Map inputSchema; + final ToolHandler handler; +} diff --git a/packages/intentcall_mcp/README.md b/packages/intentcall_mcp/README.md index 991ffe1..fbc7b2f 100644 --- a/packages/intentcall_mcp/README.md +++ b/packages/intentcall_mcp/README.md @@ -5,4 +5,29 @@ MCP bridge: `McpPublishAdapter`, `ToolRegistration`, registry ↔ `dart_mcp` publish. -Depends on `intentcall_core` and `intentcall_schema`. Only intentcall package that imports `dart_mcp`. \ No newline at end of file +Depends on `intentcall_core` and `intentcall_schema`. Only intentcall package that imports `dart_mcp`. + +## What it owns + +- publishing `AgentRegistry` tools to MCP tools +- publishing IntentCall resources and resource templates to MCP resources +- mapping `AgentResult` success/failure envelopes to MCP tool/resource results +- hot-syncing registry events into the `dart_mcp` server surface + +It does not own runtime sessions, dynamic discovery inside an app, Flutter VM +inspection, screenshots, or CLI process management. Use `intentcall_session` for +session lifecycle and concrete hosts such as `mcp_flutter` for runtime adapters. + +## Resource behavior + +Static IntentCall resources are also published as query-tolerant MCP resource +templates when the host provides a template publisher. This keeps resources +visible in `resources/list` while allowing reads such as: + +```text +visual://localhost/view/details?uri=ws%3A%2F%2F127.0.0.1%2Fws +``` + +The adapter de-duplicates resource templates by URI pattern. This matters for +dynamic hosts that may publish the same app resource through both host +capability registration and registry discovery. diff --git a/packages/intentcall_mcp/lib/src/agent_bridge.dart b/packages/intentcall_mcp/lib/src/agent_bridge.dart index c7781dd..53b2e14 100644 --- a/packages/intentcall_mcp/lib/src/agent_bridge.dart +++ b/packages/intentcall_mcp/lib/src/agent_bridge.dart @@ -1,9 +1,6 @@ import 'package:intentcall_core/intentcall_core.dart'; import 'package:intentcall_schema/intentcall_schema.dart'; -import 'resource_registration.dart'; -import 'resource_template_registration.dart'; -import 'tool_registration.dart'; import 'uri_template.dart'; /// Builds a [ToolRegistration] from a codegen [AgentCallEntry]. diff --git a/packages/intentcall_mcp/lib/src/mcp_publish_adapter.dart b/packages/intentcall_mcp/lib/src/mcp_publish_adapter.dart index 14c42aa..746427a 100644 --- a/packages/intentcall_mcp/lib/src/mcp_publish_adapter.dart +++ b/packages/intentcall_mcp/lib/src/mcp_publish_adapter.dart @@ -6,9 +6,6 @@ import 'package:intentcall_core/intentcall_core.dart'; import 'agent_bridge.dart'; import 'mcp_resource_mapper.dart'; import 'mcp_result_mapper.dart'; -import 'resource_registration.dart'; -import 'resource_template_registration.dart'; -import 'tool_registration.dart'; import 'uri_template.dart'; typedef McpToolPublisher = @@ -52,6 +49,7 @@ final class McpPublishAdapter implements AgentAdapter { final Set _publishedTools = {}; final Set _publishedResources = {}; final Set _publishedResourceTemplates = {}; + final Set _publishedResourceTemplatePatterns = {}; StreamSubscription? _events; AgentRegistry? _registry; @@ -241,6 +239,33 @@ final class McpPublishAdapter implements AgentAdapter { ), ); _publishedResources.add(key); + + final publishTemplate = publishResourceTemplate; + if (publishTemplate == null || _publishedResourceTemplates.contains(key)) { + return; + } + final uriTemplate = registration?.uri ?? d.effectiveResourceUri; + if (_publishedResourceTemplatePatterns.contains(uriTemplate)) { + return; + } + publishTemplate( + ResourceTemplate( + uriTemplate: uriTemplate, + name: registration?.name ?? d.name, + description: registration?.description ?? d.description, + mimeType: registration?.mimeType ?? d.mimeType ?? 'application/json', + ), + (final request) async { + final params = matchUriTemplate(uriTemplate, request.uri); + if (params == null) return null; + return agentResultToReadResourceResult( + await registry.invoke(key, {'uri': request.uri}), + uri: request.uri, + ); + }, + ); + _publishedResourceTemplates.add(key); + _publishedResourceTemplatePatterns.add(uriTemplate); } void _publishResourceTemplateIntent({ @@ -252,6 +277,9 @@ final class McpPublishAdapter implements AgentAdapter { final publish = publishResourceTemplate; if (publish == null) return; final uriTemplate = registration?.uriTemplate ?? descriptor!.resourceUri!; + if (_publishedResourceTemplatePatterns.contains(uriTemplate)) { + return; + } publish( ResourceTemplate( uriTemplate: uriTemplate, @@ -275,6 +303,7 @@ final class McpPublishAdapter implements AgentAdapter { }, ); _publishedResourceTemplates.add(key); + _publishedResourceTemplatePatterns.add(uriTemplate); } void _unpublishTransportKey(final String key) { diff --git a/packages/intentcall_mcp/lib/src/resource_registration.dart b/packages/intentcall_mcp/lib/src/resource_registration.dart index c103a63..5e8593f 100644 --- a/packages/intentcall_mcp/lib/src/resource_registration.dart +++ b/packages/intentcall_mcp/lib/src/resource_registration.dart @@ -1,23 +1,2 @@ -import 'package:intentcall_schema/intentcall_schema.dart'; -import 'package:meta/meta.dart'; - -/// Handler for a capability resource read — transport-agnostic [AgentResult]. -typedef ResourceHandler = Future Function(String uri); - -/// A resource the capability wants the host to expose. -@immutable -final class ResourceRegistration { - const ResourceRegistration({ - required this.uri, - required this.name, - required this.description, - required this.mimeType, - required this.handler, - }); - - final String uri; - final String name; - final String description; - final String mimeType; - final ResourceHandler handler; -} +export 'package:intentcall_core/intentcall_core.dart' + show ResourceHandler, ResourceRegistration; diff --git a/packages/intentcall_mcp/lib/src/resource_template_registration.dart b/packages/intentcall_mcp/lib/src/resource_template_registration.dart index f89e1d8..efd9fa1 100644 --- a/packages/intentcall_mcp/lib/src/resource_template_registration.dart +++ b/packages/intentcall_mcp/lib/src/resource_template_registration.dart @@ -1,21 +1,2 @@ -import 'package:meta/meta.dart'; - -import 'resource_registration.dart'; - -/// A parameterized resource template the capability wants the host to expose. -@immutable -final class ResourceTemplateRegistration { - const ResourceTemplateRegistration({ - required this.uriTemplate, - required this.name, - required this.description, - required this.mimeType, - required this.handler, - }); - - final String uriTemplate; - final String name; - final String description; - final String mimeType; - final ResourceHandler handler; -} +export 'package:intentcall_core/intentcall_core.dart' + show ResourceHandler, ResourceTemplateRegistration; diff --git a/packages/intentcall_mcp/lib/src/tool_registration.dart b/packages/intentcall_mcp/lib/src/tool_registration.dart index 09af4a1..c44cb7a 100644 --- a/packages/intentcall_mcp/lib/src/tool_registration.dart +++ b/packages/intentcall_mcp/lib/src/tool_registration.dart @@ -1,24 +1,2 @@ -import 'package:intentcall_schema/intentcall_schema.dart'; -import 'package:meta/meta.dart'; - -/// Handler for a capability tool — transport-agnostic [AgentResult]. -typedef ToolHandler = Future Function(AgentArguments arguments); - -/// A tool the capability wants the host to expose. -/// -/// [name] is the bare name (without prefix). The host applies the -/// `_` prefix when publishing to MCP clients. -@immutable -final class ToolRegistration { - const ToolRegistration({ - required this.name, - required this.description, - required this.inputSchema, - required this.handler, - }); - - final String name; - final String description; - final Map inputSchema; - final ToolHandler handler; -} +export 'package:intentcall_core/intentcall_core.dart' + show ToolHandler, ToolRegistration; diff --git a/packages/intentcall_mcp/test/mcp_publish_adapter_test.dart b/packages/intentcall_mcp/test/mcp_publish_adapter_test.dart index b22f19c..3aa8a81 100644 --- a/packages/intentcall_mcp/test/mcp_publish_adapter_test.dart +++ b/packages/intentcall_mcp/test/mcp_publish_adapter_test.dart @@ -75,6 +75,148 @@ void main() { }, ); + test( + 'McpPublishAdapter publishes static resources as query-tolerant templates', + () async { + final registry = InMemoryAgentRegistry(); + final publishedResources = + < + String, + FutureOr Function(ReadResourceRequest) + >{}; + final publishedTemplates = + < + String, + FutureOr Function(ReadResourceRequest) + >{}; + const uri = 'visual://localhost/view/details'; + + final adapter = McpPublishAdapter( + publishTool: (_, final _) {}, + unpublishTool: (_) {}, + publishResource: (final resource, final impl) { + publishedResources[resource.uri] = impl; + }, + unpublishResource: (_) {}, + publishResourceTemplate: (final template, final impl) { + publishedTemplates[template.uriTemplate] = impl; + }, + ); + + await adapter.attach(registry); + + registry.register( + RegisteredAgentIntent( + descriptor: AgentIntentDescriptor( + namespace: 'visual', + name: 'view_details', + description: 'view details', + kind: AgentIntentKind.resource, + inputSchema: const {'type': 'object'}, + resourceUri: uri, + mimeType: 'application/json', + ), + execute: (final invocation) async => AgentResult.success( + data: { + 'contents': [ + { + 'type': 'text', + 'text': '{"uri":"${invocation.arguments['uri']}"}', + 'mimeType': 'application/json', + }, + ], + }, + ), + ), + qualifiedNameOverride: uri, + ); + + await Future.delayed(Duration.zero); + expect(publishedResources, contains(uri)); + expect(publishedTemplates, contains(uri)); + + final queryRead = await publishedTemplates[uri]!( + ReadResourceRequest(uri: '$uri?uri=ws%3A%2F%2F127.0.0.1%2Fws'), + ); + expect(queryRead, isNotNull); + final text = (queryRead!.contents.first as TextResourceContents).text; + expect(text, contains('$uri?uri=ws%3A%2F%2F127.0.0.1%2Fws')); + + await adapter.detach(); + }, + ); + + test( + 'McpPublishAdapter de-duplicates resource templates by URI pattern', + () async { + final registry = InMemoryAgentRegistry(); + final publishedResources = + < + String, + FutureOr Function(ReadResourceRequest) + >{}; + final publishedTemplates = []; + const uri = 'intentcall://resource/app/state'; + + final adapter = McpPublishAdapter( + publishTool: (_, final _) {}, + unpublishTool: (_) {}, + publishResource: (final resource, final impl) { + publishedResources[resource.uri] = impl; + }, + unpublishResource: (_) {}, + publishResourceTemplate: (final template, final impl) { + publishedTemplates.add(template.uriTemplate); + }, + ); + + await adapter.attach(registry); + adapter.publishCapabilityResourceTemplate( + registry: registry, + capabilityId: 'app', + registration: ResourceTemplateRegistration( + uriTemplate: uri, + name: 'app_state', + description: 'App state', + mimeType: 'application/json', + handler: (_) async => AgentResult.success(), + ), + ); + + registry.register( + RegisteredAgentIntent( + descriptor: AgentIntentDescriptor( + namespace: 'dynamic', + name: 'app_state', + description: 'App state mirrored through dynamic discovery', + kind: AgentIntentKind.resource, + inputSchema: const {'type': 'object'}, + resourceUri: uri, + mimeType: 'application/json', + ), + execute: (_) async => AgentResult.success( + data: const { + 'contents': [ + { + 'type': 'text', + 'text': '{}', + 'mimeType': 'application/json', + }, + ], + }, + ), + ), + qualifiedNameOverride: 'dynamic_app_state', + ); + + await Future.delayed(Duration.zero); + expect(publishedTemplates, [uri]); + expect(publishedResources, contains(uri)); + + await adapter.detach(); + }, + ); + test('McpPublishAdapter detach does not unregister source intents', () async { final registry = InMemoryAgentRegistry() ..register( diff --git a/packages/intentcall_session/CHANGELOG.md b/packages/intentcall_session/CHANGELOG.md new file mode 100644 index 0000000..99e0bdc --- /dev/null +++ b/packages/intentcall_session/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## 0.1.0 + +- First pre-release of IntentCall runtime session persistence and invocation helpers. +- Includes file-backed session state, state locking, safe writes, session lifecycle + management, and an `AgentRegistry` session executor. diff --git a/packages/intentcall_session/LICENSE b/packages/intentcall_session/LICENSE new file mode 100644 index 0000000..ec57a7f --- /dev/null +++ b/packages/intentcall_session/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Anton Malofeev (Arenukvern) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/intentcall_session/README.md b/packages/intentcall_session/README.md new file mode 100644 index 0000000..f93d026 --- /dev/null +++ b/packages/intentcall_session/README.md @@ -0,0 +1,104 @@ +> WARNING: Pre-release (0.1.x) — Highly experimental. APIs may change without notice. Not for production. See the root PRE_RELEASE.md. + +# intentcall_session + +IntentCall runtime sessions for commandable tools and apps. + +Use this package when a CLI, MCP server, app host, or agent tool needs to keep a +durable attachment to a live runtime before invoking IntentCall registry entries. + +This package owns reusable runtime persistence mechanics: + +- session identity and persisted session state +- file-backed state storage, state locking, and safe writes +- lifecycle operations for start, attach, mark-used, and end +- invoking an `AgentRegistry` inside a resolved session +- JSON runtime snapshot storage, listing, loading, and diffing + +It deliberately does not define a dynamic registry, command catalog, artifact +model, transport, Flutter VM connection, MCP server, or visual debugger. Those +belong to `intentcall_core` / `intentcall_schema` or to concrete adapters. + +## Concepts + +| Concept | Purpose | +|---|---| +| `IntentSessionManager` | Starts, attaches, marks-used, and ends sessions. | +| `IntentSessionConnector` | Runtime-specific connection adapter implemented by the host. | +| `StateStore` | File-backed persisted state with tolerant JSON reads. | +| `StateLockManager` | Cross-process lock around state reads and writes. | +| `SafeFileWriter` | Atomic-ish durable file writes used by `StateStore`. | +| `IntentSessionExecutor` | Attaches to a session, invokes an `AgentRegistry`, then updates usage time. | +| `IntentSnapshotStore` | Stores and diffs JSON runtime artifacts without executing commands. | + +The connector is the only runtime-specific seam. Flutter MCP implements a +connector for VM service endpoints; another host can implement one for a device, +browser, daemon, simulator, or local service. + +## Start a session + +```dart +import 'package:intentcall_session/intentcall_session.dart'; + +final manager = IntentSessionManager( + connector: myConnector, + stateStore: StateStore(path: '.intentcall/session_state.json'), +); + +await manager.load(); +final result = await manager.startSession( + const IntentSessionStartRequest( + mode: IntentSessionConnectionMode.uri, + uri: 'ws://127.0.0.1:8181/ws', + sessionId: 'debug', + ), +); +``` + +## Invoke through a session + +```dart +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_session/intentcall_session.dart'; + +final executor = IntentSessionExecutor( + sessions: manager, + registry: registry, +); + +final result = await executor.invoke( + sessionId: 'debug', + qualifiedName: 'debug_select', + arguments: const {'id': 'node-7'}, +); +``` + +## Store JSON snapshots + +```dart +final snapshots = IntentSnapshotStore( + snapshotsDir: '.intentcall/snapshots', +); + +await snapshots.saveSnapshot( + id: 'before', + snapshot: const { + 'id': 'before', + 'createdAt': '2026-06-22T00:00:00.000Z', + 'payload': {'selected': 'node-7'}, + }, +); + +final diff = await snapshots.diffSnapshots(fromId: 'before', toId: 'after'); +``` + +Hosts own how snapshots are produced. For example, Flutter MCP has a command +snapshot service that executes its command catalog and stores the resulting JSON +through this package. + +## Boundaries + +`intentcall_session` is not a broker facade. A product broker can compose this +package with `intentcall_core`, `intentcall_schema`, an adapter such as +`intentcall_mcp`, and domain-specific artifact storage. Keep product policy in +that host; keep reusable session lifecycle here. diff --git a/packages/intentcall_session/analysis_options.yaml b/packages/intentcall_session/analysis_options.yaml new file mode 100644 index 0000000..f04c6cf --- /dev/null +++ b/packages/intentcall_session/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../analysis_options.yaml diff --git a/packages/intentcall_session/lib/intentcall_session.dart b/packages/intentcall_session/lib/intentcall_session.dart new file mode 100644 index 0000000..b2ee74b --- /dev/null +++ b/packages/intentcall_session/lib/intentcall_session.dart @@ -0,0 +1,10 @@ +library; + +export 'src/agent_session_executor.dart'; +export 'src/safe_writes.dart'; +export 'src/session_connector.dart'; +export 'src/session_manager.dart'; +export 'src/session_requests.dart'; +export 'src/snapshot_store.dart'; +export 'src/state_lock_manager.dart'; +export 'src/state_store.dart'; diff --git a/packages/intentcall_session/lib/src/agent_session_executor.dart b/packages/intentcall_session/lib/src/agent_session_executor.dart new file mode 100644 index 0000000..a700b04 --- /dev/null +++ b/packages/intentcall_session/lib/src/agent_session_executor.dart @@ -0,0 +1,43 @@ +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_schema/intentcall_schema.dart'; + +import 'session_manager.dart'; +import 'session_requests.dart'; + +/// Invokes IntentCall registry entries after resolving an optional session. +final class IntentSessionExecutor { + const IntentSessionExecutor({required this.sessions, required this.registry}); + + final IntentSessionManager sessions; + final AgentRegistry registry; + + Future invoke({ + required final String qualifiedName, + final AgentArguments arguments = const {}, + final String? sessionId, + final String? correlationId, + final bool forceReconnect = false, + }) async { + final attach = await sessions.attachSession( + IntentSessionAttachRequest( + sessionId: sessionId, + forceReconnect: forceReconnect, + ), + ); + if (!attach.ok) { + return attach; + } + + final result = await registry.invoke( + qualifiedName, + arguments, + correlationId: correlationId, + ); + + if (result.ok) { + await sessions.markSessionUsed(sessionId); + } + + return result; + } +} diff --git a/packages/intentcall_session/lib/src/json_helpers.dart b/packages/intentcall_session/lib/src/json_helpers.dart new file mode 100644 index 0000000..f5e4e0a --- /dev/null +++ b/packages/intentcall_session/lib/src/json_helpers.dart @@ -0,0 +1,18 @@ +// Copyright (c) 2025, IntentCall authors. +// Licensed under the MIT License. + +import 'package:from_json_to_json/from_json_to_json.dart'; + +Map jsonObjectOrEmpty(final Object? value) { + if (value is Map) { + return value; + } + if (value is Map) { + return value.cast(); + } + try { + return Map.from(jsonDecodeMap(value)); + } on Exception { + return const {}; + } +} diff --git a/packages/intentcall_session/lib/src/safe_writes.dart b/packages/intentcall_session/lib/src/safe_writes.dart new file mode 100644 index 0000000..99fb896 --- /dev/null +++ b/packages/intentcall_session/lib/src/safe_writes.dart @@ -0,0 +1,266 @@ +// Copyright (c) 2025, IntentCall authors. +// Licensed under the MIT License. + +import 'dart:io' as io; +import 'dart:math' as math; + +abstract final class SafeWriteStatus { + static const added = 'added'; + static const updated = 'updated'; + static const unchanged = 'unchanged'; + static const blocked = 'blocked'; +} + +final class SafeWriteOptions { + const SafeWriteOptions({ + this.check = false, + this.diff = false, + this.backup = false, + this.noOverwrite = false, + }); + + final bool check; + final bool diff; + final bool backup; + final bool noOverwrite; + + Map toJson() => { + 'check': check, + 'diff': diff, + 'backup': backup, + 'noOverwrite': noOverwrite, + }; +} + +final class SafeWriteResult { + const SafeWriteResult({ + required this.target, + required this.status, + required this.wrote, + required this.options, + this.backupPath, + this.diff, + }); + + final String target; + final String status; + final bool wrote; + final SafeWriteOptions options; + final String? backupPath; + final Map? diff; + + bool get blocked => status == SafeWriteStatus.blocked; + + Map toJson() => { + 'target': target, + 'status': status, + 'wrote': wrote, + 'options': options.toJson(), + if (backupPath != null) 'backupPath': backupPath, + if (diff != null) 'diff': diff, + }; +} + +// ignore: avoid_classes_with_only_static_members +abstract final class SafeFileWriter { + static Future writeTextFile({ + required final String path, + required final String content, + final SafeWriteOptions options = const SafeWriteOptions(), + }) async { + final file = io.File(path); + final existing = file.existsSync(); + final previousContent = existing ? file.readAsStringSync() : null; + final status = _statusForWrite( + exists: existing, + previousContent: previousContent, + nextContent: content, + ); + + final diff = options.diff + ? buildUnifiedDiffMetadata( + target: path, + previousContent: previousContent, + nextContent: content, + ) + : null; + + if (options.check) { + return SafeWriteResult( + target: path, + status: status, + wrote: false, + options: options, + diff: diff, + ); + } + + if (options.noOverwrite && existing) { + return SafeWriteResult( + target: path, + status: SafeWriteStatus.blocked, + wrote: false, + options: options, + diff: diff, + ); + } + + if (status == SafeWriteStatus.unchanged) { + return SafeWriteResult( + target: path, + status: SafeWriteStatus.unchanged, + wrote: false, + options: options, + diff: diff, + ); + } + + String? backupPath; + if (options.backup && existing) { + backupPath = createTimestampedBackupPath(path); + final backupFile = io.File(backupPath); + backupFile.parent.createSync(recursive: true); + file.copySync(backupPath); + } + + final tempPath = + '$path.tmp.${io.pid}.${DateTime.now().microsecondsSinceEpoch}'; + final tempFile = io.File(tempPath); + tempFile.parent.createSync(recursive: true); + tempFile.writeAsStringSync(content); + + if (!existing) { + tempFile.renameSync(path); + return SafeWriteResult( + target: path, + status: status, + wrote: true, + options: options, + backupPath: backupPath, + diff: diff, + ); + } + + final swapPath = + '$path.swap.${io.pid}.${DateTime.now().microsecondsSinceEpoch}'; + final swapFile = io.File(swapPath); + + file.renameSync(swapPath); + try { + tempFile.renameSync(path); + if (swapFile.existsSync()) { + swapFile.deleteSync(); + } + } on Exception { + try { + if (io.File(path).existsSync()) { + io.File(path).deleteSync(); + } + } on Exception { + // Best effort cleanup. + } + if (swapFile.existsSync()) { + swapFile.renameSync(path); + } + rethrow; + } + + return SafeWriteResult( + target: path, + status: status, + wrote: true, + options: options, + backupPath: backupPath, + diff: diff, + ); + } + + static String _statusForWrite({ + required final bool exists, + required final String? previousContent, + required final String nextContent, + }) { + if (!exists) { + return SafeWriteStatus.added; + } + if (previousContent == nextContent) { + return SafeWriteStatus.unchanged; + } + return SafeWriteStatus.updated; + } +} + +Map? buildUnifiedDiffMetadata({ + required final String target, + required final String? previousContent, + required final String nextContent, +}) { + if (previousContent == null && nextContent.isEmpty) { + return null; + } + if (previousContent != null && previousContent == nextContent) { + return null; + } + + return { + 'format': 'unified', + 'target': target, + 'text': _buildUnifiedDiffText( + target: target, + previousContent: previousContent, + nextContent: nextContent, + ), + }; +} + +String createTimestampedBackupPath(final String originalPath) { + final timestamp = DateTime.now().toUtc().toIso8601String().replaceAll( + ':', + '', + ); + return '$originalPath.backup.$timestamp'; +} + +String _buildUnifiedDiffText({ + required final String target, + required final String? previousContent, + required final String nextContent, +}) { + final before = _splitLines(previousContent ?? ''); + final after = _splitLines(nextContent); + + final lines = [ + '--- $target (before)', + '+++ $target (after)', + '@@ -1,${math.max(1, before.length)} +1,${math.max(1, after.length)} @@', + ]; + + final maxLength = math.max(before.length, after.length); + for (var index = 0; index < maxLength; index += 1) { + final left = index < before.length ? before[index] : null; + final right = index < after.length ? after[index] : null; + + if (left == right) { + if (left != null) { + lines.add(' $left'); + } + continue; + } + + if (left != null) { + lines.add('-$left'); + } + if (right != null) { + lines.add('+$right'); + } + } + + return lines.join('\n'); +} + +List _splitLines(final String source) { + if (source.isEmpty) { + return const []; + } + return source.split('\n'); +} diff --git a/packages/intentcall_session/lib/src/session_connector.dart b/packages/intentcall_session/lib/src/session_connector.dart new file mode 100644 index 0000000..aa834ff --- /dev/null +++ b/packages/intentcall_session/lib/src/session_connector.dart @@ -0,0 +1,36 @@ +import 'session_requests.dart'; + +abstract final class IntentSessionConnectionFailureReason { + static const multipleTargets = 'multipleTargets'; + static const targetNotFound = 'targetNotFound'; + static const noTargets = 'noTargets'; + static const invalidUri = 'invalidUri'; + static const invalidTargetId = 'invalidTargetId'; +} + +/// Runtime-specific connection failure surfaced to the session manager. +abstract interface class IntentSessionConnectionException implements Exception { + String get reasonName; + + String get message; + + Object? get details; +} + +/// The only runtime-specific operation required by IntentCall sessions. +abstract interface class IntentSessionConnector { + String? get activeEndpointDisplay; + + Map get lastSelectionDiagnostics; + + Future> connect({ + final IntentSessionConnectionMode mode = IntentSessionConnectionMode.auto, + final String? targetId, + final String? uri, + final String? host, + final int? port, + final bool forceReconnect = false, + }); + + Future disconnect(); +} diff --git a/packages/intentcall_session/lib/src/session_manager.dart b/packages/intentcall_session/lib/src/session_manager.dart new file mode 100644 index 0000000..208e6f8 --- /dev/null +++ b/packages/intentcall_session/lib/src/session_manager.dart @@ -0,0 +1,349 @@ +import 'dart:math'; + +import 'package:from_json_to_json/from_json_to_json.dart'; +import 'package:intentcall_schema/intentcall_schema.dart'; + +import 'json_helpers.dart'; +import 'session_connector.dart'; +import 'session_requests.dart'; +import 'state_lock_manager.dart'; +import 'state_store.dart'; + +final class IntentSessionManager { + IntentSessionManager({required this.connector, required this.stateStore}); + + final IntentSessionConnector connector; + final StateStore stateStore; + + PersistedState _state = const PersistedState(); + + PersistedState get state => _state; + + Future load() async { + _state = await stateStore.read(); + } + + SessionState? getSession(final String? sessionId) { + final resolvedId = _resolveSessionId(sessionId); + if (resolvedId == null || resolvedId.isEmpty) { + return null; + } + return _state.sessions[resolvedId]; + } + + String? get stickyEndpoint => + _state.activeSession?.endpoint ?? _state.stickyEndpoint; + + Future startSession( + final IntentSessionStartRequest request, + ) async { + try { + final connectionData = await connector.connect( + mode: request.mode, + targetId: request.targetId, + uri: request.uri, + host: request.host, + port: request.port, + forceReconnect: request.forceReconnect, + ); + + final endpoint = connector.activeEndpointDisplay; + if (endpoint == null || endpoint.isEmpty) { + return AgentResult.failure( + code: IntentSessionErrorCode.connectFailed, + message: 'Failed to resolve active endpoint after session start', + ); + } + + final id = request.sessionId ?? _newSessionId(); + + return await _withLockedResult(() async { + final current = await stateStore.readUnlocked(); + final now = DateTime.now().toUtc(); + final nextSession = SessionState( + id: id, + endpoint: endpoint, + createdAt: now, + lastUsedAt: now, + mode: request.mode.name, + host: request.host, + port: request.port, + uri: request.uri, + ); + + final nextSessions = { + ...current.sessions, + id: nextSession, + }; + + final nextState = current.copyWith( + activeSessionId: id, + sessions: nextSessions, + stickyEndpoint: endpoint, + lastMode: request.mode.name, + ); + + await stateStore.writeUnlocked(nextState); + _state = nextState; + + return AgentResult.success( + data: { + 'sessionId': id, + 'endpoint': endpoint, + 'mode': request.mode.name, + 'connected': true, + 'reusedConnection': connectionData['reusedConnection'] == true, + 'selectionDiagnostics': connector.lastSelectionDiagnostics, + }, + ); + }); + } on IntentSessionConnectionException catch (e) { + if (e.reasonName == + IntentSessionConnectionFailureReason.multipleTargets) { + return AgentResult.failure( + code: IntentSessionErrorCode.connectionSelectionRequired, + message: e.message, + details: _detailsMap(e.details), + ); + } + + return AgentResult.failure( + code: IntentSessionErrorCode.connectFailed, + message: 'Failed to start session: ${e.message}', + details: _detailsMap(e.details), + ); + } on Exception catch (e) { + return AgentResult.failure( + code: IntentSessionErrorCode.connectFailed, + message: 'Failed to start session: $e', + ); + } + } + + Future attachSession([ + final IntentSessionAttachRequest request = + const IntentSessionAttachRequest(), + ]) async { + final resolvedSession = await _withLockedResult(() async { + final current = await stateStore.readUnlocked(); + _state = current; + + final resolvedId = _resolveSessionId(request.sessionId); + if (resolvedId == null || resolvedId.isEmpty) { + return AgentResult.failure( + code: IntentSessionErrorCode.sessionNotFound, + message: 'Session not found', + details: {'requestedSessionId': request.sessionId}, + ); + } + + final session = current.sessions[resolvedId]; + if (session == null) { + return AgentResult.failure( + code: IntentSessionErrorCode.sessionNotFound, + message: 'Session not found', + details: {'requestedSessionId': request.sessionId}, + ); + } + + return AgentResult.success(data: {'session': session.toJson()}); + }); + + if (!resolvedSession.ok) { + return resolvedSession; + } + + final sessionJson = resolvedSession.data['session']; + final session = SessionState.fromJson( + (sessionJson! as Map).cast(), + ); + + try { + final data = await connector.connect( + mode: IntentSessionConnectionMode.uri, + uri: session.endpoint, + forceReconnect: request.forceReconnect, + ); + + return await _withLockedResult(() async { + await _markSessionUsedLocked( + session.id, + endpointOverride: session.endpoint, + ); + + return AgentResult.success(data: {'sessionId': session.id, ...data}); + }); + } on Exception catch (e) { + return AgentResult.failure( + code: IntentSessionErrorCode.connectFailed, + message: 'Failed to attach session ${session.id}: $e', + details: {'sessionId': session.id}, + ); + } + } + + Future endSession(final String? sessionId) async { + bool shouldDisconnect = false; + + final result = await _withLockedResult(() async { + final current = await stateStore.readUnlocked(); + _state = current; + + final resolvedId = _resolveSessionId(sessionId); + if (resolvedId == null || resolvedId.isEmpty) { + return AgentResult.failure( + code: IntentSessionErrorCode.sessionNotFound, + message: 'Session not found', + details: {'requestedSessionId': sessionId}, + ); + } + + final existing = current.sessions[resolvedId]; + if (existing == null) { + return AgentResult.failure( + code: IntentSessionErrorCode.sessionNotFound, + message: 'Session not found', + details: {'requestedSessionId': resolvedId}, + ); + } + + shouldDisconnect = current.activeSessionId == resolvedId; + + final nextSessions = {...current.sessions} + ..remove(resolvedId); + + final nextActive = current.activeSessionId == resolvedId + ? null + : current.activeSessionId; + + final sticky = nextActive == null + ? (nextSessions.values.isEmpty + ? current.stickyEndpoint + : nextSessions.values.last.endpoint) + : current.stickyEndpoint; + + final nextState = current.copyWith( + sessions: nextSessions, + activeSessionId: nextActive, + clearActiveSessionId: nextActive == null, + stickyEndpoint: sticky, + clearStickyEndpoint: sticky == null || sticky.isEmpty, + ); + + await stateStore.writeUnlocked(nextState); + _state = nextState; + + return AgentResult.success( + data: { + 'sessionId': resolvedId, + 'ended': true, + 'activeSessionId': nextState.activeSessionId, + 'remainingSessions': nextState.sessions.length, + }, + ); + }); + + if (result.ok && shouldDisconnect) { + await connector.disconnect(); + } + + return result; + } + + Future markSessionUsed( + final String? sessionId, { + final String? endpointOverride, + }) async { + final resolvedId = _resolveSessionId(sessionId); + if (resolvedId == null || resolvedId.isEmpty) { + return; + } + + await _withLockedResult(() async { + await _markSessionUsedLocked( + resolvedId, + endpointOverride: endpointOverride, + ); + return AgentResult.success(); + }); + } + + String? _resolveSessionId(final String? sessionId) { + if (sessionId != null && sessionId.isNotEmpty) { + return sessionId; + } + return _state.activeSessionId; + } + + Future _markSessionUsedLocked( + final String resolvedSessionId, { + final String? endpointOverride, + }) async { + final current = await stateStore.readUnlocked(); + final existing = current.sessions[resolvedSessionId]; + if (existing == null) { + _state = current; + return; + } + + final endpoint = endpointOverride ?? existing.endpoint; + final next = existing.copyWith( + lastUsedAt: DateTime.now().toUtc(), + endpoint: endpoint, + ); + + final nextSessions = { + ...current.sessions, + resolvedSessionId: next, + }; + + final nextState = current.copyWith( + sessions: nextSessions, + activeSessionId: resolvedSessionId, + stickyEndpoint: endpoint, + lastMode: existing.mode, + ); + + await stateStore.writeUnlocked(nextState); + _state = nextState; + } + + Future _withLockedResult( + final Future Function() action, + ) async { + try { + return await stateStore.withStateLock(action); + } on StateLockException catch (e) { + return AgentResult.failure( + code: IntentSessionErrorCode.stateLockTimeout, + message: e.message, + details: {'lockFilePath': e.lockFilePath, 'owner': e.owner}, + ); + } on Exception catch (e) { + return AgentResult.failure( + code: IntentSessionErrorCode.stateStoreWriteFailed, + message: 'State operation failed: $e', + ); + } + } + + String _newSessionId() { + final rand = Random(); + final suffix = rand.nextInt(1 << 32).toRadixString(16).padLeft(8, '0'); + return 's_${DateTime.now().millisecondsSinceEpoch}_$suffix'; + } + + Map _detailsMap(final Object? value) { + if (value is Map) { + return jsonObjectOrEmpty(value); + } + if (value == null) { + return const {}; + } + final decoded = jsonObjectOrEmpty(value); + if (decoded.isNotEmpty) { + return decoded; + } + return {'details': jsonDecodeString(value)}; + } +} diff --git a/packages/intentcall_session/lib/src/session_requests.dart b/packages/intentcall_session/lib/src/session_requests.dart new file mode 100644 index 0000000..b8b59f4 --- /dev/null +++ b/packages/intentcall_session/lib/src/session_requests.dart @@ -0,0 +1,47 @@ +import 'package:meta/meta.dart'; + +/// Connection mode requested by a session start or attach operation. +enum IntentSessionConnectionMode { auto, manual, uri } + +/// Stable error codes returned by intent session APIs. +abstract final class IntentSessionErrorCode { + static const connectFailed = 'connect_failed'; + static const connectionSelectionRequired = 'connection_selection_required'; + static const sessionNotFound = 'session_not_found'; + static const stateLockTimeout = 'state_lock_timeout'; + static const stateStoreWriteFailed = 'state_store_write_failed'; +} + +/// Starts or replaces the active runtime session. +@immutable +final class IntentSessionStartRequest { + const IntentSessionStartRequest({ + this.mode = IntentSessionConnectionMode.auto, + this.targetId, + this.uri, + this.host, + this.port, + this.forceReconnect = false, + this.sessionId, + }); + + final IntentSessionConnectionMode mode; + final String? targetId; + final String? uri; + final String? host; + final int? port; + final bool forceReconnect; + final String? sessionId; +} + +/// Attaches to an existing runtime session. +@immutable +final class IntentSessionAttachRequest { + const IntentSessionAttachRequest({ + this.sessionId, + this.forceReconnect = false, + }); + + final String? sessionId; + final bool forceReconnect; +} diff --git a/packages/intentcall_session/lib/src/snapshot_store.dart b/packages/intentcall_session/lib/src/snapshot_store.dart new file mode 100644 index 0000000..15f7b8b --- /dev/null +++ b/packages/intentcall_session/lib/src/snapshot_store.dart @@ -0,0 +1,205 @@ +// Copyright (c) 2025, IntentCall authors. +// Licensed under the MIT License. + +import 'dart:convert'; +import 'dart:io' as io; + +import 'package:from_json_to_json/from_json_to_json.dart'; + +import 'json_helpers.dart'; +import 'safe_writes.dart'; + +/// File-backed JSON snapshot persistence with structural diff support. +/// +/// This store deliberately knows nothing about command catalogs, transports, +/// live runtimes, or session connectors. Hosts build snapshot payloads and use +/// this class only for durable storage and comparison. +final class IntentSnapshotStore { + IntentSnapshotStore({required this.snapshotsDir}); + + final String snapshotsDir; + + Future> saveSnapshot({ + required final String id, + required final Map snapshot, + final SafeWriteOptions writeOptions = const SafeWriteOptions(), + }) async { + final file = _fileFor(id); + final writeResult = await SafeFileWriter.writeTextFile( + path: file.path, + content: const JsonEncoder.withIndent(' ').convert(snapshot), + options: writeOptions, + ); + + return { + ...snapshot, + 'path': file.path, + 'writeResults': [writeResult.toJson()], + }; + } + + Future> loadSnapshot(final String id) async { + final file = _fileFor(id); + if (!file.existsSync()) { + throw ArgumentError('Snapshot not found: $id'); + } + + final raw = file.readAsStringSync(); + try { + return jsonDecodeThrowableMap(raw).cast(); + } on Object catch (error) { + throw StateError('Invalid snapshot payload: $id ($error)'); + } + } + + Future>> listSnapshots() async { + final dir = io.Directory(snapshotsDir); + if (!dir.existsSync()) { + return const >[]; + } + + final entries = + dir + .listSync() + .where( + (final entity) => + entity is io.File && entity.path.endsWith('.json'), + ) + .cast() + .toList() + ..sort((final a, final b) => a.path.compareTo(b.path)); + + final snapshots = >[]; + for (final file in entries) { + try { + final raw = file.readAsStringSync(); + if (!verifyMapDecodability(raw.trim())) { + continue; + } + final json = jsonObjectOrEmpty(raw); + snapshots.add({ + 'id': jsonDecodeString(json['id']), + 'createdAt': json['createdAt'], + 'path': file.path, + }); + } on Exception { + // Skip unreadable files. + } + } + + return snapshots; + } + + Future> diffSnapshots({ + required final String fromId, + required final String toId, + }) async { + final from = await loadSnapshot(fromId); + final to = await loadSnapshot(toId); + + final changes = >[]; + _diffNode(path: r'$', left: from, right: to, out: changes); + + final summary = { + 'totalChanges': changes.length, + 'added': changes + .where((final change) => change['type'] == 'added') + .length, + 'removed': changes + .where((final change) => change['type'] == 'removed') + .length, + 'changed': changes + .where((final change) => change['type'] == 'changed') + .length, + 'typeChanged': changes + .where((final change) => change['type'] == 'type_changed') + .length, + }; + + return {'from': fromId, 'to': toId, 'summary': summary, 'changes': changes}; + } + + io.File _fileFor(final String id) { + final safe = id.replaceAll(RegExp('[^a-zA-Z0-9._-]'), '_'); + return io.File('$snapshotsDir/$safe.json'); + } + + static void _diffNode({ + required final String path, + required final Object? left, + required final Object? right, + required final List> out, + }) { + if (left == null && right == null) { + return; + } + + if (left == null) { + out.add({'path': path, 'type': 'added', 'after': right}); + return; + } + + if (right == null) { + out.add({'path': path, 'type': 'removed', 'before': left}); + return; + } + + if (left is Map && right is Map) { + final leftMap = left.cast(); + final rightMap = right.cast(); + final allKeys = {...leftMap.keys, ...rightMap.keys}.toList() + ..sort(); + + for (final key in allKeys) { + _diffNode( + path: '$path.$key', + left: leftMap[key], + right: rightMap[key], + out: out, + ); + } + return; + } + + if (left is List && right is List) { + final maxLen = left.length > right.length ? left.length : right.length; + for (var i = 0; i < maxLen; i += 1) { + final nextLeft = i < left.length ? left[i] : null; + final nextRight = i < right.length ? right[i] : null; + _diffNode( + path: '$path[$i]', + left: nextLeft, + right: nextRight, + out: out, + ); + } + return; + } + + if (left.runtimeType != right.runtimeType) { + out.add({ + 'path': path, + 'type': 'type_changed', + 'beforeType': left.runtimeType.toString(), + 'afterType': right.runtimeType.toString(), + 'before': left, + 'after': right, + }); + return; + } + + if (_jsonEquals(left, right)) { + return; + } + + out.add({'path': path, 'type': 'changed', 'before': left, 'after': right}); + } + + static bool _jsonEquals(final Object? left, final Object? right) { + try { + return jsonEncode(left) == jsonEncode(right); + } on Object { + return left == right; + } + } +} diff --git a/packages/intentcall_session/lib/src/state_lock_manager.dart b/packages/intentcall_session/lib/src/state_lock_manager.dart new file mode 100644 index 0000000..7040b5b --- /dev/null +++ b/packages/intentcall_session/lib/src/state_lock_manager.dart @@ -0,0 +1,190 @@ +// Copyright (c) 2025, IntentCall authors. +// Licensed under the MIT License. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io' as io; +import 'dart:math'; + +import 'package:meta/meta.dart'; + +@immutable +final class LockAcquisition { + const LockAcquisition({ + required this.token, + required this.lockFilePath, + required this.acquiredAt, + required this.waitMs, + required this.staleLockRecovered, + this.previousOwner, + }); + + final String token; + final String lockFilePath; + final DateTime acquiredAt; + final int waitMs; + final bool staleLockRecovered; + final Map? previousOwner; + + Map toJson() => { + 'token': token, + 'lockFilePath': lockFilePath, + 'acquiredAt': acquiredAt.toUtc().toIso8601String(), + 'waitMs': waitMs, + 'staleLockRecovered': staleLockRecovered, + 'previousOwner': previousOwner, + }; +} + +final class StateLockException implements Exception { + const StateLockException({ + required this.message, + required this.lockFilePath, + this.owner, + }); + + final String message; + final String lockFilePath; + final Map? owner; + + @override + String toString() => + 'StateLockException(message: $message, lockFilePath: $lockFilePath, owner: $owner)'; +} + +final class StateLockManager { + StateLockManager({ + required this.lockFilePath, + this.staleLockTtl = const Duration(minutes: 5), + this.acquireTimeout = const Duration(seconds: 10), + this.pollInterval = const Duration(milliseconds: 50), + }); + + final String lockFilePath; + final Duration staleLockTtl; + final Duration acquireTimeout; + final Duration pollInterval; + + Future withLock(final Future Function() action) async { + final acquisition = await acquire(); + try { + return await action(); + } finally { + await release(acquisition); + } + } + + Future acquire({final Duration? timeout}) async { + final effectiveTimeout = timeout ?? acquireTimeout; + final start = DateTime.now().toUtc(); + var staleRecovered = false; + Map? previousOwner; + + while (true) { + final token = _nextToken(); + final lockFile = io.File(lockFilePath); + + try { + lockFile.parent.createSync(recursive: true); + lockFile.createSync(exclusive: true); + + final payload = { + 'token': token, + 'pid': io.pid, + 'createdAt': DateTime.now().toUtc().toIso8601String(), + 'hostname': io.Platform.localHostname, + }; + lockFile.writeAsStringSync(jsonEncode(payload)); + + final waitMs = DateTime.now().toUtc().difference(start).inMilliseconds; + return LockAcquisition( + token: token, + lockFilePath: lockFilePath, + acquiredAt: DateTime.now().toUtc(), + waitMs: waitMs, + staleLockRecovered: staleRecovered, + previousOwner: previousOwner, + ); + } on io.FileSystemException { + final staleOutcome = await _recoverStaleLockIfNeeded(lockFile); + staleRecovered = staleRecovered || staleOutcome.recovered; + previousOwner ??= staleOutcome.owner; + + final elapsed = DateTime.now().toUtc().difference(start); + if (elapsed >= effectiveTimeout) { + throw StateLockException( + message: + 'Timed out acquiring state lock after ${elapsed.inMilliseconds}ms', + lockFilePath: lockFilePath, + owner: staleOutcome.owner, + ); + } + + await Future.delayed(pollInterval); + } + } + } + + Future release(final LockAcquisition acquisition) async { + final lockFile = io.File(lockFilePath); + if (!lockFile.existsSync()) { + return; + } + + try { + final raw = lockFile.readAsStringSync(); + final decoded = _decodeMap(raw); + final token = decoded['token']?.toString(); + + if (token != acquisition.token) { + return; + } + lockFile.deleteSync(); + } on Exception { + // Lock release must be best effort. + } + } + + Future<({bool recovered, Map? owner})> + _recoverStaleLockIfNeeded(final io.File lockFile) async { + try { + final raw = lockFile.readAsStringSync(); + final owner = _decodeMap(raw); + final createdAtRaw = owner['createdAt']?.toString(); + final createdAt = createdAtRaw == null + ? null + : DateTime.tryParse(createdAtRaw)?.toUtc(); + + if (createdAt == null) { + return (recovered: false, owner: owner); + } + + final now = DateTime.now().toUtc(); + if (now.difference(createdAt) <= staleLockTtl) { + return (recovered: false, owner: owner); + } + + lockFile.deleteSync(); + return (recovered: true, owner: owner); + } on Exception { + return (recovered: false, owner: null); + } + } + + Map _decodeMap(final String raw) { + final decoded = jsonDecode(raw); + if (decoded is Map) { + return decoded; + } + if (decoded is Map) { + return decoded.cast(); + } + return const {}; + } + + String _nextToken() { + final rand = Random(); + final suffix = rand.nextInt(1 << 32).toRadixString(16).padLeft(8, '0'); + return 'lock_${DateTime.now().microsecondsSinceEpoch}_$suffix'; + } +} diff --git a/packages/intentcall_session/lib/src/state_store.dart b/packages/intentcall_session/lib/src/state_store.dart new file mode 100644 index 0000000..be758ef --- /dev/null +++ b/packages/intentcall_session/lib/src/state_store.dart @@ -0,0 +1,205 @@ +// Copyright (c) 2025, IntentCall authors. +// Licensed under the MIT License. + +import 'dart:convert'; +import 'dart:io' as io; + +import 'package:from_json_to_json/from_json_to_json.dart'; +import 'package:path/path.dart' as p; + +import 'json_helpers.dart'; +import 'safe_writes.dart'; +import 'state_lock_manager.dart'; + +final class SessionState { + const SessionState({ + required this.id, + required this.endpoint, + required this.createdAt, + required this.lastUsedAt, + required this.mode, + this.host, + this.port, + this.uri, + }); + + factory SessionState.fromJson(final Map json) { + final mode = jsonDecodeString(json['mode']); + return SessionState( + id: jsonDecodeString(json['id']), + endpoint: jsonDecodeString(json['endpoint']), + createdAt: + DateTime.tryParse(jsonDecodeString(json['createdAt']))?.toUtc() ?? + DateTime.fromMillisecondsSinceEpoch(0, isUtc: true), + lastUsedAt: + DateTime.tryParse(jsonDecodeString(json['lastUsedAt']))?.toUtc() ?? + DateTime.fromMillisecondsSinceEpoch(0, isUtc: true), + mode: mode.isEmpty ? 'auto' : mode, + host: _optionalJsonString(json['host']), + port: jsonDecodeNullableInt(json['port']), + uri: _optionalJsonString(json['uri']), + ); + } + + final String id; + final String endpoint; + final DateTime createdAt; + final DateTime lastUsedAt; + final String mode; + final String? host; + final int? port; + final String? uri; + + SessionState copyWith({final DateTime? lastUsedAt, final String? endpoint}) => + SessionState( + id: id, + endpoint: endpoint ?? this.endpoint, + createdAt: createdAt, + lastUsedAt: lastUsedAt ?? this.lastUsedAt, + mode: mode, + host: host, + port: port, + uri: uri, + ); + + Map toJson() => { + 'id': id, + 'endpoint': endpoint, + 'createdAt': createdAt.toUtc().toIso8601String(), + 'lastUsedAt': lastUsedAt.toUtc().toIso8601String(), + 'mode': mode, + 'host': host, + 'port': port, + 'uri': uri, + }; +} + +final class PersistedState { + const PersistedState({ + this.schemaVersion = 1, + this.activeSessionId, + this.sessions = const {}, + this.stickyEndpoint, + this.lastMode, + }); + + factory PersistedState.fromJson(final Map json) { + final rawSessions = jsonObjectOrEmpty(json['sessions']); + final sessions = {}; + for (final entry in rawSessions.entries) { + final key = jsonDecodeString(entry.key); + final value = entry.value; + if (value is Map || verifyMapDecodability(value)) { + sessions[key] = SessionState.fromJson(jsonObjectOrEmpty(value)); + } + } + + return PersistedState( + schemaVersion: jsonDecodeNullableInt(json['schemaVersion']) ?? 1, + activeSessionId: _optionalJsonString(json['activeSessionId']), + stickyEndpoint: _optionalJsonString(json['stickyEndpoint']), + lastMode: _optionalJsonString(json['lastMode']), + sessions: sessions, + ); + } + + final int schemaVersion; + final String? activeSessionId; + final Map sessions; + final String? stickyEndpoint; + final String? lastMode; + + SessionState? get activeSession { + final id = activeSessionId; + if (id == null || id.isEmpty) { + return null; + } + return sessions[id]; + } + + PersistedState copyWith({ + final int? schemaVersion, + final String? activeSessionId, + final bool clearActiveSessionId = false, + final Map? sessions, + final String? stickyEndpoint, + final bool clearStickyEndpoint = false, + final String? lastMode, + final bool clearLastMode = false, + }) => PersistedState( + schemaVersion: schemaVersion ?? this.schemaVersion, + activeSessionId: clearActiveSessionId + ? null + : (activeSessionId ?? this.activeSessionId), + sessions: sessions ?? this.sessions, + stickyEndpoint: clearStickyEndpoint + ? null + : (stickyEndpoint ?? this.stickyEndpoint), + lastMode: clearLastMode ? null : (lastMode ?? this.lastMode), + ); + + Map toJson() => { + 'schemaVersion': schemaVersion, + 'activeSessionId': activeSessionId, + 'stickyEndpoint': stickyEndpoint, + 'lastMode': lastMode, + 'sessions': sessions.map( + (final key, final value) => MapEntry(key, value.toJson()), + ), + }; +} + +final class StateStore { + StateStore({required this.path, final StateLockManager? lockManager}) + : lockManager = + lockManager ?? + StateLockManager( + lockFilePath: p.normalize(p.join(p.dirname(path), 'state.lock')), + ); + + final String path; + final StateLockManager lockManager; + + Future withStateLock(final Future Function() action) => + lockManager.withLock(action); + + Future read() => withStateLock(readUnlocked); + + Future write(final PersistedState state) => + withStateLock(() => writeUnlocked(state)); + + Future readUnlocked() async { + try { + final file = io.File(path); + if (!file.existsSync()) { + return const PersistedState(); + } + + final raw = file.readAsStringSync(); + if (raw.trim().isEmpty) { + return const PersistedState(); + } + + return PersistedState.fromJson(jsonObjectOrEmpty(raw)); + } on Exception { + return const PersistedState(); + } + } + + Future writeUnlocked(final PersistedState state) async { + final file = io.File(path); + final payload = const JsonEncoder.withIndent(' ').convert(state.toJson()); + await SafeFileWriter.writeTextFile(path: file.path, content: payload); + + if (!io.Platform.isWindows) { + io.Process.runSync('chmod', ['600', p.normalize(file.path)]); + } + } +} + +String? _optionalJsonString(final Object? value) { + if (value == null) { + return null; + } + return jsonDecodeString(value); +} diff --git a/packages/intentcall_session/pubspec.yaml b/packages/intentcall_session/pubspec.yaml new file mode 100644 index 0000000..8c0dc49 --- /dev/null +++ b/packages/intentcall_session/pubspec.yaml @@ -0,0 +1,27 @@ +name: intentcall_session +description: PRE-RELEASE — IntentCall runtime session persistence and invocation helpers. +version: 0.1.0 +license: MIT +repository: https://github.com/Arenukvern/intentcall/tree/main/packages/intentcall_session +issue_tracker: https://github.com/Arenukvern/intentcall/issues +homepage: https://github.com/Arenukvern/intentcall/tree/main/packages/intentcall_session +topics: + - mcp + - agents + - dart + +environment: + sdk: ">=3.11.0 <4.0.0" +resolution: workspace + +dependencies: + from_json_to_json: ^0.5.0 + intentcall_core: ^0.1.0 + intentcall_schema: ^0.1.0 + meta: ^1.17.0 + path: ^1.9.1 + +dev_dependencies: + lints: ^6.1.0 + test: ^1.31.1 + xsoulspace_lints: ^0.1.2 diff --git a/packages/intentcall_session/test/agent_session_executor_test.dart b/packages/intentcall_session/test/agent_session_executor_test.dart new file mode 100644 index 0000000..bae6593 --- /dev/null +++ b/packages/intentcall_session/test/agent_session_executor_test.dart @@ -0,0 +1,92 @@ +import 'dart:io'; + +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_schema/intentcall_schema.dart'; +import 'package:intentcall_session/intentcall_session.dart'; +import 'package:test/test.dart'; + +void main() { + test( + 'IntentSessionExecutor invokes an AgentRegistry inside a session', + () async { + final tempDir = Directory.systemTemp.createTempSync( + 'intentcall_session_', + ); + addTearDown(() { + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + }); + + final registry = InMemoryAgentRegistry() + ..register( + AgentCallEntry.tool( + namespace: 'debug', + name: 'select', + description: 'Select an object.', + inputSchema: const { + 'type': 'object', + 'properties': { + 'id': {'type': 'string'}, + }, + 'required': ['id'], + }, + handler: (final args) => + AgentResult.success(data: {'selected': args['id']}), + ).toRegistration(), + ); + + final manager = IntentSessionManager( + connector: _FakeConnector(endpoint: 'ws://127.0.0.1:8181/ws'), + stateStore: StateStore(path: '${tempDir.path}/state.json'), + ); + await manager.startSession( + const IntentSessionStartRequest(sessionId: 's1'), + ); + + final executor = IntentSessionExecutor( + sessions: manager, + registry: registry, + ); + + final result = await executor.invoke( + sessionId: 's1', + qualifiedName: 'debug_select', + arguments: const {'id': 'node-7'}, + ); + + expect(result.ok, isTrue); + expect(result.data['selected'], equals('node-7')); + }, + ); +} + +final class _FakeConnector implements IntentSessionConnector { + _FakeConnector({required this.endpoint}); + + final String endpoint; + + @override + String? activeEndpointDisplay; + + @override + Map get lastSelectionDiagnostics => const {}; + + @override + Future> connect({ + final IntentSessionConnectionMode mode = IntentSessionConnectionMode.auto, + final String? targetId, + final String? uri, + final String? host, + final int? port, + final bool forceReconnect = false, + }) async { + activeEndpointDisplay = endpoint; + return {'connected': true}; + } + + @override + Future disconnect() async { + activeEndpointDisplay = null; + } +} diff --git a/packages/intentcall_session/test/session_manager_test.dart b/packages/intentcall_session/test/session_manager_test.dart new file mode 100644 index 0000000..fc087a3 --- /dev/null +++ b/packages/intentcall_session/test/session_manager_test.dart @@ -0,0 +1,163 @@ +import 'dart:io'; + +import 'package:intentcall_session/intentcall_session.dart'; +import 'package:test/test.dart'; + +void main() { + group('IntentSessionManager', () { + late Directory tempDir; + late String statePath; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('intentcall_session_'); + statePath = '${tempDir.path}/state.json'; + }); + + tearDown(() { + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + }); + + test('starts and persists a session through a connector', () async { + final store = StateStore(path: statePath); + final connector = _FakeConnector(endpoint: 'ws://127.0.0.1:8181/ws'); + final manager = IntentSessionManager( + connector: connector, + stateStore: store, + ); + await manager.load(); + + final result = await manager.startSession( + const IntentSessionStartRequest( + sessionId: 's1', + mode: IntentSessionConnectionMode.uri, + ), + ); + + expect(result.ok, isTrue); + expect(result.data['sessionId'], equals('s1')); + expect(connector.connectCount, equals(1)); + + final loaded = await store.read(); + expect(loaded.activeSessionId, equals('s1')); + expect(loaded.sessions['s1']?.endpoint, equals('ws://127.0.0.1:8181/ws')); + }); + + test('endSession removes active session and disconnects', () async { + final now = DateTime.now().toUtc(); + final store = StateStore(path: statePath); + await store.write( + PersistedState( + activeSessionId: 's1', + sessions: { + 's1': SessionState( + id: 's1', + endpoint: 'ws://127.0.0.1:8181/ws', + createdAt: now, + lastUsedAt: now, + mode: 'uri', + ), + }, + ), + ); + + final connector = _FakeConnector(endpoint: 'ws://127.0.0.1:8181/ws'); + final manager = IntentSessionManager( + connector: connector, + stateStore: store, + ); + await manager.load(); + + final result = await manager.endSession('s1'); + + expect(result.ok, isTrue); + expect(connector.disconnectCount, equals(1)); + expect((await store.read()).sessions, isEmpty); + }); + + test('maps multiple-target failures to selection required', () async { + final manager = IntentSessionManager( + connector: _FakeConnector( + endpoint: 'ws://127.0.0.1:8181/ws', + failure: const _FakeConnectionException( + reasonName: IntentSessionConnectionFailureReason.multipleTargets, + message: 'Multiple targets found', + details: {'count': 2}, + ), + ), + stateStore: StateStore(path: statePath), + ); + + final result = await manager.startSession( + const IntentSessionStartRequest(), + ); + + expect(result.ok, isFalse); + expect( + result.code, + equals(IntentSessionErrorCode.connectionSelectionRequired), + ); + expect(result.details, equals({'count': 2})); + }); + }); +} + +final class _FakeConnector implements IntentSessionConnector { + _FakeConnector({required this.endpoint, this.failure}); + + final String endpoint; + final _FakeConnectionException? failure; + int connectCount = 0; + int disconnectCount = 0; + + @override + String? activeEndpointDisplay; + + @override + Map get lastSelectionDiagnostics => const { + 'decision': 'fake', + }; + + @override + Future> connect({ + final IntentSessionConnectionMode mode = IntentSessionConnectionMode.auto, + final String? targetId, + final String? uri, + final String? host, + final int? port, + final bool forceReconnect = false, + }) async { + connectCount += 1; + final failure = this.failure; + if (failure != null) { + throw failure; + } + activeEndpointDisplay = endpoint; + return {'connected': true, 'reusedConnection': false, 'mode': mode.name}; + } + + @override + Future disconnect() async { + disconnectCount += 1; + activeEndpointDisplay = null; + } +} + +final class _FakeConnectionException + implements IntentSessionConnectionException { + const _FakeConnectionException({ + required this.reasonName, + required this.message, + this.details, + }); + + @override + final String reasonName; + + @override + final String message; + + @override + final Object? details; +} diff --git a/packages/intentcall_session/test/snapshot_store_test.dart b/packages/intentcall_session/test/snapshot_store_test.dart new file mode 100644 index 0000000..b1ec162 --- /dev/null +++ b/packages/intentcall_session/test/snapshot_store_test.dart @@ -0,0 +1,113 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:intentcall_session/intentcall_session.dart'; +import 'package:test/test.dart'; + +void main() { + group('IntentSnapshotStore', () { + late Directory tempDir; + late String snapshotsDir; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('intentcall_snapshots_'); + snapshotsDir = '${tempDir.path}/snapshots'; + }); + + tearDown(() { + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + }); + + test('saves, loads, and lists JSON snapshots', () async { + final store = IntentSnapshotStore(snapshotsDir: snapshotsDir); + + final saved = await store.saveSnapshot( + id: 's1', + snapshot: const { + 'id': 's1', + 'createdAt': '2026-06-22T00:00:00.000Z', + 'value': {'x': 1}, + }, + ); + + expect(saved['path'], equals('$snapshotsDir/s1.json')); + expect(File('$snapshotsDir/s1.json').existsSync(), isTrue); + + final loaded = await store.loadSnapshot('s1'); + expect(loaded['id'], equals('s1')); + expect(loaded['path'], isNull); + expect(loaded['writeResults'], isNull); + + final listed = await store.listSnapshots(); + expect(listed, hasLength(1)); + expect(listed.single['id'], equals('s1')); + expect(listed.single['path'], equals('$snapshotsDir/s1.json')); + }); + + test('computes structural diffs', () async { + final store = IntentSnapshotStore(snapshotsDir: snapshotsDir); + await Directory(snapshotsDir).create(recursive: true); + + await File('$snapshotsDir/a.json').writeAsString( + jsonEncode({ + 'id': 'a', + 'value': { + 'x': 1, + 'list': [1, 2], + }, + }), + ); + await File('$snapshotsDir/b.json').writeAsString( + jsonEncode({ + 'id': 'b', + 'value': { + 'x': 2, + 'list': [1, 3], + 'extra': true, + }, + }), + ); + + final diff = await store.diffSnapshots(fromId: 'a', toId: 'b'); + final changes = (diff['changes']! as List).cast>(); + final paths = changes.map((final change) => change['path']).toSet(); + + expect(paths, contains(r'$.value.x')); + expect(paths, contains(r'$.value.list[1]')); + expect(paths, contains(r'$.value.extra')); + }); + + test('supports check-only writes', () async { + final store = IntentSnapshotStore(snapshotsDir: snapshotsDir); + + final saved = await store.saveSnapshot( + id: 'check_only', + snapshot: const {'id': 'check_only'}, + writeOptions: const SafeWriteOptions(check: true, diff: true), + ); + + final writes = (saved['writeResults']! as List) + .cast>(); + expect(writes.single['status'], equals(SafeWriteStatus.added)); + expect(writes.single['wrote'], isFalse); + expect(writes.single['diff'], isA>()); + expect(File('$snapshotsDir/check_only.json').existsSync(), isFalse); + }); + + test('skips invalid files while listing snapshots', () async { + final store = IntentSnapshotStore(snapshotsDir: snapshotsDir); + await Directory(snapshotsDir).create(recursive: true); + await File('$snapshotsDir/good.json').writeAsString( + jsonEncode({'id': 'good', 'createdAt': '2026-06-22T00:00:00.000Z'}), + ); + await File('$snapshotsDir/bad.json').writeAsString('{not-json'); + + final listed = await store.listSnapshots(); + + expect(listed, hasLength(1)); + expect(listed.single['id'], equals('good')); + }); + }); +} diff --git a/packages/intentcall_session/test/state_store_test.dart b/packages/intentcall_session/test/state_store_test.dart new file mode 100644 index 0000000..7a1a571 --- /dev/null +++ b/packages/intentcall_session/test/state_store_test.dart @@ -0,0 +1,94 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:intentcall_session/intentcall_session.dart'; +import 'package:test/test.dart'; + +void main() { + group('StateStore', () { + late Directory tempDir; + late String statePath; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('intentcall_session_'); + statePath = '${tempDir.path}/state.json'; + }); + + tearDown(() { + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + }); + + test('persists the existing session JSON shape', () async { + final store = StateStore(path: statePath); + final now = DateTime.utc(2026, 6, 22, 10); + + await store.write( + PersistedState( + activeSessionId: 's1', + stickyEndpoint: 'ws://127.0.0.1:8181/token/ws', + lastMode: 'uri', + sessions: { + 's1': SessionState( + id: 's1', + endpoint: 'ws://127.0.0.1:8181/token/ws', + createdAt: now, + lastUsedAt: now, + mode: 'uri', + uri: 'ws://127.0.0.1:8181/token/ws', + ), + }, + ), + ); + + final raw = (jsonDecode(File(statePath).readAsStringSync()) as Map) + .cast(); + final sessions = (raw['sessions']! as Map).cast(); + final sessionJson = (sessions['s1']! as Map).cast(); + expect(raw['schemaVersion'], equals(1)); + expect(raw['activeSessionId'], equals('s1')); + expect(sessionJson['endpoint'], contains('8181')); + + final loaded = await store.read(); + expect(loaded.activeSessionId, equals('s1')); + expect(loaded.sessions['s1']?.mode, equals('uri')); + }); + + test('returns empty state for malformed JSON', () async { + File(statePath).writeAsStringSync('{not-json'); + final loaded = await StateStore(path: statePath).read(); + expect(loaded.sessions, isEmpty); + expect(loaded.activeSessionId, isNull); + }); + + test( + 'coerces tolerant persisted JSON fields at the file boundary', + () async { + File(statePath).writeAsStringSync( + jsonEncode({ + 'schemaVersion': '2', + 'activeSessionId': 's1', + 'sessions': { + 's1': { + 'id': 's1', + 'endpoint': 'ws://127.0.0.1:8181/token/ws', + 'createdAt': '2026-06-22T00:00:00.000Z', + 'lastUsedAt': '2026-06-22T00:01:00.000Z', + 'mode': '', + 'port': '8181', + }, + }, + }), + ); + + final loaded = await StateStore(path: statePath).read(); + + expect(loaded.schemaVersion, equals(2)); + expect(loaded.activeSessionId, equals('s1')); + expect(loaded.sessions['s1']?.mode, equals('auto')); + expect(loaded.sessions['s1']?.port, equals(8181)); + }, + ); + }); +} diff --git a/pubspec.lock b/pubspec.lock index 9438754..90b9e62 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -227,6 +227,14 @@ packages: description: flutter source: sdk version: "0.0.0" + from_json_to_json: + dependency: transitive + description: + name: from_json_to_json + sha256: a29219df65cd20b1b17be8141ee51b3103d1cd95b192b7512139f3ae44775f49 + url: "https://pub.dev" + source: hosted + version: "0.5.0" frontend_server_client: dependency: transitive description: @@ -283,6 +291,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + is_dart_empty_or_not: + dependency: transitive + description: + name: is_dart_empty_or_not + sha256: "1454632c2b961175d4c2807310713cbcbd054a51e63c545dc86c3eb9b2b061b0" + url: "https://pub.dev" + source: hosted + version: "0.4.0" json_annotation: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 458712c..890bc09 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,6 +16,7 @@ workspace: - packages/intentcall_android - packages/intentcall_platform - packages/intentcall_testing + - packages/intentcall_session - tool/intentcall dev_dependencies: diff --git a/release-please-config.json b/release-please-config.json index f943587..484dcda 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -50,6 +50,11 @@ "release-type": "dart", "package-name": "intentcall_testing", "component": "intentcall_testing" + }, + "packages/intentcall_session": { + "release-type": "dart", + "package-name": "intentcall_session", + "component": "intentcall_session" } } } diff --git a/tool/intentcall/bin/intentcall.dart b/tool/intentcall/bin/intentcall.dart index 0c5d1d6..ef7b8d4 100644 --- a/tool/intentcall/bin/intentcall.dart +++ b/tool/intentcall/bin/intentcall.dart @@ -7,6 +7,7 @@ import 'package:path/path.dart' as p; const publishOrder = [ 'intentcall_schema', 'intentcall_core', + 'intentcall_session', 'intentcall_mcp', 'intentcall_webmcp', 'intentcall_gemma', @@ -333,7 +334,8 @@ Future runCheckPathDeps(Directory repoRoot) async { continue; } final content = entity.readAsStringSync(); - if (content.contains('intentcall/packages')) { + if (content.contains('intentcall/packages') || + content.contains('agentkit/packages')) { matches.add(relativePath); } } @@ -348,12 +350,12 @@ Future runCheckPathDeps(Directory repoRoot) async { return 1; } - print('OK: no intentcall path deps in consumers'); + print('OK: no local intentcall path deps in publishable packages'); return 0; } void runPrintHostedDeps(String version) { - print('# Replace path: ../intentcall/packages/ with:'); + print('# Replace path: ../agentkit/packages/ with:'); print(''); for (final pkg in publishOrder) { print('$pkg: ^$version');