@independo/capacitor-inderun 1.0.1-dev.1 → 1.1.0-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/Package.swift +5 -1
  2. package/README.md +79 -5
  3. package/android/build.gradle.kts +5 -5
  4. package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunCapacitorPlugin.kt +119 -0
  5. package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunSerializer.kt +106 -1
  6. package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunStreamRegistry.kt +118 -0
  7. package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunSerializerStreamTest.kt +226 -0
  8. package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunStreamRegistryTest.kt +163 -0
  9. package/dist/definitions.d.ts +54 -1
  10. package/dist/definitions.d.ts.map +1 -1
  11. package/dist/errors.d.ts +19 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +36 -0
  14. package/dist/index.d.ts +14 -4
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +30 -13
  17. package/dist/streaming.d.ts +17 -0
  18. package/dist/streaming.d.ts.map +1 -0
  19. package/dist/streaming.js +303 -0
  20. package/dist/web.d.ts +17 -2
  21. package/dist/web.d.ts.map +1 -1
  22. package/dist/web.js +70 -0
  23. package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorBridge.swift +121 -0
  24. package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorPlugin.swift +70 -1
  25. package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorStreamRegistry.swift +121 -0
  26. package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorStreamCodecTests.swift +178 -0
  27. package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorStreamPumpTests.swift +215 -0
  28. package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorStreamRegistryTests.swift +154 -0
  29. package/package.json +3 -3
package/Package.swift CHANGED
@@ -20,7 +20,11 @@ let package = Package(
20
20
  ],
21
21
  dependencies: [
22
22
  .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0"),
23
- .package(url: "https://github.com/independo-gmbh/inderun.git", from: "0.2.2")
23
+ // `exact:` rather than `from:` while tracking a prerelease: SwiftPM's range
24
+ // operators exclude prerelease versions, so `from: "0.3.0-dev.14"` would
25
+ // silently keep resolving 0.2.2 and fail on the missing stream(). Move back
26
+ // to `from: "0.3.0"` once the stable release is out.
27
+ .package(url: "https://github.com/independo-gmbh/inderun.git", exact: "0.3.0-dev.14")
24
28
  ],
