@xandreed/plugin-render 0.8.0-next.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/LICENSE +21 -0
- package/README.md +5 -0
- package/dist/domain/conformance.entity.d.ts +9 -0
- package/dist/domain/conformance.entity.js +7 -0
- package/dist/domain/feed-frame.entity.d.ts +63 -0
- package/dist/domain/feed-frame.entity.functions.d.ts +20 -0
- package/dist/domain/feed-frame.entity.functions.js +38 -0
- package/dist/domain/feed-frame.entity.js +21 -0
- package/dist/domain/render-output.entity.d.ts +51 -0
- package/dist/domain/render-output.entity.js +33 -0
- package/dist/domain/render-surface.entity.d.ts +164 -0
- package/dist/domain/render-surface.entity.functions.d.ts +22 -0
- package/dist/domain/render-surface.entity.functions.js +36 -0
- package/dist/domain/render-surface.entity.js +63 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +15 -0
- package/dist/output.d.ts +5 -0
- package/dist/output.js +4 -0
- package/dist/plugin.adapter.d.ts +28 -0
- package/dist/plugin.adapter.js +27 -0
- package/dist/ports/render-output.port.d.ts +40 -0
- package/dist/ports/render-output.port.js +16 -0
- package/dist/ports/render.port.d.ts +86 -0
- package/dist/ports/render.port.js +13 -0
- package/dist/render-feed.adapter.d.ts +14 -0
- package/dist/render-feed.adapter.js +40 -0
- package/dist/render-output.adapter.d.ts +10 -0
- package/dist/render-output.adapter.js +28 -0
- package/dist/render-output.test.d.ts +1 -0
- package/dist/render-output.tools.d.ts +59 -0
- package/dist/render-output.tools.js +10 -0
- package/dist/render-store.memory.adapter.d.ts +69 -0
- package/dist/render-store.memory.adapter.js +47 -0
- package/dist/render-surface.adapter.d.ts +14 -0
- package/dist/render-surface.adapter.js +128 -0
- package/dist/render-surface.conformance.d.ts +18 -0
- package/dist/render-surface.conformance.js +173 -0
- package/dist/render-surface.test.d.ts +1 -0
- package/dist/render-tail.sessions.adapter.d.ts +12 -0
- package/dist/render-tail.sessions.adapter.js +15 -0
- package/dist/render-tail.sessions.test.d.ts +1 -0
- package/dist/render-transport.adapter.d.ts +15 -0
- package/dist/render-transport.adapter.js +27 -0
- package/dist/render-transport.conformance.d.ts +24 -0
- package/dist/render-transport.conformance.js +71 -0
- package/dist/render-transport.test.d.ts +1 -0
- package/package.json +34 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { definePlugin } from "@xandreed/core";
|
|
3
|
+
import { FeedOptions } from "./domain/feed-frame.entity.js";
|
|
4
|
+
import { UiOutputAdmission } from "./ports/render-output.port.js";
|
|
5
|
+
import { JournalTail, Render, RenderFeed, RenderStore } from "./ports/render.port.js";
|
|
6
|
+
import { RenderFeedLive } from "./render-feed.adapter.js";
|
|
7
|
+
import { RenderLive } from "./render-surface.adapter.js";
|
|
8
|
+
const SurfaceConfig = Schema.Struct({ maxBytes: Schema.Int.pipe(Schema.check(Schema.isBetween({ minimum: 1024, maximum: 131072 }))) });
|
|
9
|
+
export const renderSurfaceDefaults = { maxBytes: 32768 }, renderSurfacePlugin = definePlugin({
|
|
10
|
+
id: "@xandreed/plugin-render/surface",
|
|
11
|
+
version: "0.8.0-next.0",
|
|
12
|
+
scope: "runtime",
|
|
13
|
+
requires: [RenderStore, UiOutputAdmission],
|
|
14
|
+
provides: [Render],
|
|
15
|
+
config: SurfaceConfig,
|
|
16
|
+
defaults: renderSurfaceDefaults,
|
|
17
|
+
layer: ({ maxBytes }) => RenderLive(maxBytes)
|
|
18
|
+
}), renderFeedDefaults = { pollMs: 200, maxPollMs: 1000, heartbeatMs: 15000, maxDurationMs: 240000 }, renderFeedPlugin = definePlugin({
|
|
19
|
+
id: "@xandreed/plugin-render/feed",
|
|
20
|
+
version: "0.8.0-next.0",
|
|
21
|
+
scope: "runtime",
|
|
22
|
+
requires: [JournalTail],
|
|
23
|
+
provides: [RenderFeed],
|
|
24
|
+
config: FeedOptions,
|
|
25
|
+
defaults: renderFeedDefaults,
|
|
26
|
+
layer: (options) => RenderFeedLive(options)
|
|
27
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Context } from "effect";
|
|
2
|
+
import type { Effect } from "effect";
|
|
3
|
+
import type { uiOutputToolkit } from "../render-output.tools.js";
|
|
4
|
+
import type { UiOutputError, UiOutputProposal, UiOutputReceipt, UiOutputScope } from "../domain/render-output.entity.js";
|
|
5
|
+
declare const UiOutputAdmission_base: Context.ServiceClass<UiOutputAdmission, "efferent/ui/OutputAdmission", {
|
|
6
|
+
readonly validate: (scope: UiOutputScope, proposal: UiOutputProposal) => Effect.Effect<void, UiOutputError>;
|
|
7
|
+
}>;
|
|
8
|
+
/** Exact release lookup + prop schema + evidence/permissions. Fail closed. */
|
|
9
|
+
export declare class UiOutputAdmission extends UiOutputAdmission_base {
|
|
10
|
+
}
|
|
11
|
+
declare const UiOutputJournal_base: Context.ServiceClass<UiOutputJournal, "efferent/ui/OutputJournal", {
|
|
12
|
+
readonly commit: (scope: UiOutputScope, proposal: UiOutputProposal) => Effect.Effect<UiOutputReceipt, UiOutputError>;
|
|
13
|
+
}>;
|
|
14
|
+
/** A transaction must check ownership/fence, deduplicate operationId, persist
|
|
15
|
+
* the immutable revision AND its journal/outbox event before returning.
|
|
16
|
+
* Reusing an operationId with different content must fail with conflict.
|
|
17
|
+
* Streaming hosts tail this journal; they never send speculative proposals. */
|
|
18
|
+
export declare class UiOutputJournal extends UiOutputJournal_base {
|
|
19
|
+
}
|
|
20
|
+
declare const UiOutputContext_base: Context.ServiceClass<UiOutputContext, "efferent/ui/OutputContext", {
|
|
21
|
+
readonly threadId: string;
|
|
22
|
+
readonly runId: string;
|
|
23
|
+
readonly messageId: string;
|
|
24
|
+
readonly principalId: string;
|
|
25
|
+
readonly fence: number;
|
|
26
|
+
}>;
|
|
27
|
+
export declare class UiOutputContext extends UiOutputContext_base {
|
|
28
|
+
}
|
|
29
|
+
declare const UiOutput_base: Context.ServiceClass<UiOutput, "efferent/ui/Output", {
|
|
30
|
+
readonly emit: (proposal: UiOutputProposal) => Effect.Effect<UiOutputReceipt, UiOutputError>;
|
|
31
|
+
}>;
|
|
32
|
+
export declare class UiOutput extends UiOutput_base {
|
|
33
|
+
}
|
|
34
|
+
declare const UiOutputTools_base: Context.ServiceClass<UiOutputTools, "efferent/ui/OutputTools", {
|
|
35
|
+
readonly toolkit: typeof uiOutputToolkit;
|
|
36
|
+
}>;
|
|
37
|
+
/** Separate from AgentTools so a UI plugin composes with domain tool plugins. */
|
|
38
|
+
export declare class UiOutputTools extends UiOutputTools_base {
|
|
39
|
+
}
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Context } from "effect";
|
|
2
|
+
|
|
3
|
+
export class UiOutputAdmission extends Context.Service()("efferent/ui/OutputAdmission") {
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export class UiOutputJournal extends Context.Service()("efferent/ui/OutputJournal") {
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class UiOutputContext extends Context.Service()("efferent/ui/OutputContext") {
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class UiOutput extends Context.Service()("efferent/ui/Output") {
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class UiOutputTools extends Context.Service()("efferent/ui/OutputTools") {
|
|
16
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Context } from "effect";
|
|
2
|
+
import type { Effect, Option, Stream } from "effect";
|
|
3
|
+
import type { UiOutputProposal, UiOutputReceipt } from "../domain/render-output.entity.js";
|
|
4
|
+
import type { FreezeReason, PublishResult, RenderError, RenderNode, RenderSnapshotInput, SurfaceCompleted, SurfaceFrozen, SurfacePlanned, SurfaceRecord, SurfaceScope, SurfaceState } from "../domain/render-surface.entity.js";
|
|
5
|
+
import type { FeedFrame, FeedPayload, FeedScope, JournalRecord } from "../domain/feed-frame.entity.js";
|
|
6
|
+
declare const RenderStore_base: Context.ServiceClass<RenderStore, "efferent/render/Store", {
|
|
7
|
+
readonly hydrate: (scope: SurfaceScope) => Effect.Effect<ReadonlyArray<SurfaceRecord>, RenderError>;
|
|
8
|
+
readonly plan: (scope: SurfaceScope, record: SurfacePlanned) => Effect.Effect<void, RenderError>;
|
|
9
|
+
readonly commit: (scope: SurfaceScope, write: {
|
|
10
|
+
readonly versionId: string;
|
|
11
|
+
readonly proposal: UiOutputProposal;
|
|
12
|
+
}) => Effect.Effect<UiOutputReceipt, RenderError>;
|
|
13
|
+
readonly complete: (scope: SurfaceScope, record: SurfaceCompleted) => Effect.Effect<void, RenderError>;
|
|
14
|
+
readonly freeze: (scope: SurfaceScope, record: SurfaceFrozen) => Effect.Effect<void, RenderError>;
|
|
15
|
+
readonly annotate: (scope: SurfaceScope, kind: string, data: Readonly<Record<string, unknown>>) => Effect.Effect<void, RenderError>;
|
|
16
|
+
}>;
|
|
17
|
+
/**
|
|
18
|
+
* The host's durable surface storage. Every write is fenced by the scope.
|
|
19
|
+
* - `plan` MUST fail with code `frozen` inside the same transaction when a
|
|
20
|
+
* `SurfaceFrozen` exists for the scope's surface and message.
|
|
21
|
+
* - `commit` is idempotent by `proposal.operationId`; reusing an id with a
|
|
22
|
+
* different proposal fails with `conflict`. It persists a `SurfaceCommitted`.
|
|
23
|
+
* - `hydrate` returns this surface's records, in write order, for every message.
|
|
24
|
+
*/
|
|
25
|
+
export declare class RenderStore extends RenderStore_base {
|
|
26
|
+
}
|
|
27
|
+
/** Everything a host does with one surface during a run. Writes are serialized. */
|
|
28
|
+
export interface RenderSurface {
|
|
29
|
+
readonly scope: SurfaceScope;
|
|
30
|
+
/** The records hydrated when the surface was opened, for every message (e.g. the page being edited). */
|
|
31
|
+
readonly records: ReadonlyArray<SurfaceRecord>;
|
|
32
|
+
readonly state: Effect.Effect<SurfaceState>;
|
|
33
|
+
/** Versioned and deduplicated: identical content opens no new generation. A frozen surface records a frozen, partial completion instead of failing. */
|
|
34
|
+
readonly publish: (snapshot: RenderSnapshotInput) => Effect.Effect<PublishResult, RenderError>;
|
|
35
|
+
/** Commit a node into a declared placeholder of the current version; allowed after freezing. */
|
|
36
|
+
readonly fill: (placeholder: string, node: RenderNode) => Effect.Effect<UiOutputReceipt, RenderError>;
|
|
37
|
+
/** Run a stream of snapshots in the background through the host's `fork`, one preparation at a time. Failures are annotated `render.preparation-failed` and passed to `recover`. */
|
|
38
|
+
readonly prepare: <E>(work: Stream.Stream<RenderSnapshotInput, E>, recover: (error: E | RenderError) => Effect.Effect<void>) => Effect.Effect<void>;
|
|
39
|
+
/** Wait for every preparation started so far. */
|
|
40
|
+
readonly settled: Effect.Effect<void>;
|
|
41
|
+
readonly freeze: (reason: FreezeReason) => Effect.Effect<void, RenderError>;
|
|
42
|
+
readonly annotate: (kind: string, data: Readonly<Record<string, unknown>>) => Effect.Effect<void, RenderError>;
|
|
43
|
+
}
|
|
44
|
+
export interface RenderOpenOptions {
|
|
45
|
+
/** The host's background runner (e.g. its turn tasks), so preparations live and die with the host's unit of work. */
|
|
46
|
+
readonly fork: (tag: string, work: Effect.Effect<void>) => Effect.Effect<void>;
|
|
47
|
+
}
|
|
48
|
+
declare const Render_base: Context.ServiceClass<Render, "efferent/render/Render", {
|
|
49
|
+
readonly open: (scope: SurfaceScope, options: RenderOpenOptions) => Effect.Effect<RenderSurface, RenderError>;
|
|
50
|
+
}>;
|
|
51
|
+
export declare class Render extends Render_base {
|
|
52
|
+
}
|
|
53
|
+
declare const JournalTail_base: Context.ServiceClass<JournalTail, "efferent/render/JournalTail", {
|
|
54
|
+
readonly read: (feed: FeedScope, after: number) => Effect.Effect<ReadonlyArray<JournalRecord>, RenderError>;
|
|
55
|
+
/**
|
|
56
|
+
* Optional wake signal: each element means the feed's journal may have new
|
|
57
|
+
* records, and the feed polls at once instead of waiting out its interval.
|
|
58
|
+
* Polling stays the fallback; a failed or ended stream only stops waking.
|
|
59
|
+
*/
|
|
60
|
+
readonly changes?: (feed: FeedScope) => Stream.Stream<void, RenderError>;
|
|
61
|
+
}>;
|
|
62
|
+
/** The host's journal, read after a cursor. The host authorizes the feed's principal. */
|
|
63
|
+
export declare class JournalTail extends JournalTail_base {
|
|
64
|
+
}
|
|
65
|
+
/** Maps a journal record to what a client receives; `None` skips it (the cursor still advances). */
|
|
66
|
+
export type FeedProjection = (record: JournalRecord) => Effect.Effect<Option.Option<FeedPayload>, RenderError>;
|
|
67
|
+
declare const RenderFeed_base: Context.ServiceClass<RenderFeed, "efferent/render/Feed", {
|
|
68
|
+
readonly frames: (feed: FeedScope, after: number, project: FeedProjection) => Stream.Stream<FeedFrame, RenderError>;
|
|
69
|
+
}>;
|
|
70
|
+
export declare class RenderFeed extends RenderFeed_base {
|
|
71
|
+
}
|
|
72
|
+
/** A wire: SSE, WebSocket, or anything that can carry text frames to one peer. */
|
|
73
|
+
export interface RenderTransport<Peer> {
|
|
74
|
+
readonly id: string;
|
|
75
|
+
readonly serve: (frames: Stream.Stream<FeedFrame, RenderError>, peer: Peer) => Effect.Effect<void>;
|
|
76
|
+
}
|
|
77
|
+
export interface SsePeer {
|
|
78
|
+
readonly write: (chunk: string) => Effect.Effect<void>;
|
|
79
|
+
/** Completes when the client disconnects. */
|
|
80
|
+
readonly closed: Effect.Effect<void>;
|
|
81
|
+
}
|
|
82
|
+
export interface SocketPeer {
|
|
83
|
+
readonly send: (text: string) => Effect.Effect<void>;
|
|
84
|
+
readonly closed: Effect.Effect<void>;
|
|
85
|
+
}
|
|
86
|
+
export {};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Context } from "effect";
|
|
2
|
+
|
|
3
|
+
export class RenderStore extends Context.Service()("efferent/render/Store") {
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export class Render extends Context.Service()("efferent/render/Render") {
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class JournalTail extends Context.Service()("efferent/render/JournalTail") {
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class RenderFeed extends Context.Service()("efferent/render/Feed") {
|
|
13
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Layer, Stream } from "effect";
|
|
2
|
+
import type { FeedFrame, FeedOptions, FeedScope } from "./domain/feed-frame.entity.js";
|
|
3
|
+
import type { RenderError } from "./domain/render-surface.entity.js";
|
|
4
|
+
import { JournalTail, RenderFeed } from "./ports/render.port.js";
|
|
5
|
+
/**
|
|
6
|
+
* A journal tail as a stream of frames: records after the cursor (projected),
|
|
7
|
+
* one ready frame after the first empty poll, heartbeats while idle, idle
|
|
8
|
+
* polling backs off (a `changes` signal polls at once); the stream ends after
|
|
9
|
+
* `maxDurationMs`.
|
|
10
|
+
*/
|
|
11
|
+
export declare const makeRenderFeed: (tail: typeof JournalTail.Service, options: FeedOptions) => {
|
|
12
|
+
readonly frames: (feed: FeedScope, after: number, project: import("./index.js").FeedProjection) => Stream.Stream<FeedFrame, RenderError>;
|
|
13
|
+
};
|
|
14
|
+
export declare const RenderFeedLive: (options: FeedOptions) => Layer.Layer<RenderFeed, never, JournalTail>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Clock, Duration, Effect, Layer, Option, Queue, Scope, Stream } from "effect";
|
|
2
|
+
import { FeedHeartbeat, FeedReady, FeedRecord } from "./domain/feed-frame.entity.js";
|
|
3
|
+
import { nextPollMs } from "./domain/feed-frame.entity.functions.js";
|
|
4
|
+
import { JournalTail, RenderFeed } from "./ports/render.port.js";
|
|
5
|
+
const waitOf = (tail, feed) => Option.match(Option.fromNullishOr(tail.changes), {
|
|
6
|
+
onNone: () => Effect.succeed((delayMs) => Effect.sleep(Duration.millis(delayMs))),
|
|
7
|
+
onSome: (changes) => Effect.gen(function* () {
|
|
8
|
+
const wake = yield* Queue.sliding(1);
|
|
9
|
+
yield* changes(feed).pipe(Stream.runForEach(() => Queue.offer(wake, void 0)), Effect.ignore, Effect.forkScoped);
|
|
10
|
+
return (delayMs) => Effect.race(Effect.sleep(Duration.millis(delayMs)), Queue.take(wake));
|
|
11
|
+
})
|
|
12
|
+
});
|
|
13
|
+
export const makeRenderFeed = (tail, options) => RenderFeed.of({
|
|
14
|
+
frames: (feed, after, project) => Stream.unwrap(Effect.all([Clock.currentTimeMillis, waitOf(tail, feed)]).pipe(Effect.map(([now, wait]) => Stream.paginate({ after, ready: !1, delayMs: 0, quietSince: now, startedAt: now }, (cursor) => Effect.gen(function* () {
|
|
15
|
+
yield* wait(cursor.delayMs);
|
|
16
|
+
const at = yield* Clock.currentTimeMillis;
|
|
17
|
+
if (at - cursor.startedAt >= options.maxDurationMs)
|
|
18
|
+
return [[], Option.none()];
|
|
19
|
+
const fresh = (yield* tail.read(feed, cursor.after)).filter((record) => record.sequence > cursor.after);
|
|
20
|
+
if (fresh.length > 0) {
|
|
21
|
+
const frames = (yield* Effect.forEach(fresh, (record) => project(record).pipe(Effect.map(Option.map((payload) => FeedRecord.make({ sequence: record.sequence, event: payload.event, data: payload.data })))))).flatMap(Option.toArray), next = {
|
|
22
|
+
...cursor,
|
|
23
|
+
after: fresh.reduce((last, record) => Math.max(last, record.sequence), cursor.after),
|
|
24
|
+
delayMs: nextPollMs(cursor.delayMs, options, !0),
|
|
25
|
+
quietSince: frames.length > 0 ? at : cursor.quietSince
|
|
26
|
+
};
|
|
27
|
+
return [frames, Option.some(next)];
|
|
28
|
+
}
|
|
29
|
+
const heartbeat = cursor.ready && at - cursor.quietSince >= options.heartbeatMs, frames = [
|
|
30
|
+
...cursor.ready ? [] : [FeedReady.make({})],
|
|
31
|
+
...heartbeat ? [FeedHeartbeat.make({})] : []
|
|
32
|
+
], next = {
|
|
33
|
+
...cursor,
|
|
34
|
+
ready: !0,
|
|
35
|
+
delayMs: nextPollMs(cursor.delayMs, options, !1),
|
|
36
|
+
quietSince: frames.length > 0 ? at : cursor.quietSince
|
|
37
|
+
};
|
|
38
|
+
return [frames, Option.some(next)];
|
|
39
|
+
})))))
|
|
40
|
+
}), RenderFeedLive = (options) => Layer.effect(RenderFeed, JournalTail.pipe(Effect.map((tail) => makeRenderFeed(tail, options))));
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Layer } from "effect";
|
|
2
|
+
import { UiOutput, UiOutputAdmission, UiOutputContext, UiOutputJournal, UiOutputTools } from "./ports/render-output.port.js";
|
|
3
|
+
export declare const UiOutputLive: (maxBytes?: number) => Layer.Layer<UiOutput, never, UiOutputAdmission | UiOutputJournal | UiOutputContext>;
|
|
4
|
+
export declare const UiOutputHandlersLive: Layer.Layer<import("effect/ai/Tool").Handler<"render_component">, never, UiOutput>;
|
|
5
|
+
/** Transport-independent: WebSocket/SSE hosts consume the committed journal. */
|
|
6
|
+
export declare const uiOutputPlugin: import("@xandreed/core").TypedPlugin<{
|
|
7
|
+
readonly maxBytes: number;
|
|
8
|
+
}, {
|
|
9
|
+
readonly maxBytes: number;
|
|
10
|
+
}, UiOutput | UiOutputTools, never, UiOutputAdmission | UiOutputJournal | UiOutputContext>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Effect, Layer, Schema } from "effect";
|
|
2
|
+
import { definePlugin } from "@xandreed/core";
|
|
3
|
+
import { UiOutputError, UiOutputProposal } from "./domain/render-output.entity.js";
|
|
4
|
+
import { UiOutput, UiOutputAdmission, UiOutputContext, UiOutputJournal, UiOutputTools } from "./ports/render-output.port.js";
|
|
5
|
+
import { uiOutputToolkit } from "./render-output.tools.js";
|
|
6
|
+
export const UiOutputLive = (maxBytes = 32768) => Layer.effect(UiOutput, Effect.gen(function* () {
|
|
7
|
+
const admission = yield* UiOutputAdmission, journal = yield* UiOutputJournal, scope = yield* UiOutputContext;
|
|
8
|
+
return UiOutput.of({
|
|
9
|
+
emit: (input) => Effect.gen(function* () {
|
|
10
|
+
const proposal = yield* Schema.decodeUnknownEffect(UiOutputProposal)(input, { onExcessProperty: "error" }).pipe(Effect.mapError(() => new UiOutputError({ code: "invalid", message: "Invalid component proposal" }))), json = yield* Schema.encodeEffect(Schema.fromJsonString(UiOutputProposal))(proposal).pipe(Effect.mapError(() => new UiOutputError({ code: "invalid", message: "Component must be serializable" })));
|
|
11
|
+
if (new TextEncoder().encode(json).byteLength > maxBytes)
|
|
12
|
+
return yield* Effect.fail(new UiOutputError({ code: "invalid", message: "Component exceeds the configured size limit" }));
|
|
13
|
+
yield* admission.validate(scope, proposal);
|
|
14
|
+
return yield* journal.commit(scope, proposal);
|
|
15
|
+
})
|
|
16
|
+
});
|
|
17
|
+
})), UiOutputHandlersLive = uiOutputToolkit.toLayer(Effect.gen(function* () {
|
|
18
|
+
const output = yield* UiOutput;
|
|
19
|
+
return { render_component: (proposal) => output.emit(proposal).pipe(Effect.mapError((error) => ({ error: error.code, message: error.message }))) };
|
|
20
|
+
})), uiOutputPlugin = definePlugin({
|
|
21
|
+
id: "@xandreed/plugin-render/output",
|
|
22
|
+
version: "0.8.0-next.0",
|
|
23
|
+
requires: [UiOutputAdmission, UiOutputJournal, UiOutputContext],
|
|
24
|
+
provides: [UiOutput, UiOutputTools],
|
|
25
|
+
config: Schema.Struct({ maxBytes: Schema.Int.pipe(Schema.check(Schema.isBetween({ minimum: 1024, maximum: 131072 }))) }),
|
|
26
|
+
defaults: { maxBytes: 32768 },
|
|
27
|
+
layer: ({ maxBytes }) => Layer.merge(UiOutputLive(maxBytes), Layer.succeed(UiOutputTools, { toolkit: uiOutputToolkit }))
|
|
28
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Tool, Toolkit } from "effect/ai";
|
|
2
|
+
export declare const RenderComponent: Tool.Tool<"render_component", {
|
|
3
|
+
readonly parameters: import("effect/Schema").Struct<{
|
|
4
|
+
readonly operationId: import("effect/Schema").Trimmed;
|
|
5
|
+
readonly nodeId: import("effect/Schema").Trimmed;
|
|
6
|
+
readonly release: import("effect/Schema").Struct<{
|
|
7
|
+
readonly component: import("effect/Schema").Trimmed;
|
|
8
|
+
readonly version: import("effect/Schema").Trimmed;
|
|
9
|
+
readonly definitionHash: import("effect/Schema").String;
|
|
10
|
+
readonly rendererRelease: import("effect/Schema").Trimmed;
|
|
11
|
+
readonly tokensHash: import("effect/Schema").String;
|
|
12
|
+
readonly layoutVersion: import("effect/Schema").Trimmed;
|
|
13
|
+
}>;
|
|
14
|
+
readonly props: import("effect/Schema").$Record<import("effect/Schema").String, import("effect/Schema").Unknown>;
|
|
15
|
+
readonly evidence: import("effect/Schema").$Array<import("effect/Schema").Trimmed>;
|
|
16
|
+
}>;
|
|
17
|
+
readonly success: import("effect/Schema").Struct<{
|
|
18
|
+
readonly threadId: import("effect/Schema").Trimmed;
|
|
19
|
+
readonly messageId: import("effect/Schema").Trimmed;
|
|
20
|
+
readonly revisionId: import("effect/Schema").Trimmed;
|
|
21
|
+
readonly sequence: import("effect/Schema").Int;
|
|
22
|
+
readonly nodeId: import("effect/Schema").Trimmed;
|
|
23
|
+
}>;
|
|
24
|
+
readonly failure: import("effect/Schema").Struct<{
|
|
25
|
+
readonly error: import("effect/Schema").String;
|
|
26
|
+
readonly message: import("effect/Schema").String;
|
|
27
|
+
}>;
|
|
28
|
+
readonly failureMode: "return";
|
|
29
|
+
}, never>;
|
|
30
|
+
export declare const uiOutputToolkit: Toolkit.Toolkit<{
|
|
31
|
+
readonly render_component: Tool.Tool<"render_component", {
|
|
32
|
+
readonly parameters: import("effect/Schema").Struct<{
|
|
33
|
+
readonly operationId: import("effect/Schema").Trimmed;
|
|
34
|
+
readonly nodeId: import("effect/Schema").Trimmed;
|
|
35
|
+
readonly release: import("effect/Schema").Struct<{
|
|
36
|
+
readonly component: import("effect/Schema").Trimmed;
|
|
37
|
+
readonly version: import("effect/Schema").Trimmed;
|
|
38
|
+
readonly definitionHash: import("effect/Schema").String;
|
|
39
|
+
readonly rendererRelease: import("effect/Schema").Trimmed;
|
|
40
|
+
readonly tokensHash: import("effect/Schema").String;
|
|
41
|
+
readonly layoutVersion: import("effect/Schema").Trimmed;
|
|
42
|
+
}>;
|
|
43
|
+
readonly props: import("effect/Schema").$Record<import("effect/Schema").String, import("effect/Schema").Unknown>;
|
|
44
|
+
readonly evidence: import("effect/Schema").$Array<import("effect/Schema").Trimmed>;
|
|
45
|
+
}>;
|
|
46
|
+
readonly success: import("effect/Schema").Struct<{
|
|
47
|
+
readonly threadId: import("effect/Schema").Trimmed;
|
|
48
|
+
readonly messageId: import("effect/Schema").Trimmed;
|
|
49
|
+
readonly revisionId: import("effect/Schema").Trimmed;
|
|
50
|
+
readonly sequence: import("effect/Schema").Int;
|
|
51
|
+
readonly nodeId: import("effect/Schema").Trimmed;
|
|
52
|
+
}>;
|
|
53
|
+
readonly failure: import("effect/Schema").Struct<{
|
|
54
|
+
readonly error: import("effect/Schema").String;
|
|
55
|
+
readonly message: import("effect/Schema").String;
|
|
56
|
+
}>;
|
|
57
|
+
readonly failureMode: "return";
|
|
58
|
+
}, never>;
|
|
59
|
+
}>;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Tool, Toolkit } from "effect/ai";
|
|
2
|
+
import { Failure } from "@xandreed/core";
|
|
3
|
+
import { UiOutputProposal, UiOutputReceipt } from "./domain/render-output.entity.js";
|
|
4
|
+
export const RenderComponent = Tool.make("render_component", {
|
|
5
|
+
description: "Publish one complete, evidence-backed component using an exact approved release. Returns its durable canvas revision and chat anchor. Use only catalog props and evidence IDs returned by domain tools. Never emit code, HTML or guessed facts. Reuse operationId only to retry the identical call. Plain answers need no component.",
|
|
6
|
+
parameters: UiOutputProposal,
|
|
7
|
+
success: UiOutputReceipt,
|
|
8
|
+
failure: Failure,
|
|
9
|
+
failureMode: "return"
|
|
10
|
+
}), uiOutputToolkit = Toolkit.make(RenderComponent);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Effect, Layer } from "effect";
|
|
2
|
+
import type { UiOutputReceipt } from "./domain/render-output.entity.js";
|
|
3
|
+
import { RenderError, SurfaceFrozen } from "./domain/render-surface.entity.js";
|
|
4
|
+
import type { SurfaceRecord } from "./domain/render-surface.entity.js";
|
|
5
|
+
import { RenderStore } from "./ports/render.port.js";
|
|
6
|
+
export interface MemoryAnnotation {
|
|
7
|
+
readonly surfaceId: string;
|
|
8
|
+
readonly kind: string;
|
|
9
|
+
readonly data: Readonly<Record<string, unknown>>;
|
|
10
|
+
}
|
|
11
|
+
/** A complete in-process RenderStore, for tests, examples and single-process hosts. */
|
|
12
|
+
export declare const makeMemoryRenderStore: Effect.Effect<{
|
|
13
|
+
store: {
|
|
14
|
+
readonly hydrate: (scope: import("./index.js").SurfaceScope) => Effect.Effect<ReadonlyArray<SurfaceRecord>, RenderError>;
|
|
15
|
+
readonly plan: (scope: import("./index.js").SurfaceScope, record: import("./index.js").SurfacePlanned) => Effect.Effect<void, RenderError>;
|
|
16
|
+
readonly commit: (scope: import("./index.js").SurfaceScope, write: {
|
|
17
|
+
readonly versionId: string;
|
|
18
|
+
readonly proposal: import("@xandreed/ui-agent").UiOutputProposal;
|
|
19
|
+
}) => Effect.Effect<UiOutputReceipt, RenderError>;
|
|
20
|
+
readonly complete: (scope: import("./index.js").SurfaceScope, record: import("./index.js").SurfaceCompleted) => Effect.Effect<void, RenderError>;
|
|
21
|
+
readonly freeze: (scope: import("./index.js").SurfaceScope, record: SurfaceFrozen) => Effect.Effect<void, RenderError>;
|
|
22
|
+
readonly annotate: (scope: import("./index.js").SurfaceScope, kind: string, data: Readonly<Record<string, unknown>>) => Effect.Effect<void, RenderError>;
|
|
23
|
+
};
|
|
24
|
+
records: Effect.Effect<readonly ({
|
|
25
|
+
readonly _tag: "SurfacePlanned";
|
|
26
|
+
readonly surfaceId: string;
|
|
27
|
+
readonly messageId: string;
|
|
28
|
+
readonly versionId: string;
|
|
29
|
+
readonly generation: number;
|
|
30
|
+
readonly baseVersionId: import("effect/Option").Option<string>;
|
|
31
|
+
readonly phase: "partial" | "complete";
|
|
32
|
+
readonly spec: {
|
|
33
|
+
readonly [x: string]: unknown;
|
|
34
|
+
};
|
|
35
|
+
readonly nodes: readonly string[];
|
|
36
|
+
readonly placeholders: readonly string[];
|
|
37
|
+
readonly signature: string;
|
|
38
|
+
} | {
|
|
39
|
+
readonly _tag: "SurfaceCommitted";
|
|
40
|
+
readonly surfaceId: string;
|
|
41
|
+
readonly messageId: string;
|
|
42
|
+
readonly versionId: string;
|
|
43
|
+
readonly nodeId: string;
|
|
44
|
+
readonly component: string;
|
|
45
|
+
readonly receipt: {
|
|
46
|
+
readonly threadId: string;
|
|
47
|
+
readonly messageId: string;
|
|
48
|
+
readonly revisionId: string;
|
|
49
|
+
readonly sequence: number;
|
|
50
|
+
readonly nodeId: string;
|
|
51
|
+
};
|
|
52
|
+
} | {
|
|
53
|
+
readonly _tag: "SurfaceCompleted";
|
|
54
|
+
readonly surfaceId: string;
|
|
55
|
+
readonly messageId: string;
|
|
56
|
+
readonly versionId: string;
|
|
57
|
+
readonly generation: number;
|
|
58
|
+
readonly phase: "partial" | "complete";
|
|
59
|
+
readonly frozen: boolean;
|
|
60
|
+
} | {
|
|
61
|
+
readonly _tag: "SurfaceFrozen";
|
|
62
|
+
readonly surfaceId: string;
|
|
63
|
+
readonly messageId: string;
|
|
64
|
+
readonly versionId: string;
|
|
65
|
+
readonly reason: "completion" | "interaction";
|
|
66
|
+
})[], never, never>;
|
|
67
|
+
annotations: Effect.Effect<readonly MemoryAnnotation[], never, never>;
|
|
68
|
+
}, never, never>;
|
|
69
|
+
export declare const MemoryRenderStoreLive: Layer.Layer<RenderStore, never, never>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Effect, Result, Layer, Ref, Schema } from "effect";
|
|
2
|
+
import { canonicalJson } from "@xandreed/core";
|
|
3
|
+
import { RenderError, SurfaceCommitted, SurfaceFrozen } from "./domain/render-surface.entity.js";
|
|
4
|
+
import { RenderStore } from "./ports/render.port.js";
|
|
5
|
+
const isFrozen = Schema.is(SurfaceFrozen);
|
|
6
|
+
export const makeMemoryRenderStore = Effect.gen(function* () {
|
|
7
|
+
const stored = yield* Ref.make({ records: [], receipts: new Map, annotations: [], sequence: 0 }), append = (record) => Ref.update(stored, (state) => ({ ...state, records: [...state.records, record] }));
|
|
8
|
+
return {
|
|
9
|
+
store: RenderStore.of({
|
|
10
|
+
hydrate: (scope) => Ref.get(stored).pipe(Effect.map((state) => state.records.filter((record) => record.surfaceId === scope.surfaceId))),
|
|
11
|
+
plan: (_scope, record) => Ref.modify(stored, (state) => state.records.some((existing) => isFrozen(existing) && existing.surfaceId === record.surfaceId && existing.messageId === record.messageId) ? [!1, state] : [!0, { ...state, records: [...state.records, record] }]).pipe(Effect.flatMap((accepted) => accepted ? Effect.void : Effect.fail(new RenderError({ code: "frozen", message: "The surface is frozen for this message" })))),
|
|
12
|
+
commit: (scope, { versionId, proposal }) => Ref.modify(stored, (state) => {
|
|
13
|
+
const content = canonicalJson(proposal), existing = state.receipts.get(proposal.operationId);
|
|
14
|
+
if (existing !== void 0)
|
|
15
|
+
return existing.proposal === content ? [Result.succeed(existing.receipt), state] : [Result.fail(new RenderError({ code: "conflict", message: `Operation ${proposal.operationId} was already committed with other content` })), state];
|
|
16
|
+
const sequence = state.sequence + 1, receipt = {
|
|
17
|
+
threadId: scope.threadId,
|
|
18
|
+
messageId: scope.messageId,
|
|
19
|
+
revisionId: `${proposal.operationId}:r${sequence}`,
|
|
20
|
+
sequence,
|
|
21
|
+
nodeId: proposal.nodeId
|
|
22
|
+
}, committed = SurfaceCommitted.make({
|
|
23
|
+
surfaceId: scope.surfaceId,
|
|
24
|
+
messageId: scope.messageId,
|
|
25
|
+
versionId,
|
|
26
|
+
nodeId: proposal.nodeId,
|
|
27
|
+
component: proposal.release.component,
|
|
28
|
+
receipt
|
|
29
|
+
});
|
|
30
|
+
return [Result.succeed(receipt), {
|
|
31
|
+
...state,
|
|
32
|
+
sequence,
|
|
33
|
+
receipts: new Map([...state.receipts, [proposal.operationId, { proposal: content, receipt }]]),
|
|
34
|
+
records: [...state.records, committed]
|
|
35
|
+
}];
|
|
36
|
+
}).pipe(Effect.flatMap(Result.match({ onFailure: Effect.fail, onSuccess: Effect.succeed }))),
|
|
37
|
+
complete: (_scope, record) => append(record),
|
|
38
|
+
freeze: (_scope, record) => Ref.update(stored, (state) => state.records.some((existing) => isFrozen(existing) && existing.versionId === record.versionId) ? state : { ...state, records: [...state.records, record] }),
|
|
39
|
+
annotate: (scope, kind, data) => Ref.update(stored, (state) => ({
|
|
40
|
+
...state,
|
|
41
|
+
annotations: [...state.annotations, { surfaceId: scope.surfaceId, kind, data }]
|
|
42
|
+
}))
|
|
43
|
+
}),
|
|
44
|
+
records: Ref.get(stored).pipe(Effect.map((state) => state.records)),
|
|
45
|
+
annotations: Ref.get(stored).pipe(Effect.map((state) => state.annotations))
|
|
46
|
+
};
|
|
47
|
+
}), MemoryRenderStoreLive = Layer.effect(RenderStore, makeMemoryRenderStore.pipe(Effect.map(({ store }) => store)));
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Effect, Layer } from "effect";
|
|
2
|
+
import { RenderError } from "./domain/render-surface.entity.js";
|
|
3
|
+
import type { SurfaceScope } from "./domain/render-surface.entity.js";
|
|
4
|
+
import { UiOutputAdmission } from "./ports/render-output.port.js";
|
|
5
|
+
import { Render, RenderStore } from "./ports/render.port.js";
|
|
6
|
+
import type { RenderOpenOptions, RenderSurface } from "./ports/render.port.js";
|
|
7
|
+
/**
|
|
8
|
+
* One surface for one message: versions (`<messageId>:v<generation>`),
|
|
9
|
+
* signature dedupe, one idempotent commit per node, completion, freeze and
|
|
10
|
+
* background preparation. Writes are serialized; preparations run one at a
|
|
11
|
+
* time through the host's `fork`.
|
|
12
|
+
*/
|
|
13
|
+
export declare const openSurface: (store: typeof RenderStore.Service, admission: typeof UiOutputAdmission.Service, maxBytes: number) => (scope: SurfaceScope, options: RenderOpenOptions) => Effect.Effect<RenderSurface, RenderError>;
|
|
14
|
+
export declare const RenderLive: (maxBytes?: number) => Layer.Layer<Render, never, UiOutputAdmission | RenderStore>;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Deferred, Effect, Layer, Option, Ref, Schema, Stream, Semaphore } from "effect";
|
|
2
|
+
import { UiOutputProposal } from "./domain/render-output.entity.js";
|
|
3
|
+
import { RenderError, RenderNode, RenderSnapshot, SurfaceCompleted, SurfaceFrozen, SurfacePlanned } from "./domain/render-surface.entity.js";
|
|
4
|
+
import {
|
|
5
|
+
describeError,
|
|
6
|
+
fillOperationIdOf,
|
|
7
|
+
fromOutputError,
|
|
8
|
+
operationIdOf,
|
|
9
|
+
outputScopeOf,
|
|
10
|
+
progressOf,
|
|
11
|
+
proposalOf,
|
|
12
|
+
signatureOf,
|
|
13
|
+
versionIdOf
|
|
14
|
+
} from "./domain/render-surface.entity.functions.js";
|
|
15
|
+
import { UiOutputAdmission } from "./ports/render-output.port.js";
|
|
16
|
+
import { Render, RenderStore } from "./ports/render.port.js";
|
|
17
|
+
const invalid = (message) => new RenderError({ code: "invalid", message }), unique = (values) => [...new Set(values)];
|
|
18
|
+
export const openSurface = (store, admission, maxBytes) => (scope, options) => Effect.gen(function* () {
|
|
19
|
+
const records = yield* store.hydrate(scope), progress = yield* Ref.make(progressOf(scope.surfaceId, scope.messageId, records)), writer = yield* Semaphore.make(1), preparer = yield* Semaphore.make(1), preparations = yield* Ref.make([]), output = outputScopeOf(scope), identity = { surfaceId: scope.surfaceId, messageId: scope.messageId }, updateState = (change) => Ref.update(progress, (current) => ({ ...current, state: change(current.state) })), commitNode = (versionId, operationId, node) => Effect.gen(function* () {
|
|
20
|
+
const proposal = proposalOf(operationId, node), json = yield* Schema.encodeEffect(Schema.fromJsonString(UiOutputProposal))(proposal).pipe(Effect.mapError(() => invalid("A component must be serializable")));
|
|
21
|
+
if (new TextEncoder().encode(json).byteLength > maxBytes)
|
|
22
|
+
return yield* Effect.fail(invalid("A component exceeds the configured size limit"));
|
|
23
|
+
yield* admission.validate(output, proposal).pipe(Effect.mapError(fromOutputError));
|
|
24
|
+
return yield* store.commit(scope, { versionId, proposal });
|
|
25
|
+
}), snapshotResult = (frozen) => Ref.get(progress).pipe(Effect.map((current) => ({
|
|
26
|
+
version: current.state.version,
|
|
27
|
+
generation: current.state.generation,
|
|
28
|
+
changed: !1,
|
|
29
|
+
frozen
|
|
30
|
+
}))), recordFrozenCompletion = Effect.gen(function* () {
|
|
31
|
+
const current = yield* Ref.get(progress);
|
|
32
|
+
if (current.frozenRecorded)
|
|
33
|
+
return;
|
|
34
|
+
yield* Option.match(current.state.version, {
|
|
35
|
+
onNone: () => Effect.void,
|
|
36
|
+
onSome: (versionId) => store.complete(scope, SurfaceCompleted.make({
|
|
37
|
+
...identity,
|
|
38
|
+
versionId,
|
|
39
|
+
generation: current.state.generation,
|
|
40
|
+
phase: "partial",
|
|
41
|
+
frozen: !0
|
|
42
|
+
}))
|
|
43
|
+
});
|
|
44
|
+
yield* Ref.update(progress, (value) => ({ ...value, frozenRecorded: !0, state: { ...value.state, frozen: !0 } }));
|
|
45
|
+
}), markComplete = (versionId, generation) => store.complete(scope, SurfaceCompleted.make({
|
|
46
|
+
...identity,
|
|
47
|
+
versionId,
|
|
48
|
+
generation,
|
|
49
|
+
phase: "complete",
|
|
50
|
+
frozen: !1
|
|
51
|
+
})).pipe(Effect.andThen(updateState((state) => ({ ...state, completed: !0 })))), publishNow = (input) => Effect.gen(function* () {
|
|
52
|
+
const snapshot = yield* Schema.decodeUnknownEffect(RenderSnapshot)(input, { onExcessProperty: "error", reportInput: !0 }).pipe(Effect.mapError((error) => invalid(`Invalid snapshot: ${error.message}`))), current = yield* Ref.get(progress);
|
|
53
|
+
if (current.state.frozen)
|
|
54
|
+
return yield* recordFrozenCompletion.pipe(Effect.andThen(snapshotResult(!0)));
|
|
55
|
+
const signature = signatureOf(snapshot);
|
|
56
|
+
if (Option.contains(current.signature, signature)) {
|
|
57
|
+
if (snapshot.phase === "complete" && !current.state.completed)
|
|
58
|
+
yield* Option.match(current.state.version, { onNone: () => Effect.void, onSome: (versionId) => markComplete(versionId, current.state.generation) });
|
|
59
|
+
return yield* snapshotResult(!1);
|
|
60
|
+
}
|
|
61
|
+
const generation = current.state.generation + 1, versionId = versionIdOf(scope.messageId, generation);
|
|
62
|
+
if (!(yield* store.plan(scope, SurfacePlanned.make({
|
|
63
|
+
...identity,
|
|
64
|
+
versionId,
|
|
65
|
+
generation,
|
|
66
|
+
baseVersionId: Option.orElse(scope.baseVersion, () => current.state.version),
|
|
67
|
+
phase: snapshot.phase,
|
|
68
|
+
spec: snapshot.spec,
|
|
69
|
+
nodes: snapshot.nodes.map((node) => node.nodeId),
|
|
70
|
+
placeholders: snapshot.placeholders,
|
|
71
|
+
signature
|
|
72
|
+
})).pipe(Effect.as(!0), Effect.catchIf((error) => error.code === "frozen", () => Effect.succeed(!1))))) {
|
|
73
|
+
yield* updateState((state) => ({ ...state, frozen: !0 }));
|
|
74
|
+
return yield* recordFrozenCompletion.pipe(Effect.andThen(snapshotResult(!0)));
|
|
75
|
+
}
|
|
76
|
+
yield* Ref.update(progress, (value) => ({
|
|
77
|
+
...value,
|
|
78
|
+
signature: Option.some(signature),
|
|
79
|
+
state: { ...value.state, version: Option.some(versionId), generation, completed: !1, components: [], placeholders: snapshot.placeholders }
|
|
80
|
+
}));
|
|
81
|
+
yield* Effect.forEach(snapshot.nodes, (node) => commitNode(versionId, operationIdOf(scope.runId, generation, node.nodeId), node), { discard: !0 });
|
|
82
|
+
yield* updateState((state) => ({ ...state, components: unique(snapshot.nodes.map((node) => node.release.component)) }));
|
|
83
|
+
if (snapshot.phase === "complete")
|
|
84
|
+
yield* markComplete(versionId, generation);
|
|
85
|
+
return { version: Option.some(versionId), generation, changed: !0, frozen: !1 };
|
|
86
|
+
}), publish = (input) => writer.withPermits(1)(publishNow(input)), fill = (placeholder, input) => writer.withPermits(1)(Effect.gen(function* () {
|
|
87
|
+
const node = yield* Schema.decodeUnknownEffect(RenderNode)(input, { onExcessProperty: "error", reportInput: !0 }).pipe(Effect.mapError((error) => invalid(`Invalid node: ${error.message}`))), current = yield* Ref.get(progress), versionId = yield* Option.match(current.state.version, {
|
|
88
|
+
onNone: () => Effect.fail(invalid("Nothing has been published on this surface")),
|
|
89
|
+
onSome: Effect.succeed
|
|
90
|
+
});
|
|
91
|
+
if (!current.state.placeholders.includes(placeholder))
|
|
92
|
+
return yield* Effect.fail(invalid(`The current version declares no placeholder ${placeholder}`));
|
|
93
|
+
if (node.nodeId !== placeholder)
|
|
94
|
+
return yield* Effect.fail(invalid("A fill must use its placeholder as node id"));
|
|
95
|
+
const receipt = yield* commitNode(versionId, fillOperationIdOf(scope.runId, placeholder), node);
|
|
96
|
+
yield* store.annotate(scope, "render.filled", { versionId, placeholder });
|
|
97
|
+
yield* updateState((state) => ({ ...state, components: unique([...state.components, node.release.component]) }));
|
|
98
|
+
return receipt;
|
|
99
|
+
})), prepare = (work, recover) => Effect.gen(function* () {
|
|
100
|
+
const done = yield* Deferred.make();
|
|
101
|
+
yield* Ref.update(preparations, (all) => [...all, done]);
|
|
102
|
+
const run = preparer.withPermits(1)(Stream.runForEach(work, publish)).pipe(Effect.catch((error) => store.annotate(scope, "render.preparation-failed", { message: describeError(error) }).pipe(Effect.ignore, Effect.andThen(recover(error)))), Effect.ensuring(Deferred.succeed(done, void 0)));
|
|
103
|
+
yield* options.fork(`render:${scope.surfaceId}`, run);
|
|
104
|
+
}), settled = Ref.get(preparations).pipe(Effect.flatMap((all) => Effect.forEach(all, Deferred.await, { discard: !0 }))), state = Effect.gen(function* () {
|
|
105
|
+
const current = yield* Ref.get(progress), finished = yield* Effect.forEach(yield* Ref.get(preparations), Deferred.isDone);
|
|
106
|
+
return { ...current.state, pending: finished.some((done) => !done) };
|
|
107
|
+
});
|
|
108
|
+
return {
|
|
109
|
+
scope,
|
|
110
|
+
records,
|
|
111
|
+
state,
|
|
112
|
+
publish,
|
|
113
|
+
fill,
|
|
114
|
+
prepare,
|
|
115
|
+
settled,
|
|
116
|
+
freeze: (reason) => writer.withPermits(1)(Effect.gen(function* () {
|
|
117
|
+
const current = yield* Ref.get(progress);
|
|
118
|
+
yield* Option.match(current.state.version, {
|
|
119
|
+
onNone: () => Effect.void,
|
|
120
|
+
onSome: (versionId) => store.freeze(scope, SurfaceFrozen.make({ ...identity, versionId, reason })).pipe(Effect.andThen(updateState((value) => ({ ...value, frozen: !0 }))))
|
|
121
|
+
});
|
|
122
|
+
})),
|
|
123
|
+
annotate: (kind, data) => store.annotate(scope, kind, data)
|
|
124
|
+
};
|
|
125
|
+
}), RenderLive = (maxBytes = 32768) => Layer.effect(Render, Effect.gen(function* () {
|
|
126
|
+
const store = yield* RenderStore, admission = yield* UiOutputAdmission;
|
|
127
|
+
return Render.of({ open: openSurface(store, admission, maxBytes) });
|
|
128
|
+
}));
|