@irisrun/channel-core 0.2.0
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.
- package/README.md +38 -0
- package/dist/events.d.ts +20 -0
- package/dist/events.js +19 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +8 -0
- package/dist/port.d.ts +26 -0
- package/dist/port.js +1 -0
- package/dist/session.d.ts +57 -0
- package/dist/session.js +76 -0
- package/package.json +32 -0
package/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# @irisrun/channel-core
|
|
2
|
+
|
|
3
|
+
**Own, portable, verifiable state — in front of a human, over any channel.**
|
|
4
|
+
The narrow channel **port**: the contract every Iris channel implements so durable,
|
|
5
|
+
resumable, approval-gated sessions are pluggable and **replay-safe by construction**.
|
|
6
|
+
|
|
7
|
+
## What it is
|
|
8
|
+
|
|
9
|
+
`channel-rest`, `channel-mcp`, and `channel-slack` all speak the same two-identifier
|
|
10
|
+
protocol. This package is that protocol, factored into one driver:
|
|
11
|
+
|
|
12
|
+
- `makeChannelSession({ runTurn })` — mints the `sessionId`, owns and **rotates a
|
|
13
|
+
single-use continuation token**, and enforces the rotation rule in one place:
|
|
14
|
+
rotate **only on a committed turn** (`finished`/`parked`); a non-committed turn
|
|
15
|
+
(`contended`/`aborted`) journaled nothing, so it **keeps the prior token**. The
|
|
16
|
+
in-flight claim is atomic (taken in the same callback as the token check), so a
|
|
17
|
+
concurrent replay of a token is refused, not double-applied.
|
|
18
|
+
- `start` / `continueTurn` — the high-level API; `validateContinue` / `inFlight` /
|
|
19
|
+
`advance` — primitives a streaming transport uses to refuse loudly *before* opening
|
|
20
|
+
a stream. Refusals are a typed taxonomy: `unknown-session` · `missing-token` ·
|
|
21
|
+
`stale-token` · `in-flight`.
|
|
22
|
+
- `ChannelPort<Platform, Reply>` — the `normalizeInbound` / `emitOutbound` contract a
|
|
23
|
+
platform adapter implements. A transport reduces to: normalize → drive the session
|
|
24
|
+
→ emit.
|
|
25
|
+
- `ChannelEvent` / `toOutcomeEvent` — the one streaming-event vocabulary (`record` ·
|
|
26
|
+
`delta` · `outcome` · `error`), shared so SSE, WebSocket, and any future transport
|
|
27
|
+
agree field-for-field.
|
|
28
|
+
|
|
29
|
+
The proof that channels are interchangeable is a single shared **conformance suite**
|
|
30
|
+
(`tests/lib/channel-port-conformance.ts`) that `channel-rest`, `channel-mcp`, and
|
|
31
|
+
`channel-slack` all pass — "channels behind one port" is a literal, executed guarantee.
|
|
32
|
+
|
|
33
|
+
The continuation token is an instance-local ordering credential; the **durable**
|
|
34
|
+
session lives in the StateStore journal. See `docs/reference/channel-port-spec.md`.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
Part of [Iris](../../README.md) — own, portable, verifiable state.
|
package/dist/events.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Json, TurnOutcome } from "@irisrun/core";
|
|
2
|
+
export type ChannelEvent = {
|
|
3
|
+
type: "record";
|
|
4
|
+
record: Json;
|
|
5
|
+
} | {
|
|
6
|
+
type: "delta";
|
|
7
|
+
text: string;
|
|
8
|
+
} | {
|
|
9
|
+
type: "outcome";
|
|
10
|
+
sessionId: string;
|
|
11
|
+
status: TurnOutcome<Json>["status"];
|
|
12
|
+
output?: Json;
|
|
13
|
+
wait?: Json;
|
|
14
|
+
current?: number;
|
|
15
|
+
continuationToken?: string;
|
|
16
|
+
} | {
|
|
17
|
+
type: "error";
|
|
18
|
+
message: string;
|
|
19
|
+
};
|
|
20
|
+
export declare function toOutcomeEvent<S extends Json>(sessionId: string, outcome: TurnOutcome<S>, token?: string): ChannelEvent;
|
package/dist/events.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Map a finished turn to the terminal `outcome` event, plus the rotated token.
|
|
2
|
+
// `token` is omitted only when the channel did not (re)issue one. Mirrors the
|
|
3
|
+
// buffered turn-response mapping so streaming and buffered agree field-for-field.
|
|
4
|
+
export function toOutcomeEvent(sessionId, outcome, token) {
|
|
5
|
+
const ev = {
|
|
6
|
+
type: "outcome",
|
|
7
|
+
sessionId,
|
|
8
|
+
status: outcome.status,
|
|
9
|
+
};
|
|
10
|
+
if (outcome.status === "finished" && outcome.output !== undefined)
|
|
11
|
+
ev.output = outcome.output;
|
|
12
|
+
if (outcome.status === "parked")
|
|
13
|
+
ev.wait = outcome.wait;
|
|
14
|
+
if (outcome.status === "contended")
|
|
15
|
+
ev.current = outcome.current;
|
|
16
|
+
if (token !== undefined)
|
|
17
|
+
ev.continuationToken = token;
|
|
18
|
+
return ev;
|
|
19
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const PACKAGE = "@irisrun/channel-core";
|
|
2
|
+
export { makeChannelSession } from "./session.js";
|
|
3
|
+
export type { ChannelSession, ChannelSessionOptions, ChannelRefusal, StartResult, ContinueResult, AdvanceResult, } from "./session.js";
|
|
4
|
+
export type { ChannelEvent } from "./events.js";
|
|
5
|
+
export { toOutcomeEvent } from "./events.js";
|
|
6
|
+
export type { Inbound, ChannelPort } from "./port.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// @irisrun/channel-core — the narrow, replay-safe channel PORT.
|
|
2
|
+
// The two-identifier protocol (mint sessionId, own/rotate a single-use continuation
|
|
3
|
+
// token, atomic single-use, loud refusals) in one place, behind which channel-rest,
|
|
4
|
+
// channel-mcp, and channel-slack are interchangeable — proven by a shared conformance
|
|
5
|
+
// suite any channel must pass. Depends only on @irisrun/core types.
|
|
6
|
+
export const PACKAGE = "@irisrun/channel-core";
|
|
7
|
+
export { makeChannelSession } from "./session.js";
|
|
8
|
+
export { toOutcomeEvent } from "./events.js";
|
package/dist/port.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Json } from "@irisrun/core";
|
|
2
|
+
import type { StartResult, ContinueResult } from "./session.js";
|
|
3
|
+
/** A platform event normalized to a channel intent. `ignore` = a platform event the
|
|
4
|
+
* channel does not act on (a bot's own echo, a health ping, a handshake handled
|
|
5
|
+
* out of band). `continue` carries the token the platform round-tripped (null when
|
|
6
|
+
* the transport authorizes by connection rather than token, e.g. a held socket). */
|
|
7
|
+
export type Inbound = {
|
|
8
|
+
kind: "start";
|
|
9
|
+
body: Json;
|
|
10
|
+
} | {
|
|
11
|
+
kind: "continue";
|
|
12
|
+
sessionId: string;
|
|
13
|
+
token: string | null;
|
|
14
|
+
body: Json;
|
|
15
|
+
} | {
|
|
16
|
+
kind: "ignore";
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* A channel adapter for a platform. `Platform` is the inbound event type (an HTTP
|
|
20
|
+
* request, a JSON-RPC call, a Slack payload); `Reply` is the platform's outbound
|
|
21
|
+
* shape. The driver in between is the shared ChannelSession.
|
|
22
|
+
*/
|
|
23
|
+
export interface ChannelPort<Platform, Reply> {
|
|
24
|
+
normalizeInbound(ev: Platform): Inbound;
|
|
25
|
+
emitOutbound(result: StartResult<Json> | ContinueResult<Json>): Reply;
|
|
26
|
+
}
|
package/dist/port.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Json, TurnOutcome } from "@irisrun/core";
|
|
2
|
+
import type { ChannelEvent } from "./events.js";
|
|
3
|
+
/** Why a continue was refused — each maps to a transport's loud error. */
|
|
4
|
+
export type ChannelRefusal = "unknown-session" | "missing-token" | "stale-token" | "in-flight";
|
|
5
|
+
export interface StartResult<S extends Json> {
|
|
6
|
+
sessionId: string;
|
|
7
|
+
token: string;
|
|
8
|
+
outcome: TurnOutcome<S>;
|
|
9
|
+
}
|
|
10
|
+
export type ContinueResult<S extends Json> = {
|
|
11
|
+
ok: true;
|
|
12
|
+
sessionId: string;
|
|
13
|
+
token: string;
|
|
14
|
+
outcome: TurnOutcome<S>;
|
|
15
|
+
} | {
|
|
16
|
+
ok: false;
|
|
17
|
+
reason: ChannelRefusal;
|
|
18
|
+
};
|
|
19
|
+
/** advance() result: in-flight is the only refusal it can produce (validation is separate). */
|
|
20
|
+
export type AdvanceResult<S extends Json> = {
|
|
21
|
+
ok: true;
|
|
22
|
+
sessionId: string;
|
|
23
|
+
token: string;
|
|
24
|
+
outcome: TurnOutcome<S>;
|
|
25
|
+
} | {
|
|
26
|
+
ok: false;
|
|
27
|
+
reason: "in-flight";
|
|
28
|
+
};
|
|
29
|
+
export interface ChannelSessionOptions<S extends Json> {
|
|
30
|
+
runTurn: (sessionId: string, body: Json, emit?: (ev: ChannelEvent) => void) => Promise<TurnOutcome<S>>;
|
|
31
|
+
mintSessionId?: () => string;
|
|
32
|
+
mintToken?: () => string;
|
|
33
|
+
}
|
|
34
|
+
export interface ChannelSession<S extends Json> {
|
|
35
|
+
/** START: mint a session, run the first turn, issue a fresh token. */
|
|
36
|
+
start(body: Json, emit?: (ev: ChannelEvent) => void): Promise<StartResult<S>>;
|
|
37
|
+
/** CONTINUE (strict): validate the token, claim in-flight, run, rotate. */
|
|
38
|
+
continueTurn(sessionId: string, presentedToken: string | null, body: Json, emit?: (ev: ChannelEvent) => void): Promise<ContinueResult<S>>;
|
|
39
|
+
/** Pure token validation (no run) — for streaming transports that must refuse
|
|
40
|
+
* loudly BEFORE opening the stream. Returns null when the token is acceptable.
|
|
41
|
+
* Does NOT consider in-flight (use inFlight()); call inFlight() then advance() with
|
|
42
|
+
* no `await` in between to keep the check→claim atomic. */
|
|
43
|
+
validateContinue(sessionId: string, presentedToken: string | null): ChannelRefusal | null;
|
|
44
|
+
/** True if a turn is already in flight for this session (peek; for streaming). */
|
|
45
|
+
inFlight(sessionId: string): boolean;
|
|
46
|
+
/** Claim in-flight, run the turn, rotate per the committed-only rule, release.
|
|
47
|
+
* Used by START, by continueTurn, and directly by the WS path (which authorizes via
|
|
48
|
+
* the held connection rather than a presented token). A fresh session id (not yet
|
|
49
|
+
* registered) runs as a START (prior token null → always mints). */
|
|
50
|
+
advance(sessionId: string, body: Json, emit?: (ev: ChannelEvent) => void): Promise<AdvanceResult<S>>;
|
|
51
|
+
/** A fresh session id (for the WS path, which binds the session to the connection
|
|
52
|
+
* synchronously before the first turn runs). */
|
|
53
|
+
newSessionId(): string;
|
|
54
|
+
currentToken(sessionId: string): string | undefined;
|
|
55
|
+
hasSession(sessionId: string): boolean;
|
|
56
|
+
}
|
|
57
|
+
export declare function makeChannelSession<S extends Json>(opts: ChannelSessionOptions<S>): ChannelSession<S>;
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
function randomId() {
|
|
2
|
+
// node:crypto is host-side; avoid importing it here so channel-core stays
|
|
3
|
+
// dependency-light. A transport may inject mintSessionId/mintToken (they do, with
|
|
4
|
+
// randomUUID) — this fallback is only for tests/usages that don't.
|
|
5
|
+
return `${Date.now().toString(36)}-${Math.round(Math.random() * 1e9).toString(36)}`;
|
|
6
|
+
}
|
|
7
|
+
const COMMITTED = new Set(["finished", "parked"]);
|
|
8
|
+
export function makeChannelSession(opts) {
|
|
9
|
+
const mintSessionId = opts.mintSessionId ?? randomId;
|
|
10
|
+
const mintToken = opts.mintToken ?? randomId;
|
|
11
|
+
// The channel OWNS the current continuationToken per session (in-process; a durable
|
|
12
|
+
// deploy persists the durable JOURNAL in the store — the token is an instance-local
|
|
13
|
+
// ordering credential, not durable state).
|
|
14
|
+
const tokens = new Map();
|
|
15
|
+
// Single-use guard under concurrency: the in-flight claim is taken in the SAME
|
|
16
|
+
// event-loop callback as the token check, with no `await` between, so a second
|
|
17
|
+
// concurrent request presenting the same valid token is refused before rotation.
|
|
18
|
+
const inFlightSet = new Set();
|
|
19
|
+
// Issue the next token: rotate (mint) ONLY on a committed outcome; a non-committed
|
|
20
|
+
// outcome with a prior token keeps it. A START (priorToken null) always mints.
|
|
21
|
+
const issueToken = (sessionId, outcome, priorToken) => {
|
|
22
|
+
if (priorToken !== null && !COMMITTED.has(outcome.status))
|
|
23
|
+
return priorToken;
|
|
24
|
+
const token = mintToken();
|
|
25
|
+
tokens.set(sessionId, token);
|
|
26
|
+
return token;
|
|
27
|
+
};
|
|
28
|
+
const advance = async (sessionId, body, emit) => {
|
|
29
|
+
if (inFlightSet.has(sessionId))
|
|
30
|
+
return { ok: false, reason: "in-flight" };
|
|
31
|
+
const prior = tokens.get(sessionId) ?? null; // null for a fresh session → START
|
|
32
|
+
inFlightSet.add(sessionId);
|
|
33
|
+
try {
|
|
34
|
+
const outcome = await opts.runTurn(sessionId, body, emit);
|
|
35
|
+
const token = issueToken(sessionId, outcome, prior);
|
|
36
|
+
return { ok: true, sessionId, token, outcome };
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
inFlightSet.delete(sessionId);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
const validateContinue = (sessionId, presentedToken) => {
|
|
43
|
+
if (!tokens.has(sessionId))
|
|
44
|
+
return "unknown-session";
|
|
45
|
+
if (presentedToken === null || presentedToken === "")
|
|
46
|
+
return "missing-token";
|
|
47
|
+
if (presentedToken !== tokens.get(sessionId))
|
|
48
|
+
return "stale-token";
|
|
49
|
+
return null;
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
async start(body, emit) {
|
|
53
|
+
const sessionId = mintSessionId();
|
|
54
|
+
// A brand-new id cannot be in flight, so advance always succeeds here.
|
|
55
|
+
const r = await advance(sessionId, body, emit);
|
|
56
|
+
// r.ok is always true (fresh id). Narrow for the type system.
|
|
57
|
+
if (!r.ok)
|
|
58
|
+
throw new Error("channel-core: unreachable in-flight on a fresh session");
|
|
59
|
+
return { sessionId, token: r.token, outcome: r.outcome };
|
|
60
|
+
},
|
|
61
|
+
async continueTurn(sessionId, presentedToken, body, emit) {
|
|
62
|
+
// Token check (sync) → advance (claims in-flight synchronously before its first
|
|
63
|
+
// await) — one callback, no await between → the single-use claim is atomic.
|
|
64
|
+
const refusal = validateContinue(sessionId, presentedToken);
|
|
65
|
+
if (refusal)
|
|
66
|
+
return { ok: false, reason: refusal };
|
|
67
|
+
return advance(sessionId, body, emit);
|
|
68
|
+
},
|
|
69
|
+
validateContinue,
|
|
70
|
+
inFlight: (sessionId) => inFlightSet.has(sessionId),
|
|
71
|
+
advance,
|
|
72
|
+
newSessionId: () => mintSessionId(),
|
|
73
|
+
currentToken: (sessionId) => tokens.get(sessionId),
|
|
74
|
+
hasSession: (sessionId) => tokens.has(sessionId),
|
|
75
|
+
};
|
|
76
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@irisrun/channel-core",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Iris channel port — the narrow, replay-safe contract every channel implements: mint the sessionId, own and rotate a single-use continuation token (rotate only on a committed turn; keep it on a non-committed one), an atomic single-use in-flight claim, normalize-inbound / emit-outbound, and a loud refusal taxonomy. The shared driver behind channel-rest, channel-mcp, and channel-slack, with one conformance suite any channel must pass — so durable, resumable sessions are pluggable and replay-safe by construction. Depends only on @irisrun/core types.",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"iris-src": "./src/index.ts",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@irisrun/core": "^0.2.0"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=24"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/xoai/iris.git",
|
|
26
|
+
"directory": "packages/channel-core"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/xoai/iris#readme",
|
|
29
|
+
"files": [
|
|
30
|
+
"dist"
|
|
31
|
+
]
|
|
32
|
+
}
|