25
29
  targets: [
26
30
  .target(
package/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  <p align="center">Built and maintained by <a href="https://www.independo.app/">Independo</a>.</p>
16
16
 
17
- Thin Capacitor bridge for IndeRun Mode 1 `run()` execution.
17
+ Thin Capacitor bridge for IndeRun Mode 1 `run()` and Mode 2 `stream()` execution.
18
18
 
19
19
  The package delegates to the released [IndeRun](https://github.com/independo-gmbh/inderun)
20
20
  platform SDKs instead of re-implementing routing, provider logic, or normalized error handling:
@@ -63,15 +63,68 @@ const result = await inderun.run({
63
63
  });
64
64
  ```
65
65
 
66
+ ## Streaming (Mode 2)
67
+
68
+ `stream()` returns the same `StreamRun` shape the platform SDKs return directly: the
69
+ run's handle, its canonical event sequence, and a cancel hook.
70
+
71
+ ```ts
72
+ const run = await inderun.stream({
73
+ schemaVersion: "1.0",
74
+ task: { kind: "text_to_text" },
75
+ prompt: "Explain routing in two sentences."
76
+ });
77
+
78
+ console.log(run.handle.runId); // available before the first event
79
+
80
+ let text = "";
81
+ for await (const event of run.events) {
82
+ switch (event.type) {
83
+ case "content_delta":
84
+ text += event.payload.text; // append
85
+ break;
86
+ case "content_snapshot":
87
+ text = event.payload.text ?? ""; // replace — an empty one retracts
88
+ break;
89
+ case "terminal":
90
+ // exactly one of "completed" | "error" | "cancelled"
91
+ console.log(event.payload.outcome);
92
+ break;
93
+ }
94
+ }
95
+
96
+ run.cancel("user navigated away"); // idempotent; a no-op after the terminal
97
+ ```
98
+
99
+ Three things worth knowing before you build on it:
100
+
101
+ - **Order by `sequence`, not arrival.** The bridge hop is not order-preserving, which is
102
+ why `sequence` is the contract's ordering authority. `events` already yields strictly by
103
+ it; if you attach your own listener instead, you must order them yourself.
104
+ - **A failed run is not a rejected promise.** See [Error Handling](#error-handling).
105
+ - **Handle `content_snapshot` even from a token-streaming provider.** A snapshot replaces
106
+ the cumulative text rather than appending to it, and an empty one is how a provider
107
+ retracts content it already delivered — for example when an on-device safety check
108
+ rejects a half-generated response.
109
+
66
110
  ## API
67
111
 
68
- - `createIndeRunCapacitor(options)` — returns a handle that lazily `configure()`s on the first `run()` and memoizes it. Safe to call once at app startup.
69
- - The low-level plugin methods `configure(options)` and `run(request)` are also exported.
112
+ - `createIndeRunCapacitor(options)` — returns a handle that lazily `configure()`s on the first `run()` or `stream()` and memoizes it. Safe to call once at app startup.
113
+ - `run(request)` — Mode 1. Resolves with the canonical IndeRun `TaskResult`.
114
+ - `stream(request)` — Mode 2. Resolves with a `StreamRun` (`handle`, `events`, `cancel`). `events` is single-use.
115
+ - The low-level plugin methods `configure(options)`, `startStream(options)` and `cancelStream(options)` are also exported, along with the listener event names `STREAM_EVENT_NAME` (`"indeRunStreamEvent"`) and `STREAM_ERROR_NAME` (`"indeRunStreamError"`).
116
+
117
+ > The two listener event names are **public contract**. Native emits exactly these, and an
118
+ > app may attach its own listener to them; renaming one is a breaking change.
70
119
 
71
120
  The `IndeRunCapacitorPlugin`, `ConfigureOptions`, and `OpenAIProviderBootstrapOptions`
72
121
  contracts — including the `openAI` bootstrap config and the web-only
73
122
  `allowDirectOpenAIEndpoint` flag — are defined and documented in
74
- `src/definitions.ts`. `run()` returns the canonical IndeRun `TaskResult`.
123
+ `src/definitions.ts`.
124
+
125
+ Note one asymmetry in the low-level surface: `run(request)` passes the request at the
126
+ options root, while `startStream({ streamId, request })` nests it, because that envelope
127
+ also carries the bridge-local correlation id. `stream()` hides this.
75
128
 
76
129
  ## Platform Notes
77
130
 
@@ -82,12 +135,33 @@ contracts — including the `openAI` bootstrap config and the web-only
82
135
 
83
136
  ## Current Limitations
84
137
 
85
- - Mode 1 `run()` only.
138
+ - Mode 1 `run()` and Mode 2 `stream()` are supported. Mode 3 sessions are not.
139
+ - **No backpressure across the bridge.** Native pushes events; a slow consumer buys memory,
140
+ not throttling, because events buffer in JS until they are read. Runs are finite and
141
+ terminal-bounded, and any drop or throttle policy would be behaviour — which belongs in
142
+ the engines, not in a bridge.
143
+ - Which providers can actually stream is decided upstream, not here; see the
144
+ [provider matrix](https://github.com/independo-gmbh/inderun/blob/main/docs/architecture/providers.md#provider-matrix).
145
+ - An unrecognized `StreamEvent.type` is passed through untouched rather than rejected, so a
146
+ consumer built against an older contract revision keeps working when a newer one adds an
147
+ event type. Ignore what you do not recognize.
86
148
  - No plugin-level credential management API is exposed in this first cut.
87
149
  - The bridge is intentionally thin; cloud provider bootstrap still has to come from the app.
88
150
 
89
151
  ## Error Handling
90
152
 
153
+ A streaming run has **three** distinct failure surfaces, and conflating them is the easiest
154
+ mistake to make:
155
+
156
+ | Surface | When | How it reaches you |
157
+ | --- | --- | --- |
158
+ | Rejection | Request validation, or no streaming-capable provider | `stream()` rejects with an `IndeRunError` |
159
+ | Terminal `error` event | A provider failed, or the whole planned chain did | **Not** a rejection — `for await` completes normally and the last event is `terminal` with `payload.outcome === "error"` |
160
+ | Bridge fault | The transport lost or could not encode events | `events` throws an `Internal` `IndeRunError` |
161
+
162
+ So a run that fails *after starting* ends your loop normally. Branch on
163
+ `event.payload.outcome` to tell completion from failure from cancellation.
164
+
91
165
  Errors thrown by `configure()` and `run()` conform to `IndeRunError` from
92
166
  `@independo/inderun-contracts`; branch on `error.errorClass` (the shared error
93
167
  taxonomy). The bridge unwraps the native error envelope before re-throwing, so
@@ -37,11 +37,11 @@ dependencies {
37
37
  testImplementation("com.capacitorjs:core:8.0.0")
38
38
  implementation("androidx.appcompat:appcompat:1.7.1")
39
39
  implementation("androidx.core:core-ktx:1.17.0")
40
- implementation("app.independo.inderun:inderun-contracts:0.2.2")
41
- implementation("app.independo.inderun:inderun-core:0.2.2")
42
- implementation("app.independo.inderun:inderun-kotlin:0.2.2")
43
- implementation("app.independo.inderun:inderun-mlkit-providers:0.2.2")
44
- implementation("app.independo.inderun:inderun-openai-providers:0.2.2")
40
+ implementation("app.independo.inderun:inderun-contracts:0.3.0-dev.14")
41
+ implementation("app.independo.inderun:inderun-core:0.3.0-dev.14")
42
+ implementation("app.independo.inderun:inderun-kotlin:0.3.0-dev.14")
43
+ implementation("app.independo.inderun:inderun-mlkit-providers:0.3.0-dev.14")
44
+ implementation("app.independo.inderun:inderun-openai-providers:0.3.0-dev.14")
45
45
  implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
46
46
 
47
47
  testImplementation("junit:junit:4.13.2")
@@ -1,5 +1,6 @@
1
1
  package app.independo.inderun.capacitor
2
2
 
3
+ import app.independo.inderun.contracts.IndeRunError
3
4
  import app.independo.inderun.core.IndeRunException
4
5
  import app.independo.inderun.core.ProviderRegistry
5
6
  import app.independo.inderun.core.toIndeRunException
@@ -9,10 +10,12 @@ import app.independo.inderun.providers.openai.OpenAIAuthMode
9
10
  import app.independo.inderun.providers.openai.OpenAIProvider
10
11
  import app.independo.inderun.providers.openai.OpenAIProviderOptions
11
12
  import app.independo.inderun.sdk.IndeRun
13
+ import com.getcapacitor.JSObject
12
14
  import com.getcapacitor.Plugin
13
15
  import com.getcapacitor.PluginCall
14
16
  import com.getcapacitor.PluginMethod
15
17
  import com.getcapacitor.annotation.CapacitorPlugin
18
+ import kotlinx.coroutines.CancellationException
16
19
  import kotlinx.coroutines.CoroutineScope
17
20
  import kotlinx.coroutines.Dispatchers
18
21
  import kotlinx.coroutines.SupervisorJob
@@ -23,6 +26,7 @@ import kotlinx.coroutines.launch
23
26
  class IndeRunCapacitorPlugin : Plugin() {
24
27
  private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
25
28
  private var configuredRegistry: ProviderRegistry? = null
29
+ private val streams = IndeRunStreamRegistry()
26
30
 
27
31
  @PluginMethod
28
32
  fun configure(call: PluginCall) {
@@ -83,11 +87,126 @@ class IndeRunCapacitorPlugin : Plugin() {
83
87
  }
84
88
  }
85
89
 
90
+ /**
91
+ * Resolves with the run handle. Only validation and route-selection failures reject;
92
+ * a provider failure, a cancellation, or completion all arrive as the single terminal
93
+ * event on `indeRunStreamEvent` — which is why [resolved] is tracked: a PluginCall
94
+ * must settle exactly once, and a failure after the handle has gone back is an event,
95
+ * not a rejection.
96
+ *
97
+ * `retainUntilConsumed` closes the listener-registration race from the native side: an
98
+ * event emitted before the JS listener attaches is retained and replayed, not lost.
99
+ */
100
+ @PluginMethod
101
+ fun startStream(call: PluginCall) {
102
+ val streamId = call.getString("streamId")
103
+ if (streamId == null) {
104
+ rejectContractError(call, IllegalArgumentException("startStream requires a streamId."))
105
+ return
106
+ }
107
+ // Unlike run(request), which takes the request at the options root, startStream
108
+ // nests it under `request` so the envelope can also carry the bridge-local streamId.
109
+ val requestJson = call.getObject("request")
110
+ if (requestJson == null) {
111
+ rejectContractError(call, IllegalArgumentException("startStream requires a request."))
112
+ return
113
+ }
114
+
115
+ // Reserved before the engine is reached, so a cancel arriving during route
116
+ // selection is recorded rather than dropped as an unknown id.
117
+ streams.open(streamId)
118
+
119
+ val job = scope.launch {
120
+ var resolved = false
121
+ try {
122
+ val registry = configuredRegistry
123
+ ?: throw toIndeRunException(IllegalStateException("Capacitor IndeRun has not been configured. Configure providers before calling stream(request)."))
124
+ val request = IndeRunSerializer.parseTaskRequest(requestJson)
125
+ // IndeRun is stateless; new per call is intentional — registry is cached after configure().
126
+ val streamRun = IndeRun.initialize(context.applicationContext, registry).stream(request)
127
+
128
+ val outcome = streams.attach(streamId, streamRun)
129
+ if (outcome is AttachOutcome.CancelRequested) {
130
+ streamRun.cancel(outcome.reason)
131
+ }
132
+
133
+ call.resolve(IndeRunSerializer.encodeStreamRunHandle(streamRun.handle))
134
+ resolved = true
135
+
136
+ // The Flow is cold and single-use; collecting it exactly once here is what
137
+ // drives the run.
138
+ streamRun.events.collect { event ->
139
+ val payload = JSObject()
140
+ payload.put("streamId", streamId)
141
+ payload.put("event", IndeRunSerializer.encodeStreamEvent(event))
142
+ notifyListeners("indeRunStreamEvent", payload, true)
143
+ }
144
+ } catch (error: CancellationException) {
145
+ throw error
146
+ } catch (error: Throwable) {
147
+ val contractError = contractErrorFor(error)
148
+ if (resolved) {
149
+ val payload = JSObject()
150
+ payload.put("streamId", streamId)
151
+ payload.put(
152
+ "error",
153
+ runCatching { IndeRunSerializer.encodeError(contractError) }.getOrNull()
154
+ )
155
+ notifyListeners("indeRunStreamError", payload, true)
156
+ } else {
157
+ call.reject(
158
+ contractError.message,
159
+ contractError.errorClass.rawValue,
160
+ null,
161
+ runCatching { IndeRunSerializer.encodeError(contractError) }.getOrNull()
162
+ )
163
+ }
164
+ } finally {
165
+ streams.close(streamId)
166
+ }
167
+ }
168
+
169
+ streams.attachJob(streamId, job)
170
+ }
171
+
172
+ /**
173
+ * Resolves for an unknown or already-finished run: cancelling after the terminal is a
174
+ * no-op by contract, not an error.
175
+ */
176
+ @PluginMethod
177
+ fun cancelStream(call: PluginCall) {
178
+ val streamId = call.getString("streamId")
179
+ if (streamId == null) {
180
+ rejectContractError(call, IllegalArgumentException("cancelStream requires a streamId."))
181
+ return
182
+ }
183
+
184
+ streams.requestCancel(streamId, call.getString("reason"))
185
+ call.resolve()
186
+ }
187
+
86
188
  override fun handleOnDestroy() {
189
+ // Runs first, so providers unwind before the scope that collects them goes away.
190
+ streams.cancelAll("Capacitor plugin destroyed.")
87
191
  scope.cancel()
88
192
  super.handleOnDestroy()
89
193
  }
90
194
 
195
+ private fun contractErrorFor(error: Throwable): IndeRunError = when (error) {
196
+ is IndeRunException -> error.toContractError()
197
+ else -> toIndeRunException(error).toContractError()
198
+ }
199
+
200
+ private fun rejectContractError(call: PluginCall, error: Throwable) {
201
+ val contractError = contractErrorFor(error)
202
+ call.reject(
203
+ contractError.message,
204
+ contractError.errorClass.rawValue,
205
+ null,
206
+ runCatching { IndeRunSerializer.encodeError(contractError) }.getOrNull()
207
+ )
208
+ }
209
+
91
210
  private fun createRegistry(openAI: OpenAIProviderBootstrapOptions?): ProviderRegistry {
92
211
  val registry = AndroidProviderRegistryFactory.makeDefaultRegistry(context.applicationContext)
93
212
 
@@ -8,9 +8,17 @@ import app.independo.inderun.contracts.IndeRunErrorClass
8
8
  import app.independo.inderun.contracts.Message
9
9
  import app.independo.inderun.contracts.MessageRole
10
10
  import app.independo.inderun.contracts.OptimizeFor
11
+ import app.independo.inderun.contracts.Outcome
11
12
  import app.independo.inderun.contracts.OutputType
13
+ import app.independo.inderun.contracts.Payload
14
+ import app.independo.inderun.contracts.PayloadError
15
+ import app.independo.inderun.contracts.PayloadTelemetry
16
+ import app.independo.inderun.contracts.PayloadUsage
17
+ import app.independo.inderun.contracts.Phase
12
18
  import app.independo.inderun.contracts.PrivacyEnum
13
19
  import app.independo.inderun.contracts.SchemaVersion
20
+ import app.independo.inderun.contracts.StreamEvent
21
+ import app.independo.inderun.contracts.StreamRunHandle
14
22
  import app.independo.inderun.contracts.TaskKind
15
23
  import app.independo.inderun.contracts.TaskRequest
16
24
  import app.independo.inderun.contracts.TaskRequestConstraints
@@ -20,7 +28,6 @@ import app.independo.inderun.contracts.TaskRequestTelemetry
20
28
  import app.independo.inderun.contracts.TaskResult
21
29
  import app.independo.inderun.contracts.TaskResultTelemetry
22
30
  import app.independo.inderun.contracts.TelemetryLevel
23
- import app.independo.inderun.contracts.Usage
24
31
  import com.getcapacitor.JSArray
25
32
  import com.getcapacitor.JSObject
26
33
  import org.json.JSONArray
@@ -106,6 +113,104 @@ object IndeRunSerializer {
106
113
  }
107
114
  }
108
115
 
116
+ fun encodeStreamRunHandle(handle: StreamRunHandle): JSObject {
117
+ return JSObject().apply {
118
+ put("schemaVersion", handle.schemaVersion.rawValue)
119
+ put("runId", handle.runId)
120
+ put("startedAt", handle.startedAt)
121
+ handle.providerId?.let { put("providerId", it) }
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Note what is deliberately *not* here: a `when` over [StreamEvent.type].
127
+ *
128
+ * The inbound parsers in this file throw on an unrecognized enum value, and that is
129
+ * right for them — those are closed contract enums on a request, where an unknown
130
+ * value really is an invalid request. `type` is the opposite: stream-event.schema.json
131
+ * closes its union with an explicit catch-all branch, and contracts/README.md requires
132
+ * SDKs to treat an unrecognized type as ignore-or-pass-through rather than an error, so
133
+ * that an additive minor revision does not break older consumers. Copying the
134
+ * throw-on-unknown pattern here would turn the one mechanism protecting forward
135
+ * compatibility into a guaranteed hard failure at the bridge hop. The type crosses as
136
+ * the string it is.
137
+ */
138
+ fun encodeStreamEvent(event: StreamEvent): JSObject {
139
+ return JSObject().apply {
140
+ put("schemaVersion", event.schemaVersion.rawValue)
141
+ put("runId", event.runId)
142
+ put("sequence", event.sequence)
143
+ put("timestamp", event.timestamp)
144
+ put("type", event.type)
145
+ event.payload?.let { put("payload", encodeStreamPayload(it)) }
146
+ }
147
+ }
148
+
149
+ /**
150
+ * quicktype flattens the event union, so [Payload] carries every branch's fields as
151
+ * optionals. Emitting only the non-null ones is what reconstitutes the correct branch
152
+ * on the JS side: each branch's `outcome` discriminator plus its required peer field
153
+ * (finalText / error / partialText) is exactly the set that is populated.
154
+ */
155
+ private fun encodeStreamPayload(payload: Payload): JSObject {
156
+ return JSObject().apply {
157
+ payload.text?.let { put("text", it) }
158
+ payload.phase?.let { put("phase", phaseValue(it)) }
159
+ payload.finalText?.let { put("finalText", it) }
160
+ payload.finishReason?.let { put("finishReason", it.rawValue) }
161
+ payload.outcome?.let { put("outcome", outcomeValue(it)) }
162
+ payload.runId?.let { put("runId", it) }
163
+ payload.schemaVersion?.let { put("schemaVersion", it.rawValue) }
164
+ payload.telemetry?.let { put("telemetry", encodeStreamPayloadTelemetry(it)) }
165
+ payload.usage?.let { put("usage", encodeStreamPayloadUsage(it)) }
166
+ payload.error?.let { put("error", encodeStreamPayloadError(it)) }
167
+ payload.partialText?.let { put("partialText", it) }
168
+ payload.reason?.let { put("reason", it) }
169
+ }
170
+ }
171
+
172
+ private fun encodeStreamPayloadError(error: PayloadError): JSObject {
173
+ return JSObject().apply {
174
+ put("schemaVersion", error.schemaVersion.rawValue)
175
+ put("errorClass", error.errorClass.rawValue)
176
+ put("message", error.message)
177
+ error.providerId?.let { put("providerId", it) }
178
+ error.retryable?.let { put("retryable", it) }
179
+ error.retryAfterMs?.let { put("retryAfterMs", it) }
180
+ error.details?.let { put("details", JSONObject(it)) }
181
+ }
182
+ }
183
+
184
+ private fun encodeStreamPayloadTelemetry(telemetry: PayloadTelemetry): JSObject {
185
+ return JSObject().apply {
186
+ put("providerUsed", telemetry.providerUsed)
187
+ put("totalMs", telemetry.totalMs)
188
+ }
189
+ }
190
+
191
+ private fun encodeStreamPayloadUsage(usage: PayloadUsage): JSObject {
192
+ return JSObject().apply {
193
+ usage.inputTokens?.let { put("inputTokens", it) }
194
+ usage.outputTokens?.let { put("outputTokens", it) }
195
+ usage.totalTokens?.let { put("totalTokens", it) }
196
+ }
197
+ }
198
+
199
+ // Outcome and Phase are generated without a rawValue, unlike every other contract enum
200
+ // here, so the wire strings have to be written out. Both `when`s are exhaustive with no
201
+ // `else`: if a regeneration ever adds a constant, this must fail to compile rather than
202
+ // silently serialize the wrong discriminator.
203
+ private fun outcomeValue(outcome: Outcome): String = when (outcome) {
204
+ Outcome.Completed -> "completed"
205
+ Outcome.Error -> "error"
206
+ Outcome.Cancelled -> "cancelled"
207
+ }
208
+
209
+ private fun phaseValue(phase: Phase): String = when (phase) {
210
+ Phase.ProviderSelected -> "provider_selected"
211
+ Phase.Started -> "started"
212
+ }
213
+
109
214
  private fun parseOpenAIOptions(json: JSONObject): OpenAIProviderBootstrapOptions {
110
215
  return OpenAIProviderBootstrapOptions(
111
216
  model = json.getString("model"),
@@ -0,0 +1,118 @@
1
+ package app.independo.inderun.capacitor
2
+
3
+ import app.independo.inderun.core.StreamRun
4
+ import kotlinx.coroutines.Job
5
+
6
+ /**
7
+ * The outcome of attaching a freshly started run to its reserved id.
8
+ */
9
+ internal sealed interface AttachOutcome {
10
+ data object Attached : AttachOutcome
11
+
12
+ /** A cancel arrived before the run did; the caller must apply it. */
13
+ data class CancelRequested(val reason: String?) : AttachOutcome
14
+ }
15
+
16
+ /**
17
+ * Tracks the streaming runs the plugin is currently collecting, keyed by the
18
+ * bridge-local `streamId`.
19
+ *
20
+ * Two things make this more than a map. A cancel can arrive before the run it refers
21
+ * to exists — `startStream` has to reach route selection first — so a cancel in that
22
+ * window is recorded and applied on attach. And cancelling a run is *not* cancelling
23
+ * the collecting job: the engine answers a cancel with its one `cancelled` terminal
24
+ * event, which the collector still has to deliver. Job cancellation is reserved for
25
+ * teardown, where the webview is going away and nobody is left to receive a terminal.
26
+ *
27
+ * Deliberately free of any Capacitor dependency, so it is exercised by plain JVM unit
28
+ * tests.
29
+ */
30
+ internal class IndeRunStreamRegistry {
31
+ private class Entry {
32
+ var run: StreamRun? = null
33
+ var job: Job? = null
34
+ var cancelRequested: Boolean = false
35
+ var cancelReason: String? = null
36
+ }
37
+
38
+ private val lock = Any()
39
+ private val entries = mutableMapOf<String, Entry>()
40
+
41
+ val activeCount: Int
42
+ get() = synchronized(lock) { entries.size }
43
+
44
+ /** Reserves the id so a cancel arriving before the run is recorded, not dropped. */
45
+ fun open(streamId: String) {
46
+ synchronized(lock) {
47
+ entries.getOrPut(streamId) { Entry() }
48
+ }
49
+ }
50
+
51
+ fun attach(streamId: String, run: StreamRun): AttachOutcome {
52
+ synchronized(lock) {
53
+ // Torn down while the engine was still selecting a route.
54
+ val entry = entries[streamId] ?: return AttachOutcome.CancelRequested(null)
55
+ if (entry.cancelRequested) {
56
+ return AttachOutcome.CancelRequested(entry.cancelReason)
57
+ }
58
+ entry.run = run
59
+ return AttachOutcome.Attached
60
+ }
61
+ }
62
+
63
+ fun attachJob(streamId: String, job: Job) {
64
+ val orphaned = synchronized(lock) {
65
+ val entry = entries[streamId]
66
+ if (entry == null) {
67
+ true
68
+ } else {
69
+ entry.job = job
70
+ false
71
+ }
72
+ }
73
+ if (orphaned) {
74
+ job.cancel()
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Cancels the run if it has attached, otherwise records the request for attach. An
80
+ * unknown id — already finished, or never started — is a silent no-op, which is what
81
+ * makes cancelling after the terminal harmless.
82
+ */
83
+ fun requestCancel(streamId: String, reason: String?) {
84
+ val run = synchronized(lock) {
85
+ val entry = entries[streamId] ?: return
86
+ if (entry.cancelRequested) return
87
+ entry.cancelRequested = true
88
+ entry.cancelReason = reason
89
+ entry.run
90
+ }
91
+
92
+ // Never under the lock: cancel reaches into engine code.
93
+ run?.cancel(reason)
94
+ }
95
+
96
+ fun close(streamId: String) {
97
+ synchronized(lock) {
98
+ entries.remove(streamId)
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Teardown. Cancels each run first so providers unwind, then the collecting jobs,
104
+ * which at this point have nobody left to deliver to.
105
+ */
106
+ fun cancelAll(reason: String?) {
107
+ val draining = synchronized(lock) {
108
+ val copy = entries.values.toList()
109
+ entries.clear()
110
+ copy
111
+ }
112
+
113
+ for (entry in draining) {
114
+ entry.run?.cancel(reason)
115
+ entry.job?.cancel()
116
+ }
117
+ }
118
+ }